From ff1d2e27551b7c5257a3c18d1f172c2419306fd3 Mon Sep 17 00:00:00 2001 From: Blume1977 Date: Fri, 24 Jul 2026 11:24:03 +0200 Subject: [PATCH 1/3] fix(bitbox): sign the registration with a chainId-extended EIP-712 domain The BitBox02 firmware refuses to sign typed data whose EIP712Domain has no chainId and aborts with 'typed data has no chain ID' on the device, so BitBox users could not complete the registration at all. Hardware wallets now sign with the chainId-extended domain; software wallets keep the legacy domain (pinned by the golden signature test) until Aktionariat confirms its re-verification accepts the extended variant. Pair PR: DFXswiss/api#4354 (accepts both domain variants, must deploy first). --- lib/packages/wallet/eip712_signer.dart | 15 +++++++++++- .../wallet/eip712_signer_bitbox_test.dart | 23 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/lib/packages/wallet/eip712_signer.dart b/lib/packages/wallet/eip712_signer.dart index f19f0e789..42a2591a1 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#4354). + 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..c44cc1000 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'; @@ -48,6 +49,28 @@ 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). + test('signs with the chainId-extended EIP-712 domain', () async { + when( + () => manager.signETHTypedMessage(any(), any(), any()), + ).thenAnswer((_) async => Uint8List.fromList([0x01])); + + await signRegistration(); + + final jsonMessage = + verify(() => manager.signETHTypedMessage(any(), any(), captureAny())).captured.single as Uint8List; + final typedData = jsonDecode(utf8.decode(jsonMessage)) as Map; + expect(typedData['domain']['chainId'], 1); + expect( + (typedData['types']['EIP712Domain'] as List).map((e) => e['name']), + containsAll(['name', 'version', 'chainId']), + ); + }); + test('throws SigningCancelledException on empty signature', () async { when( () => manager.signETHTypedMessage(any(), any(), any()), From 4076ba20c7bfe8e487901962465f3b9eafbc966d Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Fri, 31 Jul 2026 14:11:38 -0300 Subject: [PATCH 2/3] test(bitbox): pin the chainId-extended domain shape, and fix the pair-PR reference The new test asserted chainId against the same literal the helper passed and checked the EIP712Domain members with containsAll, so three wrong implementations passed it: hardcoding chainId to 1 (breaks every testnet registration), changing the chainId type to uint64, and reordering the domain members. All three alter the domain typehash or value, so the API recovers a foreign address and rejects. Sign with a non-default chainId, assert the value reaching signETHTypedMessage as well as the JSON, and assert the member list exactly. Verified: each of the three mutations now fails. The comment pointed at DFXswiss/api#4354, which was closed unmerged; the shipped counterpart is #4542. --- lib/packages/wallet/eip712_signer.dart | 2 +- .../wallet/eip712_signer_bitbox_test.dart | 42 ++++++++++++------- 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/lib/packages/wallet/eip712_signer.dart b/lib/packages/wallet/eip712_signer.dart index 42a2591a1..e4a1d093e 100644 --- a/lib/packages/wallet/eip712_signer.dart +++ b/lib/packages/wallet/eip712_signer.dart @@ -29,7 +29,7 @@ class Eip712Signer { // 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#4354). + // accepts both (pair PR DFXswiss/api#4542). final includeChainIdInDomain = credentials is BitboxCredentials; final Map typedDataMap = { diff --git a/test/packages/wallet/eip712_signer_bitbox_test.dart b/test/packages/wallet/eip712_signer_bitbox_test.dart index c44cc1000..4861ab6d2 100644 --- a/test/packages/wallet/eip712_signer_bitbox_test.dart +++ b/test/packages/wallet/eip712_signer_bitbox_test.dart @@ -24,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', @@ -54,22 +54,32 @@ void main() { // 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). - test('signs with the chainId-extended EIP-712 domain', () async { - when( - () => manager.signETHTypedMessage(any(), any(), any()), - ).thenAnswer((_) async => Uint8List.fromList([0x01])); + // 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 eip712_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(); + await signRegistration(chainId: chainId); - final jsonMessage = - verify(() => manager.signETHTypedMessage(any(), any(), captureAny())).captured.single as Uint8List; - final typedData = jsonDecode(utf8.decode(jsonMessage)) as Map; - expect(typedData['domain']['chainId'], 1); - expect( - (typedData['types']['EIP712Domain'] as List).map((e) => e['name']), - containsAll(['name', 'version', 'chainId']), - ); - }); + final captured = verify( + () => manager.signETHTypedMessage(captureAny(), any(), captureAny()), + ).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( From a124f276700c67a74d6ed14889be91b3b1de492e Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Fri, 31 Jul 2026 14:29:23 -0300 Subject: [PATCH 3/3] test(bitbox): restore the single-sign guard and fix the cross-reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replacing .captured.single with indexed access dropped the implicit "signed exactly once" assertion, so a second signETHTypedMessage call went unnoticed — on a hardware wallet that is a second on-device confirmation prompt. Restore it via ..called(1), matching the signCallCount checks in the integration tests. The comment pointed at eip712_delegation_bitbox_test.dart, which does not exist; the file is test/integration/eip7702_delegation_bitbox_test.dart. --- test/packages/wallet/eip712_signer_bitbox_test.dart | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/packages/wallet/eip712_signer_bitbox_test.dart b/test/packages/wallet/eip712_signer_bitbox_test.dart index 4861ab6d2..006e85a60 100644 --- a/test/packages/wallet/eip712_signer_bitbox_test.dart +++ b/test/packages/wallet/eip712_signer_bitbox_test.dart @@ -57,7 +57,8 @@ void main() { // 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 eip712_delegation_bitbox_test.dart. + // 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( @@ -66,9 +67,10 @@ void main() { await signRegistration(chainId: chainId); - final captured = verify( + // called(1): a second sign would mean a second on-device confirmation prompt. + final captured = (verify( () => manager.signETHTypedMessage(captureAny(), any(), captureAny()), - ).captured; + )..called(1)).captured; final typedData = jsonDecode(utf8.decode(captured[1] as Uint8List)) as Map; expect(captured[0], chainId);