From df7befdb0b8724dabbc8174b4fb5c3d240ffe120 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 7 Aug 2026 19:45:28 -0300 Subject: [PATCH] feat(vault): hardware-verifiable x402 payments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-picked from the stale draft #383 (b373dfc7). Its companion commit, "chore: pin canonical x402 dependencies", is NOT taken: it moved device-protocol and hdwallet to pins that develop already contains, so it would have walked both submodules backwards for no gain. The x402 payment intent from the server is never trusted on its own — the signing path re-derives what the transaction actually does (SPL transfer, ATA, memo, PDA validity) and reconciles it against the declared intent, so the device screen and the payment request cannot disagree. Two fixes on top of the cherry-pick, both needed to hold the typecheck gate at its 636 baseline: - solana-x402.ts imported from 'node:crypto', which does not resolve under this tsconfig. Every other module here imports from 'crypto'. - Added both x402 test files to make test-unit. They were not in the target on the source branch, so they would never have run in CI. Pre-existing and left alone: getNestedValue in eip712-decoder.ts is dead code on develop already, not something this change orphaned. --- Makefile | 2 +- .../keepkey-vault/__tests__/evm-x402.test.ts | 51 ++++ .../__tests__/solana-x402.test.ts | 165 +++++++++++++ .../keepkey-vault/src/bun/eip712-decoder.ts | 27 +++ projects/keepkey-vault/src/bun/rest-api.ts | 4 + projects/keepkey-vault/src/bun/schemas.ts | 21 ++ .../keepkey-vault/src/bun/solana-consent.ts | 10 + .../keepkey-vault/src/bun/solana-signing.ts | 5 + projects/keepkey-vault/src/bun/solana-x402.ts | 220 ++++++++++++++++++ projects/keepkey-vault/src/bun/swagger.json | 27 ++- .../keepkey-vault/src/bun/walletconnect.ts | 47 ++-- 11 files changed, 559 insertions(+), 20 deletions(-) create mode 100644 projects/keepkey-vault/__tests__/evm-x402.test.ts create mode 100644 projects/keepkey-vault/__tests__/solana-x402.test.ts create mode 100644 projects/keepkey-vault/src/bun/solana-x402.ts diff --git a/Makefile b/Makefile index 6a0a3e55..738b22b0 100644 --- a/Makefile +++ b/Makefile @@ -348,7 +348,7 @@ dmg: verify-arch test: test-zcash-cli test-unit test-unit: - cd $(PROJECT_DIR) && bun test __tests__/evm-signer-verify.test.ts __tests__/swap-parsing.test.ts __tests__/engine-state-machine.test.ts __tests__/device-switch.test.ts __tests__/wizard-messaging.test.ts __tests__/solana-tx.test.ts __tests__/solana-message-parser.test.ts __tests__/solana-instruction-decoder.test.ts __tests__/solana-alt.test.ts __tests__/solana-spl-decimals.test.ts __tests__/ton-build.test.ts __tests__/tron-memo-inject.test.ts __tests__/audit-coverage.test.ts __tests__/chain-scan.test.ts __tests__/taproot-host.test.ts __tests__/recovery-ownership.test.ts src/bun/mcp.test.ts src/bun/txbuilder/hive-ops.test.ts + cd $(PROJECT_DIR) && bun test __tests__/evm-signer-verify.test.ts __tests__/swap-parsing.test.ts __tests__/engine-state-machine.test.ts __tests__/device-switch.test.ts __tests__/wizard-messaging.test.ts __tests__/solana-tx.test.ts __tests__/solana-message-parser.test.ts __tests__/solana-instruction-decoder.test.ts __tests__/solana-alt.test.ts __tests__/solana-spl-decimals.test.ts __tests__/ton-build.test.ts __tests__/tron-memo-inject.test.ts __tests__/audit-coverage.test.ts __tests__/chain-scan.test.ts __tests__/taproot-host.test.ts __tests__/recovery-ownership.test.ts __tests__/evm-x402.test.ts __tests__/solana-x402.test.ts src/bun/mcp.test.ts src/bun/txbuilder/hive-ops.test.ts cd $(PROJECT_DIR) && bun src/bun/btc-backend/core.test.ts test-integration: test-rest diff --git a/projects/keepkey-vault/__tests__/evm-x402.test.ts b/projects/keepkey-vault/__tests__/evm-x402.test.ts new file mode 100644 index 00000000..54c5b1bb --- /dev/null +++ b/projects/keepkey-vault/__tests__/evm-x402.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from 'bun:test' +import { decodeEIP712 } from '../src/bun/eip712-decoder' + +describe('x402 EVM signing presentation', () => { + test('recognizes the official EIP-3009 exact-payment shape', () => { + const decoded = decodeEIP712({ + types: { + TransferWithAuthorization: [ + { name: 'from', type: 'address' }, + { name: 'to', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'validAfter', type: 'uint256' }, + { name: 'validBefore', type: 'uint256' }, + { name: 'nonce', type: 'bytes32' }, + ], + }, + primaryType: 'TransferWithAuthorization', + domain: { + name: 'USDC', + version: '2', + chainId: 84532, + verifyingContract: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + }, + message: { + from: '0x73d0385F4d8E00C5e6504C6030F47BF6212736A8', + to: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', + value: '2000', + validAfter: '0', + validBefore: '2000000000', + nonce: '0xf3746613c2d920b5fdabc0856f2aeb2d4f88ee6037b8cc5d04a71a4462f13480', + }, + }) + + expect(decoded.operationName).toBe('x402 EIP-3009 Payment') + expect(decoded.isKnownType).toBe(true) + expect(decoded.domain).toEqual({ + name: 'USDC', + version: '2', + chainId: 84532, + verifyingContract: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + }) + expect(decoded.fields.map(field => [field.label, field.raw])).toEqual([ + ['From', '0x73d0385F4d8E00C5e6504C6030F47BF6212736A8'], + ['Pay To', '0x209693Bc6afc0C5328bA36FaF03C514EF312287C'], + ['Value', '2000'], + ['Valid After', '0'], + ['Valid Before', '2000000000'], + ['Nonce', '0xf3746613c2d920b5fdabc0856f2aeb2d4f88ee6037b8cc5d04a71a4462f13480'], + ]) + }) +}) diff --git a/projects/keepkey-vault/__tests__/solana-x402.test.ts b/projects/keepkey-vault/__tests__/solana-x402.test.ts new file mode 100644 index 00000000..8b16daa8 --- /dev/null +++ b/projects/keepkey-vault/__tests__/solana-x402.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, test } from 'bun:test' +import bs58 from 'bs58' +import { signSolanaWireTransaction } from '../src/bun/solana-signing' +import { + deriveAssociatedTokenAddress, + prepareSolanaX402DeviceMetadata, + SOLANA_MAINNET_CAIP2, +} from '../src/bun/solana-x402' +import { parseSolanaMessage } from '../src/bun/solana-tx' +import { SolanaSignRequest } from '../src/bun/schemas' +import { buildSolanaDecodedInfo } from '../src/bun/solana-clearsign' +import { requiresSolanaBlindSigningConsent } from '../src/bun/solana-consent' + +const PATH = [0x8000002c, 0x800001f5, 0x80000000, 0x80000000] +const SPONSOR = Buffer.alloc(32, 0x10) +const SIGNER = Buffer.alloc(32, 0x20) +const SOURCE = Buffer.alloc(32, 0x30) +const PAY_TO = Buffer.from([ + 0xea, 0x4a, 0x6c, 0x63, 0xe2, 0x9c, 0x52, 0x0a, + 0xbe, 0xf5, 0x50, 0x7b, 0x13, 0x2e, 0xc5, 0xf9, + 0x95, 0x47, 0x76, 0xae, 0xbe, 0xbe, 0x7b, 0x92, + 0x42, 0x1e, 0xea, 0x69, 0x14, 0x46, 0xd2, 0x2c, +]) +const USDC_MINT = bs58.decode('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v') +const TOKEN_PROGRAM = bs58.decode('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA') +const COMPUTE_PROGRAM = bs58.decode('ComputeBudget111111111111111111111111111111') +const MEMO_PROGRAM = bs58.decode('MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr') +const DESTINATION_ATA = Buffer.from([ + 0x67, 0x30, 0x2e, 0x49, 0x18, 0x94, 0xd7, 0x49, + 0x2e, 0xa6, 0xbe, 0x4f, 0x91, 0x4e, 0xa4, 0xf4, + 0x5f, 0xa1, 0x42, 0xe6, 0x45, 0x86, 0x7c, 0x91, + 0x64, 0xa2, 0x76, 0xd5, 0xdd, 0x76, 0xf0, 0x76, +]) + +const RANDOM_MEMO = '00112233445566778899aabbccddeeff' + +function x402Message(decimals = 6, lookupCount = 0, memo = RANDOM_MEMO): Buffer { + const memoBytes = Buffer.from(memo, 'utf8') + return Buffer.concat([ + Buffer.from([0x80, 2, 0, 3, 8]), + SPONSOR, + SIGNER, + SOURCE, + DESTINATION_ATA, + USDC_MINT, + COMPUTE_PROGRAM, + TOKEN_PROGRAM, + MEMO_PROGRAM, + Buffer.alloc(32, 0xbb), + Buffer.from([ + 4, + // setComputeUnitLimit(120000) + 5, 0, 5, 2, 0xc0, 0xd4, 0x01, 0, + // setComputeUnitPrice(1000 micro-lamports) + 5, 0, 9, 3, 0xe8, 0x03, 0, 0, 0, 0, 0, 0, + // TransferChecked(source, mint, destination ATA, signer), 2000 @ decimals + 6, 4, 2, 4, 3, 1, 10, 12, 0xd0, 0x07, 0, 0, 0, 0, 0, 0, decimals, + // Memo instruction header, signed by the token authority + 7, 1, 1, memoBytes.length, + ]), + memoBytes, + Buffer.from([lookupCount]), + ]) +} + +function requirements(amount = '2000') { + return { + scheme: 'exact' as const, + network: SOLANA_MAINNET_CAIP2, + asset: bs58.encode(USDC_MINT), + amount, + payTo: bs58.encode(PAY_TO), + maxTimeoutSeconds: 60, + extra: { feePayer: bs58.encode(SPONSOR) }, + } +} + +describe('x402 Solana hardware-verification boundary', () => { + test('REST schema accepts an official exact PaymentRequirements object', () => { + const parsed = SolanaSignRequest.parse({ + raw_tx: 'AA==', + x402: requirements(), + }) + expect(parsed.x402).toEqual(requirements()) + }) + + test('ATA implementation matches the independent Solana vector', () => { + expect(Buffer.from(deriveAssociatedTokenAddress(PAY_TO, USDC_MINT))).toEqual(DESTINATION_ATA) + }) + + test('validates the signed v0 transfer and produces device metadata', () => { + const metadata = prepareSolanaX402DeviceMetadata( + parseSolanaMessage(x402Message()), + requirements(), + SIGNER, + ) + expect(metadata.tokenInfo).toEqual([{ + mint: USDC_MINT, + symbol: 'USDC', + decimals: 6, + }]) + expect(Buffer.from(metadata.tokenRecipientOwners[0])).toEqual(PAY_TO) + }) + + test('official zero-LUT x402 shape is clear-signable in the Vault policy', async () => { + const wire = Buffer.concat([Buffer.from([2]), Buffer.alloc(128), x402Message()]) + const decoded = await buildSolanaDecodedInfo( + wire.toString('base64'), + async (pubkeys) => pubkeys.map(() => null), + ) + expect(decoded.version).toBe('v0') + expect(decoded.instructions).toHaveLength(4) + expect(requiresSolanaBlindSigningConsent(decoded, false)).toBe(false) + }) + + test('rejects underpayment and the USDC decimal-confusion attack', () => { + expect(() => prepareSolanaX402DeviceMetadata( + parseSolanaMessage(x402Message()), + requirements('2001'), + SIGNER, + )).toThrow('below required') + expect(() => prepareSolanaX402DeviceMetadata( + parseSolanaMessage(x402Message(2)), + requirements(), + SIGNER, + )).toThrow('USDC decimals mismatch') + }) + + test('binds a seller-provided memo and rejects a mismatched quote', () => { + const quoted = { + ...requirements(), + extra: { ...requirements().extra, memo: 'invoice-402' }, + } + expect(() => prepareSolanaX402DeviceMetadata( + parseSolanaMessage(x402Message(6, 0, 'invoice-402')), + quoted, + SIGNER, + )).not.toThrow() + expect(() => prepareSolanaX402DeviceMetadata( + parseSolanaMessage(x402Message(6, 0, 'different-invoice')), + quoted, + SIGNER, + )).toThrow('memo does not match') + }) + + test('routes verified x402 metadata through SolanaSignTx and signs the user slot', async () => { + const message = x402Message() + const wire = Buffer.concat([Buffer.from([2]), Buffer.alloc(128), message]) + let deviceRequest: any + const result = await signSolanaWireTransaction({ + addressNList: PATH, + rawTx: wire.toString('base64'), + x402: requirements(), + }, async (request) => { + deviceRequest = request + return { signature: Buffer.alloc(64, 0x5a) } + }, async () => bs58.encode(SIGNER)) + + expect(deviceRequest.tokenInfo[0].symbol).toBe('USDC') + expect(Buffer.from(deviceRequest.tokenRecipientOwners[0])).toEqual(PAY_TO) + const signed = Buffer.from(result.serializedTx, 'base64') + expect(signed.subarray(1, 65)).toEqual(Buffer.alloc(64)) + expect(signed.subarray(65, 129)).toEqual(Buffer.alloc(64, 0x5a)) + }) +}) diff --git a/projects/keepkey-vault/src/bun/eip712-decoder.ts b/projects/keepkey-vault/src/bun/eip712-decoder.ts index 5a5dacf1..ff4daf8b 100644 --- a/projects/keepkey-vault/src/bun/eip712-decoder.ts +++ b/projects/keepkey-vault/src/bun/eip712-decoder.ts @@ -73,6 +73,33 @@ interface KnownDescriptor { } const KNOWN_DESCRIPTORS: KnownDescriptor[] = [ + // x402 EVM exact — EIP-3009 TransferWithAuthorization. The facilitator pays + // gas, while these signed fields bind the payer, merchant, amount and window. + { + match: (td) => { + if (td.primaryType !== 'TransferWithAuthorization') return false + const fields = td.types?.TransferWithAuthorization + const expected = [ + ['from', 'address'], + ['to', 'address'], + ['value', 'uint256'], + ['validAfter', 'uint256'], + ['validBefore', 'uint256'], + ['nonce', 'bytes32'], + ] + return Array.isArray(fields) && fields.length === expected.length && + expected.every(([name, type], i) => fields[i]?.name === name && fields[i]?.type === type) + }, + operationName: 'x402 EIP-3009 Payment', + extract: (msg) => [ + { label: 'From', value: formatValue(msg.from, 'address'), format: 'address', raw: msg.from }, + { label: 'Pay To', value: formatValue(msg.to, 'address'), format: 'address', raw: msg.to }, + { label: 'Value', value: formatValue(msg.value, 'amount'), format: 'amount', raw: msg.value }, + { label: 'Valid After', value: formatValue(msg.validAfter, 'datetime'), format: 'datetime', raw: msg.validAfter }, + { label: 'Valid Before', value: formatValue(msg.validBefore, 'datetime'), format: 'datetime', raw: msg.validBefore }, + { label: 'Nonce', value: formatValue(msg.nonce, 'hex'), format: 'hex', raw: msg.nonce }, + ], + }, // Uniswap Permit2 — PermitSingle { match: (td) => diff --git a/projects/keepkey-vault/src/bun/rest-api.ts b/projects/keepkey-vault/src/bun/rest-api.ts index 1dc00517..73bd078f 100644 --- a/projects/keepkey-vault/src/bun/rest-api.ts +++ b/projects/keepkey-vault/src/bun/rest-api.ts @@ -2725,6 +2725,10 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 // program+instruction, so the device can decode this call // without a per-transaction attestation. schema: body.schema, + // x402 payment intent is never trusted directly: the signing + // helper matches network, sponsor, mint, amount, authority and + // destination ATA against the exact v0 message first. + x402: body.x402, allowBlindSigning: activeAllowBlindSigning, }, (request) => emuWrap( diff --git a/projects/keepkey-vault/src/bun/schemas.ts b/projects/keepkey-vault/src/bun/schemas.ts index f7eef22d..af26ac3c 100644 --- a/projects/keepkey-vault/src/bun/schemas.ts +++ b/projects/keepkey-vault/src/bun/schemas.ts @@ -188,6 +188,22 @@ export const SolanaInstructionSchema = z.object({ signerKeyId: z.number().int().min(0).max(3), }).strict() +/** x402 v2 SVM exact PaymentRequirements needed for device-verifiable payTo. */ +export const SolanaX402Requirements = z.object({ + scheme: z.literal('exact'), + network: z.literal('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'), + asset: z.string().min(32).max(44), + amount: z.string().regex(/^\d+$/), + payTo: z.string().min(32).max(44), + maxTimeoutSeconds: z.number().int().positive(), + extra: z.object({ + feePayer: z.string().min(32).max(44), + memo: z.string().optional(), + recentBlockhash: z.string().min(32).max(44).optional(), + lastValidBlockHeight: z.string().regex(/^\d+$/).optional(), + }).strict(), +}).strict() + export const SolanaSignRequest = z.object({ address_n: z.array(z.number().int()).optional(), addressNList: z.array(z.number().int()).optional(), @@ -196,6 +212,11 @@ export const SolanaSignRequest = z.object({ swapMetadata: SolanaSwapMetadata.optional(), /** Reusable, signer-attested instruction schema. Partial schemas rejected. */ schema: SolanaInstructionSchema.optional(), + /** + * Optional x402 PaymentRequirements. Vault cross-checks these fields against + * the signed zero-LUT v0 bytes before forwarding device display metadata. + */ + x402: SolanaX402Requirements.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() diff --git a/projects/keepkey-vault/src/bun/solana-consent.ts b/projects/keepkey-vault/src/bun/solana-consent.ts index 5803f65b..548f7203 100644 --- a/projects/keepkey-vault/src/bun/solana-consent.ts +++ b/projects/keepkey-vault/src/bun/solana-consent.ts @@ -5,6 +5,7 @@ const TOKEN_PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' const TOKEN_2022_PROGRAM = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb' const ATA_PROGRAM = 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL' const COMPUTE_BUDGET_PROGRAM = 'ComputeBudget111111111111111111111111111111' +const MEMO_V2_PROGRAM = 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr' /** * This is deliberately an allowlist, not "anything the host registry knows". @@ -13,6 +14,15 @@ const COMPUTE_BUDGET_PROGRAM = 'ComputeBudget111111111111111111111111111111' * intentionally remain opaque on-device. */ function firmwareClearSigns(instruction: SolanaTxDecodedInstruction): boolean { + // Memo v2 intentionally has no instruction discriminator in the discovery + // registry, so it is classified as a known program with an unknown ix even + // though firmware safely renders the entire payload as text. + if ( + instruction.programId === MEMO_V2_PROGRAM + && instruction.status === 'known-program-unknown-ix' + ) { + return true + } if (instruction.status !== 'known' || instruction.note) return false switch (instruction.programId) { diff --git a/projects/keepkey-vault/src/bun/solana-signing.ts b/projects/keepkey-vault/src/bun/solana-signing.ts index 90ce38f7..6fb60dcb 100644 --- a/projects/keepkey-vault/src/bun/solana-signing.ts +++ b/projects/keepkey-vault/src/bun/solana-signing.ts @@ -5,6 +5,7 @@ import { solanaMessageSlice, SolanaTxParseError, } from './solana-tx' +import { prepareSolanaX402DeviceMetadata } from './solana-x402' export type SolanaDeviceSigner = (params: any) => Promise export type SolanaAddressDeriver = (addressNList: number[]) => Promise @@ -74,8 +75,12 @@ export async function signSolanaWireTransaction( ) } + const x402Metadata = unsignedTx.x402 + ? prepareSolanaX402DeviceMetadata(message, unsignedTx.x402, signerPublicKey) + : undefined const deviceParams = { ...unsignedTx, + ...(x402Metadata || {}), rawTx: Buffer.from(messageBytes).toString('base64'), } console.debug( diff --git a/projects/keepkey-vault/src/bun/solana-x402.ts b/projects/keepkey-vault/src/bun/solana-x402.ts new file mode 100644 index 00000000..0c33911b --- /dev/null +++ b/projects/keepkey-vault/src/bun/solana-x402.ts @@ -0,0 +1,220 @@ +import { createHash } from 'crypto' +import bs58 from 'bs58' +import type { ParsedSolanaMessage } from './solana-tx' + +export const SOLANA_MAINNET_CAIP2 = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' + +const TOKEN_PROGRAM = bs58.decode('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA') +const MEMO_PROGRAM = bs58.decode('MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr') +const ATA_PROGRAM = bs58.decode('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL') +const PDA_MARKER = Buffer.from('ProgramDerivedAddress', 'ascii') +const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' +const U64_MAX = (1n << 64n) - 1n + +const ED25519_P = (1n << 255n) - 19n + +function mod(value: bigint): bigint { + const result = value % ED25519_P + return result >= 0n ? result : result + ED25519_P +} + +function modPow(base: bigint, exponent: bigint): bigint { + let result = 1n + let factor = mod(base) + let power = exponent + while (power > 0n) { + if ((power & 1n) !== 0n) result = mod(result * factor) + factor = mod(factor * factor) + power >>= 1n + } + return result +} + +const ED25519_D = mod(-121665n * modPow(121666n, ED25519_P - 2n)) +const ED25519_SQRT_M1 = modPow(2n, (ED25519_P - 1n) / 4n) + +function littleEndianToBigInt(bytes: Uint8Array): bigint { + let value = 0n + for (let i = bytes.length - 1; i >= 0; i--) value = (value << 8n) | BigInt(bytes[i]) + return value +} + +/** True when the 32-byte compressed value is a canonical Ed25519 point. */ +export function isEd25519Point(compressed: Uint8Array): boolean { + if (compressed.length !== 32) return false + const copy = Uint8Array.from(compressed) + const sign = copy[31] >>> 7 + copy[31] &= 0x7f + const y = littleEndianToBigInt(copy) + if (y >= ED25519_P) return false + + const y2 = mod(y * y) + const denominator = mod(ED25519_D * y2 + 1n) + if (denominator === 0n) return false + const x2 = mod((y2 - 1n) * modPow(denominator, ED25519_P - 2n)) + let x = modPow(x2, (ED25519_P + 3n) / 8n) + if (mod(x * x) !== x2) x = mod(x * ED25519_SQRT_M1) + if (mod(x * x) !== x2) return false + return !(x === 0n && sign === 1) +} + +export function deriveAssociatedTokenAddress( + owner: Uint8Array, + mint: Uint8Array, + tokenProgram: Uint8Array = TOKEN_PROGRAM, +): Uint8Array { + if (owner.length !== 32 || mint.length !== 32 || tokenProgram.length !== 32) { + throw new Error('x402 ATA derivation requires 32-byte owner, mint, and token program') + } + for (let bump = 255; bump >= 0; bump--) { + const digest = createHash('sha256') + .update(owner) + .update(tokenProgram) + .update(mint) + .update(Uint8Array.of(bump)) + .update(ATA_PROGRAM) + .update(PDA_MARKER) + .digest() + if (!isEd25519Point(digest)) return Uint8Array.from(digest) + } + throw new Error('Unable to derive x402 recipient associated token account') +} + +export interface SolanaX402Requirements { + scheme: 'exact' + network: string + asset: string + amount: string + payTo: string + maxTimeoutSeconds: number + extra: { + feePayer: string + memo?: string + recentBlockhash?: string + lastValidBlockHeight?: string + } +} + +export interface SolanaX402DeviceMetadata { + tokenInfo: Array<{ mint: Uint8Array; symbol?: string; decimals: number }> + tokenRecipientOwners: Uint8Array[] +} + +function decodePubkey(value: string, field: string): Uint8Array { + let decoded: Uint8Array + try { + decoded = bs58.decode(value) + } catch { + throw new Error(`x402 ${field} is not a valid base58 Solana public key`) + } + if (decoded.length !== 32) { + throw new Error(`x402 ${field} must decode to 32 bytes, got ${decoded.length}`) + } + return decoded +} + +function equalBytes(a: Uint8Array, b: Uint8Array): boolean { + return a.length === b.length && Buffer.from(a).equals(Buffer.from(b)) +} + +/** + * Validate an x402 PaymentRequirements object against the exact signed Solana + * v0 message. No chain reads are used: zero-LUT static accounts contain the + * sponsor, mint, destination ATA, authority, programs, amount, and decimals. + */ +export function prepareSolanaX402DeviceMetadata( + message: ParsedSolanaMessage, + requirements: SolanaX402Requirements, + signerPublicKey: Uint8Array, +): SolanaX402DeviceMetadata { + if (requirements.scheme !== 'exact') { + throw new Error(`Unsupported x402 Solana scheme: ${requirements.scheme}`) + } + if (requirements.network !== SOLANA_MAINNET_CAIP2) { + throw new Error(`Unsupported x402 Solana network: ${requirements.network}`) + } + if (message.version !== 'v0') throw new Error('x402 SVM exact payments require a v0 transaction') + if (message.altEntries.length !== 0) { + throw new Error('x402 hardware verification requires a zero-LUT v0 transaction') + } + + const mint = decodePubkey(requirements.asset, 'asset') + const payTo = decodePubkey(requirements.payTo, 'payTo') + const feePayer = decodePubkey(requirements.extra.feePayer, 'extra.feePayer') + if (!message.staticAccounts[0] || !equalBytes(message.staticAccounts[0], feePayer)) { + throw new Error('x402 sponsor does not match the signed transaction fee payer') + } + + if (!/^\d+$/.test(requirements.amount)) throw new Error('x402 amount must be an unsigned integer string') + const requiredAmount = BigInt(requirements.amount) + if (requiredAmount > U64_MAX) throw new Error('x402 amount exceeds the SPL u64 range') + const expectedAta = deriveAssociatedTokenAddress(payTo, mint) + + const matches: Array<{ amount: bigint; decimals: number }> = [] + const underpayments: bigint[] = [] + for (const instruction of message.instructions) { + const program = message.staticAccounts[instruction.programIdIndex] + if (!program || !equalBytes(program, TOKEN_PROGRAM) || instruction.data[0] !== 12) continue + if (instruction.data.length !== 10 || instruction.accountIndices.length !== 4) { + throw new Error('x402 TransferChecked must use the canonical 10-byte, 4-account form') + } + const sourceMint = message.staticAccounts[instruction.accountIndices[1]] + const destination = message.staticAccounts[instruction.accountIndices[2]] + const authority = message.staticAccounts[instruction.accountIndices[3]] + if (!sourceMint || !destination || !authority) { + throw new Error('x402 TransferChecked references a non-static account') + } + if (!equalBytes(sourceMint, mint) || !equalBytes(destination, expectedAta)) continue + if (!equalBytes(authority, signerPublicKey)) { + throw new Error('x402 transfer authority is not the selected KeepKey signer') + } + const data = Buffer.from(instruction.data) + const amount = data.readBigUInt64LE(1) + if (amount >= requiredAmount) { + matches.push({ amount, decimals: data[9] }) + } else { + underpayments.push(amount) + } + } + + if (matches.length !== 1) { + if (matches.length === 0 && underpayments.length > 0) { + throw new Error(`x402 transfer amount ${underpayments[0]} is below required ${requiredAmount}`) + } + throw new Error(`x402 transaction must contain exactly one matching TransferChecked; found ${matches.length}`) + } + + const knownUsdc = requirements.asset === USDC_MINT + if (knownUsdc && matches[0].decimals !== 6) { + throw new Error(`x402 USDC decimals mismatch: signed ${matches[0].decimals}, expected 6`) + } + + const memoInstructions = message.instructions.filter((instruction) => { + const program = message.staticAccounts[instruction.programIdIndex] + return !!program && equalBytes(program, MEMO_PROGRAM) + }) + if (memoInstructions.length !== 1) { + throw new Error(`x402 transaction must contain exactly one Memo instruction; found ${memoInstructions.length}`) + } + const memoBytes = memoInstructions[0].data + if (requirements.extra.memo !== undefined) { + const requiredMemo = Buffer.from(requirements.extra.memo, 'utf8') + if (requiredMemo.length > 256) throw new Error('x402 extra.memo exceeds 256 UTF-8 bytes') + if (!equalBytes(memoBytes, requiredMemo)) { + throw new Error('x402 memo does not match PaymentRequirements extra.memo') + } + } else { + const randomMemo = Buffer.from(memoBytes).toString('utf8') + if (!/^[0-9a-fA-F]{32,}$/.test(randomMemo) || randomMemo.length % 2 !== 0) { + throw new Error('x402 transaction requires a hex-encoded random memo of at least 16 bytes') + } + } + return { + tokenInfo: [{ + mint, + ...(knownUsdc ? { symbol: 'USDC' } : {}), + decimals: matches[0].decimals, + }], + tokenRecipientOwners: [payTo], + } +} diff --git a/projects/keepkey-vault/src/bun/swagger.json b/projects/keepkey-vault/src/bun/swagger.json index 8c1d8706..1c694faa 100644 --- a/projects/keepkey-vault/src/bun/swagger.json +++ b/projects/keepkey-vault/src/bun/swagger.json @@ -3771,7 +3771,7 @@ "post": { "operationId": "solana_signTransaction", "summary": "Sign a 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.", + "description": "Sign a raw Solana transaction on the KeepKey device. raw_tx is a base64-encoded serialized transaction. Self-contained v0 transactions are routed through the firmware transaction parser. An x402 caller may include the exact PaymentRequirements object so Vault can bind sponsor, mint, amount, signer and recipient ATA to the signed bytes before the device displays the verified merchant owner. Transactions that use address lookup tables remain behind explicit one-request blind-signing consent.", "parameters": [], "requestBody": { "content": { @@ -3789,6 +3789,31 @@ "type": "string", "description": "Base64-encoded raw Solana transaction" }, + "x402": { + "type": "object", + "description": "Optional x402 v2 SVM exact PaymentRequirements. Vault validates every payment field against the signed zero-LUT v0 message before forwarding device display metadata.", + "additionalProperties": false, + "properties": { + "scheme": { "type": "string", "enum": ["exact"] }, + "network": { "type": "string", "enum": ["solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"] }, + "amount": { "type": "string", "pattern": "^\\d+$" }, + "asset": { "type": "string", "description": "Base58 SPL token mint" }, + "payTo": { "type": "string", "description": "Base58 merchant owner public key" }, + "maxTimeoutSeconds": { "type": "integer", "minimum": 1 }, + "extra": { + "type": "object", + "additionalProperties": false, + "properties": { + "feePayer": { "type": "string", "description": "Base58 sponsor public key" }, + "memo": { "type": "string", "description": "Optional seller-defined UTF-8 memo" }, + "recentBlockhash": { "type": "string" }, + "lastValidBlockHeight": { "type": "string", "pattern": "^\\d+$" } + }, + "required": ["feePayer"] + } + }, + "required": ["scheme", "network", "amount", "asset", "payTo", "maxTimeoutSeconds", "extra"] + }, "swapMetadata": { "type": "object", "description": "Transaction-bound KKSOLSW1 ClearSign descriptor signed by a device-trusted metadata key.", diff --git a/projects/keepkey-vault/src/bun/walletconnect.ts b/projects/keepkey-vault/src/bun/walletconnect.ts index d906c777..28eeff62 100644 --- a/projects/keepkey-vault/src/bun/walletconnect.ts +++ b/projects/keepkey-vault/src/bun/walletconnect.ts @@ -14,8 +14,9 @@ import bs58 from 'bs58' import type { SigningRequestInfo, WcSessionInfo } from '../shared/types' import { evmAddressPath } from './evm-addresses' import { verifyEvmSigner } from './evm-rpc' -import { parseSolanaTx } from './solana-tx' import { buildSolanaMessageDecodedInfo } from './solana-message-preview' +import { buildSolanaDecodedInfo } from './solana-clearsign' +import { requiresSolanaBlindSigningConsent } from './solana-consent' function base64ToBase58(base64: string): string { return bs58.encode(Buffer.from(base64, 'base64')) @@ -48,16 +49,30 @@ function assertChainIdMatches(txChainId: unknown, sessionChainId: number) { } } -/** Versioned (v0+) Solana transactions cannot be parsed by current firmware, - * so they are signed via the message-signing path — i.e. blind-signed. We - * surface this in the approval method name so the UI can render a stronger - * warning. Returns false (treat as legacy) if the tx fails to parse, since - * legacy is the safer fallback (forces the firmware tx-parse path). */ -function isVersionedSolanaTx(transactionBase64: string): boolean { +/** Attach the same clear-sign preview and conservative firmware-policy gate + * used by the REST route. x402's v0 transaction has no lookup tables, so its + * full preview is available without an RPC read. Transactions that do use an + * ALT remain explicitly opaque in WalletConnect until the accounts can be + * independently resolved. */ +async function attachSolanaTransactionPreview( + signingInfo: SigningRequestInfo, + transactionBase64: string, +): Promise { try { - return parseSolanaTx(Buffer.from(transactionBase64, 'base64')).isVersioned - } catch { - return false + signingInfo.solanaDecoded = await buildSolanaDecodedInfo( + transactionBase64, + async (pubkeys) => pubkeys.map(() => null), + ) + } catch (e: any) { + signingInfo.solanaDecodeError = `${e?.name || 'Error'}: ${e?.message || String(e)}` + } + + signingInfo.requiresBlindSigningConsent = requiresSolanaBlindSigningConsent( + signingInfo.solanaDecoded, + false, + ) + if (signingInfo.requiresBlindSigningConsent) { + signingInfo.needsBlindSigning = true } } @@ -683,17 +698,14 @@ export class WalletConnectManager { const signingId = crypto.randomUUID() const signingInfo: SigningRequestInfo = { id: signingId, - // v0+ versioned txs hit the message-signing path on the device — the - // firmware can't display program/account details, so this is blind. - method: isVersionedSolanaTx(transaction) - ? '/solana/sign-transaction-blind' - : '/solana/sign-transaction', + method: '/solana/sign-transaction', appName, chain: 'solana', from: account.address, chainId: 0, data: transaction, } + await attachSolanaTransactionPreview(signingInfo, transaction) const approved = await this.callbacks.requestSigningApproval(signingInfo) if (!approved) throw new Error('User rejected signing') try { @@ -717,15 +729,14 @@ export class WalletConnectManager { const signingId = crypto.randomUUID() const signingInfo: SigningRequestInfo = { id: signingId, - method: isVersionedSolanaTx(transaction) - ? '/solana/sign-and-send-blind' - : '/solana/sign-and-send', + method: '/solana/sign-and-send', appName, chain: 'solana', from: account.address, chainId: 0, data: transaction, } + await attachSolanaTransactionPreview(signingInfo, transaction) const approved = await this.callbacks.requestSigningApproval(signingInfo) if (!approved) throw new Error('User rejected signing') try {