From fbd699f52b199b5a65ac9b432c95f669651519ee Mon Sep 17 00:00:00 2001 From: mblowes Date: Sat, 26 Sep 2026 07:58:35 +0200 Subject: [PATCH 1/4] orchestrator: run FreeBank as a Core fork --- .../api/explorer_broadcast.go | 4 +- .../api/explorer_broadcast_test.go | 2 +- .../api/freebank_dispatch_test.go | 41 +++---- .../api/sidechain_node_test.go | 19 ++-- sidechain-orchestrator/chains_config.json | 12 +- .../cmd/drivechaind/main.go | 5 - sidechain-orchestrator/config/binaries.go | 8 +- .../config/binaries_test.go | 40 +++---- .../config/chains_config.json | 12 +- .../config/sidechain_conf.go | 10 -- .../config/sidechain_conf_ports_test.go | 3 - .../config/sidechain_enforcer_args_test.go | 2 +- sidechain-orchestrator/orchestrator.go | 11 +- .../orchestrator_sidechain_args_test.go | 77 ++++++++++--- sidechain-orchestrator/own_wallet_test.go | 37 ++++++ .../sidechain/freebank/freebank.go | 79 +++++++++++++ .../sidechain/freebank/freebank_test.go | 105 ++++++++++++++++++ .../sidechain/nodes/nodes.go | 3 + .../sidechain/nodes/nodes_test.go | 1 - sidechain-orchestrator/sidechain/proxy.go | 8 ++ 20 files changed, 367 insertions(+), 112 deletions(-) create mode 100644 sidechain-orchestrator/own_wallet_test.go create mode 100644 sidechain-orchestrator/sidechain/freebank/freebank.go create mode 100644 sidechain-orchestrator/sidechain/freebank/freebank_test.go diff --git a/sidechain-orchestrator/api/explorer_broadcast.go b/sidechain-orchestrator/api/explorer_broadcast.go index c9372d51ae..20030e0367 100644 --- a/sidechain-orchestrator/api/explorer_broadcast.go +++ b/sidechain-orchestrator/api/explorer_broadcast.go @@ -20,9 +20,9 @@ type broadcastResult struct { func (h *ExplorerHandler) broadcastSource(chain string) (source, error) { chain = strings.TrimSpace(chain) - if chain != "bitnames" && chain != "bitassets" && chain != "freebank" { + if chain != "bitnames" && chain != "bitassets" { return source{}, connect.NewError(connect.CodeInvalidArgument, - fmt.Errorf("transaction rebroadcast supports BitNames, BitAssets and FreeBank")) + fmt.Errorf("transaction rebroadcast supports BitNames and BitAssets")) } src, err := h.sourceOf(chain) if err != nil { diff --git a/sidechain-orchestrator/api/explorer_broadcast_test.go b/sidechain-orchestrator/api/explorer_broadcast_test.go index 7ef17c098a..c99d308868 100644 --- a/sidechain-orchestrator/api/explorer_broadcast_test.go +++ b/sidechain-orchestrator/api/explorer_broadcast_test.go @@ -26,7 +26,7 @@ const broadcastTestJSON = `{ }` func TestExplorerBroadcastKeepsSignedJSON(t *testing.T) { - for _, chain := range []string{"bitnames", "bitassets", "freebank"} { + for _, chain := range []string{"bitnames", "bitassets"} { t.Run(chain, func(t *testing.T) { node := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req struct { diff --git a/sidechain-orchestrator/api/freebank_dispatch_test.go b/sidechain-orchestrator/api/freebank_dispatch_test.go index edbfc49595..2b716a7448 100644 --- a/sidechain-orchestrator/api/freebank_dispatch_test.go +++ b/sidechain-orchestrator/api/freebank_dispatch_test.go @@ -5,6 +5,8 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -13,8 +15,6 @@ import ( orchestrator "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator" "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/config" pb "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/gen/orchestrator/v1" - "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/sidechain" - bitassetssvc "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/sidechain/bitassets" ) // GetSyncStatus shows FreeBank with its own type. @@ -24,34 +24,37 @@ func TestSidechainTypeFromNameKnowsFreebank(t *testing.T) { assert.Equal(t, pb.SidechainType_SIDECHAIN_TYPE_UNSPECIFIED, sidechainTypeFromName("nosuchchain")) } -// FreeBank has no "balance" RPC, so its balance comes from bitcoin_balance. -func TestFreebankBalanceUsesBitcoinBalanceMethod(t *testing.T) { - var gotMethod string +// FreeBank keeps the one wallet its node created and predates getbalances, so +// its balance is getwalletinfo, asked at the root endpoint with the cookie. +func TestFreebankBalanceReadsItsOwnWallet(t *testing.T) { + config.SetHomeDir(t.TempDir()) + t.Cleanup(func() { config.SetHomeDir("") }) + datadir := config.FreebankDirs.DatadirNetwork(config.NetworkRegtest, "") + require.NoError(t, os.MkdirAll(datadir, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(datadir, ".cookie"), []byte("__cookie__:secret"), 0o600)) + + var gotPath, gotMethod, gotUser string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req struct { - ID uint64 `json:"id"` Method string `json:"method"` } _ = json.NewDecoder(r.Body).Decode(&req) - gotMethod = req.Method - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"total_sats":300,"available_sats":125}}`)) + gotPath, gotMethod = r.URL.Path, req.Method + gotUser, _, _ = r.BasicAuth() + _, _ = w.Write([]byte(`{"result":{"balance":1.0,"unconfirmed_balance":0.25,"immature_balance":0.5},"error":null}`)) })) defer srv.Close() host, port := hostPort(t, srv) - bh := bitassetssvc.NewHandler(sidechain.NewJSONRPCProxy(host, port)) - h := &Handler{ - orch: &orchestrator.Orchestrator{Network: string(config.NetworkRegtest)}, - sidechainBalances: map[string]SidechainBalanceFunc{"freebank": bh.WalletBalance}, - } - + h := &Handler{orch: &orchestrator.Orchestrator{Network: string(config.NetworkRegtest)}} confirmed, pending, err := h.fetchSidechainBalance( context.Background(), - orchestrator.BinaryConfig{Name: "freebank", Host: host, Port: port}, + orchestrator.BinaryConfig{Name: "freebank", Host: host, Port: port, IsBitcoinCore: true}, ) require.NoError(t, err) - assert.Equal(t, "bitcoin_balance", gotMethod) - assert.Equal(t, int64(125), confirmed) - assert.Equal(t, int64(175), pending) + assert.Equal(t, "/", gotPath) + assert.Equal(t, "getwalletinfo", gotMethod) + assert.Equal(t, "__cookie__", gotUser) + assert.Equal(t, int64(100_000_000), confirmed) + assert.Equal(t, int64(75_000_000), pending) } diff --git a/sidechain-orchestrator/api/sidechain_node_test.go b/sidechain-orchestrator/api/sidechain_node_test.go index 593373efc9..cfffe6b6ef 100644 --- a/sidechain-orchestrator/api/sidechain_node_test.go +++ b/sidechain-orchestrator/api/sidechain_node_test.go @@ -57,18 +57,21 @@ func TestCoreForkAnswersOnlyWhatItDrives(t *testing.T) { assert.Contains(t, err.Error(), "proposes no withdrawal bundle") } -// FreeBank is a BitAssets fork, so it does BMM and proposes withdrawal bundles. -func TestFreebankAnswersEveryInterfaceLikeBitassets(t *testing.T) { - cfg := orchestrator.BinaryConfig{Name: "freebank", DisplayName: "FreeBank", Port: 6130} +// FreeBank blind merge mines with its own refreshbmm ticker and settles +// withdrawals on its own paths, so it is a Node and nothing more. +func TestFreebankAnswersNeitherBMMNorWithdrawals(t *testing.T) { + cfg := orchestrator.BinaryConfig{Name: "freebank", DisplayName: "FreeBank", Port: 8454, IsBitcoinCore: true} node, err := sidechainNode(cfg, config.NetworkRegtest) require.NoError(t, err) - bmm, err := bmmNode(cfg, config.NetworkRegtest) - require.NoError(t, err) - assert.NotNil(t, bmm) + _, err = bmmNode(cfg, config.NetworkRegtest) + require.Error(t, err) + assert.Contains(t, err.Error(), "FreeBank") + assert.Contains(t, err.Error(), "produces its own blocks") - _, proposesBundles := node.(sidechain.WithdrawalNode) - assert.True(t, proposesBundles) + _, err = withdrawalNode(node, cfg.DisplayName) + require.Error(t, err) + assert.Contains(t, err.Error(), "proposes no withdrawal bundle") } // A chain with no directory config names itself in the failure, rather than diff --git a/sidechain-orchestrator/chains_config.json b/sidechain-orchestrator/chains_config.json index c33ef36d1e..9a34e7d7de 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,9 +837,9 @@ "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, "color": "orange", "health_check": { "type": "tcp" @@ -849,9 +849,9 @@ "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/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..9a34e7d7de 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,9 +837,9 @@ "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, "color": "orange", "health_check": { "type": "tcp" @@ -849,9 +849,9 @@ "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/orchestrator.go b/sidechain-orchestrator/orchestrator.go index f24a944ee6..f64c444940 100644 --- a/sidechain-orchestrator/orchestrator.go +++ b/sidechain-orchestrator/orchestrator.go @@ -1071,6 +1071,13 @@ func (o *Orchestrator) ensureCoreSidechainWallet(ctx context.Context, cfg Binary if !cfg.IsBitcoinCore || cfg.ChainLayer != 2 || cfg.Slot <= 0 || o.WalletSvc == nil { return nil } + node, err := nodes.New(cfg.Name, cfg.RPCHost(), cfg.Port, cfg.IsBitcoinCore, config.Network(o.Network)) + if err != nil { + return err + } + if _, ok := node.(sidechain.OwnWalletNode); ok { + return nil + } mnemonic, err := o.WalletSvc.GetOrDeriveSidechainStarter(cfg.Slot, cfg.DisplayName) if err != nil { return fmt.Errorf("sidechain starter: %w", err) @@ -1080,10 +1087,6 @@ func (o *Orchestrator) ensureCoreSidechainWallet(ctx context.Context, cfg Binary return fmt.Errorf("no directory config for %s", cfg.Name) } cookiePath := filepath.Join(dirs.DatadirNetwork(config.Network(o.Network), ""), ".cookie") - node, err := nodes.New(cfg.Name, cfg.RPCHost(), cfg.Port, cfg.IsBitcoinCore, config.Network(o.Network)) - if err != nil { - return err - } netParams := o.NetParams.Resolve() if own, ok := node.(sidechain.WalletParamsNode); ok { netParams = own.WalletParams() diff --git a/sidechain-orchestrator/orchestrator_sidechain_args_test.go b/sidechain-orchestrator/orchestrator_sidechain_args_test.go index ef550a0173..8f4e9cf634 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,65 @@ 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) { - useTempHome(t) - orch := &Orchestrator{log: zerolog.Nop(), Network: string(config.NetworkRegtest), DataDir: t.TempDir()} +// betanetPin is the hash of the eCash betanet fork block. +const betanetPin = "00000000000000030101ba5cfea54b22becc79f95dc6040beb76e01dd9d04042" - var windowed StartOpts - require.NoError(t, orch.appendSidechainArgs(context.Background(), - BinaryConfig{Name: "freebank", ChainLayer: 2, Port: 6130}, &windowed)) - assert.NotContains(t, windowed.TargetArgs, "--headless") +// 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) + 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 +} - fresh := 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) +// 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)) - dupe := StartOpts{ForceBackend: true, TargetArgs: []string{"--headless"}} + opts := StartOpts{ForceBackend: true} 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{ + "-mainchaintransport=enforcer", + "-mainchainrest=127.0.0.1:" + port, + "-mainchainchain=main", + "-mainchainblockpin=967680:" + betanetPin, + "-grpcurlbin=" + grpcurl, + }, 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..ccdc5270eb --- /dev/null +++ b/sidechain-orchestrator/own_wallet_test.go @@ -0,0 +1,37 @@ +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 a request 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, + } + require.NoError(t, o.ensureCoreSidechainWallet(context.Background(), freebank)) + + bbc := BinaryConfig{Name: "bbc", DisplayName: "Bbc", ChainLayer: 2, Slot: 1, Port: 8332, IsBitcoinCore: true} + require.ErrorContains(t, o.ensureCoreSidechainWallet(context.Background(), bbc), "sidechain starter") +} + +func TestFreeBankShipsAsACoreFork(t *testing.T) { + cfg, ok := BinaryConfigByName("freebank") + require.True(t, ok) + assert.True(t, cfg.IsBitcoinCore) + assert.Equal(t, 8454, cfg.Port) +} diff --git a/sidechain-orchestrator/sidechain/freebank/freebank.go b/sidechain-orchestrator/sidechain/freebank/freebank.go new file mode 100644 index 0000000000..5d27fd8e7b --- /dev/null +++ b/sidechain-orchestrator/sidechain/freebank/freebank.go @@ -0,0 +1,79 @@ +// Package freebank is the client for the FreeBank sidechain, a Bitcoin Core +// 0.16 fork. +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) + _ sidechain.OwnWalletNode = (*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)} +} + +// OwnWallet marks FreeBank as a node that creates and keeps its own wallet. +func (c *Client) OwnWallet() {} + +// GetBalance returns the wallet balance in satoshis. The node predates +// getbalances. +func (c *Client) GetBalance(ctx context.Context) (totalSats, availableSats int64, err error) { + info, err := corenode.Decode[struct { + Balance float64 `json:"balance"` + UnconfirmedBalance float64 `json:"unconfirmed_balance"` + ImmatureBalance float64 `json:"immature_balance"` + }](ctx, c.Client, "getwalletinfo", nil) + if err != nil { + return 0, 0, err + } + available := corenode.BTCToSats(info.Balance) + return available + corenode.BTCToSats(info.UnconfirmedBalance+info.ImmatureBalance), available, nil +} + +// GetNewAddress returns a fresh legacy address. +func (c *Client) GetNewAddress(ctx context.Context) (string, error) { + return corenode.Decode[string](ctx, c.Client, "getnewaddress", []any{"", "legacy"}) +} + +// Args pins the node to the mainchain fork block, without which it refuses to +// boot. +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), + } + // Without the flag, the node looks for grpcurl on PATH. + if grpcurl := host.ToolPath("grpcurl"); grpcurl != "" { + args = append(args, "-grpcurlbin="+grpcurl) + } + return args, nil +} diff --git a/sidechain-orchestrator/sidechain/freebank/freebank_test.go b/sidechain-orchestrator/sidechain/freebank/freebank_test.go new file mode 100644 index 0000000000..d7aef28079 --- /dev/null +++ b/sidechain-orchestrator/sidechain/freebank/freebank_test.go @@ -0,0 +1,105 @@ +package freebank + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/sidechain" +) + +// fakeHost answers what the orchestrator would, so a test states one mainchain +// and reads the command line the chain asks for. FreeBank reads the mainchain +// over REST and the enforcer, never over Core's RPC, so the embedded Host +// answers the mainchain RPC methods with a panic. +type fakeHost struct { + sidechain.Host + backendOnly bool + rest string + forkHeight int + hash string + hashErr error + tools map[string]string +} + +func (h fakeHost) BackendOnly() bool { return h.backendOnly } +func (h fakeHost) MainchainREST() string { return h.rest } +func (h fakeHost) ForkHeight() int { return h.forkHeight } + +func (h fakeHost) MainchainBlockHash(context.Context, int) (string, error) { + return h.hash, h.hashErr +} + +func (h fakeHost) ToolPath(name string) string { return h.tools[name] } + +func onECash() fakeHost { + return fakeHost{ + rest: "127.0.0.1:18302", + forkHeight: 967680, // what the network catalog publishes for betanet + hash: "00000000000000030101ba5cfea54b22becc79f95dc6040beb76e01dd9d04042", + } +} + +func TestArgsPinTheForkBlock(t *testing.T) { + args, err := NewClient("127.0.0.1", 8454, "").Args(context.Background(), onECash()) + require.NoError(t, err) + assert.Equal(t, []string{ + "-mainchaintransport=enforcer", + "-mainchainrest=127.0.0.1:18302", + "-mainchainchain=main", + "-mainchainblockpin=967680:00000000000000030101ba5cfea54b22becc79f95dc6040beb76e01dd9d04042", + }, args) +} + +// The node looks for grpcurl on PATH when the orchestrator holds none, so an +// absent one is not a refusal. +func TestArgsNameGrpcurlOnlyWhenItIsDownloaded(t *testing.T) { + host := onECash() + host.tools = map[string]string{"grpcurl": "/tmp/bin/grpcurl"} + args, err := NewClient("127.0.0.1", 8454, "").Args(context.Background(), host) + require.NoError(t, err) + assert.Contains(t, args, "-grpcurlbin=/tmp/bin/grpcurl") +} + +// A network with no published fork height gets a message, not a daemon that +// starts and exits. +func TestArgsRefuseANetworkWithNoForkHeight(t *testing.T) { + host := onECash() + host.forkHeight = 0 + _, err := NewClient("127.0.0.1", 8454, "").Args(context.Background(), host) + require.Error(t, err) + assert.Contains(t, err.Error(), "fork height") +} + +func TestArgsRefuseWithoutTheMainchainRestPort(t *testing.T) { + host := onECash() + host.rest = "" + _, err := NewClient("127.0.0.1", 8454, "").Args(context.Background(), host) + require.Error(t, err) + assert.Contains(t, err.Error(), "REST port") +} + +// A mainchain that has not reached the fork height yet names the height it +// waits for. +func TestArgsReportAMainchainBehindTheFork(t *testing.T) { + host := onECash() + host.hashErr = fmt.Errorf("block height out of range") + _, err := NewClient("127.0.0.1", 8454, "").Args(context.Background(), host) + require.Error(t, err) + assert.Contains(t, err.Error(), "967680") +} + +// FreeBank blind merge mines with its own ticker and settles withdrawals on its +// own paths, so the orchestrator reads that from the type. +func TestFreeBankDrivesNeitherBmmNorBundles(t *testing.T) { + var node sidechain.Node = NewClient("127.0.0.1", 8454, "") + + _, drivesBMM := node.(sidechain.BMMNode) + assert.False(t, drivesBMM) + + _, proposesBundles := node.(sidechain.WithdrawalNode) + assert.False(t, proposesBundles) +} diff --git a/sidechain-orchestrator/sidechain/nodes/nodes.go b/sidechain-orchestrator/sidechain/nodes/nodes.go index 2c3c9c0e39..50f4c15d5c 100644 --- a/sidechain-orchestrator/sidechain/nodes/nodes.go +++ b/sidechain-orchestrator/sidechain/nodes/nodes.go @@ -11,6 +11,7 @@ import ( "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/sidechain" "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/sidechain/bbc" "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/sidechain/elements" + "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/sidechain/freebank" "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/sidechain/zside" ) @@ -34,6 +35,8 @@ func New(name, host string, port int, isBitcoinCore bool, network config.Network switch name { case "liquid-signet": return elements.NewNode(host, port, datadir, network), nil + case "freebank": + return freebank.NewClient(host, port, cookie), nil default: return bbc.NewClient(host, port, cookie), nil } diff --git a/sidechain-orchestrator/sidechain/nodes/nodes_test.go b/sidechain-orchestrator/sidechain/nodes/nodes_test.go index 2ef17db221..0a11c6c789 100644 --- a/sidechain-orchestrator/sidechain/nodes/nodes_test.go +++ b/sidechain-orchestrator/sidechain/nodes/nodes_test.go @@ -28,7 +28,6 @@ func TestEveryBmmSidechainTakesAnAuditedTransport(t *testing.T) { {name: "truthcoin"}, {name: "zside"}, {name: "bbc", core: true}, - {name: "freebank"}, } for _, chain := range chains { diff --git a/sidechain-orchestrator/sidechain/proxy.go b/sidechain-orchestrator/sidechain/proxy.go index 266c343bfa..f3d68d148a 100644 --- a/sidechain-orchestrator/sidechain/proxy.go +++ b/sidechain-orchestrator/sidechain/proxy.go @@ -85,6 +85,14 @@ type WalletParamsNode interface { WalletParams() *chaincfg.Params } +// OwnWalletNode is a Core derived sidechain that creates and keeps its own +// wallet, so the orchestrator seeds none. +type OwnWalletNode interface { + Node + + OwnWallet() +} + // CoreWalletName is the wallet a Bitcoin Core derived sidechain loads. Core // creates none on its own, so the orchestrator seeds this one from the chain's // slot starter and every wallet RPC is scoped to it. From 36e414c9927910fd38cd741e0ae69052b35181cf Mon Sep 17 00:00:00 2001 From: mblowes Date: Sat, 26 Sep 2026 07:58:35 +0200 Subject: [PATCH 2/4] orchestrator: stop and probe a Core fork with its cookie --- .../get_sync_status_test.go | 49 +++++++++++++++++++ sidechain-orchestrator/orchestrator.go | 23 ++++++++- sidechain-orchestrator/stopwait_test.go | 46 +++++++++++++++++ 3 files changed, 116 insertions(+), 2 deletions(-) diff --git a/sidechain-orchestrator/get_sync_status_test.go b/sidechain-orchestrator/get_sync_status_test.go index b9026c3f16..7ac23ab36f 100644 --- a/sidechain-orchestrator/get_sync_status_test.go +++ b/sidechain-orchestrator/get_sync_status_test.go @@ -7,8 +7,11 @@ import ( "net/http" "net/http/httptest" "net/url" + "os" + "path/filepath" "strconv" "strings" + "sync" "sync/atomic" "testing" "time" @@ -328,6 +331,52 @@ func TestGetSyncStatus_NotRunningSidechainCarriesError(t *testing.T) { } } +// A Core fork answers a call without its cookie with an empty 401, so its row +// would show an error for a healthy node. The probe carries the cookie and asks +// only for the height: a Core fork reports no mainchain sync of its own. +func TestGetSyncStatus_ACoreForkIsProbedWithItsCookie(t *testing.T) { + o := newTestOrchestrator(t) + o.explorerHTTPClient = &http.Client{ + Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { + return statusResponse(http.StatusServiceUnavailable), nil + }), + } + datadir := config.FreebankDirs.DatadirNetwork(config.Network(o.CurrentNetwork()), "") + require.NoError(t, os.MkdirAll(datadir, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(datadir, ".cookie"), []byte("__cookie__:secret"), 0o600)) + + var methods []string + var mu sync.Mutex + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if user, password, ok := r.BasicAuth(); !ok || user != "__cookie__" || password != "secret" { + w.WriteHeader(http.StatusUnauthorized) + return + } + method, _ := readJSONRPC(r) + mu.Lock() + methods = append(methods, method) + mu.Unlock() + if method != "getblockcount" { + _, _ = w.Write([]byte(`{"result":null,"error":{"code":-32601,"message":"Method not found"}}`)) + return + } + _, _ = w.Write([]byte(`{"result":42,"error":null}`)) + })) + t.Cleanup(srv.Close) + setSidechainPort(t, o, "freebank", srv.URL) + adoptSidechain(t, o, "freebank") + + out, err := o.GetSyncStatus(context.Background()) + require.NoError(t, err) + slot := out.Sidechains["freebank"] + require.NotNil(t, slot) + assert.Equal(t, "", slot.Error) + assert.Equal(t, int64(42), slot.Blocks) + mu.Lock() + defer mu.Unlock() + assert.Equal(t, []string{"getblockcount"}, methods) +} + func TestGetSyncStatus_EveryL2SidechainHasAPort(t *testing.T) { // Invariant: every L2 sidechain registered in chains_config.json has a // non-zero Port. GetSyncStatus dials the sidechain's JSON-RPC endpoint at diff --git a/sidechain-orchestrator/orchestrator.go b/sidechain-orchestrator/orchestrator.go index f64c444940..0827065b03 100644 --- a/sidechain-orchestrator/orchestrator.go +++ b/sidechain-orchestrator/orchestrator.go @@ -2345,6 +2345,10 @@ func unreachable(err error) bool { if errors.Is(err, errNoStopClient) { return true } + // A Core node with no cookie yet has no RPC server. + if errors.Is(err, os.ErrNotExist) { + return true + } if errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, syscall.EHOSTUNREACH) { return true } @@ -2364,10 +2368,13 @@ func (o *Orchestrator) callBitcoindStopRPC() error { } 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 { @@ -3393,6 +3400,18 @@ func (c *sidechainSyncConnection) Fetch(ctx context.Context) (*ChainSyncResult, if !ok { return nil, fmt.Errorf("unknown sidechain: %s", c.name) } + // A Core fork reports no mainchain sync of its own. + if cfg.IsBitcoinCore { + node, err := nodes.New(cfg.Name, cfg.RPCHost(), cfg.Port, true, config.Network(c.o.CurrentNetwork())) + if err != nil { + return nil, err + } + count, err := node.GetBlockCount(ctx) + if err != nil { + return nil, err + } + return &ChainSyncResult{Blocks: count}, nil + } proxy := sidechain.NewJSONRPCProxy(cfg.RPCHost(), cfg.Port) progress, err := proxy.MainchainSyncProgress(ctx) switch { diff --git a/sidechain-orchestrator/stopwait_test.go b/sidechain-orchestrator/stopwait_test.go index a04cb15d3e..87b14050a5 100644 --- a/sidechain-orchestrator/stopwait_test.go +++ b/sidechain-orchestrator/stopwait_test.go @@ -1,13 +1,21 @@ package orchestrator import ( + "encoding/json" "errors" "fmt" "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" "syscall" "testing" "github.com/stretchr/testify/require" + + "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/config" ) // A stop RPC that never landed must skip the graceful wait. A stop RPC that @@ -21,6 +29,7 @@ func TestUnreachableTellsAMissedStopFromAFlushingDaemon(t *testing.T) { }{ {"no error", nil, false}, {"no client", fmt.Errorf("bitcoind RPC stop: %w: down", errNoStopClient), true}, + {"no cookie yet", fmt.Errorf("read rpc cookie .cookie: %w", os.ErrNotExist), true}, {"connection refused", fmt.Errorf("stop: %w", syscall.ECONNREFUSED), true}, {"dial failed", &net.OpError{Op: "dial", Err: errors.New("no route")}, true}, {"read after the ack", &net.OpError{Op: "read", Err: errors.New("reset by peer")}, false}, @@ -33,3 +42,40 @@ func TestUnreachableTellsAMissedStopFromAFlushingDaemon(t *testing.T) { }) } } + +// A Core fork answers a call without its cookie with an empty 401, which reads +// as a stop that may have landed. Shutdown then waits out the grace period for +// an exit that never comes, so stop has to carry the cookie. +func TestACoreForkStopCarriesItsCookie(t *testing.T) { + o := newTestOrchestrator(t) + + var stopped atomic.Bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if user, password, ok := r.BasicAuth(); !ok || user != "__cookie__" || password != "secret" { + w.WriteHeader(http.StatusUnauthorized) + return + } + var req struct { + Method string `json:"method"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + stopped.Store(req.Method == "stop") + _, _ = w.Write([]byte(`{"result":"FreeBank server stopping","error":null}`)) + })) + t.Cleanup(srv.Close) + setSidechainPort(t, o, "freebank", srv.URL) + cfg, err := o.getConfig("freebank") + require.NoError(t, err) + + // A node that has not written its cookie has no RPC server up yet. + err = o.callSidechainStopRPC(cfg) + require.Error(t, err) + require.True(t, unreachable(err), "a stop that never went out must go straight to the signal: %v", err) + + datadir := config.FreebankDirs.DatadirNetwork(config.Network(o.CurrentNetwork()), "") + require.NoError(t, os.MkdirAll(datadir, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(datadir, ".cookie"), []byte("__cookie__:secret"), 0o600)) + + require.NoError(t, o.callSidechainStopRPC(cfg)) + require.True(t, stopped.Load()) +} From 82a2dec7f581f952a4669725a517f7de4dde7ed7 Mon Sep 17 00:00:00 2001 From: mblowes Date: Sat, 26 Sep 2026 07:58:35 +0200 Subject: [PATCH 3/4] sidechain_core: give FreeBank the Core layout --- sidechain_core/assets/chains_config.json | 12 ++++++------ .../assets/migrations/024_chains_config.json | 19 +++++++++++++++++++ sidechain_core/lib/config/binaries.dart | 11 ++++++----- sidechain_core/lib/config/sidechains.dart | 6 ++++-- 4 files changed, 35 insertions(+), 13 deletions(-) create mode 100644 sidechain_core/assets/migrations/024_chains_config.json diff --git a/sidechain_core/assets/chains_config.json b/sidechain_core/assets/chains_config.json index c33ef36d1e..9a34e7d7de 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,9 +837,9 @@ "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, "color": "orange", "health_check": { "type": "tcp" @@ -849,9 +849,9 @@ "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..bbdc968120 --- /dev/null +++ b/sidechain_core/assets/migrations/024_chains_config.json @@ -0,0 +1,19 @@ +{ + "version": 24, + "migration": "patch", + "binaries": { + "freebank": { + "port": 8454, + "is_bitcoin_core": true, + "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 [], From e764bb0ca207b9d18f9e8f179ad850ec94ead322 Mon Sep 17 00:00:00 2001 From: mblowes Date: Sat, 26 Sep 2026 07:58:50 +0200 Subject: [PATCH 4/4] bitwindow: show FreeBank's state and settings --- bitwindow/lib/main.dart | 2 + bitwindow/lib/pages/sidechains_page.dart | 4 + bitwindow/test/sidechains_table_test.dart | 96 +++++++++++++++ .../lib/providers/backend_state_provider.dart | 2 + .../providers/binaries/binary_provider.dart | 1 + sidechain_core/lib/rpcs/freebank_rpc.dart | 115 ++++++++++++++++++ sidechain_core/lib/sidechain_core.dart | 1 + sidechain_core/test/freebank_rpc_test.dart | 90 ++++++++++++++ 8 files changed, 311 insertions(+) create mode 100644 sidechain_core/lib/rpcs/freebank_rpc.dart create mode 100644 sidechain_core/test/freebank_rpc_test.dart diff --git a/bitwindow/lib/main.dart b/bitwindow/lib/main.dart index d8e64967c8..d347a09994 100644 --- a/bitwindow/lib/main.dart +++ b/bitwindow/lib/main.dart @@ -232,6 +232,7 @@ Future<(Directory, File, Logger)> init(String arguments) async { GetIt.I.registerSingleton(CoinShiftLive()); GetIt.I.registerSingleton(PhotonLive()); GetIt.I.registerSingleton(TruthcoinLive()); + GetIt.I.registerSingleton(FreeBankLive()); registerNodeMode(); @@ -262,6 +263,7 @@ Future<(Directory, File, Logger)> init(String arguments) async { GetIt.I.get(), GetIt.I.get(), GetIt.I.get(), + GetIt.I.get(), ], ), ); diff --git a/bitwindow/lib/pages/sidechains_page.dart b/bitwindow/lib/pages/sidechains_page.dart index dd3137a14d..ed2ebbe0e1 100644 --- a/bitwindow/lib/pages/sidechains_page.dart +++ b/bitwindow/lib/pages/sidechains_page.dart @@ -736,6 +736,7 @@ class SidechainsViewModel extends BaseViewModel with ChangeTrackingMixin { Truthcoin() => SidechainType.SIDECHAIN_TYPE_TRUTHCOIN, Photon() => SidechainType.SIDECHAIN_TYPE_PHOTON, CoinShift() => SidechainType.SIDECHAIN_TYPE_COINSHIFT, + FreeBank() => SidechainType.SIDECHAIN_TYPE_FREEBANK, _ => null, }; @@ -898,6 +899,7 @@ class SidechainsViewModel extends BaseViewModel with ChangeTrackingMixin { var b when b is Truthcoin => GetIt.I.isRegistered() ? GetIt.I.get() : null, var b when b is Photon => GetIt.I.isRegistered() ? GetIt.I.get() : null, var b when b is CoinShift => GetIt.I.isRegistered() ? GetIt.I.get() : null, + var b when b is FreeBank => GetIt.I.isRegistered() ? GetIt.I.get() : null, _ => null, }; @@ -949,6 +951,7 @@ class SidechainsViewModel extends BaseViewModel with ChangeTrackingMixin { var b when b is Truthcoin => (GetIt.I.isRegistered() ? GetIt.I.get() : null), var b when b is Photon => (GetIt.I.isRegistered() ? GetIt.I.get() : null), var b when b is CoinShift => (GetIt.I.isRegistered() ? GetIt.I.get() : null), + var b when b is FreeBank => (GetIt.I.isRegistered() ? GetIt.I.get() : null), _ => null, }; } @@ -1645,6 +1648,7 @@ class _DepositModalState extends State { ZSide() => GetIt.I.isRegistered() ? GetIt.I.get() : null, CoinShift() => GetIt.I.isRegistered() ? GetIt.I.get() : null, Thunder() => GetIt.I.isRegistered() ? GetIt.I.get() : null, + FreeBank() => GetIt.I.isRegistered() ? GetIt.I.get() : null, _ => null, }; diff --git a/bitwindow/test/sidechains_table_test.dart b/bitwindow/test/sidechains_table_test.dart index 63395d15bd..0c594f507a 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() { + final base = FreeBank(); + return base.copyWith( + metadata: base.metadata.copyWith( + remoteTimestamp: null, + downloadedTimestamp: DateTime(2026, 1), + binaryPath: File('/tmp/freebankd'), + 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,68 @@ void main() { expect(find.text('—'), findsOneWidget); }); + // FreeBank reads all its state through the orchestrator. + group('FreeBank', () { + late _Orchestrator orchestrator; + late FreeBankRPC freeBankRPC; + + Future setUpFreeBank() async { + GetIt.I.registerSingleton(_Binaries([_freeBank()])); + 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 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 chain that is not downloaded offers a primary Download', (tester) async { setUpChain(_thunder(downloaded: false)); await pumpTable(tester); diff --git a/sidechain_core/lib/providers/backend_state_provider.dart b/sidechain_core/lib/providers/backend_state_provider.dart index 8abb70d9f7..8f792a599c 100644 --- a/sidechain_core/lib/providers/backend_state_provider.dart +++ b/sidechain_core/lib/providers/backend_state_provider.dart @@ -14,6 +14,7 @@ import 'package:sidechain_core/rpcs/coinshift_rpc.dart'; import 'package:sidechain_core/rpcs/enforcer_rpc.dart'; import 'package:sidechain_core/rpcs/bitcoind_connection.dart'; import 'package:sidechain_core/env.dart'; +import 'package:sidechain_core/rpcs/freebank_rpc.dart'; import 'package:sidechain_core/rpcs/orchestrator_rpc.dart'; import 'package:sidechain_core/rpcs/photon_rpc.dart'; import 'package:sidechain_core/rpcs/thunder_rpc.dart'; @@ -219,6 +220,7 @@ class BackendStateProvider extends ChangeNotifier { 'truthcoin' => GetIt.I.isRegistered() ? GetIt.I.get() : null, 'photon' => GetIt.I.isRegistered() ? GetIt.I.get() : null, 'coinshift' => GetIt.I.isRegistered() ? GetIt.I.get() : null, + 'freebank' => GetIt.I.isRegistered() ? GetIt.I.get() : null, _ => null, }; } diff --git a/sidechain_core/lib/providers/binaries/binary_provider.dart b/sidechain_core/lib/providers/binaries/binary_provider.dart index cb6c1f0dc0..f658c46239 100644 --- a/sidechain_core/lib/providers/binaries/binary_provider.dart +++ b/sidechain_core/lib/providers/binaries/binary_provider.dart @@ -654,6 +654,7 @@ class BinaryProvider extends ChangeNotifier { var b when b is BitAssets => _getRegistered(), var b when b is ZSide => _getRegistered(), var b when b is CoinShift => _getRegistered(), + var b when b is FreeBank => _getRegistered(), _ => null, }; } diff --git a/sidechain_core/lib/rpcs/freebank_rpc.dart b/sidechain_core/lib/rpcs/freebank_rpc.dart new file mode 100644 index 0000000000..38bca7e589 --- /dev/null +++ b/sidechain_core/lib/rpcs/freebank_rpc.dart @@ -0,0 +1,115 @@ +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(); + + @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 [], + ); + } + + 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; + } + + @override + Future getDepositAddress() async => throw _unsupported('addresses'); + + @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..1424b2d80a --- /dev/null +++ b/sidechain_core/test/freebank_rpc_test.dart @@ -0,0 +1,90 @@ +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 = []; + GetSidechainBalanceResponse balance = GetSidechainBalanceResponse(); + GetSyncStatusResponse syncStatus = GetSyncStatusResponse(); + + @override + Future getSidechainBalance(BinaryType sidechain) async { + balanceReads.add(sidechain); + return balance; + } + + @override + Future getSyncStatus() async => syncStatus; + + @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('a call the orchestrator does not serve is unsupported', () async { + await expectLater(rpc.getDepositAddress(), throwsUnsupportedError); + await expectLater(rpc.sideSend('addr', 1, false), throwsUnsupportedError); + await expectLater(rpc.withdraw('addr', 1000, 10, 10), throwsUnsupportedError); + }); +}