Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 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
19 changes: 16 additions & 3 deletions internal/core/application/fraud.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,9 +211,22 @@ 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.
//
// Readiness is decided from the psbt alone, without consulting the signer or
// the operator's current key set: a pre-signed forfeit must stay broadcastable
// even when the signer is down or its key has since been rotated away, which is
// the whole point of signing at collection time.
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
93 changes: 85 additions & 8 deletions internal/core/application/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -2306,11 +2306,80 @@ 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
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
}

// A client signs only its own half of a forfeit: the operator's signature is
// added by arkd at collection time, and the connector is spent with a wallet
// key the client does not hold. A submitted forfeit that already carries
// either can only be an attempt to poison the psbt, and it is not harmless:
// a planted tapscript sig under an operator key over the same leaf makes the
// collection-time signer append a second entry for that (key, leaf) pair,
// yielding a duplicate-key psbt that fails to parse and takes the whole round
// down with it. Reject it here, before it is ever stored.
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 +2391,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 +3651,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