diff --git a/lib/packages/hardware_wallet/fake_bitbox_credentials.dart b/lib/packages/hardware_wallet/fake_bitbox_credentials.dart new file mode 100644 index 00000000..d54c5241 --- /dev/null +++ b/lib/packages/hardware_wallet/fake_bitbox_credentials.dart @@ -0,0 +1,137 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:eth_sig_util_plus/eth_sig_util_plus.dart'; +import 'package:realunit_wallet/packages/hardware_wallet/bitbox_credentials.dart'; +import 'package:realunit_wallet/packages/wallet/exceptions/signing_cancelled_exception.dart'; +import 'package:web3dart/crypto.dart'; +import 'package:web3dart/web3dart.dart'; + +/// Behaviour modes for [FakeBitboxCredentials]. Each mode mirrors a real-world +/// outcome of the hardware-wallet sign ceremony that the app must handle. +enum FakeBitboxBehavior { + /// The user confirms on the device — sign returns a real signature derived + /// from a deterministic test private key. + success, + + /// The user cancels on the device — the iOS BitBox bridge returns `'0x'`. + cancel, + + /// BLE link drops before/during the ceremony — the credentials report + /// `isConnected == false` and the sign throws. + disconnect, + + /// The device hangs and never responds — the sign awaits forever (caller + /// must impose its own outer timeout). + timeout, + + /// The device returns an unparsable byte stream (frame desync regression + /// like bitbox_flutter PR #11). The fake returns a non-hex string. + malformed, +} + +/// Deterministic test private key used to derive a stable address and produce +/// real EIP-712 signatures in [FakeBitboxBehavior.success] mode. The +/// derived address is `0x9F5713dEAcb8e9CaB6c2D3FaE1aFc2715F8D2D71`. Do NOT +/// reuse this seed outside of tests. +const String _testPrivateKeyHex = + 'fb1ace12f9801e85f3db1b3935dd47d9f064f98152466f47c701b5e12680e612'; + +/// In-test stand-in for a real BitBox-backed [BitboxCredentials]. Replaces +/// the BLE/USB-driven `BitboxManager` calls with a controllable outcome. +/// +/// `is BitboxCredentials` continues to hold so all production code paths that +/// special-case the hardware wallet (e.g. the BitboxNotConnectedException +/// guard in `RealUnitRegistrationService`) treat instances of this class +/// identically to a real one. +class FakeBitboxCredentials extends BitboxCredentials { + FakeBitboxCredentials({ + String? address, + this.behavior = FakeBitboxBehavior.success, + this.signDelay = const Duration(milliseconds: 50), + }) : super(address ?? EthPrivateKey.fromHex(_testPrivateKeyHex).address.hexEip55); + + /// Outcome the next sign call will produce. Mutable so a single instance + /// can simulate a reconnect (e.g. switch from `disconnect` to `success` + /// without rebuilding the wallet). + FakeBitboxBehavior behavior; + + /// Simulated time the user spends confirming on the device. Set to zero in + /// tight unit tests, leave at the default for flows that need to observe + /// the in-flight loading state. + Duration signDelay; + + /// Number of successful or attempted signs since construction. Useful for + /// asserting that a cancelled sign was retried exactly once. + int signCallCount = 0; + + @override + bool get isConnected => behavior != FakeBitboxBehavior.disconnect; + + @override + Future signTypedDataV4(int chainId, String jsonData) async { + signCallCount++; + await Future.delayed(signDelay); + switch (behavior) { + case FakeBitboxBehavior.success: + return EthSigUtil.signTypedData( + privateKey: _testPrivateKeyHex, + jsonData: jsonData, + version: TypedDataVersion.V4, + ); + case FakeBitboxBehavior.cancel: + return '0x'; + case FakeBitboxBehavior.disconnect: + throw const SigningCancelledException(); + case FakeBitboxBehavior.timeout: + await Completer().future; + return ''; + case FakeBitboxBehavior.malformed: + return '0xnot_hex_data'; + } + } + + @override + Future signPersonalMessage(Uint8List payload, {int? chainId}) async { + signCallCount++; + await Future.delayed(signDelay); + switch (behavior) { + case FakeBitboxBehavior.success: + final pk = EthPrivateKey.fromHex(_testPrivateKeyHex); + return pk.signPersonalMessageToUint8List(payload); + case FakeBitboxBehavior.cancel: + return Uint8List(0); + case FakeBitboxBehavior.disconnect: + throw const SigningCancelledException(); + case FakeBitboxBehavior.timeout: + await Completer().future; + return Uint8List(0); + case FakeBitboxBehavior.malformed: + return Uint8List.fromList(utf8.encode('not a sig')); + } + } + + @override + Future signToSignature( + Uint8List payload, { + int? chainId, + bool isEIP1559 = false, + }) async { + signCallCount++; + await Future.delayed(signDelay); + switch (behavior) { + case FakeBitboxBehavior.success: + final pk = EthPrivateKey.fromHex(_testPrivateKeyHex); + return pk.signToSignature(payload, chainId: chainId, isEIP1559: isEIP1559); + case FakeBitboxBehavior.cancel: + case FakeBitboxBehavior.disconnect: + throw const SigningCancelledException(); + case FakeBitboxBehavior.timeout: + await Completer().future; + return MsgSignature(BigInt.zero, BigInt.zero, 0); + case FakeBitboxBehavior.malformed: + throw const FormatException('Malformed signature from device'); + } + } +} diff --git a/test/integration/kyc_sign_flow_test.dart b/test/integration/kyc_sign_flow_test.dart new file mode 100644 index 00000000..7b44b597 --- /dev/null +++ b/test/integration/kyc_sign_flow_test.dart @@ -0,0 +1,109 @@ +// Cross-layer integration tests for the BitBox-gated KYC sign flow. +// +// These tests stitch together three layers that the registration ceremony +// touches end-to-end and that have all had production bugs in the past +// month (PR #312, #316, #318, #319): +// +// FakeBitboxCredentials → Eip712Signer.signRegistration → SigningCancelledException +// +// They run headless (no device, no simulator), so they live under +// `test/integration/` and run as part of `flutter test`. Future scenarios +// that need the integration_test binding (full app boot, BLE/USB channels) +// will move to a top-level `integration_test/` directory. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/packages/hardware_wallet/fake_bitbox_credentials.dart'; +import 'package:realunit_wallet/packages/wallet/eip712_signer.dart'; +import 'package:realunit_wallet/packages/wallet/exceptions/signing_cancelled_exception.dart'; + +Future _signRegistration(FakeBitboxCredentials credentials) => + Eip712Signer.signRegistration( + credentials: credentials, + chainId: 1, + type: 'HUMAN', + email: 'fake@dfx.swiss', + name: 'Fake User', + phoneNumber: '+41790000000', + birthday: '1990-01-01', + nationality: 'CH', + addressStreet: 'Teststrasse 1', + addressPostalCode: '8000', + addressCity: 'Zurich', + addressCountry: 'CH', + swissTaxResidence: true, + registrationDate: '2026-05-13', + ); + +void main() { + group('KYC sign flow — FakeBitboxCredentials × Eip712Signer', () { + test( + 'happy path: fake produces a sig that passes the empty-signature guard', + () async { + final fake = FakeBitboxCredentials(signDelay: Duration.zero); + + final sig = await _signRegistration(fake); + + expect(sig, startsWith('0x')); + expect(sig.length, 132); + expect(fake.signCallCount, 1); + }, + ); + + test( + 'cancel mid-sign: fake returns "0x" → Eip712Signer throws SigningCancelledException', + () async { + final fake = FakeBitboxCredentials( + behavior: FakeBitboxBehavior.cancel, + signDelay: Duration.zero, + ); + + await expectLater( + _signRegistration(fake), + throwsA(isA()), + ); + expect(fake.signCallCount, 1); + }, + ); + + test( + 'BLE disconnect: fake throws SigningCancelledException directly', + () async { + final fake = FakeBitboxCredentials( + behavior: FakeBitboxBehavior.disconnect, + signDelay: Duration.zero, + ); + + await expectLater( + _signRegistration(fake), + throwsA(isA()), + ); + }, + ); + + test( + 'reconnect-and-retry: a disconnected fake flipped to success completes on retry', + () async { + final fake = FakeBitboxCredentials( + behavior: FakeBitboxBehavior.disconnect, + signDelay: Duration.zero, + ); + + await expectLater( + _signRegistration(fake), + throwsA(isA()), + ); + + fake.behavior = FakeBitboxBehavior.success; + final retrySig = await _signRegistration(fake); + + expect(retrySig, startsWith('0x')); + expect(retrySig.length, 132); + expect( + fake.signCallCount, + 2, + reason: 'one failed sign attempt and one successful retry', + ); + }, + ); + }); +} diff --git a/test/packages/hardware_wallet/fake_bitbox_credentials_test.dart b/test/packages/hardware_wallet/fake_bitbox_credentials_test.dart new file mode 100644 index 00000000..9327de5e --- /dev/null +++ b/test/packages/hardware_wallet/fake_bitbox_credentials_test.dart @@ -0,0 +1,177 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/packages/hardware_wallet/bitbox_credentials.dart'; +import 'package:realunit_wallet/packages/hardware_wallet/fake_bitbox_credentials.dart'; +import 'package:realunit_wallet/packages/wallet/exceptions/signing_cancelled_exception.dart'; + +const _typedData = + '{"types":{"EIP712Domain":[{"name":"name","type":"string"}],' + '"T":[{"name":"x","type":"string"}]},' + '"primaryType":"T","domain":{"name":"T"},"message":{"x":"y"}}'; + +void main() { + group('$FakeBitboxCredentials', () { + test('is recognised as a BitboxCredentials (type guard preserved)', () { + final fake = FakeBitboxCredentials(); + + expect(fake, isA()); + }); + + test('derives a stable address from the embedded test private key', () { + final a = FakeBitboxCredentials(); + final b = FakeBitboxCredentials(); + + expect(a.address, equals(b.address)); + // EIP-55 checksummed form — sanity that we are wired to web3dart. + expect(a.address.hexEip55, startsWith('0x')); + }); + + test('accepts an explicit address override', () { + const explicit = '0x1111111111111111111111111111111111111111'; + final fake = FakeBitboxCredentials(address: explicit); + + expect(fake.address.hex.toLowerCase(), explicit.toLowerCase()); + }); + + group('signTypedDataV4', () { + test('success: returns a deterministic valid signature', () async { + final fake = FakeBitboxCredentials(signDelay: Duration.zero); + + final sig = await fake.signTypedDataV4(1, _typedData); + + expect(sig, startsWith('0x')); + expect(sig.length, 132); // '0x' + 130 hex chars = 65 bytes + expect(fake.signCallCount, 1); + }); + + test("cancel: returns '0x' (matches the iOS bridge's cancel signal)", () async { + final fake = FakeBitboxCredentials( + behavior: FakeBitboxBehavior.cancel, + signDelay: Duration.zero, + ); + + final sig = await fake.signTypedDataV4(1, _typedData); + + expect(sig, '0x'); + }); + + test('disconnect: throws SigningCancelledException', () async { + final fake = FakeBitboxCredentials( + behavior: FakeBitboxBehavior.disconnect, + signDelay: Duration.zero, + ); + + expect( + () => fake.signTypedDataV4(1, _typedData), + throwsA(isA()), + ); + }); + + test('disconnect: reports isConnected == false', () { + final fake = FakeBitboxCredentials(behavior: FakeBitboxBehavior.disconnect); + + expect(fake.isConnected, isFalse); + }); + + test('non-disconnect behaviours report isConnected == true', () { + for (final mode in const [ + FakeBitboxBehavior.success, + FakeBitboxBehavior.cancel, + FakeBitboxBehavior.timeout, + FakeBitboxBehavior.malformed, + ]) { + final fake = FakeBitboxCredentials(behavior: mode); + expect(fake.isConnected, isTrue, reason: 'mode $mode'); + } + }); + + test('timeout: never resolves (caller must impose its own timeout)', () async { + final fake = FakeBitboxCredentials( + behavior: FakeBitboxBehavior.timeout, + signDelay: Duration.zero, + ); + + await expectLater( + fake.signTypedDataV4(1, _typedData).timeout(const Duration(milliseconds: 50)), + throwsA(isA()), + ); + }); + + test('malformed: returns a non-hex string (frame-desync simulation)', () async { + final fake = FakeBitboxCredentials( + behavior: FakeBitboxBehavior.malformed, + signDelay: Duration.zero, + ); + + final sig = await fake.signTypedDataV4(1, _typedData); + + expect(sig, isNot(matches(RegExp(r'^0x[0-9a-f]+$')))); + }); + + test('honours signDelay before resolving', () async { + final fake = FakeBitboxCredentials( + signDelay: const Duration(milliseconds: 100), + ); + + final sw = Stopwatch()..start(); + await fake.signTypedDataV4(1, _typedData); + sw.stop(); + + expect(sw.elapsedMilliseconds, greaterThanOrEqualTo(80)); + }); + + test('behaviour flip after construction simulates reconnect', () async { + final fake = FakeBitboxCredentials( + behavior: FakeBitboxBehavior.disconnect, + signDelay: Duration.zero, + ); + + await expectLater( + fake.signTypedDataV4(1, _typedData), + throwsA(isA()), + ); + + fake.behavior = FakeBitboxBehavior.success; + final retrySig = await fake.signTypedDataV4(1, _typedData); + + expect(retrySig, startsWith('0x')); + expect(retrySig.length, 132); + }); + }); + + group('signPersonalMessage', () { + test('success: returns a 65-byte signature', () async { + final fake = FakeBitboxCredentials(signDelay: Duration.zero); + + final sig = await fake.signPersonalMessage(Uint8List.fromList([1, 2, 3])); + + expect(sig.length, 65); + }); + + test('cancel: returns empty bytes', () async { + final fake = FakeBitboxCredentials( + behavior: FakeBitboxBehavior.cancel, + signDelay: Duration.zero, + ); + + final sig = await fake.signPersonalMessage(Uint8List.fromList([1, 2, 3])); + + expect(sig, isEmpty); + }); + + test('disconnect: throws SigningCancelledException', () async { + final fake = FakeBitboxCredentials( + behavior: FakeBitboxBehavior.disconnect, + signDelay: Duration.zero, + ); + + expect( + () => fake.signPersonalMessage(Uint8List.fromList([1, 2, 3])), + throwsA(isA()), + ); + }); + }); + }); +}