Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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.
Expand Down
24 changes: 24 additions & 0 deletions docker-compose.regtest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -83,6 +103,7 @@ services:
restart: unless-stopped
depends_on:
- arkd-wallet
- arkd-wallet-2
- pg
- redis
ports:
Expand All @@ -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:-}
Expand All @@ -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

Expand Down
112 changes: 96 additions & 16 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ type Config struct {
RedisUrl string
RedisTxNumOfRetries int
WalletAddr string
WalletFallbackAddrs []string
SignerAddr string
VtxoTreeExpiry arklib.RelativeLocktime
UnilateralExitDelay arklib.RelativeLocktime
Expand Down Expand Up @@ -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 []FallbackWallet
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 {
Expand All @@ -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"
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -664,6 +668,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
}
Expand Down Expand Up @@ -799,22 +815,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() ([]FallbackWallet, error) {
fallbacks := make([]FallbackWallet, 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, 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 == "" {
Expand Down
97 changes: 97 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
@@ -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"
)
Expand Down Expand Up @@ -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)
})
}
Loading