Skip to content

Commit ceb5345

Browse files
committed
test x402 payments on Solana and EVM
1 parent 879cdd4 commit ceb5345

5 files changed

Lines changed: 194 additions & 9 deletions

File tree

keepkeylib/client.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -689,6 +689,39 @@ def e712_types_values(self, n, types_prop, ptype_prop, value_prop, typevals):
689689
response = self.call(msg)
690690
return response
691691

692+
def ethereum_sign_typed_data(self, n, typed_data):
693+
"""Clear-sign structured EIP-712 data on the device.
694+
695+
The firmware hashes the domain and message itself and displays every
696+
typed value before signing. This is the safe path for EIP-3009 x402
697+
payments; ``ethereum_sign_typed_data_hash`` remains the explicit
698+
AdvancedMode-only fallback for callers that only have precomputed
699+
hashes.
700+
"""
701+
required = ('types', 'primaryType', 'domain')
702+
missing = [name for name in required if name not in typed_data]
703+
if missing:
704+
raise ValueError('Missing EIP-712 property: %s' % ', '.join(missing))
705+
706+
# The legacy structured firmware endpoint expects the standard EIP-712
707+
# root property names to remain present in each streamed JSON fragment.
708+
types_prop = json.dumps(
709+
{'types': typed_data['types']}, separators=(',', ':'))
710+
ptype_prop = json.dumps(
711+
{'primaryType': typed_data['primaryType']}, separators=(',', ':'))
712+
713+
# Firmware receives domain and message separately, and retains the
714+
# independently-computed domain separator only until message signing.
715+
self.e712_types_values(
716+
n, types_prop, ptype_prop,
717+
json.dumps({'domain': typed_data['domain']}, separators=(',', ':')),
718+
1)
719+
return self.e712_types_values(
720+
n, types_prop, ptype_prop,
721+
json.dumps(
722+
{'message': typed_data.get('message', {})},
723+
separators=(',', ':')), 2)
724+
692725
@expect(eth_proto.EthereumMessageSignature)
693726
def ethereum_sign_message(self, n, message):
694727
n = self._convert_prime(n)

scripts/generate-test-report.py

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -860,13 +860,17 @@ def _arg_shown(a):
860860
'Contract function call', 'Generic contract call signing.', []),
861861
('E16', 'test_sign_typed_data', 'test_ethereum_sign_typed_data_hash',
862862
'EIP-712 typed-data hash signing (legacy, no on-device display)',
863-
'KNOWN GAP, disclosed rather than hidden: EIP-712 (the standard behind wallet permits, '
864-
'OpenSea listings, and DAO votes — a daily-driver format) is only supported at the '
865-
'domain-separator-hash + message-hash level. The device signs two host-computed 32-byte '
866-
'hashes; it does NOT parse or display the typed-data domain or message fields, so this '
867-
'path shows the user no readable WHO/WHAT — it is effectively a blind hash-sign, not a '
868-
'clear-sign. Full structured EIP-712 display is a firmware feature, not yet built.',
869-
[]),
863+
'The legacy endpoint receives two host-computed 32-byte hashes, so firmware keeps it '
864+
'behind AdvancedMode and cannot show readable WHO/WHAT. Structured formats such as '
865+
'x402 EIP-3009 use the separate device-parsed path proven by E16b.',
866+
[]),
867+
('E16b', 'test_sign_typed_data', 'test_ethereum_sign_x402_eip3009',
868+
'x402 EVM EIP-3009 payment clear-signs structured data',
869+
'The device computes the EIP-712 hashes itself and displays the Base Sepolia USDC '
870+
'domain plus every TransferWithAuthorization field: payer, recipient, exact value, '
871+
'validity window and nonce. AdvancedMode stays OFF; the facilitator pays gas but '
872+
'cannot alter the signed destination or amount.',
873+
['USDC domain fields', 'TransferWithAuthorization fields']),
870874
('E17', 'test_msg_ethereum_erc20_uniswap_liquidity', 'test_sign_uni_approve_liquidity_ETH',
871875
'Uniswap V2 add-liquidity approve (pending)',
872876
'PENDING, disclosed: known emulator limitation — an approve to an unknown (non-registry) '
@@ -1713,6 +1717,16 @@ def _arg_shown(a):
17131717
'Lookup-table accounts cannot be resolved on-device, so the tx routes to the '
17141718
'blind-sign gate.',
17151719
[]),
1720+
('S25', 'test_msg_solana_signtx',
1721+
'test_solana_sign_x402_zero_lut_usdc_payment',
1722+
'x402 zero-LUT v0 USDC payment is hardware verified',
1723+
'The sponsor pays fees while the KeepKey key authorizes TransferChecked. The device '
1724+
'renders 0.002 USDC from firmware-owned mint metadata, derives ATA(payTo, mint) '
1725+
'offline, and displays the merchant owner only after it matches the signed '
1726+
'destination token account. The required x402 uniqueness memo is also displayed; '
1727+
'AdvancedMode stays OFF.',
1728+
['Compute budget', 'Known USDC mint', 'Verified recipient owner',
1729+
'0.002 USDC', 'x402 memo']),
17161730
]),
17171731

17181732
('T', 'TRON', '7.14.0',

tests/test_message_signing_protocol_bindings.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,13 @@ def test_solana_recipient_owner_hint_is_additive_field_12(self):
1414
'token_recipient_owner'
1515
]
1616
self.assertEqual(field.number, 12)
17-
self.assertEqual(field.label, field.LABEL_REPEATED)
17+
# protobuf 6 removed the public ``label`` accessor in favor of the
18+
# semantic predicates; generated bindings must remain testable with
19+
# both the release toolchain and current developer environments.
20+
if hasattr(field, 'label'):
21+
self.assertEqual(field.label, field.LABEL_REPEATED)
22+
else:
23+
self.assertTrue(field.is_repeated)
1824
self.assertEqual(field.type, field.TYPE_BYTES)
1925

2026
owner = bytes(range(32))

tests/test_msg_solana_signtx.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -893,6 +893,75 @@ def test_solana_sign_versioned_v0_static_verified(self):
893893
self.assertEqual(len(resp.signature), 64)
894894
self.assertFalse(all(b == 0 for b in resp.signature))
895895

896+
def test_solana_sign_x402_zero_lut_usdc_payment(self):
897+
"""Official x402 SVM shape clear-signs without blind signing.
898+
899+
The sponsor is fee payer, the KeepKey key is the token authority, the
900+
payment is TransferChecked, and payTo is supplied separately so the
901+
device must derive and verify its associated token account itself.
902+
"""
903+
self.requires_firmware("7.15.0")
904+
self.requires_fullFeature()
905+
self.setup_mnemonic_allallall()
906+
907+
authority = self._get_from_pubkey()
908+
sponsor = b'\x10' * 32
909+
source = b'\x30' * 32
910+
pay_to = bytes([
911+
0xea, 0x4a, 0x6c, 0x63, 0xe2, 0x9c, 0x52, 0x0a,
912+
0xbe, 0xf5, 0x50, 0x7b, 0x13, 0x2e, 0xc5, 0xf9,
913+
0x95, 0x47, 0x76, 0xae, 0xbe, 0xbe, 0x7b, 0x92,
914+
0x42, 0x1e, 0xea, 0x69, 0x14, 0x46, 0xd2, 0x2c,
915+
])
916+
destination_ata = bytes([
917+
0x67, 0x30, 0x2e, 0x49, 0x18, 0x94, 0xd7, 0x49,
918+
0x2e, 0xa6, 0xbe, 0x4f, 0x91, 0x4e, 0xa4, 0xf4,
919+
0x5f, 0xa1, 0x42, 0xe6, 0x45, 0x86, 0x7c, 0x91,
920+
0x64, 0xa2, 0x76, 0xd5, 0xdd, 0x76, 0xf0, 0x76,
921+
])
922+
usdc_mint = bytes([
923+
0xc6, 0xfa, 0x7a, 0xf3, 0xbe, 0xdb, 0xad, 0x3a,
924+
0x3d, 0x65, 0xf3, 0x6a, 0xab, 0xc9, 0x74, 0x31,
925+
0xb1, 0xbb, 0xe4, 0xc2, 0xd2, 0xf6, 0xe0, 0xe4,
926+
0x7c, 0xa6, 0x02, 0x03, 0x45, 0x2f, 0x5d, 0x61,
927+
])
928+
929+
accounts = [
930+
sponsor, authority, source, destination_ata, usdc_mint,
931+
self.COMPUTE_BUDGET_PROGRAM, self.TOKEN_PROGRAM,
932+
self.MEMO_PROGRAM,
933+
]
934+
raw_tx = bytearray([0x80, 2, 0, 3, len(accounts)])
935+
for account in accounts:
936+
raw_tx.extend(account)
937+
raw_tx.extend(b'\xbb' * 32)
938+
raw_tx.append(4)
939+
940+
# ComputeBudget::SetComputeUnitLimit(120000)
941+
raw_tx.extend(bytes([5, 0, 5, 2]))
942+
raw_tx.extend(struct.pack('<I', 120000))
943+
# ComputeBudget::SetComputeUnitPrice(1000 micro-lamports)
944+
raw_tx.extend(bytes([5, 0, 9, 3]))
945+
raw_tx.extend(struct.pack('<Q', 1000))
946+
# SPL TransferChecked(source, mint, destination ATA, authority)
947+
raw_tx.extend(bytes([6, 4, 2, 4, 3, 1, 10, 12]))
948+
raw_tx.extend(struct.pack('<Q', 2000))
949+
raw_tx.append(6)
950+
# Required x402 uniqueness memo: a 16-byte nonce encoded as hex.
951+
memo = b'00112233445566778899aabbccddeeff'
952+
raw_tx.extend(bytes([7, 1, 1, len(memo)]))
953+
raw_tx.extend(memo)
954+
raw_tx.append(0) # zero address-lookup tables
955+
956+
token_info = messages.SolanaTokenInfo(
957+
mint=usdc_mint, symbol="USDC", decimals=6)
958+
self.client.apply_policy('AdvancedMode', False)
959+
response = self.client.solana_sign_tx(
960+
parse_path("m/44'/501'/0'/0'"), bytes(raw_tx),
961+
token_info=[token_info], token_recipient_owner=[pay_to])
962+
self.assertEqual(len(response.signature), 64)
963+
self.assertFalse(all(b == 0 for b in response.signature))
964+
896965
def test_solana_sign_versioned_v0_opaque(self):
897966
"""Versioned v0 transaction whose instruction reaches into an address
898967
lookup table (an account index at or beyond the static account

tests/test_sign_typed_data.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,70 @@
2727
from keepkeylib import tools
2828

2929
class TestMsgEthereumSignTypedDataHash(common.KeepKeyTest):
30-
30+
31+
def test_ethereum_sign_x402_eip3009(self):
32+
"""x402 EVM exact payments clear-sign the EIP-3009 authorization.
33+
34+
This fixture follows the official v2 EVM shape: Base Sepolia USDC,
35+
``TransferWithAuthorization``, facilitator-paid gas, and the exact
36+
recipient and value embedded in the signed EIP-712 message.
37+
"""
38+
self.requires_fullFeature()
39+
self.requires_firmware("7.15.0")
40+
self.requires_message("Ethereum712TypesValues")
41+
self.setup_mnemonic_allallall()
42+
43+
typed_data = {
44+
"types": {
45+
"EIP712Domain": [
46+
{"name": "name", "type": "string"},
47+
{"name": "version", "type": "string"},
48+
{"name": "chainId", "type": "uint256"},
49+
{"name": "verifyingContract", "type": "address"},
50+
],
51+
"TransferWithAuthorization": [
52+
{"name": "from", "type": "address"},
53+
{"name": "to", "type": "address"},
54+
{"name": "value", "type": "uint256"},
55+
{"name": "validAfter", "type": "uint256"},
56+
{"name": "validBefore", "type": "uint256"},
57+
{"name": "nonce", "type": "bytes32"},
58+
],
59+
},
60+
"primaryType": "TransferWithAuthorization",
61+
"domain": {
62+
"name": "USDC",
63+
"version": "2",
64+
"chainId": 84532,
65+
"verifyingContract":
66+
"0x036CbD53842c5426634e7929541eC2318f3dCF7e",
67+
},
68+
"message": {
69+
"from": "0x73d0385F4d8E00C5e6504C6030F47BF6212736A8",
70+
"to": "0x209693Bc6afc0C5328bA36FaF03C514EF312287C",
71+
"value": "2000",
72+
"validAfter": "0",
73+
"validBefore": "2000000000",
74+
"nonce": "0xf3746613c2d920b5fdabc0856f2aeb2d4f88ee6037b8cc5d04a71a4462f13480",
75+
},
76+
}
77+
78+
# Structured EIP-712 is clear-signable with blind signing disabled.
79+
self.client.apply_policy('AdvancedMode', False)
80+
response = self.client.ethereum_sign_typed_data(
81+
tools.parse_path("m/44'/60'/0'/0/0"), typed_data)
82+
83+
# Hashes are independent reference values from the EIP-712 V4 encoder.
84+
self.assertEqual(
85+
binascii.hexlify(response.domain_separator_hash),
86+
b"71f17a3b2ff373b803d70a5a07c046c1a2bc8e89c09ef722fcb047abe94c9818")
87+
self.assertEqual(
88+
binascii.hexlify(response.message_hash),
89+
b"ccb8d59d2e8a63beafb02887b4c9dd2f79d3527df4167f8c6b36e3e43cf373be")
90+
self.assertEqual(response.address,
91+
"0x73d0385F4d8E00C5e6504C6030F47BF6212736A8")
92+
self.assertEqual(len(response.signature), 65)
93+
3194
def test_ethereum_sign_typed_data_hash(self):
3295
self.requires_fullFeature()
3396
self.requires_firmware("7.15.0")

0 commit comments

Comments
 (0)