git / moonbite-network / mobile/test/wallet_controller_test.dart · raw

import 'dart:convert';import 'package:flutter_test/flutter_test.dart';import 'package:http/testing.dart';import 'package:http/http.dart' as http;import 'package:flutter_secure_storage/flutter_secure_storage.dart';import 'package:moonbite_mobile/services/chain_service.dart';import 'package:moonbite_mobile/wallet/authenticator.dart';import 'package:moonbite_mobile/wallet/moonbite_network.dart';import 'package:moonbite_mobile/wallet/secure_key_store.dart';import 'package:moonbite_mobile/wallet/tx_builder.dart';import 'package:moonbite_mobile/wallet/wallet_controller.dart';/// Deterministic stand-in for the biometric gate. [approve] decides whether a/// spend is allowed; [calls] records every reason string it was asked with so/// tests can assert the gate ran (or didn't) exactly when expected.class _FakeAuthenticator implements Authenticator {  _FakeAuthenticator(this.approve);  final bool approve;  final List<String> calls = [];  @override  Future<bool> authenticate(String reason) async {    calls.add(reason);    return approve;  }}const _mnemonic =    'abandon abandon abandon abandon abandon abandon '    'abandon abandon abandon abandon abandon about';// index-0 testnet address for the vector above, and its scriptPubKey.const _addr0 = 'tmoon1q6rz28mcfaxtmd6v789l9rrlrusdprr9pv34j7r';const _spk0 = '0014d0c4a3ef09e997b6e99e397e518fe3e41a118ca1';const _dest = 'tmoon1qd7spv5q28348xl4myc8zmh983w5jx32c77zddz';void main() {  TestWidgetsFlutterBinding.ensureInitialized();  late String? lastBroadcastHex;  http.Client fakeHttp() => MockClient((req) async {        final path = req.url.path;        if (path.endsWith('/api/status')) {          return http.Response(              jsonEncode({                'chain': 'test',                'blocks': 50,                'headers': 50,                'bestblockhash': 'abc',                'difficulty': 1.0,                'verificationprogress': 1.0,                'mempool_txs': 0,                'connections': 3,                'subversion': '/MoonBite:2.5.0/',                'demo': false,              }),              200);        }        if (path.contains('/balance')) {          return http.Response(              jsonEncode({                'address': _addr0,                'confirmed': 50.0,                'unconfirmed': 0.0,                'total': 50.0,                'utxo_count': 1,                'demo': false,              }),              200);        }        if (path.contains('/utxos')) {          return http.Response(              jsonEncode({                'address': _addr0,                'utxos': [                  {                    'txid':                        '1111111111111111111111111111111111111111111111111111111111111111',                    'vout': 0,                    'amount': 50.0,                    'scriptPubKey': _spk0,                    'height': 10,                    'confirmations': 120,                  }                ],                'scan_height': 130,                'demo': false,              }),              200);        }        if (path.endsWith('/api/fee')) {          return http.Response(              jsonEncode({'feerate': 0.00001, 'blocks': 6, 'source': 'x'}),              200);        }        if (path.endsWith('/api/tx/broadcast')) {          lastBroadcastHex =              (jsonDecode(req.body) as Map<String, dynamic>)['rawtx'] as String;          // Model a node that accepts but echoes no txid; the controller must          // then return its own locally-computed txid.          return http.Response(jsonEncode({'txid': ''}), 200);        }        return http.Response('{"error":"unexpected ${req.url}"}', 404);      });  WalletController makeController({Authenticator? auth}) => WalletController(        chain: ChainService(baseUrl: 'https://x', httpClient: fakeHttp()),        store: SecureKeyStore(),        authenticator: auth ?? _FakeAuthenticator(true),      );  setUp(() {    lastBroadcastHex = null;    FlutterSecureStorage.setMockInitialValues({});  });  test('imports a wallet and derives the index-0 address', () async {    final c = makeController();    await c.importExisting(_mnemonic, network: MoonBiteNetwork.testnet);    expect(c.hasWallet, isTrue);    expect(c.receiveAddress, _addr0);    expect(await c.store.hasWallet(), isTrue);  });  test('createNew persists and produces a tmoon1 address', () async {    final c = makeController();    final phrase = await c.createNew(network: MoonBiteNetwork.testnet);    expect(phrase.split(' ').length, 12);    expect(c.receiveAddress.startsWith('tmoon1'), isTrue);    // A fresh controller should load the same wallet back from storage.    final c2 = makeController();    expect(await c2.loadExisting(), isTrue);    expect(c2.receiveAddress, c.receiveAddress);  });  test('refresh loads status and balance', () async {    final c = makeController();    await c.importExisting(_mnemonic, network: MoonBiteNetwork.testnet);    await c.refresh();    expect(c.status?.chain, 'test');    expect(c.balance?.total, 50.0);    expect(c.error, isNull);  });  test('send builds, signs, and returns the locally-computed txid', () async {    final c = makeController();    await c.importExisting(_mnemonic, network: MoonBiteNetwork.testnet);    final txid = await c.send(toAddress: _dest, amountBig: 1.0);    // We return OUR txid (64-hex), not whatever the relay claimed.    expect(txid, matches(RegExp(r'^[0-9a-f]{64}$')));    // The broadcast payload must be a real signed tx hex (segwit marker 0001).    expect(lastBroadcastHex, isNotNull);    expect(lastBroadcastHex!.startsWith('02000000'), isTrue);    expect(lastBroadcastHex!.contains('0001'), isTrue);  });  test('send rejects a relay that returns a mismatched txid', () async {    // A malicious relay claims a different txid than the one we signed.    final c = WalletController(      chain: ChainService(        baseUrl: 'https://x',        httpClient: MockClient((req) async {          final path = req.url.path;          if (path.contains('/utxos')) {            return http.Response(                jsonEncode({                  'utxos': [                    {                      'txid':                          '1111111111111111111111111111111111111111111111111111111111111111',                      'vout': 0,                      'amount': 50.0,                      'scriptPubKey': _spk0,                      'height': 10,                      'confirmations': 120,                    }                  ],                }),                200);          }          if (path.endsWith('/api/fee')) {            return http.Response(jsonEncode({'feerate': 0.00001}), 200);          }          if (path.endsWith('/api/tx/broadcast')) {            return http.Response(                jsonEncode({'txid': 'a' * 64}), 200); // bogus, != ours          }          return http.Response('{}', 200);        }),      ),      store: SecureKeyStore(),      authenticator: _FakeAuthenticator(true),    );    await c.importExisting(_mnemonic, network: MoonBiteNetwork.testnet);    await expectLater(      c.send(toAddress: _dest, amountBig: 1.0),      throwsA(isA<StateError>()),    );  });  test('send aborts before broadcast when authentication is denied', () async {    final denier = _FakeAuthenticator(false);    final c = makeController(auth: denier);    await c.importExisting(_mnemonic, network: MoonBiteNetwork.testnet);    await expectLater(      c.send(toAddress: _dest, amountBig: 1.0),      throwsA(isA<AuthenticationException>()),    );    // The gate was consulted, and nothing was signed or broadcast.    expect(denier.calls, hasLength(1));    expect(lastBroadcastHex, isNull);  });  test('send proceeds and broadcasts when authentication is approved',      () async {    final approver = _FakeAuthenticator(true);    final c = makeController(auth: approver);    await c.importExisting(_mnemonic, network: MoonBiteNetwork.testnet);    final txid = await c.send(toAddress: _dest, amountBig: 1.0);    expect(approver.calls, hasLength(1));    expect(txid, matches(RegExp(r'^[0-9a-f]{64}$')));    expect(lastBroadcastHex, isNotNull);  });  test('previewSpend reports the fee without broadcasting', () async {    final c = makeController();    await c.importExisting(_mnemonic, network: MoonBiteNetwork.testnet);    final preview = await c.previewSpend(toAddress: _dest, amountBig: 1.0);    expect(preview.feeSats, greaterThan(0));    expect(preview.amountSats, 100000000);    // A preview must never hit the broadcast endpoint.    expect(lastBroadcastHex, isNull);  });  test('send refuses an implausibly high node-quoted fee rate', () async {    // A hostile node quotes an absurd fee rate; the wallet must refuse rather    // than burn the UTXO as fee. Auth is approved so we prove the fee cap —    // not the auth gate — is what stops the send.    final c = WalletController(      chain: ChainService(        baseUrl: 'https://x',        httpClient: MockClient((req) async {          final path = req.url.path;          if (path.contains('/utxos')) {            return http.Response(                jsonEncode({                  'utxos': [                    {                      'txid':                          '1111111111111111111111111111111111111111111111111111111111111111',                      'vout': 0,                      'amount': 50.0,                      'scriptPubKey': _spk0,                      'height': 10,                      'confirmations': 120,                    }                  ],                }),                200);          }          if (path.endsWith('/api/fee')) {            // 1.0 BIG/kB => 100000 sat/vB, far above the safety cap.            return http.Response(jsonEncode({'feerate': 1.0}), 200);          }          return http.Response('{}', 200);        }),      ),      store: SecureKeyStore(),      authenticator: _FakeAuthenticator(true),    );    await c.importExisting(_mnemonic, network: MoonBiteNetwork.testnet);    await expectLater(      c.send(toAddress: _dest, amountBig: 1.0),      throwsA(isA<ExcessiveFeeRateException>()),    );  });  test('rejects an invalid mnemonic import', () async {    final c = makeController();    expect(      () => c.importExisting('not a valid phrase'),      throwsArgumentError,    );  });  group('app-open lock', () {    test('a wallet restored from storage starts locked', () async {      // Seed storage via one controller, then load it in a fresh one.      final seed = makeController();      await seed.importExisting(_mnemonic, network: MoonBiteNetwork.testnet);      final c = makeController();      expect(await c.loadExisting(), isTrue);      expect(c.hasWallet, isTrue);      expect(c.requiresUnlock, isTrue,          reason: 'a restored wallet must require a fresh unlock');    });    test('createNew and importExisting start unlocked (active onboarding)',        () async {      final created = makeController();      await created.createNew(network: MoonBiteNetwork.testnet);      expect(created.requiresUnlock, isFalse);      final imported = makeController();      await imported.importExisting(_mnemonic,          network: MoonBiteNetwork.testnet);      expect(imported.requiresUnlock, isFalse);    });    test('unlock clears the gate when authentication is approved', () async {      final seed = makeController();      await seed.importExisting(_mnemonic, network: MoonBiteNetwork.testnet);      final approver = _FakeAuthenticator(true);      final c = makeController(auth: approver);      await c.loadExisting();      expect(c.requiresUnlock, isTrue);      final ok = await c.unlock();      expect(ok, isTrue);      expect(c.requiresUnlock, isFalse);      expect(approver.calls, hasLength(1));      expect(approver.calls.single, contains('Unlock'));    });    test('unlock keeps the wallet locked when authentication is denied',        () async {      final seed = makeController();      await seed.importExisting(_mnemonic, network: MoonBiteNetwork.testnet);      final denier = _FakeAuthenticator(false);      final c = makeController(auth: denier);      await c.loadExisting();      final ok = await c.unlock();      expect(ok, isFalse);      expect(c.requiresUnlock, isTrue);      expect(denier.calls, hasLength(1));    });    test('lock re-engages the gate after a successful unlock', () async {      final c = makeController();      await c.importExisting(_mnemonic, network: MoonBiteNetwork.testnet);      expect(c.requiresUnlock, isFalse);      c.lock();      expect(c.requiresUnlock, isTrue,          reason: 'backgrounding must re-lock a stored wallet');    });  });}