diff --git a/bitwindow/lib/main.dart b/bitwindow/lib/main.dart index 3758db61c6..2f50daf7f6 100644 --- a/bitwindow/lib/main.dart +++ b/bitwindow/lib/main.dart @@ -227,6 +227,10 @@ Future<(Directory, File, Logger)> init(String arguments) async { GetIt.I.registerSingleton(CoinShiftLive()); 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(); @@ -257,6 +261,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..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 @@ -736,6 +754,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 +917,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 +969,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 +1666,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/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/deposit_modal_test.dart b/bitwindow/test/deposit_modal_test.dart index d33166bfb7..75235fd471 100644 --- a/bitwindow/test/deposit_modal_test.dart +++ b/bitwindow/test/deposit_modal_test.dart @@ -112,6 +112,26 @@ void main() { expect(field.controller.text, 's9_pastedaddress_abc123'); }); + testWidgets('a running FreeBank leaves the address field open for a paste and says why', (tester) async { + GetIt.I.registerSingleton(FreeBankLive()..connected = true); + await GetIt.I.unregister(); + GetIt.I.registerSingleton( + BinaryProvider.test(appDir: Directory.systemTemp, binaries: [FreeBank()]), + ); + + addTearDown(() => tester.pumpWidget(const SizedBox())); + await tester.pumpSailPage(const DepositModal(slot: 130, sidechainName: 'FreeBank')); + await tester.pump(); + + final field = tester.widget(find.byType(SailTextField).first); + expect(field.readOnly, isFalse); + expect(field.controller.text, isEmpty); + expect( + find.text('Unsupported operation: fetching a FreeBank address. Paste one from your FreeBank wallet.'), + findsOneWidget, + ); + }); + testWidgets('a pasted address with an amount arms the deposit button', (tester) async { await pumpModal(tester); diff --git a/bitwindow/test/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)); + }); +} diff --git a/bitwindow/test/sidechains_table_test.dart b/bitwindow/test/sidechains_table_test.dart index 63395d15bd..671cfa140a 100644 --- a/bitwindow/test/sidechains_table_test.dart +++ b/bitwindow/test/sidechains_table_test.dart @@ -104,6 +104,24 @@ class _CoinShiftRPC extends MockCoinShiftRPC { Future<(double, double)> balance() async => wallet; } +class _Orchestrator implements OrchestratorRPC { + final balanceReads = []; + GetSidechainBalanceResponse balance = GetSidechainBalanceResponse(); + + @override + Future getSidechainBalance(BinaryType sidechain) async { + balanceReads.add(sidechain); + return balance; + } + + @override + Future getBinaryVersion(String name, {bool forceBackend = false}) async => + GetBinaryVersionResponse(version: '$name 1.0'); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + Thunder _thunder({bool downloaded = true, bool updateAvailable = false}) { final base = Thunder(); return base.copyWith( @@ -116,6 +134,22 @@ Thunder _thunder({bool downloaded = true, bool updateAvailable = false}) { ); } +FreeBank _freeBank({bool downloaded = true}) { + final base = FreeBank(); + return base.copyWith( + metadata: base.metadata.copyWith( + remoteTimestamp: null, + downloadedTimestamp: downloaded ? DateTime(2026, 1) : null, + binaryPath: downloaded ? File('/tmp/freebankd') : null, + updateable: true, + ), + ); +} + +final Finder _settingsButton = find.byWidgetPredicate( + (widget) => widget is SailButton && widget.icon == SailSVGAsset.settings, +); + Finder _button(String label) => find.byWidgetPredicate((widget) => widget is SailButton && widget.label == label); final Finder _updateButton = find.byWidgetPredicate( @@ -268,6 +302,89 @@ void main() { expect(find.text('—'), findsOneWidget); }); + // FreeBank reads all its state through the orchestrator. + group('FreeBank', () { + late _Orchestrator orchestrator; + late FreeBankRPC freeBankRPC; + + Future setUpFreeBank({bool downloaded = true}) async { + GetIt.I.registerSingleton(_Binaries([_freeBank(downloaded: downloaded)])); + orchestrator = _Orchestrator(); + GetIt.I.registerSingleton(orchestrator); + freeBankRPC = FreeBankLive(); + GetIt.I.registerSingleton(freeBankRPC); + await GetIt.I.unregister(); + balances = BalanceProvider(connections: [thunderRPC, coinShiftRPC, freeBankRPC]); + GetIt.I.registerSingleton(balances); + GetIt.I.get().sidechains[130] = SidechainOverview( + ListSidechainsResponse_Sidechain(title: 'FreeBank', slot: 130, balanceSatoshi: Int64(300000000)), + [], + [], + ); + } + + testWidgets('the row has a settings button that opens the chain settings', (tester) async { + await setUpFreeBank(); + await pumpTable(tester); + + expect(_settingsButton, findsOneWidget); + await tester.tap(_settingsButton); + await tester.pump(kDoubleTapTimeout); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.byType(ChainSettingsModal), findsOneWidget); + expect(find.descendant(of: find.byType(ChainSettingsModal), matching: find.text('FreeBank')), findsOneWidget); + expect(find.textContaining('version freebank 1.0'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('a stopped FreeBank offers Start and reads no balance', (tester) async { + await setUpFreeBank(); + await balances.fetch(); + await pumpTable(tester); + + expect(_buttonWidget(tester, 'Start').variant, ButtonVariant.primary); + expect(_button('Stop'), findsNothing); + expect(_settingsButton, findsOneWidget); + expect(orchestrator.balanceReads, isEmpty); + expect(tester.takeException(), isNull); + }); + + testWidgets('a FreeBank that is not installed offers Download', (tester) async { + await setUpFreeBank(downloaded: false); + await pumpTable(tester); + + expect(_buttonWidget(tester, 'Download').variant, ButtonVariant.primary); + expect(_settingsButton, findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('a running FreeBank offers Stop and shows the balance the orchestrator reads', (tester) async { + await setUpFreeBank(); + orchestrator.balance = GetSidechainBalanceResponse(confirmedSats: Int64(250000000)); + freeBankRPC.connected = true; + await balances.fetch(); + await pumpTable(tester); + + expect(_buttonWidget(tester, 'Stop').variant, ButtonVariant.outline); + expect(_tooltip('Connected'), findsOneWidget); + expect(orchestrator.balanceReads, contains(BinaryType.BINARY_TYPE_FREEBANK)); + expect(find.text(GetIt.I.get().formatBTC(2.5)), findsOneWidget); + }); + + testWidgets('a running FreeBank behind its headers shows the sync bar', (tester) async { + await setUpFreeBank(); + freeBankRPC.connected = true; + sync.sidechains = { + SidechainType.SIDECHAIN_TYPE_FREEBANK: SyncInfo(progressCurrent: 100, progressGoal: 294, lastBlockAt: null), + }; + await pumpTable(tester); + + expect(find.text('34%'), findsOneWidget); + expect(_button('Stop'), findsOneWidget); + }); + }); + testWidgets('a chain that is not downloaded offers a primary Download', (tester) async { setUpChain(_thunder(downloaded: false)); await pumpTable(tester); diff --git a/sidechain-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/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..42fe8ff66f 100644 --- a/sidechain-orchestrator/api/sidechain_node_test.go +++ b/sidechain-orchestrator/api/sidechain_node_test.go @@ -57,18 +57,19 @@ 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} +// 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) - bmm, err := bmmNode(cfg, config.NetworkRegtest) + _, err = bmmNode(cfg, config.NetworkRegtest) require.NoError(t, err) - assert.NotNil(t, bmm) - _, 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..1c1d33df34 100644 --- a/sidechain-orchestrator/config.go +++ b/sidechain-orchestrator/config.go @@ -48,6 +48,12 @@ type BinaryConfig struct { 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 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. FlutterFrontendDir map[string]string @@ -82,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/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/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/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 f24a944ee6..500a2f79e2 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) @@ -1999,6 +2005,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 +2149,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 @@ -2342,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 } @@ -2360,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 { @@ -3390,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/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/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..45de690ed6 100644 --- a/sidechain-orchestrator/sidechain/corenode/client.go +++ b/sidechain-orchestrator/sidechain/corenode/client.go @@ -1,12 +1,14 @@ // 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 ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "math" @@ -20,10 +22,29 @@ 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, 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 + // 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 +55,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 +91,26 @@ 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. +// 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 { + 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, "/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 +205,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 +221,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 +313,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, diff --git a/sidechain-orchestrator/sidechain/freebank/bmm.go b/sidechain-orchestrator/sidechain/freebank/bmm.go new file mode 100644 index 0000000000..68c37fe307 --- /dev/null +++ b/sidechain-orchestrator/sidechain/freebank/bmm.go @@ -0,0 +1,124 @@ +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)" + +// 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) { + 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) + } + if code, ok := corenode.RPCErrorCode(err); ok && notReadyCodes[code] { + var zero T + return zero, fmt.Errorf("%w: %w", sidechain.ErrNotReady, 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..07f8c4d84a --- /dev/null +++ b/sidechain-orchestrator/sidechain/freebank/bmm_test.go @@ -0,0 +1,171 @@ +package freebank + +import ( + "context" + "encoding/json" + "errors" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strconv" + "testing" + + "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 +// 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 + } + 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}`)) + })) +} + +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) + } +} + +// 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 new file mode 100644 index 0000000000..b6dd9c9ff0 --- /dev/null +++ b/sidechain-orchestrator/sidechain/freebank/freebank.go @@ -0,0 +1,68 @@ +// Package freebank is what the FreeBank sidechain adds to a plain Bitcoin Core +// 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 ( + "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), + // 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 != "" { + 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..7982a24c6d --- /dev/null +++ b/sidechain-orchestrator/sidechain/freebank/freebank_test.go @@ -0,0 +1,106 @@ +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", + "-bmmbidder=engine", + }, 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") +} + +// The BMM engine drives FreeBank blocks, but FreeBank settles withdrawals on its +// own paths, so the orchestrator reads that from the type. +func TestFreeBankDrivesBmmButProposesNoBundle(t *testing.T) { + var node sidechain.Node = NewClient("127.0.0.1", 8454, "") + + _, drivesBMM := node.(sidechain.BMMNode) + assert.True(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..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,7 +29,7 @@ func TestEveryBmmSidechainTakesAnAuditedTransport(t *testing.T) { {name: "truthcoin"}, {name: "zside"}, {name: "bbc", core: true}, - {name: "freebank"}, + {name: "freebank", core: true}, } for _, chain := range chains { @@ -40,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) } 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 { 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()) +} 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" + } + } + } + } + } +} 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 [], diff --git a/sidechain_core/lib/providers/backend_state_provider.dart b/sidechain_core/lib/providers/backend_state_provider.dart index 8abb70d9f7..8f792a599c 100644 --- a/sidechain_core/lib/providers/backend_state_provider.dart +++ b/sidechain_core/lib/providers/backend_state_provider.dart @@ -14,6 +14,7 @@ import 'package:sidechain_core/rpcs/coinshift_rpc.dart'; import 'package:sidechain_core/rpcs/enforcer_rpc.dart'; import 'package:sidechain_core/rpcs/bitcoind_connection.dart'; import 'package:sidechain_core/env.dart'; +import 'package:sidechain_core/rpcs/freebank_rpc.dart'; import 'package:sidechain_core/rpcs/orchestrator_rpc.dart'; import 'package:sidechain_core/rpcs/photon_rpc.dart'; import 'package:sidechain_core/rpcs/thunder_rpc.dart'; @@ -219,6 +220,7 @@ class BackendStateProvider extends ChangeNotifier { 'truthcoin' => GetIt.I.isRegistered() ? GetIt.I.get() : null, 'photon' => GetIt.I.isRegistered() ? GetIt.I.get() : null, 'coinshift' => GetIt.I.isRegistered() ? GetIt.I.get() : null, + 'freebank' => GetIt.I.isRegistered() ? GetIt.I.get() : null, _ => null, }; } diff --git a/sidechain_core/lib/providers/binaries/binary_provider.dart b/sidechain_core/lib/providers/binaries/binary_provider.dart index cb6c1f0dc0..f658c46239 100644 --- a/sidechain_core/lib/providers/binaries/binary_provider.dart +++ b/sidechain_core/lib/providers/binaries/binary_provider.dart @@ -654,6 +654,7 @@ class BinaryProvider extends ChangeNotifier { var b when b is BitAssets => _getRegistered(), var b when b is ZSide => _getRegistered(), var b when b is CoinShift => _getRegistered(), + var b when b is FreeBank => _getRegistered(), _ => null, }; } diff --git a/sidechain_core/lib/rpcs/freebank_rpc.dart b/sidechain_core/lib/rpcs/freebank_rpc.dart new file mode 100644 index 0000000000..8c016229ee --- /dev/null +++ b/sidechain_core/lib/rpcs/freebank_rpc.dart @@ -0,0 +1,120 @@ +import 'package:collection/collection.dart'; +import 'package:get_it/get_it.dart'; +import 'package:sidechain_core/bitcoin.dart' as bitcoin; +import 'package:sidechain_core/classes/rpc_connection.dart'; +import 'package:sidechain_core/gen/orchestrator/v1/orchestrator.pb.dart'; +import 'package:sidechain_core/models/core_transaction.dart'; +import 'package:sidechain_core/rpcs/orchestrator_rpc.dart'; +import 'package:sidechain_core/rpcs/rpc_sidechain.dart'; +import 'package:sidechain_core/rpcs/thunder_utxo.dart'; + +/// API to the FreeBank node, read through the orchestrator's generic routes. +abstract class FreeBankRPC extends SidechainRPC { + FreeBankRPC({required super.binaryType}); +} + +class FreeBankLive extends FreeBankRPC { + FreeBankLive() : super(binaryType: BinaryType.BINARY_TYPE_FREEBANK); + + OrchestratorRPC get _orchestrator => GetIt.I.get(); + + /// The orchestrator builds the node's arguments. + @override + Future> binaryArgs() async => []; + + @override + Future stopRPC() async { + await _orchestrator.stopBinary('freebank'); + } + + @override + Future<(double, double)> balance() async { + final resp = await _orchestrator.getSidechainBalance(binaryType); + return ( + bitcoin.satoshiToBTC(resp.confirmedSats.toInt()), + bitcoin.satoshiToBTC(resp.pendingSats.toInt()), + ); + } + + @override + Future getBlockCount() async => (await _sync()).blocks; + + @override + Future getBlockchainInfo() async { + final sync = await _sync(); + return BlockchainInfo( + chain: '', + blocks: sync.blocks, + headers: sync.headers, + bestBlockHash: '', + difficulty: 0, + time: sync.time.toInt(), + medianTime: 0, + verificationProgress: sync.headers > 0 ? sync.blocks / sync.headers : 0, + initialBlockDownload: sync.blocks < sync.headers, + chainWork: '', + sizeOnDisk: 0, + pruned: false, + warnings: const [], + ); + } + + /// The node's sync state as the orchestrator last read it. + Future _sync() async { + final status = await _orchestrator.getSyncStatus(); + final entry = status.sidechains.firstWhereOrNull((s) => s.type == SidechainType.SIDECHAIN_TYPE_FREEBANK); + if (entry == null) { + throw StateError('the orchestrator reports no FreeBank sync status'); + } + if (entry.sync.error.isNotEmpty) { + throw StateError(entry.sync.error); + } + return entry.sync; + } + + // ── Not served through the orchestrator ──────────────────────────────────── + + @override + Future getDepositAddress() async => + throw UnsupportedError('fetching a FreeBank address. Paste one from your FreeBank wallet.'); + + @override + Future getSideAddress() async => throw _unsupported('addresses'); + + @override + Future sideSend(String address, double amount, bool subtractFeeFromAmount) async => + throw _unsupported('sends'); + + @override + Future sideEstimateFee() async => throw _unsupported('fees'); + + @override + Future> listUTXOs() async => throw _unsupported('coins'); + + @override + Future> listAllUTXOs() async => throw _unsupported('coins'); + + @override + Future> listTransactions() async => throw _unsupported('transactions'); + + @override + Future mine(int feeSats) async => throw _unsupported('mining'); + + @override + Future getPendingWithdrawalBundle() async => throw _unsupported('withdrawal bundles'); + + @override + Future getLatestFailedWithdrawalBundleHeight() async => throw _unsupported('withdrawal bundles'); + + @override + Future withdraw(String address, int amountSats, int sidechainFeeSats, int mainchainFeeSats) async => + throw _unsupported('withdrawals'); + + @override + Future callRAW(String method, [List? params]) async => throw _unsupported('raw calls'); + + @override + List getMethods() => const []; + + UnsupportedError _unsupported(String what) => UnsupportedError('FreeBank $what through the orchestrator'); +} diff --git a/sidechain_core/lib/sidechain_core.dart b/sidechain_core/lib/sidechain_core.dart index 38c14c6aa1..683f67f4f6 100644 --- a/sidechain_core/lib/sidechain_core.dart +++ b/sidechain_core/lib/sidechain_core.dart @@ -176,6 +176,7 @@ export 'rpcs/bitnames_rpc.dart'; export 'rpcs/bitwindow_api.dart'; export 'rpcs/coinshift_rpc.dart'; export 'rpcs/enforcer_rpc.dart'; +export 'rpcs/freebank_rpc.dart'; export 'rpcs/keepalive_http_client.dart'; export 'rpcs/orchestrator_multisig_lounge_rpc.dart'; export 'rpcs/orchestrator_rpc.dart'; diff --git a/sidechain_core/test/freebank_rpc_test.dart b/sidechain_core/test/freebank_rpc_test.dart new file mode 100644 index 0000000000..c6cbe559a4 --- /dev/null +++ b/sidechain_core/test/freebank_rpc_test.dart @@ -0,0 +1,152 @@ +import 'dart:io'; + +import 'package:fixnum/fixnum.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:logger/logger.dart'; +import 'package:sidechain_core/sidechain_core.dart'; + +class _FakeOrchestrator implements OrchestratorRPC { + final balanceReads = []; + final stops = []; + GetSidechainBalanceResponse balance = GetSidechainBalanceResponse(); + GetSyncStatusResponse syncStatus = GetSyncStatusResponse(); + + @override + Future getSidechainBalance(BinaryType sidechain) async { + balanceReads.add(sidechain); + return balance; + } + + @override + Future getSyncStatus() async => syncStatus; + + @override + Future stopBinary(String name, {bool force = false, bool forceBackend = false}) async { + stops.add(name); + return StopBinaryResponse(); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +SidechainStatus _status(SidechainType type, {int blocks = 0, int headers = 0, String error = ''}) => SidechainStatus( + type: type, + sync: ChainSync(blocks: blocks, headers: headers, error: error), +); + +void main() { + late _FakeOrchestrator orchestrator; + late FreeBankRPC rpc; + + setUp(() async { + await GetIt.I.reset(); + GetIt.I.registerSingleton(Logger(level: Level.off)); + orchestrator = _FakeOrchestrator(); + GetIt.I.registerSingleton(orchestrator); + rpc = FreeBankLive(); + GetIt.I.registerSingleton(rpc); + }); + + tearDown(() async { + await GetIt.I.reset(); + }); + + test('the balance is the orchestrator FreeBank balance', () async { + orchestrator.balance = GetSidechainBalanceResponse( + confirmedSats: Int64(250000000), + pendingSats: Int64(1000000), + ); + + expect(await rpc.balance(), (2.5, 0.01)); + expect(orchestrator.balanceReads, [BinaryType.BINARY_TYPE_FREEBANK]); + }); + + test('the height is the FreeBank entry of the orchestrator sync status', () async { + orchestrator.syncStatus = GetSyncStatusResponse( + sidechains: [ + _status(SidechainType.SIDECHAIN_TYPE_THUNDER, blocks: 7, headers: 7), + _status(SidechainType.SIDECHAIN_TYPE_FREEBANK, blocks: 80, headers: 90), + ], + ); + + expect(await rpc.getBlockCount(), 80); + final info = await rpc.getBlockchainInfo(); + expect(info.blocks, 80); + expect(info.headers, 90); + expect(info.initialBlockDownload, isTrue); + }); + + test('a node the orchestrator cannot read gives its reason, not a height', () async { + orchestrator.syncStatus = GetSyncStatusResponse( + sidechains: [_status(SidechainType.SIDECHAIN_TYPE_FREEBANK, error: 'not running')], + ); + await expectLater( + rpc.getBlockCount(), + throwsA(isA().having((e) => e.message, 'message', 'not running')), + ); + + orchestrator.syncStatus = GetSyncStatusResponse(); + await expectLater(rpc.getBlockchainInfo(), throwsStateError); + }); + + test('stop goes through the orchestrator', () async { + await rpc.stopRPC(); + expect(orchestrator.stops, ['freebank']); + }); + + test('a call the orchestrator does not serve is unsupported', () async { + await expectLater( + rpc.getDepositAddress(), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'fetching a FreeBank address. Paste one from your FreeBank wallet.', + ), + ), + ); + await expectLater(rpc.getSideAddress(), throwsUnsupportedError); + await expectLater(rpc.sideSend('addr', 1, false), throwsUnsupportedError); + await expectLater(rpc.listTransactions(), throwsUnsupportedError); + await expectLater(rpc.mine(1000), throwsUnsupportedError); + await expectLater(rpc.getPendingWithdrawalBundle(), throwsUnsupportedError); + await expectLater(rpc.withdraw('addr', 1000, 10, 10), throwsUnsupportedError); + await expectLater(rpc.callRAW('getbalance'), throwsUnsupportedError); + expect(rpc.getMethods(), isEmpty); + expect(await rpc.binaryArgs(), isEmpty); + }); + + test('the binary state reads the FreeBank connection', () { + final binaries = BinaryProvider.test(appDir: Directory.systemTemp, binaries: [FreeBank()]); + GetIt.I.registerSingleton(binaries); + + expect(binaries.isConnected(FreeBank()), isFalse); + rpc.initializingBinary = true; + expect(binaries.isInitializing(FreeBank()), isTrue); + rpc + ..initializingBinary = false + ..connected = true; + expect(binaries.isConnected(FreeBank()), isTrue); + expect(binaries.isSidechainUp(FreeBank()), isTrue); + rpc.stoppingBinary = true; + expect(binaries.isStopping(FreeBank()), isTrue); + rpc.connectionError = 'refused'; + expect(binaries.connectionError(FreeBank()), 'refused'); + }); + + test('a stopped FreeBank reads no balance, a running one reads it once per fetch', () async { + orchestrator.balance = GetSidechainBalanceResponse(confirmedSats: Int64(100000000)); + final balances = BalanceProvider(connections: [rpc]); + addTearDown(balances.dispose); + + await balances.fetch(); + expect(orchestrator.balanceReads, isEmpty); + + rpc.connected = true; + await balances.fetch(); + expect(orchestrator.balanceReads, [BinaryType.BINARY_TYPE_FREEBANK]); + expect(balances.balanceFor(rpc), (1.0, 0.0)); + }); +}