Skip to content

Commit a60692f

Browse files
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.
1 parent 9e5e7cb commit a60692f

6 files changed

Lines changed: 268 additions & 28 deletions

File tree

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ The `arkd` server can be configured using environment variables and the admin se
8181
| `ARKD_REDIS_NUM_OF_RETRIES` | Maximum number of retries for Redis write operations in case of conflicts | - |
8282
| `ARKD_ESPLORA_URL` | Esplora API URL | `https://blockstream.info/api` |
8383
| `ARKD_WALLET_ADDR` | The arkd wallet address to connect to in the form `host:port` | - |
84+
| `ARKD_WALLET_FALLBACK_ADDRS` | Additional arkd-wallet addresses (other LPs), comma-separated `host:port` list | - |
8485
| `ARKD_SIGNER_ADDR` | The signer address to connect to in the form `host:port` | value of `ARKD_WALLET_ADDR` |
8586
| `ARKD_NO_MACAROONS` | Disable macaroon authentication | `false` |
8687
| `ARKD_NO_TLS` | Disable TLS | `true` |
@@ -180,6 +181,16 @@ To connect `arkd` to `arkd-wallet` use this environment variable:
180181
export ARKD_WALLET_ADDR=localhost:6060
181182
```
182183

184+
### Configuring multiple LP wallets
185+
186+
`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:
187+
188+
```sh
189+
export ARKD_WALLET_FALLBACK_ADDRS=localhost:6061,localhost:6062
190+
```
191+
192+
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.
193+
183194
### Connect to signer
184195

185196
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.

docker-compose.regtest.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,26 @@ services:
6060
- ARKD_WALLET_SIGNER_KEY=afcd3fa10f82a05fddc9574fdb13b3991b568e89cc39a72ba4401df8abef35f0
6161
volumes:
6262
- arkd-wallet-volume:/app/data
63+
# A second arkd-wallet acting as an additional LP wallet, wired into arkd as a
64+
# sweep fallback via ARKD_WALLET_FALLBACK_ADDRS.
65+
arkd-wallet-2:
66+
restart: unless-stopped
67+
build:
68+
context: .
69+
dockerfile: arkdwallet.Dockerfile
70+
container_name: arkd-wallet-2
71+
depends_on:
72+
- nbxplorer
73+
ports:
74+
- "6061:6060"
75+
environment:
76+
- ARKD_WALLET_LOG_LEVEL=5
77+
- ARKD_WALLET_NBXPLORER_URL=http://nbxplorer:32838
78+
- ARKD_WALLET_DATADIR=./data/regtest-2
79+
- ARKD_WALLET_NETWORK=regtest
80+
- ARKD_WALLET_SIGNER_KEY=19422b10efd05403820ff6a3365422be2fc5f07f34a6d1603f7298328f0f80f6
81+
volumes:
82+
- arkd-wallet-2-volume:/app/data
6383
redis:
6484
restart: unless-stopped
6585
image: redis:7-alpine
@@ -82,6 +102,7 @@ services:
82102
restart: unless-stopped
83103
depends_on:
84104
- arkd-wallet
105+
- arkd-wallet-2
85106
- pg
86107
- redis
87108
ports:
@@ -106,6 +127,7 @@ services:
106127
- ARKD_BAN_THRESHOLD=1
107128
- ARKD_DATADIR=./data/regtest
108129
- ARKD_WALLET_ADDR=arkd-wallet:6060
130+
- ARKD_WALLET_FALLBACK_ADDRS=arkd-wallet-2:6060
109131
- ARKD_ESPLORA_URL=http://chopsticks:3000
110132
- ARKD_DB_TYPE=${ARKD_DB_TYPE:-sqlite}
111133
- ARKD_PG_DB_URL=${ARKD_PG_DB_URL:-}
@@ -122,6 +144,8 @@ services:
122144
volumes:
123145
arkd-wallet-volume:
124146
name: arkd-wallet-volume
147+
arkd-wallet-2-volume:
148+
name: arkd-wallet-2-volume
125149
arkd-volume:
126150
name: arkd-volume
127151

internal/config/config.go

Lines changed: 88 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ type Config struct {
9595
RedisUrl string
9696
RedisTxNumOfRetries int
9797
WalletAddr string
98+
WalletFallbackAddrs []string
9899
SignerAddr string
99100
VtxoTreeExpiry arklib.RelativeLocktime
100101
UnilateralExitDelay arklib.RelativeLocktime
@@ -145,21 +146,22 @@ type Config struct {
145146
MaxConcurrentStreams uint32
146147
StreamConnPoolSize uint32
147148

148-
fee ports.FeeManager
149-
repo ports.RepoManager
150-
svc application.Service
151-
adminSvc application.AdminService
152-
wallet ports.WalletService
153-
signer ports.SignerService
154-
txBuilder ports.TxBuilder
155-
scanner ports.BlockchainScanner
156-
scheduler ports.SchedulerService
157-
unlocker ports.Unlocker
158-
liveStore ports.LiveStore
159-
network *arklib.Network
160-
roundReportSvc application.RoundReportService
161-
alerts ports.Alerts
162-
settings *domain.Settings
149+
fee ports.FeeManager
150+
repo ports.RepoManager
151+
svc application.Service
152+
adminSvc application.AdminService
153+
wallet ports.WalletService
154+
walletFallbacks []ports.WalletService
155+
signer ports.SignerService
156+
txBuilder ports.TxBuilder
157+
scanner ports.BlockchainScanner
158+
scheduler ports.SchedulerService
159+
unlocker ports.Unlocker
160+
liveStore ports.LiveStore
161+
network *arklib.Network
162+
roundReportSvc application.RoundReportService
163+
alerts ports.Alerts
164+
settings *domain.Settings
163165
}
164166

165167
func (c *Config) String() string {
@@ -180,6 +182,7 @@ func (c *Config) String() string {
180182
var (
181183
Datadir = "DATADIR"
182184
WalletAddr = "WALLET_ADDR"
185+
WalletFallbackAddrs = "WALLET_FALLBACK_ADDRS"
183186
SignerAddr = "SIGNER_ADDR"
184187
SessionDuration = "SESSION_DURATION"
185188
BanDuration = "BAN_DURATION"
@@ -442,6 +445,7 @@ func LoadConfig() (*Config, error) {
442445
return &Config{
443446
Datadir: viper.GetString(Datadir),
444447
WalletAddr: viper.GetString(WalletAddr),
448+
WalletFallbackAddrs: parseWalletFallbackAddrs(viper.GetString(WalletFallbackAddrs)),
445449
SignerAddr: signerAddr,
446450
SessionDuration: viper.GetInt64(SessionDuration),
447451
BanDuration: viper.GetInt64(BanDuration),
@@ -664,6 +668,10 @@ func (c *Config) WalletService() ports.WalletService {
664668
return c.wallet
665669
}
666670

671+
func (c *Config) FallbackWalletServices() []ports.WalletService {
672+
return c.walletFallbacks
673+
}
674+
667675
func (c *Config) UnlockerService() ports.Unlocker {
668676
return c.unlocker
669677
}
@@ -793,22 +801,86 @@ func (c *Config) repoManager() error {
793801
return nil
794802
}
795803

804+
// newWalletClient is the wallet client constructor, indirected so tests can
805+
// stub out the gRPC dial.
806+
var newWalletClient = walletclient.New
807+
796808
func (c *Config) walletService() error {
797809
arkWallet := c.WalletAddr
798810
if arkWallet == "" {
799811
return fmt.Errorf("missing ark wallet address")
800812
}
801813

802-
walletSvc, network, err := walletclient.New(arkWallet, c.OtelCollectorEndpoint)
814+
walletSvc, network, err := newWalletClient(arkWallet, c.OtelCollectorEndpoint)
803815
if err != nil {
804816
return err
805817
}
806818

807819
c.wallet = walletSvc
808820
c.network = network
821+
822+
fallbacks, err := c.dialFallbackWallets()
823+
if err != nil {
824+
return err
825+
}
826+
c.walletFallbacks = fallbacks
827+
809828
return nil
810829
}
811830

831+
// dialFallbackWallets dials the configured fallback arkd-wallets and validates
832+
// that each one is reachable and on the same network as the primary. Fallback
833+
// wallets belong to additional liquidity providers and are used only as sweep
834+
// fallbacks; the primary remains the sole source of the forfeit pubkey,
835+
// addresses and signing. Any failure is fatal so a misconfigured wallet is
836+
// surfaced at startup rather than at sweep time.
837+
func (c *Config) dialFallbackWallets() ([]ports.WalletService, error) {
838+
fallbacks := make([]ports.WalletService, 0, len(c.WalletFallbackAddrs))
839+
for _, addr := range c.WalletFallbackAddrs {
840+
if addr == "" {
841+
continue
842+
}
843+
fbSvc, fbNetwork, err := newWalletClient(addr, c.OtelCollectorEndpoint)
844+
if err != nil {
845+
closeWallets(fallbacks)
846+
return nil, fmt.Errorf("failed to dial fallback wallet %q: %w", addr, err)
847+
}
848+
if fbNetwork.Name != c.network.Name {
849+
fbSvc.Close()
850+
closeWallets(fallbacks)
851+
return nil, fmt.Errorf(
852+
"fallback wallet %q is on network %q, expected %q (same as primary)",
853+
addr, fbNetwork.Name, c.network.Name,
854+
)
855+
}
856+
log.Infof("dialed fallback wallet %q on network %s", addr, fbNetwork.Name)
857+
fallbacks = append(fallbacks, fbSvc)
858+
}
859+
return fallbacks, nil
860+
}
861+
862+
func closeWallets(wallets []ports.WalletService) {
863+
for _, w := range wallets {
864+
w.Close()
865+
}
866+
}
867+
868+
// parseWalletFallbackAddrs splits a comma-separated list of wallet addresses,
869+
// trimming whitespace and dropping empty entries.
870+
func parseWalletFallbackAddrs(raw string) []string {
871+
parts := strings.Split(raw, ",")
872+
addrs := make([]string, 0, len(parts))
873+
for _, p := range parts {
874+
if p = strings.TrimSpace(p); p != "" {
875+
addrs = append(addrs, p)
876+
}
877+
}
878+
if len(addrs) == 0 {
879+
return nil
880+
}
881+
return addrs
882+
}
883+
812884
func (c *Config) signerService() error {
813885
signer := c.SignerAddr
814886
if signer == "" {

internal/config/config_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package config
22

33
import (
4+
"fmt"
45
"testing"
56
"time"
67

8+
"github.com/arkade-os/arkd/internal/core/ports"
79
arklib "github.com/arkade-os/arkd/pkg/ark-lib"
810
"github.com/stretchr/testify/require"
911
)
@@ -270,3 +272,98 @@ func TestConfigStringRedactsSecrets(t *testing.T) {
270272
})
271273
}
272274
}
275+
276+
func TestParseWalletFallbackAddrs(t *testing.T) {
277+
tests := []struct {
278+
name string
279+
raw string
280+
want []string
281+
}{
282+
{"empty", "", nil},
283+
{"single", "localhost:6061", []string{"localhost:6061"}},
284+
{"multiple", "a:6060,b:6060,c:6060", []string{"a:6060", "b:6060", "c:6060"}},
285+
{"trims whitespace", "a:6060, b:6060 ,c:6060", []string{"a:6060", "b:6060", "c:6060"}},
286+
{"drops empty entries", "a:6060,,b:6060,", []string{"a:6060", "b:6060"}},
287+
{"only separators", " , , ", nil},
288+
}
289+
290+
for _, tt := range tests {
291+
t.Run(tt.name, func(t *testing.T) {
292+
require.Equal(t, tt.want, parseWalletFallbackAddrs(tt.raw))
293+
})
294+
}
295+
}
296+
297+
// fakeFallbackWallet is a ports.WalletService that only implements Close; via
298+
// the embedded nil interface every other method is unused by these tests.
299+
type fakeFallbackWallet struct {
300+
ports.WalletService
301+
closed *int
302+
}
303+
304+
func (f *fakeFallbackWallet) Close() { *f.closed++ }
305+
306+
func TestDialFallbackWallets(t *testing.T) {
307+
orig := newWalletClient
308+
t.Cleanup(func() { newWalletClient = orig })
309+
310+
regtest := &arklib.Network{Name: "regtest"}
311+
testnet := &arklib.Network{Name: "testnet"}
312+
313+
t.Run("all on the same network", func(t *testing.T) {
314+
var closes int
315+
newWalletClient = func(_, _ string) (ports.WalletService, *arklib.Network, error) {
316+
return &fakeFallbackWallet{closed: &closes}, regtest, nil
317+
}
318+
319+
c := &Config{network: regtest, WalletFallbackAddrs: []string{"a:6060", "b:6060"}}
320+
fbs, err := c.dialFallbackWallets()
321+
322+
require.NoError(t, err)
323+
require.Len(t, fbs, 2)
324+
require.Zero(t, closes)
325+
})
326+
327+
t.Run("network mismatch hard-fails and closes dialed", func(t *testing.T) {
328+
var closes, calls int
329+
newWalletClient = func(_, _ string) (ports.WalletService, *arklib.Network, error) {
330+
calls++
331+
net := regtest
332+
if calls == 2 {
333+
net = testnet
334+
}
335+
return &fakeFallbackWallet{closed: &closes}, net, nil
336+
}
337+
338+
c := &Config{network: regtest, WalletFallbackAddrs: []string{"a:6060", "b:6060"}}
339+
fbs, err := c.dialFallbackWallets()
340+
341+
require.Error(t, err)
342+
require.Nil(t, fbs)
343+
require.Contains(t, err.Error(), "b:6060")
344+
require.Contains(t, err.Error(), "testnet")
345+
require.Contains(t, err.Error(), "regtest")
346+
// The mismatched wallet and the previously dialed one are both closed.
347+
require.Equal(t, 2, closes)
348+
})
349+
350+
t.Run("dial error hard-fails and closes dialed", func(t *testing.T) {
351+
var closes, calls int
352+
newWalletClient = func(_, _ string) (ports.WalletService, *arklib.Network, error) {
353+
calls++
354+
if calls == 2 {
355+
return nil, nil, fmt.Errorf("connection refused")
356+
}
357+
return &fakeFallbackWallet{closed: &closes}, regtest, nil
358+
}
359+
360+
c := &Config{network: regtest, WalletFallbackAddrs: []string{"a:6060", "b:6060"}}
361+
fbs, err := c.dialFallbackWallets()
362+
363+
require.Error(t, err)
364+
require.Nil(t, fbs)
365+
require.Contains(t, err.Error(), "b:6060")
366+
// The first, successfully dialed fallback is closed.
367+
require.Equal(t, 1, closes)
368+
})
369+
}

internal/interface/grpc/service.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,12 @@ func (s *service) stop() {
230230
log.Warn("failed to close admin transport connection")
231231
}
232232
}
233+
234+
// Close the fallback wallet connections (the primary is closed by the app
235+
// service). arkd owns these dialed connections, nothing else does.
236+
for _, fb := range s.appConfig.FallbackWalletServices() {
237+
fb.Close()
238+
}
233239
}
234240

235241
func (s *service) startAppServices() error {
@@ -673,6 +679,30 @@ func (s *service) ensureWalletReady() error {
673679
)
674680
}
675681

682+
// Fallback wallets must also be initialized and unlocked out of band: they
683+
// are needed to sign sweeps when the primary cannot. Balance is not checked
684+
// (they are not liquidity sources).
685+
for i, fb := range s.appConfig.FallbackWalletServices() {
686+
fbStatus, err := fb.Status(ctx)
687+
if err != nil {
688+
return fmt.Errorf("failed to get fallback wallet %d status: %s", i, err)
689+
}
690+
if !fbStatus.IsInitialized() {
691+
return fmt.Errorf(
692+
"fallback wallet %d is not initialized: "+
693+
"initialize the arkd-wallet out of band before starting arkd",
694+
i,
695+
)
696+
}
697+
if !fbStatus.IsUnlocked() {
698+
return fmt.Errorf(
699+
"fallback wallet %d is locked: "+
700+
"unlock the arkd-wallet out of band before starting arkd",
701+
i,
702+
)
703+
}
704+
}
705+
676706
return nil
677707
}
678708

0 commit comments

Comments
 (0)