Skip to content

Commit 5beae78

Browse files
Sign sweeps with the primary or any fallback wallet
Split the sweep build from signing: the destination and fees always come from the primary wallet (stable txid), then signing is attempted with each of the primary/fallback arkd-wallets until one succeeds. Broadcasting stays on the primary. This lets a single arkd sweep batches signed by any of its LPs' wallets.
1 parent a60692f commit 5beae78

11 files changed

Lines changed: 356 additions & 48 deletions

File tree

internal/config/config.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -981,7 +981,7 @@ func (c *Config) appService() error {
981981
}
982982

983983
svc, err := application.NewService(
984-
c.wallet, c.signer, c.repo, c.txBuilder, c.scanner,
984+
c.wallet, c.FallbackWalletServices(), c.signer, c.repo, c.txBuilder, c.scanner,
985985
c.scheduler, c.liveStore, roundReportSvc, c.alerts, c.fee,
986986
)
987987
if err != nil {
@@ -999,7 +999,7 @@ func (c *Config) adminService() error {
999999
}
10001000

10011001
c.adminSvc = application.NewAdminService(
1002-
c.wallet, c.repo, c.txBuilder, c.liveStore, unit, c.fee,
1002+
c.wallet, c.FallbackWalletServices(), c.repo, c.txBuilder, c.liveStore, unit, c.fee,
10031003
)
10041004
return nil
10051005
}

internal/core/application/admin.go

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,10 @@ type AdminService interface {
5757
}
5858

5959
type adminService struct {
60-
walletSvc ports.WalletService
60+
walletSvc ports.WalletService
61+
// walletFallbacks are additional arkd-wallets whose batches admin.Sweep may also
62+
// sign; signing is attempted with the primary wallet first, then each fallback.
63+
walletFallbacks []ports.WalletService
6164
repoManager ports.RepoManager
6265
txBuilder ports.TxBuilder
6366
sweeperTimeUnit ports.TimeUnit
@@ -70,11 +73,13 @@ type adminService struct {
7073
}
7174

7275
func NewAdminService(
73-
walletSvc ports.WalletService, repoManager ports.RepoManager, txBuilder ports.TxBuilder,
76+
walletSvc ports.WalletService, walletFallbacks []ports.WalletService,
77+
repoManager ports.RepoManager, txBuilder ports.TxBuilder,
7478
liveStoreSvc ports.LiveStore, timeUnit ports.TimeUnit, feeManager ports.FeeManager,
7579
) AdminService {
7680
return &adminService{
7781
walletSvc: walletSvc,
82+
walletFallbacks: walletFallbacks,
7883
repoManager: repoManager,
7984
txBuilder: txBuilder,
8085
sweeperTimeUnit: timeUnit,
@@ -83,6 +88,12 @@ func NewAdminService(
8388
}
8489
}
8590

91+
// signingWallets returns the wallets to try when signing a sweep, in order: the
92+
// primary wallet first, then any configured fallbacks.
93+
func (a *adminService) signingWallets() []ports.WalletService {
94+
return append([]ports.WalletService{a.walletSvc}, a.walletFallbacks...)
95+
}
96+
8697
func (a *adminService) Wallet() ports.WalletService {
8798
return a.walletSvc
8899
}
@@ -557,7 +568,7 @@ func (a *adminService) Sweep(
557568
return "", "", fmt.Errorf("no funds to sweep")
558569
}
559570

560-
txid, txhex, err = a.txBuilder.BuildSweepTx(inputs)
571+
txid, txhex, err = buildAndSignSweepTx(a.txBuilder, a.signingWallets(), inputs)
561572
if err != nil {
562573
return
563574
}

internal/core/application/admin_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ func TestAdminService_Settings(t *testing.T) {
5656
if seed != nil {
5757
require.NoError(t, repo.settingsRepo.Upsert(ctx, *seed, nil))
5858
}
59-
return application.NewAdminService(nil, repo, nil, nil, ports.UnixTime, nil)
59+
return application.NewAdminService(nil, nil, repo, nil, nil, ports.UnixTime, nil)
6060
}
6161

6262
t.Run("settings", func(t *testing.T) {
@@ -246,7 +246,7 @@ func TestAdminService_SettingsSerialization(t *testing.T) {
246246
seed := validSettings()
247247
probe := &serializeProbeRepo{settings: &seed, delay: 10 * time.Millisecond}
248248
svc := application.NewAdminService(
249-
nil, &mockRepoManager{settingsRepo: probe}, nil, nil, ports.UnixTime, nil,
249+
nil, nil, &mockRepoManager{settingsRepo: probe}, nil, nil, ports.UnixTime, nil,
250250
)
251251

252252
const workers = 8

internal/core/application/service.go

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ type service struct {
7070

7171
func NewService(
7272
wallet ports.WalletService,
73+
walletFallbacks []ports.WalletService,
7374
signer ports.SignerService,
7475
repoManager ports.RepoManager,
7576
builder ports.TxBuilder,
@@ -137,13 +138,19 @@ func NewService(
137138
ctx, cancel := context.WithCancel(ctx)
138139

139140
svc := &service{
140-
wallet: wallet,
141-
signer: signer,
142-
repoManager: repoManager,
143-
builder: builder,
144-
cache: cache,
145-
scanner: scanner,
146-
sweeper: newSweeper(wallet, repoManager, builder, scheduler),
141+
wallet: wallet,
142+
signer: signer,
143+
repoManager: repoManager,
144+
builder: builder,
145+
cache: cache,
146+
scanner: scanner,
147+
sweeper: newSweeper(
148+
wallet,
149+
walletFallbacks,
150+
repoManager,
151+
builder,
152+
scheduler,
153+
),
147154
operatorPrvkey: operatorSigningKey,
148155
operatorPubkey: operatorSigningKey.PubKey(),
149156
forfeitsBoardingSigsChan: make(chan struct{}, 1),
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package application
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
7+
"github.com/arkade-os/arkd/internal/core/ports"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
// fakeWallet is a distinct, comparable ports.WalletService identity. Only its
12+
// pointer identity matters for the fallback-iteration tests.
13+
type fakeWallet struct {
14+
ports.WalletService
15+
name string
16+
}
17+
18+
// fakeSweepBuilder implements just the two sweep methods of ports.TxBuilder that
19+
// buildAndSignSweepTx uses. SignSweepTx succeeds only for goodWallet, recording the
20+
// order in which wallets are tried.
21+
type fakeSweepBuilder struct {
22+
ports.TxBuilder
23+
buildErr error
24+
unsignedTx string
25+
txid string
26+
goodWallet ports.WalletService
27+
signCalls []ports.WalletService
28+
}
29+
30+
func (b *fakeSweepBuilder) BuildSweepTx(inputs []ports.TxInput) (string, string, error) {
31+
if b.buildErr != nil {
32+
return "", "", b.buildErr
33+
}
34+
return b.unsignedTx, b.txid, nil
35+
}
36+
37+
func (b *fakeSweepBuilder) SignSweepTx(
38+
wallet ports.WalletService, unsignedTx string,
39+
) (string, error) {
40+
b.signCalls = append(b.signCalls, wallet)
41+
if b.goodWallet != nil && wallet == b.goodWallet {
42+
return "signed:" + unsignedTx, nil
43+
}
44+
return "", fmt.Errorf("wallet %v cannot sign", wallet)
45+
}
46+
47+
func TestBuildAndSignSweepTx(t *testing.T) {
48+
inputs := []ports.TxInput{{Txid: "aa", Index: 0}}
49+
primary := &fakeWallet{name: "primary"}
50+
fb1 := &fakeWallet{name: "fb1"}
51+
fb2 := &fakeWallet{name: "fb2"}
52+
wallets := []ports.WalletService{primary, fb1, fb2}
53+
54+
t.Run("primary signs, fallbacks not tried", func(t *testing.T) {
55+
b := &fakeSweepBuilder{unsignedTx: "unsigned", txid: "txid123", goodWallet: primary}
56+
57+
txid, signed, err := buildAndSignSweepTx(b, wallets, inputs)
58+
require.NoError(t, err)
59+
require.Equal(t, "txid123", txid)
60+
require.Equal(t, "signed:unsigned", signed)
61+
require.Equal(t, []ports.WalletService{primary}, b.signCalls)
62+
})
63+
64+
t.Run("falls back to a later wallet in order", func(t *testing.T) {
65+
b := &fakeSweepBuilder{unsignedTx: "unsigned", txid: "txid123", goodWallet: fb2}
66+
67+
txid, signed, err := buildAndSignSweepTx(b, wallets, inputs)
68+
require.NoError(t, err)
69+
require.Equal(t, "txid123", txid)
70+
require.Equal(t, "signed:unsigned", signed)
71+
require.Equal(t, []ports.WalletService{primary, fb1, fb2}, b.signCalls)
72+
})
73+
74+
t.Run("no wallet can sign returns aggregated error naming each wallet", func(t *testing.T) {
75+
b := &fakeSweepBuilder{unsignedTx: "unsigned", txid: "txid123"}
76+
77+
txid, signed, err := buildAndSignSweepTx(b, wallets, inputs)
78+
require.Error(t, err)
79+
require.Empty(t, txid)
80+
require.Empty(t, signed)
81+
require.Contains(t, err.Error(), "no wallet could sign sweep tx txid123")
82+
require.Contains(t, err.Error(), "wallet[0]")
83+
require.Contains(t, err.Error(), "wallet[2]")
84+
require.Len(t, b.signCalls, 3)
85+
})
86+
87+
t.Run("no signing wallets configured", func(t *testing.T) {
88+
b := &fakeSweepBuilder{unsignedTx: "unsigned", txid: "txid123"}
89+
90+
_, _, err := buildAndSignSweepTx(b, nil, inputs)
91+
require.ErrorContains(t, err, "no signing wallets configured for sweep tx txid123")
92+
require.Empty(t, b.signCalls)
93+
})
94+
95+
t.Run("build error short-circuits before signing", func(t *testing.T) {
96+
b := &fakeSweepBuilder{buildErr: fmt.Errorf("boom")}
97+
98+
_, _, err := buildAndSignSweepTx(b, wallets, inputs)
99+
require.ErrorContains(t, err, "boom")
100+
require.Empty(t, b.signCalls)
101+
})
102+
}

internal/core/application/sweeper.go

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,13 @@ type sweeperTask struct {
3131
// it is responsible for sweeping batch outputs that reached the expiration date.
3232
// it also handles delaying the sweep events in case some parts of the tree are broadcasted
3333
type sweeper struct {
34-
wallet ports.WalletService
35-
repoManager ports.RepoManager
36-
builder ports.TxBuilder
37-
scheduler ports.SchedulerService
34+
wallet ports.WalletService
35+
// walletFallbacks are additional arkd-wallets whose batches this arkd may also
36+
// sweep; signing is attempted with the primary wallet first, then each fallback.
37+
walletFallbacks []ports.WalletService
38+
repoManager ports.RepoManager
39+
builder ports.TxBuilder
40+
scheduler ports.SchedulerService
3841

3942
// cache of scheduled tasks, avoid scheduling the same sweep event multiple times
4043
locker *sync.Mutex
@@ -44,14 +47,54 @@ type sweeper struct {
4447
}
4548

4649
func newSweeper(
47-
wallet ports.WalletService, repoManager ports.RepoManager, builder ports.TxBuilder,
50+
wallet ports.WalletService, walletFallbacks []ports.WalletService,
51+
repoManager ports.RepoManager, builder ports.TxBuilder,
4852
scheduler ports.SchedulerService,
4953
) *sweeper {
5054
return &sweeper{
51-
wallet, repoManager, builder, scheduler, &sync.Mutex{}, make(map[string]struct{}), nil,
55+
wallet, walletFallbacks, repoManager, builder, scheduler,
56+
&sync.Mutex{}, make(map[string]struct{}), nil,
5257
}
5358
}
5459

60+
// signingWallets returns the wallets to try when signing a sweep, in order: the
61+
// primary wallet first, then any configured fallbacks.
62+
func (s *sweeper) signingWallets() []ports.WalletService {
63+
return append([]ports.WalletService{s.wallet}, s.walletFallbacks...)
64+
}
65+
66+
// buildAndSignSweepTx builds the sweep transaction once (its destination and fees
67+
// come from the primary wallet) and then attempts to sign it with each wallet in
68+
// order, returning as soon as one succeeds. This lets a single arkd sweep batches
69+
// signed by any of its primary/fallback arkd-wallets. Broadcasting is left to the
70+
// caller.
71+
func buildAndSignSweepTx(
72+
builder ports.TxBuilder, wallets []ports.WalletService, inputs []ports.TxInput,
73+
) (string, string, error) {
74+
unsignedTx, txid, err := builder.BuildSweepTx(inputs)
75+
if err != nil {
76+
return "", "", err
77+
}
78+
79+
if len(wallets) == 0 {
80+
return "", "", fmt.Errorf("no signing wallets configured for sweep tx %s", txid)
81+
}
82+
83+
signErrs := make([]error, 0, len(wallets))
84+
for i, wallet := range wallets {
85+
signed, signErr := builder.SignSweepTx(wallet, unsignedTx)
86+
if signErr == nil {
87+
return txid, signed, nil
88+
}
89+
// name the failing wallet so a multi-wallet operator can tell which rejected it
90+
signErrs = append(signErrs, fmt.Errorf("wallet[%d]: %w", i, signErr))
91+
}
92+
93+
return "", "", fmt.Errorf(
94+
"no wallet could sign sweep tx %s: %w", txid, errors.Join(signErrs...),
95+
)
96+
}
97+
5598
func (s *sweeper) start(ctx context.Context) error {
5699
s.scheduledTasks = make(map[string]struct{})
57100
s.scheduler.Start()
@@ -627,7 +670,9 @@ func (s *sweeper) createBatchSweepTask(commitmentTxid, vtxoTreeRootTxid string)
627670
)
628671

629672
// build the sweep transaction with all the expired non-swept batch outputs
630-
sweepTxId, sweepTx, err = s.builder.BuildSweepTx(unspentOutputsToSweep)
673+
sweepTxId, sweepTx, err = buildAndSignSweepTx(
674+
s.builder, s.signingWallets(), unspentOutputsToSweep,
675+
)
631676
if err != nil {
632677
return err
633678
}
@@ -663,7 +708,9 @@ func (s *sweeper) createBatchSweepTask(commitmentTxid, vtxoTreeRootTxid string)
663708
} else {
664709
// if all outputs are spent, it means we missed to mark the batch as swept,
665710
// build a sweep transaction without broadcasting it. we'll use it rebuild sweepEvent.
666-
sweepTxId, sweepTx, err = s.builder.BuildSweepTx(outputsToSweep)
711+
sweepTxId, sweepTx, err = buildAndSignSweepTx(
712+
s.builder, s.signingWallets(), outputsToSweep,
713+
)
667714
if err != nil {
668715
return err
669716
}
@@ -749,7 +796,9 @@ func (s *sweeper) createCheckpointSweepTask(
749796
checkpointTxid := toSweep.Txid
750797
log.Debugf("sweeper: start sweeping checkpoint %s", checkpointTxid)
751798

752-
_, sweepTx, err := s.builder.BuildSweepTx([]ports.TxInput{toSweep})
799+
_, sweepTx, err := buildAndSignSweepTx(
800+
s.builder, s.signingWallets(), []ports.TxInput{toSweep},
801+
)
753802
if err != nil {
754803
return err
755804
}

internal/core/ports/tx_builder.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,12 @@ type TxBuilder interface {
6060
VerifyForfeitTxs(
6161
vtxos []domain.Vtxo, connectors tree.FlatTxTree, txs []string,
6262
) (valid map[domain.Outpoint]ValidForfeitTx, err error)
63-
BuildSweepTx(inputs []TxInput) (txid string, signedSweepTx string, err error)
63+
// BuildSweepTx builds the unsigned sweep transaction (its destination address
64+
// and fees come from the primary wallet); SignSweepTx signs it with a given
65+
// wallet. They are split so a sweep can be signed by any primary/fallback
66+
// wallet without rebuilding it.
67+
BuildSweepTx(inputs []TxInput) (unsignedTx string, txid string, err error)
68+
SignSweepTx(wallet WalletService, unsignedTx string) (signedTx string, err error)
6469
GetSweepableBatchOutputs(vtxoTree *tree.TxTree) (
6570
vtxoTreeExpiry *arklib.RelativeLocktime, batchOutputs *TxInput, err error,
6671
)

internal/infrastructure/live-store/live_store_test.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -834,13 +834,20 @@ func (m *mockedTxBuilder) BuildCommitmentTx(
834834

835835
func (m *mockedTxBuilder) BuildSweepTx(
836836
inputs []ports.TxInput,
837-
) (txid string, signedSweepTx string, err error) {
837+
) (unsignedTx string, txid string, err error) {
838838
args := m.Called(inputs)
839839
res0 := args.Get(0).(string)
840840
res1 := args.Get(1).(string)
841841
return res0, res1, args.Error(2)
842842
}
843843

844+
func (m *mockedTxBuilder) SignSweepTx(
845+
wallet ports.WalletService, unsignedTx string,
846+
) (signedTx string, err error) {
847+
args := m.Called(wallet, unsignedTx)
848+
return args.Get(0).(string), args.Error(1)
849+
}
850+
844851
func (m *mockedTxBuilder) GetSweepableBatchOutputs(
845852
vtxoTree *tree.TxTree,
846853
) (vtxoTreeExpiry *arklib.RelativeLocktime, sweepInput *ports.TxInput, err error) {

internal/infrastructure/tx-builder/covenantless/builder.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,10 +255,17 @@ func (b *txBuilder) FinalizeAndExtract(tx string) (string, error) {
255255
}
256256

257257
func (b *txBuilder) BuildSweepTx(inputs []ports.TxInput) (
258-
txid, signedSweepTx string, err error,
258+
unsignedTx, txid string, err error,
259259
) {
260260
ctx := context.Background()
261-
return sweepTransaction(ctx, b.wallet, inputs)
261+
return buildSweepTransaction(ctx, b.wallet, inputs)
262+
}
263+
264+
func (b *txBuilder) SignSweepTx(
265+
wallet ports.WalletService, unsignedTx string,
266+
) (signedTx string, err error) {
267+
ctx := context.Background()
268+
return signSweepTransaction(ctx, wallet, unsignedTx)
262269
}
263270

264271
func (b *txBuilder) VerifyForfeitTxs(

0 commit comments

Comments
 (0)