Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 src/bun/clearsign-studio.test.ts src/bun/solana-outflow.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 src/bun/clearsign-studio.test.ts src/bun/solana-outflow.test.ts
cd $(PROJECT_DIR) && bun src/bun/btc-backend/core.test.ts

test-integration: test-rest
Expand Down
51 changes: 51 additions & 0 deletions projects/keepkey-vault/__tests__/evm-x402.test.ts
Original file line number Diff line number Diff line change
@@ -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'],
])
})
})
165 changes: 165 additions & 0 deletions projects/keepkey-vault/__tests__/solana-x402.test.ts
Original file line number Diff line number Diff line change
@@ -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))
})
})
27 changes: 27 additions & 0 deletions projects/keepkey-vault/src/bun/eip712-decoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
4 changes: 4 additions & 0 deletions projects/keepkey-vault/src/bun/rest-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2787,6 +2787,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) => {
Expand Down
21 changes: 21 additions & 0 deletions projects/keepkey-vault/src/bun/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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()
Expand Down
10 changes: 10 additions & 0 deletions projects/keepkey-vault/src/bun/solana-consent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand All @@ -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) {
Expand Down
5 changes: 5 additions & 0 deletions projects/keepkey-vault/src/bun/solana-signing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
solanaMessageSlice,
SolanaTxParseError,
} from './solana-tx'
import { prepareSolanaX402DeviceMetadata } from './solana-x402'

export type SolanaDeviceSigner = (params: any) => Promise<any>
export type SolanaAddressDeriver = (addressNList: number[]) => Promise<string>
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading