From 03d4fe9cb5de33997da687120331368db9007ffd Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Thu, 11 Jun 2026 21:13:46 -0400 Subject: [PATCH 1/4] Dial primary plus fallback arkd-wallets Add ARKD_WALLET_FALLBACK_ADDRS so arkd can connect to a primary arkd-wallet plus additional LP wallets. Fallbacks are dialed and validated (reachable, same network, initialized and unlocked) at startup; the primary is unchanged and remains the sole source of the forfeit pubkey, addresses and signing. Fallbacks are not used by sweep yet. The regtest compose stack now runs a second arkd-wallet so the existing e2e suite exercises arkd with a fallback plugged in. --- README.md | 11 +++ docker-compose.regtest.yml | 24 +++++++ internal/config/config.go | 104 ++++++++++++++++++++++++----- internal/config/config_test.go | 97 +++++++++++++++++++++++++++ internal/interface/grpc/service.go | 30 +++++++++ internal/test/e2e/utils_test.go | 30 +++++---- 6 files changed, 268 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index ada767c92..53a396d3f 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ The `arkd` server can be configured using environment variables and the admin se | `ARKD_REDIS_NUM_OF_RETRIES` | Maximum number of retries for Redis write operations in case of conflicts | - | | `ARKD_ESPLORA_URL` | Esplora API URL | `https://blockstream.info/api` | | `ARKD_WALLET_ADDR` | The arkd wallet address to connect to in the form `host:port` | - | +| `ARKD_WALLET_FALLBACK_ADDRS` | Additional arkd-wallet addresses (other LPs), comma-separated `host:port` list | - | | `ARKD_SIGNER_ADDR` | The signer address to connect to in the form `host:port` | value of `ARKD_WALLET_ADDR` | | `ARKD_NO_MACAROONS` | Disable macaroon authentication | `false` | | `ARKD_NO_TLS` | Disable TLS | `true` | @@ -180,6 +181,16 @@ To connect `arkd` to `arkd-wallet` use this environment variable: export ARKD_WALLET_ADDR=localhost:6060 ``` +### Configuring multiple LP wallets + +`arkd` can be backed by a primary `arkd-wallet` plus additional wallets belonging to other liquidity providers. List the additional wallets with `ARKD_WALLET_FALLBACK_ADDRS`, a comma-separated list of `host:port` addresses: + +```sh +export ARKD_WALLET_FALLBACK_ADDRS=localhost:6061,localhost:6062 +``` + +Every wallet, primary and fallback, must be initialized and unlocked out of band (see [Setup arkd](#setup-arkd)) and must be on the same network as the primary; `arkd` validates this at startup and refuses to start otherwise. The primary wallet remains the sole source of the forfeit address, connector address, scanning and signing. The additional wallets are used only as sweep fallbacks; that wiring lands in a later change. + ### Connect to signer By default, `arkd` makes use of the provided `arkd-wallet` also as signer, but you can customize its url either via environment variable or via API. diff --git a/docker-compose.regtest.yml b/docker-compose.regtest.yml index 3967bc304..5303d65a4 100644 --- a/docker-compose.regtest.yml +++ b/docker-compose.regtest.yml @@ -61,6 +61,26 @@ services: - ARKD_WALLET_DEPRECATED_SIGNER_KEYS=${ARKD_WALLET_DEPRECATED_SIGNER_KEYS:-} volumes: - arkd-wallet-volume:/app/data + # A second arkd-wallet acting as an additional LP wallet, wired into arkd as a + # sweep fallback via ARKD_WALLET_FALLBACK_ADDRS. + arkd-wallet-2: + restart: unless-stopped + build: + context: . + dockerfile: arkdwallet.Dockerfile + container_name: arkd-wallet-2 + depends_on: + - nbxplorer + ports: + - "6061:6060" + environment: + - ARKD_WALLET_LOG_LEVEL=5 + - ARKD_WALLET_NBXPLORER_URL=http://nbxplorer:32838 + - ARKD_WALLET_DATADIR=./data/regtest-2 + - ARKD_WALLET_NETWORK=regtest + - ARKD_WALLET_SIGNER_KEY=19422b10efd05403820ff6a3365422be2fc5f07f34a6d1603f7298328f0f80f6 + volumes: + - arkd-wallet-2-volume:/app/data redis: restart: unless-stopped image: redis:7-alpine @@ -83,6 +103,7 @@ services: restart: unless-stopped depends_on: - arkd-wallet + - arkd-wallet-2 - pg - redis ports: @@ -107,6 +128,7 @@ services: - ARKD_BAN_THRESHOLD=1 - ARKD_DATADIR=./data/regtest - ARKD_WALLET_ADDR=arkd-wallet:6060 + - ARKD_WALLET_FALLBACK_ADDRS=arkd-wallet-2:6060 - ARKD_ESPLORA_URL=http://chopsticks:3000 - ARKD_DB_TYPE=${ARKD_DB_TYPE:-sqlite} - ARKD_PG_DB_URL=${ARKD_PG_DB_URL:-} @@ -123,6 +145,8 @@ services: volumes: arkd-wallet-volume: name: arkd-wallet-volume + arkd-wallet-2-volume: + name: arkd-wallet-2-volume arkd-volume: name: arkd-volume diff --git a/internal/config/config.go b/internal/config/config.go index b7576ecc3..23c788c02 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -95,6 +95,7 @@ type Config struct { RedisUrl string RedisTxNumOfRetries int WalletAddr string + WalletFallbackAddrs []string SignerAddr string VtxoTreeExpiry arklib.RelativeLocktime UnilateralExitDelay arklib.RelativeLocktime @@ -145,21 +146,22 @@ type Config struct { MaxConcurrentStreams uint32 StreamConnPoolSize uint32 - fee ports.FeeManager - repo ports.RepoManager - svc application.Service - adminSvc application.AdminService - wallet ports.WalletService - signer ports.SignerService - txBuilder ports.TxBuilder - scanner ports.BlockchainScanner - scheduler ports.SchedulerService - unlocker ports.Unlocker - liveStore ports.LiveStore - network *arklib.Network - roundReportSvc application.RoundReportService - alerts ports.Alerts - settings *domain.Settings + fee ports.FeeManager + repo ports.RepoManager + svc application.Service + adminSvc application.AdminService + wallet ports.WalletService + walletFallbacks []ports.WalletService + signer ports.SignerService + txBuilder ports.TxBuilder + scanner ports.BlockchainScanner + scheduler ports.SchedulerService + unlocker ports.Unlocker + liveStore ports.LiveStore + network *arklib.Network + roundReportSvc application.RoundReportService + alerts ports.Alerts + settings *domain.Settings } func (c *Config) String() string { @@ -180,6 +182,7 @@ func (c *Config) String() string { var ( Datadir = "DATADIR" WalletAddr = "WALLET_ADDR" + WalletFallbackAddrs = "WALLET_FALLBACK_ADDRS" SignerAddr = "SIGNER_ADDR" SessionDuration = "SESSION_DURATION" BanDuration = "BAN_DURATION" @@ -442,6 +445,7 @@ func LoadConfig() (*Config, error) { return &Config{ Datadir: viper.GetString(Datadir), WalletAddr: viper.GetString(WalletAddr), + WalletFallbackAddrs: parseWalletFallbackAddrs(viper.GetString(WalletFallbackAddrs)), SignerAddr: signerAddr, SessionDuration: viper.GetInt64(SessionDuration), BanDuration: viper.GetInt64(BanDuration), @@ -664,6 +668,10 @@ func (c *Config) WalletService() ports.WalletService { return c.wallet } +func (c *Config) FallbackWalletServices() []ports.WalletService { + return c.walletFallbacks +} + func (c *Config) UnlockerService() ports.Unlocker { return c.unlocker } @@ -799,22 +807,86 @@ func (c *Config) repoManager() error { return nil } +// newWalletClient is the wallet client constructor, indirected so tests can +// stub out the gRPC dial. +var newWalletClient = walletclient.New + func (c *Config) walletService() error { arkWallet := c.WalletAddr if arkWallet == "" { return fmt.Errorf("missing ark wallet address") } - walletSvc, network, err := walletclient.New(arkWallet, c.OtelCollectorEndpoint) + walletSvc, network, err := newWalletClient(arkWallet, c.OtelCollectorEndpoint) if err != nil { return err } c.wallet = walletSvc c.network = network + + fallbacks, err := c.dialFallbackWallets() + if err != nil { + return err + } + c.walletFallbacks = fallbacks + return nil } +// dialFallbackWallets dials the configured fallback arkd-wallets and validates +// that each one is reachable and on the same network as the primary. Fallback +// wallets belong to additional liquidity providers and are used only as sweep +// fallbacks; the primary remains the sole source of the forfeit pubkey, +// addresses and signing. Any failure is fatal so a misconfigured wallet is +// surfaced at startup rather than at sweep time. +func (c *Config) dialFallbackWallets() ([]ports.WalletService, error) { + fallbacks := make([]ports.WalletService, 0, len(c.WalletFallbackAddrs)) + for _, addr := range c.WalletFallbackAddrs { + if addr == "" { + continue + } + fbSvc, fbNetwork, err := newWalletClient(addr, c.OtelCollectorEndpoint) + if err != nil { + closeWallets(fallbacks) + return nil, fmt.Errorf("failed to dial fallback wallet %q: %w", addr, err) + } + if fbNetwork.Name != c.network.Name { + fbSvc.Close() + closeWallets(fallbacks) + return nil, fmt.Errorf( + "fallback wallet %q is on network %q, expected %q (same as primary)", + addr, fbNetwork.Name, c.network.Name, + ) + } + log.Infof("dialed fallback wallet %q on network %s", addr, fbNetwork.Name) + fallbacks = append(fallbacks, fbSvc) + } + return fallbacks, nil +} + +func closeWallets(wallets []ports.WalletService) { + for _, w := range wallets { + w.Close() + } +} + +// parseWalletFallbackAddrs splits a comma-separated list of wallet addresses, +// trimming whitespace and dropping empty entries. +func parseWalletFallbackAddrs(raw string) []string { + parts := strings.Split(raw, ",") + addrs := make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + addrs = append(addrs, p) + } + } + if len(addrs) == 0 { + return nil + } + return addrs +} + func (c *Config) signerService() error { signer := c.SignerAddr if signer == "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 1a5357063..779796289 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,9 +1,11 @@ package config import ( + "fmt" "testing" "time" + "github.com/arkade-os/arkd/internal/core/ports" arklib "github.com/arkade-os/arkd/pkg/ark-lib" "github.com/stretchr/testify/require" ) @@ -270,3 +272,98 @@ func TestConfigStringRedactsSecrets(t *testing.T) { }) } } + +func TestParseWalletFallbackAddrs(t *testing.T) { + tests := []struct { + name string + raw string + want []string + }{ + {"empty", "", nil}, + {"single", "localhost:6061", []string{"localhost:6061"}}, + {"multiple", "a:6060,b:6060,c:6060", []string{"a:6060", "b:6060", "c:6060"}}, + {"trims whitespace", "a:6060, b:6060 ,c:6060", []string{"a:6060", "b:6060", "c:6060"}}, + {"drops empty entries", "a:6060,,b:6060,", []string{"a:6060", "b:6060"}}, + {"only separators", " , , ", nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, parseWalletFallbackAddrs(tt.raw)) + }) + } +} + +// fakeFallbackWallet is a ports.WalletService that only implements Close; via +// the embedded nil interface every other method is unused by these tests. +type fakeFallbackWallet struct { + ports.WalletService + closed *int +} + +func (f *fakeFallbackWallet) Close() { *f.closed++ } + +func TestDialFallbackWallets(t *testing.T) { + orig := newWalletClient + t.Cleanup(func() { newWalletClient = orig }) + + regtest := &arklib.Network{Name: "regtest"} + testnet := &arklib.Network{Name: "testnet"} + + t.Run("all on the same network", func(t *testing.T) { + var closes int + newWalletClient = func(_, _ string) (ports.WalletService, *arklib.Network, error) { + return &fakeFallbackWallet{closed: &closes}, regtest, nil + } + + c := &Config{network: regtest, WalletFallbackAddrs: []string{"a:6060", "b:6060"}} + fbs, err := c.dialFallbackWallets() + + require.NoError(t, err) + require.Len(t, fbs, 2) + require.Zero(t, closes) + }) + + t.Run("network mismatch hard-fails and closes dialed", func(t *testing.T) { + var closes, calls int + newWalletClient = func(_, _ string) (ports.WalletService, *arklib.Network, error) { + calls++ + net := regtest + if calls == 2 { + net = testnet + } + return &fakeFallbackWallet{closed: &closes}, net, nil + } + + c := &Config{network: regtest, WalletFallbackAddrs: []string{"a:6060", "b:6060"}} + fbs, err := c.dialFallbackWallets() + + require.Error(t, err) + require.Nil(t, fbs) + require.Contains(t, err.Error(), "b:6060") + require.Contains(t, err.Error(), "testnet") + require.Contains(t, err.Error(), "regtest") + // The mismatched wallet and the previously dialed one are both closed. + require.Equal(t, 2, closes) + }) + + t.Run("dial error hard-fails and closes dialed", func(t *testing.T) { + var closes, calls int + newWalletClient = func(_, _ string) (ports.WalletService, *arklib.Network, error) { + calls++ + if calls == 2 { + return nil, nil, fmt.Errorf("connection refused") + } + return &fakeFallbackWallet{closed: &closes}, regtest, nil + } + + c := &Config{network: regtest, WalletFallbackAddrs: []string{"a:6060", "b:6060"}} + fbs, err := c.dialFallbackWallets() + + require.Error(t, err) + require.Nil(t, fbs) + require.Contains(t, err.Error(), "b:6060") + // The first, successfully dialed fallback is closed. + require.Equal(t, 1, closes) + }) +} diff --git a/internal/interface/grpc/service.go b/internal/interface/grpc/service.go index 315be7229..ad1d073ce 100644 --- a/internal/interface/grpc/service.go +++ b/internal/interface/grpc/service.go @@ -232,6 +232,12 @@ func (s *service) stop() { log.Warn("failed to close admin transport connection") } } + + // Close the fallback wallet connections (the primary is closed by the app + // service). arkd owns these dialed connections, nothing else does. + for _, fb := range s.appConfig.FallbackWalletServices() { + fb.Close() + } } func (s *service) startAppServices() error { @@ -683,6 +689,30 @@ func (s *service) ensureWalletReady() error { ) } + // Fallback wallets must also be initialized and unlocked out of band: they + // are needed to sign sweeps when the primary cannot. Balance is not checked + // (they are not liquidity sources). + for i, fb := range s.appConfig.FallbackWalletServices() { + fbStatus, err := fb.Status(ctx) + if err != nil { + return fmt.Errorf("failed to get fallback wallet %d status: %s", i, err) + } + if !fbStatus.IsInitialized() { + return fmt.Errorf( + "fallback wallet %d is not initialized: "+ + "initialize the arkd-wallet out of band before starting arkd", + i, + ) + } + if !fbStatus.IsUnlocked() { + return fmt.Errorf( + "fallback wallet %d is locked: "+ + "unlock the arkd-wallet out of band before starting arkd", + i, + ) + } + } + return nil } diff --git a/internal/test/e2e/utils_test.go b/internal/test/e2e/utils_test.go index c3c1cb1c2..bf26acc20 100644 --- a/internal/test/e2e/utils_test.go +++ b/internal/test/e2e/utils_test.go @@ -42,6 +42,7 @@ import ( const ( adminUrl = "http://127.0.0.1:7071" walletUrl = "http://127.0.0.1:6060" + walletUrl2 = "http://127.0.0.1:6061" serverUrl = "127.0.0.1:7070" explorerUrl = "http://127.0.0.1:3000" ) @@ -625,10 +626,14 @@ func setupArkd() error { Timeout: 15 * time.Second, } - // arkd no longer initializes or unlocks the wallet: drive the arkd-wallet - // directly so it is initialized and unlocked. arkd hard-fails to start while - // the wallet is locked, so it may have been crash-looping until now. - if err := setupArkdWallet(httpClient); err != nil { + // arkd no longer initializes or unlocks the wallets: drive each arkd-wallet + // (the primary and every fallback LP wallet) directly so they are all + // initialized and unlocked. arkd hard-fails to start while any of them is + // locked, so it may have been crash-looping until now. + if err := setupArkdWalletAt(httpClient, walletUrl); err != nil { + return err + } + if err := setupArkdWalletAt(httpClient, walletUrl2); err != nil { return err } @@ -645,12 +650,13 @@ func setupArkd() error { return refill(httpClient) } -// setupArkdWallet initializes and unlocks the arkd-wallet directly through its -// own gateway, which is what arkd now expects to be done out of band. -func setupArkdWallet(httpClient *http.Client) error { +// setupArkdWalletAt initializes and unlocks the arkd-wallet reachable at baseURL +// directly through its own gateway, which is what arkd now expects to be done +// out of band for the primary and every fallback wallet. +func setupArkdWalletAt(httpClient *http.Client, baseURL string) error { // The arkd-wallet gateway may still be coming up; retry the first read until // it is reachable. - statusURL := fmt.Sprintf("%s/v1/wallet/status", walletUrl) + statusURL := fmt.Sprintf("%s/v1/wallet/status", baseURL) var status *statusResp ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() @@ -658,7 +664,7 @@ func setupArkdWallet(httpClient *http.Client) error { for status == nil { select { case <-timeout: - return fmt.Errorf("timed out waiting for arkd-wallet to become reachable") + return fmt.Errorf("timed out waiting for arkd-wallet at %s to become reachable", baseURL) case <-ticker.C: s, err := get[statusResp](httpClient, statusURL, "wallet status") if err != nil { @@ -670,13 +676,13 @@ func setupArkdWallet(httpClient *http.Client) error { } if !status.Initialized { - url := fmt.Sprintf("%s/v1/wallet/seed", walletUrl) + url := fmt.Sprintf("%s/v1/wallet/seed", baseURL) seed, err := get[seedResp](httpClient, url, "wallet seed") if err != nil { return err } - url = fmt.Sprintf("%s/v1/wallet/create", walletUrl) + url = fmt.Sprintf("%s/v1/wallet/create", baseURL) body, err := json.Marshal(map[string]string{"seed": seed.Seed, "password": password}) if err != nil { return fmt.Errorf("failed to encode create wallet body: %s", err) @@ -687,7 +693,7 @@ func setupArkdWallet(httpClient *http.Client) error { } if !status.Unlocked { - url := fmt.Sprintf("%s/v1/wallet/unlock", walletUrl) + url := fmt.Sprintf("%s/v1/wallet/unlock", baseURL) body, err := json.Marshal(map[string]string{"password": password}) if err != nil { return fmt.Errorf("failed to encode unlock wallet body: %s", err) From 98f24639d75d390de9dc017e86635c4d550ca2e5 Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:54:48 -0400 Subject: [PATCH 2/4] fix(config): identify fallback wallet by host:port in readiness errors Pair each dialed fallback wallet with its address (FallbackWallet) so ensureWalletReady reports the specific wallet that failed (host:port) instead of a positional index, which isn't actionable when the error surfaces. Also clarify the comment on why fallback balance isn't checked. --- internal/config/config.go | 22 +++++++++++++++------- internal/interface/grpc/service.go | 22 +++++++++++----------- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 23c788c02..0f2339db6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -151,7 +151,7 @@ type Config struct { svc application.Service adminSvc application.AdminService wallet ports.WalletService - walletFallbacks []ports.WalletService + walletFallbacks []FallbackWallet signer ports.SignerService txBuilder ports.TxBuilder scanner ports.BlockchainScanner @@ -668,7 +668,15 @@ func (c *Config) WalletService() ports.WalletService { return c.wallet } -func (c *Config) FallbackWalletServices() []ports.WalletService { +// FallbackWallet pairs a dialed fallback wallet client with the address it was +// dialed at, so failures can name the specific wallet (host:port) rather than a +// positional index. +type FallbackWallet struct { + Addr string + Service ports.WalletService +} + +func (c *Config) FallbackWallets() []FallbackWallet { return c.walletFallbacks } @@ -840,8 +848,8 @@ func (c *Config) walletService() error { // fallbacks; the primary remains the sole source of the forfeit pubkey, // addresses and signing. Any failure is fatal so a misconfigured wallet is // surfaced at startup rather than at sweep time. -func (c *Config) dialFallbackWallets() ([]ports.WalletService, error) { - fallbacks := make([]ports.WalletService, 0, len(c.WalletFallbackAddrs)) +func (c *Config) dialFallbackWallets() ([]FallbackWallet, error) { + fallbacks := make([]FallbackWallet, 0, len(c.WalletFallbackAddrs)) for _, addr := range c.WalletFallbackAddrs { if addr == "" { continue @@ -860,14 +868,14 @@ func (c *Config) dialFallbackWallets() ([]ports.WalletService, error) { ) } log.Infof("dialed fallback wallet %q on network %s", addr, fbNetwork.Name) - fallbacks = append(fallbacks, fbSvc) + fallbacks = append(fallbacks, FallbackWallet{Addr: addr, Service: fbSvc}) } return fallbacks, nil } -func closeWallets(wallets []ports.WalletService) { +func closeWallets(wallets []FallbackWallet) { for _, w := range wallets { - w.Close() + w.Service.Close() } } diff --git a/internal/interface/grpc/service.go b/internal/interface/grpc/service.go index ad1d073ce..af22adc1d 100644 --- a/internal/interface/grpc/service.go +++ b/internal/interface/grpc/service.go @@ -235,8 +235,8 @@ func (s *service) stop() { // Close the fallback wallet connections (the primary is closed by the app // service). arkd owns these dialed connections, nothing else does. - for _, fb := range s.appConfig.FallbackWalletServices() { - fb.Close() + for _, fb := range s.appConfig.FallbackWallets() { + fb.Service.Close() } } @@ -690,25 +690,25 @@ func (s *service) ensureWalletReady() error { } // Fallback wallets must also be initialized and unlocked out of band: they - // are needed to sign sweeps when the primary cannot. Balance is not checked - // (they are not liquidity sources). - for i, fb := range s.appConfig.FallbackWalletServices() { - fbStatus, err := fb.Status(ctx) + // co-sign sweeps of outputs the primary's key can't spend. arkd never sources + // liquidity from them (only the primary funds batches), so balance is not checked. + for _, fb := range s.appConfig.FallbackWallets() { + fbStatus, err := fb.Service.Status(ctx) if err != nil { - return fmt.Errorf("failed to get fallback wallet %d status: %s", i, err) + return fmt.Errorf("failed to get fallback wallet %q status: %s", fb.Addr, err) } if !fbStatus.IsInitialized() { return fmt.Errorf( - "fallback wallet %d is not initialized: "+ + "fallback wallet %q is not initialized: "+ "initialize the arkd-wallet out of band before starting arkd", - i, + fb.Addr, ) } if !fbStatus.IsUnlocked() { return fmt.Errorf( - "fallback wallet %d is locked: "+ + "fallback wallet %q is locked: "+ "unlock the arkd-wallet out of band before starting arkd", - i, + fb.Addr, ) } } From 2738e6585fb90de6ba31b20e6679b214db3ba134 Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:58:32 -0400 Subject: [PATCH 3/4] docs: note fallback wallets are not yet used for sweeps On this branch arkd only dials and readiness-checks fallback wallets at startup; sweep signing isn't wired up yet. Reword the docker-compose and dialFallbackWallets comments so they don't imply active sweep-fallback use. --- docker-compose.regtest.yml | 5 +++-- internal/config/config.go | 9 +++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docker-compose.regtest.yml b/docker-compose.regtest.yml index 5303d65a4..62105c337 100644 --- a/docker-compose.regtest.yml +++ b/docker-compose.regtest.yml @@ -61,8 +61,9 @@ services: - ARKD_WALLET_DEPRECATED_SIGNER_KEYS=${ARKD_WALLET_DEPRECATED_SIGNER_KEYS:-} volumes: - arkd-wallet-volume:/app/data - # A second arkd-wallet acting as an additional LP wallet, wired into arkd as a - # sweep fallback via ARKD_WALLET_FALLBACK_ADDRS. + # A second arkd-wallet acting as an additional LP wallet, registered with arkd + # via ARKD_WALLET_FALLBACK_ADDRS. arkd currently only dials it and verifies it + # is initialized and unlocked at startup; it is not yet used to sign sweeps. arkd-wallet-2: restart: unless-stopped build: diff --git a/internal/config/config.go b/internal/config/config.go index 0f2339db6..ef9b2ce80 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -844,10 +844,11 @@ func (c *Config) walletService() error { // dialFallbackWallets dials the configured fallback arkd-wallets and validates // that each one is reachable and on the same network as the primary. Fallback -// wallets belong to additional liquidity providers and are used only as sweep -// fallbacks; the primary remains the sole source of the forfeit pubkey, -// addresses and signing. Any failure is fatal so a misconfigured wallet is -// surfaced at startup rather than at sweep time. +// wallets belong to additional liquidity providers and are intended as sweep +// fallbacks, though arkd does not yet use them to sign sweeps; for now they are +// only dialed and readiness-checked. The primary remains the sole source of the +// forfeit pubkey, addresses and signing. Any failure is fatal so a misconfigured +// wallet is surfaced at startup. func (c *Config) dialFallbackWallets() ([]FallbackWallet, error) { fallbacks := make([]FallbackWallet, 0, len(c.WalletFallbackAddrs)) for _, addr := range c.WalletFallbackAddrs { From ba566dab55c9003cfd44842451352578d227cc5c Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:08:05 -0400 Subject: [PATCH 4/4] feat(config): reject fallback wallet that duplicates primary or another fallback Fail fast at dial time if a fallback address equals the primary WalletAddr or repeats another fallback, instead of silently dialing the same wallet twice (pointless now, double-spend/lock-contention risk once fallbacks sign sweeps). Matching is by literal address. --- internal/config/config.go | 12 ++++++++++ internal/config/config_test.go | 44 ++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index ef9b2ce80..f08086449 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -851,10 +851,22 @@ func (c *Config) walletService() error { // wallet is surfaced at startup. func (c *Config) dialFallbackWallets() ([]FallbackWallet, error) { fallbacks := make([]FallbackWallet, 0, len(c.WalletFallbackAddrs)) + seen := make(map[string]struct{}, len(c.WalletFallbackAddrs)) for _, addr := range c.WalletFallbackAddrs { if addr == "" { continue } + // Reject a fallback that duplicates the primary or another fallback. + if addr == c.WalletAddr { + closeWallets(fallbacks) + return nil, fmt.Errorf("fallback wallet %q is the same as the primary wallet", addr) + } + if _, dup := seen[addr]; dup { + closeWallets(fallbacks) + return nil, fmt.Errorf("duplicate fallback wallet %q", addr) + } + seen[addr] = struct{}{} + fbSvc, fbNetwork, err := newWalletClient(addr, c.OtelCollectorEndpoint) if err != nil { closeWallets(fallbacks) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 779796289..44e2b882e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -366,4 +366,48 @@ func TestDialFallbackWallets(t *testing.T) { // The first, successfully dialed fallback is closed. require.Equal(t, 1, closes) }) + + t.Run("fallback equal to primary hard-fails", func(t *testing.T) { + var closes, calls int + newWalletClient = func(_, _ string) (ports.WalletService, *arklib.Network, error) { + calls++ + return &fakeFallbackWallet{closed: &closes}, regtest, nil + } + + c := &Config{ + network: regtest, + WalletAddr: "primary:6060", + WalletFallbackAddrs: []string{"a:6060", "primary:6060"}, + } + fbs, err := c.dialFallbackWallets() + + require.Error(t, err) + require.Nil(t, fbs) + require.Contains(t, err.Error(), "primary:6060") + require.Contains(t, err.Error(), "same as the primary") + // The first, successfully dialed fallback is closed; the primary-equal + // entry is rejected before dialing. + require.Equal(t, 1, closes) + require.Equal(t, 1, calls) + }) + + t.Run("duplicate fallback hard-fails", func(t *testing.T) { + var closes, calls int + newWalletClient = func(_, _ string) (ports.WalletService, *arklib.Network, error) { + calls++ + return &fakeFallbackWallet{closed: &closes}, regtest, nil + } + + c := &Config{network: regtest, WalletFallbackAddrs: []string{"a:6060", "a:6060"}} + fbs, err := c.dialFallbackWallets() + + require.Error(t, err) + require.Nil(t, fbs) + require.Contains(t, err.Error(), "duplicate fallback wallet") + require.Contains(t, err.Error(), "a:6060") + // The first dial succeeded and is closed; the duplicate is rejected + // before dialing. + require.Equal(t, 1, closes) + require.Equal(t, 1, calls) + }) }