diff --git a/lib/packages/wallet/eip712_signer.dart b/lib/packages/wallet/eip712_signer.dart index f19f0e789..e4a1d093e 100644 --- a/lib/packages/wallet/eip712_signer.dart +++ b/lib/packages/wallet/eip712_signer.dart @@ -24,11 +24,20 @@ class Eip712Signer { required bool swissTaxResidence, required String registrationDate, }) { + // The BitBox02 firmware refuses to sign typed data whose EIP712Domain has + // no chainId ("typed data has no chain ID" shown on the device), so + // hardware wallets sign with the chainId-extended domain. Software wallets + // keep the legacy chainId-less domain until Aktionariat has confirmed its + // signature re-verification accepts the extended variant — the API + // accepts both (pair PR DFXswiss/api#4542). + final includeChainIdInDomain = credentials is BitboxCredentials; + final Map typedDataMap = { 'types': { 'EIP712Domain': [ {'name': 'name', 'type': 'string'}, {'name': 'version', 'type': 'string'}, + if (includeChainIdInDomain) {'name': 'chainId', 'type': 'uint256'}, ], 'RealUnitUser': [ {'name': 'email', 'type': 'string'}, @@ -47,7 +56,11 @@ class Eip712Signer { ], }, 'primaryType': 'RealUnitUser', - 'domain': {'name': 'RealUnitUser', 'version': '1'}, + 'domain': { + 'name': 'RealUnitUser', + 'version': '1', + if (includeChainIdInDomain) 'chainId': chainId, + }, 'message': { 'email': email, 'name': name, diff --git a/test/packages/wallet/eip712_signer_bitbox_test.dart b/test/packages/wallet/eip712_signer_bitbox_test.dart index 438ccc787..006e85a60 100644 --- a/test/packages/wallet/eip712_signer_bitbox_test.dart +++ b/test/packages/wallet/eip712_signer_bitbox_test.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:typed_data'; import 'package:bitbox_flutter/bitbox_manager.dart'; @@ -23,9 +24,9 @@ void main() { BitboxCredentials connected() => BitboxCredentials('0x000000000000000000000000000000000000dead')..setBitbox(manager); - Future signRegistration() => Eip712Signer.signRegistration( + Future signRegistration({int chainId = 1}) => Eip712Signer.signRegistration( credentials: connected(), - chainId: 1, + chainId: chainId, email: 'jk@dfx.swiss', name: 'Joshua', type: 'human', @@ -48,6 +49,40 @@ void main() { expect(await signRegistration(), '0xcafebabe'); }); + // The BitBox02 firmware rejects typed data whose EIP712Domain has no + // chainId ("typed data has no chain ID" on the device) — hardware-wallet + // registrations must sign the chainId-extended domain. The software-wallet + // path keeps the legacy domain (pinned by the golden signature in + // eip712_signer_test.dart). + // Signs with a non-default chainId so a hardcoded value cannot pass, and asserts the + // EIP712Domain member list exactly: the domain typehash covers the names, their types + // and their order, so any of those drifting changes the digest and the API recovers a + // foreign address. Mirrors the chainId-wiring pin in + // test/integration/eip7702_delegation_bitbox_test.dart. + for (final chainId in const [1, 11155111]) { + test('signs with the chainId-extended EIP-712 domain (chainId $chainId)', () async { + when( + () => manager.signETHTypedMessage(any(), any(), any()), + ).thenAnswer((_) async => Uint8List.fromList([0x01])); + + await signRegistration(chainId: chainId); + + // called(1): a second sign would mean a second on-device confirmation prompt. + final captured = (verify( + () => manager.signETHTypedMessage(captureAny(), any(), captureAny()), + )..called(1)).captured; + final typedData = jsonDecode(utf8.decode(captured[1] as Uint8List)) as Map; + + expect(captured[0], chainId); + expect(typedData['domain']['chainId'], chainId); + expect(typedData['types']['EIP712Domain'], [ + {'name': 'name', 'type': 'string'}, + {'name': 'version', 'type': 'string'}, + {'name': 'chainId', 'type': 'uint256'}, + ]); + }); + } + test('throws SigningCancelledException on empty signature', () async { when( () => manager.signETHTypedMessage(any(), any(), any()),