From 029f676e499ce2b923113cce60db7761a699133f Mon Sep 17 00:00:00 2001 From: mblowes Date: Sat, 26 Sep 2026 11:39:45 +1000 Subject: [PATCH 1/4] orchestrator: let the BMM engine drive FreeBank --- .../api/sidechain_node_test.go | 10 +- .../sidechain/corenode/client.go | 8 + .../sidechain/freebank/bmm.go | 115 +++++++++++++ .../sidechain/freebank/bmm_test.go | 151 ++++++++++++++++++ .../sidechain/freebank/freebank_test.go | 6 +- .../sidechain/nodes/nodes_test.go | 4 +- 6 files changed, 284 insertions(+), 10 deletions(-) create mode 100644 sidechain-orchestrator/sidechain/freebank/bmm.go create mode 100644 sidechain-orchestrator/sidechain/freebank/bmm_test.go diff --git a/sidechain-orchestrator/api/sidechain_node_test.go b/sidechain-orchestrator/api/sidechain_node_test.go index cfffe6b6ef..42fe8ff66f 100644 --- a/sidechain-orchestrator/api/sidechain_node_test.go +++ b/sidechain-orchestrator/api/sidechain_node_test.go @@ -57,17 +57,15 @@ func TestCoreForkAnswersOnlyWhatItDrives(t *testing.T) { assert.Contains(t, err.Error(), "proposes no withdrawal bundle") } -// FreeBank blind merge mines with its own refreshbmm ticker and settles -// withdrawals on its own paths, so it is a Node and nothing more. -func TestFreebankAnswersNeitherBMMNorWithdrawals(t *testing.T) { +// The BMM engine drives FreeBank blocks through the node's get_block_template +// and connect_block, but FreeBank settles withdrawals on its own paths. +func TestFreebankAnswersBMMButNotWithdrawals(t *testing.T) { cfg := orchestrator.BinaryConfig{Name: "freebank", DisplayName: "FreeBank", Port: 8454, IsBitcoinCore: true} node, err := sidechainNode(cfg, config.NetworkRegtest) require.NoError(t, err) _, err = bmmNode(cfg, config.NetworkRegtest) - require.Error(t, err) - assert.Contains(t, err.Error(), "FreeBank") - assert.Contains(t, err.Error(), "produces its own blocks") + require.NoError(t, err) _, err = withdrawalNode(node, cfg.DisplayName) require.Error(t, err) diff --git a/sidechain-orchestrator/sidechain/corenode/client.go b/sidechain-orchestrator/sidechain/corenode/client.go index b782d43f48..0c473caa16 100644 --- a/sidechain-orchestrator/sidechain/corenode/client.go +++ b/sidechain-orchestrator/sidechain/corenode/client.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "math" @@ -66,6 +67,13 @@ func (e *rpcError) Error() string { return fmt.Sprintf("%s RPC error %d: %s", e.chain, e.Code, e.Message) } +// IsMethodNotFound reports whether a node refused a call because it does not +// have that method, as an older release of a fork does. +func IsMethodNotFound(err error) bool { + var rpcErr *rpcError + return errors.As(err, &rpcErr) && rpcErr.Code == -32601 +} + // WalletCall sends one wallet RPC to the wallet the orchestrator seeds. func (c *Client) WalletCall(ctx context.Context, method string, params any) (json.RawMessage, error) { return c.callAt(ctx, "/wallet/"+sidechain.CoreWalletName, method, params) diff --git a/sidechain-orchestrator/sidechain/freebank/bmm.go b/sidechain-orchestrator/sidechain/freebank/bmm.go new file mode 100644 index 0000000000..5ca9304118 --- /dev/null +++ b/sidechain-orchestrator/sidechain/freebank/bmm.go @@ -0,0 +1,115 @@ +package freebank + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "slices" + + "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/sidechain" + "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/sidechain/corenode" +) + +var _ sidechain.BMMNode = (*Client)(nil) + +// tooOld names the FreeBank release that first answers the BMM engine. Older +// nodes bid only through their own refreshbmm, so the engine gets this instead +// of a bare "method not found". +const tooOld = "this FreeBank node is too old for bidding from BitWindow (it needs v0.2.16 or later)" + +// call sends one BMM method, and turns an older node's "method not found" into +// a sentence a user can act on. +func call[T any](ctx context.Context, c *Client, method string, params any) (T, error) { + result, err := corenode.Decode[T](ctx, c.Client, method, params) + if corenode.IsMethodNotFound(err) { + var zero T + return zero, fmt.Errorf("%s: %w", tooOld, err) + } + return result, err +} + +// GetBlockTemplate returns the block the node builds next. Its critical hash is +// the block's merkle root, in internal byte order. +func (c *Client) GetBlockTemplate(ctx context.Context) (*sidechain.BlockTemplate, error) { + template, err := call[sidechain.BlockTemplate](ctx, c, "get_block_template", nil) + if err != nil { + return nil, err + } + return &template, nil +} + +// ConnectBlock hands a won block back to the node, with the mainchain block that +// carried the bid. +func (c *Client) ConnectBlock(ctx context.Context, block json.RawMessage, mainBlockHash string) (bool, error) { + return call[bool](ctx, c, "connect_block", []any{block, mainBlockHash}) +} + +// GetBmmInclusions returns the mainchain blocks that carry a critical hash. +func (c *Client) GetBmmInclusions(ctx context.Context, criticalHash string) ([]string, error) { + return call[[]string](ctx, c, "get_bmm_inclusions", []any{criticalHash}) +} + +// ChainHolds reports whether one of the depth blocks nearest the tip carries a +// critical hash. FreeBank bids with a block's merkle root, not its hash: the +// hash is fixed only once the winning mainchain block is written into the +// header. So the walk compares merkle roots. +func (c *Client) ChainHolds(ctx context.Context, criticalHash string, depth int) (bool, error) { + raw, err := hex.DecodeString(criticalHash) + if err != nil { + return false, fmt.Errorf("decode critical hash: %w", err) + } + // critical_hash is in internal byte order, and Core prints a merkle root in display order. + slices.Reverse(raw) + want := hex.EncodeToString(raw) + + next, err := corenode.Decode[string](ctx, c.Client, "getbestblockhash", nil) + if err != nil { + return false, err + } + for range depth { + header, err := corenode.Decode[struct { + MerkleRoot string `json:"merkleroot"` + PreviousBlockHash string `json:"previousblockhash"` + }](ctx, c.Client, "getblockheader", []any{next}) + if err != nil { + return false, err + } + if header.MerkleRoot == want { + return true, nil + } + if header.PreviousBlockHash == "" { + return false, nil + } + next = header.PreviousBlockHash + } + return false, nil +} + +// TemplateOnTip reports whether a block from GetBlockTemplate still builds on +// the chain tip. +func (c *Client) TemplateOnTip(ctx context.Context, block json.RawMessage) (bool, error) { + var template struct { + Hex string `json:"hex"` + } + if err := json.Unmarshal(block, &template); err != nil { + return false, fmt.Errorf("decode block: %w", err) + } + raw, err := hex.DecodeString(template.Hex) + if err != nil { + return false, fmt.Errorf("decode block hex: %w", err) + } + // The header opens with a 4-byte version, then the parent hash. + if len(raw) < 36 { + return false, fmt.Errorf("block of %d bytes has no parent hash", len(raw)) + } + parent := slices.Clone(raw[4:36]) + // The header holds internal byte order, and Core names a block in display order. + slices.Reverse(parent) + + tip, err := corenode.Decode[string](ctx, c.Client, "getbestblockhash", nil) + if err != nil { + return false, err + } + return hex.EncodeToString(parent) == tip, nil +} diff --git a/sidechain-orchestrator/sidechain/freebank/bmm_test.go b/sidechain-orchestrator/sidechain/freebank/bmm_test.go new file mode 100644 index 0000000000..d0b984cdfe --- /dev/null +++ b/sidechain-orchestrator/sidechain/freebank/bmm_test.go @@ -0,0 +1,151 @@ +package freebank + +import ( + "context" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeNode replies per method, and records the params each call carried. A +// method mapped to nil answers the way a node without it does. +func fakeNode(t *testing.T, results map[string]json.RawMessage, seen map[string]json.RawMessage) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Method string `json:"method"` + Params json.RawMessage `json:"params"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + if seen != nil { + seen[req.Method] = req.Params + } + result, ok := results[req.Method] + if !ok { + t.Fatalf("unexpected method: %s", req.Method) + } + w.Header().Set("Content-Type", "application/json") + if result == nil { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"result":null,"error":{"code":-32601,"message":"Method not found"}}`)) + return + } + _, _ = w.Write([]byte(`{"result":` + string(result) + `,"error":null}`)) + })) +} + +func clientFor(t *testing.T, srv *httptest.Server) *Client { + t.Helper() + parsed, err := url.Parse(srv.URL) + require.NoError(t, err) + host, portText, err := net.SplitHostPort(parsed.Host) + require.NoError(t, err) + port, err := strconv.Atoi(portText) + require.NoError(t, err) + + cookie := filepath.Join(t.TempDir(), ".cookie") + require.NoError(t, os.WriteFile(cookie, []byte("__cookie__:secret"), 0o600)) + return NewClient(host, port, cookie) +} + +const ( + internalRoot = "01" + "000000000000000000000000000000000000000000000000000000000000" + "ff" + displayRoot = "ff" + "000000000000000000000000000000000000000000000000000000000000" + "01" + tipHash = "aa" + "000000000000000000000000000000000000000000000000000000000000" + "aa" + parentHash = "bb" + "000000000000000000000000000000000000000000000000000000000000" + "bb" +) + +func TestBlockTemplateCarriesTheMerkleRoot(t *testing.T) { + srv := fakeNode(t, map[string]json.RawMessage{ + "get_block_template": json.RawMessage(`{"critical_hash":"` + internalRoot + `","block":{"prev_main_hash":"` + tipHash + `","hex":"00"},"fees_sats":1500}`), + }, nil) + defer srv.Close() + + template, err := clientFor(t, srv).GetBlockTemplate(context.Background()) + require.NoError(t, err) + assert.Equal(t, internalRoot, template.CriticalHash) + assert.Equal(t, int64(1500), template.FeesSats) + assert.JSONEq(t, `{"prev_main_hash":"`+tipHash+`","hex":"00"}`, string(template.Block)) +} + +// connect_block takes the block object as the template gave it, not a string, +// and the mainchain block in display order. +func TestConnectBlockSendsTheBlockBack(t *testing.T) { + seen := map[string]json.RawMessage{} + srv := fakeNode(t, map[string]json.RawMessage{"connect_block": json.RawMessage(`true`)}, seen) + defer srv.Close() + + block := json.RawMessage(`{"prev_main_hash":"` + tipHash + `","hex":"00"}`) + connected, err := clientFor(t, srv).ConnectBlock(context.Background(), block, tipHash) + require.NoError(t, err) + assert.True(t, connected) + assert.JSONEq(t, `[{"prev_main_hash":"`+tipHash+`","hex":"00"},"`+tipHash+`"]`, string(seen["connect_block"])) +} + +func TestBmmInclusionsReadNoneAsEmpty(t *testing.T) { + srv := fakeNode(t, map[string]json.RawMessage{"get_bmm_inclusions": json.RawMessage(`[]`)}, nil) + defer srv.Close() + + inclusions, err := clientFor(t, srv).GetBmmInclusions(context.Background(), internalRoot) + require.NoError(t, err) + assert.Empty(t, inclusions) +} + +// A node from before v0.2.16 has no BMM methods. The engine shows the user why. +func TestAnOlderNodeSaysWhichReleaseItNeeds(t *testing.T) { + srv := fakeNode(t, map[string]json.RawMessage{"get_block_template": nil}, nil) + defer srv.Close() + + _, err := clientFor(t, srv).GetBlockTemplate(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "v0.2.16") +} + +// FreeBank bids with a merkle root, so ChainHolds finds a block by its merkle +// root, reversed into the display order Core prints. +func TestChainHoldsFindsTheMerkleRoot(t *testing.T) { + srv := fakeNode(t, map[string]json.RawMessage{ + "getbestblockhash": json.RawMessage(`"` + tipHash + `"`), + "getblockheader": json.RawMessage(`{"merkleroot":"` + displayRoot + `","previousblockhash":"` + parentHash + `"}`), + }, nil) + defer srv.Close() + + held, err := clientFor(t, srv).ChainHolds(context.Background(), internalRoot, 11) + require.NoError(t, err) + assert.True(t, held) +} + +// A block hash equal to the critical hash is a coincidence, not a match: only +// the merkle root counts. +func TestChainHoldsIgnoresBlockHashes(t *testing.T) { + srv := fakeNode(t, map[string]json.RawMessage{ + "getbestblockhash": json.RawMessage(`"` + displayRoot + `"`), + "getblockheader": json.RawMessage(`{"merkleroot":"` + tipHash + `","previousblockhash":""}`), + }, nil) + defer srv.Close() + + held, err := clientFor(t, srv).ChainHolds(context.Background(), internalRoot, 11) + require.NoError(t, err) + assert.False(t, held) +} + +func TestTemplateOnTipReadsTheParentFromTheHeader(t *testing.T) { + // version (4 bytes) + parent in internal order: the reverse of parentHash + hexBlock := "20000000" + "bb" + "000000000000000000000000000000000000000000000000000000000000" + "bb" + "00" + for tip, want := range map[string]bool{parentHash: true, tipHash: false} { + srv := fakeNode(t, map[string]json.RawMessage{"getbestblockhash": json.RawMessage(`"` + tip + `"`)}, nil) + onTip, err := clientFor(t, srv).TemplateOnTip(context.Background(), json.RawMessage(`{"hex":"`+hexBlock+`"}`)) + srv.Close() + require.NoError(t, err) + assert.Equal(t, want, onTip, "tip %s", tip) + } +} diff --git a/sidechain-orchestrator/sidechain/freebank/freebank_test.go b/sidechain-orchestrator/sidechain/freebank/freebank_test.go index d7aef28079..308ae1de1f 100644 --- a/sidechain-orchestrator/sidechain/freebank/freebank_test.go +++ b/sidechain-orchestrator/sidechain/freebank/freebank_test.go @@ -92,13 +92,13 @@ func TestArgsReportAMainchainBehindTheFork(t *testing.T) { assert.Contains(t, err.Error(), "967680") } -// FreeBank blind merge mines with its own ticker and settles withdrawals on its +// The BMM engine drives FreeBank blocks, but FreeBank settles withdrawals on its // own paths, so the orchestrator reads that from the type. -func TestFreeBankDrivesNeitherBmmNorBundles(t *testing.T) { +func TestFreeBankDrivesBmmButProposesNoBundle(t *testing.T) { var node sidechain.Node = NewClient("127.0.0.1", 8454, "") _, drivesBMM := node.(sidechain.BMMNode) - assert.False(t, drivesBMM) + assert.True(t, drivesBMM) _, proposesBundles := node.(sidechain.WithdrawalNode) assert.False(t, proposesBundles) diff --git a/sidechain-orchestrator/sidechain/nodes/nodes_test.go b/sidechain-orchestrator/sidechain/nodes/nodes_test.go index 0a11c6c789..4ae50dc419 100644 --- a/sidechain-orchestrator/sidechain/nodes/nodes_test.go +++ b/sidechain-orchestrator/sidechain/nodes/nodes_test.go @@ -9,6 +9,7 @@ import ( "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/config" "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/sidechain" "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/sidechain/bbc" + "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/sidechain/freebank" "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/sidechain/zside" ) @@ -28,6 +29,7 @@ func TestEveryBmmSidechainTakesAnAuditedTransport(t *testing.T) { {name: "truthcoin"}, {name: "zside"}, {name: "bbc", core: true}, + {name: "freebank", core: true}, } for _, chain := range chains { @@ -39,7 +41,7 @@ func TestEveryBmmSidechainTakesAnAuditedTransport(t *testing.T) { require.True(t, ok, "the BMM engine drives this chain") switch bmm.(type) { - case *sidechain.JSONRPCProxy, *bbc.Client, *zside.Node: + case *sidechain.JSONRPCProxy, *bbc.Client, *freebank.Client, *zside.Node: default: assert.Failf(t, "unaudited transport", "%s takes %T", chain.name, bmm) } From 7e07d57f1d860092e2ddb1c9b9646290a50c4dfa Mon Sep 17 00:00:00 2001 From: mblowes Date: Sat, 26 Sep 2026 11:39:45 +1000 Subject: [PATCH 2/4] bitwindow: bid for FreeBank blocks, under a name --- .../lib/dialogs/freebank_bid_dialog.dart | 165 ++++++++++++++++++ bitwindow/lib/main.dart | 3 + bitwindow/lib/pages/sidechains_page.dart | 18 ++ bitwindow/lib/utils/freebank_conf.dart | 131 ++++++++++++++ bitwindow/test/freebank_conf_test.dart | 92 ++++++++++ bitwindow/test/sidechains_table_test.dart | 21 +++ 6 files changed, 430 insertions(+) create mode 100644 bitwindow/lib/dialogs/freebank_bid_dialog.dart create mode 100644 bitwindow/lib/utils/freebank_conf.dart create mode 100644 bitwindow/test/freebank_conf_test.dart diff --git a/bitwindow/lib/dialogs/freebank_bid_dialog.dart b/bitwindow/lib/dialogs/freebank_bid_dialog.dart new file mode 100644 index 0000000000..8d46042ec1 --- /dev/null +++ b/bitwindow/lib/dialogs/freebank_bid_dialog.dart @@ -0,0 +1,165 @@ +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'; + +// The BMM tab scrolls on its own, so the dialog gives it a fixed slot. +const _bmmTabHeight = 420.0; + +// The tab's control row holds two buttons and three fields, with a 44 px gap +// between them, and it does not wrap. Below this width Flutter clips the +// max-bid field and the manual-bid button. +const _bmmTabWidth = 1160.0; + +/// 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'); + if (!mounted) { + return; + } + setState(() => _error = 'Could not read the name: $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) { + return SailDialog( + title: 'Bid for FreeBank blocks', + subtitle: + '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.', + error: _error, + maxWidth: _bmmTabWidth + 80, + maxHeight: 720, + withCloseButton: true, + child: SailColumn( + spacing: SailStyleValues.padding16, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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( + _hasName + ? 'Blocks you win show "$_saved" on the explorer. Saving a new name restarts your FreeBank node.' + : 'Blocks you win show this name on the explorer. Change it if you like, then save. ' + 'Saving restarts your FreeBank node.', + ), + SizedBox( + height: _bmmTabHeight, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SizedBox( + width: _bmmTabWidth, + // 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.')), + ], + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/bitwindow/lib/main.dart b/bitwindow/lib/main.dart index d347a09994..2589f38b5b 100644 --- a/bitwindow/lib/main.dart +++ b/bitwindow/lib/main.dart @@ -233,6 +233,9 @@ Future<(Directory, File, Logger)> init(String arguments) async { GetIt.I.registerSingleton(PhotonLive()); GetIt.I.registerSingleton(TruthcoinLive()); GetIt.I.registerSingleton(FreeBankLive()); + // FreeBank has no app of its own, so its BMM controls live in BitWindow. Lazy: + // the provider only starts watching the engine once the bid dialog opens. + GetIt.I.registerLazySingleton(() => BMMProvider(sidechainRPC: GetIt.I.get())); registerNodeMode(); diff --git a/bitwindow/lib/pages/sidechains_page.dart b/bitwindow/lib/pages/sidechains_page.dart index 5adaee09ea..46647045a6 100644 --- a/bitwindow/lib/pages/sidechains_page.dart +++ b/bitwindow/lib/pages/sidechains_page.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:math'; import 'package:auto_route/auto_route.dart'; +import 'package:bitwindow/dialogs/freebank_bid_dialog.dart'; import 'package:bitwindow/log_window.dart'; import 'package:bitwindow/utils/deposit_fee.dart'; import 'package:bitwindow/pages/explorer/block_explorer_dialog.dart'; @@ -615,6 +616,23 @@ class _SidechainActions extends StatelessWidget { ], _depositButton(context), const SizedBox(width: SailStyleValues.padding08), + if (viewModel.sidechainForSlot(slot) is FreeBank && settingsConnection != null) ...[ + SizedBox.square( + dimension: 32, + child: SailTooltip( + message: 'Bid for FreeBank blocks', + child: SailButton( + variant: ButtonVariant.icon, + icon: SailSVGAsset.iconTabBmm, + small: true, + onPressed: () async { + await showThemedDialog(context: context, builder: (context) => const FreeBankBidDialog()); + }, + ), + ), + ), + const SizedBox(width: SailStyleValues.padding08), + ], SizedBox.square( dimension: 32, child: settingsConnection == null diff --git a/bitwindow/lib/utils/freebank_conf.dart b/bitwindow/lib/utils/freebank_conf.dart new file mode 100644 index 0000000000..4d17534b7f --- /dev/null +++ b/bitwindow/lib/utils/freebank_conf.dart @@ -0,0 +1,131 @@ +/// 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; + +/// A Core-style network header, for example `[regtest]`. Every line after one +/// belongs to that network only. +final _sectionHeader = RegExp(r'^\[[^\]]*\]$'); + +/// The network section freebankd reads. BitWindow starts the node with no chain +/// flag, so it runs its default main chain. +const activeNetworkSection = 'main'; + +/// The name the node takes, or null when the conf sets none. A `[main]` entry +/// beats the global part, and any other network section counts for nothing. The +/// last line of a scope wins, as in Core. +String? readCoinbaseTag(String conf) { + String? global; + String? active; + String? section; + for (final line in conf.split('\n')) { + final trimmed = line.trim(); + if (_sectionHeader.hasMatch(trimmed)) { + section = trimmed.substring(1, trimmed.length - 1); + continue; + } + if (!trimmed.startsWith('$coinbaseTagKey=')) { + continue; + } + final value = trimmed.substring(coinbaseTagKey.length + 1).trim(); + if (section == null) { + global = value; + } else if (section == activeNetworkSection) { + active = value; + } + } + return active ?? global; +} + +/// 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 the name the node takes set to [name]. A `[main]` entry beats +/// the global part, so the change goes there when one exists. Otherwise the line +/// goes in the global part, before the first network header. Every other line +/// stays as it was. +String setCoinbaseTag(String conf, String name) { + final line = '$coinbaseTagKey=${name.trim()}'; + final lines = conf.isEmpty ? [] : conf.split('\n'); + final target = _tagInActiveSection(lines) ? activeNetworkSection : null; + + final kept = []; + var written = false; + String? section; + for (final l in lines) { + final trimmed = l.trim(); + if (_sectionHeader.hasMatch(trimmed)) { + if (!written && target == null) { + kept.add(line); + written = true; + } + section = trimmed.substring(1, trimmed.length - 1); + } else if (section == target && trimmed.startsWith('$coinbaseTagKey=')) { + if (!written) { + kept.add(line); + written = true; + } + continue; + } + kept.add(l); + } + if (!written) { + 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'; +} + +bool _tagInActiveSection(List lines) { + String? section; + for (final l in lines) { + final trimmed = l.trim(); + if (_sectionHeader.hasMatch(trimmed)) { + section = trimmed.substring(1, trimmed.length - 1); + } else if (section == activeNetworkSection && trimmed.startsWith('$coinbaseTagKey=')) { + return true; + } + } + return false; +} + +/// 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/test/freebank_conf_test.dart b/bitwindow/test/freebank_conf_test.dart new file mode 100644 index 0000000000..6996d35cc2 --- /dev/null +++ b/bitwindow/test/freebank_conf_test.dart @@ -0,0 +1,92 @@ +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); + }); + + test('takes the [main] name over the global one', () { + expect(readCoinbaseTag('coinbasetag=global\n[main]\ncoinbasetag=active\n'), 'active'); + expect(readCoinbaseTag('[main]\ncoinbasetag=active\n'), 'active'); + }); + + test('reads no name from a network the node does not run', () { + expect(readCoinbaseTag('server=1\n[regtest]\ncoinbasetag=alice\n'), isNull); + expect(readCoinbaseTag('coinbasetag=global\n[regtest]\ncoinbasetag=alice\n'), 'global'); + }); + }); + + 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('writes the line before the first network section', () { + expect( + setCoinbaseTag('server=1\n[regtest]\nrpcport=1\n', 'alice'), + 'server=1\ncoinbasetag=alice\n[regtest]\nrpcport=1\n', + ); + }); + + test('changes the [main] name, because that one wins', () { + expect( + setCoinbaseTag('coinbasetag=global\n[main]\ncoinbasetag=old\n', 'new'), + 'coinbasetag=global\n[main]\ncoinbasetag=new\n', + ); + }); + + test('leaves a name of a network the node does not run', () { + expect( + setCoinbaseTag('coinbasetag=old\n[regtest]\ncoinbasetag=keepme\n', 'new'), + 'coinbasetag=new\n[regtest]\ncoinbasetag=keepme\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 0c594f507a..2ad1d73c52 100644 --- a/bitwindow/test/sidechains_table_test.dart +++ b/bitwindow/test/sidechains_table_test.dart @@ -156,6 +156,10 @@ final Finder _updateButton = find.byWidgetPredicate( (widget) => widget is SailButton && widget.icon == SailSVGAsset.circleArrowUp, ); +final Finder _bidButton = find.byWidgetPredicate( + (widget) => widget is SailButton && widget.icon == SailSVGAsset.iconTabBmm, +); + SailButton _buttonWidget(WidgetTester tester, String label) => tester.widget(_button(label)); void main() { @@ -338,6 +342,15 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('the row has a button for the bid dialog', (tester) async { + await setUpFreeBank(); + await pumpTable(tester); + + expect(_bidButton, findsOneWidget); + expect(_tooltip('Bid for FreeBank blocks'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + testWidgets('a stopped FreeBank offers Start and reads no balance', (tester) async { await setUpFreeBank(); await balances.fetch(); @@ -364,6 +377,14 @@ void main() { }); }); + // Only FreeBank bids from BitWindow: every other chain bids from its own app. + testWidgets('another chain has no bid button', (tester) async { + setUpChain(_thunder()); + await pumpTable(tester); + + expect(_bidButton, findsNothing); + }); + testWidgets('a chain that is not downloaded offers a primary Download', (tester) async { setUpChain(_thunder(downloaded: false)); await pumpTable(tester); From 3aa72394338361a7779917dda2f3fdc8614a1c9c Mon Sep 17 00:00:00 2001 From: mblowes Date: Sun, 27 Sep 2026 01:50:53 -0700 Subject: [PATCH 3/4] orchestrator: retry a FreeBank template on the tip --- sidechain-orchestrator/api/bmm_handler.go | 5 +++++ .../sidechain/corenode/client.go | 10 ++++++++++ .../sidechain/freebank/bmm.go | 9 +++++++++ .../sidechain/freebank/bmm_test.go | 20 +++++++++++++++++++ sidechain-orchestrator/sidechain/proxy.go | 7 +++++++ 5 files changed, 51 insertions(+) diff --git a/sidechain-orchestrator/api/bmm_handler.go b/sidechain-orchestrator/api/bmm_handler.go index df3bbd7d15..c28a39957c 100644 --- a/sidechain-orchestrator/api/bmm_handler.go +++ b/sidechain-orchestrator/api/bmm_handler.go @@ -4,6 +4,7 @@ import ( "context" "encoding/hex" "encoding/json" + "errors" "fmt" "math" "slices" @@ -414,6 +415,10 @@ func (h *BMMHandler) CreateBid( } template, err := proxy.GetBlockTemplate(ctx) + if errors.Is(err, sidechain.ErrNotReady) { + // FailedPrecondition makes the engine ask again on this tip. + return nil, connect.NewError(connect.CodeFailedPrecondition, fmt.Errorf("get block template: %w", err)) + } if err != nil { return nil, connect.NewError(connect.CodeUnavailable, fmt.Errorf("get block template: %w", err)) } diff --git a/sidechain-orchestrator/sidechain/corenode/client.go b/sidechain-orchestrator/sidechain/corenode/client.go index 0c473caa16..4fc527b5bb 100644 --- a/sidechain-orchestrator/sidechain/corenode/client.go +++ b/sidechain-orchestrator/sidechain/corenode/client.go @@ -67,6 +67,16 @@ func (e *rpcError) Error() string { return fmt.Sprintf("%s RPC error %d: %s", e.chain, e.Code, e.Message) } +// RPCErrorCode returns the JSON-RPC error code a node answered with, if err +// carries one. +func RPCErrorCode(err error) (int, bool) { + var rpcErr *rpcError + if errors.As(err, &rpcErr) { + return rpcErr.Code, true + } + return 0, false +} + // IsMethodNotFound reports whether a node refused a call because it does not // have that method, as an older release of a fork does. func IsMethodNotFound(err error) bool { diff --git a/sidechain-orchestrator/sidechain/freebank/bmm.go b/sidechain-orchestrator/sidechain/freebank/bmm.go index 5ca9304118..68c37fe307 100644 --- a/sidechain-orchestrator/sidechain/freebank/bmm.go +++ b/sidechain-orchestrator/sidechain/freebank/bmm.go @@ -18,6 +18,11 @@ var _ sidechain.BMMNode = (*Client)(nil) // of a bare "method not found". const tooOld = "this FreeBank node is too old for bidding from BitWindow (it needs v0.2.16 or later)" +// notReadyCodes are the answers with which freebankd says "not on this tip yet, +// ask again": -40 while its own block for the tip is arriving or it is +// reindexing, -10 and -28 while it starts up. +var notReadyCodes = map[int]bool{-40: true, -10: true, -28: true} + // call sends one BMM method, and turns an older node's "method not found" into // a sentence a user can act on. func call[T any](ctx context.Context, c *Client, method string, params any) (T, error) { @@ -26,6 +31,10 @@ func call[T any](ctx context.Context, c *Client, method string, params any) (T, var zero T return zero, fmt.Errorf("%s: %w", tooOld, err) } + if code, ok := corenode.RPCErrorCode(err); ok && notReadyCodes[code] { + var zero T + return zero, fmt.Errorf("%w: %w", sidechain.ErrNotReady, err) + } return result, err } diff --git a/sidechain-orchestrator/sidechain/freebank/bmm_test.go b/sidechain-orchestrator/sidechain/freebank/bmm_test.go index d0b984cdfe..07f8c4d84a 100644 --- a/sidechain-orchestrator/sidechain/freebank/bmm_test.go +++ b/sidechain-orchestrator/sidechain/freebank/bmm_test.go @@ -3,6 +3,7 @@ package freebank import ( "context" "encoding/json" + "errors" "net" "net/http" "net/http/httptest" @@ -14,6 +15,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/sidechain" ) // fakeNode replies per method, and records the params each call carried. A @@ -39,6 +42,11 @@ func fakeNode(t *testing.T, results map[string]json.RawMessage, seen map[string] _, _ = w.Write([]byte(`{"result":null,"error":{"code":-32601,"message":"Method not found"}}`)) return } + if string(result) == "notready" { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"result":null,"error":{"code":-40,"message":"side block for this eCash tip pending"}}`)) + return + } _, _ = w.Write([]byte(`{"result":` + string(result) + `,"error":null}`)) })) } @@ -149,3 +157,15 @@ func TestTemplateOnTipReadsTheParentFromTheHeader(t *testing.T) { assert.Equal(t, want, onTip, "tip %s", tip) } } + +// freebankd's "not on this tip yet" answers become sidechain.ErrNotReady, which +// the handler turns into a same-tip retry instead of skipping the block. +func TestANodeNotReadyAsksForARetryOnTheSameTip(t *testing.T) { + srv := fakeNode(t, map[string]json.RawMessage{"get_block_template": json.RawMessage("notready")}, nil) + defer srv.Close() + + _, err := clientFor(t, srv).GetBlockTemplate(context.Background()) + require.Error(t, err) + assert.True(t, errors.Is(err, sidechain.ErrNotReady)) + assert.Contains(t, err.Error(), "pending") +} diff --git a/sidechain-orchestrator/sidechain/proxy.go b/sidechain-orchestrator/sidechain/proxy.go index f3d68d148a..8ff7654fd8 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 { From 6ea460abcf4407e476c2ed953512f5e90940e7ba Mon Sep 17 00:00:00 2001 From: mblowes Date: Sun, 27 Sep 2026 01:51:00 -0700 Subject: [PATCH 4/4] orchestrator: leave FreeBank bids to the engine --- sidechain-orchestrator/orchestrator_sidechain_args_test.go | 1 + sidechain-orchestrator/sidechain/freebank/freebank.go | 3 +++ sidechain-orchestrator/sidechain/freebank/freebank_test.go | 1 + 3 files changed, 5 insertions(+) diff --git a/sidechain-orchestrator/orchestrator_sidechain_args_test.go b/sidechain-orchestrator/orchestrator_sidechain_args_test.go index 8f4e9cf634..4bffd5e5f3 100644 --- a/sidechain-orchestrator/orchestrator_sidechain_args_test.go +++ b/sidechain-orchestrator/orchestrator_sidechain_args_test.go @@ -408,6 +408,7 @@ func TestFreeBankBootsPinnedToTheBetanetForkBlock(t *testing.T) { "-mainchainrest=127.0.0.1:" + port, "-mainchainchain=main", "-mainchainblockpin=967680:" + betanetPin, + "-bmmbidder=engine", "-grpcurlbin=" + grpcurl, }, opts.TargetArgs) } diff --git a/sidechain-orchestrator/sidechain/freebank/freebank.go b/sidechain-orchestrator/sidechain/freebank/freebank.go index 5d27fd8e7b..e2bc4ff6ac 100644 --- a/sidechain-orchestrator/sidechain/freebank/freebank.go +++ b/sidechain-orchestrator/sidechain/freebank/freebank.go @@ -70,6 +70,9 @@ func (c *Client) Args(ctx context.Context, host sidechain.Host) ([]string, error "-mainchainrest=" + rest, "-mainchainchain=main", fmt.Sprintf("-mainchainblockpin=%d:%s", height, hash), + // BitWindow's BMM engine bids for this node, so the node makes no bids of + // its own. Nodes before v0.2.16 ignore the flag. + "-bmmbidder=engine", } // Without the flag, the node looks for grpcurl on PATH. if grpcurl := host.ToolPath("grpcurl"); grpcurl != "" { diff --git a/sidechain-orchestrator/sidechain/freebank/freebank_test.go b/sidechain-orchestrator/sidechain/freebank/freebank_test.go index 308ae1de1f..7982a24c6d 100644 --- a/sidechain-orchestrator/sidechain/freebank/freebank_test.go +++ b/sidechain-orchestrator/sidechain/freebank/freebank_test.go @@ -51,6 +51,7 @@ func TestArgsPinTheForkBlock(t *testing.T) { "-mainchainrest=127.0.0.1:18302", "-mainchainchain=main", "-mainchainblockpin=967680:00000000000000030101ba5cfea54b22becc79f95dc6040beb76e01dd9d04042", + "-bmmbidder=engine", }, args) }