diff --git a/bitwindow/lib/main.dart b/bitwindow/lib/main.dart index 3758db61c6..1fda6efa33 100644 --- a/bitwindow/lib/main.dart +++ b/bitwindow/lib/main.dart @@ -227,6 +227,7 @@ Future<(Directory, File, Logger)> init(String arguments) async { GetIt.I.registerSingleton(CoinShiftLive()); GetIt.I.registerSingleton(PhotonLive()); GetIt.I.registerSingleton(TruthcoinLive()); + GetIt.I.registerSingleton(FreeBankLive()); registerNodeMode(); @@ -257,6 +258,7 @@ Future<(Directory, File, Logger)> init(String arguments) async { GetIt.I.get(), GetIt.I.get(), GetIt.I.get(), + GetIt.I.get(), ], ), ); diff --git a/bitwindow/lib/pages/sidechains_page.dart b/bitwindow/lib/pages/sidechains_page.dart index dd3137a14d..ed2ebbe0e1 100644 --- a/bitwindow/lib/pages/sidechains_page.dart +++ b/bitwindow/lib/pages/sidechains_page.dart @@ -736,6 +736,7 @@ class SidechainsViewModel extends BaseViewModel with ChangeTrackingMixin { Truthcoin() => SidechainType.SIDECHAIN_TYPE_TRUTHCOIN, Photon() => SidechainType.SIDECHAIN_TYPE_PHOTON, CoinShift() => SidechainType.SIDECHAIN_TYPE_COINSHIFT, + FreeBank() => SidechainType.SIDECHAIN_TYPE_FREEBANK, _ => null, }; @@ -898,6 +899,7 @@ class SidechainsViewModel extends BaseViewModel with ChangeTrackingMixin { var b when b is Truthcoin => GetIt.I.isRegistered() ? GetIt.I.get() : null, var b when b is Photon => GetIt.I.isRegistered() ? GetIt.I.get() : null, var b when b is CoinShift => GetIt.I.isRegistered() ? GetIt.I.get() : null, + var b when b is FreeBank => GetIt.I.isRegistered() ? GetIt.I.get() : null, _ => null, }; @@ -949,6 +951,7 @@ class SidechainsViewModel extends BaseViewModel with ChangeTrackingMixin { var b when b is Truthcoin => (GetIt.I.isRegistered() ? GetIt.I.get() : null), var b when b is Photon => (GetIt.I.isRegistered() ? GetIt.I.get() : null), var b when b is CoinShift => (GetIt.I.isRegistered() ? GetIt.I.get() : null), + var b when b is FreeBank => (GetIt.I.isRegistered() ? GetIt.I.get() : null), _ => null, }; } @@ -1645,6 +1648,7 @@ class _DepositModalState extends State { ZSide() => GetIt.I.isRegistered() ? GetIt.I.get() : null, CoinShift() => GetIt.I.isRegistered() ? GetIt.I.get() : null, Thunder() => GetIt.I.isRegistered() ? GetIt.I.get() : null, + FreeBank() => GetIt.I.isRegistered() ? GetIt.I.get() : null, _ => null, }; diff --git a/bitwindow/test/deposit_modal_test.dart b/bitwindow/test/deposit_modal_test.dart index d33166bfb7..75235fd471 100644 --- a/bitwindow/test/deposit_modal_test.dart +++ b/bitwindow/test/deposit_modal_test.dart @@ -112,6 +112,26 @@ void main() { expect(field.controller.text, 's9_pastedaddress_abc123'); }); + testWidgets('a running FreeBank leaves the address field open for a paste and says why', (tester) async { + GetIt.I.registerSingleton(FreeBankLive()..connected = true); + await GetIt.I.unregister(); + GetIt.I.registerSingleton( + BinaryProvider.test(appDir: Directory.systemTemp, binaries: [FreeBank()]), + ); + + addTearDown(() => tester.pumpWidget(const SizedBox())); + await tester.pumpSailPage(const DepositModal(slot: 130, sidechainName: 'FreeBank')); + await tester.pump(); + + final field = tester.widget(find.byType(SailTextField).first); + expect(field.readOnly, isFalse); + expect(field.controller.text, isEmpty); + expect( + find.text('Unsupported operation: fetching a FreeBank address. Paste one from your FreeBank wallet.'), + findsOneWidget, + ); + }); + testWidgets('a pasted address with an amount arms the deposit button', (tester) async { await pumpModal(tester); diff --git a/bitwindow/test/sidechains_table_test.dart b/bitwindow/test/sidechains_table_test.dart index 63395d15bd..671cfa140a 100644 --- a/bitwindow/test/sidechains_table_test.dart +++ b/bitwindow/test/sidechains_table_test.dart @@ -104,6 +104,24 @@ class _CoinShiftRPC extends MockCoinShiftRPC { Future<(double, double)> balance() async => wallet; } +class _Orchestrator implements OrchestratorRPC { + final balanceReads = []; + GetSidechainBalanceResponse balance = GetSidechainBalanceResponse(); + + @override + Future getSidechainBalance(BinaryType sidechain) async { + balanceReads.add(sidechain); + return balance; + } + + @override + Future getBinaryVersion(String name, {bool forceBackend = false}) async => + GetBinaryVersionResponse(version: '$name 1.0'); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + Thunder _thunder({bool downloaded = true, bool updateAvailable = false}) { final base = Thunder(); return base.copyWith( @@ -116,6 +134,22 @@ Thunder _thunder({bool downloaded = true, bool updateAvailable = false}) { ); } +FreeBank _freeBank({bool downloaded = true}) { + final base = FreeBank(); + return base.copyWith( + metadata: base.metadata.copyWith( + remoteTimestamp: null, + downloadedTimestamp: downloaded ? DateTime(2026, 1) : null, + binaryPath: downloaded ? File('/tmp/freebankd') : null, + updateable: true, + ), + ); +} + +final Finder _settingsButton = find.byWidgetPredicate( + (widget) => widget is SailButton && widget.icon == SailSVGAsset.settings, +); + Finder _button(String label) => find.byWidgetPredicate((widget) => widget is SailButton && widget.label == label); final Finder _updateButton = find.byWidgetPredicate( @@ -268,6 +302,89 @@ void main() { expect(find.text('—'), findsOneWidget); }); + // FreeBank reads all its state through the orchestrator. + group('FreeBank', () { + late _Orchestrator orchestrator; + late FreeBankRPC freeBankRPC; + + Future setUpFreeBank({bool downloaded = true}) async { + GetIt.I.registerSingleton(_Binaries([_freeBank(downloaded: downloaded)])); + orchestrator = _Orchestrator(); + GetIt.I.registerSingleton(orchestrator); + freeBankRPC = FreeBankLive(); + GetIt.I.registerSingleton(freeBankRPC); + await GetIt.I.unregister(); + balances = BalanceProvider(connections: [thunderRPC, coinShiftRPC, freeBankRPC]); + GetIt.I.registerSingleton(balances); + GetIt.I.get().sidechains[130] = SidechainOverview( + ListSidechainsResponse_Sidechain(title: 'FreeBank', slot: 130, balanceSatoshi: Int64(300000000)), + [], + [], + ); + } + + testWidgets('the row has a settings button that opens the chain settings', (tester) async { + await setUpFreeBank(); + await pumpTable(tester); + + expect(_settingsButton, findsOneWidget); + await tester.tap(_settingsButton); + await tester.pump(kDoubleTapTimeout); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.byType(ChainSettingsModal), findsOneWidget); + expect(find.descendant(of: find.byType(ChainSettingsModal), matching: find.text('FreeBank')), findsOneWidget); + expect(find.textContaining('version freebank 1.0'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('a stopped FreeBank offers Start and reads no balance', (tester) async { + await setUpFreeBank(); + await balances.fetch(); + await pumpTable(tester); + + expect(_buttonWidget(tester, 'Start').variant, ButtonVariant.primary); + expect(_button('Stop'), findsNothing); + expect(_settingsButton, findsOneWidget); + expect(orchestrator.balanceReads, isEmpty); + expect(tester.takeException(), isNull); + }); + + testWidgets('a FreeBank that is not installed offers Download', (tester) async { + await setUpFreeBank(downloaded: false); + await pumpTable(tester); + + expect(_buttonWidget(tester, 'Download').variant, ButtonVariant.primary); + expect(_settingsButton, findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('a running FreeBank offers Stop and shows the balance the orchestrator reads', (tester) async { + await setUpFreeBank(); + orchestrator.balance = GetSidechainBalanceResponse(confirmedSats: Int64(250000000)); + freeBankRPC.connected = true; + await balances.fetch(); + await pumpTable(tester); + + expect(_buttonWidget(tester, 'Stop').variant, ButtonVariant.outline); + expect(_tooltip('Connected'), findsOneWidget); + expect(orchestrator.balanceReads, contains(BinaryType.BINARY_TYPE_FREEBANK)); + expect(find.text(GetIt.I.get().formatBTC(2.5)), findsOneWidget); + }); + + testWidgets('a running FreeBank behind its headers shows the sync bar', (tester) async { + await setUpFreeBank(); + freeBankRPC.connected = true; + sync.sidechains = { + SidechainType.SIDECHAIN_TYPE_FREEBANK: SyncInfo(progressCurrent: 100, progressGoal: 294, lastBlockAt: null), + }; + await pumpTable(tester); + + expect(find.text('34%'), findsOneWidget); + expect(_button('Stop'), findsOneWidget); + }); + }); + testWidgets('a chain that is not downloaded offers a primary Download', (tester) async { setUpChain(_thunder(downloaded: false)); await pumpTable(tester); diff --git a/sidechain_core/lib/providers/backend_state_provider.dart b/sidechain_core/lib/providers/backend_state_provider.dart index 8abb70d9f7..8f792a599c 100644 --- a/sidechain_core/lib/providers/backend_state_provider.dart +++ b/sidechain_core/lib/providers/backend_state_provider.dart @@ -14,6 +14,7 @@ import 'package:sidechain_core/rpcs/coinshift_rpc.dart'; import 'package:sidechain_core/rpcs/enforcer_rpc.dart'; import 'package:sidechain_core/rpcs/bitcoind_connection.dart'; import 'package:sidechain_core/env.dart'; +import 'package:sidechain_core/rpcs/freebank_rpc.dart'; import 'package:sidechain_core/rpcs/orchestrator_rpc.dart'; import 'package:sidechain_core/rpcs/photon_rpc.dart'; import 'package:sidechain_core/rpcs/thunder_rpc.dart'; @@ -219,6 +220,7 @@ class BackendStateProvider extends ChangeNotifier { 'truthcoin' => GetIt.I.isRegistered() ? GetIt.I.get() : null, 'photon' => GetIt.I.isRegistered() ? GetIt.I.get() : null, 'coinshift' => GetIt.I.isRegistered() ? GetIt.I.get() : null, + 'freebank' => GetIt.I.isRegistered() ? GetIt.I.get() : null, _ => null, }; } diff --git a/sidechain_core/lib/providers/binaries/binary_provider.dart b/sidechain_core/lib/providers/binaries/binary_provider.dart index cb6c1f0dc0..f658c46239 100644 --- a/sidechain_core/lib/providers/binaries/binary_provider.dart +++ b/sidechain_core/lib/providers/binaries/binary_provider.dart @@ -654,6 +654,7 @@ class BinaryProvider extends ChangeNotifier { var b when b is BitAssets => _getRegistered(), var b when b is ZSide => _getRegistered(), var b when b is CoinShift => _getRegistered(), + var b when b is FreeBank => _getRegistered(), _ => null, }; } diff --git a/sidechain_core/lib/rpcs/freebank_rpc.dart b/sidechain_core/lib/rpcs/freebank_rpc.dart new file mode 100644 index 0000000000..8c016229ee --- /dev/null +++ b/sidechain_core/lib/rpcs/freebank_rpc.dart @@ -0,0 +1,120 @@ +import 'package:collection/collection.dart'; +import 'package:get_it/get_it.dart'; +import 'package:sidechain_core/bitcoin.dart' as bitcoin; +import 'package:sidechain_core/classes/rpc_connection.dart'; +import 'package:sidechain_core/gen/orchestrator/v1/orchestrator.pb.dart'; +import 'package:sidechain_core/models/core_transaction.dart'; +import 'package:sidechain_core/rpcs/orchestrator_rpc.dart'; +import 'package:sidechain_core/rpcs/rpc_sidechain.dart'; +import 'package:sidechain_core/rpcs/thunder_utxo.dart'; + +/// API to the FreeBank node, read through the orchestrator's generic routes. +abstract class FreeBankRPC extends SidechainRPC { + FreeBankRPC({required super.binaryType}); +} + +class FreeBankLive extends FreeBankRPC { + FreeBankLive() : super(binaryType: BinaryType.BINARY_TYPE_FREEBANK); + + OrchestratorRPC get _orchestrator => GetIt.I.get(); + + /// The orchestrator builds the node's arguments. + @override + Future> binaryArgs() async => []; + + @override + Future stopRPC() async { + await _orchestrator.stopBinary('freebank'); + } + + @override + Future<(double, double)> balance() async { + final resp = await _orchestrator.getSidechainBalance(binaryType); + return ( + bitcoin.satoshiToBTC(resp.confirmedSats.toInt()), + bitcoin.satoshiToBTC(resp.pendingSats.toInt()), + ); + } + + @override + Future getBlockCount() async => (await _sync()).blocks; + + @override + Future getBlockchainInfo() async { + final sync = await _sync(); + return BlockchainInfo( + chain: '', + blocks: sync.blocks, + headers: sync.headers, + bestBlockHash: '', + difficulty: 0, + time: sync.time.toInt(), + medianTime: 0, + verificationProgress: sync.headers > 0 ? sync.blocks / sync.headers : 0, + initialBlockDownload: sync.blocks < sync.headers, + chainWork: '', + sizeOnDisk: 0, + pruned: false, + warnings: const [], + ); + } + + /// The node's sync state as the orchestrator last read it. + Future _sync() async { + final status = await _orchestrator.getSyncStatus(); + final entry = status.sidechains.firstWhereOrNull((s) => s.type == SidechainType.SIDECHAIN_TYPE_FREEBANK); + if (entry == null) { + throw StateError('the orchestrator reports no FreeBank sync status'); + } + if (entry.sync.error.isNotEmpty) { + throw StateError(entry.sync.error); + } + return entry.sync; + } + + // ── Not served through the orchestrator ──────────────────────────────────── + + @override + Future getDepositAddress() async => + throw UnsupportedError('fetching a FreeBank address. Paste one from your FreeBank wallet.'); + + @override + Future getSideAddress() async => throw _unsupported('addresses'); + + @override + Future sideSend(String address, double amount, bool subtractFeeFromAmount) async => + throw _unsupported('sends'); + + @override + Future sideEstimateFee() async => throw _unsupported('fees'); + + @override + Future> listUTXOs() async => throw _unsupported('coins'); + + @override + Future> listAllUTXOs() async => throw _unsupported('coins'); + + @override + Future> listTransactions() async => throw _unsupported('transactions'); + + @override + Future mine(int feeSats) async => throw _unsupported('mining'); + + @override + Future getPendingWithdrawalBundle() async => throw _unsupported('withdrawal bundles'); + + @override + Future getLatestFailedWithdrawalBundleHeight() async => throw _unsupported('withdrawal bundles'); + + @override + Future withdraw(String address, int amountSats, int sidechainFeeSats, int mainchainFeeSats) async => + throw _unsupported('withdrawals'); + + @override + Future callRAW(String method, [List? params]) async => throw _unsupported('raw calls'); + + @override + List getMethods() => const []; + + UnsupportedError _unsupported(String what) => UnsupportedError('FreeBank $what through the orchestrator'); +} diff --git a/sidechain_core/lib/sidechain_core.dart b/sidechain_core/lib/sidechain_core.dart index 38c14c6aa1..683f67f4f6 100644 --- a/sidechain_core/lib/sidechain_core.dart +++ b/sidechain_core/lib/sidechain_core.dart @@ -176,6 +176,7 @@ export 'rpcs/bitnames_rpc.dart'; export 'rpcs/bitwindow_api.dart'; export 'rpcs/coinshift_rpc.dart'; export 'rpcs/enforcer_rpc.dart'; +export 'rpcs/freebank_rpc.dart'; export 'rpcs/keepalive_http_client.dart'; export 'rpcs/orchestrator_multisig_lounge_rpc.dart'; export 'rpcs/orchestrator_rpc.dart'; diff --git a/sidechain_core/test/freebank_rpc_test.dart b/sidechain_core/test/freebank_rpc_test.dart new file mode 100644 index 0000000000..c6cbe559a4 --- /dev/null +++ b/sidechain_core/test/freebank_rpc_test.dart @@ -0,0 +1,152 @@ +import 'dart:io'; + +import 'package:fixnum/fixnum.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:logger/logger.dart'; +import 'package:sidechain_core/sidechain_core.dart'; + +class _FakeOrchestrator implements OrchestratorRPC { + final balanceReads = []; + final stops = []; + GetSidechainBalanceResponse balance = GetSidechainBalanceResponse(); + GetSyncStatusResponse syncStatus = GetSyncStatusResponse(); + + @override + Future getSidechainBalance(BinaryType sidechain) async { + balanceReads.add(sidechain); + return balance; + } + + @override + Future getSyncStatus() async => syncStatus; + + @override + Future stopBinary(String name, {bool force = false, bool forceBackend = false}) async { + stops.add(name); + return StopBinaryResponse(); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +SidechainStatus _status(SidechainType type, {int blocks = 0, int headers = 0, String error = ''}) => SidechainStatus( + type: type, + sync: ChainSync(blocks: blocks, headers: headers, error: error), +); + +void main() { + late _FakeOrchestrator orchestrator; + late FreeBankRPC rpc; + + setUp(() async { + await GetIt.I.reset(); + GetIt.I.registerSingleton(Logger(level: Level.off)); + orchestrator = _FakeOrchestrator(); + GetIt.I.registerSingleton(orchestrator); + rpc = FreeBankLive(); + GetIt.I.registerSingleton(rpc); + }); + + tearDown(() async { + await GetIt.I.reset(); + }); + + test('the balance is the orchestrator FreeBank balance', () async { + orchestrator.balance = GetSidechainBalanceResponse( + confirmedSats: Int64(250000000), + pendingSats: Int64(1000000), + ); + + expect(await rpc.balance(), (2.5, 0.01)); + expect(orchestrator.balanceReads, [BinaryType.BINARY_TYPE_FREEBANK]); + }); + + test('the height is the FreeBank entry of the orchestrator sync status', () async { + orchestrator.syncStatus = GetSyncStatusResponse( + sidechains: [ + _status(SidechainType.SIDECHAIN_TYPE_THUNDER, blocks: 7, headers: 7), + _status(SidechainType.SIDECHAIN_TYPE_FREEBANK, blocks: 80, headers: 90), + ], + ); + + expect(await rpc.getBlockCount(), 80); + final info = await rpc.getBlockchainInfo(); + expect(info.blocks, 80); + expect(info.headers, 90); + expect(info.initialBlockDownload, isTrue); + }); + + test('a node the orchestrator cannot read gives its reason, not a height', () async { + orchestrator.syncStatus = GetSyncStatusResponse( + sidechains: [_status(SidechainType.SIDECHAIN_TYPE_FREEBANK, error: 'not running')], + ); + await expectLater( + rpc.getBlockCount(), + throwsA(isA().having((e) => e.message, 'message', 'not running')), + ); + + orchestrator.syncStatus = GetSyncStatusResponse(); + await expectLater(rpc.getBlockchainInfo(), throwsStateError); + }); + + test('stop goes through the orchestrator', () async { + await rpc.stopRPC(); + expect(orchestrator.stops, ['freebank']); + }); + + test('a call the orchestrator does not serve is unsupported', () async { + await expectLater( + rpc.getDepositAddress(), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'fetching a FreeBank address. Paste one from your FreeBank wallet.', + ), + ), + ); + await expectLater(rpc.getSideAddress(), throwsUnsupportedError); + await expectLater(rpc.sideSend('addr', 1, false), throwsUnsupportedError); + await expectLater(rpc.listTransactions(), throwsUnsupportedError); + await expectLater(rpc.mine(1000), throwsUnsupportedError); + await expectLater(rpc.getPendingWithdrawalBundle(), throwsUnsupportedError); + await expectLater(rpc.withdraw('addr', 1000, 10, 10), throwsUnsupportedError); + await expectLater(rpc.callRAW('getbalance'), throwsUnsupportedError); + expect(rpc.getMethods(), isEmpty); + expect(await rpc.binaryArgs(), isEmpty); + }); + + test('the binary state reads the FreeBank connection', () { + final binaries = BinaryProvider.test(appDir: Directory.systemTemp, binaries: [FreeBank()]); + GetIt.I.registerSingleton(binaries); + + expect(binaries.isConnected(FreeBank()), isFalse); + rpc.initializingBinary = true; + expect(binaries.isInitializing(FreeBank()), isTrue); + rpc + ..initializingBinary = false + ..connected = true; + expect(binaries.isConnected(FreeBank()), isTrue); + expect(binaries.isSidechainUp(FreeBank()), isTrue); + rpc.stoppingBinary = true; + expect(binaries.isStopping(FreeBank()), isTrue); + rpc.connectionError = 'refused'; + expect(binaries.connectionError(FreeBank()), 'refused'); + }); + + test('a stopped FreeBank reads no balance, a running one reads it once per fetch', () async { + orchestrator.balance = GetSidechainBalanceResponse(confirmedSats: Int64(100000000)); + final balances = BalanceProvider(connections: [rpc]); + addTearDown(balances.dispose); + + await balances.fetch(); + expect(orchestrator.balanceReads, isEmpty); + + rpc.connected = true; + await balances.fetch(); + expect(orchestrator.balanceReads, [BinaryType.BINARY_TYPE_FREEBANK]); + expect(balances.balanceFor(rpc), (1.0, 0.0)); + }); +}