Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
198 changes: 198 additions & 0 deletions internal/core/application/forfeit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
package application

import (
"context"
"errors"
"strings"
"testing"

"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"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"
"github.com/stretchr/testify/require"
)

func TestForfeitTxs(t *testing.T) {
ctx := t.Context()

t.Run("sign at collection time", func(t *testing.T) {
// 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)
})
})

t.Run("reject submissions carrying operator signatures", func(t *testing.T) {
operatorKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
operatorPub := operatorKey.PubKey()

newService := func() *service {
return &service{cache: testLiveStore{settings: testSettingsStore{
settings: &ports.Settings{SignerPubkey: operatorPub},
}}}
}

t.Run("rejects a tapscript sig planted under an operator key", func(t *testing.T) {
// A forfeit carrying the operator's own key would make the
// collection-time signer append a second entry for the same
// (key, leaf) pair, producing a duplicate-key psbt that fails the
// whole round.
b64 := forfeitPsbt(t, func(p *psbt.Packet) {
p.Inputs[0].TaprootScriptSpendSig = []*psbt.TaprootScriptSpendSig{
spendSig(operatorPub, []byte{txscriptOpTrue}),
}
})

err := newService().SubmitForfeitTxs(ctx, []string{b64})

require.Error(t, err)
require.Contains(t, err.Error(), "reserved to the operator")
})

t.Run("rejects a planted key spend sig on the connector", func(t *testing.T) {
// The connector is a wallet output: a client cannot legitimately
// sign it, and a planted sig makes the signer skip it, leaving the
// forfeit unusable.
b64 := forfeitPsbt(t, func(p *psbt.Packet) {
p.Inputs[1].TaprootKeySpendSig = make([]byte, 64)
})

err := newService().SubmitForfeitTxs(ctx, []string{b64})

require.Error(t, err)
require.Contains(t, err.Error(), "reserved to the operator")
})

t.Run("rejects when no operator key is configured", func(t *testing.T) {
// SignerPubkey is nillable. With no key to compare against, the
// planted-signature check would match nothing and quietly wave every
// forfeit through, so submission has to fail instead.
s := &service{cache: testLiveStore{settings: testSettingsStore{
settings: &ports.Settings{},
}}}
b64 := forfeitPsbt(t, func(p *psbt.Packet) {
p.Inputs[0].TaprootScriptSpendSig = []*psbt.TaprootScriptSpendSig{
spendSig(operatorPub, []byte{txscriptOpTrue}),
}
})

require.Error(t, s.SubmitForfeitTxs(ctx, []string{b64}))
})
})
}

const txscriptOpTrue = 0x51

// 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
}

// forfeitPacket builds the two-input shape of a real forfeit: the vtxo at input
// 0 and the connector at input 1.
func forfeitPacket(t *testing.T) *psbt.Packet {
t.Helper()
var vtxoHash, connectorHash chainhash.Hash
vtxoHash[0] = 0xaa
connectorHash[0] = 0xcc
tx := wire.NewMsgTx(2)
tx.AddTxIn(wire.NewTxIn(&wire.OutPoint{Hash: vtxoHash, Index: 0}, nil, nil))
tx.AddTxIn(wire.NewTxIn(&wire.OutPoint{Hash: connectorHash, Index: 0}, nil, nil))
tx.AddTxOut(wire.NewTxOut(1000, []byte{txscriptOpTrue}))
p, err := psbt.NewFromUnsignedTx(tx)
require.NoError(t, err)
return p
}

func forfeitPsbt(t *testing.T, mutate func(*psbt.Packet)) string {
t.Helper()
p := forfeitPacket(t)
mutate(p)
b64, err := p.B64Encode()
require.NoError(t, err)
return b64
}

// spendSig is a well-formed tapscript spend sig entry under pubkey, committing to
// leaf. The signature bytes are never verified by the code under test.
func spendSig(pubkey *btcec.PublicKey, leaf []byte) *psbt.TaprootScriptSpendSig {
leafHash := txscript.NewBaseTapLeaf(leaf).TapHash()
return &psbt.TaprootScriptSpendSig{
XOnlyPubKey: schnorr.SerializePubKey(pubkey),
LeafHash: leafHash[:],
Signature: make([]byte, 64),
}
}
14 changes: 11 additions & 3 deletions internal/core/application/fraud.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,9 +211,17 @@ 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)
if err != nil {
return fmt.Errorf("failed to sign forfeit tx: %s", err)
// 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 a signature is still missing, as on a legacy forfeit stored
// without the operator's half.
signedForfeitTx := forfeitTxB64
if !domain.ForfeitTxReadyToBroadcast(forfeitTx) {
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)
Expand Down
81 changes: 73 additions & 8 deletions internal/core/application/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -2306,11 +2306,68 @@ func (s *service) ConfirmRegistration(ctx context.Context, intentId string) erro
return nil
}

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
Comment thread
louisinger marked this conversation as resolved.
}

// operatorXOnlyKeys returns the x-only encoding of every signer key the operator
// signs forfeit txs with: the current one and any deprecated one still accepted.
func (s *service) operatorXOnlyKeys(ctx context.Context) ([][]byte, error) {
settings, err := s.cache.Settings().Get(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get settings: %w", err)
}
if settings == nil {
return nil, fmt.Errorf("settings not available")
}

keys := make([][]byte, 0, 1+len(settings.DeprecatedSignerPubkeys))
if settings.SignerPubkey != nil {
keys = append(keys, schnorr.SerializePubKey(settings.SignerPubkey))
}
for _, deprecated := range settings.DeprecatedSignerPubkeys {
if deprecated.PubKey != nil {
keys = append(keys, schnorr.SerializePubKey(deprecated.PubKey))
}
}
// SignerPubkey is nillable, so an empty set is reachable. Callers use this to
// reject forfeits carrying a signature under one of these keys, and an empty
// set would make that check quietly pass everything, so fail instead. arkd
// cannot sign a forfeit at all in this state.
if len(keys) <= 0 {
return nil, fmt.Errorf("no operator signer key available")
}
return keys, nil
}

func (s *service) SubmitForfeitTxs(ctx context.Context, forfeitTxs []string) errors.Error {
if len(forfeitTxs) <= 0 {
return nil
}

operatorKeys, keysErr := s.operatorXOnlyKeys(ctx)
if keysErr != nil {
log.WithError(keysErr).Error("failed to get operator signer keys")
return errors.INTERNAL_ERROR.New("something went wrong")
}

for _, b64 := range forfeitTxs {
forfeitPtx, err := psbt.NewFromRawBytes(strings.NewReader(b64), true)
if err != nil {
Expand All @@ -2322,6 +2379,13 @@ func (s *service) SubmitForfeitTxs(ctx context.Context, forfeitTxs []string) err
); err != nil {
return errors.INVALID_FORFEIT_TXS.Wrap(err)
}

if domain.ForfeitTxCarriesOperatorSignature(forfeitPtx, operatorKeys) {
return errors.INVALID_FORFEIT_TXS.New(
"forfeit tx %s carries a signature reserved to the operator",
forfeitPtx.UnsignedTx.TxID(),
)
}
}

round, err := s.cache.CurrentRound().Get(ctx)
Expand Down Expand Up @@ -3575,14 +3639,15 @@ func (s *service) finalizeRound(roundId string, roundTiming roundTiming, setting
}
}

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
}
}

Expand Down
Loading
Loading