From f11b43c153377b510273443fee1b9f39476f295c Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Fri, 12 Jun 2026 03:39:17 -0400 Subject: [PATCH 1/6] 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. --- internal/config/config.go | 4 +- internal/core/application/admin.go | 17 ++- internal/core/application/admin_test.go | 4 +- internal/core/application/service.go | 21 ++-- .../sweep_fallback_internal_test.go | 102 +++++++++++++++++ internal/core/application/sweeper.go | 67 +++++++++-- internal/core/ports/tx_builder.go | 7 +- .../live-store/live_store_test.go | 9 +- .../tx-builder/covenantless/builder.go | 11 +- .../tx-builder/covenantless/sweep.go | 56 +++++---- .../tx-builder/covenantless/sweep_test.go | 106 ++++++++++++++++++ 11 files changed, 356 insertions(+), 48 deletions(-) create mode 100644 internal/core/application/sweep_fallback_internal_test.go create mode 100644 internal/infrastructure/tx-builder/covenantless/sweep_test.go diff --git a/internal/config/config.go b/internal/config/config.go index 23c788c02..fd9388551 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -987,7 +987,7 @@ func (c *Config) appService() error { } svc, err := application.NewService( - c.wallet, c.signer, c.repo, c.txBuilder, c.scanner, + c.wallet, c.FallbackWalletServices(), c.signer, c.repo, c.txBuilder, c.scanner, c.scheduler, c.liveStore, roundReportSvc, c.alerts, c.fee, ) if err != nil { @@ -1005,7 +1005,7 @@ func (c *Config) adminService() error { } c.adminSvc = application.NewAdminService( - c.wallet, c.repo, c.txBuilder, c.liveStore, unit, c.fee, + c.wallet, c.FallbackWalletServices(), c.repo, c.txBuilder, c.liveStore, unit, c.fee, ) return nil } diff --git a/internal/core/application/admin.go b/internal/core/application/admin.go index cd7e1a4ee..233518143 100644 --- a/internal/core/application/admin.go +++ b/internal/core/application/admin.go @@ -59,7 +59,10 @@ type AdminService interface { } type adminService struct { - walletSvc ports.WalletService + walletSvc ports.WalletService + // walletFallbacks are additional arkd-wallets whose batches admin.Sweep may also + // sign; signing is attempted with the primary wallet first, then each fallback. + walletFallbacks []ports.WalletService repoManager ports.RepoManager txBuilder ports.TxBuilder sweeperTimeUnit ports.TimeUnit @@ -72,11 +75,13 @@ type adminService struct { } func NewAdminService( - walletSvc ports.WalletService, repoManager ports.RepoManager, txBuilder ports.TxBuilder, + walletSvc ports.WalletService, walletFallbacks []ports.WalletService, + repoManager ports.RepoManager, txBuilder ports.TxBuilder, liveStoreSvc ports.LiveStore, timeUnit ports.TimeUnit, feeManager ports.FeeManager, ) AdminService { return &adminService{ walletSvc: walletSvc, + walletFallbacks: walletFallbacks, repoManager: repoManager, txBuilder: txBuilder, sweeperTimeUnit: timeUnit, @@ -85,6 +90,12 @@ func NewAdminService( } } +// signingWallets returns the wallets to try when signing a sweep, in order: the +// primary wallet first, then any configured fallbacks. +func (a *adminService) signingWallets() []ports.WalletService { + return append([]ports.WalletService{a.walletSvc}, a.walletFallbacks...) +} + func (a *adminService) Wallet() ports.WalletService { return a.walletSvc } @@ -583,7 +594,7 @@ func (a *adminService) Sweep( ) } - txid, txhex, err = a.txBuilder.BuildSweepTx(inputs) + txid, txhex, err = buildAndSignSweepTx(a.txBuilder, a.signingWallets(), inputs) if err != nil { return } diff --git a/internal/core/application/admin_test.go b/internal/core/application/admin_test.go index dc1dda34f..2253c5c6c 100644 --- a/internal/core/application/admin_test.go +++ b/internal/core/application/admin_test.go @@ -56,7 +56,7 @@ func TestAdminService_Settings(t *testing.T) { if seed != nil { require.NoError(t, repo.settingsRepo.Upsert(ctx, *seed, nil)) } - return application.NewAdminService(nil, repo, nil, nil, ports.UnixTime, nil) + return application.NewAdminService(nil, nil, repo, nil, nil, ports.UnixTime, nil) } t.Run("settings", func(t *testing.T) { @@ -246,7 +246,7 @@ func TestAdminService_SettingsSerialization(t *testing.T) { seed := validSettings() probe := &serializeProbeRepo{settings: &seed, delay: 10 * time.Millisecond} svc := application.NewAdminService( - nil, &mockRepoManager{settingsRepo: probe}, nil, nil, ports.UnixTime, nil, + nil, nil, &mockRepoManager{settingsRepo: probe}, nil, nil, ports.UnixTime, nil, ) const workers = 8 diff --git a/internal/core/application/service.go b/internal/core/application/service.go index c8f442e1b..4580afe7d 100644 --- a/internal/core/application/service.go +++ b/internal/core/application/service.go @@ -69,6 +69,7 @@ type service struct { func NewService( wallet ports.WalletService, + walletFallbacks []ports.WalletService, signer ports.SignerService, repoManager ports.RepoManager, builder ports.TxBuilder, @@ -142,13 +143,19 @@ func NewService( ctx, cancel := context.WithCancel(ctx) svc := &service{ - wallet: wallet, - signer: signer, - repoManager: repoManager, - builder: builder, - cache: cache, - scanner: scanner, - sweeper: newSweeper(wallet, repoManager, builder, scheduler), + wallet: wallet, + signer: signer, + repoManager: repoManager, + builder: builder, + cache: cache, + scanner: scanner, + sweeper: newSweeper( + wallet, + walletFallbacks, + repoManager, + builder, + scheduler, + ), operatorPrvkey: operatorSigningKey, operatorPubkey: operatorSigningKey.PubKey(), forfeitsBoardingSigsChan: make(chan struct{}, 1), diff --git a/internal/core/application/sweep_fallback_internal_test.go b/internal/core/application/sweep_fallback_internal_test.go new file mode 100644 index 000000000..dd772542c --- /dev/null +++ b/internal/core/application/sweep_fallback_internal_test.go @@ -0,0 +1,102 @@ +package application + +import ( + "fmt" + "testing" + + "github.com/arkade-os/arkd/internal/core/ports" + "github.com/stretchr/testify/require" +) + +// fakeWallet is a distinct, comparable ports.WalletService identity. Only its +// pointer identity matters for the fallback-iteration tests. +type fakeWallet struct { + ports.WalletService + name string +} + +// fakeSweepBuilder implements just the two sweep methods of ports.TxBuilder that +// buildAndSignSweepTx uses. SignSweepTx succeeds only for goodWallet, recording the +// order in which wallets are tried. +type fakeSweepBuilder struct { + ports.TxBuilder + buildErr error + unsignedTx string + txid string + goodWallet ports.WalletService + signCalls []ports.WalletService +} + +func (b *fakeSweepBuilder) BuildSweepTx(inputs []ports.TxInput) (string, string, error) { + if b.buildErr != nil { + return "", "", b.buildErr + } + return b.unsignedTx, b.txid, nil +} + +func (b *fakeSweepBuilder) SignSweepTx( + wallet ports.WalletService, unsignedTx string, +) (string, error) { + b.signCalls = append(b.signCalls, wallet) + if b.goodWallet != nil && wallet == b.goodWallet { + return "signed:" + unsignedTx, nil + } + return "", fmt.Errorf("wallet %v cannot sign", wallet) +} + +func TestBuildAndSignSweepTx(t *testing.T) { + inputs := []ports.TxInput{{Txid: "aa", Index: 0}} + primary := &fakeWallet{name: "primary"} + fb1 := &fakeWallet{name: "fb1"} + fb2 := &fakeWallet{name: "fb2"} + wallets := []ports.WalletService{primary, fb1, fb2} + + t.Run("primary signs, fallbacks not tried", func(t *testing.T) { + b := &fakeSweepBuilder{unsignedTx: "unsigned", txid: "txid123", goodWallet: primary} + + txid, signed, err := buildAndSignSweepTx(b, wallets, inputs) + require.NoError(t, err) + require.Equal(t, "txid123", txid) + require.Equal(t, "signed:unsigned", signed) + require.Equal(t, []ports.WalletService{primary}, b.signCalls) + }) + + t.Run("falls back to a later wallet in order", func(t *testing.T) { + b := &fakeSweepBuilder{unsignedTx: "unsigned", txid: "txid123", goodWallet: fb2} + + txid, signed, err := buildAndSignSweepTx(b, wallets, inputs) + require.NoError(t, err) + require.Equal(t, "txid123", txid) + require.Equal(t, "signed:unsigned", signed) + require.Equal(t, []ports.WalletService{primary, fb1, fb2}, b.signCalls) + }) + + t.Run("no wallet can sign returns aggregated error naming each wallet", func(t *testing.T) { + b := &fakeSweepBuilder{unsignedTx: "unsigned", txid: "txid123"} + + txid, signed, err := buildAndSignSweepTx(b, wallets, inputs) + require.Error(t, err) + require.Empty(t, txid) + require.Empty(t, signed) + require.Contains(t, err.Error(), "no wallet could sign sweep tx txid123") + require.Contains(t, err.Error(), "wallet[0]") + require.Contains(t, err.Error(), "wallet[2]") + require.Len(t, b.signCalls, 3) + }) + + t.Run("no signing wallets configured", func(t *testing.T) { + b := &fakeSweepBuilder{unsignedTx: "unsigned", txid: "txid123"} + + _, _, err := buildAndSignSweepTx(b, nil, inputs) + require.ErrorContains(t, err, "no signing wallets configured for sweep tx txid123") + require.Empty(t, b.signCalls) + }) + + t.Run("build error short-circuits before signing", func(t *testing.T) { + b := &fakeSweepBuilder{buildErr: fmt.Errorf("boom")} + + _, _, err := buildAndSignSweepTx(b, wallets, inputs) + require.ErrorContains(t, err, "boom") + require.Empty(t, b.signCalls) + }) +} diff --git a/internal/core/application/sweeper.go b/internal/core/application/sweeper.go index 60b82930f..c21d1af9f 100644 --- a/internal/core/application/sweeper.go +++ b/internal/core/application/sweeper.go @@ -31,10 +31,13 @@ type sweeperTask struct { // it is responsible for sweeping batch outputs that reached the expiration date. // it also handles delaying the sweep events in case some parts of the tree are broadcasted type sweeper struct { - wallet ports.WalletService - repoManager ports.RepoManager - builder ports.TxBuilder - scheduler ports.SchedulerService + wallet ports.WalletService + // walletFallbacks are additional arkd-wallets whose batches this arkd may also + // sweep; signing is attempted with the primary wallet first, then each fallback. + walletFallbacks []ports.WalletService + repoManager ports.RepoManager + builder ports.TxBuilder + scheduler ports.SchedulerService // cache of scheduled tasks, avoid scheduling the same sweep event multiple times locker *sync.Mutex @@ -44,14 +47,54 @@ type sweeper struct { } func newSweeper( - wallet ports.WalletService, repoManager ports.RepoManager, builder ports.TxBuilder, + wallet ports.WalletService, walletFallbacks []ports.WalletService, + repoManager ports.RepoManager, builder ports.TxBuilder, scheduler ports.SchedulerService, ) *sweeper { return &sweeper{ - wallet, repoManager, builder, scheduler, &sync.Mutex{}, make(map[string]struct{}), nil, + wallet, walletFallbacks, repoManager, builder, scheduler, + &sync.Mutex{}, make(map[string]struct{}), nil, } } +// signingWallets returns the wallets to try when signing a sweep, in order: the +// primary wallet first, then any configured fallbacks. +func (s *sweeper) signingWallets() []ports.WalletService { + return append([]ports.WalletService{s.wallet}, s.walletFallbacks...) +} + +// buildAndSignSweepTx builds the sweep transaction once (its destination and fees +// come from the primary wallet) and then attempts to sign it with each wallet in +// order, returning as soon as one succeeds. This lets a single arkd sweep batches +// signed by any of its primary/fallback arkd-wallets. Broadcasting is left to the +// caller. +func buildAndSignSweepTx( + builder ports.TxBuilder, wallets []ports.WalletService, inputs []ports.TxInput, +) (string, string, error) { + unsignedTx, txid, err := builder.BuildSweepTx(inputs) + if err != nil { + return "", "", err + } + + if len(wallets) == 0 { + return "", "", fmt.Errorf("no signing wallets configured for sweep tx %s", txid) + } + + signErrs := make([]error, 0, len(wallets)) + for i, wallet := range wallets { + signed, signErr := builder.SignSweepTx(wallet, unsignedTx) + if signErr == nil { + return txid, signed, nil + } + // name the failing wallet so a multi-wallet operator can tell which rejected it + signErrs = append(signErrs, fmt.Errorf("wallet[%d]: %w", i, signErr)) + } + + return "", "", fmt.Errorf( + "no wallet could sign sweep tx %s: %w", txid, errors.Join(signErrs...), + ) +} + func (s *sweeper) start(ctx context.Context) error { s.scheduledTasks = make(map[string]struct{}) s.scheduler.Start() @@ -627,7 +670,9 @@ func (s *sweeper) createBatchSweepTask(commitmentTxid, vtxoTreeRootTxid string) ) // build the sweep transaction with all the expired non-swept batch outputs - sweepTxId, sweepTx, err = s.builder.BuildSweepTx(unspentOutputsToSweep) + sweepTxId, sweepTx, err = buildAndSignSweepTx( + s.builder, s.signingWallets(), unspentOutputsToSweep, + ) if err != nil { return err } @@ -663,7 +708,9 @@ func (s *sweeper) createBatchSweepTask(commitmentTxid, vtxoTreeRootTxid string) } else { // if all outputs are spent, it means we missed to mark the batch as swept, // build a sweep transaction without broadcasting it. we'll use it rebuild sweepEvent. - sweepTxId, sweepTx, err = s.builder.BuildSweepTx(outputsToSweep) + sweepTxId, sweepTx, err = buildAndSignSweepTx( + s.builder, s.signingWallets(), outputsToSweep, + ) if err != nil { return err } @@ -749,7 +796,9 @@ func (s *sweeper) createCheckpointSweepTask( checkpointTxid := toSweep.Txid log.Debugf("sweeper: start sweeping checkpoint %s", checkpointTxid) - _, sweepTx, err := s.builder.BuildSweepTx([]ports.TxInput{toSweep}) + _, sweepTx, err := buildAndSignSweepTx( + s.builder, s.signingWallets(), []ports.TxInput{toSweep}, + ) if err != nil { return err } diff --git a/internal/core/ports/tx_builder.go b/internal/core/ports/tx_builder.go index f0f392448..c2e58981d 100644 --- a/internal/core/ports/tx_builder.go +++ b/internal/core/ports/tx_builder.go @@ -60,7 +60,12 @@ type TxBuilder interface { VerifyForfeitTxs( vtxos []domain.Vtxo, connectors tree.FlatTxTree, txs []string, ) (valid map[domain.Outpoint]ValidForfeitTx, err error) - BuildSweepTx(inputs []TxInput) (txid string, signedSweepTx string, err error) + // BuildSweepTx builds the unsigned sweep transaction (its destination address + // and fees come from the primary wallet); SignSweepTx signs it with a given + // wallet. They are split so a sweep can be signed by any primary/fallback + // wallet without rebuilding it. + BuildSweepTx(inputs []TxInput) (unsignedTx string, txid string, err error) + SignSweepTx(wallet WalletService, unsignedTx string) (signedTx string, err error) GetSweepableBatchOutputs(vtxoTree *tree.TxTree) ( vtxoTreeExpiry *arklib.RelativeLocktime, batchOutputs *TxInput, err error, ) diff --git a/internal/infrastructure/live-store/live_store_test.go b/internal/infrastructure/live-store/live_store_test.go index c9e35b8e3..5a8a8ead1 100644 --- a/internal/infrastructure/live-store/live_store_test.go +++ b/internal/infrastructure/live-store/live_store_test.go @@ -834,13 +834,20 @@ func (m *mockedTxBuilder) BuildCommitmentTx( func (m *mockedTxBuilder) BuildSweepTx( inputs []ports.TxInput, -) (txid string, signedSweepTx string, err error) { +) (unsignedTx string, txid string, err error) { args := m.Called(inputs) res0 := args.Get(0).(string) res1 := args.Get(1).(string) return res0, res1, args.Error(2) } +func (m *mockedTxBuilder) SignSweepTx( + wallet ports.WalletService, unsignedTx string, +) (signedTx string, err error) { + args := m.Called(wallet, unsignedTx) + return args.Get(0).(string), args.Error(1) +} + func (m *mockedTxBuilder) GetSweepableBatchOutputs( vtxoTree *tree.TxTree, ) (vtxoTreeExpiry *arklib.RelativeLocktime, sweepInput *ports.TxInput, err error) { diff --git a/internal/infrastructure/tx-builder/covenantless/builder.go b/internal/infrastructure/tx-builder/covenantless/builder.go index 68ec3b334..ea3dd5ef9 100644 --- a/internal/infrastructure/tx-builder/covenantless/builder.go +++ b/internal/infrastructure/tx-builder/covenantless/builder.go @@ -275,10 +275,17 @@ func (b *txBuilder) FinalizeAndExtract(tx string) (string, error) { } func (b *txBuilder) BuildSweepTx(inputs []ports.TxInput) ( - txid, signedSweepTx string, err error, + unsignedTx, txid string, err error, ) { ctx := context.Background() - return sweepTransaction(ctx, b.wallet, inputs) + return buildSweepTransaction(ctx, b.wallet, inputs) +} + +func (b *txBuilder) SignSweepTx( + wallet ports.WalletService, unsignedTx string, +) (signedTx string, err error) { + ctx := context.Background() + return signSweepTransaction(ctx, wallet, unsignedTx) } func (b *txBuilder) VerifyForfeitTxs( diff --git a/internal/infrastructure/tx-builder/covenantless/sweep.go b/internal/infrastructure/tx-builder/covenantless/sweep.go index dc34cd290..83dcd1f8a 100644 --- a/internal/infrastructure/tx-builder/covenantless/sweep.go +++ b/internal/infrastructure/tx-builder/covenantless/sweep.go @@ -4,6 +4,7 @@ import ( "context" "encoding/hex" "fmt" + "strings" "github.com/arkade-os/arkd/internal/core/ports" arklib "github.com/arkade-os/arkd/pkg/ark-lib" @@ -17,9 +18,13 @@ import ( "github.com/btcsuite/btcd/wire" ) -func sweepTransaction( +// buildSweepTransaction builds the UNSIGNED sweep transaction. The destination +// address, fee estimate and dust limit are all taken from the given wallet (the +// primary wallet), so every signing candidate sweeps to the same output and the +// returned txid is stable regardless of which wallet ends up signing it. +func buildSweepTransaction( ctx context.Context, wallet ports.WalletService, inputs []ports.TxInput, -) (txid string, txhex string, err error) { +) (unsignedTx string, txid string, err error) { ins := make([]*wire.OutPoint, 0) sequences := make([]uint32, 0) @@ -73,8 +78,6 @@ func sweepTransaction( amount := int64(0) - tapscriptInputIndexes := make([]int, 0) - for i, input := range inputs { if input.TapscriptLeaf != nil { tapscriptBytes, err := hex.DecodeString(input.TapscriptLeaf.Tapscript) @@ -107,8 +110,6 @@ func sweepTransaction( ptx.Inputs[i].TaprootInternalKey = schnorr.SerializePubKey( internalKey, ) - - tapscriptInputIndexes = append(tapscriptInputIndexes, i) } inputAmount := int64(input.Value) @@ -139,14 +140,14 @@ func sweepTransaction( return "", "", err } - script, err := txscript.PayToAddrScript(addr) + pkScript, err := txscript.PayToAddrScript(addr) if err != nil { return "", "", err } ptx.UnsignedTx.AddTxOut(&wire.TxOut{ Value: amount, - PkScript: script, + PkScript: pkScript, }) ptx.Outputs = append(ptx.Outputs, psbt.POutput{}) @@ -176,26 +177,39 @@ func sweepTransaction( ptx.UnsignedTx.TxOut[0].Value = amount - int64(fees) - sweepPsbtBase64, err := ptx.B64Encode() + unsignedTx, err = ptx.B64Encode() if err != nil { return "", "", err } - if len(tapscriptInputIndexes) > 0 { - sweepPsbtBase64, err = wallet.SignTransactionTapscript( - ctx, - sweepPsbtBase64, - tapscriptInputIndexes, - ) - if err != nil { - return "", "", err + return unsignedTx, ptx.UnsignedTx.TxID(), nil +} + +// signSweepTransaction signs the unsigned sweep transaction with the given wallet +// and returns the raw signed tx hex. The tapscript inputs that need signing are +// re-derived from the psbt, so the caller only has to pass the unsigned tx. +func signSweepTransaction( + ctx context.Context, wallet ports.WalletService, unsignedTx string, +) (string, error) { + ptx, err := psbt.NewFromRawBytes(strings.NewReader(unsignedTx), true) + if err != nil { + return "", err + } + + tapscriptInputIndexes := make([]int, 0) + for i, in := range ptx.Inputs { + if len(in.TaprootLeafScript) > 0 { + tapscriptInputIndexes = append(tapscriptInputIndexes, i) } } - signedTxHex, err := wallet.SignTransaction(ctx, sweepPsbtBase64, true) - if err != nil { - return "", "", err + signedTx := unsignedTx + if len(tapscriptInputIndexes) > 0 { + signedTx, err = wallet.SignTransactionTapscript(ctx, signedTx, tapscriptInputIndexes) + if err != nil { + return "", err + } } - return ptx.UnsignedTx.TxID(), signedTxHex, nil + return wallet.SignTransaction(ctx, signedTx, true) } diff --git a/internal/infrastructure/tx-builder/covenantless/sweep_test.go b/internal/infrastructure/tx-builder/covenantless/sweep_test.go new file mode 100644 index 000000000..ca57ec1c9 --- /dev/null +++ b/internal/infrastructure/tx-builder/covenantless/sweep_test.go @@ -0,0 +1,106 @@ +package txbuilder + +import ( + "context" + "encoding/hex" + "testing" + + "github.com/arkade-os/arkd/internal/core/ports" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/stretchr/testify/require" +) + +// recordingWallet captures the arguments signSweepTransaction passes to the wallet. +type recordingWallet struct { + ports.WalletService + gotTapscriptIndexes []int + gotSignInput string + tapscriptSigned string + finalSigned string +} + +func (w *recordingWallet) SignTransactionTapscript( + _ context.Context, partialTx string, inputIndexes []int, +) (string, error) { + w.gotTapscriptIndexes = inputIndexes + return w.tapscriptSigned, nil +} + +func (w *recordingWallet) SignTransaction( + _ context.Context, partialTx string, _ bool, +) (string, error) { + w.gotSignInput = partialTx + return w.finalSigned, nil +} + +// TestSignSweepTransaction_DerivesTapscriptIndexes verifies that signing re-derives +// the tapscript input indexes from the psbt (the inputs carrying a TaprootLeafScript) +// rather than relying on indexes threaded through the builder API. +func TestSignSweepTransaction_DerivesTapscriptIndexes(t *testing.T) { + op0 := &wire.OutPoint{Hash: chainhash.Hash{0x01}, Index: 0} + op1 := &wire.OutPoint{Hash: chainhash.Hash{0x02}, Index: 0} + op2 := &wire.OutPoint{Hash: chainhash.Hash{0x03}, Index: 0} + + ptx, err := psbt.New( + []*wire.OutPoint{op0, op1, op2}, nil, 2, 0, + []uint32{wire.MaxTxInSequenceNum, wire.MaxTxInSequenceNum, wire.MaxTxInSequenceNum}, + ) + require.NoError(t, err) + + // a valid control block is a leaf-version/parity byte + a valid 32-byte x-only + // internal key (here secp256k1's generator G) + an optional merkle path; psbt + // deserialization parses and validates the internal key. + internalKey, err := hex.DecodeString( + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + require.NoError(t, err) + controlBlock := append([]byte{byte(txscript.BaseLeafVersion)}, internalKey...) + + // only inputs 1 and 2 carry a taproot leaf script + for _, i := range []int{1, 2} { + ptx.Inputs[i].TaprootLeafScript = []*psbt.TaprootTapLeafScript{{ + ControlBlock: controlBlock, + Script: []byte{0x04, 0x05}, + LeafVersion: txscript.BaseLeafVersion, + }} + } + + unsignedTx, err := ptx.B64Encode() + require.NoError(t, err) + + w := &recordingWallet{tapscriptSigned: "tapscript-signed-psbt", finalSigned: "final-raw-tx"} + + out, err := signSweepTransaction(context.Background(), w, unsignedTx) + require.NoError(t, err) + + require.Equal(t, "final-raw-tx", out) + require.Equal(t, []int{1, 2}, w.gotTapscriptIndexes) + // the tapscript-signed psbt must be the one forwarded to SignTransaction + require.Equal(t, "tapscript-signed-psbt", w.gotSignInput) +} + +// TestSignSweepTransaction_NoTapscriptInputs verifies that with no tapscript inputs, +// SignTransactionTapscript is skipped and the unsigned psbt goes straight to signing. +func TestSignSweepTransaction_NoTapscriptInputs(t *testing.T) { + op0 := &wire.OutPoint{Hash: chainhash.Hash{0x01}, Index: 0} + ptx, err := psbt.New( + []*wire.OutPoint{op0}, nil, 2, 0, []uint32{wire.MaxTxInSequenceNum}, + ) + require.NoError(t, err) + + unsignedTx, err := ptx.B64Encode() + require.NoError(t, err) + + w := &recordingWallet{tapscriptSigned: "should-not-be-used", finalSigned: "final-raw-tx"} + + out, err := signSweepTransaction(context.Background(), w, unsignedTx) + require.NoError(t, err) + + require.Equal(t, "final-raw-tx", out) + require.Nil(t, w.gotTapscriptIndexes) + // with no tapscript inputs, the unsigned psbt is forwarded unchanged + require.Equal(t, unsignedTx, w.gotSignInput) +} From e5d153fbed690b83f58fd782035d441376032295 Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:00:26 -0400 Subject: [PATCH 2/6] Address review: named sweeper init, dedupe signingWallets - newSweeper uses named struct fields so inserting a field can't silently misassign the positional values. - Extract primaryThenFallbacks so the sweeper and adminService share the primary-then-fallbacks ordering instead of duplicating it. --- internal/core/application/admin.go | 2 +- internal/core/application/sweeper.go | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/internal/core/application/admin.go b/internal/core/application/admin.go index 233518143..e246ae14a 100644 --- a/internal/core/application/admin.go +++ b/internal/core/application/admin.go @@ -93,7 +93,7 @@ func NewAdminService( // signingWallets returns the wallets to try when signing a sweep, in order: the // primary wallet first, then any configured fallbacks. func (a *adminService) signingWallets() []ports.WalletService { - return append([]ports.WalletService{a.walletSvc}, a.walletFallbacks...) + return primaryThenFallbacks(a.walletSvc, a.walletFallbacks) } func (a *adminService) Wallet() ports.WalletService { diff --git a/internal/core/application/sweeper.go b/internal/core/application/sweeper.go index c21d1af9f..a855aca7c 100644 --- a/internal/core/application/sweeper.go +++ b/internal/core/application/sweeper.go @@ -52,15 +52,28 @@ func newSweeper( scheduler ports.SchedulerService, ) *sweeper { return &sweeper{ - wallet, walletFallbacks, repoManager, builder, scheduler, - &sync.Mutex{}, make(map[string]struct{}), nil, + wallet: wallet, + walletFallbacks: walletFallbacks, + repoManager: repoManager, + builder: builder, + scheduler: scheduler, + locker: &sync.Mutex{}, + scheduledTasks: make(map[string]struct{}), } } // signingWallets returns the wallets to try when signing a sweep, in order: the // primary wallet first, then any configured fallbacks. func (s *sweeper) signingWallets() []ports.WalletService { - return append([]ports.WalletService{s.wallet}, s.walletFallbacks...) + return primaryThenFallbacks(s.wallet, s.walletFallbacks) +} + +// primaryThenFallbacks returns the primary wallet followed by the fallbacks — the +// order in which sweep signing is attempted. +func primaryThenFallbacks( + primary ports.WalletService, fallbacks []ports.WalletService, +) []ports.WalletService { + return append([]ports.WalletService{primary}, fallbacks...) } // buildAndSignSweepTx builds the sweep transaction once (its destination and fees From bbb19378eedf47311fefff4031903e0afe02c0bf Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:29:06 -0400 Subject: [PATCH 3/6] docs: describe the sweep-fallback behavior in the README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 53a396d3f..743674efb 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ export ARKD_WALLET_ADDR=localhost:6060 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. +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: when a batch cannot be signed by the primary wallet (for example a batch created before this wallet became the primary), `arkd` tries each fallback wallet in turn until one can sign it. The swept funds always go to the primary wallet's address, whichever wallet signs. ### Connect to signer From b6bb1a1c18fe9b0f69a2c206a5f5a0c81d0e9109 Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Sat, 13 Jun 2026 00:47:34 -0400 Subject: [PATCH 4/6] Address review: don't block sweep reconciliation when no wallet can sign --- internal/core/application/sweeper.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/internal/core/application/sweeper.go b/internal/core/application/sweeper.go index a855aca7c..fbb7930a6 100644 --- a/internal/core/application/sweeper.go +++ b/internal/core/application/sweeper.go @@ -720,12 +720,23 @@ func (s *sweeper) createBatchSweepTask(commitmentTxid, vtxoTreeRootTxid string) log.Debugf("sweeper: batch %s swept by: %s", commitmentTxid, txid) } else { // if all outputs are spent, it means we missed to mark the batch as swept, - // build a sweep transaction without broadcasting it. we'll use it rebuild sweepEvent. + // build a sweep transaction without broadcasting it. we'll use it to rebuild + // sweepEvent. The outputs are already spent on-chain, so this tx is never + // broadcast and a signing failure (e.g. the wallet that swept them is no + // longer primary or fallback) must not block reconciliation: fall back to the + // unsigned tx, which still carries the txid the event needs. sweepTxId, sweepTx, err = buildAndSignSweepTx( s.builder, s.signingWallets(), outputsToSweep, ) if err != nil { - return err + log.WithError(err).Warnf( + "sweeper: could not sign sweep tx for already-spent batch %s, "+ + "reconciling with unsigned tx", commitmentTxid, + ) + sweepTx, sweepTxId, err = s.builder.BuildSweepTx(outputsToSweep) + if err != nil { + return err + } } } From be5ee751cccfaf187827d97effdedad3ba48433f Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:42:39 -0400 Subject: [PATCH 5/6] refactor: tidy sweep-fallback signing helpers, comments, and test file - inline the one-line primary+fallbacks append into each signingWallets() method and drop the primaryThenFallbacks helper (one less indirection) - rename sweep_fallback_internal_test.go to sweeper_test.go to match sweeper.go and the package's white-box test convention - split the BuildSweepTx/SignSweepTx interface doc into per-method comments and trim the verbose dialFallbackWallets / sweep tx comments --- internal/config/config.go | 10 +++------- internal/core/application/admin.go | 2 +- internal/core/application/sweeper.go | 16 ++++------------ ...fallback_internal_test.go => sweeper_test.go} | 0 internal/core/ports/tx_builder.go | 8 ++++---- .../tx-builder/covenantless/sweep.go | 8 ++++---- 6 files changed, 16 insertions(+), 28 deletions(-) rename internal/core/application/{sweep_fallback_internal_test.go => sweeper_test.go} (100%) diff --git a/internal/config/config.go b/internal/config/config.go index 1a7b280d8..1fece465f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -853,13 +853,9 @@ func (c *Config) walletService() error { 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 as sweep -// fallbacks: sweep signing is attempted with the primary first, then each -// fallback. The primary remains the sole source of the forfeit pubkey and -// addresses and the only wallet that funds batches. Any failure is fatal so a -// misconfigured wallet is surfaced at startup. +// dialFallbackWallets dials the configured fallback arkd-wallets, used as sweep +// signers, validating each is reachable and on the primary's network. Any +// failure is fatal so misconfiguration surfaces at startup. func (c *Config) dialFallbackWallets() ([]FallbackWallet, error) { fallbacks := make([]FallbackWallet, 0, len(c.WalletFallbackAddrs)) seen := make(map[string]struct{}, len(c.WalletFallbackAddrs)) diff --git a/internal/core/application/admin.go b/internal/core/application/admin.go index e246ae14a..233518143 100644 --- a/internal/core/application/admin.go +++ b/internal/core/application/admin.go @@ -93,7 +93,7 @@ func NewAdminService( // signingWallets returns the wallets to try when signing a sweep, in order: the // primary wallet first, then any configured fallbacks. func (a *adminService) signingWallets() []ports.WalletService { - return primaryThenFallbacks(a.walletSvc, a.walletFallbacks) + return append([]ports.WalletService{a.walletSvc}, a.walletFallbacks...) } func (a *adminService) Wallet() ports.WalletService { diff --git a/internal/core/application/sweeper.go b/internal/core/application/sweeper.go index fbb7930a6..37a768c45 100644 --- a/internal/core/application/sweeper.go +++ b/internal/core/application/sweeper.go @@ -65,15 +65,7 @@ func newSweeper( // signingWallets returns the wallets to try when signing a sweep, in order: the // primary wallet first, then any configured fallbacks. func (s *sweeper) signingWallets() []ports.WalletService { - return primaryThenFallbacks(s.wallet, s.walletFallbacks) -} - -// primaryThenFallbacks returns the primary wallet followed by the fallbacks — the -// order in which sweep signing is attempted. -func primaryThenFallbacks( - primary ports.WalletService, fallbacks []ports.WalletService, -) []ports.WalletService { - return append([]ports.WalletService{primary}, fallbacks...) + return append([]ports.WalletService{s.wallet}, s.walletFallbacks...) } // buildAndSignSweepTx builds the sweep transaction once (its destination and fees @@ -720,11 +712,11 @@ func (s *sweeper) createBatchSweepTask(commitmentTxid, vtxoTreeRootTxid string) log.Debugf("sweeper: batch %s swept by: %s", commitmentTxid, txid) } else { // if all outputs are spent, it means we missed to mark the batch as swept, - // build a sweep transaction without broadcasting it. we'll use it to rebuild + // build a sweep transaction without broadcasting it. We'll use it to rebuild // sweepEvent. The outputs are already spent on-chain, so this tx is never // broadcast and a signing failure (e.g. the wallet that swept them is no - // longer primary or fallback) must not block reconciliation: fall back to the - // unsigned tx, which still carries the txid the event needs. + // longer primary or fallback) must not block reconciliation so we fall back to the + // unsigned tx which still carries the txid the event needs. sweepTxId, sweepTx, err = buildAndSignSweepTx( s.builder, s.signingWallets(), outputsToSweep, ) diff --git a/internal/core/application/sweep_fallback_internal_test.go b/internal/core/application/sweeper_test.go similarity index 100% rename from internal/core/application/sweep_fallback_internal_test.go rename to internal/core/application/sweeper_test.go diff --git a/internal/core/ports/tx_builder.go b/internal/core/ports/tx_builder.go index c2e58981d..c084a1242 100644 --- a/internal/core/ports/tx_builder.go +++ b/internal/core/ports/tx_builder.go @@ -60,11 +60,11 @@ type TxBuilder interface { VerifyForfeitTxs( vtxos []domain.Vtxo, connectors tree.FlatTxTree, txs []string, ) (valid map[domain.Outpoint]ValidForfeitTx, err error) - // BuildSweepTx builds the unsigned sweep transaction (its destination address - // and fees come from the primary wallet); SignSweepTx signs it with a given - // wallet. They are split so a sweep can be signed by any primary/fallback - // wallet without rebuilding it. + // BuildSweepTx builds the unsigned sweep tx, using the primary wallet for the + // destination address and fees. BuildSweepTx(inputs []TxInput) (unsignedTx string, txid string, err error) + // SignSweepTx signs an unsigned sweep tx with the given wallet, so it can be + // signed by any primary/fallback wallet without rebuilding it. SignSweepTx(wallet WalletService, unsignedTx string) (signedTx string, err error) GetSweepableBatchOutputs(vtxoTree *tree.TxTree) ( vtxoTreeExpiry *arklib.RelativeLocktime, batchOutputs *TxInput, err error, diff --git a/internal/infrastructure/tx-builder/covenantless/sweep.go b/internal/infrastructure/tx-builder/covenantless/sweep.go index 83dcd1f8a..771bf7110 100644 --- a/internal/infrastructure/tx-builder/covenantless/sweep.go +++ b/internal/infrastructure/tx-builder/covenantless/sweep.go @@ -18,9 +18,9 @@ import ( "github.com/btcsuite/btcd/wire" ) -// buildSweepTransaction builds the UNSIGNED sweep transaction. The destination +// buildSweepTransaction builds the unsigned sweep transaction. The destination // address, fee estimate and dust limit are all taken from the given wallet (the -// primary wallet), so every signing candidate sweeps to the same output and the +// primary wallet) so every signing candidate sweeps to the same output and the // returned txid is stable regardless of which wallet ends up signing it. func buildSweepTransaction( ctx context.Context, wallet ports.WalletService, inputs []ports.TxInput, @@ -186,8 +186,8 @@ func buildSweepTransaction( } // signSweepTransaction signs the unsigned sweep transaction with the given wallet -// and returns the raw signed tx hex. The tapscript inputs that need signing are -// re-derived from the psbt, so the caller only has to pass the unsigned tx. +// The tapscript inputs that need signing are re-derived from the psbt, so the caller +// only has to pass the unsigned tx. func signSweepTransaction( ctx context.Context, wallet ports.WalletService, unsignedTx string, ) (string, error) { From e39ff38a482792e48adecaa745ba0ae9263f41ab Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:45:12 -0400 Subject: [PATCH 6/6] refactor(sweep): consistent txid-first returns; avoid rebuild + unsigned-tx store Address review on the sweep-fallback path: - BuildSweepTx now returns (txid, unsignedTx, err) to match buildAndSignSweepTx's (txid, signedTx, err), removing the foot-gun where createBatchSweepTask had both orderings (both string, so the compiler couldn't catch a mis-destructure). - buildAndSignSweepTx returns the txid even on signing failure, so the already-spent reconciliation path reuses it instead of calling BuildSweepTx a second time. - That path now stores an empty tx (not the unsigned PSBT) in the swept event, since it is never broadcast; round.Sweep only needs the txid. --- internal/core/application/sweeper.go | 41 +++++++++++-------- internal/core/application/sweeper_test.go | 4 +- internal/core/ports/tx_builder.go | 2 +- .../live-store/live_store_test.go | 2 +- .../tx-builder/covenantless/builder.go | 2 +- .../tx-builder/covenantless/sweep.go | 4 +- 6 files changed, 31 insertions(+), 24 deletions(-) diff --git a/internal/core/application/sweeper.go b/internal/core/application/sweeper.go index 37a768c45..380591eed 100644 --- a/internal/core/application/sweeper.go +++ b/internal/core/application/sweeper.go @@ -70,19 +70,24 @@ func (s *sweeper) signingWallets() []ports.WalletService { // buildAndSignSweepTx builds the sweep transaction once (its destination and fees // come from the primary wallet) and then attempts to sign it with each wallet in -// order, returning as soon as one succeeds. This lets a single arkd sweep batches -// signed by any of its primary/fallback arkd-wallets. Broadcasting is left to the -// caller. +// order, returning (txid, signedTx, nil) as soon as one succeeds. This lets a +// single arkd sweep batches signed by any of its primary/fallback arkd-wallets. +// Broadcasting is left to the caller. +// +// On signing failure it still returns the txid (with a non-nil error and an empty +// signed tx) so a reconcile-only caller can use the txid without rebuilding the +// tx. Callers that broadcast must treat a non-nil error as fatal. A build failure +// returns an empty txid. func buildAndSignSweepTx( builder ports.TxBuilder, wallets []ports.WalletService, inputs []ports.TxInput, ) (string, string, error) { - unsignedTx, txid, err := builder.BuildSweepTx(inputs) + txid, unsignedTx, err := builder.BuildSweepTx(inputs) if err != nil { return "", "", err } if len(wallets) == 0 { - return "", "", fmt.Errorf("no signing wallets configured for sweep tx %s", txid) + return txid, "", fmt.Errorf("no signing wallets configured for sweep tx %s", txid) } signErrs := make([]error, 0, len(wallets)) @@ -95,7 +100,7 @@ func buildAndSignSweepTx( signErrs = append(signErrs, fmt.Errorf("wallet[%d]: %w", i, signErr)) } - return "", "", fmt.Errorf( + return txid, "", fmt.Errorf( "no wallet could sign sweep tx %s: %w", txid, errors.Join(signErrs...), ) } @@ -711,24 +716,26 @@ func (s *sweeper) createBatchSweepTask(commitmentTxid, vtxoTreeRootTxid string) } log.Debugf("sweeper: batch %s swept by: %s", commitmentTxid, txid) } else { - // if all outputs are spent, it means we missed to mark the batch as swept, - // build a sweep transaction without broadcasting it. We'll use it to rebuild - // sweepEvent. The outputs are already spent on-chain, so this tx is never - // broadcast and a signing failure (e.g. the wallet that swept them is no - // longer primary or fallback) must not block reconciliation so we fall back to the - // unsigned tx which still carries the txid the event needs. + // All outputs are already spent on-chain — we just missed marking the + // batch swept. We rebuild the sweepEvent from the txid; the tx is never + // broadcast here, so a signing failure (e.g. the wallet that swept them + // is no longer primary or fallback) must not block reconciliation. + // buildAndSignSweepTx still returns the txid on signing failure, so we + // reconcile with the txid and store no tx bytes (an unsigned PSBT here + // would masquerade as a signed sweep tx in SweepTxs / the swept event). sweepTxId, sweepTx, err = buildAndSignSweepTx( s.builder, s.signingWallets(), outputsToSweep, ) if err != nil { + if sweepTxId == "" { + // the build itself failed; nothing to reconcile with + return err + } log.WithError(err).Warnf( "sweeper: could not sign sweep tx for already-spent batch %s, "+ - "reconciling with unsigned tx", commitmentTxid, + "reconciling with txid only", commitmentTxid, ) - sweepTx, sweepTxId, err = s.builder.BuildSweepTx(outputsToSweep) - if err != nil { - return err - } + sweepTx = "" } } diff --git a/internal/core/application/sweeper_test.go b/internal/core/application/sweeper_test.go index dd772542c..ae0ba22c2 100644 --- a/internal/core/application/sweeper_test.go +++ b/internal/core/application/sweeper_test.go @@ -31,7 +31,7 @@ func (b *fakeSweepBuilder) BuildSweepTx(inputs []ports.TxInput) (string, string, if b.buildErr != nil { return "", "", b.buildErr } - return b.unsignedTx, b.txid, nil + return b.txid, b.unsignedTx, nil } func (b *fakeSweepBuilder) SignSweepTx( @@ -76,7 +76,7 @@ func TestBuildAndSignSweepTx(t *testing.T) { txid, signed, err := buildAndSignSweepTx(b, wallets, inputs) require.Error(t, err) - require.Empty(t, txid) + require.Equal(t, "txid123", txid) require.Empty(t, signed) require.Contains(t, err.Error(), "no wallet could sign sweep tx txid123") require.Contains(t, err.Error(), "wallet[0]") diff --git a/internal/core/ports/tx_builder.go b/internal/core/ports/tx_builder.go index c084a1242..670b7e3bd 100644 --- a/internal/core/ports/tx_builder.go +++ b/internal/core/ports/tx_builder.go @@ -62,7 +62,7 @@ type TxBuilder interface { ) (valid map[domain.Outpoint]ValidForfeitTx, err error) // BuildSweepTx builds the unsigned sweep tx, using the primary wallet for the // destination address and fees. - BuildSweepTx(inputs []TxInput) (unsignedTx string, txid string, err error) + BuildSweepTx(inputs []TxInput) (txid string, unsignedTx string, err error) // SignSweepTx signs an unsigned sweep tx with the given wallet, so it can be // signed by any primary/fallback wallet without rebuilding it. SignSweepTx(wallet WalletService, unsignedTx string) (signedTx string, err error) diff --git a/internal/infrastructure/live-store/live_store_test.go b/internal/infrastructure/live-store/live_store_test.go index 5a8a8ead1..c0886ec43 100644 --- a/internal/infrastructure/live-store/live_store_test.go +++ b/internal/infrastructure/live-store/live_store_test.go @@ -834,7 +834,7 @@ func (m *mockedTxBuilder) BuildCommitmentTx( func (m *mockedTxBuilder) BuildSweepTx( inputs []ports.TxInput, -) (unsignedTx string, txid string, err error) { +) (txid string, unsignedTx string, err error) { args := m.Called(inputs) res0 := args.Get(0).(string) res1 := args.Get(1).(string) diff --git a/internal/infrastructure/tx-builder/covenantless/builder.go b/internal/infrastructure/tx-builder/covenantless/builder.go index ea3dd5ef9..59d25ea08 100644 --- a/internal/infrastructure/tx-builder/covenantless/builder.go +++ b/internal/infrastructure/tx-builder/covenantless/builder.go @@ -275,7 +275,7 @@ func (b *txBuilder) FinalizeAndExtract(tx string) (string, error) { } func (b *txBuilder) BuildSweepTx(inputs []ports.TxInput) ( - unsignedTx, txid string, err error, + txid, unsignedTx string, err error, ) { ctx := context.Background() return buildSweepTransaction(ctx, b.wallet, inputs) diff --git a/internal/infrastructure/tx-builder/covenantless/sweep.go b/internal/infrastructure/tx-builder/covenantless/sweep.go index 771bf7110..4ae0d4c7d 100644 --- a/internal/infrastructure/tx-builder/covenantless/sweep.go +++ b/internal/infrastructure/tx-builder/covenantless/sweep.go @@ -24,7 +24,7 @@ import ( // returned txid is stable regardless of which wallet ends up signing it. func buildSweepTransaction( ctx context.Context, wallet ports.WalletService, inputs []ports.TxInput, -) (unsignedTx string, txid string, err error) { +) (txid string, unsignedTx string, err error) { ins := make([]*wire.OutPoint, 0) sequences := make([]uint32, 0) @@ -182,7 +182,7 @@ func buildSweepTransaction( return "", "", err } - return unsignedTx, ptx.UnsignedTx.TxID(), nil + return ptx.UnsignedTx.TxID(), unsignedTx, nil } // signSweepTransaction signs the unsigned sweep transaction with the given wallet