From 0df7a575a3b8d0258f187148db7dd7e73fbc01f3 Mon Sep 17 00:00:00 2001 From: Kukks Date: Sun, 14 Jun 2026 18:52:54 +0200 Subject: [PATCH 01/34] db: add RoundRepository.PatchForfeitTxs for forfeit-tx backfill --- internal/core/domain/round_repo.go | 5 +++ internal/infrastructure/db/badger/ark_repo.go | 14 ++++++ .../infrastructure/db/postgres/round_repo.go | 17 +++++++ .../db/postgres/sqlc/queries/query.sql.go | 14 ++++++ .../infrastructure/db/postgres/sqlc/query.sql | 3 ++ internal/infrastructure/db/service_test.go | 44 +++++++++++++++++++ .../infrastructure/db/sqlite/round_repo.go | 17 +++++++ .../db/sqlite/sqlc/queries/query.sql.go | 14 ++++++ .../infrastructure/db/sqlite/sqlc/query.sql | 3 ++ 9 files changed, 131 insertions(+) diff --git a/internal/core/domain/round_repo.go b/internal/core/domain/round_repo.go index 7c8d7a877..05459c947 100644 --- a/internal/core/domain/round_repo.go +++ b/internal/core/domain/round_repo.go @@ -33,6 +33,11 @@ type RoundRepository interface { // used to lazily persist fees recomputed for rounds finalized before fee // persistence was introduced (https://github.com/arkade-os/arkd/pull/933). PatchCollectedFees(ctx context.Context, feesByRoundId map[string]uint64) error + // PatchForfeitTxs replaces the stored tx (PSBT) of the given forfeit txs, + // keyed by txid. Used to backfill the operator signature on forfeit txs that + // were persisted before collection-time signing was introduced. Signing only + // adds witness data, so the txid is unchanged and safely keys the update. + PatchForfeitTxs(ctx context.Context, txByTxid map[string]string) error Close() } diff --git a/internal/infrastructure/db/badger/ark_repo.go b/internal/infrastructure/db/badger/ark_repo.go index a0417be54..85d3ff53b 100644 --- a/internal/infrastructure/db/badger/ark_repo.go +++ b/internal/infrastructure/db/badger/ark_repo.go @@ -99,6 +99,20 @@ func (r *arkRepository) PatchCollectedFees( return nil } +// PatchForfeitTxs replaces the stored tx bytes of the given forfeit txs, keyed by +// txid. Forfeit txs are persisted as standalone Tx records (see addTxs), so the +// patch is a direct upsert under the same txid. +func (r *arkRepository) PatchForfeitTxs( + ctx context.Context, txByTxid map[string]string, +) error { + for txid, tx := range txByTxid { + if err := r.store.Upsert(txid, Tx{Txid: txid, Tx: tx}); err != nil { + return fmt.Errorf("failed to patch forfeit tx %s: %w", txid, err) + } + } + return nil +} + func (r *arkRepository) GetRoundWithCommitmentTxid( ctx context.Context, txid string, ) (*domain.Round, error) { diff --git a/internal/infrastructure/db/postgres/round_repo.go b/internal/infrastructure/db/postgres/round_repo.go index b66279a40..700e58b72 100644 --- a/internal/infrastructure/db/postgres/round_repo.go +++ b/internal/infrastructure/db/postgres/round_repo.go @@ -499,6 +499,23 @@ func (r *roundRepository) PatchCollectedFees( return execTx(ctx, r.db, txBody) } +func (r *roundRepository) PatchForfeitTxs( + ctx context.Context, txByTxid map[string]string, +) error { + txBody := func(querierWithTx *queries.Queries) error { + for txid, tx := range txByTxid { + if err := querierWithTx.UpdateForfeitTx( + ctx, + queries.UpdateForfeitTxParams{Tx: tx, Txid: txid}, + ); err != nil { + return fmt.Errorf("failed to patch forfeit tx %s: %w", txid, err) + } + } + return nil + } + return execTx(ctx, r.db, txBody) +} + func rowToReceiver(row queries.IntentWithReceiversVw) domain.Receiver { return domain.Receiver{ Amount: uint64(row.Amount.Int64), diff --git a/internal/infrastructure/db/postgres/sqlc/queries/query.sql.go b/internal/infrastructure/db/postgres/sqlc/queries/query.sql.go index e6b7acb9c..67fe024b9 100644 --- a/internal/infrastructure/db/postgres/sqlc/queries/query.sql.go +++ b/internal/infrastructure/db/postgres/sqlc/queries/query.sql.go @@ -1836,6 +1836,20 @@ func (q *Queries) UpdateConvictionPardoned(ctx context.Context, id string) error return err } +const updateForfeitTx = `-- name: UpdateForfeitTx :exec +UPDATE tx SET tx = $1 WHERE txid = $2 AND type = 'forfeit' +` + +type UpdateForfeitTxParams struct { + Tx string + Txid string +} + +func (q *Queries) UpdateForfeitTx(ctx context.Context, arg UpdateForfeitTxParams) error { + _, err := q.db.ExecContext(ctx, updateForfeitTx, arg.Tx, arg.Txid) + return err +} + const updateRoundCollectedFees = `-- name: UpdateRoundCollectedFees :exec UPDATE round SET fees = $1 WHERE id = $2 ` diff --git a/internal/infrastructure/db/postgres/sqlc/query.sql b/internal/infrastructure/db/postgres/sqlc/query.sql index bbfcfb630..22b176bac 100644 --- a/internal/infrastructure/db/postgres/sqlc/query.sql +++ b/internal/infrastructure/db/postgres/sqlc/query.sql @@ -411,6 +411,9 @@ SELECT * FROM asset WHERE asset.id = ANY($1::varchar[]); -- name: UpdateRoundCollectedFees :exec UPDATE round SET fees = @fees WHERE id = @id; +-- name: UpdateForfeitTx :exec +UPDATE tx SET tx = @tx WHERE txid = @txid AND type = 'forfeit'; + -- name: SelectAssetSupply :one SELECT (COALESCE(SUM(ap.amount), 0))::TEXT AS supply FROM asset_projection ap diff --git a/internal/infrastructure/db/service_test.go b/internal/infrastructure/db/service_test.go index 9bf761ad7..2694d0a45 100644 --- a/internal/infrastructure/db/service_test.go +++ b/internal/infrastructure/db/service_test.go @@ -720,6 +720,50 @@ func testRoundRepository(t *testing.T, svc ports.RepoManager) { } }) + t.Run("test_patch_forfeit_txs", func(t *testing.T) { + ctx := context.Background() + repo := svc.Rounds() + + // Finalize a round with four forfeit txs (as stored at collection time). + id := uuid.New().String() + commitmentTxid := randomString(32) + forfeits := []domain.ForfeitTx{f1Tx(), f2Tx(), f3Tx(), f4Tx()} + untouchedTxid := forfeits[2].Txid // f3Tx has a random txid; keep a reference + round := domain.NewRoundFromEvents([]domain.Event{ + domain.RoundStarted{ + RoundEvent: domain.RoundEvent{Id: id, Type: domain.EventTypeRoundStarted}, + Timestamp: 100, + }, + domain.RoundFinalizationStarted{ + RoundEvent: domain.RoundEvent{Id: id, Type: domain.EventTypeRoundFinalizationStarted}, + CommitmentTxid: commitmentTxid, + CommitmentTx: emptyTx, + }, + domain.RoundFinalized{ + RoundEvent: domain.RoundEvent{Id: id, Type: domain.EventTypeRoundFinalized}, + ForfeitTxs: forfeits, + FinalCommitmentTx: emptyTx, + Timestamp: 110, + }, + }) + require.NoError(t, repo.AddOrUpdateRound(ctx, *round)) + + // Patch f1 (txida) and f2 (txidb) with new operator-signed tx bytes, leaving + // f3/f4 untouched. The txid is unchanged by signing, so it keys the update. + patches := map[string]string{txida: f3, txidb: f4} + require.NoError(t, repo.PatchForfeitTxs(ctx, patches)) + + got, err := repo.GetRoundForfeitTxs(ctx, commitmentTxid) + require.NoError(t, err) + byTxid := make(map[string]string, len(got)) + for _, ftx := range got { + byTxid[ftx.Txid] = ftx.Tx + } + require.Equal(t, f3, byTxid[txida], "txida forfeit tx should be patched") + require.Equal(t, f4, byTxid[txidb], "txidb forfeit tx should be patched") + require.Equal(t, f3, byTxid[untouchedTxid], "unpatched forfeit tx must be unchanged") + }) + } func testVtxoRepository(t *testing.T, svc ports.RepoManager) { diff --git a/internal/infrastructure/db/sqlite/round_repo.go b/internal/infrastructure/db/sqlite/round_repo.go index 4c5020490..c6b485d02 100644 --- a/internal/infrastructure/db/sqlite/round_repo.go +++ b/internal/infrastructure/db/sqlite/round_repo.go @@ -564,6 +564,23 @@ func (r *roundRepository) PatchCollectedFees( return execTx(ctx, r.db, txBody) } +func (r *roundRepository) PatchForfeitTxs( + ctx context.Context, txByTxid map[string]string, +) error { + txBody := func(querierWithTx *queries.Queries) error { + for txid, tx := range txByTxid { + if err := querierWithTx.UpdateForfeitTx( + ctx, + queries.UpdateForfeitTxParams{Tx: tx, Txid: txid}, + ); err != nil { + return fmt.Errorf("failed to patch forfeit tx %s: %w", txid, err) + } + } + return nil + } + return execTx(ctx, r.db, txBody) +} + func rowToReceiver(row queries.IntentWithReceiversVw) domain.Receiver { return domain.Receiver{ Amount: uint64(row.Amount.Int64), diff --git a/internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go b/internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go index c1ec0032c..a88bee3c3 100644 --- a/internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go +++ b/internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go @@ -1963,6 +1963,20 @@ func (q *Queries) UpdateConvictionPardoned(ctx context.Context, id string) error return err } +const updateForfeitTx = `-- name: UpdateForfeitTx :exec +UPDATE tx SET tx = ?1 WHERE txid = ?2 AND type = 'forfeit' +` + +type UpdateForfeitTxParams struct { + Tx string + Txid string +} + +func (q *Queries) UpdateForfeitTx(ctx context.Context, arg UpdateForfeitTxParams) error { + _, err := q.db.ExecContext(ctx, updateForfeitTx, arg.Tx, arg.Txid) + return err +} + const updateRoundCollectedFees = `-- name: UpdateRoundCollectedFees :exec UPDATE round SET fees = ?1 WHERE id = ?2 ` diff --git a/internal/infrastructure/db/sqlite/sqlc/query.sql b/internal/infrastructure/db/sqlite/sqlc/query.sql index 2d35d0328..28b425ad4 100644 --- a/internal/infrastructure/db/sqlite/sqlc/query.sql +++ b/internal/infrastructure/db/sqlite/sqlc/query.sql @@ -419,6 +419,9 @@ VALUES (@asset_id, @txid, @vout, @amount); -- name: UpdateRoundCollectedFees :exec UPDATE round SET fees = sqlc.arg('fees') WHERE id = sqlc.arg('id'); +-- name: UpdateForfeitTx :exec +UPDATE tx SET tx = sqlc.arg('tx') WHERE txid = sqlc.arg('txid') AND type = 'forfeit'; + -- name: SelectAssetsByIds :many SELECT * FROM asset WHERE asset.id IN (sqlc.slice('ids')); From 140c1edab983e984f08ae8fb1f56bbf8d4ac7441 Mon Sep 17 00:00:00 2001 From: Kukks Date: Sun, 14 Jun 2026 18:59:44 +0200 Subject: [PATCH 02/34] arkd: sign forfeit txs at collection time so they are broadcast-ready --- internal/core/application/service.go | 42 ++++++-- .../core/application/service_forfeit_test.go | 98 +++++++++++++++++++ 2 files changed, 132 insertions(+), 8 deletions(-) create mode 100644 internal/core/application/service_forfeit_test.go diff --git a/internal/core/application/service.go b/internal/core/application/service.go index 7bc7d57d0..feac7d8dc 100644 --- a/internal/core/application/service.go +++ b/internal/core/application/service.go @@ -2052,6 +2052,31 @@ func (s *service) ConfirmRegistration(ctx context.Context, intentId string) erro return nil } +// signForfeitTxs adds the operator signature to each collected forfeit tx and +// returns them as domain.ForfeitTx ready to be persisted. Signing only adds +// witness data, so the txid is read from the signed psbt's unsigned tx and is +// identical to the txid of the user-submitted forfeit tx. +func (s *service) signForfeitTxs( + ctx context.Context, forfeitTxs []string, +) ([]domain.ForfeitTx, error) { + signed := make([]domain.ForfeitTx, 0, len(forfeitTxs)) + for _, tx := range forfeitTxs { + signedTx, err := s.signer.SignTransactionTapscript(ctx, tx, nil) + if err != nil { + return nil, fmt.Errorf("failed to sign forfeit tx: %w", err) + } + ptx, err := psbt.NewFromRawBytes(strings.NewReader(signedTx), true) + if err != nil { + return nil, fmt.Errorf("failed to parse signed forfeit tx: %w", err) + } + signed = append(signed, domain.ForfeitTx{ + Txid: ptx.UnsignedTx.TxID(), + Tx: signedTx, + }) + } + return signed, nil +} + func (s *service) SubmitForfeitTxs(ctx context.Context, forfeitTxs []string) errors.Error { if len(forfeitTxs) <= 0 { return nil @@ -3288,14 +3313,15 @@ func (s *service) finalizeRound(roundId string, roundTiming roundTiming, setting s.roundReportSvc.OpEnded(VerifyBoardingInputsSignaturesOp) } - for _, tx := range forfeitTxList { - // nolint - ptx, _ := psbt.NewFromRawBytes(strings.NewReader(tx), true) - forfeitTxid := ptx.UnsignedTx.TxID() - forfeitTxs = append(forfeitTxs, domain.ForfeitTx{ - Txid: forfeitTxid, - Tx: tx, - }) + // Add the operator signature to each forfeit tx at collection time, so the + // stored forfeit tx is broadcast-ready without needing to be signed later + // at fraud-reaction time. + forfeitTxs, err = s.signForfeitTxs(ctx, forfeitTxList) + if err != nil { + changes = round.Fail(errors.INTERNAL_ERROR.New( + "failed to sign forfeit txs: %s", err, + )) + return } } diff --git a/internal/core/application/service_forfeit_test.go b/internal/core/application/service_forfeit_test.go new file mode 100644 index 000000000..22ee22cba --- /dev/null +++ b/internal/core/application/service_forfeit_test.go @@ -0,0 +1,98 @@ +package application + +import ( + "context" + "errors" + "strings" + "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/wire" + "github.com/stretchr/testify/require" +) + +// fakeForfeitSigner is a minimal ports.SignerService double: it records calls to +// SignTransactionTapscript and returns a canned signed tx (or error). Other +// SignerService methods are inherited from the embedded interface and unused. +type fakeForfeitSigner struct { + ports.SignerService + returnTx string + err error + calls int + lastTx string +} + +func (f *fakeForfeitSigner) SignTransactionTapscript( + _ context.Context, partialTx string, _ []int, +) (string, error) { + f.calls++ + f.lastTx = partialTx + if f.err != nil { + return "", f.err + } + return f.returnTx, nil +} + +func unsignedPsbt(t *testing.T, inputIndex byte) string { + t.Helper() + var hash chainhash.Hash + hash[0] = inputIndex + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(&wire.OutPoint{Hash: hash, Index: 0}, nil, nil)) + tx.AddTxOut(wire.NewTxOut(1000, []byte{txscriptOpTrue})) + p, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + b64, err := p.B64Encode() + require.NoError(t, err) + return b64 +} + +const txscriptOpTrue = 0x51 + +func TestSignForfeitTxs(t *testing.T) { + ctx := context.Background() + + // The user-signed forfeit tx submitted at collection time. + userSigned := unsignedPsbt(t, 0xaa) + // What the operator signer returns: a distinct, fully signed forfeit tx. + operatorSigned := unsignedPsbt(t, 0xbb) + wantPtx, err := psbt.NewFromRawBytes(strings.NewReader(operatorSigned), true) + require.NoError(t, err) + wantTxid := wantPtx.UnsignedTx.TxID() + + t.Run("signs each forfeit tx via the operator signer", func(t *testing.T) { + signer := &fakeForfeitSigner{returnTx: operatorSigned} + s := &service{signer: signer} + + got, err := s.signForfeitTxs(ctx, []string{userSigned}) + + require.NoError(t, err) + require.Equal(t, 1, signer.calls, "signer must be invoked once per forfeit tx") + require.Equal(t, userSigned, signer.lastTx, "signer must receive the user-signed tx") + require.Len(t, got, 1) + require.Equal(t, operatorSigned, got[0].Tx, "stored tx must be the operator-signed tx") + require.Equal(t, wantTxid, got[0].Txid, "txid must come from the signed psbt") + }) + + t.Run("signs every forfeit tx in the batch", func(t *testing.T) { + signer := &fakeForfeitSigner{returnTx: operatorSigned} + s := &service{signer: signer} + + got, err := s.signForfeitTxs(ctx, []string{userSigned, userSigned, userSigned}) + + require.NoError(t, err) + require.Equal(t, 3, signer.calls) + require.Len(t, got, 3) + }) + + t.Run("returns error when the signer fails", func(t *testing.T) { + signer := &fakeForfeitSigner{err: errors.New("signer unavailable")} + s := &service{signer: signer} + + _, err := s.signForfeitTxs(ctx, []string{userSigned}) + + require.Error(t, err) + }) +} From 8804c1e89f9353244498e14f2d311f53480e420d Mon Sep 17 00:00:00 2001 From: Kukks Date: Sun, 14 Jun 2026 19:05:49 +0200 Subject: [PATCH 03/34] backfill: add arkd-forfeit-backfill tool to sign existing unswept forfeit txs --- cmd/arkd-forfeit-backfill/main.go | 54 ++++++ internal/backfill/backfill.go | 175 ++++++++++++++++++ internal/backfill/backfill_test.go | 282 +++++++++++++++++++++++++++++ internal/config/config.go | 9 + 4 files changed, 520 insertions(+) create mode 100644 cmd/arkd-forfeit-backfill/main.go create mode 100644 internal/backfill/backfill.go create mode 100644 internal/backfill/backfill_test.go diff --git a/cmd/arkd-forfeit-backfill/main.go b/cmd/arkd-forfeit-backfill/main.go new file mode 100644 index 000000000..744fd0efc --- /dev/null +++ b/cmd/arkd-forfeit-backfill/main.go @@ -0,0 +1,54 @@ +// Command arkd-forfeit-backfill signs the operator's half of forfeit transactions +// that were persisted before arkd started signing forfeit txs at collection time. +// +// It connects to the same database and signer as arkd (via the standard arkd +// configuration / environment), so the arkd-wallet signer must be running and +// unlocked. It scans every unswept forfeited vtxo, signs the operator's half of +// its forfeit tx when missing, and persists the result. It is safe to run +// repeatedly: forfeit txs that already carry the operator signature are skipped. +package main + +import ( + "context" + "os" + + "github.com/arkade-os/arkd/internal/backfill" + "github.com/arkade-os/arkd/internal/config" + log "github.com/sirupsen/logrus" +) + +func main() { + cfg, err := config.LoadConfig() + if err != nil { + log.Fatalf("invalid config: %s", err) + } + log.SetLevel(log.Level(cfg.LogLevel)) + + repo, err := cfg.RepoManager() + if err != nil { + log.Fatalf("failed to init repositories: %s", err) + } + defer repo.Close() + + signer, err := cfg.SignerService() + if err != nil { + log.Fatalf("failed to init signer: %s", err) + } + + log.Info("starting forfeit-tx backfill...") + res, err := backfill.Run(context.Background(), repo.Vtxos(), repo.Rounds(), signer) + if err != nil { + log.Fatalf("forfeit-tx backfill failed: %s", err) + } + + log.Infof( + "forfeit-tx backfill done: scanned=%d signed=%d already_signed=%d failed=%d", + res.Scanned, res.Signed, res.AlreadySigned, res.Failed, + ) + + // Non-zero exit when some forfeits could not be signed/persisted, so the + // operator (or a wrapping script) notices and re-runs after fixing the cause. + if res.Failed > 0 { + os.Exit(1) + } +} diff --git a/internal/backfill/backfill.go b/internal/backfill/backfill.go new file mode 100644 index 000000000..55f19b653 --- /dev/null +++ b/internal/backfill/backfill.go @@ -0,0 +1,175 @@ +// Package backfill signs the operator's half of forfeit transactions that were +// persisted before arkd started signing forfeit txs at collection time. +// +// It is meant to be run on demand by the operator (see cmd/arkd-forfeit-backfill). +// It only touches forfeit txs of vtxos that still require a forfeit (unswept, +// unexpired, not notes, not unrolled): those are the only forfeits that could +// ever still be broadcast. Forfeit txs that already carry the operator signature +// are left untouched, so the backfill is safe to run repeatedly. +package backfill + +import ( + "bytes" + "context" + "fmt" + "strings" + + "github.com/arkade-os/arkd/internal/core/domain" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil/psbt" + log "github.com/sirupsen/logrus" +) + +// VtxoSource exposes the vtxos to scan. Satisfied by domain.VtxoRepository. +type VtxoSource interface { + GetAllVtxos(ctx context.Context) ([]domain.Vtxo, error) +} + +// ForfeitStore reads rounds and patches forfeit txs. Satisfied by +// domain.RoundRepository. +type ForfeitStore interface { + GetRoundWithCommitmentTxid(ctx context.Context, txid string) (*domain.Round, error) + PatchForfeitTxs(ctx context.Context, txByTxid map[string]string) error +} + +// Signer adds the operator signature to a forfeit tx. Satisfied by +// ports.SignerService. +type Signer interface { + GetPubkey(ctx context.Context) (*btcec.PublicKey, error) + SignTransactionTapscript(ctx context.Context, partialTx string, inputIndexes []int) (string, error) +} + +// Result is a summary of a backfill run. +type Result struct { + Scanned int // unswept forfeited vtxos considered + Signed int // forfeit txs newly signed and persisted + AlreadySigned int // forfeit txs already operator-signed (skipped) + Failed int // forfeit txs that could not be signed or persisted +} + +// Run scans all unswept forfeited vtxos, signs the operator's half of their +// forfeit txs when missing, and persists the result. A per-forfeit failure is +// logged and counted but does not abort the run, so re-running retries only the +// forfeits that are still unsigned. +func Run(ctx context.Context, vtxos VtxoSource, rounds ForfeitStore, signer Signer) (Result, error) { + pubkey, err := signer.GetPubkey(ctx) + if err != nil { + return Result{}, fmt.Errorf("failed to get operator pubkey: %w", err) + } + operatorXOnly := schnorr.SerializePubKey(pubkey) + + allVtxos, err := vtxos.GetAllVtxos(ctx) + if err != nil { + return Result{}, fmt.Errorf("failed to list vtxos: %w", err) + } + + // Group the unswept forfeited vtxos by the commitment tx of the round that + // holds their forfeit tx, so each round is loaded once. + byCommitment := make(map[string][]domain.Vtxo) + for _, v := range allVtxos { + if !v.IsSettled() || !v.RequiresForfeit() { + continue + } + byCommitment[v.SettledBy] = append(byCommitment[v.SettledBy], v) + } + + var res Result + for commitmentTxid, group := range byCommitment { + round, err := rounds.GetRoundWithCommitmentTxid(ctx, commitmentTxid) + if err != nil { + res.Failed += len(group) + log.WithError(err).Errorf( + "failed to load round %s, skipping %d forfeit(s)", commitmentTxid, len(group), + ) + continue + } + + patch := make(map[string]string) + for _, v := range group { + res.Scanned++ + + forfeitTx, err := findForfeitTx(round.ForfeitTxs, v.Outpoint) + if err != nil { + res.Failed++ + log.WithError(err).Errorf("failed to find forfeit tx for vtxo %s", v.Outpoint.String()) + continue + } + + if forfeitOperatorSigned(forfeitTx, operatorXOnly) { + res.AlreadySigned++ + continue + } + + b64, err := forfeitTx.B64Encode() + if err != nil { + res.Failed++ + log.WithError(err).Errorf("failed to encode forfeit tx for vtxo %s", v.Outpoint.String()) + continue + } + + signedTx, err := signer.SignTransactionTapscript(ctx, b64, nil) + if err != nil { + res.Failed++ + log.WithError(err).Errorf("failed to sign forfeit tx for vtxo %s", v.Outpoint.String()) + continue + } + + signedPtx, err := psbt.NewFromRawBytes(strings.NewReader(signedTx), true) + if err != nil { + res.Failed++ + log.WithError(err).Errorf("failed to parse signed forfeit tx for vtxo %s", v.Outpoint.String()) + continue + } + + patch[signedPtx.UnsignedTx.TxID()] = signedTx + } + + if len(patch) == 0 { + continue + } + + if err := rounds.PatchForfeitTxs(ctx, patch); err != nil { + res.Failed += len(patch) + log.WithError(err).Errorf( + "failed to persist %d signed forfeit tx(s) for round %s", len(patch), commitmentTxid, + ) + continue + } + res.Signed += len(patch) + } + + return res, nil +} + +// findForfeitTx returns the forfeit tx whose input spends the given vtxo. Mirrors +// the lookup in internal/core/application/fraud.go (findForfeitTx), kept local so +// the tool does not depend on the application package. +func findForfeitTx(forfeits []domain.ForfeitTx, vtxo domain.Outpoint) (*psbt.Packet, error) { + for _, forfeit := range forfeits { + forfeitTx, err := psbt.NewFromRawBytes(strings.NewReader(forfeit.Tx), true) + if err != nil { + return nil, err + } + for _, in := range forfeitTx.UnsignedTx.TxIn { + if in.PreviousOutPoint.Hash.String() == vtxo.Txid && + in.PreviousOutPoint.Index == vtxo.VOut { + return forfeitTx, nil + } + } + } + return nil, fmt.Errorf("forfeit tx not found for vtxo %s", vtxo.String()) +} + +// forfeitOperatorSigned reports whether the forfeit tx already carries a tapscript +// signature from the operator (its signer key). +func forfeitOperatorSigned(ptx *psbt.Packet, operatorXOnly []byte) bool { + for _, in := range ptx.Inputs { + for _, sig := range in.TaprootScriptSpendSig { + if bytes.Equal(sig.XOnlyPubKey, operatorXOnly) { + return true + } + } + } + return false +} diff --git a/internal/backfill/backfill_test.go b/internal/backfill/backfill_test.go new file mode 100644 index 000000000..d66f10cb5 --- /dev/null +++ b/internal/backfill/backfill_test.go @@ -0,0 +1,282 @@ +package backfill_test + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + "time" + + "github.com/arkade-os/arkd/internal/backfill" + "github.com/arkade-os/arkd/internal/core/domain" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "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" +) + +// --- test doubles --- + +type fakeVtxos struct { + vtxos []domain.Vtxo + err error +} + +func (f *fakeVtxos) GetAllVtxos(_ context.Context) ([]domain.Vtxo, error) { + return f.vtxos, f.err +} + +type fakeRounds struct { + rounds map[string]*domain.Round + patches []map[string]string + getErr error + patchErr error +} + +func (f *fakeRounds) GetRoundWithCommitmentTxid( + _ context.Context, txid string, +) (*domain.Round, error) { + if f.getErr != nil { + return nil, f.getErr + } + r, ok := f.rounds[txid] + if !ok { + return nil, fmt.Errorf("round %s not found", txid) + } + return r, nil +} + +func (f *fakeRounds) PatchForfeitTxs(_ context.Context, txByTxid map[string]string) error { + if f.patchErr != nil { + return f.patchErr + } + f.patches = append(f.patches, txByTxid) + // Apply the patch to the stored rounds so re-runs observe signed forfeits. + for _, r := range f.rounds { + for i := range r.ForfeitTxs { + if newTx, ok := txByTxid[r.ForfeitTxs[i].Txid]; ok { + r.ForfeitTxs[i].Tx = newTx + } + } + } + return nil +} + +type fakeSigner struct { + pubkey *btcec.PublicKey + operatorXOnly []byte + signErr error + calls int +} + +func (f *fakeSigner) GetPubkey(_ context.Context) (*btcec.PublicKey, error) { + return f.pubkey, nil +} + +func (f *fakeSigner) SignTransactionTapscript( + _ context.Context, partialTx string, _ []int, +) (string, error) { + f.calls++ + if f.signErr != nil { + return "", f.signErr + } + p, err := psbt.NewFromRawBytes(strings.NewReader(partialTx), true) + if err != nil { + return "", err + } + p.Inputs[0].TaprootScriptSpendSig = append( + p.Inputs[0].TaprootScriptSpendSig, + operatorSig(f.operatorXOnly), + ) + return p.B64Encode() +} + +// --- helpers --- + +func txid(seed byte) string { + return strings.Repeat(fmt.Sprintf("%02x", seed), 32) +} + +func operatorSig(xOnly []byte) *psbt.TaprootScriptSpendSig { + return &psbt.TaprootScriptSpendSig{ + XOnlyPubKey: xOnly, + LeafHash: make([]byte, 32), + Signature: make([]byte, 64), + SigHash: txscript.SigHashDefault, + } +} + +// buildForfeit builds a forfeit psbt spending vtxoOp at input 0 and a connector at +// input 1. When signed is true, it carries the operator's tapscript signature. +func buildForfeit(t *testing.T, vtxoOp domain.Outpoint, operatorXOnly []byte, signed bool) domain.ForfeitTx { + t.Helper() + vh, err := chainhash.NewHashFromStr(vtxoOp.Txid) + require.NoError(t, err) + ch, err := chainhash.NewHashFromStr(txid(0xcc)) + require.NoError(t, err) + + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(&wire.OutPoint{Hash: *vh, Index: vtxoOp.VOut}, nil, nil)) + tx.AddTxIn(wire.NewTxIn(&wire.OutPoint{Hash: *ch, Index: 0}, nil, nil)) + tx.AddTxOut(wire.NewTxOut(1000, []byte{txscript.OP_TRUE})) + + p, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + if signed { + p.Inputs[0].TaprootScriptSpendSig = []*psbt.TaprootScriptSpendSig{operatorSig(operatorXOnly)} + } + b64, err := p.B64Encode() + require.NoError(t, err) + return domain.ForfeitTx{Txid: p.UnsignedTx.TxID(), Tx: b64} +} + +// forfeitableVtxo builds a settled vtxo that still requires a forfeit. +func forfeitableVtxo(op domain.Outpoint, commitmentTxid string) domain.Vtxo { + return domain.Vtxo{ + Outpoint: op, + CommitmentTxids: []string{commitmentTxid}, + SettledBy: commitmentTxid, + ExpiresAt: time.Now().Add(24 * time.Hour).Unix(), + } +} + +func newOperator(t *testing.T) (*btcec.PublicKey, []byte) { + t.Helper() + key, err := btcec.NewPrivateKey() + require.NoError(t, err) + return key.PubKey(), schnorr.SerializePubKey(key.PubKey()) +} + +// --- tests --- + +func TestBackfillSignsUnsignedForfeits(t *testing.T) { + ctx := context.Background() + pub, xOnly := newOperator(t) + commitment := txid(0x11) + vtxoOp := domain.Outpoint{Txid: txid(0xaa), VOut: 0} + + forfeit := buildForfeit(t, vtxoOp, xOnly, false) + rounds := &fakeRounds{rounds: map[string]*domain.Round{ + commitment: {CommitmentTxid: commitment, ForfeitTxs: []domain.ForfeitTx{forfeit}}, + }} + vtxos := &fakeVtxos{vtxos: []domain.Vtxo{forfeitableVtxo(vtxoOp, commitment)}} + signer := &fakeSigner{pubkey: pub, operatorXOnly: xOnly} + + res, err := backfill.Run(ctx, vtxos, rounds, signer) + require.NoError(t, err) + + require.Equal(t, 1, res.Scanned) + require.Equal(t, 1, res.Signed) + require.Equal(t, 0, res.AlreadySigned) + require.Equal(t, 0, res.Failed) + require.Equal(t, 1, signer.calls) + require.Len(t, rounds.patches, 1) + // the patched tx keeps the same txid and is now operator-signed + require.Contains(t, rounds.patches[0], forfeit.Txid) +} + +func TestBackfillSkipsAlreadySignedForfeits(t *testing.T) { + ctx := context.Background() + pub, xOnly := newOperator(t) + commitment := txid(0x11) + vtxoOp := domain.Outpoint{Txid: txid(0xaa), VOut: 0} + + forfeit := buildForfeit(t, vtxoOp, xOnly, true) // already operator-signed + rounds := &fakeRounds{rounds: map[string]*domain.Round{ + commitment: {CommitmentTxid: commitment, ForfeitTxs: []domain.ForfeitTx{forfeit}}, + }} + vtxos := &fakeVtxos{vtxos: []domain.Vtxo{forfeitableVtxo(vtxoOp, commitment)}} + signer := &fakeSigner{pubkey: pub, operatorXOnly: xOnly} + + res, err := backfill.Run(ctx, vtxos, rounds, signer) + require.NoError(t, err) + + require.Equal(t, 1, res.Scanned) + require.Equal(t, 0, res.Signed) + require.Equal(t, 1, res.AlreadySigned) + require.Equal(t, 0, signer.calls, "must not call signer for already-signed forfeits") + require.Empty(t, rounds.patches) +} + +func TestBackfillSkipsNonForfeitableVtxos(t *testing.T) { + ctx := context.Background() + pub, xOnly := newOperator(t) + commitment := txid(0x11) + + swept := forfeitableVtxo(domain.Outpoint{Txid: txid(0x01)}, commitment) + swept.Swept = true + expired := forfeitableVtxo(domain.Outpoint{Txid: txid(0x02)}, commitment) + expired.ExpiresAt = time.Now().Add(-time.Hour).Unix() + unrolled := forfeitableVtxo(domain.Outpoint{Txid: txid(0x03)}, commitment) + unrolled.Unrolled = true + note := domain.Vtxo{ // no commitment txids -> note + Outpoint: domain.Outpoint{Txid: txid(0x04)}, + SettledBy: commitment, + ExpiresAt: time.Now().Add(24 * time.Hour).Unix(), + } + unsettled := domain.Vtxo{ // never settled + Outpoint: domain.Outpoint{Txid: txid(0x05)}, + CommitmentTxids: []string{commitment}, + ExpiresAt: time.Now().Add(24 * time.Hour).Unix(), + } + + rounds := &fakeRounds{rounds: map[string]*domain.Round{}} + vtxos := &fakeVtxos{vtxos: []domain.Vtxo{swept, expired, unrolled, note, unsettled}} + signer := &fakeSigner{pubkey: pub, operatorXOnly: xOnly} + + res, err := backfill.Run(ctx, vtxos, rounds, signer) + require.NoError(t, err) + + require.Equal(t, 0, res.Scanned) + require.Equal(t, 0, res.Signed) + require.Equal(t, 0, signer.calls) +} + +func TestBackfillIsIdempotent(t *testing.T) { + ctx := context.Background() + pub, xOnly := newOperator(t) + commitment := txid(0x11) + vtxoOp := domain.Outpoint{Txid: txid(0xaa), VOut: 0} + + forfeit := buildForfeit(t, vtxoOp, xOnly, false) + rounds := &fakeRounds{rounds: map[string]*domain.Round{ + commitment: {CommitmentTxid: commitment, ForfeitTxs: []domain.ForfeitTx{forfeit}}, + }} + vtxos := &fakeVtxos{vtxos: []domain.Vtxo{forfeitableVtxo(vtxoOp, commitment)}} + signer := &fakeSigner{pubkey: pub, operatorXOnly: xOnly} + + first, err := backfill.Run(ctx, vtxos, rounds, signer) + require.NoError(t, err) + require.Equal(t, 1, first.Signed) + + second, err := backfill.Run(ctx, vtxos, rounds, signer) + require.NoError(t, err) + require.Equal(t, 0, second.Signed, "second run must sign nothing") + require.Equal(t, 1, second.AlreadySigned) + require.Equal(t, 1, signer.calls, "signer must not be called again on re-run") +} + +func TestBackfillSignerErrorCountsAsFailed(t *testing.T) { + ctx := context.Background() + pub, xOnly := newOperator(t) + commitment := txid(0x11) + vtxoOp := domain.Outpoint{Txid: txid(0xaa), VOut: 0} + + forfeit := buildForfeit(t, vtxoOp, xOnly, false) + rounds := &fakeRounds{rounds: map[string]*domain.Round{ + commitment: {CommitmentTxid: commitment, ForfeitTxs: []domain.ForfeitTx{forfeit}}, + }} + vtxos := &fakeVtxos{vtxos: []domain.Vtxo{forfeitableVtxo(vtxoOp, commitment)}} + signer := &fakeSigner{pubkey: pub, operatorXOnly: xOnly, signErr: errors.New("signer down")} + + res, err := backfill.Run(ctx, vtxos, rounds, signer) + require.NoError(t, err, "a per-forfeit signer error must not abort the whole run") + + require.Equal(t, 1, res.Failed) + require.Equal(t, 0, res.Signed) + require.Empty(t, rounds.patches, "nothing persisted when signing failed") +} diff --git a/internal/config/config.go b/internal/config/config.go index 6e7894e8e..b9b4581d4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -706,6 +706,15 @@ func (c *Config) SignerService() (ports.SignerService, error) { return c.signer, nil } +func (c *Config) RepoManager() (ports.RepoManager, error) { + if c.repo == nil { + if err := c.repoManager(); err != nil { + return nil, err + } + } + return c.repo, nil +} + func (c *Config) CacheService() ports.LiveStore { return c.liveStore } From adf2526d0e87d8eacd04ced5d0ad073f7c48adfd Mon Sep 17 00:00:00 2001 From: Kukks Date: Sun, 14 Jun 2026 19:40:23 +0200 Subject: [PATCH 04/34] build: wire arkd-forfeit-backfill into build scripts --- scripts/build-all | 7 +++++++ scripts/build-arkd-forfeit-backfill | 32 +++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100755 scripts/build-arkd-forfeit-backfill diff --git a/scripts/build-all b/scripts/build-all index 941c9de07..6680f5968 100755 --- a/scripts/build-all +++ b/scripts/build-all @@ -46,6 +46,13 @@ for os in "${OS[@]}"; do handle_error $os $arch $? fi + if VERSION=$VERSION GOOS=$os GOARCH=$arch ./scripts/build-arkd-forfeit-backfill; then + echo "Built arkd-forfeit-backfill successfully for $os $arch" + echo "" + else + handle_error $os $arch $? + fi + if VERSION=$VERSION GOOS=$os GOARCH=$arch ./pkg/ark-cli/scripts/build; then echo "Built ark-cli successfully for $os $arch" echo "" diff --git a/scripts/build-arkd-forfeit-backfill b/scripts/build-arkd-forfeit-backfill new file mode 100755 index 000000000..5b794f473 --- /dev/null +++ b/scripts/build-arkd-forfeit-backfill @@ -0,0 +1,32 @@ +#!/bin/bash +set -e + +# Get the parent directory path +PARENT_PATH=$(dirname $( + cd $(dirname $0) + pwd -P +)) + +# Set VERSION (you can modify this to get the version from a file or environment variable) +VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "unknown") + +# Get OS and ARCH +OS=$(go env GOOS) +ARCH=$(go env GOARCH) + +echo "Building arkd-forfeit-backfill for $OS $ARCH" +echo "Version: $VERSION" + +# Change to the parent directory +pushd $PARENT_PATH + +# Create build directory if it doesn't exist +mkdir -p build + +# Build the binary +go build -ldflags="-s -w" -o build/arkd-forfeit-backfill-$OS-$ARCH ./cmd/arkd-forfeit-backfill + +echo "Build complete: build/arkd-forfeit-backfill-$OS-$ARCH" + +# Return to the original directory +popd From a71e6d5f6b66b6a7cd3fc559c1301d9d976cc469 Mon Sep 17 00:00:00 2001 From: Kukks Date: Sun, 14 Jun 2026 19:46:50 +0200 Subject: [PATCH 05/34] backfill: wrap long signatures to satisfy golines --- internal/backfill/backfill.go | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/internal/backfill/backfill.go b/internal/backfill/backfill.go index 55f19b653..44934c5cc 100644 --- a/internal/backfill/backfill.go +++ b/internal/backfill/backfill.go @@ -37,7 +37,11 @@ type ForfeitStore interface { // ports.SignerService. type Signer interface { GetPubkey(ctx context.Context) (*btcec.PublicKey, error) - SignTransactionTapscript(ctx context.Context, partialTx string, inputIndexes []int) (string, error) + SignTransactionTapscript( + ctx context.Context, + partialTx string, + inputIndexes []int, + ) (string, error) } // Result is a summary of a backfill run. @@ -52,7 +56,12 @@ type Result struct { // forfeit txs when missing, and persists the result. A per-forfeit failure is // logged and counted but does not abort the run, so re-running retries only the // forfeits that are still unsigned. -func Run(ctx context.Context, vtxos VtxoSource, rounds ForfeitStore, signer Signer) (Result, error) { +func Run( + ctx context.Context, + vtxos VtxoSource, + rounds ForfeitStore, + signer Signer, +) (Result, error) { pubkey, err := signer.GetPubkey(ctx) if err != nil { return Result{}, fmt.Errorf("failed to get operator pubkey: %w", err) @@ -92,7 +101,8 @@ func Run(ctx context.Context, vtxos VtxoSource, rounds ForfeitStore, signer Sign forfeitTx, err := findForfeitTx(round.ForfeitTxs, v.Outpoint) if err != nil { res.Failed++ - log.WithError(err).Errorf("failed to find forfeit tx for vtxo %s", v.Outpoint.String()) + log.WithError(err). + Errorf("failed to find forfeit tx for vtxo %s", v.Outpoint.String()) continue } @@ -104,21 +114,24 @@ func Run(ctx context.Context, vtxos VtxoSource, rounds ForfeitStore, signer Sign b64, err := forfeitTx.B64Encode() if err != nil { res.Failed++ - log.WithError(err).Errorf("failed to encode forfeit tx for vtxo %s", v.Outpoint.String()) + log.WithError(err). + Errorf("failed to encode forfeit tx for vtxo %s", v.Outpoint.String()) continue } signedTx, err := signer.SignTransactionTapscript(ctx, b64, nil) if err != nil { res.Failed++ - log.WithError(err).Errorf("failed to sign forfeit tx for vtxo %s", v.Outpoint.String()) + log.WithError(err). + Errorf("failed to sign forfeit tx for vtxo %s", v.Outpoint.String()) continue } signedPtx, err := psbt.NewFromRawBytes(strings.NewReader(signedTx), true) if err != nil { res.Failed++ - log.WithError(err).Errorf("failed to parse signed forfeit tx for vtxo %s", v.Outpoint.String()) + log.WithError(err). + Errorf("failed to parse signed forfeit tx for vtxo %s", v.Outpoint.String()) continue } From dcbc0cea1c7bfeb55d13d96b9078aab5044d19b2 Mon Sep 17 00:00:00 2001 From: Kukks Date: Sun, 14 Jun 2026 20:12:26 +0200 Subject: [PATCH 06/34] fraud: skip re-signing already-signed forfeit txs to avoid duplicate-key PSBT Forfeit txs are now operator-signed at collection time. Re-signing them at fraud-broadcast time appended a second identical operator signature, producing an invalid PSBT (duplicate key) that failed to finalize. Only sign when the operator signature is still missing (legacy forfeits). --- internal/core/application/fraud.go | 31 +++++++++++++++-- .../core/application/service_forfeit_test.go | 33 +++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/internal/core/application/fraud.go b/internal/core/application/fraud.go index 9c7706b81..58a359689 100644 --- a/internal/core/application/fraud.go +++ b/internal/core/application/fraud.go @@ -11,6 +11,7 @@ import ( "github.com/arkade-os/arkd/internal/core/domain" "github.com/arkade-os/arkd/pkg/ark-lib/tree" "github.com/arkade-os/arkd/pkg/ark-lib/txutils" + "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/psbt" "github.com/btcsuite/btcd/chaincfg/chainhash" @@ -164,9 +165,21 @@ func (s *service) broadcastForfeitTx(ctx context.Context, vtxo domain.Vtxo) erro return fmt.Errorf("failed to encode forfeit tx: %s", err) } - signedForfeitTx, err := s.signer.SignTransactionTapscript(ctx, forfeitTxB64, nil) + // Forfeit txs are signed by the operator at collection time, so the stored tx + // is usually already broadcast-ready. Re-signing would append a duplicate + // operator signature and produce an invalid PSBT (duplicate key), so we only + // sign here when the operator signature is still missing (e.g. forfeit txs + // collected before collection-time signing was introduced). + signedForfeitTx := forfeitTxB64 + signerPubkey, err := s.signer.GetPubkey(ctx) if err != nil { - return fmt.Errorf("failed to sign forfeit tx: %s", err) + return fmt.Errorf("failed to get signer pubkey: %s", err) + } + if !forfeitTxOperatorSigned(forfeitTx, schnorr.SerializePubKey(signerPubkey)) { + signedForfeitTx, err = s.signer.SignTransactionTapscript(ctx, forfeitTxB64, nil) + if err != nil { + return fmt.Errorf("failed to sign forfeit tx: %s", err) + } } forfeitTxHex, err := s.builder.FinalizeAndExtract(signedForfeitTx) @@ -417,6 +430,20 @@ func findForfeitTx( return nil, domain.Outpoint{}, fmt.Errorf("forfeit tx not found") } +// forfeitTxOperatorSigned reports whether the forfeit tx already carries a +// tapscript signature from the operator (its signer key), i.e. it was signed at +// collection time and must not be signed again. +func forfeitTxOperatorSigned(ptx *psbt.Packet, operatorXOnly []byte) bool { + for _, in := range ptx.Inputs { + for _, sig := range in.TaprootScriptSpendSig { + if bytes.Equal(sig.XOnlyPubKey, operatorXOnly) { + return true + } + } + } + return false +} + // computeVSize calculates the virtual size (vsize) of a Bitcoin transaction // in virtual bytes (vbytes). It takes into account both the stripped size // (base size without witness data) and the total size (including witness data), diff --git a/internal/core/application/service_forfeit_test.go b/internal/core/application/service_forfeit_test.go index 22ee22cba..7786e6689 100644 --- a/internal/core/application/service_forfeit_test.go +++ b/internal/core/application/service_forfeit_test.go @@ -96,3 +96,36 @@ func TestSignForfeitTxs(t *testing.T) { require.Error(t, err) }) } + +func TestForfeitTxOperatorSigned(t *testing.T) { + operatorXOnly := make([]byte, 32) + operatorXOnly[0] = 0x07 + + build := func(withOperatorSig bool) *psbt.Packet { + var hash chainhash.Hash + hash[0] = 0xaa + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(&wire.OutPoint{Hash: hash, Index: 0}, nil, nil)) + tx.AddTxOut(wire.NewTxOut(1000, []byte{txscriptOpTrue})) + p, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + if withOperatorSig { + p.Inputs[0].TaprootScriptSpendSig = []*psbt.TaprootScriptSpendSig{{ + XOnlyPubKey: operatorXOnly, + LeafHash: make([]byte, 32), + Signature: make([]byte, 64), + }} + } + return p + } + + require.True(t, forfeitTxOperatorSigned(build(true), operatorXOnly), + "must detect the operator signature") + require.False(t, forfeitTxOperatorSigned(build(false), operatorXOnly), + "must report unsigned when the operator sig is absent") + + otherXOnly := make([]byte, 32) + otherXOnly[0] = 0x09 + require.False(t, forfeitTxOperatorSigned(build(true), otherXOnly), + "must not match a different pubkey") +} From 9bea8e4a875a12cf102832c1e8484f3e68c45402 Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:36:54 -0400 Subject: [PATCH 07/34] Require arkd-wallet to be initialized and unlocked out of band arkd no longer creates or unlocks the wallet. At startup it now fails fast unless the primary arkd-wallet reports initialized and unlocked. The unlock phase is retained but only unlocks the macaroon (admin auth) service. This is the first step towards letting a single arkd front multiple LPs (arkd-wallets). --- README.md | 33 ++-- cmd/arkd/commands.go | 54 +------ cmd/arkd/flags.go | 13 +- docker-compose.regtest.yml | 3 + internal/core/application/service.go | 5 +- .../interface/grpc/handlers/walletservice.go | 86 +++-------- internal/interface/grpc/service.go | 119 +++++++++------ internal/test/e2e/e2e_test.go | 2 +- internal/test/e2e/utils_test.go | 142 ++++++++++-------- 9 files changed, 205 insertions(+), 252 deletions(-) diff --git a/README.md b/README.md index b33440443..c0afe52c9 100644 --- a/README.md +++ b/README.md @@ -84,9 +84,9 @@ The `arkd` server can be configured using environment variables and the admin se | `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` | -| `ARKD_UNLOCKER_TYPE` | Wallet unlocker type (env, file) to enable auto-unlock | - | +| `ARKD_UNLOCKER_TYPE` | Macaroon (admin auth) unlocker type (env, file) to enable auto-unlock | - | | `ARKD_UNLOCKER_FILE_PATH` | Path to unlocker file | - | -| `ARKD_UNLOCKER_PASSWORD` | Wallet unlocker password | - | +| `ARKD_UNLOCKER_PASSWORD` | Macaroon (admin auth) unlocker password | - | | `ARKD_SCHEDULER_TYPE` | Scheduler type (gocron, block) | `gocron` | | `ARKD_TLS_EXTRA_IP` | Extra IP addresses for TLS (comma-separated) | - | | `ARKD_TLS_EXTRA_DOMAIN` | Extra domains for TLS (comma-separated) | - | @@ -195,26 +195,35 @@ export ARKD_SIGNER_ADDR=localhost:7071 ### Setup arkd +`arkd` does not manage the wallet lifecycle. Each `arkd-wallet` must be initialized and unlocked out of band before `arkd` is started: if the wallet is not initialized and unlocked, `arkd` refuses to start. This is what lets a single `arkd` be backed by more than one `arkd-wallet`. + 1. Start the wallet: ```sh arkd-wallet ``` -2. Start arkd: +2. Initialize and unlock the wallet through its own API (reachable at `ARKD_WALLET_ADDR`). For example, against its REST gateway: ```sh - arkd + # Generate a seed + curl -s http://localhost:6060/v1/wallet/seed + # Create the wallet from the returned seed + curl -s -X POST http://localhost:6060/v1/wallet/create \ + -d '{"seed": "", "password": ""}' + # Or restore an existing wallet from its mnemonic + curl -s -X POST http://localhost:6060/v1/wallet/restore \ + -d '{"seed": "", "password": "", "gap_limit": 100}' + # Unlock the wallet + curl -s -X POST http://localhost:6060/v1/wallet/unlock \ + -d '{"password": ""}' ``` + This wallet password is independent of the macaroon-service password used in step 5. -3. Create a new wallet: +3. Start arkd: ```sh - arkd wallet create --password + arkd ``` - Or restore from mnemonic: - ```sh - arkd wallet create --mnemonic "your twelve word mnemonic phrase here" --password - ``` -4. Only if you didn't configure either the wallet as signer, or a custom signer, you must load the signer before unlocking the wallet, or `arkd` will fail to start: +4. Only if you didn't configure either the wallet as signer, or a custom signer, you must load the signer, or `arkd` will be unable to start the ark service: ```sh # If you configured a custom signer arkd signer load --signer-url localhost:7071 @@ -223,7 +232,7 @@ export ARKD_SIGNER_ADDR=localhost:7071 ``` Remember, if you use this command, you must use it at every restart unless you export the required environment variable(s). -5. Unlock the wallet: +5. If macaroon authentication is enabled and you did not configure an auto-unlocker, unlock the macaroon (admin auth) service so `arkd` can serve authenticated admin requests: ```sh arkd wallet unlock --password ``` diff --git a/cmd/arkd/commands.go b/cmd/arkd/commands.go index b933b981d..7c8e2378b 100644 --- a/cmd/arkd/commands.go +++ b/cmd/arkd/commands.go @@ -28,7 +28,6 @@ var ( Usage: "Manage the Ark Server wallet", Subcommands: cli.Commands{ walletStatusCmd, - walletCreateOrRestoreCmd, walletUnlockCmd, walletAddressCmd, walletBalanceCmd, @@ -57,15 +56,9 @@ var ( Usage: "Get info about the status of the wallet", Action: walletStatusAction, } - walletCreateOrRestoreCmd = &cli.Command{ - Name: "create", - Usage: "Create or restore the wallet", - Action: walletCreateOrRestoreAction, - Flags: []cli.Flag{passwordFlag, mnemonicFlag, gapLimitFlag}, - } walletUnlockCmd = &cli.Command{ Name: "unlock", - Usage: "Unlock the wallet", + Usage: "Unlock the macaroon (admin auth) service", Action: walletUnlockAction, Flags: []cli.Flag{passwordFlag}, } @@ -304,49 +297,6 @@ func walletStatusAction(ctx *cli.Context) error { return nil } -func walletCreateOrRestoreAction(ctx *cli.Context) error { - baseURL := ctx.String(urlFlagName) - _, tlsConfig, err := getCredentials(ctx) - if err != nil { - return err - } - - password := ctx.String(passwordFlagName) - mnemonic := ctx.String(mnemonicFlagName) - gapLimit := ctx.Uint64(gapLimitFlagName) - - if len(mnemonic) > 0 { - url := fmt.Sprintf("%s/v1/admin/wallet/restore", baseURL) - body := fmt.Sprintf( - `{"seed": "%s", "password": "%s", "gap_limit": %d}`, - mnemonic, password, gapLimit, - ) - if _, err := post[struct{}](url, body, "", "", tlsConfig); err != nil { - return err - } - - fmt.Println("wallet restored") - return nil - } - - url := fmt.Sprintf("%s/v1/admin/wallet/seed", baseURL) - seed, err := get[string](url, "seed", "", tlsConfig) - if err != nil { - return err - } - - url = fmt.Sprintf("%s/v1/admin/wallet/create", baseURL) - body := fmt.Sprintf( - `{"seed": "%s", "password": "%s"}`, seed, password, - ) - if _, err := post[struct{}](url, body, "", "", tlsConfig); err != nil { - return err - } - - fmt.Println(seed) - return nil -} - func walletUnlockAction(ctx *cli.Context) error { baseURL := ctx.String(urlFlagName) _, tlsConfig, err := getCredentials(ctx) @@ -362,7 +312,7 @@ func walletUnlockAction(ctx *cli.Context) error { return err } - fmt.Println("wallet unlocked") + fmt.Println("macaroon service unlocked") return nil } diff --git a/cmd/arkd/flags.go b/cmd/arkd/flags.go index 1ef5e6303..aec5885aa 100644 --- a/cmd/arkd/flags.go +++ b/cmd/arkd/flags.go @@ -14,8 +14,6 @@ const ( datadirFlagName = "datadir" macaroonFlagName = "macaroon" passwordFlagName = "password" - mnemonicFlagName = "mnemonic" - gapLimitFlagName = "addr-gap-limit" amountFlagName = "amount" withdrawAllFlagName = "all" quantityFlagName = "quantity" @@ -93,18 +91,9 @@ var ( } passwordFlag = &cli.StringFlag{ Name: passwordFlagName, - Usage: "wallet password", + Usage: "password to unlock the macaroon (admin auth) service", Required: true, } - mnemonicFlag = &cli.StringFlag{ - Name: mnemonicFlagName, - Usage: "mnemonic from which restore the wallet", - } - gapLimitFlag = &cli.Uint64Flag{ - Name: gapLimitFlagName, - Usage: "address gap limit for wallet restoration", - Value: 100, - } amountFlag = &cli.UintFlag{ Name: amountFlagName, Usage: "amount of the note in satoshis", diff --git a/docker-compose.regtest.yml b/docker-compose.regtest.yml index 746b0aede..3967bc304 100644 --- a/docker-compose.regtest.yml +++ b/docker-compose.regtest.yml @@ -77,6 +77,9 @@ services: context: . dockerfile: Dockerfile container_name: arkd + # arkd no longer initializes or unlocks the wallet: the arkd-wallet must be + # initialized and unlocked out of band first, otherwise arkd fails to start + # and is restarted by this policy until the wallet is ready. restart: unless-stopped depends_on: - arkd-wallet diff --git a/internal/core/application/service.go b/internal/core/application/service.go index bfbde4c9e..c8f442e1b 100644 --- a/internal/core/application/service.go +++ b/internal/core/application/service.go @@ -396,9 +396,8 @@ func (s *service) Stop() { } } - // nolint - s.wallet.Lock(ctx) - log.Debug("locked wallet") + // arkd does not own the wallet lifecycle: leave the (possibly shared) + // arkd-wallet unlocked so it stays usable across restarts and by other LPs. s.wallet.Close() log.Debug("closed connection to wallet") s.repoManager.Close() diff --git a/internal/interface/grpc/handlers/walletservice.go b/internal/interface/grpc/handlers/walletservice.go index 3efb0db40..aa1bf155a 100644 --- a/internal/interface/grpc/handlers/walletservice.go +++ b/internal/interface/grpc/handlers/walletservice.go @@ -12,97 +12,57 @@ import ( type walletInitHandler struct { walletService ports.WalletService - onInit func(password string) - onUnlock func(password string) + onUnlock func(password string) error onReady func() } func NewWalletInitializerHandler( - walletService ports.WalletService, onInit, onUnlock func(string), onReady func(), + walletService ports.WalletService, onUnlock func(string) error, onReady func(), ) arkv1.WalletInitializerServiceServer { - svc := walletInitHandler{walletService, onInit, onUnlock, onReady} - if onInit != nil && onUnlock != nil && onReady != nil { + svc := walletInitHandler{walletService, onUnlock, onReady} + if onReady != nil { go svc.listenWhenReady() } return &svc } +// errWalletManagedExternally is returned by the wallet lifecycle RPCs that arkd +// no longer handles: each arkd-wallet must be initialized out of band. +const errWalletManagedExternally = "arkd no longer manages the wallet: " + + "initialize the arkd-wallet directly" + func (a *walletInitHandler) GenSeed( - ctx context.Context, _ *arkv1.GenSeedRequest, + _ context.Context, _ *arkv1.GenSeedRequest, ) (*arkv1.GenSeedResponse, error) { - seed, err := a.walletService.GenSeed(ctx) - if err != nil { - return nil, status.Error(codes.Internal, err.Error()) - } - - return &arkv1.GenSeedResponse{Seed: seed}, nil + return nil, status.Error(codes.Unimplemented, errWalletManagedExternally) } func (a *walletInitHandler) Create( - ctx context.Context, req *arkv1.CreateRequest, + _ context.Context, _ *arkv1.CreateRequest, ) (*arkv1.CreateResponse, error) { - if len(req.GetSeed()) <= 0 { - return nil, status.Error(codes.InvalidArgument, "missing wallet seed") - } - if len(req.GetPassword()) <= 0 { - return nil, status.Error(codes.InvalidArgument, "missing wallet password") - } - - if err := a.walletService.Create(ctx, req.GetSeed(), req.GetPassword()); err != nil { - return nil, status.Error(codes.Internal, err.Error()) - } - - if a.onInit != nil { - go a.onInit(req.GetPassword()) - } - - return &arkv1.CreateResponse{}, nil + return nil, status.Error(codes.Unimplemented, errWalletManagedExternally) } func (a *walletInitHandler) Restore( - ctx context.Context, req *arkv1.RestoreRequest, + _ context.Context, _ *arkv1.RestoreRequest, ) (*arkv1.RestoreResponse, error) { - if len(req.GetSeed()) <= 0 { - return nil, status.Error(codes.InvalidArgument, "missing wallet seed") - } - if len(req.GetPassword()) <= 0 { - return nil, status.Error(codes.InvalidArgument, "missing wallet password") - } - - if err := a.walletService.Restore(ctx, req.GetSeed(), req.GetPassword()); err != nil { - return nil, status.Error(codes.Internal, err.Error()) - } - - if a.onInit != nil { - go a.onInit(req.GetPassword()) - } - - return &arkv1.RestoreResponse{}, nil + return nil, status.Error(codes.Unimplemented, errWalletManagedExternally) } +// Unlock no longer unlocks the wallet (which must already be unlocked out of +// band); it only unlocks the macaroon (admin auth) service with the given +// password. func (a *walletInitHandler) Unlock( - ctx context.Context, req *arkv1.UnlockRequest, + _ context.Context, req *arkv1.UnlockRequest, ) (*arkv1.UnlockResponse, error) { if len(req.GetPassword()) <= 0 { - return nil, status.Error(codes.InvalidArgument, "missing wallet password") - } - walletStatus, err := a.walletService.Status(ctx) - if err != nil { - return nil, status.Error(codes.Internal, err.Error()) - } - if !walletStatus.IsInitialized() { - return nil, status.Error(codes.InvalidArgument, "wallet not initialized, cannot unlock") - } - if walletStatus.IsUnlocked() { - return &arkv1.UnlockResponse{}, nil - } - - if err := a.walletService.Unlock(ctx, req.GetPassword()); err != nil { - return nil, status.Error(codes.Internal, err.Error()) + return nil, status.Error(codes.InvalidArgument, "missing password") } if a.onUnlock != nil { - go a.onUnlock(req.GetPassword()) + if err := a.onUnlock(req.GetPassword()); err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } } return &arkv1.UnlockResponse{}, nil diff --git a/internal/interface/grpc/service.go b/internal/interface/grpc/service.go index 2accf3a9d..4ed759d4a 100644 --- a/internal/interface/grpc/service.go +++ b/internal/interface/grpc/service.go @@ -53,6 +53,7 @@ type service struct { adminGrpcSrvr *grpc.Server readinessSvc *interceptors.ReadinessService appSvcStarted atomic.Bool + walletReady atomic.Bool macaroonSvc *macaroons.Service otelShutdown func(context.Context) error pyroscopeShutdown func() error @@ -122,8 +123,17 @@ func (s *service) Start() error { log.Infof("started admin listening at %s", s.config.adminAddress()) } + // arkd no longer manages the wallet lifecycle. The primary arkd-wallet must + // be initialized and unlocked out of band before arkd starts, otherwise we + // refuse to serve. This is what lets a single arkd front several arkd-wallets. + if err := s.ensureWalletReady(); err != nil { + return err + } + + // The unlock phase is retained only to unlock the macaroon (admin auth) + // service; it no longer touches the wallet. if s.appConfig.UnlockerService() != nil { - return s.autoUnlock() + return s.autoUnlockMacaroons() } return nil } @@ -339,7 +349,6 @@ func (s *service) newServer(tlsConfig *tls.Config, withPprof bool) error { // Server grpc. grpcServer := grpc.NewServer(grpcConfig...) - onInit := s.onInit onUnlock := s.onUnlock onReady := s.onReady onLoadSigner := s.onLoadSigner @@ -369,7 +378,7 @@ func (s *service) newServer(tlsConfig *tls.Config, withPprof bool) error { s.config.macaroonsDatadir(), s.appConfig.NoteUriPrefix, ) walletHandler := handlers.NewWalletHandler(walletSvc) - walletInitHandler := handlers.NewWalletInitializerHandler(walletSvc, onInit, onUnlock, onReady) + walletInitHandler := handlers.NewWalletInitializerHandler(walletSvc, onUnlock, onReady) signerManagerHandler := handlers.NewSignerManagerHandler(walletSvc, onLoadSigner) healthHandler := handlers.NewHealthHandler() @@ -577,16 +586,20 @@ func (s *service) newServer(tlsConfig *tls.Config, withPprof bool) error { return nil } -func (s *service) onUnlock(password string) { +// onUnlock unlocks the macaroon (admin auth) service with the given password +// and generates the macaroon files if needed. It never unlocks the wallet. Once +// the macaroon service is unlocked it tries to start the app services, which +// only come up once the wallet is also ready. +func (s *service) onUnlock(password string) error { if s.config.NoMacaroons { - return + return nil } pwd := []byte(password) datadir := s.config.macaroonsDatadir() if err := s.macaroonSvc.CreateUnlock(&pwd); err != nil { if err != macaroons.ErrAlreadyUnlocked { - log.WithError(err).Warn("failed to unlock macaroon store") + return fmt.Errorf("failed to unlock macaroon store: %s", err) } } @@ -594,43 +607,35 @@ func (s *service) onUnlock(password string) { context.Background(), s.macaroonSvc, datadir, ) if err != nil { - log.WithError(err).Warn("failed to create macaroons") + return fmt.Errorf("failed to create macaroons: %s", err) } if done { log.Debugf("created and stored macaroons at path %s", datadir) } -} - -func (s *service) onInit(password string) { - if s.config.NoMacaroons { - return - } - pwd := []byte(password) - datadir := s.config.macaroonsDatadir() - if err := s.macaroonSvc.CreateUnlock(&pwd); err != nil { - log.WithError(err).Warn("failed to initialize macaroon store") - } - if _, err := genMacaroons( - context.Background(), s.macaroonSvc, datadir, - ); err != nil { - log.WithError(err).Warn("failed to create macaroons") - } - log.Debugf("generated macaroons at path %s", datadir) + s.tryStartAppServices() + return nil } func (s *service) onReady() { - if !s.config.NoMacaroons { - ctx := context.Background() - if s.macaroonSvc.IsLocked(ctx) { - if err := s.appConfig.WalletService().Lock(ctx); err != nil { - log.WithError(err).Warn("failed to lock wallet and properly setup auth service") - } else { - return - } - } - } + // The wallet signalled it is ready (initialized, unlocked, synced). Record it + // and try to start: app services come up once the wallet is ready AND the + // macaroon service is unlocked, whichever happens last. We never lock the + // wallet here: arkd does not own its lifecycle anymore. + s.walletReady.Store(true) + s.tryStartAppServices() +} +// tryStartAppServices starts the app services exactly once, when both the wallet +// is ready and the macaroon service is unlocked (or macaroons are disabled). It +// is safe to call repeatedly and from multiple goroutines. +func (s *service) tryStartAppServices() { + if !s.walletReady.Load() { + return + } + if !s.config.NoMacaroons && s.macaroonSvc.IsLocked(context.Background()) { + return + } if err := s.startAppServices(); err != nil { log.WithError(err).Error("failed to activate app services") } @@ -642,7 +647,11 @@ func (s *service) onLoadSigner(addr string) error { return err } -func (s *service) autoUnlock() error { +// ensureWalletReady verifies the primary arkd-wallet has been initialized and +// unlocked out of band. arkd no longer creates or unlocks wallets, so if the +// wallet is not ready we refuse to start. Sync state is intentionally not +// enforced here: it is transient and already gated by the readiness layer. +func (s *service) ensureWalletReady() error { ctx := context.Background() wallet := s.appConfig.WalletService() @@ -651,28 +660,42 @@ func (s *service) autoUnlock() error { return fmt.Errorf("failed to get wallet status: %s", err) } if !status.IsInitialized() { - log.Debug("wallet not initialized, skipping auto unlock") - return nil + return fmt.Errorf( + "wallet is not initialized: initialize the arkd-wallet out of band before starting arkd", + ) + } + if !status.IsUnlocked() { + return fmt.Errorf( + "wallet is locked: unlock the arkd-wallet out of band before starting arkd", + ) } - // If the wallet is already unlocked, force the lock to make the very next call to Unlock - // to take effect and run the onUnlock callback - if status.IsUnlocked() { - // nolint - wallet.Lock(ctx) + confirmed, unconfirmed, err := wallet.MainAccountBalance(ctx) + if err != nil { + log.WithError(err).Warn("failed to read wallet balance at startup") + } else if confirmed+unconfirmed == 0 { + log.Warn("wallet main account balance is zero: arkd may be unable to source liquidity") } - password, err := s.appConfig.UnlockerService().GetPassword(ctx) + return nil +} + +// autoUnlockMacaroons unlocks the macaroon (admin auth) service using the +// configured unlocker. It never touches the wallet. +func (s *service) autoUnlockMacaroons() error { + if s.config.NoMacaroons { + return nil + } + + password, err := s.appConfig.UnlockerService().GetPassword(context.Background()) if err != nil { return fmt.Errorf("failed to get password: %s", err) } - if err := wallet.Unlock(ctx, password); err != nil { - return fmt.Errorf("failed to auto unlock: %s", err) + if err := s.onUnlock(password); err != nil { + return err } - go s.onUnlock(password) - - log.Debug("service auto unlocked") + log.Debug("macaroon service auto unlocked") return nil } diff --git a/internal/test/e2e/e2e_test.go b/internal/test/e2e/e2e_test.go index 0f2e02d31..177656671 100644 --- a/internal/test/e2e/e2e_test.go +++ b/internal/test/e2e/e2e_test.go @@ -3591,7 +3591,7 @@ func TestSweep(t *testing.T) { time.Sleep(2 * time.Second) - // lock/unlock the wallet to restart the sweeper + // restart arkd to restart the sweeper err = restartArkd() require.NoError(t, err) diff --git a/internal/test/e2e/utils_test.go b/internal/test/e2e/utils_test.go index e5156280a..550b1f90c 100644 --- a/internal/test/e2e/utils_test.go +++ b/internal/test/e2e/utils_test.go @@ -41,6 +41,7 @@ import ( const ( adminUrl = "http://127.0.0.1:7071" + walletUrl = "http://127.0.0.1:6060" serverUrl = "127.0.0.1:7070" explorerUrl = "http://127.0.0.1:3000" ) @@ -561,12 +562,10 @@ func clearIntentFees() error { return nil } -// lock the wallet, wait 10s and unlock it +// restartArkd restarts the arkd container. The wallet stays unlocked across the +// restart (arkd no longer locks it), so arkd comes back up on its own; we just +// wait for it to be ready again. func restartArkd() error { - adminHttpClient := &http.Client{ - Timeout: 15 * time.Second, - } - // down arkd container if _, err := runCommand("docker", "container", "stop", "arkd"); err != nil { return err @@ -578,16 +577,9 @@ func restartArkd() error { return err } - time.Sleep(5 * time.Second) - - url := fmt.Sprintf("%s/v1/admin/wallet/unlock", adminUrl) - body := fmt.Sprintf(`{"password": "%s"}`, password) - if err := post(adminHttpClient, url, body, "unlock"); err != nil { - return err + adminHttpClient := &http.Client{ + Timeout: 15 * time.Second, } - - // wait until the wallet is synced again before returning, otherwise RPCs - // racing the restart get "server not ready". return waitUntilReady(adminHttpClient) } @@ -618,65 +610,88 @@ func recreateArkdWallet(signerKey, deprecated string) error { return restartArkd() } +// unlockArkdWallet unlocks the arkd-wallet directly through its own gateway. +// arkd no longer unlocks the wallet, so recreating the wallet container leaves +// it locked until we unlock it here. func unlockArkdWallet() error { - adminHttpClient := &http.Client{Timeout: 15 * time.Second} - url := fmt.Sprintf("%s/v1/admin/wallet/unlock", adminUrl) + httpClient := &http.Client{Timeout: 15 * time.Second} + url := fmt.Sprintf("%s/v1/wallet/unlock", walletUrl) body := fmt.Sprintf(`{"password": "%s"}`, password) - return post(adminHttpClient, url, body, "unlock") + return post(httpClient, url, body, "unlock wallet") } func setupArkd() error { - adminHttpClient := &http.Client{ + httpClient := &http.Client{ Timeout: 15 * time.Second, } - url := fmt.Sprintf("%s/v1/admin/wallet/status", adminUrl) - status, err := get[statusResp](adminHttpClient, url, "status") - if err != nil { + // 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 { return err } - if status.Initialized && !status.Unlocked { - url := fmt.Sprintf("%s/v1/admin/wallet/unlock", adminUrl) - body := fmt.Sprintf(`{"password": "%s"}`, password) - if err := post(adminHttpClient, url, body, "unlock"); err != nil { - return err - } - - if err := waitUntilReady(adminHttpClient); err != nil { - return err - } - - return refill(adminHttpClient) - } - - if status.Initialized && status.Unlocked && status.Synced { - return refill(adminHttpClient) + // Restart arkd for a prompt clean boot now that the wallet is ready, then + // wait for it to come up. + if _, err := runCommand("docker", "container", "restart", "arkd"); err != nil { + return err } - url = fmt.Sprintf("%s/v1/admin/wallet/seed", adminUrl) - seed, err := get[seedResp](adminHttpClient, url, "seed") - if err != nil { + if err := waitUntilReady(httpClient); err != nil { return err } - url = fmt.Sprintf("%s/v1/admin/wallet/create", adminUrl) - body := fmt.Sprintf(`{"seed": "%s", "password": "%s"}`, seed.Seed, password) - if err := post(adminHttpClient, url, body, "create"); err != nil { - return err + return refill(httpClient) +} + +// setupArkdWallet initializes and unlocks the arkd-wallet directly through its +// own gateway, which is what arkd now expects to be done out of band. +func setupArkdWallet(httpClient *http.Client) 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) + var status *statusResp + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + timeout := time.After(2 * time.Minute) + for status == nil { + select { + case <-timeout: + return fmt.Errorf("timed out waiting for arkd-wallet to become reachable") + case <-ticker.C: + s, err := get[statusResp](httpClient, statusURL, "wallet status") + if err != nil { + // gateway not up yet; keep waiting. + continue + } + status = s + } } - url = fmt.Sprintf("%s/v1/admin/wallet/unlock", adminUrl) - body = fmt.Sprintf(`{"password": "%s"}`, password) - if err := post(adminHttpClient, url, body, "unlock"); err != nil { - return err + if !status.Initialized { + url := fmt.Sprintf("%s/v1/wallet/seed", walletUrl) + seed, err := get[seedResp](httpClient, url, "wallet seed") + if err != nil { + return err + } + + url = fmt.Sprintf("%s/v1/wallet/create", walletUrl) + body := fmt.Sprintf(`{"seed": "%s", "password": "%s"}`, seed.Seed, password) + if err := post(httpClient, url, body, "create wallet"); err != nil { + return err + } } - if err := waitUntilReady(adminHttpClient); err != nil { - return err + if !status.Unlocked { + url := fmt.Sprintf("%s/v1/wallet/unlock", walletUrl) + body := fmt.Sprintf(`{"password": "%s"}`, password) + if err := post(httpClient, url, body, "unlock wallet"); err != nil { + return err + } } - return refill(adminHttpClient) + return nil } type statusResp struct { @@ -726,19 +741,24 @@ func post(httpClient *http.Client, url, body, name string) error { func waitUntilReady(httpClient *http.Client) error { ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + timeout := time.After(2 * time.Minute) url := fmt.Sprintf("%s/v1/admin/wallet/status", adminUrl) - for range ticker.C { - status, err := get[statusResp](httpClient, url, "status") - if err != nil { - return err - } - - if status.Initialized && status.Unlocked && status.Synced { - ticker.Stop() - break + for { + select { + case <-timeout: + return fmt.Errorf("timed out waiting for arkd to become ready") + case <-ticker.C: + status, err := get[statusResp](httpClient, url, "status") + if err != nil { + // arkd may still be (re)starting; keep waiting. + continue + } + if status.Initialized && status.Unlocked && status.Synced { + return nil + } } } - return nil } func refill(httpClient *http.Client) error { From f2000992c78a8c0f27a81f1015c8d17832e3e394 Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:00:44 -0400 Subject: [PATCH 08/34] arkd-wallet: add create/unlock/status subcommands Replace the README curl steps with first-class arkd-wallet commands that init/unlock the wallet out of band via its own gateway. Bare arkd-wallet still runs the service. --- README.md | 22 ++--- cmd/arkd-wallet/main.go | 194 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 200 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index c0afe52c9..ada767c92 100644 --- a/README.md +++ b/README.md @@ -202,21 +202,17 @@ export ARKD_SIGNER_ADDR=localhost:7071 arkd-wallet ``` -2. Initialize and unlock the wallet through its own API (reachable at `ARKD_WALLET_ADDR`). For example, against its REST gateway: +2. Initialize and unlock the wallet. `arkd` does not manage the wallet lifecycle, so this is done out of band against the `arkd-wallet`: ```sh - # Generate a seed - curl -s http://localhost:6060/v1/wallet/seed - # Create the wallet from the returned seed - curl -s -X POST http://localhost:6060/v1/wallet/create \ - -d '{"seed": "", "password": ""}' - # Or restore an existing wallet from its mnemonic - curl -s -X POST http://localhost:6060/v1/wallet/restore \ - -d '{"seed": "", "password": "", "gap_limit": 100}' - # Unlock the wallet - curl -s -X POST http://localhost:6060/v1/wallet/unlock \ - -d '{"password": ""}' + # Create a new wallet and unlock it (prints the seed, back it up) + arkd-wallet create --password ``` - This wallet password is independent of the macaroon-service password used in step 5. + + Or restore from mnemonic: + ```sh + arkd-wallet create --mnemonic "your twelve word mnemonic phrase here" --password + ``` + By default these target `http://localhost:6060`; use `--url` to reach a wallet on another host. This wallet password is independent of the macaroon-service password used in step 5. After a wallet restart, unlock it again with `arkd-wallet unlock --password `. 3. Start arkd: ```sh diff --git a/cmd/arkd-wallet/main.go b/cmd/arkd-wallet/main.go index a338ec652..bf0258532 100644 --- a/cmd/arkd-wallet/main.go +++ b/cmd/arkd-wallet/main.go @@ -1,20 +1,101 @@ package main import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" "os" "os/signal" "syscall" + "time" "github.com/arkade-os/arkd/pkg/arkd-wallet/config" grpcservice "github.com/arkade-os/arkd/pkg/arkd-wallet/interface/grpc" "github.com/arkade-os/arkd/pkg/arkd-wallet/telemetry" log "github.com/sirupsen/logrus" + "github.com/urfave/cli/v2" ) +// Version will be set during build time +var Version string + +const defaultWalletURL = "http://localhost:6060" + func main() { + app := cli.NewApp() + app.Version = Version + app.Name = "arkd-wallet" + app.Usage = "run or manage the Ark Server wallet" + app.UsageText = "Run the wallet with:\n\tarkd-wallet\n" + + "Manage the wallet with:\n\tarkd-wallet command [command options]" + app.Commands = append( + app.Commands, + startCmd, + createCmd, + unlockCmd, + statusCmd, + ) + app.DefaultCommand = startCmd.Name + + if err := app.Run(os.Args); err != nil { + log.Fatal(err) + } +} + +var ( + walletUrlFlag = &cli.StringFlag{ + Name: "url", + Usage: "the url where to reach the arkd-wallet gateway", + Value: defaultWalletURL, + } + walletPasswordFlag = &cli.StringFlag{ + Name: "password", + Usage: "wallet password", + Required: true, + } + walletMnemonicFlag = &cli.StringFlag{ + Name: "mnemonic", + Usage: "mnemonic from which to restore the wallet (omit to create a new one)", + } + walletGapLimitFlag = &cli.Uint64Flag{ + Name: "addr-gap-limit", + Usage: "address gap limit for wallet restoration", + Value: 100, + } + + startCmd = &cli.Command{ + Name: "start", + Usage: "Run the arkd-wallet service", + Action: startAction, + } + createCmd = &cli.Command{ + Name: "create", + Usage: "Create (or restore) the wallet and unlock it", + Action: createAction, + Flags: []cli.Flag{ + walletUrlFlag, walletPasswordFlag, walletMnemonicFlag, walletGapLimitFlag, + }, + } + unlockCmd = &cli.Command{ + Name: "unlock", + Usage: "Unlock the wallet", + Action: unlockAction, + Flags: []cli.Flag{walletUrlFlag, walletPasswordFlag}, + } + statusCmd = &cli.Command{ + Name: "status", + Usage: "Get the status of the wallet", + Action: statusAction, + Flags: []cli.Flag{walletUrlFlag}, + } +) + +func startAction(_ *cli.Context) error { cfg, err := config.LoadConfig() if err != nil { - log.Fatalf("invalid config: %s", err) + return fmt.Errorf("invalid config: %s", err) } log.SetLevel(log.Level(cfg.LogLevel)) @@ -24,14 +105,14 @@ func main() { svc, err := grpcservice.NewService(cfg) if err != nil { - log.Fatalf("failed to create service: %s", err) + return fmt.Errorf("failed to create service: %s", err) } log.Infof("arkd wallet config: %+v", cfg) log.Info("starting service...") if err := svc.Start(); err != nil { - log.Fatalf("failed to start service: %s", err) + return fmt.Errorf("failed to start service: %s", err) } log.Infof("arkd wallet listens on: %v", cfg.Port) @@ -45,4 +126,111 @@ func main() { log.Info("shutting down service...") log.Exit(0) + + return nil +} + +// createAction creates a brand new wallet (or restores one from a mnemonic) and +// unlocks it, talking directly to the arkd-wallet gateway. arkd does not manage +// the wallet lifecycle: each arkd-wallet must be set up this way out of band. +func createAction(ctx *cli.Context) error { + baseURL := ctx.String("url") + password := ctx.String("password") + mnemonic := ctx.String("mnemonic") + + if len(mnemonic) > 0 { + body := fmt.Sprintf( + `{"seed": "%s", "password": "%s", "gap_limit": %d}`, + mnemonic, password, ctx.Uint64("addr-gap-limit"), + ) + if err := walletPost(baseURL, "/v1/wallet/restore", body, nil); err != nil { + return err + } + fmt.Println("wallet restored") + } else { + var seed struct { + Seed string `json:"seed"` + } + if err := walletGet(baseURL, "/v1/wallet/seed", &seed); err != nil { + return err + } + body := fmt.Sprintf(`{"seed": "%s", "password": "%s"}`, seed.Seed, password) + if err := walletPost(baseURL, "/v1/wallet/create", body, nil); err != nil { + return err + } + fmt.Println(seed.Seed) + } + + body := fmt.Sprintf(`{"password": "%s"}`, password) + if err := walletPost(baseURL, "/v1/wallet/unlock", body, nil); err != nil { + return err + } + fmt.Println("wallet unlocked") + return nil +} + +func unlockAction(ctx *cli.Context) error { + baseURL := ctx.String("url") + body := fmt.Sprintf(`{"password": "%s"}`, ctx.String("password")) + if err := walletPost(baseURL, "/v1/wallet/unlock", body, nil); err != nil { + return err + } + fmt.Println("wallet unlocked") + return nil +} + +func statusAction(ctx *cli.Context) error { + var status struct { + Initialized bool `json:"initialized"` + Unlocked bool `json:"unlocked"` + Synced bool `json:"synced"` + } + if err := walletGet(ctx.String("url"), "/v1/wallet/status", &status); err != nil { + return err + } + fmt.Printf( + "initialized: %t\nunlocked: %t\nsynced: %t\n", + status.Initialized, status.Unlocked, status.Synced, + ) + return nil +} + +func walletGet(baseURL, path string, out any) error { + resp, err := walletHTTPClient().Get(baseURL + path) + if err != nil { + return err + } + // nolint + defer resp.Body.Close() + return parseWalletResponse(resp, out) +} + +func walletPost(baseURL, path, body string, out any) error { + resp, err := walletHTTPClient().Post( + baseURL+path, "application/json", bytes.NewReader([]byte(body)), + ) + if err != nil { + return err + } + // nolint + defer resp.Body.Close() + return parseWalletResponse(resp, out) +} + +func walletHTTPClient() *http.Client { + return &http.Client{Timeout: 15 * time.Second} +} + +func parseWalletResponse(resp *http.Response, out any) error { + data, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("request failed (%d): %s", resp.StatusCode, string(data)) + } + if out == nil { + return nil + } + return json.Unmarshal(data, out) } From 7366cec2911872706c3ba81a5c322c212ce33cc7 Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Fri, 12 Jun 2026 10:57:29 -0400 Subject: [PATCH 09/34] Address review: escape wallet CLI JSON, disable Lock RPC - arkd-wallet create/unlock subcommands build their request bodies with json.Marshal instead of fmt.Sprintf, so a password or mnemonic containing quotes or backslashes can't corrupt the payload or inject fields. - The admin Lock RPC now returns Unimplemented: arkd no longer manages the wallet lifecycle, and locking a (possibly shared) wallet via arkd would break other consumers. Lock out of band through arkd-wallet instead. - Drop the unreachable return after log.Exit by stopping the service explicitly. --- cmd/arkd-wallet/main.go | 41 +++++++++++++++---- .../interface/grpc/handlers/walletservice.go | 11 +++-- 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/cmd/arkd-wallet/main.go b/cmd/arkd-wallet/main.go index bf0258532..dc4978dfc 100644 --- a/cmd/arkd-wallet/main.go +++ b/cmd/arkd-wallet/main.go @@ -125,8 +125,7 @@ func startAction(_ *cli.Context) error { <-sigChan log.Info("shutting down service...") - log.Exit(0) - + svc.Stop() return nil } @@ -139,10 +138,14 @@ func createAction(ctx *cli.Context) error { mnemonic := ctx.String("mnemonic") if len(mnemonic) > 0 { - body := fmt.Sprintf( - `{"seed": "%s", "password": "%s", "gap_limit": %d}`, - mnemonic, password, ctx.Uint64("addr-gap-limit"), - ) + body, err := jsonBody(map[string]any{ + "seed": mnemonic, + "password": password, + "gap_limit": ctx.Uint64("addr-gap-limit"), + }) + if err != nil { + return err + } if err := walletPost(baseURL, "/v1/wallet/restore", body, nil); err != nil { return err } @@ -154,14 +157,20 @@ func createAction(ctx *cli.Context) error { if err := walletGet(baseURL, "/v1/wallet/seed", &seed); err != nil { return err } - body := fmt.Sprintf(`{"seed": "%s", "password": "%s"}`, seed.Seed, password) + body, err := jsonBody(map[string]any{"seed": seed.Seed, "password": password}) + if err != nil { + return err + } if err := walletPost(baseURL, "/v1/wallet/create", body, nil); err != nil { return err } fmt.Println(seed.Seed) } - body := fmt.Sprintf(`{"password": "%s"}`, password) + body, err := jsonBody(map[string]any{"password": password}) + if err != nil { + return err + } if err := walletPost(baseURL, "/v1/wallet/unlock", body, nil); err != nil { return err } @@ -171,7 +180,10 @@ func createAction(ctx *cli.Context) error { func unlockAction(ctx *cli.Context) error { baseURL := ctx.String("url") - body := fmt.Sprintf(`{"password": "%s"}`, ctx.String("password")) + body, err := jsonBody(map[string]any{"password": ctx.String("password")}) + if err != nil { + return err + } if err := walletPost(baseURL, "/v1/wallet/unlock", body, nil); err != nil { return err } @@ -179,6 +191,17 @@ func unlockAction(ctx *cli.Context) error { return nil } +// jsonBody marshals a request body to JSON, escaping the values so a password or +// mnemonic containing quotes or backslashes can't corrupt the payload or inject +// extra fields. +func jsonBody(v any) (string, error) { + b, err := json.Marshal(v) + if err != nil { + return "", fmt.Errorf("failed to encode request body: %w", err) + } + return string(b), nil +} + func statusAction(ctx *cli.Context) error { var status struct { Initialized bool `json:"initialized"` diff --git a/internal/interface/grpc/handlers/walletservice.go b/internal/interface/grpc/handlers/walletservice.go index aa1bf155a..65846fb0f 100644 --- a/internal/interface/grpc/handlers/walletservice.go +++ b/internal/interface/grpc/handlers/walletservice.go @@ -107,13 +107,12 @@ func NewWalletHandler(walletService ports.WalletService) arkv1.WalletServiceServ } func (a *walletHandler) Lock( - ctx context.Context, _ *arkv1.LockRequest, + _ context.Context, _ *arkv1.LockRequest, ) (*arkv1.LockResponse, error) { - if err := a.walletService.Lock(ctx); err != nil { - return nil, status.Error(codes.Internal, err.Error()) - } - - return &arkv1.LockResponse{}, nil + // arkd no longer manages the wallet lifecycle; locking the (possibly shared) + // wallet via arkd would break every other consumer, so the RPC is disabled + // — lock the wallet out of band through arkd-wallet directly. + return nil, status.Error(codes.Unimplemented, errWalletManagedExternally) } func (a *walletHandler) DeriveAddress( From 7372cf45b8b1293660627c9d360f8bcf89c9a619 Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:36:02 -0400 Subject: [PATCH 10/34] Address review: stop arkd-wallet service at most once --- cmd/arkd-wallet/main.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/cmd/arkd-wallet/main.go b/cmd/arkd-wallet/main.go index dc4978dfc..330bdd41f 100644 --- a/cmd/arkd-wallet/main.go +++ b/cmd/arkd-wallet/main.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "os/signal" + "sync" "syscall" "time" @@ -116,7 +117,11 @@ func startAction(_ *cli.Context) error { } log.Infof("arkd wallet listens on: %v", cfg.Port) - log.RegisterExitHandler(svc.Stop) + // Stop the service at most once, whether triggered by the signal handler + // below or by log.Exit/Fatal firing the registered exit handler. + var stopOnce sync.Once + stop := func() { stopOnce.Do(svc.Stop) } + log.RegisterExitHandler(stop) sigChan := make(chan os.Signal, 1) signal.Notify( @@ -125,7 +130,7 @@ func startAction(_ *cli.Context) error { <-sigChan log.Info("shutting down service...") - svc.Stop() + stop() return nil } From ea53d83391704b48bf23071b30d1f4b73accf3fb Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Fri, 12 Jun 2026 21:35:13 -0400 Subject: [PATCH 11/34] Address review: escape wallet JSON payloads and harden e2e HTTP helpers --- cmd/arkd/commands.go | 7 ++-- .../interface/grpc/handlers/walletservice.go | 7 +++- internal/interface/grpc/service.go | 8 +++-- internal/test/e2e/utils_test.go | 33 +++++++++++++++---- 4 files changed, 44 insertions(+), 11 deletions(-) diff --git a/cmd/arkd/commands.go b/cmd/arkd/commands.go index 7c8e2378b..1b7987816 100644 --- a/cmd/arkd/commands.go +++ b/cmd/arkd/commands.go @@ -306,9 +306,12 @@ func walletUnlockAction(ctx *cli.Context) error { password := ctx.String(passwordFlagName) url := fmt.Sprintf("%s/v1/admin/wallet/unlock", baseURL) - body := fmt.Sprintf(`{"password": "%s"}`, password) + body, err := json.Marshal(map[string]string{"password": password}) + if err != nil { + return fmt.Errorf("failed to encode request body: %w", err) + } - if _, err := post[struct{}](url, body, "", "", tlsConfig); err != nil { + if _, err := post[struct{}](url, string(body), "", "", tlsConfig); err != nil { return err } diff --git a/internal/interface/grpc/handlers/walletservice.go b/internal/interface/grpc/handlers/walletservice.go index 65846fb0f..29ad4756d 100644 --- a/internal/interface/grpc/handlers/walletservice.go +++ b/internal/interface/grpc/handlers/walletservice.go @@ -31,6 +31,11 @@ func NewWalletInitializerHandler( const errWalletManagedExternally = "arkd no longer manages the wallet: " + "initialize the arkd-wallet directly" +// errWalletLockManagedExternally is returned by the Lock RPC: arkd no longer +// locks the (possibly shared) wallet; lock it out of band instead. +const errWalletLockManagedExternally = "arkd no longer manages the wallet: " + + "lock the arkd-wallet directly" + func (a *walletInitHandler) GenSeed( _ context.Context, _ *arkv1.GenSeedRequest, ) (*arkv1.GenSeedResponse, error) { @@ -112,7 +117,7 @@ func (a *walletHandler) Lock( // arkd no longer manages the wallet lifecycle; locking the (possibly shared) // wallet via arkd would break every other consumer, so the RPC is disabled // — lock the wallet out of band through arkd-wallet directly. - return nil, status.Error(codes.Unimplemented, errWalletManagedExternally) + return nil, status.Error(codes.Unimplemented, errWalletLockManagedExternally) } func (a *walletHandler) DeriveAddress( diff --git a/internal/interface/grpc/service.go b/internal/interface/grpc/service.go index 4ed759d4a..73dba7163 100644 --- a/internal/interface/grpc/service.go +++ b/internal/interface/grpc/service.go @@ -672,9 +672,13 @@ func (s *service) ensureWalletReady() error { confirmed, unconfirmed, err := wallet.MainAccountBalance(ctx) if err != nil { - log.WithError(err).Warn("failed to read wallet balance at startup") + log.WithError(err). + Warnf("failed to read balance of wallet %s at startup", s.appConfig.WalletAddr) } else if confirmed+unconfirmed == 0 { - log.Warn("wallet main account balance is zero: arkd may be unable to source liquidity") + log.Warnf( + "main account balance of wallet %s is zero: arkd may be unable to source liquidity", + s.appConfig.WalletAddr, + ) } return nil diff --git a/internal/test/e2e/utils_test.go b/internal/test/e2e/utils_test.go index 550b1f90c..c3c1cb1c2 100644 --- a/internal/test/e2e/utils_test.go +++ b/internal/test/e2e/utils_test.go @@ -677,16 +677,22 @@ func setupArkdWallet(httpClient *http.Client) error { } url = fmt.Sprintf("%s/v1/wallet/create", walletUrl) - body := fmt.Sprintf(`{"seed": "%s", "password": "%s"}`, seed.Seed, password) - if err := post(httpClient, url, body, "create wallet"); err != nil { + 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) + } + if err := post(httpClient, url, string(body), "create wallet"); err != nil { return err } } if !status.Unlocked { url := fmt.Sprintf("%s/v1/wallet/unlock", walletUrl) - body := fmt.Sprintf(`{"password": "%s"}`, password) - if err := post(httpClient, url, body, "unlock wallet"); err != nil { + body, err := json.Marshal(map[string]string{"password": password}) + if err != nil { + return fmt.Errorf("failed to encode unlock wallet body: %s", err) + } + if err := post(httpClient, url, string(body), "unlock wallet"); err != nil { return err } } @@ -720,6 +726,13 @@ func get[T any](httpClient *http.Client, url, name string) (*T, error) { if err != nil { return nil, fmt.Errorf("failed to get %s: %s", name, err) } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + data, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf( + "failed to get %s: unexpected status %d: %s", name, resp.StatusCode, string(data), + ) + } var data T if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { return nil, fmt.Errorf("failed to parse %s response: %s", name, err) @@ -733,8 +746,16 @@ func post(httpClient *http.Client, url, body, name string) error { return fmt.Errorf("failed to prepare %s request: %s", name, err) } req.Header.Set("Content-Type", "application/json") - if _, err := httpClient.Do(req); err != nil { - return fmt.Errorf("failed to %s wallet: %s", name, err) + resp, err := httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to %s: %s", name, err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + data, _ := io.ReadAll(resp.Body) + return fmt.Errorf( + "failed to %s: unexpected status %d: %s", name, resp.StatusCode, string(data), + ) } return nil } From c968d25367f3fd52bfce6d436f9b526cf490b9a5 Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Sun, 14 Jun 2026 13:21:58 -0400 Subject: [PATCH 12/34] Warn when app service startup is deferred --- internal/interface/grpc/service.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/interface/grpc/service.go b/internal/interface/grpc/service.go index 73dba7163..315be7229 100644 --- a/internal/interface/grpc/service.go +++ b/internal/interface/grpc/service.go @@ -631,9 +631,11 @@ func (s *service) onReady() { // is safe to call repeatedly and from multiple goroutines. func (s *service) tryStartAppServices() { if !s.walletReady.Load() { + log.Warn("app services not started yet: waiting for the wallet to become ready") return } if !s.config.NoMacaroons && s.macaroonSvc.IsLocked(context.Background()) { + log.Warn("app services not started yet: waiting for the macaroon service to be unlocked") return } if err := s.startAppServices(); err != nil { From dd8d5b003e2163579379448e391ae10b57913d1d Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Sun, 14 Jun 2026 13:27:44 -0400 Subject: [PATCH 13/34] arkd-wallet: split create and restore into separate CLI commands --- cmd/arkd-wallet/main.go | 94 +++++++++++++++++++++++------------------ 1 file changed, 53 insertions(+), 41 deletions(-) diff --git a/cmd/arkd-wallet/main.go b/cmd/arkd-wallet/main.go index 330bdd41f..b8fc8126f 100644 --- a/cmd/arkd-wallet/main.go +++ b/cmd/arkd-wallet/main.go @@ -35,6 +35,7 @@ func main() { app.Commands, startCmd, createCmd, + restoreCmd, unlockCmd, statusCmd, ) @@ -57,8 +58,9 @@ var ( Required: true, } walletMnemonicFlag = &cli.StringFlag{ - Name: "mnemonic", - Usage: "mnemonic from which to restore the wallet (omit to create a new one)", + Name: "mnemonic", + Usage: "mnemonic from which to restore the wallet", + Required: true, } walletGapLimitFlag = &cli.Uint64Flag{ Name: "addr-gap-limit", @@ -73,8 +75,14 @@ var ( } createCmd = &cli.Command{ Name: "create", - Usage: "Create (or restore) the wallet and unlock it", + Usage: "Create a new wallet and unlock it", Action: createAction, + Flags: []cli.Flag{walletUrlFlag, walletPasswordFlag}, + } + restoreCmd = &cli.Command{ + Name: "restore", + Usage: "Restore the wallet from a mnemonic and unlock it", + Action: restoreAction, Flags: []cli.Flag{ walletUrlFlag, walletPasswordFlag, walletMnemonicFlag, walletGapLimitFlag, }, @@ -134,58 +142,62 @@ func startAction(_ *cli.Context) error { return nil } -// createAction creates a brand new wallet (or restores one from a mnemonic) and -// unlocks it, talking directly to the arkd-wallet gateway. arkd does not manage -// the wallet lifecycle: each arkd-wallet must be set up this way out of band. +// createAction generates a brand new wallet and unlocks it, talking directly to +// the arkd-wallet gateway. arkd does not manage the wallet lifecycle: each +// arkd-wallet must be set up this way out of band. func createAction(ctx *cli.Context) error { baseURL := ctx.String("url") password := ctx.String("password") - mnemonic := ctx.String("mnemonic") - if len(mnemonic) > 0 { - body, err := jsonBody(map[string]any{ - "seed": mnemonic, - "password": password, - "gap_limit": ctx.Uint64("addr-gap-limit"), - }) - if err != nil { - return err - } - if err := walletPost(baseURL, "/v1/wallet/restore", body, nil); err != nil { - return err - } - fmt.Println("wallet restored") - } else { - var seed struct { - Seed string `json:"seed"` - } - if err := walletGet(baseURL, "/v1/wallet/seed", &seed); err != nil { - return err - } - body, err := jsonBody(map[string]any{"seed": seed.Seed, "password": password}) - if err != nil { - return err - } - if err := walletPost(baseURL, "/v1/wallet/create", body, nil); err != nil { - return err - } - fmt.Println(seed.Seed) + var seed struct { + Seed string `json:"seed"` + } + if err := walletGet(baseURL, "/v1/wallet/seed", &seed); err != nil { + return err + } + body, err := jsonBody(map[string]any{"seed": seed.Seed, "password": password}) + if err != nil { + return err + } + if err := walletPost(baseURL, "/v1/wallet/create", body, nil); err != nil { + return err } + fmt.Println(seed.Seed) - body, err := jsonBody(map[string]any{"password": password}) + return unlockWallet(baseURL, password) +} + +// restoreAction restores a wallet from a mnemonic and unlocks it, talking +// directly to the arkd-wallet gateway. arkd does not manage the wallet +// lifecycle: each arkd-wallet must be set up this way out of band. +func restoreAction(ctx *cli.Context) error { + baseURL := ctx.String("url") + password := ctx.String("password") + + body, err := jsonBody(map[string]any{ + "seed": ctx.String("mnemonic"), + "password": password, + "gap_limit": ctx.Uint64("addr-gap-limit"), + }) if err != nil { return err } - if err := walletPost(baseURL, "/v1/wallet/unlock", body, nil); err != nil { + if err := walletPost(baseURL, "/v1/wallet/restore", body, nil); err != nil { return err } - fmt.Println("wallet unlocked") - return nil + fmt.Println("wallet restored") + + return unlockWallet(baseURL, password) } func unlockAction(ctx *cli.Context) error { - baseURL := ctx.String("url") - body, err := jsonBody(map[string]any{"password": ctx.String("password")}) + return unlockWallet(ctx.String("url"), ctx.String("password")) +} + +// unlockWallet unlocks the wallet via the arkd-wallet gateway and prints +// confirmation. It is shared by the create, restore and unlock commands. +func unlockWallet(baseURL, password string) error { + body, err := jsonBody(map[string]any{"password": password}) if err != nil { return err } From 5d9ad68747bdceb6d0d8c500fce1ecc3df4f3df4 Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Mon, 15 Jun 2026 11:15:53 -0400 Subject: [PATCH 14/34] arkd-wallet: warn about seed phrase security before printing it --- cmd/arkd-wallet/main.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmd/arkd-wallet/main.go b/cmd/arkd-wallet/main.go index b8fc8126f..1998b2a46 100644 --- a/cmd/arkd-wallet/main.go +++ b/cmd/arkd-wallet/main.go @@ -162,6 +162,8 @@ func createAction(ctx *cli.Context) error { if err := walletPost(baseURL, "/v1/wallet/create", body, nil); err != nil { return err } + fmt.Println("IMPORTANT: store the following seed phrase securely and offline.") + fmt.Println("Anyone with access to it can control this wallet and spend its funds.") fmt.Println(seed.Seed) return unlockWallet(baseURL, password) From 03d4fe9cb5de33997da687120331368db9007ffd Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Thu, 11 Jun 2026 21:13:46 -0400 Subject: [PATCH 15/34] 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. --- README.md | 11 +++ docker-compose.regtest.yml | 24 +++++++ internal/config/config.go | 104 ++++++++++++++++++++++++----- internal/config/config_test.go | 97 +++++++++++++++++++++++++++ internal/interface/grpc/service.go | 30 +++++++++ internal/test/e2e/utils_test.go | 30 +++++---- 6 files changed, 268 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index ada767c92..53a396d3f 100644 --- a/README.md +++ b/README.md @@ -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` | @@ -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. diff --git a/docker-compose.regtest.yml b/docker-compose.regtest.yml index 3967bc304..5303d65a4 100644 --- a/docker-compose.regtest.yml +++ b/docker-compose.regtest.yml @@ -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 @@ -83,6 +103,7 @@ services: restart: unless-stopped depends_on: - arkd-wallet + - arkd-wallet-2 - pg - redis ports: @@ -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:-} @@ -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 diff --git a/internal/config/config.go b/internal/config/config.go index b7576ecc3..23c788c02 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 @@ -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 []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 } func (c *Config) String() string { @@ -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" @@ -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), @@ -664,6 +668,10 @@ func (c *Config) WalletService() ports.WalletService { return c.wallet } +func (c *Config) FallbackWalletServices() []ports.WalletService { + return c.walletFallbacks +} + func (c *Config) UnlockerService() ports.Unlocker { return c.unlocker } @@ -799,22 +807,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() ([]ports.WalletService, error) { + fallbacks := make([]ports.WalletService, 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, fbSvc) + } + return fallbacks, nil +} + +func closeWallets(wallets []ports.WalletService) { + for _, w := range wallets { + w.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..779796289 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,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) + }) +} diff --git a/internal/interface/grpc/service.go b/internal/interface/grpc/service.go index 315be7229..ad1d073ce 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.FallbackWalletServices() { + fb.Close() + } } func (s *service) startAppServices() error { @@ -683,6 +689,30 @@ func (s *service) ensureWalletReady() error { ) } + // Fallback wallets must also be initialized and unlocked out of band: they + // are needed to sign sweeps when the primary cannot. Balance is not checked + // (they are not liquidity sources). + for i, fb := range s.appConfig.FallbackWalletServices() { + fbStatus, err := fb.Status(ctx) + if err != nil { + return fmt.Errorf("failed to get fallback wallet %d status: %s", i, err) + } + if !fbStatus.IsInitialized() { + return fmt.Errorf( + "fallback wallet %d is not initialized: "+ + "initialize the arkd-wallet out of band before starting arkd", + i, + ) + } + if !fbStatus.IsUnlocked() { + return fmt.Errorf( + "fallback wallet %d is locked: "+ + "unlock the arkd-wallet out of band before starting arkd", + i, + ) + } + } + return nil } diff --git a/internal/test/e2e/utils_test.go b/internal/test/e2e/utils_test.go index c3c1cb1c2..bf26acc20 100644 --- a/internal/test/e2e/utils_test.go +++ b/internal/test/e2e/utils_test.go @@ -42,6 +42,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" ) @@ -625,10 +626,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 } @@ -645,12 +650,13 @@ func setupArkd() error { return refill(httpClient) } -// setupArkdWallet initializes and unlocks the arkd-wallet directly through its -// own gateway, which is what arkd now expects to be done out of band. -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() @@ -658,7 +664,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 { @@ -670,13 +676,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) @@ -687,7 +693,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) 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 16/34] 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 17/34] 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 18/34] 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 19/34] 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 bdfdc054ff3903505d3a5f6ea9c1ada3aa73c941 Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Fri, 12 Jun 2026 10:30:45 -0400 Subject: [PATCH 20/34] Persist primary/fallback wallet addresses in the settings domain Move the primary and fallback arkd-wallet connection addresses into the DB-backed settings: seeded from env on first boot, sourced from the settings to dial the wallets, and settable via the admin Settings API. Wallet address changes are persisted immediately but only take effect on the next restart (re-dialing live is a follow-up). --- .../openapi/swagger/ark/v1/admin.openapi.json | 13 ++++++++ api-spec/protobuf/ark/v1/admin.proto | 4 +++ api-spec/protobuf/gen/ark/v1/admin.pb.go | 32 ++++++++++++++++--- internal/config/config.go | 23 ++++++++++--- internal/config/config_test.go | 6 ++-- internal/core/application/admin.go | 11 +++++++ internal/core/domain/settings.go | 20 +++++++++++- internal/core/domain/settings_test.go | 21 ++++++++++++ ...0000_add_wallet_addrs_to_settings.down.sql | 3 ++ ...000000_add_wallet_addrs_to_settings.up.sql | 3 ++ .../db/postgres/settings_repo.go | 14 ++++++++ .../db/postgres/settings_seed.go | 3 ++ .../db/postgres/settings_seed_test.go | 2 ++ .../db/postgres/sqlc/queries/models.go | 2 ++ .../db/postgres/sqlc/queries/query.sql.go | 22 +++++++++---- .../infrastructure/db/postgres/sqlc/query.sql | 4 +++ internal/infrastructure/db/service_test.go | 4 +++ ...0000_add_wallet_addrs_to_settings.down.sql | 2 ++ ...000000_add_wallet_addrs_to_settings.up.sql | 2 ++ .../infrastructure/db/sqlite/settings_repo.go | 14 ++++++++ .../infrastructure/db/sqlite/settings_seed.go | 3 ++ .../db/sqlite/settings_seed_test.go | 2 ++ .../db/sqlite/sqlc/queries/models.go | 2 ++ .../db/sqlite/sqlc/queries/query.sql.go | 22 +++++++++---- .../infrastructure/db/sqlite/sqlc/query.sql | 4 +++ .../live-store/redis/settings.go | 6 ++++ .../interface/grpc/handlers/adminservice.go | 14 ++++++++ 27 files changed, 233 insertions(+), 25 deletions(-) create mode 100644 internal/infrastructure/db/postgres/migration/20260612000000_add_wallet_addrs_to_settings.down.sql create mode 100644 internal/infrastructure/db/postgres/migration/20260612000000_add_wallet_addrs_to_settings.up.sql create mode 100644 internal/infrastructure/db/sqlite/migration/20260612000000_add_wallet_addrs_to_settings.down.sql create mode 100644 internal/infrastructure/db/sqlite/migration/20260612000000_add_wallet_addrs_to_settings.up.sql diff --git a/api-spec/openapi/swagger/ark/v1/admin.openapi.json b/api-spec/openapi/swagger/ark/v1/admin.openapi.json index a28810696..4af605d4c 100644 --- a/api-spec/openapi/swagger/ark/v1/admin.openapi.json +++ b/api-spec/openapi/swagger/ark/v1/admin.openapi.json @@ -2145,6 +2145,19 @@ "null" ], "format": "int64" + }, + "walletAddr": { + "type": [ + "string", + "null" + ], + "description": "Primary and fallback arkd-wallet connection addresses. Seeded from env on\nfirst boot; changes take effect on the next restart." + }, + "walletFallbackAddrs": { + "type": "array", + "items": { + "type": "string" + } } } }, diff --git a/api-spec/protobuf/ark/v1/admin.proto b/api-spec/protobuf/ark/v1/admin.proto index ae22d180e..23be66816 100644 --- a/api-spec/protobuf/ark/v1/admin.proto +++ b/api-spec/protobuf/ark/v1/admin.proto @@ -447,6 +447,10 @@ message Settings { optional bool build_version_header_required = 23; optional string updated_at = 24; optional bool digest_header_required = 25; + // Primary and fallback arkd-wallet connection addresses. Seeded from env on + // first boot; changes take effect on the next restart. + optional string wallet_addr = 26; + repeated string wallet_fallback_addrs = 27; } message GetSettingsRequest {} diff --git a/api-spec/protobuf/gen/ark/v1/admin.pb.go b/api-spec/protobuf/gen/ark/v1/admin.pb.go index 83c55ca37..961ecfe5a 100644 --- a/api-spec/protobuf/gen/ark/v1/admin.pb.go +++ b/api-spec/protobuf/gen/ark/v1/admin.pb.go @@ -3025,8 +3025,12 @@ type Settings struct { BuildVersionHeaderRequired *bool `protobuf:"varint,23,opt,name=build_version_header_required,json=buildVersionHeaderRequired,proto3,oneof" json:"build_version_header_required,omitempty"` UpdatedAt *string `protobuf:"bytes,24,opt,name=updated_at,json=updatedAt,proto3,oneof" json:"updated_at,omitempty"` DigestHeaderRequired *bool `protobuf:"varint,25,opt,name=digest_header_required,json=digestHeaderRequired,proto3,oneof" json:"digest_header_required,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Primary and fallback arkd-wallet connection addresses. Seeded from env on + // first boot; changes take effect on the next restart. + WalletAddr *string `protobuf:"bytes,26,opt,name=wallet_addr,json=walletAddr,proto3,oneof" json:"wallet_addr,omitempty"` + WalletFallbackAddrs []string `protobuf:"bytes,27,rep,name=wallet_fallback_addrs,json=walletFallbackAddrs,proto3" json:"wallet_fallback_addrs,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Settings) Reset() { @@ -3234,6 +3238,20 @@ func (x *Settings) GetDigestHeaderRequired() bool { return false } +func (x *Settings) GetWalletAddr() string { + if x != nil && x.WalletAddr != nil { + return *x.WalletAddr + } + return "" +} + +func (x *Settings) GetWalletFallbackAddrs() []string { + if x != nil { + return x.WalletFallbackAddrs + } + return nil +} + type GetSettingsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -4035,7 +4053,7 @@ const file_ark_v1_admin_proto_rawDesc = "" + "\x10commitment_txids\x18\x02 \x03(\tR\x0fcommitmentTxids\"5\n" + "\rSweepResponse\x12\x12\n" + "\x04txid\x18\x01 \x01(\tR\x04txid\x12\x10\n" + - "\x03hex\x18\x02 \x01(\tR\x03hex\"\xe6\x0f\n" + + "\x03hex\x18\x02 \x01(\tR\x03hex\"\xd0\x10\n" + "\bSettings\x12.\n" + "\x10session_duration\x18\x01 \x01(\x03H\x00R\x0fsessionDuration\x88\x01\x01\x12I\n" + "\x1funrolled_vtxo_min_expiry_margin\x18\x02 \x01(\x03H\x01R\x1bunrolledVtxoMinExpiryMargin\x88\x01\x01\x12(\n" + @@ -4064,7 +4082,10 @@ const file_ark_v1_admin_proto_rawDesc = "" + "\x1dbuild_version_header_required\x18\x17 \x01(\bH\x16R\x1abuildVersionHeaderRequired\x88\x01\x01\x12\"\n" + "\n" + "updated_at\x18\x18 \x01(\tH\x17R\tupdatedAt\x88\x01\x01\x129\n" + - "\x16digest_header_required\x18\x19 \x01(\bH\x18R\x14digestHeaderRequired\x88\x01\x01B\x13\n" + + "\x16digest_header_required\x18\x19 \x01(\bH\x18R\x14digestHeaderRequired\x88\x01\x01\x12$\n" + + "\vwallet_addr\x18\x1a \x01(\tH\x19R\n" + + "walletAddr\x88\x01\x01\x122\n" + + "\x15wallet_fallback_addrs\x18\x1b \x03(\tR\x13walletFallbackAddrsB\x13\n" + "\x11_session_durationB\"\n" + " _unrolled_vtxo_min_expiry_marginB\x10\n" + "\x0e_ban_thresholdB\x0f\n" + @@ -4089,7 +4110,8 @@ const file_ark_v1_admin_proto_rawDesc = "" + "\x15_build_version_headerB \n" + "\x1e_build_version_header_requiredB\r\n" + "\v_updated_atB\x19\n" + - "\x17_digest_header_required\"\x14\n" + + "\x17_digest_header_requiredB\x0e\n" + + "\f_wallet_addr\"\x14\n" + "\x12GetSettingsRequest\"C\n" + "\x13GetSettingsResponse\x12,\n" + "\bsettings\x18\x01 \x01(\v2\x10.ark.v1.SettingsR\bsettings\"E\n" + diff --git a/internal/config/config.go b/internal/config/config.go index fd9388551..c49be09a8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -812,7 +812,21 @@ func (c *Config) repoManager() error { var newWalletClient = walletclient.New func (c *Config) walletService() error { + // The wallet addresses are sourced from the persisted settings (seeded from + // env on first boot), so admin changes take effect on the next restart. Fall + // back to env if settings aren't available yet (e.g. the repo isn't wired). arkWallet := c.WalletAddr + fallbackAddrs := c.WalletFallbackAddrs + if c.repo != nil { + settings, err := c.repo.Settings().Get(context.Background()) + if err == nil && settings != nil { + if settings.WalletAddr != "" { + arkWallet = settings.WalletAddr + } + fallbackAddrs = settings.WalletFallbackAddrs + } + } + if arkWallet == "" { return fmt.Errorf("missing ark wallet address") } @@ -825,7 +839,7 @@ func (c *Config) walletService() error { c.wallet = walletSvc c.network = network - fallbacks, err := c.dialFallbackWallets() + fallbacks, err := c.dialFallbackWallets(fallbackAddrs) if err != nil { return err } @@ -840,9 +854,9 @@ func (c *Config) walletService() error { // 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() ([]ports.WalletService, error) { - fallbacks := make([]ports.WalletService, 0, len(c.WalletFallbackAddrs)) - for _, addr := range c.WalletFallbackAddrs { +func (c *Config) dialFallbackWallets(fallbackAddrs []string) ([]ports.WalletService, error) { + fallbacks := make([]ports.WalletService, 0, len(fallbackAddrs)) + for _, addr := range fallbackAddrs { if addr == "" { continue } @@ -1068,6 +1082,7 @@ func (c *Config) getSettings() (*domain.Settings, error) { c.BoardingExitDelay, c.VtxoTreeExpiry, c.MaxTxWeight, c.MaxOpReturnOutputs, c.AssetTxMaxWeightRatio, c.NoteUriPrefix, c.BuildVersionHeader, c.BuildVersionHeaderRequired, c.DigestHeaderRequired, + c.WalletAddr, c.WalletFallbackAddrs, ) if err != nil { return nil, err diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 779796289..da07db8cd 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -317,7 +317,7 @@ func TestDialFallbackWallets(t *testing.T) { } c := &Config{network: regtest, WalletFallbackAddrs: []string{"a:6060", "b:6060"}} - fbs, err := c.dialFallbackWallets() + fbs, err := c.dialFallbackWallets(c.WalletFallbackAddrs) require.NoError(t, err) require.Len(t, fbs, 2) @@ -336,7 +336,7 @@ func TestDialFallbackWallets(t *testing.T) { } c := &Config{network: regtest, WalletFallbackAddrs: []string{"a:6060", "b:6060"}} - fbs, err := c.dialFallbackWallets() + fbs, err := c.dialFallbackWallets(c.WalletFallbackAddrs) require.Error(t, err) require.Nil(t, fbs) @@ -358,7 +358,7 @@ func TestDialFallbackWallets(t *testing.T) { } c := &Config{network: regtest, WalletFallbackAddrs: []string{"a:6060", "b:6060"}} - fbs, err := c.dialFallbackWallets() + fbs, err := c.dialFallbackWallets(c.WalletFallbackAddrs) require.Error(t, err) require.Nil(t, fbs) diff --git a/internal/core/application/admin.go b/internal/core/application/admin.go index e246ae14a..92dc2d8b8 100644 --- a/internal/core/application/admin.go +++ b/internal/core/application/admin.go @@ -662,6 +662,17 @@ func (a *adminService) UpdateSettings( return nil, fmt.Errorf("failed to update settings: %w", err) } + // Wallet addresses are read at startup to dial the arkd-wallet(s); a change to + // them is persisted now but only takes effect on the next restart. + for _, field := range changelog { + if field == "wallet_addr" || field == "wallet_fallback_addrs" { + log.Warnf( + "settings field %q updated; wallet address changes take effect on restart", + field, + ) + } + } + return changelog, nil } diff --git a/internal/core/domain/settings.go b/internal/core/domain/settings.go index eb5cd1b0e..8c6471f6e 100644 --- a/internal/core/domain/settings.go +++ b/internal/core/domain/settings.go @@ -62,7 +62,12 @@ type Settings struct { BuildVersionHeader string BuildVersionHeaderRequired bool DigestHeaderRequired bool - UpdatedAt time.Time + // WalletAddr / WalletFallbackAddrs hold the primary and fallback arkd-wallet + // connection addresses. Seeded from env on first boot, then sourced from here; + // changing them via the admin API is applied on the next restart. + WalletAddr string + WalletFallbackAddrs []string + UpdatedAt time.Time } func NewSettings( @@ -75,6 +80,7 @@ func NewSettings( maxTxWeight, maxOpReturnOutputs uint64, assetTxMaxWeightRatio float32, noteUriPrefix, minVersionAccepted string, minVersionRequired, digestHeaderRequired bool, + walletAddr string, walletFallbackAddrs []string, ) (*Settings, error) { settings := &Settings{ SessionDuration: time.Duration(sessionDuration) * time.Second, @@ -101,6 +107,8 @@ func NewSettings( BuildVersionHeader: minVersionAccepted, BuildVersionHeaderRequired: minVersionRequired, DigestHeaderRequired: digestHeaderRequired, + WalletAddr: walletAddr, + WalletFallbackAddrs: walletFallbackAddrs, UpdatedAt: time.Now(), } if err := settings.Validate(); err != nil { @@ -267,6 +275,8 @@ type SettingsUpdate struct { BuildVersionHeader *string BuildVersionHeaderRequired *bool DigestHeaderRequired *bool + WalletAddr *string + WalletFallbackAddrs *[]string } // Update updates any field of Settings but ScheduledSession and BatchFees and returns a changelog @@ -372,6 +382,14 @@ func (s *Settings) Update(u SettingsUpdate) ([]string, error) { updated.DigestHeaderRequired = *u.DigestHeaderRequired changelog = append(changelog, "digest_header_required") } + if u.WalletAddr != nil { + updated.WalletAddr = *u.WalletAddr + changelog = append(changelog, "wallet_addr") + } + if u.WalletFallbackAddrs != nil { + updated.WalletFallbackAddrs = *u.WalletFallbackAddrs + changelog = append(changelog, "wallet_fallback_addrs") + } if err := updated.Validate(); err != nil { return nil, err diff --git a/internal/core/domain/settings_test.go b/internal/core/domain/settings_test.go index 97683de97..3c4113e9e 100644 --- a/internal/core/domain/settings_test.go +++ b/internal/core/domain/settings_test.go @@ -313,6 +313,24 @@ func testUpdateSettings(t *testing.T) { require.Equal(t, validSettings, settings) }) + t.Run("updates wallet addresses", func(t *testing.T) { + settings := validSettings + + walletAddr := "wallet:7070" + fallbacks := []string{"wallet-2:7070", "wallet-3:7070"} + + changelog, err := settings.Update(domain.SettingsUpdate{ + WalletAddr: &walletAddr, + WalletFallbackAddrs: &fallbacks, + }) + require.NoError(t, err) + require.ElementsMatch( + t, []string{"wallet_addr", "wallet_fallback_addrs"}, changelog, + ) + require.Equal(t, "wallet:7070", settings.WalletAddr) + require.Equal(t, fallbacks, settings.WalletFallbackAddrs) + }) + t.Run("invalid update leaves settings untouched", func(t *testing.T) { settings := validSettings @@ -374,6 +392,7 @@ func testNewSettings(t *testing.T) { boardingExitDelay, vtxoTreeExpiry, maxTxWeight, maxOpReturnOutputs, assetTxMaxWeightRatio, noteUriPrefix, buildVersionHeader, buildVersionHeaderRequired, digestHeaderRequired, + "localhost:6060", nil, ) require.NoError(t, err) require.NotNil(t, settings) @@ -396,6 +415,7 @@ func testNewSettings(t *testing.T) { boardingExitDelay, vtxoTreeExpiry, maxTxWeight, maxOpReturnOutputs, assetTxMaxWeightRatio, noteUriPrefix, buildVersionHeader, buildVersionHeaderRequired, digestHeaderRequired, + "localhost:6060", nil, ) require.ErrorContains(t, err, "invalid session duration") require.Nil(t, settings) @@ -411,6 +431,7 @@ func testNewSettings(t *testing.T) { boardingExitDelay, vtxoTreeExpiry, maxTxWeight, maxOpReturnOutputs, assetTxMaxWeightRatio, noteUriPrefix, "", true, true, + "localhost:6060", nil, ) require.ErrorContains(t, err, "build version header is required but no version is set") require.Nil(t, settings) diff --git a/internal/infrastructure/db/postgres/migration/20260612000000_add_wallet_addrs_to_settings.down.sql b/internal/infrastructure/db/postgres/migration/20260612000000_add_wallet_addrs_to_settings.down.sql new file mode 100644 index 000000000..14dd1a38d --- /dev/null +++ b/internal/infrastructure/db/postgres/migration/20260612000000_add_wallet_addrs_to_settings.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE settings + DROP COLUMN wallet_addr, + DROP COLUMN wallet_fallback_addrs; diff --git a/internal/infrastructure/db/postgres/migration/20260612000000_add_wallet_addrs_to_settings.up.sql b/internal/infrastructure/db/postgres/migration/20260612000000_add_wallet_addrs_to_settings.up.sql new file mode 100644 index 000000000..b43cc673c --- /dev/null +++ b/internal/infrastructure/db/postgres/migration/20260612000000_add_wallet_addrs_to_settings.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE settings + ADD COLUMN wallet_addr TEXT NOT NULL DEFAULT '', + ADD COLUMN wallet_fallback_addrs TEXT NOT NULL DEFAULT ''; diff --git a/internal/infrastructure/db/postgres/settings_repo.go b/internal/infrastructure/db/postgres/settings_repo.go index 14ec05b35..e5c81bb26 100644 --- a/internal/infrastructure/db/postgres/settings_repo.go +++ b/internal/infrastructure/db/postgres/settings_repo.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "strings" "sync" "time" @@ -13,6 +14,15 @@ import ( arklib "github.com/arkade-os/arkd/pkg/ark-lib" ) +// splitFallbackAddrs decodes the comma-separated wallet_fallback_addrs column +// back into a slice (empty string -> nil). +func splitFallbackAddrs(s string) []string { + if s == "" { + return nil + } + return strings.Split(s, ",") +} + type settingsRepository struct { db *sql.DB querier *queries.Queries @@ -98,6 +108,8 @@ func (r *settingsRepository) Get(ctx context.Context) (*domain.Settings, error) BuildVersionHeader: row.BuildVersionHeader, BuildVersionHeaderRequired: row.BuildVersionHeaderRequired, DigestHeaderRequired: row.DigestHeaderRequired, + WalletAddr: row.WalletAddr, + WalletFallbackAddrs: splitFallbackAddrs(row.WalletFallbackAddrs), ScheduledSession: scheduledSession, BatchFees: domain.BatchFees{ OnchainInputFee: row.BatchOnchainInputFee, @@ -140,6 +152,8 @@ func (r *settingsRepository) Upsert( BuildVersionHeader: settings.BuildVersionHeader, BuildVersionHeaderRequired: settings.BuildVersionHeaderRequired, DigestHeaderRequired: settings.DigestHeaderRequired, + WalletAddr: settings.WalletAddr, + WalletFallbackAddrs: strings.Join(settings.WalletFallbackAddrs, ","), BatchOnchainInputFee: settings.BatchFees.OnchainInputFee, BatchOffchainInputFee: settings.BatchFees.OffchainInputFee, BatchOnchainOutputFee: settings.BatchFees.OnchainOutputFee, diff --git a/internal/infrastructure/db/postgres/settings_seed.go b/internal/infrastructure/db/postgres/settings_seed.go index 307d9eb28..4b7f7cfcd 100644 --- a/internal/infrastructure/db/postgres/settings_seed.go +++ b/internal/infrastructure/db/postgres/settings_seed.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "strings" "time" "github.com/arkade-os/arkd/internal/core/domain" @@ -144,6 +145,8 @@ func seedParams(settings domain.Settings) queries.UpsertSettingsParams { BuildVersionHeader: settings.BuildVersionHeader, BuildVersionHeaderRequired: settings.BuildVersionHeaderRequired, DigestHeaderRequired: settings.DigestHeaderRequired, + WalletAddr: settings.WalletAddr, + WalletFallbackAddrs: strings.Join(settings.WalletFallbackAddrs, ","), BatchOnchainInputFee: settings.BatchFees.OnchainInputFee, BatchOffchainInputFee: settings.BatchFees.OffchainInputFee, BatchOnchainOutputFee: settings.BatchFees.OnchainOutputFee, diff --git a/internal/infrastructure/db/postgres/settings_seed_test.go b/internal/infrastructure/db/postgres/settings_seed_test.go index f98156aaa..a696b07c7 100644 --- a/internal/infrastructure/db/postgres/settings_seed_test.go +++ b/internal/infrastructure/db/postgres/settings_seed_test.go @@ -78,6 +78,8 @@ CREATE TABLE settings ( max_op_return_outputs BIGINT NOT NULL DEFAULT 0, asset_tx_max_weight_ratio REAL NOT NULL DEFAULT 0, note_uri_prefix TEXT NOT NULL DEFAULT '', + wallet_addr TEXT NOT NULL DEFAULT '', + wallet_fallback_addrs TEXT NOT NULL DEFAULT '', scheduled_session_start_time BIGINT NOT NULL DEFAULT 0, scheduled_session_end_time BIGINT NOT NULL DEFAULT 0, scheduled_session_period BIGINT NOT NULL DEFAULT 0, diff --git a/internal/infrastructure/db/postgres/sqlc/queries/models.go b/internal/infrastructure/db/postgres/sqlc/queries/models.go index 52eddb932..1befc6af0 100644 --- a/internal/infrastructure/db/postgres/sqlc/queries/models.go +++ b/internal/infrastructure/db/postgres/sqlc/queries/models.go @@ -245,6 +245,8 @@ type Setting struct { BuildVersionHeaderRequired bool DigestHeaderRequired bool UpdatedAt int64 + WalletAddr string + WalletFallbackAddrs string } type SettingsHistory struct { diff --git a/internal/infrastructure/db/postgres/sqlc/queries/query.sql.go b/internal/infrastructure/db/postgres/sqlc/queries/query.sql.go index a744dc04f..d717a3ddf 100644 --- a/internal/infrastructure/db/postgres/sqlc/queries/query.sql.go +++ b/internal/infrastructure/db/postgres/sqlc/queries/query.sql.go @@ -1393,7 +1393,7 @@ func (q *Queries) SelectRoundsWithTxids(ctx context.Context, dollar_1 []string) } const selectSettings = `-- name: SelectSettings :one -SELECT id, session_duration, unrolled_vtxo_min_expiry_margin, ban_threshold, ban_duration, unilateral_exit_delay, public_unilateral_exit_delay, checkpoint_exit_delay, boarding_exit_delay, vtxo_tree_expiry, round_min_participants_count, round_max_participants_count, vtxo_min_amount, vtxo_max_amount, utxo_min_amount, utxo_max_amount, settlement_min_expiry_gap, vtxo_no_csv_validation_cutoff_date, max_tx_weight, max_op_return_outputs, asset_tx_max_weight_ratio, note_uri_prefix, scheduled_session_start_time, scheduled_session_end_time, scheduled_session_period, scheduled_session_duration, scheduled_session_round_min_participants_count, scheduled_session_round_max_participants_count, batch_onchain_input_fee, batch_offchain_input_fee, batch_onchain_output_fee, batch_offchain_output_fee, build_version_header, build_version_header_required, digest_header_required, updated_at FROM settings WHERE id = 1 +SELECT id, session_duration, unrolled_vtxo_min_expiry_margin, ban_threshold, ban_duration, unilateral_exit_delay, public_unilateral_exit_delay, checkpoint_exit_delay, boarding_exit_delay, vtxo_tree_expiry, round_min_participants_count, round_max_participants_count, vtxo_min_amount, vtxo_max_amount, utxo_min_amount, utxo_max_amount, settlement_min_expiry_gap, vtxo_no_csv_validation_cutoff_date, max_tx_weight, max_op_return_outputs, asset_tx_max_weight_ratio, note_uri_prefix, scheduled_session_start_time, scheduled_session_end_time, scheduled_session_period, scheduled_session_duration, scheduled_session_round_min_participants_count, scheduled_session_round_max_participants_count, batch_onchain_input_fee, batch_offchain_input_fee, batch_onchain_output_fee, batch_offchain_output_fee, build_version_header, build_version_header_required, digest_header_required, updated_at, wallet_addr, wallet_fallback_addrs FROM settings WHERE id = 1 ` func (q *Queries) SelectSettings(ctx context.Context) (Setting, error) { @@ -1436,6 +1436,8 @@ func (q *Queries) SelectSettings(ctx context.Context) (Setting, error) { &i.BuildVersionHeaderRequired, &i.DigestHeaderRequired, &i.UpdatedAt, + &i.WalletAddr, + &i.WalletFallbackAddrs, ) return i, err } @@ -2233,6 +2235,7 @@ INSERT INTO settings ( settlement_min_expiry_gap, vtxo_no_csv_validation_cutoff_date, max_tx_weight, max_op_return_outputs, asset_tx_max_weight_ratio, note_uri_prefix, + wallet_addr, wallet_fallback_addrs, scheduled_session_start_time, scheduled_session_end_time, scheduled_session_period, scheduled_session_duration, scheduled_session_round_min_participants_count, @@ -2254,12 +2257,13 @@ INSERT INTO settings ( $21, $22, $23, $24, $25, - $26, - $27, - $28, $29, + $26, $27, + $28, + $29, $30, $31, - $32, $33, $34, - $35 + $32, $33, + $34, $35, $36, + $37 ) ON CONFLICT(id) DO UPDATE SET session_duration = EXCLUDED.session_duration, @@ -2283,6 +2287,8 @@ ON CONFLICT(id) DO UPDATE SET max_op_return_outputs = EXCLUDED.max_op_return_outputs, asset_tx_max_weight_ratio = EXCLUDED.asset_tx_max_weight_ratio, note_uri_prefix = EXCLUDED.note_uri_prefix, + wallet_addr = EXCLUDED.wallet_addr, + wallet_fallback_addrs = EXCLUDED.wallet_fallback_addrs, scheduled_session_start_time = EXCLUDED.scheduled_session_start_time, scheduled_session_end_time = EXCLUDED.scheduled_session_end_time, scheduled_session_period = EXCLUDED.scheduled_session_period, @@ -2323,6 +2329,8 @@ type UpsertSettingsParams struct { MaxOpReturnOutputs int64 AssetTxMaxWeightRatio float32 NoteUriPrefix string + WalletAddr string + WalletFallbackAddrs string ScheduledSessionStartTime int64 ScheduledSessionEndTime int64 ScheduledSessionPeriod int64 @@ -2362,6 +2370,8 @@ func (q *Queries) UpsertSettings(ctx context.Context, arg UpsertSettingsParams) arg.MaxOpReturnOutputs, arg.AssetTxMaxWeightRatio, arg.NoteUriPrefix, + arg.WalletAddr, + arg.WalletFallbackAddrs, arg.ScheduledSessionStartTime, arg.ScheduledSessionEndTime, arg.ScheduledSessionPeriod, diff --git a/internal/infrastructure/db/postgres/sqlc/query.sql b/internal/infrastructure/db/postgres/sqlc/query.sql index d319e665a..f8948d1be 100644 --- a/internal/infrastructure/db/postgres/sqlc/query.sql +++ b/internal/infrastructure/db/postgres/sqlc/query.sql @@ -451,6 +451,7 @@ INSERT INTO settings ( settlement_min_expiry_gap, vtxo_no_csv_validation_cutoff_date, max_tx_weight, max_op_return_outputs, asset_tx_max_weight_ratio, note_uri_prefix, + wallet_addr, wallet_fallback_addrs, scheduled_session_start_time, scheduled_session_end_time, scheduled_session_period, scheduled_session_duration, scheduled_session_round_min_participants_count, @@ -470,6 +471,7 @@ INSERT INTO settings ( @settlement_min_expiry_gap, @vtxo_no_csv_validation_cutoff_date, @max_tx_weight, @max_op_return_outputs, @asset_tx_max_weight_ratio, @note_uri_prefix, + @wallet_addr, @wallet_fallback_addrs, @scheduled_session_start_time, @scheduled_session_end_time, @scheduled_session_period, @scheduled_session_duration, @scheduled_session_round_min_participants_count, @@ -501,6 +503,8 @@ ON CONFLICT(id) DO UPDATE SET max_op_return_outputs = EXCLUDED.max_op_return_outputs, asset_tx_max_weight_ratio = EXCLUDED.asset_tx_max_weight_ratio, note_uri_prefix = EXCLUDED.note_uri_prefix, + wallet_addr = EXCLUDED.wallet_addr, + wallet_fallback_addrs = EXCLUDED.wallet_fallback_addrs, scheduled_session_start_time = EXCLUDED.scheduled_session_start_time, scheduled_session_end_time = EXCLUDED.scheduled_session_end_time, scheduled_session_period = EXCLUDED.scheduled_session_period, diff --git a/internal/infrastructure/db/service_test.go b/internal/infrastructure/db/service_test.go index 6867e9095..71e8d8331 100644 --- a/internal/infrastructure/db/service_test.go +++ b/internal/infrastructure/db/service_test.go @@ -1903,6 +1903,8 @@ func validSettings() domain.Settings { BuildVersionHeader: "v1.0.0", BuildVersionHeaderRequired: true, DigestHeaderRequired: true, + WalletAddr: "wallet:6060", + WalletFallbackAddrs: []string{"wallet-2:6060", "wallet-3:6060"}, UpdatedAt: time.Unix(1700000000, 0), } } @@ -2010,6 +2012,8 @@ func assertSettingsEqual(t *testing.T, expected, actual domain.Settings) { assert.Equal(t, expected.MaxTxWeight, actual.MaxTxWeight, "MaxTxWeight not equal") assert.True(t, expected.UpdatedAt.Equal(actual.UpdatedAt), "UpdatedAt not equal") assert.Equal(t, expected.AssetTxMaxWeightRatio, actual.AssetTxMaxWeightRatio, "AssetTxMaxWeightRatio not equal") + assert.Equal(t, expected.WalletAddr, actual.WalletAddr, "WalletAddr not equal") + assert.Equal(t, expected.WalletFallbackAddrs, actual.WalletFallbackAddrs, "WalletFallbackAddrs not equal") assert.Equal(t, expected.MaxOpReturnOutputs, actual.MaxOpReturnOutputs, "MaxOpReturnOutputs not equal") assert.Equal(t, expected.NoteUriPrefix, actual.NoteUriPrefix, "NoteUriPrefix not equal") assert.Equal(t, expected.BuildVersionHeader, actual.BuildVersionHeader, "BuildVersionHeader not equal") diff --git a/internal/infrastructure/db/sqlite/migration/20260612000000_add_wallet_addrs_to_settings.down.sql b/internal/infrastructure/db/sqlite/migration/20260612000000_add_wallet_addrs_to_settings.down.sql new file mode 100644 index 000000000..0034d967e --- /dev/null +++ b/internal/infrastructure/db/sqlite/migration/20260612000000_add_wallet_addrs_to_settings.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE settings DROP COLUMN wallet_addr; +ALTER TABLE settings DROP COLUMN wallet_fallback_addrs; diff --git a/internal/infrastructure/db/sqlite/migration/20260612000000_add_wallet_addrs_to_settings.up.sql b/internal/infrastructure/db/sqlite/migration/20260612000000_add_wallet_addrs_to_settings.up.sql new file mode 100644 index 000000000..3165333b0 --- /dev/null +++ b/internal/infrastructure/db/sqlite/migration/20260612000000_add_wallet_addrs_to_settings.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE settings ADD COLUMN wallet_addr TEXT NOT NULL DEFAULT ''; +ALTER TABLE settings ADD COLUMN wallet_fallback_addrs TEXT NOT NULL DEFAULT ''; diff --git a/internal/infrastructure/db/sqlite/settings_repo.go b/internal/infrastructure/db/sqlite/settings_repo.go index a0251c428..470921136 100644 --- a/internal/infrastructure/db/sqlite/settings_repo.go +++ b/internal/infrastructure/db/sqlite/settings_repo.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "strings" "sync" "time" @@ -13,6 +14,15 @@ import ( arklib "github.com/arkade-os/arkd/pkg/ark-lib" ) +// splitFallbackAddrs decodes the comma-separated wallet_fallback_addrs column +// back into a slice (empty string -> nil). +func splitFallbackAddrs(s string) []string { + if s == "" { + return nil + } + return strings.Split(s, ",") +} + type settingsRepository struct { db SQLiteDB @@ -103,6 +113,8 @@ func (r *settingsRepository) Get(ctx context.Context) (*domain.Settings, error) BuildVersionHeader: row.BuildVersionHeader, BuildVersionHeaderRequired: row.BuildVersionHeaderRequired, DigestHeaderRequired: row.DigestHeaderRequired, + WalletAddr: row.WalletAddr, + WalletFallbackAddrs: splitFallbackAddrs(row.WalletFallbackAddrs), ScheduledSession: scheduledSession, BatchFees: domain.BatchFees{ OnchainInputFee: row.BatchOnchainInputFee, @@ -144,6 +156,8 @@ func (r *settingsRepository) Upsert( BuildVersionHeader: settings.BuildVersionHeader, BuildVersionHeaderRequired: settings.BuildVersionHeaderRequired, DigestHeaderRequired: settings.DigestHeaderRequired, + WalletAddr: settings.WalletAddr, + WalletFallbackAddrs: strings.Join(settings.WalletFallbackAddrs, ","), BatchOnchainInputFee: settings.BatchFees.OnchainInputFee, BatchOffchainInputFee: settings.BatchFees.OffchainInputFee, BatchOnchainOutputFee: settings.BatchFees.OnchainOutputFee, diff --git a/internal/infrastructure/db/sqlite/settings_seed.go b/internal/infrastructure/db/sqlite/settings_seed.go index 0f039e78d..6697ccfce 100644 --- a/internal/infrastructure/db/sqlite/settings_seed.go +++ b/internal/infrastructure/db/sqlite/settings_seed.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "strings" "time" "github.com/arkade-os/arkd/internal/core/domain" @@ -144,6 +145,8 @@ func seedParams(settings domain.Settings) queries.UpsertSettingsParams { BuildVersionHeader: settings.BuildVersionHeader, BuildVersionHeaderRequired: settings.BuildVersionHeaderRequired, DigestHeaderRequired: settings.DigestHeaderRequired, + WalletAddr: settings.WalletAddr, + WalletFallbackAddrs: strings.Join(settings.WalletFallbackAddrs, ","), BatchOnchainInputFee: settings.BatchFees.OnchainInputFee, BatchOffchainInputFee: settings.BatchFees.OffchainInputFee, BatchOnchainOutputFee: settings.BatchFees.OnchainOutputFee, diff --git a/internal/infrastructure/db/sqlite/settings_seed_test.go b/internal/infrastructure/db/sqlite/settings_seed_test.go index e8e656e0e..301e1964e 100644 --- a/internal/infrastructure/db/sqlite/settings_seed_test.go +++ b/internal/infrastructure/db/sqlite/settings_seed_test.go @@ -212,6 +212,8 @@ CREATE TABLE IF NOT EXISTS settings ( max_op_return_outputs BIGINT NOT NULL DEFAULT 0, asset_tx_max_weight_ratio REAL NOT NULL DEFAULT 0, note_uri_prefix TEXT NOT NULL DEFAULT '', + wallet_addr TEXT NOT NULL DEFAULT '', + wallet_fallback_addrs TEXT NOT NULL DEFAULT '', scheduled_session_start_time BIGINT NOT NULL DEFAULT 0, scheduled_session_end_time BIGINT NOT NULL DEFAULT 0, scheduled_session_period BIGINT NOT NULL DEFAULT 0, diff --git a/internal/infrastructure/db/sqlite/sqlc/queries/models.go b/internal/infrastructure/db/sqlite/sqlc/queries/models.go index 4a42b2ec9..32263a61a 100644 --- a/internal/infrastructure/db/sqlite/sqlc/queries/models.go +++ b/internal/infrastructure/db/sqlite/sqlc/queries/models.go @@ -231,6 +231,8 @@ type Setting struct { BuildVersionHeaderRequired bool DigestHeaderRequired bool UpdatedAt int64 + WalletAddr string + WalletFallbackAddrs string } type Tx struct { diff --git a/internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go b/internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go index 12212dc4a..59cca76e2 100644 --- a/internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go +++ b/internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go @@ -1405,7 +1405,7 @@ func (q *Queries) SelectRoundsWithTxids(ctx context.Context, txids []string) ([] } const selectSettings = `-- name: SelectSettings :one -SELECT id, session_duration, unrolled_vtxo_min_expiry_margin, ban_threshold, ban_duration, unilateral_exit_delay, public_unilateral_exit_delay, checkpoint_exit_delay, boarding_exit_delay, vtxo_tree_expiry, round_min_participants_count, round_max_participants_count, vtxo_min_amount, vtxo_max_amount, utxo_min_amount, utxo_max_amount, settlement_min_expiry_gap, vtxo_no_csv_validation_cutoff_date, max_tx_weight, max_op_return_outputs, asset_tx_max_weight_ratio, note_uri_prefix, scheduled_session_start_time, scheduled_session_end_time, scheduled_session_period, scheduled_session_duration, scheduled_session_round_min_participants_count, scheduled_session_round_max_participants_count, batch_onchain_input_fee, batch_offchain_input_fee, batch_onchain_output_fee, batch_offchain_output_fee, build_version_header, build_version_header_required, digest_header_required, updated_at FROM settings WHERE id = 1 +SELECT id, session_duration, unrolled_vtxo_min_expiry_margin, ban_threshold, ban_duration, unilateral_exit_delay, public_unilateral_exit_delay, checkpoint_exit_delay, boarding_exit_delay, vtxo_tree_expiry, round_min_participants_count, round_max_participants_count, vtxo_min_amount, vtxo_max_amount, utxo_min_amount, utxo_max_amount, settlement_min_expiry_gap, vtxo_no_csv_validation_cutoff_date, max_tx_weight, max_op_return_outputs, asset_tx_max_weight_ratio, note_uri_prefix, scheduled_session_start_time, scheduled_session_end_time, scheduled_session_period, scheduled_session_duration, scheduled_session_round_min_participants_count, scheduled_session_round_max_participants_count, batch_onchain_input_fee, batch_offchain_input_fee, batch_onchain_output_fee, batch_offchain_output_fee, build_version_header, build_version_header_required, digest_header_required, updated_at, wallet_addr, wallet_fallback_addrs FROM settings WHERE id = 1 ` func (q *Queries) SelectSettings(ctx context.Context) (Setting, error) { @@ -1448,6 +1448,8 @@ func (q *Queries) SelectSettings(ctx context.Context) (Setting, error) { &i.BuildVersionHeaderRequired, &i.DigestHeaderRequired, &i.UpdatedAt, + &i.WalletAddr, + &i.WalletFallbackAddrs, ) return i, err } @@ -2370,6 +2372,7 @@ INSERT INTO settings ( settlement_min_expiry_gap, vtxo_no_csv_validation_cutoff_date, max_tx_weight, max_op_return_outputs, asset_tx_max_weight_ratio, note_uri_prefix, + wallet_addr, wallet_fallback_addrs, scheduled_session_start_time, scheduled_session_end_time, scheduled_session_period, scheduled_session_duration, scheduled_session_round_min_participants_count, @@ -2391,12 +2394,13 @@ INSERT INTO settings ( ?21, ?22, ?23, ?24, ?25, - ?26, - ?27, - ?28, ?29, + ?26, ?27, + ?28, + ?29, ?30, ?31, - ?32, ?33, ?34, - ?35 + ?32, ?33, + ?34, ?35, ?36, + ?37 ) ON CONFLICT(id) DO UPDATE SET session_duration = EXCLUDED.session_duration, @@ -2420,6 +2424,8 @@ ON CONFLICT(id) DO UPDATE SET max_op_return_outputs = EXCLUDED.max_op_return_outputs, asset_tx_max_weight_ratio = EXCLUDED.asset_tx_max_weight_ratio, note_uri_prefix = EXCLUDED.note_uri_prefix, + wallet_addr = EXCLUDED.wallet_addr, + wallet_fallback_addrs = EXCLUDED.wallet_fallback_addrs, scheduled_session_start_time = EXCLUDED.scheduled_session_start_time, scheduled_session_end_time = EXCLUDED.scheduled_session_end_time, scheduled_session_period = EXCLUDED.scheduled_session_period, @@ -2460,6 +2466,8 @@ type UpsertSettingsParams struct { MaxOpReturnOutputs int64 AssetTxMaxWeightRatio float64 NoteUriPrefix string + WalletAddr string + WalletFallbackAddrs string ScheduledSessionStartTime int64 ScheduledSessionEndTime int64 ScheduledSessionPeriod int64 @@ -2499,6 +2507,8 @@ func (q *Queries) UpsertSettings(ctx context.Context, arg UpsertSettingsParams) arg.MaxOpReturnOutputs, arg.AssetTxMaxWeightRatio, arg.NoteUriPrefix, + arg.WalletAddr, + arg.WalletFallbackAddrs, arg.ScheduledSessionStartTime, arg.ScheduledSessionEndTime, arg.ScheduledSessionPeriod, diff --git a/internal/infrastructure/db/sqlite/sqlc/query.sql b/internal/infrastructure/db/sqlite/sqlc/query.sql index f567992dd..b15878600 100644 --- a/internal/infrastructure/db/sqlite/sqlc/query.sql +++ b/internal/infrastructure/db/sqlite/sqlc/query.sql @@ -460,6 +460,7 @@ INSERT INTO settings ( settlement_min_expiry_gap, vtxo_no_csv_validation_cutoff_date, max_tx_weight, max_op_return_outputs, asset_tx_max_weight_ratio, note_uri_prefix, + wallet_addr, wallet_fallback_addrs, scheduled_session_start_time, scheduled_session_end_time, scheduled_session_period, scheduled_session_duration, scheduled_session_round_min_participants_count, @@ -479,6 +480,7 @@ INSERT INTO settings ( @settlement_min_expiry_gap, @vtxo_no_csv_validation_cutoff_date, @max_tx_weight, @max_op_return_outputs, @asset_tx_max_weight_ratio, @note_uri_prefix, + @wallet_addr, @wallet_fallback_addrs, @scheduled_session_start_time, @scheduled_session_end_time, @scheduled_session_period, @scheduled_session_duration, @scheduled_session_round_min_participants_count, @@ -510,6 +512,8 @@ ON CONFLICT(id) DO UPDATE SET max_op_return_outputs = EXCLUDED.max_op_return_outputs, asset_tx_max_weight_ratio = EXCLUDED.asset_tx_max_weight_ratio, note_uri_prefix = EXCLUDED.note_uri_prefix, + wallet_addr = EXCLUDED.wallet_addr, + wallet_fallback_addrs = EXCLUDED.wallet_fallback_addrs, scheduled_session_start_time = EXCLUDED.scheduled_session_start_time, scheduled_session_end_time = EXCLUDED.scheduled_session_end_time, scheduled_session_period = EXCLUDED.scheduled_session_period, diff --git a/internal/infrastructure/live-store/redis/settings.go b/internal/infrastructure/live-store/redis/settings.go index 3499cb73e..447f8d760 100644 --- a/internal/infrastructure/live-store/redis/settings.go +++ b/internal/infrastructure/live-store/redis/settings.go @@ -149,6 +149,8 @@ type settingsDTO struct { BuildVersionHeader string BuildVersionHeaderRequired bool DigestHeaderRequired bool + WalletAddr string + WalletFallbackAddrs []string ScheduledSession scheduledSessionDTO BatchFees batchFeesDTO Network string @@ -214,6 +216,8 @@ func newSettingsDTO(settings ports.Settings) settingsDTO { BuildVersionHeader: settings.BuildVersionHeader, BuildVersionHeaderRequired: settings.BuildVersionHeaderRequired, DigestHeaderRequired: settings.DigestHeaderRequired, + WalletAddr: settings.WalletAddr, + WalletFallbackAddrs: settings.WalletFallbackAddrs, BatchFees: settings.BatchFees, Network: settings.Network.Name, DustAmount: settings.DustAmount, @@ -296,6 +300,8 @@ func (s settingsDTO) parse() (*ports.Settings, error) { BuildVersionHeader: s.BuildVersionHeader, BuildVersionHeaderRequired: s.BuildVersionHeaderRequired, DigestHeaderRequired: s.DigestHeaderRequired, + WalletAddr: s.WalletAddr, + WalletFallbackAddrs: s.WalletFallbackAddrs, ScheduledSession: s.ScheduledSession.parse(), BatchFees: s.BatchFees, }, diff --git a/internal/interface/grpc/handlers/adminservice.go b/internal/interface/grpc/handlers/adminservice.go index f23eb9186..1757b5220 100644 --- a/internal/interface/grpc/handlers/adminservice.go +++ b/internal/interface/grpc/handlers/adminservice.go @@ -725,6 +725,8 @@ func (a *adminHandler) GetSettings( BuildVersionHeader: &settings.BuildVersionHeader, BuildVersionHeaderRequired: &settings.BuildVersionHeaderRequired, DigestHeaderRequired: &settings.DigestHeaderRequired, + WalletAddr: &settings.WalletAddr, + WalletFallbackAddrs: settings.WalletFallbackAddrs, UpdatedAt: formatTime(settings.UpdatedAt), } } @@ -921,6 +923,16 @@ func parseSettings(settings *arkv1.Settings) (*domain.SettingsUpdate, error) { t := settings.GetDigestHeaderRequired() digestHeaderRequired = &t } + var walletAddr *string + if settings.WalletAddr != nil { + t := settings.GetWalletAddr() + walletAddr = &t + } + var walletFallbackAddrs *[]string + if len(settings.WalletFallbackAddrs) > 0 { + t := settings.GetWalletFallbackAddrs() + walletFallbackAddrs = &t + } return &domain.SettingsUpdate{ SessionDuration: parseDuration(settings.SessionDuration), @@ -947,6 +959,8 @@ func parseSettings(settings *arkv1.Settings) (*domain.SettingsUpdate, error) { BuildVersionHeader: buildVersionHeader, BuildVersionHeaderRequired: buildVersionHeaderRequired, DigestHeaderRequired: digestHeaderRequired, + WalletAddr: walletAddr, + WalletFallbackAddrs: walletFallbackAddrs, }, nil } From 33f927c181cab49dcabca498c89b69f0d64d7e32 Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Fri, 12 Jun 2026 10:42:13 -0400 Subject: [PATCH 21/34] Address review: keep env fallback wallets on upgrade, normalize addrs - walletService only overrides env wallet addresses when the settings carry a value, so an upgrade (whose migrated row defaults them to empty) doesn't drop env-configured fallback wallets; log instead of swallowing a settings read error. - splitFallbackAddrs trims and drops empty entries, matching the env parser. - Document that the fallback list can be replaced but not cleared via the API. --- internal/config/config.go | 12 ++++++++++-- .../infrastructure/db/postgres/settings_repo.go | 14 +++++++++++--- internal/infrastructure/db/sqlite/settings_repo.go | 14 +++++++++++--- internal/interface/grpc/handlers/adminservice.go | 4 ++++ 4 files changed, 36 insertions(+), 8 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index c49be09a8..c2e23d138 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -819,11 +819,19 @@ func (c *Config) walletService() error { fallbackAddrs := c.WalletFallbackAddrs if c.repo != nil { settings, err := c.repo.Settings().Get(context.Background()) - if err == nil && settings != nil { + switch { + case err != nil: + log.Warnf("failed to read settings for wallet addresses, using env: %v", err) + case settings != nil: + // Only override env when the settings actually carry a value, so an + // upgrade (whose migrated row defaults these to empty) keeps the + // env-configured addresses until they're set through the admin API. if settings.WalletAddr != "" { arkWallet = settings.WalletAddr } - fallbackAddrs = settings.WalletFallbackAddrs + if len(settings.WalletFallbackAddrs) > 0 { + fallbackAddrs = settings.WalletFallbackAddrs + } } } diff --git a/internal/infrastructure/db/postgres/settings_repo.go b/internal/infrastructure/db/postgres/settings_repo.go index e5c81bb26..5ae47ac56 100644 --- a/internal/infrastructure/db/postgres/settings_repo.go +++ b/internal/infrastructure/db/postgres/settings_repo.go @@ -15,12 +15,20 @@ import ( ) // splitFallbackAddrs decodes the comma-separated wallet_fallback_addrs column -// back into a slice (empty string -> nil). +// back into a slice, trimming whitespace and dropping empty entries (so it +// normalizes the same way the env parser does); an empty result is returned as nil. func splitFallbackAddrs(s string) []string { - if s == "" { + parts := strings.Split(s, ",") + 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 strings.Split(s, ",") + return addrs } type settingsRepository struct { diff --git a/internal/infrastructure/db/sqlite/settings_repo.go b/internal/infrastructure/db/sqlite/settings_repo.go index 470921136..4642278b6 100644 --- a/internal/infrastructure/db/sqlite/settings_repo.go +++ b/internal/infrastructure/db/sqlite/settings_repo.go @@ -15,12 +15,20 @@ import ( ) // splitFallbackAddrs decodes the comma-separated wallet_fallback_addrs column -// back into a slice (empty string -> nil). +// back into a slice, trimming whitespace and dropping empty entries (so it +// normalizes the same way the env parser does); an empty result is returned as nil. func splitFallbackAddrs(s string) []string { - if s == "" { + parts := strings.Split(s, ",") + 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 strings.Split(s, ",") + return addrs } type settingsRepository struct { diff --git a/internal/interface/grpc/handlers/adminservice.go b/internal/interface/grpc/handlers/adminservice.go index 1757b5220..43e108bce 100644 --- a/internal/interface/grpc/handlers/adminservice.go +++ b/internal/interface/grpc/handlers/adminservice.go @@ -928,6 +928,10 @@ func parseSettings(settings *arkv1.Settings) (*domain.SettingsUpdate, error) { t := settings.GetWalletAddr() walletAddr = &t } + // wallet_fallback_addrs is a repeated field with no presence, so an empty list + // is indistinguishable from "not provided" and is treated as no-change. The + // fallback list can be replaced but not cleared via the API; clear it by + // reconfiguring env and re-seeding. var walletFallbackAddrs *[]string if len(settings.WalletFallbackAddrs) > 0 { t := settings.GetWalletFallbackAddrs() From 3995eb9e28474ef272dc1c3e384876eb4269c897 Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:08:32 -0400 Subject: [PATCH 22/34] Address review: reject commas in wallet addrs, dedupe addr codec - Validate() rejects a comma in the primary or any fallback wallet address, since they're persisted comma-separated (a comma would corrupt the column). - Move the comma-separated encode/decode into domain (EncodeFallbackAddrs / DecodeFallbackAddrs) so postgres and sqlite share one implementation. - Document the wallet-target trust boundary in walletService and log the effective primary wallet address at startup so operators can audit it. --- internal/config/config.go | 8 +++++ internal/core/domain/settings.go | 33 +++++++++++++++++++ internal/core/domain/settings_test.go | 15 +++++++++ .../db/postgres/settings_repo.go | 22 ++----------- .../db/postgres/settings_seed.go | 3 +- .../infrastructure/db/sqlite/settings_repo.go | 22 ++----------- .../infrastructure/db/sqlite/settings_seed.go | 3 +- 7 files changed, 62 insertions(+), 44 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index c2e23d138..5c4fe9fe3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -815,6 +815,10 @@ func (c *Config) walletService() error { // The wallet addresses are sourced from the persisted settings (seeded from // env on first boot), so admin changes take effect on the next restart. Fall // back to env if settings aren't available yet (e.g. the repo isn't wired). + // + // Trust boundary: the wallet gRPC target controls signing, address derivation + // and sweeps, so persisting it via the admin Settings API is equivalent to full + // operator (admin macaroon) trust — the same trust needed to change the env var. arkWallet := c.WalletAddr fallbackAddrs := c.WalletFallbackAddrs if c.repo != nil { @@ -847,6 +851,10 @@ func (c *Config) walletService() error { c.wallet = walletSvc c.network = network + // Surface the effective wallet target so operators can audit which arkd-wallet + // this node dialed (it may come from the DB, not the env var). + log.Infof("dialed primary arkd-wallet at %q on network %s", arkWallet, network.Name) + fallbacks, err := c.dialFallbackWallets(fallbackAddrs) if err != nil { return err diff --git a/internal/core/domain/settings.go b/internal/core/domain/settings.go index 8c6471f6e..591d18fb6 100644 --- a/internal/core/domain/settings.go +++ b/internal/core/domain/settings.go @@ -2,6 +2,7 @@ package domain import ( "fmt" + "strings" "time" arklib "github.com/arkade-os/arkd/pkg/ark-lib" @@ -245,9 +246,41 @@ func (s Settings) Validate() error { if s.BuildVersionHeaderRequired && len(s.BuildVersionHeader) <= 0 { return fmt.Errorf("build version header is required but no version is set") } + + // Fallback addrs are persisted comma-separated by the SQL backends, so a comma + // in an address would corrupt the column on the next read. + if strings.Contains(s.WalletAddr, ",") { + return fmt.Errorf("wallet addr must not contain a comma") + } + for _, addr := range s.WalletFallbackAddrs { + if strings.Contains(addr, ",") { + return fmt.Errorf("wallet fallback addr %q must not contain a comma", addr) + } + } return nil } +// EncodeFallbackAddrs and DecodeFallbackAddrs convert WalletFallbackAddrs to and +// from the comma-separated form the SQL backends persist it in. Decoding trims +// whitespace and drops empty entries; an empty result is returned as nil. +func EncodeFallbackAddrs(addrs []string) string { + return strings.Join(addrs, ",") +} + +func DecodeFallbackAddrs(s string) []string { + parts := strings.Split(s, ",") + 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 +} + // SettingsUpdate is a copy of the Settings repo struct, but with optional fields to easily handle // changes. type SettingsUpdate struct { diff --git a/internal/core/domain/settings_test.go b/internal/core/domain/settings_test.go index 3c4113e9e..cbc0a10d4 100644 --- a/internal/core/domain/settings_test.go +++ b/internal/core/domain/settings_test.go @@ -145,6 +145,12 @@ func testValidateSettings(t *testing.T) { requiredVersionWithoutHeader.BuildVersionHeaderRequired = true requiredVersionWithoutHeader.BuildVersionHeader = "" + commaWalletAddr := validSettings + commaWalletAddr.WalletAddr = "host:6060,evil" + + commaFallbackAddr := validSettings + commaFallbackAddr.WalletFallbackAddrs = []string{"host:6060,evil"} + fixtures := []struct { settings domain.Settings expectedErr string @@ -250,6 +256,15 @@ func testValidateSettings(t *testing.T) { settings: requiredVersionWithoutHeader, expectedErr: "build version header is required but no version is set", }, + { + settings: commaWalletAddr, + expectedErr: "wallet addr must not contain a comma", + }, + { + settings: commaFallbackAddr, + expectedErr: `wallet fallback addr "host:6060,evil" ` + + "must not contain a comma", + }, } for _, f := range fixtures { diff --git a/internal/infrastructure/db/postgres/settings_repo.go b/internal/infrastructure/db/postgres/settings_repo.go index 5ae47ac56..68817907d 100644 --- a/internal/infrastructure/db/postgres/settings_repo.go +++ b/internal/infrastructure/db/postgres/settings_repo.go @@ -5,7 +5,6 @@ import ( "database/sql" "errors" "fmt" - "strings" "sync" "time" @@ -14,23 +13,6 @@ import ( arklib "github.com/arkade-os/arkd/pkg/ark-lib" ) -// splitFallbackAddrs decodes the comma-separated wallet_fallback_addrs column -// back into a slice, trimming whitespace and dropping empty entries (so it -// normalizes the same way the env parser does); an empty result is returned as nil. -func splitFallbackAddrs(s string) []string { - parts := strings.Split(s, ",") - 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 -} - type settingsRepository struct { db *sql.DB querier *queries.Queries @@ -117,7 +99,7 @@ func (r *settingsRepository) Get(ctx context.Context) (*domain.Settings, error) BuildVersionHeaderRequired: row.BuildVersionHeaderRequired, DigestHeaderRequired: row.DigestHeaderRequired, WalletAddr: row.WalletAddr, - WalletFallbackAddrs: splitFallbackAddrs(row.WalletFallbackAddrs), + WalletFallbackAddrs: domain.DecodeFallbackAddrs(row.WalletFallbackAddrs), ScheduledSession: scheduledSession, BatchFees: domain.BatchFees{ OnchainInputFee: row.BatchOnchainInputFee, @@ -161,7 +143,7 @@ func (r *settingsRepository) Upsert( BuildVersionHeaderRequired: settings.BuildVersionHeaderRequired, DigestHeaderRequired: settings.DigestHeaderRequired, WalletAddr: settings.WalletAddr, - WalletFallbackAddrs: strings.Join(settings.WalletFallbackAddrs, ","), + WalletFallbackAddrs: domain.EncodeFallbackAddrs(settings.WalletFallbackAddrs), BatchOnchainInputFee: settings.BatchFees.OnchainInputFee, BatchOffchainInputFee: settings.BatchFees.OffchainInputFee, BatchOnchainOutputFee: settings.BatchFees.OnchainOutputFee, diff --git a/internal/infrastructure/db/postgres/settings_seed.go b/internal/infrastructure/db/postgres/settings_seed.go index 4b7f7cfcd..fee6e5ebe 100644 --- a/internal/infrastructure/db/postgres/settings_seed.go +++ b/internal/infrastructure/db/postgres/settings_seed.go @@ -5,7 +5,6 @@ import ( "database/sql" "errors" "fmt" - "strings" "time" "github.com/arkade-os/arkd/internal/core/domain" @@ -146,7 +145,7 @@ func seedParams(settings domain.Settings) queries.UpsertSettingsParams { BuildVersionHeaderRequired: settings.BuildVersionHeaderRequired, DigestHeaderRequired: settings.DigestHeaderRequired, WalletAddr: settings.WalletAddr, - WalletFallbackAddrs: strings.Join(settings.WalletFallbackAddrs, ","), + WalletFallbackAddrs: domain.EncodeFallbackAddrs(settings.WalletFallbackAddrs), BatchOnchainInputFee: settings.BatchFees.OnchainInputFee, BatchOffchainInputFee: settings.BatchFees.OffchainInputFee, BatchOnchainOutputFee: settings.BatchFees.OnchainOutputFee, diff --git a/internal/infrastructure/db/sqlite/settings_repo.go b/internal/infrastructure/db/sqlite/settings_repo.go index 4642278b6..9af703af6 100644 --- a/internal/infrastructure/db/sqlite/settings_repo.go +++ b/internal/infrastructure/db/sqlite/settings_repo.go @@ -5,7 +5,6 @@ import ( "database/sql" "errors" "fmt" - "strings" "sync" "time" @@ -14,23 +13,6 @@ import ( arklib "github.com/arkade-os/arkd/pkg/ark-lib" ) -// splitFallbackAddrs decodes the comma-separated wallet_fallback_addrs column -// back into a slice, trimming whitespace and dropping empty entries (so it -// normalizes the same way the env parser does); an empty result is returned as nil. -func splitFallbackAddrs(s string) []string { - parts := strings.Split(s, ",") - 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 -} - type settingsRepository struct { db SQLiteDB @@ -122,7 +104,7 @@ func (r *settingsRepository) Get(ctx context.Context) (*domain.Settings, error) BuildVersionHeaderRequired: row.BuildVersionHeaderRequired, DigestHeaderRequired: row.DigestHeaderRequired, WalletAddr: row.WalletAddr, - WalletFallbackAddrs: splitFallbackAddrs(row.WalletFallbackAddrs), + WalletFallbackAddrs: domain.DecodeFallbackAddrs(row.WalletFallbackAddrs), ScheduledSession: scheduledSession, BatchFees: domain.BatchFees{ OnchainInputFee: row.BatchOnchainInputFee, @@ -165,7 +147,7 @@ func (r *settingsRepository) Upsert( BuildVersionHeaderRequired: settings.BuildVersionHeaderRequired, DigestHeaderRequired: settings.DigestHeaderRequired, WalletAddr: settings.WalletAddr, - WalletFallbackAddrs: strings.Join(settings.WalletFallbackAddrs, ","), + WalletFallbackAddrs: domain.EncodeFallbackAddrs(settings.WalletFallbackAddrs), BatchOnchainInputFee: settings.BatchFees.OnchainInputFee, BatchOffchainInputFee: settings.BatchFees.OffchainInputFee, BatchOnchainOutputFee: settings.BatchFees.OnchainOutputFee, diff --git a/internal/infrastructure/db/sqlite/settings_seed.go b/internal/infrastructure/db/sqlite/settings_seed.go index 6697ccfce..fc23c6c36 100644 --- a/internal/infrastructure/db/sqlite/settings_seed.go +++ b/internal/infrastructure/db/sqlite/settings_seed.go @@ -5,7 +5,6 @@ import ( "database/sql" "errors" "fmt" - "strings" "time" "github.com/arkade-os/arkd/internal/core/domain" @@ -146,7 +145,7 @@ func seedParams(settings domain.Settings) queries.UpsertSettingsParams { BuildVersionHeaderRequired: settings.BuildVersionHeaderRequired, DigestHeaderRequired: settings.DigestHeaderRequired, WalletAddr: settings.WalletAddr, - WalletFallbackAddrs: strings.Join(settings.WalletFallbackAddrs, ","), + WalletFallbackAddrs: domain.EncodeFallbackAddrs(settings.WalletFallbackAddrs), BatchOnchainInputFee: settings.BatchFees.OnchainInputFee, BatchOffchainInputFee: settings.BatchFees.OffchainInputFee, BatchOnchainOutputFee: settings.BatchFees.OnchainOutputFee, From c4103c5083cf0a91cfaa1004588a337ac3f9ee15 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:36 -0400 Subject: [PATCH 23/34] docs: document wallet address settings in settings.md --- docs/settings.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/settings.md b/docs/settings.md index c419cba94..7714b0dd4 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -39,8 +39,10 @@ The seed runs exactly once, gated on the settings table being empty. It never re-runs and never overwrites a value an admin changed later. > **Note:** only the *settings* environment variables below follow this first-boot-only rule. -> Infrastructure variables (database, wallet/signer addresses, ports, TLS, unlocker, …) are read -> on **every** boot as usual. +> Infrastructure variables (database, signer address, ports, TLS, unlocker, …) are read on +> **every** boot as usual. The wallet addresses are an exception: like the other settings they +> are seeded on first boot and then sourced from the stored row (env is only a fallback when the +> stored value is empty). ## Seed environment variables @@ -73,6 +75,12 @@ is unset on first boot. | `ARKD_MIN_BUILD_VERSION_HEADER` | `build_version_header` | min accepted client build version, semver (e.g. `v2.3.4`); empty = no minimum | `""` | | `ARKD_MIN_BUILD_VERSION_HEADER_REQUIRED` | `build_version_header_required` | bool; if `true`, clients must send a valid `X-Build-Version` header (requires `build_version_header`) | `false` | | `ARKD_DIGEST_HEADER_REQUIRED` | `digest_header_required` | bool; if `true`, clients must send a matching `X-Digest` header | `false` | +| `ARKD_WALLET_ADDR` | `wallet_addr` | primary arkd-wallet `host:port` | - | +| `ARKD_WALLET_FALLBACK_ADDRS` | `wallet_fallback_addrs` | fallback arkd-wallet addresses, comma-separated `host:port` | - | + +The wallet addresses follow the same first-boot-only rule as the other settings: +they are read from these env vars on first boot, then the stored values win and +are changed via the admin Settings API (taking effect on the next restart). ### Notes on specific values From 16830b5f2fcad217a15c02b0c627214c838aaa67 Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:25:33 -0400 Subject: [PATCH 24/34] fraud: broadcast pre-signed forfeits without the live signer broadcastForfeitTx now reads the operator key set (current + deprecated) from the cached settings instead of calling the live signer, so a forfeit pre-signed at collection time stays broadcastable when the signer is unavailable, which is the point of signing at collection time. Deprecated keys are included so a forfeit signed before a signer-key rotation is recognized as already signed and not re-signed with the current key (which would not satisfy its old-key tapscript). The backfill tool applies the same current+deprecated check. --- internal/backfill/backfill.go | 30 ++++++++++--- internal/backfill/backfill_test.go | 38 +++++++++++++++++ internal/core/application/fraud.go | 42 +++++++++++++++---- .../core/application/service_forfeit_test.go | 8 ++-- 4 files changed, 100 insertions(+), 18 deletions(-) diff --git a/internal/backfill/backfill.go b/internal/backfill/backfill.go index 44934c5cc..586a0dd9c 100644 --- a/internal/backfill/backfill.go +++ b/internal/backfill/backfill.go @@ -15,6 +15,7 @@ import ( "strings" "github.com/arkade-os/arkd/internal/core/domain" + "github.com/arkade-os/arkd/internal/core/ports" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcutil/psbt" @@ -37,6 +38,7 @@ type ForfeitStore interface { // ports.SignerService. type Signer interface { GetPubkey(ctx context.Context) (*btcec.PublicKey, error) + GetDeprecatedPubkeys(ctx context.Context) ([]ports.DeprecatedSignerPubkey, error) SignTransactionTapscript( ctx context.Context, partialTx string, @@ -66,7 +68,19 @@ func Run( if err != nil { return Result{}, fmt.Errorf("failed to get operator pubkey: %w", err) } - operatorXOnly := schnorr.SerializePubKey(pubkey) + operatorKeys := [][]byte{schnorr.SerializePubKey(pubkey)} + + // Include deprecated keys so forfeits signed before a key rotation are + // recognized as already signed and not re-signed with the current key. + deprecated, err := signer.GetDeprecatedPubkeys(ctx) + if err != nil { + return Result{}, fmt.Errorf("failed to get deprecated operator pubkeys: %w", err) + } + for _, d := range deprecated { + if d.PubKey != nil { + operatorKeys = append(operatorKeys, schnorr.SerializePubKey(d.PubKey)) + } + } allVtxos, err := vtxos.GetAllVtxos(ctx) if err != nil { @@ -106,7 +120,7 @@ func Run( continue } - if forfeitOperatorSigned(forfeitTx, operatorXOnly) { + if forfeitOperatorSigned(forfeitTx, operatorKeys) { res.AlreadySigned++ continue } @@ -175,12 +189,16 @@ func findForfeitTx(forfeits []domain.ForfeitTx, vtxo domain.Outpoint) (*psbt.Pac } // forfeitOperatorSigned reports whether the forfeit tx already carries a tapscript -// signature from the operator (its signer key). -func forfeitOperatorSigned(ptx *psbt.Packet, operatorXOnly []byte) bool { +// signature from one of the operator's signer keys (the current key or any +// deprecated one). Deprecated keys are included so a forfeit signed before a key +// rotation is recognized as already signed and not re-signed with the current key. +func forfeitOperatorSigned(ptx *psbt.Packet, operatorXOnlyKeys [][]byte) bool { for _, in := range ptx.Inputs { for _, sig := range in.TaprootScriptSpendSig { - if bytes.Equal(sig.XOnlyPubKey, operatorXOnly) { - return true + for _, key := range operatorXOnlyKeys { + if bytes.Equal(sig.XOnlyPubKey, key) { + return true + } } } } diff --git a/internal/backfill/backfill_test.go b/internal/backfill/backfill_test.go index d66f10cb5..5769c6844 100644 --- a/internal/backfill/backfill_test.go +++ b/internal/backfill/backfill_test.go @@ -10,6 +10,7 @@ import ( "github.com/arkade-os/arkd/internal/backfill" "github.com/arkade-os/arkd/internal/core/domain" + "github.com/arkade-os/arkd/internal/core/ports" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcutil/psbt" @@ -69,6 +70,7 @@ func (f *fakeRounds) PatchForfeitTxs(_ context.Context, txByTxid map[string]stri type fakeSigner struct { pubkey *btcec.PublicKey operatorXOnly []byte + deprecated []ports.DeprecatedSignerPubkey signErr error calls int } @@ -77,6 +79,12 @@ func (f *fakeSigner) GetPubkey(_ context.Context) (*btcec.PublicKey, error) { return f.pubkey, nil } +func (f *fakeSigner) GetDeprecatedPubkeys( + _ context.Context, +) ([]ports.DeprecatedSignerPubkey, error) { + return f.deprecated, nil +} + func (f *fakeSigner) SignTransactionTapscript( _ context.Context, partialTx string, _ []int, ) (string, error) { @@ -280,3 +288,33 @@ func TestBackfillSignerErrorCountsAsFailed(t *testing.T) { require.Equal(t, 0, res.Signed) require.Empty(t, rounds.patches, "nothing persisted when signing failed") } + +func TestBackfillSkipsForfeitsSignedWithDeprecatedKey(t *testing.T) { + ctx := context.Background() + pub, xOnly := newOperator(t) // current operator key + depPub, depXOnly := newOperator(t) // a now-deprecated operator key + commitment := txid(0x11) + vtxoOp := domain.Outpoint{Txid: txid(0xaa), VOut: 0} + + // The forfeit was signed with the operator key that was active before a key + // rotation; it must be recognized as already signed, not re-signed. + forfeit := buildForfeit(t, vtxoOp, depXOnly, true) + rounds := &fakeRounds{rounds: map[string]*domain.Round{ + commitment: {CommitmentTxid: commitment, ForfeitTxs: []domain.ForfeitTx{forfeit}}, + }} + vtxos := &fakeVtxos{vtxos: []domain.Vtxo{forfeitableVtxo(vtxoOp, commitment)}} + signer := &fakeSigner{ + pubkey: pub, + operatorXOnly: xOnly, + deprecated: []ports.DeprecatedSignerPubkey{{PubKey: depPub}}, + } + + res, err := backfill.Run(ctx, vtxos, rounds, signer) + require.NoError(t, err) + + require.Equal(t, 1, res.Scanned) + require.Equal(t, 0, res.Signed) + require.Equal(t, 1, res.AlreadySigned) + require.Equal(t, 0, signer.calls, "must not re-sign a forfeit signed with a deprecated key") + require.Empty(t, rounds.patches) +} diff --git a/internal/core/application/fraud.go b/internal/core/application/fraud.go index 58a359689..e642d7ee5 100644 --- a/internal/core/application/fraud.go +++ b/internal/core/application/fraud.go @@ -170,12 +170,31 @@ func (s *service) broadcastForfeitTx(ctx context.Context, vtxo domain.Vtxo) erro // operator signature and produce an invalid PSBT (duplicate key), so we only // sign here when the operator signature is still missing (e.g. forfeit txs // collected before collection-time signing was introduced). - signedForfeitTx := forfeitTxB64 - signerPubkey, err := s.signer.GetPubkey(ctx) + // + // The operator key set is read from the cached settings, not the live signer: + // a pre-signed forfeit must stay broadcastable even when the signer is down, + // which is the whole point of signing at collection time. Deprecated keys are + // included so a forfeit signed before a key rotation is still recognized as + // signed and not re-signed with the current (wrong-for-its-tapscript) key. + settings, err := s.cache.Settings().Get(ctx) if err != nil { - return fmt.Errorf("failed to get signer pubkey: %s", err) + return fmt.Errorf("failed to get settings: %s", err) + } + if settings == nil { + return fmt.Errorf("settings not available") + } + operatorKeys := make([][]byte, 0, 1+len(settings.DeprecatedSignerPubkeys)) + if settings.SignerPubkey != nil { + operatorKeys = append(operatorKeys, schnorr.SerializePubKey(settings.SignerPubkey)) + } + for _, deprecated := range settings.DeprecatedSignerPubkeys { + if deprecated.PubKey != nil { + operatorKeys = append(operatorKeys, schnorr.SerializePubKey(deprecated.PubKey)) + } } - if !forfeitTxOperatorSigned(forfeitTx, schnorr.SerializePubKey(signerPubkey)) { + + signedForfeitTx := forfeitTxB64 + if !forfeitTxOperatorSigned(forfeitTx, operatorKeys) { signedForfeitTx, err = s.signer.SignTransactionTapscript(ctx, forfeitTxB64, nil) if err != nil { return fmt.Errorf("failed to sign forfeit tx: %s", err) @@ -431,13 +450,18 @@ func findForfeitTx( } // forfeitTxOperatorSigned reports whether the forfeit tx already carries a -// tapscript signature from the operator (its signer key), i.e. it was signed at -// collection time and must not be signed again. -func forfeitTxOperatorSigned(ptx *psbt.Packet, operatorXOnly []byte) bool { +// tapscript signature from one of the operator's signer keys (the current key or +// any deprecated one), i.e. it was signed at collection time and must not be +// signed again. Deprecated keys are included because a forfeit signed before a +// key rotation is still valid for its own (old-key) tapscript and must not be +// re-signed with the current key. +func forfeitTxOperatorSigned(ptx *psbt.Packet, operatorXOnlyKeys [][]byte) bool { for _, in := range ptx.Inputs { for _, sig := range in.TaprootScriptSpendSig { - if bytes.Equal(sig.XOnlyPubKey, operatorXOnly) { - return true + for _, key := range operatorXOnlyKeys { + if bytes.Equal(sig.XOnlyPubKey, key) { + return true + } } } } diff --git a/internal/core/application/service_forfeit_test.go b/internal/core/application/service_forfeit_test.go index 7786e6689..ff7d1db9b 100644 --- a/internal/core/application/service_forfeit_test.go +++ b/internal/core/application/service_forfeit_test.go @@ -119,13 +119,15 @@ func TestForfeitTxOperatorSigned(t *testing.T) { return p } - require.True(t, forfeitTxOperatorSigned(build(true), operatorXOnly), + require.True(t, forfeitTxOperatorSigned(build(true), [][]byte{operatorXOnly}), "must detect the operator signature") - require.False(t, forfeitTxOperatorSigned(build(false), operatorXOnly), + require.False(t, forfeitTxOperatorSigned(build(false), [][]byte{operatorXOnly}), "must report unsigned when the operator sig is absent") otherXOnly := make([]byte, 32) otherXOnly[0] = 0x09 - require.False(t, forfeitTxOperatorSigned(build(true), otherXOnly), + require.False(t, forfeitTxOperatorSigned(build(true), [][]byte{otherXOnly}), "must not match a different pubkey") + require.True(t, forfeitTxOperatorSigned(build(true), [][]byte{otherXOnly, operatorXOnly}), + "must match when any key in the set is present (e.g. a deprecated key)") } From 06aa0d8e1fd7554b39d9053a2761964c5f8a4e18 Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Tue, 16 Jun 2026 10:51:00 -0400 Subject: [PATCH 25/34] fix(db): fail loudly when PatchForfeitTxs targets a missing txid UpdateForfeitTx silently no-op'd on a txid miss (sqlite/postgres) or inserted a stray record (badger), so the forfeit backfill could report a tx as signed when nothing was written. - sqlite/postgres: UpdateForfeitTx -> :execrows; PatchForfeitTxs errors when zero rows match. - badger: Upsert -> Update so a missing txid returns ErrNotFound instead of inserting; mapped to the same not-found error. - tests: assert the not-found path on the sqlite/postgres harness and add a focused badger PatchForfeitTxs test. --- internal/infrastructure/db/badger/ark_repo.go | 9 ++++- .../infrastructure/db/badger/ark_repo_test.go | 39 +++++++++++++++++++ .../infrastructure/db/postgres/round_repo.go | 8 +++- .../db/postgres/sqlc/queries/query.sql.go | 11 ++++-- .../infrastructure/db/postgres/sqlc/query.sql | 2 +- internal/infrastructure/db/service_test.go | 5 +++ .../infrastructure/db/sqlite/round_repo.go | 8 +++- .../db/sqlite/sqlc/queries/query.sql.go | 11 ++++-- .../infrastructure/db/sqlite/sqlc/query.sql | 2 +- 9 files changed, 80 insertions(+), 15 deletions(-) create mode 100644 internal/infrastructure/db/badger/ark_repo_test.go diff --git a/internal/infrastructure/db/badger/ark_repo.go b/internal/infrastructure/db/badger/ark_repo.go index 85d3ff53b..0781db825 100644 --- a/internal/infrastructure/db/badger/ark_repo.go +++ b/internal/infrastructure/db/badger/ark_repo.go @@ -106,7 +106,14 @@ func (r *arkRepository) PatchForfeitTxs( ctx context.Context, txByTxid map[string]string, ) error { for txid, tx := range txByTxid { - if err := r.store.Upsert(txid, Tx{Txid: txid, Tx: tx}); err != nil { + // Update (not Upsert) so a missing txid fails loudly instead of inserting a + // stray record, matching the SQL backends' "UPDATE ... WHERE txid = ?" guard. + // Badger keys txs by txid alone (no type column), so unlike SQL this can't + // also assert type = 'forfeit'; that's safe because txids are globally unique. + if err := r.store.Update(txid, Tx{Txid: txid, Tx: tx}); err != nil { + if errors.Is(err, badgerhold.ErrNotFound) { + return fmt.Errorf("forfeit tx %s not found", txid) + } return fmt.Errorf("failed to patch forfeit tx %s: %w", txid, err) } } diff --git a/internal/infrastructure/db/badger/ark_repo_test.go b/internal/infrastructure/db/badger/ark_repo_test.go new file mode 100644 index 000000000..63630c857 --- /dev/null +++ b/internal/infrastructure/db/badger/ark_repo_test.go @@ -0,0 +1,39 @@ +package badgerdb + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "github.com/timshannon/badgerhold/v4" +) + +func TestArkRepositoryPatchForfeitTxs(t *testing.T) { + repo, err := NewArkRepository(t.TempDir(), nil) + require.NoError(t, err) + + r, ok := repo.(*arkRepository) + require.True(t, ok) + t.Cleanup(r.Close) + + ctx := context.Background() + const ( + txid = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + missing = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ) + + // Seed an existing forfeit tx. + require.NoError(t, r.store.Upsert(txid, Tx{Txid: txid, Tx: "old"})) + + // Patching an existing txid updates it in place. + require.NoError(t, repo.PatchForfeitTxs(ctx, map[string]string{txid: "new"})) + var got Tx + require.NoError(t, r.store.Get(txid, &got)) + require.Equal(t, "new", got.Tx) + + // Patching an unknown txid fails loudly instead of inserting a stray record, + // matching the SQL backends' "UPDATE ... WHERE txid = ?" no-match behavior. + err = repo.PatchForfeitTxs(ctx, map[string]string{missing: "new"}) + require.ErrorContains(t, err, "not found") + require.ErrorIs(t, r.store.Get(missing, &got), badgerhold.ErrNotFound) +} diff --git a/internal/infrastructure/db/postgres/round_repo.go b/internal/infrastructure/db/postgres/round_repo.go index 700e58b72..a242a4393 100644 --- a/internal/infrastructure/db/postgres/round_repo.go +++ b/internal/infrastructure/db/postgres/round_repo.go @@ -504,12 +504,16 @@ func (r *roundRepository) PatchForfeitTxs( ) error { txBody := func(querierWithTx *queries.Queries) error { for txid, tx := range txByTxid { - if err := querierWithTx.UpdateForfeitTx( + affectedRows, err := querierWithTx.UpdateForfeitTx( ctx, queries.UpdateForfeitTxParams{Tx: tx, Txid: txid}, - ); err != nil { + ) + if err != nil { return fmt.Errorf("failed to patch forfeit tx %s: %w", txid, err) } + if affectedRows == 0 { + return fmt.Errorf("forfeit tx %s not found", txid) + } } return nil } diff --git a/internal/infrastructure/db/postgres/sqlc/queries/query.sql.go b/internal/infrastructure/db/postgres/sqlc/queries/query.sql.go index 34282aee4..75dc5136c 100644 --- a/internal/infrastructure/db/postgres/sqlc/queries/query.sql.go +++ b/internal/infrastructure/db/postgres/sqlc/queries/query.sql.go @@ -1892,7 +1892,7 @@ func (q *Queries) UpdateConvictionPardoned(ctx context.Context, id string) error return err } -const updateForfeitTx = `-- name: UpdateForfeitTx :exec +const updateForfeitTx = `-- name: UpdateForfeitTx :execrows UPDATE tx SET tx = $1 WHERE txid = $2 AND type = 'forfeit' ` @@ -1901,9 +1901,12 @@ type UpdateForfeitTxParams struct { Txid string } -func (q *Queries) UpdateForfeitTx(ctx context.Context, arg UpdateForfeitTxParams) error { - _, err := q.db.ExecContext(ctx, updateForfeitTx, arg.Tx, arg.Txid) - return err +func (q *Queries) UpdateForfeitTx(ctx context.Context, arg UpdateForfeitTxParams) (int64, error) { + result, err := q.db.ExecContext(ctx, updateForfeitTx, arg.Tx, arg.Txid) + if err != nil { + return 0, err + } + return result.RowsAffected() } const updateRoundCollectedFees = `-- name: UpdateRoundCollectedFees :exec diff --git a/internal/infrastructure/db/postgres/sqlc/query.sql b/internal/infrastructure/db/postgres/sqlc/query.sql index 327d28fef..e9b20c348 100644 --- a/internal/infrastructure/db/postgres/sqlc/query.sql +++ b/internal/infrastructure/db/postgres/sqlc/query.sql @@ -411,7 +411,7 @@ SELECT * FROM asset WHERE asset.id = ANY($1::varchar[]); -- name: UpdateRoundCollectedFees :exec UPDATE round SET fees = @fees WHERE id = @id; --- name: UpdateForfeitTx :exec +-- name: UpdateForfeitTx :execrows UPDATE tx SET tx = @tx WHERE txid = @txid AND type = 'forfeit'; -- name: SelectAssetsWithUnspentAmountsByIds :many diff --git a/internal/infrastructure/db/service_test.go b/internal/infrastructure/db/service_test.go index da957bdfb..46a034fa2 100644 --- a/internal/infrastructure/db/service_test.go +++ b/internal/infrastructure/db/service_test.go @@ -763,6 +763,11 @@ func testRoundRepository(t *testing.T, svc ports.RepoManager) { require.Equal(t, f3, byTxid[txida], "txida forfeit tx should be patched") require.Equal(t, f4, byTxid[txidb], "txidb forfeit tx should be patched") require.Equal(t, f3, byTxid[untouchedTxid], "unpatched forfeit tx must be unchanged") + + // Patching an unknown txid must fail loudly rather than silently no-op, + // so the backfill never reports a forfeit as signed when nothing was written. + err = repo.PatchForfeitTxs(ctx, map[string]string{randomString(32): f3}) + require.ErrorContains(t, err, "not found") }) } diff --git a/internal/infrastructure/db/sqlite/round_repo.go b/internal/infrastructure/db/sqlite/round_repo.go index 97a4e9378..4a10b6408 100644 --- a/internal/infrastructure/db/sqlite/round_repo.go +++ b/internal/infrastructure/db/sqlite/round_repo.go @@ -631,12 +631,16 @@ func (r *roundRepository) PatchForfeitTxs( ) error { txBody := func(querierWithTx *queries.Queries) error { for txid, tx := range txByTxid { - if err := querierWithTx.UpdateForfeitTx( + affectedRows, err := querierWithTx.UpdateForfeitTx( ctx, queries.UpdateForfeitTxParams{Tx: tx, Txid: txid}, - ); err != nil { + ) + if err != nil { return fmt.Errorf("failed to patch forfeit tx %s: %w", txid, err) } + if affectedRows == 0 { + return fmt.Errorf("forfeit tx %s not found", txid) + } } return nil } diff --git a/internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go b/internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go index be89940b3..b241ce0a3 100644 --- a/internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go +++ b/internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go @@ -2029,7 +2029,7 @@ func (q *Queries) UpdateConvictionPardoned(ctx context.Context, id string) error return err } -const updateForfeitTx = `-- name: UpdateForfeitTx :exec +const updateForfeitTx = `-- name: UpdateForfeitTx :execrows UPDATE tx SET tx = ?1 WHERE txid = ?2 AND type = 'forfeit' ` @@ -2038,9 +2038,12 @@ type UpdateForfeitTxParams struct { Txid string } -func (q *Queries) UpdateForfeitTx(ctx context.Context, arg UpdateForfeitTxParams) error { - _, err := q.db.ExecContext(ctx, updateForfeitTx, arg.Tx, arg.Txid) - return err +func (q *Queries) UpdateForfeitTx(ctx context.Context, arg UpdateForfeitTxParams) (int64, error) { + result, err := q.db.ExecContext(ctx, updateForfeitTx, arg.Tx, arg.Txid) + if err != nil { + return 0, err + } + return result.RowsAffected() } const updateRoundCollectedFees = `-- name: UpdateRoundCollectedFees :exec diff --git a/internal/infrastructure/db/sqlite/sqlc/query.sql b/internal/infrastructure/db/sqlite/sqlc/query.sql index 1dc1ad9f7..e5d4d6821 100644 --- a/internal/infrastructure/db/sqlite/sqlc/query.sql +++ b/internal/infrastructure/db/sqlite/sqlc/query.sql @@ -419,7 +419,7 @@ VALUES (@asset_id, @txid, @vout, @amount); -- name: UpdateRoundCollectedFees :exec UPDATE round SET fees = sqlc.arg('fees') WHERE id = sqlc.arg('id'); --- name: UpdateForfeitTx :exec +-- name: UpdateForfeitTx :execrows UPDATE tx SET tx = sqlc.arg('tx') WHERE txid = sqlc.arg('txid') AND type = 'forfeit'; -- name: SelectAssetsByIds :many From 98f24639d75d390de9dc017e86635c4d550ca2e5 Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:54:48 -0400 Subject: [PATCH 26/34] fix(config): identify fallback wallet by host:port in readiness errors Pair each dialed fallback wallet with its address (FallbackWallet) so ensureWalletReady reports the specific wallet that failed (host:port) instead of a positional index, which isn't actionable when the error surfaces. Also clarify the comment on why fallback balance isn't checked. --- internal/config/config.go | 22 +++++++++++++++------- internal/interface/grpc/service.go | 22 +++++++++++----------- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 23c788c02..0f2339db6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -151,7 +151,7 @@ type Config struct { svc application.Service adminSvc application.AdminService wallet ports.WalletService - walletFallbacks []ports.WalletService + walletFallbacks []FallbackWallet signer ports.SignerService txBuilder ports.TxBuilder scanner ports.BlockchainScanner @@ -668,7 +668,15 @@ func (c *Config) WalletService() ports.WalletService { return c.wallet } -func (c *Config) FallbackWalletServices() []ports.WalletService { +// 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 } @@ -840,8 +848,8 @@ func (c *Config) walletService() error { // 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() ([]ports.WalletService, error) { - fallbacks := make([]ports.WalletService, 0, len(c.WalletFallbackAddrs)) +func (c *Config) dialFallbackWallets() ([]FallbackWallet, error) { + fallbacks := make([]FallbackWallet, 0, len(c.WalletFallbackAddrs)) for _, addr := range c.WalletFallbackAddrs { if addr == "" { continue @@ -860,14 +868,14 @@ func (c *Config) dialFallbackWallets() ([]ports.WalletService, error) { ) } log.Infof("dialed fallback wallet %q on network %s", addr, fbNetwork.Name) - fallbacks = append(fallbacks, fbSvc) + fallbacks = append(fallbacks, FallbackWallet{Addr: addr, Service: fbSvc}) } return fallbacks, nil } -func closeWallets(wallets []ports.WalletService) { +func closeWallets(wallets []FallbackWallet) { for _, w := range wallets { - w.Close() + w.Service.Close() } } diff --git a/internal/interface/grpc/service.go b/internal/interface/grpc/service.go index ad1d073ce..af22adc1d 100644 --- a/internal/interface/grpc/service.go +++ b/internal/interface/grpc/service.go @@ -235,8 +235,8 @@ func (s *service) stop() { // 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.FallbackWalletServices() { - fb.Close() + for _, fb := range s.appConfig.FallbackWallets() { + fb.Service.Close() } } @@ -690,25 +690,25 @@ func (s *service) ensureWalletReady() error { } // Fallback wallets must also be initialized and unlocked out of band: they - // are needed to sign sweeps when the primary cannot. Balance is not checked - // (they are not liquidity sources). - for i, fb := range s.appConfig.FallbackWalletServices() { - fbStatus, err := fb.Status(ctx) + // 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 %d status: %s", i, err) + return fmt.Errorf("failed to get fallback wallet %q status: %s", fb.Addr, err) } if !fbStatus.IsInitialized() { return fmt.Errorf( - "fallback wallet %d is not initialized: "+ + "fallback wallet %q is not initialized: "+ "initialize the arkd-wallet out of band before starting arkd", - i, + fb.Addr, ) } if !fbStatus.IsUnlocked() { return fmt.Errorf( - "fallback wallet %d is locked: "+ + "fallback wallet %q is locked: "+ "unlock the arkd-wallet out of band before starting arkd", - i, + fb.Addr, ) } } From 2738e6585fb90de6ba31b20e6679b214db3ba134 Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:58:32 -0400 Subject: [PATCH 27/34] docs: note fallback wallets are not yet used for sweeps On this branch arkd only dials and readiness-checks fallback wallets at startup; sweep signing isn't wired up yet. Reword the docker-compose and dialFallbackWallets comments so they don't imply active sweep-fallback use. --- docker-compose.regtest.yml | 5 +++-- internal/config/config.go | 9 +++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docker-compose.regtest.yml b/docker-compose.regtest.yml index 5303d65a4..62105c337 100644 --- a/docker-compose.regtest.yml +++ b/docker-compose.regtest.yml @@ -61,8 +61,9 @@ 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. + # 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: diff --git a/internal/config/config.go b/internal/config/config.go index 0f2339db6..ef9b2ce80 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -844,10 +844,11 @@ func (c *Config) walletService() error { // 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. +// 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)) for _, addr := range c.WalletFallbackAddrs { From ba566dab55c9003cfd44842451352578d227cc5c Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:08:05 -0400 Subject: [PATCH 28/34] feat(config): reject fallback wallet that duplicates primary or another fallback Fail fast at dial time if a fallback address equals the primary WalletAddr or repeats another fallback, instead of silently dialing the same wallet twice (pointless now, double-spend/lock-contention risk once fallbacks sign sweeps). Matching is by literal address. --- internal/config/config.go | 12 ++++++++++ internal/config/config_test.go | 44 ++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index ef9b2ce80..f08086449 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -851,10 +851,22 @@ func (c *Config) walletService() error { // 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) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 779796289..44e2b882e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -366,4 +366,48 @@ func TestDialFallbackWallets(t *testing.T) { // 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) + }) } 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 29/34] 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 4e9d5403b9d7e7f57b3bf72d9d50a26c0acbecd0 Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Wed, 17 Jun 2026 18:35:30 -0400 Subject: [PATCH 30/34] docs: clarify wallet-address env/settings hybrid behavior The previous note read as a contradiction ('an exception: like the other settings'). Spell out that wallet addresses are seeded+persisted like settings but, unlike other settings, the env var stays a fallback on every boot when the stored value is empty, which is what keeps upgrades (empty migrated columns) working. --- docs/settings.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/settings.md b/docs/settings.md index 7714b0dd4..215184f76 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -40,9 +40,14 @@ It never re-runs and never overwrites a value an admin changed later. > **Note:** only the *settings* environment variables below follow this first-boot-only rule. > Infrastructure variables (database, signer address, ports, TLS, unlocker, …) are read on -> **every** boot as usual. The wallet addresses are an exception: like the other settings they -> are seeded on first boot and then sourced from the stored row (env is only a fallback when the -> stored value is empty). +> **every** boot as usual. +> +> The wallet addresses (`ARKD_WALLET_ADDR` / `ARKD_WALLET_FALLBACK_ADDRS`) are a hybrid. Like +> settings, they're seeded on first boot, persisted, and normally read from the stored row (and +> changed via the admin API). But unlike other settings — whose env vars are ignored after first +> boot — the wallet env vars are still honored on **every** boot as a fallback whenever the stored +> value is empty. This keeps upgrades safe: the migration adds the new columns empty, so a node +> falls back to its env-configured address until an admin sets it. ## Seed environment variables From 368bd808279f841dcc0060c83d30ded67bbc9da0 Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Wed, 17 Jun 2026 18:38:19 -0400 Subject: [PATCH 31/34] refactor: trim wallet-address settings comments; drop restart warning Trim verbose comments across the wallet-address settings code (proto, config walletService, domain Settings, admin UpdateSettings, adminservice parse) and remove the UpdateSettings log that warned wallet-address changes apply on the next restart. --- api-spec/protobuf/ark/v1/admin.proto | 2 -- internal/config/config.go | 15 +++------------ internal/core/application/admin.go | 16 ++-------------- internal/core/domain/settings.go | 9 +++------ internal/interface/grpc/handlers/adminservice.go | 8 ++------ 5 files changed, 10 insertions(+), 40 deletions(-) diff --git a/api-spec/protobuf/ark/v1/admin.proto b/api-spec/protobuf/ark/v1/admin.proto index 23be66816..739b2467c 100644 --- a/api-spec/protobuf/ark/v1/admin.proto +++ b/api-spec/protobuf/ark/v1/admin.proto @@ -447,8 +447,6 @@ message Settings { optional bool build_version_header_required = 23; optional string updated_at = 24; optional bool digest_header_required = 25; - // Primary and fallback arkd-wallet connection addresses. Seeded from env on - // first boot; changes take effect on the next restart. optional string wallet_addr = 26; repeated string wallet_fallback_addrs = 27; } diff --git a/internal/config/config.go b/internal/config/config.go index 0ddfaad34..1ba1a6157 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -831,13 +831,9 @@ func (c *Config) repoManager() error { var newWalletClient = walletclient.New func (c *Config) walletService() error { - // The wallet addresses are sourced from the persisted settings (seeded from - // env on first boot), so admin changes take effect on the next restart. Fall - // back to env if settings aren't available yet (e.g. the repo isn't wired). - // - // Trust boundary: the wallet gRPC target controls signing, address derivation - // and sweeps, so persisting it via the admin Settings API is equivalent to full - // operator (admin macaroon) trust — the same trust needed to change the env var. + // Wallet addresses are sourced from the persisted settings (seeded from + // env on first boot) so admin changes take effect on the next restart. Fall + // back to env if settings aren't available yet. arkWallet := c.WalletAddr fallbackAddrs := c.WalletFallbackAddrs if c.repo != nil { @@ -846,9 +842,6 @@ func (c *Config) walletService() error { case err != nil: log.Warnf("failed to read settings for wallet addresses, using env: %v", err) case settings != nil: - // Only override env when the settings actually carry a value, so an - // upgrade (whose migrated row defaults these to empty) keeps the - // env-configured addresses until they're set through the admin API. if settings.WalletAddr != "" { arkWallet = settings.WalletAddr } @@ -870,8 +863,6 @@ func (c *Config) walletService() error { c.wallet = walletSvc c.network = network - // Surface the effective wallet target so operators can audit which arkd-wallet - // this node dialed (it may come from the DB, not the env var). log.Infof("dialed primary arkd-wallet at %q on network %s", arkWallet, network.Name) fallbacks, err := c.dialFallbackWallets(arkWallet, fallbackAddrs) diff --git a/internal/core/application/admin.go b/internal/core/application/admin.go index eb921f14c..34f0d88d7 100644 --- a/internal/core/application/admin.go +++ b/internal/core/application/admin.go @@ -642,9 +642,8 @@ func (a *adminService) UpdateSettings( a.settingsMu.Lock() defer a.settingsMu.Unlock() - // Partial update: only the fields set on the request (non-nil pointers) are - // applied to the stored settings; omitted fields are left unchanged. The - // returned changelog lists exactly the fields that were updated. + // Partial updates allowed. Only the fields set on the request (non-nil pointers) are + // applied to the stored settings and omitted fields are left unchanged. settings, err := a.repoManager.Settings().Get(ctx) if err != nil { return nil, fmt.Errorf("failed to get current settings: %w", err) @@ -662,17 +661,6 @@ func (a *adminService) UpdateSettings( return nil, fmt.Errorf("failed to update settings: %w", err) } - // Wallet addresses are read at startup to dial the arkd-wallet(s); a change to - // them is persisted now but only takes effect on the next restart. - for _, field := range changelog { - if field == "wallet_addr" || field == "wallet_fallback_addrs" { - log.Warnf( - "settings field %q updated; wallet address changes take effect on restart", - field, - ) - } - } - return changelog, nil } diff --git a/internal/core/domain/settings.go b/internal/core/domain/settings.go index 591d18fb6..260530067 100644 --- a/internal/core/domain/settings.go +++ b/internal/core/domain/settings.go @@ -63,12 +63,9 @@ type Settings struct { BuildVersionHeader string BuildVersionHeaderRequired bool DigestHeaderRequired bool - // WalletAddr / WalletFallbackAddrs hold the primary and fallback arkd-wallet - // connection addresses. Seeded from env on first boot, then sourced from here; - // changing them via the admin API is applied on the next restart. - WalletAddr string - WalletFallbackAddrs []string - UpdatedAt time.Time + WalletAddr string + WalletFallbackAddrs []string + UpdatedAt time.Time } func NewSettings( diff --git a/internal/interface/grpc/handlers/adminservice.go b/internal/interface/grpc/handlers/adminservice.go index 43e108bce..7852d8edd 100644 --- a/internal/interface/grpc/handlers/adminservice.go +++ b/internal/interface/grpc/handlers/adminservice.go @@ -866,6 +866,8 @@ func parseSettings(settings *arkv1.Settings) (*domain.SettingsUpdate, error) { noteUriPrefix *string buildVersionHeader *string buildVersionHeaderRequired, digestHeaderRequired *bool + walletAddr *string + walletFallbackAddrs *[]string ) if settings.BanThreshold != nil { t := uint64(settings.GetBanThreshold()) @@ -923,16 +925,10 @@ func parseSettings(settings *arkv1.Settings) (*domain.SettingsUpdate, error) { t := settings.GetDigestHeaderRequired() digestHeaderRequired = &t } - var walletAddr *string if settings.WalletAddr != nil { t := settings.GetWalletAddr() walletAddr = &t } - // wallet_fallback_addrs is a repeated field with no presence, so an empty list - // is indistinguishable from "not provided" and is treated as no-change. The - // fallback list can be replaced but not cleared via the API; clear it by - // reconfiguring env and re-seeding. - var walletFallbackAddrs *[]string if len(settings.WalletFallbackAddrs) > 0 { t := settings.GetWalletFallbackAddrs() walletFallbackAddrs = &t 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 32/34] 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 From 77306d1f59fb39b446f6479a510fa518dafdc5d0 Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:53:22 -0400 Subject: [PATCH 33/34] fix(arkd-wallet): select signer key per leaf across all multisig closures Introspect CSV and condition-CSV closures too so deprecated-key sweeps use the right key, and error when a required leaf references none of the wallet's keys instead of silently signing with the current one. Also stop dereferencing keyMgr in signer mode. --- .../core/application/wallet/service.go | 89 ++++++++++++++----- .../application/wallet/signer_keys_test.go | 69 ++++++++++++++ 2 files changed, 137 insertions(+), 21 deletions(-) diff --git a/pkg/arkd-wallet/core/application/wallet/service.go b/pkg/arkd-wallet/core/application/wallet/service.go index fdb5fd668..035a24a50 100644 --- a/pkg/arkd-wallet/core/application/wallet/service.go +++ b/pkg/arkd-wallet/core/application/wallet/service.go @@ -669,9 +669,17 @@ func (w *wallet) SignTransaction( } if len(input.TaprootLeafScript) > 0 { - signingKey := w.keyMgr.forfeitPrvkey + var signingKey *btcec.PrivateKey if signMode == application.SignModeSigner { - signingKey = w.signerKeyForLeaf(input.TaprootLeafScript[0].Script) + leafKey, err := w.signerKeyForLeaf( + input.TaprootLeafScript[0].Script, len(inputIndexes) > 0, + ) + if err != nil { + return "", err + } + signingKey = leafKey + } else { + signingKey = w.keyMgr.forfeitPrvkey } tapLeaf := txscript.NewBaseTapLeaf(input.TaprootLeafScript[0].Script) @@ -786,38 +794,77 @@ func (w *wallet) SignTransaction( return ptx.B64Encode() } -// signerKeyForLeaf returns the deprecated signer key referenced by the leaf, or the current SignerKey. -func (w *wallet) signerKeyForLeaf(leafScript []byte) *btcec.PrivateKey { - if len(w.DeprecatedSignerKeys) == 0 { - return w.SignerKey +// signerKeyForLeaf returns the wallet's signer key that must sign the given +// tapscript leaf: the current SignerKey or a deprecated one, whichever the leaf's +// multisig closure references. +// +// required is set when the caller explicitly asked for this input to be signed. In +// that case a leaf that references none of the wallet's keys is a hard error, +// usually because a signer key was rotated without being retained as a deprecated +// key, rather than silently signing with the wrong key and failing later at +// finalize. Best-effort callers (required false) fall back to the current key so a +// redundant signature on a leaf the wallet is not part of stays harmless. Leaves +// whose closure cannot be introspected also fall back to the current key. +func (w *wallet) signerKeyForLeaf(leafScript []byte, required bool) (*btcec.PrivateKey, error) { + leafKeys, ok := multisigClosureKeys(leafScript) + if !ok { + return w.SignerKey, nil + } + + if keyInLeaf(w.SignerKey, leafKeys) { + return w.SignerKey, nil + } + for _, k := range w.DeprecatedSignerKeys { + if keyInLeaf(k.Key, leafKeys) { + return k.Key, nil + } } + if required { + return nil, fmt.Errorf( + "no signer key for tapscript leaf: it references none of the wallet's keys " + + "(current or deprecated); a rotated signer key may not have been retained " + + "as a deprecated key", + ) + } + return w.SignerKey, nil +} + +// multisigClosureKeys returns the public keys of a multisig-bearing closure and +// whether the leaf could be decoded as one. +func multisigClosureKeys(leafScript []byte) ([]*btcec.PublicKey, bool) { closure, err := script.DecodeClosure(leafScript) if err != nil { - return w.SignerKey + return nil, false } - - leafKeys := make([]*btcec.PublicKey, 0) switch c := closure.(type) { case *script.MultisigClosure: - leafKeys = c.PubKeys + return c.PubKeys, true + case *script.CSVMultisigClosure: + return c.PubKeys, true case *script.CLTVMultisigClosure: - leafKeys = c.PubKeys + return c.PubKeys, true case *script.ConditionMultisigClosure: - leafKeys = c.PubKeys + return c.PubKeys, true + case *script.ConditionCSVMultisigClosure: + return c.PubKeys, true default: - return w.SignerKey + return nil, false } - - for _, k := range w.DeprecatedSignerKeys { - want := schnorr.SerializePubKey(k.Key.PubKey()) - for _, pubkey := range leafKeys { - if bytes.Equal(schnorr.SerializePubKey(pubkey), want) { - return k.Key - } +} + +// keyInLeaf reports whether key is one of the leaf's multisig public keys. +func keyInLeaf(key *btcec.PrivateKey, leafKeys []*btcec.PublicKey) bool { + if key == nil { + return false + } + want := schnorr.SerializePubKey(key.PubKey()) + for _, pubkey := range leafKeys { + if bytes.Equal(schnorr.SerializePubKey(pubkey), want) { + return true } } - return w.SignerKey + return false } // WithdrawAll withdraws all available balance including connectors account funds diff --git a/pkg/arkd-wallet/core/application/wallet/signer_keys_test.go b/pkg/arkd-wallet/core/application/wallet/signer_keys_test.go index 89fd99873..5af8524d1 100644 --- a/pkg/arkd-wallet/core/application/wallet/signer_keys_test.go +++ b/pkg/arkd-wallet/core/application/wallet/signer_keys_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + arklib "github.com/arkade-os/arkd/pkg/ark-lib" "github.com/arkade-os/arkd/pkg/ark-lib/script" "github.com/arkade-os/arkd/pkg/arkd-wallet/core/application" "github.com/btcsuite/btcd/btcec/v2" @@ -132,3 +133,71 @@ func leafScript(t *testing.T, owner, signer *btcec.PublicKey) []byte { require.NoError(t, err) return s } + +// TestSignerKeyForLeaf covers the key selection used when signing tapscript leaves: +// the wallet signs with whichever of its keys (current or deprecated) the leaf +// references, including CSV (sweep) leaves; a required leaf that references none of +// the wallet's keys is a hard error instead of a silent wrong-key signature. +func TestSignerKeyForLeaf(t *testing.T) { + mustKey := func() *btcec.PrivateKey { + k, err := btcec.NewPrivateKey() + require.NoError(t, err) + return k + } + current, deprecated, user, stranger := mustKey(), mustKey(), mustKey(), mustKey() + + multisigLeaf := func(keys ...*btcec.PublicKey) []byte { + s, err := (&script.MultisigClosure{ + PubKeys: keys, Type: script.MultisigTypeChecksig, + }).Script() + require.NoError(t, err) + return s + } + csvLeaf := func(keys ...*btcec.PublicKey) []byte { + s, err := (&script.CSVMultisigClosure{ + MultisigClosure: script.MultisigClosure{ + PubKeys: keys, Type: script.MultisigTypeChecksig, + }, + Locktime: arklib.RelativeLocktime{Type: arklib.LocktimeTypeBlock, Value: 144}, + }).Script() + require.NoError(t, err) + return s + } + pub := func(k *btcec.PrivateKey) []byte { return schnorr.SerializePubKey(k.PubKey()) } + + w := &wallet{WalletOptions: WalletOptions{ + SignerKey: current, + DeprecatedSignerKeys: []DeprecatedSignerKey{{Key: deprecated}}, + }} + + t.Run("current key in multisig leaf", func(t *testing.T) { + key, err := w.signerKeyForLeaf(multisigLeaf(user.PubKey(), current.PubKey()), true) + require.NoError(t, err) + require.Equal(t, pub(current), pub(key)) + }) + + // regression: CSV (sweep) leaves must be introspected so an old-key sweep is + // signed with the deprecated key rather than the current one. + t.Run("deprecated key in csv sweep leaf", func(t *testing.T) { + key, err := w.signerKeyForLeaf(csvLeaf(deprecated.PubKey()), true) + require.NoError(t, err) + require.Equal(t, pub(deprecated), pub(key)) + }) + + t.Run("required leaf with no held key errors", func(t *testing.T) { + _, err := w.signerKeyForLeaf(multisigLeaf(user.PubKey(), stranger.PubKey()), true) + require.ErrorContains(t, err, "no signer key for tapscript leaf") + }) + + t.Run("best-effort leaf with no held key falls back to current", func(t *testing.T) { + key, err := w.signerKeyForLeaf(multisigLeaf(user.PubKey(), stranger.PubKey()), false) + require.NoError(t, err) + require.Equal(t, pub(current), pub(key)) + }) + + t.Run("non-multisig leaf falls back to current", func(t *testing.T) { + key, err := w.signerKeyForLeaf([]byte{0x01, 0x02, 0x03}, true) + require.NoError(t, err) + require.Equal(t, pub(current), pub(key)) + }) +} From 15a8651b4cc87bde3d9f1e4e73c8182b8b283ccb Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:56:35 -0400 Subject: [PATCH 34/34] test(e2e): add TestEagerForfeitSurvivesWalletRotation; dedup compose env Regression test proving #1110's collection-time forfeit signing lets the server punish fraud across a hard signer rotation. Also remove the duplicate ARKD_BAN_THRESHOLD that fails docker compose validation. --- docker-compose.regtest.yml | 1 - internal/test/e2e/e2e_test.go | 128 ++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 1 deletion(-) diff --git a/docker-compose.regtest.yml b/docker-compose.regtest.yml index 842589325..0d7fad4d5 100644 --- a/docker-compose.regtest.yml +++ b/docker-compose.regtest.yml @@ -140,7 +140,6 @@ services: - ARKD_REDIS_URL=${ARKD_REDIS_URL:-} - ARKD_SESSION_DURATION=${ARKD_SESSION_DURATION:-10} - ARKD_ROUND_REPORT_ENABLED=${ARKD_ROUND_REPORT_ENABLED:-true} - - ARKD_BAN_THRESHOLD=1 - ARKD_UNROLLED_VTXO_MIN_EXPIRY_MARGIN=10 volumes: diff --git a/internal/test/e2e/e2e_test.go b/internal/test/e2e/e2e_test.go index 177656671..a6a3fcab7 100644 --- a/internal/test/e2e/e2e_test.go +++ b/internal/test/e2e/e2e_test.go @@ -6756,3 +6756,131 @@ func TestDeprecatedSignerKey(t *testing.T) { require.ErrorContains(t, err, "is a deprecated key since") }) } + +// TestEagerForfeitSurvivesWalletRotation verifies that signing forfeit txs at +// collection time (this PR) closes the unpunishable-fraud window opened by a +// signer key rotation. +// +// On the pre-PR behavior the operator half of a forfeit was added lazily, at +// fraud-broadcast time; after a hard rotation (no deprecated key) the wallet +// signed the old-key forfeit leaf with the new key, the tx could not be +// finalized, and the fraud went unpunished. Here the forfeit is already +// operator-signed when it is collected (with the then-current/old key), so the +// stored tx stays broadcast-ready across the rotation: when the user +// fraudulently unrolls the forfeited vtxo the server still broadcasts the +// forfeit and claims it. +// +// The scenario is identical to the pre-PR repro (hard rotation, no deprecated +// key); only the expected outcome is inverted. +func TestEagerForfeitSurvivesWalletRotation(t *testing.T) { + const ( + oldSignerKey = "afcd3fa10f82a05fddc9574fdb13b3991b568e89cc39a72ba4401df8abef35f0" + newSignerKey = "2222222222222222222222222222222222222222222222222222222222222222" + ) + ctx := t.Context() + + // restore the old signer key (no deprecated keys) for other integration tests + t.Cleanup(func() { + require.NoError(t, recreateArkdWallet(oldSignerKey, "")) + }) + + client := setupClientWallet(t) + indexerClient := client.Indexer() + + _, arkAddr, boardingAddress, err := client.Receive(ctx) + require.NoError(t, err) + + faucetOnchain(t, boardingAddress.Address, 0.00021) + time.Sleep(5 * time.Second) + + // settle 1: board -> vtxo locked to the OLD signer key + wg := &sync.WaitGroup{} + wg.Add(1) + go func() { + defer wg.Done() + vtxos, err := client.NotifyIncomingFunds(ctx, arkAddr.Address) + require.NoError(t, err) + require.NotNil(t, vtxos) + }() + res, err := client.Settle(ctx) + require.NoError(t, err) + require.NotNil(t, res) + require.NotEmpty(t, res.CommitmentTxid) + wg.Wait() + time.Sleep(5 * time.Second) + + // settle 2: the first vtxo is forfeited. Its forfeit tx is collected AND + // operator-signed here, while the OLD key is still current, so the stored + // forfeit is broadcast-ready. + wg.Add(1) + go func() { + defer wg.Done() + vtxos, err := client.NotifyIncomingFunds(ctx, arkAddr.Address) + require.NoError(t, err) + require.NotNil(t, vtxos) + }() + _, err = client.Settle(ctx) + require.NoError(t, err) + wg.Wait() + time.Sleep(time.Second) + + // the forfeited vtxo: spent, confirmed (non-preconfirmed), from commitment 1 + _, spentVtxos, err := client.ListVtxos(ctx) + require.NoError(t, err) + require.NotEmpty(t, spentVtxos) + + var vtxo types.Vtxo + for _, v := range spentVtxos { + if !v.Preconfirmed && v.CommitmentTxids[0] == res.CommitmentTxid { + vtxo = v + break + } + } + require.NotEmpty(t, vtxo.Txid) + + // hard rotation: new signer key, NO deprecated key. The forfeit was already + // signed with the old key at collection time, so it must remain broadcastable. + require.NoError(t, recreateArkdWallet(newSignerKey, "")) + + // confirm the rotation is a clean switch: no deprecated keys remain + info, err := client.Client().GetInfo(ctx) + require.NoError(t, err) + require.Empty(t, info.DeprecatedSignerPubKeys) + + explorer, err := mempoolexplorer.NewExplorer( + "http://localhost:3000", arklib.BitcoinRegTest, + mempoolexplorer.WithTracker(false), + ) + require.NoError(t, err) + + // fraud: unroll the already-forfeited vtxo onchain + branch, err := redemption.NewRedeemBranch(ctx, explorer, indexerClient, vtxo) + require.NoError(t, err) + leafTx, err := branch.NextRedeemTx() + require.NoError(t, err) + require.NotEmpty(t, leafTx) + + bumpAndBroadcastTx(t, leafTx, explorer) + time.Sleep(5 * time.Second) + + // the vtxo is now unrolled and unspent in the mempool + spentStatus, err := explorer.GetTxOutspends(vtxo.Txid) + require.NoError(t, err) + require.GreaterOrEqual(t, len(spentStatus), int(vtxo.VOut)) + require.False(t, spentStatus[vtxo.VOut].Spent) + + require.NoError(t, generateBlocks(1)) + + // give the server time to react to the fraud + time.Sleep(8 * time.Second) + + // the pre-signed forfeit survives the rotation: the server broadcast it and + // claimed the unrolled vtxo. Fraud is punished. + spentStatus, err = explorer.GetTxOutspends(vtxo.Txid) + require.NoError(t, err) + require.NotEmpty(t, spentStatus) + require.True(t, spentStatus[vtxo.VOut].Spent, + "forfeit signed at collection time must stay broadcastable across a hard "+ + "signer rotation, letting the server punish the fraud") + require.NotEmpty(t, spentStatus[vtxo.VOut].SpentBy) +}