diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index 767ac2b..5860a5b 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -34,7 +34,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: '1.24' + go-version-file: go/go.mod cache: true cache-dependency-path: go/go.sum - run: go vet ./... diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..5ebf057 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,94 @@ +# Testing + +## Fast PR gate + +Run the same checks the `PR checks` workflow enforces. CI fails on any of +these, so it is cheaper to catch them locally first: + +```sh +# Flutter +dart format --set-exit-if-changed --output=none lib test example/lib example/test +flutter analyze --no-fatal-infos +flutter test + +# Go (from go/) +cd go +go vet ./... +go test -race -timeout 60s ./... +``` + +The Go API tests include a generic fake BitBox device. It is not +app-specific: it can simulate pairing, channel hashes, confirmations, +capabilities, ETH address lookup, ETH signing, BTC xpubs, BTC message signing, +device errors, missing devices, and recovered panics without connecting +hardware. + +## Test layers + +Use the lowest layer that can expose the bug: + +- Official simulator: validates against BitBox firmware behavior. The upstream + simulator binaries currently referenced by `bitbox02-api-go` are Linux amd64, + so this is best suited for Linux CI or explicit hardware-wallet integration + jobs. +- U2FHID/BLE contract tests: validate framing assumptions, stale-buffer + behavior, repeated poll responses, and the iOS BLE bridge source contract. + These tests do not emulate firmware. +- Native API fake: validates gomobile-exported API behavior, zero values, + panic recovery, and BTC/ETH request plumbing without USB, BLE, or firmware. +- Flutter API fake: validates app/plugin flows through `BitboxManager` without + USB, BLE, native code, or firmware. + +Keep physical BitBox smoke tests for behavior that requires the real device: +firmware UI, touch confirmation timing, pairing UX, cable/BLE hardware +instability, and secure-chip behavior. + +## Reusable Flutter testkit + +Flutter apps can import the standalone Dart simulator: + +```dart +import 'package:bitbox_flutter/testing.dart'; + +final bitbox = installSimulatedBitboxPlatform( + channelHash: 'hash-shown-to-the-user', +); +``` + +The simulator replaces `BitboxUsbPlatform.instance`, so app tests can exercise +their real production BitboxManager flow without USB, BLE, or a physical +BitBox. Save and restore the previous platform in `setUp`/`tearDown` when a test +suite needs isolation. + +The Dart simulator is deliberately not app-specific. It covers device +discovery/no-device states, permission/open/close, pairing channel hashes +including empty hashes and rejected confirmations, capability checks, BTC/ETH +signing, custom per-method delays, custom per-method errors, custom method +behavior, and a call log that tests can assert against. + +## Official BitBox simulator + +`github.com/BitBoxSwiss/bitbox02-api-go` also ships official `TestSimulator*` +integration tests. Their README documents: + +```sh +go test -v -run TestSimulator ./... +SIMULATOR=/path/to/simulator go test -v -run TestSimulator ./... +``` + +The published simulator binaries referenced by the dependency are Linux amd64 +binaries, so they are best suited for Linux CI or a Linux development machine. +The fast fake-device tests in this plugin remain the default local and PR gate. + +## Regression coverage + +The tests explicitly guard against these hardware-wallet regressions: + +- gomobile-exported API functions without `recoverPanic` +- iOS BLE packet deduplication being reintroduced +- iOS BLE read timeout regressing from 60 seconds to 10 seconds +- U2FHID assumptions drifting away from the iOS BLE bridge contract +- Pairing/channel-hash behavior not being simulatable without hardware +- ETH/BTC success, error, and panic flows not being simulatable without hardware +- App-level Flutter flows not being testable with deterministic BitBox delays + and aborts diff --git a/go/api/api.go b/go/api/api.go index 259debb..ce07e66 100644 --- a/go/api/api.go +++ b/go/api/api.go @@ -100,8 +100,6 @@ func (d deviceInfo) Open() (io.ReadWriteCloser, error) { return readWriteCloser{device}, nil } -var bitbox *firmware.Device - //export GetDevice func GetDevice(device GoReadWriteCloserInterface) { defer recoverPanic("GetDevice") diff --git a/go/api/bitbox_device.go b/go/api/bitbox_device.go new file mode 100644 index 0000000..63e83ee --- /dev/null +++ b/go/api/bitbox_device.go @@ -0,0 +1,69 @@ +package api + +import ( + "math/big" + + "github.com/BitBoxSwiss/bitbox02-api-go/api/firmware" + "github.com/BitBoxSwiss/bitbox02-api-go/api/firmware/messages" + "github.com/btcsuite/btcd/btcutil/psbt" +) + +type bitboxDevice interface { + Init() error + ChannelHash() (string, bool) + ChannelHashVerify(ok bool) + DeviceInfo() (*firmware.DeviceInfo, error) + RootFingerprint() ([]byte, error) + SupportsETH(chainID uint64) bool + SupportsLTC() bool + SupportsBluetooth() bool + SupportsERC20(contractAddress string) bool + + ETHPub( + chainID uint64, + keypath []uint32, + outputType messages.ETHPubRequest_OutputType, + display bool, + contractAddress []byte, + ) (string, error) + ETHSign( + chainID uint64, + keypath []uint32, + nonce uint64, + gasPrice *big.Int, + gasLimit uint64, + recipient [20]byte, + value *big.Int, + data []byte, + recipientAddressCase messages.ETHAddressCase, + ) ([]byte, error) + ETHSignEIP1559( + chainID uint64, + keypath []uint32, + nonce uint64, + maxPriorityFeePerGas *big.Int, + maxFeePerGas *big.Int, + gasLimit uint64, + recipient [20]byte, + value *big.Int, + data []byte, + recipientAddressCase messages.ETHAddressCase, + ) ([]byte, error) + ETHSignMessage(chainID uint64, keypath []uint32, msg []byte) ([]byte, error) + ETHSignTypedMessage(chainID uint64, keypath []uint32, jsonMsg []byte) ([]byte, error) + + BTCXPub( + coin messages.BTCCoin, + keypath []uint32, + xpubType messages.BTCPubRequest_XPubType, + display bool, + ) (string, error) + BTCSignPSBT(coin messages.BTCCoin, psbt *psbt.Packet, options *firmware.PSBTSignOptions) error + BTCSignMessage( + coin messages.BTCCoin, + scriptConfig *messages.BTCScriptConfigWithKeypath, + message []byte, + ) (*firmware.BTCSignMessageResult, error) +} + +var bitbox bitboxDevice diff --git a/go/api/fake_bitbox_test.go b/go/api/fake_bitbox_test.go new file mode 100644 index 0000000..3a24f47 --- /dev/null +++ b/go/api/fake_bitbox_test.go @@ -0,0 +1,318 @@ +package api + +import ( + "encoding/hex" + "errors" + "math/big" + "reflect" + "testing" + + "github.com/BitBoxSwiss/bitbox02-api-go/api/firmware" + "github.com/BitBoxSwiss/bitbox02-api-go/api/firmware/messages" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/rlp" +) + +type fakeBitboxDevice struct { + initErr error + + channelHash string + channelHashOk bool + channelHashVerified *bool + + deviceInfo *firmware.DeviceInfo + deviceInfoErr error + rootFingerprint []byte + rootFingerprintErr error + + supportsETH bool + supportsLTC bool + supportsBluetooth bool + supportedERC20 map[string]bool + + ethPubResult string + ethPubErr error + ethSignResult []byte + ethSignErr error + ethSignEIP1559Result []byte + ethSignEIP1559Err error + ethSignMessageResult []byte + ethSignMessageErr error + ethSignTypedMessageResult []byte + ethSignTypedMessageErr error + + btcXPubResult string + btcXPubErr error + btcSignPSBTErr error + btcSignMessageSig []byte + btcSignMessageErr error + panicOnETHSignTyped bool + + calls []string +} + +func (f *fakeBitboxDevice) Init() error { + f.calls = append(f.calls, "Init") + return f.initErr +} + +func (f *fakeBitboxDevice) ChannelHash() (string, bool) { + f.calls = append(f.calls, "ChannelHash") + return f.channelHash, f.channelHashOk +} + +func (f *fakeBitboxDevice) ChannelHashVerify(ok bool) { + f.calls = append(f.calls, "ChannelHashVerify") + f.channelHashVerified = &ok +} + +func (f *fakeBitboxDevice) DeviceInfo() (*firmware.DeviceInfo, error) { + f.calls = append(f.calls, "DeviceInfo") + return f.deviceInfo, f.deviceInfoErr +} + +func (f *fakeBitboxDevice) RootFingerprint() ([]byte, error) { + f.calls = append(f.calls, "RootFingerprint") + return f.rootFingerprint, f.rootFingerprintErr +} + +func (f *fakeBitboxDevice) SupportsETH(chainID uint64) bool { + f.calls = append(f.calls, "SupportsETH") + return f.supportsETH && chainID != 0 +} + +func (f *fakeBitboxDevice) SupportsLTC() bool { + f.calls = append(f.calls, "SupportsLTC") + return f.supportsLTC +} + +func (f *fakeBitboxDevice) SupportsBluetooth() bool { + f.calls = append(f.calls, "SupportsBluetooth") + return f.supportsBluetooth +} + +func (f *fakeBitboxDevice) SupportsERC20(contractAddress string) bool { + f.calls = append(f.calls, "SupportsERC20") + return f.supportedERC20[contractAddress] +} + +func (f *fakeBitboxDevice) ETHPub(uint64, []uint32, messages.ETHPubRequest_OutputType, bool, []byte) (string, error) { + f.calls = append(f.calls, "ETHPub") + return f.ethPubResult, f.ethPubErr +} + +func (f *fakeBitboxDevice) ETHSign(uint64, []uint32, uint64, *big.Int, uint64, [20]byte, *big.Int, []byte, messages.ETHAddressCase) ([]byte, error) { + f.calls = append(f.calls, "ETHSign") + return f.ethSignResult, f.ethSignErr +} + +func (f *fakeBitboxDevice) ETHSignEIP1559(uint64, []uint32, uint64, *big.Int, *big.Int, uint64, [20]byte, *big.Int, []byte, messages.ETHAddressCase) ([]byte, error) { + f.calls = append(f.calls, "ETHSignEIP1559") + return f.ethSignEIP1559Result, f.ethSignEIP1559Err +} + +func (f *fakeBitboxDevice) ETHSignMessage(uint64, []uint32, []byte) ([]byte, error) { + f.calls = append(f.calls, "ETHSignMessage") + return f.ethSignMessageResult, f.ethSignMessageErr +} + +func (f *fakeBitboxDevice) ETHSignTypedMessage(uint64, []uint32, []byte) ([]byte, error) { + f.calls = append(f.calls, "ETHSignTypedMessage") + if f.panicOnETHSignTyped { + panic("simulated typed-data panic") + } + return f.ethSignTypedMessageResult, f.ethSignTypedMessageErr +} + +func (f *fakeBitboxDevice) BTCXPub(messages.BTCCoin, []uint32, messages.BTCPubRequest_XPubType, bool) (string, error) { + f.calls = append(f.calls, "BTCXPub") + return f.btcXPubResult, f.btcXPubErr +} + +func (f *fakeBitboxDevice) BTCSignPSBT(messages.BTCCoin, *psbt.Packet, *firmware.PSBTSignOptions) error { + f.calls = append(f.calls, "BTCSignPSBT") + return f.btcSignPSBTErr +} + +func (f *fakeBitboxDevice) BTCSignMessage(messages.BTCCoin, *messages.BTCScriptConfigWithKeypath, []byte) (*firmware.BTCSignMessageResult, error) { + f.calls = append(f.calls, "BTCSignMessage") + return &firmware.BTCSignMessageResult{Signature: f.btcSignMessageSig}, f.btcSignMessageErr +} + +func withFakeBitbox(t *testing.T, fake *fakeBitboxDevice) { + t.Helper() + previous := bitbox + bitbox = fake + t.Cleanup(func() { + bitbox = previous + }) +} + +func TestFakeBitboxHarnessSimulatesPairingAndCapabilities(t *testing.T) { + verified := false + fake := &fakeBitboxDevice{ + channelHash: "PAIR-CODE", + channelHashOk: true, + channelHashVerified: &verified, + deviceInfo: &firmware.DeviceInfo{Name: "Simulated BitBox"}, + rootFingerprint: []byte{0x01, 0x02, 0x03, 0x04}, + supportsETH: true, + supportsLTC: true, + supportsBluetooth: true, + supportedERC20: map[string]bool{"0xToken": true}, + } + withFakeBitbox(t, fake) + + if !InitDevice() { + t.Fatal("expected simulated init to succeed") + } + if got := GetChannelHash(); got != "PAIR-CODE" { + t.Fatalf("expected channel hash, got %q", got) + } + ChannelHashVerify(true) + if fake.channelHashVerified == nil || !*fake.channelHashVerified { + t.Fatal("expected simulated channel hash confirmation") + } + if !SupportsETH(1) || !SupportsLTC() || !SupportsBluetooth() || !SupportsERC20("0xToken") { + t.Fatal("expected simulated capabilities to be exposed") + } + if got := DeviceInfo().Name; got != "Simulated BitBox" { + t.Fatalf("expected simulated device info, got %q", got) + } + if got := GetMasterFingerprint(); !reflect.DeepEqual(got, []byte{0x01, 0x02, 0x03, 0x04}) { + t.Fatalf("expected simulated root fingerprint, got %x", got) + } +} + +func TestFakeBitboxHarnessSimulatesEthereumAndBitcoinOperations(t *testing.T) { + fake := &fakeBitboxDevice{ + ethPubResult: "0x0000000000000000000000000000000000000001", + ethSignResult: []byte{0x11}, + ethSignEIP1559Result: []byte{0x22}, + ethSignMessageResult: []byte{0x33}, + ethSignTypedMessageResult: []byte{0x44}, + btcXPubResult: "xpub-simulated", + btcSignMessageSig: []byte{0x55}, + } + withFakeBitbox(t, fake) + + keypath := "0000002c0000003c000000000000000000000000" + if got := ETHGetAddress(1, keypath, int(messages.ETHPubRequest_ADDRESS), false, nil); got != fake.ethPubResult { + t.Fatalf("expected fake ETH address, got %q", got) + } + if got := ETHSignTransaction(1, keypath, 1, "01", 21000, make([]byte, 20), "01", nil, int(messages.ETHAddressCase_ETH_ADDRESS_CASE_LOWER)); !reflect.DeepEqual(got, []byte{0x11}) { + t.Fatalf("expected fake ETH legacy signature, got %x", got) + } + encodedLegacyTx, err := rlp.EncodeToBytes(legacyTxPayload{ + Nonce: 1, + GasPrice: big.NewInt(1), + Gas: 21000, + To: ptr(common.HexToAddress("0x0000000000000000000000000000000000000001")), + Value: big.NewInt(1), + }) + if err != nil { + t.Fatal(err) + } + if got := ETHSignRPLTx(1, keypath, hex.EncodeToString(encodedLegacyTx), false); !reflect.DeepEqual(got, []byte{0x11}) { + t.Fatalf("expected fake ETH RLP signature, got %x", got) + } + if got := ETHSignEIP1559(1, keypath, 1, "01", "02", 21000, make([]byte, 20), "01", nil, int(messages.ETHAddressCase_ETH_ADDRESS_CASE_LOWER)); !reflect.DeepEqual(got, []byte{0x22}) { + t.Fatalf("expected fake ETH EIP1559 signature, got %x", got) + } + if got := ETHSignMessage(1, keypath, []byte("hello")); !reflect.DeepEqual(got, []byte{0x33}) { + t.Fatalf("expected fake ETH message signature, got %x", got) + } + if got := ETHSignTypedMessage(1, keypath, []byte(`{"types":{},"primaryType":"Mail"}`)); !reflect.DeepEqual(got, []byte{0x44}) { + t.Fatalf("expected fake ETH typed signature, got %x", got) + } + if got := BTCXPub(int(messages.BTCCoin_BTC), keypath, int(messages.BTCPubRequest_XPUB), false); got != "xpub-simulated" { + t.Fatalf("expected fake xpub, got %q", got) + } + const validPSBT = "cHNidP8BAHECAAAAAfbXTun4YYxDroWyzRq3jDsWFVlsZ7HUzxiORY/iR4goAAAAAAD9////AuLCAAAAAAAAFgAUg3w5W0zt3AmxRmgA5Q6wZJUDRhUowwAAAAAAABYAFJjQqUoXDcwUEqfExu9pnaSn5XBct0ElAAABAR+ghgEAAAAAABYAFHn03igII+hp819N2Zlb5LnN8atRAQDfAQAAAAABAZ9EJlMJnXF5bFVrb1eFBYrEev3pg35WpvS3RlELsMMrAQAAAAD9////AqCGAQAAAAAAFgAUefTeKAgj6GnzX03ZmVvkuc3xq1EoRs4JAAAAABYAFKG2PzjYjknaA6lmXFqPaSgHwXX9AkgwRQIhAL0v0r3LisQ9KOlGzMhM/xYqUmrv2a5sORRlkX1fqDC8AiB9XqxSNEdb4mPnp7ylF1cAlbAZ7jMhgIxHUXylTww3bwEhA0AEOM0yYEpexPoKE3vT51uxZ+8hk9sOEfBFKOeo6oDDAAAAACIGAyNQfmAT/YLmZaxxfDwClmVNt2BkFnfQu/i8Uc/hHDUiGBKiwYlUAACAAQAAgAAAAIAAAAAAAAAAAAAAIgIDnxFM7Qr9LvJwQDB9GozdTRIe3MYVuHOqT7dU2EuvHrIYEqLBiVQAAIABAACAAAAAgAEAAAAAAAAAAA==" + if got := BTCSignPSBT(int(messages.BTCCoin_BTC), validPSBT); got == "" { + t.Fatal("expected fake PSBT signing to return an encoded PSBT") + } + if got := BTCSignMessage(int(messages.BTCCoin_BTC), keypath, []byte("hello")); !reflect.DeepEqual(got, []byte{0x55}) { + t.Fatalf("expected fake BTC message signature, got %x", got) + } +} + +func ptr[T any](value T) *T { + return &value +} + +func TestFakeBitboxHarnessSimulatesErrorsAndPanicsWithoutCrashing(t *testing.T) { + fake := &fakeBitboxDevice{ + initErr: errors.New("init failed"), + ethPubErr: errors.New("address rejected"), + ethSignMessageErr: errors.New("message rejected"), + btcXPubErr: errors.New("xpub rejected"), + rootFingerprintErr: errors.New("fingerprint rejected"), + panicOnETHSignTyped: true, + ethSignTypedMessageErr: errors.New("unused"), + ethSignTypedMessageResult: []byte{0xaa}, + } + withFakeBitbox(t, fake) + + if InitDevice() { + t.Fatal("expected simulated init error") + } + keypath := "0000002c0000003c000000000000000000000000" + if got := ETHGetAddress(1, keypath, int(messages.ETHPubRequest_ADDRESS), false, nil); got != "" { + t.Fatalf("expected empty address on simulated error, got %q", got) + } + if got := ETHSignMessage(1, keypath, []byte("hello")); got != nil { + t.Fatalf("expected nil ETH signature on simulated error, got %x", got) + } + if got := BTCXPub(int(messages.BTCCoin_BTC), keypath, int(messages.BTCPubRequest_XPUB), false); got != "" { + t.Fatalf("expected empty xpub on simulated error, got %q", got) + } + if got := GetMasterFingerprint(); len(got) != 0 { + t.Fatalf("expected empty fingerprint on simulated error, got %x", got) + } + if got := ETHSignTypedMessage(1, keypath, []byte(`{}`)); got != nil { + t.Fatalf("expected panic recovery to return nil signature, got %x", got) + } +} + +func TestExportedAPIsReturnZeroValuesWithoutDeviceInsteadOfCrashing(t *testing.T) { + previous := bitbox + bitbox = nil + t.Cleanup(func() { + bitbox = previous + }) + + keypath := "0000002c0000003c000000000000000000000000" + if InitDevice() { + t.Fatal("expected InitDevice to fail without a device") + } + if got := GetChannelHash(); got != "" { + t.Fatalf("expected empty channel hash without device, got %q", got) + } + ChannelHashVerify(false) + if SupportsETH(1) || SupportsLTC() || SupportsBluetooth() || SupportsERC20("0xToken") { + t.Fatal("expected no capabilities without device") + } + if got := DeviceInfo(); got.Name != "" { + t.Fatalf("expected zero device info without device, got %+v", got) + } + if got := ETHGetAddress(1, keypath, int(messages.ETHPubRequest_ADDRESS), false, nil); got != "" { + t.Fatalf("expected empty ETH address without device, got %q", got) + } + if got := ETHSignMessage(1, keypath, []byte("hello")); got != nil { + t.Fatalf("expected nil ETH message signature without device, got %x", got) + } + if got := ETHSignTypedMessage(1, keypath, []byte(`{}`)); got != nil { + t.Fatalf("expected nil ETH typed signature without device, got %x", got) + } + if got := BTCXPub(int(messages.BTCCoin_BTC), keypath, int(messages.BTCPubRequest_XPUB), false); got != "" { + t.Fatalf("expected empty xpub without device, got %q", got) + } + if got := BTCSignMessage(int(messages.BTCCoin_BTC), keypath, []byte("hello")); got != nil { + t.Fatalf("expected nil BTC message signature without device, got %x", got) + } + if got := GetMasterFingerprint(); len(got) != 0 { + t.Fatalf("expected empty fingerprint without device, got %x", got) + } +} diff --git a/go/api/ios_bluetooth_regression_test.go b/go/api/ios_bluetooth_regression_test.go new file mode 100644 index 0000000..9f41c0b --- /dev/null +++ b/go/api/ios_bluetooth_regression_test.go @@ -0,0 +1,25 @@ +package api + +import ( + "os" + "strings" + "testing" +) + +// 60-second BLE read timeout is required for long BitBox confirmation flows +// (typed-data signing, multi-output PSBTs). The previous 10s timeout +// regressed signing on real hardware and must not come back. +func TestIOSBluetoothKeeps60sReadTimeout(t *testing.T) { + contentBytes, err := os.ReadFile("../../ios/Classes/Bluetooth.swift") + if err != nil { + t.Fatal(err) + } + content := string(contentBytes) + + if !strings.Contains(content, "let waitResult = ctx.semaphore.wait(timeout: .now() + 60)") { + t.Fatal("Bluetooth.swift must keep the 60s BLE read timeout for long BitBox confirmations") + } + if strings.Contains(content, "read timed out after 10s") || strings.Contains(content, ".now() + 10") { + t.Fatal("Bluetooth.swift must not regress to the old 10s BLE read timeout") + } +} diff --git a/lib/testing.dart b/lib/testing.dart new file mode 100644 index 0000000..ad5ba39 --- /dev/null +++ b/lib/testing.dart @@ -0,0 +1 @@ +export 'testing/bitbox_testkit.dart'; diff --git a/lib/testing/bitbox_testkit.dart b/lib/testing/bitbox_testkit.dart new file mode 100644 index 0000000..89522d2 --- /dev/null +++ b/lib/testing/bitbox_testkit.dart @@ -0,0 +1,624 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:bitbox_flutter/usb/bitbox_device.dart'; +import 'package:bitbox_flutter/usb/bitbox_usb_platform_interface.dart'; + +typedef SimulatedBitboxBehavior = FutureOr Function( + SimulatedBitboxCall call, +); + +final class SimulatedBitboxMethod { + static const getDevices = 'getDevices'; + static const startScan = 'startScan'; + static const requestPermission = 'requestPermission'; + static const open = 'open'; + static const initBitBox = 'initBitBox'; + static const getMasterFingerprint = 'getMasterFingerprint'; + static const getChannelHash = 'getChannelHash'; + static const channelHashVerify = 'channelHashVerify'; + static const supportsETH = 'supportsETH'; + static const supportsERC20 = 'supportsERC20'; + static const supportsLTC = 'supportsLTC'; + static const getBTCXPub = 'getBTCXPub'; + static const signBTCPsbt = 'signBTCPsbt'; + static const signBTCMessage = 'signBTCMessage'; + static const getETHAddress = 'getETHAddress'; + static const signETHRPLTransaction = 'signETHRPLTransaction'; + static const signETHTransaction = 'signETHTransaction'; + static const signETHTransactionEIP1559 = 'signETHTransactionEIP1559'; + static const signETHMessage = 'signETHMessage'; + static const signETHTypedMessage = 'signETHTypedMessage'; + static const close = 'close'; + + const SimulatedBitboxMethod._(); +} + +final class SimulatedBitboxCall { + const SimulatedBitboxCall(this.method, this.arguments); + + final String method; + final Map arguments; + + T argument(String name) => arguments[name] as T; + + @override + String toString() => '$method($arguments)'; +} + +final class SimulatedBitboxStateException implements Exception { + const SimulatedBitboxStateException(this.message); + + final String message; + + @override + String toString() => 'SimulatedBitboxStateException: $message'; +} + +class SimulatedBitboxPlatform extends BitboxUsbPlatform { + SimulatedBitboxPlatform({ + List? devices, + this.defaultDelay = Duration.zero, + this.requireOpen = true, + bool channelHashVerified = false, + this.startScanResult = true, + this.permissionResult = true, + this.openResult = true, + this.initResult = true, + this.channelHashVerifyResult = true, + this.supportsETHResult = true, + this.supportsERC20Result = true, + this.supportsLTCResult = true, + String? channelHash, + Uint8List? masterFingerprint, + String? btcXPub, + String? btcPsbt, + Uint8List? btcMessageSignature, + String? ethAddress, + Uint8List? ethTransactionSignature, + Uint8List? ethEip1559Signature, + Uint8List? ethRlpSignature, + Uint8List? ethMessageSignature, + Uint8List? ethTypedMessageSignature, + Map? delays, + Map? errors, + Map? behaviors, + }) : _devices = List.of( + devices ?? [_defaultDevice], + ), + channelHash = channelHash ?? _defaultChannelHash, + masterFingerprint = _copy(masterFingerprint ?? _defaultFingerprint), + btcXPub = btcXPub ?? _defaultBtcXPub, + btcPsbt = btcPsbt ?? _defaultSignedPsbt, + btcMessageSignature = _copy( + btcMessageSignature ?? _defaultBtcSignature, + ), + ethAddress = ethAddress ?? _defaultEthAddress, + ethTransactionSignature = _copy( + ethTransactionSignature ?? _defaultEthSignature, + ), + ethEip1559Signature = _copy( + ethEip1559Signature ?? _defaultEthEip1559Signature, + ), + ethRlpSignature = _copy(ethRlpSignature ?? _defaultEthRlpSignature), + ethMessageSignature = _copy( + ethMessageSignature ?? _defaultEthMessageSignature, + ), + ethTypedMessageSignature = _copy( + ethTypedMessageSignature ?? _defaultEthTypedMessageSignature, + ), + _delays = Map.of(delays ?? const {}), + _errors = Map.of(errors ?? const {}), + _behaviors = Map.of( + behaviors ?? const {}, + ), + _channelHashVerified = channelHashVerified; + + static final BitboxDevice _defaultDevice = BitboxDevice( + identifier: 'simulated-bitbox-02', + vendorId: 0x03eb, + productId: 0x2403, + productName: 'BitBox02 Simulator', + deviceId: 1, + deviceName: 'BitBox02 Simulator', + manufacturerName: 'Shift Crypto', + configurationCount: 1, + ); + + static const _defaultChannelHash = 'simulated-channel-hash'; + static const _defaultBtcXPub = 'xpub-simulated'; + static const _defaultSignedPsbt = 'psbt-signed-by-simulator'; + static const _defaultEthAddress = + '0x1111111111111111111111111111111111111111'; + + static final _defaultFingerprint = Uint8List.fromList( + [0x12, 0x34, 0x56, 0x78], + ); + static final _defaultBtcSignature = Uint8List.fromList( + [0x30, 0x44, 0x02, 0x20], + ); + static final _defaultEthSignature = Uint8List.fromList([0x01, 0x02]); + static final _defaultEthEip1559Signature = Uint8List.fromList( + [0x03, 0x04], + ); + static final _defaultEthRlpSignature = Uint8List.fromList([0x05, 0x06]); + static final _defaultEthMessageSignature = Uint8List.fromList( + [0x07, 0x08], + ); + static final _defaultEthTypedMessageSignature = Uint8List.fromList( + [0x09, 0x0a], + ); + + final Duration defaultDelay; + final bool requireOpen; + final bool startScanResult; + final bool permissionResult; + final bool openResult; + final bool initResult; + final bool channelHashVerifyResult; + final bool supportsETHResult; + final bool supportsERC20Result; + final bool supportsLTCResult; + final String channelHash; + final Uint8List masterFingerprint; + final String btcXPub; + final String btcPsbt; + final Uint8List btcMessageSignature; + final String ethAddress; + final Uint8List ethTransactionSignature; + final Uint8List ethEip1559Signature; + final Uint8List ethRlpSignature; + final Uint8List ethMessageSignature; + final Uint8List ethTypedMessageSignature; + + final List calls = []; + final Map _delays; + final Map _errors; + final Map _behaviors; + final List _devices; + + bool _isOpen = false; + bool _channelHashVerified; + + static SimulatedBitboxPlatform install({ + List? devices, + Duration defaultDelay = Duration.zero, + bool requireOpen = true, + bool channelHashVerified = false, + bool startScanResult = true, + bool permissionResult = true, + bool openResult = true, + bool initResult = true, + bool channelHashVerifyResult = true, + bool supportsETHResult = true, + bool supportsERC20Result = true, + bool supportsLTCResult = true, + String? channelHash, + Uint8List? masterFingerprint, + String? btcXPub, + String? btcPsbt, + Uint8List? btcMessageSignature, + String? ethAddress, + Uint8List? ethTransactionSignature, + Uint8List? ethEip1559Signature, + Uint8List? ethRlpSignature, + Uint8List? ethMessageSignature, + Uint8List? ethTypedMessageSignature, + Map? delays, + Map? errors, + Map? behaviors, + }) { + final platform = SimulatedBitboxPlatform( + devices: devices, + defaultDelay: defaultDelay, + requireOpen: requireOpen, + channelHashVerified: channelHashVerified, + startScanResult: startScanResult, + permissionResult: permissionResult, + openResult: openResult, + initResult: initResult, + channelHashVerifyResult: channelHashVerifyResult, + supportsETHResult: supportsETHResult, + supportsERC20Result: supportsERC20Result, + supportsLTCResult: supportsLTCResult, + channelHash: channelHash, + masterFingerprint: masterFingerprint, + btcXPub: btcXPub, + btcPsbt: btcPsbt, + btcMessageSignature: btcMessageSignature, + ethAddress: ethAddress, + ethTransactionSignature: ethTransactionSignature, + ethEip1559Signature: ethEip1559Signature, + ethRlpSignature: ethRlpSignature, + ethMessageSignature: ethMessageSignature, + ethTypedMessageSignature: ethTypedMessageSignature, + delays: delays, + errors: errors, + behaviors: behaviors, + ); + BitboxUsbPlatform.instance = platform; + return platform; + } + + bool get isOpen => _isOpen; + + bool get channelHashVerified => _channelHashVerified; + + List get devices => List.unmodifiable(_devices); + + void setDelay(String method, Duration delay) { + _delays[method] = delay; + } + + void throwOn(String method, Object error) { + _errors[method] = error; + } + + void clearError(String method) { + _errors.remove(method); + } + + void when(String method, SimulatedBitboxBehavior behavior) { + _behaviors[method] = behavior; + } + + int count(String method) => + calls.where((call) => call.method == method).length; + + List callsFor(String method) => + calls.where((call) => call.method == method).toList(growable: false); + + @override + Future> getDevices() => _run( + SimulatedBitboxMethod.getDevices, + const {}, + List.of(_devices), + needsOpen: false, + ); + + @override + Future startScan() => _run( + SimulatedBitboxMethod.startScan, + const {}, + startScanResult, + needsOpen: false, + ); + + @override + Future requestPermission(BitboxDevice usbDevice) => _run( + SimulatedBitboxMethod.requestPermission, + {'device': usbDevice}, + permissionResult, + needsOpen: false, + ); + + @override + Future open(BitboxDevice usbDevice) async { + final result = await _run( + SimulatedBitboxMethod.open, + {'device': usbDevice}, + openResult, + needsOpen: false, + ); + _isOpen = result; + return result; + } + + @override + Future initBitBox() => _run( + SimulatedBitboxMethod.initBitBox, + const {}, + initResult, + ); + + @override + Future getMasterFingerprint() => _run( + SimulatedBitboxMethod.getMasterFingerprint, + const {}, + _copy(masterFingerprint), + ); + + @override + Future getChannelHash() => _run( + SimulatedBitboxMethod.getChannelHash, + const {}, + channelHash, + ); + + @override + Future channelHashVerify() async { + final result = await _run( + SimulatedBitboxMethod.channelHashVerify, + const {}, + channelHashVerifyResult, + ); + _channelHashVerified = result; + return result; + } + + @override + Future supportsETH(int chainId) => _run( + SimulatedBitboxMethod.supportsETH, + {'chainId': chainId}, + supportsETHResult, + ); + + @override + Future supportsERC20(String contractAddress) => _run( + SimulatedBitboxMethod.supportsERC20, + {'contractAddress': contractAddress}, + supportsERC20Result, + ); + + @override + Future supportsLTC() => _run( + SimulatedBitboxMethod.supportsLTC, + const {}, + supportsLTCResult, + ); + + @override + Future getBTCXPub( + int coinType, + Uint8List keypath, + int addressType, + bool display, + ) => + _run( + SimulatedBitboxMethod.getBTCXPub, + { + 'coinType': coinType, + 'keypath': _copy(keypath), + 'addressType': addressType, + 'display': display, + }, + btcXPub, + ); + + @override + Future signBTCPsbt(int coinType, String psbt) => _run( + SimulatedBitboxMethod.signBTCPsbt, + {'coinType': coinType, 'psbt': psbt}, + btcPsbt, + ); + + @override + Future signBTCMessage( + int chainId, + Uint8List keypath, + Uint8List message, + ) => + _run( + SimulatedBitboxMethod.signBTCMessage, + { + 'chainId': chainId, + 'keypath': _copy(keypath), + 'message': _copy(message), + }, + _copy(btcMessageSignature), + ); + + @override + Future getETHAddress( + int chainId, + Uint8List keypath, + int outputType, + bool display, + ) => + _run( + SimulatedBitboxMethod.getETHAddress, + { + 'chainId': chainId, + 'keypath': _copy(keypath), + 'outputType': outputType, + 'display': display, + }, + ethAddress, + ); + + @override + Future signETHRPLTransaction( + int chainId, + Uint8List keypath, + String transactionData, + bool isEIP1559, + ) => + _run( + SimulatedBitboxMethod.signETHRPLTransaction, + { + 'chainId': chainId, + 'keypath': _copy(keypath), + 'transactionData': transactionData, + 'isEIP1559': isEIP1559, + }, + _copy(ethRlpSignature), + ); + + @override + Future signETHTransaction( + int chainId, + Uint8List keypath, + int nonce, + String gasPrice, + int gasLimit, + Uint8List recipient, + String value, + Uint8List data, + int recipientAddressCase, + ) => + _run( + SimulatedBitboxMethod.signETHTransaction, + { + 'chainId': chainId, + 'keypath': _copy(keypath), + 'nonce': nonce, + 'gasPrice': gasPrice, + 'gasLimit': gasLimit, + 'recipient': _copy(recipient), + 'value': value, + 'data': _copy(data), + 'recipientAddressCase': recipientAddressCase, + }, + _copy(ethTransactionSignature), + ); + + @override + Future signETHTransactionEIP1559( + int chainId, + Uint8List keypath, + int nonce, + String maxPriorityFeePerGas, + String maxFeePerGas, + int gasLimit, + Uint8List recipient, + String value, + Uint8List data, + int recipientAddressCase, + ) => + _run( + SimulatedBitboxMethod.signETHTransactionEIP1559, + { + 'chainId': chainId, + 'keypath': _copy(keypath), + 'nonce': nonce, + 'maxPriorityFeePerGas': maxPriorityFeePerGas, + 'maxFeePerGas': maxFeePerGas, + 'gasLimit': gasLimit, + 'recipient': _copy(recipient), + 'value': value, + 'data': _copy(data), + 'recipientAddressCase': recipientAddressCase, + }, + _copy(ethEip1559Signature), + ); + + @override + Future signETHMessage( + int chainId, + Uint8List keypath, + Uint8List message, + ) => + _run( + SimulatedBitboxMethod.signETHMessage, + { + 'chainId': chainId, + 'keypath': _copy(keypath), + 'message': _copy(message), + }, + _copy(ethMessageSignature), + ); + + @override + Future signETHTypedMessage( + int chainId, + Uint8List keypath, + Uint8List jsonMessage, + ) => + _run( + SimulatedBitboxMethod.signETHTypedMessage, + { + 'chainId': chainId, + 'keypath': _copy(keypath), + 'jsonMessage': _copy(jsonMessage), + }, + _copy(ethTypedMessageSignature), + ); + + @override + Future close() async { + final result = await _run( + SimulatedBitboxMethod.close, + const {}, + true, + needsOpen: false, + ); + if (result) _isOpen = false; + return result; + } + + Future _run( + String method, + Map arguments, + T fallback, { + bool needsOpen = true, + }) async { + final call = + SimulatedBitboxCall(method, Map.of(arguments)); + calls.add(call); + + final delay = _delays[method] ?? defaultDelay; + if (delay > Duration.zero) await Future.delayed(delay); + + final error = _errors[method]; + if (error != null) throw error; + + if (requireOpen && needsOpen && !_isOpen) { + throw const SimulatedBitboxStateException('BitBox is not open'); + } + + final behavior = _behaviors[method]; + if (behavior == null) return fallback; + + final result = await behavior(call); + return result as T; + } + + static Uint8List _copy(Uint8List bytes) => Uint8List.fromList(bytes); +} + +SimulatedBitboxPlatform installSimulatedBitboxPlatform({ + List? devices, + Duration defaultDelay = Duration.zero, + bool requireOpen = true, + bool channelHashVerified = false, + bool startScanResult = true, + bool permissionResult = true, + bool openResult = true, + bool initResult = true, + bool channelHashVerifyResult = true, + bool supportsETHResult = true, + bool supportsERC20Result = true, + bool supportsLTCResult = true, + String? channelHash, + Uint8List? masterFingerprint, + String? btcXPub, + String? btcPsbt, + Uint8List? btcMessageSignature, + String? ethAddress, + Uint8List? ethTransactionSignature, + Uint8List? ethEip1559Signature, + Uint8List? ethRlpSignature, + Uint8List? ethMessageSignature, + Uint8List? ethTypedMessageSignature, + Map? delays, + Map? errors, + Map? behaviors, +}) => + SimulatedBitboxPlatform.install( + devices: devices, + defaultDelay: defaultDelay, + requireOpen: requireOpen, + channelHashVerified: channelHashVerified, + startScanResult: startScanResult, + permissionResult: permissionResult, + openResult: openResult, + initResult: initResult, + channelHashVerifyResult: channelHashVerifyResult, + supportsETHResult: supportsETHResult, + supportsERC20Result: supportsERC20Result, + supportsLTCResult: supportsLTCResult, + channelHash: channelHash, + masterFingerprint: masterFingerprint, + btcXPub: btcXPub, + btcPsbt: btcPsbt, + btcMessageSignature: btcMessageSignature, + ethAddress: ethAddress, + ethTransactionSignature: ethTransactionSignature, + ethEip1559Signature: ethEip1559Signature, + ethRlpSignature: ethRlpSignature, + ethMessageSignature: ethMessageSignature, + ethTypedMessageSignature: ethTypedMessageSignature, + delays: delays, + errors: errors, + behaviors: behaviors, + ); diff --git a/test/bitbox_testkit_test.dart b/test/bitbox_testkit_test.dart new file mode 100644 index 0000000..7088823 --- /dev/null +++ b/test/bitbox_testkit_test.dart @@ -0,0 +1,230 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:bitbox_flutter/bitbox_manager.dart'; +import 'package:bitbox_flutter/testing.dart'; +import 'package:bitbox_flutter/usb/bitbox_usb_platform_interface.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late BitboxUsbPlatform previousPlatform; + + setUp(() { + previousPlatform = BitboxUsbPlatform.instance; + }); + + tearDown(() { + BitboxUsbPlatform.instance = previousPlatform; + }); + + test('simulates pairing and records the connection flow', () async { + final platform = installSimulatedBitboxPlatform( + channelHash: 'hash-visible-to-user', + ); + final manager = BitboxManager(); + + final devices = await manager.devices; + await manager.connect(devices.single); + + expect(await manager.initBitBox(), isTrue); + expect(await manager.getChannelHash(), 'hash-visible-to-user'); + expect(platform.channelHashVerified, isFalse); + expect(await manager.channelHashVerify(), isTrue); + expect(platform.channelHashVerified, isTrue); + + expect( + platform.calls.map((call) => call.method), + containsAllInOrder([ + SimulatedBitboxMethod.getDevices, + SimulatedBitboxMethod.requestPermission, + SimulatedBitboxMethod.open, + SimulatedBitboxMethod.initBitBox, + SimulatedBitboxMethod.getChannelHash, + SimulatedBitboxMethod.channelHashVerify, + ])); + }); + + test('covers generic BTC and ETH operations without hardware', () async { + final platform = installSimulatedBitboxPlatform( + masterFingerprint: Uint8List.fromList([1, 2, 3, 4]), + btcXPub: 'xpub-test', + btcPsbt: 'signed-psbt', + btcMessageSignature: Uint8List.fromList([5, 6]), + ethAddress: '0x2222222222222222222222222222222222222222', + ethTransactionSignature: Uint8List.fromList([7, 8]), + ethEip1559Signature: Uint8List.fromList([9, 10]), + ethRlpSignature: Uint8List.fromList([11, 12]), + ethMessageSignature: Uint8List.fromList([13, 14]), + ethTypedMessageSignature: Uint8List.fromList([15, 16]), + ); + final manager = BitboxManager(); + await manager.connect((await manager.devices).single); + + expect(await manager.supportsETH(1), isTrue); + expect(await manager.supportsERC20('0xToken'), isTrue); + expect(await manager.supportsLTC(), isTrue); + expect(await manager.getMasterFingerprint(), [1, 2, 3, 4]); + expect(await manager.getBTCXPub(0, "m/84'/0'/0'"), 'xpub-test'); + expect(await manager.signBTCPsbt(0, 'psbt'), 'signed-psbt'); + expect( + await manager.signBTCMessage( + 0, + "m/84'/0'/0'/0/0", + Uint8List.fromList([1]), + ), + [5, 6], + ); + expect( + await manager.getETHAddress(1, "m/44'/60'/0'/0/0"), + '0x2222222222222222222222222222222222222222', + ); + expect( + await manager.signETHTransaction( + 1, + "m/44'/60'/0'/0/0", + 1, + BigInt.from(2), + 21000, + Uint8List(20), + BigInt.from(3), + Uint8List(0), + 0, + ), + [7, 8], + ); + expect( + await manager.signETHTransactionEIP1559( + 1, + "m/44'/60'/0'/0/0", + 1, + BigInt.from(2), + BigInt.from(3), + 21000, + Uint8List(20), + BigInt.from(4), + Uint8List(0), + 0, + ), + [9, 10], + ); + expect( + await manager.signETHRLPTransaction(1, "m/44'/60'/0'/0/0", '0x01', true), + [11, 12], + ); + expect( + await manager.signETHMessage( + 1, + "m/44'/60'/0'/0/0", + Uint8List.fromList([1]), + ), + [13, 14], + ); + expect( + await manager.signETHTypedMessage( + 1, + "m/44'/60'/0'/0/0", + Uint8List.fromList([123, 125]), + ), + [15, 16], + ); + + expect(platform.count(SimulatedBitboxMethod.getETHAddress), 1); + final call = + platform.callsFor(SimulatedBitboxMethod.signETHTransaction).single; + expect(call.argument('gasPrice'), '2'); + expect(call.argument('value'), '3'); + }); + + test('simulates delays and hardware errors deterministically', () async { + final releaseSigning = Completer(); + final platform = installSimulatedBitboxPlatform( + behaviors: { + SimulatedBitboxMethod.signETHMessage: (_) async { + await releaseSigning.future; + return Uint8List.fromList([42]); + }, + }, + ); + final manager = BitboxManager(); + await manager.connect((await manager.devices).single); + + final pending = manager.signETHMessage( + 1, + "m/44'/60'/0'/0/0", + Uint8List.fromList([1]), + ); + await Future.delayed(Duration.zero); + expect(platform.count(SimulatedBitboxMethod.signETHMessage), 1); + + releaseSigning.complete(); + expect(await pending, [42]); + + platform.throwOn( + SimulatedBitboxMethod.signETHMessage, + StateError('signature aborted'), + ); + expect( + manager.signETHMessage( + 1, + "m/44'/60'/0'/0/0", + Uint8List.fromList([1]), + ), + throwsA(isA()), + ); + }); + + test('simulates rejected pairing, unsupported capabilities, and close', + () async { + final platform = installSimulatedBitboxPlatform( + channelHashVerifyResult: false, + supportsETHResult: false, + supportsERC20Result: false, + supportsLTCResult: false, + ); + final manager = BitboxManager(); + await manager.connect((await manager.devices).single); + + expect(await manager.channelHashVerify(), isFalse); + expect(platform.channelHashVerified, isFalse); + expect(await manager.supportsETH(1), isFalse); + expect(await manager.supportsERC20('0xToken'), isFalse); + expect(await manager.supportsLTC(), isFalse); + + await manager.disconnect(); + expect(platform.isOpen, isFalse); + expect( + manager.getMasterFingerprint(), + throwsA(isA()), + ); + }); + + test('simulates no device and empty channel hash states', () async { + final platform = installSimulatedBitboxPlatform( + devices: const [], + channelHash: '', + channelHashVerifyResult: false, + requireOpen: false, + ); + final manager = BitboxManager(); + + expect(await manager.devices, isEmpty); + expect(await manager.getChannelHash(), isEmpty); + expect(await manager.channelHashVerify(), isFalse); + expect(platform.channelHashVerified, isFalse); + }); + + test('guards against signing before the simulated device is opened', + () async { + installSimulatedBitboxPlatform(); + final manager = BitboxManager(); + + expect( + manager.signETHMessage( + 1, + "m/44'/60'/0'/0/0", + Uint8List.fromList([1]), + ), + throwsA(isA()), + ); + }); +}