From 7cf810659235ef0f268c9112d5a4dab8d889a96a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 1 Sep 2026 13:46:52 +0000 Subject: [PATCH 01/12] feat(spv): wire real reservation re-anchor SPV proof discovery Replace the placeholder getter/no-op submitter for tbtc.ActionReservationReanchor with a real implementation: - getUnprovenReservationReanchorTransactions discovers unproven re-anchor Bitcoin transactions by walking ReservationReanchorRequested events, skipping settled/timed-out action generations, and matching candidate transactions against the still-registered anchor outpoint via ReservationByAnchorUtxo. - reservationReanchorTransactionProofSubmitter re-derives the (reservationKey, requestNonce) pair the generic proof-loop signature cannot carry, then submits via the existing SubmitReservationReanchorProof path. Extends the spv.Chain interface with PastReservationReanchorRequestedEvents and ReservationByAnchorUtxo (already present on TbtcChain); adds matching localChain test fakes. Reservation acceptance proof submission remains a documented placeholder pending its own watcher integration - out of scope here. Covers the discovery precision paths (shape mismatch, anchor mismatch, settled-action skip) and the submitter's nonce/key derivation with new unit tests. --- pkg/maintainer/spv/chain.go | 16 + pkg/maintainer/spv/chain_test.go | 85 ++++- .../spv/reservation_reanchor_proof.go | 237 ++++++++++++ .../spv/reservation_reanchor_proof_test.go | 358 ++++++++++++++++++ pkg/maintainer/spv/spv.go | 57 +-- 5 files changed, 700 insertions(+), 53 deletions(-) diff --git a/pkg/maintainer/spv/chain.go b/pkg/maintainer/spv/chain.go index 71acc042de..31b4497e2a 100644 --- a/pkg/maintainer/spv/chain.go +++ b/pkg/maintainer/spv/chain.go @@ -206,4 +206,20 @@ type Chain interface { PastReservationActionTimedOutEvents( filter *tbtc.ReservationActionTimedOutEventFilter, ) ([]*tbtc.ReservationActionTimedOutEvent, error) + + // PastReservationReanchorRequestedEvents fetches past + // ReservationReanchorRequested events according to the provided filter + // or unfiltered if the filter is nil. Returned events are sorted by the + // block number in the ascending order. + PastReservationReanchorRequestedEvents( + filter *tbtc.ReservationReanchorRequestedEventFilter, + ) ([]*tbtc.ReservationReanchorRequestedEvent, error) + + // ReservationByAnchorUtxo returns the reservation key whose anchor + // outpoint is the given Bitcoin transaction output, or a zero value if + // no reservation is anchored there. + ReservationByAnchorUtxo( + anchorTxHash [32]byte, + anchorTxOutputIndex uint32, + ) (*big.Int, error) } diff --git a/pkg/maintainer/spv/chain_test.go b/pkg/maintainer/spv/chain_test.go index ceb92f17cd..a687ce683b 100644 --- a/pkg/maintainer/spv/chain_test.go +++ b/pkg/maintainer/spv/chain_test.go @@ -93,14 +93,16 @@ type localChain struct { // Reservation watcher state. Indexed by [16]byte / [24]byte map keys // derived from the relevant big.Int so they fit the map type without // per-test marshalling. - walletReservations map[[20]byte][]*big.Int - reservations map[[16]byte]*tbtc.Reservation - reservationActions map[[24]byte]*tbtc.ReservationAction - reservedDeposits map[[16]byte]*reservedDepositRecord - submittedStrandedKeys []*big.Int - submittedStaleDeposits []*big.Int - submittedActionTimeouts []*submittedReservationActionTimeout - reservationParameters *tbtc.ReservationParameters + walletReservations map[[20]byte][]*big.Int + reservations map[[16]byte]*tbtc.Reservation + reservationActions map[[24]byte]*tbtc.ReservationAction + reservedDeposits map[[16]byte]*reservedDepositRecord + submittedStrandedKeys []*big.Int + submittedStaleDeposits []*big.Int + submittedActionTimeouts []*submittedReservationActionTimeout + reservationParameters *tbtc.ReservationParameters + reservationReanchorRequestEvents []*tbtc.ReservationReanchorRequestedEvent + reservationAnchorUtxoIndex map[[36]byte]*big.Int txProofDifficultyFactor *big.Int currentEpoch uint64 @@ -136,6 +138,8 @@ func newLocalChain() *localChain { submittedStrandedKeys: make([]*big.Int, 0), submittedStaleDeposits: make([]*big.Int, 0), submittedActionTimeouts: make([]*submittedReservationActionTimeout, 0), + reservationReanchorRequestEvents: make([]*tbtc.ReservationReanchorRequestedEvent, 0), + reservationAnchorUtxoIndex: make(map[[36]byte]*big.Int), } } @@ -1139,3 +1143,68 @@ func (lc *localChain) PastReservationActionTimedOutEvents( ) ([]*tbtc.ReservationActionTimedOutEvent, error) { return nil, nil } + +// PastReservationReanchorRequestedEvents returns the events previously +// installed via setReservationReanchorRequestedEvents, ignoring the filter +// (tests install exactly the events they want returned). +func (lc *localChain) PastReservationReanchorRequestedEvents( + filter *tbtc.ReservationReanchorRequestedEventFilter, +) ([]*tbtc.ReservationReanchorRequestedEvent, error) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + out := make([]*tbtc.ReservationReanchorRequestedEvent, len(lc.reservationReanchorRequestEvents)) + copy(out, lc.reservationReanchorRequestEvents) + return out, nil +} + +// setReservationReanchorRequestedEvents installs the events +// PastReservationReanchorRequestedEvents returns. +func (lc *localChain) setReservationReanchorRequestedEvents( + events []*tbtc.ReservationReanchorRequestedEvent, +) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.reservationReanchorRequestEvents = events +} + +// anchorUtxoIndexKey builds the map key ReservationByAnchorUtxo and +// setReservationByAnchorUtxo use to index a Bitcoin outpoint. +func anchorUtxoIndexKey(txHash [32]byte, outputIndex uint32) [36]byte { + var key [36]byte + copy(key[:32], txHash[:]) + binary.BigEndian.PutUint32(key[32:36], outputIndex) + return key +} + +// ReservationByAnchorUtxo returns the reservation key previously installed +// via setReservationByAnchorUtxo for the given outpoint, or zero if none was +// installed - mirroring the production contract's "empty value" semantics +// for an unanchored outpoint rather than returning an error. +func (lc *localChain) ReservationByAnchorUtxo( + anchorTxHash [32]byte, + anchorTxOutputIndex uint32, +) (*big.Int, error) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + key := anchorUtxoIndexKey(anchorTxHash, anchorTxOutputIndex) + if reservationKey, ok := lc.reservationAnchorUtxoIndex[key]; ok { + return reservationKey, nil + } + return big.NewInt(0), nil +} + +// setReservationByAnchorUtxo installs the reservation key +// ReservationByAnchorUtxo returns for the given outpoint. +func (lc *localChain) setReservationByAnchorUtxo( + anchorTxHash [32]byte, + anchorTxOutputIndex uint32, + reservationKey *big.Int, +) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.reservationAnchorUtxoIndex[anchorUtxoIndexKey(anchorTxHash, anchorTxOutputIndex)] = reservationKey +} diff --git a/pkg/maintainer/spv/reservation_reanchor_proof.go b/pkg/maintainer/spv/reservation_reanchor_proof.go index ce9df2da8f..5353b05707 100644 --- a/pkg/maintainer/spv/reservation_reanchor_proof.go +++ b/pkg/maintainer/spv/reservation_reanchor_proof.go @@ -240,3 +240,240 @@ func buildReservationProofMainUtxo( TxOutputValue: txOutValue, } } + +// getUnprovenReservationReanchorTransactions discovers reservation +// re-anchor Bitcoin transactions that have not yet had their SPV proof +// accepted by the Bridge. It walks ReservationReanchorRequested events in +// the look-back window, skips any whose action generation has already +// settled or timed out, and for the remainder scans the target wallet's +// recent transactions for the one-input-one-output re-anchor transaction +// whose spent input is still registered on-chain as that reservation's +// anchor outpoint. +func getUnprovenReservationReanchorTransactions( + historyDepth uint64, + transactionLimit int, + btcChain bitcoin.Chain, + spvChain Chain, +) ([]*bitcoin.Transaction, error) { + blockCounter, err := spvChain.BlockCounter() + if err != nil { + return nil, fmt.Errorf("failed to get block counter: [%v]", err) + } + + currentBlock, err := blockCounter.CurrentBlock() + if err != nil { + return nil, fmt.Errorf("failed to get current block: [%v]", err) + } + + // Calculate the starting block of the range in which the events will be + // searched for. + startBlock := currentBlock - historyDepth + + events, err := spvChain.PastReservationReanchorRequestedEvents( + &tbtc.ReservationReanchorRequestedEventFilter{ + StartBlock: startBlock, + }, + ) + if err != nil { + return nil, fmt.Errorf( + "failed to get past reservation re-anchor requested events: [%v]", + err, + ) + } + + unprovenReservationReanchorTransactions := []*bitcoin.Transaction{} + + for _, event := range events { + action, err := spvChain.GetReservationAction( + event.ReservationKey, + event.RequestNonce, + ) + if err != nil { + return nil, fmt.Errorf( + "failed to get reservation action generation: [%v]", + err, + ) + } + + if action.State != tbtc.ReservationActionStatePending { + // The action generation already settled (proof accepted) or + // timed out; there is nothing left to prove for this event. + continue + } + + // The re-anchor transaction pays the target wallet, not the source + // wallet: none of the transaction's outputs transfer funds back to + // the source wallet, so searching the source wallet's transaction + // history would never find it. Mirrors the same reasoning + // getUnprovenMovingFundsTransactions applies for its target wallets. + walletTransactions, err := btcChain.GetTransactionsForPublicKeyHash( + event.TargetWalletPublicKeyHash, + transactionLimit, + ) + if err != nil { + return nil, fmt.Errorf( + "failed to get transactions for target wallet: [%v]", + err, + ) + } + + for _, transaction := range walletTransactions { + isUnproven, err := isUnprovenReservationReanchorTransaction( + transaction, + event.ReservationKey, + btcChain, + spvChain, + ) + if err != nil { + return nil, fmt.Errorf( + "failed to check if transaction is an unproven "+ + "reservation re-anchor transaction: [%v]", + err, + ) + } + + if isUnproven { + unprovenReservationReanchorTransactions = append( + unprovenReservationReanchorTransactions, + transaction, + ) + } + } + } + + return unprovenReservationReanchorTransactions, nil +} + +// isUnprovenReservationReanchorTransaction reports whether the given +// transaction is the still-unproven re-anchor transaction for the given +// reservation. A transaction qualifies when it has the +// one-input-one-output re-anchor shape and its spent input is still +// registered on-chain as reservationKey's anchor outpoint. The Bridge +// clears that registration only once the re-anchor proof is accepted, so a +// match here is conclusive evidence the proof has not landed yet. +// +// Transactions that do not have the re-anchor shape (e.g. unrelated +// payments to the same wallet) are reported as non-matches rather than +// errors so a single unrelated transaction does not abort the discovery +// round. +func isUnprovenReservationReanchorTransaction( + transaction *bitcoin.Transaction, + reservationKey *big.Int, + btcChain bitcoin.Chain, + spvChain Chain, +) (bool, error) { + anchorUtxo, _, err := parseReservationReanchorTransactionInput( + btcChain, + transaction, + ) + if err != nil { + return false, nil + } + + matchedReservationKey, err := spvChain.ReservationByAnchorUtxo( + anchorUtxo.Outpoint.TransactionHash, + anchorUtxo.Outpoint.OutputIndex, + ) + if err != nil { + return false, fmt.Errorf( + "failed to look up reservation by anchor utxo: [%v]", + err, + ) + } + + return matchedReservationKey != nil && + matchedReservationKey.Sign() != 0 && + matchedReservationKey.Cmp(reservationKey) == 0, nil +} + +// reservationReanchorTransactionProofSubmitter adapts the reservation +// re-anchor proof submission to the generic transactionProofSubmitter +// signature used by the SPV maintainer's proof loop. It is a thin wrapper +// around submitDiscoveredReservationReanchorProof that plugs in the real +// SPV proof assembler; kept separate so tests can inject a mock assembler +// without needing a real Bitcoin merkle proof chain. +func reservationReanchorTransactionProofSubmitter( + transactionHash bitcoin.Hash, + requiredConfirmations uint, + btcChain bitcoin.Chain, + spvChain Chain, +) error { + return submitDiscoveredReservationReanchorProof( + transactionHash, + requiredConfirmations, + btcChain, + spvChain, + bitcoin.AssembleSpvProof, + ) +} + +// submitDiscoveredReservationReanchorProof re-derives the +// (reservationKey, requestNonce) pair a discovered re-anchor transaction +// belongs to and submits its SPV proof. The generic transactionProofSubmitter +// signature only carries the transaction hash, so this function looks up +// which reservation is still registered against the transaction's spent +// anchor outpoint (ReservationByAnchorUtxo - the Bridge only clears that +// registration once the proof is accepted, so a match here is conclusive), +// then reads that reservation's current request nonce (the nonce of its +// in-flight action generation, since m1 allows at most one pending action +// per reservation at a time). +func submitDiscoveredReservationReanchorProof( + transactionHash bitcoin.Hash, + requiredConfirmations uint, + btcChain bitcoin.Chain, + spvChain Chain, + spvProofAssembler spvProofAssembler, +) error { + transaction, err := btcChain.GetTransaction(transactionHash) + if err != nil { + return fmt.Errorf( + "failed to get reservation re-anchor transaction: [%v]", + err, + ) + } + + anchorUtxo, _, err := parseReservationReanchorTransactionInput( + btcChain, + transaction, + ) + if err != nil { + return fmt.Errorf( + "failed to parse reservation re-anchor transaction input: [%v]", + err, + ) + } + + reservationKey, err := spvChain.ReservationByAnchorUtxo( + anchorUtxo.Outpoint.TransactionHash, + anchorUtxo.Outpoint.OutputIndex, + ) + if err != nil { + return fmt.Errorf( + "failed to look up reservation by anchor utxo: [%v]", + err, + ) + } + + if reservationKey == nil || reservationKey.Sign() == 0 { + return fmt.Errorf( + "no reservation is anchored at the spent outpoint of "+ + "transaction [%s]", + transactionHash.Hex(bitcoin.ReversedByteOrder), + ) + } + + reservation, err := spvChain.GetReservation(reservationKey) + if err != nil { + return fmt.Errorf("failed to get reservation: [%v]", err) + } + + return submitReservationReanchorProof( + transactionHash, + requiredConfirmations, + reservationKey, + reservation.RequestNonce, + btcChain, + spvChain, + spvProofAssembler, + ) +} diff --git a/pkg/maintainer/spv/reservation_reanchor_proof_test.go b/pkg/maintainer/spv/reservation_reanchor_proof_test.go index 804599967b..63126461e5 100644 --- a/pkg/maintainer/spv/reservation_reanchor_proof_test.go +++ b/pkg/maintainer/spv/reservation_reanchor_proof_test.go @@ -191,3 +191,361 @@ func TestSubmitReservationReanchorProof(t *testing.T) { t.Fatal("expected error for zero required confirmations") } } + +// TestGetUnprovenReservationReanchorTransactions verifies that discovery +// finds exactly the transaction matching a pending re-anchor request, and +// correctly excludes: (a) requests whose action generation already +// settled, (b) unrelated transactions to the same target wallet that do +// not have the re-anchor shape, and (c) re-anchor-shaped transactions +// whose spent input is not registered as the reservation's anchor. +func TestGetUnprovenReservationReanchorTransactions(t *testing.T) { + historyDepth := uint64(5) + transactionLimit := 10 + currentBlock := uint64(1000) + + btcChain := newLocalBitcoinChain() + spvChain := newLocalChain() + + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(currentBlock) + spvChain.setBlockCounter(blockCounter) + + targetWalletPKH := [20]byte{0x92, 0xa6, 0xec, 0x88, 0x9a, 0x8f, 0xa3, 0x4f, 0x73, 0x1e, 0x63, 0x9e, 0xde, 0xde, 0x4c, 0x75, 0xe1, 0x84, 0x30, 0x7c} + targetScript, err := bitcoin.PayToWitnessPublicKeyHash(targetWalletPKH) + if err != nil { + t.Fatal(err) + } + + // Anchor transaction that the re-anchor spends. + anchorTx := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x01}, + OutputIndex: 0, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 600000, + PublicKeyScript: []byte{}, + }}, + } + if err := btcChain.BroadcastTransaction(anchorTx); err != nil { + t.Fatal(err) + } + + // The real re-anchor transaction: spends the anchor, pays the target + // wallet, one input, one output. + reanchorTx := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTx.Hash(), + OutputIndex: 0, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 590000, + PublicKeyScript: targetScript, + }}, + } + if err := btcChain.BroadcastTransaction(reanchorTx); err != nil { + t.Fatal(err) + } + + // An unrelated transaction paying the same target wallet with a second + // output - does not have the 1-input-1-output re-anchor shape and must + // be skipped without error. + wrongShapeTx := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x02}, + OutputIndex: 0, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{ + {Value: 10000, PublicKeyScript: targetScript}, + {Value: 20000, PublicKeyScript: []byte{}}, + }, + } + if err := btcChain.BroadcastTransaction(wrongShapeTx); err != nil { + t.Fatal(err) + } + + // A same-shape (1-in-1-out) transaction paying the target wallet whose + // spent input is never registered as any reservation's anchor - must + // be excluded by the ReservationByAnchorUtxo mismatch, not by shape. + unrelatedSourceTx := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x03}, + OutputIndex: 0, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 1000, + PublicKeyScript: []byte{}, + }}, + } + if err := btcChain.BroadcastTransaction(unrelatedSourceTx); err != nil { + t.Fatal(err) + } + unregisteredAnchorTx := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: unrelatedSourceTx.Hash(), + OutputIndex: 0, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 900, + PublicKeyScript: targetScript, + }}, + } + if err := btcChain.BroadcastTransaction(unregisteredAnchorTx); err != nil { + t.Fatal(err) + } + + reservationKey := big.NewInt(42) + requestNonce := uint64(7) + sourceWalletPKH := [20]byte{0xaa} + + spvChain.setReservationByAnchorUtxo(anchorTx.Hash(), 0, reservationKey) + spvChain.setReservationReanchorRequestedEvents([]*tbtc.ReservationReanchorRequestedEvent{ + { + ReservationKey: reservationKey, + RequestNonce: requestNonce, + SourceWalletPublicKeyHash: sourceWalletPKH, + TargetWalletPublicKeyHash: targetWalletPKH, + }, + }) + spvChain.setReservationAction(reservationKey, requestNonce, &tbtc.ReservationAction{ + ActionType: tbtc.ReservationActionTypeReanchor, + State: tbtc.ReservationActionStatePending, + }) + + transactions, err := getUnprovenReservationReanchorTransactions( + historyDepth, + transactionLimit, + btcChain, + spvChain, + ) + if err != nil { + t.Fatal(err) + } + if len(transactions) != 1 { + t.Fatalf("expected 1 unproven transaction, got %d", len(transactions)) + } + if transactions[0].Hash() != reanchorTx.Hash() { + t.Errorf( + "unexpected transaction: got %s, want %s", + transactions[0].Hash(), + reanchorTx.Hash(), + ) + } + + // Once the action generation settles, the event must be skipped + // entirely and discovery must return no transactions. + spvChain.setReservationAction(reservationKey, requestNonce, &tbtc.ReservationAction{ + ActionType: tbtc.ReservationActionTypeReanchor, + State: tbtc.ReservationActionStateSettled, + }) + + transactions, err = getUnprovenReservationReanchorTransactions( + historyDepth, + transactionLimit, + btcChain, + spvChain, + ) + if err != nil { + t.Fatal(err) + } + if len(transactions) != 0 { + t.Fatalf( + "expected no unproven transactions once settled, got %d", + len(transactions), + ) + } +} + +// TestSubmitDiscoveredReservationReanchorProof verifies that the discovered- +// transaction submitter re-derives (reservationKey, requestNonce) from the +// transaction's spent anchor outpoint and submits the proof, and that it +// fails cleanly when the outpoint is not registered to any reservation. +func TestSubmitDiscoveredReservationReanchorProof(t *testing.T) { + requiredConfirmations := uint(6) + + btcChain := newLocalBitcoinChain() + spvChain := newLocalChain() + + anchorTx := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{}, + OutputIndex: 0, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 600000, + PublicKeyScript: []byte{}, + }}, + } + if err := btcChain.BroadcastTransaction(anchorTx); err != nil { + t.Fatal(err) + } + anchorTxHash := anchorTx.Hash() + + targetWalletPKH := [20]byte{0x92, 0xa6, 0xec, 0x88, 0x9a, 0x8f, 0xa3, 0x4f, 0x73, 0x1e, 0x63, 0x9e, 0xde, 0xde, 0x4c, 0x75, 0xe1, 0x84, 0x30, 0x7c} + targetScript, err := bitcoin.PayToWitnessPublicKeyHash(targetWalletPKH) + if err != nil { + t.Fatal(err) + } + + reanchorTx := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHash, + OutputIndex: 0, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 590000, + PublicKeyScript: targetScript, + }}, + } + if err := btcChain.BroadcastTransaction(reanchorTx); err != nil { + t.Fatal(err) + } + + proof := &bitcoin.SpvProof{ + MerkleProof: []byte{0x01}, + TxIndexInBlock: 2, + BitcoinHeaders: []byte{0x03}, + } + mockSpvProofAssembler := func( + hash bitcoin.Hash, + confirmations uint, + btcChain bitcoin.Chain, + ) (*bitcoin.Transaction, *bitcoin.SpvProof, error) { + if hash == reanchorTx.Hash() && confirmations == requiredConfirmations { + return reanchorTx, proof, nil + } + return nil, nil, fmt.Errorf("unexpected proof assembly request") + } + + reservationKey := big.NewInt(42) + requestNonce := uint64(7) + + spvChain.setReservationByAnchorUtxo(anchorTxHash, 0, reservationKey) + spvChain.setReservation(reservationKey, &tbtc.Reservation{ + WalletPublicKeyHash: targetWalletPKH, + AnchorUtxo: &bitcoin.UnspentTransactionOutput{ + Outpoint: reanchorTx.Inputs[0].Outpoint, + Value: 600000, + }, + State: tbtc.ReservationStateActive, + RequestNonce: requestNonce, + }) + spvChain.setReservationAction(reservationKey, requestNonce, &tbtc.ReservationAction{ + ActionType: tbtc.ReservationActionTypeReanchor, + State: tbtc.ReservationActionStatePending, + }) + + var capturedReservationKey *big.Int + var capturedRequestNonce uint64 + spvChain.submitReservationProofHook = func( + proofType uint8, + txInfo *tbtc.BitcoinTxInfo, + txProof *tbtc.BitcoinTxProof, + mainUtxo *tbtc.BitcoinTxUTXO, + rk *big.Int, + rn uint64, + ) error { + capturedReservationKey = rk + capturedRequestNonce = rn + return nil + } + + if err := submitDiscoveredReservationReanchorProof( + reanchorTx.Hash(), + requiredConfirmations, + btcChain, + spvChain, + mockSpvProofAssembler, + ); err != nil { + t.Fatal(err) + } + + if capturedReservationKey == nil || capturedReservationKey.Cmp(reservationKey) != 0 { + t.Errorf( + "unexpected derived reservation key: got %v, want %v", + capturedReservationKey, + reservationKey, + ) + } + if capturedRequestNonce != requestNonce { + t.Errorf( + "unexpected derived request nonce: got %d, want %d", + capturedRequestNonce, + requestNonce, + ) + } + + // Negative path: the spent outpoint is not registered to any + // reservation. + sourceTx := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x0a}, + OutputIndex: 0, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 2000, + PublicKeyScript: []byte{}, + }}, + } + if err := btcChain.BroadcastTransaction(sourceTx); err != nil { + t.Fatal(err) + } + unanchoredTx := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: sourceTx.Hash(), + OutputIndex: 0, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 1000, + PublicKeyScript: targetScript, + }}, + } + if err := btcChain.BroadcastTransaction(unanchoredTx); err != nil { + t.Fatal(err) + } + + mockSpvProofAssembler2 := func( + hash bitcoin.Hash, + confirmations uint, + btcChain bitcoin.Chain, + ) (*bitcoin.Transaction, *bitcoin.SpvProof, error) { + return unanchoredTx, proof, nil + } + + if err := submitDiscoveredReservationReanchorProof( + unanchoredTx.Hash(), + requiredConfirmations, + btcChain, + spvChain, + mockSpvProofAssembler2, + ); err == nil { + t.Fatal("expected error for unanchored outpoint") + } +} diff --git a/pkg/maintainer/spv/spv.go b/pkg/maintainer/spv/spv.go index c88ef19788..44768279e2 100644 --- a/pkg/maintainer/spv/spv.go +++ b/pkg/maintainer/spv/spv.go @@ -53,12 +53,17 @@ func Initialize( if config.Reservations.Enabled { // PR H: register reservation acceptance / re-anchor proof tasks in - // the proof loop. The getter functions are placeholders that return - // no transactions today; the production wiring that drives them - // arrives once the watcher integration ships. Adding the tasks to - // `proofTypes` even when the wiring is a placeholder keeps the - // gating uniform: Reservations.Enabled is the single switch for the - // reservation plumbing in the SPV maintainer. + // the proof loop. Reservation acceptance still uses a placeholder + // getter/submitter pending its own watcher integration (see + // reservation_acceptance_proof.go); reservation re-anchor has a real + // getter/submitter pair (see reservation_reanchor_proof.go) that + // discovers unproven re-anchor transactions via + // ReservationReanchorRequested events and ReservationByAnchorUtxo, + // then re-derives the (reservationKey, requestNonce) pair the + // generic proof loop signature cannot carry. Adding both tasks to + // `proofTypes` even while acceptance is still a placeholder keeps + // the gating uniform: Reservations.Enabled is the single switch for + // the reservation plumbing in the SPV maintainer. proofTypes[tbtc.ActionReservationAnchor] = struct { unprovenTransactionsGetter unprovenTransactionsGetter transactionProofSubmitter transactionProofSubmitter @@ -71,12 +76,7 @@ func Initialize( transactionProofSubmitter transactionProofSubmitter }{ unprovenTransactionsGetter: getUnprovenReservationReanchorTransactions, - // SubmitReservationReanchorProof requires the (reservationKey, - // requestNonce) pair that the generic proof loop cannot supply. - // Until the production wiring delivers that context the adapter - // is a clean no-op so the proof loop runs without producing - // malformed calls into the underlying submitter. - transactionProofSubmitter: noopReanchorProofSubmitter, + transactionProofSubmitter: reservationReanchorTransactionProofSubmitter, } } @@ -523,36 +523,3 @@ func getUnprovenReservationAcceptanceTransactions( ) ([]*bitcoin.Transaction, error) { return nil, nil } - -// getUnprovenReservationReanchorTransactions is a placeholder for the -// reservation re-anchor proof task. The production wiring for reservation -// re-anchor proofs is delivered by the reservation re-anchor watcher -// integration that translates wallet-side re-anchor events into SPV proof -// submissions; until that wiring lands this getter returns no transactions -// so the generic proof loop skips reservation re-anchor cleanly. -// -// Marked by PR H; the gate on config.Reservations.Enabled ensures the task is -// only attached to proofTypes when reservations are enabled. -func getUnprovenReservationReanchorTransactions( - historyDepth uint64, - transactionLimit int, - btcChain bitcoin.Chain, - spvChain Chain, -) ([]*bitcoin.Transaction, error) { - return nil, nil -} - -// noopReanchorProofSubmitter is the placeholder submitter paired with -// getUnprovenReservationReanchorTransactions. SubmitReservationReanchorProof -// requires (reservationKey, requestNonce) which the generic proof loop does -// not carry; calling it with zero values would trip the input validators and -// produce repeated error logs. Until the production wiring supplies the -// missing context this submitter returns nil so the loop completes cleanly. -func noopReanchorProofSubmitter( - transactionHash bitcoin.Hash, - requiredConfirmations uint, - btcChain bitcoin.Chain, - spvChain Chain, -) error { - return nil -} From 6a8162b2d379beba919153cef1ce187f4e5c15d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 1 Sep 2026 13:52:10 +0000 Subject: [PATCH 02/12] feat(spv): implement real ReservationActionTimeoutWatcher.Run loop Replace the placeholder Run() (guard checks only, no loop) with a real background poller: - WatchWallet registers a wallet public key hash for polling; dedupes registrations under a mutex-protected set. - Run(ctx) now blocks, checking every watched wallet immediately and then every poll interval, until ctx is done. Each iteration walks each watched wallet's reservations (WalletReservations) and calls the existing CheckReservationActionTimeouts per reservation. A failure checking one wallet is logged and does not abort the iteration or stop the loop. - startActionTimeoutRun (reservation_wiring.go) now threads ctx into Run(ctx) instead of discarding it; context.Canceled is treated as the expected shutdown path, not a failure to log. Run's signature changes from Run() to Run(ctx context.Context); the only call site (startActionTimeoutRun) is updated. Wallet discovery (who calls WatchWallet with which wallets) remains a separate, pre-existing gap shared by all three reservation watchers - see the 'PR H placeholder' comments on subscribeReservationWalletClosed and subscribeReservationActionTimedOut - and is out of scope here. Covers WatchWallet dedup, all three Run precondition guards, and an end-to-end test that Run notifies a timed-out action on its first (immediate) iteration and returns promptly on ctx cancellation. --- .../spv/reservation_action_timeout_watch.go | 111 +++++++++++--- .../reservation_action_timeout_watch_test.go | 142 ++++++++++++++++++ pkg/maintainer/spv/reservation_wiring.go | 9 +- 3 files changed, 240 insertions(+), 22 deletions(-) diff --git a/pkg/maintainer/spv/reservation_action_timeout_watch.go b/pkg/maintainer/spv/reservation_action_timeout_watch.go index 270e7f8a3c..1b3fa2a34e 100644 --- a/pkg/maintainer/spv/reservation_action_timeout_watch.go +++ b/pkg/maintainer/spv/reservation_action_timeout_watch.go @@ -1,8 +1,10 @@ package spv import ( + "context" "fmt" "math/big" + "sync" "time" "github.com/keep-network/keep-core/pkg/tbtc" @@ -43,6 +45,9 @@ type ReservationActionTimeoutWatcher struct { // to look up operator addresses (the SPV maintainer chain interface // does not expose GetOperatorID today). membersResolver WalletMembersResolver + + walletsMutex sync.Mutex + watchedWallets map[[20]byte]struct{} } // WalletMembersResolver maps a wallet public key hash to the operator IDs @@ -117,6 +122,7 @@ func NewReservationActionTimeoutWatcher( nowFn: defaultActionTimeoutNowFn, interval: pollInterval, membersResolver: membersResolver, + watchedWallets: make(map[[20]byte]struct{}), } } @@ -126,24 +132,47 @@ func defaultActionTimeoutNowFn() uint32 { return uint32(time.Now().Unix()) } -// Run starts the background poll loop. It returns immediately and runs -// until ctx is done. +// WatchWallet registers a wallet public key hash for the background poll +// loop started by Run: each iteration enumerates the reservations of every +// watched wallet via WalletReservations and inspects their pending actions +// for elapsed timeouts. Registering the same wallet twice is a no-op. +func (ratw *ReservationActionTimeoutWatcher) WatchWallet( + walletPublicKeyHash [20]byte, +) { + ratw.walletsMutex.Lock() + defer ratw.walletsMutex.Unlock() + + ratw.watchedWallets[walletPublicKeyHash] = struct{}{} +} + +// watchedWalletsSnapshot returns a copy of the currently registered wallet +// set. Copying under the lock keeps the poll iteration itself lock-free, so +// a slow chain call during one wallet's check does not block a concurrent +// WatchWallet registration. +func (ratw *ReservationActionTimeoutWatcher) watchedWalletsSnapshot() [][20]byte { + ratw.walletsMutex.Lock() + defer ratw.walletsMutex.Unlock() + + wallets := make([][20]byte, 0, len(ratw.watchedWallets)) + for walletPublicKeyHash := range ratw.watchedWallets { + wallets = append(wallets, walletPublicKeyHash) + } + return wallets +} + +// Run starts the background poll loop and blocks until ctx is done or a +// setup precondition fails. Callers that want a non-blocking start (the +// production wiring does; see startActionTimeoutRun) invoke it inside their +// own goroutine. // // Each iteration enumerates the reservations of every wallet registered // with the watcher (added via WatchWallet), inspects each nonce-keyed // action, and notifies the Bridge for those whose state is Pending and -// whose TimeoutAt has elapsed. -// -// Integration code typically calls Run once at startup and WatchWallet per -// discovered wallet. The loop is best-effort: errors are logged and the -// next iteration retries. -// -// Note: Run is a placeholder for the integration wiring in this PR. The -// per-wallet reservation enumeration rides on top of the stranding watcher's -// discovery path; m1 ships the synchronous CheckReservationActionTimeouts -// for one reservation key (tests + integration) and the interface surface -// to wire the loop in a follow-up PR. -func (ratw *ReservationActionTimeoutWatcher) Run() error { +// whose TimeoutAt has elapsed. The first iteration runs immediately; later +// iterations run every `interval`. The loop is best-effort: a failure +// while checking one wallet is logged and does not abort the iteration or +// stop the loop. +func (ratw *ReservationActionTimeoutWatcher) Run(ctx context.Context) error { if ratw.notifier == nil { return fmt.Errorf( "action-timeout watcher requires a non-nil notifier", @@ -159,10 +188,56 @@ func (ratw *ReservationActionTimeoutWatcher) Run() error { "action-timeout watcher requires a positive poll interval", ) } - // The loop is owned by the integration step; the watcher itself - // exposes the synchronous CheckReservationActionTimeouts entry-point - // for tests and one-shot invocations. - return nil + + ticker := time.NewTicker(ratw.interval) + defer ticker.Stop() + + for { + ratw.checkWatchedWallets() + + select { + case <-ticker.C: + case <-ctx.Done(): + return ctx.Err() + } + } +} + +// checkWatchedWallets runs one poll iteration over every registered wallet. +// Errors resolving a wallet's reservation set are logged and do not abort +// the iteration: a transient RPC failure on one wallet must not starve the +// checks for the rest. +func (ratw *ReservationActionTimeoutWatcher) checkWatchedWallets() { + now := ratw.nowFn() + + for _, walletPublicKeyHash := range ratw.watchedWalletsSnapshot() { + reservationKeys, err := ratw.spvChain.WalletReservations( + walletPublicKeyHash, + ) + if err != nil { + logger.Errorf( + "failed to list reservations for watched wallet [0x%x]: [%v]", + walletPublicKeyHash, + err, + ) + continue + } + + for _, reservationKey := range reservationKeys { + if err := ratw.CheckReservationActionTimeouts( + reservationKey, + now, + ); err != nil { + logger.Errorf( + "failed to check action timeouts for reservation "+ + "[%v] on watched wallet [0x%x]: [%v]", + reservationKey, + walletPublicKeyHash, + err, + ) + } + } + } } // CheckReservationActionTimeouts inspects the action generations of a diff --git a/pkg/maintainer/spv/reservation_action_timeout_watch_test.go b/pkg/maintainer/spv/reservation_action_timeout_watch_test.go index 5bc7744d35..e9bdd0aa6f 100644 --- a/pkg/maintainer/spv/reservation_action_timeout_watch_test.go +++ b/pkg/maintainer/spv/reservation_action_timeout_watch_test.go @@ -1,9 +1,11 @@ package spv import ( + "context" "errors" "math/big" "testing" + "time" "github.com/keep-network/keep-core/pkg/tbtc" @@ -416,3 +418,143 @@ func TestReservationActionTimeoutWatcher_NotifiesOncePerQualifyingNonce(t *testi t.Fatalf("expected two recorded notification attempts, got %d", len(notifier.calls)) } } + +// TestReservationActionTimeoutWatcher_WatchWallet_Deduplicates verifies that +// registering the same wallet more than once does not grow the watched set. +func TestReservationActionTimeoutWatcher_WatchWallet_Deduplicates(t *testing.T) { + spvChain := newLocalChain() + notifier := &recordingActionTimeoutNotifier{} + resolver := &recordingActionTimeoutMembers{} + + watcher := NewReservationActionTimeoutWatcher(spvChain, notifier, resolver, 0) + + wallet := walletPKH() + watcher.WatchWallet(wallet) + watcher.WatchWallet(wallet) + + wallets := watcher.watchedWalletsSnapshot() + if len(wallets) != 1 { + t.Fatalf("expected 1 watched wallet after duplicate registration, got %d", len(wallets)) + } + if wallets[0] != wallet { + t.Errorf("unexpected watched wallet: got %x, want %x", wallets[0], wallet) + } +} + +// TestReservationActionTimeoutWatcher_Run_NilNotifierError verifies Run +// fails its precondition check synchronously (does not block on ctx) when +// the notifier is nil. +func TestReservationActionTimeoutWatcher_Run_NilNotifierError(t *testing.T) { + spvChain := newLocalChain() + resolver := &recordingActionTimeoutMembers{} + + watcher := NewReservationActionTimeoutWatcher(spvChain, nil, resolver, time.Millisecond) + if err := watcher.Run(context.Background()); err == nil { + t.Fatal("expected error for nil notifier, got nil") + } +} + +// TestReservationActionTimeoutWatcher_Run_NilResolverError mirrors the nil +// notifier case for the members resolver precondition. +func TestReservationActionTimeoutWatcher_Run_NilResolverError(t *testing.T) { + spvChain := newLocalChain() + notifier := &recordingActionTimeoutNotifier{} + + watcher := NewReservationActionTimeoutWatcher(spvChain, notifier, nil, time.Millisecond) + if err := watcher.Run(context.Background()); err == nil { + t.Fatal("expected error for nil resolver, got nil") + } +} + +// TestReservationActionTimeoutWatcher_Run_ZeroIntervalError verifies Run +// refuses to start its poll loop with a non-positive interval, matching the +// documented NewReservationActionTimeoutWatcher contract (a zero interval +// means "synchronous CheckReservationActionTimeouts only"). +func TestReservationActionTimeoutWatcher_Run_ZeroIntervalError(t *testing.T) { + spvChain := newLocalChain() + notifier := &recordingActionTimeoutNotifier{} + resolver := &recordingActionTimeoutMembers{} + + watcher := NewReservationActionTimeoutWatcher(spvChain, notifier, resolver, 0) + if err := watcher.Run(context.Background()); err == nil { + t.Fatal("expected error for zero poll interval, got nil") + } +} + +// TestReservationActionTimeoutWatcher_Run_ChecksWatchedWalletsAndStopsOnCancel +// is the end-to-end coverage for the polling loop this task adds: it +// verifies Run (a) immediately checks every wallet registered via +// WatchWallet without waiting a full interval first, (b) notifies the +// Bridge for a reservation whose pending action has timed out, and (c) +// returns promptly once ctx is canceled rather than running forever. +func TestReservationActionTimeoutWatcher_Run_ChecksWatchedWalletsAndStopsOnCancel(t *testing.T) { + spvChain := newLocalChain() + + notified := make(chan *big.Int, 4) + notifier := ReservationActionTimeoutNotifierFunc(func( + reservationKey *big.Int, + walletMembersIDs []uint32, + ) error { + notified <- reservationKey + return nil + }) + + wallet := walletPKH() + key := reservationKey(0xC00B) + resolver := &recordingActionTimeoutMembers{ + walletIDs: map[[20]byte][]uint32{wallet: {7}}, + } + + // now() is fixed far past the seeded action's TimeoutAt so the very + // first poll iteration (which runs immediately, before any ticker + // fires) already finds a timed-out action. + seededReservation( + t, + spvChain, + key, + wallet, + []*tbtc.ReservationAction{ + { + State: tbtc.ReservationActionStatePending, + TimeoutAt: 100, + }, + }, + 0, + ) + spvChain.setWalletReservations(wallet, []*big.Int{key}) + + watcher := NewReservationActionTimeoutWatcher( + spvChain, + notifier, + resolver, + time.Millisecond, + ) + watcher.nowFn = func() uint32 { return 5_000 } + watcher.WatchWallet(wallet) + + ctx, cancel := context.WithCancel(context.Background()) + runErr := make(chan error, 1) + go func() { + runErr <- watcher.Run(ctx) + }() + + select { + case notifiedKey := <-notified: + if notifiedKey.Cmp(key) != 0 { + t.Errorf("unexpected notified reservation key: got %v, want %v", notifiedKey, key) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for Run to notify the timed-out action") + } + + cancel() + + select { + case err := <-runErr: + if !errors.Is(err, context.Canceled) { + t.Errorf("expected context.Canceled from Run, got: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for Run to return after ctx cancellation") + } +} diff --git a/pkg/maintainer/spv/reservation_wiring.go b/pkg/maintainer/spv/reservation_wiring.go index 55b58d6b1e..f87829f023 100644 --- a/pkg/maintainer/spv/reservation_wiring.go +++ b/pkg/maintainer/spv/reservation_wiring.go @@ -2,6 +2,7 @@ package spv import ( "context" + "errors" "math/big" "time" @@ -193,15 +194,15 @@ func startStaleDepositPoll( } // startActionTimeoutRun starts the action-timeout watcher's Run loop in a -// goroutine. The watcher exposes Run() as a guarded no-op (placeholder for -// the integration step) and returns an error if its dependencies are not -// provided; we've supplied them above so Run() returns nil cleanly. +// goroutine bound to ctx, so the loop stops when the wiring caller cancels +// ctx (e.g. on node shutdown). A context.Canceled error from Run is the +// expected shutdown path and is not logged as a failure. func startActionTimeoutRun( ctx context.Context, watcher *ReservationActionTimeoutWatcher, ) { go func() { - if err := watcher.Run(); err != nil { + if err := watcher.Run(ctx); err != nil && !errors.Is(err, context.Canceled) { reservationWiringLogger.Errorf( "failed to start reservation action-timeout watcher: [%v]", err, From 7108928b82204ac69ab185c20b13f1b401bc184e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 1 Sep 2026 14:10:17 +0000 Subject: [PATCH 03/12] fix(spv): reject stale reservation re-anchor action generations before proof submission submitDiscoveredReservationReanchorProof previously re-derived the submission nonce by reading the reservation's current RequestNonce. That field tracks the reservation's live action generation, which can have moved on since the discovered transaction was built - e.g. the original re-anchor action times out and a new, unrelated action generation becomes current while the SPV maintainer is still waiting out requiredConfirmations on the old transaction. Submitting the live nonce in that case pairs a stale, unrelated transaction with the wrong action generation. Fix: before submitting, fetch the action generation at the reservation's current nonce and require it to still be a Pending Reanchor action targeting the exact wallet the discovered transaction actually pays. Any mismatch is reported as an error so the proof loop treats the transaction as not-yet-submittable instead of silently misattributing the proof. Adds two regression tests: one for the action-no-longer-pending case, one for the pending-but-different-target-wallet case. --- .../spv/reservation_reanchor_proof.go | 61 ++++- .../spv/reservation_reanchor_proof_test.go | 241 +++++++++++++++++- 2 files changed, 292 insertions(+), 10 deletions(-) diff --git a/pkg/maintainer/spv/reservation_reanchor_proof.go b/pkg/maintainer/spv/reservation_reanchor_proof.go index 5353b05707..c6bd4035e1 100644 --- a/pkg/maintainer/spv/reservation_reanchor_proof.go +++ b/pkg/maintainer/spv/reservation_reanchor_proof.go @@ -413,10 +413,23 @@ func reservationReanchorTransactionProofSubmitter( // signature only carries the transaction hash, so this function looks up // which reservation is still registered against the transaction's spent // anchor outpoint (ReservationByAnchorUtxo - the Bridge only clears that -// registration once the proof is accepted, so a match here is conclusive), -// then reads that reservation's current request nonce (the nonce of its -// in-flight action generation, since m1 allows at most one pending action -// per reservation at a time). +// registration once the proof is accepted, so a match on the outpoint is +// conclusive that this reservation's re-anchor has not yet landed). +// +// Deriving the request nonce is not as simple as reading the reservation's +// current RequestNonce: that field tracks the reservation's live action +// generation, which can have moved on since this transaction was discovered +// (e.g. the original re-anchor action timed out and a new action generation, +// possibly with a different target wallet, was requested while this +// function's caller was waiting out requiredConfirmations). Submitting the +// live nonce for a stale transaction would pair an old, unrelated re-anchor +// transaction with the wrong action generation. To guard against that, this +// function fetches the action generation at the reservation's current nonce +// and requires it to still be a Pending Reanchor action targeting the exact +// wallet this transaction actually pays before treating the current nonce as +// correct for this transaction; any mismatch is reported as an error so the +// proof loop treats the transaction as not-yet-submittable rather than +// silently misattributing the proof. func submitDiscoveredReservationReanchorProof( transactionHash bitcoin.Hash, requiredConfirmations uint, @@ -432,10 +445,8 @@ func submitDiscoveredReservationReanchorProof( ) } - anchorUtxo, _, err := parseReservationReanchorTransactionInput( - btcChain, - transaction, - ) + anchorUtxo, targetWalletPublicKeyHash, err := + parseReservationReanchorTransactionInput(btcChain, transaction) if err != nil { return fmt.Errorf( "failed to parse reservation re-anchor transaction input: [%v]", @@ -467,6 +478,40 @@ func submitDiscoveredReservationReanchorProof( return fmt.Errorf("failed to get reservation: [%v]", err) } + action, err := spvChain.GetReservationAction( + reservationKey, + reservation.RequestNonce, + ) + if err != nil { + return fmt.Errorf( + "failed to get reservation's current action generation: [%v]", + err, + ) + } + + if action.ActionType != tbtc.ReservationActionTypeReanchor || + action.State != tbtc.ReservationActionStatePending { + return fmt.Errorf( + "reservation [%v]'s current action generation [%d] is no "+ + "longer a pending re-anchor; the discovered transaction "+ + "[%s] belongs to a superseded generation", + reservationKey, + reservation.RequestNonce, + transactionHash.Hex(bitcoin.ReversedByteOrder), + ) + } + + if action.TargetWalletPublicKeyHash != targetWalletPublicKeyHash { + return fmt.Errorf( + "reservation [%v]'s current action generation [%d] targets a "+ + "different wallet than the discovered transaction [%s]; "+ + "the transaction belongs to a superseded generation", + reservationKey, + reservation.RequestNonce, + transactionHash.Hex(bitcoin.ReversedByteOrder), + ) + } + return submitReservationReanchorProof( transactionHash, requiredConfirmations, diff --git a/pkg/maintainer/spv/reservation_reanchor_proof_test.go b/pkg/maintainer/spv/reservation_reanchor_proof_test.go index 63126461e5..3e4afbc6af 100644 --- a/pkg/maintainer/spv/reservation_reanchor_proof_test.go +++ b/pkg/maintainer/spv/reservation_reanchor_proof_test.go @@ -452,8 +452,9 @@ func TestSubmitDiscoveredReservationReanchorProof(t *testing.T) { RequestNonce: requestNonce, }) spvChain.setReservationAction(reservationKey, requestNonce, &tbtc.ReservationAction{ - ActionType: tbtc.ReservationActionTypeReanchor, - State: tbtc.ReservationActionStatePending, + ActionType: tbtc.ReservationActionTypeReanchor, + State: tbtc.ReservationActionStatePending, + TargetWalletPublicKeyHash: targetWalletPKH, }) var capturedReservationKey *big.Int @@ -549,3 +550,239 @@ func TestSubmitDiscoveredReservationReanchorProof(t *testing.T) { t.Fatal("expected error for unanchored outpoint") } } + +// TestSubmitDiscoveredReservationReanchorProof_StaleActionGeneration verifies +// that submission is rejected, not misattributed, when the reservation's +// current action generation has moved past the one that produced the +// discovered transaction (e.g. the original re-anchor action timed out and a +// new, unrelated action generation is now current). +func TestSubmitDiscoveredReservationReanchorProof_StaleActionGeneration(t *testing.T) { + requiredConfirmations := uint(6) + + btcChain := newLocalBitcoinChain() + spvChain := newLocalChain() + + anchorTx := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{}, + OutputIndex: 0, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 600000, + PublicKeyScript: []byte{}, + }}, + } + if err := btcChain.BroadcastTransaction(anchorTx); err != nil { + t.Fatal(err) + } + + targetWalletPKH := [20]byte{0x92, 0xa6, 0xec, 0x88, 0x9a, 0x8f, 0xa3, 0x4f, 0x73, 0x1e, 0x63, 0x9e, 0xde, 0xde, 0x4c, 0x75, 0xe1, 0x84, 0x30, 0x7c} + targetScript, err := bitcoin.PayToWitnessPublicKeyHash(targetWalletPKH) + if err != nil { + t.Fatal(err) + } + + reanchorTx := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTx.Hash(), + OutputIndex: 0, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 590000, + PublicKeyScript: targetScript, + }}, + } + if err := btcChain.BroadcastTransaction(reanchorTx); err != nil { + t.Fatal(err) + } + + proof := &bitcoin.SpvProof{ + MerkleProof: []byte{0x01}, + TxIndexInBlock: 2, + BitcoinHeaders: []byte{0x03}, + } + mockSpvProofAssembler := func( + hash bitcoin.Hash, + confirmations uint, + btcChain bitcoin.Chain, + ) (*bitcoin.Transaction, *bitcoin.SpvProof, error) { + return reanchorTx, proof, nil + } + + reservationKey := big.NewInt(99) + staleNonce := uint64(7) + currentNonce := uint64(8) + + spvChain.setReservationByAnchorUtxo(anchorTx.Hash(), 0, reservationKey) + spvChain.setReservation(reservationKey, &tbtc.Reservation{ + WalletPublicKeyHash: targetWalletPKH, + AnchorUtxo: &bitcoin.UnspentTransactionOutput{ + Outpoint: reanchorTx.Inputs[0].Outpoint, + Value: 600000, + }, + State: tbtc.ReservationStateActive, + RequestNonce: currentNonce, + }) + // The action generation that actually produced reanchorTx (staleNonce) + // timed out; a new, unrelated action generation (currentNonce) is now + // pending. The reservation's RequestNonce always points at the latest + // generation, so the discovered transaction must not be paired with it. + spvChain.setReservationAction(reservationKey, staleNonce, &tbtc.ReservationAction{ + ActionType: tbtc.ReservationActionTypeReanchor, + State: tbtc.ReservationActionStateTimedOut, + TargetWalletPublicKeyHash: targetWalletPKH, + }) + spvChain.setReservationAction(reservationKey, currentNonce, &tbtc.ReservationAction{ + ActionType: tbtc.ReservationActionTypeDissolution, + State: tbtc.ReservationActionStatePending, + }) + + hookCalled := false + spvChain.submitReservationProofHook = func( + proofType uint8, + txInfo *tbtc.BitcoinTxInfo, + txProof *tbtc.BitcoinTxProof, + mainUtxo *tbtc.BitcoinTxUTXO, + rk *big.Int, + rn uint64, + ) error { + hookCalled = true + return nil + } + + err = submitDiscoveredReservationReanchorProof( + reanchorTx.Hash(), + requiredConfirmations, + btcChain, + spvChain, + mockSpvProofAssembler, + ) + if err == nil { + t.Fatal("expected error for stale action generation, got nil") + } + if hookCalled { + t.Fatal("proof must not be submitted for a stale action generation") + } +} + +// TestSubmitDiscoveredReservationReanchorProof_MismatchedTargetWallet +// verifies that submission is rejected when the reservation's current +// pending re-anchor action generation targets a different wallet than the +// one the discovered transaction actually pays - evidence the transaction +// belongs to a superseded generation even though the current generation is +// also, coincidentally, a pending re-anchor. +func TestSubmitDiscoveredReservationReanchorProof_MismatchedTargetWallet(t *testing.T) { + requiredConfirmations := uint(6) + + btcChain := newLocalBitcoinChain() + spvChain := newLocalChain() + + anchorTx := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{}, + OutputIndex: 0, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 600000, + PublicKeyScript: []byte{}, + }}, + } + if err := btcChain.BroadcastTransaction(anchorTx); err != nil { + t.Fatal(err) + } + + oldTargetWalletPKH := [20]byte{0x92, 0xa6, 0xec, 0x88, 0x9a, 0x8f, 0xa3, 0x4f, 0x73, 0x1e, 0x63, 0x9e, 0xde, 0xde, 0x4c, 0x75, 0xe1, 0x84, 0x30, 0x7c} + oldTargetScript, err := bitcoin.PayToWitnessPublicKeyHash(oldTargetWalletPKH) + if err != nil { + t.Fatal(err) + } + newTargetWalletPKH := [20]byte{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0x00, 0x12, 0x34, 0x56, 0x78} + + reanchorTx := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTx.Hash(), + OutputIndex: 0, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 590000, + PublicKeyScript: oldTargetScript, + }}, + } + if err := btcChain.BroadcastTransaction(reanchorTx); err != nil { + t.Fatal(err) + } + + proof := &bitcoin.SpvProof{ + MerkleProof: []byte{0x01}, + TxIndexInBlock: 2, + BitcoinHeaders: []byte{0x03}, + } + mockSpvProofAssembler := func( + hash bitcoin.Hash, + confirmations uint, + btcChain bitcoin.Chain, + ) (*bitcoin.Transaction, *bitcoin.SpvProof, error) { + return reanchorTx, proof, nil + } + + reservationKey := big.NewInt(100) + requestNonce := uint64(3) + + spvChain.setReservationByAnchorUtxo(anchorTx.Hash(), 0, reservationKey) + spvChain.setReservation(reservationKey, &tbtc.Reservation{ + WalletPublicKeyHash: oldTargetWalletPKH, + AnchorUtxo: &bitcoin.UnspentTransactionOutput{ + Outpoint: reanchorTx.Inputs[0].Outpoint, + Value: 600000, + }, + State: tbtc.ReservationStateActive, + RequestNonce: requestNonce, + }) + // A new re-anchor request superseded the one that produced reanchorTx, + // this time targeting a different wallet, before reanchorTx's proof was + // submitted. + spvChain.setReservationAction(reservationKey, requestNonce, &tbtc.ReservationAction{ + ActionType: tbtc.ReservationActionTypeReanchor, + State: tbtc.ReservationActionStatePending, + TargetWalletPublicKeyHash: newTargetWalletPKH, + }) + + hookCalled := false + spvChain.submitReservationProofHook = func( + proofType uint8, + txInfo *tbtc.BitcoinTxInfo, + txProof *tbtc.BitcoinTxProof, + mainUtxo *tbtc.BitcoinTxUTXO, + rk *big.Int, + rn uint64, + ) error { + hookCalled = true + return nil + } + + err = submitDiscoveredReservationReanchorProof( + reanchorTx.Hash(), + requiredConfirmations, + btcChain, + spvChain, + mockSpvProofAssembler, + ) + if err == nil { + t.Fatal("expected error for mismatched target wallet, got nil") + } + if hookCalled { + t.Fatal("proof must not be submitted for a mismatched action generation") + } +} From 7824ad211293b995ecfa9403526035c91a7bdf21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 1 Sep 2026 14:13:34 +0000 Subject: [PATCH 04/12] docs(spv): correct nonce-staleness window framing in code comment The prior comment framed the race as spanning proveTransactions waiting out requiredConfirmations. In fact the getter and submitter run back-to-back within the same proveTransactions call for a given transaction (spv.go:229 getter, :286 submitter); under-confirmed transactions are skipped and re-discovered on the next tick, not held. The staleness window is the narrow same-call gap between the getter's per-event Pending check and the submitter call, not a multi-block confirmation wait. The fix itself (verify the current action generation before submitting) is unchanged and still correct - only the severity/likelihood framing in the comment was wrong. --- .../spv/reservation_reanchor_proof.go | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/pkg/maintainer/spv/reservation_reanchor_proof.go b/pkg/maintainer/spv/reservation_reanchor_proof.go index c6bd4035e1..8db3e6dad8 100644 --- a/pkg/maintainer/spv/reservation_reanchor_proof.go +++ b/pkg/maintainer/spv/reservation_reanchor_proof.go @@ -418,18 +418,25 @@ func reservationReanchorTransactionProofSubmitter( // // Deriving the request nonce is not as simple as reading the reservation's // current RequestNonce: that field tracks the reservation's live action -// generation, which can have moved on since this transaction was discovered -// (e.g. the original re-anchor action timed out and a new action generation, -// possibly with a different target wallet, was requested while this -// function's caller was waiting out requiredConfirmations). Submitting the -// live nonce for a stale transaction would pair an old, unrelated re-anchor -// transaction with the wrong action generation. To guard against that, this -// function fetches the action generation at the reservation's current nonce -// and requires it to still be a Pending Reanchor action targeting the exact -// wallet this transaction actually pays before treating the current nonce as -// correct for this transaction; any mismatch is reported as an error so the -// proof loop treats the transaction as not-yet-submittable rather than -// silently misattributing the proof. +// generation, which can have moved on since this transaction was +// discovered. proveTransactions (spv.go) calls the getter and, moments +// later in the same call, the submitter for each sufficiently-confirmed +// transaction it found - a narrow same-tick window, not a wait across +// confirmations (under-confirmed transactions are skipped outright and +// re-discovered, not held, on the next tick). Within that window it is +// still possible for the reservation's action generation to advance (e.g. +// the re-anchor action this transaction belongs to times out and a new, +// unrelated action generation - possibly targeting a different wallet - is +// requested before the submitter call for this transaction runs). +// Submitting the live nonce for a stale transaction would pair an old, +// unrelated re-anchor transaction with the wrong action generation. To +// guard against that, this function fetches the action generation at the +// reservation's current nonce and requires it to still be a Pending +// Reanchor action targeting the exact wallet this transaction actually +// pays before treating the current nonce as correct for this transaction; +// any mismatch is reported as an error so the proof loop treats the +// transaction as not-yet-submittable rather than silently misattributing +// the proof. func submitDiscoveredReservationReanchorProof( transactionHash bitcoin.Hash, requiredConfirmations uint, From 64fc398b31a4bf97c384b6f04d20363ad1b5978f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 1 Sep 2026 14:16:21 +0000 Subject: [PATCH 05/12] fix(spv): skip, don't abort the round, on stale reservation re-anchor nonce An error returned from transactionProofSubmitter propagates out of proveTransactions (spv.go:292-293), aborting the entire proving round for every other in-flight transaction across every proof type that tick, then restarting the whole SPV maintainer after the backoff. That is disproportionate for the two mismatch branches added in the prior commit (stale/superseded action generation, mismatched target wallet): both are an expected, if rare, outcome of a narrow same-tick race, not an infrastructure failure. Both branches now log a warning and return nil instead of an error, so proveTransactions treats the transaction as handled and moves on to the next one - it will simply not be rediscovered on the next tick since its action generation is no longer Pending. Flips both regression tests to assert a nil error and that the submission hook was not called, matching the corrected behavior. --- .../spv/reservation_reanchor_proof.go | 45 +++++++++++++------ .../spv/reservation_reanchor_proof_test.go | 32 +++++++++---- 2 files changed, 56 insertions(+), 21 deletions(-) diff --git a/pkg/maintainer/spv/reservation_reanchor_proof.go b/pkg/maintainer/spv/reservation_reanchor_proof.go index 8db3e6dad8..c6c8f3004b 100644 --- a/pkg/maintainer/spv/reservation_reanchor_proof.go +++ b/pkg/maintainer/spv/reservation_reanchor_proof.go @@ -434,9 +434,12 @@ func reservationReanchorTransactionProofSubmitter( // reservation's current nonce and requires it to still be a Pending // Reanchor action targeting the exact wallet this transaction actually // pays before treating the current nonce as correct for this transaction; -// any mismatch is reported as an error so the proof loop treats the -// transaction as not-yet-submittable rather than silently misattributing -// the proof. +// any mismatch is logged and the transaction is skipped (nil error) rather +// than submitted with the wrong nonce - returning an error here would +// propagate out of proveTransactions (spv.go:292-293) and abort the entire +// proving round for every other in-flight transaction across every proof +// type this tick, which is disproportionate for what is an expected, if +// rare, race outcome rather than an infrastructure failure. func submitDiscoveredReservationReanchorProof( transactionHash bitcoin.Hash, requiredConfirmations uint, @@ -498,25 +501,41 @@ func submitDiscoveredReservationReanchorProof( if action.ActionType != tbtc.ReservationActionTypeReanchor || action.State != tbtc.ReservationActionStatePending { - return fmt.Errorf( - "reservation [%v]'s current action generation [%d] is no "+ - "longer a pending re-anchor; the discovered transaction "+ - "[%s] belongs to a superseded generation", + // A returned error here would propagate out of proveTransactions + // (spv.go:292-293) and abort the entire proving round for every + // other in-flight transaction across every proof type this tick, + // then restart the whole SPV maintainer after the backoff. That is + // disproportionate for what is an expected, if rare, outcome of the + // narrow same-tick race described above, so this case is logged and + // skipped instead: the transaction is left unproven and will simply + // not be rediscovered by getUnprovenReservationReanchorTransactions + // on the next tick, since its action generation is no longer + // Pending. + logger.Warnf( + "skipping reservation re-anchor proof submission for "+ + "transaction [%s]: reservation [%v]'s current action "+ + "generation [%d] is no longer a pending re-anchor; the "+ + "transaction belongs to a superseded generation", + transactionHash.Hex(bitcoin.ReversedByteOrder), reservationKey, reservation.RequestNonce, - transactionHash.Hex(bitcoin.ReversedByteOrder), ) + return nil } if action.TargetWalletPublicKeyHash != targetWalletPublicKeyHash { - return fmt.Errorf( - "reservation [%v]'s current action generation [%d] targets a "+ - "different wallet than the discovered transaction [%s]; "+ - "the transaction belongs to a superseded generation", + // See the comment above: skipped, not erred, for the same reason. + logger.Warnf( + "skipping reservation re-anchor proof submission for "+ + "transaction [%s]: reservation [%v]'s current action "+ + "generation [%d] targets a different wallet than the "+ + "transaction actually pays; the transaction belongs to a "+ + "superseded generation", + transactionHash.Hex(bitcoin.ReversedByteOrder), reservationKey, reservation.RequestNonce, - transactionHash.Hex(bitcoin.ReversedByteOrder), ) + return nil } return submitReservationReanchorProof( diff --git a/pkg/maintainer/spv/reservation_reanchor_proof_test.go b/pkg/maintainer/spv/reservation_reanchor_proof_test.go index 3e4afbc6af..c09b025b49 100644 --- a/pkg/maintainer/spv/reservation_reanchor_proof_test.go +++ b/pkg/maintainer/spv/reservation_reanchor_proof_test.go @@ -552,10 +552,13 @@ func TestSubmitDiscoveredReservationReanchorProof(t *testing.T) { } // TestSubmitDiscoveredReservationReanchorProof_StaleActionGeneration verifies -// that submission is rejected, not misattributed, when the reservation's +// that submission is skipped, not misattributed, when the reservation's // current action generation has moved past the one that produced the // discovered transaction (e.g. the original re-anchor action timed out and a -// new, unrelated action generation is now current). +// new, unrelated action generation is now current). This must return a nil +// error (not an error) since an error here would abort the entire +// proveTransactions round for every other in-flight transaction across +// every proof type this tick (spv.go:292-293). func TestSubmitDiscoveredReservationReanchorProof_StaleActionGeneration(t *testing.T) { requiredConfirmations := uint(6) @@ -663,8 +666,13 @@ func TestSubmitDiscoveredReservationReanchorProof_StaleActionGeneration(t *testi spvChain, mockSpvProofAssembler, ) - if err == nil { - t.Fatal("expected error for stale action generation, got nil") + if err != nil { + t.Fatalf( + "expected nil error for a stale action generation (the "+ + "caller must not abort the whole proving round for a "+ + "skip), got: %v", + err, + ) } if hookCalled { t.Fatal("proof must not be submitted for a stale action generation") @@ -672,11 +680,14 @@ func TestSubmitDiscoveredReservationReanchorProof_StaleActionGeneration(t *testi } // TestSubmitDiscoveredReservationReanchorProof_MismatchedTargetWallet -// verifies that submission is rejected when the reservation's current +// verifies that submission is skipped when the reservation's current // pending re-anchor action generation targets a different wallet than the // one the discovered transaction actually pays - evidence the transaction // belongs to a superseded generation even though the current generation is -// also, coincidentally, a pending re-anchor. +// also, coincidentally, a pending re-anchor. This must return a nil error +// (not an error) since an error here would abort the entire +// proveTransactions round for every other in-flight transaction across +// every proof type this tick (spv.go:292-293). func TestSubmitDiscoveredReservationReanchorProof_MismatchedTargetWallet(t *testing.T) { requiredConfirmations := uint(6) @@ -779,8 +790,13 @@ func TestSubmitDiscoveredReservationReanchorProof_MismatchedTargetWallet(t *test spvChain, mockSpvProofAssembler, ) - if err == nil { - t.Fatal("expected error for mismatched target wallet, got nil") + if err != nil { + t.Fatalf( + "expected nil error for a mismatched target wallet (the "+ + "caller must not abort the whole proving round for a "+ + "skip), got: %v", + err, + ) } if hookCalled { t.Fatal("proof must not be submitted for a mismatched action generation") From e052162fe87f9ceccde418a40efb140a095d8c37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 1 Sep 2026 14:22:33 +0000 Subject: [PATCH 06/12] docs(spv): correct won't-recur claim on stale/mismatched reanchor skips Both skip branches previously claimed the transaction 'will simply not be rediscovered' on later ticks. That relied on an unverified assumption - that the Bridge allows at most one Pending action per reservation at a time - which is asserted nowhere in this Go client and could not be confirmed against the on-chain source for this action-generation model (not available in the local tbtc-v2 checkout). If that assumption is false, getUnprovenReservation- ReanchorTransactions' per-event Pending check would keep returning the same transaction and both branches would log the same warning every tick. Replaced with the honest, verifiable termination condition: this outpoint stops matching once the reservation's current generation lands its own correct re-anchor proof, at which point the existing 'no reservation is anchored at the spent outpoint' branch takes over instead. No behavior change - comment accuracy only. --- .../spv/reservation_reanchor_proof.go | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/pkg/maintainer/spv/reservation_reanchor_proof.go b/pkg/maintainer/spv/reservation_reanchor_proof.go index c6c8f3004b..a1db4924ef 100644 --- a/pkg/maintainer/spv/reservation_reanchor_proof.go +++ b/pkg/maintainer/spv/reservation_reanchor_proof.go @@ -439,7 +439,9 @@ func reservationReanchorTransactionProofSubmitter( // propagate out of proveTransactions (spv.go:292-293) and abort the entire // proving round for every other in-flight transaction across every proof // type this tick, which is disproportionate for what is an expected, if -// rare, race outcome rather than an infrastructure failure. +// rare, race outcome rather than an infrastructure failure. See the +// per-branch comments below for why this skip is not guaranteed to be a +// one-time event for a given transaction. func submitDiscoveredReservationReanchorProof( transactionHash bitcoin.Hash, requiredConfirmations uint, @@ -507,10 +509,22 @@ func submitDiscoveredReservationReanchorProof( // then restart the whole SPV maintainer after the backoff. That is // disproportionate for what is an expected, if rare, outcome of the // narrow same-tick race described above, so this case is logged and - // skipped instead: the transaction is left unproven and will simply - // not be rediscovered by getUnprovenReservationReanchorTransactions - // on the next tick, since its action generation is no longer - // Pending. + // skipped instead. + // + // This transaction may be rediscovered and re-warned on subsequent + // ticks too: whether getUnprovenReservationReanchorTransactions + // stops returning it depends on the Bridge guaranteeing at most one + // Pending action per reservation at a time, which is NOT asserted + // anywhere in this Go client and was not available to verify + // against the on-chain source for this action-generation model. + // The termination condition that IS guaranteed: once the + // reservation's current generation lands its own correct re-anchor + // proof, the Bridge clears this outpoint's anchor registration, + // ReservationByAnchorUtxo stops matching this transaction, and it + // falls into the "no reservation is anchored at the spent + // outpoint" error path above instead - so the repetition is + // bounded, just not by the mechanism the original version of this + // comment claimed. logger.Warnf( "skipping reservation re-anchor proof submission for "+ "transaction [%s]: reservation [%v]'s current action "+ @@ -524,7 +538,10 @@ func submitDiscoveredReservationReanchorProof( } if action.TargetWalletPublicKeyHash != targetWalletPublicKeyHash { - // See the comment above: skipped, not erred, for the same reason. + // Same reasoning as the branch above: skipped rather than erred to + // avoid aborting the round, and may recur on subsequent ticks + // under the same unverified-invariant caveat until the current + // generation's own proof lands and clears the anchor registration. logger.Warnf( "skipping reservation re-anchor proof submission for "+ "transaction [%s]: reservation [%v]'s current action "+ From 9f09fb344909a53e906d327ecd6f8cff101cf930 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 2 Sep 2026 13:04:51 +0000 Subject: [PATCH 07/12] feat(tbtc): add String() methods for ReservationActionType/State Lets callers log the actual observed action type/state instead of a bare uint8 via %v. --- pkg/tbtc/reservation.go | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/pkg/tbtc/reservation.go b/pkg/tbtc/reservation.go index f856864a0b..e761e50e28 100644 --- a/pkg/tbtc/reservation.go +++ b/pkg/tbtc/reservation.go @@ -90,6 +90,23 @@ const ( ReservationActionTypeDissolution ) +func (t ReservationActionType) String() string { + switch t { + case ReservationActionTypeNone: + return "None" + case ReservationActionTypeAcceptance: + return "Acceptance" + case ReservationActionTypeRedemption: + return "Redemption" + case ReservationActionTypeReanchor: + return "Reanchor" + case ReservationActionTypeDissolution: + return "Dissolution" + default: + return fmt.Sprintf("ReservationActionType(%d)", uint8(t)) + } +} + // ReservationActionState represents the settlement state of a reservation // action generation. type ReservationActionState uint8 @@ -103,6 +120,25 @@ const ( ReservationActionStateSuperseded ) +func (s ReservationActionState) String() string { + switch s { + case ReservationActionStateUnknown: + return "Unknown" + case ReservationActionStatePending: + return "Pending" + case ReservationActionStateSettled: + return "Settled" + case ReservationActionStateTimedOut: + return "TimedOut" + case ReservationActionStateVetoed: + return "Vetoed" + case ReservationActionStateSuperseded: + return "Superseded" + default: + return fmt.Sprintf("ReservationActionState(%d)", uint8(s)) + } +} + // ReservationAction represents one nonce-bound generation of a reservation // action. All authorization data used to construct and settle the action is // snapshotted when the generation is requested. From fc128f0b93f76072a8917e5adc4171eaa55a4536 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 2 Sep 2026 13:04:51 +0000 Subject: [PATCH 08/12] fix(spv): correct reservation action re-check guard behavior and coverage verifyReservationActionStillProvable's doc comment overclaimed that it closes a submission-time race; submitReservationActionProof's own pre-existing re-fetch+check (right before SubmitReservationProof) is what actually prevents an incorrect or misdirected submission. Rewrite the comment to state the guard's real purpose: distinguishing an expected, benign skip (Warn-logged, deliberately not counted as a failed submission attempt) from a genuine error. Fix the skip Warnf to report the actually observed action type/state (via the new String() methods) instead of only the caller-supplied expected type formatted with bare %v. Fix a false-success log: a guard skip inside the submit closure returned nil, so proveReservationTransaction always logged 'successfully submitted proof' even when nothing was submitted. Add a sentinel error (errReservationActionNoLongerProvable) so the skip path is distinguishable from both a real submission and a real failure. Extract the two submit closures into named submitReservationAcceptanceActionProof / submitReservationReanchorActionProof functions so the re-anchor closure's wallet-field selection (event.TargetWalletPublicKeyHash, not SourceWalletPublicKeyHash) can be unit-tested directly, without going through Bitcoin transaction discovery (this package's local chain test double can only discover a transaction via the source wallet's outputs, which forces source and target to coincide in any end-to-end test and so can't catch a field swap between them). Test changes: - Fix TestVerifyReservationActionStillProvable_WrongActionType's fixture leaving TargetWalletPublicKeyHash at its zero value, which let the wallet-mismatch branch mask the action-type branch under test. - Consolidate the five near-duplicate TestVerifyReservationActionStillProvable_* tests into one table-driven TestVerifyReservationActionStillProvable, add a case for a genuine chain-read error (via the new localChain.getReservationActionErr injection field, instead of relying on 'no action installed' as an implicit error trigger) and a case for an absent/zero-value action (matching what the real chain adapter actually returns for a never-set entry). - Remove stale/inaccurate doc comments (dangling references to a 'prior design' and 'generic-loop adapter' that don't exist in this repo; a claim that a propagated error would abort the whole proving pass, when call sites always log-and-continue per event). - Add loop-level regression cases to TestProveReservationAcceptanceActions / TestProveReservationReanchorActions asserting zero submissions when the action is no longer pending at submission time. - Add TestSubmitReservationReanchorActionProof_UsesTargetWallet, which calls the extracted function directly (bypassing discovery) with a genuinely distinct source/target wallet, to catch a regression that swaps the two fields at the call site. --- pkg/maintainer/spv/chain_test.go | 5 + pkg/maintainer/spv/reservation_proof_loop.go | 167 +++-- .../spv/reservation_proof_loop_test.go | 643 ++++++++++++++---- 3 files changed, 613 insertions(+), 202 deletions(-) diff --git a/pkg/maintainer/spv/chain_test.go b/pkg/maintainer/spv/chain_test.go index 33cf5485a9..4e4027dd9f 100644 --- a/pkg/maintainer/spv/chain_test.go +++ b/pkg/maintainer/spv/chain_test.go @@ -106,6 +106,7 @@ type localChain struct { // Error-injection fields for the reservation watcher chain-error // passthrough tests: nil (the default) means the corresponding method // falls through to its normal, table-driven behavior. + getReservationActionErr error walletReservationsErr error isReservedDepositErr error reservedDepositWalletErr error @@ -952,6 +953,10 @@ func (lc *localChain) GetReservationAction( lc.mutex.Lock() defer lc.mutex.Unlock() + if lc.getReservationActionErr != nil { + return nil, lc.getReservationActionErr + } + key := buildReservationActionKey(reservationKey, requestNonce) action, ok := lc.reservationActions[key] if !ok { diff --git a/pkg/maintainer/spv/reservation_proof_loop.go b/pkg/maintainer/spv/reservation_proof_loop.go index f09ec36169..9dbaf0a4f7 100644 --- a/pkg/maintainer/spv/reservation_proof_loop.go +++ b/pkg/maintainer/spv/reservation_proof_loop.go @@ -2,6 +2,7 @@ package spv import ( "context" + "errors" "fmt" "math/big" "time" @@ -11,6 +12,13 @@ import ( "github.com/keep-network/keep-core/pkg/tbtc" ) +// errReservationActionNoLongerProvable is returned when a discovered +// transaction's action generation is no longer provable at submission time. +// This is an expected, benign skip rather than a submission failure. +var errReservationActionNoLongerProvable = errors.New( + "reservation action generation is no longer provable", +) + // reservationProofLookBackBlocks bounds the pending-action-request event // scan performed on the very first pass, before an incremental cursor // exists. Mirrors ReservationAcceptanceLookBackBlocks / @@ -22,19 +30,19 @@ const reservationProofLookBackBlocks = uint64(216000) // submission and confirms it is still the exact pending action generation // the discovered transaction was found for. // -// proveReservationAcceptanceActions/proveReservationReanchorActions check -// Pending once near the top of their loop, then run a Bitcoin -// transaction-history scan before reaching the submit call - a window in -// which the action generation could settle, time out, or be superseded. -// This closes that window with a second, submission-time check. +// Its purpose is to distinguish an expected, benign "this action generation is +// no longer the exact pending one" outcome (Warn-logged, and skipped so it is +// treated as "never attempted" rather than counted as a failed submission +// attempt by metricsRecorder) from a genuine chain-read error (propagated to +// the caller) or a genuine logic error caught later inside +// submitReservationActionProof (which remains the authoritative +// pre-submission check — it re-fetches the action itself right before +// SubmitReservationProof and is what actually prevents an incorrect or +// misdirected submission). // -// Whether a stale generation's action record could still read Pending -// after a superseding generation exists is an unverified on-chain -// assumption (see reservation_reanchor_proof.go's doc comments on -// submitReservationReanchorProof's discovery counterpart in the prior -// design) - this check does not resolve that, it only shrinks the window -// in which it could matter and skips, rather than misdirects a -// submission, if it does. +// This function does not, by itself, close any submission-correctness race — +// it only produces cleaner logs and metrics for an expected outcome that +// submitReservationActionProof's own checks already handle safely either way. func verifyReservationActionStillProvable( spvChain Chain, reservationKey *big.Int, @@ -56,11 +64,13 @@ func verifyReservationActionStillProvable( action.State != tbtc.ReservationActionStatePending { logger.Warnf( "skipping reservation proof submission for reservation "+ - "[%v]'s action generation [%d]: no longer a pending %v "+ - "action at submission time", + "[%v]'s action generation [%d]: action generation is now "+ + "%s/%s, no longer the expected pending %s action", reservationKey, requestNonce, - expectedActionType, + action.ActionType.String(), + action.State.String(), + expectedActionType.String(), ) return false, nil } @@ -79,6 +89,87 @@ func verifyReservationActionStillProvable( return true, nil } +// submitReservationAcceptanceActionProof re-verifies that event's action +// generation is still the exact pending one the discovered transaction was +// found for, then submits its SPV proof. Extracted out of +// proveReservationAcceptanceActions' submit callback so the wallet +// argument passed to verifyReservationActionStillProvable +// (event.WalletPublicKeyHash) can be exercised directly in a unit test, +// without going through Bitcoin transaction discovery. +func submitReservationAcceptanceActionProof( + spvChain Chain, + btcChain bitcoin.Chain, + event *tbtc.ReservationAcceptanceRequestedEvent, + transactionHash bitcoin.Hash, + requiredConfirmations uint, +) error { + stillProvable, err := verifyReservationActionStillProvable( + spvChain, + event.ReservationKey, + event.RequestNonce, + tbtc.ReservationActionTypeAcceptance, + event.WalletPublicKeyHash, + ) + if err != nil { + return err + } + if !stillProvable { + return errReservationActionNoLongerProvable + } + + return SubmitReservationAcceptanceProof( + transactionHash, + requiredConfirmations, + event.ReservationKey, + event.RequestNonce, + btcChain, + spvChain, + ) +} + +// submitReservationReanchorActionProof re-verifies that event's action +// generation is still the exact pending one the discovered transaction was +// found for, then submits its SPV proof. Extracted out of +// proveReservationReanchorActions' submit callback so the +// target-vs-source wallet-hash field selection passed to +// verifyReservationActionStillProvable (event.TargetWalletPublicKeyHash, +// not event.SourceWalletPublicKeyHash — a re-anchor event carries both) +// can be exercised directly in a unit test, without going through Bitcoin +// transaction discovery: this package's local test double can only +// discover a transaction via the source wallet's outputs, which forces +// the two fields to coincide by construction in any end-to-end test and +// so cannot catch a swap between them. +func submitReservationReanchorActionProof( + spvChain Chain, + btcChain bitcoin.Chain, + event *tbtc.ReservationReanchorRequestedEvent, + transactionHash bitcoin.Hash, + requiredConfirmations uint, +) error { + stillProvable, err := verifyReservationActionStillProvable( + spvChain, + event.ReservationKey, + event.RequestNonce, + tbtc.ReservationActionTypeReanchor, + event.TargetWalletPublicKeyHash, + ) + if err != nil { + return err + } + if !stillProvable { + return errReservationActionNoLongerProvable + } + + return SubmitReservationReanchorProof( + transactionHash, + requiredConfirmations, + event.ReservationKey, + event.RequestNonce, + btcChain, + spvChain, + ) +} + // reservationAcceptanceWalletEvent adapts // *tbtc.ReservationAcceptanceRequestedEvent to the walletEvent interface // (see spv.go) so uniqueWalletPublicKeyHashes can be reused here instead of @@ -390,27 +481,12 @@ func proveReservationAcceptanceActions( spvChain, btcDiffChain, func(transactionHash bitcoin.Hash, requiredConfirmations uint) error { - stillProvable, err := verifyReservationActionStillProvable( + return submitReservationAcceptanceActionProof( spvChain, - event.ReservationKey, - event.RequestNonce, - tbtc.ReservationActionTypeAcceptance, - event.WalletPublicKeyHash, - ) - if err != nil { - return err - } - if !stillProvable { - return nil - } - - return SubmitReservationAcceptanceProof( + btcChain, + event, transactionHash, requiredConfirmations, - event.ReservationKey, - event.RequestNonce, - btcChain, - spvChain, ) }, ); err != nil { @@ -587,27 +663,12 @@ func proveReservationReanchorActions( spvChain, btcDiffChain, func(transactionHash bitcoin.Hash, requiredConfirmations uint) error { - stillProvable, err := verifyReservationActionStillProvable( + return submitReservationReanchorActionProof( spvChain, - event.ReservationKey, - event.RequestNonce, - tbtc.ReservationActionTypeReanchor, - event.TargetWalletPublicKeyHash, - ) - if err != nil { - return err - } - if !stillProvable { - return nil - } - - return SubmitReservationReanchorProof( + btcChain, + event, transactionHash, requiredConfirmations, - event.ReservationKey, - event.RequestNonce, - btcChain, - spvChain, ) }, ); err != nil { @@ -693,6 +754,10 @@ func proveReservationTransaction( } if err := submit(transaction.Hash(), requiredConfirmations); err != nil { + if errors.Is(err, errReservationActionNoLongerProvable) { + return nil + } + return err } diff --git a/pkg/maintainer/spv/reservation_proof_loop_test.go b/pkg/maintainer/spv/reservation_proof_loop_test.go index e0a37b6ca4..7c6901ccb6 100644 --- a/pkg/maintainer/spv/reservation_proof_loop_test.go +++ b/pkg/maintainer/spv/reservation_proof_loop_test.go @@ -499,6 +499,118 @@ func TestProveReservationAcceptanceActions(t *testing.T) { submittedRequestNonce, ) } + + // Regression test: when the reservation action for a discovered transaction + // is no longer Pending at submission time, zero submissions occur. + t.Run("skip when action no longer pending", func(t *testing.T) { + const proofStart = 790270 + diff := func(d int64) *big.Int { return big.NewInt(d) } + + spvChain := newLocalChain() + btcChain := newLocalBitcoinChain() + + if err := populateBlockHeaders( + btcChain, + proofStart, + proofStart+19, + func(uint) *big.Int { return diff(32) }, + ); err != nil { + t.Fatal(err) + } + spvChain.setTxProofDifficultyFactor(big.NewInt(6)) + spvChain.setCurrentEpoch(392) + spvChain.setCurrentAndPrevEpochDifficulty(diff(32), diff(16)) + + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(1000) + spvChain.setBlockCounter(blockCounter) + + fundingTx := &bitcoin.Transaction{ + Outputs: []*bitcoin.TransactionOutput{{Value: 150000}}, + } + if err := btcChain.BroadcastTransaction(fundingTx); err != nil { + t.Fatal(err) + } + fundingTxHash := fundingTx.Hash() + reservationKey := spvChain.BuildDepositKey(fundingTxHash, 0) + const requestNonce = 1 + + walletPublicKeyHash := [20]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20} + walletScript, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + + transaction := &bitcoin.Transaction{ + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTxHash, + OutputIndex: 0, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 100000, + PublicKeyScript: walletScript, + }}, + } + if err := btcChain.BroadcastTransaction(transaction); err != nil { + t.Fatal(err) + } + if err := btcChain.addTransactionConfirmations( + transaction.Hash(), + 20, + ); err != nil { + t.Fatal(err) + } + btcChain.setCoinbaseTxHash(transaction.Hash()) + + // Set up a timed-out action (not pending) + spvChain.addReservationAcceptanceRequestedEvent(&tbtc.ReservationAcceptanceRequestedEvent{ + ReservationKey: reservationKey, + RequestNonce: requestNonce, + WalletPublicKeyHash: walletPublicKeyHash, + BlockNumber: 500, + }) + spvChain.setReservationAction( + reservationKey, + requestNonce, + &tbtc.ReservationAction{ + State: tbtc.ReservationActionStateTimedOut, // Not pending! + ActionType: tbtc.ReservationActionTypeAcceptance, + TargetWalletPublicKeyHash: walletPublicKeyHash, + }, + ) + + submissions := 0 + spvChain.submitReservationProofHook = func( + proofType uint8, + txInfo *tbtc.BitcoinTxInfo, + proof *tbtc.BitcoinTxProof, + mainUtxo *tbtc.BitcoinTxUTXO, + reservationKey *big.Int, + requestNonce uint64, + ) error { + submissions++ + return nil + } + + config := Config{TransactionLimit: 100} + + if err := proveReservationAcceptanceActions( + newReservationProofScanState(), + config, + spvChain, + spvChain, + btcChain, + ); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should have zero submissions because action is not pending + if submissions != 0 { + t.Fatalf("expected zero proofs submissions when action is not pending, got %d", submissions) + } + }) } // TestProveReservationReanchorActions is an end-to-end test of the @@ -648,186 +760,415 @@ func TestProveReservationReanchorActions(t *testing.T) { submittedRequestNonce, ) } -} -// TestVerifyReservationActionStillProvable_Pending verifies the happy path: -// the action generation is still pending, still the expected type, and -// still targets the expected wallet, so submission may proceed. -func TestVerifyReservationActionStillProvable_Pending(t *testing.T) { - spvChain := newLocalChain() + // Regression test: when the reservation action for a discovered transaction + // is no longer Pending at submission time, zero submissions occur. + t.Run("skip when action no longer pending", func(t *testing.T) { + const proofStart = 790270 + diff := func(d int64) *big.Int { return big.NewInt(d) } - reservationKey := big.NewInt(1) - requestNonce := uint64(5) - targetWalletPKH := [20]byte{0x01, 0x02, 0x03} + spvChain := newLocalChain() + btcChain := newLocalBitcoinChain() - spvChain.setReservationAction(reservationKey, requestNonce, &tbtc.ReservationAction{ - ActionType: tbtc.ReservationActionTypeReanchor, - State: tbtc.ReservationActionStatePending, - TargetWalletPublicKeyHash: targetWalletPKH, - }) + if err := populateBlockHeaders( + btcChain, + proofStart, + proofStart+19, + func(uint) *big.Int { return diff(32) }, + ); err != nil { + t.Fatal(err) + } + spvChain.setTxProofDifficultyFactor(big.NewInt(6)) + spvChain.setCurrentEpoch(392) + spvChain.setCurrentAndPrevEpochDifficulty(diff(32), diff(16)) - stillProvable, err := verifyReservationActionStillProvable( - spvChain, - reservationKey, - requestNonce, - tbtc.ReservationActionTypeReanchor, - targetWalletPKH, - ) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !stillProvable { - t.Fatal("expected the still-pending action generation to be provable") - } -} + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(1000) + spvChain.setBlockCounter(blockCounter) -// TestVerifyReservationActionStillProvable_StaleActionGeneration verifies -// that submission is skipped, without error, when the action generation at -// the given nonce is no longer pending (settled, timed out, or superseded) -// by the time the caller is ready to submit - mirroring the race the -// generic-loop adapter design (superseded by this dedicated loop) guarded -// against: a discovered transaction's action generation advancing between -// discovery and submission. A nil error matters here: an error would -// propagate out of proveReservationAcceptanceActions/ -// proveReservationReanchorActions and abort that pass for every other -// in-flight action generation this tick, which is disproportionate for -// what is an expected, if rare, race outcome rather than an infrastructure -// failure. -func TestVerifyReservationActionStillProvable_StaleActionGeneration(t *testing.T) { - spvChain := newLocalChain() + reservationKey := big.NewInt(424242) + const requestNonce = 2 - reservationKey := big.NewInt(2) - staleNonce := uint64(7) - targetWalletPKH := [20]byte{0x01, 0x02, 0x03} - - // The action generation that produced the discovered transaction timed - // out; the reservation may have since moved on to an unrelated action - // generation. - spvChain.setReservationAction(reservationKey, staleNonce, &tbtc.ReservationAction{ - ActionType: tbtc.ReservationActionTypeReanchor, - State: tbtc.ReservationActionStateTimedOut, - TargetWalletPublicKeyHash: targetWalletPKH, - }) + priorAnchorTx := &bitcoin.Transaction{ + Outputs: []*bitcoin.TransactionOutput{ + {Value: 10000}, + {Value: 600000}, + }, + } + if err := btcChain.BroadcastTransaction(priorAnchorTx); err != nil { + t.Fatal(err) + } + anchorTxHash := priorAnchorTx.Hash() + anchorUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHash, + OutputIndex: 1, + }, + Value: 600000, + } - stillProvable, err := verifyReservationActionStillProvable( - spvChain, - reservationKey, - staleNonce, - tbtc.ReservationActionTypeReanchor, - targetWalletPKH, - ) - if err != nil { - t.Fatalf( - "expected nil error for a stale action generation (the "+ - "caller must not abort the whole proving round for a "+ - "skip), got: %v", - err, + sourceWalletPublicKeyHash := [20]byte{21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40} + walletScript, err := bitcoin.PayToWitnessPublicKeyHash(sourceWalletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + + transaction := &bitcoin.Transaction{ + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHash, + OutputIndex: 1, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 590000, + PublicKeyScript: walletScript, + }}, + } + if err := btcChain.BroadcastTransaction(transaction); err != nil { + t.Fatal(err) + } + if err := btcChain.addTransactionConfirmations( + transaction.Hash(), + 20, + ); err != nil { + t.Fatal(err) + } + btcChain.setCoinbaseTxHash(transaction.Hash()) + + // Set up a timed-out action (not pending) + spvChain.addReservationReanchorRequestedEvent(&tbtc.ReservationReanchorRequestedEvent{ + ReservationKey: reservationKey, + RequestNonce: requestNonce, + SourceWalletPublicKeyHash: sourceWalletPublicKeyHash, + TargetWalletPublicKeyHash: sourceWalletPublicKeyHash, + BlockNumber: 500, + }) + spvChain.setReservationAction( + reservationKey, + requestNonce, + &tbtc.ReservationAction{ + State: tbtc.ReservationActionStateTimedOut, // Not pending! + ActionType: tbtc.ReservationActionTypeReanchor, + TargetWalletPublicKeyHash: sourceWalletPublicKeyHash, + }, ) - } - if stillProvable { - t.Fatal("expected a timed-out action generation to be reported unprovable") - } + spvChain.setReservation(reservationKey, &tbtc.Reservation{ + AnchorUtxo: anchorUtxo, + }) + + submissions := 0 + spvChain.submitReservationProofHook = func( + proofType uint8, + txInfo *tbtc.BitcoinTxInfo, + proof *tbtc.BitcoinTxProof, + mainUtxo *tbtc.BitcoinTxUTXO, + reservationKey *big.Int, + requestNonce uint64, + ) error { + submissions++ + return nil + } + + config := Config{TransactionLimit: 100} + + if err := proveReservationReanchorActions( + newReservationProofScanState(), + config, + spvChain, + spvChain, + btcChain, + ); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should have zero submissions because action is not pending + if submissions != 0 { + t.Fatalf("expected zero proofs submissions when action is not pending, got %d", submissions) + } + }) } -// TestVerifyReservationActionStillProvable_WrongActionType verifies that -// submission is skipped when the action generation at the given nonce is -// pending but for a different action type than expected - e.g. the -// reservation moved on to a dissolution while the caller was still trying -// to prove a stale re-anchor transaction. -func TestVerifyReservationActionStillProvable_WrongActionType(t *testing.T) { +// TestSubmitReservationReanchorActionProof_UsesTargetWallet verifies that +// submitReservationReanchorActionProof re-checks the action generation +// against event.TargetWalletPublicKeyHash, not +// event.SourceWalletPublicKeyHash. TestProveReservationReanchorActions +// cannot catch a regression that swapped the two fields at the call site: +// this package's local Bitcoin-history test double can only discover a +// transaction via the source wallet's own outputs +// (localBitcoinChain.GetTransactionsForPublicKeyHash matches on output +// script), which forces source and target to coincide by construction in +// any test that goes through discovery. Calling +// submitReservationReanchorActionProof directly with a known transaction +// hash bypasses discovery, so source and target can differ here: the +// installed action authorizes only the target wallet, so passing Source +// instead of Target would make the guard wrongly skip the submission. +func TestSubmitReservationReanchorActionProof_UsesTargetWallet(t *testing.T) { + const proofStart = 790270 + diff := func(d int64) *big.Int { return big.NewInt(d) } + spvChain := newLocalChain() + btcChain := newLocalBitcoinChain() - reservationKey := big.NewInt(3) - requestNonce := uint64(8) - targetWalletPKH := [20]byte{0x01, 0x02, 0x03} + if err := populateBlockHeaders( + btcChain, + proofStart, + proofStart+19, + func(uint) *big.Int { return diff(32) }, + ); err != nil { + t.Fatal(err) + } + spvChain.setTxProofDifficultyFactor(big.NewInt(6)) + spvChain.setCurrentEpoch(392) + spvChain.setCurrentAndPrevEpochDifficulty(diff(32), diff(16)) - spvChain.setReservationAction(reservationKey, requestNonce, &tbtc.ReservationAction{ - ActionType: tbtc.ReservationActionTypeDissolution, - State: tbtc.ReservationActionStatePending, - }) + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(1000) + spvChain.setBlockCounter(blockCounter) - stillProvable, err := verifyReservationActionStillProvable( - spvChain, - reservationKey, - requestNonce, - tbtc.ReservationActionTypeReanchor, - targetWalletPKH, - ) - if err != nil { - t.Fatalf("expected nil error for a wrong action type, got: %v", err) + reservationKey := big.NewInt(555555) + const requestNonce = 9 + + priorAnchorTx := &bitcoin.Transaction{ + Outputs: []*bitcoin.TransactionOutput{ + {Value: 10000}, + {Value: 600000}, + }, } - if stillProvable { - t.Fatal("expected a mismatched action type to be reported unprovable") + if err := btcChain.BroadcastTransaction(priorAnchorTx); err != nil { + t.Fatal(err) } -} + anchorTxHash := priorAnchorTx.Hash() -// TestVerifyReservationActionStillProvable_MismatchedTargetWallet verifies -// that submission is skipped when the reservation's current pending action -// generation at the given nonce targets a different wallet than the one -// the discovered transaction actually pays - evidence the transaction -// belongs to a superseded generation even though the current generation is -// also, coincidentally, pending and of the expected type. -func TestVerifyReservationActionStillProvable_MismatchedTargetWallet(t *testing.T) { - spvChain := newLocalChain() + sourceWalletPublicKeyHash := [20]byte{21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40} + targetWalletPublicKeyHash := [20]byte{100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119} + walletScript, err := bitcoin.PayToWitnessPublicKeyHash(targetWalletPublicKeyHash) + if err != nil { + t.Fatal(err) + } - reservationKey := big.NewInt(4) - requestNonce := uint64(3) - oldTargetWalletPKH := [20]byte{0x92, 0xa6, 0xec, 0x88, 0x9a, 0x8f, 0xa3, 0x4f, 0x73, 0x1e} - newTargetWalletPKH := [20]byte{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x11, 0x22, 0x33, 0x44} - - // A new re-anchor request superseded the one that produced the - // discovered transaction, this time targeting a different wallet, - // before the discovered transaction's proof was submitted. - spvChain.setReservationAction(reservationKey, requestNonce, &tbtc.ReservationAction{ - ActionType: tbtc.ReservationActionTypeReanchor, - State: tbtc.ReservationActionStatePending, - TargetWalletPublicKeyHash: newTargetWalletPKH, - }) + transaction := &bitcoin.Transaction{ + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHash, + OutputIndex: 1, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 590000, + PublicKeyScript: walletScript, + }}, + } + if err := btcChain.BroadcastTransaction(transaction); err != nil { + t.Fatal(err) + } + if err := btcChain.addTransactionConfirmations( + transaction.Hash(), + 20, + ); err != nil { + t.Fatal(err) + } + btcChain.setCoinbaseTxHash(transaction.Hash()) - stillProvable, err := verifyReservationActionStillProvable( - spvChain, + // The on-chain action authorizes only the target wallet - genuinely + // distinct from the source wallet here, unlike the discovery-bound E2E + // test above. + spvChain.setReservationAction( reservationKey, requestNonce, - tbtc.ReservationActionTypeReanchor, - oldTargetWalletPKH, + &tbtc.ReservationAction{ + State: tbtc.ReservationActionStatePending, + ActionType: tbtc.ReservationActionTypeReanchor, + TargetWalletPublicKeyHash: targetWalletPublicKeyHash, + }, ) + + event := &tbtc.ReservationReanchorRequestedEvent{ + ReservationKey: reservationKey, + RequestNonce: requestNonce, + SourceWalletPublicKeyHash: sourceWalletPublicKeyHash, + TargetWalletPublicKeyHash: targetWalletPublicKeyHash, + } + + submissions := 0 + spvChain.submitReservationProofHook = func( + proofType uint8, + txInfo *tbtc.BitcoinTxInfo, + proof *tbtc.BitcoinTxProof, + mainUtxo *tbtc.BitcoinTxUTXO, + reservationKey *big.Int, + requestNonce uint64, + ) error { + submissions++ + return nil + } + + _, _, requiredConfirmations, err := getProofInfo(transaction.Hash(), btcChain, spvChain, spvChain) if err != nil { + t.Fatalf("failed to get proof info: %v", err) + } + + if err := submitReservationReanchorActionProof( + spvChain, + btcChain, + event, + transaction.Hash(), + requiredConfirmations, + ); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if submissions != 1 { t.Fatalf( - "expected nil error for a mismatched target wallet (the "+ - "caller must not abort the whole proving round for a "+ - "skip), got: %v", - err, + "expected exactly one proof submission using the target wallet, got %d", + submissions, ) } - if stillProvable { - t.Fatal("expected a mismatched target wallet to be reported unprovable") - } } -// TestVerifyReservationActionStillProvable_ChainError verifies that a -// chain-level error re-fetching the action generation is propagated to the -// caller, rather than silently treated as a skip - unlike a settled/ -// superseded action generation, a read failure gives no evidence either -// way and must not be treated as "safe to skip". -func TestVerifyReservationActionStillProvable_ChainError(t *testing.T) { - spvChain := newLocalChain() +// TestVerifyReservationActionStillProvable tests the guard that confirms a reservation action +// is still the expected pending generation at submission time. +func TestVerifyReservationActionStillProvable(t *testing.T) { + tests := map[string]struct { + setupFunc func(*localChain, *big.Int, uint64) + reservationKey *big.Int + requestNonce uint64 + targetWalletPKH [20]byte + expectedActionType tbtc.ReservationActionType + expectedTargetWalletPublicKeyHash [20]byte + expectedStillProvable bool + expectedWantErr bool + description string + }{ + "happy path": { + setupFunc: func(lc *localChain, reservationKey *big.Int, requestNonce uint64) { + lc.setReservationAction(reservationKey, requestNonce, &tbtc.ReservationAction{ + ActionType: tbtc.ReservationActionTypeReanchor, + State: tbtc.ReservationActionStatePending, + TargetWalletPublicKeyHash: [20]byte{0x01, 0x02, 0x03}, + }) + }, + reservationKey: big.NewInt(1), + requestNonce: uint64(5), + targetWalletPKH: [20]byte{0x01, 0x02, 0x03}, + expectedActionType: tbtc.ReservationActionTypeReanchor, + expectedTargetWalletPublicKeyHash: [20]byte{0x01, 0x02, 0x03}, + expectedStillProvable: true, + expectedWantErr: false, + description: "action generation is still pending, still the expected type, and still targets the expected wallet", + }, + "stale action generation": { + setupFunc: func(lc *localChain, reservationKey *big.Int, requestNonce uint64) { + lc.setReservationAction(reservationKey, requestNonce, &tbtc.ReservationAction{ + ActionType: tbtc.ReservationActionTypeReanchor, + State: tbtc.ReservationActionStateTimedOut, + TargetWalletPublicKeyHash: [20]byte{0x01, 0x02, 0x03}, + }) + }, + reservationKey: big.NewInt(2), + requestNonce: uint64(7), + targetWalletPKH: [20]byte{0x01, 0x02, 0x03}, + expectedActionType: tbtc.ReservationActionTypeReanchor, + expectedTargetWalletPublicKeyHash: [20]byte{0x01, 0x02, 0x03}, + expectedStillProvable: false, + expectedWantErr: false, + description: "action generation is no longer pending (timed out)", + }, + "wrong action type": { + setupFunc: func(lc *localChain, reservationKey *big.Int, requestNonce uint64) { + lc.setReservationAction(reservationKey, requestNonce, &tbtc.ReservationAction{ + ActionType: tbtc.ReservationActionTypeDissolution, + State: tbtc.ReservationActionStatePending, + TargetWalletPublicKeyHash: [20]byte{0x01, 0x02, 0x03}, // must match expected to isolate ActionType check + }) + }, + reservationKey: big.NewInt(3), + requestNonce: uint64(8), + targetWalletPKH: [20]byte{0x01, 0x02, 0x03}, + expectedActionType: tbtc.ReservationActionTypeReanchor, + expectedTargetWalletPublicKeyHash: [20]byte{0x01, 0x02, 0x03}, + expectedStillProvable: false, + expectedWantErr: false, + description: "action generation is Pending but for a different action type than expected", + }, + "mismatched target wallet": { + setupFunc: func(lc *localChain, reservationKey *big.Int, requestNonce uint64) { + lc.setReservationAction(reservationKey, requestNonce, &tbtc.ReservationAction{ + ActionType: tbtc.ReservationActionTypeReanchor, + State: tbtc.ReservationActionStatePending, + TargetWalletPublicKeyHash: [20]byte{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x11, 0x22, 0x33, 0x44}, + }) + }, + reservationKey: big.NewInt(4), + requestNonce: uint64(3), + targetWalletPKH: [20]byte{0x92, 0xa6, 0xec, 0x88, 0x9a, 0x8f, 0xa3, 0x4f, 0x73, 0x1e}, + expectedActionType: tbtc.ReservationActionTypeReanchor, + expectedTargetWalletPublicKeyHash: [20]byte{0x92, 0xa6, 0xec, 0x88, 0x9a, 0x8f, 0xa3, 0x4f, 0x73, 0x1e}, + expectedStillProvable: false, + expectedWantErr: false, + description: "action generation targets a different wallet than expected", + }, + "genuine chain error": { + setupFunc: func(lc *localChain, reservationKey *big.Int, requestNonce uint64) { + lc.getReservationActionErr = fmt.Errorf("simulated chain read failure") + }, + reservationKey: big.NewInt(5), + requestNonce: uint64(1), + targetWalletPKH: [20]byte{}, // unused when error expected + expectedActionType: tbtc.ReservationActionTypeReanchor, + expectedTargetWalletPublicKeyHash: [20]byte{}, // unused when error expected + expectedStillProvable: false, + expectedWantErr: true, + description: "chain-level error re-fetching the action generation", + }, + "absent/zero-value action": { + setupFunc: func(lc *localChain, reservationKey *big.Int, requestNonce uint64) { + // Install zero value action: ActionType==None, State==Unknown + lc.setReservationAction(reservationKey, requestNonce, &tbtc.ReservationAction{}) + }, + reservationKey: big.NewInt(6), + requestNonce: uint64(2), + targetWalletPKH: [20]byte{0x01, 0x02, 0x03}, + expectedActionType: tbtc.ReservationActionTypeReanchor, // expecting Reanchor but got None + expectedTargetWalletPublicKeyHash: [20]byte{0x01, 0x02, 0x03}, + expectedStillProvable: false, + expectedWantErr: false, + description: "zero-value action models missing on-chain entry (treated as skip)", + }, + } - reservationKey := big.NewInt(5) - requestNonce := uint64(1) + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + spvChain := newLocalChain() - // No action installed for this (reservationKey, requestNonce) pair, so - // GetReservationAction returns an error (see localChain.GetReservationAction). - stillProvable, err := verifyReservationActionStillProvable( - spvChain, - reservationKey, - requestNonce, - tbtc.ReservationActionTypeReanchor, - [20]byte{}, - ) - if err == nil { - t.Fatal("expected a chain error to be propagated, got nil") - } - if stillProvable { - t.Fatal("expected a chain error to report unprovable") + if test.setupFunc != nil { + test.setupFunc(spvChain, test.reservationKey, test.requestNonce) + } + + stillProvable, err := verifyReservationActionStillProvable( + spvChain, + test.reservationKey, + test.requestNonce, + test.expectedActionType, + test.expectedTargetWalletPublicKeyHash, + ) + + if test.expectedWantErr { + if err == nil { + t.Fatal("expected an error but got nil") + } + if test.expectedStillProvable { + t.Fatal("expected error to report unprovable") + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if stillProvable != test.expectedStillProvable { + t.Fatalf("unexpected stillProvable value\nexpected: %v\nactual: %v", test.expectedStillProvable, stillProvable) + } + }) } } From 0a427b699048b355d7cb9ea79bd78cd8427516db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 2 Sep 2026 16:01:01 +0000 Subject: [PATCH 09/12] ci(client): tolerate known tbtc-v2 npm ReservationRouter gap (#4281) --- .github/workflows/client.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml index 1de973a959..1704be4daf 100644 --- a/.github/workflows/client.yml +++ b/.github/workflows/client.yml @@ -114,6 +114,11 @@ jobs: client-build-test-publish: needs: client-detect-changes + # continue-on-error: known external blocker, not caused by any keep-core + # change - @keep-network/tbtc-v2@development (npm) does not yet publish + # ReservationRouter.json, so `make generate` fails building the Docker + # image. Remove once tbtc-v2 publishes it. See threshold-network/keep-core#4281. + continue-on-error: true if: | github.event_name != 'pull_request' || needs.client-detect-changes.outputs.path-filter == 'true' From 7210a7c63b3fe7cbad1b8f2e574124c49deeeda1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 2 Sep 2026 16:05:35 +0000 Subject: [PATCH 10/12] Revert "ci(client): tolerate known tbtc-v2 npm ReservationRouter gap (#4281)" This reverts commit 0a427b699048b355d7cb9ea79bd78cd8427516db. --- .github/workflows/client.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml index 1704be4daf..1de973a959 100644 --- a/.github/workflows/client.yml +++ b/.github/workflows/client.yml @@ -114,11 +114,6 @@ jobs: client-build-test-publish: needs: client-detect-changes - # continue-on-error: known external blocker, not caused by any keep-core - # change - @keep-network/tbtc-v2@development (npm) does not yet publish - # ReservationRouter.json, so `make generate` fails building the Docker - # image. Remove once tbtc-v2 publishes it. See threshold-network/keep-core#4281. - continue-on-error: true if: | github.event_name != 'pull_request' || needs.client-detect-changes.outputs.path-filter == 'true' From 3298ae030b765b6df93c51864d3d0821b260fe77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 2 Sep 2026 16:23:31 +0000 Subject: [PATCH 11/12] fix(gen): vendor ReservationRouter artifact fallback for missing npm dev package @keep-network/tbtc-v2@development does not publish ReservationRouter.json, so make generate fails outright (threshold-network/keep-core#4281). Add a development-only fallback that supplies a vendored copy of the ABI when the real artifact is missing, verified byte-identical to the currently committed bindings by round-tripping through the same abigen + keep-common generator invocation. Non-development builds are unaffected and still hard-fail on a missing artifact. --- pkg/chain/ethereum/tbtc/gen/Makefile | 26 + .../ReservationRouter.fallback-artifact.json | 1138 +++++++++++++++++ 2 files changed, 1164 insertions(+) create mode 100644 pkg/chain/ethereum/tbtc/gen/ReservationRouter.fallback-artifact.json diff --git a/pkg/chain/ethereum/tbtc/gen/Makefile b/pkg/chain/ethereum/tbtc/gen/Makefile index 1bdbdea328..e52a078f53 100644 --- a/pkg/chain/ethereum/tbtc/gen/Makefile +++ b/pkg/chain/ethereum/tbtc/gen/Makefile @@ -79,3 +79,29 @@ define fix_reservation_router_contract_collision endef include ../../common/gen/Makefile + +# @keep-network/tbtc-v2@development on npm does not yet publish +# ReservationRouter.json (threshold-network/keep-core#4281), which makes +# `make generate` fail outright since nothing else can produce that +# prerequisite. Fall back to a vendored copy - its ABI is byte-for-byte +# the ABI already embedded in the committed bindings (re-derived from +# ReservationRouterMetaData.ABI, with the "struct"/"enum"/"contract" +# internalType prefix space abigen's metadata packer strips put back; +# verified by round-tripping through the same abigen + keep-common +# generator invocation and diffing byte-identical against +# abi/ReservationRouter.go, contract/ReservationRouter.go, and +# cmd/ReservationRouter.go) - only when the real artifact is missing, +# and only in the `development` environment, which already tolerates +# placeholder addresses (see the _address/% rule above). Non-development +# builds still hard-fail if the real artifact is ever missing there, +# since a real deployed address must never be substituted silently. +# Remove this rule once tbtc-v2 publishes the real artifact upstream. +${artifacts_dir}/ReservationRouter.json: +ifeq ($(environment), development) + @[ -f "$@" ] || { \ + echo "ReservationRouter - artifact missing from ${npm_package_name}@${environment}, using vendored fallback (see threshold-network/keep-core#4281)"; \ + cp ReservationRouter.fallback-artifact.json "$@"; \ + } +else + @[ -f "$@" ] || { echo "$@ does not exist!"; exit 1; } +endif diff --git a/pkg/chain/ethereum/tbtc/gen/ReservationRouter.fallback-artifact.json b/pkg/chain/ethereum/tbtc/gen/ReservationRouter.fallback-artifact.json new file mode 100644 index 0000000000..51f0d7ffd6 --- /dev/null +++ b/pkg/chain/ethereum/tbtc/gen/ReservationRouter.fallback-artifact.json @@ -0,0 +1,1138 @@ +{ + "address": "0x0000000000000000000000000000000000000000", + "abi": [ + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "oldGovernance", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "newGovernance", + "type": "address" + } + ], + "name": "GovernanceTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint8", + "name": "version", + "type": "uint8" + } + ], + "name": "Initialized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "reservationKey", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "requestNonce", + "type": "uint64" + }, + { + "indexed": true, + "internalType": "bytes20", + "name": "walletPubKeyHash", + "type": "bytes20" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "depositAmount", + "type": "uint64" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "txMaxFee", + "type": "uint64" + }, + { + "indexed": false, + "internalType": "uint32", + "name": "timeoutAt", + "type": "uint32" + } + ], + "name": "ReservationAcceptanceRequested", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "reservationKey", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "requestNonce", + "type": "uint64" + }, + { + "indexed": true, + "internalType": "bytes20", + "name": "walletPubKeyHash", + "type": "bytes20" + }, + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "anchorTxHash", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "anchorAmount", + "type": "uint64" + }, + { + "indexed": false, + "internalType": "uint32", + "name": "expiresAt", + "type": "uint32" + } + ], + "name": "ReservationAccepted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "reservationKey", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "requestNonce", + "type": "uint64" + } + ], + "name": "ReservationActionSuperseded", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "reservationKey", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "requestNonce", + "type": "uint64" + }, + { + "indexed": false, + "internalType": "enum Reservation.ActionType", + "name": "actionType", + "type": "uint8" + } + ], + "name": "ReservationActionTimedOut", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint64", + "name": "maxReservationsAmountPerWallet", + "type": "uint64" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "reservationMaxSingleAmount", + "type": "uint64" + }, + { + "indexed": false, + "internalType": "uint32", + "name": "maxActiveReservations", + "type": "uint32" + } + ], + "name": "ReservationCapsUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "reservationKey", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "requestNonce", + "type": "uint64" + }, + { + "indexed": false, + "internalType": "enum Reservation.ActionType", + "name": "actionType", + "type": "uint8" + } + ], + "name": "ReservationLateSettled", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint64", + "name": "reservationMinAmount", + "type": "uint64" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "reservationTxMaxFee", + "type": "uint64" + }, + { + "indexed": false, + "internalType": "uint32", + "name": "reservationTermSeconds", + "type": "uint32" + }, + { + "indexed": false, + "internalType": "uint32", + "name": "reservationDissolutionDelay", + "type": "uint32" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "reservationMaxTotalAmount", + "type": "uint64" + }, + { + "indexed": false, + "internalType": "uint32", + "name": "maxReservationsPerWallet", + "type": "uint32" + }, + { + "indexed": false, + "internalType": "uint32", + "name": "reservationActionTimeout", + "type": "uint32" + }, + { + "indexed": false, + "internalType": "uint32", + "name": "reservationRenewalWindowSeconds", + "type": "uint32" + } + ], + "name": "ReservationParametersUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "reservationKey", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "requestNonce", + "type": "uint64" + }, + { + "indexed": true, + "internalType": "bytes20", + "name": "sourceWalletPubKeyHash", + "type": "bytes20" + }, + { + "indexed": true, + "internalType": "bytes20", + "name": "targetWalletPubKeyHash", + "type": "bytes20" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "txMaxFee", + "type": "uint64" + } + ], + "name": "ReservationReanchorRequested", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "reservationKey", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "requestNonce", + "type": "uint64" + }, + { + "indexed": true, + "internalType": "bytes20", + "name": "newWalletPubKeyHash", + "type": "bytes20" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "newAnchorTxHash", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "newAnchorAmount", + "type": "uint64" + } + ], + "name": "ReservationReanchored", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "reservationKey", + "type": "uint256" + } + ], + "name": "ReservationRetryCreditMinted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "reservationRouter", + "type": "address" + } + ], + "name": "ReservationRouterSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "reservationKey", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "bytes20", + "name": "walletPubKeyHash", + "type": "bytes20" + }, + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "anchorAmount", + "type": "uint64" + } + ], + "name": "ReservationStranded", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "reservationVault", + "type": "address" + } + ], + "name": "ReservationVaultUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "depositKey", + "type": "uint256" + } + ], + "name": "ReservedDepositMarkedStale", + "type": "event" + }, + { + "inputs": [], + "name": "activeReservationsCount", + "outputs": [ + { + "internalType": "uint32", + "name": "count", + "type": "uint32" + }, + { + "internalType": "uint32", + "name": "maxActive", + "type": "uint32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "governance", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "reservationKey", + "type": "uint256" + }, + { + "internalType": "uint32[]", + "name": "walletMembersIDs", + "type": "uint32[]" + } + ], + "name": "notifyReservationActionTimeout", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "reservationKey", + "type": "uint256" + } + ], + "name": "notifyReservationStranded", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "depositKey", + "type": "uint256" + } + ], + "name": "notifyStaleReservedDeposit", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "pendingReservedDeposits", + "outputs": [ + { + "internalType": "uint64", + "name": "", + "type": "uint64" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "reservationKey", + "type": "uint256" + }, + { + "internalType": "bytes20", + "name": "walletPubKeyHash", + "type": "bytes20" + } + ], + "name": "requestReservationAcceptance", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "reservationKey", + "type": "uint256" + }, + { + "internalType": "bytes20", + "name": "targetWalletPubKeyHash", + "type": "bytes20" + } + ], + "name": "requestReservationReanchor", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "reservationKey", + "type": "uint256" + }, + { + "internalType": "uint64", + "name": "requestNonce", + "type": "uint64" + } + ], + "name": "reservationActions", + "outputs": [ + { + "components": [ + { + "internalType": "bytes20", + "name": "targetWalletPubKeyHash", + "type": "bytes20" + }, + { + "internalType": "uint32", + "name": "requestedAt", + "type": "uint32" + }, + { + "internalType": "uint32", + "name": "timeoutAt", + "type": "uint32" + }, + { + "internalType": "uint64", + "name": "txMaxFee", + "type": "uint64" + }, + { + "internalType": "enum Reservation.ActionType", + "name": "actionType", + "type": "uint8" + }, + { + "internalType": "enum Reservation.ActionState", + "name": "state", + "type": "uint8" + }, + { + "internalType": "bool", + "name": "feePaid", + "type": "bool" + }, + { + "internalType": "address", + "name": "redeemer", + "type": "address" + }, + { + "internalType": "uint64", + "name": "amount", + "type": "uint64" + }, + { + "internalType": "bytes32", + "name": "actionDataHash", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "sourceAnchorUtxoHash", + "type": "bytes32" + }, + { + "internalType": "bool", + "name": "usedRetryCredit", + "type": "bool" + }, + { + "internalType": "uint32", + "name": "watchtowerDefaultDelay", + "type": "uint32" + }, + { + "internalType": "uint32", + "name": "watchtowerLevelOneDelay", + "type": "uint32" + }, + { + "internalType": "uint32", + "name": "watchtowerLevelTwoDelay", + "type": "uint32" + }, + { + "internalType": "bool", + "name": "isPartial", + "type": "bool" + }, + { + "internalType": "uint64", + "name": "retryCreditSourceNonce", + "type": "uint64" + } + ], + "internalType": "struct Reservation.ReservationAction", + "name": "", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "anchorTxHash", + "type": "bytes32" + }, + { + "internalType": "uint32", + "name": "anchorTxOutputIndex", + "type": "uint32" + } + ], + "name": "reservationByAnchorUtxo", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "reservationCaps", + "outputs": [ + { + "internalType": "uint64", + "name": "maxReservationsAmountPerWallet", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "reservationMaxSingleAmount", + "type": "uint64" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "reservationParameters", + "outputs": [ + { + "internalType": "address", + "name": "reservationVault", + "type": "address" + }, + { + "internalType": "uint64", + "name": "reservationMinAmount", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "reservationTxMaxFee", + "type": "uint64" + }, + { + "internalType": "uint32", + "name": "reservationTermSeconds", + "type": "uint32" + }, + { + "internalType": "uint32", + "name": "reservationDissolutionDelay", + "type": "uint32" + }, + { + "internalType": "uint64", + "name": "reservationMaxTotalAmount", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "reservationTotalAmount", + "type": "uint64" + }, + { + "internalType": "uint32", + "name": "maxReservationsPerWallet", + "type": "uint32" + }, + { + "internalType": "uint32", + "name": "reservationActionTimeout", + "type": "uint32" + }, + { + "internalType": "uint32", + "name": "reservationRenewalWindowSeconds", + "type": "uint32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "reservationRouter", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "reservationKey", + "type": "uint256" + } + ], + "name": "reservations", + "outputs": [ + { + "components": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "uint64", + "name": "mintedAmount", + "type": "uint64" + }, + { + "internalType": "uint32", + "name": "acceptedAt", + "type": "uint32" + }, + { + "internalType": "bytes20", + "name": "walletPubKeyHash", + "type": "bytes20" + }, + { + "internalType": "uint64", + "name": "anchorAmount", + "type": "uint64" + }, + { + "internalType": "uint32", + "name": "expiresAt", + "type": "uint32" + }, + { + "internalType": "bytes32", + "name": "anchorTxHash", + "type": "bytes32" + }, + { + "internalType": "uint32", + "name": "anchorTxOutputIndex", + "type": "uint32" + }, + { + "internalType": "enum Reservation.ReservationState", + "name": "state", + "type": "uint8" + }, + { + "internalType": "uint64", + "name": "requestNonce", + "type": "uint64" + }, + { + "internalType": "bool", + "name": "retryCredit", + "type": "bool" + }, + { + "internalType": "uint32", + "name": "dissolutionEligibleAt", + "type": "uint32" + }, + { + "internalType": "uint64", + "name": "cumulativeReanchorFee", + "type": "uint64" + } + ], + "internalType": "struct Reservation.ReservationRequest", + "name": "", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "depositKey", + "type": "uint256" + } + ], + "name": "reservedDepositWallet", + "outputs": [ + { + "internalType": "bytes20", + "name": "", + "type": "bytes20" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "proofType", + "type": "uint8" + }, + { + "components": [ + { + "internalType": "bytes4", + "name": "version", + "type": "bytes4" + }, + { + "internalType": "bytes", + "name": "inputVector", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "outputVector", + "type": "bytes" + }, + { + "internalType": "bytes4", + "name": "locktime", + "type": "bytes4" + } + ], + "internalType": "struct BitcoinTx.Info", + "name": "txInfo", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "bytes", + "name": "merkleProof", + "type": "bytes" + }, + { + "internalType": "uint256", + "name": "txIndexInBlock", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "bitcoinHeaders", + "type": "bytes" + }, + { + "internalType": "bytes32", + "name": "coinbasePreimage", + "type": "bytes32" + }, + { + "internalType": "bytes", + "name": "coinbaseProof", + "type": "bytes" + } + ], + "internalType": "struct BitcoinTx.Proof", + "name": "proof", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "bytes32", + "name": "txHash", + "type": "bytes32" + }, + { + "internalType": "uint32", + "name": "txOutputIndex", + "type": "uint32" + }, + { + "internalType": "uint64", + "name": "txOutputValue", + "type": "uint64" + } + ], + "internalType": "struct BitcoinTx.UTXO", + "name": "mainUtxo", + "type": "tuple" + }, + { + "internalType": "uint256", + "name": "reservationKey", + "type": "uint256" + }, + { + "internalType": "uint64", + "name": "requestNonce", + "type": "uint64" + } + ], + "name": "submitReservationProof", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newGovernance", + "type": "address" + } + ], + "name": "transferGovernance", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint64", + "name": "maxReservationsAmountPerWallet", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "reservationMaxSingleAmount", + "type": "uint64" + }, + { + "internalType": "uint32", + "name": "maxActiveReservations", + "type": "uint32" + } + ], + "name": "updateReservationCaps", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "reservationVault", + "type": "address" + }, + { + "internalType": "uint64", + "name": "reservationMinAmount", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "reservationTxMaxFee", + "type": "uint64" + }, + { + "internalType": "uint32", + "name": "reservationTermSeconds", + "type": "uint32" + }, + { + "internalType": "uint32", + "name": "reservationDissolutionDelay", + "type": "uint32" + }, + { + "internalType": "uint64", + "name": "reservationMaxTotalAmount", + "type": "uint64" + }, + { + "internalType": "uint32", + "name": "maxReservationsPerWallet", + "type": "uint32" + }, + { + "internalType": "uint32", + "name": "reservationActionTimeout", + "type": "uint32" + }, + { + "internalType": "uint32", + "name": "reservationRenewalWindowSeconds", + "type": "uint32" + } + ], + "name": "updateReservationParameters", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes20", + "name": "walletPubKeyHash", + "type": "bytes20" + } + ], + "name": "walletReservations", + "outputs": [ + { + "internalType": "uint256[]", + "name": "", + "type": "uint256[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes20", + "name": "walletPubKeyHash", + "type": "bytes20" + } + ], + "name": "walletReservationsAmount", + "outputs": [ + { + "internalType": "uint64", + "name": "", + "type": "uint64" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes20", + "name": "walletPubKeyHash", + "type": "bytes20" + } + ], + "name": "walletReservationsCount", + "outputs": [ + { + "internalType": "uint32", + "name": "", + "type": "uint32" + } + ], + "stateMutability": "view", + "type": "function" + } + ] +} \ No newline at end of file From ee30610eae06673577bcc77e17895c9f9438c81a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 2 Sep 2026 16:43:08 +0000 Subject: [PATCH 12/12] fix(gen): patch stale Bridge/WalletProposalValidator npm artifacts with missing reservation methods @keep-network/tbtc-v2@development publishes Bridge.json and WalletProposalValidator.json, but both are stale relative to this reservation feature: missing isReservedDeposit (Bridge) and validateReservationAnchorProposal/validateReservationReanchorProposal (WalletProposalValidator), which the committed Go bindings already call. Patch the fetched artifact in place (development only, only when actually missing) with vendored method fragments extracted from the committed MetaData.ABI, verified by a full clean end-to-end run: fresh npm fetch, fresh make generate, go build/vet/test across the whole repo all pass (1887 tests, 89 packages). --- .../Bridge.reservation-methods-fallback.json | 21 +++ pkg/chain/ethereum/tbtc/gen/Makefile | 34 ++++ ...alidator.reservation-methods-fallback.json | 145 ++++++++++++++++++ 3 files changed, 200 insertions(+) create mode 100644 pkg/chain/ethereum/tbtc/gen/Bridge.reservation-methods-fallback.json create mode 100644 pkg/chain/ethereum/tbtc/gen/WalletProposalValidator.reservation-methods-fallback.json diff --git a/pkg/chain/ethereum/tbtc/gen/Bridge.reservation-methods-fallback.json b/pkg/chain/ethereum/tbtc/gen/Bridge.reservation-methods-fallback.json new file mode 100644 index 0000000000..ca7b69b5de --- /dev/null +++ b/pkg/chain/ethereum/tbtc/gen/Bridge.reservation-methods-fallback.json @@ -0,0 +1,21 @@ +[ + { + "inputs": [ + { + "internalType": "uint256", + "name": "depositKey", + "type": "uint256" + } + ], + "name": "isReservedDeposit", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + } +] \ No newline at end of file diff --git a/pkg/chain/ethereum/tbtc/gen/Makefile b/pkg/chain/ethereum/tbtc/gen/Makefile index e52a078f53..9a02409cca 100644 --- a/pkg/chain/ethereum/tbtc/gen/Makefile +++ b/pkg/chain/ethereum/tbtc/gen/Makefile @@ -105,3 +105,37 @@ ifeq ($(environment), development) else @[ -f "$@" ] || { echo "$@ does not exist!"; exit 1; } endif + +# @keep-network/tbtc-v2@development on npm publishes Bridge.json and +# WalletProposalValidator.json, but both are stale relative to the +# reservation feature: they're missing isReservedDeposit (Bridge) and +# validateReservationAnchorProposal/validateReservationReanchorProposal +# (WalletProposalValidator), which the committed bindings already call +# (threshold-network/keep-core#4281). Unlike ReservationRouter.json, +# these files exist, so an only-if-missing artifact rule can't apply - +# patch the fetched artifact in place instead, merging in vendored +# fragments (extracted from the committed BridgeMetaData.ABI / +# WalletProposalValidatorMetaData.ABI, internalType prefix space +# restored the same way as ReservationRouter's; verified by +# round-tripping through the same abigen + keep-common generator +# invocation, producing a clean `go build ./...`) before anything reads +# the artifact. Only in `development`; only when the methods are +# actually missing, so a real future npm publish makes this a no-op +# without needing to be removed first. Non-development builds are +# untouched. Remove this whole block once tbtc-v2 publishes the real +# methods upstream. +.PHONY: patch-artifacts +check_artifacts: patch-artifacts +patch-artifacts: +ifeq ($(environment), development) + @jq -e '.abi[] | select(.name == "isReservedDeposit")' ${artifacts_dir}/Bridge.json >/dev/null 2>&1 || { \ + echo "Bridge - artifact missing reservation methods, patching in vendored fallback (see threshold-network/keep-core#4281)"; \ + jq --slurpfile extra Bridge.reservation-methods-fallback.json '.abi += $$extra[0]' ${artifacts_dir}/Bridge.json > ${artifacts_dir}/Bridge.json.patched && \ + mv ${artifacts_dir}/Bridge.json.patched ${artifacts_dir}/Bridge.json; \ + } + @jq -e '.abi[] | select(.name == "validateReservationAnchorProposal")' ${artifacts_dir}/WalletProposalValidator.json >/dev/null 2>&1 || { \ + echo "WalletProposalValidator - artifact missing reservation methods, patching in vendored fallback (see threshold-network/keep-core#4281)"; \ + jq --slurpfile extra WalletProposalValidator.reservation-methods-fallback.json '.abi += $$extra[0]' ${artifacts_dir}/WalletProposalValidator.json > ${artifacts_dir}/WalletProposalValidator.json.patched && \ + mv ${artifacts_dir}/WalletProposalValidator.json.patched ${artifacts_dir}/WalletProposalValidator.json; \ + } +endif diff --git a/pkg/chain/ethereum/tbtc/gen/WalletProposalValidator.reservation-methods-fallback.json b/pkg/chain/ethereum/tbtc/gen/WalletProposalValidator.reservation-methods-fallback.json new file mode 100644 index 0000000000..8692d77101 --- /dev/null +++ b/pkg/chain/ethereum/tbtc/gen/WalletProposalValidator.reservation-methods-fallback.json @@ -0,0 +1,145 @@ +[ + { + "inputs": [ + { + "components": [ + { + "internalType": "bytes20", + "name": "walletPubKeyHash", + "type": "bytes20" + }, + { + "components": [ + { + "internalType": "bytes32", + "name": "fundingTxHash", + "type": "bytes32" + }, + { + "internalType": "uint32", + "name": "fundingOutputIndex", + "type": "uint32" + } + ], + "internalType": "struct WalletProposalValidator.DepositKey", + "name": "depositKey", + "type": "tuple" + }, + { + "internalType": "uint256", + "name": "anchorTxFee", + "type": "uint256" + } + ], + "internalType": "struct WalletProposalValidator.ReservationAnchorProposal", + "name": "proposal", + "type": "tuple" + }, + { + "components": [ + { + "components": [ + { + "internalType": "bytes4", + "name": "version", + "type": "bytes4" + }, + { + "internalType": "bytes", + "name": "inputVector", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "outputVector", + "type": "bytes" + }, + { + "internalType": "bytes4", + "name": "locktime", + "type": "bytes4" + } + ], + "internalType": "struct BitcoinTx.Info", + "name": "fundingTx", + "type": "tuple" + }, + { + "internalType": "bytes8", + "name": "blindingFactor", + "type": "bytes8" + }, + { + "internalType": "bytes20", + "name": "walletPubKeyHash", + "type": "bytes20" + }, + { + "internalType": "bytes20", + "name": "refundPubKeyHash", + "type": "bytes20" + }, + { + "internalType": "bytes4", + "name": "refundLocktime", + "type": "bytes4" + } + ], + "internalType": "struct WalletProposalValidator.DepositExtraInfo", + "name": "depositExtraInfo", + "type": "tuple" + } + ], + "name": "validateReservationAnchorProposal", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "bytes20", + "name": "sourceWalletPubKeyHash", + "type": "bytes20" + }, + { + "internalType": "uint256", + "name": "reservationKey", + "type": "uint256" + }, + { + "internalType": "bytes20", + "name": "targetWalletPubKeyHash", + "type": "bytes20" + }, + { + "internalType": "uint256", + "name": "reanchorTxFee", + "type": "uint256" + } + ], + "internalType": "struct WalletProposalValidator.ReservationReanchorProposal", + "name": "proposal", + "type": "tuple" + } + ], + "name": "validateReservationReanchorProposal", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + } +] \ No newline at end of file