From 24093ca5cc74b344be104c42c643df518b80ade5 Mon Sep 17 00:00:00 2001 From: BitHighlander Date: Mon, 23 Mar 2026 21:04:09 -0500 Subject: [PATCH 01/17] fix: strip nested node_modules from file:-linked packages + tar --force-local Two Windows build fixes: 1. collect-externals.ts: strip node_modules from file:-linked packages after copying to staging. Prevents Inno Setup MAX_PATH failures when npm/bun installs transitive deps inside source dirs (e.g. modules/proto-tx-builder/node_modules/). 2. patch-electrobun.sh: add --force-local to tar extraction command. Windows tar interprets "C:" in paths as a remote host, causing electrobun CLI download to fail. Co-Authored-By: Claude Opus 4.6 (1M context) --- projects/keepkey-vault/scripts/collect-externals.ts | 12 ++++++++++++ projects/keepkey-vault/scripts/patch-electrobun.sh | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/projects/keepkey-vault/scripts/collect-externals.ts b/projects/keepkey-vault/scripts/collect-externals.ts index 07c7e211..fb75c49f 100644 --- a/projects/keepkey-vault/scripts/collect-externals.ts +++ b/projects/keepkey-vault/scripts/collect-externals.ts @@ -232,6 +232,18 @@ for (const dep of sorted) { console.log(`[collect-externals] Copied ${copiedCount} packages to ${nmDest}`) +// Strip node_modules from file:-linked packages in the staging area. +// npm/bun may have installed transitive deps INSIDE the source directory +// (e.g. modules/proto-tx-builder/node_modules/). These should be resolved +// at top level, not nested. Leaving them causes Inno Setup MAX_PATH failures. +for (const [name] of fileLinkedPaths) { + const nestedNm = join(nmDest, name, 'node_modules') + if (existsSync(nestedNm)) { + rmSync(nestedNm, { recursive: true }) + console.log(`[collect-externals] Stripped nested node_modules from file:-linked ${name}`) + } +} + // device-protocol is now bundled into index.js by bundle-backend.ts, // so we no longer need to verify messages_pb.js here. diff --git a/projects/keepkey-vault/scripts/patch-electrobun.sh b/projects/keepkey-vault/scripts/patch-electrobun.sh index dfb1ab85..5b653cd0 100755 --- a/projects/keepkey-vault/scripts/patch-electrobun.sh +++ b/projects/keepkey-vault/scripts/patch-electrobun.sh @@ -25,3 +25,15 @@ if [ -f "$EBUN_CLI" ]; then else echo "[patch-electrobun] $EBUN_CLI not found, skipping (expected during CI or fresh install)" fi + +# Patch electrobun CLI bootstrap to use --force-local with tar on Windows. +# Without this, tar interprets the "C:" in Windows paths as a remote host. +EBUN_CJS="node_modules/electrobun/bin/electrobun.cjs" +if [ -f "$EBUN_CJS" ]; then + if grep -q 'tar --force-local' "$EBUN_CJS"; then + echo "[patch-electrobun] tar --force-local already patched" + elif grep -q 'tar -xzf' "$EBUN_CJS"; then + sed -i 's/tar -xzf/tar --force-local -xzf/g' "$EBUN_CJS" + echo "[patch-electrobun] Patched tar --force-local (Windows path fix)" + fi +fi From 382635eef37e4c95b8754d4bd375ed91c3bd7645 Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 23 Mar 2026 22:26:14 -0600 Subject: [PATCH 02/17] fix: CSV report exports were dead clicks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs: 1. Frontend catch {} silently swallowed errors — now shows error message 2. Backend threw when 0 transactions had timestamps, which is the common case for fresh reports. Removed the guard — empty CSV with headers is better than a silent failure. --- projects/keepkey-vault/src/bun/index.ts | 6 ++---- .../keepkey-vault/src/mainview/components/ReportDialog.tsx | 5 ++++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index 6eecb93b..d0f198b8 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -2055,14 +2055,12 @@ const rpc = BrowserView.defineRPC({ if (params.format === 'cointracker') { filePath = path.join(downloadsDir, `keepkey_cointracker_${year}.csv`) const txs = extractTransactionsFromReport(report.data) - const serializableTxs = txs.filter(tx => !!tx.timestamp) - if (serializableTxs.length === 0) throw new Error('No transactions with confirmed dates found in report. Generate a new report to fetch transaction history from the network.') + console.log(`[reports] CoinTracker: ${txs.length} transactions extracted`) await Bun.write(filePath, toCoinTrackerCsv(txs)) } else if (params.format === 'zenledger') { filePath = path.join(downloadsDir, `keepkey_zenledger_${year}.csv`) const txs = extractTransactionsFromReport(report.data) - const serializableTxs = txs.filter(tx => !!tx.timestamp) - if (serializableTxs.length === 0) throw new Error('No transactions with confirmed dates found in report. Generate a new report to fetch transaction history from the network.') + console.log(`[reports] ZenLedger: ${txs.length} transactions extracted`) await Bun.write(filePath, toZenLedgerCsv(txs)) } else if (params.format === 'pdf') { const shortId = params.id.slice(-6).replace(/[^a-zA-Z0-9]/g, '') diff --git a/projects/keepkey-vault/src/mainview/components/ReportDialog.tsx b/projects/keepkey-vault/src/mainview/components/ReportDialog.tsx index 289d6112..191ff615 100644 --- a/projects/keepkey-vault/src/mainview/components/ReportDialog.tsx +++ b/projects/keepkey-vault/src/mainview/components/ReportDialog.tsx @@ -81,8 +81,11 @@ export function ReportDialog({ onClose }: ReportDialogProps) { const handleDownload = useCallback(async (id: string, format: ExportFormat) => { try { setSaving(`${id}-${format}`) + setError(null) await rpcRequest<{ filePath: string }>("saveReportFile", { id, format }, 30000) - } catch {} finally { + } catch (e: any) { + setError(e.message || `Failed to export ${format}`) + } finally { setSaving(null) } }, []) From da793f1571afcc40caf2a02b571c5edf5ee65256 Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 23 Mar 2026 22:41:27 -0600 Subject: [PATCH 03/17] feat: add KeepKey CSV export with full portfolio data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CoinTracker/ZenLedger CSVs are tax-specific (transactions only) — empty when Pioneer returns no tx history. Added "KeepKey CSV" option that uses the existing reportToCsv() function to export ALL report sections: device info, chain balances, tokens, xpubs, addresses, and transactions. --- projects/keepkey-vault/src/bun/index.ts | 9 +++++++-- .../src/mainview/components/ReportDialog.tsx | 7 ++++--- projects/keepkey-vault/src/shared/rpc-schema.ts | 2 +- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index d0f198b8..097bdd78 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -47,7 +47,7 @@ import type { ChainDef } from "../shared/chains" import { BtcAccountManager } from "./btc-accounts" import { EvmAddressManager, evmAddressPath } from "./evm-addresses" import { initDb, factoryResetDb, getCustomTokens, addCustomToken as dbAddCustomToken, removeCustomToken as dbRemoveCustomToken, getCustomChains, addCustomChainDb, removeCustomChainDb, getSetting, setSetting, setTokenVisibility as dbSetTokenVisibility, removeTokenVisibility as dbRemoveTokenVisibility, getAllTokenVisibility, insertApiLog, getApiLogs, clearApiLogs, setCachedBalances, getCachedBalances, updateCachedBalance, clearBalances, saveCachedPubkey, getLatestDeviceSnapshot, getCachedPubkeys, saveReport, getReportsList, getReportById, deleteReport, reportExists, getSwapHistory, getSwapHistoryStats, getSwapHistoryByTxid, getBip85Seeds, saveBip85Seed, deleteBip85Seed, clearCachedPubkeys, getRecentActivityFromLog, apiLogTxidExists, updateApiLogTxMeta, getPioneerServers, addPioneerServerDb, removePioneerServerDb } from "./db" -import { generateReport, reportToPdfBuffer } from "./reports" +import { generateReport, reportToPdfBuffer, reportToCsv } from "./reports" import { extractTransactionsFromReport, toCoinTrackerCsv, toZenLedgerCsv } from "./tax-export" import * as os from "os" import * as path from "path" @@ -2052,7 +2052,12 @@ const rpc = BrowserView.defineRPC({ console.log(`[reports] saveReportFile: format=${params.format}, id=${params.id}`) let filePath: string - if (params.format === 'cointracker') { + if (params.format === 'csv') { + const shortId = params.id.slice(-6).replace(/[^a-zA-Z0-9]/g, '') + filePath = path.join(downloadsDir, `keepkey-report-${dateSuffix}-${shortId}.csv`) + await Bun.write(filePath, reportToCsv(report.data)) + console.log(`[reports] Full CSV written: ${report.data.sections.length} sections`) + } else if (params.format === 'cointracker') { filePath = path.join(downloadsDir, `keepkey_cointracker_${year}.csv`) const txs = extractTransactionsFromReport(report.data) console.log(`[reports] CoinTracker: ${txs.length} transactions extracted`) diff --git a/projects/keepkey-vault/src/mainview/components/ReportDialog.tsx b/projects/keepkey-vault/src/mainview/components/ReportDialog.tsx index 191ff615..4e14c229 100644 --- a/projects/keepkey-vault/src/mainview/components/ReportDialog.tsx +++ b/projects/keepkey-vault/src/mainview/components/ReportDialog.tsx @@ -8,12 +8,13 @@ import keepkeyLogo from "../assets/icon.png" import coinTrackerLogo from "../assets/logo/cointracker.png" import zenLedgerLogo from "../assets/logo/zenledger.png" -type ExportFormat = "pdf" | "cointracker" | "zenledger" +type ExportFormat = "pdf" | "csv" | "cointracker" | "zenledger" const EXPORT_OPTIONS: { key: ExportFormat; label: string; sub: string; logo: string; bg: string }[] = [ { key: "pdf", label: "KeepKey PDF", sub: "Full portfolio report", logo: keepkeyLogo, bg: "rgba(192,168,96,0.10)" }, - { key: "cointracker", label: "CoinTracker", sub: "Tax CSV export", logo: coinTrackerLogo, bg: "rgba(255,255,255,0.05)" }, - { key: "zenledger", label: "ZenLedger", sub: "Tax CSV export", logo: zenLedgerLogo, bg: "rgba(255,255,255,0.05)" }, + { key: "csv", label: "KeepKey CSV", sub: "Full portfolio data", logo: keepkeyLogo, bg: "rgba(192,168,96,0.10)" }, + { key: "cointracker", label: "CoinTracker", sub: "Tax transactions", logo: coinTrackerLogo, bg: "rgba(255,255,255,0.05)" }, + { key: "zenledger", label: "ZenLedger", sub: "Tax transactions", logo: zenLedgerLogo, bg: "rgba(255,255,255,0.05)" }, ] interface ReportDialogProps { diff --git a/projects/keepkey-vault/src/shared/rpc-schema.ts b/projects/keepkey-vault/src/shared/rpc-schema.ts index a6b538eb..a22c9970 100644 --- a/projects/keepkey-vault/src/shared/rpc-schema.ts +++ b/projects/keepkey-vault/src/shared/rpc-schema.ts @@ -154,7 +154,7 @@ export type VaultRPCSchema = ElectrobunRPCSchema & { listReports: { params: void; response: ReportMeta[] } getReport: { params: { id: string }; response: { meta: ReportMeta; data: ReportData } | null } deleteReport: { params: { id: string }; response: void } - saveReportFile: { params: { id: string; format: 'pdf' | 'cointracker' | 'zenledger' }; response: { filePath: string } } + saveReportFile: { params: { id: string; format: 'pdf' | 'csv' | 'cointracker' | 'zenledger' }; response: { filePath: string } } // ── Swap ────────────────────────────────────────────────────────── getSwappableChainIds: { params: void; response: string[] } From 114c08e115909df787d0c616dced987acf7bb835 Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 23 Mar 2026 22:51:53 -0600 Subject: [PATCH 04/17] =?UTF-8?q?fix:=20tx=20history=20empty=20=E2=80=94?= =?UTF-8?q?=20wrong=20Pioneer=20method=20name=20+=20no=20retry=20on=20asyn?= =?UTF-8?q?c=20load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two root causes: 1. Vault called GetTxHistory but Pioneer's operationId is GetTransactionHistory. Swagger client returned undefined, parsed as empty array. Zero errors. 2. Pioneer's tx history is async — first call triggers background Blockbook fetch and returns { transactions: [], loading: true }. Vault never retried. Fix: correct method name + poll up to 3 times (5s apart) when Pioneer indicates loading/cache-miss. --- projects/keepkey-vault/src/bun/index.ts | 4 +- projects/keepkey-vault/src/bun/reports.ts | 56 +++++++++++++++++------ 2 files changed, 43 insertions(+), 17 deletions(-) diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index 097bdd78..e56d0c97 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -2320,9 +2320,9 @@ const rpc = BrowserView.defineRPC({ console.log(`[activity] Scanning ${chain.symbol} history for ${chain.chainFamily === 'utxo' ? 'xpub' : 'address'}: ${pubkey.slice(0, 16)}...`) const resp = await withTimeout( - pioneer.GetTxHistory({ queries: [{ pubkey, caip: chain.caip }] }), + pioneer.GetTransactionHistory({ queries: [{ pubkey, caip: chain.caip }] }), PIONEER_TIMEOUT_MS, - `GetTxHistory(${chain.symbol})` + `GetTransactionHistory(${chain.symbol})` ) const data = resp?.data || resp const histories = data?.histories || data?.data?.histories || [] diff --git a/projects/keepkey-vault/src/bun/reports.ts b/projects/keepkey-vault/src/bun/reports.ts index f3f6e1d9..7aa67b64 100644 --- a/projects/keepkey-vault/src/bun/reports.ts +++ b/projects/keepkey-vault/src/bun/reports.ts @@ -48,22 +48,48 @@ async function fetchPubkeyInfo(xpub: string): Promise { async function fetchTxHistory(xpub: string, caip: string): Promise { const pioneer = await getPioneer() - const resp = await pioneer.GetTxHistory({ queries: [{ pubkey: xpub, caip }] }) - const data = resp?.data || resp - if (typeof data !== 'object' || data === null) { - console.warn('[Report] fetchTxHistory: unexpected response shape:', typeof data) - return [] - } - // Pioneer may nest transactions in various response shapes - const histories = data.histories || data?.data?.histories || [] - const candidate = histories[0]?.transactions - || data.transactions - || data?.data?.transactions - const txs = Array.isArray(candidate) ? candidate : [] - if (txs.length === 0) { - console.warn(`[Report] fetchTxHistory: 0 transactions for xpub=${xpub.substring(0, 20)}... Response keys: ${Object.keys(data).join(', ')}`) + + // Pioneer's tx history is async: first call triggers a background worker and + // returns { transactions: [], loading: true }. We poll up to 3 times (5s apart) + // to wait for the worker to finish fetching from Blockbook. + const MAX_RETRIES = 3 + const RETRY_DELAY = 5000 + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + const resp = await pioneer.GetTransactionHistory({ queries: [{ pubkey: xpub, caip }] }) + const data = resp?.data || resp + if (typeof data !== 'object' || data === null) { + console.warn('[Report] fetchTxHistory: unexpected response shape:', typeof data) + return [] + } + + const histories = data.histories || data?.data?.histories || [] + const hist = histories[0] + const candidate = hist?.transactions + || data.transactions + || data?.data?.transactions + const txs = Array.isArray(candidate) ? candidate : [] + + // If we got transactions, return them + if (txs.length > 0) { + console.log(`[Report] fetchTxHistory: ${txs.length} txs for xpub=${xpub.substring(0, 20)}... (attempt ${attempt + 1})`) + return txs + } + + // If Pioneer says it's still loading, wait and retry + const isLoading = hist?.loading || hist?.cached === false + if (isLoading && attempt < MAX_RETRIES) { + console.log(`[Report] fetchTxHistory: loading (attempt ${attempt + 1}/${MAX_RETRIES + 1}), retrying in ${RETRY_DELAY / 1000}s...`) + await new Promise(r => setTimeout(r, RETRY_DELAY)) + continue + } + + // Not loading, just no transactions + console.warn(`[Report] fetchTxHistory: 0 transactions for xpub=${xpub.substring(0, 20)}... cached=${hist?.cached} loading=${hist?.loading}`) + return txs } - return txs + + return [] } // ── Section Builders ───────────────────────────────────────────────── From 443df8790553aebf7fe18c5a58d27e33b7212f88 Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 24 Mar 2026 17:39:27 -0600 Subject: [PATCH 05/17] =?UTF-8?q?feat:=20add=20Zcash=20deshielding=20(Orch?= =?UTF-8?q?ard=20=E2=86=92=20transparent)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable unshielding from the Orchard pool to transparent t1/t3 addresses. No firmware changes needed — device signs Orchard spends with RedPallas (same as shielded send), transparent outputs require no device signatures. Sidecar: build_deshield_pczt + finalize_deshield IPC commands, transparent address decoding (P2PKH/P2SH), hybrid v5 tx serialization with transparent outputs. Backend: zcash-deshield.ts tx builder, zcashDeshieldZec RPC method. Frontend: "Unshield to Transparent" section in Privacy tab with address validation, amount input, max button, and progress feedback. --- projects/keepkey-vault/src/bun/index.ts | 14 + .../src/bun/txbuilder/zcash-deshield.ts | 120 +++++ .../mainview/components/ZcashPrivacyTab.tsx | 154 +++++++ .../src/mainview/i18n/locales/en/asset.json | 4 + .../keepkey-vault/src/shared/rpc-schema.ts | 1 + projects/keepkey-vault/zcash-cli/src/main.rs | 208 ++++++++- .../zcash-cli/src/pczt_builder.rs | 435 ++++++++++++++++++ 7 files changed, 932 insertions(+), 4 deletions(-) create mode 100644 projects/keepkey-vault/src/bun/txbuilder/zcash-deshield.ts diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index e56d0c97..63ef8e20 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -1707,6 +1707,20 @@ const rpc = BrowserView.defineRPC({ return result }, + zcashDeshieldZec: async (params) => { + if (!zcashPrivacyEnabled) throw new Error('Zcash privacy feature is disabled') + if (!engine.wallet) throw new Error('No device connected') + const { deshieldZec } = await import("./txbuilder/zcash-deshield") + try { rpc.send['shield-progress']({ step: 'building' }) } catch { /* webview not ready */ } + const result = await deshieldZec(engine.wallet as any, { + recipient: params.recipient, + amount: params.amount, + account: params.account, + }) + try { rpc.send['shield-progress']({ step: 'complete', detail: result.txid }) } catch { /* webview not ready */ } + return result + }, + zcashGetTransactions: async () => { if (!zcashPrivacyEnabled) throw new Error('Zcash privacy feature is disabled') const { getZcashTransactions } = await import("./zcash-sidecar") diff --git a/projects/keepkey-vault/src/bun/txbuilder/zcash-deshield.ts b/projects/keepkey-vault/src/bun/txbuilder/zcash-deshield.ts new file mode 100644 index 00000000..1a5167ed --- /dev/null +++ b/projects/keepkey-vault/src/bun/txbuilder/zcash-deshield.ts @@ -0,0 +1,120 @@ +/** + * Zcash Orchard → transparent deshielding transaction builder. + * + * Orchestrates the flow: + * 1. Sidecar builds deshield PCZT (Orchard spends + transparent output) + * 2. Device signs Orchard actions (RedPallas) — no transparent signing needed + * 3. Sidecar finalizes + serializes hybrid v5 tx + * 4. Broadcast via lightwalletd + */ + +import { sendCommand, isSidecarReady, startSidecar, getCachedFvk } from "../zcash-sidecar" +import { initializeOrchardFromDevice } from "./zcash-shielded" + +export interface DeshieldParams { + /** Transparent recipient address (t1... or t3...) */ + recipient: string + /** Amount in zatoshis */ + amount: number + /** Account index (default 0) */ + account?: number +} + +interface DeshieldBuildResult { + orchard_signing_request: { + n_actions: number + account: number + branch_id: number + sighash: string + digests: { header: string; transparent: string; sapling: string; orchard: string } + bundle_meta: { flags: number; value_balance: number; anchor: string } + actions: Array<{ + index: number; alpha: string; cv_net: string; nullifier: string + cmx: string; epk: string; enc_compact: string; enc_memo: string + enc_noncompact: string; rk: string; out_ciphertext: string + value: number; is_spend: boolean + }> + display: { amount: string; fee: string; to: string } + } + transparent_outputs: Array<{ value: number; script_pubkey: string }> + display: { amount: string; fee: string; action: string } +} + +let deshieldInProgress = false + +/** + * Full deshield flow: Orchard shielded pool → transparent ZEC. + * + * @param wallet - hdwallet instance with zcashSignPczt method + * @param params - Deshield parameters + * @returns Transaction ID + */ +export async function deshieldZec( + wallet: any, + params: DeshieldParams, +): Promise<{ txid: string }> { + if (deshieldInProgress) { + throw new Error("A deshield transaction is already in progress") + } + deshieldInProgress = true + try { + return await _deshieldZecInner(wallet, params) + } finally { + deshieldInProgress = false + } +} + +async function _deshieldZecInner( + wallet: any, + params: DeshieldParams, +): Promise<{ txid: string }> { + const account = params.account ?? 0 + + // 0. Ensure sidecar running + FVK set + if (!isSidecarReady()) { + await startSidecar() + } + const cached = getCachedFvk() + if (!cached) { + await initializeOrchardFromDevice(wallet, account) + } + + // 1. Build deshield PCZT via sidecar + console.log("[zcash-deshield] Building deshield PCZT...") + const buildResult: DeshieldBuildResult = await sendCommand("build_deshield_pczt", { + recipient: params.recipient, + amount: params.amount, + account, + }, 600000) // Halo2 proof can take a while + + const sr = buildResult.orchard_signing_request + console.log(`[zcash-deshield] PCZT built: ${sr.n_actions} Orchard actions`) + console.log(`[zcash-deshield] Display: ${buildResult.display.amount} → ${buildResult.display.action}`) + + // 2. Device signs Orchard actions (same as shielded send — no transparent signing needed) + console.log("[zcash-deshield] Requesting device signatures...") + if (typeof wallet.zcashSignPczt !== "function") { + throw new Error("hdwallet does not support zcashSignPczt — ensure Zcash-capable firmware") + } + + const signatures = await wallet.zcashSignPczt(sr, sr.sighash) + if (!signatures || !Array.isArray(signatures)) { + throw new Error("Device did not return signatures") + } + + console.log(`[zcash-deshield] Got ${signatures.length} Orchard signatures`) + + // 3. Finalize via sidecar — only Orchard signatures, no transparent sigs + console.log("[zcash-deshield] Finalizing deshield transaction...") + const { raw_tx, txid } = await sendCommand("finalize_deshield", { + orchard_signatures: signatures, + }) + + // 4. Broadcast + console.log(`[zcash-deshield] raw_tx length: ${raw_tx?.length / 2} bytes`) + console.log("[zcash-deshield] Broadcasting...") + await sendCommand("broadcast", { raw_tx }) + + console.log(`[zcash-deshield] Deshield transaction sent: ${txid}`) + return { txid } +} diff --git a/projects/keepkey-vault/src/mainview/components/ZcashPrivacyTab.tsx b/projects/keepkey-vault/src/mainview/components/ZcashPrivacyTab.tsx index 3fd79bed..9f3bba03 100644 --- a/projects/keepkey-vault/src/mainview/components/ZcashPrivacyTab.tsx +++ b/projects/keepkey-vault/src/mainview/components/ZcashPrivacyTab.tsx @@ -71,6 +71,14 @@ export function ZcashPrivacyTab() { const [shieldStep, setShieldStep] = useState(null) const [transparentBalance, setTransparentBalance] = useState(null) + // Deshield form state + const [deshieldRecipient, setDeshieldRecipient] = useState("") + const [deshieldAmount, setDeshieldAmount] = useState("") + const [deshielding, setDeshielding] = useState(false) + const [deshieldResult, setDeshieldResult] = useState(null) + const [deshieldError, setDeshieldError] = useState(null) + const [deshieldStep, setDeshieldStep] = useState(null) + // Transaction history state const [transactions, setTransactions] = useState(null) const [expandedMemo, setExpandedMemo] = useState(null) + // Deshield address validation (transparent only) + const deshieldRecipientValidation = useMemo(() => { + if (!deshieldRecipient) return null + const s = deshieldRecipient.trim() + const BASE58 = /^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$/ + if ((s.startsWith('t1') || s.startsWith('t3')) && s.length === 35 && BASE58.test(s)) return { valid: true } + return { valid: false, error: 'deshieldRequiresTransparent' } + }, [deshieldRecipient]) + // Address validation const recipientValidation = useMemo(() => { if (!recipient) return null @@ -360,6 +377,42 @@ export function ZcashPrivacyTab() { setShieldStep(null) }, [shieldAmount, refreshBalance]) + // ── Deshield to transparent ────────────────────────────────────── + const handleDeshield = useCallback(async () => { + if (!deshieldRecipient || !deshieldAmount) return + if (deshieldRecipientValidation && !deshieldRecipientValidation.valid) { + setDeshieldError(deshieldRecipientValidation.error ? t(deshieldRecipientValidation.error) : t("invalidAddress")) + return + } + setDeshielding(true) + setDeshieldError(null) + setDeshieldResult(null) + setDeshieldStep("building") + try { + const parts = deshieldAmount.split(".") + const whole = BigInt(parts[0] || "0") * 100_000_000n + const fracStr = (parts[1] || "").padEnd(8, "0").slice(0, 8) + const frac = BigInt(fracStr) + const zatoshisBig = whole + frac + if (zatoshisBig <= 0n || zatoshisBig > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error("Invalid amount") + const zatoshis = Number(zatoshisBig) + + const result = await rpcRequest<{ txid: string }>( + "zcashDeshieldZec", + { recipient: deshieldRecipient, amount: zatoshis }, + 600000 // 10 min — Halo2 proof + device signing + ) + setDeshieldResult(result.txid) + setDeshieldRecipient("") + setDeshieldAmount("") + refreshBalance() + } catch (e: any) { + setDeshieldError(e.message || "Deshield failed") + } + setDeshielding(false) + setDeshieldStep(null) + }, [deshieldRecipient, deshieldAmount, deshieldRecipientValidation, refreshBalance]) + // ── Copy address ────────────────────────────────────────────────── const copyAddress = useCallback(() => { if (!orchardAddress) return @@ -546,6 +599,107 @@ export function ZcashPrivacyTab() { )} + {/* Section F2: Deshield Orchard → transparent */} + {orchardAddress && balance && balance.confirmed > 0 && ( + + + + + {t("deshieldToTransparent")} + + + + {t("deshieldDescription")} + + + {deshielding ? ( + + + + {deshieldStep === "building" ? t("shieldBuilding") : + deshieldStep === "signing" ? t("shieldSigning") : + deshieldStep === "broadcasting" ? t("shieldBroadcasting") : + t("shieldProcessing")} + + + ) : ( + + setDeshieldRecipient(e.target.value)} + size="sm" + bg="rgba(255,255,255,0.03)" + borderColor="kk.border" + color="white" + fontFamily="mono" + fontSize="12px" + _hover={{ borderColor: "kk.textMuted" }} + _focus={{ borderColor: "#F87171", boxShadow: "none" }} + /> + {deshieldRecipientValidation && !deshieldRecipientValidation.valid && deshieldRecipientValidation.error && ( + {t(deshieldRecipientValidation.error)} + )} + + setDeshieldAmount(e.target.value)} + size="sm" + type="number" + step="0.00000001" + bg="rgba(255,255,255,0.03)" + borderColor="kk.border" + color="white" + fontFamily="mono" + fontSize="12px" + _hover={{ borderColor: "kk.textMuted" }} + _focus={{ borderColor: "#F87171", boxShadow: "none" }} + flex="1" + /> + {balance && balance.confirmed > 0 && ( + + )} + + + + )} + + {deshieldResult && ( + + {t("deshielded")} + + {deshieldResult} + + + )} + {deshieldError && ( + {deshieldError} + )} + + )} + {/* Section C: Orchard address (receive) */} {orchardAddress && ( diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/en/asset.json b/projects/keepkey-vault/src/mainview/i18n/locales/en/asset.json index 2092fd21..ca751124 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/en/asset.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/en/asset.json @@ -53,6 +53,10 @@ "shieldBroadcasting": "Broadcasting...", "shieldProcessing": "Processing...", "shielded": "Shielded!", + "deshieldToTransparent": "Unshield to Transparent", + "deshieldDescription": "Move shielded ZEC from the Orchard pool back to a transparent address (t1/t3).", + "deshielded": "Unshielded!", + "deshieldRequiresTransparent": "Deshielding requires a transparent address (t1... or t3...)", "transactionHistory": "Transaction History", "noTransactions": "No transactions yet — scan to find payments", "received": "Received", diff --git a/projects/keepkey-vault/src/shared/rpc-schema.ts b/projects/keepkey-vault/src/shared/rpc-schema.ts index a22c9970..256469d6 100644 --- a/projects/keepkey-vault/src/shared/rpc-schema.ts +++ b/projects/keepkey-vault/src/shared/rpc-schema.ts @@ -116,6 +116,7 @@ export type VaultRPCSchema = ElectrobunRPCSchema & { zcashShieldedBalance: { params: void; response: { confirmed: number; pending: number; synced_to?: number | null; notes_total?: number; notes_unspent?: number; keepkey_release_block?: number } } zcashShieldedSend: { params: { recipient: string; amount: number; memo?: string }; response: { txid: string } } zcashShieldZec: { params: { amount: number; account?: number }; response: { txid: string } } + zcashDeshieldZec: { params: { recipient: string; amount: number; account?: number }; response: { txid: string } } zcashGetTransactions: { params: void; response: { transactions: ZcashTransaction[] } } zcashBackfillMemos: { params: void; response: { backfilled: number } } diff --git a/projects/keepkey-vault/zcash-cli/src/main.rs b/projects/keepkey-vault/zcash-cli/src/main.rs index a0e3487d..ca5322df 100644 --- a/projects/keepkey-vault/zcash-cli/src/main.rs +++ b/projects/keepkey-vault/zcash-cli/src/main.rs @@ -27,6 +27,8 @@ struct State { pending_pczt: Option, /// Pending shield (transparent → Orchard) PCZT state waiting for signatures pending_shield_pczt: Option, + /// Pending deshield (Orchard → transparent) PCZT state waiting for signatures + pending_deshield_pczt: Option, } impl State { @@ -36,6 +38,7 @@ impl State { fvk: None, pending_pczt: None, pending_shield_pczt: None, + pending_deshield_pczt: None, } } @@ -46,6 +49,7 @@ impl State { fvk: None, pending_pczt: None, pending_shield_pczt: None, + pending_deshield_pczt: None, } } @@ -132,11 +136,16 @@ fn encode_unified_address(addr: &orchard::Address) -> Result { Ok(ua.encode(&NetworkType::Main)) } +/// Parsed recipient — either an Orchard address or a transparent scriptPubKey. +enum ParsedRecipient { + Orchard(orchard::Address), + Transparent { script_pubkey: Vec }, +} + /// Parse a recipient address string into an Orchard Address. /// /// Supports: /// - Unified Address (`u1...`) — extracts the Orchard receiver -/// - Transparent (`t1...`) — returns error (deshielding not yet supported) /// - Raw hex (86 chars = 43 bytes) — legacy/debug path fn parse_recipient_address(addr: &str) -> Result { let trimmed = addr.trim(); @@ -159,11 +168,11 @@ fn parse_recipient_address(addr: &str) -> Result { return Err(anyhow::anyhow!("Unified Address has no Orchard receiver — cannot send from shielded pool")); } - // Transparent address (t1... / t3...) + // Transparent address (t1... / t3...) — use parse_recipient_flexible for deshielding if trimmed.starts_with("t1") || trimmed.starts_with("t3") { return Err(anyhow::anyhow!( - "Deshielding (Orchard → transparent) is not yet supported. \ - Please send to a Unified Address (u1...) that contains an Orchard receiver." + "Transparent addresses require the deshield command. \ + Use build_deshield_pczt for Orchard → transparent transactions." )); } @@ -177,6 +186,93 @@ fn parse_recipient_address(addr: &str) -> Result { .ok_or_else(|| anyhow::anyhow!("Invalid raw Orchard address bytes")) } +/// Parse a recipient that may be Orchard or transparent (for deshielding). +fn parse_recipient_flexible(addr: &str) -> Result { + let trimmed = addr.trim(); + + // Transparent address (t1... P2PKH / t3... P2SH) + if trimmed.starts_with("t1") || trimmed.starts_with("t3") { + let script_pubkey = decode_transparent_address(trimmed)?; + return Ok(ParsedRecipient::Transparent { script_pubkey }); + } + + // Otherwise, try Orchard + let orchard_addr = parse_recipient_address(trimmed)?; + Ok(ParsedRecipient::Orchard(orchard_addr)) +} + +/// Decode a Zcash transparent address (t1=P2PKH, t3=P2SH) into its scriptPubKey. +fn decode_transparent_address(addr: &str) -> Result> { + // Base58Check decode + const ALPHABET: &str = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + let mut num = vec![0u8; 0]; + + // Base58 → big integer → bytes + let mut big = vec![0u8; 1]; + for ch in addr.chars() { + let digit = ALPHABET.find(ch) + .ok_or_else(|| anyhow::anyhow!("Invalid base58 character: {}", ch))? as u8; + // Multiply big by 58 and add digit + let mut carry = digit as u32; + for byte in big.iter_mut().rev() { + carry += (*byte as u32) * 58; + *byte = (carry & 0xFF) as u8; + carry >>= 8; + } + while carry > 0 { + big.insert(0, (carry & 0xFF) as u8); + carry >>= 8; + } + } + + // Count leading '1's → leading zero bytes + let leading_zeros = addr.chars().take_while(|&c| c == '1').count(); + num = vec![0u8; leading_zeros]; + num.extend_from_slice(&big); + + // Should be 26 bytes: 2 version + 20 hash + 4 checksum + if num.len() < 26 { + return Err(anyhow::anyhow!("Base58Check decode too short: {} bytes", num.len())); + } + + let payload = &num[..num.len() - 4]; + let _checksum = &num[num.len() - 4..]; + // Skip checksum verification for brevity — zcash addresses are validated by the wallet + + let version = &payload[..2]; + let hash = &payload[2..]; + + if hash.len() != 20 { + return Err(anyhow::anyhow!("Invalid address hash length: {}", hash.len())); + } + + match version { + // Zcash mainnet P2PKH: 0x1CB8 + [0x1C, 0xB8] => { + // OP_DUP OP_HASH160 <20> OP_EQUALVERIFY OP_CHECKSIG + let mut script = Vec::with_capacity(25); + script.push(0x76); // OP_DUP + script.push(0xA9); // OP_HASH160 + script.push(0x14); // push 20 bytes + script.extend_from_slice(hash); + script.push(0x88); // OP_EQUALVERIFY + script.push(0xAC); // OP_CHECKSIG + Ok(script) + } + // Zcash mainnet P2SH: 0x1CBD + [0x1C, 0xBD] => { + // OP_HASH160 <20> OP_EQUAL + let mut script = Vec::with_capacity(23); + script.push(0xA9); // OP_HASH160 + script.push(0x14); // push 20 bytes + script.extend_from_slice(hash); + script.push(0x87); // OP_EQUAL + Ok(script) + } + _ => Err(anyhow::anyhow!("Unknown address version: {:02x}{:02x}", version[0], version[1])), + } +} + // ── Command handlers ─────────────────────────────────────────────────── async fn handle_derive_fvk(state: &mut State, params: &Value) -> Result { @@ -612,6 +708,108 @@ async fn handle_finalize_shield(state: &mut State, params: &Value) -> Result Result { + let fvk = state.fvk.as_ref() + .ok_or_else(|| anyhow::anyhow!("No FVK set — call set_fvk first"))? + .clone(); + + let recipient_str = params.get("recipient") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing recipient (t1... or t3... address)"))?; + let amount = params.get("amount") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing amount"))?; + let account = params.get("account") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + + // Parse recipient — must be a transparent address for deshielding + let script_pubkey = match parse_recipient_flexible(recipient_str)? { + ParsedRecipient::Transparent { script_pubkey } => script_pubkey, + ParsedRecipient::Orchard(_) => { + return Err(anyhow::anyhow!( + "Deshield requires a transparent address (t1.../t3...). \ + For shielded sends (u1...), use build_pczt instead." + )); + } + }; + + // Get spendable notes + let db = state.ensure_db()?; + let notes = db.get_spendable_notes()?; + if notes.is_empty() { + return Err(anyhow::anyhow!("No spendable notes — scan first")); + } + + let mut lwd_client = scanner::LightwalletClient::connect(None).await?; + let branch_id = lwd_client.get_consensus_branch_id().await?; + info!("Using consensus branch ID: 0x{:08x}", branch_id); + + // Build the transparent output(s) + let transparent_output = pczt_builder::DeshieldTransparentOutput { + script_pubkey: hex::encode(&script_pubkey), + value: amount, + }; + + let deshield_state = pczt_builder::build_deshield_pczt( + &fvk, notes, transparent_output, amount, account, branch_id, + &mut lwd_client, db, + ).await?; + + // Build the signing request JSON for the TypeScript layer + let orchard_json = serde_json::to_value(&deshield_state.orchard_signing_request) + .unwrap_or_default(); + + let transparent_outputs_json: Vec = deshield_state.transparent_outputs.iter().map(|o| { + serde_json::json!({ + "value": o.value, + "script_pubkey": hex::encode(&o.script_pubkey), + }) + }).collect(); + + let signing_request = serde_json::json!({ + "orchard_signing_request": orchard_json, + "transparent_outputs": transparent_outputs_json, + "display": { + "amount": format!("{:.8} ZEC", amount as f64 / 1e8), + "fee": deshield_state.orchard_signing_request.display.fee, + "action": "Deshield to transparent" + } + }); + + state.pending_deshield_pczt = Some(deshield_state); + + Ok(signing_request) +} + +async fn handle_finalize_deshield(state: &mut State, params: &Value) -> Result { + let deshield_state = state.pending_deshield_pczt.take() + .ok_or_else(|| anyhow::anyhow!("No pending deshield PCZT — call build_deshield_pczt first"))?; + + let orchard_sigs_json = params.get("orchard_signatures") + .and_then(|v| v.as_array()) + .ok_or_else(|| anyhow::anyhow!("Missing orchard_signatures array"))?; + + let mut orchard_sigs: Vec> = Vec::new(); + for sig_val in orchard_sigs_json { + let sig_hex = sig_val.as_str() + .ok_or_else(|| anyhow::anyhow!("Orchard signature must be hex string"))?; + orchard_sigs.push(hex::decode(sig_hex)?); + } + + let (raw_tx, txid) = pczt_builder::finalize_deshield_pczt( + deshield_state, + &orchard_sigs, + )?; + + Ok(serde_json::json!({ + "raw_tx": hex::encode(&raw_tx), + "txid": txid, + })) +} + /// Decode a 512-byte raw Zcash memo per ZIP-302. /// Returns Some(text) for UTF-8 text memos, None for empty/binary. fn decode_zip302_memo(raw: &[u8]) -> Option { @@ -1012,6 +1210,8 @@ async fn main() { "finalize" => handle_finalize(&mut state, &request.params).await, "build_shield_pczt" => handle_build_shield_pczt(&mut state, &request.params).await, "finalize_shield" => handle_finalize_shield(&mut state, &request.params).await, + "build_deshield_pczt" => handle_build_deshield_pczt(&mut state, &request.params).await, + "finalize_deshield" => handle_finalize_deshield(&mut state, &request.params).await, "broadcast" => handle_broadcast(&mut state, &request.params).await, "get_transactions" => handle_get_transactions(&mut state, &request.params).await, "backfill_memos" => handle_backfill_memos(&mut state, &request.params).await, diff --git a/projects/keepkey-vault/zcash-cli/src/pczt_builder.rs b/projects/keepkey-vault/zcash-cli/src/pczt_builder.rs index f464f961..63130c6d 100644 --- a/projects/keepkey-vault/zcash-cli/src/pczt_builder.rs +++ b/projects/keepkey-vault/zcash-cli/src/pczt_builder.rs @@ -1487,6 +1487,441 @@ fn write_compact_size(buf: &mut Vec, n: u64) { } } +// ── Deshielding (Orchard → transparent) ────────────────────────────── + +/// Transparent output for deshield PCZT construction. +#[derive(Debug, Clone, Serialize)] +pub struct DeshieldTransparentOutput { + pub script_pubkey: String, // hex + pub value: u64, // zatoshis +} + +/// Intermediate state for deshield PCZT (between build and finalize). +pub struct DeshieldPcztState { + pub pczt_bundle: orchard::pczt::Bundle, + pub sighash: [u8; 32], + pub branch_id: u32, + pub orchard_signing_request: SigningRequest, + pub transparent_outputs: Vec, +} + +/// Build a deshield PCZT: Orchard spends → transparent output. +/// +/// Uses the same tree-building + witness extraction as `build_pczt`, but instead +/// of an Orchard recipient output, the value goes to a transparent output. +/// Orchard change (if any) goes back to an internal Orchard address. +pub async fn build_deshield_pczt( + fvk: &FullViewingKey, + notes: Vec, + transparent_output: DeshieldTransparentOutput, + amount: u64, + account: u32, + branch_id: u32, + lwd_client: &mut crate::scanner::LightwalletClient, + _db: &crate::wallet_db::WalletDb, +) -> Result { + let mut rng = OsRng; + let total_input: u64 = notes.iter().map(|n| n.value).sum(); + + // ZIP-317 fee for a deshield tx: + // Orchard actions = max(n_spends, n_orchard_outputs) where n_orchard_outputs = change only + // Transparent logical actions = max(0 inputs, 1 output) = 1 + let n_spends = notes.len(); + let n_orchard_outputs = 1usize; // change output (or dummy pad) + let orchard_actions = std::cmp::max(n_spends, n_orchard_outputs); + let transparent_actions = 1usize; // one transparent output + let logical_actions = orchard_actions + transparent_actions; + let fee = ZIP317_MARGINAL_FEE * std::cmp::max(ZIP317_GRACE_ACTIONS, logical_actions as u64); + + let change = total_input.checked_sub(amount + fee) + .ok_or_else(|| anyhow::anyhow!( + "Insufficient shielded funds: have {} ZAT, need {} ZAT (amount {} + fee {})", + total_input, amount + fee, amount, fee + ))?; + + info!("Building deshield transaction:"); + info!(" Inputs: {} ZAT from {} notes", total_input, notes.len()); + info!(" Amount: {} ZAT → transparent", amount); + info!(" Fee: {} ZAT", fee); + info!(" Change: {} ZAT → Orchard", change); + + // Build transparent output + let script_pubkey_bytes = hex::decode(&transparent_output.script_pubkey)?; + let transparent_outputs = vec![ + zip244::TransparentOutput { + value: amount, + script_pubkey: script_pubkey_bytes, + }, + ]; + + // ── Tree building: reuse exact same pattern as build_pczt ────────────── + + const SHARD_SIZE: u64 = 1 << 16; + let mut note_positions: Vec = vec![0; notes.len()]; + let mut found_notes = vec![false; notes.len()]; + let mut note_shards: std::collections::BTreeSet = std::collections::BTreeSet::new(); + + for (i, spendable) in notes.iter().enumerate() { + let approx_pos = if let Some(pos) = spendable.position { + pos + } else { + let tree_size_before = if spendable.block_height > 0 { + lwd_client.get_orchard_tree_size_at(spendable.block_height - 1).await? + } else { 0 }; + tree_size_before + }; + note_shards.insert((approx_pos / SHARD_SIZE) as u32); + info!("Note {}: block={}, approx_shard={}", i, spendable.block_height, approx_pos / SHARD_SIZE); + } + + let lwd_tip_height = lwd_client.get_latest_block_height().await?; + let subtree_roots = lwd_client.get_subtree_roots(0, 0).await?; + let num_shards = subtree_roots.len(); + + if subtree_roots.is_empty() { + return Err(anyhow::anyhow!("No Orchard subtree roots available from lightwalletd")); + } + + let note_cmx_set: std::collections::HashMap<[u8; 32], usize> = notes.iter().enumerate() + .map(|(i, n)| (n.cmx, i)) + .collect(); + + let mut tree: ShardTree, 32, 16> = + ShardTree::new(MemoryShardStore::empty(), 100); + + // Insert completed shard roots (not containing our notes) + for (shard_idx, root_hash, completing_height) in &subtree_roots { + if note_shards.contains(shard_idx) { continue; } + let root = MerkleHashOrchard::from_bytes(&root_hash); + if bool::from(root.is_none()) { continue; } + let addr = incrementalmerkletree::Address::above_position( + 16.into(), + incrementalmerkletree::Position::from((*shard_idx as u64) * SHARD_SIZE), + ); + tree.insert(addr, root.unwrap()) + .map_err(|e| anyhow::anyhow!("Failed to insert shard root {}: {:?}", shard_idx, e))?; + debug!("Inserted shard {} root (completing_height={})", shard_idx, completing_height); + } + + // For shards containing our notes, fetch all leaves and append + for shard_idx in ¬e_shards { + let shard_start_pos = (*shard_idx as u64) * SHARD_SIZE; + + let (fetch_start_height, actions_to_skip) = if *shard_idx == 0 { + (1687104u64, 0u64) + } else { + let prev_completing = subtree_roots.iter() + .find(|(idx, _, _)| *idx == shard_idx - 1) + .map(|(_, _, h)| *h) + .unwrap_or(1687104); + let tree_size_before_completing = if prev_completing > 0 { + lwd_client.get_orchard_tree_size_at(prev_completing - 1).await? + } else { 0 }; + let tree_size_after_completing = lwd_client.get_orchard_tree_size_at(prev_completing).await?; + let plan = plan_incomplete_shard_fetch( + prev_completing, shard_start_pos, + tree_size_before_completing, tree_size_after_completing, + ); + (plan.fetch_start_height, plan.actions_to_skip) + }; + + let is_complete_shard = subtree_roots.iter().any(|(idx, _, _)| idx == shard_idx); + let shard_end_height = if is_complete_shard { + subtree_roots.iter().find(|(idx, _, _)| idx == shard_idx).map(|(_, _, h)| *h).unwrap() + } else { lwd_tip_height }; + let shard_end_pos = if is_complete_shard { + (*shard_idx as u64 + 1) * SHARD_SIZE + } else { u64::MAX }; + + info!("Fetching leaves for shard {} (heights {} to {})", shard_idx, fetch_start_height, shard_end_height); + + let chunk_size = 10000u64; + let mut current_pos = shard_start_pos; + let mut current_height = fetch_start_height; + let mut global_action_counter = 0u64; + 'block_fetch: while current_height <= shard_end_height { + let end = std::cmp::min(current_height + chunk_size - 1, shard_end_height); + let blocks = lwd_client.fetch_block_actions(current_height, end).await?; + + for (_block_height, txs) in &blocks { + for (_tx_idx, cmxs) in txs { + for cmx_bytes in cmxs.iter() { + if global_action_counter < actions_to_skip { + global_action_counter += 1; + continue; + } + global_action_counter += 1; + if current_pos >= shard_end_pos { break 'block_fetch; } + + let cmx = orchard::note::ExtractedNoteCommitment::from_bytes(cmx_bytes); + if bool::from(cmx.is_none()) { continue; } + let leaf = MerkleHashOrchard::from_cmx(&cmx.unwrap()); + + let retention = if let Some(¬e_idx) = note_cmx_set.get(cmx_bytes) { + note_positions[note_idx] = current_pos; + found_notes[note_idx] = true; + info!("Note {} found at pos {}", note_idx, current_pos); + Retention::Marked + } else { + Retention::Ephemeral + }; + + tree.append(leaf, retention) + .context(format!("Failed to append leaf at pos {}", current_pos))?; + current_pos += 1; + } + } + } + current_height = end + 1; + } + } + + // Reconstruct notes + let mut orchard_notes: Vec = Vec::new(); + for (i, spendable) in notes.iter().enumerate() { + let recipient_arr: [u8; 43] = spendable.recipient.clone().try_into() + .map_err(|_| anyhow::anyhow!("Invalid recipient bytes for note {}", i))?; + let note_recipient = Address::from_raw_address_bytes(&recipient_arr) + .into_option() + .ok_or_else(|| anyhow::anyhow!("Invalid Orchard address for note {}", i))?; + let rho = Rho::from_bytes(&spendable.rho) + .into_option() + .ok_or_else(|| anyhow::anyhow!("Invalid rho for note {}", i))?; + let rseed = RandomSeed::from_bytes(spendable.rseed, &rho) + .into_option() + .ok_or_else(|| anyhow::anyhow!("Invalid rseed for note {}", i))?; + let note = Note::from_parts( + note_recipient, NoteValue::from_raw(spendable.value), rho, rseed, + ).into_option() + .ok_or_else(|| anyhow::anyhow!("Failed to reconstruct note {}", i))?; + orchard_notes.push(note); + } + + if found_notes.iter().any(|found| !found) { + return Err(anyhow::anyhow!("Not all note cmxs found during tree walk")); + } + + // Checkpoint and validate anchor + let anchor_checkpoint_id = u32::MAX; + tree.checkpoint(anchor_checkpoint_id).context("Failed to checkpoint")?; + let root = tree.root_at_checkpoint_id(&anchor_checkpoint_id) + .context("Failed to get root")? + .ok_or_else(|| anyhow::anyhow!("Empty Merkle tree"))?; + + let computed_anchor_bytes = root.to_bytes(); + let expected_anchor = lwd_client.get_orchard_anchor(lwd_tip_height).await?; + if computed_anchor_bytes != expected_anchor { + return Err(anyhow::anyhow!( + "Orchard anchor mismatch: computed={} vs expected={}", + hex::encode(&computed_anchor_bytes), hex::encode(&expected_anchor), + )); + } + let anchor: Anchor = root.into(); + + // ── Build PCZT bundle ────────────────────────────────────────── + + let mut builder = Builder::new(BundleType::DEFAULT, anchor); + + let mut sorted_notes: Vec<(u64, usize)> = note_positions.iter().enumerate() + .map(|(i, &pos)| (pos, i)).collect(); + sorted_notes.sort_by_key(|(pos, _)| *pos); + + for &(pos, orig_idx) in &sorted_notes { + let position = incrementalmerkletree::Position::from(pos); + let merkle_path = tree.witness_at_checkpoint_id(position, &anchor_checkpoint_id) + .context(format!("Failed to get witness for note {} at pos {}", orig_idx, pos))? + .ok_or_else(|| anyhow::anyhow!("No witness for note {} at pos {}", orig_idx, pos))?; + builder.add_spend(fvk.clone(), orchard_notes[orig_idx].clone(), merkle_path.into()) + .map_err(|e| anyhow::anyhow!("Failed to add spend {}: {:?}", orig_idx, e))?; + } + + // Change goes to Orchard (internal) + if change > 0 { + let change_addr = fvk.address_at(0u32, Scope::Internal); + let internal_ovk = fvk.to_ovk(Scope::Internal); + let empty_memo = { let mut m = [0u8; 512]; m[0] = 0xF6; m }; + builder.add_output(Some(internal_ovk), change_addr, NoteValue::from_raw(change), empty_memo) + .map_err(|e| anyhow::anyhow!("Failed to add change output: {:?}", e))?; + } + + let (mut pczt_bundle, _) = builder.build_for_pczt(&mut rng) + .map_err(|e| anyhow::anyhow!("Failed to build PCZT: {:?}", e))?; + + // ── Compute ZIP-244 digests (hybrid: transparent outputs + Orchard) ── + + let effects_bundle = pczt_bundle.extract_effects::() + .map_err(|e| anyhow::anyhow!("Failed to extract effects: {:?}", e))? + .ok_or_else(|| anyhow::anyhow!("Empty effects bundle"))?; + + let digests = zip244::compute_zip244_digests_hybrid( + &effects_bundle, &[], &transparent_outputs, branch_id, 0, 0, + ); + let sighash = zip244::compute_sighash(&digests, branch_id); + + pczt_bundle.finalize_io(sighash, &mut rng) + .map_err(|e| anyhow::anyhow!("IO finalization failed: {:?}", e))?; + + info!("Generating Halo2 proof for deshield..."); + let pk = ProvingKey::build(); + pczt_bundle.create_proof(&pk, &mut rng) + .map_err(|e| anyhow::anyhow!("Proof generation failed: {:?}", e))?; + info!("Proof generated successfully"); + + // ── Extract signing fields ────────────────────────────────────── + + let n_actions = pczt_bundle.actions().len(); + let mut action_fields: Vec = Vec::new(); + + for i in 0..n_actions { + let alpha_bytes = pczt_bundle.actions()[i].spend().alpha() + .map(|a| a.to_repr().to_vec()) + .unwrap_or_else(|| vec![0u8; 32]); + let cv_net_bytes = pczt_bundle.actions()[i].cv_net().to_bytes().to_vec(); + let is_spend = pczt_bundle.actions()[i].spend().spend_auth_sig().is_none(); + let value = pczt_bundle.actions()[i].spend().value() + .map(|v| v.inner()).unwrap_or(0); + + let effects_action = &effects_bundle.actions()[i]; + let nullifier_bytes = effects_action.nullifier().to_bytes().to_vec(); + let cmx_bytes = effects_action.cmx().to_bytes().to_vec(); + let epk_bytes = effects_action.encrypted_note().epk_bytes.as_ref().to_vec(); + let enc = &effects_action.encrypted_note().enc_ciphertext; + if enc.len() != 580 { + return Err(anyhow::anyhow!("Invalid enc_ciphertext length: {}", enc.len())); + } + let rk_bytes: [u8; 32] = effects_action.rk().into(); + + action_fields.push(ActionFields { + index: i as u32, + alpha: alpha_bytes, + cv_net: cv_net_bytes, + nullifier: nullifier_bytes, + cmx: cmx_bytes, + epk: epk_bytes, + enc_compact: enc[..52].to_vec(), + enc_memo: enc[52..564].to_vec(), + enc_noncompact: enc[564..].to_vec(), + rk: rk_bytes.to_vec(), + out_ciphertext: effects_action.encrypted_note().out_ciphertext.to_vec(), + value, + is_spend, + }); + } + + let signing_request = SigningRequest { + n_actions: n_actions as u32, + account, + branch_id, + sighash: sighash.to_vec(), + digests: DigestFields { + header: digests.header_digest.to_vec(), + transparent: digests.transparent_digest.to_vec(), + sapling: digests.sapling_digest.to_vec(), + orchard: digests.orchard_digest.to_vec(), + }, + bundle_meta: BundleMeta { + flags: effects_bundle.flags().to_byte() as u32, + value_balance: *effects_bundle.value_balance(), + anchor: effects_bundle.anchor().to_bytes().to_vec(), + }, + actions: action_fields, + display: DisplayInfo { + amount: format!("{:.8} ZEC", amount as f64 / 1e8), + fee: format!("{:.8} ZEC", fee as f64 / 1e8), + to: format!("transparent (deshield)"), + }, + }; + + Ok(DeshieldPcztState { + pczt_bundle, + sighash, + branch_id, + orchard_signing_request: signing_request, + transparent_outputs, + }) +} + +/// Finalize a deshield PCZT: apply Orchard signatures, serialize hybrid v5 tx. +/// +/// No transparent signatures needed — deshield has no transparent inputs. +pub fn finalize_deshield_pczt( + state: DeshieldPcztState, + orchard_signatures: &[Vec], +) -> Result<(Vec, String)> { + let mut rng = OsRng; + let mut pczt_bundle = state.pczt_bundle; + let sighash = state.sighash; + + let n_actions = pczt_bundle.actions().len(); + let is_real_spend: Vec = (0..n_actions) + .map(|i| pczt_bundle.actions()[i].spend().spend_auth_sig().is_none()) + .collect(); + let signature_plan = plan_orchard_signature_application(&is_real_spend, orchard_signatures.len())?; + + // Apply Orchard signatures + for (i, sig_index) in signature_plan.iter().enumerate() { + let Some(sig_index) = sig_index else { + info!("Action {}: dummy spend — skipping", i); + continue; + }; + let sig_bytes = &orchard_signatures[*sig_index]; + if sig_bytes.len() != 64 { + return Err(anyhow::anyhow!("Invalid Orchard sig length for action {}: {}", i, sig_bytes.len())); + } + + let mut sig_arr = [0u8; 64]; + sig_arr.copy_from_slice(sig_bytes); + let signature: orchard::primitives::redpallas::Signature = sig_arr.into(); + + let rk = pczt_bundle.actions()[i].spend().rk(); + let verify_result = rk.verify(&sighash, &signature); + if verify_result.is_err() { + return Err(anyhow::anyhow!("Orchard sig verification failed for action {}", i)); + } + + pczt_bundle.actions_mut()[i] + .apply_signature(sighash, signature) + .map_err(|e| anyhow::anyhow!("Failed to apply sig for action {}: {}", i, e))?; + } + + // Extract final bundle + let unbound_bundle = pczt_bundle.extract::() + .map_err(|e| anyhow::anyhow!("Failed to extract bundle: {}", e))? + .ok_or_else(|| anyhow::anyhow!("Empty bundle after extraction"))?; + + let authorized_bundle = unbound_bundle.apply_binding_signature(sighash, &mut rng) + .ok_or_else(|| anyhow::anyhow!("Binding signature verification failed"))?; + + // Serialize as hybrid v5 tx: no transparent inputs, transparent outputs, Orchard bundle + let tx_bytes = serialize_v5_hybrid_tx( + &authorized_bundle, + &[], // no transparent inputs + &state.transparent_outputs, + &[], // no transparent signatures + state.branch_id, + None, // no pubkey needed (no transparent inputs) + )?; + + // Compute txid + let header_digest = zip244::digest_header(state.branch_id, 0, 0); + let transparent_txid_digest = zip244::digest_transparent_txid( + &[], + &state.transparent_outputs, + ); + let orchard_digest = zip244::digest_orchard(&authorized_bundle); + let txid_digests = zip244::Zip244Digests { + header_digest, + transparent_digest: transparent_txid_digest, + sapling_digest: zip244::EMPTY_SAPLING_DIGEST, + orchard_digest, + }; + let txid_hash = zip244::compute_sighash(&txid_digests, state.branch_id); + let txid = hex::encode(&txid_hash); + + info!("Deshield tx built: {} bytes, txid: {}", tx_bytes.len(), txid); + Ok((tx_bytes, txid)) +} + // ── Tests ───────────────────────────────────────────────────────────────── #[cfg(test)] From 6424663cb2223f64e4a78e181c868f7d45c31ef1 Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 24 Mar 2026 17:43:27 -0600 Subject: [PATCH 06/17] fix: address validation, fee estimation, and progress channel for deshield MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Base58Check checksum verification in transparent address decoder — rejects typo'd t1/t3 addresses before building the output script, preventing funds from being sent to invalid addresses. 2. Deshield Max button now uses ZIP-317 fee estimation based on actual unspent note count instead of hardcoded 10000 zatoshis. Reads notes_unspent from balance response and computes 5000 * max(2, max(n_spends, 1) + 1). 3. Separate deshield-progress RPC message channel so deshield stage transitions don't leak into the shield UI state. Added dedicated useEffect listener that updates deshieldStep/deshieldResult. --- projects/keepkey-vault/src/bun/index.ts | 4 +-- .../mainview/components/ZcashPrivacyTab.tsx | 27 ++++++++++++++++--- .../keepkey-vault/src/shared/rpc-schema.ts | 1 + projects/keepkey-vault/zcash-cli/src/main.rs | 16 +++++++++-- 4 files changed, 40 insertions(+), 8 deletions(-) diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index 63ef8e20..3256503c 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -1711,13 +1711,13 @@ const rpc = BrowserView.defineRPC({ if (!zcashPrivacyEnabled) throw new Error('Zcash privacy feature is disabled') if (!engine.wallet) throw new Error('No device connected') const { deshieldZec } = await import("./txbuilder/zcash-deshield") - try { rpc.send['shield-progress']({ step: 'building' }) } catch { /* webview not ready */ } + try { rpc.send['deshield-progress']({ step: 'building' }) } catch { /* webview not ready */ } const result = await deshieldZec(engine.wallet as any, { recipient: params.recipient, amount: params.amount, account: params.account, }) - try { rpc.send['shield-progress']({ step: 'complete', detail: result.txid }) } catch { /* webview not ready */ } + try { rpc.send['deshield-progress']({ step: 'complete', detail: result.txid }) } catch { /* webview not ready */ } return result }, diff --git a/projects/keepkey-vault/src/mainview/components/ZcashPrivacyTab.tsx b/projects/keepkey-vault/src/mainview/components/ZcashPrivacyTab.tsx index 9f3bba03..8de11736 100644 --- a/projects/keepkey-vault/src/mainview/components/ZcashPrivacyTab.tsx +++ b/projects/keepkey-vault/src/mainview/components/ZcashPrivacyTab.tsx @@ -49,7 +49,7 @@ export function ZcashPrivacyTab() { // ── State ────────────────────────────────────────────────────────── const [status, setStatus] = useState("checking") const [orchardAddress, setOrchardAddress] = useState(null) - const [balance, setBalance] = useState<{ confirmed: number; pending: number } | null>(null) + const [balance, setBalance] = useState<{ confirmed: number; pending: number; notes_unspent?: number } | null>(null) const [syncedTo, setSyncedTo] = useState(null) const [scanState, setScanState] = useState("idle") const [scanResult, setScanResult] = useState(null) @@ -117,16 +117,28 @@ export function ZcashPrivacyTab() { }) }, []) + // ── Deshield progress listener ─────────────────────────────────── + useEffect(() => { + return onRpcMessage("deshield-progress", (payload: { step: string; detail?: string }) => { + setDeshieldStep(payload.step) + if (payload.step === "complete" && payload.detail) { + setDeshieldResult(payload.detail) + setDeshielding(false) + setDeshieldStep(null) + } + }) + }, []) + // Whether the wallet has never been scanned (needs initial scan) const [needsScan, setNeedsScan] = useState(false) // ── Fetch balance ───────────────────────────────────────────────── const refreshBalance = useCallback(async () => { try { - const bal = await rpcRequest<{ confirmed: number; pending: number; synced_to?: number | null }>( + const bal = await rpcRequest<{ confirmed: number; pending: number; synced_to?: number | null; notes_unspent?: number }>( "zcashShieldedBalance", undefined, 10000 ) - setBalance(bal) + setBalance({ confirmed: bal.confirmed, pending: bal.pending, notes_unspent: bal.notes_unspent }) if (bal.synced_to != null) { setSyncedTo(bal.synced_to) setNeedsScan(false) @@ -662,7 +674,14 @@ export function ZcashPrivacyTab() { size="xs" variant="ghost" color="#F87171" - onClick={() => setDeshieldAmount(((balance.confirmed - 10000) / 1e8).toFixed(8))} + onClick={() => { + // ZIP-317 fee: 5000 * max(2, max(n_spends, n_orchard_outputs) + 1 transparent) + const nSpends = balance.notes_unspent || 1 + const orchardActions = Math.max(nSpends, 1) // at least 1 change output + const fee = 5000 * Math.max(2, orchardActions + 1) + const max = Math.max(0, balance.confirmed - fee) + setDeshieldAmount((max / 1e8).toFixed(8)) + }} _hover={{ bg: "rgba(248,113,113,0.1)" }} > Max diff --git a/projects/keepkey-vault/src/shared/rpc-schema.ts b/projects/keepkey-vault/src/shared/rpc-schema.ts index 256469d6..1e7d57cb 100644 --- a/projects/keepkey-vault/src/shared/rpc-schema.ts +++ b/projects/keepkey-vault/src/shared/rpc-schema.ts @@ -230,6 +230,7 @@ export type VaultRPCSchema = ElectrobunRPCSchema & { 'scan-progress': { percent: number; scannedHeight: number; tipHeight: number; blocksPerSec: number; etaSeconds: number } 'balance-updated': ChainBalance 'shield-progress': { step: string; detail?: string } + 'deshield-progress': { step: string; detail?: string } } } webview: { diff --git a/projects/keepkey-vault/zcash-cli/src/main.rs b/projects/keepkey-vault/zcash-cli/src/main.rs index ca5322df..07c88f97 100644 --- a/projects/keepkey-vault/zcash-cli/src/main.rs +++ b/projects/keepkey-vault/zcash-cli/src/main.rs @@ -236,8 +236,20 @@ fn decode_transparent_address(addr: &str) -> Result> { } let payload = &num[..num.len() - 4]; - let _checksum = &num[num.len() - 4..]; - // Skip checksum verification for brevity — zcash addresses are validated by the wallet + let expected_checksum = &num[num.len() - 4..]; + + // Base58Check: checksum = first 4 bytes of SHA256(SHA256(payload)) + use sha2::{Sha256, Digest}; + let first = Sha256::digest(payload); + let second = Sha256::digest(&first); + if &second[..4] != expected_checksum { + return Err(anyhow::anyhow!( + "Invalid address checksum — address may contain a typo. \ + Expected {:02x}{:02x}{:02x}{:02x}, got {:02x}{:02x}{:02x}{:02x}", + second[0], second[1], second[2], second[3], + expected_checksum[0], expected_checksum[1], expected_checksum[2], expected_checksum[3], + )); + } let version = &payload[..2]; let hash = &payload[2..]; From 82d70e308ead8840cb5552b2ce062d8d01483aa7 Mon Sep 17 00:00:00 2001 From: highlander Date: Wed, 25 Mar 2026 18:01:30 -0600 Subject: [PATCH 07/17] feat: add mobile pairing via vault.keepkey.com relay Desktop vault derives all pubkeys from the connected KeepKey, POSTs them to vault.keepkey.com/api/pairing, and displays a QR code + 8-char code for the mobile app to scan. Pubkey format matches Pioneer SDK expectations (addressNList, context, path, available_scripts_types). Also adds a Mobile panel (phone icon in TopNav) with an App Store QR code linking to https://apps.apple.com/us/app/keepkey-mobile/id6755204956, a "Pair Device" button, and feature overview. Co-Authored-By: Claude Opus 4.6 (1M context) --- projects/keepkey-vault/src/bun/index.ts | 167 ++++++++++++++ projects/keepkey-vault/src/mainview/App.tsx | 19 ++ .../components/DeviceSettingsDrawer.tsx | 34 ++- .../components/MobilePairingDialog.tsx | 213 ++++++++++++++++++ .../src/mainview/components/MobilePanel.tsx | 197 ++++++++++++++++ .../src/mainview/components/TopNav.tsx | 23 +- .../mainview/i18n/locales/en/settings.json | 35 +++ .../keepkey-vault/src/shared/rpc-schema.ts | 3 + 8 files changed, 687 insertions(+), 4 deletions(-) create mode 100644 projects/keepkey-vault/src/mainview/components/MobilePairingDialog.tsx create mode 100644 projects/keepkey-vault/src/mainview/components/MobilePanel.tsx diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index 3256503c..be9c5cb6 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -1774,6 +1774,173 @@ const rpc = BrowserView.defineRPC({ auth.revoke(params.apiKey) }, + // ── Mobile pairing (relay via vault.keepkey.com) ───────── + generateMobilePairing: async () => { + if (!engine.wallet) throw new Error('No device connected') + const wallet = engine.wallet as any + + // Get device info + const features = await wallet.getFeatures() + const deviceId = features?.deviceId || features?.device_id || engine.getDeviceState().deviceId || 'keepkey-device' + const deviceLabel = features?.label || engine.getDeviceState().label || 'My KeepKey' + const context = `keepkey:${deviceLabel}.json` + + // Helper: convert hardened BIP44 path array to string + const pathToString = (p: number[]) => 'm/' + p.map((n: number) => n >= 0x80000000 ? `${n - 0x80000000}'` : String(n)).join('/') + // Helper: account-level path (first 3 elements) + const accountPath = (p: number[]) => p.slice(0, 3) + + // Only use built-in CHAINS (not custom chains — those may lack rpc methods) + const fwVersion = engine.getDeviceState().firmwareVersion + const builtinChains = CHAINS.filter(c => isChainSupported(c, fwVersion) && !c.hidden) + + const pubkeys: any[] = [] + + // ── BTC: all 3 script types ── + const btcScripts = [ + { scriptType: 'p2pkh', purpose: 44, type: 'xpub', note: 'Bitcoin Legacy' }, + { scriptType: 'p2sh-p2wpkh', purpose: 49, type: 'ypub', note: 'Bitcoin SegWit' }, + { scriptType: 'p2wpkh', purpose: 84, type: 'zpub', note: 'Bitcoin Native SegWit' }, + ] + const btcChain = builtinChains.find(c => c.id === 'bitcoin') + const btcNetwork = btcChain?.networkId || 'bip122:000000000019d6689c085ae165831e93' + for (const s of btcScripts) { + try { + const addressNList = [s.purpose + 0x80000000, 0x80000000, 0x80000000] + const addressNListMaster = [...addressNList, 0, 0] + const result = await wallet.getPublicKeys([{ + addressNList, coin: 'Bitcoin', scriptType: s.scriptType, curve: 'secp256k1', + }]) + const xpub = result?.[0]?.xpub + if (xpub && typeof xpub === 'string') { + pubkeys.push({ + type: s.type, pubkey: xpub, master: xpub, + address: xpub, // SDK expects address field + path: pathToString(addressNList), + pathMaster: pathToString(addressNListMaster), + scriptType: s.scriptType, + available_scripts_types: ['p2pkh', 'p2sh', 'p2wpkh', 'p2sh-p2wpkh'], + note: s.note, context, + networks: [btcNetwork], + addressNList, addressNListMaster, + }) + } + } catch (e: any) { console.warn(`[mobilePairing] BTC ${s.scriptType} failed:`, e.message) } + } + + // ── Non-BTC UTXO chains: batch xpub derivation ── + const utxoChains = builtinChains.filter(c => c.chainFamily === 'utxo' && c.id !== 'bitcoin') + if (utxoChains.length > 0) { + try { + const xpubResults = await wallet.getPublicKeys(utxoChains.map(c => ({ + addressNList: accountPath(c.defaultPath), coin: c.coin, + scriptType: c.scriptType, curve: 'secp256k1', + }))) || [] + for (let i = 0; i < utxoChains.length; i++) { + const xpub = xpubResults?.[i]?.xpub + if (xpub && typeof xpub === 'string') { + const chain = utxoChains[i] + const addressNList = accountPath(chain.defaultPath) + const addressNListMaster = [...addressNList, 0, 0] + pubkeys.push({ + type: 'xpub', pubkey: xpub, master: xpub, + address: xpub, + path: pathToString(addressNList), + pathMaster: pathToString(addressNListMaster), + scriptType: chain.scriptType, + available_scripts_types: [chain.scriptType || 'p2pkh'], + note: `${chain.symbol} Default path`, context, + networks: [chain.networkId], + addressNList, addressNListMaster, + }) + } + } + } catch (e: any) { console.warn('[mobilePairing] UTXO xpub batch failed:', e.message) } + } + + // ── EVM chains: derive ONCE, emit with all EVM networkIds + wildcard ── + const evmChains = builtinChains.filter(c => c.chainFamily === 'evm') + if (evmChains.length > 0) { + try { + const addressNList = [0x8000002C, 0x8000003C, 0x80000000] + const addressNListMaster = [0x8000002C, 0x8000003C, 0x80000000, 0, 0] + const result = await wallet.ethGetAddress({ addressNList: addressNListMaster, showDisplay: false, coin: 'Ethereum' }) + const address = typeof result === 'string' ? result : result?.address + if (address && typeof address === 'string') { + const evmNetworks = [...evmChains.map(c => c.networkId), 'eip155:*'] + pubkeys.push({ + type: 'address', pubkey: address, master: address, address, + path: pathToString(addressNList), + pathMaster: pathToString(addressNListMaster), + note: 'ETH primary (default)', context, + networks: evmNetworks, + addressNList, addressNListMaster, + }) + } + } catch (e: any) { console.warn('[mobilePairing] EVM address failed:', e.message) } + } + + // ── Non-EVM, non-UTXO chains: individual address derivation ── + const otherChains = builtinChains.filter(c => + c.chainFamily !== 'utxo' && c.chainFamily !== 'evm' && c.chainFamily !== 'zcash-shielded' + ) + for (const chain of otherChains) { + try { + const addrParams: any = { addressNList: chain.defaultPath, showDisplay: false, coin: chain.coin } + if (chain.scriptType) addrParams.scriptType = chain.scriptType + if (chain.chainFamily === 'ton') addrParams.bounceable = false + const method = chain.id === 'ripple' ? 'rippleGetAddress' : chain.rpcMethod + if (typeof wallet[method] !== 'function') { + console.warn(`[mobilePairing] ${chain.coin}: wallet.${method} not found, skipping`) + continue + } + const result = await wallet[method](addrParams) + const address = typeof result === 'string' ? result : result?.address + if (address && typeof address === 'string') { + pubkeys.push({ + type: 'address', pubkey: address, master: address, address, + path: pathToString(chain.defaultPath), + pathMaster: pathToString(chain.defaultPath), + scriptType: chain.scriptType || chain.chainFamily, + note: `Default ${chain.symbol} path`, context, + networks: [chain.networkId], + addressNList: chain.defaultPath, + addressNListMaster: chain.defaultPath, + }) + } + } catch (e: any) { console.warn(`[mobilePairing] ${chain.coin} address failed:`, e.message) } + } + + // Final safety: strip any entry with missing required fields + const validPubkeys = pubkeys.filter(p => + p.pubkey && typeof p.pubkey === 'string' && + p.pathMaster && typeof p.pathMaster === 'string' && + Array.isArray(p.networks) && p.networks.length > 0 + ) + + if (validPubkeys.length === 0) throw new Error('No pubkeys could be derived from device') + console.log(`[mobilePairing] ${validPubkeys.length} valid pubkeys (${pubkeys.length - validPubkeys.length} dropped)`) + + // POST to vault.keepkey.com relay + const RELAY_URL = 'https://vault.keepkey.com/api/pairing' + const resp = await fetch(RELAY_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ deviceId, label: deviceLabel, pubkeys: validPubkeys }), + }) + if (!resp.ok) { + const body = await resp.text().catch(() => '') + throw new Error(`Pairing relay returned ${resp.status}: ${body}`) + } + const data = await resp.json() as { success: boolean; code: string; expiresAt: number; expiresIn: number } + if (!data.success || !data.code) throw new Error('Invalid response from pairing relay') + + const qrPayload = JSON.stringify({ code: data.code, url: 'https://vault.keepkey.com' }) + console.log(`[mobilePairing] Code ${data.code} — ${validPubkeys.length} pubkeys sent to relay`) + + return { code: data.code, expiresAt: data.expiresAt, expiresIn: data.expiresIn, qrPayload } + }, + // ── App Settings ───────────────────────────────────────── getAppSettings: async () => { return getAppSettings() diff --git a/projects/keepkey-vault/src/mainview/App.tsx b/projects/keepkey-vault/src/mainview/App.tsx index 2508a9ce..0a1b4195 100644 --- a/projects/keepkey-vault/src/mainview/App.tsx +++ b/projects/keepkey-vault/src/mainview/App.tsx @@ -8,6 +8,8 @@ import { PairingApproval } from "./components/device/PairingApproval" import { SigningApproval } from "./components/device/SigningApproval" import { ApiAuditLog } from "./components/ApiAuditLog" import { PairedAppsPanel } from "./components/PairedAppsPanel" +import { MobilePairingDialog } from "./components/MobilePairingDialog" +import { MobilePanel } from "./components/MobilePanel" import { WalletConnectPanel } from "./components/WalletConnectPanel" import { FirmwareDropZone } from "./components/FirmwareDropZone" import { SplashScreen } from "./components/SplashScreen" @@ -230,6 +232,10 @@ function App() { // ── Paired Apps panel ─────────────────────────────────────────── const [pairedAppsOpen, setPairedAppsOpen] = useState(false) + // ── Mobile panel + pairing dialog ─────────────────────────────── + const [mobilePanelOpen, setMobilePanelOpen] = useState(false) + const [mobilePairingOpen, setMobilePairingOpen] = useState(false) + // ── API Audit Log ─────────────────────────────────────────────── const [auditLogOpen, setAuditLogOpen] = useState(false) const [auditLogEntries, setAuditLogEntries] = useState([]) @@ -651,7 +657,9 @@ function App() { needsFirmwareUpdate={deviceState.needsFirmwareUpdate} latestFirmware={deviceState.latestFirmware} onSettingsToggle={() => setSettingsOpen((o) => !o)} + onMobileToggle={() => setMobilePanelOpen((o) => !o)} settingsOpen={settingsOpen} + mobileOpen={mobilePanelOpen} activeTab={activeTab} onTabChange={handleTabChange} passphraseActive={deviceState.passphraseProtection} @@ -680,9 +688,14 @@ function App() { appVersion={appVersion} onOpenAuditLog={() => setAuditLogOpen(true)} onOpenPairedApps={() => setPairedAppsOpen(true)} + onOpenMobilePairing={() => setMobilePairingOpen(true)} onRestApiChanged={setRestApiEnabled} onWordCountChange={setRecoveryWordCount} /> + setMobilePairingOpen(false)} + /> setPairedAppsOpen(false)} /> + setMobilePanelOpen(false)} + deviceReady={deviceState.state === "ready"} + onOpenPairing={() => { setMobilePanelOpen(false); setMobilePairingOpen(true) }} + /> void onOpenPairedApps?: () => void + onOpenMobilePairing?: () => void onRestApiChanged?: (enabled: boolean) => void onWordCountChange?: (count: 12 | 18 | 24) => void } @@ -138,7 +139,7 @@ function VerificationBadge({ verified, t }: { verified?: boolean; t: (key: strin // ── Main Component ────────────────────────────────────────────────── -export function DeviceSettingsDrawer({ open, onClose, deviceState, onCheckForUpdate, onDownloadUpdate, onApplyUpdate, updatePhase, updateVersion, appVersion, onOpenAuditLog, onOpenPairedApps, onRestApiChanged, onWordCountChange }: DeviceSettingsDrawerProps) { +export function DeviceSettingsDrawer({ open, onClose, deviceState, onCheckForUpdate, onDownloadUpdate, onApplyUpdate, updatePhase, updateVersion, appVersion, onOpenAuditLog, onOpenPairedApps, onOpenMobilePairing, onRestApiChanged, onWordCountChange }: DeviceSettingsDrawerProps) { const { t } = useTranslation("settings") const [features, setFeatures] = useState(null) const [featuresError, setFeaturesError] = useState(false) @@ -961,6 +962,37 @@ export function DeviceSettingsDrawer({ open, onClose, deviceState, onCheckForUpd )} + {/* ── Mobile Pairing ────── */} + + + + + {t("mobilePairing.title")} + + + {t("mobilePairing.securityNote")} + + + { if (deviceState.state === "ready") onOpenMobilePairing?.() }} + > + {t("mobilePairing.button")} + + + + {/* ── App Version + Update Check ────── */} diff --git a/projects/keepkey-vault/src/mainview/components/MobilePairingDialog.tsx b/projects/keepkey-vault/src/mainview/components/MobilePairingDialog.tsx new file mode 100644 index 00000000..baf31589 --- /dev/null +++ b/projects/keepkey-vault/src/mainview/components/MobilePairingDialog.tsx @@ -0,0 +1,213 @@ +import { useState, useEffect, useCallback } from "react" +import { Box, Flex, Text, VStack, Button } from "@chakra-ui/react" +import { useTranslation } from "react-i18next" +import { rpcRequest } from "../lib/rpc" +import { generateQRSvg } from "../lib/qr" +import { Z } from "../lib/z-index" + +interface MobilePairingDialogProps { + open: boolean + onClose: () => void +} + +interface PairingResult { + code: string + expiresAt: number + expiresIn: number + qrPayload: string +} + +export function MobilePairingDialog({ open, onClose }: MobilePairingDialogProps) { + const { t } = useTranslation("settings") + const [loading, setLoading] = useState(false) + const [error, setError] = useState("") + const [result, setResult] = useState(null) + const [timeLeft, setTimeLeft] = useState("") + + const generate = useCallback(async () => { + setLoading(true) + setError("") + setResult(null) + try { + const data = await rpcRequest("generateMobilePairing", undefined, 120000) + setResult(data) + } catch (e: any) { + setError(e.message || "Failed to generate pairing code") + } + setLoading(false) + }, []) + + // Generate on open + useEffect(() => { + if (open) generate() + }, [open, generate]) + + // Countdown timer + useEffect(() => { + if (!result?.expiresAt) { setTimeLeft(""); return } + const tick = () => { + const diff = result.expiresAt - Date.now() + if (diff <= 0) { setTimeLeft(t("mobilePairing.expired")); return } + const min = Math.floor(diff / 60000) + const sec = Math.floor((diff % 60000) / 1000) + setTimeLeft(`${min}:${sec.toString().padStart(2, "0")}`) + } + tick() + const id = setInterval(tick, 1000) + return () => clearInterval(id) + }, [result?.expiresAt, t]) + + // Escape to close + useEffect(() => { + if (!open) return + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") { e.preventDefault(); onClose() } + } + document.addEventListener("keydown", handler) + return () => document.removeEventListener("keydown", handler) + }, [open, onClose]) + + if (!open) return null + + const qrSvg = result?.qrPayload ? generateQRSvg(result.qrPayload, 5, 4) : "" + + return ( + { if (e.target === e.currentTarget) onClose() }} + > + e.stopPropagation()} + > + {/* Header */} + + + + + + + + {t("mobilePairing.title")} + + + + + + + + + + + {/* Loading */} + {loading && ( + + + {t("mobilePairing.generating")} + + + {t("mobilePairing.derivingKeys")} + + + )} + + {/* Error */} + {error && ( + + {error} + + )} + + {/* Success — QR + Code */} + {!loading && !error && result && ( + <> + {/* Pairing code */} + + + {t("mobilePairing.codeLabel")} + + + {result.code} + + {timeLeft && ( + + {timeLeft === t("mobilePairing.expired") ? timeLeft : t("mobilePairing.expiresIn", { time: timeLeft })} + + )} + + + {/* QR code */} + + + {t("mobilePairing.scanQr")} + + + + + {/* Instructions */} + + + {t("mobilePairing.instructions")} + + + 1. {t("mobilePairing.step1")} + 2. {t("mobilePairing.step2")} + 3. {t("mobilePairing.step3")} + + + + {/* Security note */} + + {t("mobilePairing.security")} + {t("mobilePairing.securityNote")} + + + )} + + {/* Actions */} + + {!loading && (error || result) && ( + + )} + + + + ) +} diff --git a/projects/keepkey-vault/src/mainview/components/MobilePanel.tsx b/projects/keepkey-vault/src/mainview/components/MobilePanel.tsx new file mode 100644 index 00000000..e78871c0 --- /dev/null +++ b/projects/keepkey-vault/src/mainview/components/MobilePanel.tsx @@ -0,0 +1,197 @@ +import { useState, useCallback } from "react" +import { Box, Flex, Text, VStack, IconButton } from "@chakra-ui/react" +import { useTranslation } from "react-i18next" +import { generateQRSvg } from "../lib/qr" +import { Z } from "../lib/z-index" +import { rpcRequest } from "../lib/rpc" + +const APP_STORE_URL = "https://apps.apple.com/us/app/keepkey-mobile/id6755204956" + +interface MobilePanelProps { + open: boolean + onClose: () => void + deviceReady?: boolean + onOpenPairing?: () => void +} + +export function MobilePanel({ open, onClose, deviceReady, onOpenPairing }: MobilePanelProps) { + const { t } = useTranslation("settings") + const appStoreQr = generateQRSvg(APP_STORE_URL, 4, 3) + + if (!open) return null + + return ( + + {/* Header */} + + + + + + + + {t("mobile.panelTitle")} + + + + + + + + + + + + + {/* ── Get the App ───────────────────────────────── */} + + + + + + + + + {t("mobile.getTheApp")} + + + + + {t("mobile.getTheAppDescription")} + + + {/* QR Code */} + + + + {t("mobile.scanToDownload")} + + + {/* Open in browser button */} + rpcRequest("openUrl", { url: APP_STORE_URL })} + > + {t("mobile.openAppStore")} + + + + {/* ── Pair Device ───────────────────────────────── */} + + + + + + + + {t("mobile.pairDevice")} + + + + + {t("mobile.pairDescription")} + + + { if (deviceReady && onOpenPairing) onOpenPairing() }} + > + {deviceReady ? t("mobile.pairNow") : t("mobile.connectDevice")} + + + + {/* ── About Mobile ──────────────────────────────── */} + + + {t("mobile.about")} + + + + 1. + {t("mobile.feature1")} + + + 2. + {t("mobile.feature2")} + + + 3. + {t("mobile.feature3")} + + + + + {t("mobile.watchOnlyNote")} + + + + + + + ) +} diff --git a/projects/keepkey-vault/src/mainview/components/TopNav.tsx b/projects/keepkey-vault/src/mainview/components/TopNav.tsx index e701d5af..91965d92 100644 --- a/projects/keepkey-vault/src/mainview/components/TopNav.tsx +++ b/projects/keepkey-vault/src/mainview/components/TopNav.tsx @@ -24,7 +24,9 @@ interface TopNavProps { needsFirmwareUpdate?: boolean latestFirmware?: string onSettingsToggle: () => void + onMobileToggle?: () => void settingsOpen?: boolean + mobileOpen?: boolean activeTab: NavTab onTabChange: (tab: NavTab) => void watchOnly?: boolean @@ -95,7 +97,7 @@ export function SplashNav() { ) } -export function TopNav({ label, connected, firmwareVersion, firmwareVerified, needsFirmwareUpdate, latestFirmware, onSettingsToggle, settingsOpen, activeTab, onTabChange, watchOnly, passphraseActive }: TopNavProps) { +export function TopNav({ label, connected, firmwareVersion, firmwareVerified, needsFirmwareUpdate, latestFirmware, onSettingsToggle, onMobileToggle, settingsOpen, mobileOpen, activeTab, onTabChange, watchOnly, passphraseActive }: TopNavProps) { const { t } = useTranslation("nav") const windowDrag = useWindowDrag() @@ -236,8 +238,23 @@ export function TopNav({ label, connected, firmwareVersion, firmwareVerified, ne })} - {/* Right: settings gear */} - + {/* Right: mobile + settings gear */} + + {onMobileToggle && ( + + + + + + + )} Date: Wed, 25 Mar 2026 18:02:45 -0600 Subject: [PATCH 08/17] fix: remove unused imports from MobilePanel Co-Authored-By: Claude Opus 4.6 (1M context) --- projects/keepkey-vault/src/mainview/components/MobilePanel.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/projects/keepkey-vault/src/mainview/components/MobilePanel.tsx b/projects/keepkey-vault/src/mainview/components/MobilePanel.tsx index e78871c0..413012b3 100644 --- a/projects/keepkey-vault/src/mainview/components/MobilePanel.tsx +++ b/projects/keepkey-vault/src/mainview/components/MobilePanel.tsx @@ -1,4 +1,3 @@ -import { useState, useCallback } from "react" import { Box, Flex, Text, VStack, IconButton } from "@chakra-ui/react" import { useTranslation } from "react-i18next" import { generateQRSvg } from "../lib/qr" From 5112ba9c2c088dcf28fe0fa2525dde7637c74d9e Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 26 Mar 2026 01:20:14 -0600 Subject: [PATCH 09/17] fix: use user's fiat currency and locale settings consistently app-wide Replace all hardcoded USD/en-US formatting with the user's chosen currency and locale from FiatProvider. Removes legacy formatUsd(), updates AnimatedUsd to use fiat symbol, fixes i18n strings that hardcoded "$0.00 USD", and adds browser language auto-detection on first launch. --- projects/keepkey-vault/src/bun/reports.ts | 8 +++++--- projects/keepkey-vault/src/bun/swap-report.ts | 4 +++- .../src/mainview/components/AnimatedUsd.tsx | 16 +++++++++++----- .../src/mainview/components/AssetPage.tsx | 12 +++++++----- .../src/mainview/components/BtcXpubSelector.tsx | 6 ++++-- .../mainview/components/EvmAddressSelector.tsx | 5 +++-- .../src/mainview/components/ReportDialog.tsx | 6 ++++-- .../src/mainview/components/SendForm.tsx | 14 ++++++++------ .../src/mainview/components/StakingPanel.tsx | 4 +++- .../src/mainview/components/SwapDialog.tsx | 7 +++---- .../src/mainview/components/ZcashPrivacyTab.tsx | 14 ++++++++------ .../keepkey-vault/src/mainview/i18n/index.ts | 16 ++++++++++++++-- .../src/mainview/i18n/locales/de/asset.json | 2 +- .../src/mainview/i18n/locales/de/staking.json | 2 +- .../src/mainview/i18n/locales/en/asset.json | 2 +- .../src/mainview/i18n/locales/en/staking.json | 2 +- .../src/mainview/i18n/locales/es/asset.json | 2 +- .../src/mainview/i18n/locales/es/staking.json | 2 +- .../src/mainview/i18n/locales/fr/asset.json | 2 +- .../src/mainview/i18n/locales/fr/staking.json | 2 +- .../src/mainview/i18n/locales/it/asset.json | 2 +- .../src/mainview/i18n/locales/it/staking.json | 2 +- .../src/mainview/i18n/locales/ja/asset.json | 2 +- .../src/mainview/i18n/locales/ja/staking.json | 2 +- .../src/mainview/i18n/locales/ko/asset.json | 2 +- .../src/mainview/i18n/locales/ko/staking.json | 2 +- .../src/mainview/i18n/locales/nl/asset.json | 2 +- .../src/mainview/i18n/locales/pl/asset.json | 2 +- .../src/mainview/i18n/locales/pt/asset.json | 2 +- .../src/mainview/i18n/locales/pt/staking.json | 2 +- .../src/mainview/i18n/locales/ru/asset.json | 2 +- .../src/mainview/i18n/locales/ru/staking.json | 2 +- .../src/mainview/i18n/locales/th/asset.json | 2 +- .../src/mainview/i18n/locales/tr/asset.json | 2 +- .../src/mainview/i18n/locales/vi/asset.json | 2 +- .../src/mainview/i18n/locales/zh/asset.json | 2 +- .../src/mainview/i18n/locales/zh/staking.json | 2 +- .../keepkey-vault/src/mainview/lib/formatting.ts | 14 +++----------- 38 files changed, 101 insertions(+), 75 deletions(-) diff --git a/projects/keepkey-vault/src/bun/reports.ts b/projects/keepkey-vault/src/bun/reports.ts index 7aa67b64..3d338ee5 100644 --- a/projects/keepkey-vault/src/bun/reports.ts +++ b/projects/keepkey-vault/src/bun/reports.ts @@ -12,7 +12,7 @@ */ import type { ReportData, ReportSection, ChainBalance } from '../shared/types' -import { getLatestDeviceSnapshot, getCachedPubkeys } from './db' +import { getLatestDeviceSnapshot, getCachedPubkeys, getSetting } from './db' import { getPioneer } from './pioneer' /** Section title prefixes — shared with tax-export.ts for reliable extraction. */ @@ -767,6 +767,8 @@ function sanitize(text: string): string { export async function reportToPdfBuffer(data: ReportData): Promise { const { PDFDocument, StandardFonts, rgb, degrees } = await import('pdf-lib') + const reportLocale = getSetting('number_locale') || 'en-US' + const reportCurrency = getSetting('fiat_currency') || 'USD' console.log('[reports] Starting PDF generation...') @@ -897,7 +899,7 @@ export async function reportToPdfBuffer(data: ReportData): Promise { y -= 30 // ── Total Portfolio Value (big number) ── - const totalStr = `$${totalPortfolioUsd.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` + const totalStr = new Intl.NumberFormat(reportLocale, { style: 'currency', currency: reportCurrency, minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(totalPortfolioUsd) const totalLabel = 'Total Portfolio Value' const totalLabelW = font.widthOfTextAtSize(totalLabel, 11) const totalValW = bold.widthOfTextAtSize(totalStr, 28) @@ -958,7 +960,7 @@ export async function reportToPdfBuffer(data: ReportData): Promise { for (const slice of slices.slice(0, 12)) { const pct = ((slice.usd / totalPortfolioUsd) * 100).toFixed(1) - const usdStr = `$${slice.usd.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` + const usdStr = new Intl.NumberFormat(reportLocale, { style: 'currency', currency: reportCurrency, minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(slice.usd) // Color swatch page.drawRectangle({ diff --git a/projects/keepkey-vault/src/bun/swap-report.ts b/projects/keepkey-vault/src/bun/swap-report.ts index 819e9b62..b3e714d5 100644 --- a/projects/keepkey-vault/src/bun/swap-report.ts +++ b/projects/keepkey-vault/src/bun/swap-report.ts @@ -5,6 +5,7 @@ * CSV is plain-text, compatible with spreadsheet apps and tax tools. */ import type { SwapHistoryRecord } from '../shared/types' +import { getSetting } from './db' // ── CSV Export ──────────────────────────────────────────────────────── @@ -189,7 +190,8 @@ export async function generateSwapPdf(records: SwapHistoryRecord[]): Promise{prefix}0.{'0'.repeat(decimals)}{suffix} + return {p}0{dec}{'0'.repeat(decimals)}{suffix} } return ( - {prefix} - + {p} + {suffix} ) diff --git a/projects/keepkey-vault/src/mainview/components/AssetPage.tsx b/projects/keepkey-vault/src/mainview/components/AssetPage.tsx index b9df6297..6bb10324 100644 --- a/projects/keepkey-vault/src/mainview/components/AssetPage.tsx +++ b/projects/keepkey-vault/src/mainview/components/AssetPage.tsx @@ -8,7 +8,8 @@ import { CHAINS, BTC_SCRIPT_TYPES, btcAccountPath, isChainSupported } from "../. import type { ChainBalance, TokenBalance, TokenVisibilityStatus, AppSettings } from "../../shared/types" import { getAssetIcon, caipToIcon } from "../../shared/assetLookup" import { AnimatedUsd } from "./AnimatedUsd" -import { formatBalance, formatUsd } from "../lib/formatting" +import { formatBalance } from "../lib/formatting" +import { useFiat } from "../lib/fiat-context" import { ReceiveView } from "./ReceiveView" import { SendForm } from "./SendForm" @@ -48,6 +49,7 @@ interface AssetPageProps { export function AssetPage({ chain, balance, onBack, firmwareVersion }: AssetPageProps) { const { t } = useTranslation("asset") + const { fmtCompact, symbol: fiatSymbol } = useFiat() const [view, setView] = useState("receive") const [selectedToken, setSelectedToken] = useState(null) const [address, setAddress] = useState(balance?.address || null) @@ -381,7 +383,7 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion }: AssetPage {tok.balanceUsd > 0 && ( - ${formatUsd(tok.balanceUsd)} + {fmtCompact(tok.balanceUsd)} )} @@ -476,7 +478,7 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion }: AssetPage {activeBalance.balance} {chain.symbol} {cleanBalanceUsd > 0 && ( - + )} )} {tokenTotalUsd > 0 && ( - ${formatUsd(tokenTotalUsd)} + {fmtCompact(tokenTotalUsd)} )} {isEvmChain && ( 0 && ( <> - {t("zeroValueTokens", { count: zeroValueTokens.length })} + {t("zeroValueTokens", { count: zeroValueTokens.length, zeroValue: fmtCompact(0) || `${fiatSymbol}0` })} {zeroValueTokens.map((tok) => renderTokenRow(tok))} diff --git a/projects/keepkey-vault/src/mainview/components/BtcXpubSelector.tsx b/projects/keepkey-vault/src/mainview/components/BtcXpubSelector.tsx index c61fcd5d..2bbebca7 100644 --- a/projects/keepkey-vault/src/mainview/components/BtcXpubSelector.tsx +++ b/projects/keepkey-vault/src/mainview/components/BtcXpubSelector.tsx @@ -2,7 +2,8 @@ import { Box, Flex, Text, Button } from "@chakra-ui/react" import { FaPlus } from "react-icons/fa" import { useTranslation } from "react-i18next" import { BTC_SCRIPT_TYPES } from "../../shared/chains" -import { formatBalance, formatUsd } from "../lib/formatting" +import { formatBalance } from "../lib/formatting" +import { useFiat } from "../lib/fiat-context" import type { BtcAccountSet, BtcScriptType } from "../../shared/types" interface BtcXpubSelectorProps { @@ -15,6 +16,7 @@ interface BtcXpubSelectorProps { export function BtcXpubSelector({ btcAccounts, onSelectXpub, onAddAccount, addingAccount }: BtcXpubSelectorProps) { const { accounts, selectedXpub } = btcAccounts const { t } = useTranslation("receive") + const { fmtCompact } = useFiat() if (accounts.length === 0) return null const selAcct = selectedXpub?.accountIndex ?? 0 @@ -95,7 +97,7 @@ export function BtcXpubSelector({ btcAccounts, onSelectXpub, onAddAccount, addin )} {xpubData && xpubData.balanceUsd > 0 && ( - ${formatUsd(xpubData.balanceUsd)} + {fmtCompact(xpubData.balanceUsd)} )} diff --git a/projects/keepkey-vault/src/mainview/components/EvmAddressSelector.tsx b/projects/keepkey-vault/src/mainview/components/EvmAddressSelector.tsx index 94ef69da..d73ef6de 100644 --- a/projects/keepkey-vault/src/mainview/components/EvmAddressSelector.tsx +++ b/projects/keepkey-vault/src/mainview/components/EvmAddressSelector.tsx @@ -1,6 +1,6 @@ import { Box, Flex, Text, Button } from "@chakra-ui/react" import { FaPlus, FaTimes } from "react-icons/fa" -import { formatUsd } from "../lib/formatting" +import { useFiat } from "../lib/fiat-context" import type { EvmAddressSet } from "../../shared/types" interface EvmAddressSelectorProps { @@ -12,6 +12,7 @@ interface EvmAddressSelectorProps { } export function EvmAddressSelector({ evmAddresses, onSelectIndex, onAddIndex, onRemoveIndex, adding }: EvmAddressSelectorProps) { + const { fmtCompact } = useFiat() const { addresses, selectedIndex } = evmAddresses // Don't render if only one address tracked @@ -56,7 +57,7 @@ export function EvmAddressSelector({ evmAddresses, onSelectIndex, onAddIndex, on {addr.balanceUsd > 0 && ( - ${formatUsd(addr.balanceUsd)} + {fmtCompact(addr.balanceUsd)} )} diff --git a/projects/keepkey-vault/src/mainview/components/ReportDialog.tsx b/projects/keepkey-vault/src/mainview/components/ReportDialog.tsx index 4e14c229..f062753f 100644 --- a/projects/keepkey-vault/src/mainview/components/ReportDialog.tsx +++ b/projects/keepkey-vault/src/mainview/components/ReportDialog.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback, useRef } from "react" import { Box, Flex, Text, Spinner, Image } from "@chakra-ui/react" import { rpcRequest, onRpcMessage } from "../lib/rpc" +import { useFiat } from "../lib/fiat-context" import { Z } from "../lib/z-index" import type { ReportMeta } from "../../shared/types" @@ -22,6 +23,7 @@ interface ReportDialogProps { } export function ReportDialog({ onClose }: ReportDialogProps) { + const { locale: fiatLocale, fmtCompact } = useFiat() const [generating, setGenerating] = useState(false) const [progress, setProgress] = useState<{ message: string; percent: number } | null>(null) const [reports, setReports] = useState([]) @@ -250,11 +252,11 @@ export function ReportDialog({ onClose }: ReportDialogProps) { Full Detail Report - {r.status === "error" ? "Failed" : `$${r.totalUsd.toFixed(2)}`} + {r.status === "error" ? "Failed" : fmtCompact(r.totalUsd)} - {new Date(r.createdAt).toLocaleString()} + {new Date(r.createdAt).toLocaleString(fiatLocale)} {r.error && ( {r.error} diff --git a/projects/keepkey-vault/src/mainview/components/SendForm.tsx b/projects/keepkey-vault/src/mainview/components/SendForm.tsx index e2f4d7b2..a4226567 100644 --- a/projects/keepkey-vault/src/mainview/components/SendForm.tsx +++ b/projects/keepkey-vault/src/mainview/components/SendForm.tsx @@ -2,7 +2,8 @@ import { useState, useEffect, useCallback, useMemo, Fragment } from "react" import { useTranslation } from "react-i18next" import { Box, Flex, Text, VStack, Button, Input } from "@chakra-ui/react" import { rpcRequest } from "../lib/rpc" -import { formatBalance, formatUsd } from "../lib/formatting" +import { formatBalance } from "../lib/formatting" +import { useFiat } from "../lib/fiat-context" import { getAsset } from "../../shared/assetLookup" import { QrScannerOverlay } from "./QrScannerOverlay" import type { ChainDef } from "../../shared/chains" @@ -41,6 +42,7 @@ interface SendFormProps { export function SendForm({ chain, address, balance, token, onClearToken, xpubOverride, scriptTypeOverride, evmAddressIndex }: SendFormProps) { const { t } = useTranslation("send") + const { fmtCompact } = useFiat() const [recipient, setRecipient] = useState("") const [amount, setAmount] = useState("") const [usdAmount, setUsdAmount] = useState("") @@ -301,7 +303,7 @@ export function SendForm({ chain, address, balance, token, onClearToken, xpubOve {hasPrice && ( - ${formatUsd(parseFloat(displayBalance) * pricePerUnit)} + {fmtCompact(parseFloat(displayBalance) * pricePerUnit)} )} @@ -408,7 +410,7 @@ export function SendForm({ chain, address, balance, token, onClearToken, xpubOve {inputMode === 'crypto' ? ( - {amountUsdPreview !== null ? `$${formatUsd(amountUsdPreview)}` : '$0.00'} + {amountUsdPreview !== null ? fmtCompact(amountUsdPreview) : fmtCompact(0)} ) : ( @@ -421,7 +423,7 @@ export function SendForm({ chain, address, balance, token, onClearToken, xpubOve )} {pricePerUnit > 0 && ( - 1 {displaySymbol} = ${formatUsd(pricePerUnit)} + 1 {displaySymbol} = {fmtCompact(pricePerUnit)} )} )} @@ -492,7 +494,7 @@ export function SendForm({ chain, address, balance, token, onClearToken, xpubOve {isMax ? 'MAX' : amount} {displaySymbol} {!isMax && amountUsdPreview !== null && ( - ${formatUsd(amountUsdPreview)} + {fmtCompact(amountUsdPreview)} )} @@ -501,7 +503,7 @@ export function SendForm({ chain, address, balance, token, onClearToken, xpubOve {formatBalance(buildResult.fee)} {chain.symbol} {buildResult.feeUsd != null && buildResult.feeUsd > 0 && ( - ${formatUsd(buildResult.feeUsd)} + {fmtCompact(buildResult.feeUsd)} )} diff --git a/projects/keepkey-vault/src/mainview/components/StakingPanel.tsx b/projects/keepkey-vault/src/mainview/components/StakingPanel.tsx index 241966aa..c42749c1 100644 --- a/projects/keepkey-vault/src/mainview/components/StakingPanel.tsx +++ b/projects/keepkey-vault/src/mainview/components/StakingPanel.tsx @@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next" import type { ChainDef } from "../../shared/chains" import type { BuildTxResult, BroadcastResult, StakingPosition } from "../../shared/types" import { rpcRequest } from "../lib/rpc" +import { useFiat } from "../lib/fiat-context" import { Z } from "../lib/z-index" interface StakingPanelProps { @@ -53,6 +54,7 @@ interface DelegateDialogProps { function DelegateDialog({ isOpen, onClose, chain, availableBalance, rewardAmount, rewardUsd, onSuccess, watchOnly }: DelegateDialogProps) { const { t } = useTranslation("staking") + const { fmtCompact } = useFiat() const [validatorAddress, setValidatorAddress] = useState("") const [amount, setAmount] = useState("") const [memo, setMemo] = useState(t('defaultDelegationMemo')) @@ -287,7 +289,7 @@ function DelegateDialog({ isOpen, onClose, chain, availableBalance, rewardAmount {rewardAmount && ( {rewardUsd && rewardUsd > 0 - ? t('rewardsAvailableWithUsd', { amount: rewardAmount, symbol: chain.symbol, usd: rewardUsd.toFixed(2) }) + ? t('rewardsAvailableWithUsd', { amount: rewardAmount, symbol: chain.symbol, fiatValue: fmtCompact(rewardUsd) }) : t('rewardsAvailable', { amount: rewardAmount, symbol: chain.symbol })} )} diff --git a/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx b/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx index 7e2c555c..b9a26ef6 100644 --- a/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx +++ b/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx @@ -10,7 +10,7 @@ import { Box, Flex, Text, VStack, Button, Input, Image, HStack } from "@chakra-u import CountUp from "react-countup" import { rpcRequest, onRpcMessage } from "../lib/rpc" import { formatBalance } from "../lib/formatting" -import { formatUsd } from "../lib/formatting" +import { useFiat } from "../lib/fiat-context" import { getAssetIcon } from "../../shared/assetLookup" import { CHAINS, getExplorerTxUrl } from "../../shared/chains" import type { ChainDef } from "../../shared/chains" @@ -240,7 +240,7 @@ interface AssetSelectorProps { function AssetSelector({ label, selected, assets, onSelect, balances, exclude, disabled, nativeOnly }: AssetSelectorProps) { const { t } = useTranslation("swap") - const fmtCompact = (v: number | string | null | undefined) => { const n = typeof v === 'string' ? parseFloat(v) : (v ?? 0); return !isFinite(n) || n === 0 ? '' : `$${formatUsd(n)}` } + const { fmtCompact } = useFiat() const [open, setOpen] = useState(false) const [search, setSearch] = useState("") const inputRef = useRef(null) @@ -445,8 +445,7 @@ interface SwapDialogProps { // ── Main SwapDialog ───────────────────────────────────────────────── export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap }: SwapDialogProps) { const { t } = useTranslation("swap") - const fmtCompact = (v: number | string | null | undefined) => { const n = typeof v === 'string' ? parseFloat(v) : (v ?? 0); return !isFinite(n) || n === 0 ? '' : `$${formatUsd(n)}` } - const fiatSymbol = '$' + const { fmtCompact, symbol: fiatSymbol } = useFiat() // ── State ───────────────────────────────────────────────────────── const [phase, setPhase] = useState('input') diff --git a/projects/keepkey-vault/src/mainview/components/ZcashPrivacyTab.tsx b/projects/keepkey-vault/src/mainview/components/ZcashPrivacyTab.tsx index 8de11736..98672449 100644 --- a/projects/keepkey-vault/src/mainview/components/ZcashPrivacyTab.tsx +++ b/projects/keepkey-vault/src/mainview/components/ZcashPrivacyTab.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next" import { Box, Flex, Text, Button, Input, Spinner } from "@chakra-ui/react" import { FaShieldAlt, FaCopy, FaCheck, FaEnvelope, FaChevronDown, FaChevronUp } from "react-icons/fa" import { rpcRequest, onRpcMessage } from "../lib/rpc" +import { useFiat } from "../lib/fiat-context" import { generateQRSvg } from "../lib/qr" /** Validate Zcash recipient: unified (u1...), Sapling (zs1...), or transparent (t1.../t3...) */ @@ -45,6 +46,7 @@ function formatEta(seconds: number): string { export function ZcashPrivacyTab() { const { t } = useTranslation("asset") + const { locale: fiatLocale } = useFiat() // ── State ────────────────────────────────────────────────────────── const [status, setStatus] = useState("checking") @@ -497,7 +499,7 @@ export function ZcashPrivacyTab() { {scanState === "scanning" ? ( <> {t("scanning")} ) : ( - t("scanFromBlock", { block: KEEPKEY_RELEASE_BLOCK.toLocaleString() }) + t("scanFromBlock", { block: KEEPKEY_RELEASE_BLOCK.toLocaleString(fiatLocale) }) )} @@ -516,7 +518,7 @@ export function ZcashPrivacyTab() { )} {syncedTo && ( - {t("lastSynced", { height: syncedTo.toLocaleString() })} + {t("lastSynced", { height: syncedTo.toLocaleString(fiatLocale) })} )} @@ -778,7 +780,7 @@ export function ZcashPrivacyTab() { title="Click to use KeepKey release block" onClick={() => setScanFromHeight(String(KEEPKEY_RELEASE_BLOCK))} > - #{KEEPKEY_RELEASE_BLOCK.toLocaleString()} + #{KEEPKEY_RELEASE_BLOCK.toLocaleString(fiatLocale)} @@ -812,11 +814,11 @@ export function ZcashPrivacyTab() { {scanProgress ? ( - {scanProgress.scannedHeight.toLocaleString()} / {scanProgress.tipHeight.toLocaleString()} + {scanProgress.scannedHeight.toLocaleString(fiatLocale)} / {scanProgress.tipHeight.toLocaleString(fiatLocale)} {scanProgress.blocksPerSec > 0 && ( - {scanProgress.blocksPerSec.toLocaleString()} blk/s + {scanProgress.blocksPerSec.toLocaleString(fiatLocale)} blk/s )} @@ -1060,7 +1062,7 @@ export function ZcashPrivacyTab() { setScanFromHeight(String(tx.block_height)) }} > - #{tx.block_height.toLocaleString()} + #{tx.block_height.toLocaleString(fiatLocale)} Date: Thu, 26 Mar 2026 01:41:50 -0600 Subject: [PATCH 10/17] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20rep?= =?UTF-8?q?orts=20stay=20USD,=20AnimatedUsd=20uses=20Intl,=20zero=20previe?= =?UTF-8?q?w=20restored?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Reports: stored values are USD — keep USD currency label, only use user's number_locale for digit separators (no relabeling bug). 2. AnimatedUsd: replace brittle separator guessing with CountUp's formattingFn backed by Intl.NumberFormat. Correctly handles fr-FR spaces, pl-PL suffix symbols, de-CH apostrophes, and 0-decimal currencies (JPY/KRW/HUF). 3. SendForm: zero preview now falls back to fmt(0) instead of fmtCompact(0) which returns '' for zero values. --- projects/keepkey-vault/src/bun/reports.ts | 3 +- .../src/mainview/components/AnimatedUsd.tsx | 41 ++++++++++++++----- .../src/mainview/components/AssetPage.tsx | 2 +- .../src/mainview/components/SendForm.tsx | 4 +- 4 files changed, 36 insertions(+), 14 deletions(-) diff --git a/projects/keepkey-vault/src/bun/reports.ts b/projects/keepkey-vault/src/bun/reports.ts index 3d338ee5..61b54356 100644 --- a/projects/keepkey-vault/src/bun/reports.ts +++ b/projects/keepkey-vault/src/bun/reports.ts @@ -768,7 +768,8 @@ function sanitize(text: string): string { export async function reportToPdfBuffer(data: ReportData): Promise { const { PDFDocument, StandardFonts, rgb, degrees } = await import('pdf-lib') const reportLocale = getSetting('number_locale') || 'en-US' - const reportCurrency = getSetting('fiat_currency') || 'USD' + // Stored values are always USD — use USD currency label with user's number locale for separators + const reportCurrency = 'USD' console.log('[reports] Starting PDF generation...') diff --git a/projects/keepkey-vault/src/mainview/components/AnimatedUsd.tsx b/projects/keepkey-vault/src/mainview/components/AnimatedUsd.tsx index 029f6408..3d4cbc99 100644 --- a/projects/keepkey-vault/src/mainview/components/AnimatedUsd.tsx +++ b/projects/keepkey-vault/src/mainview/components/AnimatedUsd.tsx @@ -1,30 +1,51 @@ +import { useMemo } from "react" import CountUp from "react-countup" import { Text, type TextProps } from "@chakra-ui/react" import { useFiat } from "../lib/fiat-context" +import { getFiatConfig } from "../../shared/fiat" interface AnimatedUsdProps extends TextProps { value: number + /** Wrapper text before the formatted value (e.g. "(" for parenthesized display) */ prefix?: string + /** Wrapper text after the formatted value (e.g. ")") */ suffix?: string duration?: number + /** Override decimal places (defaults to the currency's configured decimals) */ decimals?: number } -/** Animated fiat counter with CountUp animation. Uses the user's fiat symbol by default. */ -export function AnimatedUsd({ value, prefix, suffix, duration = 1.2, decimals = 2, color = "#23DCC8", ...textProps }: AnimatedUsdProps) { - const { symbol, locale } = useFiat() - const p = prefix ?? symbol - const sep = locale.startsWith('de') || locale.startsWith('fr') || locale.startsWith('pt') || locale.startsWith('it') || locale.startsWith('pl') || locale.startsWith('cs') || locale.startsWith('da') || locale.startsWith('nb') || locale.startsWith('sv') || locale.startsWith('hu') || locale.startsWith('tr') || locale.startsWith('ru') ? '.' : ',' - const dec = sep === '.' ? ',' : '.' +/** Animated fiat counter. Delegates all number+symbol formatting to Intl.NumberFormat. */ +export function AnimatedUsd({ value, prefix = "", suffix = "", duration = 1.2, decimals, color = "#23DCC8", ...textProps }: AnimatedUsdProps) { + const { currency, locale } = useFiat() + const cfg = getFiatConfig(currency) + const dec = decimals ?? cfg.decimals + + const formatter = useMemo(() => { + try { + return new Intl.NumberFormat(locale, { + style: 'currency', + currency, + minimumFractionDigits: dec, + maximumFractionDigits: dec, + currencyDisplay: 'narrowSymbol', + }) + } catch { + return null + } + }, [locale, currency, dec]) + + const formatValue = (n: number) => { + const formatted = formatter ? formatter.format(n) : `${cfg.symbol}${n.toFixed(dec)}` + return `${prefix}${formatted}${suffix}` + } if (!isFinite(value) || value <= 0) { - return {p}0{dec}{'0'.repeat(decimals)}{suffix} + return {formatValue(0)} } return ( - {p} - - {suffix} + ) } diff --git a/projects/keepkey-vault/src/mainview/components/AssetPage.tsx b/projects/keepkey-vault/src/mainview/components/AssetPage.tsx index 6bb10324..aa4fb0b2 100644 --- a/projects/keepkey-vault/src/mainview/components/AssetPage.tsx +++ b/projects/keepkey-vault/src/mainview/components/AssetPage.tsx @@ -478,7 +478,7 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion }: AssetPage {activeBalance.balance} {chain.symbol} {cleanBalanceUsd > 0 && ( - + )} {inputMode === 'crypto' ? ( - {amountUsdPreview !== null ? fmtCompact(amountUsdPreview) : fmtCompact(0)} + {amountUsdPreview !== null ? (fmtCompact(amountUsdPreview) || fmt(0)) : fmt(0)} ) : ( From 17e3e1d2f2b6752ec7e8e95dd10d0bfbdcd29323 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 26 Mar 2026 11:12:01 -0600 Subject: [PATCH 11/17] fix: CI macOS dual-arch builds + architecture verification gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The x86_64 DMG shipped ARM64 binaries because build-intel used arch -x86_64 on ARM64-only Bun — Rosetta can't force a native binary to produce cross-arch output. CI: - Add macos-14 (ARM64) and macos-13 (Intel) runners to build matrix - Build zcash-cli sidecar on macOS runners (Rust pre-installed) - Verify binary architecture post-build (lipo -archs on launcher+bun) - Create unsigned DMGs in CI for testing - Generate combined SHA256SUMS in release job Makefile: - Add verify-arch target (now a dependency of dmg) - Deprecate build-intel/build-signed-intel with clear explanation - Add sign-release: downloads CI artifacts, signs both archs, uploads - Add upload-all-dmgs convenience target --- .github/workflows/build.yml | 113 ++++++++++++++++++- Makefile | 215 +++++++++++++++++++++++++++++------- 2 files changed, 284 insertions(+), 44 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index de78c379..b421442a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,11 +27,20 @@ jobs: fail-fast: false matrix: include: - # macOS and Windows are built + signed locally (see `make build-signed`) - name: Linux x64 runner: ubuntu-latest artifact-suffix: linux-x64 + - name: macOS ARM64 + runner: macos-14 + artifact-suffix: macos-arm64 + expected-arch: arm64 + + - name: macOS x64 + runner: macos-13 + artifact-suffix: macos-x64 + expected-arch: x86_64 + steps: - name: Checkout uses: actions/checkout@v4 @@ -72,6 +81,16 @@ jobs: path: projects/keepkey-vault/node_modules key: vault-${{ runner.os }}-${{ hashFiles('projects/keepkey-vault/package.json') }} + - name: Cache Rust/Cargo (macOS) + if: runner.os == 'macOS' + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + projects/keepkey-vault/zcash-cli/target + key: zcash-cli-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('projects/keepkey-vault/zcash-cli/Cargo.lock') }} + - name: Build modules (hdwallet + proto-tx-builder) shell: bash run: | @@ -131,6 +150,16 @@ jobs: run: cd projects/keepkey-vault && bun install shell: bash + - name: Build zcash-cli sidecar (macOS) + if: runner.os == 'macOS' + shell: bash + run: | + echo "=== Building zcash-cli for $(uname -m) ===" + cd projects/keepkey-vault/zcash-cli + cargo test + cargo build --release + file target/release/zcash-cli + - name: Build unsigned app (CI mode) env: CI: 'true' @@ -141,6 +170,74 @@ jobs: run: cd projects/keepkey-vault && bun scripts/prune-app-bundle.ts shell: bash + - name: Verify binary architecture (macOS) + if: runner.os == 'macOS' + shell: bash + run: | + EXPECTED="${{ matrix.expected-arch }}" + echo "=== Verifying macOS binary architecture (expecting $EXPECTED) ===" + + TAR_ZST=$(find projects/keepkey-vault/artifacts -name "*.app.tar.zst" | head -1) + if [ -z "$TAR_ZST" ]; then echo "::error::No .app.tar.zst found"; exit 1; fi + + WORK=$(mktemp -d) + trap 'rm -rf "$WORK"' EXIT + + zstd -d "$TAR_ZST" -o "$WORK/app.tar" --force + LAUNCHER=$(tar tf "$WORK/app.tar" | grep "MacOS/launcher$" | head -1) + BUN_BIN=$(tar tf "$WORK/app.tar" | grep "MacOS/bun$" | head -1) + + if [ -z "$LAUNCHER" ]; then echo "::error::No launcher binary found in archive"; exit 1; fi + tar xf "$WORK/app.tar" -C "$WORK/" "$LAUNCHER" + if [ -n "$BUN_BIN" ]; then tar xf "$WORK/app.tar" -C "$WORK/" "$BUN_BIN"; fi + + echo "Checking binaries:" + for BIN in "$WORK/$LAUNCHER" "$WORK/$BUN_BIN"; do + [ -f "$BIN" ] || continue + ACTUAL=$(lipo -archs "$BIN" 2>/dev/null) + NAME=$(basename "$BIN") + echo " $NAME: $ACTUAL" + if [ "$ACTUAL" != "$EXPECTED" ]; then + echo "::error::Architecture mismatch! $NAME is $ACTUAL but expected $EXPECTED" + exit 1 + fi + done + + # Also verify node-hid prebuild matches + HID_DIR=$(tar tf "$WORK/app.tar" | grep "node-hid/prebuilds/HID-darwin" | head -1) + if [ -n "$HID_DIR" ]; then + echo " node-hid prebuild dir: $(dirname $HID_DIR | xargs basename)" + fi + + echo "Architecture verified: $EXPECTED" + + - name: Create unsigned DMG (macOS) + if: runner.os == 'macOS' + shell: bash + run: | + cd projects/keepkey-vault/artifacts + TAR_ZST=$(ls *.app.tar.zst 2>/dev/null | head -1) + if [ -z "$TAR_ZST" ]; then echo "No .app.tar.zst found, skipping DMG"; exit 0; fi + + VERSION=$(grep '"version"' ../package.json | head -1 | sed 's/.*"version": "\(.*\)".*/\1/') + ARCH=$(uname -m) + DMG_NAME="KeepKey-Vault-${VERSION}-${ARCH}.dmg" + echo "Creating unsigned DMG: $DMG_NAME" + + STAGING=$(mktemp -d) + trap 'rm -rf "$STAGING"' EXIT + + zstd -d "$TAR_ZST" -o "$STAGING/app.tar" --force + tar xf "$STAGING/app.tar" -C "$STAGING/" + rm "$STAGING/app.tar" + + APP=$(find "$STAGING" -name "*.app" -maxdepth 1 | head -1) + if [ -z "$APP" ]; then echo "No .app found after extraction"; exit 1; fi + + ln -s /Applications "$STAGING/Applications" + hdiutil create -volname "KeepKey Vault" -srcfolder "$STAGING" -ov -format UDZO "$DMG_NAME" + echo "Created: $DMG_NAME (unsigned — sign locally with 'make sign-release')" + - name: Package AppImage (Linux) if: runner.os == 'Linux' run: | @@ -203,8 +300,8 @@ jobs: - name: Generate checksums run: | cd projects/keepkey-vault/artifacts - shasum -a 256 * > SHA256SUMS.txt 2>/dev/null || true - cat SHA256SUMS.txt + shasum -a 256 * > SHA256SUMS-${{ matrix.artifact-suffix }}.txt 2>/dev/null || true + cat SHA256SUMS-${{ matrix.artifact-suffix }}.txt - name: List artifacts run: ls -lh projects/keepkey-vault/artifacts/ @@ -246,6 +343,16 @@ jobs: path: artifacts merge-multiple: true + - name: Generate combined checksums + run: | + cd artifacts + # Remove per-platform checksum files (will regenerate combined) + rm -f SHA256SUMS-*.txt SHA256SUMS.txt + # Generate checksums for release-worthy files only + shasum -a 256 *.dmg *.AppImage *.exe *.tar.zst 2>/dev/null > SHA256SUMS.txt || true + echo "=== Release checksums ===" + cat SHA256SUMS.txt + - name: List release artifacts run: find artifacts -type f | sort diff --git a/Makefile b/Makefile index e293aef1..107a8633 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ include .env export ELECTROBUN_DEVELOPER_ID ELECTROBUN_TEAMID ELECTROBUN_APPLEID ELECTROBUN_APPLEIDPASS endif -.PHONY: install dev dev-hmr build build-stable build-canary build-signed prune-bundle dmg clean help vault sign-check verify publish release upload-dmg submodules modules-install modules-build modules-clean audit build-zcash-cli build-zcash-cli-debug build-zcash-cli-intel test test-unit test-rest test-zcash-cli build-intel build-signed-intel +.PHONY: install dev dev-hmr build build-stable build-canary build-signed prune-bundle dmg clean help vault sign-check verify publish release upload-dmg upload-all-dmgs sign-release verify-arch submodules modules-install modules-build modules-clean audit build-zcash-cli build-zcash-cli-debug build-zcash-cli-intel test test-unit test-rest test-zcash-cli build-intel build-signed-intel # --- Submodules (auto-init on fresh worktrees/clones) --- @@ -104,47 +104,75 @@ ifdef ELECTROBUN_DEVELOPER_ID endif @echo "=== Intel zcash-cli ready at $(PROJECT_DIR)/zcash-cli/target/x86_64-apple-darwin/release/zcash-cli ===" -# --- Intel Mac Build --- -# Build the full app targeting Intel (x86_64) Macs from an Apple Silicon machine. -# Requires: rustup target add x86_64-apple-darwin (one-time setup) -# The Zcash CLI is cross-compiled, native node addons use prebuilt x64 binaries, -# and Electrobun + Bun handle the rest. +# --- Architecture Verification --- +# Verify that the binaries in the tar.zst match the expected architecture. +# Prevents mislabeled DMGs (e.g. ARM64 binaries in an x86_64-named DMG). +# Usage: make verify-arch (auto-detects from uname -m) +# make verify-arch EXPECTED_ARCH=x86_64 (explicit override) +EXPECTED_ARCH ?= $(ARCH) +verify-arch: + @echo "Verifying artifact architecture (expecting $(EXPECTED_ARCH))..." + @TAR_ZST=$$(find $(PROJECT_DIR)/artifacts -name "*.app.tar.zst" | head -1); \ + if [ -z "$$TAR_ZST" ]; then echo "ERROR: No .app.tar.zst found in artifacts/"; exit 1; fi; \ + TMPDIR=$$(mktemp -d); \ + trap 'rm -rf "$$TMPDIR"' EXIT; \ + zstd -d "$$TAR_ZST" -o "$$TMPDIR/app.tar" --force 2>/dev/null; \ + LAUNCHER=$$(tar tf "$$TMPDIR/app.tar" | grep "MacOS/launcher$$" | head -1); \ + BUN_BIN=$$(tar tf "$$TMPDIR/app.tar" | grep "MacOS/bun$$" | head -1); \ + if [ -z "$$LAUNCHER" ]; then echo "ERROR: No launcher binary found in archive"; exit 1; fi; \ + tar xf "$$TMPDIR/app.tar" -C "$$TMPDIR/" "$$LAUNCHER"; \ + if [ -n "$$BUN_BIN" ]; then tar xf "$$TMPDIR/app.tar" -C "$$TMPDIR/" "$$BUN_BIN"; fi; \ + FAIL=0; \ + for BIN in "$$TMPDIR/$$LAUNCHER" "$$TMPDIR/$$BUN_BIN"; do \ + [ -f "$$BIN" ] || continue; \ + ACTUAL=$$(lipo -archs "$$BIN" 2>/dev/null); \ + NAME=$$(basename "$$BIN"); \ + echo " $$NAME: $$ACTUAL"; \ + if [ "$$ACTUAL" != "$(EXPECTED_ARCH)" ]; then \ + echo ""; \ + echo "ERROR: Architecture mismatch! $$NAME is $$ACTUAL but expected $(EXPECTED_ARCH)"; \ + FAIL=1; \ + fi; \ + done; \ + if [ "$$FAIL" = "1" ]; then \ + echo ""; \ + echo "The artifact contains binaries for the wrong architecture."; \ + echo "Use CI macOS runners for correct architecture builds:"; \ + echo " macos-13 → x86_64 (Intel)"; \ + echo " macos-14 → arm64 (Apple Silicon)"; \ + echo ""; \ + echo "To sign CI-built artifacts locally: make sign-release"; \ + exit 1; \ + fi; \ + echo "Architecture verified: $(EXPECTED_ARCH)" + +# --- Intel Mac Build (DEPRECATED) --- +# WARNING: arch -x86_64 does NOT make Bun/Electrobun produce x86_64 output. +# Bun is ARM64-only — the resulting binary will STILL be ARM64 regardless. +# Use CI (macos-13 runner) for real Intel builds, then sign locally with: +# make sign-release INTEL_DMG_NAME := KeepKey-Vault-$(VERSION)-x86_64.dmg -build-intel: install build-zcash-cli-intel - @echo "=== Building Vault for Intel Mac (x86_64) ===" - @# Copy the cross-compiled zcash-cli into the expected location - @mkdir -p $(PROJECT_DIR)/zcash-cli/target/release - cp $(PROJECT_DIR)/zcash-cli/target/x86_64-apple-darwin/release/zcash-cli \ - $(PROJECT_DIR)/zcash-cli/target/release/zcash-cli - @# Run the build under Rosetta so Bun + Electrobun produce x86_64 output - arch -x86_64 /bin/bash -c "cd $(PROJECT_DIR) && bun run build:stable" - @echo "=== Intel build complete ===" +build-intel: + @echo "" + @echo "ERROR: build-intel is DEPRECATED and does NOT produce x86_64 binaries." + @echo "" + @echo "Bun and Electrobun are ARM64-only on Apple Silicon. The arch -x86_64" + @echo "wrapper has no effect — the output is still ARM64, just mislabeled." + @echo "" + @echo "For real Intel Mac builds:" + @echo " 1. Push to develop or release/* branch (CI builds both architectures)" + @echo " 2. Sign the CI artifacts locally: make sign-release" + @echo "" + @exit 1 -build-signed-intel: sign-check build-intel audit prune-bundle - @echo "Creating Intel Mac DMG..." - @TAR_ZST=$$(find $(PROJECT_DIR)/artifacts -name "*.app.tar.zst" | head -1); \ - if [ -z "$$TAR_ZST" ]; then echo "ERROR: No .app.tar.zst found in artifacts/"; exit 1; fi; \ - STAGING=$$(mktemp -d); \ - trap 'rm -rf "$$STAGING"' EXIT; \ - zstd -d "$$TAR_ZST" -o "$$STAGING/app.tar" --force; \ - tar xf "$$STAGING/app.tar" -C "$$STAGING/"; \ - rm "$$STAGING/app.tar"; \ - APP=$$(find "$$STAGING" -name "*.app" -maxdepth 1 | head -1); \ - codesign --verify --deep --strict "$$APP" || (echo "ERROR: codesign failed"; exit 1); \ - ln -s /Applications "$$STAGING/Applications"; \ - DMG_OUT="$$(pwd)/$(PROJECT_DIR)/artifacts/$(INTEL_DMG_NAME)"; \ - rm -f "$$DMG_OUT"; \ - hdiutil create -volname "KeepKey Vault" -srcfolder "$$STAGING" -ov -format UDZO "$$DMG_OUT"; \ - codesign --force --timestamp --sign "Developer ID Application: $$ELECTROBUN_DEVELOPER_ID ($$ELECTROBUN_TEAMID)" "$$DMG_OUT"; \ - echo "Notarizing Intel DMG..."; \ - ZIP_TMP=$$(mktemp).zip; \ - (cd "$$(dirname "$$DMG_OUT")" && zip -q "$$ZIP_TMP" "$$(basename "$$DMG_OUT")"); \ - xcrun notarytool submit --apple-id "$$ELECTROBUN_APPLEID" --password "$$ELECTROBUN_APPLEIDPASS" --team-id "$$ELECTROBUN_TEAMID" --wait "$$ZIP_TMP"; \ - rm -f "$$ZIP_TMP"; \ - xcrun stapler staple "$$DMG_OUT"; \ - echo "Intel DMG ready: $$DMG_OUT" +build-signed-intel: + @echo "" + @echo "ERROR: build-signed-intel is DEPRECATED. See 'make build-intel' for details." + @echo "Use: make sign-release" + @echo "" + @exit 1 # --- Vault --- @@ -188,7 +216,7 @@ build-signed: sign-check @ls -lh $(PROJECT_DIR)/artifacts/$(DMG_NAME) # Create a proper DMG from the fully-extracted app (workaround for Electrobun self-extractor bug) -dmg: +dmg: verify-arch @echo "Creating DMG from tar.zst artifact..." @TAR_ZST=$$(find $(PROJECT_DIR)/artifacts -name "*.app.tar.zst" | head -1); \ if [ -z "$$TAR_ZST" ]; then echo "ERROR: No .app.tar.zst found in artifacts/"; exit 1; fi; \ @@ -305,6 +333,110 @@ release: sign-check build-signed $$UPDATE_JSON $$TAR_ZST @echo "Release v$(VERSION) published to $(GITHUB_REPO)" +# Sign CI-built macOS artifacts and upload to draft release. +# Downloads both arm64 and x64 tar.zst from CI, signs all binaries, +# creates DMGs, notarizes, and uploads to the GitHub release. +# Usage: make sign-release +sign-release: sign-check + @echo "=== Signing macOS release v$(VERSION) ===" + @mkdir -p $(PROJECT_DIR)/artifacts/ci-arm64 $(PROJECT_DIR)/artifacts/ci-x64 + @echo "Downloading CI-built macOS artifacts..." + @gh release download v$(VERSION) --repo $(GITHUB_REPO) \ + --pattern "stable-macos-arm64-keepkey-vault.app.tar.zst" \ + --dir $(PROJECT_DIR)/artifacts/ci-arm64 --clobber 2>/dev/null && \ + echo " Downloaded arm64 artifact" || echo " No arm64 artifact found (skipping)" + @gh release download v$(VERSION) --repo $(GITHUB_REPO) \ + --pattern "stable-macos-x64-keepkey-vault.app.tar.zst" \ + --dir $(PROJECT_DIR)/artifacts/ci-x64 --clobber 2>/dev/null && \ + echo " Downloaded x64 artifact" || echo " No x64 artifact found (skipping)" + @echo "" + @# Process arm64 + @if [ -f $(PROJECT_DIR)/artifacts/ci-arm64/stable-macos-arm64-keepkey-vault.app.tar.zst ]; then \ + echo "--- Signing arm64 artifact ---"; \ + $(MAKE) _sign-one-dmg \ + _SRC_TAR="$$(pwd)/$(PROJECT_DIR)/artifacts/ci-arm64/stable-macos-arm64-keepkey-vault.app.tar.zst" \ + _DMG_ARCH=arm64; \ + fi + @# Process x64 + @if [ -f $(PROJECT_DIR)/artifacts/ci-x64/stable-macos-x64-keepkey-vault.app.tar.zst ]; then \ + echo "--- Signing x86_64 artifact ---"; \ + $(MAKE) _sign-one-dmg \ + _SRC_TAR="$$(pwd)/$(PROJECT_DIR)/artifacts/ci-x64/stable-macos-x64-keepkey-vault.app.tar.zst" \ + _DMG_ARCH=x86_64; \ + fi + @echo "" + @echo "=== Uploading signed DMGs ===" + @for DMG in $(PROJECT_DIR)/artifacts/KeepKey-Vault-$(VERSION)-*.dmg; do \ + [ -f "$$DMG" ] || continue; \ + echo " Uploading $$(basename $$DMG)..."; \ + gh release upload v$(VERSION) --repo $(GITHUB_REPO) --clobber "$$DMG"; \ + done + @echo "" + @echo "=== Release v$(VERSION) signed and uploaded ===" + @echo "https://github.com/$(GITHUB_REPO)/releases/tag/v$(VERSION)" + @# Cleanup CI temp dirs + @rm -rf $(PROJECT_DIR)/artifacts/ci-arm64 $(PROJECT_DIR)/artifacts/ci-x64 + +# Internal: sign a single tar.zst and produce a DMG +# Args: _SRC_TAR (path to tar.zst), _DMG_ARCH (arm64 or x86_64) +_sign-one-dmg: + @test -f "$(_SRC_TAR)" || (echo "ERROR: $(_SRC_TAR) not found"; exit 1) + @STAGING=$$(mktemp -d); \ + trap 'rm -rf "$$STAGING"' EXIT; \ + echo " Extracting..."; \ + zstd -d "$(_SRC_TAR)" -o "$$STAGING/app.tar" --force; \ + tar xf "$$STAGING/app.tar" -C "$$STAGING/"; \ + rm "$$STAGING/app.tar"; \ + APP=$$(find "$$STAGING" -name "*.app" -maxdepth 1 | head -1); \ + if [ -z "$$APP" ]; then echo "ERROR: No .app found after extraction"; exit 1; fi; \ + echo " Verifying architecture ($(_DMG_ARCH))..."; \ + ACTUAL=$$(lipo -archs "$$APP/Contents/MacOS/launcher" 2>/dev/null); \ + if [ "$$ACTUAL" != "$(_DMG_ARCH)" ]; then \ + echo "ERROR: Binary is $$ACTUAL but expected $(_DMG_ARCH)"; exit 1; \ + fi; \ + echo " Signing Mach-O binaries..."; \ + find "$$APP" -type f -exec sh -c 'file "$$1" 2>/dev/null | grep -q "Mach-O" && \ + codesign --force --timestamp --sign "Developer ID Application: '"$$ELECTROBUN_DEVELOPER_ID"' ('"$$ELECTROBUN_TEAMID"')" \ + --options runtime "$$1" 2>/dev/null' _ {} \; ; \ + echo " Signing .app bundle with entitlements..."; \ + codesign --force --timestamp \ + --sign "Developer ID Application: $$ELECTROBUN_DEVELOPER_ID ($$ELECTROBUN_TEAMID)" \ + --options runtime \ + --entitlements $(PROJECT_DIR)/entitlements.plist \ + "$$APP"; \ + codesign --verify --deep --strict "$$APP" || (echo "ERROR: Signature verification failed"; exit 1); \ + ln -s /Applications "$$STAGING/Applications"; \ + DMG_OUT="$$(pwd)/$(PROJECT_DIR)/artifacts/KeepKey-Vault-$(VERSION)-$(_DMG_ARCH).dmg"; \ + rm -f "$$DMG_OUT"; \ + echo " Creating DMG..."; \ + hdiutil create -volname "KeepKey Vault" -srcfolder "$$STAGING" -ov -format UDZO "$$DMG_OUT"; \ + echo " Signing DMG..."; \ + codesign --force --timestamp \ + --sign "Developer ID Application: $$ELECTROBUN_DEVELOPER_ID ($$ELECTROBUN_TEAMID)" \ + "$$DMG_OUT"; \ + echo " Notarizing DMG..."; \ + ZIP_TMP=$$(mktemp).zip; \ + (cd "$$(dirname "$$DMG_OUT")" && zip -q "$$ZIP_TMP" "$$(basename "$$DMG_OUT")"); \ + xcrun notarytool submit --apple-id "$$ELECTROBUN_APPLEID" --password "$$ELECTROBUN_APPLEIDPASS" \ + --team-id "$$ELECTROBUN_TEAMID" --wait "$$ZIP_TMP"; \ + rm -f "$$ZIP_TMP"; \ + echo " Stapling notarization ticket..."; \ + xcrun stapler staple "$$DMG_OUT"; \ + echo " Done: $$DMG_OUT" + +# Upload all signed DMGs to the draft release +upload-all-dmgs: sign-check + @echo "Uploading all signed DMGs for v$(VERSION)..." + @FOUND=0; \ + for DMG in $(PROJECT_DIR)/artifacts/KeepKey-Vault-$(VERSION)-*.dmg; do \ + [ -f "$$DMG" ] || continue; \ + FOUND=1; \ + echo " Uploading $$(basename $$DMG)..."; \ + gh release upload v$(VERSION) --repo $(GITHUB_REPO) --clobber "$$DMG"; \ + done; \ + if [ "$$FOUND" = "0" ]; then echo "ERROR: No DMGs found. Run 'make build-signed' or 'make sign-release' first."; exit 1; fi + @echo "DMGs uploaded to https://github.com/$(GITHUB_REPO)/releases/tag/v$(VERSION)" + help: @echo "KeepKey Vault v11 - Electrobun Desktop App" @echo "" @@ -319,8 +451,9 @@ help: @echo " make dmg - Create DMG from existing build artifacts" @echo " make modules-build - Build hdwallet + proto-tx-builder from source" @echo " make modules-clean - Clean module build artifacts" - @echo " make build-intel - Build for Intel Mac (x86_64) from Apple Silicon" - @echo " make build-signed-intel - Full Intel Mac pipeline: build → DMG → sign → notarize" + @echo " make verify-arch - Verify build artifact matches expected architecture" + @echo " make sign-release - Download CI artifacts, sign both architectures, upload DMGs" + @echo " make upload-all-dmgs - Upload all signed DMGs to draft release" @echo " make build-zcash-cli - Test + build Zcash CLI sidecar (release)" @echo " make build-zcash-cli-intel - Cross-compile Zcash CLI for Intel Mac" @echo " make build-zcash-cli-debug - Test + build Zcash CLI sidecar (debug)" From 5a431c236d5d317887eabb89a63304c231cfe38b Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 26 Mar 2026 11:22:15 -0600 Subject: [PATCH 12/17] fix: sign-release uploads signed auto-update payloads, guards against stale/missing artifacts Three review fixes: 1. _sign-one-dmg now re-packs the signed .app into a tar.zst so auto-update payloads on the release are signed, not just the DMGs. sign-release uploads both DMGs and signed tar.zst files. 2. build-intel deprecation message corrected: references release/* or v* tag (not develop) since only those trigger the draft release job. 3. sign-release now: verifies the draft release exists before starting, cleans stale DMGs/tar.zst from artifacts/ before signing, fails hard if zero CI artifacts were downloaded, and verifies at least one DMG was produced. --- Makefile | 52 +++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/Makefile b/Makefile index 107a8633..09ee24d8 100644 --- a/Makefile +++ b/Makefile @@ -162,7 +162,8 @@ build-intel: @echo "wrapper has no effect — the output is still ARM64, just mislabeled." @echo "" @echo "For real Intel Mac builds:" - @echo " 1. Push to develop or release/* branch (CI builds both architectures)" + @echo " 1. Push to a release/* branch or v* tag (CI creates draft release)" + @echo " Or trigger manually: gh workflow run build.yml" @echo " 2. Sign the CI artifacts locally: make sign-release" @echo "" @exit 1 @@ -335,20 +336,38 @@ release: sign-check build-signed # Sign CI-built macOS artifacts and upload to draft release. # Downloads both arm64 and x64 tar.zst from CI, signs all binaries, -# creates DMGs, notarizes, and uploads to the GitHub release. +# re-packs signed tar.zst (auto-update), creates DMGs, notarizes, and uploads. +# Requires: draft release v$(VERSION) created by CI (push to release/* or v* tag). # Usage: make sign-release sign-release: sign-check @echo "=== Signing macOS release v$(VERSION) ===" + @# Verify draft release exists before doing any work + @gh release view v$(VERSION) --repo $(GITHUB_REPO) >/dev/null 2>&1 || \ + (echo "ERROR: No release v$(VERSION) found." && \ + echo "Create one by pushing to a release/* branch or v* tag, or run:" && \ + echo " gh workflow run build.yml --repo $(GITHUB_REPO)" && exit 1) + @# Clean stale artifacts from previous runs to prevent uploading old files + @rm -f $(PROJECT_DIR)/artifacts/KeepKey-Vault-$(VERSION)-*.dmg + @rm -f $(PROJECT_DIR)/artifacts/stable-macos-*-keepkey-vault.app.tar.zst @mkdir -p $(PROJECT_DIR)/artifacts/ci-arm64 $(PROJECT_DIR)/artifacts/ci-x64 @echo "Downloading CI-built macOS artifacts..." @gh release download v$(VERSION) --repo $(GITHUB_REPO) \ --pattern "stable-macos-arm64-keepkey-vault.app.tar.zst" \ --dir $(PROJECT_DIR)/artifacts/ci-arm64 --clobber 2>/dev/null && \ - echo " Downloaded arm64 artifact" || echo " No arm64 artifact found (skipping)" + echo " Downloaded arm64 artifact" || echo " No arm64 artifact found" @gh release download v$(VERSION) --repo $(GITHUB_REPO) \ --pattern "stable-macos-x64-keepkey-vault.app.tar.zst" \ --dir $(PROJECT_DIR)/artifacts/ci-x64 --clobber 2>/dev/null && \ - echo " Downloaded x64 artifact" || echo " No x64 artifact found (skipping)" + echo " Downloaded x64 artifact" || echo " No x64 artifact found" + @# Fail if neither artifact was found + @if [ ! -f $(PROJECT_DIR)/artifacts/ci-arm64/stable-macos-arm64-keepkey-vault.app.tar.zst ] && \ + [ ! -f $(PROJECT_DIR)/artifacts/ci-x64/stable-macos-x64-keepkey-vault.app.tar.zst ]; then \ + echo ""; \ + echo "ERROR: No CI macOS artifacts found on release v$(VERSION)."; \ + echo "Ensure CI has completed and uploaded artifacts before running sign-release."; \ + rm -rf $(PROJECT_DIR)/artifacts/ci-arm64 $(PROJECT_DIR)/artifacts/ci-x64; \ + exit 1; \ + fi @echo "" @# Process arm64 @if [ -f $(PROJECT_DIR)/artifacts/ci-arm64/stable-macos-arm64-keepkey-vault.app.tar.zst ]; then \ @@ -365,19 +384,34 @@ sign-release: sign-check _DMG_ARCH=x86_64; \ fi @echo "" - @echo "=== Uploading signed DMGs ===" + @# Verify at least one DMG was produced in this run + @PRODUCED=0; \ + for DMG in $(PROJECT_DIR)/artifacts/KeepKey-Vault-$(VERSION)-*.dmg; do \ + [ -f "$$DMG" ] && PRODUCED=1; \ + done; \ + if [ "$$PRODUCED" = "0" ]; then \ + echo "ERROR: No DMGs were produced — signing may have failed."; \ + rm -rf $(PROJECT_DIR)/artifacts/ci-arm64 $(PROJECT_DIR)/artifacts/ci-x64; \ + exit 1; \ + fi + @echo "=== Uploading signed artifacts ===" @for DMG in $(PROJECT_DIR)/artifacts/KeepKey-Vault-$(VERSION)-*.dmg; do \ [ -f "$$DMG" ] || continue; \ echo " Uploading $$(basename $$DMG)..."; \ gh release upload v$(VERSION) --repo $(GITHUB_REPO) --clobber "$$DMG"; \ done + @for TAR in $(PROJECT_DIR)/artifacts/stable-macos-*-keepkey-vault.app.tar.zst; do \ + [ -f "$$TAR" ] || continue; \ + echo " Uploading $$(basename $$TAR) (signed auto-update payload)..."; \ + gh release upload v$(VERSION) --repo $(GITHUB_REPO) --clobber "$$TAR"; \ + done @echo "" @echo "=== Release v$(VERSION) signed and uploaded ===" @echo "https://github.com/$(GITHUB_REPO)/releases/tag/v$(VERSION)" @# Cleanup CI temp dirs @rm -rf $(PROJECT_DIR)/artifacts/ci-arm64 $(PROJECT_DIR)/artifacts/ci-x64 -# Internal: sign a single tar.zst and produce a DMG +# Internal: sign a single tar.zst, produce a signed tar.zst (auto-update) and DMG # Args: _SRC_TAR (path to tar.zst), _DMG_ARCH (arm64 or x86_64) _sign-one-dmg: @test -f "$(_SRC_TAR)" || (echo "ERROR: $(_SRC_TAR) not found"; exit 1) @@ -405,6 +439,10 @@ _sign-one-dmg: --entitlements $(PROJECT_DIR)/entitlements.plist \ "$$APP"; \ codesign --verify --deep --strict "$$APP" || (echo "ERROR: Signature verification failed"; exit 1); \ + echo " Re-packing signed app into tar.zst for auto-update..."; \ + SIGNED_TAR="$$(pwd)/$(PROJECT_DIR)/artifacts/$$(basename $(_SRC_TAR))"; \ + (cd "$$STAGING" && tar cf - "$$(basename $$APP)") | zstd -o "$$SIGNED_TAR" --force; \ + echo " Signed tar.zst: $$SIGNED_TAR"; \ ln -s /Applications "$$STAGING/Applications"; \ DMG_OUT="$$(pwd)/$(PROJECT_DIR)/artifacts/KeepKey-Vault-$(VERSION)-$(_DMG_ARCH).dmg"; \ rm -f "$$DMG_OUT"; \ @@ -452,7 +490,7 @@ help: @echo " make modules-build - Build hdwallet + proto-tx-builder from source" @echo " make modules-clean - Clean module build artifacts" @echo " make verify-arch - Verify build artifact matches expected architecture" - @echo " make sign-release - Download CI artifacts, sign both architectures, upload DMGs" + @echo " make sign-release - Download CI artifacts, sign + repack, upload DMGs + auto-update tar.zst" @echo " make upload-all-dmgs - Upload all signed DMGs to draft release" @echo " make build-zcash-cli - Test + build Zcash CLI sidecar (release)" @echo " make build-zcash-cli-intel - Cross-compile Zcash CLI for Intel Mac" From 38d680ff32ba778ab963cd8c8a0fafd062cb967b Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 26 Mar 2026 11:29:25 -0600 Subject: [PATCH 13/17] fix: install protoc on macOS CI runners for zcash-cli build zcash-cli uses prost-build which requires the protoc compiler. GitHub macOS runners don't include it by default. --- .github/workflows/build.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b421442a..7d4711de 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -150,6 +150,10 @@ jobs: run: cd projects/keepkey-vault && bun install shell: bash + - name: Install protoc (macOS) + if: runner.os == 'macOS' + run: brew install protobuf + - name: Build zcash-cli sidecar (macOS) if: runner.os == 'macOS' shell: bash From 3ad56dadf01dfafdcade56c1dab00f886d6bde82 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 26 Mar 2026 11:48:31 -0600 Subject: [PATCH 14/17] fix: use macos-15-intel runner for x86_64 builds (macos-13 deprecated) --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7d4711de..ffeea005 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,7 +37,7 @@ jobs: expected-arch: arm64 - name: macOS x64 - runner: macos-13 + runner: macos-15-intel artifact-suffix: macos-x64 expected-arch: x86_64 From dee7bc1b5d5b5161d2916a2e811c1001a7360c62 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 26 Mar 2026 12:30:01 -0600 Subject: [PATCH 15/17] fix: build x64 macOS variant via binary swap on ARM64 runner Electrobun's build tooling is ARM64-only, so we can't build natively on Intel CI runners. Instead, build on ARM64 (macos-14) then create the x64 variant by downloading pre-built x64 Electrobun core binaries from GitHub and swapping them into the app bundle. - Remove macos-15-intel matrix entry (Electrobun can't run there) - Single macOS runner produces both arm64 and x64 artifacts - Downloads electrobun-core-darwin-x64.tar.gz matching installed version - Swaps launcher, bun, libNativeWrapper.dylib, libasar.dylib - Removes zcash-cli from x64 bundle (not supported on Intel) - Verifies x86_64 architecture before creating DMG --- .github/workflows/build.yml | 117 +++++++++++++++++++++++++++++++++--- 1 file changed, 110 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ffeea005..99e28aaa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,16 +31,11 @@ jobs: runner: ubuntu-latest artifact-suffix: linux-x64 - - name: macOS ARM64 + - name: macOS runner: macos-14 - artifact-suffix: macos-arm64 + artifact-suffix: macos expected-arch: arm64 - - name: macOS x64 - runner: macos-15-intel - artifact-suffix: macos-x64 - expected-arch: x86_64 - steps: - name: Checkout uses: actions/checkout@v4 @@ -242,6 +237,114 @@ jobs: hdiutil create -volname "KeepKey Vault" -srcfolder "$STAGING" -ov -format UDZO "$DMG_NAME" echo "Created: $DMG_NAME (unsigned — sign locally with 'make sign-release')" + - name: Create x86_64 variant (macOS) + if: runner.os == 'macOS' + shell: bash + run: | + echo "=== Creating x86_64 variant from ARM64 build ===" + + # Get Electrobun version for downloading matching x64 binaries + ELECTROBUN_VER=$(node -p "require('./projects/keepkey-vault/node_modules/electrobun/package.json').version") + echo "Electrobun version: v$ELECTROBUN_VER" + + # Download x64 Electrobun core binaries + echo "Downloading Electrobun core for x86_64..." + curl -fsSL "https://github.com/blackboardsh/electrobun/releases/download/v${ELECTROBUN_VER}/electrobun-core-darwin-x64.tar.gz" \ + -o /tmp/electrobun-core-x64.tar.gz + mkdir -p /tmp/electrobun-x64 + tar xzf /tmp/electrobun-core-x64.tar.gz -C /tmp/electrobun-x64/ + + # Find the directory containing the x64 binaries (handles nested or flat tar structure) + X64_BIN_DIR=$(find /tmp/electrobun-x64 -name "launcher" -type f -exec dirname {} \; | head -1) + if [ -z "$X64_BIN_DIR" ]; then echo "::error::No launcher found in electrobun-core-x64"; exit 1; fi + echo "x64 binaries at: $X64_BIN_DIR" + ls "$X64_BIN_DIR/" + + # Find the ARM64 tar.zst + ARM64_TAR=$(find projects/keepkey-vault/artifacts -name "*.app.tar.zst" | head -1) + if [ -z "$ARM64_TAR" ]; then echo "::error::No ARM64 tar.zst found"; exit 1; fi + echo "Source: $ARM64_TAR" + + # Extract the ARM64 .app + WORK=$(mktemp -d) + zstd -d "$ARM64_TAR" -o "$WORK/app.tar" --force + tar xf "$WORK/app.tar" -C "$WORK/" + rm "$WORK/app.tar" + + APP=$(find "$WORK" -name "*.app" -maxdepth 1 | head -1) + if [ -z "$APP" ]; then echo "::error::No .app found after extraction"; exit 1; fi + + # Swap Electrobun runtime binaries with x64 versions + echo "Swapping Electrobun binaries for x86_64..." + for BIN in launcher bun libNativeWrapper.dylib libasar.dylib; do + SRC="$X64_BIN_DIR/$BIN" + DEST=$(find "$APP" -name "$BIN" -type f | head -1) + if [ -f "$SRC" ] && [ -n "$DEST" ]; then + cp "$SRC" "$DEST" + ARCH=$(lipo -archs "$DEST" 2>/dev/null || echo "non-macho") + echo " $BIN: $ARCH" + elif [ -f "$SRC" ]; then + echo " $BIN: not found in .app (skipping)" + fi + done + + # Remove zcash-cli from x64 bundle (Zcash shielded not supported on Intel) + ZCASH_DEST=$(find "$APP" -name "zcash-cli" -type f | head -1) + if [ -n "$ZCASH_DEST" ]; then + rm "$ZCASH_DEST" + echo " zcash-cli: removed (Intel not supported)" + fi + + # Verify key binaries are now x86_64 + echo "Verifying swapped binaries:" + FAIL=0 + for BIN in launcher bun; do + FOUND=$(find "$APP" -name "$BIN" -path "*/MacOS/*" | head -1) + if [ -n "$FOUND" ]; then + ACTUAL=$(lipo -archs "$FOUND" 2>/dev/null) + echo " $BIN: $ACTUAL" + if [ "$ACTUAL" != "x86_64" ]; then + echo "::error::$BIN is $ACTUAL, expected x86_64" + FAIL=1 + fi + fi + done + if [ "$FAIL" = "1" ]; then exit 1; fi + + # Re-pack as x64 tar.zst + X64_TAR="projects/keepkey-vault/artifacts/stable-macos-x64-keepkey-vault.app.tar.zst" + (cd "$WORK" && tar cf - "$(basename "$APP")") | zstd -o "$X64_TAR" --force + echo "Created: $X64_TAR ($(du -h "$X64_TAR" | cut -f1))" + + # Cleanup + rm -rf "$WORK" /tmp/electrobun-x64 /tmp/electrobun-core-x64.tar.gz + + - name: Create unsigned x86_64 DMG (macOS) + if: runner.os == 'macOS' + shell: bash + run: | + cd projects/keepkey-vault/artifacts + TAR_ZST="stable-macos-x64-keepkey-vault.app.tar.zst" + if [ ! -f "$TAR_ZST" ]; then echo "No x64 tar.zst, skipping DMG"; exit 0; fi + + VERSION=$(grep '"version"' ../package.json | head -1 | sed 's/.*"version": "\(.*\)".*/\1/') + DMG_NAME="KeepKey-Vault-${VERSION}-x86_64.dmg" + echo "Creating unsigned x86_64 DMG: $DMG_NAME" + + STAGING=$(mktemp -d) + trap 'rm -rf "$STAGING"' EXIT + + zstd -d "$TAR_ZST" -o "$STAGING/app.tar" --force + tar xf "$STAGING/app.tar" -C "$STAGING/" + rm "$STAGING/app.tar" + + APP=$(find "$STAGING" -name "*.app" -maxdepth 1 | head -1) + if [ -z "$APP" ]; then echo "No .app found after extraction"; exit 1; fi + + ln -s /Applications "$STAGING/Applications" + hdiutil create -volname "KeepKey Vault" -srcfolder "$STAGING" -ov -format UDZO "$DMG_NAME" + echo "Created: $DMG_NAME (unsigned — sign locally with 'make sign-release')" + - name: Package AppImage (Linux) if: runner.os == 'Linux' run: | From 25010fb8b13ad33569ba34e05e3940691305a1f2 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 26 Mar 2026 12:57:22 -0600 Subject: [PATCH 16/17] fix: swap node-hid prebuild to x64 in Intel variant (required for firmware updates) node-hid HID-darwin-x64 prebuild exists in the npm package but collect-externals strips it during ARM64 builds. The x64 variant needs it for firmware updates, bootloader recovery, and OOB setup. --- .github/workflows/build.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 99e28aaa..e2410cd0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -288,6 +288,22 @@ jobs: fi done + # Swap node-hid prebuilds: replace ARM64 with x64 (required for firmware updates + HID transport) + HID_SRC="projects/keepkey-vault/node_modules/node-hid/prebuilds/HID-darwin-x64" + if [ -d "$HID_SRC" ]; then + HID_DEST=$(find "$APP" -type d -name "HID-darwin-arm64" | head -1) + if [ -n "$HID_DEST" ]; then + HID_PARENT=$(dirname "$HID_DEST") + rm -rf "$HID_DEST" + cp -r "$HID_SRC" "$HID_PARENT/HID-darwin-x64" + echo " node-hid: swapped ARM64 → x64 prebuild" + else + echo " node-hid: HID-darwin-arm64 not found in bundle (skipping)" + fi + else + echo "::warning::node-hid HID-darwin-x64 prebuild not found in node_modules" + fi + # Remove zcash-cli from x64 bundle (Zcash shielded not supported on Intel) ZCASH_DEST=$(find "$APP" -name "zcash-cli" -type f | head -1) if [ -n "$ZCASH_DEST" ]; then From 9ae714d625ee295716c67937400d558318f70c6f Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 26 Mar 2026 13:25:09 -0600 Subject: [PATCH 17/17] chore: bump version to 1.2.10 --- projects/keepkey-vault/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/keepkey-vault/package.json b/projects/keepkey-vault/package.json index fc4406bc..b8b7214c 100644 --- a/projects/keepkey-vault/package.json +++ b/projects/keepkey-vault/package.json @@ -1,6 +1,6 @@ { "name": "keepkey-vault", - "version": "1.2.9", + "version": "1.2.10", "description": "KeepKey Vault - Desktop hardware wallet management powered by Electrobun", "scripts": { "dev": "bun scripts/bundle-backend.ts && vite build && bun scripts/collect-externals.ts && electrobun build && bun scripts/patch-bundle.ts && electrobun dev",