Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0df7a57
db: add RoundRepository.PatchForfeitTxs for forfeit-tx backfill
Kukks Jun 14, 2026
140c1ed
arkd: sign forfeit txs at collection time so they are broadcast-ready
Kukks Jun 14, 2026
8804c1e
backfill: add arkd-forfeit-backfill tool to sign existing unswept for…
Kukks Jun 14, 2026
adf2526
build: wire arkd-forfeit-backfill into build scripts
Kukks Jun 14, 2026
a71e6d5
backfill: wrap long signatures to satisfy golines
Kukks Jun 14, 2026
dcbc0ce
fraud: skip re-signing already-signed forfeit txs to avoid duplicate-…
Kukks Jun 14, 2026
97d5541
Merge remote-tracking branch 'origin/master' into presign-forfeit-txs
bitcoin-coder-bob Jun 15, 2026
16830b5
fraud: broadcast pre-signed forfeits without the live signer
bitcoin-coder-bob Jun 15, 2026
06aa0d8
fix(db): fail loudly when PatchForfeitTxs targets a missing txid
bitcoin-coder-bob Jun 16, 2026
f38c739
test(e2e): verify eager forfeit survives a hard signer-key rotation
bitcoin-coder-bob Jun 19, 2026
1ff85b3
Merge remote-tracking branch 'origin/master' into presign-forfeit-txs
bitcoin-coder-bob Aug 25, 2026
37fd692
forfeit: reject planted operator sigs, decide readiness from the leaf
bitcoin-coder-bob Aug 25, 2026
558b9f6
test(backfill): tests on top as TestBackfill subtests, scaffolding last
bitcoin-coder-bob Aug 25, 2026
d8a4906
comment fixes, backfill script logging
bitcoin-coder-bob Aug 25, 2026
7fbdef7
forfeit: require the sig to commit to the leaf, honour cancellation
bitcoin-coder-bob Aug 25, 2026
97e2ab1
Drop the forfeit backfill tool and the repo method it needed
bitcoin-coder-bob Aug 27, 2026
d74cd77
move some tests to domain
louisinger Aug 28, 2026
4d04577
revert query.sql files changes
louisinger Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions cmd/arkd-forfeit-backfill/main.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
188 changes: 188 additions & 0 deletions internal/backfill/backfill.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
// 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
}
Loading
Loading