From b9fa087423d7a1ba4557727e7c18e517bf714148 Mon Sep 17 00:00:00 2001 From: mblowes Date: Thu, 24 Sep 2026 19:32:38 +1000 Subject: [PATCH 01/11] bitwindow: give FreeBank its settings button and running state FreeBank had no RPC connection in BitWindow, so its Sidechains row had no settings button, and never showed Stop, the status dot, the sync bar or Your balance while the node ran. FreeBankRPC reads all of it through the orchestrator's generic routes, so any FreeBank build works. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Gqbnw5hYV8j1ekU6P4GgYT --- bitwindow/lib/main.dart | 2 + bitwindow/lib/pages/sidechains_page.dart | 4 + bitwindow/test/deposit_modal_test.dart | 17 +++ bitwindow/test/sidechains_table_test.dart | 117 ++++++++++++++ .../lib/providers/backend_state_provider.dart | 2 + .../providers/binaries/binary_provider.dart | 1 + sidechain_core/lib/rpcs/freebank_rpc.dart | 118 +++++++++++++++ sidechain_core/lib/sidechain_core.dart | 1 + sidechain_core/test/freebank_rpc_test.dart | 143 ++++++++++++++++++ 9 files changed, 405 insertions(+) create mode 100644 sidechain_core/lib/rpcs/freebank_rpc.dart create mode 100644 sidechain_core/test/freebank_rpc_test.dart diff --git a/bitwindow/lib/main.dart b/bitwindow/lib/main.dart index 3758db61c6..08da18a34d 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(FreeBankRPC()); 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..ca7fd6f14b 100644 --- a/bitwindow/test/deposit_modal_test.dart +++ b/bitwindow/test/deposit_modal_test.dart @@ -112,6 +112,23 @@ 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(FreeBankRPC()..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.textContaining('Paste one from the 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..5dab51933b 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 = FreeBankRPC(); + 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..e1ed298abc --- /dev/null +++ b/sidechain_core/lib/rpcs/freebank_rpc.dart @@ -0,0 +1,118 @@ +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, served by the orchestrator. +/// +/// It uses only the orchestrator's generic routes, so it works for any FreeBank +/// build. A call those routes do not cover is unsupported. +class FreeBankRPC extends SidechainRPC { + FreeBankRPC() : 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('BitWindow has no FreeBank route for addresses. Paste one from the FreeBank wallet.'); + + @override + Future getSideAddress() async => throw _noRoute('addresses'); + + @override + Future sideSend(String address, double amount, bool subtractFeeFromAmount) async => throw _noRoute('sends'); + + @override + Future sideEstimateFee() async => throw _noRoute('fees'); + + @override + Future> listUTXOs() async => throw _noRoute('coins'); + + @override + Future> listAllUTXOs() async => throw _noRoute('coins'); + + @override + Future> listTransactions() async => throw _noRoute('transactions'); + + @override + Future mine(int feeSats) async => throw _noRoute('mining'); + + @override + Future getPendingWithdrawalBundle() async => throw _noRoute('withdrawal bundles'); + + @override + Future getLatestFailedWithdrawalBundleHeight() async => throw _noRoute('withdrawal bundles'); + + @override + Future withdraw(String address, int amountSats, int sidechainFeeSats, int mainchainFeeSats) async => + throw _noRoute('withdrawals'); + + @override + Future callRAW(String method, [List? params]) async => throw _noRoute('raw calls'); + + @override + List getMethods() => const []; + + UnsupportedError _noRoute(String what) => UnsupportedError('BitWindow has no FreeBank route for $what'); +} 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..481590ecdc --- /dev/null +++ b/sidechain_core/test/freebank_rpc_test.dart @@ -0,0 +1,143 @@ +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 = FreeBankRPC(); + 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 has no route for is unsupported', () async { + await expectLater(rpc.getDepositAddress(), throwsUnsupportedError); + 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)); + }); +} From 1245ff6ad1272183f7d762cbbd9e4541843cd77c Mon Sep 17 00:00:00 2001 From: mblowes Date: Thu, 24 Sep 2026 20:27:41 +1000 Subject: [PATCH 02/11] sidechain_core: give FreeBank the usual RPC shape and plain errors FreeBankRPC is now an abstract SidechainRPC with a FreeBankLive implementation, as every other chain has. BitWindow registers FreeBankLive. The deposit modal shows "Unsupported operation: fetching a FreeBank address. Paste one from your FreeBank wallet." The other unsupported calls no longer name the app. The class doc is one line and no longer says it works for any FreeBank build: the row reports balance and sync only where the orchestrator serves them for FreeBank. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Gqbnw5hYV8j1ekU6P4GgYT --- bitwindow/lib/main.dart | 2 +- bitwindow/test/deposit_modal_test.dart | 7 ++-- bitwindow/test/sidechains_table_test.dart | 2 +- sidechain_core/lib/rpcs/freebank_rpc.dart | 40 ++++++++++++---------- sidechain_core/test/freebank_rpc_test.dart | 15 ++++++-- 5 files changed, 40 insertions(+), 26 deletions(-) diff --git a/bitwindow/lib/main.dart b/bitwindow/lib/main.dart index 08da18a34d..1fda6efa33 100644 --- a/bitwindow/lib/main.dart +++ b/bitwindow/lib/main.dart @@ -227,7 +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(FreeBankRPC()); + GetIt.I.registerSingleton(FreeBankLive()); registerNodeMode(); diff --git a/bitwindow/test/deposit_modal_test.dart b/bitwindow/test/deposit_modal_test.dart index ca7fd6f14b..75235fd471 100644 --- a/bitwindow/test/deposit_modal_test.dart +++ b/bitwindow/test/deposit_modal_test.dart @@ -113,7 +113,7 @@ void main() { }); testWidgets('a running FreeBank leaves the address field open for a paste and says why', (tester) async { - GetIt.I.registerSingleton(FreeBankRPC()..connected = true); + GetIt.I.registerSingleton(FreeBankLive()..connected = true); await GetIt.I.unregister(); GetIt.I.registerSingleton( BinaryProvider.test(appDir: Directory.systemTemp, binaries: [FreeBank()]), @@ -126,7 +126,10 @@ void main() { final field = tester.widget(find.byType(SailTextField).first); expect(field.readOnly, isFalse); expect(field.controller.text, isEmpty); - expect(find.textContaining('Paste one from the FreeBank wallet'), findsOneWidget); + 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 { diff --git a/bitwindow/test/sidechains_table_test.dart b/bitwindow/test/sidechains_table_test.dart index 5dab51933b..671cfa140a 100644 --- a/bitwindow/test/sidechains_table_test.dart +++ b/bitwindow/test/sidechains_table_test.dart @@ -311,7 +311,7 @@ void main() { GetIt.I.registerSingleton(_Binaries([_freeBank(downloaded: downloaded)])); orchestrator = _Orchestrator(); GetIt.I.registerSingleton(orchestrator); - freeBankRPC = FreeBankRPC(); + freeBankRPC = FreeBankLive(); GetIt.I.registerSingleton(freeBankRPC); await GetIt.I.unregister(); balances = BalanceProvider(connections: [thunderRPC, coinShiftRPC, freeBankRPC]); diff --git a/sidechain_core/lib/rpcs/freebank_rpc.dart b/sidechain_core/lib/rpcs/freebank_rpc.dart index e1ed298abc..8c016229ee 100644 --- a/sidechain_core/lib/rpcs/freebank_rpc.dart +++ b/sidechain_core/lib/rpcs/freebank_rpc.dart @@ -8,12 +8,13 @@ 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, served by the orchestrator. -/// -/// It uses only the orchestrator's generic routes, so it works for any FreeBank -/// build. A call those routes do not cover is unsupported. -class FreeBankRPC extends SidechainRPC { - FreeBankRPC() : super(binaryType: BinaryType.BINARY_TYPE_FREEBANK); +/// 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(); @@ -75,44 +76,45 @@ class FreeBankRPC extends SidechainRPC { @override Future getDepositAddress() async => - throw UnsupportedError('BitWindow has no FreeBank route for addresses. Paste one from the FreeBank wallet.'); + throw UnsupportedError('fetching a FreeBank address. Paste one from your FreeBank wallet.'); @override - Future getSideAddress() async => throw _noRoute('addresses'); + Future getSideAddress() async => throw _unsupported('addresses'); @override - Future sideSend(String address, double amount, bool subtractFeeFromAmount) async => throw _noRoute('sends'); + Future sideSend(String address, double amount, bool subtractFeeFromAmount) async => + throw _unsupported('sends'); @override - Future sideEstimateFee() async => throw _noRoute('fees'); + Future sideEstimateFee() async => throw _unsupported('fees'); @override - Future> listUTXOs() async => throw _noRoute('coins'); + Future> listUTXOs() async => throw _unsupported('coins'); @override - Future> listAllUTXOs() async => throw _noRoute('coins'); + Future> listAllUTXOs() async => throw _unsupported('coins'); @override - Future> listTransactions() async => throw _noRoute('transactions'); + Future> listTransactions() async => throw _unsupported('transactions'); @override - Future mine(int feeSats) async => throw _noRoute('mining'); + Future mine(int feeSats) async => throw _unsupported('mining'); @override - Future getPendingWithdrawalBundle() async => throw _noRoute('withdrawal bundles'); + Future getPendingWithdrawalBundle() async => throw _unsupported('withdrawal bundles'); @override - Future getLatestFailedWithdrawalBundleHeight() async => throw _noRoute('withdrawal bundles'); + Future getLatestFailedWithdrawalBundleHeight() async => throw _unsupported('withdrawal bundles'); @override Future withdraw(String address, int amountSats, int sidechainFeeSats, int mainchainFeeSats) async => - throw _noRoute('withdrawals'); + throw _unsupported('withdrawals'); @override - Future callRAW(String method, [List? params]) async => throw _noRoute('raw calls'); + Future callRAW(String method, [List? params]) async => throw _unsupported('raw calls'); @override List getMethods() => const []; - UnsupportedError _noRoute(String what) => UnsupportedError('BitWindow has no FreeBank route for $what'); + UnsupportedError _unsupported(String what) => UnsupportedError('FreeBank $what through the orchestrator'); } diff --git a/sidechain_core/test/freebank_rpc_test.dart b/sidechain_core/test/freebank_rpc_test.dart index 481590ecdc..c6cbe559a4 100644 --- a/sidechain_core/test/freebank_rpc_test.dart +++ b/sidechain_core/test/freebank_rpc_test.dart @@ -45,7 +45,7 @@ void main() { GetIt.I.registerSingleton(Logger(level: Level.off)); orchestrator = _FakeOrchestrator(); GetIt.I.registerSingleton(orchestrator); - rpc = FreeBankRPC(); + rpc = FreeBankLive(); GetIt.I.registerSingleton(rpc); }); @@ -96,8 +96,17 @@ void main() { expect(orchestrator.stops, ['freebank']); }); - test('a call the orchestrator has no route for is unsupported', () async { - await expectLater(rpc.getDepositAddress(), throwsUnsupportedError); + 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); From c4801e3bf0685a5fa798296224b635f9c7a85a5d Mon Sep 17 00:00:00 2001 From: mblowes Date: Thu, 24 Sep 2026 19:30:54 +1000 Subject: [PATCH 03/11] orchestrator: let a Core fork name its wallet endpoint again Brings back corenode.Options, which #2335 removed: the endpoint wallet calls go to, getwalletinfo for a fork that predates getbalances, and the address type getnewaddress asks for. Bbc and Elements pass no options and keep the behaviour they have. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Gqbnw5hYV8j1ekU6P4GgYT --- .../sidechain/bbc/client.go | 2 +- .../sidechain/corenode/client.go | 54 ++++++++++++++++-- .../sidechain/corenode/client_test.go | 57 +++++++++++++++---- .../sidechain/elements/node.go | 2 +- 4 files changed, 95 insertions(+), 20 deletions(-) diff --git a/sidechain-orchestrator/sidechain/bbc/client.go b/sidechain-orchestrator/sidechain/bbc/client.go index 9eb471cbbf..b7503280ad 100644 --- a/sidechain-orchestrator/sidechain/bbc/client.go +++ b/sidechain-orchestrator/sidechain/bbc/client.go @@ -24,7 +24,7 @@ type Client struct { // NewClient creates a client pointed at host:port. cookiePath is the node's // .cookie. func NewClient(host string, port int, cookiePath string) *Client { - return &Client{Client: corenode.New("bbc", host, port, cookiePath)} + return &Client{Client: corenode.New("bbc", host, port, cookiePath, corenode.Options{})} } // SidechainInfo is the node's view of its link to the mainchain. diff --git a/sidechain-orchestrator/sidechain/corenode/client.go b/sidechain-orchestrator/sidechain/corenode/client.go index b782d43f48..16c331a3e0 100644 --- a/sidechain-orchestrator/sidechain/corenode/client.go +++ b/sidechain-orchestrator/sidechain/corenode/client.go @@ -1,6 +1,7 @@ // Package corenode is the RPC client every Bitcoin Core derived sidechain // shares. It speaks Core's JSON-RPC with cookie auth, unlike the CUSF -// sidechains. +// sidechains. A fork states its differences in Options and writes no client of +// its own. package corenode import ( @@ -20,10 +21,28 @@ import ( var _ sidechain.Node = (*Client)(nil) +// Options are the ways one Core fork differs from another. +type Options struct { + // WalletPath is the endpoint a wallet RPC goes to. Empty takes + // /wallet/, the wallet the orchestrator seeds. A fork that + // keeps a wallet of its own takes "/", where Core answers for the one + // wallet it loaded. + WalletPath string + + // LegacyBalance reads getwalletinfo in place of getbalances, for a fork + // that predates getbalances. + LegacyBalance bool + + // AddressType is the getnewaddress argument, one of "legacy", + // "p2sh-segwit" or "bech32". Empty takes the node default. + AddressType string +} + // Client talks to one Core derived sidechain node. type Client struct { name string baseURL string + opts Options cookiePath string http *http.Client // timeout is the deadline one method gets. http.Client.Timeout would cap @@ -34,10 +53,14 @@ type Client struct { // New creates a client pointed at host:port. name prefixes an RPC error, so a // log names the chain that failed. cookiePath is the node's .cookie, read on // every call because Core rewrites it on each restart. -func New(name, host string, port int, cookiePath string) *Client { +func New(name, host string, port int, cookiePath string, opts Options) *Client { + if opts.WalletPath == "" { + opts.WalletPath = "/wallet/" + sidechain.CoreWalletName + } return &Client{ name: name, baseURL: fmt.Sprintf("http://%s:%d", host, port), + opts: opts, cookiePath: cookiePath, http: &http.Client{}, timeout: rpc.MethodTimeout, @@ -66,9 +89,9 @@ func (e *rpcError) Error() string { return fmt.Sprintf("%s RPC error %d: %s", e.chain, e.Code, e.Message) } -// WalletCall sends one wallet RPC to the wallet the orchestrator seeds. +// WalletCall sends one wallet RPC to the endpoint this fork keeps its wallet at. func (c *Client) WalletCall(ctx context.Context, method string, params any) (json.RawMessage, error) { - return c.callAt(ctx, "/wallet/"+sidechain.CoreWalletName, method, params) + return c.callAt(ctx, c.opts.WalletPath, method, params) } // Call sends one node RPC to the root endpoint. @@ -163,6 +186,14 @@ func BTCToSats(btc float64) int64 { return int64(math.Round(btc * 1e8)) } // GetBalance returns the wallet balance in satoshis. Peg-ins arrive in the // coinbase, so immature counts as pending rather than as missing. func (c *Client) GetBalance(ctx context.Context) (totalSats, availableSats int64, err error) { + if c.opts.LegacyBalance { + info, err := DecodeWallet[WalletInfo](ctx, c, "getwalletinfo", nil) + if err != nil { + return 0, 0, err + } + available := BTCToSats(info.Balance) + return available + BTCToSats(info.UnconfirmedBalance+info.ImmatureBalance), available, nil + } balances, err := DecodeWallet[Balances](ctx, c, "getbalances", nil) if err != nil { return 0, 0, err @@ -171,9 +202,12 @@ func (c *Client) GetBalance(ctx context.Context) (totalSats, availableSats int64 return available + BTCToSats(balances.Mine.UntrustedPending+balances.Mine.Immature), available, nil } -// GetNewAddress returns a fresh address of the node default type. +// GetNewAddress returns a fresh address of the type this fork takes. func (c *Client) GetNewAddress(ctx context.Context) (string, error) { - return DecodeWallet[string](ctx, c, "getnewaddress", nil) + if c.opts.AddressType == "" { + return DecodeWallet[string](ctx, c, "getnewaddress", nil) + } + return DecodeWallet[string](ctx, c, "getnewaddress", []any{"", c.opts.AddressType}) } // ListUnspent returns the wallet's UTXOs. @@ -260,6 +294,14 @@ type Balances struct { } `json:"mine"` } +// WalletInfo is Core's getwalletinfo reply, amounts in BTC. A fork that +// predates getbalances carries the same split here. +type WalletInfo struct { + Balance float64 `json:"balance"` + UnconfirmedBalance float64 `json:"unconfirmed_balance"` + ImmatureBalance float64 `json:"immature_balance"` +} + // Unspent is one wallet UTXO from listunspent, amount in BTC. type Unspent struct { Txid string `json:"txid"` diff --git a/sidechain-orchestrator/sidechain/corenode/client_test.go b/sidechain-orchestrator/sidechain/corenode/client_test.go index 0aeffeafb7..b61cc267ae 100644 --- a/sidechain-orchestrator/sidechain/corenode/client_test.go +++ b/sidechain-orchestrator/sidechain/corenode/client_test.go @@ -55,9 +55,9 @@ func cookieFile(t *testing.T, contents string) string { return path } -func clientFor(t *testing.T, srv *httptest.Server, cookiePath string) *Client { +func clientFor(t *testing.T, srv *httptest.Server, cookiePath string, opts Options) *Client { t.Helper() - c := New("testchain", "127.0.0.1", 0, cookiePath) + c := New("testchain", "127.0.0.1", 0, cookiePath, opts) c.baseURL = srv.URL c.http = srv.Client() return c @@ -69,13 +69,29 @@ func TestBalanceCountsImmatureAsPending(t *testing.T) { }) defer srv.Close() - total, available, err := clientFor(t, srv, cookieFile(t, "__cookie__:secret")).GetBalance(context.Background()) + total, available, err := clientFor(t, srv, cookieFile(t, "__cookie__:secret"), Options{}).GetBalance(context.Background()) require.NoError(t, err) // A peg-in lands in the coinbase, so it is immature for a hundred blocks. assert.Equal(t, int64(150_000_000), available) assert.Equal(t, int64(5_175_000_000), total) } +// A fork that predates getbalances carries the same split in getwalletinfo. +func TestLegacyBalanceReadsGetWalletInfo(t *testing.T) { + srv, seen := fakeNode(t, map[string]json.RawMessage{ + "getwalletinfo": json.RawMessage(`{"balance":1.0,"unconfirmed_balance":0.25,"immature_balance":0.5}`), + }) + defer srv.Close() + + client := clientFor(t, srv, cookieFile(t, "__cookie__:secret"), Options{LegacyBalance: true}) + total, available, err := client.GetBalance(context.Background()) + require.NoError(t, err) + assert.Equal(t, int64(100_000_000), available) + assert.Equal(t, int64(175_000_000), total) + require.Len(t, *seen, 1) + assert.Equal(t, "getwalletinfo", (*seen)[0].method) +} + func TestWalletCallsAreScopedToTheWallet(t *testing.T) { srv, seen := fakeNode(t, map[string]json.RawMessage{ "getnewaddress": json.RawMessage(`"bcrt1qexample"`), @@ -83,7 +99,7 @@ func TestWalletCallsAreScopedToTheWallet(t *testing.T) { }) defer srv.Close() - client := clientFor(t, srv, cookieFile(t, "__cookie__:secret")) + client := clientFor(t, srv, cookieFile(t, "__cookie__:secret"), Options{}) ctx := context.Background() _, err := client.GetNewAddress(ctx) require.NoError(t, err) @@ -95,11 +111,28 @@ func TestWalletCallsAreScopedToTheWallet(t *testing.T) { assert.Equal(t, "/", (*seen)[1].path, "node-level RPCs must not be wallet scoped") } +// A fork that keeps one wallet of its own answers at the root endpoint, and +// takes the address type it documents. +func TestWalletPathAndAddressTypeFollowTheOptions(t *testing.T) { + srv, seen := fakeNode(t, map[string]json.RawMessage{ + "getnewaddress": json.RawMessage(`"XaUJwsK9hTY7m4GdKqvfTELDrtZA9Kokhm"`), + }) + defer srv.Close() + + client := clientFor(t, srv, cookieFile(t, "__cookie__:secret"), Options{WalletPath: "/", AddressType: "legacy"}) + _, err := client.GetNewAddress(context.Background()) + require.NoError(t, err) + + require.Len(t, *seen, 1) + assert.Equal(t, "/", (*seen)[0].path) + assert.JSONEq(t, `["","legacy"]`, string((*seen)[0].params)) +} + func TestCallsAuthenticateWithTheCookie(t *testing.T) { srv, seen := fakeNode(t, map[string]json.RawMessage{"getblockcount": json.RawMessage(`1`)}) defer srv.Close() - _, err := clientFor(t, srv, cookieFile(t, " __cookie__:s3cret\n")).GetBlockCount(context.Background()) + _, err := clientFor(t, srv, cookieFile(t, " __cookie__:s3cret\n"), Options{}).GetBlockCount(context.Background()) require.NoError(t, err) require.Len(t, *seen, 1) assert.Equal(t, "__cookie__", (*seen)[0].user) @@ -112,7 +145,7 @@ func TestMissingCookieFailsBeforeCalling(t *testing.T) { srv, seen := fakeNode(t, map[string]json.RawMessage{}) defer srv.Close() - _, err := clientFor(t, srv, filepath.Join(t.TempDir(), "absent")).GetBlockCount(context.Background()) + _, err := clientFor(t, srv, filepath.Join(t.TempDir(), "absent"), Options{}).GetBlockCount(context.Background()) require.Error(t, err) assert.Contains(t, err.Error(), "read rpc cookie") assert.Empty(t, *seen, "must not reach the node without credentials") @@ -124,7 +157,7 @@ func TestUnauthenticatedResponseReportsHTTPStatus(t *testing.T) { })) defer srv.Close() - _, err := clientFor(t, srv, cookieFile(t, "user:pass")).GetBlockCount(context.Background()) + _, err := clientFor(t, srv, cookieFile(t, "user:pass"), Options{}).GetBlockCount(context.Background()) require.Error(t, err) assert.Contains(t, err.Error(), "401") } @@ -138,7 +171,7 @@ func TestRPCErrorNamesTheChain(t *testing.T) { })) defer srv.Close() - _, err := clientFor(t, srv, cookieFile(t, "user:pass")).GetBlockCount(context.Background()) + _, err := clientFor(t, srv, cookieFile(t, "user:pass"), Options{}).GetBlockCount(context.Background()) require.Error(t, err) assert.Contains(t, err.Error(), "testchain RPC error -32601") } @@ -151,7 +184,7 @@ func TestFeeEstimateFallsBackWithoutHistory(t *testing.T) { }) defer srv.Close() - rate, err := clientFor(t, srv, cookieFile(t, "user:pass")).EstimateSmartFee(context.Background()) + rate, err := clientFor(t, srv, cookieFile(t, "user:pass"), Options{}).EstimateSmartFee(context.Background()) require.NoError(t, err) assert.Equal(t, FallbackFeeRate, rate) } @@ -180,7 +213,7 @@ func slowNode(t *testing.T, delay time.Duration) *httptest.Server { // connect_block stores the mainchain ancestors of the block before it answers, // so it takes a deadline the ordinary calls never get. func TestConnectBlockOutlastsAnOrdinaryCall(t *testing.T) { - client := clientFor(t, slowNode(t, 200*time.Millisecond), cookieFile(t, "user:pass")) + client := clientFor(t, slowNode(t, 200*time.Millisecond), cookieFile(t, "user:pass"), Options{}) client.timeout = func(method string) time.Duration { if method == "connect_block" { return 5 * time.Second @@ -200,7 +233,7 @@ func TestConnectBlockOutlastsAnOrdinaryCall(t *testing.T) { // The longer deadline still ends the call. A node that hangs must not hold the // BMM engine for as long as it runs. func TestConnectBlockStopsAtItsOwnDeadline(t *testing.T) { - client := clientFor(t, slowNode(t, time.Hour), cookieFile(t, "user:pass")) + client := clientFor(t, slowNode(t, time.Hour), cookieFile(t, "user:pass"), Options{}) client.timeout = func(string) time.Duration { return 100 * time.Millisecond } _, err := Decode[bool](context.Background(), client, "connect_block", []any{"{}", "hash"}) @@ -209,7 +242,7 @@ func TestConnectBlockStopsAtItsOwnDeadline(t *testing.T) { } func TestMethodTimeoutBindsTheCoreTransport(t *testing.T) { - client := New("testchain", "127.0.0.1", 0, "") + client := New("testchain", "127.0.0.1", 0, "", Options{}) assert.Equal(t, rpc.ConnectBlockTimeout, client.timeout("connect_block")) assert.Equal(t, rpc.CallTimeout, client.timeout("getblockcount")) } diff --git a/sidechain-orchestrator/sidechain/elements/node.go b/sidechain-orchestrator/sidechain/elements/node.go index 85a68e4625..aa99cda480 100644 --- a/sidechain-orchestrator/sidechain/elements/node.go +++ b/sidechain-orchestrator/sidechain/elements/node.go @@ -39,7 +39,7 @@ var ( // NewNode returns a client for the node that runs in datadir. func NewNode(host string, port int, datadir string, network config.Network) *Node { return &Node{ - Client: corenode.New("Elements Alpha", host, port, filepath.Join(datadir, ".cookie")), + Client: corenode.New("Elements Alpha", host, port, filepath.Join(datadir, ".cookie"), corenode.Options{}), port: port, datadir: datadir, network: network, From f35a19bafe3dc7e1b800218cb026baf9875db706 Mon Sep 17 00:00:00 2001 From: mblowes Date: Thu, 24 Sep 2026 19:32:12 +1000 Subject: [PATCH 04/11] orchestrator: fetch the tools a chain boots with A chain lists a helper such as grpcurl among its dependencies, and a start puts it on disk before the chain names it in its boot arguments. The desktop app fetches grpcurl as it opens, but headless drivechaind never did, so a chain that reaches the enforcer through grpcurl fell back to whatever PATH held. A failed download only warns. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Gqbnw5hYV8j1ekU6P4GgYT --- sidechain-orchestrator/fetch_tools_test.go | 47 ++++++++++++++++++++++ sidechain-orchestrator/orchestrator.go | 22 ++++++++++ 2 files changed, 69 insertions(+) create mode 100644 sidechain-orchestrator/fetch_tools_test.go diff --git a/sidechain-orchestrator/fetch_tools_test.go b/sidechain-orchestrator/fetch_tools_test.go new file mode 100644 index 0000000000..70081ce66f --- /dev/null +++ b/sidechain-orchestrator/fetch_tools_test.go @@ -0,0 +1,47 @@ +package orchestrator + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" +) + +// A chain lists the tools its boot arguments name among its dependencies. A +// start fetches them first, so a headless daemon boots the chain with the path. +func TestAStartFetchesTheToolsAChainBootsWith(t *testing.T) { + o := newTestOrchestrator(t) + archive := makeZipBytes(t, map[string][]byte{"grpcurl": []byte("binary")}) + var fetched atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fetched.Add(1) + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archive))) + _, _ = w.Write(archive) + })) + defer srv.Close() + o.download.httpClient = srv.Client() + o.mu.Lock() + o.configs["grpcurl"] = BinaryConfig{ + Name: "grpcurl", + DisplayName: "grpcurl", + BinaryName: "grpcurl", + DownloadSource: DownloadSourceDirect, + DownloadURLs: map[string]string{"default": srv.URL + "/"}, + Files: map[string]string{currentPlatform(): "grpcurl.zip"}, + } + o.mu.Unlock() + + chain := BinaryConfig{Name: "freebank", Dependencies: []string{"bitcoind", "enforcer", "grpcurl"}} + progress := make(chan StartupProgress, 100) + o.fetchTools(context.Background(), chain, progress) + require.NotEmpty(t, bootHost{orch: o}.ToolPath("grpcurl")) + requests := fetched.Load() + require.Positive(t, requests) + + o.fetchTools(context.Background(), chain, progress) + require.Equal(t, requests, fetched.Load(), "a tool already on disk is not fetched again") +} diff --git a/sidechain-orchestrator/orchestrator.go b/sidechain-orchestrator/orchestrator.go index f24a944ee6..69c84e39e1 100644 --- a/sidechain-orchestrator/orchestrator.go +++ b/sidechain-orchestrator/orchestrator.go @@ -1999,6 +1999,7 @@ func (o *Orchestrator) startTargetOnly(ctx context.Context, config BinaryConfig, return } + o.fetchTools(ctx, config, ch) if err := o.appendSidechainArgs(ctx, config, &opts); err != nil { failBoot(targetMon, ch, "configure "+config.Name, err) return @@ -2142,6 +2143,27 @@ func (o *Orchestrator) awaitBinaryOnDisk( return forwardDownload(downloadCh, ch, "downloading-"+config.Name) } +// fetchTools puts the tools a chain lists among its dependencies, such as +// grpcurl, on disk before the chain names them in its boot arguments. The +// desktop app fetches grpcurl as it opens; a headless daemon has only this. A +// failed download only warns: ToolPath then names nothing, and the chain +// decides what that means. +func (o *Orchestrator) fetchTools(ctx context.Context, cfg BinaryConfig, ch chan<- StartupProgress) { + for _, name := range cfg.Dependencies { + tool, err := o.getConfig(name) + if err != nil || tool.ChainLayer != 0 { + continue + } + if (bootHost{orch: o}).ToolPath(tool.BinaryName) != "" { + continue + } + if err := o.awaitBinaryOnDisk(ctx, tool, StartOpts{}, ch, nil); err != nil { + o.log.Warn().Err(err).Str("binary", cfg.Name).Str("tool", name). + Msg("could not download a tool the chain boots with") + } + } +} + // waitForConnectedOrExit blocks until the monitor reports connected, the // process exits, or ctx is canceled. Errors are returned raw so the caller // (failBoot) is the single owner of the "wait for X:" prefix — the From 3bbc23622f7f67a89187c5980118c42aa6d5ae9a Mon Sep 17 00:00:00 2001 From: mblowes Date: Thu, 24 Sep 2026 19:32:25 +1000 Subject: [PATCH 05/11] orchestrator: run FreeBank as a Core fork that keeps its own wallet FreeBank is back on its C++ chassis: freebankd is a Core 0.16 fork with a legacy wallet, released as the same freebank- tarballs the download block already matches. - Restore sidechain/freebank from before #2302 and route "freebank" to it. Its Args pin the node to the mainchain: the enforcer transport, the REST port of BitWindow's Core, main as the chain, the fork block at the published fork height, and the grpcurl on disk. - Add own_wallet. A Core fork with it takes no starter and no wallet provisioning: freebankd creates its wallet.dat at startup and has no createwallet or importdescriptors. The return comes before GetOrDeriveSidechainStarter, so the boot never waits on the wallet service. Balance and addresses come from the node's own wallet at the root endpoint. - chains_config: port 8454, is_bitcoin_core, own_wallet, the Core directories, "Done loading", and grpcurl as a dependency. Migration 024 carries the change to existing installs. - Drop the Rust-only pieces: the CUSF conf spec, which would write a CUSF freebank.conf into ~/.freebank where freebankd reads its own; the bitcoin_balance reader; FreeBank in the explorer rebroadcast; and the freebankd arms of the CUSF directory layout. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Gqbnw5hYV8j1ekU6P4GgYT --- .../api/explorer_broadcast.go | 4 +- .../api/explorer_broadcast_test.go | 2 +- .../api/freebank_dispatch_test.go | 41 +++---- .../api/sidechain_node_test.go | 19 ++-- sidechain-orchestrator/chains_config.json | 17 +-- .../cmd/drivechaind/main.go | 5 - sidechain-orchestrator/config.go | 1 + sidechain-orchestrator/config/binaries.go | 8 +- .../config/binaries_test.go | 40 +++---- .../config/chains_config.json | 17 +-- .../config/sidechain_conf.go | 10 -- .../config/sidechain_conf_ports_test.go | 3 - .../config/sidechain_enforcer_args_test.go | 2 +- sidechain-orchestrator/config_loader.go | 2 + sidechain-orchestrator/orchestrator.go | 6 + .../orchestrator_sidechain_args_test.go | 89 ++++++++++++--- sidechain-orchestrator/own_wallet_test.go | 43 +++++++ .../sidechain/freebank/freebank.go | 64 +++++++++++ .../sidechain/freebank/freebank_test.go | 105 ++++++++++++++++++ .../sidechain/nodes/nodes.go | 3 + .../sidechain/nodes/nodes_test.go | 1 - sidechain_core/assets/chains_config.json | 17 +-- .../assets/migrations/024_chains_config.json | 28 +++++ 23 files changed, 409 insertions(+), 118 deletions(-) create mode 100644 sidechain-orchestrator/own_wallet_test.go create mode 100644 sidechain-orchestrator/sidechain/freebank/freebank.go create mode 100644 sidechain-orchestrator/sidechain/freebank/freebank_test.go create mode 100644 sidechain_core/assets/migrations/024_chains_config.json diff --git a/sidechain-orchestrator/api/explorer_broadcast.go b/sidechain-orchestrator/api/explorer_broadcast.go index c9372d51ae..20030e0367 100644 --- a/sidechain-orchestrator/api/explorer_broadcast.go +++ b/sidechain-orchestrator/api/explorer_broadcast.go @@ -20,9 +20,9 @@ type broadcastResult struct { func (h *ExplorerHandler) broadcastSource(chain string) (source, error) { chain = strings.TrimSpace(chain) - if chain != "bitnames" && chain != "bitassets" && chain != "freebank" { + if chain != "bitnames" && chain != "bitassets" { return source{}, connect.NewError(connect.CodeInvalidArgument, - fmt.Errorf("transaction rebroadcast supports BitNames, BitAssets and FreeBank")) + fmt.Errorf("transaction rebroadcast supports BitNames and BitAssets")) } src, err := h.sourceOf(chain) if err != nil { diff --git a/sidechain-orchestrator/api/explorer_broadcast_test.go b/sidechain-orchestrator/api/explorer_broadcast_test.go index 7ef17c098a..c99d308868 100644 --- a/sidechain-orchestrator/api/explorer_broadcast_test.go +++ b/sidechain-orchestrator/api/explorer_broadcast_test.go @@ -26,7 +26,7 @@ const broadcastTestJSON = `{ }` func TestExplorerBroadcastKeepsSignedJSON(t *testing.T) { - for _, chain := range []string{"bitnames", "bitassets", "freebank"} { + for _, chain := range []string{"bitnames", "bitassets"} { t.Run(chain, func(t *testing.T) { node := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req struct { diff --git a/sidechain-orchestrator/api/freebank_dispatch_test.go b/sidechain-orchestrator/api/freebank_dispatch_test.go index edbfc49595..2b716a7448 100644 --- a/sidechain-orchestrator/api/freebank_dispatch_test.go +++ b/sidechain-orchestrator/api/freebank_dispatch_test.go @@ -5,6 +5,8 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -13,8 +15,6 @@ import ( orchestrator "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator" "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/config" pb "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/gen/orchestrator/v1" - "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/sidechain" - bitassetssvc "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/sidechain/bitassets" ) // GetSyncStatus shows FreeBank with its own type. @@ -24,34 +24,37 @@ func TestSidechainTypeFromNameKnowsFreebank(t *testing.T) { assert.Equal(t, pb.SidechainType_SIDECHAIN_TYPE_UNSPECIFIED, sidechainTypeFromName("nosuchchain")) } -// FreeBank has no "balance" RPC, so its balance comes from bitcoin_balance. -func TestFreebankBalanceUsesBitcoinBalanceMethod(t *testing.T) { - var gotMethod string +// FreeBank keeps the one wallet its node created and predates getbalances, so +// its balance is getwalletinfo, asked at the root endpoint with the cookie. +func TestFreebankBalanceReadsItsOwnWallet(t *testing.T) { + config.SetHomeDir(t.TempDir()) + t.Cleanup(func() { config.SetHomeDir("") }) + datadir := config.FreebankDirs.DatadirNetwork(config.NetworkRegtest, "") + require.NoError(t, os.MkdirAll(datadir, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(datadir, ".cookie"), []byte("__cookie__:secret"), 0o600)) + + var gotPath, gotMethod, gotUser string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req struct { - ID uint64 `json:"id"` Method string `json:"method"` } _ = json.NewDecoder(r.Body).Decode(&req) - gotMethod = req.Method - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"total_sats":300,"available_sats":125}}`)) + gotPath, gotMethod = r.URL.Path, req.Method + gotUser, _, _ = r.BasicAuth() + _, _ = w.Write([]byte(`{"result":{"balance":1.0,"unconfirmed_balance":0.25,"immature_balance":0.5},"error":null}`)) })) defer srv.Close() host, port := hostPort(t, srv) - bh := bitassetssvc.NewHandler(sidechain.NewJSONRPCProxy(host, port)) - h := &Handler{ - orch: &orchestrator.Orchestrator{Network: string(config.NetworkRegtest)}, - sidechainBalances: map[string]SidechainBalanceFunc{"freebank": bh.WalletBalance}, - } - + h := &Handler{orch: &orchestrator.Orchestrator{Network: string(config.NetworkRegtest)}} confirmed, pending, err := h.fetchSidechainBalance( context.Background(), - orchestrator.BinaryConfig{Name: "freebank", Host: host, Port: port}, + orchestrator.BinaryConfig{Name: "freebank", Host: host, Port: port, IsBitcoinCore: true}, ) require.NoError(t, err) - assert.Equal(t, "bitcoin_balance", gotMethod) - assert.Equal(t, int64(125), confirmed) - assert.Equal(t, int64(175), pending) + assert.Equal(t, "/", gotPath) + assert.Equal(t, "getwalletinfo", gotMethod) + assert.Equal(t, "__cookie__", gotUser) + assert.Equal(t, int64(100_000_000), confirmed) + assert.Equal(t, int64(75_000_000), pending) } diff --git a/sidechain-orchestrator/api/sidechain_node_test.go b/sidechain-orchestrator/api/sidechain_node_test.go index 593373efc9..cfffe6b6ef 100644 --- a/sidechain-orchestrator/api/sidechain_node_test.go +++ b/sidechain-orchestrator/api/sidechain_node_test.go @@ -57,18 +57,21 @@ func TestCoreForkAnswersOnlyWhatItDrives(t *testing.T) { assert.Contains(t, err.Error(), "proposes no withdrawal bundle") } -// FreeBank is a BitAssets fork, so it does BMM and proposes withdrawal bundles. -func TestFreebankAnswersEveryInterfaceLikeBitassets(t *testing.T) { - cfg := orchestrator.BinaryConfig{Name: "freebank", DisplayName: "FreeBank", Port: 6130} +// FreeBank blind merge mines with its own refreshbmm ticker and settles +// withdrawals on its own paths, so it is a Node and nothing more. +func TestFreebankAnswersNeitherBMMNorWithdrawals(t *testing.T) { + cfg := orchestrator.BinaryConfig{Name: "freebank", DisplayName: "FreeBank", Port: 8454, IsBitcoinCore: true} node, err := sidechainNode(cfg, config.NetworkRegtest) require.NoError(t, err) - bmm, err := bmmNode(cfg, config.NetworkRegtest) - require.NoError(t, err) - assert.NotNil(t, bmm) + _, err = bmmNode(cfg, config.NetworkRegtest) + require.Error(t, err) + assert.Contains(t, err.Error(), "FreeBank") + assert.Contains(t, err.Error(), "produces its own blocks") - _, proposesBundles := node.(sidechain.WithdrawalNode) - assert.True(t, proposesBundles) + _, err = withdrawalNode(node, cfg.DisplayName) + require.Error(t, err) + assert.Contains(t, err.Error(), "proposes no withdrawal bundle") } // A chain with no directory config names itself in the failure, rather than diff --git a/sidechain-orchestrator/chains_config.json b/sidechain-orchestrator/chains_config.json index c33ef36d1e..1d827bc7e7 100644 --- a/sidechain-orchestrator/chains_config.json +++ b/sidechain-orchestrator/chains_config.json @@ -1,5 +1,5 @@ { - "version": 23, + "version": 24, "binaries": { "bitcoincore": { "type": "core", @@ -837,21 +837,22 @@ "version": "latest", "description": "Credit-creation drivechain (Scottish free-banking lineage).", "repo_url": "https://github.com/mbdrivechains/freebank", - "port": 6130, + "port": 8454, "chain_layer": 2, - "is_bitcoin_core": false, + "is_bitcoin_core": true, + "own_wallet": true, "color": "orange", "health_check": { "type": "tcp" }, - "dependencies": ["bitcoind", "enforcer"], - "startup_log_patterns": [], + "dependencies": ["bitcoind", "enforcer", "grpcurl"], + "startup_log_patterns": ["Done loading"], "directories": { "binary": { "default": { - "linux": "freebank", - "macos": "freebank", - "windows": "freebank" + "linux": ".freebank", + "macos": "FreeBank", + "windows": "FreeBank" } }, "flutter_frontend": {} diff --git a/sidechain-orchestrator/cmd/drivechaind/main.go b/sidechain-orchestrator/cmd/drivechaind/main.go index bb304be866..5c43624778 100644 --- a/sidechain-orchestrator/cmd/drivechaind/main.go +++ b/sidechain-orchestrator/cmd/drivechaind/main.go @@ -633,11 +633,6 @@ func run(cctx *cli.Context) error { path, bitassetsHandler := bitassetsrpc.NewBitAssetsServiceHandler(h, connect.WithInterceptors(authIC)) mux.Handle(path, bitassetsHandler) log.Info().Str("sidechain", name).Int("port", cfg.Port).Msg("registered sidechain RPC service") - case "freebank": - // FreeBank is a BitAssets fork and uses the same wallet RPC. - h := bitassetssvc.NewHandler(proxy) - handler.SetSidechainBalance(name, h.WalletBalance) - log.Info().Str("sidechain", name).Int("port", cfg.Port).Msg("registered sidechain balance reader") case "photon": h := photonsvc.NewHandler(proxy) handler.SetSidechainBalance(name, h.WalletBalance) diff --git a/sidechain-orchestrator/config.go b/sidechain-orchestrator/config.go index d9a4feffd8..cfb321dd48 100644 --- a/sidechain-orchestrator/config.go +++ b/sidechain-orchestrator/config.go @@ -47,6 +47,7 @@ type BinaryConfig struct { DataDir map[string]string // os -> subdir under AppDir() (default for all networks) DataDirMainnet map[string]string // os -> subdir (mainnet override, empty = use DataDir) IsBitcoinCore bool // built on Bitcoin Core: Core datadir layout, Core JSON-RPC, cookie auth + OwnWallet bool // Core fork that keeps the wallet its node creates: no starter, no provisioning // Flutter frontend directory — Dart: flutterFrontendDir() extension (L1306-1353) // Per-OS subdir under the platform app support dir. Empty = no frontend. diff --git a/sidechain-orchestrator/config/binaries.go b/sidechain-orchestrator/config/binaries.go index 6fbbe30e36..548d8b61f3 100644 --- a/sidechain-orchestrator/config/binaries.go +++ b/sidechain-orchestrator/config/binaries.go @@ -378,7 +378,7 @@ func (b BinaryDirConfig) GetBlockchainDataPaths(networkDir string, network Netwo case "bitwindowd": return GetExistingFilesInDir(networkDir, []string{"bitdrive", "bitwindow.db"}, log) - case "thunder", "bitnames", "bitassets", "thunder-orchard", "truthcoin", "photon", "coinshift", "freebankd": + case "thunder", "bitnames", "bitassets", "thunder-orchard", "truthcoin", "photon", "coinshift": return GetExistingFilesInDir(networkDir, SidechainChainDataNames, log) default: @@ -538,7 +538,7 @@ func (b BinaryDirConfig) GetWalletPaths(networkDir string, network Network, log paths = append(paths, legacyWallet) } - case "thunder", "bitnames", "bitassets", "thunder-orchard", "truthcoin", "photon", "coinshift", "freebankd": + case "thunder", "bitnames", "bitassets", "thunder-orchard", "truthcoin", "photon", "coinshift": paths = append(paths, GetExistingFilesInDir(networkDir, []string{"wallet.mdb"}, log)...) case "bitwindowd": @@ -633,7 +633,7 @@ func (b BinaryDirConfig) GetLogPaths(networkDir string, log zerolog.Logger) []st logfile.Name, "debug.log", "drivechaind.log", }, log)...) - case "thunder", "bitnames", "bitassets", "thunder-orchard", "truthcoin", "photon", "coinshift", "freebankd": + case "thunder", "bitnames", "bitassets", "thunder-orchard", "truthcoin", "photon", "coinshift": paths = append(paths, GetExistingFilesInDir(networkDir, []string{"logs"}, log)...) } @@ -662,7 +662,7 @@ func (b BinaryDirConfig) LogPath(networkDir string) string { case "bip300301-enforcer": return findLatestEnforcerLog(networkDir) - case "thunder", "bitnames", "bitassets", "thunder-orchard", "truthcoin", "photon", "coinshift", "freebankd": + case "thunder", "bitnames", "bitassets", "thunder-orchard", "truthcoin", "photon", "coinshift": return findLatestDirVersionedLog(networkDir) default: diff --git a/sidechain-orchestrator/config/binaries_test.go b/sidechain-orchestrator/config/binaries_test.go index e59bba4df9..356c738ab6 100644 --- a/sidechain-orchestrator/config/binaries_test.go +++ b/sidechain-orchestrator/config/binaries_test.go @@ -1,12 +1,9 @@ package config import ( - "os" "path/filepath" "runtime" "testing" - - "github.com/rs/zerolog" ) func home() string { @@ -244,29 +241,20 @@ func TestFlutterFrontendPath(t *testing.T) { } } -func TestFreebankFindsItsChainWalletAndLogs(t *testing.T) { - dir := t.TempDir() - for _, name := range []string{"data.mdb", "lock.mdb", "wallet.mdb", filepath.Join("logs", "v0.3.3", "freebank.log")} { - path := filepath.Join(dir, name) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, nil, 0o600); err != nil { - t.Fatal(err) - } - } - log := zerolog.Nop() - - if got := FreebankDirs.GetBlockchainDataPaths(dir, NetworkSignet, log); len(got) != 3 { - t.Errorf("chain data paths = %v, want data.mdb, lock.mdb and logs", got) - } - if got := FreebankDirs.GetWalletPaths(dir, NetworkSignet, log); len(got) != 1 || filepath.Base(got[0]) != "wallet.mdb" { - t.Errorf("wallet paths = %v, want wallet.mdb", got) - } - if got := FreebankDirs.GetLogPaths(dir, log); len(got) != 1 || filepath.Base(got[0]) != "logs" { - t.Errorf("log paths = %v, want logs", got) +// FreeBank is a Core fork, so its datadir is the one freebankd picks for itself, +// with no network subdir on eCash. Its .cookie is read from there. +func TestFreebankPath(t *testing.T) { + p := FreebankDirs.DatadirNetwork(NetworkECash, "") + var want string + switch runtime.GOOS { + case "darwin": + want = filepath.Join(home(), "Library", "Application Support", "FreeBank") + case "windows": + want = filepath.Join(home(), "AppData", "Roaming", "FreeBank") + default: + want = filepath.Join(home(), ".freebank") } - if got, want := FreebankDirs.LogPath(dir), filepath.Join(dir, "logs", "v0.3.3", "freebank.log"); got != want { - t.Errorf("LogPath = %q, want %q", got, want) + if p != want { + t.Errorf("FreeBank eCash datadir = %q, want %q", p, want) } } diff --git a/sidechain-orchestrator/config/chains_config.json b/sidechain-orchestrator/config/chains_config.json index c33ef36d1e..1d827bc7e7 100644 --- a/sidechain-orchestrator/config/chains_config.json +++ b/sidechain-orchestrator/config/chains_config.json @@ -1,5 +1,5 @@ { - "version": 23, + "version": 24, "binaries": { "bitcoincore": { "type": "core", @@ -837,21 +837,22 @@ "version": "latest", "description": "Credit-creation drivechain (Scottish free-banking lineage).", "repo_url": "https://github.com/mbdrivechains/freebank", - "port": 6130, + "port": 8454, "chain_layer": 2, - "is_bitcoin_core": false, + "is_bitcoin_core": true, + "own_wallet": true, "color": "orange", "health_check": { "type": "tcp" }, - "dependencies": ["bitcoind", "enforcer"], - "startup_log_patterns": [], + "dependencies": ["bitcoind", "enforcer", "grpcurl"], + "startup_log_patterns": ["Done loading"], "directories": { "binary": { "default": { - "linux": "freebank", - "macos": "freebank", - "windows": "freebank" + "linux": ".freebank", + "macos": "FreeBank", + "windows": "FreeBank" } }, "flutter_frontend": {} diff --git a/sidechain-orchestrator/config/sidechain_conf.go b/sidechain-orchestrator/config/sidechain_conf.go index fb955c4e75..8144859d5a 100644 --- a/sidechain-orchestrator/config/sidechain_conf.go +++ b/sidechain-orchestrator/config/sidechain_conf.go @@ -597,16 +597,6 @@ var KnownSidechainSpecs = map[string]SidechainConfSpec{ RPCKey: "rpc-port", DirKey: "bitassets", }, - "freebank": { - EnforcerArg: "mainchain-grpc-host", - Name: "FreeBank", - ConfigFilename: "freebank.conf", - BasePort: 6130, - CliArgKeys: []string{"net-addr", "zmq-addr"}, - PortStyle: "zmq", - RPCKey: "rpc-port", - DirKey: "freebank", - }, "bitnames": { EnforcerArg: "mainchain-grpc-host", Name: "BitNames", diff --git a/sidechain-orchestrator/config/sidechain_conf_ports_test.go b/sidechain-orchestrator/config/sidechain_conf_ports_test.go index c92915001a..5a5ccc9944 100644 --- a/sidechain-orchestrator/config/sidechain_conf_ports_test.go +++ b/sidechain-orchestrator/config/sidechain_conf_ports_test.go @@ -13,7 +13,6 @@ import ( var provedChainFlags = map[string][]string{ "thunder": {"datadir", "headless", "log-dir", "log-level-file", "log-level", "mainchain-grpc-url", "mnemonic-seed-phrase-path", "net-addr", "network", "network-magic", "private-rpc-addr", "rpc-addr"}, "bitassets": {"datadir", "file-log-level", "headless", "log-dir", "log-level", "mainchain-grpc-host", "mainchain-grpc-port", "mnemonic-seed-phrase-path", "net-addr", "network", "rpc-host", "rpc-port", "zmq-addr"}, - "freebank": {"datadir", "file-log-level", "headless", "log-dir", "log-level", "mainchain-grpc-host", "mainchain-grpc-port", "mnemonic-seed-phrase-path", "net-addr", "network", "rpc-host", "rpc-port", "zmq-addr"}, "bitnames": {"datadir", "file-log-level", "headless", "log-dir", "log-level", "mainchain-grpc-host", "mainchain-grpc-port", "mnemonic-seed-phrase-path", "net-addr", "network", "network-magic", "private-rpc-addr", "rpc-addr", "zmq-addr"}, "photon": {"datadir", "headless", "log-dir", "log-level-file", "log-level", "mainchain-grpc-url", "mnemonic-seed-phrase-path", "net-addr", "network", "network-magic", "rpc-addr"}, "truthcoin": {"add-peer", "datadir", "decision-config-testing", "file-log-level", "headless", "log-dir", "log-level", "mainchain-grpc-host", "mainchain-grpc-port", "mnemonic-seed-phrase-path", "net-addr", "network", "network-magic", "private-rpc-host", "private-rpc-port", "rpc-host", "rpc-port", "server-name", "wallet-dir", "zmq-addr"}, @@ -57,7 +56,6 @@ func TestRpcEndpointFollowsTheBinary(t *testing.T) { for name, want := range map[string]string{ "thunder": "127.0.0.1:36009", "bitassets": "36004", - "freebank": "36130", "bitnames": "127.0.0.1:36002", "photon": "127.0.0.1:36099", "coinshift": "127.0.0.1:36255", @@ -141,7 +139,6 @@ func TestECashKeepsTheDaemonDefaults(t *testing.T) { for name, want := range map[string]map[string]string{ "thunder": {"rpc-addr": "127.0.0.1:6009", "net-addr": "0.0.0.0:4009"}, "bitassets": {"rpc-port": "6004", "net-addr": "0.0.0.0:4004", "zmq-addr": "127.0.0.1:28004"}, - "freebank": {"rpc-port": "6130", "net-addr": "0.0.0.0:4130", "zmq-addr": "127.0.0.1:28130"}, "bitnames": {"rpc-addr": "127.0.0.1:6002", "net-addr": "0.0.0.0:4002", "zmq-addr": "127.0.0.1:28002"}, "photon": {"rpc-addr": "127.0.0.1:6099", "net-addr": "0.0.0.0:4099"}, "coinshift": {"rpc-addr": "127.0.0.1:6255", "net-addr": "0.0.0.0:4255"}, diff --git a/sidechain-orchestrator/config/sidechain_enforcer_args_test.go b/sidechain-orchestrator/config/sidechain_enforcer_args_test.go index 61181eedc6..3cb80bd7a3 100644 --- a/sidechain-orchestrator/config/sidechain_enforcer_args_test.go +++ b/sidechain-orchestrator/config/sidechain_enforcer_args_test.go @@ -12,7 +12,7 @@ func TestSidechainEnforcerArgsUseTheDaemonFlags(t *testing.T) { require.NoError(t, err) require.Equal(t, []string{"--mainchain-grpc-url=http://127.0.0.1:32123"}, args) } - for _, name := range []string{"bitnames", "bitassets", "truthcoin", "freebank"} { + for _, name := range []string{"bitnames", "bitassets", "truthcoin"} { args, err := KnownSidechainSpecs[name].EnforcerArgs("http://127.0.0.1:32123") require.NoError(t, err) require.Equal(t, []string{"--mainchain-grpc-host=127.0.0.1", "--mainchain-grpc-port=32123"}, args) diff --git a/sidechain-orchestrator/config_loader.go b/sidechain-orchestrator/config_loader.go index 9a691345ed..bcf8a52f75 100644 --- a/sidechain-orchestrator/config_loader.go +++ b/sidechain-orchestrator/config_loader.go @@ -35,6 +35,7 @@ type jsonBinaryConf struct { Color string `json:"color"` IsBitcoinCore bool `json:"is_bitcoin_core"` + OwnWallet bool `json:"own_wallet"` HealthCheck *struct { Type string `json:"type"` @@ -266,6 +267,7 @@ func jsonToBinaryConfig(key string, jb jsonBinaryConf) BinaryConfig { ChainLayer: jb.ChainLayer, Slot: jb.Slot, IsBitcoinCore: jb.IsBitcoinCore, + OwnWallet: jb.OwnWallet, Dependencies: jb.Dependencies, StartupLogPatterns: jb.StartupLogPatterns, } diff --git a/sidechain-orchestrator/orchestrator.go b/sidechain-orchestrator/orchestrator.go index 69c84e39e1..f8609f1c3d 100644 --- a/sidechain-orchestrator/orchestrator.go +++ b/sidechain-orchestrator/orchestrator.go @@ -1071,6 +1071,12 @@ func (o *Orchestrator) ensureCoreSidechainWallet(ctx context.Context, cfg Binary if !cfg.IsBitcoinCore || cfg.ChainLayer != 2 || cfg.Slot <= 0 || o.WalletSvc == nil { return nil } + // A fork that keeps its own wallet creates it at startup and has no RPC to + // import one, so it takes no starter, and its boot never waits on the + // wallet service. + if cfg.OwnWallet { + return nil + } mnemonic, err := o.WalletSvc.GetOrDeriveSidechainStarter(cfg.Slot, cfg.DisplayName) if err != nil { return fmt.Errorf("sidechain starter: %w", err) diff --git a/sidechain-orchestrator/orchestrator_sidechain_args_test.go b/sidechain-orchestrator/orchestrator_sidechain_args_test.go index ef550a0173..80da1a587e 100644 --- a/sidechain-orchestrator/orchestrator_sidechain_args_test.go +++ b/sidechain-orchestrator/orchestrator_sidechain_args_test.go @@ -2,8 +2,13 @@ package orchestrator import ( "context" + "encoding/json" "errors" + "net" + "net/http" + "net/http/httptest" "os" + "path/filepath" "slices" "strings" "testing" @@ -13,6 +18,7 @@ import ( "github.com/stretchr/testify/require" "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/config" + "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/config/netcatalog" ) func thunderOrchestrator(t *testing.T, settings map[string]string) *Orchestrator { @@ -158,7 +164,6 @@ func TestPrepareSidechainArgsPassesTheZmqChainPorts(t *testing.T) { for name, want := range map[string][]string{ "bitnames": {"--net-addr=0.0.0.0:34002", "--zmq-addr=127.0.0.1:58002", "--network=signet"}, "bitassets": {"--net-addr=0.0.0.0:34004", "--zmq-addr=127.0.0.1:58004", "--network=signet"}, - "freebank": {"--net-addr=0.0.0.0:34130", "--zmq-addr=127.0.0.1:58130", "--network=signet"}, } { useTempHome(t) spec, ok := config.KnownSidechainSpecs[name] @@ -346,25 +351,81 @@ func TestUnforcedBackendKeepsItsWindow(t *testing.T) { assert.Empty(t, opts.TargetArgs) } -// FreeBank has no frontend, so only a forced backend hides its window. -func TestFreeBankHidesItsWindowOnlyAsABackend(t *testing.T) { +// betanetPin is the hash of the eCash betanet fork block. +const betanetPin = "00000000000000030101ba5cfea54b22becc79f95dc6040beb76e01dd9d04042" + +// freebankOnBetanet is an orchestrator on the eCash betanet. Its Core runs the +// conf BitWindow writes and answers the hash of the fork block. +func freebankOnBetanet(t *testing.T) (*Orchestrator, string) { + t.Helper() useTempHome(t) - orch := &Orchestrator{log: zerolog.Nop(), Network: string(config.NetworkRegtest), DataDir: t.TempDir()} + height := config.PublishedForkHeight(config.NetworkECash) + t.Cleanup(func() { config.SetForkHeight(config.NetworkECash, height) }) + betanet, ok := netcatalog.Embedded().ByID("betanet") + require.True(t, ok) + config.SetForkHeight(config.NetworkECash, betanet.ForkHeight) + + core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Method string `json:"method"` + Params []int `json:"params"` + } + assert.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + assert.Equal(t, "getblockhash", req.Method) + assert.Equal(t, []int{967680}, req.Params) + _ = json.NewEncoder(w).Encode(map[string]any{"result": betanetPin, "error": nil, "id": "orchestrator"}) + })) + t.Cleanup(core.Close) + _, port, err := net.SplitHostPort(core.Listener.Addr().String()) + require.NoError(t, err) + + conf := &config.BitcoinConfManager{Network: config.NetworkECash} + conf.Config = config.ParseBitcoinConfig(conf.GetDefaultConfig()) + for key, value := range map[string]string{"rpcport": port, "rpcuser": "user", "rpcpassword": "password"} { + conf.Config.SetSetting(key, value, config.CoreSectionForNetwork(config.NetworkECash)) + } + orch := &Orchestrator{log: zerolog.Nop(), Network: string(config.NetworkECash), DataDir: t.TempDir(), BitcoinConf: conf} + return orch, port +} - var windowed StartOpts - require.NoError(t, orch.appendSidechainArgs(context.Background(), - BinaryConfig{Name: "freebank", ChainLayer: 2, Port: 6130}, &windowed)) - assert.NotContains(t, windowed.TargetArgs, "--headless") +// On betanet FreeBank boots pinned to the fork block the catalog publishes. It +// verifies deposits over the REST port of the Core BitWindow runs, and freebankd +// refuses to start unless that Core serves REST with a txindex. +func TestFreeBankBootsPinnedToTheBetanetForkBlock(t *testing.T) { + orch, port := freebankOnBetanet(t) + for _, key := range []string{"rest", "txindex"} { + assert.Equal(t, "1", orch.BitcoinConf.Config.GetEffectiveSetting(key, "main"), key) + } + grpcurl := BinaryPath(orch.DataDir, "grpcurl") + require.NoError(t, os.MkdirAll(filepath.Dir(grpcurl), 0o755)) + require.NoError(t, os.WriteFile(grpcurl, nil, 0o755)) - fresh := StartOpts{ForceBackend: true} + opts := StartOpts{ForceBackend: true} require.NoError(t, orch.appendSidechainArgs(context.Background(), - BinaryConfig{Name: "freebank", ChainLayer: 2, Port: 6130}, &fresh)) - assert.Equal(t, []string{"--headless"}, fresh.TargetArgs) + BinaryConfig{Name: "freebank", ChainLayer: 2, Port: 8454, IsBitcoinCore: true}, &opts)) + assert.Equal(t, []string{ + "-mainchaintransport=enforcer", + "-mainchainrest=127.0.0.1:" + port, + "-mainchainchain=main", + "-mainchainblockpin=967680:" + betanetPin, + "-grpcurlbin=" + grpcurl, + }, opts.TargetArgs) +} + +// A caller that passes its own value for a flag keeps it. A second copy would +// leave the node with two answers for one option. +func TestACallerValueBeatsTheGeneratedFlag(t *testing.T) { + orch, _ := freebankOnBetanet(t) - dupe := StartOpts{ForceBackend: true, TargetArgs: []string{"--headless"}} + opts := StartOpts{TargetArgs: []string{"-mainchainrest=10.0.0.9:1234"}} require.NoError(t, orch.appendSidechainArgs(context.Background(), - BinaryConfig{Name: "freebank", ChainLayer: 2, Port: 6130}, &dupe)) - assert.Equal(t, []string{"--headless"}, dupe.TargetArgs) + BinaryConfig{Name: "freebank", ChainLayer: 2, Port: 8454, IsBitcoinCore: true}, &opts)) + assert.Equal(t, []string{ + "-mainchainrest=10.0.0.9:1234", + "-mainchaintransport=enforcer", + "-mainchainchain=main", + "-mainchainblockpin=967680:" + betanetPin, + }, opts.TargetArgs) } // A command line that already carries the flag takes it one time. diff --git a/sidechain-orchestrator/own_wallet_test.go b/sidechain-orchestrator/own_wallet_test.go new file mode 100644 index 0000000000..7044171d48 --- /dev/null +++ b/sidechain-orchestrator/own_wallet_test.go @@ -0,0 +1,43 @@ +package orchestrator + +import ( + "context" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/config" + "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/wallet" +) + +// A fork that keeps its own wallet boots without a starter. The wallet service +// here holds no wallet, so asking it for a starter fails the boot. +func TestAForkWithItsOwnWalletTakesNoStarter(t *testing.T) { + o := &Orchestrator{ + log: zerolog.Nop(), + Network: string(config.NetworkECash), + WalletSvc: wallet.NewService(t.TempDir(), zerolog.Nop()), + } + freebank := BinaryConfig{ + Name: "freebank", DisplayName: "FreeBank", ChainLayer: 2, Slot: 130, Port: 8454, + IsBitcoinCore: true, OwnWallet: true, + } + require.NoError(t, o.ensureCoreSidechainWallet(context.Background(), freebank)) + + freebank.OwnWallet = false + require.ErrorContains(t, o.ensureCoreSidechainWallet(context.Background(), freebank), "sidechain starter") +} + +// FreeBank ships as a Core fork that keeps its own wallet and fetches the +// grpcurl it reaches the enforcer with. +func TestFreeBankShipsAsACoreForkWithItsOwnWallet(t *testing.T) { + cfg, ok := BinaryConfigByName("freebank") + require.True(t, ok) + assert.True(t, cfg.IsBitcoinCore) + assert.True(t, cfg.OwnWallet) + assert.Equal(t, 8454, cfg.Port) + assert.Equal(t, []string{"Done loading"}, cfg.StartupLogPatterns) + assert.Contains(t, cfg.Dependencies, "grpcurl") +} diff --git a/sidechain-orchestrator/sidechain/freebank/freebank.go b/sidechain-orchestrator/sidechain/freebank/freebank.go new file mode 100644 index 0000000000..a137187c84 --- /dev/null +++ b/sidechain-orchestrator/sidechain/freebank/freebank.go @@ -0,0 +1,64 @@ +// Package freebank is what the FreeBank sidechain adds to a plain Bitcoin Core +// fork: the wallet dialect its node speaks, and the mainchain identity it pins +// itself to at boot. Everything Core answers comes from the shared Core client. +package freebank + +import ( + "context" + "fmt" + + "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/sidechain" + "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/sidechain/corenode" +) + +var _ sidechain.Node = (*Client)(nil) + +// Client talks to a FreeBank node. +type Client struct { + *corenode.Client +} + +// NewClient creates a client pointed at host:port. cookiePath is the node's +// .cookie. +func NewClient(host string, port int, cookiePath string) *Client { + return &Client{corenode.New("freebank", host, port, cookiePath, corenode.Options{ + // The node creates and keeps one wallet of its own, which Core answers + // for at the root endpoint. + WalletPath: "/", + // The fork predates getbalances. + LegacyBalance: true, + // The node defaults to p2sh-segwit, but every FreeBank path that takes + // an address is documented against legacy. + AddressType: "legacy", + })} +} + +// Args pins the node to one mainchain. freebankd refuses to boot without an L1 +// identity, so it takes the transport, the REST port and the fork block that +// names the chain it belongs to. +func (c *Client) Args(ctx context.Context, host sidechain.Host) ([]string, error) { + height := host.ForkHeight() + if height <= 0 { + return nil, fmt.Errorf("FreeBank pins its L1 identity to the eCash fork block, and this network publishes no fork height") + } + rest := host.MainchainREST() + if rest == "" { + return nil, fmt.Errorf("FreeBank reads the mainchain REST port from bitwindow-bitcoin.conf, which failed to load") + } + hash, err := host.MainchainBlockHash(ctx, height) + if err != nil { + return nil, fmt.Errorf("the mainchain has not reached the FreeBank fork height %d: %w", height, err) + } + + args := []string{ + "-mainchaintransport=enforcer", + "-mainchainrest=" + rest, + "-mainchainchain=main", + fmt.Sprintf("-mainchainblockpin=%d:%s", height, hash), + } + // An absent grpcurl is not fatal: the node looks for one on PATH. + if grpcurl := host.ToolPath("grpcurl"); grpcurl != "" { + args = append(args, "-grpcurlbin="+grpcurl) + } + return args, nil +} diff --git a/sidechain-orchestrator/sidechain/freebank/freebank_test.go b/sidechain-orchestrator/sidechain/freebank/freebank_test.go new file mode 100644 index 0000000000..d7aef28079 --- /dev/null +++ b/sidechain-orchestrator/sidechain/freebank/freebank_test.go @@ -0,0 +1,105 @@ +package freebank + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/sidechain" +) + +// fakeHost answers what the orchestrator would, so a test states one mainchain +// and reads the command line the chain asks for. FreeBank reads the mainchain +// over REST and the enforcer, never over Core's RPC, so the embedded Host +// answers the mainchain RPC methods with a panic. +type fakeHost struct { + sidechain.Host + backendOnly bool + rest string + forkHeight int + hash string + hashErr error + tools map[string]string +} + +func (h fakeHost) BackendOnly() bool { return h.backendOnly } +func (h fakeHost) MainchainREST() string { return h.rest } +func (h fakeHost) ForkHeight() int { return h.forkHeight } + +func (h fakeHost) MainchainBlockHash(context.Context, int) (string, error) { + return h.hash, h.hashErr +} + +func (h fakeHost) ToolPath(name string) string { return h.tools[name] } + +func onECash() fakeHost { + return fakeHost{ + rest: "127.0.0.1:18302", + forkHeight: 967680, // what the network catalog publishes for betanet + hash: "00000000000000030101ba5cfea54b22becc79f95dc6040beb76e01dd9d04042", + } +} + +func TestArgsPinTheForkBlock(t *testing.T) { + args, err := NewClient("127.0.0.1", 8454, "").Args(context.Background(), onECash()) + require.NoError(t, err) + assert.Equal(t, []string{ + "-mainchaintransport=enforcer", + "-mainchainrest=127.0.0.1:18302", + "-mainchainchain=main", + "-mainchainblockpin=967680:00000000000000030101ba5cfea54b22becc79f95dc6040beb76e01dd9d04042", + }, args) +} + +// The node looks for grpcurl on PATH when the orchestrator holds none, so an +// absent one is not a refusal. +func TestArgsNameGrpcurlOnlyWhenItIsDownloaded(t *testing.T) { + host := onECash() + host.tools = map[string]string{"grpcurl": "/tmp/bin/grpcurl"} + args, err := NewClient("127.0.0.1", 8454, "").Args(context.Background(), host) + require.NoError(t, err) + assert.Contains(t, args, "-grpcurlbin=/tmp/bin/grpcurl") +} + +// A network with no published fork height gets a message, not a daemon that +// starts and exits. +func TestArgsRefuseANetworkWithNoForkHeight(t *testing.T) { + host := onECash() + host.forkHeight = 0 + _, err := NewClient("127.0.0.1", 8454, "").Args(context.Background(), host) + require.Error(t, err) + assert.Contains(t, err.Error(), "fork height") +} + +func TestArgsRefuseWithoutTheMainchainRestPort(t *testing.T) { + host := onECash() + host.rest = "" + _, err := NewClient("127.0.0.1", 8454, "").Args(context.Background(), host) + require.Error(t, err) + assert.Contains(t, err.Error(), "REST port") +} + +// A mainchain that has not reached the fork height yet names the height it +// waits for. +func TestArgsReportAMainchainBehindTheFork(t *testing.T) { + host := onECash() + host.hashErr = fmt.Errorf("block height out of range") + _, err := NewClient("127.0.0.1", 8454, "").Args(context.Background(), host) + require.Error(t, err) + assert.Contains(t, err.Error(), "967680") +} + +// FreeBank blind merge mines with its own ticker and settles withdrawals on its +// own paths, so the orchestrator reads that from the type. +func TestFreeBankDrivesNeitherBmmNorBundles(t *testing.T) { + var node sidechain.Node = NewClient("127.0.0.1", 8454, "") + + _, drivesBMM := node.(sidechain.BMMNode) + assert.False(t, drivesBMM) + + _, proposesBundles := node.(sidechain.WithdrawalNode) + assert.False(t, proposesBundles) +} diff --git a/sidechain-orchestrator/sidechain/nodes/nodes.go b/sidechain-orchestrator/sidechain/nodes/nodes.go index 2c3c9c0e39..50f4c15d5c 100644 --- a/sidechain-orchestrator/sidechain/nodes/nodes.go +++ b/sidechain-orchestrator/sidechain/nodes/nodes.go @@ -11,6 +11,7 @@ import ( "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/sidechain" "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/sidechain/bbc" "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/sidechain/elements" + "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/sidechain/freebank" "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/sidechain/zside" ) @@ -34,6 +35,8 @@ func New(name, host string, port int, isBitcoinCore bool, network config.Network switch name { case "liquid-signet": return elements.NewNode(host, port, datadir, network), nil + case "freebank": + return freebank.NewClient(host, port, cookie), nil default: return bbc.NewClient(host, port, cookie), nil } diff --git a/sidechain-orchestrator/sidechain/nodes/nodes_test.go b/sidechain-orchestrator/sidechain/nodes/nodes_test.go index 2ef17db221..0a11c6c789 100644 --- a/sidechain-orchestrator/sidechain/nodes/nodes_test.go +++ b/sidechain-orchestrator/sidechain/nodes/nodes_test.go @@ -28,7 +28,6 @@ func TestEveryBmmSidechainTakesAnAuditedTransport(t *testing.T) { {name: "truthcoin"}, {name: "zside"}, {name: "bbc", core: true}, - {name: "freebank"}, } for _, chain := range chains { diff --git a/sidechain_core/assets/chains_config.json b/sidechain_core/assets/chains_config.json index c33ef36d1e..1d827bc7e7 100644 --- a/sidechain_core/assets/chains_config.json +++ b/sidechain_core/assets/chains_config.json @@ -1,5 +1,5 @@ { - "version": 23, + "version": 24, "binaries": { "bitcoincore": { "type": "core", @@ -837,21 +837,22 @@ "version": "latest", "description": "Credit-creation drivechain (Scottish free-banking lineage).", "repo_url": "https://github.com/mbdrivechains/freebank", - "port": 6130, + "port": 8454, "chain_layer": 2, - "is_bitcoin_core": false, + "is_bitcoin_core": true, + "own_wallet": true, "color": "orange", "health_check": { "type": "tcp" }, - "dependencies": ["bitcoind", "enforcer"], - "startup_log_patterns": [], + "dependencies": ["bitcoind", "enforcer", "grpcurl"], + "startup_log_patterns": ["Done loading"], "directories": { "binary": { "default": { - "linux": "freebank", - "macos": "freebank", - "windows": "freebank" + "linux": ".freebank", + "macos": "FreeBank", + "windows": "FreeBank" } }, "flutter_frontend": {} diff --git a/sidechain_core/assets/migrations/024_chains_config.json b/sidechain_core/assets/migrations/024_chains_config.json new file mode 100644 index 0000000000..475471a5cd --- /dev/null +++ b/sidechain_core/assets/migrations/024_chains_config.json @@ -0,0 +1,28 @@ +{ + "version": 24, + "migration": "patch", + "binaries": { + "freebank": { + "port": 8454, + "is_bitcoin_core": true, + "own_wallet": true, + "dependencies": [ + "bitcoind", + "enforcer", + "grpcurl" + ], + "startup_log_patterns": [ + "Done loading" + ], + "directories": { + "binary": { + "default": { + "linux": ".freebank", + "macos": "FreeBank", + "windows": "FreeBank" + } + } + } + } + } +} From 7e07c5c89e337340b4e999335b2ca713c24a0dd3 Mon Sep 17 00:00:00 2001 From: mblowes Date: Thu, 24 Sep 2026 19:32:31 +1000 Subject: [PATCH 06/11] sidechain_core: give FreeBank the Core layout again FreeBank is a Core fork again, so its log is debug.log in the Core network dir, its Linux datadir sits directly under $HOME (~/.freebank), and it takes the per-network subdir the CUSF chains do not have. Port back to 8454. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Gqbnw5hYV8j1ekU6P4GgYT --- sidechain_core/lib/config/binaries.dart | 11 ++++++----- sidechain_core/lib/config/sidechains.dart | 6 ++++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/sidechain_core/lib/config/binaries.dart b/sidechain_core/lib/config/binaries.dart index 377294405b..617d176de6 100644 --- a/sidechain_core/lib/config/binaries.dart +++ b/sidechain_core/lib/config/binaries.dart @@ -943,11 +943,10 @@ extension BinaryPaths on Binary { BinaryType.BINARY_TYPE_ZSIDE || BinaryType.BINARY_TYPE_TRUTHCOIN || BinaryType.BINARY_TYPE_PHOTON || - BinaryType.BINARY_TYPE_FREEBANK || BinaryType.BINARY_TYPE_COINSHIFT => _findLatestDirVersionedLog(), // Core-derived, so the bitcoind layout: debug.log under the network dir, // not the versioned-directory logs the Rust sidechains write. - BinaryType.BINARY_TYPE_BBC => filePath([datadirNetwork(), 'debug.log']), + BinaryType.BINARY_TYPE_BBC || BinaryType.BINARY_TYPE_FREEBANK => filePath([datadirNetwork(), 'debug.log']), BinaryType.BINARY_TYPE_ENFORCER => _findLatestEnforcerLog(), BinaryType.BINARY_TYPE_GRPCURL || BinaryType.BINARY_TYPE_DRIVECHAIND || BinaryType.BINARY_TYPE_ZSIDED => '', BinaryType.BINARY_TYPE_UNSPECIFIED => _unsupportedBinaryType(type), @@ -1090,7 +1089,9 @@ extension BinaryPaths on Binary { switch (OS.current) { case OS.linux: - if (type == BinaryType.BINARY_TYPE_BITCOIND || type == BinaryType.BINARY_TYPE_BBC) { + if (type == BinaryType.BINARY_TYPE_BITCOIND || + type == BinaryType.BINARY_TYPE_BBC || + type == BinaryType.BINARY_TYPE_FREEBANK) { // Core and its forks keep their datadir directly under $HOME return filePath([home]); } @@ -1178,10 +1179,11 @@ extension BinaryPaths on Binary { final baseDir = datadir(); switch (type) { - // Bbc is Bitcoin Core derived, so it takes the per-network + // Bbc and FreeBank are Bitcoin Core derived, so they take the per-network // subdir the CUSF sidechains do not have. case BinaryType.BINARY_TYPE_BITCOIND: case BinaryType.BINARY_TYPE_BBC: + case BinaryType.BINARY_TYPE_FREEBANK: if (network == BitcoinNetwork.BITCOIN_NETWORK_MAINNET || network == BitcoinNetwork.BITCOIN_NETWORK_ECASH) { return baseDir; } @@ -1198,7 +1200,6 @@ extension BinaryPaths on Binary { case BinaryType.BINARY_TYPE_TRUTHCOIN: case BinaryType.BINARY_TYPE_PHOTON: case BinaryType.BINARY_TYPE_COINSHIFT: - case BinaryType.BINARY_TYPE_FREEBANK: case BinaryType.BINARY_TYPE_LIQUID_SIGNET: case BinaryType.BINARY_TYPE_GRPCURL: case BinaryType.BINARY_TYPE_DRIVECHAIND: diff --git a/sidechain_core/lib/config/sidechains.dart b/sidechain_core/lib/config/sidechains.dart index 3b8e1ca036..07881680da 100644 --- a/sidechain_core/lib/config/sidechains.dart +++ b/sidechain_core/lib/config/sidechains.dart @@ -346,7 +346,9 @@ class Bbc extends Sidechain { } /// Credit-creation drivechain of Scottish free-banking lineage: discount houses -/// issue redeemable notes and discount bills of exchange. A fork of BitAssets. +/// issue redeemable notes and discount bills of exchange. A Bitcoin Core fork, +/// Core-derived like Bbc, so it speaks Core-style JSON-RPC rather than the CUSF +/// interface the Rust sidechains use. class FreeBank extends Sidechain { FreeBank({ super.name = 'FreeBank', @@ -355,7 +357,7 @@ class FreeBank extends Sidechain { super.repoUrl = 'https://github.com/mbdrivechains/freebank', DirectoryConfig? directories, MetadataConfig? metadata, - super.port = 6130, + super.port = 8454, super.chainLayer = 2, super.downloadInfo = const DownloadInfo(), super.extraBootArgs = const [], From 3fca908190e54410d59a4ad380c5c861261038b5 Mon Sep 17 00:00:00 2001 From: mblowes Date: Thu, 24 Sep 2026 20:30:37 +1000 Subject: [PATCH 07/11] orchestrator: stop and probe a Core fork with its cookie Stop and the sync probe built a bare JSON-RPC proxy for every sidechain. A Core fork answers a call without its cookie with an empty 401, which surfaced as "decode response: EOF". On shutdown and Reset that did not read as a stop that never landed, so the orchestrator waited the whole grace period before signalling, and every binary after it waited too. The sync row showed an error for a healthy node. Both now take the client nodes.New builds, which reads the cookie, and the probe asks a Core fork only for its height. A stop that finds no cookie never went out, so it goes straight to the signal. The CUSF chains get the client they had. Bbc and Elements had the same bugs. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Gqbnw5hYV8j1ekU6P4GgYT --- .../get_sync_status_test.go | 49 +++++++++++++++++++ sidechain-orchestrator/orchestrator.go | 29 ++++++++++- sidechain-orchestrator/stopwait_test.go | 46 +++++++++++++++++ 3 files changed, 122 insertions(+), 2 deletions(-) diff --git a/sidechain-orchestrator/get_sync_status_test.go b/sidechain-orchestrator/get_sync_status_test.go index b9026c3f16..7ac23ab36f 100644 --- a/sidechain-orchestrator/get_sync_status_test.go +++ b/sidechain-orchestrator/get_sync_status_test.go @@ -7,8 +7,11 @@ import ( "net/http" "net/http/httptest" "net/url" + "os" + "path/filepath" "strconv" "strings" + "sync" "sync/atomic" "testing" "time" @@ -328,6 +331,52 @@ func TestGetSyncStatus_NotRunningSidechainCarriesError(t *testing.T) { } } +// A Core fork answers a call without its cookie with an empty 401, so its row +// would show an error for a healthy node. The probe carries the cookie and asks +// only for the height: a Core fork reports no mainchain sync of its own. +func TestGetSyncStatus_ACoreForkIsProbedWithItsCookie(t *testing.T) { + o := newTestOrchestrator(t) + o.explorerHTTPClient = &http.Client{ + Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { + return statusResponse(http.StatusServiceUnavailable), nil + }), + } + datadir := config.FreebankDirs.DatadirNetwork(config.Network(o.CurrentNetwork()), "") + require.NoError(t, os.MkdirAll(datadir, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(datadir, ".cookie"), []byte("__cookie__:secret"), 0o600)) + + var methods []string + var mu sync.Mutex + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if user, password, ok := r.BasicAuth(); !ok || user != "__cookie__" || password != "secret" { + w.WriteHeader(http.StatusUnauthorized) + return + } + method, _ := readJSONRPC(r) + mu.Lock() + methods = append(methods, method) + mu.Unlock() + if method != "getblockcount" { + _, _ = w.Write([]byte(`{"result":null,"error":{"code":-32601,"message":"Method not found"}}`)) + return + } + _, _ = w.Write([]byte(`{"result":42,"error":null}`)) + })) + t.Cleanup(srv.Close) + setSidechainPort(t, o, "freebank", srv.URL) + adoptSidechain(t, o, "freebank") + + out, err := o.GetSyncStatus(context.Background()) + require.NoError(t, err) + slot := out.Sidechains["freebank"] + require.NotNil(t, slot) + assert.Equal(t, "", slot.Error) + assert.Equal(t, int64(42), slot.Blocks) + mu.Lock() + defer mu.Unlock() + assert.Equal(t, []string{"getblockcount"}, methods) +} + func TestGetSyncStatus_EveryL2SidechainHasAPort(t *testing.T) { // Invariant: every L2 sidechain registered in chains_config.json has a // non-zero Port. GetSyncStatus dials the sidechain's JSON-RPC endpoint at diff --git a/sidechain-orchestrator/orchestrator.go b/sidechain-orchestrator/orchestrator.go index f8609f1c3d..500a2f79e2 100644 --- a/sidechain-orchestrator/orchestrator.go +++ b/sidechain-orchestrator/orchestrator.go @@ -2370,6 +2370,11 @@ func unreachable(err error) bool { if errors.Is(err, errNoStopClient) { return true } + // A Core node writes its cookie as its RPC server starts, so with no cookie + // to read no request went out. + if errors.Is(err, os.ErrNotExist) { + return true + } if errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, syscall.EHOSTUNREACH) { return true } @@ -2388,11 +2393,18 @@ func (o *Orchestrator) callBitcoindStopRPC() error { return client.Stop(rpcCtx) } +// callSidechainStopRPC sends stop with the client the chain speaks. A Core fork +// answers a call without its cookie with an empty 401, which reads as a stop +// that may have landed, so shutdown would wait out the grace period for an +// exit that never comes. func (o *Orchestrator) callSidechainStopRPC(cfg BinaryConfig) error { - proxy := sidechain.NewJSONRPCProxy(cfg.RPCHost(), cfg.Port) + node, err := nodes.New(cfg.Name, cfg.RPCHost(), cfg.Port, cfg.IsBitcoinCore, config.Network(o.CurrentNetwork())) + if err != nil { + return fmt.Errorf("%s RPC stop: %w: %w", cfg.Name, errNoStopClient, err) + } rpcCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - return proxy.Stop(rpcCtx) + return node.Stop(rpcCtx) } func (o *Orchestrator) callEnforcerStopRPC() error { @@ -3418,6 +3430,19 @@ func (c *sidechainSyncConnection) Fetch(ctx context.Context) (*ChainSyncResult, if !ok { return nil, fmt.Errorf("unknown sidechain: %s", c.name) } + // A Core fork answers only a call that carries its cookie, and reports no + // mainchain sync of its own. + if cfg.IsBitcoinCore { + node, err := nodes.New(cfg.Name, cfg.RPCHost(), cfg.Port, true, config.Network(c.o.CurrentNetwork())) + if err != nil { + return nil, err + } + count, err := node.GetBlockCount(ctx) + if err != nil { + return nil, err + } + return &ChainSyncResult{Blocks: count}, nil + } proxy := sidechain.NewJSONRPCProxy(cfg.RPCHost(), cfg.Port) progress, err := proxy.MainchainSyncProgress(ctx) switch { diff --git a/sidechain-orchestrator/stopwait_test.go b/sidechain-orchestrator/stopwait_test.go index a04cb15d3e..87b14050a5 100644 --- a/sidechain-orchestrator/stopwait_test.go +++ b/sidechain-orchestrator/stopwait_test.go @@ -1,13 +1,21 @@ package orchestrator import ( + "encoding/json" "errors" "fmt" "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" "syscall" "testing" "github.com/stretchr/testify/require" + + "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/config" ) // A stop RPC that never landed must skip the graceful wait. A stop RPC that @@ -21,6 +29,7 @@ func TestUnreachableTellsAMissedStopFromAFlushingDaemon(t *testing.T) { }{ {"no error", nil, false}, {"no client", fmt.Errorf("bitcoind RPC stop: %w: down", errNoStopClient), true}, + {"no cookie yet", fmt.Errorf("read rpc cookie .cookie: %w", os.ErrNotExist), true}, {"connection refused", fmt.Errorf("stop: %w", syscall.ECONNREFUSED), true}, {"dial failed", &net.OpError{Op: "dial", Err: errors.New("no route")}, true}, {"read after the ack", &net.OpError{Op: "read", Err: errors.New("reset by peer")}, false}, @@ -33,3 +42,40 @@ func TestUnreachableTellsAMissedStopFromAFlushingDaemon(t *testing.T) { }) } } + +// A Core fork answers a call without its cookie with an empty 401, which reads +// as a stop that may have landed. Shutdown then waits out the grace period for +// an exit that never comes, so stop has to carry the cookie. +func TestACoreForkStopCarriesItsCookie(t *testing.T) { + o := newTestOrchestrator(t) + + var stopped atomic.Bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if user, password, ok := r.BasicAuth(); !ok || user != "__cookie__" || password != "secret" { + w.WriteHeader(http.StatusUnauthorized) + return + } + var req struct { + Method string `json:"method"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + stopped.Store(req.Method == "stop") + _, _ = w.Write([]byte(`{"result":"FreeBank server stopping","error":null}`)) + })) + t.Cleanup(srv.Close) + setSidechainPort(t, o, "freebank", srv.URL) + cfg, err := o.getConfig("freebank") + require.NoError(t, err) + + // A node that has not written its cookie has no RPC server up yet. + err = o.callSidechainStopRPC(cfg) + require.Error(t, err) + require.True(t, unreachable(err), "a stop that never went out must go straight to the signal: %v", err) + + datadir := config.FreebankDirs.DatadirNetwork(config.Network(o.CurrentNetwork()), "") + require.NoError(t, os.MkdirAll(datadir, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(datadir, ".cookie"), []byte("__cookie__:secret"), 0o600)) + + require.NoError(t, o.callSidechainStopRPC(cfg)) + require.True(t, stopped.Load()) +} From 0df7827483f2923babdb1b08552baa13f64ec0fb Mon Sep 17 00:00:00 2001 From: mblowes Date: Thu, 24 Sep 2026 20:30:37 +1000 Subject: [PATCH 08/11] orchestrator: say what dependencies and own_wallet mean Dependencies now names tools that must be on disk as well as daemons that must be running. own_wallet and a client's WalletPath "/" only work together, so each doc comment points at the other. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Gqbnw5hYV8j1ekU6P4GgYT --- sidechain-orchestrator/config.go | 11 +++++++++-- sidechain-orchestrator/sidechain/corenode/client.go | 3 ++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/sidechain-orchestrator/config.go b/sidechain-orchestrator/config.go index cfb321dd48..1c1d33df34 100644 --- a/sidechain-orchestrator/config.go +++ b/sidechain-orchestrator/config.go @@ -47,7 +47,12 @@ type BinaryConfig struct { DataDir map[string]string // os -> subdir under AppDir() (default for all networks) DataDirMainnet map[string]string // os -> subdir (mainnet override, empty = use DataDir) IsBitcoinCore bool // built on Bitcoin Core: Core datadir layout, Core JSON-RPC, cookie auth - OwnWallet bool // Core fork that keeps the wallet its node creates: no starter, no provisioning + + // OwnWallet marks a Core fork that keeps the wallet its node creates: no + // starter, no provisioning. Its client must send wallet calls to that + // wallet (corenode.Options.WalletPath "/"), since the seeded wallet the + // default path names is never created. + OwnWallet bool // Flutter frontend directory — Dart: flutterFrontendDir() extension (L1306-1353) // Per-OS subdir under the platform app support dir. Empty = no frontend. @@ -83,7 +88,9 @@ type BinaryConfig struct { HealthCheckType HealthCheckType HealthCheckRPC string // JSON-RPC method for health check - // Dependencies: names of binaries that must be running before this one + // Dependencies: names of binaries this one needs. A daemon (chain_layer + // 1 or 2) must be running before this one starts; a tool (chain_layer 0, + // such as grpcurl) must be on disk before its boot arguments are built. Dependencies []string } diff --git a/sidechain-orchestrator/sidechain/corenode/client.go b/sidechain-orchestrator/sidechain/corenode/client.go index 16c331a3e0..6da858ea42 100644 --- a/sidechain-orchestrator/sidechain/corenode/client.go +++ b/sidechain-orchestrator/sidechain/corenode/client.go @@ -26,7 +26,8 @@ type Options struct { // WalletPath is the endpoint a wallet RPC goes to. Empty takes // /wallet/, the wallet the orchestrator seeds. A fork that // keeps a wallet of its own takes "/", where Core answers for the one - // wallet it loaded. + // wallet it loaded, and sets own_wallet in chains_config so that the + // orchestrator seeds none. WalletPath string // LegacyBalance reads getwalletinfo in place of getbalances, for a fork From 4c87da862162df4d8b2e06fef8b8eb94fe907603 Mon Sep 17 00:00:00 2001 From: mblowes Date: Sat, 26 Sep 2026 11:39:45 +1000 Subject: [PATCH 09/11] orchestrator: let the BMM engine drive FreeBank FreeBank was a Node and nothing more: its own refreshbmm made and paid its bids, so the engine refused it ("produces its own blocks"). The FreeBank client now implements BMMNode against three node RPCs that match Bbc's, get_block_template, connect_block and get_bmm_inclusions, so a BitWindow user can bid for FreeBank blocks from their own wallet, with the engine's raises and settling. FreeBank bids with a block's merkle root rather than its hash, because the hash is fixed only once the winning mainchain block is written into the header. So ChainHolds walks the chain comparing merkle roots, not block hashes as Bbc's does. TemplateOnTip reads the parent from the block hex as Bbc's does. freebankd answers these from v0.2.16. An older node answers "method not found", which the client turns into a sentence naming the release it needs. corenode gains IsMethodNotFound for that. FreeBank joins the audited BMM transports: connect_block goes through the Core client, so it keeps its three-minute deadline. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ES4QHkwW4ByYRrG4H39PHo --- .../api/sidechain_node_test.go | 10 +- .../sidechain/corenode/client.go | 8 + .../sidechain/freebank/bmm.go | 115 +++++++++++++ .../sidechain/freebank/bmm_test.go | 151 ++++++++++++++++++ .../sidechain/freebank/freebank.go | 5 +- .../sidechain/freebank/freebank_test.go | 6 +- .../sidechain/nodes/nodes_test.go | 4 +- 7 files changed, 287 insertions(+), 12 deletions(-) create mode 100644 sidechain-orchestrator/sidechain/freebank/bmm.go create mode 100644 sidechain-orchestrator/sidechain/freebank/bmm_test.go diff --git a/sidechain-orchestrator/api/sidechain_node_test.go b/sidechain-orchestrator/api/sidechain_node_test.go index cfffe6b6ef..42fe8ff66f 100644 --- a/sidechain-orchestrator/api/sidechain_node_test.go +++ b/sidechain-orchestrator/api/sidechain_node_test.go @@ -57,17 +57,15 @@ func TestCoreForkAnswersOnlyWhatItDrives(t *testing.T) { assert.Contains(t, err.Error(), "proposes no withdrawal bundle") } -// FreeBank blind merge mines with its own refreshbmm ticker and settles -// withdrawals on its own paths, so it is a Node and nothing more. -func TestFreebankAnswersNeitherBMMNorWithdrawals(t *testing.T) { +// The BMM engine drives FreeBank blocks through the node's get_block_template +// and connect_block, but FreeBank settles withdrawals on its own paths. +func TestFreebankAnswersBMMButNotWithdrawals(t *testing.T) { cfg := orchestrator.BinaryConfig{Name: "freebank", DisplayName: "FreeBank", Port: 8454, IsBitcoinCore: true} node, err := sidechainNode(cfg, config.NetworkRegtest) require.NoError(t, err) _, err = bmmNode(cfg, config.NetworkRegtest) - require.Error(t, err) - assert.Contains(t, err.Error(), "FreeBank") - assert.Contains(t, err.Error(), "produces its own blocks") + require.NoError(t, err) _, err = withdrawalNode(node, cfg.DisplayName) require.Error(t, err) diff --git a/sidechain-orchestrator/sidechain/corenode/client.go b/sidechain-orchestrator/sidechain/corenode/client.go index 6da858ea42..068a073f30 100644 --- a/sidechain-orchestrator/sidechain/corenode/client.go +++ b/sidechain-orchestrator/sidechain/corenode/client.go @@ -8,6 +8,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "math" @@ -90,6 +91,13 @@ func (e *rpcError) Error() string { return fmt.Sprintf("%s RPC error %d: %s", e.chain, e.Code, e.Message) } +// IsMethodNotFound reports whether a node refused a call because it does not +// have that method, as an older release of a fork does. +func IsMethodNotFound(err error) bool { + var rpcErr *rpcError + return errors.As(err, &rpcErr) && rpcErr.Code == -32601 +} + // WalletCall sends one wallet RPC to the endpoint this fork keeps its wallet at. func (c *Client) WalletCall(ctx context.Context, method string, params any) (json.RawMessage, error) { return c.callAt(ctx, c.opts.WalletPath, method, params) diff --git a/sidechain-orchestrator/sidechain/freebank/bmm.go b/sidechain-orchestrator/sidechain/freebank/bmm.go new file mode 100644 index 0000000000..5ca9304118 --- /dev/null +++ b/sidechain-orchestrator/sidechain/freebank/bmm.go @@ -0,0 +1,115 @@ +package freebank + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "slices" + + "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/sidechain" + "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/sidechain/corenode" +) + +var _ sidechain.BMMNode = (*Client)(nil) + +// tooOld names the FreeBank release that first answers the BMM engine. Older +// nodes bid only through their own refreshbmm, so the engine gets this instead +// of a bare "method not found". +const tooOld = "this FreeBank node is too old for bidding from BitWindow (it needs v0.2.16 or later)" + +// call sends one BMM method, and turns an older node's "method not found" into +// a sentence a user can act on. +func call[T any](ctx context.Context, c *Client, method string, params any) (T, error) { + result, err := corenode.Decode[T](ctx, c.Client, method, params) + if corenode.IsMethodNotFound(err) { + var zero T + return zero, fmt.Errorf("%s: %w", tooOld, err) + } + return result, err +} + +// GetBlockTemplate returns the block the node builds next. Its critical hash is +// the block's merkle root, in internal byte order. +func (c *Client) GetBlockTemplate(ctx context.Context) (*sidechain.BlockTemplate, error) { + template, err := call[sidechain.BlockTemplate](ctx, c, "get_block_template", nil) + if err != nil { + return nil, err + } + return &template, nil +} + +// ConnectBlock hands a won block back to the node, with the mainchain block that +// carried the bid. +func (c *Client) ConnectBlock(ctx context.Context, block json.RawMessage, mainBlockHash string) (bool, error) { + return call[bool](ctx, c, "connect_block", []any{block, mainBlockHash}) +} + +// GetBmmInclusions returns the mainchain blocks that carry a critical hash. +func (c *Client) GetBmmInclusions(ctx context.Context, criticalHash string) ([]string, error) { + return call[[]string](ctx, c, "get_bmm_inclusions", []any{criticalHash}) +} + +// ChainHolds reports whether one of the depth blocks nearest the tip carries a +// critical hash. FreeBank bids with a block's merkle root, not its hash: the +// hash is fixed only once the winning mainchain block is written into the +// header. So the walk compares merkle roots. +func (c *Client) ChainHolds(ctx context.Context, criticalHash string, depth int) (bool, error) { + raw, err := hex.DecodeString(criticalHash) + if err != nil { + return false, fmt.Errorf("decode critical hash: %w", err) + } + // critical_hash is in internal byte order, and Core prints a merkle root in display order. + slices.Reverse(raw) + want := hex.EncodeToString(raw) + + next, err := corenode.Decode[string](ctx, c.Client, "getbestblockhash", nil) + if err != nil { + return false, err + } + for range depth { + header, err := corenode.Decode[struct { + MerkleRoot string `json:"merkleroot"` + PreviousBlockHash string `json:"previousblockhash"` + }](ctx, c.Client, "getblockheader", []any{next}) + if err != nil { + return false, err + } + if header.MerkleRoot == want { + return true, nil + } + if header.PreviousBlockHash == "" { + return false, nil + } + next = header.PreviousBlockHash + } + return false, nil +} + +// TemplateOnTip reports whether a block from GetBlockTemplate still builds on +// the chain tip. +func (c *Client) TemplateOnTip(ctx context.Context, block json.RawMessage) (bool, error) { + var template struct { + Hex string `json:"hex"` + } + if err := json.Unmarshal(block, &template); err != nil { + return false, fmt.Errorf("decode block: %w", err) + } + raw, err := hex.DecodeString(template.Hex) + if err != nil { + return false, fmt.Errorf("decode block hex: %w", err) + } + // The header opens with a 4-byte version, then the parent hash. + if len(raw) < 36 { + return false, fmt.Errorf("block of %d bytes has no parent hash", len(raw)) + } + parent := slices.Clone(raw[4:36]) + // The header holds internal byte order, and Core names a block in display order. + slices.Reverse(parent) + + tip, err := corenode.Decode[string](ctx, c.Client, "getbestblockhash", nil) + if err != nil { + return false, err + } + return hex.EncodeToString(parent) == tip, nil +} diff --git a/sidechain-orchestrator/sidechain/freebank/bmm_test.go b/sidechain-orchestrator/sidechain/freebank/bmm_test.go new file mode 100644 index 0000000000..d0b984cdfe --- /dev/null +++ b/sidechain-orchestrator/sidechain/freebank/bmm_test.go @@ -0,0 +1,151 @@ +package freebank + +import ( + "context" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeNode replies per method, and records the params each call carried. A +// method mapped to nil answers the way a node without it does. +func fakeNode(t *testing.T, results map[string]json.RawMessage, seen map[string]json.RawMessage) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Method string `json:"method"` + Params json.RawMessage `json:"params"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + if seen != nil { + seen[req.Method] = req.Params + } + result, ok := results[req.Method] + if !ok { + t.Fatalf("unexpected method: %s", req.Method) + } + w.Header().Set("Content-Type", "application/json") + if result == nil { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"result":null,"error":{"code":-32601,"message":"Method not found"}}`)) + return + } + _, _ = w.Write([]byte(`{"result":` + string(result) + `,"error":null}`)) + })) +} + +func clientFor(t *testing.T, srv *httptest.Server) *Client { + t.Helper() + parsed, err := url.Parse(srv.URL) + require.NoError(t, err) + host, portText, err := net.SplitHostPort(parsed.Host) + require.NoError(t, err) + port, err := strconv.Atoi(portText) + require.NoError(t, err) + + cookie := filepath.Join(t.TempDir(), ".cookie") + require.NoError(t, os.WriteFile(cookie, []byte("__cookie__:secret"), 0o600)) + return NewClient(host, port, cookie) +} + +const ( + internalRoot = "01" + "000000000000000000000000000000000000000000000000000000000000" + "ff" + displayRoot = "ff" + "000000000000000000000000000000000000000000000000000000000000" + "01" + tipHash = "aa" + "000000000000000000000000000000000000000000000000000000000000" + "aa" + parentHash = "bb" + "000000000000000000000000000000000000000000000000000000000000" + "bb" +) + +func TestBlockTemplateCarriesTheMerkleRoot(t *testing.T) { + srv := fakeNode(t, map[string]json.RawMessage{ + "get_block_template": json.RawMessage(`{"critical_hash":"` + internalRoot + `","block":{"prev_main_hash":"` + tipHash + `","hex":"00"},"fees_sats":1500}`), + }, nil) + defer srv.Close() + + template, err := clientFor(t, srv).GetBlockTemplate(context.Background()) + require.NoError(t, err) + assert.Equal(t, internalRoot, template.CriticalHash) + assert.Equal(t, int64(1500), template.FeesSats) + assert.JSONEq(t, `{"prev_main_hash":"`+tipHash+`","hex":"00"}`, string(template.Block)) +} + +// connect_block takes the block object as the template gave it, not a string, +// and the mainchain block in display order. +func TestConnectBlockSendsTheBlockBack(t *testing.T) { + seen := map[string]json.RawMessage{} + srv := fakeNode(t, map[string]json.RawMessage{"connect_block": json.RawMessage(`true`)}, seen) + defer srv.Close() + + block := json.RawMessage(`{"prev_main_hash":"` + tipHash + `","hex":"00"}`) + connected, err := clientFor(t, srv).ConnectBlock(context.Background(), block, tipHash) + require.NoError(t, err) + assert.True(t, connected) + assert.JSONEq(t, `[{"prev_main_hash":"`+tipHash+`","hex":"00"},"`+tipHash+`"]`, string(seen["connect_block"])) +} + +func TestBmmInclusionsReadNoneAsEmpty(t *testing.T) { + srv := fakeNode(t, map[string]json.RawMessage{"get_bmm_inclusions": json.RawMessage(`[]`)}, nil) + defer srv.Close() + + inclusions, err := clientFor(t, srv).GetBmmInclusions(context.Background(), internalRoot) + require.NoError(t, err) + assert.Empty(t, inclusions) +} + +// A node from before v0.2.16 has no BMM methods. The engine shows the user why. +func TestAnOlderNodeSaysWhichReleaseItNeeds(t *testing.T) { + srv := fakeNode(t, map[string]json.RawMessage{"get_block_template": nil}, nil) + defer srv.Close() + + _, err := clientFor(t, srv).GetBlockTemplate(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "v0.2.16") +} + +// FreeBank bids with a merkle root, so ChainHolds finds a block by its merkle +// root, reversed into the display order Core prints. +func TestChainHoldsFindsTheMerkleRoot(t *testing.T) { + srv := fakeNode(t, map[string]json.RawMessage{ + "getbestblockhash": json.RawMessage(`"` + tipHash + `"`), + "getblockheader": json.RawMessage(`{"merkleroot":"` + displayRoot + `","previousblockhash":"` + parentHash + `"}`), + }, nil) + defer srv.Close() + + held, err := clientFor(t, srv).ChainHolds(context.Background(), internalRoot, 11) + require.NoError(t, err) + assert.True(t, held) +} + +// A block hash equal to the critical hash is a coincidence, not a match: only +// the merkle root counts. +func TestChainHoldsIgnoresBlockHashes(t *testing.T) { + srv := fakeNode(t, map[string]json.RawMessage{ + "getbestblockhash": json.RawMessage(`"` + displayRoot + `"`), + "getblockheader": json.RawMessage(`{"merkleroot":"` + tipHash + `","previousblockhash":""}`), + }, nil) + defer srv.Close() + + held, err := clientFor(t, srv).ChainHolds(context.Background(), internalRoot, 11) + require.NoError(t, err) + assert.False(t, held) +} + +func TestTemplateOnTipReadsTheParentFromTheHeader(t *testing.T) { + // version (4 bytes) + parent in internal order: the reverse of parentHash + hexBlock := "20000000" + "bb" + "000000000000000000000000000000000000000000000000000000000000" + "bb" + "00" + for tip, want := range map[string]bool{parentHash: true, tipHash: false} { + srv := fakeNode(t, map[string]json.RawMessage{"getbestblockhash": json.RawMessage(`"` + tip + `"`)}, nil) + onTip, err := clientFor(t, srv).TemplateOnTip(context.Background(), json.RawMessage(`{"hex":"`+hexBlock+`"}`)) + srv.Close() + require.NoError(t, err) + assert.Equal(t, want, onTip, "tip %s", tip) + } +} diff --git a/sidechain-orchestrator/sidechain/freebank/freebank.go b/sidechain-orchestrator/sidechain/freebank/freebank.go index a137187c84..f4987de8c0 100644 --- a/sidechain-orchestrator/sidechain/freebank/freebank.go +++ b/sidechain-orchestrator/sidechain/freebank/freebank.go @@ -1,6 +1,7 @@ // Package freebank is what the FreeBank sidechain adds to a plain Bitcoin Core -// fork: the wallet dialect its node speaks, and the mainchain identity it pins -// itself to at boot. Everything Core answers comes from the shared Core client. +// fork: the wallet dialect its node speaks, the mainchain identity it pins +// itself to at boot, and the blind-merge-mining methods the BMM engine drives +// (bmm.go). Everything Core answers comes from the shared Core client. package freebank import ( diff --git a/sidechain-orchestrator/sidechain/freebank/freebank_test.go b/sidechain-orchestrator/sidechain/freebank/freebank_test.go index d7aef28079..308ae1de1f 100644 --- a/sidechain-orchestrator/sidechain/freebank/freebank_test.go +++ b/sidechain-orchestrator/sidechain/freebank/freebank_test.go @@ -92,13 +92,13 @@ func TestArgsReportAMainchainBehindTheFork(t *testing.T) { assert.Contains(t, err.Error(), "967680") } -// FreeBank blind merge mines with its own ticker and settles withdrawals on its +// The BMM engine drives FreeBank blocks, but FreeBank settles withdrawals on its // own paths, so the orchestrator reads that from the type. -func TestFreeBankDrivesNeitherBmmNorBundles(t *testing.T) { +func TestFreeBankDrivesBmmButProposesNoBundle(t *testing.T) { var node sidechain.Node = NewClient("127.0.0.1", 8454, "") _, drivesBMM := node.(sidechain.BMMNode) - assert.False(t, drivesBMM) + assert.True(t, drivesBMM) _, proposesBundles := node.(sidechain.WithdrawalNode) assert.False(t, proposesBundles) diff --git a/sidechain-orchestrator/sidechain/nodes/nodes_test.go b/sidechain-orchestrator/sidechain/nodes/nodes_test.go index 0a11c6c789..4ae50dc419 100644 --- a/sidechain-orchestrator/sidechain/nodes/nodes_test.go +++ b/sidechain-orchestrator/sidechain/nodes/nodes_test.go @@ -9,6 +9,7 @@ import ( "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/config" "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/sidechain" "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/sidechain/bbc" + "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/sidechain/freebank" "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/sidechain/zside" ) @@ -28,6 +29,7 @@ func TestEveryBmmSidechainTakesAnAuditedTransport(t *testing.T) { {name: "truthcoin"}, {name: "zside"}, {name: "bbc", core: true}, + {name: "freebank", core: true}, } for _, chain := range chains { @@ -39,7 +41,7 @@ func TestEveryBmmSidechainTakesAnAuditedTransport(t *testing.T) { require.True(t, ok, "the BMM engine drives this chain") switch bmm.(type) { - case *sidechain.JSONRPCProxy, *bbc.Client, *zside.Node: + case *sidechain.JSONRPCProxy, *bbc.Client, *freebank.Client, *zside.Node: default: assert.Failf(t, "unaudited transport", "%s takes %T", chain.name, bmm) } From a4b02955ca70eb0dcce4ed0b92468f5827697c3d Mon Sep 17 00:00:00 2001 From: mblowes Date: Sat, 26 Sep 2026 11:39:45 +1000 Subject: [PATCH 10/11] bitwindow: bid for FreeBank blocks, under a name FreeBank has no app of its own, so its bidding lives in BitWindow. A button beside FreeBank's settings opens a dialog with the engine's own BMM tab, bound to FreeBank through a lazily registered BMMProvider. Every block a bid wins carries the name the node writes into its coinbase (coinbasetag in freebank.conf, read at startup), and explorers credit the block to it. So the dialog asks for that name first, offered as bitwindow-xxxx to change, and bidding waits until one is saved. Saving edits that one line of the conf and restarts the node. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ES4QHkwW4ByYRrG4H39PHo --- bitwindow/lib/main.dart | 3 + bitwindow/lib/pages/sidechains_page.dart | 18 ++ bitwindow/lib/utils/freebank_conf.dart | 85 ++++++++++ .../lib/widgets/freebank_bid_dialog.dart | 160 ++++++++++++++++++ bitwindow/test/freebank_conf_test.dart | 61 +++++++ 5 files changed, 327 insertions(+) create mode 100644 bitwindow/lib/utils/freebank_conf.dart create mode 100644 bitwindow/lib/widgets/freebank_bid_dialog.dart create mode 100644 bitwindow/test/freebank_conf_test.dart diff --git a/bitwindow/lib/main.dart b/bitwindow/lib/main.dart index 1fda6efa33..2f50daf7f6 100644 --- a/bitwindow/lib/main.dart +++ b/bitwindow/lib/main.dart @@ -228,6 +228,9 @@ Future<(Directory, File, Logger)> init(String arguments) async { GetIt.I.registerSingleton(PhotonLive()); GetIt.I.registerSingleton(TruthcoinLive()); GetIt.I.registerSingleton(FreeBankLive()); + // FreeBank has no app of its own, so its BMM controls live in BitWindow. Lazy: + // the provider only starts watching the engine once the bid dialog opens. + GetIt.I.registerLazySingleton(() => BMMProvider(sidechainRPC: GetIt.I.get())); registerNodeMode(); diff --git a/bitwindow/lib/pages/sidechains_page.dart b/bitwindow/lib/pages/sidechains_page.dart index ed2ebbe0e1..aa11b707f4 100644 --- a/bitwindow/lib/pages/sidechains_page.dart +++ b/bitwindow/lib/pages/sidechains_page.dart @@ -9,6 +9,7 @@ import 'package:bitwindow/pages/sidechain_activation_management_page.dart'; import 'package:bitwindow/providers/sidechain_provider.dart'; import 'package:bitwindow/providers/transactions_provider.dart'; import 'package:bitwindow/widgets/fast_withdrawal_tab.dart'; +import 'package:bitwindow/widgets/freebank_bid_dialog.dart'; import 'package:bitwindow/widgets/starters_tab.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; @@ -615,6 +616,23 @@ class _SidechainActions extends StatelessWidget { ], _depositButton(context), const SizedBox(width: SailStyleValues.padding08), + if (viewModel.sidechainForSlot(slot) is FreeBank && settingsConnection != null) ...[ + SizedBox.square( + dimension: 32, + child: SailTooltip( + message: 'Bid for FreeBank blocks', + child: SailButton( + variant: ButtonVariant.icon, + icon: SailSVGAsset.iconTabBmm, + small: true, + onPressed: () async { + await showThemedDialog(context: context, builder: (context) => const FreeBankBidDialog()); + }, + ), + ), + ), + const SizedBox(width: SailStyleValues.padding08), + ], SizedBox.square( dimension: 32, child: settingsConnection == null diff --git a/bitwindow/lib/utils/freebank_conf.dart b/bitwindow/lib/utils/freebank_conf.dart new file mode 100644 index 0000000000..1dffe26bea --- /dev/null +++ b/bitwindow/lib/utils/freebank_conf.dart @@ -0,0 +1,85 @@ +/// The one line of freebank.conf that BitWindow edits: `coinbasetag=`, the name +/// a FreeBank node writes into the coinbase of every block it makes, so block +/// explorers can credit the block to it. freebankd reads it at startup only. +library; + +import 'dart:math'; + +const coinbaseTagKey = 'coinbasetag'; +const coinbaseTagMaxBytes = 64; + +/// The name the conf gives, or null when it sets none. The last line wins, as in +/// Core. +String? readCoinbaseTag(String conf) { + String? tag; + for (final line in conf.split('\n')) { + final trimmed = line.trim(); + if (trimmed.startsWith('$coinbaseTagKey=')) { + tag = trimmed.substring(coinbaseTagKey.length + 1).trim(); + } + } + return tag; +} + +/// Why freebankd would refuse this name, or null if it takes it. It mirrors the +/// node's own check (1 to 64 printable ASCII bytes), plus the two things the conf +/// file itself would bend: a `#` starts a comment, and quotes become part of the +/// name. +String? coinbaseTagProblem(String name) { + final tag = name.trim(); + if (tag.isEmpty) { + return 'Give a name.'; + } + if (tag.length > coinbaseTagMaxBytes) { + return 'Use at most $coinbaseTagMaxBytes characters.'; + } + for (final unit in tag.codeUnits) { + if (unit < 0x20 || unit > 0x7e) { + return 'Use plain letters, digits, punctuation and spaces.'; + } + } + if (tag.contains('#')) { + return 'Leave out #: it starts a comment in freebank.conf.'; + } + if (tag.startsWith('"') || tag.startsWith("'") || tag.endsWith('"') || tag.endsWith("'")) { + return 'Leave out the quotes: they would become part of the name.'; + } + return null; +} + +/// The conf with its name set to [name]: an existing `coinbasetag=` line is +/// replaced in place, and every other line is kept as it was. +String setCoinbaseTag(String conf, String name) { + final line = '$coinbaseTagKey=${name.trim()}'; + final lines = conf.isEmpty ? [] : conf.split('\n'); + final kept = []; + var replaced = false; + for (final l in lines) { + if (l.trim().startsWith('$coinbaseTagKey=')) { + if (!replaced) { + kept.add(line); + replaced = true; + } + continue; + } + kept.add(l); + } + if (!replaced) { + if (kept.isNotEmpty && kept.last.isEmpty) { + kept.insert(kept.length - 1, line); + } else { + kept.add(line); + } + } + final out = kept.join('\n'); + return out.endsWith('\n') ? out : '$out\n'; +} + +/// A name to offer a user who has none yet: unique enough to spot their own +/// blocks, it says where they came from, and it carries nothing personal. +String suggestCoinbaseTag([Random? random]) { + const letters = 'abcdefghjkmnpqrstuvwxyz23456789'; + final rng = random ?? Random.secure(); + final suffix = List.generate(4, (_) => letters[rng.nextInt(letters.length)]).join(); + return 'bitwindow-$suffix'; +} diff --git a/bitwindow/lib/widgets/freebank_bid_dialog.dart b/bitwindow/lib/widgets/freebank_bid_dialog.dart new file mode 100644 index 0000000000..eead27c59d --- /dev/null +++ b/bitwindow/lib/widgets/freebank_bid_dialog.dart @@ -0,0 +1,160 @@ +import 'dart:io'; + +import 'package:bitwindow/utils/freebank_conf.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:logger/logger.dart'; +import 'package:sail_ui/pages/sidechains/bmm_tab.dart'; +import 'package:sail_ui/sail_ui.dart'; + +/// Bidding for FreeBank blocks from BitWindow: the name the node writes into the +/// blocks it wins, then the BMM engine's own controls. FreeBank has no app of its +/// own, so BitWindow is where a user starts it. +class FreeBankBidDialog extends StatefulWidget { + const FreeBankBidDialog({super.key}); + + @override + State createState() => _FreeBankBidDialogState(); +} + +class _FreeBankBidDialogState extends State { + final FreeBankRPC _rpc = GetIt.I.get(); + final TextEditingController _name = TextEditingController(); + String? _saved; + String? _error; + bool _saving = false; + + bool get _hasName => _saved != null && _saved!.isNotEmpty; + + File get _conf => File(filePath([_rpc.binary.datadirNetwork(), 'freebank.conf'])); + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final conf = await _conf.exists() ? await _conf.readAsString() : ''; + final tag = readCoinbaseTag(conf); + if (!mounted) { + return; + } + setState(() { + _saved = tag; + // A name is required before the first bid, so offer one to change. + _name.text = (tag == null || tag.isEmpty) ? suggestCoinbaseTag() : tag; + }); + } catch (e) { + GetIt.I.get().w('FreeBank: could not read freebank.conf: $e'); + } + } + + Future _save() async { + final problem = coinbaseTagProblem(_name.text); + if (problem != null) { + setState(() => _error = problem); + return; + } + setState(() { + _error = null; + _saving = true; + }); + try { + final conf = await _conf.exists() ? await _conf.readAsString() : ''; + await _conf.parent.create(recursive: true); + await _conf.writeAsString(setCoinbaseTag(conf, _name.text)); + // freebankd reads the name at startup only. + await GetIt.I.get().restart(_rpc.binary); + if (!mounted) { + return; + } + setState(() => _saved = _name.text.trim()); + } catch (e) { + if (!mounted) { + return; + } + setState(() => _error = 'Could not save the name: $e'); + } finally { + if (mounted) { + setState(() => _saving = false); + } + } + } + + @override + void dispose() { + _name.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = SailTheme.of(context); + return Dialog( + backgroundColor: Colors.transparent, + child: Container( + width: 760, + height: 720, + padding: const EdgeInsets.all(SailStyleValues.padding25), + decoration: BoxDecoration( + color: theme.colors.backgroundSecondary, + borderRadius: SailStyleValues.borderRadius, + ), + child: SailColumn( + spacing: SailStyleValues.padding16, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SailText.primary20('Bid for FreeBank blocks'), + SailText.secondary13( + 'For each eCash block, your node offers a small fee to have its next FreeBank block included. ' + 'A bid is paid from your BitWindow wallet, and only when it wins.', + ), + SailText.primary13('Name on your blocks', bold: true), + SailRow( + spacing: SailStyleValues.padding08, + children: [ + Expanded( + child: SailTextField( + controller: _name, + hintText: "Your name, or your pool's name", + enabled: !_saving, + ), + ), + SailButton( + label: 'Save', + loading: _saving, + onPressed: _save, + ), + ], + ), + SailText.secondary12( + _error ?? + (!_hasName + ? 'Blocks you win show this name on the explorer. Change it if you like, then save. ' + 'Saving restarts your FreeBank node.' + : 'Blocks you win show "$_saved" on the explorer. Saving a new name restarts your FreeBank node.'), + color: _error == null ? null : theme.colors.error, + ), + Expanded( + // Every block a bid wins carries the name, so bidding waits for one. + child: _hasName + ? const BMMTab() + : Stack( + children: [ + const IgnorePointer(child: Opacity(opacity: 0.35, child: BMMTab())), + Center(child: SailText.primary15('Save a name above to start bidding.')), + ], + ), + ), + Align( + alignment: Alignment.centerRight, + child: SailButton(label: 'Close', onPressed: () async => Navigator.of(context).pop()), + ), + ], + ), + ), + ); + } +} diff --git a/bitwindow/test/freebank_conf_test.dart b/bitwindow/test/freebank_conf_test.dart new file mode 100644 index 0000000000..166484c7f5 --- /dev/null +++ b/bitwindow/test/freebank_conf_test.dart @@ -0,0 +1,61 @@ +import 'dart:math'; + +import 'package:bitwindow/utils/freebank_conf.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('readCoinbaseTag', () { + test('reads the name, and the last line wins as in Core', () { + expect(readCoinbaseTag('server=1\ncoinbasetag=alice\n'), 'alice'); + expect(readCoinbaseTag('coinbasetag=alice\ncoinbasetag=bob\n'), 'bob'); + }); + + test('reads no name from a conf that sets none', () { + expect(readCoinbaseTag(''), isNull); + expect(readCoinbaseTag('server=1\n# coinbasetag=old\n'), isNull); + }); + }); + + group('coinbaseTagProblem', () { + test('takes what freebankd takes', () { + expect(coinbaseTagProblem('alice'), isNull); + expect(coinbaseTagProblem('Pool One (EU)'), isNull); + expect(coinbaseTagProblem('x' * 64), isNull); + }); + + test('refuses what freebankd or the conf file would refuse or bend', () { + expect(coinbaseTagProblem(' '), isNotNull); + expect(coinbaseTagProblem('x' * 65), isNotNull); + expect(coinbaseTagProblem('café'), isNotNull); + expect(coinbaseTagProblem('a#b'), isNotNull); + expect(coinbaseTagProblem('"alice"'), isNotNull); + }); + }); + + group('setCoinbaseTag', () { + test('adds the line to a conf without one, keeping the rest', () { + expect(setCoinbaseTag('server=1\n', 'alice'), 'server=1\ncoinbasetag=alice\n'); + expect(setCoinbaseTag('', 'alice'), 'coinbasetag=alice\n'); + }); + + test('replaces the name in place and drops duplicates', () { + expect( + setCoinbaseTag('a=1\ncoinbasetag=old\nb=2\ncoinbasetag=older\n', ' new '), + 'a=1\ncoinbasetag=new\nb=2\n', + ); + }); + + test('keeps a commented-out name as a comment', () { + expect(setCoinbaseTag('# coinbasetag=x\n', 'y'), '# coinbasetag=x\ncoinbasetag=y\n'); + }); + }); + + test('suggests a name freebankd takes, different each time', () { + final names = {for (var seed = 0; seed < 20; seed++) suggestCoinbaseTag(Random(seed))}; + for (final name in names) { + expect(name, matches(RegExp(r'^bitwindow-[a-z2-9]{4}$'))); + expect(coinbaseTagProblem(name), isNull); + } + expect(names.length, greaterThan(15)); + }); +} From a0af19c27ea578f797c31a769878d39cbfd7b3a1 Mon Sep 17 00:00:00 2001 From: mblowes Date: Sat, 26 Sep 2026 14:19:34 +1000 Subject: [PATCH 11/11] orchestrator: retry a FreeBank template on the same tip, and leave bidding to the engine freebankd answers get_block_template with -40 while its own block for the current mainchain tip is still arriving (for up to 60 s) or while it reindexes, and with -10 or -28 while it starts. A template error made the engine skip the whole mainchain block, so a late side block cost a round. The FreeBank client now reports those codes as sidechain.ErrNotReady, and CreateBid maps that to FailedPrecondition, which the engine already retries on the same tip every tick. Other chains are unaffected. The engine is the only bidder for a node BitWindow drives, so the node is started with -bmmbidder=engine: from v0.2.16 freebankd then makes no bids of its own. Older nodes ignore unknown options. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ES4QHkwW4ByYRrG4H39PHo --- sidechain-orchestrator/api/bmm_handler.go | 5 +++++ .../sidechain/corenode/client.go | 10 ++++++++++ .../sidechain/freebank/bmm.go | 9 +++++++++ .../sidechain/freebank/bmm_test.go | 20 +++++++++++++++++++ .../sidechain/freebank/freebank.go | 3 +++ .../sidechain/freebank/freebank_test.go | 1 + sidechain-orchestrator/sidechain/proxy.go | 7 +++++++ 7 files changed, 55 insertions(+) diff --git a/sidechain-orchestrator/api/bmm_handler.go b/sidechain-orchestrator/api/bmm_handler.go index df3bbd7d15..c28a39957c 100644 --- a/sidechain-orchestrator/api/bmm_handler.go +++ b/sidechain-orchestrator/api/bmm_handler.go @@ -4,6 +4,7 @@ import ( "context" "encoding/hex" "encoding/json" + "errors" "fmt" "math" "slices" @@ -414,6 +415,10 @@ func (h *BMMHandler) CreateBid( } template, err := proxy.GetBlockTemplate(ctx) + if errors.Is(err, sidechain.ErrNotReady) { + // FailedPrecondition makes the engine ask again on this tip. + return nil, connect.NewError(connect.CodeFailedPrecondition, fmt.Errorf("get block template: %w", err)) + } if err != nil { return nil, connect.NewError(connect.CodeUnavailable, fmt.Errorf("get block template: %w", err)) } diff --git a/sidechain-orchestrator/sidechain/corenode/client.go b/sidechain-orchestrator/sidechain/corenode/client.go index 068a073f30..45de690ed6 100644 --- a/sidechain-orchestrator/sidechain/corenode/client.go +++ b/sidechain-orchestrator/sidechain/corenode/client.go @@ -91,6 +91,16 @@ func (e *rpcError) Error() string { return fmt.Sprintf("%s RPC error %d: %s", e.chain, e.Code, e.Message) } +// RPCErrorCode returns the JSON-RPC error code a node answered with, if err +// carries one. +func RPCErrorCode(err error) (int, bool) { + var rpcErr *rpcError + if errors.As(err, &rpcErr) { + return rpcErr.Code, true + } + return 0, false +} + // IsMethodNotFound reports whether a node refused a call because it does not // have that method, as an older release of a fork does. func IsMethodNotFound(err error) bool { diff --git a/sidechain-orchestrator/sidechain/freebank/bmm.go b/sidechain-orchestrator/sidechain/freebank/bmm.go index 5ca9304118..68c37fe307 100644 --- a/sidechain-orchestrator/sidechain/freebank/bmm.go +++ b/sidechain-orchestrator/sidechain/freebank/bmm.go @@ -18,6 +18,11 @@ var _ sidechain.BMMNode = (*Client)(nil) // of a bare "method not found". const tooOld = "this FreeBank node is too old for bidding from BitWindow (it needs v0.2.16 or later)" +// notReadyCodes are the answers with which freebankd says "not on this tip yet, +// ask again": -40 while its own block for the tip is arriving or it is +// reindexing, -10 and -28 while it starts up. +var notReadyCodes = map[int]bool{-40: true, -10: true, -28: true} + // call sends one BMM method, and turns an older node's "method not found" into // a sentence a user can act on. func call[T any](ctx context.Context, c *Client, method string, params any) (T, error) { @@ -26,6 +31,10 @@ func call[T any](ctx context.Context, c *Client, method string, params any) (T, var zero T return zero, fmt.Errorf("%s: %w", tooOld, err) } + if code, ok := corenode.RPCErrorCode(err); ok && notReadyCodes[code] { + var zero T + return zero, fmt.Errorf("%w: %w", sidechain.ErrNotReady, err) + } return result, err } diff --git a/sidechain-orchestrator/sidechain/freebank/bmm_test.go b/sidechain-orchestrator/sidechain/freebank/bmm_test.go index d0b984cdfe..07f8c4d84a 100644 --- a/sidechain-orchestrator/sidechain/freebank/bmm_test.go +++ b/sidechain-orchestrator/sidechain/freebank/bmm_test.go @@ -3,6 +3,7 @@ package freebank import ( "context" "encoding/json" + "errors" "net" "net/http" "net/http/httptest" @@ -14,6 +15,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/sidechain" ) // fakeNode replies per method, and records the params each call carried. A @@ -39,6 +42,11 @@ func fakeNode(t *testing.T, results map[string]json.RawMessage, seen map[string] _, _ = w.Write([]byte(`{"result":null,"error":{"code":-32601,"message":"Method not found"}}`)) return } + if string(result) == "notready" { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"result":null,"error":{"code":-40,"message":"side block for this eCash tip pending"}}`)) + return + } _, _ = w.Write([]byte(`{"result":` + string(result) + `,"error":null}`)) })) } @@ -149,3 +157,15 @@ func TestTemplateOnTipReadsTheParentFromTheHeader(t *testing.T) { assert.Equal(t, want, onTip, "tip %s", tip) } } + +// freebankd's "not on this tip yet" answers become sidechain.ErrNotReady, which +// the handler turns into a same-tip retry instead of skipping the block. +func TestANodeNotReadyAsksForARetryOnTheSameTip(t *testing.T) { + srv := fakeNode(t, map[string]json.RawMessage{"get_block_template": json.RawMessage("notready")}, nil) + defer srv.Close() + + _, err := clientFor(t, srv).GetBlockTemplate(context.Background()) + require.Error(t, err) + assert.True(t, errors.Is(err, sidechain.ErrNotReady)) + assert.Contains(t, err.Error(), "pending") +} diff --git a/sidechain-orchestrator/sidechain/freebank/freebank.go b/sidechain-orchestrator/sidechain/freebank/freebank.go index f4987de8c0..b6dd9c9ff0 100644 --- a/sidechain-orchestrator/sidechain/freebank/freebank.go +++ b/sidechain-orchestrator/sidechain/freebank/freebank.go @@ -56,6 +56,9 @@ func (c *Client) Args(ctx context.Context, host sidechain.Host) ([]string, error "-mainchainrest=" + rest, "-mainchainchain=main", fmt.Sprintf("-mainchainblockpin=%d:%s", height, hash), + // BitWindow's BMM engine bids for this node, so the node makes no bids of + // its own. Nodes before v0.2.16 ignore the flag. + "-bmmbidder=engine", } // An absent grpcurl is not fatal: the node looks for one on PATH. if grpcurl := host.ToolPath("grpcurl"); grpcurl != "" { diff --git a/sidechain-orchestrator/sidechain/freebank/freebank_test.go b/sidechain-orchestrator/sidechain/freebank/freebank_test.go index 308ae1de1f..7982a24c6d 100644 --- a/sidechain-orchestrator/sidechain/freebank/freebank_test.go +++ b/sidechain-orchestrator/sidechain/freebank/freebank_test.go @@ -51,6 +51,7 @@ func TestArgsPinTheForkBlock(t *testing.T) { "-mainchainrest=127.0.0.1:18302", "-mainchainchain=main", "-mainchainblockpin=967680:00000000000000030101ba5cfea54b22becc79f95dc6040beb76e01dd9d04042", + "-bmmbidder=engine", }, args) } diff --git a/sidechain-orchestrator/sidechain/proxy.go b/sidechain-orchestrator/sidechain/proxy.go index 266c343bfa..7b34d59b49 100644 --- a/sidechain-orchestrator/sidechain/proxy.go +++ b/sidechain-orchestrator/sidechain/proxy.go @@ -3,6 +3,7 @@ package sidechain import ( "context" "encoding/json" + "errors" "github.com/btcsuite/btcd/chaincfg" ) @@ -16,6 +17,12 @@ type BlockTemplate struct { FeesSats int64 `json:"fees_sats"` } +// ErrNotReady is what a node answers when it cannot build on the current +// mainchain tip yet but will shortly, for example while its own block for that +// tip is still arriving. The engine asks again on the same tip rather than +// skipping the block. +var ErrNotReady = errors.New("sidechain not ready for this mainchain tip yet") + // Node is every operation the orchestrator asks of a sidechain, whatever the // chain is built on. A new sidechain satisfies this and nothing more. type Node interface {