diff --git a/projects/keepkey-vault/__tests__/balance-reconciliation.test.ts b/projects/keepkey-vault/__tests__/balance-reconciliation.test.ts new file mode 100644 index 00000000..a55a5bd0 --- /dev/null +++ b/projects/keepkey-vault/__tests__/balance-reconciliation.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, test } from 'bun:test' +import type { ChainBalance, PendingSwap } from '../src/shared/types' +import { + balanceAmountForAsset, + beginSwapBalanceReconciliation, + completeSwapBalanceReconciliation, + expireBalanceReconciliations, + mergeTrustedBalanceSnapshot, + observeBalanceRefresh, + protectedBalanceChainIds, + reconcileTerminalSwapBalance, + RECONCILIATION_CEILING_MS, + shouldReplaceBalanceSnapshot, +} from '../src/shared/balance-reconciliation' + +const SOL_USDT = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB' + +function solanaBalance(usdt?: string, syncState: ChainBalance['syncState'] = 'confirmed'): ChainBalance { + return { + chainId: 'solana', + symbol: 'SOL', + balance: '2.5', + balanceUsd: 500, + address: 'wallet', + syncState, + tokens: usdt === undefined ? undefined : [{ + symbol: 'USDT', + name: 'Tether', + balance: usdt, + balanceUsd: Number(usdt), + priceUsd: 1, + caip: SOL_USDT, + }], + } +} + +describe('post-swap balance reconciliation', () => { + test('failed swaps immediately discard their provisional record and release the chain', () => { + const balances = new Map([['solana', solanaBalance('4')]]) + const record = beginSwapBalanceReconciliation({ + txid: 'tx-failed', + fromChainId: 'bitcoin', + toChainId: 'solana', + toCaip: SOL_USDT, + fromSymbol: 'BTC', + toSymbol: 'USDT', + }, balances, 100)! + + expect(protectedBalanceChainIds([record]).has('solana')).toBe(true) + const reconciled = reconcileTerminalSwapBalance([record], { + txid: 'tx-failed', + fromAsset: 'BTC.BTC', + toAsset: 'SOL.USDT', + fromSymbol: 'BTC', + toSymbol: 'USDT', + fromChainId: 'bitcoin', + toChainId: 'solana', + toCaip: SOL_USDT, + fromAmount: '0.001', + expectedOutput: '10', + memo: '', + inboundAddress: '', + integration: 'nearIntents', + status: 'failed', + confirmations: 0, + createdAt: 1, + updatedAt: 2, + estimatedTime: 30, + } satisfies PendingSwap, balances, 200) + + expect(reconciled).toEqual([]) + expect(protectedBalanceChainIds(reconciled).has('solana')).toBe(false) + }) + + test('wall-clock expiry releases protection without any balance refresh', () => { + const balances = new Map([['solana', solanaBalance('4')]]) + const pending = beginSwapBalanceReconciliation({ + txid: 'tx-offline', + fromChainId: 'bitcoin', + toChainId: 'solana', + toCaip: SOL_USDT, + fromSymbol: 'BTC', + toSymbol: 'USDT', + }, balances, 100)! + const terminal = completeSwapBalanceReconciliation(pending, { + txid: 'tx-offline', + fromAsset: 'BTC.BTC', + toAsset: 'SOL.USDT', + fromSymbol: 'BTC', + toSymbol: 'USDT', + fromChainId: 'bitcoin', + toChainId: 'solana', + toCaip: SOL_USDT, + fromAmount: '0.001', + expectedOutput: '10', + memo: '', + inboundAddress: '', + integration: 'nearIntents', + status: 'completed', + confirmations: 1, + createdAt: 1, + updatedAt: 2, + estimatedTime: 30, + } satisfies PendingSwap, balances, 200) + + expect(expireBalanceReconciliations( + [terminal], + 200 + RECONCILIATION_CEILING_MS - 1, + )[0].expired).not.toBe(true) + const expired = expireBalanceReconciliations( + [terminal], + 200 + RECONCILIATION_CEILING_MS, + ) + expect(expired[0].expired).toBe(true) + expect(protectedBalanceChainIds(expired).has('solana')).toBe(false) + }) + + test('protects a same-chain snapshot while SPL output is still missing', () => { + const before = new Map([['solana', solanaBalance()]]) + const record = beginSwapBalanceReconciliation({ + txid: 'tx1', + fromChainId: 'solana', + toChainId: 'solana', + fromCaip: 'solana:mainnet/slip44:501', + toCaip: SOL_USDT, + fromSymbol: 'SOL', + toSymbol: 'USDT', + expectedOutput: '10', + }, before, 100) + + expect(record).not.toBeNull() + const waiting = observeBalanceRefresh([record!], [solanaBalance()]) + expect(waiting).toHaveLength(1) + expect(waiting[0].observed).toBe(false) + expect(protectedBalanceChainIds(waiting).has('solana')).toBe(true) + }) + + test('does not trust a stale response even when it contains a higher amount', () => { + const before = new Map([['solana', solanaBalance('4')]]) + const record = beginSwapBalanceReconciliation({ + txid: 'tx2', + fromChainId: 'solana', + toChainId: 'solana', + toCaip: SOL_USDT, + fromSymbol: 'SOL', + toSymbol: 'USDT', + }, before)! + + const waiting = observeBalanceRefresh([record], [solanaBalance('14', 'stale')]) + expect(waiting[0].observed).toBe(false) + }) + + test('accepts a stale portfolio row when the target asset was verified directly', () => { + const before = new Map([['solana', solanaBalance('4')]]) + const record = beginSwapBalanceReconciliation({ + txid: 'tx-direct', + fromChainId: 'solana', + toChainId: 'solana', + toCaip: SOL_USDT, + fromSymbol: 'SOL', + toSymbol: 'USDT', + }, before)! + const direct = solanaBalance('14', 'stale') + direct.confirmedAssetCaips = [SOL_USDT] + + const observed = observeBalanceRefresh([record], [direct]) + expect(observed[0].observed).toBe(true) + expect(protectedBalanceChainIds(observed).has('solana')).toBe(false) + }) + + test('releases the protected chain after a confirmed token increase', () => { + const before = new Map([['solana', solanaBalance('4')]]) + const pending = beginSwapBalanceReconciliation({ + txid: 'tx3', + fromChainId: 'solana', + toChainId: 'solana', + toCaip: SOL_USDT, + fromSymbol: 'SOL', + toSymbol: 'USDT', + }, before)! + const observed = observeBalanceRefresh([pending], [solanaBalance('14')]) + + expect(observed[0].observed).toBe(true) + expect(protectedBalanceChainIds(observed).has('solana')).toBe(false) + + const completed = completeSwapBalanceReconciliation(observed[0], { + txid: 'tx3', + fromAsset: 'SOL.SOL', + toAsset: 'SOL.USDT', + fromSymbol: 'SOL', + toSymbol: 'USDT', + fromChainId: 'solana', + toChainId: 'solana', + toCaip: SOL_USDT, + fromAmount: '0.1', + expectedOutput: '10', + memo: '', + inboundAddress: '', + integration: 'nearIntents', + status: 'completed', + confirmations: 1, + createdAt: 1, + updatedAt: 2, + estimatedTime: 30, + } satisfies PendingSwap, new Map([['solana', solanaBalance('14')]]), 200) + + expect(observeBalanceRefresh([completed], [solanaBalance('14')])).toHaveLength(0) + }) + + test('reads native and token amounts independently', () => { + const balance = solanaBalance('9.25') + expect(balanceAmountForAsset(balance, SOL_USDT)).toBe(9.25) + expect(balanceAmountForAsset(balance, 'solana:mainnet/slip44:501')).toBe(2.5) + }) + + test('never replaces a trusted snapshot with stale, degraded, or protected data', () => { + const trusted = solanaBalance('10') + expect(shouldReplaceBalanceSnapshot(trusted, solanaBalance(undefined, 'stale'), false)).toBe(false) + expect(shouldReplaceBalanceSnapshot(trusted, solanaBalance(undefined, 'degraded'), false)).toBe(false) + expect(shouldReplaceBalanceSnapshot(trusted, solanaBalance('11'), true)).toBe(false) + expect(shouldReplaceBalanceSnapshot(trusted, solanaBalance('11'), false)).toBe(true) + expect(shouldReplaceBalanceSnapshot(undefined, solanaBalance(undefined, 'degraded'), false)).toBe(true) + }) + + test('merges only a directly confirmed token from an otherwise stale chain row', () => { + const trusted = solanaBalance() + trusted.tokens = [{ + symbol: 'BONK', + name: 'Bonk', + balance: '5', + balanceUsd: 2, + priceUsd: 0.4, + caip: 'solana:main/token:bonk', + }] + trusted.balanceUsd = 502 + const stale = solanaBalance('14', 'stale') + stale.balance = '0' + stale.balanceUsd = 14 + stale.confirmedAssetCaips = [SOL_USDT] + + const merged = mergeTrustedBalanceSnapshot(trusted, stale, false)! + expect(merged.balance).toBe('2.5') + expect(merged.balanceUsd).toBe(516) + expect(merged.tokens?.map(token => token.symbol)).toEqual(['BONK', 'USDT']) + }) +}) diff --git a/projects/keepkey-vault/__tests__/offline-asset-icons.test.ts b/projects/keepkey-vault/__tests__/offline-asset-icons.test.ts new file mode 100644 index 00000000..fe3166f1 --- /dev/null +++ b/projects/keepkey-vault/__tests__/offline-asset-icons.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from 'bun:test' +import { readFile, stat } from 'node:fs/promises' +import { join } from 'node:path' +import manifestJson from '../src/shared/offlineAssetIcons.json' +import { getAssetIcon, isBundledAssetIcon } from '../src/shared/assetLookup' + +const manifest = manifestJson as Record +const iconDir = join(import.meta.dir, '../src/mainview/public/assets/token-icons') +const SOL_USDT = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB' +const SOL_USDC = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' +const TRON_USDT = 'tron:0x2b6653dc/token:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t' + +describe('offline asset icon pack', () => { + test('contains a broad cross-chain set and critical swap assets', () => { + expect(Object.keys(manifest).length).toBeGreaterThanOrEqual(180) + expect(manifest[SOL_USDT]).toBeTruthy() + expect(manifest[SOL_USDC]).toBeTruthy() + expect(manifest[TRON_USDT]).toBeTruthy() + expect(isBundledAssetIcon(getAssetIcon(SOL_USDT))).toBe(true) + }) + + test('every manifest target is a bounded raster file', async () => { + for (const filename of new Set(Object.values(manifest))) { + expect(filename).toMatch(/^[a-f0-9]{24}\.(png|jpg|webp|gif)$/) + const path = join(iconDir, filename) + const fileStat = await stat(path) + expect(fileStat.size).toBeGreaterThan(0) + expect(fileStat.size).toBeLessThanOrEqual(768 * 1024) + const bytes = new Uint8Array(await readFile(path)) + const isPng = bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 + const isJpeg = bytes[0] === 0xff && bytes[1] === 0xd8 + const isGif = bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 + const isWebp = bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 + expect(isPng || isJpeg || isGif || isWebp).toBe(true) + } + }) +}) diff --git a/projects/keepkey-vault/__tests__/solana-signing.test.ts b/projects/keepkey-vault/__tests__/solana-signing.test.ts new file mode 100644 index 00000000..b98d60b7 --- /dev/null +++ b/projects/keepkey-vault/__tests__/solana-signing.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, test } from 'bun:test' +import bs58 from 'bs58' +import { requiresSolanaBlindSigningConsent } from '../src/bun/solana-consent' +import { signSolanaWireTransaction } from '../src/bun/solana-signing' +import { SolanaSignRequest } from '../src/bun/schemas' + +const SIGNER_0 = Buffer.alloc(32, 0x11) +const SIGNER_1 = Buffer.alloc(32, 0x22) +const NOT_A_SIGNER = Buffer.alloc(32, 0x33) +const ADDRESS_N = [0x8000002c, 0x800001f5, 0x80000000, 0x80000000] + +function solanaMessage(signers: Buffer[], versioned = false): Buffer { + return Buffer.concat([ + ...(versioned ? [Buffer.from([0x80])] : []), + Buffer.from([signers.length, 0, 0]), // header + Buffer.from([signers.length]), // static account count + ...signers, + Buffer.alloc(32, 0x44), // recent blockhash + Buffer.from([0]), // zero instructions + ...(versioned ? [Buffer.from([0])] : []), // zero ALT entries + ]) +} + +function wireTransaction( + signers = [SIGNER_0], + options: { versioned?: boolean; signatures?: Buffer[] } = {}, +): { rawTx: string; message: Buffer } { + const message = solanaMessage(signers, options.versioned) + const signatures = options.signatures + ?? signers.map(() => Buffer.alloc(64)) + return { + rawTx: Buffer.concat([ + Buffer.from([signers.length]), + ...signatures, + message, + ]).toString('base64'), + message, + } +} + +describe('Solana transaction signing route', () => { + test('REST schema preserves a complete descriptor but strips caller-asserted blind consent', () => { + const swapMetadata = { + payload: Buffer.from('KKSOLSW1-test').toString('base64'), + signature: Buffer.alloc(64, 1).toString('base64'), + signerKeyId: 3, + } + const parsed = SolanaSignRequest.parse({ + raw_tx: wireTransaction().rawTx, + addressNList: ADDRESS_N, + swapMetadata, + allowBlindSigning: true, + }) + expect(parsed.swapMetadata).toEqual(swapMetadata) + expect('allowBlindSigning' in parsed).toBe(false) + }) + + test('REST schema rejects partial or out-of-range descriptors', () => { + expect(() => SolanaSignRequest.parse({ + raw_tx: wireTransaction().rawTx, + swapMetadata: { payload: 'S0tTT0xTVzE=', signerKeyId: 3 }, + })).toThrow() + expect(() => SolanaSignRequest.parse({ + raw_tx: wireTransaction().rawTx, + swapMetadata: { payload: 'S0tTT0xTVzE=', signature: 'AA==', signerKeyId: 4 }, + })).toThrow() + }) + + test('opaque REST policy requires UI consent unless transaction-bound metadata is present', () => { + const systemTransfer = { + version: 'legacy', + staticAccountCount: 3, + instructions: [{ + status: 'known', + programId: '11111111111111111111111111111111', + programName: 'System Program', + instructionName: 'transfer', + args: [], + accounts: [{ pubkey: 'source' }, { pubkey: 'destination' }], + }], + altPubkeys: [], + } as const + expect(requiresSolanaBlindSigningConsent(undefined, false)).toBe(true) + expect(requiresSolanaBlindSigningConsent({ + ...systemTransfer, + instructions: [{ ...systemTransfer.instructions[0], status: 'unknown-program' }], + }, false)).toBe(true) + expect(requiresSolanaBlindSigningConsent(systemTransfer, false)).toBe(false) + expect(requiresSolanaBlindSigningConsent({ + ...systemTransfer, + instructions: [{ + ...systemTransfer.instructions[0], + instructionName: 'createAccount', + }], + }, false)).toBe(true) + expect(requiresSolanaBlindSigningConsent({ + ...systemTransfer, + instructions: [{ + ...systemTransfer.instructions[0], + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + programName: 'SPL Token', + instructionName: 'transfer', + }], + }, false)).toBe(true) + expect(requiresSolanaBlindSigningConsent({ + ...systemTransfer, + version: 'v0', + altPubkeys: ['lookup-table'], + }, false)).toBe(true) + expect(requiresSolanaBlindSigningConsent(undefined, true)).toBe(false) + }) + + test('routes v0 through SolanaSignTx and preserves internal one-shot/metadata parameters', async () => { + let deviceRequest: any + let messageCalls = 0 + const signature = Uint8Array.from({ length: 64 }, (_, i) => i) + const wallet = { + solanaSignTx: async (request: any) => { + deviceRequest = request + return { signature } + }, + solanaSignMessage: async () => { + messageCalls++ + throw new Error('v0 transaction must not use message signing') + }, + } + const swapMetadata = { + payload: Buffer.from('KKSOLSW1-test').toString('base64'), + signature: Buffer.alloc(64, 1).toString('base64'), + signerKeyId: 1, + } + const wire = wireTransaction([SIGNER_0], { versioned: true }) + const unsignedTx = { + addressNList: ADDRESS_N, + rawTx: wire.rawTx, + allowBlindSigning: true, + swapMetadata, + } + const result = await signSolanaWireTransaction( + unsignedTx, + (request) => wallet.solanaSignTx(request), + async () => bs58.encode(SIGNER_0), + ) + + expect(messageCalls).toBe(0) + expect(Buffer.from(deviceRequest.rawTx, 'base64')).toEqual(wire.message) + expect(deviceRequest.allowBlindSigning).toBe(true) + expect(deviceRequest.swapMetadata).toEqual(swapMetadata) + expect(Buffer.from(result.signature)).toEqual(Buffer.from(signature)) + expect(Buffer.from(result.serializedTx, 'base64').subarray(1, 65)).toEqual(Buffer.from(signature)) + }) + + test('routes legacy transactions through the same transaction API', async () => { + let deviceRequest: any + const wallet = { + solanaSignTx: async (request: any) => { + deviceRequest = request + return { signature: Buffer.alloc(64, 0x7f) } + }, + } + const wire = wireTransaction() + await signSolanaWireTransaction({ + addressNList: ADDRESS_N, + rawTx: wire.rawTx, + }, (request) => wallet.solanaSignTx(request), async () => bs58.encode(SIGNER_0)) + expect(Buffer.from(deviceRequest.rawTx, 'base64')).toEqual(wire.message) + }) + + test('multisig signing writes the derived wallet slot and preserves an existing cosigner', async () => { + const cosignerSignature = Buffer.alloc(64, 0xa5) + const walletSignature = Buffer.alloc(64, 0x5a) + const wire = wireTransaction([SIGNER_0, SIGNER_1], { + signatures: [cosignerSignature, Buffer.alloc(64)], + }) + + const result = await signSolanaWireTransaction({ + addressNList: ADDRESS_N, + rawTx: wire.rawTx, + }, async () => ({ signature: walletSignature }), async () => bs58.encode(SIGNER_1)) + + const signed = Buffer.from(result.serializedTx, 'base64') + expect(signed.subarray(1, 65)).toEqual(cosignerSignature) + expect(signed.subarray(65, 129)).toEqual(walletSignature) + }) + + test('refuses to sign when the derived wallet is not a required signer', async () => { + let signCalls = 0 + const wire = wireTransaction([SIGNER_0, SIGNER_1]) + await expect(signSolanaWireTransaction({ + addressNList: ADDRESS_N, + rawTx: wire.rawTx, + }, async () => { + signCalls++ + return { signature: Buffer.alloc(64) } + }, async () => bs58.encode(NOT_A_SIGNER))).rejects.toThrow('not a required transaction signer') + expect(signCalls).toBe(0) + }) +}) diff --git a/projects/keepkey-vault/__tests__/solana-token-balance.test.ts b/projects/keepkey-vault/__tests__/solana-token-balance.test.ts new file mode 100644 index 00000000..2572ed6d --- /dev/null +++ b/projects/keepkey-vault/__tests__/solana-token-balance.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from 'bun:test' +import { getSolanaTokenBalance } from '../src/bun/solana-token' + +const owner = '7RmhMArQM9E67YgYJX4vXuA5SydYV2NUWQwnRg14Q2kF' +const usdtMint = 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB' + +describe('direct Solana token balance reconciliation', () => { + test('sums every token account owned for the mint', async () => { + const calls: any[] = [] + const fetchMock = (async (_url: string | URL | Request, init?: RequestInit) => { + calls.push(JSON.parse(String(init?.body))) + return new Response(JSON.stringify({ + result: { + value: [ + { account: { data: { parsed: { info: { tokenAmount: { amount: '1000000', decimals: 6 } } } } } }, + { account: { data: { parsed: { info: { tokenAmount: { amount: '2500000', decimals: 6 } } } } } }, + ], + }, + }), { status: 200 }) + }) as typeof fetch + + const result = await getSolanaTokenBalance(owner, usdtMint, 'https://solana.invalid', fetchMock) + + expect(result).toEqual({ amount: '3.5', decimals: 6 }) + expect(calls[0].method).toBe('getTokenAccountsByOwner') + expect(calls[0].params[1]).toEqual({ mint: usdtMint }) + }) +}) diff --git a/projects/keepkey-vault/__tests__/swap-parsing.test.ts b/projects/keepkey-vault/__tests__/swap-parsing.test.ts index 4a80d1d0..d0b3aa76 100644 --- a/projects/keepkey-vault/__tests__/swap-parsing.test.ts +++ b/projects/keepkey-vault/__tests__/swap-parsing.test.ts @@ -155,6 +155,65 @@ const FIXTURE_ASSETS_FLAT = { // ── Quote parsing tests ───────────────────────────────────────────── describe('parseQuoteResponse', () => { + test('carries a complete signed Solana swap descriptor into the prebuilt transaction', () => { + const payload = Buffer.from('KKSOLSW1-test-payload').toString('base64') + const signature = Buffer.alloc(64, 0x5a).toString('base64') + const resp = { + data: [{ + integration: 'shapeshiftSwap', + quote: { + swapper: 'Relay', + buyAmount: '100', + amountOutMin: '95', + txs: [{ + txParams: { + serializedTx: Buffer.from([0, 1, 2, 3]).toString('base64'), + recipientAddress: 'TRecipient', + solanaSwapMetadata: { payload, signature, signerKeyId: 2 }, + }, + }], + }, + }], + } + const result = parseQuoteResponse(resp, { + fromCaip: 'solana:5eykt4usfv8p8njdtrepy1vzqkqzkvdp/slip44:501', + toCaip: 'tron:0x2b6653dc/slip44:195', + slippageBps: 500, + }) + expect(result.relayTx?.solanaSwapMetadata).toEqual({ + payload, + signature, + signerKeyId: 2, + }) + }) + + test('rejects partial Solana ClearSign metadata instead of downgrading to blind signing', () => { + const resp = { + data: [{ + integration: 'shapeshiftSwap', + quote: { + swapper: 'Relay', + buyAmount: '100', + txs: [{ + txParams: { + serializedTx: Buffer.from([0, 1, 2, 3]).toString('base64'), + recipientAddress: 'TRecipient', + solanaSwapMetadata: { + payload: Buffer.from('KKSOLSW1-test').toString('base64'), + signerKeyId: 0, + }, + }, + }], + }, + }], + } + expect(() => parseQuoteResponse(resp, { + fromCaip: 'solana:5eykt4usfv8p8njdtrepy1vzqkqzkvdp/slip44:501', + toCaip: 'tron:0x2b6653dc/slip44:195', + slippageBps: 500, + })).toThrow('missing signature') + }) + // CAIP-only — Pioneer's Quote endpoint is the source of truth for routing. const baseParams = { fromCaip: 'eip155:8453/slip44:60', toCaip: 'eip155:1/slip44:60', slippageBps: 300 } diff --git a/projects/keepkey-vault/docs/handoff-rest-sign-ui-gating-audit.md b/projects/keepkey-vault/docs/handoff-rest-sign-ui-gating-audit.md new file mode 100644 index 00000000..97fca6fc --- /dev/null +++ b/projects/keepkey-vault/docs/handoff-rest-sign-ui-gating-audit.md @@ -0,0 +1,201 @@ +# Handoff — Audit every REST sign endpoint for UI gating + +**Captured:** 2026-06-19 +**Baseline:** `develop` @ `0fbd2062` (PR #264 merged). NOTE: PR #266 +(`fix/swap-execute-requires-review`) is in flight and already addresses the +two swap-path items called out below — audit against develop, but check whether +#266 has merged before re-deriving the swap findings. +**Repo:** `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-vault-v11/projects/keepkey-vault` + +--- + +## Goal / the invariant being audited + +**No REST caller may cause the device to sign anything without the user being +able to see what they're signing and physically approve it.** "See" means one +of two real review surfaces: + +- **Vault overlay** — vault renders the decoded request (amounts, to-address, + typed data, message text) in its own window and the user approves there + *before* the device is touched. +- **Device screen** — the firmware renders the actual content on the KeepKey + OLED (`showDisplay`/policy) and the user approves with the physical button. + +A signing endpoint that, under **default or adversarial inputs**, reaches the +device with neither surface showing the real content is a **blind-sign hole**. +The job is to classify every sign endpoint and prove each one always hits at +least one surface — or fix the ones that don't. + +This was prompted by the swap epic: `/api/v2/swap/execute` was added as a +headless (no-vault-GUI) device sign, which is uniquely bad for swaps because the +device can only render "send X to ``" — it cannot convey that `` is +a router/inbound vault and the intent is in an opaque memo. The same lens now +needs to sweep **all** sign endpoints. + +--- + +## The three gating mechanisms in the codebase (know these before auditing) + +1. **Central `SIGNING_ROUTES` gate** — `src/bun/rest-api.ts:1038` (the set) + + `:1481` (the gate). Any POST whose path is in `SIGNING_ROUTES` is intercepted + *before* its handler: empty-body probe rejection + (`requiredSigningFields`, `:1072`), then `callbacks.onSigningRequest(info)` + (`index.ts:1074` → `rpc.send['signing-request']` → vault overlay → + `auth.requestSigningApproval(id)` blocks for the user's decision). Preview + decoders that populate the overlay live at `:1530-1640` (EIP-712, personal_sign, + TON/TRON tx, Solana message/tx). **This is the vault-overlay surface.** + +2. **Inline per-handler approval** — a handler calls `onSigningRequest` itself + instead of relying on the set. Example: `/api/v2/sweep/execute` + (`src/bun/rest-sweep.ts:~129`, the "Signing approval gate" block) builds a + `SigningRequestInfo` and awaits approval before `btcSignTx`. Also the swap + dialog path. **Same vault-overlay surface, reached a different way** — so + "not in `SIGNING_ROUTES`" does NOT by itself mean "ungated"; grep the handler. + +3. **Device `showDisplay` / firmware policy** — message-signing handlers pass + `showDisplay: body.show_display` to `wallet.*SignMessage(...)`. When true (or + when firmware policy forces it), the KeepKey renders the message on its OLED. + **This is the device-screen surface.** Its sufficiency hinges on the + `show_display` default and the firmware's behavior when it's omitted — see + the open questions. + +A given endpoint may rely on (1), (2), (3), or a combination. The audit must +record which, and prove it can't be bypassed. + +--- + +## Full sign-endpoint inventory (classify + verify each) + +All routes require `auth.requireAuth` (bearer). Handlers in `rest-api.ts` +unless noted. "In set" = present in `SIGNING_ROUTES`. + +### A. Transaction signing — covered by the central set (mechanism 1) +Verify: each is in `SIGNING_ROUTES`, has a `requiredSigningFields` entry, and +the overlay preview decoder renders meaningful detail (not just raw JSON). + +| Route | Handler | In set | Preview decoder | +|---|---|---|---| +| `/eth/sign-transaction` | `:1953` | ✅ | generic body + to/value | +| `/eth/sign-typed-data` | `:2040` | ✅ | `decodeEIP712` `:1537` | +| `/eth/sign` (personal_sign) | `:2066` | ✅ | hex→text `:1539-1571` | +| `/utxo/sign-transaction` | `:2090` | ✅ | inputs/outputs | +| `/xrp/sign-transaction` | `:2244` | ✅ | — (verify) | +| `/solana/sign-transaction` | `:2253` | ✅ | `:1611` | +| `/solana/sign-message` | `:2320` | ✅ | `:1578` signer-derive check | +| `/tron/sign-transaction` | `:2342` | ✅ | to/value `:1576` | +| `/ton/sign-transaction` | `:2361` | ✅ | to/value `:1572` | +| `/cosmos/sign-amino*` (6) | `:2122-2158` | ✅ | shared amino preview | +| `/osmosis/sign-amino*` (9) | `:2160-2214` | ✅ | shared amino preview | +| `/thorchain/sign-amino-{transfer,deposit}` | `:2216-2228` | ✅ | shared | +| `/mayachain/sign-amino-{transfer,deposit}` | `:2230-2242` | ✅ | shared | + +### B. Message / off-chain signing — NOT in the central set; rely on device screen (mechanism 3) +These were a false-positive in an earlier review ("they bypass the gate") — they +*do* surface review **via the device screen** (`showDisplay`), which is adequate +for message signing IF the device actually renders. **Confirm that assumption.** + +| Route | Handler | In set | Surface | Must verify | +|---|---|---|---|---| +| `/tron/sign-message` (TIP-191) | `:2379` | ❌ | device `show_display` | default when omitted | +| `/tron/sign-typed-hash` (TIP-712) | `:2413` | ❌ | device (hash mode) | device shows what for a bare hash? | +| `/ton/sign-message` | `:2435` | ❌ | device `show_display` + AdvancedMode policy | default when omitted | +| `/solana/sign-offchain-message` | `:2453` | ❌ | device `show_display` | default when omitted | + +`show_display` is `z.boolean().optional()` (`schemas.ts:33,175,209,228`) — i.e. +it can be **omitted**, and the handler forwards `undefined`. **Open question +O1** below. + +### C. Sweep — inline approval (mechanism 2) +| Route | Handler | Gate | +|---|---|---| +| `/api/v2/sweep/execute` | `rest-sweep.ts:88` | inline `onSigningRequest` before `btcSignTx` — verify the `SigningRequestInfo` carries real outputs (to-address + amount), not an empty stub | + +### D. Swap — remote-control + headless (mechanisms 2/none) +On **develop** these are the two known gaps; PR #266 fixes them — confirm state: +| Route | Handler | Develop behavior | PR #266 | +|---|---|---|---| +| `/api/v2/swap/execute` | `rest-swap.ts:73` → `headlessExecuteSwap` (`index.ts`) | **headless device sign, no vault review** | drives SwapDialog review, blocks on on-screen approval | +| `/api/v2/swap/confirm` | `rest-swap.ts:118` → `swap-cmd:'confirm'` → `SwapDialog` `handleExecuteSwap()` | **programmatic "click Approve"** — signs with no human gesture | route 403'd + dialog ignores programmatic confirm | +| `/api/v2/swap/{open,set,advance,requote,close}` | `rest-swap.ts` | navigation only — **verify none can trigger a sign** | unchanged | + +### E. Broadcast-only (no signing — confirm they never sign) +| Route | Handler | Note | +|---|---|---| +| `/api/v2/tx/broadcast` | `rest-pioneer.ts:91` | broadcasts a caller-supplied pre-signed `serialized` tx — no device sign. Confirm it cannot be coerced into signing. | +| `/api/zcash/shielded/broadcast` | `rest-api.ts:3640` | broadcast of pre-built tx — confirm the **build/sign** half (`buildShieldedTx`/`finalizeShieldedTx`, wherever it signs) is itself gated. | + +--- + +## Open questions to resolve (the crux of the audit) + +- **O1 — `show_display` default + firmware semantics.** For B's four routes: + when `show_display` is omitted/`false`, does the firmware still render the + message and require a button press, or does it sign silently? If silent, these + are blind-sign holes and need either (a) force `showDisplay: true` server-side, + or (b) addition to `SIGNING_ROUTES` + `requiredSigningFields` + a preview + decoder (message text / typed-hash). Test on a real device both ways. +- **O2 — typed-hash on device (`/tron/sign-typed-hash`).** Hash mode sends only + `domain_separator_hash` + `message_hash`. Even if the device displays, a raw + 32-byte hash is not human-meaningful. Decide whether vault must decode/echo the + structured data (like `/eth/sign-typed-data`) for real consent. +- **O3 — preview completeness for set-gated routes (A).** For each, confirm the + overlay shows decoded detail, not `rawRequestBody` JSON only. XRP and the + amino family are the ones to eyeball. +- **O4 — empty-probe parity.** Every route in `SIGNING_ROUTES` must have a + `requiredSigningFields` entry (`:1072`) or empty probes fall through to the + handler's schema (one layer deeper, still rejected, but no early gate). Diff + the set against the function and list any missing. +- **O5 — sweep `SigningRequestInfo` fidelity (C).** Confirm the inline gate + passes the real destination + amount so the user approves a meaningful request. + +--- + +## Method (how to audit each endpoint) + +For every row above, with a KeepKey connected and `KEEPKEY_REST_API=true`: +1. **Happy path:** POST a minimal real body with a bearer token. Observe whether + (a) a vault overlay appears AND/OR (b) the device screen shows the content. + Record which surface(s) fired. +2. **Adversarial — omit display:** for B, omit `show_display` (and try `false`). + Confirm a review surface still fires. If none → **blind-sign hole**. +3. **Adversarial — empty/probe body (`{}`):** confirm 400 before any device + interaction (probe gating). +4. **Adversarial — swap bypass:** drive `/open`+`/advance` then `/confirm` (and + `/execute`) and confirm no sign happens without an on-screen human click + (post-#266). +5. Record auth: every route 401s without a bearer. + +Produce a table: route → surface(s) that fire on happy path → behavior under +each adversarial case → verdict (gated / **hole**) → fix. + +--- + +## Definition of done + +- A filled-in verdict table for **every** route in the inventory. +- Every endpoint proven to hit a review surface under default AND adversarial + inputs, OR a filed fix (force `showDisplay`, add to `SIGNING_ROUTES` + + `requiredSigningFields` + preview decoder, or remove the route). +- O1–O5 answered with on-device evidence (not assumed from code). +- A short note on whether the device-screen surface is considered sufficient + *policy* for message signing, or whether vault should mirror it in the overlay + for consistency. + +--- + +## File index (absolute) + +- `…/src/bun/rest-api.ts` — `SIGNING_ROUTES:1038`, `requiredSigningFields:1072`, + central gate `:1481`, overlay preview decoders `:1530-1640`, tx-sign handlers + `:1953-2370`, message-sign handlers `:2379-2475`, zcash broadcast `:3640` +- `…/src/bun/rest-swap.ts` — swap routes incl. `/execute:73`, `/confirm:118` +- `…/src/bun/rest-sweep.ts` — `/sweep/execute:88` + inline approval gate +- `…/src/bun/rest-pioneer.ts` — `/tx/broadcast:91` +- `…/src/bun/index.ts` — `onSigningRequest:1074` (overlay bridge), swap dialog + drive/await (`headlessExecuteSwap`, `awaitSwapUiState`) +- `…/src/bun/auth.ts` — `requestSigningApproval` (the blocking approval promise) +- `…/src/bun/schemas.ts` — `show_display` defs (`:33,175,209,228`) +- `…/src/shared/types.ts` — `SigningRequestInfo:506` +- `…/src/mainview/components/SwapDialog.tsx` — `swap-cmd` handler (`confirm`/`advance`) +- Signing-approval overlay component (frontend) — listens for `signing-request`; + locate via `grep -rn "signing-request" src/mainview` diff --git a/projects/keepkey-vault/package.json b/projects/keepkey-vault/package.json index 9ecea0c8..3f7f253f 100644 --- a/projects/keepkey-vault/package.json +++ b/projects/keepkey-vault/package.json @@ -10,6 +10,7 @@ "build": "bun scripts/bundle-backend.ts && vite build && bun scripts/collect-externals.ts && electrobun build && bun scripts/patch-bundle.ts", "build:stable": "bun scripts/bundle-backend.ts && vite build && bun scripts/collect-externals.ts && bun scripts/build-signed.ts stable", "build:canary": "bun scripts/bundle-backend.ts && vite build && bun scripts/collect-externals.ts && bun scripts/build-signed.ts canary", + "assets:vendor-icons": "bun scripts/vendor-asset-icons.ts", "start": "bun run dev", "postinstall": "bash scripts/patch-electrobun.sh" }, diff --git a/projects/keepkey-vault/scripts/vendor-asset-icons.ts b/projects/keepkey-vault/scripts/vendor-asset-icons.ts new file mode 100644 index 00000000..9afb71b7 --- /dev/null +++ b/projects/keepkey-vault/scripts/vendor-asset-icons.ts @@ -0,0 +1,207 @@ +/** + * Build the offline token-icon pack. + * + * Selection is CAIP-first: all supported native assets plus every chain + * variant of a curated CoinGecko asset set. Images are downloaded as bounded + * raster files, content-hashed for deduplication, and mapped back to exact + * CAIP-19 ids in a small runtime manifest. + */ +import { createHash } from 'node:crypto' +import { mkdir, readFile, readdir, stat, unlink, writeFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' + +interface DiscoveryAsset { + assetId?: string + icon?: string + isNative?: boolean + symbol?: string +} + +const projectRoot = resolve(import.meta.dir, '..') +const discoveryRoot = join(projectRoot, 'node_modules/@pioneer-platform/pioneer-discovery/lib') +const catalogPath = join(discoveryRoot, 'assets-top500.json') +const mappingPath = join(discoveryRoot, 'coingecko-mapping.json') +const outputDir = join(projectRoot, 'src/mainview/public/assets/token-icons') +const manifestPath = join(projectRoot, 'src/shared/offlineAssetIcons.json') + +// Broad enough to cover the assets users commonly hold or swap, while +// avoiding thousands of obscure/spam-token images in every desktop binary. +const CORE_COINGECKO_IDS = new Set([ + '0x', '1inch', 'aave', 'akash-network', 'algorand', 'ankr', 'apecoin', + 'arbitrum', 'arweave', 'avalanche-2', 'axelar', 'balancer', + 'basic-attention-token', 'bitcoin', 'bitcoin-cash', 'bitcoin-sv', + 'binance-usd', 'binancecoin', 'bittensor', 'blur', 'bonk', 'cardano', + 'celo', 'chainlink', 'compound-governance-token', 'convex-finance', + 'cosmos', 'crv', 'curve-dao-token', 'dai', 'dash', 'decentraland', + 'digibyte', 'dogecoin', 'dogwifcoin', 'dydx-chain', 'echelon-prime', + 'enjincoin', 'ens', 'ethereum', 'ethereum-name-service', + 'ethena', 'ethena-staked-usde', 'ethena-usde', 'ether-fi', + 'ether-fi-staked-eth', 'fetch-ai', 'filecoin', 'first-digital-usd', + 'frax', 'frax-ether', 'gala', 'gemini-dollar', 'gho', 'hedera-hashgraph', + 'hive', 'immutable-x', 'injective-protocol', 'internet-computer', + 'jito-governance-token', 'jito-staked-sol', 'jupiter-exchange-solana', + 'kaspa', 'lido-dao', 'lido-staked-ether', 'litecoin', 'liquity-usd', + 'maker', 'mantle', 'marinade-staked-sol', 'matic-network', 'monero', + 'near', 'near-protocol', 'ondo-finance', 'optimism', 'orca', + 'osmosis', 'pancakeswap-token', 'pax-gold', 'paxos-standard', + 'paypal-usd', 'pepe', 'pendle', 'polkadot', 'polygon-ecosystem-token', + 'pyth-network', 'quant-network', 'raydium', 'render-token', 'ripple', + 'rocket-pool-eth', 'shiba-inu', 'sky', 'solana', 'stacks', + 'stellar', 'stepn', 'staked-frax-ether', 'sui', 'sushi', + 'synthetix-network-token', 'tether', 'tether-gold', 'tezos', + 'the-graph', 'the-sandbox', 'thorchain', 'toncoin', 'tron', 'true-usd', + 'uniswap', 'usd-coin', 'usdd', 'usds', 'vechain', 'woo-network', + 'worldcoin-wld', 'wormhole', 'wrapped-bitcoin', 'wrapped-eeth', + 'wrapped-solana', 'wrapped-steth', 'yearn-finance', 'zcash', +]) + +// Canonical assets whose catalog mapping has historically changed names. +const ALWAYS_INCLUDE_CAIPS = new Set([ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + 'tron:0x2b6653dc/token:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', +]) + +const MIME_EXTENSIONS: Record = { + 'image/png': '.png', + 'image/jpeg': '.jpg', + 'image/webp': '.webp', + 'image/gif': '.gif', +} +const MAX_ICON_BYTES = 768 * 1024 +const CONCURRENCY = 8 + +async function downloadIcon(url: string): Promise<{ bytes: Uint8Array; extension: string }> { + let lastError: unknown + for (let attempt = 0; attempt < 2; attempt++) { + try { + const response = await fetch(url, { + headers: { 'User-Agent': 'KeepKey-Vault-Asset-Vendor/1.0' }, + signal: AbortSignal.timeout(15_000), + }) + if (!response.ok) throw new Error(`HTTP ${response.status}`) + const mime = (response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase() + const extension = MIME_EXTENSIONS[mime] + if (!extension) throw new Error(`unsupported content type ${mime || 'unknown'}`) + const declaredSize = Number(response.headers.get('content-length') || 0) + if (declaredSize > MAX_ICON_BYTES) throw new Error(`file is ${declaredSize} bytes`) + const bytes = new Uint8Array(await response.arrayBuffer()) + if (bytes.length === 0 || bytes.length > MAX_ICON_BYTES) { + throw new Error(`file is ${bytes.length} bytes`) + } + return { bytes, extension } + } catch (error) { + lastError = error + } + } + throw lastError +} + +const catalog = JSON.parse(await readFile(catalogPath, 'utf8')) as Record +const coingeckoMapping = JSON.parse(await readFile(mappingPath, 'utf8')) as Record +const fallbackIconByCoingeckoId = new Map() +for (const [key, asset] of Object.entries(catalog)) { + const caip = asset.assetId || key + const coingeckoId = coingeckoMapping[caip] + if (coingeckoId && asset.icon && !fallbackIconByCoingeckoId.has(coingeckoId)) { + fallbackIconByCoingeckoId.set(coingeckoId, asset.icon) + } +} +const selected = Object.entries(catalog) + .filter(([key, asset]) => { + const caip = asset.assetId || key + const coingeckoId = coingeckoMapping[caip] + return !!(asset.icon || (coingeckoId && fallbackIconByCoingeckoId.has(coingeckoId))) && ( + asset.isNative === true + || !caip.startsWith('eip155:') + || ALWAYS_INCLUDE_CAIPS.has(caip) + || CORE_COINGECKO_IDS.has(coingeckoId) + ) + }) + .map(([key, asset]) => { + const caip = asset.assetId || key + return { + caip, + url: asset.icon || fallbackIconByCoingeckoId.get(coingeckoMapping[caip])!, + } + }) + +for (const caip of ALWAYS_INCLUDE_CAIPS) { + if (selected.some(item => item.caip === caip)) continue + const fallbackUrl = fallbackIconByCoingeckoId.get(coingeckoMapping[caip]) + if (fallbackUrl) selected.push({ caip, url: fallbackUrl }) +} +selected.sort((a, b) => a.caip.localeCompare(b.caip)) + +await mkdir(outputDir, { recursive: true }) +await mkdir(dirname(manifestPath), { recursive: true }) +const previousManifest = await readFile(manifestPath, 'utf8') + .then(value => JSON.parse(value) as Record) + .catch(() => ({})) + +const urlPromises = new Map>() +const failures: Array<{ caip: string; reason: string }> = [] +let priorFilesRetained = 0 +let cursor = 0 + +async function storeUrl(url: string): Promise { + const existing = urlPromises.get(url) + if (existing) return existing + const promise = (async () => { + const { bytes, extension } = await downloadIcon(url) + const hash = createHash('sha256').update(bytes).digest('hex').slice(0, 24) + const filename = `${hash}${extension}` + await writeFile(join(outputDir, filename), bytes) + return filename + })() + urlPromises.set(url, promise) + return promise +} + +const manifestEntries: Array<[string, string]> = [] +async function worker(): Promise { + while (true) { + const index = cursor++ + if (index >= selected.length) return + const item = selected[index] + try { + manifestEntries.push([item.caip, await storeUrl(item.url)]) + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + const priorFilename = previousManifest[item.caip] + const priorExists = priorFilename + ? await stat(join(outputDir, priorFilename)).then(value => value.isFile()).catch(() => false) + : false + if (priorFilename && priorExists) { + manifestEntries.push([item.caip, priorFilename]) + priorFilesRetained++ + } + failures.push({ caip: item.caip, reason: `${reason}${priorExists ? ' (kept prior file)' : ''}` }) + } + } +} +await Promise.all(Array.from({ length: CONCURRENCY }, () => worker())) + +manifestEntries.sort(([a], [b]) => a.localeCompare(b)) +const manifest = Object.fromEntries(manifestEntries) +const uniqueFiles = new Set(Object.values(manifest)) +const criticalMissing = [...ALWAYS_INCLUDE_CAIPS].filter(caip => !manifest[caip]) +// Transactional guard: a network outage must never replace a working offline +// pack with an empty manifest or prune its files. +if (criticalMissing.length) { + throw new Error(`Critical offline icons missing: ${criticalMissing.join(', ')}`) +} + +await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8') +let staleFilesRemoved = 0 +for (const filename of await readdir(outputDir)) { + if (!/^[a-f0-9]{24}\.(?:png|jpg|webp|gif)$/.test(filename)) continue + if (uniqueFiles.has(filename)) continue + await unlink(join(outputDir, filename)) + staleFilesRemoved++ +} +console.log(`[vendor-asset-icons] ${Object.keys(manifest).length}/${selected.length} CAIPs, ${uniqueFiles.size} deduplicated files${priorFilesRetained ? `, ${priorFilesRetained} prior files retained` : ''}${staleFilesRemoved ? `, ${staleFilesRemoved} stale files removed` : ''}`) +if (failures.length) { + console.warn(`[vendor-asset-icons] ${failures.length} downloads skipped`) + for (const failure of failures.slice(0, 12)) console.warn(` ${failure.caip}: ${failure.reason}`) +} diff --git a/projects/keepkey-vault/src/bun/emulator-window-layout.test.ts b/projects/keepkey-vault/src/bun/emulator-window-layout.test.ts new file mode 100644 index 00000000..f1711b82 --- /dev/null +++ b/projects/keepkey-vault/src/bun/emulator-window-layout.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'bun:test' +import { readFileSync } from 'node:fs' + +describe('emulator window layout', () => { + // Keep this test independent from the Electrobun runtime. Importing the + // window module starts native services, while this regression only needs to + // guard the document structure and CSS that caused the controls to drift. + const html = readFileSync(new URL('./emulator-window.ts', import.meta.url), 'utf8') + + test('keeps the OLED, metadata, and buttons in document order', () => { + const oled = html.indexOf('id="displayArea"') + const metadata = html.indexOf('id="confirmMeta"') + const buttons = html.indexOf('id="buttons"') + + expect(oled).toBeGreaterThan(-1) + expect(metadata).toBeGreaterThan(oled) + expect(buttons).toBeGreaterThan(metadata) + }) + + test('does not stretch the OLED area to consume window height', () => { + const displayRule = html.match(/\.display-area\s*\{([^}]*)\}/)?.[1] ?? '' + + expect(displayRule).toContain('flex: 0 0 auto') + expect(displayRule).toContain('justify-content: flex-start') + expect(displayRule).not.toMatch(/flex:\s*1(?:;|\s)/) + }) + + test('keeps controls responsive and scrollable in a short or narrow window', () => { + const bodyRule = html.match(/body\s*\{([^}]*)\}/)?.[1] ?? '' + const oledRule = html.match(/\.oled\s*\{([^}]*)\}/)?.[1] ?? '' + const metadataRule = html.match(/\.confirm-meta\s*\{([^}]*)\}/)?.[1] ?? '' + const buttonsRule = html.match(/\.buttons\s*\{([^}]*)\}/)?.[1] ?? '' + + expect(bodyRule).toContain('overflow-y: auto') + expect(oledRule).toContain('width: min(320px, calc(100vw - 24px))') + expect(oledRule).toContain('aspect-ratio: 4 / 1') + expect(metadataRule).toContain('width: min(320px, calc(100vw - 24px))') + expect(buttonsRule).toContain('width: min(320px, calc(100vw - 24px))') + }) +}) diff --git a/projects/keepkey-vault/src/bun/emulator-window.ts b/projects/keepkey-vault/src/bun/emulator-window.ts index 6c94f452..db58beeb 100644 --- a/projects/keepkey-vault/src/bun/emulator-window.ts +++ b/projects/keepkey-vault/src/bun/emulator-window.ts @@ -648,7 +648,8 @@ function buildEmulatorHTML(bridgePort: number): string { color: #e0e0e0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; user-select: none; - overflow: hidden; + overflow-x: hidden; + overflow-y: auto; display: flex; flex-direction: column; height: 100vh; @@ -679,19 +680,21 @@ function buildEmulatorHTML(bridgePort: number): string { text-transform: uppercase; } .display-area { - flex: 1; + flex: 0 0 auto; display: flex; flex-direction: column; align-items: center; - justify-content: center; - padding: 12px; + justify-content: flex-start; + padding: 12px 12px 4px; } .oled { background: #000; border: 2px solid #333; border-radius: 4px; - width: 320px; - height: 80px; + width: min(320px, calc(100vw - 24px)); + height: auto; + aspect-ratio: 4 / 1; + flex: 0 0 auto; position: relative; overflow: hidden; } @@ -719,7 +722,9 @@ function buildEmulatorHTML(bridgePort: number): string { .idle-text { color: #666; font-size: 12px; } .confirm-meta { display: none; - padding: 8px 14px 4px; + width: min(320px, calc(100vw - 24px)); + margin: 0 auto; + padding: 6px 0 2px; font-family: 'Courier New', monospace; font-size: 11px; line-height: 1.5; @@ -730,7 +735,15 @@ function buildEmulatorHTML(bridgePort: number): string { .confirm-meta .op-label { color: #4fc3f7; font-weight: bold; font-size: 13px; margin-bottom: 4px; } .confirm-meta .detail { color: #ccc; font-size: 11px; } .confirm-meta .addr { color: #81c784; font-size: 11px; word-break: break-all; } - .buttons { display: none; padding: 10px 16px 14px; gap: 12px; justify-content: center; } + .buttons { + display: none; + width: min(320px, calc(100vw - 24px)); + margin: 0 auto; + padding: 6px 0 12px; + gap: 12px; + justify-content: center; + flex-wrap: wrap; + } .buttons.visible { display: flex; } .btn { padding: 8px 24px; border: none; border-radius: 6px; diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index 925ada6c..f4b4f88b 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -108,7 +108,7 @@ process.on('unhandledRejection', (reason) => { import { EngineController, withTimeout } from "./engine-controller" import { runUsbDiagnostic as runUsbDiagnosticProbe } from "./windows-usb-probe" import { startRestApi, clearFeaturesCache, setUiActive, uiHeartbeat, type RestApiCallbacks } from "./rest-api" -import { parseSolanaTx, SolanaTxParseError, solanaMessageSlice } from "./solana-tx" +import { signSolanaWireTransaction } from "./solana-signing" import { AuthStore } from "./auth" import { getPioneer, getPioneerApiBase, resetPioneer, DEFAULT_API_BASE, getQueryKey as getPioneerQueryKey } from "./pioneer" import { setBtcBackendOffline, setBtcNodeConfig, setBtcNodeDeviceEligible, isBtcNodeActive, getBtcBackend, broadcastBtcTx } from "./btc-backend" @@ -728,6 +728,16 @@ function getRpcUrl(chain: ChainDef): string | undefined { // ── REST API Server (on by default, can be disabled in Settings) ─────── const auth = new AuthStore() +// The REST caller cannot assert opaque-signing permission. The internal UI +// approval RPC records the one request for which the user clicked "Allow once"; +// the awaiting REST callback consumes and deletes it immediately. +const blindSigningApprovalIds = new Set() +async function requestSigningApprovalDecision(id: string) { + const approved = await auth.requestSigningApproval(id) + const allowBlindSigning = approved && blindSigningApprovalIds.has(id) + blindSigningApprovalIds.delete(id) + return { approved, allowBlindSigning: allowBlindSigning || undefined } +} // Settings loaded lazily after DB init — defaults used until then let restApiEnabled = false let walletConnectEnabled = false @@ -1022,58 +1032,29 @@ function getOrCreateWcManager(): WalletConnectManager { }, solanaSignTransactionRaw: async ({ addressNList, signerAddress, transactionBase64 }) => { if (!engine.wallet) throw new Error('Device disconnected') - const { parseSolanaTx, solanaMessageSlice, parseSolanaMessage } = await import('./solana-tx') - const bs58 = (await import('bs58')).default - const fullTx = Buffer.from(transactionBase64, 'base64') - const parsed = parseSolanaTx(fullTx) - const messageBytes = solanaMessageSlice(fullTx, parsed) - - // Find which signer slot belongs to our account. Required signers are - // the first `numRequiredSignatures` entries of `staticAccounts`. If our - // pubkey isn't among them, this tx isn't ours to sign and writing to - // any slot would produce an invalid signed transaction. - const message = parseSolanaMessage(messageBytes) - const ourPubkey = bs58.decode(signerAddress) - if (ourPubkey.length !== 32) { - throw new Error(`Invalid signer address: bs58-decoded length ${ourPubkey.length} (expected 32)`) - } - let signerIdx = -1 - for (let i = 0; i < message.header.numRequiredSignatures; i++) { - const acct = message.staticAccounts[i] - if (acct && acct.length === ourPubkey.length && Buffer.from(acct).equals(Buffer.from(ourPubkey))) { - signerIdx = i - break - } - } - if (signerIdx < 0) { - throw new Error(`Wallet account ${signerAddress} is not a required signer for this transaction`) - } - - let sigBytes: Uint8Array - if (parsed.isVersioned) { - const msgRes = await engine.wallet.solanaSignMessage({ addressNList, message: messageBytes, showDisplay: true }) - const sig = msgRes?.signature - if (!sig) throw new Error('Device returned no signature for v0 tx') - sigBytes = sig instanceof Uint8Array ? sig : Buffer.from(sig, 'base64') - } else { - const result = await engine.wallet.solanaSignTx({ - addressNList, - rawTx: Buffer.from(fullTx.subarray(parsed.messageStart)).toString('base64'), - }) - if (!result?.signature) throw new Error('Device returned no signature for legacy tx') - sigBytes = result.signature instanceof Uint8Array ? result.signature : Buffer.from(result.signature, 'base64') - } - if (sigBytes.length !== 64) throw new Error(`Unexpected signature length ${sigBytes.length}`) - - const slotOffset = parsed.sigStart + signerIdx * 64 - if (fullTx.length < slotOffset + 64) { - throw new Error('Raw tx too short to hold our signer slot') + const result = await signSolanaWireTransaction( + { addressNList, rawTx: transactionBase64 }, + (deviceParams) => engine.wallet!.solanaSignTx(deviceParams), + async (signerPath) => { + const derived = await engine.wallet!.solanaGetAddress({ + addressNList: signerPath, + showDisplay: false, + }) + const address = typeof derived === 'string' ? derived : derived?.address + if (!address) throw new Error('Device returned no Solana signer address') + if (address !== signerAddress) { + throw new Error(`Derived Solana signer ${address} does not match wallet account ${signerAddress}`) + } + return address + }, + 'walletconnect:solanaSignTransaction', + ) + if (!result?.signature || !result.serializedTx) { + throw new Error('Device returned no Solana transaction signature') } - const out = Buffer.from(fullTx) - for (let i = 0; i < 64; i++) out[slotOffset + i] = sigBytes[i] return { - transactionBase64: out.toString('base64'), - signatureBase64: Buffer.from(sigBytes).toString('base64'), + transactionBase64: result.serializedTx, + signatureBase64: Buffer.from(result.signature).toString('base64'), } }, requestSigningApproval: async (info) => { @@ -1081,7 +1062,7 @@ function getOrCreateWcManager(): WalletConnectManager { try { rpc.send['signing-request'](info) } catch { /* webview not ready */ } acquireWindowFocus() try { - return await auth.requestSigningApproval(info.id) + return (await requestSigningApprovalDecision(info.id)).approved } finally { releaseWindowFocus() } @@ -1344,7 +1325,7 @@ const restCallbacks: RestApiCallbacks = { try { rpc.send['signing-request'](info) } catch { /* webview not ready */ } acquireWindowFocus() try { - return await auth.requestSigningApproval(info.id) + return await requestSigningApprovalDecision(info.id) } finally { releaseWindowFocus() } @@ -1879,6 +1860,15 @@ async function headlessExecuteSwap(params: ExecuteSwapParams, pushSubStage: (sta throw new Error(`TCY / RUJI swaps require KeepKey firmware ${THORCHAIN_BANK_TOKEN_MIN_FW}+ (device has ${fw || 'unknown'}). Update your firmware.`) } } + if (params.fromCaip?.startsWith('solana:') && params.relayTx?.serializedTx) { + const fw = engine.getDeviceState().firmwareVersion + if (!fw || versionCompare(fw, '7.15.0') < 0) { + throw new Error( + `Versioned Solana swaps require KeepKey firmware 7.15.0+ ` + + `(device has ${fw || 'unknown'}). Update your firmware before signing.`, + ) + } + } const { executeSwap } = await import('./swap') const { trackSwap, isTrackerInitialized, initSwapTracker } = await import('./swap-tracker') @@ -2571,68 +2561,28 @@ const rpc = BrowserView.defineRPC({ console.debug(`[solanaSignTx] RPC call received`) - // Pioneer returns full serialized tx: [compact-u16:sigCount][sig0(64)]...[sigN(64)][message] - // See solana-tx.ts for the wire-format contract + malformed-input rejection rules. if (!params.rawTx) { throw new Error('[solanaSignTx] rawTx is required') } - const fullTx = Buffer.from( - typeof params.rawTx === 'string' ? params.rawTx : Buffer.from(params.rawTx).toString('base64'), - 'base64', + return signSolanaWireTransaction( + params, + (deviceParams) => engine.isEmulator + ? emuSigningOp( + () => engine.wallet!.solanaSignTx(deviceParams), + { operation: 'solanaSignTx', chain: 'Solana' }, + ) + : engine.wallet!.solanaSignTx(deviceParams), + async (addressNList) => { + const derived = await engine.wallet!.solanaGetAddress({ + addressNList, + showDisplay: false, + }) + const address = typeof derived === 'string' ? derived : derived?.address + if (!address) throw new Error('Device returned no Solana signer address') + return address + }, + 'solanaSignTx', ) - let parsed - try { - parsed = parseSolanaTx(fullTx) - } catch (err) { - if (err instanceof SolanaTxParseError) throw new Error(`[solanaSignTx] ${err.message}`) - throw err - } - - // KeepKey firmware message type 752 (SolanaSignTx) parses legacy - // messages only. Versioned (v0) messages are signed via type - // 754 (SolanaSignMessage) over the exact message bytes — the - // 0x80 prefix and v0 payload are preserved, producing an - // Ed25519 signature valid for the original v0 transaction. - // The device shows a generic "sign message" prompt; users - // review the parsed tx in the Vault approval dialog. - let sigBytes: Uint8Array - if (parsed.isVersioned) { - const messageBytes = solanaMessageSlice(fullTx, parsed) - console.debug(`[solanaSignTx] v0 tx detected — routing through solanaSignMessage (${messageBytes.length}B message incl. 0x80 prefix)`) - const msgRes = engine.isEmulator - ? await emuSigningOp(() => engine.wallet!.solanaSignMessage({ addressNList: params.addressNList, message: messageBytes, showDisplay: true }), { operation: 'solanaSignTx', opLabel: 'Solana Sign Transaction (v0)', chain: 'Solana' }) - : await engine.wallet.solanaSignMessage({ addressNList: params.addressNList, message: messageBytes, showDisplay: true }) - const sig = msgRes?.signature - if (!sig) throw new Error('[solanaSignTx] v0: device returned no signature') - sigBytes = sig instanceof Uint8Array ? sig : Buffer.from(sig, 'base64') - } else { - const deviceParams = { - ...params, - rawTx: Buffer.from(fullTx.subarray(parsed.messageStart)).toString('base64'), - } - console.debug(`[solanaSignTx] legacy — fullTx=${fullTx.length}B sigCount=${parsed.sigCount} messageStart=${parsed.messageStart}`) - const result = engine.isEmulator - ? await emuSigningOp(() => engine.wallet!.solanaSignTx(deviceParams), { operation: 'solanaSignTx', chain: 'Solana' }) - : await engine.wallet.solanaSignTx(deviceParams) - if (!result?.signature) return result - sigBytes = result.signature instanceof Uint8Array - ? result.signature - : Buffer.from(result.signature, 'base64') - } - - // Assemble signed tx: write sig into the first sig slot - // (starts at `parsed.sigStart`, 64 bytes). - if (sigBytes.length !== 64) { - throw new Error(`[solanaSignTx] Unexpected signature length ${sigBytes.length}`) - } - const rawBytes = Buffer.from(fullTx) - if (rawBytes.length < parsed.sigStart + 64) { - throw new Error('[solanaSignTx] Raw tx too short to hold signature') - } - for (let i = 0; i < 64; i++) rawBytes[parsed.sigStart + i] = sigBytes[i] - const assembled = rawBytes.toString('base64') - console.debug(`[solanaSignTx] Assembled signed tx: ${rawBytes.length}B (versioned=${parsed.isVersioned})`) - return { signature: sigBytes, serializedTx: assembled } }, solanaSignMessage: async (params) => { if (!engine.wallet) throw new Error('No device connected') @@ -3251,6 +3201,65 @@ const rpc = BrowserView.defineRPC({ ) console.log(`[getBalances] effectivePubkeys: ${effectivePubkeys.length}/${pubkeys.length} — chains: ${[...new Set(effectivePubkeys.map(p => p.chainId))].join(', ')}`) const allEntries = chunkResults.flatMap(r => r.entries) + // A completed SPL swap can beat Pioneer's portfolio indexer by + // several seconds. Query the mint directly on Solana so the first + // post-swap refresh can prove the output exists instead of briefly + // presenting "source gone, destination missing". + const directlyConfirmedAssetsByChain = new Map>() + for (const destCaip of swapDestCaips) { + const match = /^(solana:[^/]+)\/(?:token|spl):([1-9A-HJ-NP-Za-km-z]{32,44})$/.exec(destCaip) + if (!match) continue + const [, networkId, mint] = match + const owner = pubkeys.find(p => p.chainId === 'solana')?.pubkey + if (!owner) continue + try { + const { getSolanaTokenBalance } = await import('./solana-token') + const direct = await getSolanaTokenBalance( + owner, + mint, + getSetting('solana_rpc_endpoint') || undefined, + ) + const directAmount = Number.parseFloat(direct.amount) + const existing = allEntries.find((entry: any) => { + const entryCaip = String(entry?.caip || '') + const entryMint = /\/(?:token|spl):([^/]+)$/.exec(entryCaip)?.[1] + || String(entry?.contract || entry?.contractAddress || '') + return entryMint === mint + && String(entry?.networkId || entryCaip.split('/')[0]).toLowerCase() === networkId.toLowerCase() + }) + const catalogEntry = discoveryLookup[destCaip] + || discoveryLookup[destCaip.replace('/spl:', '/token:')] + const existingAmount = Number.parseFloat(String(existing?.balance ?? '0')) || 0 + const priceUsd = Number(existing?.priceUsd ?? 0) || 0 + const directEntry = { + ...(existing || {}), + caip: destCaip, + networkId, + pubkey: owner, + address: owner, + contract: mint, + type: 'token', + symbol: existing?.symbol || catalogEntry?.symbol || `${mint.slice(0, 4)}…${mint.slice(-4)}`, + name: existing?.name || catalogEntry?.name || 'SPL Token', + icon: existing?.icon || catalogEntry?.icon, + decimals: direct.decimals, + balance: direct.amount, + priceUsd, + valueUsd: directAmount * priceUsd, + } + if (existing) Object.assign(existing, directEntry) + else allEntries.push(directEntry) + const confirmed = directlyConfirmedAssetsByChain.get('solana') || new Set() + confirmed.add(destCaip) + directlyConfirmedAssetsByChain.set('solana', confirmed) + console.log(`[getBalances] Post-swap reconcile: direct SPL ${directEntry.symbol} balance=${direct.amount}`) + if (existingAmount > directAmount) { + console.warn(`[getBalances] Direct SPL balance is below indexed balance (${directAmount} < ${existingAmount}); direct chain value wins`) + } + } catch (e: any) { + console.warn(`[getBalances] Post-swap SPL reconcile failed for ${destCaip}:`, e?.message) + } + } // Self-host: BTC balances from the node, produced in Pioneer's entry shape // so the BTC loop below processes them identically. Price still via Pioneer // (GetMarketInfo — price isn't node data). A node failure for an xpub is @@ -3635,7 +3644,13 @@ const rpc = BrowserView.defineRPC({ // available — fall back to xpub only if Pioneer didn't return an address. // confirmedChainIds: chains where Pioneer returned a real response (not a failed chunk). // These are allowed to write 0 to the cache — genuine empty balance, not a transient failure. - const confirmedChainIds = new Set(effectivePubkeys.map(p => p.chainId)) + const confirmedChainIds = new Set( + [...new Set(pubkeys.map(p => p.chainId))].filter(chainId => + pubkeys + .filter(p => p.chainId === chainId) + .every(p => !failedPubkeySetForDb.has(`${p.caip}:${p.pubkey}`)) + ) + ) // BTC is aggregated from multiple pubkeys — it's confirmed only if ALL its pubkeys succeeded. const btcConfirmed = btcPubkeyEntries.every(e => !failedPubkeySetForDb.has(`${e.caip}:${e.pubkey}`)) if (btcConfirmed) confirmedChainIds.add('bitcoin') @@ -3723,6 +3738,15 @@ const rpc = BrowserView.defineRPC({ let unresolvedFaultCount = 0 for (const f of portfolioMeta.failures) { const ch = caipToChain(f.caip); if (ch) degradedChainIds.add(ch.id); else unresolvedFaultCount++ } for (const s of portfolioMeta.staleChains) { const ch = caipToChain(s.caip); if (ch) staleChainIds.add(ch.id); else unresolvedFaultCount++ } + for (const result of results) { + result.syncState = degradedChainIds.has(result.chainId) + ? 'degraded' + : staleChainIds.has(result.chainId) + ? 'stale' + : 'confirmed' + const directAssets = directlyConfirmedAssetsByChain.get(result.chainId) + if (directAssets?.size) result.confirmedAssetCaips = [...directAssets] + } const staleMinutes = portfolioMeta.staleChains.length ? Math.floor(Math.max(...portfolioMeta.staleChains.map(s => s.ageMs || 0)) / 60000) : 0 @@ -5322,9 +5346,14 @@ const rpc = BrowserView.defineRPC({ auth.rejectPairing() }, approveSigningRequest: async (params) => { - if (!auth.approveSigningRequest(params.id)) throw new Error('No pending signing request with that id') + if (params.allowBlindSigning === true) blindSigningApprovalIds.add(params.id) + if (!auth.approveSigningRequest(params.id)) { + blindSigningApprovalIds.delete(params.id) + throw new Error('No pending signing request with that id') + } }, rejectSigningRequest: async (params) => { + blindSigningApprovalIds.delete(params.id) if (!auth.rejectSigningRequest(params.id)) throw new Error('No pending signing request with that id') }, listPairedApps: async () => { diff --git a/projects/keepkey-vault/src/bun/rest-api.ts b/projects/keepkey-vault/src/bun/rest-api.ts index a5c74b76..8e86b4a9 100644 --- a/projects/keepkey-vault/src/bun/rest-api.ts +++ b/projects/keepkey-vault/src/bun/rest-api.ts @@ -25,9 +25,11 @@ import { getSetting, findApiLogs, getApiLogById, getRecentActivityFromLog, getSw import { detectSpamToken, categorizeTokens } from '../shared/spamFilter' import { rebuildActivityHistory, type ActivityHistoryRebuildOptions } from './activity-history' import type { SwapTrackingStatus } from '../shared/types' -import { parseSolanaTx, SolanaTxParseError, solanaMessageSlice } from './solana-tx' +import { parseSolanaTx, SolanaTxParseError } from './solana-tx' +import { signSolanaWireTransaction } from './solana-signing' import { buildSolanaDecodedInfo } from './solana-clearsign' import { buildSolanaMessageDecodedInfo } from './solana-message-preview' +import { requiresSolanaBlindSigningConsent } from './solana-consent' import { createRpcAltFetcher, DEFAULT_SOLANA_RPC_ENDPOINT } from './solana-alt' import { buildTonTransfer, @@ -55,9 +57,15 @@ export interface EmuSigningDetails { memo?: string } +export interface SigningApprovalDecision { + approved: boolean + /** Explicit Vault UI consent for this request only. */ + allowBlindSigning?: boolean +} + export interface RestApiCallbacks { onApiLog: (entry: ApiLogEntry) => void - onSigningRequest: (info: SigningRequestInfo) => Promise + onSigningRequest: (info: SigningRequestInfo) => Promise onSigningDismissed?: (id: string) => void onPairRequest: (info: { name: string; url: string; imageUrl: string }) => void onPairDismissed?: () => void @@ -1445,6 +1453,7 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 // actual handler completes (success or failure), not when the user clicks approve. let activeSigningId: string | undefined let activeSigningInfo: SigningRequestInfo | undefined + let activeAllowBlindSigning = false try { // ═══════════════════════════════════════════════════════════════ @@ -1753,6 +1762,13 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 } else { signingInfo.solanaDecodeError = 'missing raw_tx payload' } + signingInfo.requiresBlindSigningConsent = requiresSolanaBlindSigningConsent( + signingInfo.solanaDecoded, + preview.swapMetadata !== undefined, + ) + if (signingInfo.requiresBlindSigningConsent) { + signingInfo.needsBlindSigning = true + } } else if ( path === '/tron/sign-message' || path === '/ton/sign-message' @@ -1900,13 +1916,21 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 console.warn('[rest-api] Failed to read AdvancedMode policy:', e?.message || e) } - const approved = await callbacks.onSigningRequest(signingInfo) - if (!approved) { + // Track before waiting so rejection, timeout, or a malformed approval + // decision still dismisses the Vault overlay in the request finally. + activeSigningId = id + const approval = await callbacks.onSigningRequest(signingInfo) + if (!approval.approved) { return json({ error: 'Signing rejected by user' }, 403) } - // Approved — track ID + decoded info so handlers can pass metadata to device - activeSigningId = id + if (signingInfo.requiresBlindSigningConsent && !approval.allowBlindSigning) { + return json({ error: 'One-shot blind-signing consent required' }, 403) + } + // Approved — retain decoded info so handlers can pass metadata to device. activeSigningInfo = signingInfo + activeAllowBlindSigning = + signingInfo.requiresBlindSigningConsent === true + && approval.allowBlindSigning === true } // ── List paired apps (public — shows connected dApps, keys stripped) ── @@ -2664,64 +2688,48 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 const body = await parseRequest(req, S.SolanaSignRequest) const addressNList = pickAddressNList(body, DEFAULT_SOLANA_ADDRESS_N) - // Pioneer returns full serialized tx: [compact-u16:sigCount][sig0(64)]...[sigN(64)][message] - // Firmware expects just the message bytes. See solana-tx.ts for the - // wire-format contract and malformed-input rejection rules. + // Validate the serialized wrapper here so malformed client input is a + // 400 rather than a generic device/route failure. const fullTx = Buffer.from(body.raw_tx, 'base64') - let parsed try { - parsed = parseSolanaTx(fullTx) + parseSolanaTx(fullTx) } catch (err) { if (err instanceof SolanaTxParseError) throw new HttpError(400, err.message) throw err } - // KeepKey firmware message type 752 (SolanaSignTx) parses legacy - // messages only. For versioned (v0) messages we route the exact - // message bytes — including the 0x80 prefix — through type 754 - // (SolanaSignMessage), which signs raw bytes with Ed25519 over the - // user's Solana key. The resulting 64-byte signature is valid for - // the original v0 tx because Solana computes signatures over the - // message payload (not the wrapper). - // - // Trade-off: the device displays a generic "sign message" prompt - // rather than a parsed-tx summary. Users must review the resolved - // accounts/amounts in the Vault approval dialog. Full on-device - // parsing of v0 + ALT display is a firmware-side follow-up. - let sigBytes: Uint8Array - if (parsed.isVersioned) { - const messageBytes = solanaMessageSlice(fullTx, parsed) - const msgResult = await emuWrap(() => wallet.solanaSignMessage({ - addressNList, - message: Buffer.from(messageBytes).toString('base64'), - showDisplay: body.show_display !== false, - }), { operation: 'solanaSignMessage', chain: 'Solana' }) - const sig = msgResult?.signature - if (!sig) throw new HttpError(500, 'Solana v0 sign: device returned no signature') - sigBytes = sig instanceof Uint8Array ? sig : Buffer.from(sig, 'base64') - } else { - const deviceRawTx = Buffer.from(fullTx.subarray(parsed.messageStart)).toString('base64') - const result = await emuWrap(() => wallet.solanaSignTx({ - addressNList, - rawTx: deviceRawTx, - }), { operation: 'solanaSignTx', chain: 'Solana' }) - if (!result?.signature) return json(result) - sigBytes = result.signature instanceof Uint8Array - ? result.signature - : Buffer.from(result.signature, 'base64') - } - // Assemble signed tx: write the real signature into the first sig - // slot (starts at `parsed.sigStart`, 64 bytes long). Same layout - // for legacy and v0 because the wrapper format is identical. - if (sigBytes.length !== 64) { - throw new HttpError(500, `Solana sign: unexpected signature length ${sigBytes.length}`) - } - const rawBytes = Buffer.from(body.raw_tx, 'base64') - if (rawBytes.length < parsed.sigStart + 64) { - throw new HttpError(500, 'Solana sign: raw tx too short to hold signature') - } - for (let i = 0; i < 64; i++) rawBytes[parsed.sigStart + i] = sigBytes[i] - return json({ signature: Buffer.from(sigBytes).toString('base64'), serializedTx: rawBytes.toString('base64') }) + // Both legacy and v0 messages use SolanaSignTx. The helper removes + // the signature wrapper, forwards the transaction-bound KKSOLSW1 + // descriptor unchanged, or adds a one-shot opaque fallback only when + // the Vault UI returned explicit consent, then splices + // the returned signature back into the original wire transaction. + const result = await signSolanaWireTransaction( + { + addressNList, + rawTx: body.raw_tx, + swapMetadata: body.swapMetadata, + allowBlindSigning: activeAllowBlindSigning, + }, + (request) => emuWrap( + () => wallet.solanaSignTx(request), + { operation: 'solanaSignTx', chain: 'Solana' }, + ), + async (signerPath) => { + const derived = await wallet.solanaGetAddress({ + addressNList: signerPath, + showDisplay: false, + }) + const address = typeof derived === 'string' ? derived : derived?.address + if (!address) throw new Error('Device returned no Solana signer address') + return address + }, + 'rest:solanaSignTx', + ) + if (!result?.signature) return json(result) + return json({ + signature: Buffer.from(result.signature).toString('base64'), + serializedTx: result.serializedTx, + }) } // ── SOLANA MESSAGE SIGNING (firmware type 754) ────────────────── @@ -4335,6 +4343,7 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 } activeSigningId = undefined activeSigningInfo = undefined + activeAllowBlindSigning = false } }, // WS endpoint for the BEX agent bridge (/bex-bridge upgrade above). diff --git a/projects/keepkey-vault/src/bun/schemas.ts b/projects/keepkey-vault/src/bun/schemas.ts index 884d7721..20a1cc0b 100644 --- a/projects/keepkey-vault/src/bun/schemas.ts +++ b/projects/keepkey-vault/src/bun/schemas.ts @@ -160,10 +160,23 @@ export const XrpSignRequest = z.object({ }).strip() /** POST /solana/sign-transaction — sign a raw Solana transaction */ +export const SolanaSwapMetadata = z.object({ + /** Base64-encoded canonical KKSOLSW1 descriptor. */ + payload: z.string().min(1), + /** Base64-encoded 64-byte compact secp256k1 signature over SHA256(payload). */ + signature: z.string().min(1), + /** Device ClearSign signer slot (0 = built-in, 1..3 = user-loaded). */ + signerKeyId: z.number().int().min(0).max(3), +}).strict() + export const SolanaSignRequest = z.object({ address_n: z.array(z.number().int()).optional(), addressNList: z.array(z.number().int()).optional(), raw_tx: z.string().min(1), + /** Transaction-bound ClearSign metadata. Partial descriptors are rejected. */ + swapMetadata: SolanaSwapMetadata.optional(), + // One-shot opaque-signing consent is intentionally not part of the public + // REST contract. Unknown fields are stripped; the Vault UI grants consent. }).strip() /** POST /tron/sign-transaction — sign a raw Tron transaction */ diff --git a/projects/keepkey-vault/src/bun/solana-clearsign.ts b/projects/keepkey-vault/src/bun/solana-clearsign.ts index 8d0b09ee..99b0acbb 100644 --- a/projects/keepkey-vault/src/bun/solana-clearsign.ts +++ b/projects/keepkey-vault/src/bun/solana-clearsign.ts @@ -81,6 +81,7 @@ export async function buildSolanaDecodedInfo( return { version: parsedMsg.version, + staticAccountCount: parsedMsg.staticAccounts.length, instructions, altPubkeys, altResolutionIncomplete: altResolutionIncomplete || undefined, diff --git a/projects/keepkey-vault/src/bun/solana-consent.ts b/projects/keepkey-vault/src/bun/solana-consent.ts new file mode 100644 index 00000000..5803f65b --- /dev/null +++ b/projects/keepkey-vault/src/bun/solana-consent.ts @@ -0,0 +1,69 @@ +import type { SolanaTxDecodedInfo, SolanaTxDecodedInstruction } from '../shared/types' + +const SYSTEM_PROGRAM = '11111111111111111111111111111111' +const TOKEN_PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' +const TOKEN_2022_PROGRAM = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb' +const ATA_PROGRAM = 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL' +const COMPUTE_BUDGET_PROGRAM = 'ComputeBudget111111111111111111111111111111' + +/** + * This is deliberately an allowlist, not "anything the host registry knows". + * Firmware has stricter semantics than the preview registry: for example, + * unchecked SPL transfers and System createAccount are decoded for display but + * intentionally remain opaque on-device. + */ +function firmwareClearSigns(instruction: SolanaTxDecodedInstruction): boolean { + if (instruction.status !== 'known' || instruction.note) return false + + switch (instruction.programId) { + case SYSTEM_PROGRAM: + return instruction.instructionName === 'transfer' + || instruction.instructionName === 'allocate' + case TOKEN_PROGRAM: + if (instruction.instructionName === 'transferChecked') { + return instruction.accounts.length >= 4 + } + return instruction.instructionName === 'mintTo' + || instruction.instructionName === 'burn' + || instruction.instructionName === 'closeAccount' + case TOKEN_2022_PROGRAM: + // Firmware forces all Token-2022 transfer variants opaque because + // extensions can add undisclosed hooks or fees. + return instruction.instructionName === 'mintTo' + || instruction.instructionName === 'burn' + || instruction.instructionName === 'closeAccount' + case ATA_PROGRAM: + return instruction.instructionName === 'create' + case COMPUTE_BUDGET_PROGRAM: + return instruction.instructionName === 'setComputeUnitLimit' + || instruction.instructionName === 'setComputeUnitPrice' + || instruction.instructionName === 'setLoadedAccountsDataSizeLimit' + default: + return false + } +} + +/** + * Decide whether an external REST Solana transaction needs explicit one-shot + * opaque-signing consent. Provider-signed transaction metadata is verified (or + * rejected without fallback) by firmware. Without it, mirror firmware's + * clear-sign boundary conservatively so a richer host decoder cannot turn an + * on-device opaque transaction into an implicitly approved request. + */ +export function requiresSolanaBlindSigningConsent( + decoded: SolanaTxDecodedInfo | undefined, + hasTransactionBoundMetadata: boolean, +): boolean { + if (hasTransactionBoundMetadata) return false + if ( + !decoded + || decoded.staticAccountCount > 32 + || decoded.instructions.length === 0 + || decoded.instructions.length > 8 + || decoded.altPubkeys.length > 0 + || decoded.altResolutionIncomplete + ) { + return true + } + return decoded.instructions.some((instruction) => !firmwareClearSigns(instruction)) +} diff --git a/projects/keepkey-vault/src/bun/solana-signing.ts b/projects/keepkey-vault/src/bun/solana-signing.ts new file mode 100644 index 00000000..90ce38f7 --- /dev/null +++ b/projects/keepkey-vault/src/bun/solana-signing.ts @@ -0,0 +1,107 @@ +import bs58 from 'bs58' +import { + parseSolanaMessage, + parseSolanaTx, + solanaMessageSlice, + SolanaTxParseError, +} from './solana-tx' + +export type SolanaDeviceSigner = (params: any) => Promise +export type SolanaAddressDeriver = (addressNList: number[]) => Promise + +/** + * Route a serialized Solana transaction through the transaction-specific + * firmware message and splice the returned signature into the original wire + * transaction. The device receives the exact legacy/v0 message bytes, while + * metadata and one-shot opaque-signing consent are forwarded unchanged. + */ +export async function signSolanaWireTransaction( + unsignedTx: any, + signWithDevice: SolanaDeviceSigner, + deriveSignerAddress: SolanaAddressDeriver, + logPrefix = 'signTx:solana', +): Promise { + const fullTx = Buffer.from( + typeof unsignedTx.rawTx === 'string' + ? unsignedTx.rawTx + : Buffer.from(unsignedTx.rawTx).toString('base64'), + 'base64', + ) + + let parsed + try { + parsed = parseSolanaTx(fullTx) + } catch (err) { + if (err instanceof SolanaTxParseError) throw new Error(`[${logPrefix}] ${err.message}`) + throw err + } + + const messageBytes = solanaMessageSlice(fullTx, parsed) + const message = parseSolanaMessage(messageBytes) + if (message.header.numRequiredSignatures !== parsed.sigCount) { + throw new Error( + `[${logPrefix}] Signature count mismatch: wrapper declares ${parsed.sigCount}, ` + + `message requires ${message.header.numRequiredSignatures}`, + ) + } + + const addressNList = unsignedTx.addressNList || unsignedTx.address_n + if (!Array.isArray(addressNList)) { + throw new Error(`[${logPrefix}] addressNList is required to select the signer slot`) + } + const signerAddress = await deriveSignerAddress(addressNList) + let signerPublicKey: Uint8Array + try { + signerPublicKey = bs58.decode(signerAddress) + } catch { + throw new Error(`[${logPrefix}] Invalid derived signer address`) + } + if (signerPublicKey.length !== 32) { + throw new Error( + `[${logPrefix}] Invalid derived signer address length ${signerPublicKey.length} (expected 32)`, + ) + } + + // Required signers are the first N static message accounts. Find the slot + // belonging to this address before asking the device to sign; slot zero may + // already contain a cosigner signature in a multisig transaction. + const signerIndex = message.staticAccounts + .slice(0, message.header.numRequiredSignatures) + .findIndex((account) => Buffer.from(account).equals(Buffer.from(signerPublicKey))) + if (signerIndex < 0) { + throw new Error( + `[${logPrefix}] Derived wallet account ${signerAddress} is not a required transaction signer`, + ) + } + + const deviceParams = { + ...unsignedTx, + rawTx: Buffer.from(messageBytes).toString('base64'), + } + console.debug( + `[${logPrefix}] routing ${parsed.isVersioned ? 'v0' : 'legacy'} transaction ` + + `through SolanaSignTx (${messageBytes.length}B message)`, + ) + + const result = await signWithDevice(deviceParams) + if (!result?.signature) return result + + const sigBytes: Uint8Array = result.signature instanceof Uint8Array + ? result.signature + : Buffer.from(result.signature, 'base64') + if (sigBytes.length !== 64) { + throw new Error(`[${logPrefix}] Unexpected signature length ${sigBytes.length}`) + } + + const rawBytes = Buffer.from(fullTx) + const slotOffset = parsed.sigStart + signerIndex * 64 + if (rawBytes.length < slotOffset + 64) { + throw new Error(`[${logPrefix}] Raw tx too short to hold signer slot ${signerIndex}`) + } + for (let i = 0; i < 64; i++) rawBytes[slotOffset + i] = sigBytes[i] + + return { + signature: sigBytes, + serializedTx: rawBytes.toString('base64'), + } +} diff --git a/projects/keepkey-vault/src/bun/solana-token.ts b/projects/keepkey-vault/src/bun/solana-token.ts index 6e7063a2..1cb49aa8 100644 --- a/projects/keepkey-vault/src/bun/solana-token.ts +++ b/projects/keepkey-vault/src/bun/solana-token.ts @@ -38,6 +38,84 @@ export interface SolanaTokenMeta { iconUrl?: string } +export interface SolanaTokenBalance { + amount: string + decimals: number +} + +function formatTokenBaseUnits(value: bigint, decimals: number): string { + if (decimals <= 0) return value.toString() + const padded = value.toString().padStart(decimals + 1, '0') + const whole = padded.slice(0, -decimals) + const fraction = padded.slice(-decimals).replace(/0+$/, '') + return fraction ? `${whole}.${fraction}` : whole +} + +/** + * Direct, indexer-independent SPL balance lookup used after a swap completes. + * An owner can have multiple token accounts for one mint, so sum raw base units + * and only format after every account has been included. + */ +export async function getSolanaTokenBalance( + owner: string, + mint: string, + endpoint: string = DEFAULT_SOLANA_RPC_ENDPOINT, + fetchImpl: typeof fetch = fetch, +): Promise { + if (!SOLANA_MINT_RE.test(mint)) throw new Error(`Invalid Solana mint: ${mint}`) + const res = await fetchImpl(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'getTokenAccountsByOwner', + params: [owner, { mint }, { encoding: 'jsonParsed', commitment: 'confirmed' }], + }), + signal: AbortSignal.timeout(8000), + }) + if (!res.ok) throw new Error(`Solana RPC balance lookup failed (${res.status})`) + const body = await res.json() as { + error?: { message?: string } + result?: { + value?: Array<{ + account?: { + data?: { + parsed?: { + info?: { + tokenAmount?: { amount?: string; decimals?: number } + } + } + } + } + }> + } + } + if (body.error) throw new Error(body.error.message || 'Solana RPC balance lookup failed') + + let total = 0n + let decimals: number | undefined + for (const entry of body.result?.value || []) { + const tokenAmount = entry.account?.data?.parsed?.info?.tokenAmount + if (!tokenAmount || !/^\d+$/.test(tokenAmount.amount || '')) continue + if (typeof tokenAmount.decimals !== 'number') continue + if (decimals !== undefined && decimals !== tokenAmount.decimals) { + throw new Error(`Solana RPC returned inconsistent decimals for ${mint}`) + } + decimals = tokenAmount.decimals + total += BigInt(tokenAmount.amount!) + } + + // A wallet with no account for this mint legitimately returns [] and carries + // no decimals. Resolve the mint so callers still receive an honest zero. + if (decimals === undefined) { + const meta = await getMintInfo(endpoint, mint).catch(() => null) + if (!meta) throw new Error(`Could not resolve decimals for Solana mint ${mint}`) + decimals = meta.decimals + } + return { amount: formatTokenBaseUnits(total, decimals), decimals } +} + /** On-chain validation + decimals. Returns null when the address isn't a real * SPL/Token-2022 mint. Surfaces Token-2022 inline metadata when present. */ async function getMintInfo(endpoint: string, mint: string): Promise<{ diff --git a/projects/keepkey-vault/src/bun/swagger.json b/projects/keepkey-vault/src/bun/swagger.json index 5bf30c6a..4f6c5ee9 100644 --- a/projects/keepkey-vault/src/bun/swagger.json +++ b/projects/keepkey-vault/src/bun/swagger.json @@ -3768,7 +3768,7 @@ "post": { "operationId": "solana_signTransaction", "summary": "Sign a Solana transaction", - "description": "Sign a raw Solana transaction on the KeepKey device. The raw_tx is a base64-encoded serialized Solana transaction.", + "description": "Sign a raw Solana transaction on the KeepKey device. raw_tx is a base64-encoded serialized transaction. Opaque cross-chain/versioned transactions should include provider-signed KKSOLSW1 swapMetadata so firmware can verify and display ClearSign details. If opaque signing is still required, the Vault UI asks the user for one-request consent; REST callers cannot assert that consent.", "parameters": [], "requestBody": { "content": { @@ -3785,6 +3785,32 @@ "raw_tx": { "type": "string", "description": "Base64-encoded raw Solana transaction" + }, + "swapMetadata": { + "type": "object", + "description": "Transaction-bound KKSOLSW1 ClearSign descriptor signed by a device-trusted metadata key.", + "additionalProperties": false, + "properties": { + "payload": { + "type": "string", + "description": "Base64-encoded canonical KKSOLSW1 descriptor" + }, + "signature": { + "type": "string", + "description": "Base64-encoded 64-byte compact secp256k1 signature over SHA256(payload)" + }, + "signerKeyId": { + "type": "integer", + "minimum": 0, + "maximum": 3, + "description": "Trusted device ClearSign signer slot" + } + }, + "required": [ + "payload", + "signature", + "signerKeyId" + ] } }, "required": [ diff --git a/projects/keepkey-vault/src/bun/swap-parsing.ts b/projects/keepkey-vault/src/bun/swap-parsing.ts index 07390d75..513374e3 100644 --- a/projects/keepkey-vault/src/bun/swap-parsing.ts +++ b/projects/keepkey-vault/src/bun/swap-parsing.ts @@ -10,6 +10,56 @@ import { COIN_MAP_LONG } from '@pioneer-platform/pioneer-coins' const TAG = '[swap]' +function decodeStrictBase64(value: unknown, field: string, maxBytes: number): string { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`Invalid Solana ClearSign metadata: missing ${field}`) + } + const normalized = value.replace(/\s+/g, '') + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(normalized) || normalized.length % 4 !== 0) { + throw new Error(`Invalid Solana ClearSign metadata: ${field} is not base64`) + } + const bytes = Buffer.from(normalized, 'base64') + if (bytes.length === 0 || bytes.length > maxBytes) { + throw new Error( + `Invalid Solana ClearSign metadata: ${field} must be 1..${maxBytes} bytes`, + ) + } + return normalized +} + +/** Extract the provider-signed KKSOLSW1 envelope without manufacturing trust in + * the Vault. Missing metadata means the device retains its opaque fallback; + * partially supplied or malformed metadata rejects the quote to prevent a + * silent ClearSign downgrade. */ +function parseSolanaSwapMetadata(...candidates: unknown[]): RelayTxParams['solanaSwapMetadata'] { + const candidate = candidates.find((value) => value != null) + if (candidate == null) return undefined + if (typeof candidate !== 'object') { + throw new Error('Invalid Solana ClearSign metadata: expected an object') + } + const metadata = candidate as Record + const payload = decodeStrictBase64( + metadata.payload ?? metadata.signedPayload ?? metadata.signed_payload, + 'payload', + 320, + ) + const signature = decodeStrictBase64( + metadata.signature, + 'signature', + 64, + ) + if (Buffer.from(signature, 'base64').length !== 64) { + throw new Error('Invalid Solana ClearSign metadata: signature must be 64 bytes') + } + const signerKeyId = Number( + metadata.signerKeyId ?? metadata.signer_key_id ?? metadata.keyId ?? metadata.key_id, + ) + if (!Number.isInteger(signerKeyId) || signerKeyId < 0 || signerKeyId > 3) { + throw new Error('Invalid Solana ClearSign metadata: signerKeyId must be 0..3') + } + return { payload, signature, signerKeyId } +} + // ── Asset mapping helpers ─────────────────────────────────────────── /** True for assets that swap via a THORChain/Maya MsgDeposit (no inbound vault @@ -209,6 +259,14 @@ function parseSingleQuote( let relayTx: RelayTxParams | undefined if (hasSolanaPrebuiltTx) { + const solanaSwapMetadata = parseSolanaSwapMetadata( + txParams.solanaSwapMetadata, + txParams.solana_swap_metadata, + txParams.clearSignMetadata, + txParams.clearsign_metadata, + quote.solanaSwapMetadata, + quote.meta?.solanaSwapMetadata, + ) relayTx = { to: txParams.recipientAddress || '', data: '0x', @@ -216,6 +274,7 @@ function parseSingleQuote( gasLimit: undefined, chainId: 0, serializedTx: txParams.serializedTx, + solanaSwapMetadata, } console.log(`${TAG} ${integration} (${swapper}) — Solana prebuilt tx extracted (recipient=${txParams.recipientAddress})`) } else if (hasPrebuiltTx) { diff --git a/projects/keepkey-vault/src/bun/swap.ts b/projects/keepkey-vault/src/bun/swap.ts index c605715f..98c8beeb 100644 --- a/projects/keepkey-vault/src/bun/swap.ts +++ b/projects/keepkey-vault/src/bun/swap.ts @@ -41,6 +41,12 @@ const isBitcoin = (c: ChainDef) => c.networkId === BTC_NETWORK_ID // CAIP namespace parser is shared with the picker so a future namespace // addition (Solana SPL, etc.) only needs editing in one place. import { parseCaip } from '../shared/swap-discovery' +import { + DEFAULT_SLIPPAGE_BPS, + MAX_SLIPPAGE_BPS, + MIN_SLIPPAGE_BPS, + normalizeSlippageBps, +} from '../shared/slippage' /** True for ERC-20 / BEP-20 / TRC-20 token sources. */ function isTokenCaip(caip: string): boolean { @@ -277,11 +283,14 @@ export async function getSwapQuote(params: SwapQuoteParams): Promise // volatile pair). Anything outside [10, 5000] bps is clamped to that range. // Default to 100 bps (1%) when caller omits the field. if (params.slippageBps === 0) { - throw new Error('Slippage of 0 is not allowed — choose a tolerance between 0.1% and 50%') + throw new Error( + `Slippage of 0 is not allowed — choose a tolerance between ` + + `${MIN_SLIPPAGE_BPS / 100}% and ${MAX_SLIPPAGE_BPS / 100}%`, + ) } const slippageBps = params.slippageBps == null - ? 100 - : Math.max(10, Math.min(5000, Math.round(params.slippageBps))) + ? DEFAULT_SLIPPAGE_BPS + : normalizeSlippageBps(params.slippageBps) const pioneer = await getPioneer() @@ -363,7 +372,8 @@ export async function getSwapQuote(params: SwapQuoteParams): Promise throw new Error( `Swap amount too low: quoted fees (${(feeBps / 100).toFixed(2)}%) meet or exceed the ` + `slippage allowance (${(slippageBps / 100).toFixed(2)}%), so the protocol would refund ` + - `this swap on-chain — minus another network fee. Increase the amount.` + `this swap on-chain — minus another network fee. Increase the amount or choose Custom ` + + `slippage above ${(feeBps / 100).toFixed(2)}%.` ) } // 2. Route-declared minimum sell amount — enforced for ALL integrations @@ -648,7 +658,12 @@ export async function executeSwap(params: ExecuteSwapParams, ctx: SwapContext): // Skip the EVM-only buildRelaySwapTx and feed it straight to the Solana signer. if (fromChain.chainFamily === 'solana' && params.relayTx?.serializedTx) { swapLog(`${TAG} Relay Solana prebuilt tx — bypassing EVM relay builder`) - unsignedTx = { addressNList: fromChain.defaultPath, rawTx: params.relayTx.serializedTx } + unsignedTx = { + addressNList: fromChain.defaultPath, + rawTx: params.relayTx.serializedTx, + allowBlindSigning: params.allowSolanaBlindSigning === true, + swapMetadata: params.relayTx.solanaSwapMetadata, + } } else if (fromChain.chainFamily === 'tron') { // Tron has no nonces — buildRelaySwapTx is EVM-only. Route through TronGrid txbuilder. swapLog(`${TAG} Relay Tron prebuilt tx — bypassing EVM relay builder`) @@ -853,15 +868,20 @@ export async function executeSwap(params: ExecuteSwapParams, ctx: SwapContext): unsignedTx = buildResult.unsignedTx } - // Solana swaps are v0 (versioned) txs the device can only blind-sign via - // solanaSignMessage. Firmware hard-gates that path behind the AdvancedMode - // policy (fsm_msg_solana.h) — when it's off the device shows a "Blocked" - // screen and returns a generic ActionCancelled whose real reason ("Message - // signing disabled by policy") is dropped by hdwallet's transport. Detect the - // disabled policy up front so the UI can prompt the user to enable blind - // signing instead of bouncing off an opaque device cancel. Only block when we - // positively know the policy is disabled; if unknown, let signing proceed. - if (fromChain.chainFamily === 'solana' && ctx.isAdvancedModeEnabled?.() === false) { + // Prebuilt Relay Solana payloads currently use a custom program plus + // lookup-table accounts. Without a transaction-bound ClearSign descriptor the + // device must classify that exact route as opaque. Ask for explicit one-shot + // consent before the hardware prompt, but do not block other Solana or v0 + // transactions that firmware can natively ClearSign. + const needsOpaqueSolanaFallback = + fromChain.chainFamily === 'solana' && + !!params.relayTx?.serializedTx && + !params.relayTx.solanaSwapMetadata + if ( + needsOpaqueSolanaFallback && + ctx.isAdvancedModeEnabled?.() !== true && + params.allowSolanaBlindSigning !== true + ) { throw new Error(SOLANA_BLIND_SIGNING_REQUIRED) } diff --git a/projects/keepkey-vault/src/bun/txbuilder/index.ts b/projects/keepkey-vault/src/bun/txbuilder/index.ts index 97b8d982..e25e0d52 100644 --- a/projects/keepkey-vault/src/bun/txbuilder/index.ts +++ b/projects/keepkey-vault/src/bun/txbuilder/index.ts @@ -12,7 +12,7 @@ import { sendShielded, type ShieldedSendParams } from './zcash-shielded' import { buildTonTransfer, assembleTonSignedBoc, getTonSeqno, getTonWalletState, broadcastTonBoc, type TonBuildResult } from './ton' import { buildHiveTransfer, broadcastHiveTx } from './hive' import { SOLANA_LAMPORTS_PER_SIGNATURE, solanaTransferLamportsForAmount } from './solana' -import { parseSolanaTx, solanaMessageSlice, SolanaTxParseError } from '../solana-tx' +import { signSolanaWireTransaction } from '../solana-signing' import { getBtcBackend } from '../btc-backend' /** BTC mainnet — the only UTXO chain that broadcasts via a self-host node. */ @@ -445,17 +445,10 @@ export async function buildTx( console.warn(`[buildTx] TRON fee estimate failed, displaying fee_limit: ${e.message}`) } - // Intentionally do NOT pass `toAddress`/`amount` for TRC-20. - // Firmware 7.14's TRON FSM has only a TransferContract clear-sign - // path — given those fields it shows "Send TRX to - // " regardless of the actual contract being called. For a - // TRC-20 transfer that means the device shows "Send 1 TRX to " when the user is actually sending 1 USDT to a recipient - // — accurate to the bytes signed but a misleading interpretation. By - // omitting the hint fields the firmware falls back to the generic - // "Really sign this TRON transaction?" prompt, which matches the - // SwapDialog blind-sign warning text. Once firmware adds TRC-20 - // clear-signing, populate proper hint fields here. + // Firmware 7.15+ parses TriggerSmartContract directly from raw_data, + // including the TRC-20 recipient, uint256 amount, owner, fee limit, and + // memo. Do not add host hint fields: the signed protobuf remains the + // single source of truth for ClearSign. const tronUnsignedTx = { addressNList: chain.defaultPath, rawTx: tronGridTx.raw_data_hex, @@ -627,71 +620,21 @@ export async function signTx( case 'xrp': return wallet.rippleSignTx(unsignedTx) case 'solana': { - // KeepKey firmware message type 752 (SolanaSignTx) parses LEGACY messages - // only. For versioned (v0) messages we route the exact message bytes - // through type 754 (SolanaSignMessage), preserving the 0x80 prefix and - // payload. Same logic as the solanaSignTx RPC handler in bun/index.ts — - // duplicated here because the swap path calls hdwallet directly and - // bypasses the RPC wrapper. Without this, Pioneer-built v0 swap txs - // (Solana inbound to THORChain etc.) hit "Malformed Solana transaction" - // from the device. - const fullTx = Buffer.from( - typeof unsignedTx.rawTx === 'string' - ? unsignedTx.rawTx - : Buffer.from(unsignedTx.rawTx).toString('base64'), - 'base64', + // Transaction bytes always use firmware message type 752 (SolanaSignTx). + // Current firmware parses legacy and v0 messages, classifies them as + // verified/opaque/malformed, verifies the derived signer even for + // parseable opaque payloads, and rejects malformed transactions. Routing + // v0 through SolanaSignMessage loses those transaction-specific checks. + return signSolanaWireTransaction( + unsignedTx, + (deviceParams) => wallet.solanaSignTx(deviceParams), + async (addressNList) => { + const derived = await wallet.solanaGetAddress({ addressNList, showDisplay: false }) + const address = typeof derived === 'string' ? derived : derived?.address + if (!address) throw new Error('Device returned no Solana signer address') + return address + }, ) - let parsed - try { - parsed = parseSolanaTx(fullTx) - } catch (err) { - if (err instanceof SolanaTxParseError) throw new Error(`[signTx:solana] ${err.message}`) - throw err - } - console.debug(`[signTx:solana] signing tx versioned=${parsed.isVersioned} sigCount=${parsed.sigCount} fullTx=${fullTx.length}B`) - - let sigBytes: Uint8Array - if (parsed.isVersioned) { - const messageBytes = solanaMessageSlice(fullTx, parsed) - console.debug(`[signTx:solana] v0 — routing through solanaSignMessage (${messageBytes.length}B incl. 0x80 prefix)`) - const msgRes = await wallet.solanaSignMessage({ - addressNList: unsignedTx.addressNList, - message: messageBytes, - showDisplay: true, - }) - const sig = msgRes?.signature - if (!sig) throw new Error('[signTx:solana] v0: device returned no signature') - sigBytes = sig instanceof Uint8Array ? sig : Buffer.from(sig, 'base64') - } else { - // Legacy: hdwallet expects the message bytes (no sig section), not the full tx. - const deviceParams = { - ...unsignedTx, - rawTx: Buffer.from(fullTx.subarray(parsed.messageStart)).toString('base64'), - } - const result = await wallet.solanaSignTx(deviceParams) - if (!result?.signature) { - // Device returned a non-signature result — pass through (preserves - // the existing legacy behaviour for callers expecting that shape). - console.debug(`[signTx:solana] legacy result has no signature, passing through`) - return result - } - sigBytes = result.signature instanceof Uint8Array - ? result.signature - : Buffer.from(result.signature, 'base64') - } - - if (sigBytes.length !== 64) { - throw new Error(`[signTx:solana] Unexpected signature length ${sigBytes.length}`) - } - // Splice the signature into the first sig slot of the original wire-format tx. - const rawBytes = Buffer.from(fullTx) - if (rawBytes.length < parsed.sigStart + 64) { - throw new Error('[signTx:solana] Raw tx too short to hold signature') - } - for (let i = 0; i < 64; i++) rawBytes[parsed.sigStart + i] = sigBytes[i] - const serializedTx = rawBytes.toString('base64') - console.debug(`[signTx:solana] assembled signed tx ${rawBytes.length}B (versioned=${parsed.isVersioned})`) - return { signature: sigBytes, serializedTx } } case 'tron': { // hdwallet returns { signature, serializedTx, ... } but does NOT echo diff --git a/projects/keepkey-vault/src/mainview/App.tsx b/projects/keepkey-vault/src/mainview/App.tsx index 93eb1af3..ca7a8bc1 100644 --- a/projects/keepkey-vault/src/mainview/App.tsx +++ b/projects/keepkey-vault/src/mainview/App.tsx @@ -394,7 +394,7 @@ function App() { }) }, [clearSigningConfirmTimer, setSigningPhaseTracked]) - const handleApproveSign = useCallback(async () => { + const handleApproveSign = useCallback(async (approval?: { allowBlindSigning?: boolean }) => { if (!signingRequest) return clearSigningConfirmTimer() signingPayloadStartedAt.current = Date.now() @@ -403,7 +403,10 @@ function App() { // to press the physical button. setSigningPhaseTracked('sending-payload') try { - await rpcRequest("approveSigningRequest", { id: signingRequest.id }) + await rpcRequest("approveSigningRequest", { + id: signingRequest.id, + ...(approval?.allowBlindSigning === true ? { allowBlindSigning: true } : {}), + }) } catch (e) { console.error("approveSign:", e) clearSigningConfirmTimer() @@ -425,6 +428,23 @@ function App() { setSigningPhaseTracked('approve') }, [clearSigningConfirmTimer, setSigningPhaseTracked, signingRequest]) + const handleCancelSign = useCallback(async () => { + if (!signingRequest) return + clearSigningConfirmTimer() + signingPayloadStartedAt.current = null + try { + // This sends the protocol Cancel message to the device, releasing the + // transport even when the REST caller is blocked on confirmation. + await rpcRequest("cancelDeviceSigning", undefined, 5000) + } catch (e) { + console.error("cancelDeviceSigning:", e) + } finally { + signingRequestRef.current = null + setSigningRequest(null) + setSigningPhaseTracked('approve') + } + }, [clearSigningConfirmTimer, setSigningPhaseTracked, signingRequest]) + // ── Paired Apps panel ─────────────────────────────────────────── const [pairedAppsOpen, setPairedAppsOpen] = useState(false) @@ -779,7 +799,7 @@ function App() { // PIN is highest priority (z-index 2010) — must show above signing // approval so users can unlock a PIN-locked device during API signing. const signingOverlay = signingRequest ? ( - + ) : null const pairingOverlay = pairRequest ? ( diff --git a/projects/keepkey-vault/src/mainview/components/ActivityTracker.tsx b/projects/keepkey-vault/src/mainview/components/ActivityTracker.tsx index bdab3d44..fda6f562 100644 --- a/projects/keepkey-vault/src/mainview/components/ActivityTracker.tsx +++ b/projects/keepkey-vault/src/mainview/components/ActivityTracker.tsx @@ -113,7 +113,7 @@ export function ActivityTracker() { fetchActivities() if (swap.status === 'completed' || swap.status === 'refunded') { window.dispatchEvent(new CustomEvent('keepkey-swap-completed', { - detail: { fromChainId: swap.fromChainId, toChainId: swap.toChainId, toCaip: swap.toCaip } + detail: { ...swap, swap } })) } }) diff --git a/projects/keepkey-vault/src/mainview/components/AssetIcon.tsx b/projects/keepkey-vault/src/mainview/components/AssetIcon.tsx index c2ca5f5a..fdfa6a09 100644 --- a/projects/keepkey-vault/src/mainview/components/AssetIcon.tsx +++ b/projects/keepkey-vault/src/mainview/components/AssetIcon.tsx @@ -9,9 +9,9 @@ * The badge is also auto-suppressed below `size={24}` since it would render * as visual mush at very small sizes. */ -import { useState } from "react" +import { useEffect, useMemo, useState } from "react" import { Box, Image, Text } from "@chakra-ui/react" -import { caipToIcon, getAssetIcon } from "../../shared/assetLookup" +import { caipToIcon, getAssetIcon, isBundledAssetIcon } from "../../shared/assetLookup" interface AssetIconProps { /** Asset CAIP (token CAIP for ERC-20s, chain CAIP for natives) */ @@ -37,12 +37,25 @@ const FALLBACK_BG = "rgba(255,255,255,0.06)" * positioned with overflow:hidden + the symbol initial gives a * predictable shape at every size. */ export function AssetIcon({ caip, iconUrl, chainCaip, size, alt, ring }: AssetIconProps) { - const mainSrc = iconUrl || (caip ? getAssetIcon(caip) : undefined) + const lookupSrc = caip ? getAssetIcon(caip) : undefined + const mainSources = useMemo(() => { + // Packaged assets win over remote metadata. For uncatalogued assets, try + // the metadata URL first and then KeepKey's CAIP-derived CDN URL. + const candidates = isBundledAssetIcon(lookupSrc) + ? [lookupSrc, iconUrl] + : [iconUrl, lookupSrc] + return candidates.filter((item, index): item is string => + !!item && candidates.indexOf(item) === index) + }, [iconUrl, lookupSrc]) const showBadge = !!chainCaip && chainCaip !== caip && size >= 24 const badgeSize = Math.max(12, Math.round(size * 0.38)) - const [mainBroken, setMainBroken] = useState(false) + const [mainAttempt, setMainAttempt] = useState(0) const [badgeBroken, setBadgeBroken] = useState(false) const initial = (alt || '?').trim().charAt(0).toUpperCase() || '?' + const mainSrc = mainSources[mainAttempt] + + useEffect(() => { setMainAttempt(0) }, [caip, iconUrl]) + useEffect(() => { setBadgeBroken(false) }, [chainCaip]) return ( @@ -58,13 +71,14 @@ export function AssetIcon({ caip, iconUrl, chainCaip, size, alt, ring }: AssetIc justifyContent="center" position="relative" > - {mainSrc && !mainBroken ? ( + {mainSrc ? ( setMainBroken(true)} + objectFit="cover" + onError={() => setMainAttempt(attempt => attempt + 1)} /> ) : ( { setSelectedToken(tok); setView('send') }} > - {tok.symbol} @@ -821,15 +820,12 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi {selectedToken ? ( <> - {selectedToken.symbol} diff --git a/projects/keepkey-vault/src/mainview/components/Dashboard.tsx b/projects/keepkey-vault/src/mainview/components/Dashboard.tsx index c69c4616..6e1ccaee 100644 --- a/projects/keepkey-vault/src/mainview/components/Dashboard.tsx +++ b/projects/keepkey-vault/src/mainview/components/Dashboard.tsx @@ -33,10 +33,23 @@ import { useDashboardView } from "../lib/dashboardViewContext" import { useFiat } from "../lib/fiat-context" import { ViewPickerButton } from "./ViewPickerMenu" import { DashboardLoading } from "./DashboardLoading" +import { AssetIcon } from "./AssetIcon" import { categorizeTokens } from "../../shared/spamFilter" import { useBtcAccounts } from "../hooks/useBtcAccounts" import { useEvmAddresses } from "../hooks/useEvmAddresses" import type { ChainBalance, CustomChain, TokenVisibilityStatus, AppSettings, TokenBalance, PendingSwap, SwapStatusUpdate, AuditPortfolioSnapshot } from "../../shared/types" +import { + beginSwapBalanceReconciliation, + reconcileTerminalSwapBalance, + expireBalanceReconciliations, + observeBalanceRefresh, + protectedBalanceChainIds, + mergeTrustedBalanceSnapshot, + shouldReplaceBalanceSnapshot, + RECONCILIATION_CEILING_MS, + type BalanceReconciliation, + type SwapExecutionBalanceDetail, +} from "../../shared/balance-reconciliation" import { playChaChing } from "../lib/sounds" /** Error boundary wrapping AssetPage — ensures user can always go back to Dashboard */ @@ -423,14 +436,11 @@ function TokenSatellite({ cursor="pointer" aria-label={tok.symbol} > - {tok.symbol} {isHover && ( >(new Map()) + const balancesRef = useRef(balances) + useEffect(() => { balancesRef.current = balances }, [balances]) + const [balanceReconciliations, setBalanceReconciliations] = useState([]) + const balanceReconciliationsRef = useRef([]) + const externalDestinationTxidsRef = useRef(new Set()) + const completedReconciliationTxidsRef = useRef(new Set()) + const commitBalanceReconciliations = useCallback(( + update: BalanceReconciliation[] | ((current: BalanceReconciliation[]) => BalanceReconciliation[]), + ) => { + const next = typeof update === 'function' ? update(balanceReconciliationsRef.current) : update + balanceReconciliationsRef.current = next + setBalanceReconciliations(next) + }, []) // Per-account (BTC) and per-address (EVM) balances for the sidebar account drop-down. // The Bun side already derives + tracks these; we only render what's already there. const { btcAccounts: btcAccountSet, selectXpub: btcSelectXpub } = useBtcAccounts() @@ -768,6 +791,28 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin // the bulk request was in-flight (prevents old getBalances response from overwriting newer data). const chainLastUpdatedRef = useRef(new Map()) + // Capture a destination baseline as soon as a swap is broadcast. This must + // happen before any post-transaction portfolio refresh can replace the + // source-chain row with an indexer snapshot that does not contain the output. + useEffect(() => { + const handler = (event: Event) => { + const detail = (event as CustomEvent).detail + if (!detail?.txid) return + if (detail.isWalletDestination === false) { + externalDestinationTxidsRef.current.add(detail.txid) + return + } + const record = beginSwapBalanceReconciliation(detail, balancesRef.current) + if (!record) return + commitBalanceReconciliations(current => [ + ...current.filter(item => item.txid !== record.txid), + record, + ]) + } + window.addEventListener('keepkey-swap-executed', handler) + return () => window.removeEventListener('keepkey-swap-executed', handler) + }, [commitBalanceReconciliations]) + // Fetch pending swaps on mount and keep them live via swap-update messages. // Non-terminal swaps mean funds are in-flight — we surface a banner so the // user knows their balance isn't missing, it's in a swap. @@ -1066,9 +1111,9 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin // Manual refresh: fetch live data from Pioneer API // forceRefresh=true bypasses Pioneer's balance cache — only pass it on explicit user action - // swapDestCaip: CAIP-19 of a just-received swap output token — forwarded so the - // backend asks Pioneer for a direct on-chain balanceOf (indexers lag post-swap). - const refreshBalances = useCallback(async (forceRefresh = false, swapDestCaip?: string) => { + // swapDestCaips: CAIP-19 ids of just-received swap outputs — forwarded so the + // backend can verify token balances directly while portfolio indexers catch up. + const refreshBalances = useCallback(async (forceRefresh = false, swapDestCaips: string[] = []) => { // Watch-only: no device, so re-fetch from Pioneer using cached addresses. if (watchOnly) { if (loadingBalancesRef.current) return @@ -1077,9 +1122,14 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin try { const result = await rpcRequest('refreshWatchOnlyBalances', watchOnlyDeviceId ? { deviceId: watchOnlyDeviceId } : undefined, 200000) if (result) { - const map = new Map() - for (const b of result) map.set(b.chainId, b) - setBalances(map) + setBalances(prev => { + const map = new Map(prev) + for (const b of result) { + if (shouldReplaceBalanceSnapshot(map.get(b.chainId), b, false)) map.set(b.chainId, b) + } + balancesRef.current = map + return map + }) setCacheUpdatedAt(Date.now()) setHasEverRefreshed(true) clearPioneerError() @@ -1105,7 +1155,7 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin setLoadingBalances(true) try { - const result = await rpcRequest('getBalances', swapDestCaip ? { forceRefresh, swapDestCaips: [swapDestCaip] } : { forceRefresh }, 200000) + const result = await rpcRequest('getBalances', swapDestCaips.length ? { forceRefresh, swapDestCaips } : { forceRefresh }, 200000) if (result && gen === refreshGenRef.current) { const tokenTotal = result.reduce((n, b) => n + (b.tokens?.length || 0), 0) const balTotal = result.reduce((n, b) => n + (b.balanceUsd || 0), 0) @@ -1119,14 +1169,24 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin } } } - // Merge: skip chains where a per-chain balance-updated arrived AFTER this - // full refresh started — that single-chain data is fresher than our bulk result. + const reconciled = observeBalanceRefresh(balanceReconciliationsRef.current, result) + const protectedChains = protectedBalanceChainIds(reconciled) + commitBalanceReconciliations(reconciled) + // Merge only trusted rows. Stale/degraded data remains available to + // explain the fault, but never replaces a last-known-good snapshot. + // A swap destination stays protected until its output is confirmed. setBalances(prev => { const map = new Map(prev) for (const b of result) { if ((chainLastUpdatedRef.current.get(b.chainId) ?? 0) > refreshStartedAt) continue - map.set(b.chainId, b) + const merged = mergeTrustedBalanceSnapshot( + map.get(b.chainId), + b, + protectedChains.has(b.chainId), + ) + if (merged) map.set(b.chainId, merged) } + balancesRef.current = map return map }) setCacheUpdatedAt(Date.now()) @@ -1145,7 +1205,7 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin loadingBalancesRef.current = false setLoadingBalances(false) } - }, [watchOnly, watchOnlyDeviceId, clearPioneerError, stagePioneerError]) + }, [watchOnly, watchOnlyDeviceId, clearPioneerError, stagePioneerError, commitBalanceReconciliations]) // Phase 2 trigger — window focus: catch long idle periods. If the last live // fetch is older than 5 min, force-refresh on return to the window. @@ -1237,29 +1297,81 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin } }, [forceRefresh, initialLoaded, hasEverRefreshed, loadingBalances, refreshBalances, onForceRefreshConsumed]) - // Auto-refresh balances when a swap completes (both chains affected). - // Force-refresh bypasses Pioneer's balance cache since we know balances changed. - // Delayed retries catch Pioneer's indexer lag (1-2 blocks after confirmation). + // A protocol can report terminal before its portfolio index has observed the + // output. Keep the reconciliation banner and last trusted chain snapshot + // until a confirmed response (or direct token RPC check) proves the asset. useEffect(() => { - let t1: ReturnType - let t2: ReturnType + const timers = new Set>() + const reconcileSwap = (swap: PendingSwap) => { + if (swap?.status !== 'completed' && swap?.status !== 'refunded' && swap?.status !== 'failed') return + if (!swap?.txid || completedReconciliationTxidsRef.current.has(swap.txid)) return + completedReconciliationTxidsRef.current.add(swap.txid) + if (swap.status === 'failed') { + commitBalanceReconciliations(current => + reconcileTerminalSwapBalance(current, swap, balancesRef.current), + ) + externalDestinationTxidsRef.current.delete(swap.txid) + console.log(`[Dashboard] Swap failed — released balance reconciliation for ${swap.txid}`) + return + } + const externalDestination = externalDestinationTxidsRef.current.has(swap.txid) + if (!externalDestination) { + commitBalanceReconciliations(current => + reconcileTerminalSwapBalance(current, swap, balancesRef.current), + ) + } + const reconcileCaip = swap.status === 'refunded' ? swap.fromCaip : swap.toCaip + const assets = reconcileCaip ? [reconcileCaip] : [] + console.log(`[Dashboard] Swap terminal — reconciling ${reconcileCaip || swap.toChainId}`) + refreshBalances(true, assets) + for (const delay of [30_000, 90_000]) { + const timer = setTimeout(() => { + timers.delete(timer) + refreshBalances(true, assets) + }, delay) + timers.add(timer) + } + } const handler = (e: Event) => { - console.log('[Dashboard] Swap completed — force-refreshing balances') - // EVM token output: pass its CAIP so Pioneer does a direct on-chain - // balanceOf instead of trusting the (still-lagging) indexer. - const toCaip: string | undefined = (e as CustomEvent).detail?.toCaip - const destTokenCaip = typeof toCaip === 'string' && toCaip.includes('/erc20:') ? toCaip : undefined - refreshBalances(true, destTokenCaip) - t1 = setTimeout(() => refreshBalances(true, destTokenCaip), 30000) - t2 = setTimeout(() => refreshBalances(true, destTokenCaip), 90000) + const detail = (e as CustomEvent).detail + reconcileSwap(detail?.swap || detail) } + const unsubscribe = onRpcMessage('swap-complete', reconcileSwap) window.addEventListener('keepkey-swap-completed', handler) return () => { + unsubscribe() window.removeEventListener('keepkey-swap-completed', handler) - clearTimeout(t1) - clearTimeout(t2) + for (const timer of timers) clearTimeout(timer) } - }, [refreshBalances]) + }, [refreshBalances, commitBalanceReconciliations]) + + // Expire reconciliation protection at its wall-clock deadline even if every + // balance refresh fails or a refresh is still active. Network state must not + // control the 15-minute UI/protection ceiling. + useEffect(() => { + const waiting = balanceReconciliations.filter(item => item.terminal && !item.observed && !item.expired) + if (waiting.length === 0) return + const nextDeadline = Math.min(...waiting.map( + item => (item.completedAt ?? item.startedAt) + RECONCILIATION_CEILING_MS, + )) + const timer = setTimeout(() => { + commitBalanceReconciliations(current => expireBalanceReconciliations(current)) + }, Math.max(0, nextDeadline - Date.now()) + 1) + return () => clearTimeout(timer) + }, [balanceReconciliations, commitBalanceReconciliations]) + + // Keep retrying at a low cadence if a terminal swap is still waiting on a + // trusted balance response after the eager 0/30/90-second refreshes. + useEffect(() => { + const waiting = balanceReconciliations.filter(item => item.terminal && !item.observed && !item.expired) + if (watchOnly || waiting.length === 0) return + const timer = setInterval(() => { + if (!loadingBalancesRef.current) { + refreshBalances(true, waiting.flatMap(item => item.assetCaip ? [item.assetCaip] : [])) + } + }, 120_000) + return () => clearInterval(timer) + }, [balanceReconciliations, watchOnly, refreshBalances]) // SSE stream status updates from backend @@ -1282,21 +1394,31 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin return onRpcMessage("balance-updated", (updated: ChainBalance) => { const receivedAt = Date.now() chainLastUpdatedRef.current.set(updated.chainId, receivedAt) + const reconciled = observeBalanceRefresh(balanceReconciliationsRef.current, [updated]) + const protectedChains = protectedBalanceChainIds(reconciled) + commitBalanceReconciliations(reconciled) setBalances(prev => { + const merged = mergeTrustedBalanceSnapshot( + prev.get(updated.chainId), + updated, + protectedChains.has(updated.chainId), + ) + if (!merged) return prev const old = prev.get(updated.chainId) const oldUsd = old?.balanceUsd ?? 0 - const newUsd = updated.balanceUsd ?? 0 + const newUsd = merged.balanceUsd ?? 0 // Cha-ching when balance increased if (newUsd > oldUsd && oldUsd > 0) { playChaChing() } const next = new Map(prev) - next.set(updated.chainId, updated) + next.set(updated.chainId, merged) + balancesRef.current = next return next }) setCacheUpdatedAt(receivedAt) }) - }, []) + }, [commitBalanceReconciliations]) // Seed-staleness purge: the backend detected the wallet data belonged to a // DIFFERENT seed than the device (passphrase toggle, hidden↔standard @@ -1307,13 +1429,17 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin return onRpcMessage("wallet-data-purged", ({ reason }: { reason: string }) => { console.warn(`[Dashboard] Wallet data purged (${reason}) — clearing displayed balances`) chainLastUpdatedRef.current.clear() + commitBalanceReconciliations([]) + externalDestinationTxidsRef.current.clear() + completedReconciliationTxidsRef.current.clear() + balancesRef.current = new Map() setBalances(new Map()) // If a fetch is already in flight it self-heals (the purge ran at its // start, before any derivation) and its result repopulates the cleared // map. Otherwise force-refresh now. if (!loadingBalancesRef.current) refreshBalances(true) }) - }, [refreshBalances]) + }, [refreshBalances, commitBalanceReconciliations]) const cleanBalanceUsd = useMemo(() => { const overrides = new Map( @@ -2036,6 +2162,86 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin )} + {/* Protocol completion and portfolio reconciliation are distinct. + Keep this non-dismissible until a trusted balance confirms output. */} + {balanceReconciliations + .filter(item => item.terminal && !item.observed && !item.expired) + .map(item => ( + + + + + + {t("swapBalanceSyncTitle", { + symbol: item.symbol, + defaultValue: `Swap complete — syncing ${item.symbol} balance`, + })} + + + {t("swapBalanceSyncDesc", { + defaultValue: "Keeping your last verified balances visible until the new funds are independently confirmed.", + })} + + + + + ))} + + {/* Ceiling reached: we couldn't independently confirm the output within + the reconciliation window (e.g. a lagging single-node indexer). + Stop spinning, surface the real balance, and let the user dismiss. */} + {balanceReconciliations + .filter(item => item.terminal && !item.observed && item.expired) + .map(item => ( + + + + + {t("swapBalanceUnconfirmedTitle", { + symbol: item.symbol, + defaultValue: `Couldn't confirm your ${item.symbol} balance yet`, + })} + + + {t("swapBalanceUnconfirmedDesc", { + defaultValue: "The swap completed on-chain. Your balance service hasn't reported the new funds yet — check the block explorer if it doesn't appear shortly.", + })} + + + commitBalanceReconciliations(current => current.filter(r => r.txid !== item.txid))} + fontSize="14px" + color="kk.textMuted" + px="1" + flexShrink={0} + _hover={{ color: "kk.gold" }} + > + ✕ + + + + ))} + {/* In-flight swap banners — shown when funds are in a pending swap so users don't think their balance is missing */} {activeSwaps.map(swap => ( @@ -2740,14 +2946,12 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin border="0" title={tok.name || tok.symbol} > - {tok.symbol} diff --git a/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx b/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx index 5b123c8e..f2da72f6 100644 --- a/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx +++ b/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx @@ -26,10 +26,20 @@ import { ProviderBadge, ProverChip, resolveProvider } from "./ProviderBadge" import { getSwapperAnimation } from "../lib/swapper-animations" import { computeDustWarning, shouldWarnHighSlippage, computeEffectiveSlippageBps } from "../../shared/swap-warnings" import { useEvmAddresses } from "../hooks/useEvmAddresses" +import { useDeviceState } from "../hooks/useDeviceState" +import { versionCompare } from "../../shared/firmware-versions" import { AssetPickerDialog } from "./AssetPickerDialog" import { networkDisplayName, ellipsizeCaip, parseCaip } from "../../shared/swap-discovery" import { isSymbolSquatter } from "../../shared/symbolSquatter" import { shouldRetryCompletedSwapMetadata } from "../../shared/swap-tracker-guards" +import { + DEFAULT_SLIPPAGE_BPS, + MAX_SLIPPAGE_BPS, + MIN_SLIPPAGE_BPS, + SLIPPAGE_PRESETS_BPS, + normalizeSlippageBps, + parseCustomSlippagePercent, +} from "../../shared/slippage" import { KeepKeyDevice, RouteMap, SpinningDevice } from "./v3" import calculatingGif from "../assets/swap/calculating.gif" import shiftingGif from "../assets/swap/shifting.gif" @@ -141,6 +151,15 @@ function nativePriceUsd(balance: ChainBalance): number { return nativeUsd > 0 ? nativeUsd / bal : 0 } +// nativePriceUsd(cb) is the chain GAS asset's price (nativeBalanceUsd / balance). +// It's only a valid price for `asset` when `asset` IS that native asset. THORChain +// / Cosmos sub-denoms (TCY, RUJI, xRUJI, secured assets) share the chain's single +// ChainBalance but trade at their own price — pricing them as native values them at +// the gas asset (e.g. 102 TCY @ RUNE's $0.42 = $43 instead of the real ~$12). +function balanceIsForAsset(cb: ChainBalance, asset: SwapAsset): boolean { + return !!cb.symbol && !!asset.symbol && cb.symbol.toUpperCase() === asset.symbol.toUpperCase() +} + // Extended-pubkey prefix regex. Used in two places: (1) the UTXO destination // resolver, to decide whether the cached balance address is actually an xpub // that needs re-derivation; (2) canQuote, to refuse to send an xpub to Pioneer @@ -728,6 +747,7 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap, const { t } = useTranslation("swap") const { fmtCompact, symbol: fiatSymbol } = useFiat() const { evmAddresses } = useEvmAddresses() + const { firmwareVersion } = useDeviceState() // Local EVM address selection — scoped to this dialog so switching here doesn't // mutate the global selected index in AssetPage. null = use global selectedIndex. const [evmAddressIndexOverride, setEvmAddressIndexOverride] = useState(null) @@ -772,11 +792,11 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap, // only then do we offer Retry. Deterministic errors (pool unavailable, amount // below minimum) are not retryable: retrying just repeats the same failure. const [quoteRetryable, setQuoteRetryable] = useState(false) - // Solana blind-signing gate: when a Solana swap is blocked by the device's - // disabled AdvancedMode policy, phase flips to 'blind-signing-required' and - // this drives the enable page. + // Solana opaque-signing gate: a Relay route without signed ClearSign metadata + // flips to a route-specific, one-request consent page. const [enablingBlindSign, setEnablingBlindSign] = useState(false) const [blindSignError, setBlindSignError] = useState(null) + const [allowSolanaBlindSigning, setAllowSolanaBlindSigning] = useState(false) const [txid, setTxid] = useState(null) const [copied, setCopied] = useState(false) // Bump to force the quote useEffect to re-run with the same inputs (used by @@ -790,15 +810,36 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap, try { const raw = localStorage.getItem('swap.slippageBps') const n = raw ? parseInt(raw, 10) : NaN - if (Number.isFinite(n) && n >= 10 && n <= 5000) return n + if (Number.isFinite(n) && n >= MIN_SLIPPAGE_BPS && n <= MAX_SLIPPAGE_BPS) return n } catch { /* localStorage unavailable */ } - return 100 // 1% default + return DEFAULT_SLIPPAGE_BPS }) const setSlippageBps = useCallback((bps: number) => { - const clamped = Math.max(10, Math.min(5000, Math.round(bps))) + const clamped = normalizeSlippageBps(bps) setSlippageBpsState(clamped) try { localStorage.setItem('swap.slippageBps', String(clamped)) } catch { /* ignore */ } }, []) + const [customSlippageOpen, setCustomSlippageOpen] = useState(false) + const [customSlippageInput, setCustomSlippageInput] = useState(() => (slippageBps / 100).toString()) + const [customSlippageError, setCustomSlippageError] = useState(null) + + const openCustomSlippage = useCallback(() => { + setCustomSlippageInput((slippageBps / 100).toString()) + setCustomSlippageError(null) + setCustomSlippageOpen(true) + }, [slippageBps]) + + const updateCustomSlippage = useCallback((raw: string) => { + setCustomSlippageInput(raw) + const parsed = parseCustomSlippagePercent(raw) + if (!parsed.ok) { + setCustomSlippageError(parsed.error) + return false + } + setCustomSlippageError(null) + setSlippageBps(parsed.bps) + return true + }, [setSlippageBps]) // ── ExecuteSwap substage (retro #1 fix) ──────────────────────── // Coarse `phase` is approving/signing/broadcasting. For ERC-20 swaps that @@ -1516,7 +1557,9 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap, const token = candidate.tokens?.find(t => t.contractAddress?.toLowerCase() === fromAsset.contractAddress?.toLowerCase()) return (token?.priceUsd || 0) > 0 } - return nativePriceUsd(candidate) > 0 + // Prefer the candidate that actually represents this asset (e.g. the RUJI + // ChainBalance passed as a prop) over the chain's aggregated native balance. + return balanceIsForAsset(candidate, fromAsset) && nativePriceUsd(candidate) > 0 }) || cachedBalance || propBalance if (!cb) { swapLog(`[SWAP-PRICE] fromPriceUsd: no balance for chainId=${fromAsset.chainId}`); return 0 } // Token assets: only ever use the token's own price. Falling through to the @@ -1533,6 +1576,12 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap, const bal = parseFloat(cb.balance) const nativeUsd = nativeUsdValue(cb) swapLog(`[SWAP-PRICE] fromPriceUsd: ${fromAsset.symbol} chainId=${fromAsset.chainId} bal=${bal} nativeUsd=${nativeUsd} nativeBalanceUsd=${cb.nativeBalanceUsd} balanceUsd=${cb.balanceUsd} tokens=${cb.tokens?.length || 0}`) + // Never apply the chain's native price to a non-native sub-denom — return 0 + // so the caller degrades (or the TO side derives price from the quote ratio). + if (!balanceIsForAsset(cb, fromAsset)) { + swapLog(`[SWAP-PRICE] fromPriceUsd: ${fromAsset.symbol} is not the native asset of ${cb.symbol} balance — returning 0`) + return 0 + } const result = nativePriceUsd(cb) swapLog(`[SWAP-PRICE] fromPriceUsd RESULT: $${result}`) return result @@ -1552,6 +1601,13 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap, const bal = parseFloat(cb.balance) const nativeUsd = nativeUsdValue(cb) swapLog(`[SWAP-PRICE] toPriceUsdFromBalance: ${toAsset.symbol} chainId=${toAsset.chainId} bal=${bal} nativeUsd=${nativeUsd} nativeBalanceUsd=${cb.nativeBalanceUsd} balanceUsd=${cb.balanceUsd} tokens=${cb.tokens?.length || 0}`) + // Non-native THORChain/Cosmos denom (TCY, RUJI, …) received into a chain whose + // ChainBalance is the gas asset (RUNE): the native price is NOT this asset's + // price. Return 0 so toPriceUsd derives it from the quote ratio + fromPriceUsd. + if (!balanceIsForAsset(cb, toAsset)) { + swapLog(`[SWAP-PRICE] toPriceUsdFromBalance: ${toAsset.symbol} is not the native asset of ${cb.symbol} balance — returning 0`) + return 0 + } const result = nativePriceUsd(cb) swapLog(`[SWAP-PRICE] toPriceUsdFromBalance RESULT: $${result}`) return result @@ -1788,6 +1844,25 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap, // address that didn't come from the user's wallet. Wait for the UTXO // resolver to populate a real receive address. const toAddressIsXpub = !!toAddress && !useCustomAddress && XPUB_RE.test(toAddress) + + // One-time opaque consent is tied to the exact quoted route and user inputs. + // Any amount/address/slippage/serialized-transaction change invalidates it. + const solanaOpaqueConsentKey = [ + fromAsset?.caip, + toAsset?.caip, + amount, + slippageBps, + fromAddress, + toAddress, + quote?.relayTx?.serializedTx, + ].join('|') + const previousSolanaOpaqueConsentKey = useRef(solanaOpaqueConsentKey) + useEffect(() => { + if (previousSolanaOpaqueConsentKey.current !== solanaOpaqueConsentKey) { + previousSolanaOpaqueConsentKey.current = solanaOpaqueConsentKey + setAllowSolanaBlindSigning(false) + } + }, [solanaOpaqueConsentKey]) const canQuote = !!(fromAsset && toAsset && !sameAsset && validAmount && fromAddress && toAddress && !toAddressIsXpub && !exceedsBalance && !exceedsSafeMax && !customAddressError) // Human reason a quote can't fire right now, but ONLY for the cases that @@ -2121,6 +2196,7 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap, integration: liveQuote.integration, swapper: liveQuote.swapper, relayTx: liveQuote.relayTx, + allowSolanaBlindSigning, // Same as the preview build: synthesized token sources need the // picker's decimals — Pioneer's available-assets won't have them. tokenDecimals: isTokenCaip(fromAsset.caip!) ? normalizeDecimals(fromAsset.decimals) ?? undefined : undefined, @@ -2128,11 +2204,25 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap, setTxid(result.txid) setPhase('submitted') - window.dispatchEvent(new CustomEvent('keepkey-swap-executed')) + window.dispatchEvent(new CustomEvent('keepkey-swap-executed', { + detail: { + txid: result.txid, + fromChainId: fromAsset.chainId, + toChainId: toAsset.chainId, + fromCaip: fromAsset.caip, + toCaip: toAsset.caip, + fromSymbol: fromAsset.symbol, + toSymbol: toAsset.symbol, + expectedOutput: liveQuote.expectedOutput, + // Custom destinations intentionally leave the connected wallet. Do + // not wait for an output that should never appear in its portfolio. + isWalletDestination: !useCustomAddress, + }, + })) } catch (e: any) { const raw = e?.message || '' - // Solana swap blocked because the device's blind-signing (AdvancedMode) - // policy is off — show the dedicated enable page instead of an error. + // This exact Relay payload has no signed ClearSign descriptor and needs + // route-specific, one-request opaque consent. if (raw.includes(SOLANA_BLIND_SIGNING_REQUIRED)) { setBlindSignError(null) setPhase('blind-signing-required') @@ -2158,7 +2248,7 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap, setError(friendly) setPhase('review') } - }, [quote, quoteFetchedAt, fromAsset, toAsset, sendAmount, sendIsMax, fromBalance, fromAddress, toAddress, slippageBps, balances, phase, previewLoading, previewError, previewBuild]) + }, [quote, quoteFetchedAt, fromAsset, toAsset, sendAmount, sendIsMax, fromBalance, fromAddress, toAddress, slippageBps, balances, phase, previewLoading, previewError, previewBuild, allowSolanaBlindSigning, useCustomAddress]) // ── Reset ───────────────────────────────────────────────────────── const reset = useCallback(() => { @@ -2174,6 +2264,7 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap, setError(null) setBlindSignError(null) setEnablingBlindSign(false) + setAllowSolanaBlindSigning(false) setTxid(null) setBeforeFromBal(null) setBeforeToBal(null) @@ -2211,27 +2302,17 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap, setError(t('swapCancelled', 'Swap cancelled — confirm again or change inputs')) }, [phase, t]) - // Enable the device's AdvancedMode (blind-signing) policy so Solana swaps can - // be signed. applyPolicy prompts the user to confirm on device. On success we - // return to review so the user re-verifies the quote before signing. + // Authorize only the pending Solana transaction to use the opaque fallback. + // The authorization travels with one SolanaSignTx request and does not mutate + // the device's persistent, global AdvancedMode policy. const handleEnableBlindSigning = useCallback(async () => { setEnablingBlindSign(true) setBlindSignError(null) - try { - await rpcRequest('applyPolicy', { policyName: 'AdvancedMode', enabled: true }, 60000) - setError(null) - setPhase('review') - } catch (e: any) { - const raw = e?.message || '' - setBlindSignError( - /cancel/i.test(raw) - ? t('blindSignDeclined', 'Declined on device — blind signing was not enabled.') - : (raw || t('blindSignEnableFailed', 'Failed to enable blind signing on the device.')), - ) - } finally { - setEnablingBlindSign(false) - } - }, [t]) + setAllowSolanaBlindSigning(true) + setError(null) + setPhase('review') + setEnablingBlindSign(false) + }, []) const copyTxid = useCallback(() => { if (!txid) return @@ -3582,10 +3663,10 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap, - {t('blindSignTitle', 'Enable Blind Signing for Solana')} + {t('solanaOpaqueTitle', 'One-time Blind Signing for Solana')} - {t('blindSignBody', 'Solana swaps use a versioned transaction your KeepKey cannot fully decode, so it must be blind-signed. This is turned off by default. Enabling it switches on Advanced Mode (blind signing) on your device — you will be asked to confirm the change on the device.')} + {t('solanaOpaqueBody', 'This Relay transaction uses a custom Solana program and lookup-table accounts that your KeepKey cannot fully verify. No signed ClearSign descriptor was supplied for the route, so this transaction requires the opaque fallback.')} @@ -3597,7 +3678,7 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap, - {t('blindSignCaveat', 'Blind signing means the device shows a generic prompt instead of decoded details. Only proceed for swaps you initiated here. You can turn it back off in Device Settings → Signing Policy.')} + {t('solanaOpaqueCaveat', 'Blind signing means the device shows a generic prompt instead of authenticated swap details. This approval applies only to this transaction and does not enable global Advanced Mode. Confirm only the swap you reviewed here.')} @@ -3616,10 +3697,10 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap, @@ -3970,11 +4051,14 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap, ) })()} - {/* TRON blind-sign warning OR verify-on-device note */} - {fromAsset.chainFamily === 'tron' ? ( + {/* Firmware 7.15+ parses native TRX and TRC-20 transfers from the + signed raw_data. Only older firmware gets the legacy generic + warning; TRON as the destination never affects source signing. */} + {fromAsset.chainFamily === 'tron' && + (!firmwareVersion || versionCompare(firmwareVersion, '7.15.0') < 0) ? ( - {t("tronBlindSignWarning", "Your KeepKey will display a generic Tron transaction prompt — it cannot decode the THORChain swap. Verify the amounts, vault, and memo above before approving on device.")} + {t("tronLegacySigningWarning", "Firmware before 7.15 may show a generic Tron prompt. Update firmware for native TRX and TRC-20 ClearSign details, or verify the complete payload above before approving.")} ) : ( @@ -4594,14 +4678,17 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap, {/* Slippage tolerance — visible whenever a swap target is selected. - Bps math: 50 = 0.5%, 100 = 1%, 300 = 3%. Custom prompts for a value. */} + Bps math: 50 = 0.5%, 100 = 1%, 300 = 3%. Custom uses an inline + controlled input because native window.prompt is unavailable in + some Electron/WebView builds. */} {fromAsset && toAsset && ( - - - {t("slippage") || "Slippage"} - - - {[50, 100, 300].map(bps => { + + + + {t("slippage") || "Slippage"} + + + {SLIPPAGE_PRESETS_BPS.map(bps => { const active = slippageBps === bps return ( setSlippageBps(bps)}> + onClick={() => { + setCustomSlippageOpen(false) + setCustomSlippageError(null) + setSlippageBps(bps) + }}> {(bps / 100).toFixed(bps < 100 ? 1 : 0)}% ) })} - { - const raw = prompt(`${t("slippage") || "Slippage"} %`, (slippageBps / 100).toString()) - if (raw == null) return - const pct = parseFloat(raw) - if (!Number.isFinite(pct) || pct <= 0 || pct > 50) { - alert("Enter a percentage between 0.1 and 50") - return - } - setSlippageBps(Math.round(pct * 100)) - }}> - {![50, 100, 300].includes(slippageBps) ? `${(slippageBps / 100).toFixed(2)}%` : (t("custom") || "Custom")} - - - + {customSlippageOpen ? ( + + updateCustomSlippage(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && updateCustomSlippage(customSlippageInput)) { + setCustomSlippageOpen(false) + } else if (e.key === 'Escape') { + setCustomSlippageOpen(false) + setCustomSlippageError(null) + } + }} + inputMode="decimal" + fontSize="10px" + fontWeight="700" + color="kk.textPrimary" + p="0" + h="20px" + minW="0" + border="0" + outline="none" + _focus={{ boxShadow: "none", border: "0" }} + /> + % + + ) : ( + + {!SLIPPAGE_PRESETS_BPS.includes(slippageBps as typeof SLIPPAGE_PRESETS_BPS[number]) + ? `${(slippageBps / 100).toFixed(2)}%` + : (t("custom") || "Custom")} + + )} + + + {customSlippageError && ( + {customSlippageError} + )} + )} {/* Review Swap button — only when quote is ready. Gradient diff --git a/projects/keepkey-vault/src/mainview/components/SwapTracker.tsx b/projects/keepkey-vault/src/mainview/components/SwapTracker.tsx index 1a33a7d3..6ca73232 100644 --- a/projects/keepkey-vault/src/mainview/components/SwapTracker.tsx +++ b/projects/keepkey-vault/src/mainview/components/SwapTracker.tsx @@ -84,7 +84,7 @@ export function SwapTracker() { // Trigger balance refresh for both chains when swap completes if (swap.status === 'completed' || swap.status === 'refunded') { window.dispatchEvent(new CustomEvent('keepkey-swap-completed', { - detail: { fromChainId: swap.fromChainId, toChainId: swap.toChainId, toCaip: swap.toCaip } + detail: { ...swap, swap } })) } }) diff --git a/projects/keepkey-vault/src/mainview/components/device/SigningApproval.tsx b/projects/keepkey-vault/src/mainview/components/device/SigningApproval.tsx index 53a353c9..864641c7 100644 --- a/projects/keepkey-vault/src/mainview/components/device/SigningApproval.tsx +++ b/projects/keepkey-vault/src/mainview/components/device/SigningApproval.tsx @@ -9,8 +9,9 @@ import { versionCompare } from "../../../shared/firmware-versions" interface SigningApprovalProps { request: SigningRequestInfo phase: 'approve' | 'sending-payload' | 'device-confirm' - onApprove: () => void + onApprove: (approval?: { allowBlindSigning?: boolean }) => void onReject: () => void + onCancel: () => void } const METHOD_LABEL_KEYS: Record = { @@ -232,6 +233,60 @@ function SolanaBlindSignBanner({ t }: { t: (k: string, f?: string) => string }) ) } +function SolanaBlindSigningConsent({ + granted, + onGrant, + t, +}: { + granted: boolean + onGrant: () => void + t: (k: string, f?: string) => string +}) { + return ( + + + + + {granted + ? t("signing.solanaBlindSignAllowedOnce", "Blind signing allowed for this request") + : t("signing.solanaBlindSignConsentTitle", "Opaque Solana transaction")} + + + {t( + "signing.solanaBlindSignConsentDescription", + "The Vault and device cannot fully verify this transaction. Only continue if you trust the requesting app and independently verified the payload. This permission applies once.", + )} + + + {!granted && ( + + {t("signing.allowBlindSigningOnce", "Allow once")} + + )} + + + ) +} + function SolanaUnsafeMessageBanner({ classification, t }: { classification?: SolanaMessageDecodedInfo['classification']; t: (k: string, f?: string) => string }) { @@ -559,12 +614,17 @@ function TypedDataSection({ decoded, t }: { decoded: EIP712DecodedInfo; t: (k: s // Main Component // ═══════════════════════════════════════════════════════════════════════ -export function SigningApproval({ request, phase, onApprove, onReject }: SigningApprovalProps) { +export function SigningApproval({ request, phase, onApprove, onReject, onCancel }: SigningApprovalProps) { const { t } = useTranslation("device") const [elapsed, setElapsed] = useState(0) const [advancedModeEnabled, setAdvancedModeEnabled] = useState(request.advancedModeEnabled ?? false) const [enablingPolicy, setEnablingPolicy] = useState(false) const [advancedModeError, setAdvancedModeError] = useState(null) + // Bind consent to the signing id instead of carrying a boolean between + // renders. A new request can never inherit the previous request's grant, + // even for the render before effects run. + const [blindSigningConsentRequestId, setBlindSigningConsentRequestId] = useState(null) + const blindSigningConsentGranted = blindSigningConsentRequestId === request.id // Only show blind-signing warnings on firmware 7.14.0+ const fwSupportsBlindSignGate = request.firmwareVersion @@ -605,9 +665,14 @@ export function SigningApproval({ request, phase, onApprove, onReject }: Signing request.chain === 'solana' const isSimpleTransfer = !hasCalldata && !request.typedDataDecoded && !request.ethMessageDecoded && !request.solanaMessageDecoded && !isSolanaRequest - const advancedModeRequired = isSolanaSignMessage || !!request.requiresAdvancedMode || (fwSupportsBlindSignGate && !!request.needsBlindSigning) + const blindSigningConsentRequired = !!request.requiresBlindSigningConsent + const advancedModeRequired = + isSolanaSignMessage + || !!request.requiresAdvancedMode + || (fwSupportsBlindSignGate && !!request.needsBlindSigning && !blindSigningConsentRequired) const advancedModeBlocked = advancedModeRequired && !advancedModeEnabled - const approveDisabled = enablingPolicy || advancedModeBlocked + const blindSigningConsentBlocked = blindSigningConsentRequired && !blindSigningConsentGranted + const approveDisabled = enablingPolicy || advancedModeBlocked || blindSigningConsentBlocked const [showAdvancedConfirm, setShowAdvancedConfirm] = useState(false) @@ -629,17 +694,29 @@ export function SigningApproval({ request, phase, onApprove, onReject }: Signing setShowAdvancedConfirm(false) }, [showAdvancedConfirm, t]) + const submitApproval = useCallback(() => { + if (approveDisabled) return + onApprove({ + allowBlindSigning: blindSigningConsentRequired && blindSigningConsentGranted, + }) + }, [ + approveDisabled, + blindSigningConsentGranted, + blindSigningConsentRequired, + onApprove, + ]) + useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === "Enter" && phase === 'approve') { e.preventDefault() - if (!approveDisabled) onApprove() + submitApproval() } if (e.key === "Escape" && phase === 'approve') { e.preventDefault(); onReject() } } document.addEventListener("keydown", handler) return () => document.removeEventListener("keydown", handler) - }, [onApprove, onReject, phase, approveDisabled]) + }, [onReject, phase, submitApproval]) useEffect(() => { if (phase !== 'approve') return @@ -699,6 +776,9 @@ export function SigningApproval({ request, phase, onApprove, onReject }: Signing /> ))} + ) @@ -750,6 +830,9 @@ export function SigningApproval({ request, phase, onApprove, onReject }: Signing /> ))} + ) @@ -835,6 +918,14 @@ export function SigningApproval({ request, phase, onApprove, onReject }: Signing : )} + {blindSigningConsentRequired && ( + setBlindSigningConsentRequestId(request.id)} + t={t} + /> + )} + {/* ── Two-column: decoded info (left) + tx details (right) ── */} {/* Left: decoded calldata, typed data, Solana tx, or message payload */} @@ -882,10 +973,14 @@ export function SigningApproval({ request, phase, onApprove, onReject }: Signing flex="1" bg="kk.gold" color="black" fontWeight="600" size="md" _hover={{ bg: "kk.goldHover" }} - onClick={onApprove} disabled={approveDisabled} + onClick={submitApproval} disabled={approveDisabled} cursor={approveDisabled ? "not-allowed" : "pointer"} > - {advancedModeBlocked ? t("signing.enableAdvancedModeFirst", "Enable Advanced Mode to approve") : t("signing.approve")} + {advancedModeBlocked + ? t("signing.enableAdvancedModeFirst", "Enable Advanced Mode to approve") + : blindSigningConsentBlocked + ? t("signing.allowBlindSigningOnceFirst", "Choose Allow once to approve") + : t("signing.approve")}