From 879cdd43d2b3f26a5ef05b4e14c554307ebd7823 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 31 Jul 2026 14:59:26 -0300 Subject: [PATCH 1/2] feat(solana): carry verified recipient owner hints --- device-protocol | 2 +- keepkeylib/client.py | 19 +++++-- keepkeylib/messages_solana_pb2.py | 54 ++++++++++++++----- .../test_message_signing_protocol_bindings.py | 17 ++++++ tests/test_msg_solana_signtx.py | 6 +-- 5 files changed, 77 insertions(+), 21 deletions(-) diff --git a/device-protocol b/device-protocol index f2246ceb..5a8e2702 160000 --- a/device-protocol +++ b/device-protocol @@ -1 +1 @@ -Subproject commit f2246cebea8f96fcd7ec2883588a784a60b430ae +Subproject commit 5a8e2702a4cc5f6e6c401f9cbb9bb07b43828c8b diff --git a/keepkeylib/client.py b/keepkeylib/client.py index 1860301f..22b12162 100644 --- a/keepkeylib/client.py +++ b/keepkeylib/client.py @@ -1722,10 +1722,21 @@ def solana_get_address(self, address_n, show_display=False): ) @expect(solana_proto.SolanaSignedTx) - def solana_sign_tx(self, address_n, raw_tx): - return self.call( - solana_proto.SolanaSignTx(address_n=address_n, raw_tx=raw_tx) - ) + def solana_sign_tx(self, address_n, raw_tx, token_info=None, + token_recipient_owner=None): + """Sign a Solana transaction with optional display metadata. + + ``token_recipient_owner`` contains candidate 32-byte SPL token-account + owners (for example an x402 ``payTo`` address). Firmware only displays + a candidate after deriving its associated token account and matching + the destination present in the signed TransferChecked instruction. + """ + return self.call(solana_proto.SolanaSignTx( + address_n=address_n, + raw_tx=raw_tx, + token_info=token_info or [], + token_recipient_owner=token_recipient_owner or [], + )) @expect(solana_proto.SolanaMessageSignature) def solana_sign_message(self, address_n, message, show_display=False): diff --git a/keepkeylib/messages_solana_pb2.py b/keepkeylib/messages_solana_pb2.py index 14410b17..dfdd0674 100644 --- a/keepkeylib/messages_solana_pb2.py +++ b/keepkeylib/messages_solana_pb2.py @@ -19,7 +19,7 @@ name='messages-solana.proto', package='', syntax='proto2', - serialized_pb=_b('\n\x15messages-solana.proto\"V\n\x10SolanaGetAddress\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x19\n\tcoin_name\x18\x02 \x01(\t:\x06Solana\x12\x14\n\x0cshow_display\x18\x03 \x01(\x08\" \n\rSolanaAddress\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\"k\n\x0fSolanaTokenInfo\x12\x0c\n\x04mint\x18\x01 \x01(\x0c\x12\x0e\n\x06symbol\x18\x02 \x01(\t\x12\x10\n\x08\x64\x65\x63imals\x18\x03 \x01(\r\x12\x11\n\tsignature\x18\x04 \x01(\x0c\x12\x15\n\rsigner_key_id\x18\x05 \x01(\r\"r\n\x0cSolanaSignTx\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x19\n\tcoin_name\x18\x02 \x01(\t:\x06Solana\x12\x0e\n\x06raw_tx\x18\x03 \x01(\x0c\x12$\n\ntoken_info\x18\x04 \x03(\x0b\x32\x10.SolanaTokenInfo\"#\n\x0eSolanaSignedTx\x12\x11\n\tsignature\x18\x01 \x01(\x0c\"h\n\x11SolanaSignMessage\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x19\n\tcoin_name\x18\x02 \x01(\t:\x06Solana\x12\x0f\n\x07message\x18\x03 \x01(\x0c\x12\x14\n\x0cshow_display\x18\x04 \x01(\x08\"?\n\x16SolanaMessageSignature\x12\x12\n\npublic_key\x18\x01 \x01(\x0c\x12\x11\n\tsignature\x18\x02 \x01(\x0c\"\x9c\x01\n\x19SolanaSignOffchainMessage\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x19\n\tcoin_name\x18\x02 \x01(\t:\x06Solana\x12\x12\n\x07version\x18\x03 \x01(\r:\x01\x30\x12\x16\n\x0emessage_format\x18\x04 \x01(\r\x12\x0f\n\x07message\x18\x05 \x01(\x0c\x12\x14\n\x0cshow_display\x18\x06 \x01(\x08\"G\n\x1eSolanaOffchainMessageSignature\x12\x12\n\npublic_key\x18\x01 \x01(\x0c\x12\x11\n\tsignature\x18\x02 \x01(\x0c\x42\x32\n\x1a\x63om.keepkey.deviceprotocolB\x14KeepKeyMessageSolana') + serialized_pb=_b('\n\x15messages-solana.proto\"V\n\x10SolanaGetAddress\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x19\n\tcoin_name\x18\x02 \x01(\t:\x06Solana\x12\x14\n\x0cshow_display\x18\x03 \x01(\x08\" \n\rSolanaAddress\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\"k\n\x0fSolanaTokenInfo\x12\x0c\n\x04mint\x18\x01 \x01(\x0c\x12\x0e\n\x06symbol\x18\x02 \x01(\t\x12\x10\n\x08\x64\x65\x63imals\x18\x03 \x01(\r\x12\x11\n\tsignature\x18\x04 \x01(\x0c\x12\x15\n\rsigner_key_id\x18\x05 \x01(\r\"\xe7\x01\n\x0cSolanaSignTx\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x19\n\tcoin_name\x18\x02 \x01(\t:\x06Solana\x12\x0e\n\x06raw_tx\x18\x03 \x01(\x0c\x12$\n\ntoken_info\x18\x04 \x03(\x0b\x32\x10.SolanaTokenInfo\x12\x16\n\x0eschema_payload\x18\t \x01(\x0c\x12\x18\n\x10schema_signature\x18\n \x01(\x0c\x12\x1c\n\x14schema_signer_key_id\x18\x0b \x01(\r\x12\x1d\n\x15token_recipient_owner\x18\x0c \x03(\x0cJ\x04\x08\x05\x10\t\"#\n\x0eSolanaSignedTx\x12\x11\n\tsignature\x18\x01 \x01(\x0c\"h\n\x11SolanaSignMessage\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x19\n\tcoin_name\x18\x02 \x01(\t:\x06Solana\x12\x0f\n\x07message\x18\x03 \x01(\x0c\x12\x14\n\x0cshow_display\x18\x04 \x01(\x08\"?\n\x16SolanaMessageSignature\x12\x12\n\npublic_key\x18\x01 \x01(\x0c\x12\x11\n\tsignature\x18\x02 \x01(\x0c\"\x9c\x01\n\x19SolanaSignOffchainMessage\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x19\n\tcoin_name\x18\x02 \x01(\t:\x06Solana\x12\x12\n\x07version\x18\x03 \x01(\r:\x01\x30\x12\x16\n\x0emessage_format\x18\x04 \x01(\r\x12\x0f\n\x07message\x18\x05 \x01(\x0c\x12\x14\n\x0cshow_display\x18\x06 \x01(\x08\"G\n\x1eSolanaOffchainMessageSignature\x12\x12\n\npublic_key\x18\x01 \x01(\x0c\x12\x11\n\tsignature\x18\x02 \x01(\x0c\x42\x32\n\x1a\x63om.keepkey.deviceprotocolB\x14KeepKeyMessageSolana') ) @@ -195,6 +195,34 @@ message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='schema_payload', full_name='SolanaSignTx.schema_payload', index=4, + number=9, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='schema_signature', full_name='SolanaSignTx.schema_signature', index=5, + number=10, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='schema_signer_key_id', full_name='SolanaSignTx.schema_signer_key_id', index=6, + number=11, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='token_recipient_owner', full_name='SolanaSignTx.token_recipient_owner', index=7, + number=12, type=12, cpp_type=9, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), ], extensions=[ ], @@ -207,8 +235,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=256, - serialized_end=370, + serialized_start=257, + serialized_end=488, ) @@ -238,8 +266,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=372, - serialized_end=407, + serialized_start=490, + serialized_end=525, ) @@ -290,8 +318,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=409, - serialized_end=513, + serialized_start=527, + serialized_end=631, ) @@ -328,8 +356,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=515, - serialized_end=578, + serialized_start=633, + serialized_end=696, ) @@ -394,8 +422,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=581, - serialized_end=737, + serialized_start=699, + serialized_end=855, ) @@ -432,8 +460,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=739, - serialized_end=810, + serialized_start=857, + serialized_end=928, ) _SOLANASIGNTX.fields_by_name['token_info'].message_type = _SOLANATOKENINFO diff --git a/tests/test_message_signing_protocol_bindings.py b/tests/test_message_signing_protocol_bindings.py index 10cce3f7..b36ba5b2 100644 --- a/tests/test_message_signing_protocol_bindings.py +++ b/tests/test_message_signing_protocol_bindings.py @@ -9,6 +9,23 @@ class TestMessageSigningProtocolBindings(unittest.TestCase): + def test_solana_recipient_owner_hint_is_additive_field_12(self): + field = solana_proto.SolanaSignTx.DESCRIPTOR.fields_by_name[ + 'token_recipient_owner' + ] + self.assertEqual(field.number, 12) + self.assertEqual(field.label, field.LABEL_REPEATED) + self.assertEqual(field.type, field.TYPE_BYTES) + + owner = bytes(range(32)) + encoded = solana_proto.SolanaSignTx( + address_n=[0x8000002c, 0x800001f5, 0x80000000, 0x80000000], + raw_tx=b'\x80x402', + token_recipient_owner=[owner], + ).SerializeToString() + decoded = solana_proto.SolanaSignTx.FromString(encoded) + self.assertEqual(list(decoded.token_recipient_owner), [owner]) + def test_solana_offchain_messages_are_mapped(self): self.assertEqual(proto.MessageType_SolanaSignOffchainMessage, 756) self.assertEqual(proto.MessageType_SolanaOffchainMessageSignature, 757) diff --git a/tests/test_msg_solana_signtx.py b/tests/test_msg_solana_signtx.py index 8c708609..58ea9d66 100644 --- a/tests/test_msg_solana_signtx.py +++ b/tests/test_msg_solana_signtx.py @@ -638,7 +638,7 @@ def test_solana_sign_token_transfer_with_metadata(self): 0xc6, 0xfa, 0x7a, 0xf3, 0xbe, 0xdb, 0xad, 0x3a, 0x3d, 0x65, 0xf3, 0x6a, 0xab, 0xc9, 0x74, 0x31, 0xb1, 0xbb, 0xe4, 0xc2, 0xd2, 0xf6, 0xe0, 0xe4, - 0x7c, 0xa6, 0x02, 0x03, 0x45, 0x20, 0x23, 0x34, + 0x7c, 0xa6, 0x02, 0x03, 0x45, 0x2f, 0x5d, 0x61, ]) # SPL Token Transfer instruction: opcode=3 (u8) + amount (LE u64) @@ -687,7 +687,7 @@ def test_solana_sign_token_transfer_checked(self): 0xc6, 0xfa, 0x7a, 0xf3, 0xbe, 0xdb, 0xad, 0x3a, 0x3d, 0x65, 0xf3, 0x6a, 0xab, 0xc9, 0x74, 0x31, 0xb1, 0xbb, 0xe4, 0xc2, 0xd2, 0xf6, 0xe0, 0xe4, - 0x7c, 0xa6, 0x02, 0x03, 0x45, 0x20, 0x23, 0x34, + 0x7c, 0xa6, 0x02, 0x03, 0x45, 0x2f, 0x5d, 0x61, ]) # TransferChecked: opcode=12 (u8) + amount (LE u64) + decimals (u8); @@ -747,7 +747,7 @@ def test_solana_sign_token_transfer_checked_attested_symbol(self): 0xc6, 0xfa, 0x7a, 0xf3, 0xbe, 0xdb, 0xad, 0x3a, 0x3d, 0x65, 0xf3, 0x6a, 0xab, 0xc9, 0x74, 0x31, 0xb1, 0xbb, 0xe4, 0xc2, 0xd2, 0xf6, 0xe0, 0xe4, - 0x7c, 0xa6, 0x02, 0x03, 0x45, 0x20, 0x23, 0x34, + 0x7c, 0xa6, 0x02, 0x03, 0x45, 0x2f, 0x5d, 0x61, ]) decimals = 6 symbol = "USDC" From ceb53455368358c792c8b534cb02f12f5ef89a96 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 31 Jul 2026 16:16:06 -0300 Subject: [PATCH 2/2] test x402 payments on Solana and EVM --- keepkeylib/client.py | 33 +++++++++ scripts/generate-test-report.py | 28 ++++++-- .../test_message_signing_protocol_bindings.py | 8 ++- tests/test_msg_solana_signtx.py | 69 +++++++++++++++++++ tests/test_sign_typed_data.py | 65 ++++++++++++++++- 5 files changed, 194 insertions(+), 9 deletions(-) diff --git a/keepkeylib/client.py b/keepkeylib/client.py index 22b12162..31ebcf68 100644 --- a/keepkeylib/client.py +++ b/keepkeylib/client.py @@ -689,6 +689,39 @@ def e712_types_values(self, n, types_prop, ptype_prop, value_prop, typevals): response = self.call(msg) return response + def ethereum_sign_typed_data(self, n, typed_data): + """Clear-sign structured EIP-712 data on the device. + + The firmware hashes the domain and message itself and displays every + typed value before signing. This is the safe path for EIP-3009 x402 + payments; ``ethereum_sign_typed_data_hash`` remains the explicit + AdvancedMode-only fallback for callers that only have precomputed + hashes. + """ + required = ('types', 'primaryType', 'domain') + missing = [name for name in required if name not in typed_data] + if missing: + raise ValueError('Missing EIP-712 property: %s' % ', '.join(missing)) + + # The legacy structured firmware endpoint expects the standard EIP-712 + # root property names to remain present in each streamed JSON fragment. + types_prop = json.dumps( + {'types': typed_data['types']}, separators=(',', ':')) + ptype_prop = json.dumps( + {'primaryType': typed_data['primaryType']}, separators=(',', ':')) + + # Firmware receives domain and message separately, and retains the + # independently-computed domain separator only until message signing. + self.e712_types_values( + n, types_prop, ptype_prop, + json.dumps({'domain': typed_data['domain']}, separators=(',', ':')), + 1) + return self.e712_types_values( + n, types_prop, ptype_prop, + json.dumps( + {'message': typed_data.get('message', {})}, + separators=(',', ':')), 2) + @expect(eth_proto.EthereumMessageSignature) def ethereum_sign_message(self, n, message): n = self._convert_prime(n) diff --git a/scripts/generate-test-report.py b/scripts/generate-test-report.py index 28be651f..6726fe16 100644 --- a/scripts/generate-test-report.py +++ b/scripts/generate-test-report.py @@ -860,13 +860,17 @@ def _arg_shown(a): 'Contract function call', 'Generic contract call signing.', []), ('E16', 'test_sign_typed_data', 'test_ethereum_sign_typed_data_hash', 'EIP-712 typed-data hash signing (legacy, no on-device display)', - 'KNOWN GAP, disclosed rather than hidden: EIP-712 (the standard behind wallet permits, ' - 'OpenSea listings, and DAO votes — a daily-driver format) is only supported at the ' - 'domain-separator-hash + message-hash level. The device signs two host-computed 32-byte ' - 'hashes; it does NOT parse or display the typed-data domain or message fields, so this ' - 'path shows the user no readable WHO/WHAT — it is effectively a blind hash-sign, not a ' - 'clear-sign. Full structured EIP-712 display is a firmware feature, not yet built.', - []), + 'The legacy endpoint receives two host-computed 32-byte hashes, so firmware keeps it ' + 'behind AdvancedMode and cannot show readable WHO/WHAT. Structured formats such as ' + 'x402 EIP-3009 use the separate device-parsed path proven by E16b.', + []), + ('E16b', 'test_sign_typed_data', 'test_ethereum_sign_x402_eip3009', + 'x402 EVM EIP-3009 payment clear-signs structured data', + 'The device computes the EIP-712 hashes itself and displays the Base Sepolia USDC ' + 'domain plus every TransferWithAuthorization field: payer, recipient, exact value, ' + 'validity window and nonce. AdvancedMode stays OFF; the facilitator pays gas but ' + 'cannot alter the signed destination or amount.', + ['USDC domain fields', 'TransferWithAuthorization fields']), ('E17', 'test_msg_ethereum_erc20_uniswap_liquidity', 'test_sign_uni_approve_liquidity_ETH', 'Uniswap V2 add-liquidity approve (pending)', 'PENDING, disclosed: known emulator limitation — an approve to an unknown (non-registry) ' @@ -1713,6 +1717,16 @@ def _arg_shown(a): 'Lookup-table accounts cannot be resolved on-device, so the tx routes to the ' 'blind-sign gate.', []), + ('S25', 'test_msg_solana_signtx', + 'test_solana_sign_x402_zero_lut_usdc_payment', + 'x402 zero-LUT v0 USDC payment is hardware verified', + 'The sponsor pays fees while the KeepKey key authorizes TransferChecked. The device ' + 'renders 0.002 USDC from firmware-owned mint metadata, derives ATA(payTo, mint) ' + 'offline, and displays the merchant owner only after it matches the signed ' + 'destination token account. The required x402 uniqueness memo is also displayed; ' + 'AdvancedMode stays OFF.', + ['Compute budget', 'Known USDC mint', 'Verified recipient owner', + '0.002 USDC', 'x402 memo']), ]), ('T', 'TRON', '7.14.0', diff --git a/tests/test_message_signing_protocol_bindings.py b/tests/test_message_signing_protocol_bindings.py index b36ba5b2..cc5bb8fc 100644 --- a/tests/test_message_signing_protocol_bindings.py +++ b/tests/test_message_signing_protocol_bindings.py @@ -14,7 +14,13 @@ def test_solana_recipient_owner_hint_is_additive_field_12(self): 'token_recipient_owner' ] self.assertEqual(field.number, 12) - self.assertEqual(field.label, field.LABEL_REPEATED) + # protobuf 6 removed the public ``label`` accessor in favor of the + # semantic predicates; generated bindings must remain testable with + # both the release toolchain and current developer environments. + if hasattr(field, 'label'): + self.assertEqual(field.label, field.LABEL_REPEATED) + else: + self.assertTrue(field.is_repeated) self.assertEqual(field.type, field.TYPE_BYTES) owner = bytes(range(32)) diff --git a/tests/test_msg_solana_signtx.py b/tests/test_msg_solana_signtx.py index 58ea9d66..9e3c391a 100644 --- a/tests/test_msg_solana_signtx.py +++ b/tests/test_msg_solana_signtx.py @@ -893,6 +893,75 @@ def test_solana_sign_versioned_v0_static_verified(self): self.assertEqual(len(resp.signature), 64) self.assertFalse(all(b == 0 for b in resp.signature)) + def test_solana_sign_x402_zero_lut_usdc_payment(self): + """Official x402 SVM shape clear-signs without blind signing. + + The sponsor is fee payer, the KeepKey key is the token authority, the + payment is TransferChecked, and payTo is supplied separately so the + device must derive and verify its associated token account itself. + """ + self.requires_firmware("7.15.0") + self.requires_fullFeature() + self.setup_mnemonic_allallall() + + authority = self._get_from_pubkey() + sponsor = b'\x10' * 32 + source = b'\x30' * 32 + pay_to = bytes([ + 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, + ]) + destination_ata = bytes([ + 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, + ]) + usdc_mint = bytes([ + 0xc6, 0xfa, 0x7a, 0xf3, 0xbe, 0xdb, 0xad, 0x3a, + 0x3d, 0x65, 0xf3, 0x6a, 0xab, 0xc9, 0x74, 0x31, + 0xb1, 0xbb, 0xe4, 0xc2, 0xd2, 0xf6, 0xe0, 0xe4, + 0x7c, 0xa6, 0x02, 0x03, 0x45, 0x2f, 0x5d, 0x61, + ]) + + accounts = [ + sponsor, authority, source, destination_ata, usdc_mint, + self.COMPUTE_BUDGET_PROGRAM, self.TOKEN_PROGRAM, + self.MEMO_PROGRAM, + ] + raw_tx = bytearray([0x80, 2, 0, 3, len(accounts)]) + for account in accounts: + raw_tx.extend(account) + raw_tx.extend(b'\xbb' * 32) + raw_tx.append(4) + + # ComputeBudget::SetComputeUnitLimit(120000) + raw_tx.extend(bytes([5, 0, 5, 2])) + raw_tx.extend(struct.pack('