diff --git a/README.md b/README.md index ce2355cd2..26b022451 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ The `arkd` server can be configured using environment variables and the admin se | `ARKD_ARKADE_EXPLORER_URL` | Arkade explorer URL | `https://arkade.space` | | `ARKD_ALERT_MANAGER_URL` | AlertManager URL for pushing alerts | - | | `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` | @@ -186,6 +187,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 5097bc24b..478b59226 100644 --- a/docker-compose.regtest.yml +++ b/docker-compose.regtest.yml @@ -66,6 +66,32 @@ services: interval: 2s timeout: 2s retries: 60 + # 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: + 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 + healthcheck: + test: ["CMD", "nc", "-z", "localhost", "6060"] + interval: 2s + timeout: 2s + retries: 60 redis: restart: unless-stopped image: redis:7-alpine @@ -89,6 +115,8 @@ services: depends_on: arkd-wallet: condition: service_healthy + arkd-wallet-2: + condition: service_healthy pg: condition: service_started redis: @@ -115,6 +143,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:-} @@ -129,6 +158,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 0b6e690b0..a349d3317 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 @@ -150,20 +151,21 @@ type Config struct { // empty, every session starts a round (legacy behaviour). BatchTrigger string - 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 - alerts ports.Alerts - settings *domain.Settings + fee ports.FeeManager + repo ports.RepoManager + svc application.Service + adminSvc application.AdminService + wallet ports.WalletService + walletFallbacks []FallbackWallet + signer ports.SignerService + txBuilder ports.TxBuilder + scanner ports.BlockchainScanner + scheduler ports.SchedulerService + unlocker ports.Unlocker + liveStore ports.LiveStore + network *arklib.Network + alerts ports.Alerts + settings *domain.Settings } func (c *Config) String() string { @@ -184,6 +186,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" @@ -449,6 +452,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), @@ -672,6 +676,18 @@ func (c *Config) WalletService() ports.WalletService { return c.wallet } +// 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 +} + func (c *Config) UnlockerService() ports.Unlocker { return c.unlocker } @@ -798,22 +814,99 @@ 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 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)) + 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) + 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, FallbackWallet{Addr: addr, Service: fbSvc}) + } + return fallbacks, nil +} + +func closeWallets(wallets []FallbackWallet) { + for _, w := range wallets { + w.Service.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..44e2b882e 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,142 @@ 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) + }) + + 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) + }) +} diff --git a/internal/interface/grpc/service.go b/internal/interface/grpc/service.go index 8aed8bccd..ef1cdf0e1 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.FallbackWallets() { + fb.Service.Close() + } } func (s *service) startAppServices() error { @@ -681,6 +687,30 @@ func (s *service) ensureWalletReady() error { ) } + // Fallback wallets must also be initialized and unlocked out of band: they + // 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 %q status: %s", fb.Addr, err) + } + if !fbStatus.IsInitialized() { + return fmt.Errorf( + "fallback wallet %q is not initialized: "+ + "initialize the arkd-wallet out of band before starting arkd", + fb.Addr, + ) + } + if !fbStatus.IsUnlocked() { + return fmt.Errorf( + "fallback wallet %q is locked: "+ + "unlock the arkd-wallet out of band before starting arkd", + fb.Addr, + ) + } + } + return nil } diff --git a/internal/test/e2e/utils_test.go b/internal/test/e2e/utils_test.go index 11f99fa57..2bf7bd3e4 100644 --- a/internal/test/e2e/utils_test.go +++ b/internal/test/e2e/utils_test.go @@ -47,6 +47,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" ) @@ -1345,10 +1346,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 } @@ -1373,10 +1378,13 @@ func setupArkd() error { return refill(httpClient) } -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() @@ -1384,7 +1392,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 { @@ -1396,13 +1404,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) @@ -1413,7 +1421,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)