From 2e3570ed34e5227cad78383dfb7ac5bd19cdcf25 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 7 Aug 2026 16:32:07 -0400 Subject: [PATCH 01/39] feat(tbtc): UTXO reservation wallet-side foundations Companion of the tbtc-v2 UTXO reservation draft (threshold-network/ tbtc-v2#1088). A reservation is a deposit the wallet anchors -- spends in a 1-input-1-output transaction into a fresh wallet-controlled output with no refund path -- instead of sweeping, so the reserved coins never commingle with the pooled supply and are redeemable in-kind. Adds the wallet-side foundations: - wallet action types for the four reservation lifecycle actions (anchor, reserved redemption, re-anchor, dissolution), appended after the existing enum values to preserve serialized compatibility, - coordination proposal types with marshaling and factory registration (JSON-based for now; switching to protobuf once the reservation message types are added to the coordination proto definition), - Chain interface extensions for reading reservations and parameters and validating the four proposal kinds via WalletProposalValidator, - unsigned transaction assembly for all four lifecycle shapes, enforcing the 1-input-1-output lineage (dissolution additionally spends the wallet main UTXO as its second input, per the Bridge rules), - tests for action parsing, proposal marshaling roundtrips, and assembler input validation. The Ethereum chain implementation stubs the new interface methods with descriptive errors: the contract bindings can only be regenerated once the reservation Bridge API is published with the @keep-network/tbtc-v2 package. Coordination executor wiring and tbtcpg proposal generation follow in the same step. --- pkg/chain/ethereum/tbtc.go | 80 +++++++ pkg/tbtc/chain.go | 44 ++++ pkg/tbtc/chain_test.go | 42 ++++ pkg/tbtc/marshaling.go | 16 +- pkg/tbtc/reservation.go | 440 +++++++++++++++++++++++++++++++++++ pkg/tbtc/reservation_test.go | 141 +++++++++++ pkg/tbtc/wallet.go | 20 ++ pkg/tbtc/wallet_test.go | 20 +- 8 files changed, 795 insertions(+), 8 deletions(-) create mode 100644 pkg/tbtc/reservation.go create mode 100644 pkg/tbtc/reservation_test.go diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index 50e42be20e..cf84de27b1 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -2408,3 +2408,83 @@ func (tc *TbtcChain) GetRedemptionDelay( func (tc *TbtcChain) GetDepositMinAge() (uint32, error) { return tc.walletProposalValidator.DEPOSITMINAGE() } + +// GetReservation is not yet supported by the Ethereum chain implementation: +// the reservation contract bindings will be regenerated once the reservation +// Bridge API is published with the @keep-network/tbtc-v2 package. +func (tc *TbtcChain) GetReservation( + reservationKey *big.Int, +) (*tbtc.Reservation, error) { + return nil, fmt.Errorf( + "reservations not supported yet by the Ethereum chain implementation", + ) +} + +// ReservationParameters is not yet supported by the Ethereum chain +// implementation: the reservation contract bindings will be regenerated once +// the reservation Bridge API is published with the @keep-network/tbtc-v2 +// package. +func (tc *TbtcChain) ReservationParameters() ( + *tbtc.ReservationParameters, + error, +) { + return nil, fmt.Errorf( + "reservations not supported yet by the Ethereum chain implementation", + ) +} + +// ValidateReservationAnchorProposal is not yet supported by the Ethereum +// chain implementation: the reservation contract bindings will be +// regenerated once the reservation Bridge API is published with the +// @keep-network/tbtc-v2 package. +func (tc *TbtcChain) ValidateReservationAnchorProposal( + walletPublicKeyHash [20]byte, + proposal *tbtc.ReservationAnchorProposal, + depositExtraInfo struct { + *tbtc.Deposit + FundingTx *bitcoin.Transaction + }, +) error { + return fmt.Errorf( + "reservations not supported yet by the Ethereum chain implementation", + ) +} + +// ValidateReservedRedemptionProposal is not yet supported by the Ethereum +// chain implementation: the reservation contract bindings will be +// regenerated once the reservation Bridge API is published with the +// @keep-network/tbtc-v2 package. +func (tc *TbtcChain) ValidateReservedRedemptionProposal( + walletPublicKeyHash [20]byte, + proposal *tbtc.ReservedRedemptionProposal, +) error { + return fmt.Errorf( + "reservations not supported yet by the Ethereum chain implementation", + ) +} + +// ValidateReservationReanchorProposal is not yet supported by the Ethereum +// chain implementation: the reservation contract bindings will be +// regenerated once the reservation Bridge API is published with the +// @keep-network/tbtc-v2 package. +func (tc *TbtcChain) ValidateReservationReanchorProposal( + sourceWalletPublicKeyHash [20]byte, + proposal *tbtc.ReservationReanchorProposal, +) error { + return fmt.Errorf( + "reservations not supported yet by the Ethereum chain implementation", + ) +} + +// ValidateReservationDissolutionProposal is not yet supported by the +// Ethereum chain implementation: the reservation contract bindings will be +// regenerated once the reservation Bridge API is published with the +// @keep-network/tbtc-v2 package. +func (tc *TbtcChain) ValidateReservationDissolutionProposal( + walletPublicKeyHash [20]byte, + proposal *tbtc.ReservationDissolutionProposal, +) error { + return fmt.Errorf( + "reservations not supported yet by the Ethereum chain implementation", + ) +} diff --git a/pkg/tbtc/chain.go b/pkg/tbtc/chain.go index a58599f273..1bf1fbf2de 100644 --- a/pkg/tbtc/chain.go +++ b/pkg/tbtc/chain.go @@ -427,6 +427,50 @@ type WalletProposalValidatorChain interface { }, ) error + // GetReservation gets the on-chain reservation record for the given + // reservation key. Returns an error if the reservation was not found. + GetReservation(reservationKey *big.Int) (*Reservation, error) + + // ReservationParameters gets the current on-chain values of the Bridge + // reservation parameters. + ReservationParameters() (*ReservationParameters, error) + + // ValidateReservationAnchorProposal validates the given reservation + // anchor proposal against the chain. Returns an error if the proposal + // is not valid or nil otherwise. + ValidateReservationAnchorProposal( + walletPublicKeyHash [20]byte, + proposal *ReservationAnchorProposal, + depositExtraInfo struct { + *Deposit + FundingTx *bitcoin.Transaction + }, + ) error + + // ValidateReservedRedemptionProposal validates the given reserved + // redemption proposal against the chain. Returns an error if the + // proposal is not valid or nil otherwise. + ValidateReservedRedemptionProposal( + walletPublicKeyHash [20]byte, + proposal *ReservedRedemptionProposal, + ) error + + // ValidateReservationReanchorProposal validates the given reservation + // re-anchor proposal against the chain. Returns an error if the + // proposal is not valid or nil otherwise. + ValidateReservationReanchorProposal( + sourceWalletPublicKeyHash [20]byte, + proposal *ReservationReanchorProposal, + ) error + + // ValidateReservationDissolutionProposal validates the given reservation + // dissolution proposal against the chain. Returns an error if the + // proposal is not valid or nil otherwise. + ValidateReservationDissolutionProposal( + walletPublicKeyHash [20]byte, + proposal *ReservationDissolutionProposal, + ) error + // ValidateRedemptionProposal validates the given redemption proposal // against the chain. Returns an error if the proposal is not valid or // nil otherwise. diff --git a/pkg/tbtc/chain_test.go b/pkg/tbtc/chain_test.go index 1dc799dc84..6bf9a1ae85 100644 --- a/pkg/tbtc/chain_test.go +++ b/pkg/tbtc/chain_test.go @@ -1450,3 +1450,45 @@ func generateHandlerID() int { // Local chain implementation doesn't require secure randomness. return rand.Int() } + +func (lc *localChain) GetReservation( + reservationKey *big.Int, +) (*Reservation, error) { + panic("unsupported") +} + +func (lc *localChain) ReservationParameters() (*ReservationParameters, error) { + panic("unsupported") +} + +func (lc *localChain) ValidateReservationAnchorProposal( + walletPublicKeyHash [20]byte, + proposal *ReservationAnchorProposal, + depositExtraInfo struct { + *Deposit + FundingTx *bitcoin.Transaction + }, +) error { + panic("unsupported") +} + +func (lc *localChain) ValidateReservedRedemptionProposal( + walletPublicKeyHash [20]byte, + proposal *ReservedRedemptionProposal, +) error { + panic("unsupported") +} + +func (lc *localChain) ValidateReservationReanchorProposal( + sourceWalletPublicKeyHash [20]byte, + proposal *ReservationReanchorProposal, +) error { + panic("unsupported") +} + +func (lc *localChain) ValidateReservationDissolutionProposal( + walletPubKeyHash [20]byte, + proposal *ReservationDissolutionProposal, +) error { + panic("unsupported") +} diff --git a/pkg/tbtc/marshaling.go b/pkg/tbtc/marshaling.go index 02b5195e45..d31180ca27 100644 --- a/pkg/tbtc/marshaling.go +++ b/pkg/tbtc/marshaling.go @@ -229,12 +229,16 @@ func unmarshalCoordinationProposal(actionType uint32, payload []byte) ( } proposal, ok := map[WalletActionType]CoordinationProposal{ - ActionNoop: &NoopProposal{}, - ActionHeartbeat: &HeartbeatProposal{}, - ActionDepositSweep: &DepositSweepProposal{}, - ActionRedemption: &RedemptionProposal{}, - ActionMovingFunds: &MovingFundsProposal{}, - ActionMovedFundsSweep: &MovedFundsSweepProposal{}, + ActionNoop: &NoopProposal{}, + ActionHeartbeat: &HeartbeatProposal{}, + ActionDepositSweep: &DepositSweepProposal{}, + ActionRedemption: &RedemptionProposal{}, + ActionMovingFunds: &MovingFundsProposal{}, + ActionMovedFundsSweep: &MovedFundsSweepProposal{}, + ActionReservationAnchor: &ReservationAnchorProposal{}, + ActionReservedRedemption: &ReservedRedemptionProposal{}, + ActionReservationReanchor: &ReservationReanchorProposal{}, + ActionReservationDissolution: &ReservationDissolutionProposal{}, }[parsedActionType] if !ok { return nil, fmt.Errorf( diff --git a/pkg/tbtc/reservation.go b/pkg/tbtc/reservation.go new file mode 100644 index 0000000000..a26c37d578 --- /dev/null +++ b/pkg/tbtc/reservation.go @@ -0,0 +1,440 @@ +package tbtc + +import ( + "encoding/json" + "fmt" + "math/big" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/chain" +) + +const ( + // reservationAnchorProposalValidityBlocks determines the reservation + // anchor proposal validity time expressed in blocks. + reservationAnchorProposalValidityBlocks = 600 + // reservedRedemptionProposalValidityBlocks determines the reserved + // redemption proposal validity time expressed in blocks. + reservedRedemptionProposalValidityBlocks = 600 + // reservationReanchorProposalValidityBlocks determines the reservation + // re-anchor proposal validity time expressed in blocks. + reservationReanchorProposalValidityBlocks = 600 + // reservationDissolutionProposalValidityBlocks determines the reservation + // dissolution proposal validity time expressed in blocks. + reservationDissolutionProposalValidityBlocks = 600 +) + +// ReservationState represents the state of an on-chain UTXO reservation. +type ReservationState uint8 + +const ( + // ReservationStateUnknown means the reservation is unknown to the Bridge. + ReservationStateUnknown ReservationState = iota + // ReservationStateActive means the reservation's anchor outpoint is under + // wallet custody. + ReservationStateActive + // ReservationStateRedemptionRequested means the reservation owner + // requested an in-kind redemption of the anchor outpoint. + ReservationStateRedemptionRequested + // ReservationStateClosed means the reservation was closed by an in-kind + // redemption or a dissolution. + ReservationStateClosed +) + +// Reservation represents an on-chain UTXO reservation record. A reservation +// is a deposit that was anchored by the wallet - spent in a 1-input-1-output +// transaction into a fresh wallet-controlled output with no refund path - +// instead of being swept into the wallet main UTXO. The anchor outpoint is +// custodied without ever commingling with the pooled supply and is +// redeemable in-kind by the reservation owner. +type Reservation struct { + // Owner is the reservation owner's address on the host chain. + Owner chain.Address + // MintedAmount is the gross amount in satoshi credited to the owner at + // acceptance time. + MintedAmount uint64 + // WalletPublicKeyHash is the 20-byte public key hash of the wallet + // custodying the current anchor outpoint. + WalletPublicKeyHash [20]byte + // AnchorUtxo is the reservation's current anchor outpoint, i.e. the + // wallet-controlled output holding the reserved coins. + AnchorUtxo *bitcoin.UnspentTransactionOutput + // ExpiresAt is the UNIX timestamp the custody term expires at. + ExpiresAt uint32 + // State is the current state of the reservation. + State ReservationState + // RedemptionRequestedAt is the UNIX timestamp the pending reserved + // redemption was requested at. Zero when no redemption is pending. + RedemptionRequestedAt uint32 + // RedemptionTxMaxFee is the maximum transaction fee in satoshi + // snapshotted at redemption request time. + RedemptionTxMaxFee uint64 + // RedeemerOutputScript is the output script the pending reserved + // redemption must pay to. Empty when no redemption is pending. + RedeemerOutputScript bitcoin.Script +} + +// ReservationParameters represents the on-chain values of the Bridge +// reservation parameters. +type ReservationParameters struct { + // ReservationVault is the address of the reservation vault. Deposits + // revealed with this vault address are treated as UTXO reservations. + ReservationVault chain.Address + // ReservationMinAmount is the minimal anchor output amount in satoshi + // accepted for a reservation. + ReservationMinAmount uint64 + // ReservationTxMaxFee is the maximum transaction fee in satoshi for a + // single reservation lifecycle transaction. + ReservationTxMaxFee uint64 + // ReservationTermSeconds is the custody term length in seconds. + ReservationTermSeconds uint32 + // ReservationGracePeriod is the grace period in seconds after term + // expiry during which the reservation cannot be dissolved yet. + ReservationGracePeriod uint32 +} + +// ReservationAnchorProposal represents a reservation anchor proposal issued +// by a wallet's coordination leader. +type ReservationAnchorProposal struct { + // DepositFundingTxHash is the funding transaction hash of the reserved + // deposit to anchor. + DepositFundingTxHash bitcoin.Hash + // DepositFundingOutputIndex is the funding output index of the reserved + // deposit to anchor. + DepositFundingOutputIndex uint32 + // AnchorTxFee is the proposed BTC fee for the anchor transaction. + AnchorTxFee *big.Int +} + +// ActionType returns the specific type of the walletAction being subject +// of this proposal. +func (rap *ReservationAnchorProposal) ActionType() WalletActionType { + return ActionReservationAnchor +} + +// ValidityBlocks returns the number of blocks for which the proposal is valid. +func (rap *ReservationAnchorProposal) ValidityBlocks() uint64 { + return reservationAnchorProposalValidityBlocks +} + +// Marshal converts the reservationAnchorProposal to a byte array. +// +// TODO: Switch to protobuf-based marshaling (see pkg/tbtc/gen/pb) once the +// reservation message types are added to the coordination proto definition. +func (rap *ReservationAnchorProposal) Marshal() ([]byte, error) { + return json.Marshal(rap) +} + +// Unmarshal converts a byte array back to the reservationAnchorProposal. +func (rap *ReservationAnchorProposal) Unmarshal(bytes []byte) error { + return json.Unmarshal(bytes, rap) +} + +// ReservedRedemptionProposal represents a reserved redemption proposal +// issued by a wallet's coordination leader. +type ReservedRedemptionProposal struct { + // ReservationKey is the key of the reservation with the pending reserved + // redemption. + ReservationKey *big.Int + // RedemptionTxFee is the proposed BTC fee for the reserved redemption + // transaction. + RedemptionTxFee *big.Int +} + +// ActionType returns the specific type of the walletAction being subject +// of this proposal. +func (rrp *ReservedRedemptionProposal) ActionType() WalletActionType { + return ActionReservedRedemption +} + +// ValidityBlocks returns the number of blocks for which the proposal is valid. +func (rrp *ReservedRedemptionProposal) ValidityBlocks() uint64 { + return reservedRedemptionProposalValidityBlocks +} + +// Marshal converts the reservedRedemptionProposal to a byte array. +// +// TODO: Switch to protobuf-based marshaling (see pkg/tbtc/gen/pb) once the +// reservation message types are added to the coordination proto definition. +func (rrp *ReservedRedemptionProposal) Marshal() ([]byte, error) { + return json.Marshal(rrp) +} + +// Unmarshal converts a byte array back to the reservedRedemptionProposal. +func (rrp *ReservedRedemptionProposal) Unmarshal(bytes []byte) error { + return json.Unmarshal(bytes, rrp) +} + +// ReservationReanchorProposal represents a reservation re-anchor proposal +// issued by a wallet's coordination leader, moving a reservation's anchor +// outpoint to another wallet (e.g. during wallet migration). +type ReservationReanchorProposal struct { + // ReservationKey is the key of the reservation to re-anchor. + ReservationKey *big.Int + // TargetWalletPublicKeyHash is the 20-byte public key hash of the wallet + // receiving the anchor. + TargetWalletPublicKeyHash [20]byte + // ReanchorTxFee is the proposed BTC fee for the re-anchor transaction. + ReanchorTxFee *big.Int +} + +// ActionType returns the specific type of the walletAction being subject +// of this proposal. +func (rrp *ReservationReanchorProposal) ActionType() WalletActionType { + return ActionReservationReanchor +} + +// ValidityBlocks returns the number of blocks for which the proposal is valid. +func (rrp *ReservationReanchorProposal) ValidityBlocks() uint64 { + return reservationReanchorProposalValidityBlocks +} + +// Marshal converts the reservationReanchorProposal to a byte array. +// +// TODO: Switch to protobuf-based marshaling (see pkg/tbtc/gen/pb) once the +// reservation message types are added to the coordination proto definition. +func (rrp *ReservationReanchorProposal) Marshal() ([]byte, error) { + return json.Marshal(rrp) +} + +// Unmarshal converts a byte array back to the reservationReanchorProposal. +func (rrp *ReservationReanchorProposal) Unmarshal(bytes []byte) error { + return json.Unmarshal(bytes, rrp) +} + +// ReservationDissolutionProposal represents a reservation dissolution +// proposal issued by a wallet's coordination leader once the reservation's +// custody term and grace period elapsed. +type ReservationDissolutionProposal struct { + // ReservationKey is the key of the reservation to dissolve. + ReservationKey *big.Int + // DissolutionTxFee is the proposed BTC fee for the dissolution + // transaction. + DissolutionTxFee *big.Int +} + +// ActionType returns the specific type of the walletAction being subject +// of this proposal. +func (rdp *ReservationDissolutionProposal) ActionType() WalletActionType { + return ActionReservationDissolution +} + +// ValidityBlocks returns the number of blocks for which the proposal is valid. +func (rdp *ReservationDissolutionProposal) ValidityBlocks() uint64 { + return reservationDissolutionProposalValidityBlocks +} + +// Marshal converts the reservationDissolutionProposal to a byte array. +// +// TODO: Switch to protobuf-based marshaling (see pkg/tbtc/gen/pb) once the +// reservation message types are added to the coordination proto definition. +func (rdp *ReservationDissolutionProposal) Marshal() ([]byte, error) { + return json.Marshal(rdp) +} + +// Unmarshal converts a byte array back to the reservationDissolutionProposal. +func (rdp *ReservationDissolutionProposal) Unmarshal(bytes []byte) error { + return json.Unmarshal(bytes, rdp) +} + +// assembleReservationAnchorTransaction constructs an unsigned reservation +// anchor transaction: a 1-input-1-output spend of the given reserved deposit +// into a fresh output controlled by the given wallet. The anchor mirrors the +// sweep's refund-disabling role without its consolidating role: the Bridge +// credits the reservation owner only against the SPV proof of this +// transaction. +func assembleReservationAnchorTransaction( + bitcoinChain bitcoin.Chain, + deposit *Deposit, + walletPublicKeyHash [20]byte, + fee int64, +) (*bitcoin.TransactionBuilder, error) { + if deposit == nil { + return nil, fmt.Errorf("deposit is required") + } + + builder := bitcoin.NewTransactionBuilder(bitcoinChain) + + depositScript, err := deposit.Script() + if err != nil { + return nil, fmt.Errorf("cannot get deposit script: [%v]", err) + } + + err = builder.AddScriptHashInput(deposit.Utxo, depositScript) + if err != nil { + return nil, fmt.Errorf( + "cannot add input pointing to deposit UTXO: [%v]", + err, + ) + } + + anchorValue := deposit.Utxo.Value - fee + if anchorValue <= 0 { + return nil, fmt.Errorf( + "transaction fee exceeds the deposit value", + ) + } + + anchorScript, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) + if err != nil { + return nil, fmt.Errorf("cannot compute anchor script: [%v]", err) + } + + builder.AddOutput(&bitcoin.TransactionOutput{ + Value: anchorValue, + PublicKeyScript: anchorScript, + }) + + return builder, nil +} + +// assembleReservedRedemptionTransaction constructs an unsigned reserved +// redemption transaction: a 1-input-1-output spend of the reservation's +// anchor outpoint to the redeemer output script. The full gross claim is +// burned on the host chain upon the SPV proof of this transaction; the +// Bitcoin miner fee is the only in-kind deduction. +func assembleReservedRedemptionTransaction( + bitcoinChain bitcoin.Chain, + anchorUtxo *bitcoin.UnspentTransactionOutput, + redeemerOutputScript bitcoin.Script, + fee int64, +) (*bitcoin.TransactionBuilder, error) { + if anchorUtxo == nil { + return nil, fmt.Errorf("anchor UTXO is required") + } + if len(redeemerOutputScript) == 0 { + return nil, fmt.Errorf("redeemer output script is required") + } + + builder := bitcoin.NewTransactionBuilder(bitcoinChain) + + err := builder.AddPublicKeyHashInput(anchorUtxo) + if err != nil { + return nil, fmt.Errorf( + "cannot add input pointing to anchor UTXO: [%v]", + err, + ) + } + + redemptionValue := anchorUtxo.Value - fee + if redemptionValue <= 0 { + return nil, fmt.Errorf( + "transaction fee exceeds the anchor value", + ) + } + + builder.AddOutput(&bitcoin.TransactionOutput{ + Value: redemptionValue, + PublicKeyScript: redeemerOutputScript, + }) + + return builder, nil +} + +// assembleReservationReanchorTransaction constructs an unsigned reservation +// re-anchor transaction: a 1-input-1-output spend of the reservation's +// anchor outpoint into a fresh output controlled by the target wallet. Used +// during wallet migration so reservations never pin retiring wallets. +func assembleReservationReanchorTransaction( + bitcoinChain bitcoin.Chain, + anchorUtxo *bitcoin.UnspentTransactionOutput, + targetWalletPublicKeyHash [20]byte, + fee int64, +) (*bitcoin.TransactionBuilder, error) { + if anchorUtxo == nil { + return nil, fmt.Errorf("anchor UTXO is required") + } + + builder := bitcoin.NewTransactionBuilder(bitcoinChain) + + err := builder.AddPublicKeyHashInput(anchorUtxo) + if err != nil { + return nil, fmt.Errorf( + "cannot add input pointing to anchor UTXO: [%v]", + err, + ) + } + + reanchorValue := anchorUtxo.Value - fee + if reanchorValue <= 0 { + return nil, fmt.Errorf( + "transaction fee exceeds the anchor value", + ) + } + + reanchorScript, err := bitcoin.PayToWitnessPublicKeyHash( + targetWalletPublicKeyHash, + ) + if err != nil { + return nil, fmt.Errorf("cannot compute re-anchor script: [%v]", err) + } + + builder.AddOutput(&bitcoin.TransactionOutput{ + Value: reanchorValue, + PublicKeyScript: reanchorScript, + }) + + return builder, nil +} + +// assembleReservationDissolutionTransaction constructs an unsigned +// reservation dissolution transaction merging an expired reservation's +// anchor outpoint into the wallet main UTXO: the anchor outpoint is the +// first input, the wallet main UTXO (if it exists) is the second input, and +// the single output paying back to the wallet becomes its new main UTXO. +func assembleReservationDissolutionTransaction( + bitcoinChain bitcoin.Chain, + anchorUtxo *bitcoin.UnspentTransactionOutput, + walletMainUtxo *bitcoin.UnspentTransactionOutput, + walletPublicKeyHash [20]byte, + fee int64, +) (*bitcoin.TransactionBuilder, error) { + if anchorUtxo == nil { + return nil, fmt.Errorf("anchor UTXO is required") + } + + builder := bitcoin.NewTransactionBuilder(bitcoinChain) + + // The Bridge requires the anchor outpoint to be the first input. + err := builder.AddPublicKeyHashInput(anchorUtxo) + if err != nil { + return nil, fmt.Errorf( + "cannot add input pointing to anchor UTXO: [%v]", + err, + ) + } + + totalInputsValue := anchorUtxo.Value + + if walletMainUtxo != nil { + err = builder.AddPublicKeyHashInput(walletMainUtxo) + if err != nil { + return nil, fmt.Errorf( + "cannot add input pointing to wallet main UTXO: [%v]", + err, + ) + } + totalInputsValue += walletMainUtxo.Value + } + + dissolutionValue := totalInputsValue - fee + if dissolutionValue <= 0 { + return nil, fmt.Errorf( + "transaction fee exceeds the total inputs value", + ) + } + + dissolutionScript, err := bitcoin.PayToWitnessPublicKeyHash( + walletPublicKeyHash, + ) + if err != nil { + return nil, fmt.Errorf("cannot compute dissolution script: [%v]", err) + } + + builder.AddOutput(&bitcoin.TransactionOutput{ + Value: dissolutionValue, + PublicKeyScript: dissolutionScript, + }) + + return builder, nil +} diff --git a/pkg/tbtc/reservation_test.go b/pkg/tbtc/reservation_test.go new file mode 100644 index 0000000000..e3fab1f32a --- /dev/null +++ b/pkg/tbtc/reservation_test.go @@ -0,0 +1,141 @@ +package tbtc + +import ( + "math/big" + "reflect" + "testing" + + "github.com/keep-network/keep-core/pkg/bitcoin" +) + +func TestReservationActionTypes(t *testing.T) { + for value, expected := range map[uint8]WalletActionType{ + 6: ActionReservationAnchor, + 7: ActionReservedRedemption, + 8: ActionReservationReanchor, + 9: ActionReservationDissolution, + } { + parsed, err := ParseWalletActionType(value) + if err != nil { + t.Fatal(err) + } + if parsed != expected { + t.Errorf( + "unexpected action type for [%v]: expected [%v] got [%v]", + value, + expected, + parsed, + ) + } + } +} + +func TestReservationProposals_MarshalingRoundtrip(t *testing.T) { + anchorProposal := &ReservationAnchorProposal{ + DepositFundingTxHash: bitcoin.Hash{0x01, 0x02}, + DepositFundingOutputIndex: 3, + AnchorTxFee: big.NewInt(1500), + } + + redemptionProposal := &ReservedRedemptionProposal{ + ReservationKey: big.NewInt(12345), + RedemptionTxFee: big.NewInt(1600), + } + + reanchorProposal := &ReservationReanchorProposal{ + ReservationKey: big.NewInt(54321), + TargetWalletPublicKeyHash: [20]byte{0xaa, 0xbb}, + ReanchorTxFee: big.NewInt(1700), + } + + dissolutionProposal := &ReservationDissolutionProposal{ + ReservationKey: big.NewInt(99999), + DissolutionTxFee: big.NewInt(1800), + } + + roundtrip := func( + proposal CoordinationProposal, + fresh CoordinationProposal, + ) { + marshaled, err := proposal.Marshal() + if err != nil { + t.Fatal(err) + } + if err := fresh.Unmarshal(marshaled); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(proposal, fresh) { + t.Errorf( + "unexpected unmarshaled proposal: expected [%+v] got [%+v]", + proposal, + fresh, + ) + } + } + + roundtrip(anchorProposal, &ReservationAnchorProposal{}) + roundtrip(redemptionProposal, &ReservedRedemptionProposal{}) + roundtrip(reanchorProposal, &ReservationReanchorProposal{}) + roundtrip(dissolutionProposal, &ReservationDissolutionProposal{}) +} + +func TestAssembleReservationTransactions_InputValidation(t *testing.T) { + bitcoinChain := newLocalBitcoinChain() + walletPublicKeyHash := [20]byte{0x01} + redeemerScript := bitcoin.Script{0x00, 0x14, 0x02} + + anchorUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x03}, + OutputIndex: 0, + }, + Value: 100000, + } + + assertError := func(err error, expected string) { + if err == nil || err.Error() != expected { + t.Errorf("expected error [%v], got [%v]", expected, err) + } + } + + _, err := assembleReservationAnchorTransaction( + bitcoinChain, + nil, + walletPublicKeyHash, + 1500, + ) + assertError(err, "deposit is required") + + _, err = assembleReservedRedemptionTransaction( + bitcoinChain, + nil, + redeemerScript, + 1500, + ) + assertError(err, "anchor UTXO is required") + + _, err = assembleReservedRedemptionTransaction( + bitcoinChain, + anchorUtxo, + bitcoin.Script{}, + 1500, + ) + assertError(err, "redeemer output script is required") + + _, err = assembleReservationReanchorTransaction( + bitcoinChain, + nil, + walletPublicKeyHash, + 1500, + ) + assertError(err, "anchor UTXO is required") + + _, err = assembleReservationDissolutionTransaction( + bitcoinChain, + nil, + nil, + walletPublicKeyHash, + 1500, + ) + assertError(err, "anchor UTXO is required") +} diff --git a/pkg/tbtc/wallet.go b/pkg/tbtc/wallet.go index ca346dec69..fee65b9737 100644 --- a/pkg/tbtc/wallet.go +++ b/pkg/tbtc/wallet.go @@ -32,6 +32,10 @@ const ( ActionRedemption ActionMovingFunds ActionMovedFundsSweep + ActionReservationAnchor + ActionReservedRedemption + ActionReservationReanchor + ActionReservationDissolution ) // ParseWalletActionType parses the given value into a WalletActionType. @@ -49,6 +53,14 @@ func ParseWalletActionType(value uint8) (WalletActionType, error) { return ActionMovingFunds, nil case 5: return ActionMovedFundsSweep, nil + case 6: + return ActionReservationAnchor, nil + case 7: + return ActionReservedRedemption, nil + case 8: + return ActionReservationReanchor, nil + case 9: + return ActionReservationDissolution, nil default: return 0, fmt.Errorf("unknown wallet action type [%v]", value) } @@ -68,6 +80,14 @@ func (wat WalletActionType) String() string { return "MovingFunds" case ActionMovedFundsSweep: return "MovedFundsSweep" + case ActionReservationAnchor: + return "ReservationAnchor" + case ActionReservedRedemption: + return "ReservedRedemption" + case ActionReservationReanchor: + return "ReservationReanchor" + case ActionReservationDissolution: + return "ReservationDissolution" default: panic("unknown wallet action type") } diff --git a/pkg/tbtc/wallet_test.go b/pkg/tbtc/wallet_test.go index 9ef4e41576..502400972c 100644 --- a/pkg/tbtc/wallet_test.go +++ b/pkg/tbtc/wallet_test.go @@ -53,9 +53,25 @@ func TestParseWalletActionType(t *testing.T) { value: 5, expectedAction: ActionMovedFundsSweep, }, + "reservation anchor": { + value: 6, + expectedAction: ActionReservationAnchor, + }, + "reserved redemption": { + value: 7, + expectedAction: ActionReservedRedemption, + }, + "reservation re-anchor": { + value: 8, + expectedAction: ActionReservationReanchor, + }, + "reservation dissolution": { + value: 9, + expectedAction: ActionReservationDissolution, + }, "unknown": { - value: 6, - expectedErr: fmt.Errorf("unknown wallet action type [6]"), + value: 10, + expectedErr: fmt.Errorf("unknown wallet action type [10]"), }, } From 77fca949953fee369aa39d5f2bbc1278df583f77 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 7 Aug 2026 16:51:11 -0400 Subject: [PATCH 02/39] fix(tbtcpg): align GetRedemptionParameters call with struct return Repairs a pre-existing build break on main: the tbtcpg Chain interface was refactored to return tbtc.RedemptionParameters as a struct, but the fee-estimation call site in redemptions.go still destructured the old 8-value tuple. All other call sites already use the struct form. --- pkg/tbtcpg/redemptions.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/tbtcpg/redemptions.go b/pkg/tbtcpg/redemptions.go index a52d00eeb9..d4d845ee6c 100644 --- a/pkg/tbtcpg/redemptions.go +++ b/pkg/tbtcpg/redemptions.go @@ -222,13 +222,15 @@ func (rt *RedemptionTask) ProposeRedemption( if fee <= 0 { taskLogger.Infof("estimating redemption transaction fee") - _, _, txMaxFee, txMaxTotalFee, _, _, _, err := rt.chain.GetRedemptionParameters() + redemptionParams, err := rt.chain.GetRedemptionParameters() if err != nil { return nil, fmt.Errorf( "cannot get redemption tx max total fee: [%w]", err, ) } + txMaxFee := redemptionParams.TxMaxFee + txMaxTotalFee := redemptionParams.TxMaxTotalFee estimatedFee, err := EstimateRedemptionFee( rt.btcChain, From 7826468436edf50b700504bdb9d6efe47f6abe34 Mon Sep 17 00:00:00 2001 From: maclane Date: Sun, 9 Aug 2026 11:47:00 -0400 Subject: [PATCH 03/39] fix(tbtc): validate reservation proposal payloads --- pkg/clientinfo/performance.go | 4 ++ pkg/clientinfo/performance_test.go | 51 +++++++++++++++++++++++ pkg/tbtc/reservation.go | 57 ++++++++++++++++++++++++-- pkg/tbtc/reservation_test.go | 65 ++++++++++++++++++++++++++++++ pkg/tbtc/wallet.go | 8 ++++ pkg/tbtc/wallet_test.go | 26 ++++++++++++ 6 files changed, 207 insertions(+), 4 deletions(-) diff --git a/pkg/clientinfo/performance.go b/pkg/clientinfo/performance.go index d48c1c6b4d..0abae84eb0 100644 --- a/pkg/clientinfo/performance.go +++ b/pkg/clientinfo/performance.go @@ -751,5 +751,9 @@ func GetAllWalletActionTypes() []string { "redemption", "moving_funds", "moved_funds_sweep", + "reservation_anchor", + "reserved_redemption", + "reservation_reanchor", + "reservation_dissolution", } } diff --git a/pkg/clientinfo/performance_test.go b/pkg/clientinfo/performance_test.go index 5ebf253288..0354527ade 100644 --- a/pkg/clientinfo/performance_test.go +++ b/pkg/clientinfo/performance_test.go @@ -430,3 +430,54 @@ func TestJoinFailureAndOnChainCountersRegistered(t *testing.T) { } } } + +func TestWalletActionMetricsRegistered(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + registry := &Registry{keepclientinfo.NewRegistry(), ctx} + pm := NewPerformanceMetrics(ctx, registry) + + expectedActionTypes := []string{ + "heartbeat", + "deposit_sweep", + "redemption", + "moving_funds", + "moved_funds_sweep", + "reservation_anchor", + "reserved_redemption", + "reservation_reanchor", + "reservation_dissolution", + } + + for _, actionType := range expectedActionTypes { + for _, metricType := range []string{ + "total", + "success_total", + "failed_total", + } { + metricName := WalletActionMetricName(actionType, metricType) + + pm.countersMutex.RLock() + _, exists := pm.counters[metricName] + pm.countersMutex.RUnlock() + if !exists { + t.Errorf("counter %s should be registered upfront", metricName) + } + } + + durationMetricName := WalletActionMetricName( + actionType, + "duration_seconds", + ) + pm.histogramsMutex.RLock() + _, exists := pm.histograms[durationMetricName] + pm.histogramsMutex.RUnlock() + if !exists { + t.Errorf( + "histogram %s should be registered upfront", + durationMetricName, + ) + } + } +} diff --git a/pkg/tbtc/reservation.go b/pkg/tbtc/reservation.go index a26c37d578..d354223b11 100644 --- a/pkg/tbtc/reservation.go +++ b/pkg/tbtc/reservation.go @@ -127,7 +127,17 @@ func (rap *ReservationAnchorProposal) Marshal() ([]byte, error) { // Unmarshal converts a byte array back to the reservationAnchorProposal. func (rap *ReservationAnchorProposal) Unmarshal(bytes []byte) error { - return json.Unmarshal(bytes, rap) + var proposal ReservationAnchorProposal + if err := json.Unmarshal(bytes, &proposal); err != nil { + return err + } + if proposal.AnchorTxFee == nil { + return fmt.Errorf("anchor transaction fee is required") + } + + *rap = proposal + + return nil } // ReservedRedemptionProposal represents a reserved redemption proposal @@ -162,7 +172,20 @@ func (rrp *ReservedRedemptionProposal) Marshal() ([]byte, error) { // Unmarshal converts a byte array back to the reservedRedemptionProposal. func (rrp *ReservedRedemptionProposal) Unmarshal(bytes []byte) error { - return json.Unmarshal(bytes, rrp) + var proposal ReservedRedemptionProposal + if err := json.Unmarshal(bytes, &proposal); err != nil { + return err + } + if proposal.ReservationKey == nil { + return fmt.Errorf("reservation key is required") + } + if proposal.RedemptionTxFee == nil { + return fmt.Errorf("redemption transaction fee is required") + } + + *rrp = proposal + + return nil } // ReservationReanchorProposal represents a reservation re-anchor proposal @@ -199,7 +222,20 @@ func (rrp *ReservationReanchorProposal) Marshal() ([]byte, error) { // Unmarshal converts a byte array back to the reservationReanchorProposal. func (rrp *ReservationReanchorProposal) Unmarshal(bytes []byte) error { - return json.Unmarshal(bytes, rrp) + var proposal ReservationReanchorProposal + if err := json.Unmarshal(bytes, &proposal); err != nil { + return err + } + if proposal.ReservationKey == nil { + return fmt.Errorf("reservation key is required") + } + if proposal.ReanchorTxFee == nil { + return fmt.Errorf("re-anchor transaction fee is required") + } + + *rrp = proposal + + return nil } // ReservationDissolutionProposal represents a reservation dissolution @@ -234,7 +270,20 @@ func (rdp *ReservationDissolutionProposal) Marshal() ([]byte, error) { // Unmarshal converts a byte array back to the reservationDissolutionProposal. func (rdp *ReservationDissolutionProposal) Unmarshal(bytes []byte) error { - return json.Unmarshal(bytes, rdp) + var proposal ReservationDissolutionProposal + if err := json.Unmarshal(bytes, &proposal); err != nil { + return err + } + if proposal.ReservationKey == nil { + return fmt.Errorf("reservation key is required") + } + if proposal.DissolutionTxFee == nil { + return fmt.Errorf("dissolution transaction fee is required") + } + + *rdp = proposal + + return nil } // assembleReservationAnchorTransaction constructs an unsigned reservation diff --git a/pkg/tbtc/reservation_test.go b/pkg/tbtc/reservation_test.go index e3fab1f32a..1ebe9be8d8 100644 --- a/pkg/tbtc/reservation_test.go +++ b/pkg/tbtc/reservation_test.go @@ -79,6 +79,71 @@ func TestReservationProposals_MarshalingRoundtrip(t *testing.T) { roundtrip(dissolutionProposal, &ReservationDissolutionProposal{}) } +func TestReservationProposals_UnmarshalRejectsMissingIntegers(t *testing.T) { + tests := map[string]struct { + actionType WalletActionType + payload string + expectedError string + }{ + "anchor empty object": { + actionType: ActionReservationAnchor, + payload: `{}`, + expectedError: "cannot unmarshal proposal payload: [anchor transaction fee is required]", + }, + "anchor null payload": { + actionType: ActionReservationAnchor, + payload: `null`, + expectedError: "cannot unmarshal proposal payload: [anchor transaction fee is required]", + }, + "reserved redemption null payload": { + actionType: ActionReservedRedemption, + payload: `null`, + expectedError: "cannot unmarshal proposal payload: [reservation key is required]", + }, + "reserved redemption missing fee": { + actionType: ActionReservedRedemption, + payload: `{"ReservationKey":12345}`, + expectedError: "cannot unmarshal proposal payload: [redemption transaction fee is required]", + }, + "re-anchor null payload": { + actionType: ActionReservationReanchor, + payload: `null`, + expectedError: "cannot unmarshal proposal payload: [reservation key is required]", + }, + "re-anchor missing fee": { + actionType: ActionReservationReanchor, + payload: `{"ReservationKey":54321}`, + expectedError: "cannot unmarshal proposal payload: [re-anchor transaction fee is required]", + }, + "dissolution null payload": { + actionType: ActionReservationDissolution, + payload: `null`, + expectedError: "cannot unmarshal proposal payload: [reservation key is required]", + }, + "dissolution missing fee": { + actionType: ActionReservationDissolution, + payload: `{"ReservationKey":99999}`, + expectedError: "cannot unmarshal proposal payload: [dissolution transaction fee is required]", + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + _, err := unmarshalCoordinationProposal( + uint32(test.actionType), + []byte(test.payload), + ) + if err == nil || err.Error() != test.expectedError { + t.Errorf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + test.expectedError, + err, + ) + } + }) + } +} + func TestAssembleReservationTransactions_InputValidation(t *testing.T) { bitcoinChain := newLocalBitcoinChain() walletPublicKeyHash := [20]byte{0x01} diff --git a/pkg/tbtc/wallet.go b/pkg/tbtc/wallet.go index fee65b9737..ef4303302f 100644 --- a/pkg/tbtc/wallet.go +++ b/pkg/tbtc/wallet.go @@ -109,6 +109,14 @@ func (wat WalletActionType) MetricName() string { return "moving_funds" case ActionMovedFundsSweep: return "moved_funds_sweep" + case ActionReservationAnchor: + return "reservation_anchor" + case ActionReservedRedemption: + return "reserved_redemption" + case ActionReservationReanchor: + return "reservation_reanchor" + case ActionReservationDissolution: + return "reservation_dissolution" default: panic("unknown wallet action type") } diff --git a/pkg/tbtc/wallet_test.go b/pkg/tbtc/wallet_test.go index 502400972c..7d5a086414 100644 --- a/pkg/tbtc/wallet_test.go +++ b/pkg/tbtc/wallet_test.go @@ -98,6 +98,32 @@ func TestParseWalletActionType(t *testing.T) { } } +func TestWalletActionType_MetricName(t *testing.T) { + tests := map[WalletActionType]string{ + ActionNoop: "noop", + ActionHeartbeat: "heartbeat", + ActionDepositSweep: "deposit_sweep", + ActionRedemption: "redemption", + ActionMovingFunds: "moving_funds", + ActionMovedFundsSweep: "moved_funds_sweep", + ActionReservationAnchor: "reservation_anchor", + ActionReservedRedemption: "reserved_redemption", + ActionReservationReanchor: "reservation_reanchor", + ActionReservationDissolution: "reservation_dissolution", + } + + for actionType, expected := range tests { + if actual := actionType.MetricName(); actual != expected { + t.Errorf( + "unexpected metric name for action type [%v]\nexpected: [%v]\nactual: [%v]", + actionType, + expected, + actual, + ) + } + } +} + func TestWalletDispatcher_Dispatch(t *testing.T) { walletDispatcher := newWalletDispatcher() From 62b18c17c0d43842e61d8e2b22da2a3a115f07bf Mon Sep 17 00:00:00 2001 From: maclane Date: Sun, 9 Aug 2026 14:00:19 -0400 Subject: [PATCH 04/39] fix(tbtc): bind reservations to action generations --- pkg/chain/ethereum/tbtc.go | 13 ++ pkg/tbtc/chain.go | 8 + pkg/tbtc/chain_test.go | 7 + pkg/tbtc/reservation.go | 242 ++++++++++++++++++++++--- pkg/tbtc/reservation_test.go | 341 ++++++++++++++++++++++++++++++++++- 5 files changed, 583 insertions(+), 28 deletions(-) diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index cf84de27b1..83c9629fef 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -2420,6 +2420,19 @@ func (tc *TbtcChain) GetReservation( ) } +// GetReservationAction is not yet supported by the Ethereum chain +// implementation: the reservation contract bindings will be regenerated once +// the reservation Bridge API is published with the @keep-network/tbtc-v2 +// package. +func (tc *TbtcChain) GetReservationAction( + reservationKey *big.Int, + requestNonce uint64, +) (*tbtc.ReservationAction, error) { + return nil, fmt.Errorf( + "reservations not supported yet by the Ethereum chain implementation", + ) +} + // ReservationParameters is not yet supported by the Ethereum chain // implementation: the reservation contract bindings will be regenerated once // the reservation Bridge API is published with the @keep-network/tbtc-v2 diff --git a/pkg/tbtc/chain.go b/pkg/tbtc/chain.go index 1bf1fbf2de..02b2406b96 100644 --- a/pkg/tbtc/chain.go +++ b/pkg/tbtc/chain.go @@ -431,6 +431,14 @@ type WalletProposalValidatorChain interface { // reservation key. Returns an error if the reservation was not found. GetReservation(reservationKey *big.Int) (*Reservation, error) + // GetReservationAction gets the on-chain action record for the given + // reservation key and request nonce. Returns an error if the action + // generation was not found. + GetReservationAction( + reservationKey *big.Int, + requestNonce uint64, + ) (*ReservationAction, error) + // ReservationParameters gets the current on-chain values of the Bridge // reservation parameters. ReservationParameters() (*ReservationParameters, error) diff --git a/pkg/tbtc/chain_test.go b/pkg/tbtc/chain_test.go index 6bf9a1ae85..5768d03422 100644 --- a/pkg/tbtc/chain_test.go +++ b/pkg/tbtc/chain_test.go @@ -1457,6 +1457,13 @@ func (lc *localChain) GetReservation( panic("unsupported") } +func (lc *localChain) GetReservationAction( + reservationKey *big.Int, + requestNonce uint64, +) (*ReservationAction, error) { + panic("unsupported") +} + func (lc *localChain) ReservationParameters() (*ReservationParameters, error) { panic("unsupported") } diff --git a/pkg/tbtc/reservation.go b/pkg/tbtc/reservation.go index d354223b11..d6d6b153a1 100644 --- a/pkg/tbtc/reservation.go +++ b/pkg/tbtc/reservation.go @@ -5,6 +5,8 @@ import ( "fmt" "math/big" + "golang.org/x/crypto/sha3" + "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/chain" ) @@ -31,14 +33,18 @@ const ( // ReservationStateUnknown means the reservation is unknown to the Bridge. ReservationStateUnknown ReservationState = iota // ReservationStateActive means the reservation's anchor outpoint is under - // wallet custody. + // wallet custody with no action in flight. ReservationStateActive - // ReservationStateRedemptionRequested means the reservation owner - // requested an in-kind redemption of the anchor outpoint. - ReservationStateRedemptionRequested + // ReservationStateActionPending means a redemption, re-anchor, or + // dissolution action is pending. The action details are held in the + // nonce-keyed ReservationAction record. + ReservationStateActionPending // ReservationStateClosed means the reservation was closed by an in-kind - // redemption or a dissolution. + // redemption, dissolution, or late settlement. ReservationStateClosed + // ReservationStateStranded means the custodying wallet was terminated + // while the anchor was outstanding and the anchor is no longer tracked. + ReservationStateStranded ) // Reservation represents an on-chain UTXO reservation record. A reservation @@ -53,6 +59,8 @@ type Reservation struct { // MintedAmount is the gross amount in satoshi credited to the owner at // acceptance time. MintedAmount uint64 + // AcceptedAt is the UNIX timestamp the reservation was accepted at. + AcceptedAt uint32 // WalletPublicKeyHash is the 20-byte public key hash of the wallet // custodying the current anchor outpoint. WalletPublicKeyHash [20]byte @@ -63,15 +71,75 @@ type Reservation struct { ExpiresAt uint32 // State is the current state of the reservation. State ReservationState - // RedemptionRequestedAt is the UNIX timestamp the pending reserved - // redemption was requested at. Zero when no redemption is pending. - RedemptionRequestedAt uint32 - // RedemptionTxMaxFee is the maximum transaction fee in satoshi - // snapshotted at redemption request time. - RedemptionTxMaxFee uint64 - // RedeemerOutputScript is the output script the pending reserved - // redemption must pay to. Empty when no redemption is pending. - RedeemerOutputScript bitcoin.Script + // RequestNonce is the current monotonic reservation action generation. + RequestNonce uint64 + // RetryCredit indicates the owner has a single-use fee-free redemption + // retry entitlement after a fee-paid redemption timed out. + RetryCredit bool + // DissolutionEligibleAt is the UNIX timestamp at which the current term + // becomes eligible for dissolution. + DissolutionEligibleAt uint32 +} + +// ReservationActionType represents the type of a reservation action +// generation. +type ReservationActionType uint8 + +const ( + ReservationActionTypeNone ReservationActionType = iota + ReservationActionTypeAcceptance + ReservationActionTypeRedemption + ReservationActionTypeReanchor + ReservationActionTypeDissolution +) + +// ReservationActionState represents the settlement state of a reservation +// action generation. +type ReservationActionState uint8 + +const ( + ReservationActionStateUnknown ReservationActionState = iota + ReservationActionStatePending + ReservationActionStateSettled + ReservationActionStateTimedOut + ReservationActionStateVetoed + ReservationActionStateSuperseded +) + +// 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. +type ReservationAction struct { + // TargetWalletPublicKeyHash is the wallet an acceptance, re-anchor, or + // dissolution output must pay to. It is zero for redemptions. + TargetWalletPublicKeyHash [20]byte + // RequestedAt is the UNIX timestamp the action was requested at. + RequestedAt uint32 + // TimeoutAt is the UNIX timestamp after which the action may time out. + TimeoutAt uint32 + // TxMaxFee is the snapshotted maximum Bitcoin transaction fee in satoshi. + TxMaxFee uint64 + // ActionType is the type of this action generation. + ActionType ReservationActionType + // State is the settlement state of this action generation. + State ReservationActionState + // FeePaid indicates the generation was created through a fee-paying vault + // entry point. + FeePaid bool + // Redeemer is the address that can reclaim escrow after a redemption + // timeout. It is empty for other action types. + Redeemer chain.Address + // Amount is the satoshi amount associated with the action generation. + Amount uint64 + // RedeemerOutputScriptHash is the keccak256 hash of the length-prefixed + // output script authorized for a redemption. + RedeemerOutputScriptHash [32]byte + // ExpectedMainUtxoHash identifies the wallet main UTXO snapshotted for a + // dissolution. It is zero for other action types and no-main-UTXO wallets. + ExpectedMainUtxoHash [32]byte + // IsPartial indicates a redemption spends only Amount and must re-anchor + // the remaining reservation value back to the custodying wallet. + IsPartial bool } // ReservationParameters represents the on-chain values of the Bridge @@ -88,9 +156,24 @@ type ReservationParameters struct { ReservationTxMaxFee uint64 // ReservationTermSeconds is the custody term length in seconds. ReservationTermSeconds uint32 - // ReservationGracePeriod is the grace period in seconds after term - // expiry during which the reservation cannot be dissolved yet. - ReservationGracePeriod uint32 + // ReservationDissolutionDelay is the delay snapshotted after term expiry + // before a reservation becomes dissolvable. + ReservationDissolutionDelay uint32 + // ReservationMaxTotalAmount is the maximum total amount of all active + // reservations in satoshi. + ReservationMaxTotalAmount uint64 + // ReservationTotalAmount is the current total amount of all active + // reservations in satoshi. + ReservationTotalAmount uint64 + // MaxReservationsPerWallet is the maximum number of reservations a wallet + // may custody. + MaxReservationsPerWallet uint32 + // ReservationActionTimeout is the timeout for reservation actions in + // seconds. + ReservationActionTimeout uint32 + // ReservationRenewalWindowSeconds is the period before expiry during which + // a reservation can be renewed. + ReservationRenewalWindowSeconds uint32 } // ReservationAnchorProposal represents a reservation anchor proposal issued @@ -102,6 +185,8 @@ type ReservationAnchorProposal struct { // DepositFundingOutputIndex is the funding output index of the reserved // deposit to anchor. DepositFundingOutputIndex uint32 + // RequestNonce is the acceptance authorization generation being executed. + RequestNonce uint64 // AnchorTxFee is the proposed BTC fee for the anchor transaction. AnchorTxFee *big.Int } @@ -134,6 +219,9 @@ func (rap *ReservationAnchorProposal) Unmarshal(bytes []byte) error { if proposal.AnchorTxFee == nil { return fmt.Errorf("anchor transaction fee is required") } + if proposal.RequestNonce == 0 { + return fmt.Errorf("request nonce is required") + } *rap = proposal @@ -146,6 +234,8 @@ type ReservedRedemptionProposal struct { // ReservationKey is the key of the reservation with the pending reserved // redemption. ReservationKey *big.Int + // RequestNonce is the redemption request generation being executed. + RequestNonce uint64 // RedemptionTxFee is the proposed BTC fee for the reserved redemption // transaction. RedemptionTxFee *big.Int @@ -179,6 +269,9 @@ func (rrp *ReservedRedemptionProposal) Unmarshal(bytes []byte) error { if proposal.ReservationKey == nil { return fmt.Errorf("reservation key is required") } + if proposal.RequestNonce == 0 { + return fmt.Errorf("request nonce is required") + } if proposal.RedemptionTxFee == nil { return fmt.Errorf("redemption transaction fee is required") } @@ -194,6 +287,8 @@ func (rrp *ReservedRedemptionProposal) Unmarshal(bytes []byte) error { type ReservationReanchorProposal struct { // ReservationKey is the key of the reservation to re-anchor. ReservationKey *big.Int + // RequestNonce is the re-anchor authorization generation being executed. + RequestNonce uint64 // TargetWalletPublicKeyHash is the 20-byte public key hash of the wallet // receiving the anchor. TargetWalletPublicKeyHash [20]byte @@ -229,6 +324,9 @@ func (rrp *ReservationReanchorProposal) Unmarshal(bytes []byte) error { if proposal.ReservationKey == nil { return fmt.Errorf("reservation key is required") } + if proposal.RequestNonce == 0 { + return fmt.Errorf("request nonce is required") + } if proposal.ReanchorTxFee == nil { return fmt.Errorf("re-anchor transaction fee is required") } @@ -244,6 +342,8 @@ func (rrp *ReservationReanchorProposal) Unmarshal(bytes []byte) error { type ReservationDissolutionProposal struct { // ReservationKey is the key of the reservation to dissolve. ReservationKey *big.Int + // RequestNonce is the dissolution authorization generation being executed. + RequestNonce uint64 // DissolutionTxFee is the proposed BTC fee for the dissolution // transaction. DissolutionTxFee *big.Int @@ -277,6 +377,9 @@ func (rdp *ReservationDissolutionProposal) Unmarshal(bytes []byte) error { if proposal.ReservationKey == nil { return fmt.Errorf("reservation key is required") } + if proposal.RequestNonce == 0 { + return fmt.Errorf("request nonce is required") + } if proposal.DissolutionTxFee == nil { return fmt.Errorf("dissolution transaction fee is required") } @@ -338,14 +441,17 @@ func assembleReservationAnchorTransaction( } // assembleReservedRedemptionTransaction constructs an unsigned reserved -// redemption transaction: a 1-input-1-output spend of the reservation's -// anchor outpoint to the redeemer output script. The full gross claim is -// burned on the host chain upon the SPV proof of this transaction; the -// Bitcoin miner fee is the only in-kind deduction. +// redemption transaction for the given nonce-bound action. A whole redemption +// is a 1-input-1-output spend to the redeemer. A partial redemption is a +// 1-input-2-output spend whose first output pays the authorized amount less +// the miner fee to the redeemer and whose second output re-anchors the exact +// remainder to the custodying wallet. func assembleReservedRedemptionTransaction( bitcoinChain bitcoin.Chain, anchorUtxo *bitcoin.UnspentTransactionOutput, + walletPublicKeyHash [20]byte, redeemerOutputScript bitcoin.Script, + action *ReservationAction, fee int64, ) (*bitcoin.TransactionBuilder, error) { if anchorUtxo == nil { @@ -354,10 +460,56 @@ func assembleReservedRedemptionTransaction( if len(redeemerOutputScript) == 0 { return nil, fmt.Errorf("redeemer output script is required") } + if action == nil { + return nil, fmt.Errorf("reservation action is required") + } + if action.ActionType != ReservationActionTypeRedemption { + return nil, fmt.Errorf("reservation action is not a redemption") + } + if action.State != ReservationActionStatePending { + return nil, fmt.Errorf("reservation action is not pending") + } + if anchorUtxo.Value <= 0 { + return nil, fmt.Errorf("anchor UTXO value must be positive") + } + if action.Amount == 0 { + return nil, fmt.Errorf("redemption amount must be positive") + } + if action.Amount > uint64(anchorUtxo.Value) { + return nil, fmt.Errorf("redemption amount exceeds the anchor value") + } + if fee <= 0 { + return nil, fmt.Errorf("transaction fee must be positive") + } + if uint64(fee) > action.TxMaxFee { + return nil, fmt.Errorf("transaction fee exceeds the action fee limit") + } + + redeemerOutputScriptHash, err := computeReservationRedeemerOutputScriptHash( + redeemerOutputScript, + ) + if err != nil { + return nil, err + } + if redeemerOutputScriptHash != action.RedeemerOutputScriptHash { + return nil, fmt.Errorf("redeemer output script is not authorized") + } + + if action.IsPartial { + if action.Amount == uint64(anchorUtxo.Value) { + return nil, fmt.Errorf( + "partial redemption amount must be less than the anchor value", + ) + } + } else if action.Amount != uint64(anchorUtxo.Value) { + return nil, fmt.Errorf( + "whole redemption amount must equal the anchor value", + ) + } builder := bitcoin.NewTransactionBuilder(bitcoinChain) - err := builder.AddPublicKeyHashInput(anchorUtxo) + err = builder.AddPublicKeyHashInput(anchorUtxo) if err != nil { return nil, fmt.Errorf( "cannot add input pointing to anchor UTXO: [%v]", @@ -365,10 +517,15 @@ func assembleReservedRedemptionTransaction( ) } - redemptionValue := anchorUtxo.Value - fee + redemptionAmount := anchorUtxo.Value + if action.IsPartial { + redemptionAmount = int64(action.Amount) + } + + redemptionValue := redemptionAmount - fee if redemptionValue <= 0 { return nil, fmt.Errorf( - "transaction fee exceeds the anchor value", + "transaction fee exceeds the redemption amount", ) } @@ -377,9 +534,46 @@ func assembleReservedRedemptionTransaction( PublicKeyScript: redeemerOutputScript, }) + if action.IsPartial { + remainderScript, err := bitcoin.PayToWitnessPublicKeyHash( + walletPublicKeyHash, + ) + if err != nil { + return nil, fmt.Errorf("cannot compute remainder script: [%v]", err) + } + + builder.AddOutput(&bitcoin.TransactionOutput{ + Value: anchorUtxo.Value - int64(action.Amount), + PublicKeyScript: remainderScript, + }) + } + return builder, nil } +// computeReservationRedeemerOutputScriptHash computes the authorization hash +// stored in a reservation action. The Bridge hashes the Bitcoin output script +// including its CompactSize length prefix. +func computeReservationRedeemerOutputScriptHash( + redeemerOutputScript bitcoin.Script, +) ([32]byte, error) { + prefixedScript, err := redeemerOutputScript.ToVarLenData() + if err != nil { + return [32]byte{}, fmt.Errorf( + "cannot build prefixed redeemer output script: [%v]", + err, + ) + } + + hasher := sha3.NewLegacyKeccak256() + _, _ = hasher.Write(prefixedScript) + + var result [32]byte + copy(result[:], hasher.Sum(nil)) + + return result, nil +} + // assembleReservationReanchorTransaction constructs an unsigned reservation // re-anchor transaction: a 1-input-1-output spend of the reservation's // anchor outpoint into a fresh output controlled by the target wallet. Used diff --git a/pkg/tbtc/reservation_test.go b/pkg/tbtc/reservation_test.go index 1ebe9be8d8..3cd45db21f 100644 --- a/pkg/tbtc/reservation_test.go +++ b/pkg/tbtc/reservation_test.go @@ -1,6 +1,8 @@ package tbtc import ( + "crypto/ecdsa" + "crypto/rand" "math/big" "reflect" "testing" @@ -30,26 +32,91 @@ func TestReservationActionTypes(t *testing.T) { } } +func TestReservationStateValues(t *testing.T) { + tests := map[ReservationState]uint8{ + ReservationStateUnknown: 0, + ReservationStateActive: 1, + ReservationStateActionPending: 2, + ReservationStateClosed: 3, + ReservationStateStranded: 4, + } + + for state, expected := range tests { + if actual := uint8(state); actual != expected { + t.Errorf( + "unexpected reservation state value\nexpected: [%v]\nactual: [%v]", + expected, + actual, + ) + } + } +} + +func TestReservationActionTypeValues(t *testing.T) { + tests := map[ReservationActionType]uint8{ + ReservationActionTypeNone: 0, + ReservationActionTypeAcceptance: 1, + ReservationActionTypeRedemption: 2, + ReservationActionTypeReanchor: 3, + ReservationActionTypeDissolution: 4, + } + + for actionType, expected := range tests { + if actual := uint8(actionType); actual != expected { + t.Errorf( + "unexpected reservation action type value\nexpected: [%v]\nactual: [%v]", + expected, + actual, + ) + } + } +} + +func TestReservationActionStateValues(t *testing.T) { + tests := map[ReservationActionState]uint8{ + ReservationActionStateUnknown: 0, + ReservationActionStatePending: 1, + ReservationActionStateSettled: 2, + ReservationActionStateTimedOut: 3, + ReservationActionStateVetoed: 4, + ReservationActionStateSuperseded: 5, + } + + for state, expected := range tests { + if actual := uint8(state); actual != expected { + t.Errorf( + "unexpected reservation action state value\nexpected: [%v]\nactual: [%v]", + expected, + actual, + ) + } + } +} + func TestReservationProposals_MarshalingRoundtrip(t *testing.T) { anchorProposal := &ReservationAnchorProposal{ DepositFundingTxHash: bitcoin.Hash{0x01, 0x02}, DepositFundingOutputIndex: 3, + RequestNonce: 1, AnchorTxFee: big.NewInt(1500), } redemptionProposal := &ReservedRedemptionProposal{ ReservationKey: big.NewInt(12345), + RequestNonce: 2, RedemptionTxFee: big.NewInt(1600), } reanchorProposal := &ReservationReanchorProposal{ ReservationKey: big.NewInt(54321), + RequestNonce: 3, TargetWalletPublicKeyHash: [20]byte{0xaa, 0xbb}, ReanchorTxFee: big.NewInt(1700), } dissolutionProposal := &ReservationDissolutionProposal{ ReservationKey: big.NewInt(99999), + RequestNonce: 4, DissolutionTxFee: big.NewInt(1800), } @@ -95,14 +162,24 @@ func TestReservationProposals_UnmarshalRejectsMissingIntegers(t *testing.T) { payload: `null`, expectedError: "cannot unmarshal proposal payload: [anchor transaction fee is required]", }, + "anchor missing nonce": { + actionType: ActionReservationAnchor, + payload: `{"AnchorTxFee":1500}`, + expectedError: "cannot unmarshal proposal payload: [request nonce is required]", + }, "reserved redemption null payload": { actionType: ActionReservedRedemption, payload: `null`, expectedError: "cannot unmarshal proposal payload: [reservation key is required]", }, + "reserved redemption missing nonce": { + actionType: ActionReservedRedemption, + payload: `{"ReservationKey":12345,"RedemptionTxFee":1600}`, + expectedError: "cannot unmarshal proposal payload: [request nonce is required]", + }, "reserved redemption missing fee": { actionType: ActionReservedRedemption, - payload: `{"ReservationKey":12345}`, + payload: `{"ReservationKey":12345,"RequestNonce":2}`, expectedError: "cannot unmarshal proposal payload: [redemption transaction fee is required]", }, "re-anchor null payload": { @@ -110,9 +187,14 @@ func TestReservationProposals_UnmarshalRejectsMissingIntegers(t *testing.T) { payload: `null`, expectedError: "cannot unmarshal proposal payload: [reservation key is required]", }, + "re-anchor missing nonce": { + actionType: ActionReservationReanchor, + payload: `{"ReservationKey":54321,"ReanchorTxFee":1700}`, + expectedError: "cannot unmarshal proposal payload: [request nonce is required]", + }, "re-anchor missing fee": { actionType: ActionReservationReanchor, - payload: `{"ReservationKey":54321}`, + payload: `{"ReservationKey":54321,"RequestNonce":3}`, expectedError: "cannot unmarshal proposal payload: [re-anchor transaction fee is required]", }, "dissolution null payload": { @@ -120,9 +202,14 @@ func TestReservationProposals_UnmarshalRejectsMissingIntegers(t *testing.T) { payload: `null`, expectedError: "cannot unmarshal proposal payload: [reservation key is required]", }, + "dissolution missing nonce": { + actionType: ActionReservationDissolution, + payload: `{"ReservationKey":99999,"DissolutionTxFee":1800}`, + expectedError: "cannot unmarshal proposal payload: [request nonce is required]", + }, "dissolution missing fee": { actionType: ActionReservationDissolution, - payload: `{"ReservationKey":99999}`, + payload: `{"ReservationKey":99999,"RequestNonce":4}`, expectedError: "cannot unmarshal proposal payload: [dissolution transaction fee is required]", }, } @@ -144,6 +231,152 @@ func TestReservationProposals_UnmarshalRejectsMissingIntegers(t *testing.T) { } } +func TestAssembleReservedRedemptionTransaction(t *testing.T) { + bitcoinChain := newLocalBitcoinChain() + + privateKeyValue := big.NewInt(100) + wallet := generateWallet(privateKeyValue) + walletPublicKeyHash := bitcoin.PublicKeyHash(wallet.publicKey) + walletScript, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + + redeemerScript, err := bitcoin.PayToWitnessPublicKeyHash([20]byte{0x01}) + if err != nil { + t.Fatal(err) + } + + fundingTransaction := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x01}, + OutputIndex: 0, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 100000, + PublicKeyScript: walletScript, + }, + }, + } + if err := bitcoinChain.BroadcastTransaction(fundingTransaction); err != nil { + t.Fatal(err) + } + + anchorUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTransaction.Hash(), + OutputIndex: 0, + }, + Value: 100000, + } + + redeemerOutputScriptHash, err := computeReservationRedeemerOutputScriptHash( + redeemerScript, + ) + if err != nil { + t.Fatal(err) + } + + tests := map[string]struct { + action *ReservationAction + expectedOutputs []*bitcoin.TransactionOutput + }{ + "whole redemption": { + action: &ReservationAction{ + TxMaxFee: 2000, + ActionType: ReservationActionTypeRedemption, + State: ReservationActionStatePending, + Amount: 100000, + RedeemerOutputScriptHash: redeemerOutputScriptHash, + }, + expectedOutputs: []*bitcoin.TransactionOutput{ + { + Value: 98500, + PublicKeyScript: redeemerScript, + }, + }, + }, + "partial redemption": { + action: &ReservationAction{ + TxMaxFee: 2000, + ActionType: ReservationActionTypeRedemption, + State: ReservationActionStatePending, + Amount: 40000, + RedeemerOutputScriptHash: redeemerOutputScriptHash, + IsPartial: true, + }, + expectedOutputs: []*bitcoin.TransactionOutput{ + { + Value: 38500, + PublicKeyScript: redeemerScript, + }, + { + Value: 60000, + PublicKeyScript: walletScript, + }, + }, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + builder, err := assembleReservedRedemptionTransaction( + bitcoinChain, + anchorUtxo, + walletPublicKeyHash, + redeemerScript, + test.action, + 1500, + ) + if err != nil { + t.Fatal(err) + } + + sigHashes, err := builder.ComputeSignatureHashes() + if err != nil { + t.Fatal(err) + } + + privateKey := &ecdsa.PrivateKey{ + PublicKey: *wallet.publicKey, + D: privateKeyValue, + } + signatures := make([]*bitcoin.SignatureContainer, len(sigHashes)) + for i, sigHash := range sigHashes { + r, s, err := ecdsa.Sign(rand.Reader, privateKey, sigHash.Bytes()) + if err != nil { + t.Fatal(err) + } + signatures[i] = &bitcoin.SignatureContainer{ + R: r, + S: s, + PublicKey: wallet.publicKey, + } + } + + transaction, err := builder.AddSignatures(signatures) + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(test.expectedOutputs, transaction.Outputs) { + t.Errorf( + "unexpected outputs\nexpected: [%+v]\nactual: [%+v]", + test.expectedOutputs, + transaction.Outputs, + ) + } + }) + } +} + func TestAssembleReservationTransactions_InputValidation(t *testing.T) { bitcoinChain := newLocalBitcoinChain() walletPublicKeyHash := [20]byte{0x01} @@ -156,6 +389,19 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { }, Value: 100000, } + redeemerOutputScriptHash, err := computeReservationRedeemerOutputScriptHash( + redeemerScript, + ) + if err != nil { + t.Fatal(err) + } + redemptionAction := &ReservationAction{ + TxMaxFee: 2000, + ActionType: ReservationActionTypeRedemption, + State: ReservationActionStatePending, + Amount: 100000, + RedeemerOutputScriptHash: redeemerOutputScriptHash, + } assertError := func(err error, expected string) { if err == nil || err.Error() != expected { @@ -163,7 +409,7 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { } } - _, err := assembleReservationAnchorTransaction( + _, err = assembleReservationAnchorTransaction( bitcoinChain, nil, walletPublicKeyHash, @@ -174,7 +420,9 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { _, err = assembleReservedRedemptionTransaction( bitcoinChain, nil, + walletPublicKeyHash, redeemerScript, + redemptionAction, 1500, ) assertError(err, "anchor UTXO is required") @@ -182,11 +430,96 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { _, err = assembleReservedRedemptionTransaction( bitcoinChain, anchorUtxo, + walletPublicKeyHash, bitcoin.Script{}, + redemptionAction, 1500, ) assertError(err, "redeemer output script is required") + _, err = assembleReservedRedemptionTransaction( + bitcoinChain, + anchorUtxo, + walletPublicKeyHash, + redeemerScript, + nil, + 1500, + ) + assertError(err, "reservation action is required") + + nonRedemptionAction := *redemptionAction + nonRedemptionAction.ActionType = ReservationActionTypeReanchor + _, err = assembleReservedRedemptionTransaction( + bitcoinChain, + anchorUtxo, + walletPublicKeyHash, + redeemerScript, + &nonRedemptionAction, + 1500, + ) + assertError(err, "reservation action is not a redemption") + + nonPendingAction := *redemptionAction + nonPendingAction.State = ReservationActionStateTimedOut + _, err = assembleReservedRedemptionTransaction( + bitcoinChain, + anchorUtxo, + walletPublicKeyHash, + redeemerScript, + &nonPendingAction, + 1500, + ) + assertError(err, "reservation action is not pending") + + wrongScriptAction := *redemptionAction + wrongScriptAction.RedeemerOutputScriptHash = [32]byte{0x01} + _, err = assembleReservedRedemptionTransaction( + bitcoinChain, + anchorUtxo, + walletPublicKeyHash, + redeemerScript, + &wrongScriptAction, + 1500, + ) + assertError(err, "redeemer output script is not authorized") + + partialWholeAmountAction := *redemptionAction + partialWholeAmountAction.IsPartial = true + _, err = assembleReservedRedemptionTransaction( + bitcoinChain, + anchorUtxo, + walletPublicKeyHash, + redeemerScript, + &partialWholeAmountAction, + 1500, + ) + assertError( + err, + "partial redemption amount must be less than the anchor value", + ) + + partialAmountAction := *redemptionAction + partialAmountAction.Amount = 40000 + _, err = assembleReservedRedemptionTransaction( + bitcoinChain, + anchorUtxo, + walletPublicKeyHash, + redeemerScript, + &partialAmountAction, + 1500, + ) + assertError(err, "whole redemption amount must equal the anchor value") + + _, err = assembleReservedRedemptionTransaction( + bitcoinChain, + anchorUtxo, + walletPublicKeyHash, + redeemerScript, + redemptionAction, + 2500, + ) + assertError(err, "transaction fee exceeds the action fee limit") + _, err = assembleReservationReanchorTransaction( bitcoinChain, nil, From b4f63944246d9205aa4a6a55961d6d6df7fa04e6 Mon Sep 17 00:00:00 2001 From: maclane Date: Sun, 9 Aug 2026 19:45:41 -0400 Subject: [PATCH 05/39] fix(tbtc): bind dissolution inputs to action snapshot --- pkg/tbtc/reservation.go | 56 +++++++- pkg/tbtc/reservation_test.go | 271 ++++++++++++++++++++++++++++++++--- 2 files changed, 302 insertions(+), 25 deletions(-) diff --git a/pkg/tbtc/reservation.go b/pkg/tbtc/reservation.go index d6d6b153a1..a7a4de2577 100644 --- a/pkg/tbtc/reservation.go +++ b/pkg/tbtc/reservation.go @@ -621,20 +621,66 @@ func assembleReservationReanchorTransaction( } // assembleReservationDissolutionTransaction constructs an unsigned -// reservation dissolution transaction merging an expired reservation's -// anchor outpoint into the wallet main UTXO: the anchor outpoint is the -// first input, the wallet main UTXO (if it exists) is the second input, and -// the single output paying back to the wallet becomes its new main UTXO. +// reservation dissolution transaction for the given nonce-bound action. The +// anchor outpoint is the first input. The wallet main UTXO is the second input +// only when it is present in the action snapshot and matches that snapshot +// exactly. The single output pays back to the custodying wallet. func assembleReservationDissolutionTransaction( bitcoinChain bitcoin.Chain, + bridgeChain BridgeChain, anchorUtxo *bitcoin.UnspentTransactionOutput, walletMainUtxo *bitcoin.UnspentTransactionOutput, walletPublicKeyHash [20]byte, + action *ReservationAction, fee int64, ) (*bitcoin.TransactionBuilder, error) { if anchorUtxo == nil { return nil, fmt.Errorf("anchor UTXO is required") } + if action == nil { + return nil, fmt.Errorf("reservation action is required") + } + if action.ActionType != ReservationActionTypeDissolution { + return nil, fmt.Errorf("reservation action is not a dissolution") + } + if action.State != ReservationActionStatePending { + return nil, fmt.Errorf("reservation action is not pending") + } + if action.TargetWalletPublicKeyHash != walletPublicKeyHash { + return nil, fmt.Errorf("dissolution action targets a different wallet") + } + if anchorUtxo.Value <= 0 { + return nil, fmt.Errorf("anchor UTXO value must be positive") + } + if action.Amount != uint64(anchorUtxo.Value) { + return nil, fmt.Errorf( + "dissolution action amount does not match the anchor value", + ) + } + if fee <= 0 { + return nil, fmt.Errorf("transaction fee must be positive") + } + if uint64(fee) > action.TxMaxFee { + return nil, fmt.Errorf("transaction fee exceeds the action fee limit") + } + + mainUtxoExpected := action.ExpectedMainUtxoHash != [32]byte{} + if mainUtxoExpected { + if bridgeChain == nil { + return nil, fmt.Errorf("bridge chain is required") + } + if walletMainUtxo == nil { + return nil, fmt.Errorf( + "wallet main UTXO is required by the dissolution action", + ) + } + if bridgeChain.ComputeMainUtxoHash(walletMainUtxo) != + action.ExpectedMainUtxoHash { + return nil, fmt.Errorf( + "wallet main UTXO does not match the dissolution action snapshot", + ) + } + } builder := bitcoin.NewTransactionBuilder(bitcoinChain) @@ -649,7 +695,7 @@ func assembleReservationDissolutionTransaction( totalInputsValue := anchorUtxo.Value - if walletMainUtxo != nil { + if mainUtxoExpected { err = builder.AddPublicKeyHashInput(walletMainUtxo) if err != nil { return nil, fmt.Errorf( diff --git a/pkg/tbtc/reservation_test.go b/pkg/tbtc/reservation_test.go index 3cd45db21f..16ab16c160 100644 --- a/pkg/tbtc/reservation_test.go +++ b/pkg/tbtc/reservation_test.go @@ -339,37 +339,166 @@ func TestAssembleReservedRedemptionTransaction(t *testing.T) { t.Fatal(err) } - sigHashes, err := builder.ComputeSignatureHashes() + transaction := signReservationTransaction( + t, + builder, + wallet.publicKey, + privateKeyValue, + ) + + if !reflect.DeepEqual(test.expectedOutputs, transaction.Outputs) { + t.Errorf( + "unexpected outputs\nexpected: [%+v]\nactual: [%+v]", + test.expectedOutputs, + transaction.Outputs, + ) + } + }) + } +} + +func TestAssembleReservationDissolutionTransaction(t *testing.T) { + bitcoinChain := newLocalBitcoinChain() + bridgeChain := Connect() + + privateKeyValue := big.NewInt(100) + wallet := generateWallet(privateKeyValue) + walletPublicKeyHash := bitcoin.PublicKeyHash(wallet.publicKey) + walletScript, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + + fundingTransaction := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x01}, + OutputIndex: 0, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 100000, + PublicKeyScript: walletScript, + }, + { + Value: 200000, + PublicKeyScript: walletScript, + }, + }, + } + if err := bitcoinChain.BroadcastTransaction(fundingTransaction); err != nil { + t.Fatal(err) + } + + anchorUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTransaction.Hash(), + OutputIndex: 0, + }, + Value: 100000, + } + walletMainUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTransaction.Hash(), + OutputIndex: 1, + }, + Value: 200000, + } + + baseAction := ReservationAction{ + TargetWalletPublicKeyHash: walletPublicKeyHash, + TxMaxFee: 2000, + ActionType: ReservationActionTypeDissolution, + State: ReservationActionStatePending, + Amount: 100000, + } + + tests := map[string]struct { + action *ReservationAction + expectedInputUtxos []*bitcoin.UnspentTransactionOutput + expectedOutputValue int64 + }{ + "snapshotted main UTXO": { + action: func() *ReservationAction { + action := baseAction + action.ExpectedMainUtxoHash = bridgeChain.ComputeMainUtxoHash( + walletMainUtxo, + ) + return &action + }(), + expectedInputUtxos: []*bitcoin.UnspentTransactionOutput{ + anchorUtxo, + walletMainUtxo, + }, + expectedOutputValue: 298500, + }, + "no-main-UTXO snapshot with newly current main UTXO": { + action: &baseAction, + expectedInputUtxos: []*bitcoin.UnspentTransactionOutput{ + anchorUtxo, + }, + expectedOutputValue: 98500, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + builder, err := assembleReservationDissolutionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + walletMainUtxo, + walletPublicKeyHash, + test.action, + 1500, + ) if err != nil { t.Fatal(err) } - privateKey := &ecdsa.PrivateKey{ - PublicKey: *wallet.publicKey, - D: privateKeyValue, + transaction := signReservationTransaction( + t, + builder, + wallet.publicKey, + privateKeyValue, + ) + + if len(transaction.Inputs) != len(test.expectedInputUtxos) { + t.Fatalf( + "unexpected input count\nexpected: [%v]\nactual: [%v]", + len(test.expectedInputUtxos), + len(transaction.Inputs), + ) } - signatures := make([]*bitcoin.SignatureContainer, len(sigHashes)) - for i, sigHash := range sigHashes { - r, s, err := ecdsa.Sign(rand.Reader, privateKey, sigHash.Bytes()) - if err != nil { - t.Fatal(err) - } - signatures[i] = &bitcoin.SignatureContainer{ - R: r, - S: s, - PublicKey: wallet.publicKey, + for i, expectedInputUtxo := range test.expectedInputUtxos { + if !reflect.DeepEqual( + expectedInputUtxo.Outpoint, + transaction.Inputs[i].Outpoint, + ) { + t.Errorf( + "unexpected input at index [%v]\nexpected: [%+v]\nactual: [%+v]", + i, + expectedInputUtxo.Outpoint, + transaction.Inputs[i].Outpoint, + ) } } - transaction, err := builder.AddSignatures(signatures) - if err != nil { - t.Fatal(err) + expectedOutputs := []*bitcoin.TransactionOutput{ + { + Value: test.expectedOutputValue, + PublicKeyScript: walletScript, + }, } - - if !reflect.DeepEqual(test.expectedOutputs, transaction.Outputs) { + if !reflect.DeepEqual(expectedOutputs, transaction.Outputs) { t.Errorf( "unexpected outputs\nexpected: [%+v]\nactual: [%+v]", - test.expectedOutputs, + expectedOutputs, transaction.Outputs, ) } @@ -377,8 +506,47 @@ func TestAssembleReservedRedemptionTransaction(t *testing.T) { } } +func signReservationTransaction( + t *testing.T, + builder *bitcoin.TransactionBuilder, + publicKey *ecdsa.PublicKey, + privateKeyValue *big.Int, +) *bitcoin.Transaction { + t.Helper() + + sigHashes, err := builder.ComputeSignatureHashes() + if err != nil { + t.Fatal(err) + } + + privateKey := &ecdsa.PrivateKey{ + PublicKey: *publicKey, + D: privateKeyValue, + } + signatures := make([]*bitcoin.SignatureContainer, len(sigHashes)) + for i, sigHash := range sigHashes { + r, s, err := ecdsa.Sign(rand.Reader, privateKey, sigHash.Bytes()) + if err != nil { + t.Fatal(err) + } + signatures[i] = &bitcoin.SignatureContainer{ + R: r, + S: s, + PublicKey: publicKey, + } + } + + transaction, err := builder.AddSignatures(signatures) + if err != nil { + t.Fatal(err) + } + + return transaction +} + func TestAssembleReservationTransactions_InputValidation(t *testing.T) { bitcoinChain := newLocalBitcoinChain() + bridgeChain := Connect() walletPublicKeyHash := [20]byte{0x01} redeemerScript := bitcoin.Script{0x00, 0x14, 0x02} @@ -402,6 +570,13 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { Amount: 100000, RedeemerOutputScriptHash: redeemerOutputScriptHash, } + dissolutionAction := &ReservationAction{ + TargetWalletPublicKeyHash: walletPublicKeyHash, + TxMaxFee: 2000, + ActionType: ReservationActionTypeDissolution, + State: ReservationActionStatePending, + Amount: 100000, + } assertError := func(err error, expected string) { if err == nil || err.Error() != expected { @@ -530,10 +705,66 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { _, err = assembleReservationDissolutionTransaction( bitcoinChain, + bridgeChain, nil, nil, walletPublicKeyHash, + dissolutionAction, 1500, ) assertError(err, "anchor UTXO is required") + + _, err = assembleReservationDissolutionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + nil, + walletPublicKeyHash, + nil, + 1500, + ) + assertError(err, "reservation action is required") + + snapshottedMainUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x04}, + OutputIndex: 1, + }, + Value: 200000, + } + actionWithMainUtxo := *dissolutionAction + actionWithMainUtxo.ExpectedMainUtxoHash = bridgeChain.ComputeMainUtxoHash( + snapshottedMainUtxo, + ) + _, err = assembleReservationDissolutionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + nil, + walletPublicKeyHash, + &actionWithMainUtxo, + 1500, + ) + assertError(err, "wallet main UTXO is required by the dissolution action") + + currentMainUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x05}, + OutputIndex: 2, + }, + Value: 300000, + } + _, err = assembleReservationDissolutionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + currentMainUtxo, + walletPublicKeyHash, + &actionWithMainUtxo, + 1500, + ) + assertError( + err, + "wallet main UTXO does not match the dissolution action snapshot", + ) } From 277865cb435ea3b7c3be97f6d1759b299ccad6f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 26 Aug 2026 09:23:51 +0000 Subject: [PATCH 06/39] feat(tbtc): regenerate ABI bindings for reservation router surface Regenerates the @keep-network/tbtc-v2 ABI bindings against the m1 bridge-integration surface (/tmp/m1-g @ 9362cda1), adding the ReservationRouter contract to the required_contracts list and introducing a fix_reservation_router_collision Makefile hook that renames ReservationRouter's BitcoinTxInfo / BitcoinTxProof / BitcoinTxUTXO structs to BitcoinTxInfo4 / BitcoinTxProof3 / BitcoinTxUTXO4 (the next free suffixes after Bridge / WalletProposalValidator / MaintainerProxy). The new abi/Bridge.go surface carries the reservation selectors exposed on the Bridge itself -- isReservedDeposit, setReservationRouter, and getReservationRouter -- in addition to the existing Bridge API; the regenerated MaintainerProxy, WalletProposalValidator, RedemptionWatchtower, and Relay bindings reflect minor ABI surface additions that landed in the same bridge-integration commit. The abi/ReservationRouter.go binding is the encoding source for the six read/validate methods filled in on the next commit; its call site is the Bridge address (Bridge.fallback routes the router selector via delegatecall), so all reads, writes, and event/log filters must target the Bridge address, not the router's own deployment address. --- pkg/chain/ethereum/tbtc/gen/Makefile | 28 +- .../tbtc/gen/_address/ReservationRouter | 0 pkg/chain/ethereum/tbtc/gen/abi/Bridge.go | 614 +- pkg/chain/ethereum/tbtc/gen/abi/LightRelay.go | 5 +- .../tbtc/gen/abi/LightRelayMaintainerProxy.go | 5 +- .../ethereum/tbtc/gen/abi/MaintainerProxy.go | 5 +- .../tbtc/gen/abi/RedemptionWatchtower.go | 33 +- .../tbtc/gen/abi/ReservationRouter.go | 3270 +++++++++++ .../tbtc/gen/abi/WalletProposalValidator.go | 79 +- pkg/chain/ethereum/tbtc/gen/cmd/Bridge.go | 367 ++ .../tbtc/gen/cmd/RedemptionWatchtower.go | 35 + .../tbtc/gen/cmd/ReservationRouter.go | 1331 +++++ .../tbtc/gen/cmd/WalletProposalValidator.go | 88 + .../ethereum/tbtc/gen/contract/Bridge.go | 1257 +++- .../tbtc/gen/contract/RedemptionWatchtower.go | 37 + .../tbtc/gen/contract/ReservationRouter.go | 5187 +++++++++++++++++ .../gen/contract/WalletProposalValidator.go | 91 + 17 files changed, 12396 insertions(+), 36 deletions(-) create mode 100644 pkg/chain/ethereum/tbtc/gen/_address/ReservationRouter create mode 100644 pkg/chain/ethereum/tbtc/gen/abi/ReservationRouter.go create mode 100644 pkg/chain/ethereum/tbtc/gen/cmd/ReservationRouter.go create mode 100644 pkg/chain/ethereum/tbtc/gen/contract/ReservationRouter.go diff --git a/pkg/chain/ethereum/tbtc/gen/Makefile b/pkg/chain/ethereum/tbtc/gen/Makefile index 2229760f03..1bdbdea328 100644 --- a/pkg/chain/ethereum/tbtc/gen/Makefile +++ b/pkg/chain/ethereum/tbtc/gen/Makefile @@ -1,7 +1,7 @@ npm_package_name=@keep-network/tbtc-v2 # Contracts for which the bindings should be generated. -required_contracts := Bridge MaintainerProxy LightRelay LightRelayMaintainerProxy WalletProposalValidator RedemptionWatchtower +required_contracts := Bridge MaintainerProxy LightRelay LightRelayMaintainerProxy WalletProposalValidator RedemptionWatchtower ReservationRouter # There is a bug in the currently used abigen version (v1.10.19) that makes it # re-declaring structs used by multiple contracts @@ -22,6 +22,7 @@ define after_abi_hook $(eval type := $(1)) $(if $(filter $(type),WalletProposalValidator),$(call fix_wallet_proposal_validator_collision)) $(if $(filter $(type),MaintainerProxy),$(call fix_maintainer_proxy_collision)) + $(if $(filter $(type),ReservationRouter),$(call fix_reservation_router_collision)) endef define fix_wallet_proposal_validator_collision @perl -pi -e s,BitcoinTxInfo,BitcoinTxInfo2,g ./abi/WalletProposalValidator.go @@ -32,12 +33,26 @@ define fix_maintainer_proxy_collision @perl -pi -e s,BitcoinTxProof,BitcoinTxProof2,g ./abi/MaintainerProxy.go @perl -pi -e s,BitcoinTxInfo,BitcoinTxInfo3,g ./abi/MaintainerProxy.go endef +# ReservationRouter introduces its own copies of the BitcoinTx.* structs +# (BitcoinTxInfo, BitcoinTxProof, BitcoinTxUTXO) used by submitReservationProof. +# These names already collide with Bridge's (BitcoinTxInfo/BitcoinTxProof/BitcoinTxUTXO), +# WalletProposalValidator's (BitcoinTxInfo2/BitcoinTxUTXO3), and MaintainerProxy's +# (BitcoinTxInfo3/BitcoinTxProof2/BitcoinTxUTXO2). Renumber ReservationRouter's +# copies to the next free suffix in each family. The router code only ever runs +# via Bridge.fallback delegatecall, so its storage pointer is unused; this is +# a purely textual rename to keep the abi package compiling. +define fix_reservation_router_collision + @perl -pi -e s,BitcoinTxInfo,BitcoinTxInfo4,g ./abi/ReservationRouter.go + @perl -pi -e s,BitcoinTxProof,BitcoinTxProof3,g ./abi/ReservationRouter.go + @perl -pi -e s,BitcoinTxUTXO,BitcoinTxUTXO4,g ./abi/ReservationRouter.go +endef # See explanation in https://github.com/keep-network/keep-common/issues/117. define after_contract_hook $(eval type := $(1)) $(if $(filter $(type),WalletProposalValidator),$(call fix_wallet_proposal_validator_contract_collision)) $(if $(filter $(type),MaintainerProxy),$(call fix_maintainer_proxy_contract_collision)) + $(if $(filter $(type),ReservationRouter),$(call fix_reservation_router_contract_collision)) endef define fix_wallet_proposal_validator_contract_collision @perl -pi -e s,BitcoinTxUTXO,BitcoinTxUTXO3,g ./contract/WalletProposalValidator.go @@ -51,5 +66,16 @@ define fix_maintainer_proxy_contract_collision @perl -pi -e s,BitcoinTxProof,BitcoinTxProof2,g ./cmd/MaintainerProxy.go @perl -pi -e s,BitcoinTxInfo,BitcoinTxInfo3,g ./cmd/MaintainerProxy.go endef +# keep-common's generator emits BitcoinTx* references inside the generated +# contract/cmd binding code paths too (e.g. method wrappers). Apply the same +# renames there so the contract and cmd packages compile. +define fix_reservation_router_contract_collision + @perl -pi -e s,BitcoinTxInfo,BitcoinTxInfo4,g ./contract/ReservationRouter.go + @perl -pi -e s,BitcoinTxProof,BitcoinTxProof3,g ./contract/ReservationRouter.go + @perl -pi -e s,BitcoinTxUTXO,BitcoinTxUTXO4,g ./contract/ReservationRouter.go + @perl -pi -e s,BitcoinTxInfo,BitcoinTxInfo4,g ./cmd/ReservationRouter.go + @perl -pi -e s,BitcoinTxProof,BitcoinTxProof3,g ./cmd/ReservationRouter.go + @perl -pi -e s,BitcoinTxUTXO,BitcoinTxUTXO4,g ./cmd/ReservationRouter.go +endef include ../../common/gen/Makefile diff --git a/pkg/chain/ethereum/tbtc/gen/_address/ReservationRouter b/pkg/chain/ethereum/tbtc/gen/_address/ReservationRouter new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pkg/chain/ethereum/tbtc/gen/abi/Bridge.go b/pkg/chain/ethereum/tbtc/gen/abi/Bridge.go index e76e6f779f..80df5a4d72 100644 --- a/pkg/chain/ethereum/tbtc/gen/abi/Bridge.go +++ b/pkg/chain/ethereum/tbtc/gen/abi/Bridge.go @@ -121,7 +121,7 @@ type WalletsWallet struct { // BridgeMetaData contains all meta data concerning the Bridge contract. var BridgeMetaData = &bind.MetaData{ - ABI: "[{\"inputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"depositDustThreshold\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"depositTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"depositTxMaxFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"depositRevealAheadPeriod\",\"type\":\"uint32\"}],\"name\":\"DepositParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"fundingTxHash\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"fundingOutputIndex\",\"type\":\"uint32\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"depositor\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"amount\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"bytes8\",\"name\":\"blindingFactor\",\"type\":\"bytes8\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes20\",\"name\":\"refundPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes4\",\"name\":\"refundLocktime\",\"type\":\"bytes4\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"name\":\"DepositRevealed\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"sweepTxHash\",\"type\":\"bytes32\"}],\"name\":\"DepositsSwept\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"sighash\",\"type\":\"bytes32\"}],\"name\":\"FraudChallengeDefeatTimedOut\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"sighash\",\"type\":\"bytes32\"}],\"name\":\"FraudChallengeDefeated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"sighash\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"uint8\",\"name\":\"v\",\"type\":\"uint8\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"r\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"s\",\"type\":\"bytes32\"}],\"name\":\"FraudChallengeSubmitted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"fraudChallengeDepositAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"fraudChallengeDefeatTimeout\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"fraudSlashingAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"fraudNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"FraudParametersUpdated\",\"type\":\"event\"},{\"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\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"movingFundsTxHash\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movingFundsTxOutputIndex\",\"type\":\"uint32\"}],\"name\":\"MovedFundsSweepTimedOut\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"sweepTxHash\",\"type\":\"bytes32\"}],\"name\":\"MovedFundsSwept\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"MovingFundsBelowDustReported\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes20[]\",\"name\":\"targetWallets\",\"type\":\"bytes20[]\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"submitter\",\"type\":\"address\"}],\"name\":\"MovingFundsCommitmentSubmitted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"movingFundsTxHash\",\"type\":\"bytes32\"}],\"name\":\"MovingFundsCompleted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"movingFundsTxMaxTotalFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"movingFundsDustThreshold\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutResetDelay\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movingFundsTimeout\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"movingFundsTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint16\",\"name\":\"movingFundsCommitmentGasOffset\",\"type\":\"uint16\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"movedFundsSweepTxMaxTotalFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeout\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"movedFundsSweepTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"MovingFundsParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"MovingFundsTimedOut\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"MovingFundsTimeoutReset\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"NewWalletRegistered\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[],\"name\":\"NewWalletRequested\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"redemptionDustThreshold\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"redemptionTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxTotalFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"redemptionTimeout\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"redemptionTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"redemptionTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"RedemptionParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"requestedAmount\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"treasuryFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"txMaxFee\",\"type\":\"uint64\"}],\"name\":\"RedemptionRequested\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"}],\"name\":\"RedemptionTimedOut\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"redemptionWatchtower\",\"type\":\"address\"}],\"name\":\"RedemptionWatchtowerSet\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"redemptionTxHash\",\"type\":\"bytes32\"}],\"name\":\"RedemptionsCompleted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"spvMaintainer\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"bool\",\"name\":\"isTrusted\",\"type\":\"bool\"}],\"name\":\"SpvMaintainerStatusUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"treasury\",\"type\":\"address\"}],\"name\":\"TreasuryUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"bool\",\"name\":\"isTrusted\",\"type\":\"bool\"}],\"name\":\"VaultStatusUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"WalletClosed\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"WalletClosing\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"WalletMovingFunds\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"walletCreationPeriod\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"walletCreationMinBtcBalance\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"walletCreationMaxBtcBalance\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"walletClosureMinBtcBalance\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"walletMaxAge\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"walletMaxBtcTransfer\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"walletClosingPeriod\",\"type\":\"uint32\"}],\"name\":\"WalletParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"WalletTerminated\",\"type\":\"event\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"publicKeyX\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"publicKeyY\",\"type\":\"bytes32\"}],\"name\":\"__ecdsaWalletCreatedCallback\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"publicKeyX\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"publicKeyY\",\"type\":\"bytes32\"}],\"name\":\"__ecdsaWalletHeartbeatFailedCallback\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"activeWalletPubKeyHash\",\"outputs\":[{\"internalType\":\"bytes20\",\"name\":\"\",\"type\":\"bytes20\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"contractReferences\",\"outputs\":[{\"internalType\":\"contractBank\",\"name\":\"bank\",\"type\":\"address\"},{\"internalType\":\"contractIRelay\",\"name\":\"relay\",\"type\":\"address\"},{\"internalType\":\"contractIWalletRegistry\",\"name\":\"ecdsaWalletRegistry\",\"type\":\"address\"},{\"internalType\":\"contractReimbursementPool\",\"name\":\"reimbursementPool\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes\",\"name\":\"walletPublicKey\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"preimage\",\"type\":\"bytes\"},{\"internalType\":\"bool\",\"name\":\"witness\",\"type\":\"bool\"}],\"name\":\"defeatFraudChallenge\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes\",\"name\":\"walletPublicKey\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"heartbeatMessage\",\"type\":\"bytes\"}],\"name\":\"defeatFraudChallengeWithHeartbeat\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"depositParameters\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"depositDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"depositTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"depositTxMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"depositRevealAheadPeriod\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"depositKey\",\"type\":\"uint256\"}],\"name\":\"deposits\",\"outputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"depositor\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"amount\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"revealedAt\",\"type\":\"uint32\"},{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"treasuryFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"sweptAt\",\"type\":\"uint32\"},{\"internalType\":\"bytes32\",\"name\":\"extraData\",\"type\":\"bytes32\"}],\"internalType\":\"structDeposit.DepositRequest\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"challengeKey\",\"type\":\"uint256\"}],\"name\":\"fraudChallenges\",\"outputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"challenger\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"depositAmount\",\"type\":\"uint256\"},{\"internalType\":\"uint32\",\"name\":\"reportedAt\",\"type\":\"uint32\"},{\"internalType\":\"bool\",\"name\":\"resolved\",\"type\":\"bool\"}],\"internalType\":\"structFraud.FraudChallenge\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"fraudParameters\",\"outputs\":[{\"internalType\":\"uint96\",\"name\":\"fraudChallengeDepositAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"fraudChallengeDefeatTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"fraudSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"fraudNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"getRedemptionWatchtower\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"governance\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_bank\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_relay\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_treasury\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_ecdsaWalletRegistry\",\"type\":\"address\"},{\"internalType\":\"addresspayable\",\"name\":\"_reimbursementPool\",\"type\":\"address\"},{\"internalType\":\"uint96\",\"name\":\"_txProofDifficultyFactor\",\"type\":\"uint96\"}],\"name\":\"initialize\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"name\":\"isVaultTrusted\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"liveWalletsCount\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"requestKey\",\"type\":\"uint256\"}],\"name\":\"movedFundsSweepRequests\",\"outputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"uint64\",\"name\":\"value\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"createdAt\",\"type\":\"uint32\"},{\"internalType\":\"enumMovingFunds.MovedFundsSweepRequestState\",\"name\":\"state\",\"type\":\"uint8\"}],\"internalType\":\"structMovingFunds.MovedFundsSweepRequest\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"movingFundsParameters\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"movingFundsTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"movingFundsDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutResetDelay\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"movingFundsTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"},{\"internalType\":\"uint16\",\"name\":\"movingFundsCommitmentGasOffset\",\"type\":\"uint16\"},{\"internalType\":\"uint64\",\"name\":\"movedFundsSweepTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"movedFundsSweepTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes\",\"name\":\"walletPublicKey\",\"type\":\"bytes\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"},{\"internalType\":\"bytes\",\"name\":\"preimageSha256\",\"type\":\"bytes\"}],\"name\":\"notifyFraudChallengeDefeatTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"movingFundsTxHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTxOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"}],\"name\":\"notifyMovedFundsSweepTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"}],\"name\":\"notifyMovingFundsBelowDust\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"}],\"name\":\"notifyMovingFundsTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"},{\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"}],\"name\":\"notifyRedemptionTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"}],\"name\":\"notifyRedemptionVeto\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"walletMainUtxo\",\"type\":\"tuple\"}],\"name\":\"notifyWalletCloseable\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"notifyWalletClosingPeriodElapsed\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"}],\"name\":\"pendingRedemptions\",\"outputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"requestedAmount\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"treasuryFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"txMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"requestedAt\",\"type\":\"uint32\"}],\"internalType\":\"structRedemption.RedemptionRequest\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"balanceOwner\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"redemptionData\",\"type\":\"bytes\"}],\"name\":\"receiveBalanceApproval\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"redemptionParameters\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"redemptionDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"redemptionTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"redemptionTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"redemptionTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"activeWalletMainUtxo\",\"type\":\"tuple\"}],\"name\":\"requestNewWallet\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"},{\"internalType\":\"uint64\",\"name\":\"amount\",\"type\":\"uint64\"}],\"name\":\"requestRedemption\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"resetMovingFundsTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"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\":\"structBitcoinTx.Info\",\"name\":\"fundingTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"uint32\",\"name\":\"fundingOutputIndex\",\"type\":\"uint32\"},{\"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\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"internalType\":\"structDeposit.DepositRevealInfo\",\"name\":\"reveal\",\"type\":\"tuple\"}],\"name\":\"revealDeposit\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"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\":\"structBitcoinTx.Info\",\"name\":\"fundingTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"uint32\",\"name\":\"fundingOutputIndex\",\"type\":\"uint32\"},{\"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\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"internalType\":\"structDeposit.DepositRevealInfo\",\"name\":\"reveal\",\"type\":\"tuple\"},{\"internalType\":\"bytes32\",\"name\":\"extraData\",\"type\":\"bytes32\"}],\"name\":\"revealDepositWithExtraData\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"redemptionWatchtower\",\"type\":\"address\"}],\"name\":\"setRedemptionWatchtower\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"spvMaintainer\",\"type\":\"address\"},{\"internalType\":\"bool\",\"name\":\"isTrusted\",\"type\":\"bool\"}],\"name\":\"setSpvMaintainerStatus\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"},{\"internalType\":\"bool\",\"name\":\"isTrusted\",\"type\":\"bool\"}],\"name\":\"setVaultStatus\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"utxoKey\",\"type\":\"uint256\"}],\"name\":\"spentMainUTXOs\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"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\":\"structBitcoinTx.Info\",\"name\":\"sweepTx\",\"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\":\"structBitcoinTx.Proof\",\"name\":\"sweepProof\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"name\":\"submitDepositSweepProof\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes\",\"name\":\"walletPublicKey\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"preimageSha256\",\"type\":\"bytes\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"r\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"s\",\"type\":\"bytes32\"},{\"internalType\":\"uint8\",\"name\":\"v\",\"type\":\"uint8\"}],\"internalType\":\"structBitcoinTx.RSVSignature\",\"name\":\"signature\",\"type\":\"tuple\"}],\"name\":\"submitFraudChallenge\",\"outputs\":[],\"stateMutability\":\"payable\",\"type\":\"function\"},{\"inputs\":[{\"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\":\"structBitcoinTx.Info\",\"name\":\"sweepTx\",\"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\":\"structBitcoinTx.Proof\",\"name\":\"sweepProof\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"}],\"name\":\"submitMovedFundsSweepProof\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"walletMainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"},{\"internalType\":\"uint256\",\"name\":\"walletMemberIndex\",\"type\":\"uint256\"},{\"internalType\":\"bytes20[]\",\"name\":\"targetWallets\",\"type\":\"bytes20[]\"}],\"name\":\"submitMovingFundsCommitment\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"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\":\"structBitcoinTx.Info\",\"name\":\"movingFundsTx\",\"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\":\"structBitcoinTx.Proof\",\"name\":\"movingFundsProof\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"submitMovingFundsProof\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"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\":\"structBitcoinTx.Info\",\"name\":\"redemptionTx\",\"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\":\"structBitcoinTx.Proof\",\"name\":\"redemptionProof\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"submitRedemptionProof\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"}],\"name\":\"timedOutRedemptions\",\"outputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"requestedAmount\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"treasuryFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"txMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"requestedAt\",\"type\":\"uint32\"}],\"internalType\":\"structRedemption.RedemptionRequest\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"newGovernance\",\"type\":\"address\"}],\"name\":\"transferGovernance\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"treasury\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"txProofDifficultyFactor\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint64\",\"name\":\"depositDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"depositTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"depositTxMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"depositRevealAheadPeriod\",\"type\":\"uint32\"}],\"name\":\"updateDepositParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint96\",\"name\":\"fraudChallengeDepositAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"fraudChallengeDefeatTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"fraudSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"fraudNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"updateFraudParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint64\",\"name\":\"movingFundsTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"movingFundsDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutResetDelay\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"movingFundsTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"},{\"internalType\":\"uint16\",\"name\":\"movingFundsCommitmentGasOffset\",\"type\":\"uint16\"},{\"internalType\":\"uint64\",\"name\":\"movedFundsSweepTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"movedFundsSweepTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"updateMovingFundsParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint64\",\"name\":\"redemptionDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"redemptionTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"redemptionTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"redemptionTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"updateRedemptionParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"treasury\",\"type\":\"address\"}],\"name\":\"updateTreasury\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint32\",\"name\":\"walletCreationPeriod\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"walletCreationMinBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"walletCreationMaxBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"walletClosureMinBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"walletMaxAge\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"walletMaxBtcTransfer\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"walletClosingPeriod\",\"type\":\"uint32\"}],\"name\":\"updateWalletParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"walletParameters\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"walletCreationPeriod\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"walletCreationMinBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"walletCreationMaxBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"walletClosureMinBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"walletMaxAge\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"walletMaxBtcTransfer\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"walletClosingPeriod\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"wallets\",\"outputs\":[{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"mainUtxoHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint64\",\"name\":\"pendingRedemptionsValue\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"createdAt\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsRequestedAt\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"closingStartedAt\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"pendingMovedFundsSweepRequestsCount\",\"type\":\"uint32\"},{\"internalType\":\"enumWallets.WalletState\",\"name\":\"state\",\"type\":\"uint8\"},{\"internalType\":\"bytes32\",\"name\":\"movingFundsTargetWalletsCommitmentHash\",\"type\":\"bytes32\"}],\"internalType\":\"structWallets.Wallet\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"}]", + ABI: "[{\"inputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"depositDustThreshold\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"depositTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"depositTxMaxFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"depositRevealAheadPeriod\",\"type\":\"uint32\"}],\"name\":\"DepositParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"fundingTxHash\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"fundingOutputIndex\",\"type\":\"uint32\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"depositor\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"amount\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"bytes8\",\"name\":\"blindingFactor\",\"type\":\"bytes8\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes20\",\"name\":\"refundPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes4\",\"name\":\"refundLocktime\",\"type\":\"bytes4\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"name\":\"DepositRevealed\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"uint256\",\"name\":\"depositKey\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"newVault\",\"type\":\"address\"}],\"name\":\"DepositVaultFixed\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"sweepTxHash\",\"type\":\"bytes32\"}],\"name\":\"DepositsSwept\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"sighash\",\"type\":\"bytes32\"}],\"name\":\"FraudChallengeDefeatTimedOut\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"sighash\",\"type\":\"bytes32\"}],\"name\":\"FraudChallengeDefeated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"sighash\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"uint8\",\"name\":\"v\",\"type\":\"uint8\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"r\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"s\",\"type\":\"bytes32\"}],\"name\":\"FraudChallengeSubmitted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"fraudChallengeDepositAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"fraudChallengeDefeatTimeout\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"fraudSlashingAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"fraudNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"FraudParametersUpdated\",\"type\":\"event\"},{\"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\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"movingFundsTxHash\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movingFundsTxOutputIndex\",\"type\":\"uint32\"}],\"name\":\"MovedFundsSweepTimedOut\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"sweepTxHash\",\"type\":\"bytes32\"}],\"name\":\"MovedFundsSwept\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"MovingFundsBelowDustReported\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes20[]\",\"name\":\"targetWallets\",\"type\":\"bytes20[]\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"submitter\",\"type\":\"address\"}],\"name\":\"MovingFundsCommitmentSubmitted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"movingFundsTxHash\",\"type\":\"bytes32\"}],\"name\":\"MovingFundsCompleted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"movingFundsTxMaxTotalFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"movingFundsDustThreshold\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutResetDelay\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movingFundsTimeout\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"movingFundsTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint16\",\"name\":\"movingFundsCommitmentGasOffset\",\"type\":\"uint16\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"movedFundsSweepTxMaxTotalFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeout\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"movedFundsSweepTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"MovingFundsParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"MovingFundsTimedOut\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"MovingFundsTimeoutReset\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"NewWalletRegistered\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[],\"name\":\"NewWalletRequested\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"oldRebateStaking\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"newRebateStaking\",\"type\":\"address\"}],\"name\":\"RebateStakingRepaired\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"rebateStaking\",\"type\":\"address\"}],\"name\":\"RebateStakingSet\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"redemptionDustThreshold\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"redemptionTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxTotalFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"redemptionTimeout\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"redemptionTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"redemptionTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"RedemptionParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"requestedAmount\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"treasuryFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"txMaxFee\",\"type\":\"uint64\"}],\"name\":\"RedemptionRequested\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"}],\"name\":\"RedemptionTimedOut\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"redemptionWatchtower\",\"type\":\"address\"}],\"name\":\"RedemptionWatchtowerSet\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"redemptionTxHash\",\"type\":\"bytes32\"}],\"name\":\"RedemptionsCompleted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"spvMaintainer\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"bool\",\"name\":\"isTrusted\",\"type\":\"bool\"}],\"name\":\"SpvMaintainerStatusUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"treasury\",\"type\":\"address\"}],\"name\":\"TreasuryUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"bool\",\"name\":\"isTrusted\",\"type\":\"bool\"}],\"name\":\"VaultStatusUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"WalletClosed\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"WalletClosing\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"WalletMovingFunds\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"walletCreationPeriod\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"walletCreationMinBtcBalance\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"walletCreationMaxBtcBalance\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"walletClosureMinBtcBalance\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"walletMaxAge\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"walletMaxBtcTransfer\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"walletClosingPeriod\",\"type\":\"uint32\"}],\"name\":\"WalletParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"WalletTerminated\",\"type\":\"event\"},{\"stateMutability\":\"payable\",\"type\":\"fallback\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"publicKeyX\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"publicKeyY\",\"type\":\"bytes32\"}],\"name\":\"__ecdsaWalletCreatedCallback\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"publicKeyX\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"publicKeyY\",\"type\":\"bytes32\"}],\"name\":\"__ecdsaWalletHeartbeatFailedCallback\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"activeWalletPubKeyHash\",\"outputs\":[{\"internalType\":\"bytes20\",\"name\":\"\",\"type\":\"bytes20\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"contractReferences\",\"outputs\":[{\"internalType\":\"contractBank\",\"name\":\"bank\",\"type\":\"address\"},{\"internalType\":\"contractIRelay\",\"name\":\"relay\",\"type\":\"address\"},{\"internalType\":\"contractIWalletRegistry\",\"name\":\"ecdsaWalletRegistry\",\"type\":\"address\"},{\"internalType\":\"contractReimbursementPool\",\"name\":\"reimbursementPool\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes\",\"name\":\"walletPublicKey\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"preimage\",\"type\":\"bytes\"},{\"internalType\":\"bool\",\"name\":\"witness\",\"type\":\"bool\"}],\"name\":\"defeatFraudChallenge\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes\",\"name\":\"walletPublicKey\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"heartbeatMessage\",\"type\":\"bytes\"}],\"name\":\"defeatFraudChallengeWithHeartbeat\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"depositParameters\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"depositDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"depositTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"depositTxMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"depositRevealAheadPeriod\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"depositKey\",\"type\":\"uint256\"}],\"name\":\"deposits\",\"outputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"depositor\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"amount\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"revealedAt\",\"type\":\"uint32\"},{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"treasuryFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"sweptAt\",\"type\":\"uint32\"},{\"internalType\":\"bytes32\",\"name\":\"extraData\",\"type\":\"bytes32\"}],\"internalType\":\"structDeposit.DepositRequest\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"challengeKey\",\"type\":\"uint256\"}],\"name\":\"fraudChallenges\",\"outputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"challenger\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"depositAmount\",\"type\":\"uint256\"},{\"internalType\":\"uint32\",\"name\":\"reportedAt\",\"type\":\"uint32\"},{\"internalType\":\"bool\",\"name\":\"resolved\",\"type\":\"bool\"}],\"internalType\":\"structFraud.FraudChallenge\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"fraudParameters\",\"outputs\":[{\"internalType\":\"uint96\",\"name\":\"fraudChallengeDepositAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"fraudChallengeDefeatTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"fraudSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"fraudNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"getRebateStaking\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"getRedemptionWatchtower\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"getReservationRouter\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"governance\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_bank\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_relay\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_treasury\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_ecdsaWalletRegistry\",\"type\":\"address\"},{\"internalType\":\"addresspayable\",\"name\":\"_reimbursementPool\",\"type\":\"address\"},{\"internalType\":\"uint96\",\"name\":\"_txProofDifficultyFactor\",\"type\":\"uint96\"}],\"name\":\"initialize\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"initializeV2_FixVaultZeroDeposit\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"newRebateStaking\",\"type\":\"address\"}],\"name\":\"initializeV5_RepairRebateStaking\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"depositKey\",\"type\":\"uint256\"}],\"name\":\"isReservedDeposit\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"name\":\"isVaultTrusted\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"liveWalletsCount\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"requestKey\",\"type\":\"uint256\"}],\"name\":\"movedFundsSweepRequests\",\"outputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"uint64\",\"name\":\"value\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"createdAt\",\"type\":\"uint32\"},{\"internalType\":\"enumMovingFunds.MovedFundsSweepRequestState\",\"name\":\"state\",\"type\":\"uint8\"}],\"internalType\":\"structMovingFunds.MovedFundsSweepRequest\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"movingFundsParameters\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"movingFundsTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"movingFundsDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutResetDelay\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"movingFundsTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"},{\"internalType\":\"uint16\",\"name\":\"movingFundsCommitmentGasOffset\",\"type\":\"uint16\"},{\"internalType\":\"uint64\",\"name\":\"movedFundsSweepTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"movedFundsSweepTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes\",\"name\":\"walletPublicKey\",\"type\":\"bytes\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"},{\"internalType\":\"bytes\",\"name\":\"preimageSha256\",\"type\":\"bytes\"}],\"name\":\"notifyFraudChallengeDefeatTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"movingFundsTxHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTxOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"}],\"name\":\"notifyMovedFundsSweepTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"}],\"name\":\"notifyMovingFundsBelowDust\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"}],\"name\":\"notifyMovingFundsTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"},{\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"}],\"name\":\"notifyRedemptionTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"}],\"name\":\"notifyRedemptionVeto\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"walletMainUtxo\",\"type\":\"tuple\"}],\"name\":\"notifyWalletCloseable\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"notifyWalletClosingPeriodElapsed\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"}],\"name\":\"pendingRedemptions\",\"outputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"requestedAmount\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"treasuryFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"txMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"requestedAt\",\"type\":\"uint32\"}],\"internalType\":\"structRedemption.RedemptionRequest\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"balanceOwner\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"redemptionData\",\"type\":\"bytes\"}],\"name\":\"receiveBalanceApproval\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"redemptionParameters\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"redemptionDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"redemptionTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"redemptionTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"redemptionTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"activeWalletMainUtxo\",\"type\":\"tuple\"}],\"name\":\"requestNewWallet\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"},{\"internalType\":\"uint64\",\"name\":\"amount\",\"type\":\"uint64\"}],\"name\":\"requestRedemption\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"resetMovingFundsTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"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\":\"structBitcoinTx.Info\",\"name\":\"fundingTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"uint32\",\"name\":\"fundingOutputIndex\",\"type\":\"uint32\"},{\"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\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"internalType\":\"structDeposit.DepositRevealInfo\",\"name\":\"reveal\",\"type\":\"tuple\"}],\"name\":\"revealDeposit\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"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\":\"structBitcoinTx.Info\",\"name\":\"fundingTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"uint32\",\"name\":\"fundingOutputIndex\",\"type\":\"uint32\"},{\"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\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"internalType\":\"structDeposit.DepositRevealInfo\",\"name\":\"reveal\",\"type\":\"tuple\"},{\"internalType\":\"bytes32\",\"name\":\"extraData\",\"type\":\"bytes32\"}],\"name\":\"revealDepositWithExtraData\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"rebateStaking\",\"type\":\"address\"}],\"name\":\"setRebateStaking\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"redemptionWatchtower\",\"type\":\"address\"}],\"name\":\"setRedemptionWatchtower\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_reservationRouter\",\"type\":\"address\"}],\"name\":\"setReservationRouter\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"spvMaintainer\",\"type\":\"address\"},{\"internalType\":\"bool\",\"name\":\"isTrusted\",\"type\":\"bool\"}],\"name\":\"setSpvMaintainerStatus\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"},{\"internalType\":\"bool\",\"name\":\"isTrusted\",\"type\":\"bool\"}],\"name\":\"setVaultStatus\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"utxoKey\",\"type\":\"uint256\"}],\"name\":\"spentMainUTXOs\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"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\":\"structBitcoinTx.Info\",\"name\":\"sweepTx\",\"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\":\"structBitcoinTx.Proof\",\"name\":\"sweepProof\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"name\":\"submitDepositSweepProof\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes\",\"name\":\"walletPublicKey\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"preimageSha256\",\"type\":\"bytes\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"r\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"s\",\"type\":\"bytes32\"},{\"internalType\":\"uint8\",\"name\":\"v\",\"type\":\"uint8\"}],\"internalType\":\"structBitcoinTx.RSVSignature\",\"name\":\"signature\",\"type\":\"tuple\"}],\"name\":\"submitFraudChallenge\",\"outputs\":[],\"stateMutability\":\"payable\",\"type\":\"function\"},{\"inputs\":[{\"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\":\"structBitcoinTx.Info\",\"name\":\"sweepTx\",\"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\":\"structBitcoinTx.Proof\",\"name\":\"sweepProof\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"}],\"name\":\"submitMovedFundsSweepProof\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"walletMainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"},{\"internalType\":\"uint256\",\"name\":\"walletMemberIndex\",\"type\":\"uint256\"},{\"internalType\":\"bytes20[]\",\"name\":\"targetWallets\",\"type\":\"bytes20[]\"}],\"name\":\"submitMovingFundsCommitment\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"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\":\"structBitcoinTx.Info\",\"name\":\"movingFundsTx\",\"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\":\"structBitcoinTx.Proof\",\"name\":\"movingFundsProof\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"submitMovingFundsProof\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"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\":\"structBitcoinTx.Info\",\"name\":\"redemptionTx\",\"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\":\"structBitcoinTx.Proof\",\"name\":\"redemptionProof\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"submitRedemptionProof\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"}],\"name\":\"timedOutRedemptions\",\"outputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"requestedAmount\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"treasuryFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"txMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"requestedAt\",\"type\":\"uint32\"}],\"internalType\":\"structRedemption.RedemptionRequest\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"newGovernance\",\"type\":\"address\"}],\"name\":\"transferGovernance\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"treasury\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"txProofDifficultyFactor\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint64\",\"name\":\"depositDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"depositTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"depositTxMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"depositRevealAheadPeriod\",\"type\":\"uint32\"}],\"name\":\"updateDepositParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint96\",\"name\":\"fraudChallengeDepositAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"fraudChallengeDefeatTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"fraudSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"fraudNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"updateFraudParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint64\",\"name\":\"movingFundsTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"movingFundsDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutResetDelay\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"movingFundsTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"},{\"internalType\":\"uint16\",\"name\":\"movingFundsCommitmentGasOffset\",\"type\":\"uint16\"},{\"internalType\":\"uint64\",\"name\":\"movedFundsSweepTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"movedFundsSweepTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"updateMovingFundsParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint64\",\"name\":\"redemptionDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"redemptionTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"redemptionTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"redemptionTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"updateRedemptionParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"treasury\",\"type\":\"address\"}],\"name\":\"updateTreasury\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint32\",\"name\":\"walletCreationPeriod\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"walletCreationMinBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"walletCreationMaxBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"walletClosureMinBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"walletMaxAge\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"walletMaxBtcTransfer\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"walletClosingPeriod\",\"type\":\"uint32\"}],\"name\":\"updateWalletParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"walletParameters\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"walletCreationPeriod\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"walletCreationMinBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"walletCreationMaxBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"walletClosureMinBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"walletMaxAge\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"walletMaxBtcTransfer\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"walletClosingPeriod\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"wallets\",\"outputs\":[{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"mainUtxoHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint64\",\"name\":\"pendingRedemptionsValue\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"createdAt\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsRequestedAt\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"closingStartedAt\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"pendingMovedFundsSweepRequestsCount\",\"type\":\"uint32\"},{\"internalType\":\"enumWallets.WalletState\",\"name\":\"state\",\"type\":\"uint8\"},{\"internalType\":\"bytes32\",\"name\":\"movingFundsTargetWalletsCommitmentHash\",\"type\":\"bytes32\"}],\"internalType\":\"structWallets.Wallet\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"}]", } // BridgeABI is the input ABI used to generate the binding from. @@ -528,6 +528,37 @@ func (_Bridge *BridgeCallerSession) FraudParameters() (struct { return _Bridge.Contract.FraudParameters(&_Bridge.CallOpts) } +// GetRebateStaking is a free data retrieval call binding the contract method 0x3edf8238. +// +// Solidity: function getRebateStaking() view returns(address) +func (_Bridge *BridgeCaller) GetRebateStaking(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _Bridge.contract.Call(opts, &out, "getRebateStaking") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// GetRebateStaking is a free data retrieval call binding the contract method 0x3edf8238. +// +// Solidity: function getRebateStaking() view returns(address) +func (_Bridge *BridgeSession) GetRebateStaking() (common.Address, error) { + return _Bridge.Contract.GetRebateStaking(&_Bridge.CallOpts) +} + +// GetRebateStaking is a free data retrieval call binding the contract method 0x3edf8238. +// +// Solidity: function getRebateStaking() view returns(address) +func (_Bridge *BridgeCallerSession) GetRebateStaking() (common.Address, error) { + return _Bridge.Contract.GetRebateStaking(&_Bridge.CallOpts) +} + // GetRedemptionWatchtower is a free data retrieval call binding the contract method 0x5f3281ca. // // Solidity: function getRedemptionWatchtower() view returns(address) @@ -559,6 +590,37 @@ func (_Bridge *BridgeCallerSession) GetRedemptionWatchtower() (common.Address, e return _Bridge.Contract.GetRedemptionWatchtower(&_Bridge.CallOpts) } +// GetReservationRouter is a free data retrieval call binding the contract method 0x5157ec0a. +// +// Solidity: function getReservationRouter() view returns(address) +func (_Bridge *BridgeCaller) GetReservationRouter(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _Bridge.contract.Call(opts, &out, "getReservationRouter") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// GetReservationRouter is a free data retrieval call binding the contract method 0x5157ec0a. +// +// Solidity: function getReservationRouter() view returns(address) +func (_Bridge *BridgeSession) GetReservationRouter() (common.Address, error) { + return _Bridge.Contract.GetReservationRouter(&_Bridge.CallOpts) +} + +// GetReservationRouter is a free data retrieval call binding the contract method 0x5157ec0a. +// +// Solidity: function getReservationRouter() view returns(address) +func (_Bridge *BridgeCallerSession) GetReservationRouter() (common.Address, error) { + return _Bridge.Contract.GetReservationRouter(&_Bridge.CallOpts) +} + // Governance is a free data retrieval call binding the contract method 0x5aa6e675. // // Solidity: function governance() view returns(address) @@ -590,6 +652,37 @@ func (_Bridge *BridgeCallerSession) Governance() (common.Address, error) { return _Bridge.Contract.Governance(&_Bridge.CallOpts) } +// IsReservedDeposit is a free data retrieval call binding the contract method 0x93df529b. +// +// Solidity: function isReservedDeposit(uint256 depositKey) view returns(bool) +func (_Bridge *BridgeCaller) IsReservedDeposit(opts *bind.CallOpts, depositKey *big.Int) (bool, error) { + var out []interface{} + err := _Bridge.contract.Call(opts, &out, "isReservedDeposit", depositKey) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// IsReservedDeposit is a free data retrieval call binding the contract method 0x93df529b. +// +// Solidity: function isReservedDeposit(uint256 depositKey) view returns(bool) +func (_Bridge *BridgeSession) IsReservedDeposit(depositKey *big.Int) (bool, error) { + return _Bridge.Contract.IsReservedDeposit(&_Bridge.CallOpts, depositKey) +} + +// IsReservedDeposit is a free data retrieval call binding the contract method 0x93df529b. +// +// Solidity: function isReservedDeposit(uint256 depositKey) view returns(bool) +func (_Bridge *BridgeCallerSession) IsReservedDeposit(depositKey *big.Int) (bool, error) { + return _Bridge.Contract.IsReservedDeposit(&_Bridge.CallOpts, depositKey) +} + // IsVaultTrusted is a free data retrieval call binding the contract method 0xe53c0b55. // // Solidity: function isVaultTrusted(address vault) view returns(bool) @@ -1204,6 +1297,48 @@ func (_Bridge *BridgeTransactorSession) Initialize(_bank common.Address, _relay return _Bridge.Contract.Initialize(&_Bridge.TransactOpts, _bank, _relay, _treasury, _ecdsaWalletRegistry, _reimbursementPool, _txProofDifficultyFactor) } +// InitializeV2FixVaultZeroDeposit is a paid mutator transaction binding the contract method 0x456ffee0. +// +// Solidity: function initializeV2_FixVaultZeroDeposit() returns() +func (_Bridge *BridgeTransactor) InitializeV2FixVaultZeroDeposit(opts *bind.TransactOpts) (*types.Transaction, error) { + return _Bridge.contract.Transact(opts, "initializeV2_FixVaultZeroDeposit") +} + +// InitializeV2FixVaultZeroDeposit is a paid mutator transaction binding the contract method 0x456ffee0. +// +// Solidity: function initializeV2_FixVaultZeroDeposit() returns() +func (_Bridge *BridgeSession) InitializeV2FixVaultZeroDeposit() (*types.Transaction, error) { + return _Bridge.Contract.InitializeV2FixVaultZeroDeposit(&_Bridge.TransactOpts) +} + +// InitializeV2FixVaultZeroDeposit is a paid mutator transaction binding the contract method 0x456ffee0. +// +// Solidity: function initializeV2_FixVaultZeroDeposit() returns() +func (_Bridge *BridgeTransactorSession) InitializeV2FixVaultZeroDeposit() (*types.Transaction, error) { + return _Bridge.Contract.InitializeV2FixVaultZeroDeposit(&_Bridge.TransactOpts) +} + +// InitializeV5RepairRebateStaking is a paid mutator transaction binding the contract method 0x1ebf670d. +// +// Solidity: function initializeV5_RepairRebateStaking(address newRebateStaking) returns() +func (_Bridge *BridgeTransactor) InitializeV5RepairRebateStaking(opts *bind.TransactOpts, newRebateStaking common.Address) (*types.Transaction, error) { + return _Bridge.contract.Transact(opts, "initializeV5_RepairRebateStaking", newRebateStaking) +} + +// InitializeV5RepairRebateStaking is a paid mutator transaction binding the contract method 0x1ebf670d. +// +// Solidity: function initializeV5_RepairRebateStaking(address newRebateStaking) returns() +func (_Bridge *BridgeSession) InitializeV5RepairRebateStaking(newRebateStaking common.Address) (*types.Transaction, error) { + return _Bridge.Contract.InitializeV5RepairRebateStaking(&_Bridge.TransactOpts, newRebateStaking) +} + +// InitializeV5RepairRebateStaking is a paid mutator transaction binding the contract method 0x1ebf670d. +// +// Solidity: function initializeV5_RepairRebateStaking(address newRebateStaking) returns() +func (_Bridge *BridgeTransactorSession) InitializeV5RepairRebateStaking(newRebateStaking common.Address) (*types.Transaction, error) { + return _Bridge.Contract.InitializeV5RepairRebateStaking(&_Bridge.TransactOpts, newRebateStaking) +} + // NotifyFraudChallengeDefeatTimeout is a paid mutator transaction binding the contract method 0x79fc4eb3. // // Solidity: function notifyFraudChallengeDefeatTimeout(bytes walletPublicKey, uint32[] walletMembersIDs, bytes preimageSha256) returns() @@ -1498,6 +1633,27 @@ func (_Bridge *BridgeTransactorSession) RevealDepositWithExtraData(fundingTx Bit return _Bridge.Contract.RevealDepositWithExtraData(&_Bridge.TransactOpts, fundingTx, reveal, extraData) } +// SetRebateStaking is a paid mutator transaction binding the contract method 0xca73c462. +// +// Solidity: function setRebateStaking(address rebateStaking) returns() +func (_Bridge *BridgeTransactor) SetRebateStaking(opts *bind.TransactOpts, rebateStaking common.Address) (*types.Transaction, error) { + return _Bridge.contract.Transact(opts, "setRebateStaking", rebateStaking) +} + +// SetRebateStaking is a paid mutator transaction binding the contract method 0xca73c462. +// +// Solidity: function setRebateStaking(address rebateStaking) returns() +func (_Bridge *BridgeSession) SetRebateStaking(rebateStaking common.Address) (*types.Transaction, error) { + return _Bridge.Contract.SetRebateStaking(&_Bridge.TransactOpts, rebateStaking) +} + +// SetRebateStaking is a paid mutator transaction binding the contract method 0xca73c462. +// +// Solidity: function setRebateStaking(address rebateStaking) returns() +func (_Bridge *BridgeTransactorSession) SetRebateStaking(rebateStaking common.Address) (*types.Transaction, error) { + return _Bridge.Contract.SetRebateStaking(&_Bridge.TransactOpts, rebateStaking) +} + // SetRedemptionWatchtower is a paid mutator transaction binding the contract method 0xbe26ebad. // // Solidity: function setRedemptionWatchtower(address redemptionWatchtower) returns() @@ -1519,6 +1675,27 @@ func (_Bridge *BridgeTransactorSession) SetRedemptionWatchtower(redemptionWatcht return _Bridge.Contract.SetRedemptionWatchtower(&_Bridge.TransactOpts, redemptionWatchtower) } +// SetReservationRouter is a paid mutator transaction binding the contract method 0xd394a4d3. +// +// Solidity: function setReservationRouter(address _reservationRouter) returns() +func (_Bridge *BridgeTransactor) SetReservationRouter(opts *bind.TransactOpts, _reservationRouter common.Address) (*types.Transaction, error) { + return _Bridge.contract.Transact(opts, "setReservationRouter", _reservationRouter) +} + +// SetReservationRouter is a paid mutator transaction binding the contract method 0xd394a4d3. +// +// Solidity: function setReservationRouter(address _reservationRouter) returns() +func (_Bridge *BridgeSession) SetReservationRouter(_reservationRouter common.Address) (*types.Transaction, error) { + return _Bridge.Contract.SetReservationRouter(&_Bridge.TransactOpts, _reservationRouter) +} + +// SetReservationRouter is a paid mutator transaction binding the contract method 0xd394a4d3. +// +// Solidity: function setReservationRouter(address _reservationRouter) returns() +func (_Bridge *BridgeTransactorSession) SetReservationRouter(_reservationRouter common.Address) (*types.Transaction, error) { + return _Bridge.Contract.SetReservationRouter(&_Bridge.TransactOpts, _reservationRouter) +} + // SetSpvMaintainerStatus is a paid mutator transaction binding the contract method 0x5f2b2d0d. // // Solidity: function setSpvMaintainerStatus(address spvMaintainer, bool isTrusted) returns() @@ -1834,6 +2011,27 @@ func (_Bridge *BridgeTransactorSession) UpdateWalletParameters(walletCreationPer return _Bridge.Contract.UpdateWalletParameters(&_Bridge.TransactOpts, walletCreationPeriod, walletCreationMinBtcBalance, walletCreationMaxBtcBalance, walletClosureMinBtcBalance, walletMaxAge, walletMaxBtcTransfer, walletClosingPeriod) } +// Fallback is a paid mutator transaction binding the contract fallback function. +// +// Solidity: fallback() payable returns() +func (_Bridge *BridgeTransactor) Fallback(opts *bind.TransactOpts, calldata []byte) (*types.Transaction, error) { + return _Bridge.contract.RawTransact(opts, calldata) +} + +// Fallback is a paid mutator transaction binding the contract fallback function. +// +// Solidity: fallback() payable returns() +func (_Bridge *BridgeSession) Fallback(calldata []byte) (*types.Transaction, error) { + return _Bridge.Contract.Fallback(&_Bridge.TransactOpts, calldata) +} + +// Fallback is a paid mutator transaction binding the contract fallback function. +// +// Solidity: fallback() payable returns() +func (_Bridge *BridgeTransactorSession) Fallback(calldata []byte) (*types.Transaction, error) { + return _Bridge.Contract.Fallback(&_Bridge.TransactOpts, calldata) +} + // BridgeDepositParametersUpdatedIterator is returned from FilterDepositParametersUpdated and is used to iterate over the raw logs and unpacked data for DepositParametersUpdated events raised by the Bridge contract. type BridgeDepositParametersUpdatedIterator struct { Event *BridgeDepositParametersUpdated // Event containing the contract specifics and raw log @@ -2133,6 +2331,151 @@ func (_Bridge *BridgeFilterer) ParseDepositRevealed(log types.Log) (*BridgeDepos return event, nil } +// BridgeDepositVaultFixedIterator is returned from FilterDepositVaultFixed and is used to iterate over the raw logs and unpacked data for DepositVaultFixed events raised by the Bridge contract. +type BridgeDepositVaultFixedIterator struct { + Event *BridgeDepositVaultFixed // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *BridgeDepositVaultFixedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(BridgeDepositVaultFixed) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(BridgeDepositVaultFixed) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *BridgeDepositVaultFixedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *BridgeDepositVaultFixedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// BridgeDepositVaultFixed represents a DepositVaultFixed event raised by the Bridge contract. +type BridgeDepositVaultFixed struct { + DepositKey *big.Int + NewVault common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterDepositVaultFixed is a free log retrieval operation binding the contract event 0x6851c9da8832e374b52353e89727e1f35bd403bf45bc19c889e416393bd53973. +// +// Solidity: event DepositVaultFixed(uint256 indexed depositKey, address newVault) +func (_Bridge *BridgeFilterer) FilterDepositVaultFixed(opts *bind.FilterOpts, depositKey []*big.Int) (*BridgeDepositVaultFixedIterator, error) { + + var depositKeyRule []interface{} + for _, depositKeyItem := range depositKey { + depositKeyRule = append(depositKeyRule, depositKeyItem) + } + + logs, sub, err := _Bridge.contract.FilterLogs(opts, "DepositVaultFixed", depositKeyRule) + if err != nil { + return nil, err + } + return &BridgeDepositVaultFixedIterator{contract: _Bridge.contract, event: "DepositVaultFixed", logs: logs, sub: sub}, nil +} + +// WatchDepositVaultFixed is a free log subscription operation binding the contract event 0x6851c9da8832e374b52353e89727e1f35bd403bf45bc19c889e416393bd53973. +// +// Solidity: event DepositVaultFixed(uint256 indexed depositKey, address newVault) +func (_Bridge *BridgeFilterer) WatchDepositVaultFixed(opts *bind.WatchOpts, sink chan<- *BridgeDepositVaultFixed, depositKey []*big.Int) (event.Subscription, error) { + + var depositKeyRule []interface{} + for _, depositKeyItem := range depositKey { + depositKeyRule = append(depositKeyRule, depositKeyItem) + } + + logs, sub, err := _Bridge.contract.WatchLogs(opts, "DepositVaultFixed", depositKeyRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(BridgeDepositVaultFixed) + if err := _Bridge.contract.UnpackLog(event, "DepositVaultFixed", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseDepositVaultFixed is a log parse operation binding the contract event 0x6851c9da8832e374b52353e89727e1f35bd403bf45bc19c889e416393bd53973. +// +// Solidity: event DepositVaultFixed(uint256 indexed depositKey, address newVault) +func (_Bridge *BridgeFilterer) ParseDepositVaultFixed(log types.Log) (*BridgeDepositVaultFixed, error) { + event := new(BridgeDepositVaultFixed) + if err := _Bridge.contract.UnpackLog(event, "DepositVaultFixed", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + // BridgeDepositsSweptIterator is returned from FilterDepositsSwept and is used to iterate over the raw logs and unpacked data for DepositsSwept events raised by the Bridge contract. type BridgeDepositsSweptIterator struct { Event *BridgeDepositsSwept // Event containing the contract specifics and raw log @@ -4556,6 +4899,275 @@ func (_Bridge *BridgeFilterer) ParseNewWalletRequested(log types.Log) (*BridgeNe return event, nil } +// BridgeRebateStakingRepairedIterator is returned from FilterRebateStakingRepaired and is used to iterate over the raw logs and unpacked data for RebateStakingRepaired events raised by the Bridge contract. +type BridgeRebateStakingRepairedIterator struct { + Event *BridgeRebateStakingRepaired // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *BridgeRebateStakingRepairedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(BridgeRebateStakingRepaired) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(BridgeRebateStakingRepaired) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *BridgeRebateStakingRepairedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *BridgeRebateStakingRepairedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// BridgeRebateStakingRepaired represents a RebateStakingRepaired event raised by the Bridge contract. +type BridgeRebateStakingRepaired struct { + OldRebateStaking common.Address + NewRebateStaking common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterRebateStakingRepaired is a free log retrieval operation binding the contract event 0x3e9cf92a8b1e7b429a80cef0378c64004ed9e723a896f58a2bb02005bb34d8c5. +// +// Solidity: event RebateStakingRepaired(address oldRebateStaking, address newRebateStaking) +func (_Bridge *BridgeFilterer) FilterRebateStakingRepaired(opts *bind.FilterOpts) (*BridgeRebateStakingRepairedIterator, error) { + + logs, sub, err := _Bridge.contract.FilterLogs(opts, "RebateStakingRepaired") + if err != nil { + return nil, err + } + return &BridgeRebateStakingRepairedIterator{contract: _Bridge.contract, event: "RebateStakingRepaired", logs: logs, sub: sub}, nil +} + +// WatchRebateStakingRepaired is a free log subscription operation binding the contract event 0x3e9cf92a8b1e7b429a80cef0378c64004ed9e723a896f58a2bb02005bb34d8c5. +// +// Solidity: event RebateStakingRepaired(address oldRebateStaking, address newRebateStaking) +func (_Bridge *BridgeFilterer) WatchRebateStakingRepaired(opts *bind.WatchOpts, sink chan<- *BridgeRebateStakingRepaired) (event.Subscription, error) { + + logs, sub, err := _Bridge.contract.WatchLogs(opts, "RebateStakingRepaired") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(BridgeRebateStakingRepaired) + if err := _Bridge.contract.UnpackLog(event, "RebateStakingRepaired", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseRebateStakingRepaired is a log parse operation binding the contract event 0x3e9cf92a8b1e7b429a80cef0378c64004ed9e723a896f58a2bb02005bb34d8c5. +// +// Solidity: event RebateStakingRepaired(address oldRebateStaking, address newRebateStaking) +func (_Bridge *BridgeFilterer) ParseRebateStakingRepaired(log types.Log) (*BridgeRebateStakingRepaired, error) { + event := new(BridgeRebateStakingRepaired) + if err := _Bridge.contract.UnpackLog(event, "RebateStakingRepaired", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// BridgeRebateStakingSetIterator is returned from FilterRebateStakingSet and is used to iterate over the raw logs and unpacked data for RebateStakingSet events raised by the Bridge contract. +type BridgeRebateStakingSetIterator struct { + Event *BridgeRebateStakingSet // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *BridgeRebateStakingSetIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(BridgeRebateStakingSet) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(BridgeRebateStakingSet) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *BridgeRebateStakingSetIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *BridgeRebateStakingSetIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// BridgeRebateStakingSet represents a RebateStakingSet event raised by the Bridge contract. +type BridgeRebateStakingSet struct { + RebateStaking common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterRebateStakingSet is a free log retrieval operation binding the contract event 0xd1d9d4e9f516cb983e81d2a124ec97cb8d4ff00637f2a7f3229eadbed84e2df6. +// +// Solidity: event RebateStakingSet(address rebateStaking) +func (_Bridge *BridgeFilterer) FilterRebateStakingSet(opts *bind.FilterOpts) (*BridgeRebateStakingSetIterator, error) { + + logs, sub, err := _Bridge.contract.FilterLogs(opts, "RebateStakingSet") + if err != nil { + return nil, err + } + return &BridgeRebateStakingSetIterator{contract: _Bridge.contract, event: "RebateStakingSet", logs: logs, sub: sub}, nil +} + +// WatchRebateStakingSet is a free log subscription operation binding the contract event 0xd1d9d4e9f516cb983e81d2a124ec97cb8d4ff00637f2a7f3229eadbed84e2df6. +// +// Solidity: event RebateStakingSet(address rebateStaking) +func (_Bridge *BridgeFilterer) WatchRebateStakingSet(opts *bind.WatchOpts, sink chan<- *BridgeRebateStakingSet) (event.Subscription, error) { + + logs, sub, err := _Bridge.contract.WatchLogs(opts, "RebateStakingSet") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(BridgeRebateStakingSet) + if err := _Bridge.contract.UnpackLog(event, "RebateStakingSet", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseRebateStakingSet is a log parse operation binding the contract event 0xd1d9d4e9f516cb983e81d2a124ec97cb8d4ff00637f2a7f3229eadbed84e2df6. +// +// Solidity: event RebateStakingSet(address rebateStaking) +func (_Bridge *BridgeFilterer) ParseRebateStakingSet(log types.Log) (*BridgeRebateStakingSet, error) { + event := new(BridgeRebateStakingSet) + if err := _Bridge.contract.UnpackLog(event, "RebateStakingSet", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + // BridgeRedemptionParametersUpdatedIterator is returned from FilterRedemptionParametersUpdated and is used to iterate over the raw logs and unpacked data for RedemptionParametersUpdated events raised by the Bridge contract. type BridgeRedemptionParametersUpdatedIterator struct { Event *BridgeRedemptionParametersUpdated // Event containing the contract specifics and raw log diff --git a/pkg/chain/ethereum/tbtc/gen/abi/LightRelay.go b/pkg/chain/ethereum/tbtc/gen/abi/LightRelay.go index 971aae98b0..a0196e92c3 100644 --- a/pkg/chain/ethereum/tbtc/gen/abi/LightRelay.go +++ b/pkg/chain/ethereum/tbtc/gen/abi/LightRelay.go @@ -26,6 +26,7 @@ var ( _ = common.Big1 _ = types.BloomLookup _ = event.NewSubscription + _ = abi.ConvertType ) // LightRelayMetaData contains all meta data concerning the LightRelay contract. @@ -134,11 +135,11 @@ func NewLightRelayFilterer(address common.Address, filterer bind.ContractFiltere // bindLightRelay binds a generic wrapper to an already deployed contract. func bindLightRelay(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { - parsed, err := abi.JSON(strings.NewReader(LightRelayABI)) + parsed, err := LightRelayMetaData.GetAbi() if err != nil { return nil, err } - return bind.NewBoundContract(address, parsed, caller, transactor, filterer), nil + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil } // Call invokes the (constant) contract method with params as input values and diff --git a/pkg/chain/ethereum/tbtc/gen/abi/LightRelayMaintainerProxy.go b/pkg/chain/ethereum/tbtc/gen/abi/LightRelayMaintainerProxy.go index e90d8b1efa..910dedb510 100644 --- a/pkg/chain/ethereum/tbtc/gen/abi/LightRelayMaintainerProxy.go +++ b/pkg/chain/ethereum/tbtc/gen/abi/LightRelayMaintainerProxy.go @@ -26,6 +26,7 @@ var ( _ = common.Big1 _ = types.BloomLookup _ = event.NewSubscription + _ = abi.ConvertType ) // LightRelayMaintainerProxyMetaData contains all meta data concerning the LightRelayMaintainerProxy contract. @@ -134,11 +135,11 @@ func NewLightRelayMaintainerProxyFilterer(address common.Address, filterer bind. // bindLightRelayMaintainerProxy binds a generic wrapper to an already deployed contract. func bindLightRelayMaintainerProxy(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { - parsed, err := abi.JSON(strings.NewReader(LightRelayMaintainerProxyABI)) + parsed, err := LightRelayMaintainerProxyMetaData.GetAbi() if err != nil { return nil, err } - return bind.NewBoundContract(address, parsed, caller, transactor, filterer), nil + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil } // Call invokes the (constant) contract method with params as input values and diff --git a/pkg/chain/ethereum/tbtc/gen/abi/MaintainerProxy.go b/pkg/chain/ethereum/tbtc/gen/abi/MaintainerProxy.go index d76fa92d2c..ae37378e5d 100644 --- a/pkg/chain/ethereum/tbtc/gen/abi/MaintainerProxy.go +++ b/pkg/chain/ethereum/tbtc/gen/abi/MaintainerProxy.go @@ -26,6 +26,7 @@ var ( _ = common.Big1 _ = types.BloomLookup _ = event.NewSubscription + _ = abi.ConvertType ) // BitcoinTxInfo3 is an auto generated low-level Go binding around an user-defined struct. @@ -158,11 +159,11 @@ func NewMaintainerProxyFilterer(address common.Address, filterer bind.ContractFi // bindMaintainerProxy binds a generic wrapper to an already deployed contract. func bindMaintainerProxy(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { - parsed, err := abi.JSON(strings.NewReader(MaintainerProxyABI)) + parsed, err := MaintainerProxyMetaData.GetAbi() if err != nil { return nil, err } - return bind.NewBoundContract(address, parsed, caller, transactor, filterer), nil + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil } // Call invokes the (constant) contract method with params as input values and diff --git a/pkg/chain/ethereum/tbtc/gen/abi/RedemptionWatchtower.go b/pkg/chain/ethereum/tbtc/gen/abi/RedemptionWatchtower.go index 30c4b7ca59..8d48d59321 100644 --- a/pkg/chain/ethereum/tbtc/gen/abi/RedemptionWatchtower.go +++ b/pkg/chain/ethereum/tbtc/gen/abi/RedemptionWatchtower.go @@ -31,7 +31,7 @@ var ( // RedemptionWatchtowerMetaData contains all meta data concerning the RedemptionWatchtower contract. var RedemptionWatchtowerMetaData = &bind.MetaData{ - ABI: "[{\"inputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"}],\"name\":\"Banned\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"guardian\",\"type\":\"address\"}],\"name\":\"GuardianAdded\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"guardian\",\"type\":\"address\"}],\"name\":\"GuardianRemoved\",\"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\":\"redemptionKey\",\"type\":\"uint256\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"guardian\",\"type\":\"address\"}],\"name\":\"ObjectionRaised\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"previousOwner\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"newOwner\",\"type\":\"address\"}],\"name\":\"OwnershipTransferred\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"}],\"name\":\"Unbanned\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"}],\"name\":\"VetoFinalized\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"}],\"name\":\"VetoPeriodCheckOmitted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"amount\",\"type\":\"uint64\"}],\"name\":\"VetoedFundsWithdrawn\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"disabledAt\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"executor\",\"type\":\"address\"}],\"name\":\"WatchtowerDisabled\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"enabledAt\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"manager\",\"type\":\"address\"}],\"name\":\"WatchtowerEnabled\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"watchtowerLifetime\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"vetoPenaltyFeeDivisor\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"vetoFreezePeriod\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"defaultDelay\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"levelOneDelay\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"levelTwoDelay\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"waivedAmountLimit\",\"type\":\"uint64\"}],\"name\":\"WatchtowerParametersUpdated\",\"type\":\"event\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"guardian\",\"type\":\"address\"}],\"name\":\"addGuardian\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"bank\",\"outputs\":[{\"internalType\":\"contractBank\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"bridge\",\"outputs\":[{\"internalType\":\"contractBridge\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"defaultDelay\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"disableWatchtower\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_manager\",\"type\":\"address\"},{\"internalType\":\"address[]\",\"name\":\"_guardians\",\"type\":\"address[]\"}],\"name\":\"enableWatchtower\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"}],\"name\":\"getRedemptionDelay\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"contractBridge\",\"name\":\"_bridge\",\"type\":\"address\"}],\"name\":\"initialize\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"name\":\"isBanned\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"name\":\"isGuardian\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"},{\"internalType\":\"address\",\"name\":\"balanceOwner\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"}],\"name\":\"isSafeRedemption\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"levelOneDelay\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"levelTwoDelay\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"manager\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"name\":\"objections\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"owner\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"}],\"name\":\"raiseObjection\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"guardian\",\"type\":\"address\"}],\"name\":\"removeGuardian\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"renounceOwnership\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"newOwner\",\"type\":\"address\"}],\"name\":\"transferOwnership\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"}],\"name\":\"unban\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint32\",\"name\":\"_watchtowerLifetime\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"_vetoPenaltyFeeDivisor\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"_vetoFreezePeriod\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"_defaultDelay\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"_levelOneDelay\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"_levelTwoDelay\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"_waivedAmountLimit\",\"type\":\"uint64\"}],\"name\":\"updateWatchtowerParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"vetoFreezePeriod\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"vetoPenaltyFeeDivisor\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"\",\"type\":\"uint64\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"name\":\"vetoProposals\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"withdrawableAmount\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"finalizedAt\",\"type\":\"uint32\"},{\"internalType\":\"uint8\",\"name\":\"objectionsCount\",\"type\":\"uint8\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"waivedAmountLimit\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"\",\"type\":\"uint64\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"watchtowerDisabledAt\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"watchtowerEnabledAt\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"watchtowerLifetime\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"}],\"name\":\"withdrawVetoedFunds\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"}]", + ABI: "[{\"inputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"}],\"name\":\"Banned\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"guardian\",\"type\":\"address\"}],\"name\":\"GuardianAdded\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"guardian\",\"type\":\"address\"}],\"name\":\"GuardianRemoved\",\"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\":\"redemptionKey\",\"type\":\"uint256\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"guardian\",\"type\":\"address\"}],\"name\":\"ObjectionRaised\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"previousOwner\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"newOwner\",\"type\":\"address\"}],\"name\":\"OwnershipTransferred\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"}],\"name\":\"Unbanned\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"}],\"name\":\"VetoFinalized\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"}],\"name\":\"VetoPeriodCheckOmitted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"amount\",\"type\":\"uint64\"}],\"name\":\"VetoedFundsWithdrawn\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"disabledAt\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"executor\",\"type\":\"address\"}],\"name\":\"WatchtowerDisabled\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"enabledAt\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"manager\",\"type\":\"address\"}],\"name\":\"WatchtowerEnabled\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"watchtowerLifetime\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"vetoPenaltyFeeDivisor\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"vetoFreezePeriod\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"defaultDelay\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"levelOneDelay\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"levelTwoDelay\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"waivedAmountLimit\",\"type\":\"uint64\"}],\"name\":\"WatchtowerParametersUpdated\",\"type\":\"event\"},{\"inputs\":[],\"name\":\"REQUIRED_OBJECTIONS_COUNT\",\"outputs\":[{\"internalType\":\"uint8\",\"name\":\"\",\"type\":\"uint8\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"guardian\",\"type\":\"address\"}],\"name\":\"addGuardian\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"bank\",\"outputs\":[{\"internalType\":\"contractBank\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"bridge\",\"outputs\":[{\"internalType\":\"contractBridge\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"defaultDelay\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"disableWatchtower\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_manager\",\"type\":\"address\"},{\"internalType\":\"address[]\",\"name\":\"_guardians\",\"type\":\"address[]\"}],\"name\":\"enableWatchtower\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"}],\"name\":\"getRedemptionDelay\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"contractBridge\",\"name\":\"_bridge\",\"type\":\"address\"}],\"name\":\"initialize\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"name\":\"isBanned\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"name\":\"isGuardian\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"},{\"internalType\":\"address\",\"name\":\"balanceOwner\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"}],\"name\":\"isSafeRedemption\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"levelOneDelay\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"levelTwoDelay\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"manager\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"name\":\"objections\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"owner\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"}],\"name\":\"raiseObjection\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"guardian\",\"type\":\"address\"}],\"name\":\"removeGuardian\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"renounceOwnership\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"newOwner\",\"type\":\"address\"}],\"name\":\"transferOwnership\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"}],\"name\":\"unban\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint32\",\"name\":\"_watchtowerLifetime\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"_vetoPenaltyFeeDivisor\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"_vetoFreezePeriod\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"_defaultDelay\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"_levelOneDelay\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"_levelTwoDelay\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"_waivedAmountLimit\",\"type\":\"uint64\"}],\"name\":\"updateWatchtowerParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"vetoFreezePeriod\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"vetoPenaltyFeeDivisor\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"\",\"type\":\"uint64\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"name\":\"vetoProposals\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"withdrawableAmount\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"finalizedAt\",\"type\":\"uint32\"},{\"internalType\":\"uint8\",\"name\":\"objectionsCount\",\"type\":\"uint8\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"waivedAmountLimit\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"\",\"type\":\"uint64\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"watchtowerDisabledAt\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"watchtowerEnabledAt\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"watchtowerLifetime\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"}],\"name\":\"withdrawVetoedFunds\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"}]", } // RedemptionWatchtowerABI is the input ABI used to generate the binding from. @@ -180,6 +180,37 @@ func (_RedemptionWatchtower *RedemptionWatchtowerTransactorRaw) Transact(opts *b return _RedemptionWatchtower.Contract.contract.Transact(opts, method, params...) } +// REQUIREDOBJECTIONSCOUNT is a free data retrieval call binding the contract method 0x7a497647. +// +// Solidity: function REQUIRED_OBJECTIONS_COUNT() view returns(uint8) +func (_RedemptionWatchtower *RedemptionWatchtowerCaller) REQUIREDOBJECTIONSCOUNT(opts *bind.CallOpts) (uint8, error) { + var out []interface{} + err := _RedemptionWatchtower.contract.Call(opts, &out, "REQUIRED_OBJECTIONS_COUNT") + + if err != nil { + return *new(uint8), err + } + + out0 := *abi.ConvertType(out[0], new(uint8)).(*uint8) + + return out0, err + +} + +// REQUIREDOBJECTIONSCOUNT is a free data retrieval call binding the contract method 0x7a497647. +// +// Solidity: function REQUIRED_OBJECTIONS_COUNT() view returns(uint8) +func (_RedemptionWatchtower *RedemptionWatchtowerSession) REQUIREDOBJECTIONSCOUNT() (uint8, error) { + return _RedemptionWatchtower.Contract.REQUIREDOBJECTIONSCOUNT(&_RedemptionWatchtower.CallOpts) +} + +// REQUIREDOBJECTIONSCOUNT is a free data retrieval call binding the contract method 0x7a497647. +// +// Solidity: function REQUIRED_OBJECTIONS_COUNT() view returns(uint8) +func (_RedemptionWatchtower *RedemptionWatchtowerCallerSession) REQUIREDOBJECTIONSCOUNT() (uint8, error) { + return _RedemptionWatchtower.Contract.REQUIREDOBJECTIONSCOUNT(&_RedemptionWatchtower.CallOpts) +} + // Bank is a free data retrieval call binding the contract method 0x76cdb03b. // // Solidity: function bank() view returns(address) diff --git a/pkg/chain/ethereum/tbtc/gen/abi/ReservationRouter.go b/pkg/chain/ethereum/tbtc/gen/abi/ReservationRouter.go new file mode 100644 index 0000000000..8f4ac35f2e --- /dev/null +++ b/pkg/chain/ethereum/tbtc/gen/abi/ReservationRouter.go @@ -0,0 +1,3270 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package abi + +import ( + "errors" + "math/big" + "strings" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = errors.New + _ = big.NewInt + _ = strings.NewReader + _ = ethereum.NotFound + _ = bind.Bind + _ = common.Big1 + _ = types.BloomLookup + _ = event.NewSubscription + _ = abi.ConvertType +) + +// BitcoinTxInfo4 is an auto generated low-level Go binding around an user-defined struct. +type BitcoinTxInfo4 struct { + Version [4]byte + InputVector []byte + OutputVector []byte + Locktime [4]byte +} + +// BitcoinTxProof3 is an auto generated low-level Go binding around an user-defined struct. +type BitcoinTxProof3 struct { + MerkleProof []byte + TxIndexInBlock *big.Int + BitcoinHeaders []byte + CoinbasePreimage [32]byte + CoinbaseProof []byte +} + +// BitcoinTxUTXO4 is an auto generated low-level Go binding around an user-defined struct. +type BitcoinTxUTXO4 struct { + TxHash [32]byte + TxOutputIndex uint32 + TxOutputValue uint64 +} + +// ReservationReservationAction is an auto generated low-level Go binding around an user-defined struct. +type ReservationReservationAction struct { + TargetWalletPubKeyHash [20]byte + RequestedAt uint32 + TimeoutAt uint32 + TxMaxFee uint64 + ActionType uint8 + State uint8 + FeePaid bool + Redeemer common.Address + Amount uint64 + ActionDataHash [32]byte + SourceAnchorUtxoHash [32]byte + UsedRetryCredit bool + WatchtowerDefaultDelay uint32 + WatchtowerLevelOneDelay uint32 + WatchtowerLevelTwoDelay uint32 + IsPartial bool + RetryCreditSourceNonce uint64 +} + +// ReservationReservationRequest is an auto generated low-level Go binding around an user-defined struct. +type ReservationReservationRequest struct { + Owner common.Address + MintedAmount uint64 + AcceptedAt uint32 + WalletPubKeyHash [20]byte + AnchorAmount uint64 + ExpiresAt uint32 + AnchorTxHash [32]byte + AnchorTxOutputIndex uint32 + State uint8 + RequestNonce uint64 + RetryCredit bool + DissolutionEligibleAt uint32 + CumulativeReanchorFee uint64 +} + +// ReservationRouterMetaData contains all meta data concerning the ReservationRouter contract. +var ReservationRouterMetaData = &bind.MetaData{ + 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\":\"enumReservation.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\":\"enumReservation.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\":\"enumReservation.ActionType\",\"name\":\"actionType\",\"type\":\"uint8\"},{\"internalType\":\"enumReservation.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\":\"structReservation.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\":\"enumReservation.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\":\"structReservation.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\":\"structBitcoinTx.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\":\"structBitcoinTx.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\":\"structBitcoinTx.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\"}]", +} + +// ReservationRouterABI is the input ABI used to generate the binding from. +// Deprecated: Use ReservationRouterMetaData.ABI instead. +var ReservationRouterABI = ReservationRouterMetaData.ABI + +// ReservationRouter is an auto generated Go binding around an Ethereum contract. +type ReservationRouter struct { + ReservationRouterCaller // Read-only binding to the contract + ReservationRouterTransactor // Write-only binding to the contract + ReservationRouterFilterer // Log filterer for contract events +} + +// ReservationRouterCaller is an auto generated read-only Go binding around an Ethereum contract. +type ReservationRouterCaller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// ReservationRouterTransactor is an auto generated write-only Go binding around an Ethereum contract. +type ReservationRouterTransactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// ReservationRouterFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type ReservationRouterFilterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// ReservationRouterSession is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type ReservationRouterSession struct { + Contract *ReservationRouter // Generic contract binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// ReservationRouterCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type ReservationRouterCallerSession struct { + Contract *ReservationRouterCaller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// ReservationRouterTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type ReservationRouterTransactorSession struct { + Contract *ReservationRouterTransactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// ReservationRouterRaw is an auto generated low-level Go binding around an Ethereum contract. +type ReservationRouterRaw struct { + Contract *ReservationRouter // Generic contract binding to access the raw methods on +} + +// ReservationRouterCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type ReservationRouterCallerRaw struct { + Contract *ReservationRouterCaller // Generic read-only contract binding to access the raw methods on +} + +// ReservationRouterTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type ReservationRouterTransactorRaw struct { + Contract *ReservationRouterTransactor // Generic write-only contract binding to access the raw methods on +} + +// NewReservationRouter creates a new instance of ReservationRouter, bound to a specific deployed contract. +func NewReservationRouter(address common.Address, backend bind.ContractBackend) (*ReservationRouter, error) { + contract, err := bindReservationRouter(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &ReservationRouter{ReservationRouterCaller: ReservationRouterCaller{contract: contract}, ReservationRouterTransactor: ReservationRouterTransactor{contract: contract}, ReservationRouterFilterer: ReservationRouterFilterer{contract: contract}}, nil +} + +// NewReservationRouterCaller creates a new read-only instance of ReservationRouter, bound to a specific deployed contract. +func NewReservationRouterCaller(address common.Address, caller bind.ContractCaller) (*ReservationRouterCaller, error) { + contract, err := bindReservationRouter(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &ReservationRouterCaller{contract: contract}, nil +} + +// NewReservationRouterTransactor creates a new write-only instance of ReservationRouter, bound to a specific deployed contract. +func NewReservationRouterTransactor(address common.Address, transactor bind.ContractTransactor) (*ReservationRouterTransactor, error) { + contract, err := bindReservationRouter(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &ReservationRouterTransactor{contract: contract}, nil +} + +// NewReservationRouterFilterer creates a new log filterer instance of ReservationRouter, bound to a specific deployed contract. +func NewReservationRouterFilterer(address common.Address, filterer bind.ContractFilterer) (*ReservationRouterFilterer, error) { + contract, err := bindReservationRouter(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &ReservationRouterFilterer{contract: contract}, nil +} + +// bindReservationRouter binds a generic wrapper to an already deployed contract. +func bindReservationRouter(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := ReservationRouterMetaData.GetAbi() + if err != nil { + return nil, err + } + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_ReservationRouter *ReservationRouterRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _ReservationRouter.Contract.ReservationRouterCaller.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_ReservationRouter *ReservationRouterRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _ReservationRouter.Contract.ReservationRouterTransactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_ReservationRouter *ReservationRouterRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _ReservationRouter.Contract.ReservationRouterTransactor.contract.Transact(opts, method, params...) +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_ReservationRouter *ReservationRouterCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _ReservationRouter.Contract.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_ReservationRouter *ReservationRouterTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _ReservationRouter.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_ReservationRouter *ReservationRouterTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _ReservationRouter.Contract.contract.Transact(opts, method, params...) +} + +// ActiveReservationsCount is a free data retrieval call binding the contract method 0x93fe5eab. +// +// Solidity: function activeReservationsCount() view returns(uint32 count, uint32 maxActive) +func (_ReservationRouter *ReservationRouterCaller) ActiveReservationsCount(opts *bind.CallOpts) (struct { + Count uint32 + MaxActive uint32 +}, error) { + var out []interface{} + err := _ReservationRouter.contract.Call(opts, &out, "activeReservationsCount") + + outstruct := new(struct { + Count uint32 + MaxActive uint32 + }) + if err != nil { + return *outstruct, err + } + + outstruct.Count = *abi.ConvertType(out[0], new(uint32)).(*uint32) + outstruct.MaxActive = *abi.ConvertType(out[1], new(uint32)).(*uint32) + + return *outstruct, err + +} + +// ActiveReservationsCount is a free data retrieval call binding the contract method 0x93fe5eab. +// +// Solidity: function activeReservationsCount() view returns(uint32 count, uint32 maxActive) +func (_ReservationRouter *ReservationRouterSession) ActiveReservationsCount() (struct { + Count uint32 + MaxActive uint32 +}, error) { + return _ReservationRouter.Contract.ActiveReservationsCount(&_ReservationRouter.CallOpts) +} + +// ActiveReservationsCount is a free data retrieval call binding the contract method 0x93fe5eab. +// +// Solidity: function activeReservationsCount() view returns(uint32 count, uint32 maxActive) +func (_ReservationRouter *ReservationRouterCallerSession) ActiveReservationsCount() (struct { + Count uint32 + MaxActive uint32 +}, error) { + return _ReservationRouter.Contract.ActiveReservationsCount(&_ReservationRouter.CallOpts) +} + +// Governance is a free data retrieval call binding the contract method 0x5aa6e675. +// +// Solidity: function governance() view returns(address) +func (_ReservationRouter *ReservationRouterCaller) Governance(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _ReservationRouter.contract.Call(opts, &out, "governance") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// Governance is a free data retrieval call binding the contract method 0x5aa6e675. +// +// Solidity: function governance() view returns(address) +func (_ReservationRouter *ReservationRouterSession) Governance() (common.Address, error) { + return _ReservationRouter.Contract.Governance(&_ReservationRouter.CallOpts) +} + +// Governance is a free data retrieval call binding the contract method 0x5aa6e675. +// +// Solidity: function governance() view returns(address) +func (_ReservationRouter *ReservationRouterCallerSession) Governance() (common.Address, error) { + return _ReservationRouter.Contract.Governance(&_ReservationRouter.CallOpts) +} + +// PendingReservedDeposits is a free data retrieval call binding the contract method 0x34830fc8. +// +// Solidity: function pendingReservedDeposits() view returns(uint64) +func (_ReservationRouter *ReservationRouterCaller) PendingReservedDeposits(opts *bind.CallOpts) (uint64, error) { + var out []interface{} + err := _ReservationRouter.contract.Call(opts, &out, "pendingReservedDeposits") + + if err != nil { + return *new(uint64), err + } + + out0 := *abi.ConvertType(out[0], new(uint64)).(*uint64) + + return out0, err + +} + +// PendingReservedDeposits is a free data retrieval call binding the contract method 0x34830fc8. +// +// Solidity: function pendingReservedDeposits() view returns(uint64) +func (_ReservationRouter *ReservationRouterSession) PendingReservedDeposits() (uint64, error) { + return _ReservationRouter.Contract.PendingReservedDeposits(&_ReservationRouter.CallOpts) +} + +// PendingReservedDeposits is a free data retrieval call binding the contract method 0x34830fc8. +// +// Solidity: function pendingReservedDeposits() view returns(uint64) +func (_ReservationRouter *ReservationRouterCallerSession) PendingReservedDeposits() (uint64, error) { + return _ReservationRouter.Contract.PendingReservedDeposits(&_ReservationRouter.CallOpts) +} + +// ReservationActions is a free data retrieval call binding the contract method 0xcec8c6e9. +// +// Solidity: function reservationActions(uint256 reservationKey, uint64 requestNonce) view returns((bytes20,uint32,uint32,uint64,uint8,uint8,bool,address,uint64,bytes32,bytes32,bool,uint32,uint32,uint32,bool,uint64)) +func (_ReservationRouter *ReservationRouterCaller) ReservationActions(opts *bind.CallOpts, reservationKey *big.Int, requestNonce uint64) (ReservationReservationAction, error) { + var out []interface{} + err := _ReservationRouter.contract.Call(opts, &out, "reservationActions", reservationKey, requestNonce) + + if err != nil { + return *new(ReservationReservationAction), err + } + + out0 := *abi.ConvertType(out[0], new(ReservationReservationAction)).(*ReservationReservationAction) + + return out0, err + +} + +// ReservationActions is a free data retrieval call binding the contract method 0xcec8c6e9. +// +// Solidity: function reservationActions(uint256 reservationKey, uint64 requestNonce) view returns((bytes20,uint32,uint32,uint64,uint8,uint8,bool,address,uint64,bytes32,bytes32,bool,uint32,uint32,uint32,bool,uint64)) +func (_ReservationRouter *ReservationRouterSession) ReservationActions(reservationKey *big.Int, requestNonce uint64) (ReservationReservationAction, error) { + return _ReservationRouter.Contract.ReservationActions(&_ReservationRouter.CallOpts, reservationKey, requestNonce) +} + +// ReservationActions is a free data retrieval call binding the contract method 0xcec8c6e9. +// +// Solidity: function reservationActions(uint256 reservationKey, uint64 requestNonce) view returns((bytes20,uint32,uint32,uint64,uint8,uint8,bool,address,uint64,bytes32,bytes32,bool,uint32,uint32,uint32,bool,uint64)) +func (_ReservationRouter *ReservationRouterCallerSession) ReservationActions(reservationKey *big.Int, requestNonce uint64) (ReservationReservationAction, error) { + return _ReservationRouter.Contract.ReservationActions(&_ReservationRouter.CallOpts, reservationKey, requestNonce) +} + +// ReservationByAnchorUtxo is a free data retrieval call binding the contract method 0x79731f67. +// +// Solidity: function reservationByAnchorUtxo(bytes32 anchorTxHash, uint32 anchorTxOutputIndex) view returns(uint256) +func (_ReservationRouter *ReservationRouterCaller) ReservationByAnchorUtxo(opts *bind.CallOpts, anchorTxHash [32]byte, anchorTxOutputIndex uint32) (*big.Int, error) { + var out []interface{} + err := _ReservationRouter.contract.Call(opts, &out, "reservationByAnchorUtxo", anchorTxHash, anchorTxOutputIndex) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// ReservationByAnchorUtxo is a free data retrieval call binding the contract method 0x79731f67. +// +// Solidity: function reservationByAnchorUtxo(bytes32 anchorTxHash, uint32 anchorTxOutputIndex) view returns(uint256) +func (_ReservationRouter *ReservationRouterSession) ReservationByAnchorUtxo(anchorTxHash [32]byte, anchorTxOutputIndex uint32) (*big.Int, error) { + return _ReservationRouter.Contract.ReservationByAnchorUtxo(&_ReservationRouter.CallOpts, anchorTxHash, anchorTxOutputIndex) +} + +// ReservationByAnchorUtxo is a free data retrieval call binding the contract method 0x79731f67. +// +// Solidity: function reservationByAnchorUtxo(bytes32 anchorTxHash, uint32 anchorTxOutputIndex) view returns(uint256) +func (_ReservationRouter *ReservationRouterCallerSession) ReservationByAnchorUtxo(anchorTxHash [32]byte, anchorTxOutputIndex uint32) (*big.Int, error) { + return _ReservationRouter.Contract.ReservationByAnchorUtxo(&_ReservationRouter.CallOpts, anchorTxHash, anchorTxOutputIndex) +} + +// ReservationCaps is a free data retrieval call binding the contract method 0x63dfb29c. +// +// Solidity: function reservationCaps() view returns(uint64 maxReservationsAmountPerWallet, uint64 reservationMaxSingleAmount) +func (_ReservationRouter *ReservationRouterCaller) ReservationCaps(opts *bind.CallOpts) (struct { + MaxReservationsAmountPerWallet uint64 + ReservationMaxSingleAmount uint64 +}, error) { + var out []interface{} + err := _ReservationRouter.contract.Call(opts, &out, "reservationCaps") + + outstruct := new(struct { + MaxReservationsAmountPerWallet uint64 + ReservationMaxSingleAmount uint64 + }) + if err != nil { + return *outstruct, err + } + + outstruct.MaxReservationsAmountPerWallet = *abi.ConvertType(out[0], new(uint64)).(*uint64) + outstruct.ReservationMaxSingleAmount = *abi.ConvertType(out[1], new(uint64)).(*uint64) + + return *outstruct, err + +} + +// ReservationCaps is a free data retrieval call binding the contract method 0x63dfb29c. +// +// Solidity: function reservationCaps() view returns(uint64 maxReservationsAmountPerWallet, uint64 reservationMaxSingleAmount) +func (_ReservationRouter *ReservationRouterSession) ReservationCaps() (struct { + MaxReservationsAmountPerWallet uint64 + ReservationMaxSingleAmount uint64 +}, error) { + return _ReservationRouter.Contract.ReservationCaps(&_ReservationRouter.CallOpts) +} + +// ReservationCaps is a free data retrieval call binding the contract method 0x63dfb29c. +// +// Solidity: function reservationCaps() view returns(uint64 maxReservationsAmountPerWallet, uint64 reservationMaxSingleAmount) +func (_ReservationRouter *ReservationRouterCallerSession) ReservationCaps() (struct { + MaxReservationsAmountPerWallet uint64 + ReservationMaxSingleAmount uint64 +}, error) { + return _ReservationRouter.Contract.ReservationCaps(&_ReservationRouter.CallOpts) +} + +// ReservationParameters is a free data retrieval call binding the contract method 0xf75b4b1c. +// +// Solidity: function reservationParameters() view returns(address reservationVault, uint64 reservationMinAmount, uint64 reservationTxMaxFee, uint32 reservationTermSeconds, uint32 reservationDissolutionDelay, uint64 reservationMaxTotalAmount, uint64 reservationTotalAmount, uint32 maxReservationsPerWallet, uint32 reservationActionTimeout, uint32 reservationRenewalWindowSeconds) +func (_ReservationRouter *ReservationRouterCaller) ReservationParameters(opts *bind.CallOpts) (struct { + ReservationVault common.Address + ReservationMinAmount uint64 + ReservationTxMaxFee uint64 + ReservationTermSeconds uint32 + ReservationDissolutionDelay uint32 + ReservationMaxTotalAmount uint64 + ReservationTotalAmount uint64 + MaxReservationsPerWallet uint32 + ReservationActionTimeout uint32 + ReservationRenewalWindowSeconds uint32 +}, error) { + var out []interface{} + err := _ReservationRouter.contract.Call(opts, &out, "reservationParameters") + + outstruct := new(struct { + ReservationVault common.Address + ReservationMinAmount uint64 + ReservationTxMaxFee uint64 + ReservationTermSeconds uint32 + ReservationDissolutionDelay uint32 + ReservationMaxTotalAmount uint64 + ReservationTotalAmount uint64 + MaxReservationsPerWallet uint32 + ReservationActionTimeout uint32 + ReservationRenewalWindowSeconds uint32 + }) + if err != nil { + return *outstruct, err + } + + outstruct.ReservationVault = *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + outstruct.ReservationMinAmount = *abi.ConvertType(out[1], new(uint64)).(*uint64) + outstruct.ReservationTxMaxFee = *abi.ConvertType(out[2], new(uint64)).(*uint64) + outstruct.ReservationTermSeconds = *abi.ConvertType(out[3], new(uint32)).(*uint32) + outstruct.ReservationDissolutionDelay = *abi.ConvertType(out[4], new(uint32)).(*uint32) + outstruct.ReservationMaxTotalAmount = *abi.ConvertType(out[5], new(uint64)).(*uint64) + outstruct.ReservationTotalAmount = *abi.ConvertType(out[6], new(uint64)).(*uint64) + outstruct.MaxReservationsPerWallet = *abi.ConvertType(out[7], new(uint32)).(*uint32) + outstruct.ReservationActionTimeout = *abi.ConvertType(out[8], new(uint32)).(*uint32) + outstruct.ReservationRenewalWindowSeconds = *abi.ConvertType(out[9], new(uint32)).(*uint32) + + return *outstruct, err + +} + +// ReservationParameters is a free data retrieval call binding the contract method 0xf75b4b1c. +// +// Solidity: function reservationParameters() view returns(address reservationVault, uint64 reservationMinAmount, uint64 reservationTxMaxFee, uint32 reservationTermSeconds, uint32 reservationDissolutionDelay, uint64 reservationMaxTotalAmount, uint64 reservationTotalAmount, uint32 maxReservationsPerWallet, uint32 reservationActionTimeout, uint32 reservationRenewalWindowSeconds) +func (_ReservationRouter *ReservationRouterSession) ReservationParameters() (struct { + ReservationVault common.Address + ReservationMinAmount uint64 + ReservationTxMaxFee uint64 + ReservationTermSeconds uint32 + ReservationDissolutionDelay uint32 + ReservationMaxTotalAmount uint64 + ReservationTotalAmount uint64 + MaxReservationsPerWallet uint32 + ReservationActionTimeout uint32 + ReservationRenewalWindowSeconds uint32 +}, error) { + return _ReservationRouter.Contract.ReservationParameters(&_ReservationRouter.CallOpts) +} + +// ReservationParameters is a free data retrieval call binding the contract method 0xf75b4b1c. +// +// Solidity: function reservationParameters() view returns(address reservationVault, uint64 reservationMinAmount, uint64 reservationTxMaxFee, uint32 reservationTermSeconds, uint32 reservationDissolutionDelay, uint64 reservationMaxTotalAmount, uint64 reservationTotalAmount, uint32 maxReservationsPerWallet, uint32 reservationActionTimeout, uint32 reservationRenewalWindowSeconds) +func (_ReservationRouter *ReservationRouterCallerSession) ReservationParameters() (struct { + ReservationVault common.Address + ReservationMinAmount uint64 + ReservationTxMaxFee uint64 + ReservationTermSeconds uint32 + ReservationDissolutionDelay uint32 + ReservationMaxTotalAmount uint64 + ReservationTotalAmount uint64 + MaxReservationsPerWallet uint32 + ReservationActionTimeout uint32 + ReservationRenewalWindowSeconds uint32 +}, error) { + return _ReservationRouter.Contract.ReservationParameters(&_ReservationRouter.CallOpts) +} + +// ReservationRouter is a free data retrieval call binding the contract method 0x06ca90d2. +// +// Solidity: function reservationRouter() view returns(address) +func (_ReservationRouter *ReservationRouterCaller) ReservationRouter(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _ReservationRouter.contract.Call(opts, &out, "reservationRouter") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// ReservationRouter is a free data retrieval call binding the contract method 0x06ca90d2. +// +// Solidity: function reservationRouter() view returns(address) +func (_ReservationRouter *ReservationRouterSession) ReservationRouter() (common.Address, error) { + return _ReservationRouter.Contract.ReservationRouter(&_ReservationRouter.CallOpts) +} + +// ReservationRouter is a free data retrieval call binding the contract method 0x06ca90d2. +// +// Solidity: function reservationRouter() view returns(address) +func (_ReservationRouter *ReservationRouterCallerSession) ReservationRouter() (common.Address, error) { + return _ReservationRouter.Contract.ReservationRouter(&_ReservationRouter.CallOpts) +} + +// Reservations is a free data retrieval call binding the contract method 0x067cf832. +// +// Solidity: function reservations(uint256 reservationKey) view returns((address,uint64,uint32,bytes20,uint64,uint32,bytes32,uint32,uint8,uint64,bool,uint32,uint64)) +func (_ReservationRouter *ReservationRouterCaller) Reservations(opts *bind.CallOpts, reservationKey *big.Int) (ReservationReservationRequest, error) { + var out []interface{} + err := _ReservationRouter.contract.Call(opts, &out, "reservations", reservationKey) + + if err != nil { + return *new(ReservationReservationRequest), err + } + + out0 := *abi.ConvertType(out[0], new(ReservationReservationRequest)).(*ReservationReservationRequest) + + return out0, err + +} + +// Reservations is a free data retrieval call binding the contract method 0x067cf832. +// +// Solidity: function reservations(uint256 reservationKey) view returns((address,uint64,uint32,bytes20,uint64,uint32,bytes32,uint32,uint8,uint64,bool,uint32,uint64)) +func (_ReservationRouter *ReservationRouterSession) Reservations(reservationKey *big.Int) (ReservationReservationRequest, error) { + return _ReservationRouter.Contract.Reservations(&_ReservationRouter.CallOpts, reservationKey) +} + +// Reservations is a free data retrieval call binding the contract method 0x067cf832. +// +// Solidity: function reservations(uint256 reservationKey) view returns((address,uint64,uint32,bytes20,uint64,uint32,bytes32,uint32,uint8,uint64,bool,uint32,uint64)) +func (_ReservationRouter *ReservationRouterCallerSession) Reservations(reservationKey *big.Int) (ReservationReservationRequest, error) { + return _ReservationRouter.Contract.Reservations(&_ReservationRouter.CallOpts, reservationKey) +} + +// ReservedDepositWallet is a free data retrieval call binding the contract method 0x56803b55. +// +// Solidity: function reservedDepositWallet(uint256 depositKey) view returns(bytes20) +func (_ReservationRouter *ReservationRouterCaller) ReservedDepositWallet(opts *bind.CallOpts, depositKey *big.Int) ([20]byte, error) { + var out []interface{} + err := _ReservationRouter.contract.Call(opts, &out, "reservedDepositWallet", depositKey) + + if err != nil { + return *new([20]byte), err + } + + out0 := *abi.ConvertType(out[0], new([20]byte)).(*[20]byte) + + return out0, err + +} + +// ReservedDepositWallet is a free data retrieval call binding the contract method 0x56803b55. +// +// Solidity: function reservedDepositWallet(uint256 depositKey) view returns(bytes20) +func (_ReservationRouter *ReservationRouterSession) ReservedDepositWallet(depositKey *big.Int) ([20]byte, error) { + return _ReservationRouter.Contract.ReservedDepositWallet(&_ReservationRouter.CallOpts, depositKey) +} + +// ReservedDepositWallet is a free data retrieval call binding the contract method 0x56803b55. +// +// Solidity: function reservedDepositWallet(uint256 depositKey) view returns(bytes20) +func (_ReservationRouter *ReservationRouterCallerSession) ReservedDepositWallet(depositKey *big.Int) ([20]byte, error) { + return _ReservationRouter.Contract.ReservedDepositWallet(&_ReservationRouter.CallOpts, depositKey) +} + +// WalletReservations is a free data retrieval call binding the contract method 0x78699d2f. +// +// Solidity: function walletReservations(bytes20 walletPubKeyHash) view returns(uint256[]) +func (_ReservationRouter *ReservationRouterCaller) WalletReservations(opts *bind.CallOpts, walletPubKeyHash [20]byte) ([]*big.Int, error) { + var out []interface{} + err := _ReservationRouter.contract.Call(opts, &out, "walletReservations", walletPubKeyHash) + + if err != nil { + return *new([]*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new([]*big.Int)).(*[]*big.Int) + + return out0, err + +} + +// WalletReservations is a free data retrieval call binding the contract method 0x78699d2f. +// +// Solidity: function walletReservations(bytes20 walletPubKeyHash) view returns(uint256[]) +func (_ReservationRouter *ReservationRouterSession) WalletReservations(walletPubKeyHash [20]byte) ([]*big.Int, error) { + return _ReservationRouter.Contract.WalletReservations(&_ReservationRouter.CallOpts, walletPubKeyHash) +} + +// WalletReservations is a free data retrieval call binding the contract method 0x78699d2f. +// +// Solidity: function walletReservations(bytes20 walletPubKeyHash) view returns(uint256[]) +func (_ReservationRouter *ReservationRouterCallerSession) WalletReservations(walletPubKeyHash [20]byte) ([]*big.Int, error) { + return _ReservationRouter.Contract.WalletReservations(&_ReservationRouter.CallOpts, walletPubKeyHash) +} + +// WalletReservationsAmount is a free data retrieval call binding the contract method 0x63481e98. +// +// Solidity: function walletReservationsAmount(bytes20 walletPubKeyHash) view returns(uint64) +func (_ReservationRouter *ReservationRouterCaller) WalletReservationsAmount(opts *bind.CallOpts, walletPubKeyHash [20]byte) (uint64, error) { + var out []interface{} + err := _ReservationRouter.contract.Call(opts, &out, "walletReservationsAmount", walletPubKeyHash) + + if err != nil { + return *new(uint64), err + } + + out0 := *abi.ConvertType(out[0], new(uint64)).(*uint64) + + return out0, err + +} + +// WalletReservationsAmount is a free data retrieval call binding the contract method 0x63481e98. +// +// Solidity: function walletReservationsAmount(bytes20 walletPubKeyHash) view returns(uint64) +func (_ReservationRouter *ReservationRouterSession) WalletReservationsAmount(walletPubKeyHash [20]byte) (uint64, error) { + return _ReservationRouter.Contract.WalletReservationsAmount(&_ReservationRouter.CallOpts, walletPubKeyHash) +} + +// WalletReservationsAmount is a free data retrieval call binding the contract method 0x63481e98. +// +// Solidity: function walletReservationsAmount(bytes20 walletPubKeyHash) view returns(uint64) +func (_ReservationRouter *ReservationRouterCallerSession) WalletReservationsAmount(walletPubKeyHash [20]byte) (uint64, error) { + return _ReservationRouter.Contract.WalletReservationsAmount(&_ReservationRouter.CallOpts, walletPubKeyHash) +} + +// WalletReservationsCount is a free data retrieval call binding the contract method 0x1de0555a. +// +// Solidity: function walletReservationsCount(bytes20 walletPubKeyHash) view returns(uint32) +func (_ReservationRouter *ReservationRouterCaller) WalletReservationsCount(opts *bind.CallOpts, walletPubKeyHash [20]byte) (uint32, error) { + var out []interface{} + err := _ReservationRouter.contract.Call(opts, &out, "walletReservationsCount", walletPubKeyHash) + + if err != nil { + return *new(uint32), err + } + + out0 := *abi.ConvertType(out[0], new(uint32)).(*uint32) + + return out0, err + +} + +// WalletReservationsCount is a free data retrieval call binding the contract method 0x1de0555a. +// +// Solidity: function walletReservationsCount(bytes20 walletPubKeyHash) view returns(uint32) +func (_ReservationRouter *ReservationRouterSession) WalletReservationsCount(walletPubKeyHash [20]byte) (uint32, error) { + return _ReservationRouter.Contract.WalletReservationsCount(&_ReservationRouter.CallOpts, walletPubKeyHash) +} + +// WalletReservationsCount is a free data retrieval call binding the contract method 0x1de0555a. +// +// Solidity: function walletReservationsCount(bytes20 walletPubKeyHash) view returns(uint32) +func (_ReservationRouter *ReservationRouterCallerSession) WalletReservationsCount(walletPubKeyHash [20]byte) (uint32, error) { + return _ReservationRouter.Contract.WalletReservationsCount(&_ReservationRouter.CallOpts, walletPubKeyHash) +} + +// NotifyReservationActionTimeout is a paid mutator transaction binding the contract method 0x88aa5729. +// +// Solidity: function notifyReservationActionTimeout(uint256 reservationKey, uint32[] walletMembersIDs) returns() +func (_ReservationRouter *ReservationRouterTransactor) NotifyReservationActionTimeout(opts *bind.TransactOpts, reservationKey *big.Int, walletMembersIDs []uint32) (*types.Transaction, error) { + return _ReservationRouter.contract.Transact(opts, "notifyReservationActionTimeout", reservationKey, walletMembersIDs) +} + +// NotifyReservationActionTimeout is a paid mutator transaction binding the contract method 0x88aa5729. +// +// Solidity: function notifyReservationActionTimeout(uint256 reservationKey, uint32[] walletMembersIDs) returns() +func (_ReservationRouter *ReservationRouterSession) NotifyReservationActionTimeout(reservationKey *big.Int, walletMembersIDs []uint32) (*types.Transaction, error) { + return _ReservationRouter.Contract.NotifyReservationActionTimeout(&_ReservationRouter.TransactOpts, reservationKey, walletMembersIDs) +} + +// NotifyReservationActionTimeout is a paid mutator transaction binding the contract method 0x88aa5729. +// +// Solidity: function notifyReservationActionTimeout(uint256 reservationKey, uint32[] walletMembersIDs) returns() +func (_ReservationRouter *ReservationRouterTransactorSession) NotifyReservationActionTimeout(reservationKey *big.Int, walletMembersIDs []uint32) (*types.Transaction, error) { + return _ReservationRouter.Contract.NotifyReservationActionTimeout(&_ReservationRouter.TransactOpts, reservationKey, walletMembersIDs) +} + +// NotifyReservationStranded is a paid mutator transaction binding the contract method 0xf95ea36f. +// +// Solidity: function notifyReservationStranded(uint256 reservationKey) returns() +func (_ReservationRouter *ReservationRouterTransactor) NotifyReservationStranded(opts *bind.TransactOpts, reservationKey *big.Int) (*types.Transaction, error) { + return _ReservationRouter.contract.Transact(opts, "notifyReservationStranded", reservationKey) +} + +// NotifyReservationStranded is a paid mutator transaction binding the contract method 0xf95ea36f. +// +// Solidity: function notifyReservationStranded(uint256 reservationKey) returns() +func (_ReservationRouter *ReservationRouterSession) NotifyReservationStranded(reservationKey *big.Int) (*types.Transaction, error) { + return _ReservationRouter.Contract.NotifyReservationStranded(&_ReservationRouter.TransactOpts, reservationKey) +} + +// NotifyReservationStranded is a paid mutator transaction binding the contract method 0xf95ea36f. +// +// Solidity: function notifyReservationStranded(uint256 reservationKey) returns() +func (_ReservationRouter *ReservationRouterTransactorSession) NotifyReservationStranded(reservationKey *big.Int) (*types.Transaction, error) { + return _ReservationRouter.Contract.NotifyReservationStranded(&_ReservationRouter.TransactOpts, reservationKey) +} + +// NotifyStaleReservedDeposit is a paid mutator transaction binding the contract method 0x6ceb1b54. +// +// Solidity: function notifyStaleReservedDeposit(uint256 depositKey) returns() +func (_ReservationRouter *ReservationRouterTransactor) NotifyStaleReservedDeposit(opts *bind.TransactOpts, depositKey *big.Int) (*types.Transaction, error) { + return _ReservationRouter.contract.Transact(opts, "notifyStaleReservedDeposit", depositKey) +} + +// NotifyStaleReservedDeposit is a paid mutator transaction binding the contract method 0x6ceb1b54. +// +// Solidity: function notifyStaleReservedDeposit(uint256 depositKey) returns() +func (_ReservationRouter *ReservationRouterSession) NotifyStaleReservedDeposit(depositKey *big.Int) (*types.Transaction, error) { + return _ReservationRouter.Contract.NotifyStaleReservedDeposit(&_ReservationRouter.TransactOpts, depositKey) +} + +// NotifyStaleReservedDeposit is a paid mutator transaction binding the contract method 0x6ceb1b54. +// +// Solidity: function notifyStaleReservedDeposit(uint256 depositKey) returns() +func (_ReservationRouter *ReservationRouterTransactorSession) NotifyStaleReservedDeposit(depositKey *big.Int) (*types.Transaction, error) { + return _ReservationRouter.Contract.NotifyStaleReservedDeposit(&_ReservationRouter.TransactOpts, depositKey) +} + +// RequestReservationAcceptance is a paid mutator transaction binding the contract method 0xbc78a18e. +// +// Solidity: function requestReservationAcceptance(uint256 reservationKey, bytes20 walletPubKeyHash) returns() +func (_ReservationRouter *ReservationRouterTransactor) RequestReservationAcceptance(opts *bind.TransactOpts, reservationKey *big.Int, walletPubKeyHash [20]byte) (*types.Transaction, error) { + return _ReservationRouter.contract.Transact(opts, "requestReservationAcceptance", reservationKey, walletPubKeyHash) +} + +// RequestReservationAcceptance is a paid mutator transaction binding the contract method 0xbc78a18e. +// +// Solidity: function requestReservationAcceptance(uint256 reservationKey, bytes20 walletPubKeyHash) returns() +func (_ReservationRouter *ReservationRouterSession) RequestReservationAcceptance(reservationKey *big.Int, walletPubKeyHash [20]byte) (*types.Transaction, error) { + return _ReservationRouter.Contract.RequestReservationAcceptance(&_ReservationRouter.TransactOpts, reservationKey, walletPubKeyHash) +} + +// RequestReservationAcceptance is a paid mutator transaction binding the contract method 0xbc78a18e. +// +// Solidity: function requestReservationAcceptance(uint256 reservationKey, bytes20 walletPubKeyHash) returns() +func (_ReservationRouter *ReservationRouterTransactorSession) RequestReservationAcceptance(reservationKey *big.Int, walletPubKeyHash [20]byte) (*types.Transaction, error) { + return _ReservationRouter.Contract.RequestReservationAcceptance(&_ReservationRouter.TransactOpts, reservationKey, walletPubKeyHash) +} + +// RequestReservationReanchor is a paid mutator transaction binding the contract method 0xf934beb5. +// +// Solidity: function requestReservationReanchor(uint256 reservationKey, bytes20 targetWalletPubKeyHash) returns() +func (_ReservationRouter *ReservationRouterTransactor) RequestReservationReanchor(opts *bind.TransactOpts, reservationKey *big.Int, targetWalletPubKeyHash [20]byte) (*types.Transaction, error) { + return _ReservationRouter.contract.Transact(opts, "requestReservationReanchor", reservationKey, targetWalletPubKeyHash) +} + +// RequestReservationReanchor is a paid mutator transaction binding the contract method 0xf934beb5. +// +// Solidity: function requestReservationReanchor(uint256 reservationKey, bytes20 targetWalletPubKeyHash) returns() +func (_ReservationRouter *ReservationRouterSession) RequestReservationReanchor(reservationKey *big.Int, targetWalletPubKeyHash [20]byte) (*types.Transaction, error) { + return _ReservationRouter.Contract.RequestReservationReanchor(&_ReservationRouter.TransactOpts, reservationKey, targetWalletPubKeyHash) +} + +// RequestReservationReanchor is a paid mutator transaction binding the contract method 0xf934beb5. +// +// Solidity: function requestReservationReanchor(uint256 reservationKey, bytes20 targetWalletPubKeyHash) returns() +func (_ReservationRouter *ReservationRouterTransactorSession) RequestReservationReanchor(reservationKey *big.Int, targetWalletPubKeyHash [20]byte) (*types.Transaction, error) { + return _ReservationRouter.Contract.RequestReservationReanchor(&_ReservationRouter.TransactOpts, reservationKey, targetWalletPubKeyHash) +} + +// SubmitReservationProof is a paid mutator transaction binding the contract method 0x668a4980. +// +// Solidity: function submitReservationProof(uint8 proofType, (bytes4,bytes,bytes,bytes4) txInfo, (bytes,uint256,bytes,bytes32,bytes) proof, (bytes32,uint32,uint64) mainUtxo, uint256 reservationKey, uint64 requestNonce) returns() +func (_ReservationRouter *ReservationRouterTransactor) SubmitReservationProof(opts *bind.TransactOpts, proofType uint8, txInfo BitcoinTxInfo4, proof BitcoinTxProof3, mainUtxo BitcoinTxUTXO4, reservationKey *big.Int, requestNonce uint64) (*types.Transaction, error) { + return _ReservationRouter.contract.Transact(opts, "submitReservationProof", proofType, txInfo, proof, mainUtxo, reservationKey, requestNonce) +} + +// SubmitReservationProof is a paid mutator transaction binding the contract method 0x668a4980. +// +// Solidity: function submitReservationProof(uint8 proofType, (bytes4,bytes,bytes,bytes4) txInfo, (bytes,uint256,bytes,bytes32,bytes) proof, (bytes32,uint32,uint64) mainUtxo, uint256 reservationKey, uint64 requestNonce) returns() +func (_ReservationRouter *ReservationRouterSession) SubmitReservationProof(proofType uint8, txInfo BitcoinTxInfo4, proof BitcoinTxProof3, mainUtxo BitcoinTxUTXO4, reservationKey *big.Int, requestNonce uint64) (*types.Transaction, error) { + return _ReservationRouter.Contract.SubmitReservationProof(&_ReservationRouter.TransactOpts, proofType, txInfo, proof, mainUtxo, reservationKey, requestNonce) +} + +// SubmitReservationProof is a paid mutator transaction binding the contract method 0x668a4980. +// +// Solidity: function submitReservationProof(uint8 proofType, (bytes4,bytes,bytes,bytes4) txInfo, (bytes,uint256,bytes,bytes32,bytes) proof, (bytes32,uint32,uint64) mainUtxo, uint256 reservationKey, uint64 requestNonce) returns() +func (_ReservationRouter *ReservationRouterTransactorSession) SubmitReservationProof(proofType uint8, txInfo BitcoinTxInfo4, proof BitcoinTxProof3, mainUtxo BitcoinTxUTXO4, reservationKey *big.Int, requestNonce uint64) (*types.Transaction, error) { + return _ReservationRouter.Contract.SubmitReservationProof(&_ReservationRouter.TransactOpts, proofType, txInfo, proof, mainUtxo, reservationKey, requestNonce) +} + +// TransferGovernance is a paid mutator transaction binding the contract method 0xd38bfff4. +// +// Solidity: function transferGovernance(address newGovernance) returns() +func (_ReservationRouter *ReservationRouterTransactor) TransferGovernance(opts *bind.TransactOpts, newGovernance common.Address) (*types.Transaction, error) { + return _ReservationRouter.contract.Transact(opts, "transferGovernance", newGovernance) +} + +// TransferGovernance is a paid mutator transaction binding the contract method 0xd38bfff4. +// +// Solidity: function transferGovernance(address newGovernance) returns() +func (_ReservationRouter *ReservationRouterSession) TransferGovernance(newGovernance common.Address) (*types.Transaction, error) { + return _ReservationRouter.Contract.TransferGovernance(&_ReservationRouter.TransactOpts, newGovernance) +} + +// TransferGovernance is a paid mutator transaction binding the contract method 0xd38bfff4. +// +// Solidity: function transferGovernance(address newGovernance) returns() +func (_ReservationRouter *ReservationRouterTransactorSession) TransferGovernance(newGovernance common.Address) (*types.Transaction, error) { + return _ReservationRouter.Contract.TransferGovernance(&_ReservationRouter.TransactOpts, newGovernance) +} + +// UpdateReservationCaps is a paid mutator transaction binding the contract method 0x8308c2ca. +// +// Solidity: function updateReservationCaps(uint64 maxReservationsAmountPerWallet, uint64 reservationMaxSingleAmount, uint32 maxActiveReservations) returns() +func (_ReservationRouter *ReservationRouterTransactor) UpdateReservationCaps(opts *bind.TransactOpts, maxReservationsAmountPerWallet uint64, reservationMaxSingleAmount uint64, maxActiveReservations uint32) (*types.Transaction, error) { + return _ReservationRouter.contract.Transact(opts, "updateReservationCaps", maxReservationsAmountPerWallet, reservationMaxSingleAmount, maxActiveReservations) +} + +// UpdateReservationCaps is a paid mutator transaction binding the contract method 0x8308c2ca. +// +// Solidity: function updateReservationCaps(uint64 maxReservationsAmountPerWallet, uint64 reservationMaxSingleAmount, uint32 maxActiveReservations) returns() +func (_ReservationRouter *ReservationRouterSession) UpdateReservationCaps(maxReservationsAmountPerWallet uint64, reservationMaxSingleAmount uint64, maxActiveReservations uint32) (*types.Transaction, error) { + return _ReservationRouter.Contract.UpdateReservationCaps(&_ReservationRouter.TransactOpts, maxReservationsAmountPerWallet, reservationMaxSingleAmount, maxActiveReservations) +} + +// UpdateReservationCaps is a paid mutator transaction binding the contract method 0x8308c2ca. +// +// Solidity: function updateReservationCaps(uint64 maxReservationsAmountPerWallet, uint64 reservationMaxSingleAmount, uint32 maxActiveReservations) returns() +func (_ReservationRouter *ReservationRouterTransactorSession) UpdateReservationCaps(maxReservationsAmountPerWallet uint64, reservationMaxSingleAmount uint64, maxActiveReservations uint32) (*types.Transaction, error) { + return _ReservationRouter.Contract.UpdateReservationCaps(&_ReservationRouter.TransactOpts, maxReservationsAmountPerWallet, reservationMaxSingleAmount, maxActiveReservations) +} + +// UpdateReservationParameters is a paid mutator transaction binding the contract method 0x59f6408b. +// +// Solidity: function updateReservationParameters(address reservationVault, uint64 reservationMinAmount, uint64 reservationTxMaxFee, uint32 reservationTermSeconds, uint32 reservationDissolutionDelay, uint64 reservationMaxTotalAmount, uint32 maxReservationsPerWallet, uint32 reservationActionTimeout, uint32 reservationRenewalWindowSeconds) returns() +func (_ReservationRouter *ReservationRouterTransactor) UpdateReservationParameters(opts *bind.TransactOpts, reservationVault common.Address, reservationMinAmount uint64, reservationTxMaxFee uint64, reservationTermSeconds uint32, reservationDissolutionDelay uint32, reservationMaxTotalAmount uint64, maxReservationsPerWallet uint32, reservationActionTimeout uint32, reservationRenewalWindowSeconds uint32) (*types.Transaction, error) { + return _ReservationRouter.contract.Transact(opts, "updateReservationParameters", reservationVault, reservationMinAmount, reservationTxMaxFee, reservationTermSeconds, reservationDissolutionDelay, reservationMaxTotalAmount, maxReservationsPerWallet, reservationActionTimeout, reservationRenewalWindowSeconds) +} + +// UpdateReservationParameters is a paid mutator transaction binding the contract method 0x59f6408b. +// +// Solidity: function updateReservationParameters(address reservationVault, uint64 reservationMinAmount, uint64 reservationTxMaxFee, uint32 reservationTermSeconds, uint32 reservationDissolutionDelay, uint64 reservationMaxTotalAmount, uint32 maxReservationsPerWallet, uint32 reservationActionTimeout, uint32 reservationRenewalWindowSeconds) returns() +func (_ReservationRouter *ReservationRouterSession) UpdateReservationParameters(reservationVault common.Address, reservationMinAmount uint64, reservationTxMaxFee uint64, reservationTermSeconds uint32, reservationDissolutionDelay uint32, reservationMaxTotalAmount uint64, maxReservationsPerWallet uint32, reservationActionTimeout uint32, reservationRenewalWindowSeconds uint32) (*types.Transaction, error) { + return _ReservationRouter.Contract.UpdateReservationParameters(&_ReservationRouter.TransactOpts, reservationVault, reservationMinAmount, reservationTxMaxFee, reservationTermSeconds, reservationDissolutionDelay, reservationMaxTotalAmount, maxReservationsPerWallet, reservationActionTimeout, reservationRenewalWindowSeconds) +} + +// UpdateReservationParameters is a paid mutator transaction binding the contract method 0x59f6408b. +// +// Solidity: function updateReservationParameters(address reservationVault, uint64 reservationMinAmount, uint64 reservationTxMaxFee, uint32 reservationTermSeconds, uint32 reservationDissolutionDelay, uint64 reservationMaxTotalAmount, uint32 maxReservationsPerWallet, uint32 reservationActionTimeout, uint32 reservationRenewalWindowSeconds) returns() +func (_ReservationRouter *ReservationRouterTransactorSession) UpdateReservationParameters(reservationVault common.Address, reservationMinAmount uint64, reservationTxMaxFee uint64, reservationTermSeconds uint32, reservationDissolutionDelay uint32, reservationMaxTotalAmount uint64, maxReservationsPerWallet uint32, reservationActionTimeout uint32, reservationRenewalWindowSeconds uint32) (*types.Transaction, error) { + return _ReservationRouter.Contract.UpdateReservationParameters(&_ReservationRouter.TransactOpts, reservationVault, reservationMinAmount, reservationTxMaxFee, reservationTermSeconds, reservationDissolutionDelay, reservationMaxTotalAmount, maxReservationsPerWallet, reservationActionTimeout, reservationRenewalWindowSeconds) +} + +// ReservationRouterGovernanceTransferredIterator is returned from FilterGovernanceTransferred and is used to iterate over the raw logs and unpacked data for GovernanceTransferred events raised by the ReservationRouter contract. +type ReservationRouterGovernanceTransferredIterator struct { + Event *ReservationRouterGovernanceTransferred // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ReservationRouterGovernanceTransferredIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ReservationRouterGovernanceTransferred) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ReservationRouterGovernanceTransferred) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ReservationRouterGovernanceTransferredIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ReservationRouterGovernanceTransferredIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ReservationRouterGovernanceTransferred represents a GovernanceTransferred event raised by the ReservationRouter contract. +type ReservationRouterGovernanceTransferred struct { + OldGovernance common.Address + NewGovernance common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterGovernanceTransferred is a free log retrieval operation binding the contract event 0x5f56bee8cffbe9a78652a74a60705edede02af10b0bbb888ca44b79a0d42ce80. +// +// Solidity: event GovernanceTransferred(address oldGovernance, address newGovernance) +func (_ReservationRouter *ReservationRouterFilterer) FilterGovernanceTransferred(opts *bind.FilterOpts) (*ReservationRouterGovernanceTransferredIterator, error) { + + logs, sub, err := _ReservationRouter.contract.FilterLogs(opts, "GovernanceTransferred") + if err != nil { + return nil, err + } + return &ReservationRouterGovernanceTransferredIterator{contract: _ReservationRouter.contract, event: "GovernanceTransferred", logs: logs, sub: sub}, nil +} + +// WatchGovernanceTransferred is a free log subscription operation binding the contract event 0x5f56bee8cffbe9a78652a74a60705edede02af10b0bbb888ca44b79a0d42ce80. +// +// Solidity: event GovernanceTransferred(address oldGovernance, address newGovernance) +func (_ReservationRouter *ReservationRouterFilterer) WatchGovernanceTransferred(opts *bind.WatchOpts, sink chan<- *ReservationRouterGovernanceTransferred) (event.Subscription, error) { + + logs, sub, err := _ReservationRouter.contract.WatchLogs(opts, "GovernanceTransferred") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ReservationRouterGovernanceTransferred) + if err := _ReservationRouter.contract.UnpackLog(event, "GovernanceTransferred", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseGovernanceTransferred is a log parse operation binding the contract event 0x5f56bee8cffbe9a78652a74a60705edede02af10b0bbb888ca44b79a0d42ce80. +// +// Solidity: event GovernanceTransferred(address oldGovernance, address newGovernance) +func (_ReservationRouter *ReservationRouterFilterer) ParseGovernanceTransferred(log types.Log) (*ReservationRouterGovernanceTransferred, error) { + event := new(ReservationRouterGovernanceTransferred) + if err := _ReservationRouter.contract.UnpackLog(event, "GovernanceTransferred", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ReservationRouterInitializedIterator is returned from FilterInitialized and is used to iterate over the raw logs and unpacked data for Initialized events raised by the ReservationRouter contract. +type ReservationRouterInitializedIterator struct { + Event *ReservationRouterInitialized // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ReservationRouterInitializedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ReservationRouterInitialized) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ReservationRouterInitialized) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ReservationRouterInitializedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ReservationRouterInitializedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ReservationRouterInitialized represents a Initialized event raised by the ReservationRouter contract. +type ReservationRouterInitialized struct { + Version uint8 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterInitialized is a free log retrieval operation binding the contract event 0x7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb3847402498. +// +// Solidity: event Initialized(uint8 version) +func (_ReservationRouter *ReservationRouterFilterer) FilterInitialized(opts *bind.FilterOpts) (*ReservationRouterInitializedIterator, error) { + + logs, sub, err := _ReservationRouter.contract.FilterLogs(opts, "Initialized") + if err != nil { + return nil, err + } + return &ReservationRouterInitializedIterator{contract: _ReservationRouter.contract, event: "Initialized", logs: logs, sub: sub}, nil +} + +// WatchInitialized is a free log subscription operation binding the contract event 0x7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb3847402498. +// +// Solidity: event Initialized(uint8 version) +func (_ReservationRouter *ReservationRouterFilterer) WatchInitialized(opts *bind.WatchOpts, sink chan<- *ReservationRouterInitialized) (event.Subscription, error) { + + logs, sub, err := _ReservationRouter.contract.WatchLogs(opts, "Initialized") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ReservationRouterInitialized) + if err := _ReservationRouter.contract.UnpackLog(event, "Initialized", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseInitialized is a log parse operation binding the contract event 0x7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb3847402498. +// +// Solidity: event Initialized(uint8 version) +func (_ReservationRouter *ReservationRouterFilterer) ParseInitialized(log types.Log) (*ReservationRouterInitialized, error) { + event := new(ReservationRouterInitialized) + if err := _ReservationRouter.contract.UnpackLog(event, "Initialized", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ReservationRouterReservationAcceptanceRequestedIterator is returned from FilterReservationAcceptanceRequested and is used to iterate over the raw logs and unpacked data for ReservationAcceptanceRequested events raised by the ReservationRouter contract. +type ReservationRouterReservationAcceptanceRequestedIterator struct { + Event *ReservationRouterReservationAcceptanceRequested // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ReservationRouterReservationAcceptanceRequestedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationAcceptanceRequested) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationAcceptanceRequested) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ReservationRouterReservationAcceptanceRequestedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ReservationRouterReservationAcceptanceRequestedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ReservationRouterReservationAcceptanceRequested represents a ReservationAcceptanceRequested event raised by the ReservationRouter contract. +type ReservationRouterReservationAcceptanceRequested struct { + ReservationKey *big.Int + RequestNonce uint64 + WalletPubKeyHash [20]byte + DepositAmount uint64 + TxMaxFee uint64 + TimeoutAt uint32 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterReservationAcceptanceRequested is a free log retrieval operation binding the contract event 0x1444a0a2e553520e4766f36c68368e10105489e52dc701d7fc9859c651475059. +// +// Solidity: event ReservationAcceptanceRequested(uint256 indexed reservationKey, uint64 requestNonce, bytes20 indexed walletPubKeyHash, uint64 depositAmount, uint64 txMaxFee, uint32 timeoutAt) +func (_ReservationRouter *ReservationRouterFilterer) FilterReservationAcceptanceRequested(opts *bind.FilterOpts, reservationKey []*big.Int, walletPubKeyHash [][20]byte) (*ReservationRouterReservationAcceptanceRequestedIterator, error) { + + var reservationKeyRule []interface{} + for _, reservationKeyItem := range reservationKey { + reservationKeyRule = append(reservationKeyRule, reservationKeyItem) + } + + var walletPubKeyHashRule []interface{} + for _, walletPubKeyHashItem := range walletPubKeyHash { + walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) + } + + logs, sub, err := _ReservationRouter.contract.FilterLogs(opts, "ReservationAcceptanceRequested", reservationKeyRule, walletPubKeyHashRule) + if err != nil { + return nil, err + } + return &ReservationRouterReservationAcceptanceRequestedIterator{contract: _ReservationRouter.contract, event: "ReservationAcceptanceRequested", logs: logs, sub: sub}, nil +} + +// WatchReservationAcceptanceRequested is a free log subscription operation binding the contract event 0x1444a0a2e553520e4766f36c68368e10105489e52dc701d7fc9859c651475059. +// +// Solidity: event ReservationAcceptanceRequested(uint256 indexed reservationKey, uint64 requestNonce, bytes20 indexed walletPubKeyHash, uint64 depositAmount, uint64 txMaxFee, uint32 timeoutAt) +func (_ReservationRouter *ReservationRouterFilterer) WatchReservationAcceptanceRequested(opts *bind.WatchOpts, sink chan<- *ReservationRouterReservationAcceptanceRequested, reservationKey []*big.Int, walletPubKeyHash [][20]byte) (event.Subscription, error) { + + var reservationKeyRule []interface{} + for _, reservationKeyItem := range reservationKey { + reservationKeyRule = append(reservationKeyRule, reservationKeyItem) + } + + var walletPubKeyHashRule []interface{} + for _, walletPubKeyHashItem := range walletPubKeyHash { + walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) + } + + logs, sub, err := _ReservationRouter.contract.WatchLogs(opts, "ReservationAcceptanceRequested", reservationKeyRule, walletPubKeyHashRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ReservationRouterReservationAcceptanceRequested) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationAcceptanceRequested", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseReservationAcceptanceRequested is a log parse operation binding the contract event 0x1444a0a2e553520e4766f36c68368e10105489e52dc701d7fc9859c651475059. +// +// Solidity: event ReservationAcceptanceRequested(uint256 indexed reservationKey, uint64 requestNonce, bytes20 indexed walletPubKeyHash, uint64 depositAmount, uint64 txMaxFee, uint32 timeoutAt) +func (_ReservationRouter *ReservationRouterFilterer) ParseReservationAcceptanceRequested(log types.Log) (*ReservationRouterReservationAcceptanceRequested, error) { + event := new(ReservationRouterReservationAcceptanceRequested) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationAcceptanceRequested", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ReservationRouterReservationAcceptedIterator is returned from FilterReservationAccepted and is used to iterate over the raw logs and unpacked data for ReservationAccepted events raised by the ReservationRouter contract. +type ReservationRouterReservationAcceptedIterator struct { + Event *ReservationRouterReservationAccepted // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ReservationRouterReservationAcceptedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationAccepted) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationAccepted) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ReservationRouterReservationAcceptedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ReservationRouterReservationAcceptedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ReservationRouterReservationAccepted represents a ReservationAccepted event raised by the ReservationRouter contract. +type ReservationRouterReservationAccepted struct { + ReservationKey *big.Int + RequestNonce uint64 + WalletPubKeyHash [20]byte + Owner common.Address + AnchorTxHash [32]byte + AnchorAmount uint64 + ExpiresAt uint32 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterReservationAccepted is a free log retrieval operation binding the contract event 0xcdba3d32072456500fc4b138dd3c63bb0a72d568e71af8a51744bab51238b770. +// +// Solidity: event ReservationAccepted(uint256 indexed reservationKey, uint64 requestNonce, bytes20 indexed walletPubKeyHash, address indexed owner, bytes32 anchorTxHash, uint64 anchorAmount, uint32 expiresAt) +func (_ReservationRouter *ReservationRouterFilterer) FilterReservationAccepted(opts *bind.FilterOpts, reservationKey []*big.Int, walletPubKeyHash [][20]byte, owner []common.Address) (*ReservationRouterReservationAcceptedIterator, error) { + + var reservationKeyRule []interface{} + for _, reservationKeyItem := range reservationKey { + reservationKeyRule = append(reservationKeyRule, reservationKeyItem) + } + + var walletPubKeyHashRule []interface{} + for _, walletPubKeyHashItem := range walletPubKeyHash { + walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) + } + var ownerRule []interface{} + for _, ownerItem := range owner { + ownerRule = append(ownerRule, ownerItem) + } + + logs, sub, err := _ReservationRouter.contract.FilterLogs(opts, "ReservationAccepted", reservationKeyRule, walletPubKeyHashRule, ownerRule) + if err != nil { + return nil, err + } + return &ReservationRouterReservationAcceptedIterator{contract: _ReservationRouter.contract, event: "ReservationAccepted", logs: logs, sub: sub}, nil +} + +// WatchReservationAccepted is a free log subscription operation binding the contract event 0xcdba3d32072456500fc4b138dd3c63bb0a72d568e71af8a51744bab51238b770. +// +// Solidity: event ReservationAccepted(uint256 indexed reservationKey, uint64 requestNonce, bytes20 indexed walletPubKeyHash, address indexed owner, bytes32 anchorTxHash, uint64 anchorAmount, uint32 expiresAt) +func (_ReservationRouter *ReservationRouterFilterer) WatchReservationAccepted(opts *bind.WatchOpts, sink chan<- *ReservationRouterReservationAccepted, reservationKey []*big.Int, walletPubKeyHash [][20]byte, owner []common.Address) (event.Subscription, error) { + + var reservationKeyRule []interface{} + for _, reservationKeyItem := range reservationKey { + reservationKeyRule = append(reservationKeyRule, reservationKeyItem) + } + + var walletPubKeyHashRule []interface{} + for _, walletPubKeyHashItem := range walletPubKeyHash { + walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) + } + var ownerRule []interface{} + for _, ownerItem := range owner { + ownerRule = append(ownerRule, ownerItem) + } + + logs, sub, err := _ReservationRouter.contract.WatchLogs(opts, "ReservationAccepted", reservationKeyRule, walletPubKeyHashRule, ownerRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ReservationRouterReservationAccepted) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationAccepted", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseReservationAccepted is a log parse operation binding the contract event 0xcdba3d32072456500fc4b138dd3c63bb0a72d568e71af8a51744bab51238b770. +// +// Solidity: event ReservationAccepted(uint256 indexed reservationKey, uint64 requestNonce, bytes20 indexed walletPubKeyHash, address indexed owner, bytes32 anchorTxHash, uint64 anchorAmount, uint32 expiresAt) +func (_ReservationRouter *ReservationRouterFilterer) ParseReservationAccepted(log types.Log) (*ReservationRouterReservationAccepted, error) { + event := new(ReservationRouterReservationAccepted) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationAccepted", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ReservationRouterReservationActionSupersededIterator is returned from FilterReservationActionSuperseded and is used to iterate over the raw logs and unpacked data for ReservationActionSuperseded events raised by the ReservationRouter contract. +type ReservationRouterReservationActionSupersededIterator struct { + Event *ReservationRouterReservationActionSuperseded // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ReservationRouterReservationActionSupersededIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationActionSuperseded) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationActionSuperseded) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ReservationRouterReservationActionSupersededIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ReservationRouterReservationActionSupersededIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ReservationRouterReservationActionSuperseded represents a ReservationActionSuperseded event raised by the ReservationRouter contract. +type ReservationRouterReservationActionSuperseded struct { + ReservationKey *big.Int + RequestNonce uint64 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterReservationActionSuperseded is a free log retrieval operation binding the contract event 0x64979c37b08d25f639dac3b74caf99840af5995eba8a493b29dc8599312ea252. +// +// Solidity: event ReservationActionSuperseded(uint256 indexed reservationKey, uint64 requestNonce) +func (_ReservationRouter *ReservationRouterFilterer) FilterReservationActionSuperseded(opts *bind.FilterOpts, reservationKey []*big.Int) (*ReservationRouterReservationActionSupersededIterator, error) { + + var reservationKeyRule []interface{} + for _, reservationKeyItem := range reservationKey { + reservationKeyRule = append(reservationKeyRule, reservationKeyItem) + } + + logs, sub, err := _ReservationRouter.contract.FilterLogs(opts, "ReservationActionSuperseded", reservationKeyRule) + if err != nil { + return nil, err + } + return &ReservationRouterReservationActionSupersededIterator{contract: _ReservationRouter.contract, event: "ReservationActionSuperseded", logs: logs, sub: sub}, nil +} + +// WatchReservationActionSuperseded is a free log subscription operation binding the contract event 0x64979c37b08d25f639dac3b74caf99840af5995eba8a493b29dc8599312ea252. +// +// Solidity: event ReservationActionSuperseded(uint256 indexed reservationKey, uint64 requestNonce) +func (_ReservationRouter *ReservationRouterFilterer) WatchReservationActionSuperseded(opts *bind.WatchOpts, sink chan<- *ReservationRouterReservationActionSuperseded, reservationKey []*big.Int) (event.Subscription, error) { + + var reservationKeyRule []interface{} + for _, reservationKeyItem := range reservationKey { + reservationKeyRule = append(reservationKeyRule, reservationKeyItem) + } + + logs, sub, err := _ReservationRouter.contract.WatchLogs(opts, "ReservationActionSuperseded", reservationKeyRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ReservationRouterReservationActionSuperseded) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationActionSuperseded", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseReservationActionSuperseded is a log parse operation binding the contract event 0x64979c37b08d25f639dac3b74caf99840af5995eba8a493b29dc8599312ea252. +// +// Solidity: event ReservationActionSuperseded(uint256 indexed reservationKey, uint64 requestNonce) +func (_ReservationRouter *ReservationRouterFilterer) ParseReservationActionSuperseded(log types.Log) (*ReservationRouterReservationActionSuperseded, error) { + event := new(ReservationRouterReservationActionSuperseded) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationActionSuperseded", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ReservationRouterReservationActionTimedOutIterator is returned from FilterReservationActionTimedOut and is used to iterate over the raw logs and unpacked data for ReservationActionTimedOut events raised by the ReservationRouter contract. +type ReservationRouterReservationActionTimedOutIterator struct { + Event *ReservationRouterReservationActionTimedOut // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ReservationRouterReservationActionTimedOutIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationActionTimedOut) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationActionTimedOut) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ReservationRouterReservationActionTimedOutIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ReservationRouterReservationActionTimedOutIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ReservationRouterReservationActionTimedOut represents a ReservationActionTimedOut event raised by the ReservationRouter contract. +type ReservationRouterReservationActionTimedOut struct { + ReservationKey *big.Int + RequestNonce uint64 + ActionType uint8 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterReservationActionTimedOut is a free log retrieval operation binding the contract event 0xd3bb43b8c8b259f4da0efa2c7a34ce683c05d6e31864299fa4a867bb3ff218ba. +// +// Solidity: event ReservationActionTimedOut(uint256 indexed reservationKey, uint64 requestNonce, uint8 actionType) +func (_ReservationRouter *ReservationRouterFilterer) FilterReservationActionTimedOut(opts *bind.FilterOpts, reservationKey []*big.Int) (*ReservationRouterReservationActionTimedOutIterator, error) { + + var reservationKeyRule []interface{} + for _, reservationKeyItem := range reservationKey { + reservationKeyRule = append(reservationKeyRule, reservationKeyItem) + } + + logs, sub, err := _ReservationRouter.contract.FilterLogs(opts, "ReservationActionTimedOut", reservationKeyRule) + if err != nil { + return nil, err + } + return &ReservationRouterReservationActionTimedOutIterator{contract: _ReservationRouter.contract, event: "ReservationActionTimedOut", logs: logs, sub: sub}, nil +} + +// WatchReservationActionTimedOut is a free log subscription operation binding the contract event 0xd3bb43b8c8b259f4da0efa2c7a34ce683c05d6e31864299fa4a867bb3ff218ba. +// +// Solidity: event ReservationActionTimedOut(uint256 indexed reservationKey, uint64 requestNonce, uint8 actionType) +func (_ReservationRouter *ReservationRouterFilterer) WatchReservationActionTimedOut(opts *bind.WatchOpts, sink chan<- *ReservationRouterReservationActionTimedOut, reservationKey []*big.Int) (event.Subscription, error) { + + var reservationKeyRule []interface{} + for _, reservationKeyItem := range reservationKey { + reservationKeyRule = append(reservationKeyRule, reservationKeyItem) + } + + logs, sub, err := _ReservationRouter.contract.WatchLogs(opts, "ReservationActionTimedOut", reservationKeyRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ReservationRouterReservationActionTimedOut) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationActionTimedOut", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseReservationActionTimedOut is a log parse operation binding the contract event 0xd3bb43b8c8b259f4da0efa2c7a34ce683c05d6e31864299fa4a867bb3ff218ba. +// +// Solidity: event ReservationActionTimedOut(uint256 indexed reservationKey, uint64 requestNonce, uint8 actionType) +func (_ReservationRouter *ReservationRouterFilterer) ParseReservationActionTimedOut(log types.Log) (*ReservationRouterReservationActionTimedOut, error) { + event := new(ReservationRouterReservationActionTimedOut) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationActionTimedOut", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ReservationRouterReservationCapsUpdatedIterator is returned from FilterReservationCapsUpdated and is used to iterate over the raw logs and unpacked data for ReservationCapsUpdated events raised by the ReservationRouter contract. +type ReservationRouterReservationCapsUpdatedIterator struct { + Event *ReservationRouterReservationCapsUpdated // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ReservationRouterReservationCapsUpdatedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationCapsUpdated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationCapsUpdated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ReservationRouterReservationCapsUpdatedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ReservationRouterReservationCapsUpdatedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ReservationRouterReservationCapsUpdated represents a ReservationCapsUpdated event raised by the ReservationRouter contract. +type ReservationRouterReservationCapsUpdated struct { + MaxReservationsAmountPerWallet uint64 + ReservationMaxSingleAmount uint64 + MaxActiveReservations uint32 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterReservationCapsUpdated is a free log retrieval operation binding the contract event 0x846df5edb182147898ee0a522f03f78718544ce16b0f06e64fe2f593b1fb160d. +// +// Solidity: event ReservationCapsUpdated(uint64 maxReservationsAmountPerWallet, uint64 reservationMaxSingleAmount, uint32 maxActiveReservations) +func (_ReservationRouter *ReservationRouterFilterer) FilterReservationCapsUpdated(opts *bind.FilterOpts) (*ReservationRouterReservationCapsUpdatedIterator, error) { + + logs, sub, err := _ReservationRouter.contract.FilterLogs(opts, "ReservationCapsUpdated") + if err != nil { + return nil, err + } + return &ReservationRouterReservationCapsUpdatedIterator{contract: _ReservationRouter.contract, event: "ReservationCapsUpdated", logs: logs, sub: sub}, nil +} + +// WatchReservationCapsUpdated is a free log subscription operation binding the contract event 0x846df5edb182147898ee0a522f03f78718544ce16b0f06e64fe2f593b1fb160d. +// +// Solidity: event ReservationCapsUpdated(uint64 maxReservationsAmountPerWallet, uint64 reservationMaxSingleAmount, uint32 maxActiveReservations) +func (_ReservationRouter *ReservationRouterFilterer) WatchReservationCapsUpdated(opts *bind.WatchOpts, sink chan<- *ReservationRouterReservationCapsUpdated) (event.Subscription, error) { + + logs, sub, err := _ReservationRouter.contract.WatchLogs(opts, "ReservationCapsUpdated") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ReservationRouterReservationCapsUpdated) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationCapsUpdated", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseReservationCapsUpdated is a log parse operation binding the contract event 0x846df5edb182147898ee0a522f03f78718544ce16b0f06e64fe2f593b1fb160d. +// +// Solidity: event ReservationCapsUpdated(uint64 maxReservationsAmountPerWallet, uint64 reservationMaxSingleAmount, uint32 maxActiveReservations) +func (_ReservationRouter *ReservationRouterFilterer) ParseReservationCapsUpdated(log types.Log) (*ReservationRouterReservationCapsUpdated, error) { + event := new(ReservationRouterReservationCapsUpdated) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationCapsUpdated", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ReservationRouterReservationLateSettledIterator is returned from FilterReservationLateSettled and is used to iterate over the raw logs and unpacked data for ReservationLateSettled events raised by the ReservationRouter contract. +type ReservationRouterReservationLateSettledIterator struct { + Event *ReservationRouterReservationLateSettled // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ReservationRouterReservationLateSettledIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationLateSettled) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationLateSettled) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ReservationRouterReservationLateSettledIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ReservationRouterReservationLateSettledIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ReservationRouterReservationLateSettled represents a ReservationLateSettled event raised by the ReservationRouter contract. +type ReservationRouterReservationLateSettled struct { + ReservationKey *big.Int + RequestNonce uint64 + ActionType uint8 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterReservationLateSettled is a free log retrieval operation binding the contract event 0x152c5b7b78a634931e032d1ab3d0c033e2e5d00e0e75f20252767746a1fa4f6d. +// +// Solidity: event ReservationLateSettled(uint256 indexed reservationKey, uint64 requestNonce, uint8 actionType) +func (_ReservationRouter *ReservationRouterFilterer) FilterReservationLateSettled(opts *bind.FilterOpts, reservationKey []*big.Int) (*ReservationRouterReservationLateSettledIterator, error) { + + var reservationKeyRule []interface{} + for _, reservationKeyItem := range reservationKey { + reservationKeyRule = append(reservationKeyRule, reservationKeyItem) + } + + logs, sub, err := _ReservationRouter.contract.FilterLogs(opts, "ReservationLateSettled", reservationKeyRule) + if err != nil { + return nil, err + } + return &ReservationRouterReservationLateSettledIterator{contract: _ReservationRouter.contract, event: "ReservationLateSettled", logs: logs, sub: sub}, nil +} + +// WatchReservationLateSettled is a free log subscription operation binding the contract event 0x152c5b7b78a634931e032d1ab3d0c033e2e5d00e0e75f20252767746a1fa4f6d. +// +// Solidity: event ReservationLateSettled(uint256 indexed reservationKey, uint64 requestNonce, uint8 actionType) +func (_ReservationRouter *ReservationRouterFilterer) WatchReservationLateSettled(opts *bind.WatchOpts, sink chan<- *ReservationRouterReservationLateSettled, reservationKey []*big.Int) (event.Subscription, error) { + + var reservationKeyRule []interface{} + for _, reservationKeyItem := range reservationKey { + reservationKeyRule = append(reservationKeyRule, reservationKeyItem) + } + + logs, sub, err := _ReservationRouter.contract.WatchLogs(opts, "ReservationLateSettled", reservationKeyRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ReservationRouterReservationLateSettled) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationLateSettled", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseReservationLateSettled is a log parse operation binding the contract event 0x152c5b7b78a634931e032d1ab3d0c033e2e5d00e0e75f20252767746a1fa4f6d. +// +// Solidity: event ReservationLateSettled(uint256 indexed reservationKey, uint64 requestNonce, uint8 actionType) +func (_ReservationRouter *ReservationRouterFilterer) ParseReservationLateSettled(log types.Log) (*ReservationRouterReservationLateSettled, error) { + event := new(ReservationRouterReservationLateSettled) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationLateSettled", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ReservationRouterReservationParametersUpdatedIterator is returned from FilterReservationParametersUpdated and is used to iterate over the raw logs and unpacked data for ReservationParametersUpdated events raised by the ReservationRouter contract. +type ReservationRouterReservationParametersUpdatedIterator struct { + Event *ReservationRouterReservationParametersUpdated // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ReservationRouterReservationParametersUpdatedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationParametersUpdated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationParametersUpdated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ReservationRouterReservationParametersUpdatedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ReservationRouterReservationParametersUpdatedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ReservationRouterReservationParametersUpdated represents a ReservationParametersUpdated event raised by the ReservationRouter contract. +type ReservationRouterReservationParametersUpdated struct { + ReservationMinAmount uint64 + ReservationTxMaxFee uint64 + ReservationTermSeconds uint32 + ReservationDissolutionDelay uint32 + ReservationMaxTotalAmount uint64 + MaxReservationsPerWallet uint32 + ReservationActionTimeout uint32 + ReservationRenewalWindowSeconds uint32 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterReservationParametersUpdated is a free log retrieval operation binding the contract event 0x7e6c56281c83edd8db45ddcb09afd1cc2cc009bcc94213c657592cb15a5b2901. +// +// Solidity: event ReservationParametersUpdated(uint64 reservationMinAmount, uint64 reservationTxMaxFee, uint32 reservationTermSeconds, uint32 reservationDissolutionDelay, uint64 reservationMaxTotalAmount, uint32 maxReservationsPerWallet, uint32 reservationActionTimeout, uint32 reservationRenewalWindowSeconds) +func (_ReservationRouter *ReservationRouterFilterer) FilterReservationParametersUpdated(opts *bind.FilterOpts) (*ReservationRouterReservationParametersUpdatedIterator, error) { + + logs, sub, err := _ReservationRouter.contract.FilterLogs(opts, "ReservationParametersUpdated") + if err != nil { + return nil, err + } + return &ReservationRouterReservationParametersUpdatedIterator{contract: _ReservationRouter.contract, event: "ReservationParametersUpdated", logs: logs, sub: sub}, nil +} + +// WatchReservationParametersUpdated is a free log subscription operation binding the contract event 0x7e6c56281c83edd8db45ddcb09afd1cc2cc009bcc94213c657592cb15a5b2901. +// +// Solidity: event ReservationParametersUpdated(uint64 reservationMinAmount, uint64 reservationTxMaxFee, uint32 reservationTermSeconds, uint32 reservationDissolutionDelay, uint64 reservationMaxTotalAmount, uint32 maxReservationsPerWallet, uint32 reservationActionTimeout, uint32 reservationRenewalWindowSeconds) +func (_ReservationRouter *ReservationRouterFilterer) WatchReservationParametersUpdated(opts *bind.WatchOpts, sink chan<- *ReservationRouterReservationParametersUpdated) (event.Subscription, error) { + + logs, sub, err := _ReservationRouter.contract.WatchLogs(opts, "ReservationParametersUpdated") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ReservationRouterReservationParametersUpdated) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationParametersUpdated", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseReservationParametersUpdated is a log parse operation binding the contract event 0x7e6c56281c83edd8db45ddcb09afd1cc2cc009bcc94213c657592cb15a5b2901. +// +// Solidity: event ReservationParametersUpdated(uint64 reservationMinAmount, uint64 reservationTxMaxFee, uint32 reservationTermSeconds, uint32 reservationDissolutionDelay, uint64 reservationMaxTotalAmount, uint32 maxReservationsPerWallet, uint32 reservationActionTimeout, uint32 reservationRenewalWindowSeconds) +func (_ReservationRouter *ReservationRouterFilterer) ParseReservationParametersUpdated(log types.Log) (*ReservationRouterReservationParametersUpdated, error) { + event := new(ReservationRouterReservationParametersUpdated) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationParametersUpdated", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ReservationRouterReservationReanchorRequestedIterator is returned from FilterReservationReanchorRequested and is used to iterate over the raw logs and unpacked data for ReservationReanchorRequested events raised by the ReservationRouter contract. +type ReservationRouterReservationReanchorRequestedIterator struct { + Event *ReservationRouterReservationReanchorRequested // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ReservationRouterReservationReanchorRequestedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationReanchorRequested) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationReanchorRequested) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ReservationRouterReservationReanchorRequestedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ReservationRouterReservationReanchorRequestedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ReservationRouterReservationReanchorRequested represents a ReservationReanchorRequested event raised by the ReservationRouter contract. +type ReservationRouterReservationReanchorRequested struct { + ReservationKey *big.Int + RequestNonce uint64 + SourceWalletPubKeyHash [20]byte + TargetWalletPubKeyHash [20]byte + TxMaxFee uint64 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterReservationReanchorRequested is a free log retrieval operation binding the contract event 0x90323e7ede1e7009754d91387f23522528e6356b2667952ff305a500ffaa9c6d. +// +// Solidity: event ReservationReanchorRequested(uint256 indexed reservationKey, uint64 requestNonce, bytes20 indexed sourceWalletPubKeyHash, bytes20 indexed targetWalletPubKeyHash, uint64 txMaxFee) +func (_ReservationRouter *ReservationRouterFilterer) FilterReservationReanchorRequested(opts *bind.FilterOpts, reservationKey []*big.Int, sourceWalletPubKeyHash [][20]byte, targetWalletPubKeyHash [][20]byte) (*ReservationRouterReservationReanchorRequestedIterator, error) { + + var reservationKeyRule []interface{} + for _, reservationKeyItem := range reservationKey { + reservationKeyRule = append(reservationKeyRule, reservationKeyItem) + } + + var sourceWalletPubKeyHashRule []interface{} + for _, sourceWalletPubKeyHashItem := range sourceWalletPubKeyHash { + sourceWalletPubKeyHashRule = append(sourceWalletPubKeyHashRule, sourceWalletPubKeyHashItem) + } + var targetWalletPubKeyHashRule []interface{} + for _, targetWalletPubKeyHashItem := range targetWalletPubKeyHash { + targetWalletPubKeyHashRule = append(targetWalletPubKeyHashRule, targetWalletPubKeyHashItem) + } + + logs, sub, err := _ReservationRouter.contract.FilterLogs(opts, "ReservationReanchorRequested", reservationKeyRule, sourceWalletPubKeyHashRule, targetWalletPubKeyHashRule) + if err != nil { + return nil, err + } + return &ReservationRouterReservationReanchorRequestedIterator{contract: _ReservationRouter.contract, event: "ReservationReanchorRequested", logs: logs, sub: sub}, nil +} + +// WatchReservationReanchorRequested is a free log subscription operation binding the contract event 0x90323e7ede1e7009754d91387f23522528e6356b2667952ff305a500ffaa9c6d. +// +// Solidity: event ReservationReanchorRequested(uint256 indexed reservationKey, uint64 requestNonce, bytes20 indexed sourceWalletPubKeyHash, bytes20 indexed targetWalletPubKeyHash, uint64 txMaxFee) +func (_ReservationRouter *ReservationRouterFilterer) WatchReservationReanchorRequested(opts *bind.WatchOpts, sink chan<- *ReservationRouterReservationReanchorRequested, reservationKey []*big.Int, sourceWalletPubKeyHash [][20]byte, targetWalletPubKeyHash [][20]byte) (event.Subscription, error) { + + var reservationKeyRule []interface{} + for _, reservationKeyItem := range reservationKey { + reservationKeyRule = append(reservationKeyRule, reservationKeyItem) + } + + var sourceWalletPubKeyHashRule []interface{} + for _, sourceWalletPubKeyHashItem := range sourceWalletPubKeyHash { + sourceWalletPubKeyHashRule = append(sourceWalletPubKeyHashRule, sourceWalletPubKeyHashItem) + } + var targetWalletPubKeyHashRule []interface{} + for _, targetWalletPubKeyHashItem := range targetWalletPubKeyHash { + targetWalletPubKeyHashRule = append(targetWalletPubKeyHashRule, targetWalletPubKeyHashItem) + } + + logs, sub, err := _ReservationRouter.contract.WatchLogs(opts, "ReservationReanchorRequested", reservationKeyRule, sourceWalletPubKeyHashRule, targetWalletPubKeyHashRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ReservationRouterReservationReanchorRequested) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationReanchorRequested", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseReservationReanchorRequested is a log parse operation binding the contract event 0x90323e7ede1e7009754d91387f23522528e6356b2667952ff305a500ffaa9c6d. +// +// Solidity: event ReservationReanchorRequested(uint256 indexed reservationKey, uint64 requestNonce, bytes20 indexed sourceWalletPubKeyHash, bytes20 indexed targetWalletPubKeyHash, uint64 txMaxFee) +func (_ReservationRouter *ReservationRouterFilterer) ParseReservationReanchorRequested(log types.Log) (*ReservationRouterReservationReanchorRequested, error) { + event := new(ReservationRouterReservationReanchorRequested) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationReanchorRequested", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ReservationRouterReservationReanchoredIterator is returned from FilterReservationReanchored and is used to iterate over the raw logs and unpacked data for ReservationReanchored events raised by the ReservationRouter contract. +type ReservationRouterReservationReanchoredIterator struct { + Event *ReservationRouterReservationReanchored // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ReservationRouterReservationReanchoredIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationReanchored) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationReanchored) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ReservationRouterReservationReanchoredIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ReservationRouterReservationReanchoredIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ReservationRouterReservationReanchored represents a ReservationReanchored event raised by the ReservationRouter contract. +type ReservationRouterReservationReanchored struct { + ReservationKey *big.Int + RequestNonce uint64 + NewWalletPubKeyHash [20]byte + NewAnchorTxHash [32]byte + NewAnchorAmount uint64 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterReservationReanchored is a free log retrieval operation binding the contract event 0xe42922c665e9600f84def7070f9dfeeb5dd650fb3522fb6bb2326e676bd23319. +// +// Solidity: event ReservationReanchored(uint256 indexed reservationKey, uint64 requestNonce, bytes20 indexed newWalletPubKeyHash, bytes32 newAnchorTxHash, uint64 newAnchorAmount) +func (_ReservationRouter *ReservationRouterFilterer) FilterReservationReanchored(opts *bind.FilterOpts, reservationKey []*big.Int, newWalletPubKeyHash [][20]byte) (*ReservationRouterReservationReanchoredIterator, error) { + + var reservationKeyRule []interface{} + for _, reservationKeyItem := range reservationKey { + reservationKeyRule = append(reservationKeyRule, reservationKeyItem) + } + + var newWalletPubKeyHashRule []interface{} + for _, newWalletPubKeyHashItem := range newWalletPubKeyHash { + newWalletPubKeyHashRule = append(newWalletPubKeyHashRule, newWalletPubKeyHashItem) + } + + logs, sub, err := _ReservationRouter.contract.FilterLogs(opts, "ReservationReanchored", reservationKeyRule, newWalletPubKeyHashRule) + if err != nil { + return nil, err + } + return &ReservationRouterReservationReanchoredIterator{contract: _ReservationRouter.contract, event: "ReservationReanchored", logs: logs, sub: sub}, nil +} + +// WatchReservationReanchored is a free log subscription operation binding the contract event 0xe42922c665e9600f84def7070f9dfeeb5dd650fb3522fb6bb2326e676bd23319. +// +// Solidity: event ReservationReanchored(uint256 indexed reservationKey, uint64 requestNonce, bytes20 indexed newWalletPubKeyHash, bytes32 newAnchorTxHash, uint64 newAnchorAmount) +func (_ReservationRouter *ReservationRouterFilterer) WatchReservationReanchored(opts *bind.WatchOpts, sink chan<- *ReservationRouterReservationReanchored, reservationKey []*big.Int, newWalletPubKeyHash [][20]byte) (event.Subscription, error) { + + var reservationKeyRule []interface{} + for _, reservationKeyItem := range reservationKey { + reservationKeyRule = append(reservationKeyRule, reservationKeyItem) + } + + var newWalletPubKeyHashRule []interface{} + for _, newWalletPubKeyHashItem := range newWalletPubKeyHash { + newWalletPubKeyHashRule = append(newWalletPubKeyHashRule, newWalletPubKeyHashItem) + } + + logs, sub, err := _ReservationRouter.contract.WatchLogs(opts, "ReservationReanchored", reservationKeyRule, newWalletPubKeyHashRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ReservationRouterReservationReanchored) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationReanchored", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseReservationReanchored is a log parse operation binding the contract event 0xe42922c665e9600f84def7070f9dfeeb5dd650fb3522fb6bb2326e676bd23319. +// +// Solidity: event ReservationReanchored(uint256 indexed reservationKey, uint64 requestNonce, bytes20 indexed newWalletPubKeyHash, bytes32 newAnchorTxHash, uint64 newAnchorAmount) +func (_ReservationRouter *ReservationRouterFilterer) ParseReservationReanchored(log types.Log) (*ReservationRouterReservationReanchored, error) { + event := new(ReservationRouterReservationReanchored) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationReanchored", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ReservationRouterReservationRetryCreditMintedIterator is returned from FilterReservationRetryCreditMinted and is used to iterate over the raw logs and unpacked data for ReservationRetryCreditMinted events raised by the ReservationRouter contract. +type ReservationRouterReservationRetryCreditMintedIterator struct { + Event *ReservationRouterReservationRetryCreditMinted // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ReservationRouterReservationRetryCreditMintedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationRetryCreditMinted) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationRetryCreditMinted) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ReservationRouterReservationRetryCreditMintedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ReservationRouterReservationRetryCreditMintedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ReservationRouterReservationRetryCreditMinted represents a ReservationRetryCreditMinted event raised by the ReservationRouter contract. +type ReservationRouterReservationRetryCreditMinted struct { + ReservationKey *big.Int + Raw types.Log // Blockchain specific contextual infos +} + +// FilterReservationRetryCreditMinted is a free log retrieval operation binding the contract event 0x919795353bc408e11a0d5a133a9c6969367014ad975910a8b31d08b12597c4c2. +// +// Solidity: event ReservationRetryCreditMinted(uint256 indexed reservationKey) +func (_ReservationRouter *ReservationRouterFilterer) FilterReservationRetryCreditMinted(opts *bind.FilterOpts, reservationKey []*big.Int) (*ReservationRouterReservationRetryCreditMintedIterator, error) { + + var reservationKeyRule []interface{} + for _, reservationKeyItem := range reservationKey { + reservationKeyRule = append(reservationKeyRule, reservationKeyItem) + } + + logs, sub, err := _ReservationRouter.contract.FilterLogs(opts, "ReservationRetryCreditMinted", reservationKeyRule) + if err != nil { + return nil, err + } + return &ReservationRouterReservationRetryCreditMintedIterator{contract: _ReservationRouter.contract, event: "ReservationRetryCreditMinted", logs: logs, sub: sub}, nil +} + +// WatchReservationRetryCreditMinted is a free log subscription operation binding the contract event 0x919795353bc408e11a0d5a133a9c6969367014ad975910a8b31d08b12597c4c2. +// +// Solidity: event ReservationRetryCreditMinted(uint256 indexed reservationKey) +func (_ReservationRouter *ReservationRouterFilterer) WatchReservationRetryCreditMinted(opts *bind.WatchOpts, sink chan<- *ReservationRouterReservationRetryCreditMinted, reservationKey []*big.Int) (event.Subscription, error) { + + var reservationKeyRule []interface{} + for _, reservationKeyItem := range reservationKey { + reservationKeyRule = append(reservationKeyRule, reservationKeyItem) + } + + logs, sub, err := _ReservationRouter.contract.WatchLogs(opts, "ReservationRetryCreditMinted", reservationKeyRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ReservationRouterReservationRetryCreditMinted) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationRetryCreditMinted", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseReservationRetryCreditMinted is a log parse operation binding the contract event 0x919795353bc408e11a0d5a133a9c6969367014ad975910a8b31d08b12597c4c2. +// +// Solidity: event ReservationRetryCreditMinted(uint256 indexed reservationKey) +func (_ReservationRouter *ReservationRouterFilterer) ParseReservationRetryCreditMinted(log types.Log) (*ReservationRouterReservationRetryCreditMinted, error) { + event := new(ReservationRouterReservationRetryCreditMinted) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationRetryCreditMinted", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ReservationRouterReservationRouterSetIterator is returned from FilterReservationRouterSet and is used to iterate over the raw logs and unpacked data for ReservationRouterSet events raised by the ReservationRouter contract. +type ReservationRouterReservationRouterSetIterator struct { + Event *ReservationRouterReservationRouterSet // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ReservationRouterReservationRouterSetIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationRouterSet) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationRouterSet) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ReservationRouterReservationRouterSetIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ReservationRouterReservationRouterSetIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ReservationRouterReservationRouterSet represents a ReservationRouterSet event raised by the ReservationRouter contract. +type ReservationRouterReservationRouterSet struct { + ReservationRouter common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterReservationRouterSet is a free log retrieval operation binding the contract event 0xd9eacf62803dd1f1bb5342d8eb5951546c371915e06223f589c0c95486c7c769. +// +// Solidity: event ReservationRouterSet(address reservationRouter) +func (_ReservationRouter *ReservationRouterFilterer) FilterReservationRouterSet(opts *bind.FilterOpts) (*ReservationRouterReservationRouterSetIterator, error) { + + logs, sub, err := _ReservationRouter.contract.FilterLogs(opts, "ReservationRouterSet") + if err != nil { + return nil, err + } + return &ReservationRouterReservationRouterSetIterator{contract: _ReservationRouter.contract, event: "ReservationRouterSet", logs: logs, sub: sub}, nil +} + +// WatchReservationRouterSet is a free log subscription operation binding the contract event 0xd9eacf62803dd1f1bb5342d8eb5951546c371915e06223f589c0c95486c7c769. +// +// Solidity: event ReservationRouterSet(address reservationRouter) +func (_ReservationRouter *ReservationRouterFilterer) WatchReservationRouterSet(opts *bind.WatchOpts, sink chan<- *ReservationRouterReservationRouterSet) (event.Subscription, error) { + + logs, sub, err := _ReservationRouter.contract.WatchLogs(opts, "ReservationRouterSet") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ReservationRouterReservationRouterSet) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationRouterSet", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseReservationRouterSet is a log parse operation binding the contract event 0xd9eacf62803dd1f1bb5342d8eb5951546c371915e06223f589c0c95486c7c769. +// +// Solidity: event ReservationRouterSet(address reservationRouter) +func (_ReservationRouter *ReservationRouterFilterer) ParseReservationRouterSet(log types.Log) (*ReservationRouterReservationRouterSet, error) { + event := new(ReservationRouterReservationRouterSet) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationRouterSet", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ReservationRouterReservationStrandedIterator is returned from FilterReservationStranded and is used to iterate over the raw logs and unpacked data for ReservationStranded events raised by the ReservationRouter contract. +type ReservationRouterReservationStrandedIterator struct { + Event *ReservationRouterReservationStranded // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ReservationRouterReservationStrandedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationStranded) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationStranded) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ReservationRouterReservationStrandedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ReservationRouterReservationStrandedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ReservationRouterReservationStranded represents a ReservationStranded event raised by the ReservationRouter contract. +type ReservationRouterReservationStranded struct { + ReservationKey *big.Int + WalletPubKeyHash [20]byte + Owner common.Address + AnchorAmount uint64 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterReservationStranded is a free log retrieval operation binding the contract event 0x95a304fee209cd8169392534093ab2cb9ee3b8c7031cf873b2f0c40a03b44d4d. +// +// Solidity: event ReservationStranded(uint256 indexed reservationKey, bytes20 indexed walletPubKeyHash, address indexed owner, uint64 anchorAmount) +func (_ReservationRouter *ReservationRouterFilterer) FilterReservationStranded(opts *bind.FilterOpts, reservationKey []*big.Int, walletPubKeyHash [][20]byte, owner []common.Address) (*ReservationRouterReservationStrandedIterator, error) { + + var reservationKeyRule []interface{} + for _, reservationKeyItem := range reservationKey { + reservationKeyRule = append(reservationKeyRule, reservationKeyItem) + } + var walletPubKeyHashRule []interface{} + for _, walletPubKeyHashItem := range walletPubKeyHash { + walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) + } + var ownerRule []interface{} + for _, ownerItem := range owner { + ownerRule = append(ownerRule, ownerItem) + } + + logs, sub, err := _ReservationRouter.contract.FilterLogs(opts, "ReservationStranded", reservationKeyRule, walletPubKeyHashRule, ownerRule) + if err != nil { + return nil, err + } + return &ReservationRouterReservationStrandedIterator{contract: _ReservationRouter.contract, event: "ReservationStranded", logs: logs, sub: sub}, nil +} + +// WatchReservationStranded is a free log subscription operation binding the contract event 0x95a304fee209cd8169392534093ab2cb9ee3b8c7031cf873b2f0c40a03b44d4d. +// +// Solidity: event ReservationStranded(uint256 indexed reservationKey, bytes20 indexed walletPubKeyHash, address indexed owner, uint64 anchorAmount) +func (_ReservationRouter *ReservationRouterFilterer) WatchReservationStranded(opts *bind.WatchOpts, sink chan<- *ReservationRouterReservationStranded, reservationKey []*big.Int, walletPubKeyHash [][20]byte, owner []common.Address) (event.Subscription, error) { + + var reservationKeyRule []interface{} + for _, reservationKeyItem := range reservationKey { + reservationKeyRule = append(reservationKeyRule, reservationKeyItem) + } + var walletPubKeyHashRule []interface{} + for _, walletPubKeyHashItem := range walletPubKeyHash { + walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) + } + var ownerRule []interface{} + for _, ownerItem := range owner { + ownerRule = append(ownerRule, ownerItem) + } + + logs, sub, err := _ReservationRouter.contract.WatchLogs(opts, "ReservationStranded", reservationKeyRule, walletPubKeyHashRule, ownerRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ReservationRouterReservationStranded) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationStranded", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseReservationStranded is a log parse operation binding the contract event 0x95a304fee209cd8169392534093ab2cb9ee3b8c7031cf873b2f0c40a03b44d4d. +// +// Solidity: event ReservationStranded(uint256 indexed reservationKey, bytes20 indexed walletPubKeyHash, address indexed owner, uint64 anchorAmount) +func (_ReservationRouter *ReservationRouterFilterer) ParseReservationStranded(log types.Log) (*ReservationRouterReservationStranded, error) { + event := new(ReservationRouterReservationStranded) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationStranded", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ReservationRouterReservationVaultUpdatedIterator is returned from FilterReservationVaultUpdated and is used to iterate over the raw logs and unpacked data for ReservationVaultUpdated events raised by the ReservationRouter contract. +type ReservationRouterReservationVaultUpdatedIterator struct { + Event *ReservationRouterReservationVaultUpdated // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ReservationRouterReservationVaultUpdatedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationVaultUpdated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationVaultUpdated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ReservationRouterReservationVaultUpdatedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ReservationRouterReservationVaultUpdatedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ReservationRouterReservationVaultUpdated represents a ReservationVaultUpdated event raised by the ReservationRouter contract. +type ReservationRouterReservationVaultUpdated struct { + ReservationVault common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterReservationVaultUpdated is a free log retrieval operation binding the contract event 0x81b37784221191020714846b5fdcdfcbde796cfad2627d47dc81c2b7765b1910. +// +// Solidity: event ReservationVaultUpdated(address reservationVault) +func (_ReservationRouter *ReservationRouterFilterer) FilterReservationVaultUpdated(opts *bind.FilterOpts) (*ReservationRouterReservationVaultUpdatedIterator, error) { + + logs, sub, err := _ReservationRouter.contract.FilterLogs(opts, "ReservationVaultUpdated") + if err != nil { + return nil, err + } + return &ReservationRouterReservationVaultUpdatedIterator{contract: _ReservationRouter.contract, event: "ReservationVaultUpdated", logs: logs, sub: sub}, nil +} + +// WatchReservationVaultUpdated is a free log subscription operation binding the contract event 0x81b37784221191020714846b5fdcdfcbde796cfad2627d47dc81c2b7765b1910. +// +// Solidity: event ReservationVaultUpdated(address reservationVault) +func (_ReservationRouter *ReservationRouterFilterer) WatchReservationVaultUpdated(opts *bind.WatchOpts, sink chan<- *ReservationRouterReservationVaultUpdated) (event.Subscription, error) { + + logs, sub, err := _ReservationRouter.contract.WatchLogs(opts, "ReservationVaultUpdated") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ReservationRouterReservationVaultUpdated) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationVaultUpdated", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseReservationVaultUpdated is a log parse operation binding the contract event 0x81b37784221191020714846b5fdcdfcbde796cfad2627d47dc81c2b7765b1910. +// +// Solidity: event ReservationVaultUpdated(address reservationVault) +func (_ReservationRouter *ReservationRouterFilterer) ParseReservationVaultUpdated(log types.Log) (*ReservationRouterReservationVaultUpdated, error) { + event := new(ReservationRouterReservationVaultUpdated) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationVaultUpdated", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ReservationRouterReservedDepositMarkedStaleIterator is returned from FilterReservedDepositMarkedStale and is used to iterate over the raw logs and unpacked data for ReservedDepositMarkedStale events raised by the ReservationRouter contract. +type ReservationRouterReservedDepositMarkedStaleIterator struct { + Event *ReservationRouterReservedDepositMarkedStale // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ReservationRouterReservedDepositMarkedStaleIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservedDepositMarkedStale) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservedDepositMarkedStale) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ReservationRouterReservedDepositMarkedStaleIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ReservationRouterReservedDepositMarkedStaleIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ReservationRouterReservedDepositMarkedStale represents a ReservedDepositMarkedStale event raised by the ReservationRouter contract. +type ReservationRouterReservedDepositMarkedStale struct { + DepositKey *big.Int + Raw types.Log // Blockchain specific contextual infos +} + +// FilterReservedDepositMarkedStale is a free log retrieval operation binding the contract event 0xf4a10f7395c3a5e8165714c665ddb7f2e320f8bc2a21d9f8722f48f8d1d71eab. +// +// Solidity: event ReservedDepositMarkedStale(uint256 indexed depositKey) +func (_ReservationRouter *ReservationRouterFilterer) FilterReservedDepositMarkedStale(opts *bind.FilterOpts, depositKey []*big.Int) (*ReservationRouterReservedDepositMarkedStaleIterator, error) { + + var depositKeyRule []interface{} + for _, depositKeyItem := range depositKey { + depositKeyRule = append(depositKeyRule, depositKeyItem) + } + + logs, sub, err := _ReservationRouter.contract.FilterLogs(opts, "ReservedDepositMarkedStale", depositKeyRule) + if err != nil { + return nil, err + } + return &ReservationRouterReservedDepositMarkedStaleIterator{contract: _ReservationRouter.contract, event: "ReservedDepositMarkedStale", logs: logs, sub: sub}, nil +} + +// WatchReservedDepositMarkedStale is a free log subscription operation binding the contract event 0xf4a10f7395c3a5e8165714c665ddb7f2e320f8bc2a21d9f8722f48f8d1d71eab. +// +// Solidity: event ReservedDepositMarkedStale(uint256 indexed depositKey) +func (_ReservationRouter *ReservationRouterFilterer) WatchReservedDepositMarkedStale(opts *bind.WatchOpts, sink chan<- *ReservationRouterReservedDepositMarkedStale, depositKey []*big.Int) (event.Subscription, error) { + + var depositKeyRule []interface{} + for _, depositKeyItem := range depositKey { + depositKeyRule = append(depositKeyRule, depositKeyItem) + } + + logs, sub, err := _ReservationRouter.contract.WatchLogs(opts, "ReservedDepositMarkedStale", depositKeyRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ReservationRouterReservedDepositMarkedStale) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservedDepositMarkedStale", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseReservedDepositMarkedStale is a log parse operation binding the contract event 0xf4a10f7395c3a5e8165714c665ddb7f2e320f8bc2a21d9f8722f48f8d1d71eab. +// +// Solidity: event ReservedDepositMarkedStale(uint256 indexed depositKey) +func (_ReservationRouter *ReservationRouterFilterer) ParseReservedDepositMarkedStale(log types.Log) (*ReservationRouterReservedDepositMarkedStale, error) { + event := new(ReservationRouterReservedDepositMarkedStale) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservedDepositMarkedStale", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} diff --git a/pkg/chain/ethereum/tbtc/gen/abi/WalletProposalValidator.go b/pkg/chain/ethereum/tbtc/gen/abi/WalletProposalValidator.go index ed86d98785..c3197e49c0 100644 --- a/pkg/chain/ethereum/tbtc/gen/abi/WalletProposalValidator.go +++ b/pkg/chain/ethereum/tbtc/gen/abi/WalletProposalValidator.go @@ -95,9 +95,24 @@ type WalletProposalValidatorRedemptionProposal struct { RedemptionTxFee *big.Int } +// WalletProposalValidatorReservationAnchorProposal is an auto generated low-level Go binding around an user-defined struct. +type WalletProposalValidatorReservationAnchorProposal struct { + WalletPubKeyHash [20]byte + DepositKey WalletProposalValidatorDepositKey + AnchorTxFee *big.Int +} + +// WalletProposalValidatorReservationReanchorProposal is an auto generated low-level Go binding around an user-defined struct. +type WalletProposalValidatorReservationReanchorProposal struct { + SourceWalletPubKeyHash [20]byte + ReservationKey *big.Int + TargetWalletPubKeyHash [20]byte + ReanchorTxFee *big.Int +} + // WalletProposalValidatorMetaData contains all meta data concerning the WalletProposalValidator contract. var WalletProposalValidatorMetaData = &bind.MetaData{ - ABI: "[{\"inputs\":[{\"internalType\":\"contractBridge\",\"name\":\"_bridge\",\"type\":\"address\"}],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"inputs\":[],\"name\":\"DEPOSIT_MIN_AGE\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"DEPOSIT_REFUND_SAFETY_MARGIN\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"DEPOSIT_SWEEP_MAX_SIZE\",\"outputs\":[{\"internalType\":\"uint16\",\"name\":\"\",\"type\":\"uint16\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"REDEMPTION_MAX_SIZE\",\"outputs\":[{\"internalType\":\"uint16\",\"name\":\"\",\"type\":\"uint16\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"REDEMPTION_REQUEST_MIN_AGE\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"REDEMPTION_REQUEST_TIMEOUT_SAFETY_MARGIN\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"bridge\",\"outputs\":[{\"internalType\":\"contractBridge\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"fundingTxHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"fundingOutputIndex\",\"type\":\"uint32\"}],\"internalType\":\"structWalletProposalValidator.DepositKey[]\",\"name\":\"depositsKeys\",\"type\":\"tuple[]\"},{\"internalType\":\"uint256\",\"name\":\"sweepTxFee\",\"type\":\"uint256\"},{\"internalType\":\"uint256[]\",\"name\":\"depositsRevealBlocks\",\"type\":\"uint256[]\"}],\"internalType\":\"structWalletProposalValidator.DepositSweepProposal\",\"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\":\"structBitcoinTx.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\":\"structWalletProposalValidator.DepositExtraInfo[]\",\"name\":\"depositsExtraInfo\",\"type\":\"tuple[]\"}],\"name\":\"validateDepositSweepProposal\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes\",\"name\":\"message\",\"type\":\"bytes\"}],\"internalType\":\"structWalletProposalValidator.HeartbeatProposal\",\"name\":\"proposal\",\"type\":\"tuple\"}],\"name\":\"validateHeartbeatProposal\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes32\",\"name\":\"movingFundsTxHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTxOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint256\",\"name\":\"movedFundsSweepTxFee\",\"type\":\"uint256\"}],\"internalType\":\"structWalletProposalValidator.MovedFundsSweepProposal\",\"name\":\"proposal\",\"type\":\"tuple\"}],\"name\":\"validateMovedFundsSweepProposal\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes20[]\",\"name\":\"targetWallets\",\"type\":\"bytes20[]\"},{\"internalType\":\"uint256\",\"name\":\"movingFundsTxFee\",\"type\":\"uint256\"}],\"internalType\":\"structWalletProposalValidator.MovingFundsProposal\",\"name\":\"proposal\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"walletMainUtxo\",\"type\":\"tuple\"}],\"name\":\"validateMovingFundsProposal\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes[]\",\"name\":\"redeemersOutputScripts\",\"type\":\"bytes[]\"},{\"internalType\":\"uint256\",\"name\":\"redemptionTxFee\",\"type\":\"uint256\"}],\"internalType\":\"structWalletProposalValidator.RedemptionProposal\",\"name\":\"proposal\",\"type\":\"tuple\"}],\"name\":\"validateRedemptionProposal\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"}]", + ABI: "[{\"inputs\":[{\"internalType\":\"contractBridge\",\"name\":\"_bridge\",\"type\":\"address\"}],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"inputs\":[],\"name\":\"DEPOSIT_MIN_AGE\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"DEPOSIT_REFUND_SAFETY_MARGIN\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"DEPOSIT_SWEEP_MAX_SIZE\",\"outputs\":[{\"internalType\":\"uint16\",\"name\":\"\",\"type\":\"uint16\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"REDEMPTION_MAX_SIZE\",\"outputs\":[{\"internalType\":\"uint16\",\"name\":\"\",\"type\":\"uint16\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"REDEMPTION_REQUEST_MIN_AGE\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"REDEMPTION_REQUEST_TIMEOUT_SAFETY_MARGIN\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"bridge\",\"outputs\":[{\"internalType\":\"contractBridge\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"fundingTxHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"fundingOutputIndex\",\"type\":\"uint32\"}],\"internalType\":\"structWalletProposalValidator.DepositKey[]\",\"name\":\"depositsKeys\",\"type\":\"tuple[]\"},{\"internalType\":\"uint256\",\"name\":\"sweepTxFee\",\"type\":\"uint256\"},{\"internalType\":\"uint256[]\",\"name\":\"depositsRevealBlocks\",\"type\":\"uint256[]\"}],\"internalType\":\"structWalletProposalValidator.DepositSweepProposal\",\"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\":\"structBitcoinTx.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\":\"structWalletProposalValidator.DepositExtraInfo[]\",\"name\":\"depositsExtraInfo\",\"type\":\"tuple[]\"}],\"name\":\"validateDepositSweepProposal\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes\",\"name\":\"message\",\"type\":\"bytes\"}],\"internalType\":\"structWalletProposalValidator.HeartbeatProposal\",\"name\":\"proposal\",\"type\":\"tuple\"}],\"name\":\"validateHeartbeatProposal\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes32\",\"name\":\"movingFundsTxHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTxOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint256\",\"name\":\"movedFundsSweepTxFee\",\"type\":\"uint256\"}],\"internalType\":\"structWalletProposalValidator.MovedFundsSweepProposal\",\"name\":\"proposal\",\"type\":\"tuple\"}],\"name\":\"validateMovedFundsSweepProposal\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes20[]\",\"name\":\"targetWallets\",\"type\":\"bytes20[]\"},{\"internalType\":\"uint256\",\"name\":\"movingFundsTxFee\",\"type\":\"uint256\"}],\"internalType\":\"structWalletProposalValidator.MovingFundsProposal\",\"name\":\"proposal\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"walletMainUtxo\",\"type\":\"tuple\"}],\"name\":\"validateMovingFundsProposal\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes[]\",\"name\":\"redeemersOutputScripts\",\"type\":\"bytes[]\"},{\"internalType\":\"uint256\",\"name\":\"redemptionTxFee\",\"type\":\"uint256\"}],\"internalType\":\"structWalletProposalValidator.RedemptionProposal\",\"name\":\"proposal\",\"type\":\"tuple\"}],\"name\":\"validateRedemptionProposal\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"fundingTxHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"fundingOutputIndex\",\"type\":\"uint32\"}],\"internalType\":\"structWalletProposalValidator.DepositKey\",\"name\":\"depositKey\",\"type\":\"tuple\"},{\"internalType\":\"uint256\",\"name\":\"anchorTxFee\",\"type\":\"uint256\"}],\"internalType\":\"structWalletProposalValidator.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\":\"structBitcoinTx.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\":\"structWalletProposalValidator.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\":\"structWalletProposalValidator.ReservationReanchorProposal\",\"name\":\"proposal\",\"type\":\"tuple\"}],\"name\":\"validateReservationReanchorProposal\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"}]", } // WalletProposalValidatorABI is the input ABI used to generate the binding from. @@ -617,3 +632,65 @@ func (_WalletProposalValidator *WalletProposalValidatorSession) ValidateRedempti func (_WalletProposalValidator *WalletProposalValidatorCallerSession) ValidateRedemptionProposal(proposal WalletProposalValidatorRedemptionProposal) (bool, error) { return _WalletProposalValidator.Contract.ValidateRedemptionProposal(&_WalletProposalValidator.CallOpts, proposal) } + +// ValidateReservationAnchorProposal is a free data retrieval call binding the contract method 0xddded0a4. +// +// Solidity: function validateReservationAnchorProposal((bytes20,(bytes32,uint32),uint256) proposal, ((bytes4,bytes,bytes,bytes4),bytes8,bytes20,bytes20,bytes4) depositExtraInfo) view returns(bool) +func (_WalletProposalValidator *WalletProposalValidatorCaller) ValidateReservationAnchorProposal(opts *bind.CallOpts, proposal WalletProposalValidatorReservationAnchorProposal, depositExtraInfo WalletProposalValidatorDepositExtraInfo) (bool, error) { + var out []interface{} + err := _WalletProposalValidator.contract.Call(opts, &out, "validateReservationAnchorProposal", proposal, depositExtraInfo) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// ValidateReservationAnchorProposal is a free data retrieval call binding the contract method 0xddded0a4. +// +// Solidity: function validateReservationAnchorProposal((bytes20,(bytes32,uint32),uint256) proposal, ((bytes4,bytes,bytes,bytes4),bytes8,bytes20,bytes20,bytes4) depositExtraInfo) view returns(bool) +func (_WalletProposalValidator *WalletProposalValidatorSession) ValidateReservationAnchorProposal(proposal WalletProposalValidatorReservationAnchorProposal, depositExtraInfo WalletProposalValidatorDepositExtraInfo) (bool, error) { + return _WalletProposalValidator.Contract.ValidateReservationAnchorProposal(&_WalletProposalValidator.CallOpts, proposal, depositExtraInfo) +} + +// ValidateReservationAnchorProposal is a free data retrieval call binding the contract method 0xddded0a4. +// +// Solidity: function validateReservationAnchorProposal((bytes20,(bytes32,uint32),uint256) proposal, ((bytes4,bytes,bytes,bytes4),bytes8,bytes20,bytes20,bytes4) depositExtraInfo) view returns(bool) +func (_WalletProposalValidator *WalletProposalValidatorCallerSession) ValidateReservationAnchorProposal(proposal WalletProposalValidatorReservationAnchorProposal, depositExtraInfo WalletProposalValidatorDepositExtraInfo) (bool, error) { + return _WalletProposalValidator.Contract.ValidateReservationAnchorProposal(&_WalletProposalValidator.CallOpts, proposal, depositExtraInfo) +} + +// ValidateReservationReanchorProposal is a free data retrieval call binding the contract method 0x97dd538f. +// +// Solidity: function validateReservationReanchorProposal((bytes20,uint256,bytes20,uint256) proposal) view returns(bool) +func (_WalletProposalValidator *WalletProposalValidatorCaller) ValidateReservationReanchorProposal(opts *bind.CallOpts, proposal WalletProposalValidatorReservationReanchorProposal) (bool, error) { + var out []interface{} + err := _WalletProposalValidator.contract.Call(opts, &out, "validateReservationReanchorProposal", proposal) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// ValidateReservationReanchorProposal is a free data retrieval call binding the contract method 0x97dd538f. +// +// Solidity: function validateReservationReanchorProposal((bytes20,uint256,bytes20,uint256) proposal) view returns(bool) +func (_WalletProposalValidator *WalletProposalValidatorSession) ValidateReservationReanchorProposal(proposal WalletProposalValidatorReservationReanchorProposal) (bool, error) { + return _WalletProposalValidator.Contract.ValidateReservationReanchorProposal(&_WalletProposalValidator.CallOpts, proposal) +} + +// ValidateReservationReanchorProposal is a free data retrieval call binding the contract method 0x97dd538f. +// +// Solidity: function validateReservationReanchorProposal((bytes20,uint256,bytes20,uint256) proposal) view returns(bool) +func (_WalletProposalValidator *WalletProposalValidatorCallerSession) ValidateReservationReanchorProposal(proposal WalletProposalValidatorReservationReanchorProposal) (bool, error) { + return _WalletProposalValidator.Contract.ValidateReservationReanchorProposal(&_WalletProposalValidator.CallOpts, proposal) +} diff --git a/pkg/chain/ethereum/tbtc/gen/cmd/Bridge.go b/pkg/chain/ethereum/tbtc/gen/cmd/Bridge.go index f7a5944669..fe214dfed0 100644 --- a/pkg/chain/ethereum/tbtc/gen/cmd/Bridge.go +++ b/pkg/chain/ethereum/tbtc/gen/cmd/Bridge.go @@ -58,8 +58,11 @@ func init() { bDepositsCommand(), bFraudChallengesCommand(), bFraudParametersCommand(), + bGetRebateStakingCommand(), bGetRedemptionWatchtowerCommand(), + bGetReservationRouterCommand(), bGovernanceCommand(), + bIsReservedDepositCommand(), bIsVaultTrustedCommand(), bLiveWalletsCountCommand(), bMovedFundsSweepRequestsCommand(), @@ -77,6 +80,8 @@ func init() { bEcdsaWalletCreatedCallbackCommand(), bEcdsaWalletHeartbeatFailedCallbackCommand(), bInitializeCommand(), + bInitializeV2FixVaultZeroDepositCommand(), + bInitializeV5RepairRebateStakingCommand(), bNotifyMovingFundsBelowDustCommand(), bNotifyRedemptionVetoCommand(), bNotifyWalletCloseableCommand(), @@ -87,7 +92,9 @@ func init() { bResetMovingFundsTimeoutCommand(), bRevealDepositCommand(), bRevealDepositWithExtraDataCommand(), + bSetRebateStakingCommand(), bSetRedemptionWatchtowerCommand(), + bSetReservationRouterCommand(), bSetSpvMaintainerStatusCommand(), bSetVaultStatusCommand(), bSubmitDepositSweepProofCommand(), @@ -331,6 +338,40 @@ func bFraudParameters(c *cobra.Command, args []string) error { return nil } +func bGetRebateStakingCommand() *cobra.Command { + c := &cobra.Command{ + Use: "get-rebate-staking", + Short: "Calls the view method getRebateStaking on the Bridge contract.", + Args: cmd.ArgCountChecker(0), + RunE: bGetRebateStaking, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func bGetRebateStaking(c *cobra.Command, args []string) error { + contract, err := initializeBridge(c) + if err != nil { + return err + } + + result, err := contract.GetRebateStakingAtBlock( + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + func bGetRedemptionWatchtowerCommand() *cobra.Command { c := &cobra.Command{ Use: "get-redemption-watchtower", @@ -365,6 +406,40 @@ func bGetRedemptionWatchtower(c *cobra.Command, args []string) error { return nil } +func bGetReservationRouterCommand() *cobra.Command { + c := &cobra.Command{ + Use: "get-reservation-router", + Short: "Calls the view method getReservationRouter on the Bridge contract.", + Args: cmd.ArgCountChecker(0), + RunE: bGetReservationRouter, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func bGetReservationRouter(c *cobra.Command, args []string) error { + contract, err := initializeBridge(c) + if err != nil { + return err + } + + result, err := contract.GetReservationRouterAtBlock( + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + func bGovernanceCommand() *cobra.Command { c := &cobra.Command{ Use: "governance", @@ -399,6 +474,49 @@ func bGovernance(c *cobra.Command, args []string) error { return nil } +func bIsReservedDepositCommand() *cobra.Command { + c := &cobra.Command{ + Use: "is-reserved-deposit [arg_depositKey]", + Short: "Calls the view method isReservedDeposit on the Bridge contract.", + Args: cmd.ArgCountChecker(1), + RunE: bIsReservedDeposit, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func bIsReservedDeposit(c *cobra.Command, args []string) error { + contract, err := initializeBridge(c) + if err != nil { + return err + } + + arg_depositKey, err := hexutil.DecodeBig(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_depositKey, a uint256, from passed value %v", + args[0], + ) + } + + result, err := contract.IsReservedDepositAtBlock( + arg_depositKey, + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + func bIsVaultTrustedCommand() *cobra.Command { c := &cobra.Command{ Use: "is-vault-trusted [arg_vault]", @@ -1296,6 +1414,125 @@ func bInitialize(c *cobra.Command, args []string) error { return nil } +func bInitializeV2FixVaultZeroDepositCommand() *cobra.Command { + c := &cobra.Command{ + Use: "initialize-v2-fix-vault-zero-deposit", + Short: "Calls the nonpayable method initializeV2FixVaultZeroDeposit on the Bridge contract.", + Args: cmd.ArgCountChecker(0), + RunE: bInitializeV2FixVaultZeroDeposit, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + c.PreRunE = cmd.NonConstArgsChecker + cmd.InitNonConstFlags(c) + + return c +} + +func bInitializeV2FixVaultZeroDeposit(c *cobra.Command, args []string) error { + contract, err := initializeBridge(c) + if err != nil { + return err + } + + var ( + transaction *types.Transaction + ) + + if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { + // Do a regular submission. Take payable into account. + transaction, err = contract.InitializeV2FixVaultZeroDeposit() + if err != nil { + return err + } + + cmd.PrintOutput(transaction.Hash()) + } else { + // Do a call. + err = contract.CallInitializeV2FixVaultZeroDeposit( + cmd.BlockFlagValue.Int, + ) + if err != nil { + return err + } + + cmd.PrintOutput("success") + + cmd.PrintOutput( + "the transaction was not submitted to the chain; " + + "please add the `--submit` flag", + ) + } + + return nil +} + +func bInitializeV5RepairRebateStakingCommand() *cobra.Command { + c := &cobra.Command{ + Use: "initialize-v5-repair-rebate-staking [arg_newRebateStaking]", + Short: "Calls the nonpayable method initializeV5RepairRebateStaking on the Bridge contract.", + Args: cmd.ArgCountChecker(1), + RunE: bInitializeV5RepairRebateStaking, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + c.PreRunE = cmd.NonConstArgsChecker + cmd.InitNonConstFlags(c) + + return c +} + +func bInitializeV5RepairRebateStaking(c *cobra.Command, args []string) error { + contract, err := initializeBridge(c) + if err != nil { + return err + } + + arg_newRebateStaking, err := chainutil.AddressFromHex(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_newRebateStaking, a address, from passed value %v", + args[0], + ) + } + + var ( + transaction *types.Transaction + ) + + if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { + // Do a regular submission. Take payable into account. + transaction, err = contract.InitializeV5RepairRebateStaking( + arg_newRebateStaking, + ) + if err != nil { + return err + } + + cmd.PrintOutput(transaction.Hash()) + } else { + // Do a call. + err = contract.CallInitializeV5RepairRebateStaking( + arg_newRebateStaking, + cmd.BlockFlagValue.Int, + ) + if err != nil { + return err + } + + cmd.PrintOutput("success") + + cmd.PrintOutput( + "the transaction was not submitted to the chain; " + + "please add the `--submit` flag", + ) + } + + return nil +} + func bNotifyMovingFundsBelowDustCommand() *cobra.Command { c := &cobra.Command{ Use: "notify-moving-funds-below-dust [arg_walletPubKeyHash] [arg_mainUtxo_json]", @@ -2026,6 +2263,71 @@ func bRevealDepositWithExtraData(c *cobra.Command, args []string) error { return nil } +func bSetRebateStakingCommand() *cobra.Command { + c := &cobra.Command{ + Use: "set-rebate-staking [arg_rebateStaking]", + Short: "Calls the nonpayable method setRebateStaking on the Bridge contract.", + Args: cmd.ArgCountChecker(1), + RunE: bSetRebateStaking, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + c.PreRunE = cmd.NonConstArgsChecker + cmd.InitNonConstFlags(c) + + return c +} + +func bSetRebateStaking(c *cobra.Command, args []string) error { + contract, err := initializeBridge(c) + if err != nil { + return err + } + + arg_rebateStaking, err := chainutil.AddressFromHex(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_rebateStaking, a address, from passed value %v", + args[0], + ) + } + + var ( + transaction *types.Transaction + ) + + if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { + // Do a regular submission. Take payable into account. + transaction, err = contract.SetRebateStaking( + arg_rebateStaking, + ) + if err != nil { + return err + } + + cmd.PrintOutput(transaction.Hash()) + } else { + // Do a call. + err = contract.CallSetRebateStaking( + arg_rebateStaking, + cmd.BlockFlagValue.Int, + ) + if err != nil { + return err + } + + cmd.PrintOutput("success") + + cmd.PrintOutput( + "the transaction was not submitted to the chain; " + + "please add the `--submit` flag", + ) + } + + return nil +} + func bSetRedemptionWatchtowerCommand() *cobra.Command { c := &cobra.Command{ Use: "set-redemption-watchtower [arg_redemptionWatchtower]", @@ -2091,6 +2393,71 @@ func bSetRedemptionWatchtower(c *cobra.Command, args []string) error { return nil } +func bSetReservationRouterCommand() *cobra.Command { + c := &cobra.Command{ + Use: "set-reservation-router [arg__reservationRouter]", + Short: "Calls the nonpayable method setReservationRouter on the Bridge contract.", + Args: cmd.ArgCountChecker(1), + RunE: bSetReservationRouter, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + c.PreRunE = cmd.NonConstArgsChecker + cmd.InitNonConstFlags(c) + + return c +} + +func bSetReservationRouter(c *cobra.Command, args []string) error { + contract, err := initializeBridge(c) + if err != nil { + return err + } + + arg__reservationRouter, err := chainutil.AddressFromHex(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg__reservationRouter, a address, from passed value %v", + args[0], + ) + } + + var ( + transaction *types.Transaction + ) + + if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { + // Do a regular submission. Take payable into account. + transaction, err = contract.SetReservationRouter( + arg__reservationRouter, + ) + if err != nil { + return err + } + + cmd.PrintOutput(transaction.Hash()) + } else { + // Do a call. + err = contract.CallSetReservationRouter( + arg__reservationRouter, + cmd.BlockFlagValue.Int, + ) + if err != nil { + return err + } + + cmd.PrintOutput("success") + + cmd.PrintOutput( + "the transaction was not submitted to the chain; " + + "please add the `--submit` flag", + ) + } + + return nil +} + func bSetSpvMaintainerStatusCommand() *cobra.Command { c := &cobra.Command{ Use: "set-spv-maintainer-status [arg_spvMaintainer] [arg_isTrusted]", diff --git a/pkg/chain/ethereum/tbtc/gen/cmd/RedemptionWatchtower.go b/pkg/chain/ethereum/tbtc/gen/cmd/RedemptionWatchtower.go index ccbddec189..cb5578a1b1 100644 --- a/pkg/chain/ethereum/tbtc/gen/cmd/RedemptionWatchtower.go +++ b/pkg/chain/ethereum/tbtc/gen/cmd/RedemptionWatchtower.go @@ -61,6 +61,7 @@ func init() { rwManagerCommand(), rwObjectionsCommand(), rwOwnerCommand(), + rwREQUIREDOBJECTIONSCOUNTCommand(), rwVetoFreezePeriodCommand(), rwVetoPenaltyFeeDivisorCommand(), rwVetoProposalsCommand(), @@ -562,6 +563,40 @@ func rwOwner(c *cobra.Command, args []string) error { return nil } +func rwREQUIREDOBJECTIONSCOUNTCommand() *cobra.Command { + c := &cobra.Command{ + Use: "r-e-q-u-i-r-e-d-o-b-j-e-c-t-i-o-n-s-c-o-u-n-t", + Short: "Calls the view method rEQUIREDOBJECTIONSCOUNT on the RedemptionWatchtower contract.", + Args: cmd.ArgCountChecker(0), + RunE: rwREQUIREDOBJECTIONSCOUNT, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func rwREQUIREDOBJECTIONSCOUNT(c *cobra.Command, args []string) error { + contract, err := initializeRedemptionWatchtower(c) + if err != nil { + return err + } + + result, err := contract.REQUIREDOBJECTIONSCOUNTAtBlock( + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + func rwVetoFreezePeriodCommand() *cobra.Command { c := &cobra.Command{ Use: "veto-freeze-period", diff --git a/pkg/chain/ethereum/tbtc/gen/cmd/ReservationRouter.go b/pkg/chain/ethereum/tbtc/gen/cmd/ReservationRouter.go new file mode 100644 index 0000000000..8c23881c8e --- /dev/null +++ b/pkg/chain/ethereum/tbtc/gen/cmd/ReservationRouter.go @@ -0,0 +1,1331 @@ +// Code generated - DO NOT EDIT. +// This file is a generated command and any manual changes will be lost. + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "sync" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + + chainutil "github.com/keep-network/keep-common/pkg/chain/ethereum/ethutil" + "github.com/keep-network/keep-common/pkg/cmd" + "github.com/keep-network/keep-common/pkg/utils/decode" + "github.com/keep-network/keep-core/pkg/chain/ethereum/tbtc/gen/abi" + "github.com/keep-network/keep-core/pkg/chain/ethereum/tbtc/gen/contract" + + "github.com/spf13/cobra" +) + +var ReservationRouterCommand *cobra.Command + +var reservationRouterDescription = `The reservation-router command allows calling the ReservationRouter contract on an + Ethereum network. It has subcommands corresponding to each contract method, + which respectively each take parameters based on the contract method's + parameters. + + Subcommands will submit a non-mutating call to the network and output the + result. + + All subcommands can be called against a specific block by passing the + -b/--block flag. + + Subcommands for mutating methods may be submitted as a mutating transaction + by passing the -s/--submit flag. In this mode, this command will terminate + successfully once the transaction has been submitted, but will not wait for + the transaction to be included in a block. They return the transaction hash. + + Calls that require ether to be paid will get 0 ether by default, which can + be changed by passing the -v/--value flag.` + +func init() { + ReservationRouterCommand := &cobra.Command{ + Use: "reservation-router", + Short: `Provides access to the ReservationRouter contract.`, + Long: reservationRouterDescription, + } + + ReservationRouterCommand.AddCommand( + rrActiveReservationsCountCommand(), + rrGovernanceCommand(), + rrPendingReservedDepositsCommand(), + rrReservationActionsCommand(), + rrReservationByAnchorUtxoCommand(), + rrReservationCapsCommand(), + rrReservationParametersCommand(), + rrReservationRouterCommand(), + rrReservationsCommand(), + rrReservedDepositWalletCommand(), + rrWalletReservationsCommand(), + rrWalletReservationsAmountCommand(), + rrWalletReservationsCountCommand(), + rrNotifyReservationStrandedCommand(), + rrNotifyStaleReservedDepositCommand(), + rrRequestReservationAcceptanceCommand(), + rrRequestReservationReanchorCommand(), + rrSubmitReservationProofCommand(), + rrTransferGovernanceCommand(), + rrUpdateReservationCapsCommand(), + rrUpdateReservationParametersCommand(), + ) + + ModuleCommand.AddCommand(ReservationRouterCommand) +} + +/// ------------------- Const methods ------------------- + +func rrActiveReservationsCountCommand() *cobra.Command { + c := &cobra.Command{ + Use: "active-reservations-count", + Short: "Calls the view method activeReservationsCount on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(0), + RunE: rrActiveReservationsCount, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func rrActiveReservationsCount(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + result, err := contract.ActiveReservationsCountAtBlock( + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + +func rrGovernanceCommand() *cobra.Command { + c := &cobra.Command{ + Use: "governance", + Short: "Calls the view method governance on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(0), + RunE: rrGovernance, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func rrGovernance(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + result, err := contract.GovernanceAtBlock( + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + +func rrPendingReservedDepositsCommand() *cobra.Command { + c := &cobra.Command{ + Use: "pending-reserved-deposits", + Short: "Calls the view method pendingReservedDeposits on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(0), + RunE: rrPendingReservedDeposits, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func rrPendingReservedDeposits(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + result, err := contract.PendingReservedDepositsAtBlock( + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + +func rrReservationActionsCommand() *cobra.Command { + c := &cobra.Command{ + Use: "reservation-actions [arg_reservationKey] [arg_requestNonce]", + Short: "Calls the view method reservationActions on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(2), + RunE: rrReservationActions, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func rrReservationActions(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + arg_reservationKey, err := hexutil.DecodeBig(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_reservationKey, a uint256, from passed value %v", + args[0], + ) + } + arg_requestNonce, err := decode.ParseUint[uint64](args[1], 64) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_requestNonce, a uint64, from passed value %v", + args[1], + ) + } + + result, err := contract.ReservationActionsAtBlock( + arg_reservationKey, + arg_requestNonce, + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + +func rrReservationByAnchorUtxoCommand() *cobra.Command { + c := &cobra.Command{ + Use: "reservation-by-anchor-utxo [arg_anchorTxHash] [arg_anchorTxOutputIndex]", + Short: "Calls the view method reservationByAnchorUtxo on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(2), + RunE: rrReservationByAnchorUtxo, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func rrReservationByAnchorUtxo(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + arg_anchorTxHash, err := decode.ParseBytes32(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_anchorTxHash, a bytes32, from passed value %v", + args[0], + ) + } + arg_anchorTxOutputIndex, err := decode.ParseUint[uint32](args[1], 32) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_anchorTxOutputIndex, a uint32, from passed value %v", + args[1], + ) + } + + result, err := contract.ReservationByAnchorUtxoAtBlock( + arg_anchorTxHash, + arg_anchorTxOutputIndex, + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + +func rrReservationCapsCommand() *cobra.Command { + c := &cobra.Command{ + Use: "reservation-caps", + Short: "Calls the view method reservationCaps on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(0), + RunE: rrReservationCaps, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func rrReservationCaps(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + result, err := contract.ReservationCapsAtBlock( + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + +func rrReservationParametersCommand() *cobra.Command { + c := &cobra.Command{ + Use: "reservation-parameters", + Short: "Calls the view method reservationParameters on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(0), + RunE: rrReservationParameters, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func rrReservationParameters(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + result, err := contract.ReservationParametersAtBlock( + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + +func rrReservationRouterCommand() *cobra.Command { + c := &cobra.Command{ + Use: "reservation-router", + Short: "Calls the view method reservationRouter on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(0), + RunE: rrReservationRouter, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func rrReservationRouter(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + result, err := contract.ReservationRouterAtBlock( + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + +func rrReservationsCommand() *cobra.Command { + c := &cobra.Command{ + Use: "reservations [arg_reservationKey]", + Short: "Calls the view method reservations on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(1), + RunE: rrReservations, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func rrReservations(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + arg_reservationKey, err := hexutil.DecodeBig(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_reservationKey, a uint256, from passed value %v", + args[0], + ) + } + + result, err := contract.ReservationsAtBlock( + arg_reservationKey, + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + +func rrReservedDepositWalletCommand() *cobra.Command { + c := &cobra.Command{ + Use: "reserved-deposit-wallet [arg_depositKey]", + Short: "Calls the view method reservedDepositWallet on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(1), + RunE: rrReservedDepositWallet, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func rrReservedDepositWallet(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + arg_depositKey, err := hexutil.DecodeBig(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_depositKey, a uint256, from passed value %v", + args[0], + ) + } + + result, err := contract.ReservedDepositWalletAtBlock( + arg_depositKey, + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + +func rrWalletReservationsCommand() *cobra.Command { + c := &cobra.Command{ + Use: "wallet-reservations [arg_walletPubKeyHash]", + Short: "Calls the view method walletReservations on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(1), + RunE: rrWalletReservations, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func rrWalletReservations(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + arg_walletPubKeyHash, err := decode.ParseBytes20(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_walletPubKeyHash, a bytes20, from passed value %v", + args[0], + ) + } + + result, err := contract.WalletReservationsAtBlock( + arg_walletPubKeyHash, + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + +func rrWalletReservationsAmountCommand() *cobra.Command { + c := &cobra.Command{ + Use: "wallet-reservations-amount [arg_walletPubKeyHash]", + Short: "Calls the view method walletReservationsAmount on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(1), + RunE: rrWalletReservationsAmount, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func rrWalletReservationsAmount(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + arg_walletPubKeyHash, err := decode.ParseBytes20(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_walletPubKeyHash, a bytes20, from passed value %v", + args[0], + ) + } + + result, err := contract.WalletReservationsAmountAtBlock( + arg_walletPubKeyHash, + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + +func rrWalletReservationsCountCommand() *cobra.Command { + c := &cobra.Command{ + Use: "wallet-reservations-count [arg_walletPubKeyHash]", + Short: "Calls the view method walletReservationsCount on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(1), + RunE: rrWalletReservationsCount, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func rrWalletReservationsCount(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + arg_walletPubKeyHash, err := decode.ParseBytes20(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_walletPubKeyHash, a bytes20, from passed value %v", + args[0], + ) + } + + result, err := contract.WalletReservationsCountAtBlock( + arg_walletPubKeyHash, + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + +/// ------------------- Non-const methods ------------------- + +func rrNotifyReservationStrandedCommand() *cobra.Command { + c := &cobra.Command{ + Use: "notify-reservation-stranded [arg_reservationKey]", + Short: "Calls the nonpayable method notifyReservationStranded on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(1), + RunE: rrNotifyReservationStranded, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + c.PreRunE = cmd.NonConstArgsChecker + cmd.InitNonConstFlags(c) + + return c +} + +func rrNotifyReservationStranded(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + arg_reservationKey, err := hexutil.DecodeBig(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_reservationKey, a uint256, from passed value %v", + args[0], + ) + } + + var ( + transaction *types.Transaction + ) + + if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { + // Do a regular submission. Take payable into account. + transaction, err = contract.NotifyReservationStranded( + arg_reservationKey, + ) + if err != nil { + return err + } + + cmd.PrintOutput(transaction.Hash()) + } else { + // Do a call. + err = contract.CallNotifyReservationStranded( + arg_reservationKey, + cmd.BlockFlagValue.Int, + ) + if err != nil { + return err + } + + cmd.PrintOutput("success") + + cmd.PrintOutput( + "the transaction was not submitted to the chain; " + + "please add the `--submit` flag", + ) + } + + return nil +} + +func rrNotifyStaleReservedDepositCommand() *cobra.Command { + c := &cobra.Command{ + Use: "notify-stale-reserved-deposit [arg_depositKey]", + Short: "Calls the nonpayable method notifyStaleReservedDeposit on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(1), + RunE: rrNotifyStaleReservedDeposit, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + c.PreRunE = cmd.NonConstArgsChecker + cmd.InitNonConstFlags(c) + + return c +} + +func rrNotifyStaleReservedDeposit(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + arg_depositKey, err := hexutil.DecodeBig(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_depositKey, a uint256, from passed value %v", + args[0], + ) + } + + var ( + transaction *types.Transaction + ) + + if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { + // Do a regular submission. Take payable into account. + transaction, err = contract.NotifyStaleReservedDeposit( + arg_depositKey, + ) + if err != nil { + return err + } + + cmd.PrintOutput(transaction.Hash()) + } else { + // Do a call. + err = contract.CallNotifyStaleReservedDeposit( + arg_depositKey, + cmd.BlockFlagValue.Int, + ) + if err != nil { + return err + } + + cmd.PrintOutput("success") + + cmd.PrintOutput( + "the transaction was not submitted to the chain; " + + "please add the `--submit` flag", + ) + } + + return nil +} + +func rrRequestReservationAcceptanceCommand() *cobra.Command { + c := &cobra.Command{ + Use: "request-reservation-acceptance [arg_reservationKey] [arg_walletPubKeyHash]", + Short: "Calls the nonpayable method requestReservationAcceptance on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(2), + RunE: rrRequestReservationAcceptance, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + c.PreRunE = cmd.NonConstArgsChecker + cmd.InitNonConstFlags(c) + + return c +} + +func rrRequestReservationAcceptance(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + arg_reservationKey, err := hexutil.DecodeBig(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_reservationKey, a uint256, from passed value %v", + args[0], + ) + } + arg_walletPubKeyHash, err := decode.ParseBytes20(args[1]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_walletPubKeyHash, a bytes20, from passed value %v", + args[1], + ) + } + + var ( + transaction *types.Transaction + ) + + if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { + // Do a regular submission. Take payable into account. + transaction, err = contract.RequestReservationAcceptance( + arg_reservationKey, + arg_walletPubKeyHash, + ) + if err != nil { + return err + } + + cmd.PrintOutput(transaction.Hash()) + } else { + // Do a call. + err = contract.CallRequestReservationAcceptance( + arg_reservationKey, + arg_walletPubKeyHash, + cmd.BlockFlagValue.Int, + ) + if err != nil { + return err + } + + cmd.PrintOutput("success") + + cmd.PrintOutput( + "the transaction was not submitted to the chain; " + + "please add the `--submit` flag", + ) + } + + return nil +} + +func rrRequestReservationReanchorCommand() *cobra.Command { + c := &cobra.Command{ + Use: "request-reservation-reanchor [arg_reservationKey] [arg_targetWalletPubKeyHash]", + Short: "Calls the nonpayable method requestReservationReanchor on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(2), + RunE: rrRequestReservationReanchor, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + c.PreRunE = cmd.NonConstArgsChecker + cmd.InitNonConstFlags(c) + + return c +} + +func rrRequestReservationReanchor(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + arg_reservationKey, err := hexutil.DecodeBig(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_reservationKey, a uint256, from passed value %v", + args[0], + ) + } + arg_targetWalletPubKeyHash, err := decode.ParseBytes20(args[1]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_targetWalletPubKeyHash, a bytes20, from passed value %v", + args[1], + ) + } + + var ( + transaction *types.Transaction + ) + + if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { + // Do a regular submission. Take payable into account. + transaction, err = contract.RequestReservationReanchor( + arg_reservationKey, + arg_targetWalletPubKeyHash, + ) + if err != nil { + return err + } + + cmd.PrintOutput(transaction.Hash()) + } else { + // Do a call. + err = contract.CallRequestReservationReanchor( + arg_reservationKey, + arg_targetWalletPubKeyHash, + cmd.BlockFlagValue.Int, + ) + if err != nil { + return err + } + + cmd.PrintOutput("success") + + cmd.PrintOutput( + "the transaction was not submitted to the chain; " + + "please add the `--submit` flag", + ) + } + + return nil +} + +func rrSubmitReservationProofCommand() *cobra.Command { + c := &cobra.Command{ + Use: "submit-reservation-proof [arg_proofType] [arg_txInfo_json] [arg_proof_json] [arg_mainUtxo_json] [arg_reservationKey] [arg_requestNonce]", + Short: "Calls the nonpayable method submitReservationProof on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(6), + RunE: rrSubmitReservationProof, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + c.PreRunE = cmd.NonConstArgsChecker + cmd.InitNonConstFlags(c) + + return c +} + +func rrSubmitReservationProof(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + arg_proofType, err := decode.ParseUint[uint8](args[0], 8) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_proofType, a uint8, from passed value %v", + args[0], + ) + } + + arg_txInfo_json := abi.BitcoinTxInfo4{} + if err := json.Unmarshal([]byte(args[1]), &arg_txInfo_json); err != nil { + return fmt.Errorf("failed to unmarshal arg_txInfo_json to abi.BitcoinTxInfo4: %w", err) + } + + arg_proof_json := abi.BitcoinTxProof3{} + if err := json.Unmarshal([]byte(args[2]), &arg_proof_json); err != nil { + return fmt.Errorf("failed to unmarshal arg_proof_json to abi.BitcoinTxProof3: %w", err) + } + + arg_mainUtxo_json := abi.BitcoinTxUTXO4{} + if err := json.Unmarshal([]byte(args[3]), &arg_mainUtxo_json); err != nil { + return fmt.Errorf("failed to unmarshal arg_mainUtxo_json to abi.BitcoinTxUTXO4: %w", err) + } + arg_reservationKey, err := hexutil.DecodeBig(args[4]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_reservationKey, a uint256, from passed value %v", + args[4], + ) + } + arg_requestNonce, err := decode.ParseUint[uint64](args[5], 64) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_requestNonce, a uint64, from passed value %v", + args[5], + ) + } + + var ( + transaction *types.Transaction + ) + + if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { + // Do a regular submission. Take payable into account. + transaction, err = contract.SubmitReservationProof( + arg_proofType, + arg_txInfo_json, + arg_proof_json, + arg_mainUtxo_json, + arg_reservationKey, + arg_requestNonce, + ) + if err != nil { + return err + } + + cmd.PrintOutput(transaction.Hash()) + } else { + // Do a call. + err = contract.CallSubmitReservationProof( + arg_proofType, + arg_txInfo_json, + arg_proof_json, + arg_mainUtxo_json, + arg_reservationKey, + arg_requestNonce, + cmd.BlockFlagValue.Int, + ) + if err != nil { + return err + } + + cmd.PrintOutput("success") + + cmd.PrintOutput( + "the transaction was not submitted to the chain; " + + "please add the `--submit` flag", + ) + } + + return nil +} + +func rrTransferGovernanceCommand() *cobra.Command { + c := &cobra.Command{ + Use: "transfer-governance [arg_newGovernance]", + Short: "Calls the nonpayable method transferGovernance on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(1), + RunE: rrTransferGovernance, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + c.PreRunE = cmd.NonConstArgsChecker + cmd.InitNonConstFlags(c) + + return c +} + +func rrTransferGovernance(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + arg_newGovernance, err := chainutil.AddressFromHex(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_newGovernance, a address, from passed value %v", + args[0], + ) + } + + var ( + transaction *types.Transaction + ) + + if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { + // Do a regular submission. Take payable into account. + transaction, err = contract.TransferGovernance( + arg_newGovernance, + ) + if err != nil { + return err + } + + cmd.PrintOutput(transaction.Hash()) + } else { + // Do a call. + err = contract.CallTransferGovernance( + arg_newGovernance, + cmd.BlockFlagValue.Int, + ) + if err != nil { + return err + } + + cmd.PrintOutput("success") + + cmd.PrintOutput( + "the transaction was not submitted to the chain; " + + "please add the `--submit` flag", + ) + } + + return nil +} + +func rrUpdateReservationCapsCommand() *cobra.Command { + c := &cobra.Command{ + Use: "update-reservation-caps [arg_maxReservationsAmountPerWallet] [arg_reservationMaxSingleAmount] [arg_maxActiveReservations]", + Short: "Calls the nonpayable method updateReservationCaps on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(3), + RunE: rrUpdateReservationCaps, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + c.PreRunE = cmd.NonConstArgsChecker + cmd.InitNonConstFlags(c) + + return c +} + +func rrUpdateReservationCaps(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + arg_maxReservationsAmountPerWallet, err := decode.ParseUint[uint64](args[0], 64) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_maxReservationsAmountPerWallet, a uint64, from passed value %v", + args[0], + ) + } + arg_reservationMaxSingleAmount, err := decode.ParseUint[uint64](args[1], 64) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_reservationMaxSingleAmount, a uint64, from passed value %v", + args[1], + ) + } + arg_maxActiveReservations, err := decode.ParseUint[uint32](args[2], 32) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_maxActiveReservations, a uint32, from passed value %v", + args[2], + ) + } + + var ( + transaction *types.Transaction + ) + + if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { + // Do a regular submission. Take payable into account. + transaction, err = contract.UpdateReservationCaps( + arg_maxReservationsAmountPerWallet, + arg_reservationMaxSingleAmount, + arg_maxActiveReservations, + ) + if err != nil { + return err + } + + cmd.PrintOutput(transaction.Hash()) + } else { + // Do a call. + err = contract.CallUpdateReservationCaps( + arg_maxReservationsAmountPerWallet, + arg_reservationMaxSingleAmount, + arg_maxActiveReservations, + cmd.BlockFlagValue.Int, + ) + if err != nil { + return err + } + + cmd.PrintOutput("success") + + cmd.PrintOutput( + "the transaction was not submitted to the chain; " + + "please add the `--submit` flag", + ) + } + + return nil +} + +func rrUpdateReservationParametersCommand() *cobra.Command { + c := &cobra.Command{ + Use: "update-reservation-parameters [arg_reservationVault] [arg_reservationMinAmount] [arg_reservationTxMaxFee] [arg_reservationTermSeconds] [arg_reservationDissolutionDelay] [arg_reservationMaxTotalAmount] [arg_maxReservationsPerWallet] [arg_reservationActionTimeout] [arg_reservationRenewalWindowSeconds]", + Short: "Calls the nonpayable method updateReservationParameters on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(9), + RunE: rrUpdateReservationParameters, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + c.PreRunE = cmd.NonConstArgsChecker + cmd.InitNonConstFlags(c) + + return c +} + +func rrUpdateReservationParameters(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + arg_reservationVault, err := chainutil.AddressFromHex(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_reservationVault, a address, from passed value %v", + args[0], + ) + } + arg_reservationMinAmount, err := decode.ParseUint[uint64](args[1], 64) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_reservationMinAmount, a uint64, from passed value %v", + args[1], + ) + } + arg_reservationTxMaxFee, err := decode.ParseUint[uint64](args[2], 64) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_reservationTxMaxFee, a uint64, from passed value %v", + args[2], + ) + } + arg_reservationTermSeconds, err := decode.ParseUint[uint32](args[3], 32) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_reservationTermSeconds, a uint32, from passed value %v", + args[3], + ) + } + arg_reservationDissolutionDelay, err := decode.ParseUint[uint32](args[4], 32) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_reservationDissolutionDelay, a uint32, from passed value %v", + args[4], + ) + } + arg_reservationMaxTotalAmount, err := decode.ParseUint[uint64](args[5], 64) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_reservationMaxTotalAmount, a uint64, from passed value %v", + args[5], + ) + } + arg_maxReservationsPerWallet, err := decode.ParseUint[uint32](args[6], 32) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_maxReservationsPerWallet, a uint32, from passed value %v", + args[6], + ) + } + arg_reservationActionTimeout, err := decode.ParseUint[uint32](args[7], 32) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_reservationActionTimeout, a uint32, from passed value %v", + args[7], + ) + } + arg_reservationRenewalWindowSeconds, err := decode.ParseUint[uint32](args[8], 32) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_reservationRenewalWindowSeconds, a uint32, from passed value %v", + args[8], + ) + } + + var ( + transaction *types.Transaction + ) + + if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { + // Do a regular submission. Take payable into account. + transaction, err = contract.UpdateReservationParameters( + arg_reservationVault, + arg_reservationMinAmount, + arg_reservationTxMaxFee, + arg_reservationTermSeconds, + arg_reservationDissolutionDelay, + arg_reservationMaxTotalAmount, + arg_maxReservationsPerWallet, + arg_reservationActionTimeout, + arg_reservationRenewalWindowSeconds, + ) + if err != nil { + return err + } + + cmd.PrintOutput(transaction.Hash()) + } else { + // Do a call. + err = contract.CallUpdateReservationParameters( + arg_reservationVault, + arg_reservationMinAmount, + arg_reservationTxMaxFee, + arg_reservationTermSeconds, + arg_reservationDissolutionDelay, + arg_reservationMaxTotalAmount, + arg_maxReservationsPerWallet, + arg_reservationActionTimeout, + arg_reservationRenewalWindowSeconds, + cmd.BlockFlagValue.Int, + ) + if err != nil { + return err + } + + cmd.PrintOutput("success") + + cmd.PrintOutput( + "the transaction was not submitted to the chain; " + + "please add the `--submit` flag", + ) + } + + return nil +} + +/// ------------------- Initialization ------------------- + +func initializeReservationRouter(c *cobra.Command) (*contract.ReservationRouter, error) { + cfg := *ModuleCommand.GetConfig() + + client, err := ethclient.Dial(cfg.URL) + if err != nil { + return nil, fmt.Errorf("error connecting to host chain node: [%v]", err) + } + + chainID, err := client.ChainID(context.Background()) + if err != nil { + return nil, fmt.Errorf( + "failed to resolve host chain id: [%v]", + err, + ) + } + + key, err := chainutil.DecryptKeyFile( + cfg.Account.KeyFile, + cfg.Account.KeyFilePassword, + ) + if err != nil { + return nil, fmt.Errorf( + "failed to read KeyFile: %s: [%v]", + cfg.Account.KeyFile, + err, + ) + } + + miningWaiter := chainutil.NewMiningWaiter(client, cfg) + + blockCounter, err := chainutil.NewBlockCounter(client) + if err != nil { + return nil, fmt.Errorf( + "failed to create block counter: [%v]", + err, + ) + } + + address, err := cfg.ContractAddress("ReservationRouter") + if err != nil { + return nil, fmt.Errorf( + "failed to get %s address: [%w]", + "ReservationRouter", + err, + ) + } + + return contract.NewReservationRouter( + address, + chainID, + key, + client, + chainutil.NewNonceManager(client, key.Address), + miningWaiter, + blockCounter, + &sync.Mutex{}, + ) +} diff --git a/pkg/chain/ethereum/tbtc/gen/cmd/WalletProposalValidator.go b/pkg/chain/ethereum/tbtc/gen/cmd/WalletProposalValidator.go index d86d97458f..92932d206d 100644 --- a/pkg/chain/ethereum/tbtc/gen/cmd/WalletProposalValidator.go +++ b/pkg/chain/ethereum/tbtc/gen/cmd/WalletProposalValidator.go @@ -59,6 +59,8 @@ func init() { wpvValidateMovedFundsSweepProposalCommand(), wpvValidateMovingFundsProposalCommand(), wpvValidateRedemptionProposalCommand(), + wpvValidateReservationAnchorProposalCommand(), + wpvValidateReservationReanchorProposalCommand(), ) ModuleCommand.AddCommand(WalletProposalValidatorCommand) @@ -470,6 +472,92 @@ func wpvValidateRedemptionProposal(c *cobra.Command, args []string) error { return nil } +func wpvValidateReservationAnchorProposalCommand() *cobra.Command { + c := &cobra.Command{ + Use: "validate-reservation-anchor-proposal [arg_proposal_json] [arg_depositExtraInfo_json]", + Short: "Calls the view method validateReservationAnchorProposal on the WalletProposalValidator contract.", + Args: cmd.ArgCountChecker(2), + RunE: wpvValidateReservationAnchorProposal, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func wpvValidateReservationAnchorProposal(c *cobra.Command, args []string) error { + contract, err := initializeWalletProposalValidator(c) + if err != nil { + return err + } + + arg_proposal_json := abi.WalletProposalValidatorReservationAnchorProposal{} + if err := json.Unmarshal([]byte(args[0]), &arg_proposal_json); err != nil { + return fmt.Errorf("failed to unmarshal arg_proposal_json to abi.WalletProposalValidatorReservationAnchorProposal: %w", err) + } + + arg_depositExtraInfo_json := abi.WalletProposalValidatorDepositExtraInfo{} + if err := json.Unmarshal([]byte(args[1]), &arg_depositExtraInfo_json); err != nil { + return fmt.Errorf("failed to unmarshal arg_depositExtraInfo_json to abi.WalletProposalValidatorDepositExtraInfo: %w", err) + } + + result, err := contract.ValidateReservationAnchorProposalAtBlock( + arg_proposal_json, + arg_depositExtraInfo_json, + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + +func wpvValidateReservationReanchorProposalCommand() *cobra.Command { + c := &cobra.Command{ + Use: "validate-reservation-reanchor-proposal [arg_proposal_json]", + Short: "Calls the view method validateReservationReanchorProposal on the WalletProposalValidator contract.", + Args: cmd.ArgCountChecker(1), + RunE: wpvValidateReservationReanchorProposal, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func wpvValidateReservationReanchorProposal(c *cobra.Command, args []string) error { + contract, err := initializeWalletProposalValidator(c) + if err != nil { + return err + } + + arg_proposal_json := abi.WalletProposalValidatorReservationReanchorProposal{} + if err := json.Unmarshal([]byte(args[0]), &arg_proposal_json); err != nil { + return fmt.Errorf("failed to unmarshal arg_proposal_json to abi.WalletProposalValidatorReservationReanchorProposal: %w", err) + } + + result, err := contract.ValidateReservationReanchorProposalAtBlock( + arg_proposal_json, + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + /// ------------------- Non-const methods ------------------- /// ------------------- Initialization ------------------- diff --git a/pkg/chain/ethereum/tbtc/gen/contract/Bridge.go b/pkg/chain/ethereum/tbtc/gen/contract/Bridge.go index ae73c92607..30a9b3faa3 100644 --- a/pkg/chain/ethereum/tbtc/gen/contract/Bridge.go +++ b/pkg/chain/ethereum/tbtc/gen/contract/Bridge.go @@ -914,6 +914,268 @@ func (b *Bridge) InitializeGasEstimate( return result, err } +// Transaction submission. +func (b *Bridge) InitializeV2FixVaultZeroDeposit( + + transactionOptions ...chainutil.TransactionOptions, +) (*types.Transaction, error) { + bLogger.Debug( + "submitting transaction initializeV2FixVaultZeroDeposit", + ) + + b.transactionMutex.Lock() + defer b.transactionMutex.Unlock() + + // create a copy + transactorOptions := new(bind.TransactOpts) + *transactorOptions = *b.transactorOptions + + if len(transactionOptions) > 1 { + return nil, fmt.Errorf( + "could not process multiple transaction options sets", + ) + } else if len(transactionOptions) > 0 { + transactionOptions[0].Apply(transactorOptions) + } + + nonce, err := b.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } + + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) + + transaction, err := b.contract.InitializeV2FixVaultZeroDeposit( + transactorOptions, + ) + if err != nil { + return transaction, b.errorResolver.ResolveError( + err, + b.transactorOptions.From, + nil, + "initializeV2FixVaultZeroDeposit", + ) + } + + bLogger.Infof( + "submitted transaction initializeV2FixVaultZeroDeposit with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + go b.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + // If original transactor options has a non-zero gas limit, that + // means the client code set it on their own. In that case, we + // should rewrite the gas limit from the original transaction + // for each resubmission. If the gas limit is not set by the client + // code, let the the submitter re-estimate the gas limit on each + // resubmission. + if transactorOptions.GasLimit != 0 { + newTransactorOptions.GasLimit = transactorOptions.GasLimit + } + + transaction, err := b.contract.InitializeV2FixVaultZeroDeposit( + newTransactorOptions, + ) + if err != nil { + return nil, b.errorResolver.ResolveError( + err, + b.transactorOptions.From, + nil, + "initializeV2FixVaultZeroDeposit", + ) + } + + bLogger.Infof( + "submitted transaction initializeV2FixVaultZeroDeposit with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + return transaction, nil + }, + ) + + b.nonceManager.IncrementNonce() + + return transaction, err +} + +// Non-mutating call, not a transaction submission. +func (b *Bridge) CallInitializeV2FixVaultZeroDeposit( + blockNumber *big.Int, +) error { + var result interface{} = nil + + err := chainutil.CallAtBlock( + b.transactorOptions.From, + blockNumber, nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "initializeV2FixVaultZeroDeposit", + &result, + ) + + return err +} + +func (b *Bridge) InitializeV2FixVaultZeroDepositGasEstimate() (uint64, error) { + var result uint64 + + result, err := chainutil.EstimateGas( + b.callerOptions.From, + b.contractAddress, + "initializeV2FixVaultZeroDeposit", + b.contractABI, + b.transactor, + ) + + return result, err +} + +// Transaction submission. +func (b *Bridge) InitializeV5RepairRebateStaking( + arg_newRebateStaking common.Address, + + transactionOptions ...chainutil.TransactionOptions, +) (*types.Transaction, error) { + bLogger.Debug( + "submitting transaction initializeV5RepairRebateStaking", + " params: ", + fmt.Sprint( + arg_newRebateStaking, + ), + ) + + b.transactionMutex.Lock() + defer b.transactionMutex.Unlock() + + // create a copy + transactorOptions := new(bind.TransactOpts) + *transactorOptions = *b.transactorOptions + + if len(transactionOptions) > 1 { + return nil, fmt.Errorf( + "could not process multiple transaction options sets", + ) + } else if len(transactionOptions) > 0 { + transactionOptions[0].Apply(transactorOptions) + } + + nonce, err := b.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } + + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) + + transaction, err := b.contract.InitializeV5RepairRebateStaking( + transactorOptions, + arg_newRebateStaking, + ) + if err != nil { + return transaction, b.errorResolver.ResolveError( + err, + b.transactorOptions.From, + nil, + "initializeV5RepairRebateStaking", + arg_newRebateStaking, + ) + } + + bLogger.Infof( + "submitted transaction initializeV5RepairRebateStaking with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + go b.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + // If original transactor options has a non-zero gas limit, that + // means the client code set it on their own. In that case, we + // should rewrite the gas limit from the original transaction + // for each resubmission. If the gas limit is not set by the client + // code, let the the submitter re-estimate the gas limit on each + // resubmission. + if transactorOptions.GasLimit != 0 { + newTransactorOptions.GasLimit = transactorOptions.GasLimit + } + + transaction, err := b.contract.InitializeV5RepairRebateStaking( + newTransactorOptions, + arg_newRebateStaking, + ) + if err != nil { + return nil, b.errorResolver.ResolveError( + err, + b.transactorOptions.From, + nil, + "initializeV5RepairRebateStaking", + arg_newRebateStaking, + ) + } + + bLogger.Infof( + "submitted transaction initializeV5RepairRebateStaking with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + return transaction, nil + }, + ) + + b.nonceManager.IncrementNonce() + + return transaction, err +} + +// Non-mutating call, not a transaction submission. +func (b *Bridge) CallInitializeV5RepairRebateStaking( + arg_newRebateStaking common.Address, + blockNumber *big.Int, +) error { + var result interface{} = nil + + err := chainutil.CallAtBlock( + b.transactorOptions.From, + blockNumber, nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "initializeV5RepairRebateStaking", + &result, + arg_newRebateStaking, + ) + + return err +} + +func (b *Bridge) InitializeV5RepairRebateStakingGasEstimate( + arg_newRebateStaking common.Address, +) (uint64, error) { + var result uint64 + + result, err := chainutil.EstimateGas( + b.callerOptions.From, + b.contractAddress, + "initializeV5RepairRebateStaking", + b.contractABI, + b.transactor, + arg_newRebateStaking, + ) + + return result, err +} + // Transaction submission. func (b *Bridge) NotifyFraudChallengeDefeatTimeout( arg_walletPublicKey []byte, @@ -3027,16 +3289,16 @@ func (b *Bridge) RevealDepositWithExtraDataGasEstimate( } // Transaction submission. -func (b *Bridge) SetRedemptionWatchtower( - arg_redemptionWatchtower common.Address, +func (b *Bridge) SetRebateStaking( + arg_rebateStaking common.Address, transactionOptions ...chainutil.TransactionOptions, ) (*types.Transaction, error) { bLogger.Debug( - "submitting transaction setRedemptionWatchtower", + "submitting transaction setRebateStaking", " params: ", fmt.Sprint( - arg_redemptionWatchtower, + arg_rebateStaking, ), ) @@ -3062,22 +3324,22 @@ func (b *Bridge) SetRedemptionWatchtower( transactorOptions.Nonce = new(big.Int).SetUint64(nonce) - transaction, err := b.contract.SetRedemptionWatchtower( + transaction, err := b.contract.SetRebateStaking( transactorOptions, - arg_redemptionWatchtower, + arg_rebateStaking, ) if err != nil { return transaction, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "setRedemptionWatchtower", - arg_redemptionWatchtower, + "setRebateStaking", + arg_rebateStaking, ) } bLogger.Infof( - "submitted transaction setRedemptionWatchtower with id: [%s] and nonce [%v]", + "submitted transaction setRebateStaking with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -3096,22 +3358,22 @@ func (b *Bridge) SetRedemptionWatchtower( newTransactorOptions.GasLimit = transactorOptions.GasLimit } - transaction, err := b.contract.SetRedemptionWatchtower( + transaction, err := b.contract.SetRebateStaking( newTransactorOptions, - arg_redemptionWatchtower, + arg_rebateStaking, ) if err != nil { return nil, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "setRedemptionWatchtower", - arg_redemptionWatchtower, + "setRebateStaking", + arg_rebateStaking, ) } bLogger.Infof( - "submitted transaction setRedemptionWatchtower with id: [%s] and nonce [%v]", + "submitted transaction setRebateStaking with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -3126,8 +3388,8 @@ func (b *Bridge) SetRedemptionWatchtower( } // Non-mutating call, not a transaction submission. -func (b *Bridge) CallSetRedemptionWatchtower( - arg_redemptionWatchtower common.Address, +func (b *Bridge) CallSetRebateStaking( + arg_rebateStaking common.Address, blockNumber *big.Int, ) error { var result interface{} = nil @@ -3139,40 +3401,316 @@ func (b *Bridge) CallSetRedemptionWatchtower( b.caller, b.errorResolver, b.contractAddress, - "setRedemptionWatchtower", + "setRebateStaking", &result, - arg_redemptionWatchtower, + arg_rebateStaking, ) return err } -func (b *Bridge) SetRedemptionWatchtowerGasEstimate( - arg_redemptionWatchtower common.Address, +func (b *Bridge) SetRebateStakingGasEstimate( + arg_rebateStaking common.Address, ) (uint64, error) { var result uint64 result, err := chainutil.EstimateGas( b.callerOptions.From, b.contractAddress, - "setRedemptionWatchtower", + "setRebateStaking", b.contractABI, b.transactor, - arg_redemptionWatchtower, + arg_rebateStaking, ) return result, err } // Transaction submission. -func (b *Bridge) SetSpvMaintainerStatus( - arg_spvMaintainer common.Address, - arg_isTrusted bool, +func (b *Bridge) SetRedemptionWatchtower( + arg_redemptionWatchtower common.Address, transactionOptions ...chainutil.TransactionOptions, ) (*types.Transaction, error) { bLogger.Debug( - "submitting transaction setSpvMaintainerStatus", + "submitting transaction setRedemptionWatchtower", + " params: ", + fmt.Sprint( + arg_redemptionWatchtower, + ), + ) + + b.transactionMutex.Lock() + defer b.transactionMutex.Unlock() + + // create a copy + transactorOptions := new(bind.TransactOpts) + *transactorOptions = *b.transactorOptions + + if len(transactionOptions) > 1 { + return nil, fmt.Errorf( + "could not process multiple transaction options sets", + ) + } else if len(transactionOptions) > 0 { + transactionOptions[0].Apply(transactorOptions) + } + + nonce, err := b.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } + + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) + + transaction, err := b.contract.SetRedemptionWatchtower( + transactorOptions, + arg_redemptionWatchtower, + ) + if err != nil { + return transaction, b.errorResolver.ResolveError( + err, + b.transactorOptions.From, + nil, + "setRedemptionWatchtower", + arg_redemptionWatchtower, + ) + } + + bLogger.Infof( + "submitted transaction setRedemptionWatchtower with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + go b.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + // If original transactor options has a non-zero gas limit, that + // means the client code set it on their own. In that case, we + // should rewrite the gas limit from the original transaction + // for each resubmission. If the gas limit is not set by the client + // code, let the the submitter re-estimate the gas limit on each + // resubmission. + if transactorOptions.GasLimit != 0 { + newTransactorOptions.GasLimit = transactorOptions.GasLimit + } + + transaction, err := b.contract.SetRedemptionWatchtower( + newTransactorOptions, + arg_redemptionWatchtower, + ) + if err != nil { + return nil, b.errorResolver.ResolveError( + err, + b.transactorOptions.From, + nil, + "setRedemptionWatchtower", + arg_redemptionWatchtower, + ) + } + + bLogger.Infof( + "submitted transaction setRedemptionWatchtower with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + return transaction, nil + }, + ) + + b.nonceManager.IncrementNonce() + + return transaction, err +} + +// Non-mutating call, not a transaction submission. +func (b *Bridge) CallSetRedemptionWatchtower( + arg_redemptionWatchtower common.Address, + blockNumber *big.Int, +) error { + var result interface{} = nil + + err := chainutil.CallAtBlock( + b.transactorOptions.From, + blockNumber, nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "setRedemptionWatchtower", + &result, + arg_redemptionWatchtower, + ) + + return err +} + +func (b *Bridge) SetRedemptionWatchtowerGasEstimate( + arg_redemptionWatchtower common.Address, +) (uint64, error) { + var result uint64 + + result, err := chainutil.EstimateGas( + b.callerOptions.From, + b.contractAddress, + "setRedemptionWatchtower", + b.contractABI, + b.transactor, + arg_redemptionWatchtower, + ) + + return result, err +} + +// Transaction submission. +func (b *Bridge) SetReservationRouter( + arg__reservationRouter common.Address, + + transactionOptions ...chainutil.TransactionOptions, +) (*types.Transaction, error) { + bLogger.Debug( + "submitting transaction setReservationRouter", + " params: ", + fmt.Sprint( + arg__reservationRouter, + ), + ) + + b.transactionMutex.Lock() + defer b.transactionMutex.Unlock() + + // create a copy + transactorOptions := new(bind.TransactOpts) + *transactorOptions = *b.transactorOptions + + if len(transactionOptions) > 1 { + return nil, fmt.Errorf( + "could not process multiple transaction options sets", + ) + } else if len(transactionOptions) > 0 { + transactionOptions[0].Apply(transactorOptions) + } + + nonce, err := b.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } + + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) + + transaction, err := b.contract.SetReservationRouter( + transactorOptions, + arg__reservationRouter, + ) + if err != nil { + return transaction, b.errorResolver.ResolveError( + err, + b.transactorOptions.From, + nil, + "setReservationRouter", + arg__reservationRouter, + ) + } + + bLogger.Infof( + "submitted transaction setReservationRouter with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + go b.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + // If original transactor options has a non-zero gas limit, that + // means the client code set it on their own. In that case, we + // should rewrite the gas limit from the original transaction + // for each resubmission. If the gas limit is not set by the client + // code, let the the submitter re-estimate the gas limit on each + // resubmission. + if transactorOptions.GasLimit != 0 { + newTransactorOptions.GasLimit = transactorOptions.GasLimit + } + + transaction, err := b.contract.SetReservationRouter( + newTransactorOptions, + arg__reservationRouter, + ) + if err != nil { + return nil, b.errorResolver.ResolveError( + err, + b.transactorOptions.From, + nil, + "setReservationRouter", + arg__reservationRouter, + ) + } + + bLogger.Infof( + "submitted transaction setReservationRouter with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + return transaction, nil + }, + ) + + b.nonceManager.IncrementNonce() + + return transaction, err +} + +// Non-mutating call, not a transaction submission. +func (b *Bridge) CallSetReservationRouter( + arg__reservationRouter common.Address, + blockNumber *big.Int, +) error { + var result interface{} = nil + + err := chainutil.CallAtBlock( + b.transactorOptions.From, + blockNumber, nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "setReservationRouter", + &result, + arg__reservationRouter, + ) + + return err +} + +func (b *Bridge) SetReservationRouterGasEstimate( + arg__reservationRouter common.Address, +) (uint64, error) { + var result uint64 + + result, err := chainutil.EstimateGas( + b.callerOptions.From, + b.contractAddress, + "setReservationRouter", + b.contractABI, + b.transactor, + arg__reservationRouter, + ) + + return result, err +} + +// Transaction submission. +func (b *Bridge) SetSpvMaintainerStatus( + arg_spvMaintainer common.Address, + arg_isTrusted bool, + + transactionOptions ...chainutil.TransactionOptions, +) (*types.Transaction, error) { + bLogger.Debug( + "submitting transaction setSpvMaintainerStatus", " params: ", fmt.Sprint( arg_spvMaintainer, @@ -5961,6 +6499,43 @@ func (b *Bridge) FraudParametersAtBlock( return result, err } +func (b *Bridge) GetRebateStaking() (common.Address, error) { + result, err := b.contract.GetRebateStaking( + b.callerOptions, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "getRebateStaking", + ) + } + + return result, err +} + +func (b *Bridge) GetRebateStakingAtBlock( + blockNumber *big.Int, +) (common.Address, error) { + var result common.Address + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "getRebateStaking", + &result, + ) + + return result, err +} + func (b *Bridge) GetRedemptionWatchtower() (common.Address, error) { result, err := b.contract.GetRedemptionWatchtower( b.callerOptions, @@ -5998,6 +6573,43 @@ func (b *Bridge) GetRedemptionWatchtowerAtBlock( return result, err } +func (b *Bridge) GetReservationRouter() (common.Address, error) { + result, err := b.contract.GetReservationRouter( + b.callerOptions, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "getReservationRouter", + ) + } + + return result, err +} + +func (b *Bridge) GetReservationRouterAtBlock( + blockNumber *big.Int, +) (common.Address, error) { + var result common.Address + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "getReservationRouter", + &result, + ) + + return result, err +} + func (b *Bridge) Governance() (common.Address, error) { result, err := b.contract.Governance( b.callerOptions, @@ -6035,6 +6647,49 @@ func (b *Bridge) GovernanceAtBlock( return result, err } +func (b *Bridge) IsReservedDeposit( + arg_depositKey *big.Int, +) (bool, error) { + result, err := b.contract.IsReservedDeposit( + b.callerOptions, + arg_depositKey, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "isReservedDeposit", + arg_depositKey, + ) + } + + return result, err +} + +func (b *Bridge) IsReservedDepositAtBlock( + arg_depositKey *big.Int, + blockNumber *big.Int, +) (bool, error) { + var result bool + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "isReservedDeposit", + &result, + arg_depositKey, + ) + + return result, err +} + func (b *Bridge) IsVaultTrusted( arg_vault common.Address, ) (bool, error) { @@ -6949,6 +7604,196 @@ func (b *Bridge) PastDepositRevealedEvents( return events, nil } +func (b *Bridge) DepositVaultFixedEvent( + opts *ethereum.SubscribeOpts, + depositKeyFilter []*big.Int, +) *BDepositVaultFixedSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &BDepositVaultFixedSubscription{ + b, + opts, + depositKeyFilter, + } +} + +type BDepositVaultFixedSubscription struct { + contract *Bridge + opts *ethereum.SubscribeOpts + depositKeyFilter []*big.Int +} + +type bridgeDepositVaultFixedFunc func( + DepositKey *big.Int, + NewVault common.Address, + blockNumber uint64, +) + +func (dvfs *BDepositVaultFixedSubscription) OnEvent( + handler bridgeDepositVaultFixedFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.BridgeDepositVaultFixed) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.DepositKey, + event.NewVault, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := dvfs.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (dvfs *BDepositVaultFixedSubscription) Pipe( + sink chan *abi.BridgeDepositVaultFixed, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(dvfs.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := dvfs.contract.blockCounter.CurrentBlock() + if err != nil { + bLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - dvfs.opts.PastBlocks + + bLogger.Infof( + "subscription monitoring fetching past DepositVaultFixed events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := dvfs.contract.PastDepositVaultFixedEvents( + fromBlock, + nil, + dvfs.depositKeyFilter, + ) + if err != nil { + bLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + bLogger.Infof( + "subscription monitoring fetched [%v] past DepositVaultFixed events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := dvfs.contract.watchDepositVaultFixed( + sink, + dvfs.depositKeyFilter, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (b *Bridge) watchDepositVaultFixed( + sink chan *abi.BridgeDepositVaultFixed, + depositKeyFilter []*big.Int, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return b.contract.WatchDepositVaultFixed( + &bind.WatchOpts{Context: ctx}, + sink, + depositKeyFilter, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + bLogger.Warnf( + "subscription to event DepositVaultFixed had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + bLogger.Errorf( + "subscription to event DepositVaultFixed failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (b *Bridge) PastDepositVaultFixedEvents( + startBlock uint64, + endBlock *uint64, + depositKeyFilter []*big.Int, +) ([]*abi.BridgeDepositVaultFixed, error) { + iterator, err := b.contract.FilterDepositVaultFixed( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + depositKeyFilter, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past DepositVaultFixed events: [%v]", + err, + ) + } + + events := make([]*abi.BridgeDepositVaultFixed, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + func (b *Bridge) DepositsSweptEvent( opts *ethereum.SubscribeOpts, ) *BDepositsSweptSubscription { @@ -10154,6 +10999,366 @@ func (b *Bridge) PastNewWalletRequestedEvents( return events, nil } +func (b *Bridge) RebateStakingRepairedEvent( + opts *ethereum.SubscribeOpts, +) *BRebateStakingRepairedSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &BRebateStakingRepairedSubscription{ + b, + opts, + } +} + +type BRebateStakingRepairedSubscription struct { + contract *Bridge + opts *ethereum.SubscribeOpts +} + +type bridgeRebateStakingRepairedFunc func( + OldRebateStaking common.Address, + NewRebateStaking common.Address, + blockNumber uint64, +) + +func (rsrs *BRebateStakingRepairedSubscription) OnEvent( + handler bridgeRebateStakingRepairedFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.BridgeRebateStakingRepaired) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.OldRebateStaking, + event.NewRebateStaking, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := rsrs.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rsrs *BRebateStakingRepairedSubscription) Pipe( + sink chan *abi.BridgeRebateStakingRepaired, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(rsrs.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := rsrs.contract.blockCounter.CurrentBlock() + if err != nil { + bLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - rsrs.opts.PastBlocks + + bLogger.Infof( + "subscription monitoring fetching past RebateStakingRepaired events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := rsrs.contract.PastRebateStakingRepairedEvents( + fromBlock, + nil, + ) + if err != nil { + bLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + bLogger.Infof( + "subscription monitoring fetched [%v] past RebateStakingRepaired events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := rsrs.contract.watchRebateStakingRepaired( + sink, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (b *Bridge) watchRebateStakingRepaired( + sink chan *abi.BridgeRebateStakingRepaired, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return b.contract.WatchRebateStakingRepaired( + &bind.WatchOpts{Context: ctx}, + sink, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + bLogger.Warnf( + "subscription to event RebateStakingRepaired had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + bLogger.Errorf( + "subscription to event RebateStakingRepaired failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (b *Bridge) PastRebateStakingRepairedEvents( + startBlock uint64, + endBlock *uint64, +) ([]*abi.BridgeRebateStakingRepaired, error) { + iterator, err := b.contract.FilterRebateStakingRepaired( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past RebateStakingRepaired events: [%v]", + err, + ) + } + + events := make([]*abi.BridgeRebateStakingRepaired, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (b *Bridge) RebateStakingSetEvent( + opts *ethereum.SubscribeOpts, +) *BRebateStakingSetSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &BRebateStakingSetSubscription{ + b, + opts, + } +} + +type BRebateStakingSetSubscription struct { + contract *Bridge + opts *ethereum.SubscribeOpts +} + +type bridgeRebateStakingSetFunc func( + RebateStaking common.Address, + blockNumber uint64, +) + +func (rsss *BRebateStakingSetSubscription) OnEvent( + handler bridgeRebateStakingSetFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.BridgeRebateStakingSet) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.RebateStaking, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := rsss.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rsss *BRebateStakingSetSubscription) Pipe( + sink chan *abi.BridgeRebateStakingSet, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(rsss.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := rsss.contract.blockCounter.CurrentBlock() + if err != nil { + bLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - rsss.opts.PastBlocks + + bLogger.Infof( + "subscription monitoring fetching past RebateStakingSet events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := rsss.contract.PastRebateStakingSetEvents( + fromBlock, + nil, + ) + if err != nil { + bLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + bLogger.Infof( + "subscription monitoring fetched [%v] past RebateStakingSet events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := rsss.contract.watchRebateStakingSet( + sink, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (b *Bridge) watchRebateStakingSet( + sink chan *abi.BridgeRebateStakingSet, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return b.contract.WatchRebateStakingSet( + &bind.WatchOpts{Context: ctx}, + sink, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + bLogger.Warnf( + "subscription to event RebateStakingSet had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + bLogger.Errorf( + "subscription to event RebateStakingSet failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (b *Bridge) PastRebateStakingSetEvents( + startBlock uint64, + endBlock *uint64, +) ([]*abi.BridgeRebateStakingSet, error) { + iterator, err := b.contract.FilterRebateStakingSet( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past RebateStakingSet events: [%v]", + err, + ) + } + + events := make([]*abi.BridgeRebateStakingSet, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + func (b *Bridge) RedemptionParametersUpdatedEvent( opts *ethereum.SubscribeOpts, ) *BRedemptionParametersUpdatedSubscription { diff --git a/pkg/chain/ethereum/tbtc/gen/contract/RedemptionWatchtower.go b/pkg/chain/ethereum/tbtc/gen/contract/RedemptionWatchtower.go index 9e49a30418..bd40cd601a 100644 --- a/pkg/chain/ethereum/tbtc/gen/contract/RedemptionWatchtower.go +++ b/pkg/chain/ethereum/tbtc/gen/contract/RedemptionWatchtower.go @@ -2165,6 +2165,43 @@ func (rw *RedemptionWatchtower) OwnerAtBlock( return result, err } +func (rw *RedemptionWatchtower) REQUIREDOBJECTIONSCOUNT() (uint8, error) { + result, err := rw.contract.REQUIREDOBJECTIONSCOUNT( + rw.callerOptions, + ) + + if err != nil { + return result, rw.errorResolver.ResolveError( + err, + rw.callerOptions.From, + nil, + "rEQUIREDOBJECTIONSCOUNT", + ) + } + + return result, err +} + +func (rw *RedemptionWatchtower) REQUIREDOBJECTIONSCOUNTAtBlock( + blockNumber *big.Int, +) (uint8, error) { + var result uint8 + + err := chainutil.CallAtBlock( + rw.callerOptions.From, + blockNumber, + nil, + rw.contractABI, + rw.caller, + rw.errorResolver, + rw.contractAddress, + "rEQUIREDOBJECTIONSCOUNT", + &result, + ) + + return result, err +} + func (rw *RedemptionWatchtower) VetoFreezePeriod() (uint32, error) { result, err := rw.contract.VetoFreezePeriod( rw.callerOptions, diff --git a/pkg/chain/ethereum/tbtc/gen/contract/ReservationRouter.go b/pkg/chain/ethereum/tbtc/gen/contract/ReservationRouter.go new file mode 100644 index 0000000000..97b6eba015 --- /dev/null +++ b/pkg/chain/ethereum/tbtc/gen/contract/ReservationRouter.go @@ -0,0 +1,5187 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package contract + +import ( + "context" + "fmt" + "math/big" + "strings" + "sync" + "time" + + hostchainabi "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/accounts/keystore" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" + + "github.com/ipfs/go-log" + + "github.com/keep-network/keep-common/pkg/chain/ethereum" + chainutil "github.com/keep-network/keep-common/pkg/chain/ethereum/ethutil" + "github.com/keep-network/keep-common/pkg/subscription" + "github.com/keep-network/keep-core/pkg/chain/ethereum/tbtc/gen/abi" +) + +// Create a package-level logger for this contract. The logger exists at +// package level so that the logger is registered at startup and can be +// included or excluded from logging at startup by name. +var rrLogger = log.Logger("keep-contract-ReservationRouter") + +type ReservationRouter struct { + contract *abi.ReservationRouter + contractAddress common.Address + contractABI *hostchainabi.ABI + caller bind.ContractCaller + transactor bind.ContractTransactor + callerOptions *bind.CallOpts + transactorOptions *bind.TransactOpts + errorResolver *chainutil.ErrorResolver + nonceManager *ethereum.NonceManager + miningWaiter *chainutil.MiningWaiter + blockCounter *ethereum.BlockCounter + + transactionMutex *sync.Mutex +} + +func NewReservationRouter( + contractAddress common.Address, + chainId *big.Int, + accountKey *keystore.Key, + backend bind.ContractBackend, + nonceManager *ethereum.NonceManager, + miningWaiter *chainutil.MiningWaiter, + blockCounter *ethereum.BlockCounter, + transactionMutex *sync.Mutex, +) (*ReservationRouter, error) { + callerOptions := &bind.CallOpts{ + From: accountKey.Address, + } + + transactorOptions, err := bind.NewKeyedTransactorWithChainID( + accountKey.PrivateKey, + chainId, + ) + if err != nil { + return nil, fmt.Errorf("failed to instantiate transactor: [%v]", err) + } + + contract, err := abi.NewReservationRouter( + contractAddress, + backend, + ) + if err != nil { + return nil, fmt.Errorf( + "failed to instantiate contract at address: %s [%v]", + contractAddress.String(), + err, + ) + } + + contractABI, err := hostchainabi.JSON(strings.NewReader(abi.ReservationRouterABI)) + if err != nil { + return nil, fmt.Errorf("failed to instantiate ABI: [%v]", err) + } + + return &ReservationRouter{ + contract: contract, + contractAddress: contractAddress, + contractABI: &contractABI, + caller: backend, + transactor: backend, + callerOptions: callerOptions, + transactorOptions: transactorOptions, + errorResolver: chainutil.NewErrorResolver(backend, &contractABI, &contractAddress), + nonceManager: nonceManager, + miningWaiter: miningWaiter, + blockCounter: blockCounter, + transactionMutex: transactionMutex, + }, nil +} + +// ----- Non-const Methods ------ + +// Transaction submission. +func (rr *ReservationRouter) NotifyReservationActionTimeout( + arg_reservationKey *big.Int, + arg_walletMembersIDs []uint32, + + transactionOptions ...chainutil.TransactionOptions, +) (*types.Transaction, error) { + rrLogger.Debug( + "submitting transaction notifyReservationActionTimeout", + " params: ", + fmt.Sprint( + arg_reservationKey, + arg_walletMembersIDs, + ), + ) + + rr.transactionMutex.Lock() + defer rr.transactionMutex.Unlock() + + // create a copy + transactorOptions := new(bind.TransactOpts) + *transactorOptions = *rr.transactorOptions + + if len(transactionOptions) > 1 { + return nil, fmt.Errorf( + "could not process multiple transaction options sets", + ) + } else if len(transactionOptions) > 0 { + transactionOptions[0].Apply(transactorOptions) + } + + nonce, err := rr.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } + + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) + + transaction, err := rr.contract.NotifyReservationActionTimeout( + transactorOptions, + arg_reservationKey, + arg_walletMembersIDs, + ) + if err != nil { + return transaction, rr.errorResolver.ResolveError( + err, + rr.transactorOptions.From, + nil, + "notifyReservationActionTimeout", + arg_reservationKey, + arg_walletMembersIDs, + ) + } + + rrLogger.Infof( + "submitted transaction notifyReservationActionTimeout with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + go rr.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + // If original transactor options has a non-zero gas limit, that + // means the client code set it on their own. In that case, we + // should rewrite the gas limit from the original transaction + // for each resubmission. If the gas limit is not set by the client + // code, let the the submitter re-estimate the gas limit on each + // resubmission. + if transactorOptions.GasLimit != 0 { + newTransactorOptions.GasLimit = transactorOptions.GasLimit + } + + transaction, err := rr.contract.NotifyReservationActionTimeout( + newTransactorOptions, + arg_reservationKey, + arg_walletMembersIDs, + ) + if err != nil { + return nil, rr.errorResolver.ResolveError( + err, + rr.transactorOptions.From, + nil, + "notifyReservationActionTimeout", + arg_reservationKey, + arg_walletMembersIDs, + ) + } + + rrLogger.Infof( + "submitted transaction notifyReservationActionTimeout with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + return transaction, nil + }, + ) + + rr.nonceManager.IncrementNonce() + + return transaction, err +} + +// Non-mutating call, not a transaction submission. +func (rr *ReservationRouter) CallNotifyReservationActionTimeout( + arg_reservationKey *big.Int, + arg_walletMembersIDs []uint32, + blockNumber *big.Int, +) error { + var result interface{} = nil + + err := chainutil.CallAtBlock( + rr.transactorOptions.From, + blockNumber, nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "notifyReservationActionTimeout", + &result, + arg_reservationKey, + arg_walletMembersIDs, + ) + + return err +} + +func (rr *ReservationRouter) NotifyReservationActionTimeoutGasEstimate( + arg_reservationKey *big.Int, + arg_walletMembersIDs []uint32, +) (uint64, error) { + var result uint64 + + result, err := chainutil.EstimateGas( + rr.callerOptions.From, + rr.contractAddress, + "notifyReservationActionTimeout", + rr.contractABI, + rr.transactor, + arg_reservationKey, + arg_walletMembersIDs, + ) + + return result, err +} + +// Transaction submission. +func (rr *ReservationRouter) NotifyReservationStranded( + arg_reservationKey *big.Int, + + transactionOptions ...chainutil.TransactionOptions, +) (*types.Transaction, error) { + rrLogger.Debug( + "submitting transaction notifyReservationStranded", + " params: ", + fmt.Sprint( + arg_reservationKey, + ), + ) + + rr.transactionMutex.Lock() + defer rr.transactionMutex.Unlock() + + // create a copy + transactorOptions := new(bind.TransactOpts) + *transactorOptions = *rr.transactorOptions + + if len(transactionOptions) > 1 { + return nil, fmt.Errorf( + "could not process multiple transaction options sets", + ) + } else if len(transactionOptions) > 0 { + transactionOptions[0].Apply(transactorOptions) + } + + nonce, err := rr.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } + + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) + + transaction, err := rr.contract.NotifyReservationStranded( + transactorOptions, + arg_reservationKey, + ) + if err != nil { + return transaction, rr.errorResolver.ResolveError( + err, + rr.transactorOptions.From, + nil, + "notifyReservationStranded", + arg_reservationKey, + ) + } + + rrLogger.Infof( + "submitted transaction notifyReservationStranded with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + go rr.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + // If original transactor options has a non-zero gas limit, that + // means the client code set it on their own. In that case, we + // should rewrite the gas limit from the original transaction + // for each resubmission. If the gas limit is not set by the client + // code, let the the submitter re-estimate the gas limit on each + // resubmission. + if transactorOptions.GasLimit != 0 { + newTransactorOptions.GasLimit = transactorOptions.GasLimit + } + + transaction, err := rr.contract.NotifyReservationStranded( + newTransactorOptions, + arg_reservationKey, + ) + if err != nil { + return nil, rr.errorResolver.ResolveError( + err, + rr.transactorOptions.From, + nil, + "notifyReservationStranded", + arg_reservationKey, + ) + } + + rrLogger.Infof( + "submitted transaction notifyReservationStranded with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + return transaction, nil + }, + ) + + rr.nonceManager.IncrementNonce() + + return transaction, err +} + +// Non-mutating call, not a transaction submission. +func (rr *ReservationRouter) CallNotifyReservationStranded( + arg_reservationKey *big.Int, + blockNumber *big.Int, +) error { + var result interface{} = nil + + err := chainutil.CallAtBlock( + rr.transactorOptions.From, + blockNumber, nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "notifyReservationStranded", + &result, + arg_reservationKey, + ) + + return err +} + +func (rr *ReservationRouter) NotifyReservationStrandedGasEstimate( + arg_reservationKey *big.Int, +) (uint64, error) { + var result uint64 + + result, err := chainutil.EstimateGas( + rr.callerOptions.From, + rr.contractAddress, + "notifyReservationStranded", + rr.contractABI, + rr.transactor, + arg_reservationKey, + ) + + return result, err +} + +// Transaction submission. +func (rr *ReservationRouter) NotifyStaleReservedDeposit( + arg_depositKey *big.Int, + + transactionOptions ...chainutil.TransactionOptions, +) (*types.Transaction, error) { + rrLogger.Debug( + "submitting transaction notifyStaleReservedDeposit", + " params: ", + fmt.Sprint( + arg_depositKey, + ), + ) + + rr.transactionMutex.Lock() + defer rr.transactionMutex.Unlock() + + // create a copy + transactorOptions := new(bind.TransactOpts) + *transactorOptions = *rr.transactorOptions + + if len(transactionOptions) > 1 { + return nil, fmt.Errorf( + "could not process multiple transaction options sets", + ) + } else if len(transactionOptions) > 0 { + transactionOptions[0].Apply(transactorOptions) + } + + nonce, err := rr.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } + + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) + + transaction, err := rr.contract.NotifyStaleReservedDeposit( + transactorOptions, + arg_depositKey, + ) + if err != nil { + return transaction, rr.errorResolver.ResolveError( + err, + rr.transactorOptions.From, + nil, + "notifyStaleReservedDeposit", + arg_depositKey, + ) + } + + rrLogger.Infof( + "submitted transaction notifyStaleReservedDeposit with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + go rr.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + // If original transactor options has a non-zero gas limit, that + // means the client code set it on their own. In that case, we + // should rewrite the gas limit from the original transaction + // for each resubmission. If the gas limit is not set by the client + // code, let the the submitter re-estimate the gas limit on each + // resubmission. + if transactorOptions.GasLimit != 0 { + newTransactorOptions.GasLimit = transactorOptions.GasLimit + } + + transaction, err := rr.contract.NotifyStaleReservedDeposit( + newTransactorOptions, + arg_depositKey, + ) + if err != nil { + return nil, rr.errorResolver.ResolveError( + err, + rr.transactorOptions.From, + nil, + "notifyStaleReservedDeposit", + arg_depositKey, + ) + } + + rrLogger.Infof( + "submitted transaction notifyStaleReservedDeposit with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + return transaction, nil + }, + ) + + rr.nonceManager.IncrementNonce() + + return transaction, err +} + +// Non-mutating call, not a transaction submission. +func (rr *ReservationRouter) CallNotifyStaleReservedDeposit( + arg_depositKey *big.Int, + blockNumber *big.Int, +) error { + var result interface{} = nil + + err := chainutil.CallAtBlock( + rr.transactorOptions.From, + blockNumber, nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "notifyStaleReservedDeposit", + &result, + arg_depositKey, + ) + + return err +} + +func (rr *ReservationRouter) NotifyStaleReservedDepositGasEstimate( + arg_depositKey *big.Int, +) (uint64, error) { + var result uint64 + + result, err := chainutil.EstimateGas( + rr.callerOptions.From, + rr.contractAddress, + "notifyStaleReservedDeposit", + rr.contractABI, + rr.transactor, + arg_depositKey, + ) + + return result, err +} + +// Transaction submission. +func (rr *ReservationRouter) RequestReservationAcceptance( + arg_reservationKey *big.Int, + arg_walletPubKeyHash [20]byte, + + transactionOptions ...chainutil.TransactionOptions, +) (*types.Transaction, error) { + rrLogger.Debug( + "submitting transaction requestReservationAcceptance", + " params: ", + fmt.Sprint( + arg_reservationKey, + arg_walletPubKeyHash, + ), + ) + + rr.transactionMutex.Lock() + defer rr.transactionMutex.Unlock() + + // create a copy + transactorOptions := new(bind.TransactOpts) + *transactorOptions = *rr.transactorOptions + + if len(transactionOptions) > 1 { + return nil, fmt.Errorf( + "could not process multiple transaction options sets", + ) + } else if len(transactionOptions) > 0 { + transactionOptions[0].Apply(transactorOptions) + } + + nonce, err := rr.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } + + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) + + transaction, err := rr.contract.RequestReservationAcceptance( + transactorOptions, + arg_reservationKey, + arg_walletPubKeyHash, + ) + if err != nil { + return transaction, rr.errorResolver.ResolveError( + err, + rr.transactorOptions.From, + nil, + "requestReservationAcceptance", + arg_reservationKey, + arg_walletPubKeyHash, + ) + } + + rrLogger.Infof( + "submitted transaction requestReservationAcceptance with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + go rr.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + // If original transactor options has a non-zero gas limit, that + // means the client code set it on their own. In that case, we + // should rewrite the gas limit from the original transaction + // for each resubmission. If the gas limit is not set by the client + // code, let the the submitter re-estimate the gas limit on each + // resubmission. + if transactorOptions.GasLimit != 0 { + newTransactorOptions.GasLimit = transactorOptions.GasLimit + } + + transaction, err := rr.contract.RequestReservationAcceptance( + newTransactorOptions, + arg_reservationKey, + arg_walletPubKeyHash, + ) + if err != nil { + return nil, rr.errorResolver.ResolveError( + err, + rr.transactorOptions.From, + nil, + "requestReservationAcceptance", + arg_reservationKey, + arg_walletPubKeyHash, + ) + } + + rrLogger.Infof( + "submitted transaction requestReservationAcceptance with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + return transaction, nil + }, + ) + + rr.nonceManager.IncrementNonce() + + return transaction, err +} + +// Non-mutating call, not a transaction submission. +func (rr *ReservationRouter) CallRequestReservationAcceptance( + arg_reservationKey *big.Int, + arg_walletPubKeyHash [20]byte, + blockNumber *big.Int, +) error { + var result interface{} = nil + + err := chainutil.CallAtBlock( + rr.transactorOptions.From, + blockNumber, nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "requestReservationAcceptance", + &result, + arg_reservationKey, + arg_walletPubKeyHash, + ) + + return err +} + +func (rr *ReservationRouter) RequestReservationAcceptanceGasEstimate( + arg_reservationKey *big.Int, + arg_walletPubKeyHash [20]byte, +) (uint64, error) { + var result uint64 + + result, err := chainutil.EstimateGas( + rr.callerOptions.From, + rr.contractAddress, + "requestReservationAcceptance", + rr.contractABI, + rr.transactor, + arg_reservationKey, + arg_walletPubKeyHash, + ) + + return result, err +} + +// Transaction submission. +func (rr *ReservationRouter) RequestReservationReanchor( + arg_reservationKey *big.Int, + arg_targetWalletPubKeyHash [20]byte, + + transactionOptions ...chainutil.TransactionOptions, +) (*types.Transaction, error) { + rrLogger.Debug( + "submitting transaction requestReservationReanchor", + " params: ", + fmt.Sprint( + arg_reservationKey, + arg_targetWalletPubKeyHash, + ), + ) + + rr.transactionMutex.Lock() + defer rr.transactionMutex.Unlock() + + // create a copy + transactorOptions := new(bind.TransactOpts) + *transactorOptions = *rr.transactorOptions + + if len(transactionOptions) > 1 { + return nil, fmt.Errorf( + "could not process multiple transaction options sets", + ) + } else if len(transactionOptions) > 0 { + transactionOptions[0].Apply(transactorOptions) + } + + nonce, err := rr.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } + + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) + + transaction, err := rr.contract.RequestReservationReanchor( + transactorOptions, + arg_reservationKey, + arg_targetWalletPubKeyHash, + ) + if err != nil { + return transaction, rr.errorResolver.ResolveError( + err, + rr.transactorOptions.From, + nil, + "requestReservationReanchor", + arg_reservationKey, + arg_targetWalletPubKeyHash, + ) + } + + rrLogger.Infof( + "submitted transaction requestReservationReanchor with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + go rr.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + // If original transactor options has a non-zero gas limit, that + // means the client code set it on their own. In that case, we + // should rewrite the gas limit from the original transaction + // for each resubmission. If the gas limit is not set by the client + // code, let the the submitter re-estimate the gas limit on each + // resubmission. + if transactorOptions.GasLimit != 0 { + newTransactorOptions.GasLimit = transactorOptions.GasLimit + } + + transaction, err := rr.contract.RequestReservationReanchor( + newTransactorOptions, + arg_reservationKey, + arg_targetWalletPubKeyHash, + ) + if err != nil { + return nil, rr.errorResolver.ResolveError( + err, + rr.transactorOptions.From, + nil, + "requestReservationReanchor", + arg_reservationKey, + arg_targetWalletPubKeyHash, + ) + } + + rrLogger.Infof( + "submitted transaction requestReservationReanchor with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + return transaction, nil + }, + ) + + rr.nonceManager.IncrementNonce() + + return transaction, err +} + +// Non-mutating call, not a transaction submission. +func (rr *ReservationRouter) CallRequestReservationReanchor( + arg_reservationKey *big.Int, + arg_targetWalletPubKeyHash [20]byte, + blockNumber *big.Int, +) error { + var result interface{} = nil + + err := chainutil.CallAtBlock( + rr.transactorOptions.From, + blockNumber, nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "requestReservationReanchor", + &result, + arg_reservationKey, + arg_targetWalletPubKeyHash, + ) + + return err +} + +func (rr *ReservationRouter) RequestReservationReanchorGasEstimate( + arg_reservationKey *big.Int, + arg_targetWalletPubKeyHash [20]byte, +) (uint64, error) { + var result uint64 + + result, err := chainutil.EstimateGas( + rr.callerOptions.From, + rr.contractAddress, + "requestReservationReanchor", + rr.contractABI, + rr.transactor, + arg_reservationKey, + arg_targetWalletPubKeyHash, + ) + + return result, err +} + +// Transaction submission. +func (rr *ReservationRouter) SubmitReservationProof( + arg_proofType uint8, + arg_txInfo abi.BitcoinTxInfo4, + arg_proof abi.BitcoinTxProof3, + arg_mainUtxo abi.BitcoinTxUTXO4, + arg_reservationKey *big.Int, + arg_requestNonce uint64, + + transactionOptions ...chainutil.TransactionOptions, +) (*types.Transaction, error) { + rrLogger.Debug( + "submitting transaction submitReservationProof", + " params: ", + fmt.Sprint( + arg_proofType, + arg_txInfo, + arg_proof, + arg_mainUtxo, + arg_reservationKey, + arg_requestNonce, + ), + ) + + rr.transactionMutex.Lock() + defer rr.transactionMutex.Unlock() + + // create a copy + transactorOptions := new(bind.TransactOpts) + *transactorOptions = *rr.transactorOptions + + if len(transactionOptions) > 1 { + return nil, fmt.Errorf( + "could not process multiple transaction options sets", + ) + } else if len(transactionOptions) > 0 { + transactionOptions[0].Apply(transactorOptions) + } + + nonce, err := rr.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } + + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) + + transaction, err := rr.contract.SubmitReservationProof( + transactorOptions, + arg_proofType, + arg_txInfo, + arg_proof, + arg_mainUtxo, + arg_reservationKey, + arg_requestNonce, + ) + if err != nil { + return transaction, rr.errorResolver.ResolveError( + err, + rr.transactorOptions.From, + nil, + "submitReservationProof", + arg_proofType, + arg_txInfo, + arg_proof, + arg_mainUtxo, + arg_reservationKey, + arg_requestNonce, + ) + } + + rrLogger.Infof( + "submitted transaction submitReservationProof with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + go rr.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + // If original transactor options has a non-zero gas limit, that + // means the client code set it on their own. In that case, we + // should rewrite the gas limit from the original transaction + // for each resubmission. If the gas limit is not set by the client + // code, let the the submitter re-estimate the gas limit on each + // resubmission. + if transactorOptions.GasLimit != 0 { + newTransactorOptions.GasLimit = transactorOptions.GasLimit + } + + transaction, err := rr.contract.SubmitReservationProof( + newTransactorOptions, + arg_proofType, + arg_txInfo, + arg_proof, + arg_mainUtxo, + arg_reservationKey, + arg_requestNonce, + ) + if err != nil { + return nil, rr.errorResolver.ResolveError( + err, + rr.transactorOptions.From, + nil, + "submitReservationProof", + arg_proofType, + arg_txInfo, + arg_proof, + arg_mainUtxo, + arg_reservationKey, + arg_requestNonce, + ) + } + + rrLogger.Infof( + "submitted transaction submitReservationProof with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + return transaction, nil + }, + ) + + rr.nonceManager.IncrementNonce() + + return transaction, err +} + +// Non-mutating call, not a transaction submission. +func (rr *ReservationRouter) CallSubmitReservationProof( + arg_proofType uint8, + arg_txInfo abi.BitcoinTxInfo4, + arg_proof abi.BitcoinTxProof3, + arg_mainUtxo abi.BitcoinTxUTXO4, + arg_reservationKey *big.Int, + arg_requestNonce uint64, + blockNumber *big.Int, +) error { + var result interface{} = nil + + err := chainutil.CallAtBlock( + rr.transactorOptions.From, + blockNumber, nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "submitReservationProof", + &result, + arg_proofType, + arg_txInfo, + arg_proof, + arg_mainUtxo, + arg_reservationKey, + arg_requestNonce, + ) + + return err +} + +func (rr *ReservationRouter) SubmitReservationProofGasEstimate( + arg_proofType uint8, + arg_txInfo abi.BitcoinTxInfo4, + arg_proof abi.BitcoinTxProof3, + arg_mainUtxo abi.BitcoinTxUTXO4, + arg_reservationKey *big.Int, + arg_requestNonce uint64, +) (uint64, error) { + var result uint64 + + result, err := chainutil.EstimateGas( + rr.callerOptions.From, + rr.contractAddress, + "submitReservationProof", + rr.contractABI, + rr.transactor, + arg_proofType, + arg_txInfo, + arg_proof, + arg_mainUtxo, + arg_reservationKey, + arg_requestNonce, + ) + + return result, err +} + +// Transaction submission. +func (rr *ReservationRouter) TransferGovernance( + arg_newGovernance common.Address, + + transactionOptions ...chainutil.TransactionOptions, +) (*types.Transaction, error) { + rrLogger.Debug( + "submitting transaction transferGovernance", + " params: ", + fmt.Sprint( + arg_newGovernance, + ), + ) + + rr.transactionMutex.Lock() + defer rr.transactionMutex.Unlock() + + // create a copy + transactorOptions := new(bind.TransactOpts) + *transactorOptions = *rr.transactorOptions + + if len(transactionOptions) > 1 { + return nil, fmt.Errorf( + "could not process multiple transaction options sets", + ) + } else if len(transactionOptions) > 0 { + transactionOptions[0].Apply(transactorOptions) + } + + nonce, err := rr.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } + + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) + + transaction, err := rr.contract.TransferGovernance( + transactorOptions, + arg_newGovernance, + ) + if err != nil { + return transaction, rr.errorResolver.ResolveError( + err, + rr.transactorOptions.From, + nil, + "transferGovernance", + arg_newGovernance, + ) + } + + rrLogger.Infof( + "submitted transaction transferGovernance with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + go rr.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + // If original transactor options has a non-zero gas limit, that + // means the client code set it on their own. In that case, we + // should rewrite the gas limit from the original transaction + // for each resubmission. If the gas limit is not set by the client + // code, let the the submitter re-estimate the gas limit on each + // resubmission. + if transactorOptions.GasLimit != 0 { + newTransactorOptions.GasLimit = transactorOptions.GasLimit + } + + transaction, err := rr.contract.TransferGovernance( + newTransactorOptions, + arg_newGovernance, + ) + if err != nil { + return nil, rr.errorResolver.ResolveError( + err, + rr.transactorOptions.From, + nil, + "transferGovernance", + arg_newGovernance, + ) + } + + rrLogger.Infof( + "submitted transaction transferGovernance with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + return transaction, nil + }, + ) + + rr.nonceManager.IncrementNonce() + + return transaction, err +} + +// Non-mutating call, not a transaction submission. +func (rr *ReservationRouter) CallTransferGovernance( + arg_newGovernance common.Address, + blockNumber *big.Int, +) error { + var result interface{} = nil + + err := chainutil.CallAtBlock( + rr.transactorOptions.From, + blockNumber, nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "transferGovernance", + &result, + arg_newGovernance, + ) + + return err +} + +func (rr *ReservationRouter) TransferGovernanceGasEstimate( + arg_newGovernance common.Address, +) (uint64, error) { + var result uint64 + + result, err := chainutil.EstimateGas( + rr.callerOptions.From, + rr.contractAddress, + "transferGovernance", + rr.contractABI, + rr.transactor, + arg_newGovernance, + ) + + return result, err +} + +// Transaction submission. +func (rr *ReservationRouter) UpdateReservationCaps( + arg_maxReservationsAmountPerWallet uint64, + arg_reservationMaxSingleAmount uint64, + arg_maxActiveReservations uint32, + + transactionOptions ...chainutil.TransactionOptions, +) (*types.Transaction, error) { + rrLogger.Debug( + "submitting transaction updateReservationCaps", + " params: ", + fmt.Sprint( + arg_maxReservationsAmountPerWallet, + arg_reservationMaxSingleAmount, + arg_maxActiveReservations, + ), + ) + + rr.transactionMutex.Lock() + defer rr.transactionMutex.Unlock() + + // create a copy + transactorOptions := new(bind.TransactOpts) + *transactorOptions = *rr.transactorOptions + + if len(transactionOptions) > 1 { + return nil, fmt.Errorf( + "could not process multiple transaction options sets", + ) + } else if len(transactionOptions) > 0 { + transactionOptions[0].Apply(transactorOptions) + } + + nonce, err := rr.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } + + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) + + transaction, err := rr.contract.UpdateReservationCaps( + transactorOptions, + arg_maxReservationsAmountPerWallet, + arg_reservationMaxSingleAmount, + arg_maxActiveReservations, + ) + if err != nil { + return transaction, rr.errorResolver.ResolveError( + err, + rr.transactorOptions.From, + nil, + "updateReservationCaps", + arg_maxReservationsAmountPerWallet, + arg_reservationMaxSingleAmount, + arg_maxActiveReservations, + ) + } + + rrLogger.Infof( + "submitted transaction updateReservationCaps with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + go rr.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + // If original transactor options has a non-zero gas limit, that + // means the client code set it on their own. In that case, we + // should rewrite the gas limit from the original transaction + // for each resubmission. If the gas limit is not set by the client + // code, let the the submitter re-estimate the gas limit on each + // resubmission. + if transactorOptions.GasLimit != 0 { + newTransactorOptions.GasLimit = transactorOptions.GasLimit + } + + transaction, err := rr.contract.UpdateReservationCaps( + newTransactorOptions, + arg_maxReservationsAmountPerWallet, + arg_reservationMaxSingleAmount, + arg_maxActiveReservations, + ) + if err != nil { + return nil, rr.errorResolver.ResolveError( + err, + rr.transactorOptions.From, + nil, + "updateReservationCaps", + arg_maxReservationsAmountPerWallet, + arg_reservationMaxSingleAmount, + arg_maxActiveReservations, + ) + } + + rrLogger.Infof( + "submitted transaction updateReservationCaps with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + return transaction, nil + }, + ) + + rr.nonceManager.IncrementNonce() + + return transaction, err +} + +// Non-mutating call, not a transaction submission. +func (rr *ReservationRouter) CallUpdateReservationCaps( + arg_maxReservationsAmountPerWallet uint64, + arg_reservationMaxSingleAmount uint64, + arg_maxActiveReservations uint32, + blockNumber *big.Int, +) error { + var result interface{} = nil + + err := chainutil.CallAtBlock( + rr.transactorOptions.From, + blockNumber, nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "updateReservationCaps", + &result, + arg_maxReservationsAmountPerWallet, + arg_reservationMaxSingleAmount, + arg_maxActiveReservations, + ) + + return err +} + +func (rr *ReservationRouter) UpdateReservationCapsGasEstimate( + arg_maxReservationsAmountPerWallet uint64, + arg_reservationMaxSingleAmount uint64, + arg_maxActiveReservations uint32, +) (uint64, error) { + var result uint64 + + result, err := chainutil.EstimateGas( + rr.callerOptions.From, + rr.contractAddress, + "updateReservationCaps", + rr.contractABI, + rr.transactor, + arg_maxReservationsAmountPerWallet, + arg_reservationMaxSingleAmount, + arg_maxActiveReservations, + ) + + return result, err +} + +// Transaction submission. +func (rr *ReservationRouter) UpdateReservationParameters( + arg_reservationVault common.Address, + arg_reservationMinAmount uint64, + arg_reservationTxMaxFee uint64, + arg_reservationTermSeconds uint32, + arg_reservationDissolutionDelay uint32, + arg_reservationMaxTotalAmount uint64, + arg_maxReservationsPerWallet uint32, + arg_reservationActionTimeout uint32, + arg_reservationRenewalWindowSeconds uint32, + + transactionOptions ...chainutil.TransactionOptions, +) (*types.Transaction, error) { + rrLogger.Debug( + "submitting transaction updateReservationParameters", + " params: ", + fmt.Sprint( + arg_reservationVault, + arg_reservationMinAmount, + arg_reservationTxMaxFee, + arg_reservationTermSeconds, + arg_reservationDissolutionDelay, + arg_reservationMaxTotalAmount, + arg_maxReservationsPerWallet, + arg_reservationActionTimeout, + arg_reservationRenewalWindowSeconds, + ), + ) + + rr.transactionMutex.Lock() + defer rr.transactionMutex.Unlock() + + // create a copy + transactorOptions := new(bind.TransactOpts) + *transactorOptions = *rr.transactorOptions + + if len(transactionOptions) > 1 { + return nil, fmt.Errorf( + "could not process multiple transaction options sets", + ) + } else if len(transactionOptions) > 0 { + transactionOptions[0].Apply(transactorOptions) + } + + nonce, err := rr.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } + + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) + + transaction, err := rr.contract.UpdateReservationParameters( + transactorOptions, + arg_reservationVault, + arg_reservationMinAmount, + arg_reservationTxMaxFee, + arg_reservationTermSeconds, + arg_reservationDissolutionDelay, + arg_reservationMaxTotalAmount, + arg_maxReservationsPerWallet, + arg_reservationActionTimeout, + arg_reservationRenewalWindowSeconds, + ) + if err != nil { + return transaction, rr.errorResolver.ResolveError( + err, + rr.transactorOptions.From, + nil, + "updateReservationParameters", + arg_reservationVault, + arg_reservationMinAmount, + arg_reservationTxMaxFee, + arg_reservationTermSeconds, + arg_reservationDissolutionDelay, + arg_reservationMaxTotalAmount, + arg_maxReservationsPerWallet, + arg_reservationActionTimeout, + arg_reservationRenewalWindowSeconds, + ) + } + + rrLogger.Infof( + "submitted transaction updateReservationParameters with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + go rr.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + // If original transactor options has a non-zero gas limit, that + // means the client code set it on their own. In that case, we + // should rewrite the gas limit from the original transaction + // for each resubmission. If the gas limit is not set by the client + // code, let the the submitter re-estimate the gas limit on each + // resubmission. + if transactorOptions.GasLimit != 0 { + newTransactorOptions.GasLimit = transactorOptions.GasLimit + } + + transaction, err := rr.contract.UpdateReservationParameters( + newTransactorOptions, + arg_reservationVault, + arg_reservationMinAmount, + arg_reservationTxMaxFee, + arg_reservationTermSeconds, + arg_reservationDissolutionDelay, + arg_reservationMaxTotalAmount, + arg_maxReservationsPerWallet, + arg_reservationActionTimeout, + arg_reservationRenewalWindowSeconds, + ) + if err != nil { + return nil, rr.errorResolver.ResolveError( + err, + rr.transactorOptions.From, + nil, + "updateReservationParameters", + arg_reservationVault, + arg_reservationMinAmount, + arg_reservationTxMaxFee, + arg_reservationTermSeconds, + arg_reservationDissolutionDelay, + arg_reservationMaxTotalAmount, + arg_maxReservationsPerWallet, + arg_reservationActionTimeout, + arg_reservationRenewalWindowSeconds, + ) + } + + rrLogger.Infof( + "submitted transaction updateReservationParameters with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + return transaction, nil + }, + ) + + rr.nonceManager.IncrementNonce() + + return transaction, err +} + +// Non-mutating call, not a transaction submission. +func (rr *ReservationRouter) CallUpdateReservationParameters( + arg_reservationVault common.Address, + arg_reservationMinAmount uint64, + arg_reservationTxMaxFee uint64, + arg_reservationTermSeconds uint32, + arg_reservationDissolutionDelay uint32, + arg_reservationMaxTotalAmount uint64, + arg_maxReservationsPerWallet uint32, + arg_reservationActionTimeout uint32, + arg_reservationRenewalWindowSeconds uint32, + blockNumber *big.Int, +) error { + var result interface{} = nil + + err := chainutil.CallAtBlock( + rr.transactorOptions.From, + blockNumber, nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "updateReservationParameters", + &result, + arg_reservationVault, + arg_reservationMinAmount, + arg_reservationTxMaxFee, + arg_reservationTermSeconds, + arg_reservationDissolutionDelay, + arg_reservationMaxTotalAmount, + arg_maxReservationsPerWallet, + arg_reservationActionTimeout, + arg_reservationRenewalWindowSeconds, + ) + + return err +} + +func (rr *ReservationRouter) UpdateReservationParametersGasEstimate( + arg_reservationVault common.Address, + arg_reservationMinAmount uint64, + arg_reservationTxMaxFee uint64, + arg_reservationTermSeconds uint32, + arg_reservationDissolutionDelay uint32, + arg_reservationMaxTotalAmount uint64, + arg_maxReservationsPerWallet uint32, + arg_reservationActionTimeout uint32, + arg_reservationRenewalWindowSeconds uint32, +) (uint64, error) { + var result uint64 + + result, err := chainutil.EstimateGas( + rr.callerOptions.From, + rr.contractAddress, + "updateReservationParameters", + rr.contractABI, + rr.transactor, + arg_reservationVault, + arg_reservationMinAmount, + arg_reservationTxMaxFee, + arg_reservationTermSeconds, + arg_reservationDissolutionDelay, + arg_reservationMaxTotalAmount, + arg_maxReservationsPerWallet, + arg_reservationActionTimeout, + arg_reservationRenewalWindowSeconds, + ) + + return result, err +} + +// ----- Const Methods ------ + +type activeReservationsCount struct { + Count uint32 + MaxActive uint32 +} + +func (rr *ReservationRouter) ActiveReservationsCount() (activeReservationsCount, error) { + result, err := rr.contract.ActiveReservationsCount( + rr.callerOptions, + ) + + if err != nil { + return result, rr.errorResolver.ResolveError( + err, + rr.callerOptions.From, + nil, + "activeReservationsCount", + ) + } + + return result, err +} + +func (rr *ReservationRouter) ActiveReservationsCountAtBlock( + blockNumber *big.Int, +) (activeReservationsCount, error) { + var result activeReservationsCount + + err := chainutil.CallAtBlock( + rr.callerOptions.From, + blockNumber, + nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "activeReservationsCount", + &result, + ) + + return result, err +} + +func (rr *ReservationRouter) Governance() (common.Address, error) { + result, err := rr.contract.Governance( + rr.callerOptions, + ) + + if err != nil { + return result, rr.errorResolver.ResolveError( + err, + rr.callerOptions.From, + nil, + "governance", + ) + } + + return result, err +} + +func (rr *ReservationRouter) GovernanceAtBlock( + blockNumber *big.Int, +) (common.Address, error) { + var result common.Address + + err := chainutil.CallAtBlock( + rr.callerOptions.From, + blockNumber, + nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "governance", + &result, + ) + + return result, err +} + +func (rr *ReservationRouter) PendingReservedDeposits() (uint64, error) { + result, err := rr.contract.PendingReservedDeposits( + rr.callerOptions, + ) + + if err != nil { + return result, rr.errorResolver.ResolveError( + err, + rr.callerOptions.From, + nil, + "pendingReservedDeposits", + ) + } + + return result, err +} + +func (rr *ReservationRouter) PendingReservedDepositsAtBlock( + blockNumber *big.Int, +) (uint64, error) { + var result uint64 + + err := chainutil.CallAtBlock( + rr.callerOptions.From, + blockNumber, + nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "pendingReservedDeposits", + &result, + ) + + return result, err +} + +func (rr *ReservationRouter) ReservationActions( + arg_reservationKey *big.Int, + arg_requestNonce uint64, +) (abi.ReservationReservationAction, error) { + result, err := rr.contract.ReservationActions( + rr.callerOptions, + arg_reservationKey, + arg_requestNonce, + ) + + if err != nil { + return result, rr.errorResolver.ResolveError( + err, + rr.callerOptions.From, + nil, + "reservationActions", + arg_reservationKey, + arg_requestNonce, + ) + } + + return result, err +} + +func (rr *ReservationRouter) ReservationActionsAtBlock( + arg_reservationKey *big.Int, + arg_requestNonce uint64, + blockNumber *big.Int, +) (abi.ReservationReservationAction, error) { + var result abi.ReservationReservationAction + + err := chainutil.CallAtBlock( + rr.callerOptions.From, + blockNumber, + nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "reservationActions", + &result, + arg_reservationKey, + arg_requestNonce, + ) + + return result, err +} + +func (rr *ReservationRouter) ReservationByAnchorUtxo( + arg_anchorTxHash [32]byte, + arg_anchorTxOutputIndex uint32, +) (*big.Int, error) { + result, err := rr.contract.ReservationByAnchorUtxo( + rr.callerOptions, + arg_anchorTxHash, + arg_anchorTxOutputIndex, + ) + + if err != nil { + return result, rr.errorResolver.ResolveError( + err, + rr.callerOptions.From, + nil, + "reservationByAnchorUtxo", + arg_anchorTxHash, + arg_anchorTxOutputIndex, + ) + } + + return result, err +} + +func (rr *ReservationRouter) ReservationByAnchorUtxoAtBlock( + arg_anchorTxHash [32]byte, + arg_anchorTxOutputIndex uint32, + blockNumber *big.Int, +) (*big.Int, error) { + var result *big.Int + + err := chainutil.CallAtBlock( + rr.callerOptions.From, + blockNumber, + nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "reservationByAnchorUtxo", + &result, + arg_anchorTxHash, + arg_anchorTxOutputIndex, + ) + + return result, err +} + +type reservationCaps struct { + MaxReservationsAmountPerWallet uint64 + ReservationMaxSingleAmount uint64 +} + +func (rr *ReservationRouter) ReservationCaps() (reservationCaps, error) { + result, err := rr.contract.ReservationCaps( + rr.callerOptions, + ) + + if err != nil { + return result, rr.errorResolver.ResolveError( + err, + rr.callerOptions.From, + nil, + "reservationCaps", + ) + } + + return result, err +} + +func (rr *ReservationRouter) ReservationCapsAtBlock( + blockNumber *big.Int, +) (reservationCaps, error) { + var result reservationCaps + + err := chainutil.CallAtBlock( + rr.callerOptions.From, + blockNumber, + nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "reservationCaps", + &result, + ) + + return result, err +} + +type reservationParameters struct { + ReservationVault common.Address + ReservationMinAmount uint64 + ReservationTxMaxFee uint64 + ReservationTermSeconds uint32 + ReservationDissolutionDelay uint32 + ReservationMaxTotalAmount uint64 + ReservationTotalAmount uint64 + MaxReservationsPerWallet uint32 + ReservationActionTimeout uint32 + ReservationRenewalWindowSeconds uint32 +} + +func (rr *ReservationRouter) ReservationParameters() (reservationParameters, error) { + result, err := rr.contract.ReservationParameters( + rr.callerOptions, + ) + + if err != nil { + return result, rr.errorResolver.ResolveError( + err, + rr.callerOptions.From, + nil, + "reservationParameters", + ) + } + + return result, err +} + +func (rr *ReservationRouter) ReservationParametersAtBlock( + blockNumber *big.Int, +) (reservationParameters, error) { + var result reservationParameters + + err := chainutil.CallAtBlock( + rr.callerOptions.From, + blockNumber, + nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "reservationParameters", + &result, + ) + + return result, err +} + +func (rr *ReservationRouter) ReservationRouter() (common.Address, error) { + result, err := rr.contract.ReservationRouter( + rr.callerOptions, + ) + + if err != nil { + return result, rr.errorResolver.ResolveError( + err, + rr.callerOptions.From, + nil, + "reservationRouter", + ) + } + + return result, err +} + +func (rr *ReservationRouter) ReservationRouterAtBlock( + blockNumber *big.Int, +) (common.Address, error) { + var result common.Address + + err := chainutil.CallAtBlock( + rr.callerOptions.From, + blockNumber, + nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "reservationRouter", + &result, + ) + + return result, err +} + +func (rr *ReservationRouter) Reservations( + arg_reservationKey *big.Int, +) (abi.ReservationReservationRequest, error) { + result, err := rr.contract.Reservations( + rr.callerOptions, + arg_reservationKey, + ) + + if err != nil { + return result, rr.errorResolver.ResolveError( + err, + rr.callerOptions.From, + nil, + "reservations", + arg_reservationKey, + ) + } + + return result, err +} + +func (rr *ReservationRouter) ReservationsAtBlock( + arg_reservationKey *big.Int, + blockNumber *big.Int, +) (abi.ReservationReservationRequest, error) { + var result abi.ReservationReservationRequest + + err := chainutil.CallAtBlock( + rr.callerOptions.From, + blockNumber, + nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "reservations", + &result, + arg_reservationKey, + ) + + return result, err +} + +func (rr *ReservationRouter) ReservedDepositWallet( + arg_depositKey *big.Int, +) ([20]byte, error) { + result, err := rr.contract.ReservedDepositWallet( + rr.callerOptions, + arg_depositKey, + ) + + if err != nil { + return result, rr.errorResolver.ResolveError( + err, + rr.callerOptions.From, + nil, + "reservedDepositWallet", + arg_depositKey, + ) + } + + return result, err +} + +func (rr *ReservationRouter) ReservedDepositWalletAtBlock( + arg_depositKey *big.Int, + blockNumber *big.Int, +) ([20]byte, error) { + var result [20]byte + + err := chainutil.CallAtBlock( + rr.callerOptions.From, + blockNumber, + nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "reservedDepositWallet", + &result, + arg_depositKey, + ) + + return result, err +} + +func (rr *ReservationRouter) WalletReservations( + arg_walletPubKeyHash [20]byte, +) ([]*big.Int, error) { + result, err := rr.contract.WalletReservations( + rr.callerOptions, + arg_walletPubKeyHash, + ) + + if err != nil { + return result, rr.errorResolver.ResolveError( + err, + rr.callerOptions.From, + nil, + "walletReservations", + arg_walletPubKeyHash, + ) + } + + return result, err +} + +func (rr *ReservationRouter) WalletReservationsAtBlock( + arg_walletPubKeyHash [20]byte, + blockNumber *big.Int, +) ([]*big.Int, error) { + var result []*big.Int + + err := chainutil.CallAtBlock( + rr.callerOptions.From, + blockNumber, + nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "walletReservations", + &result, + arg_walletPubKeyHash, + ) + + return result, err +} + +func (rr *ReservationRouter) WalletReservationsAmount( + arg_walletPubKeyHash [20]byte, +) (uint64, error) { + result, err := rr.contract.WalletReservationsAmount( + rr.callerOptions, + arg_walletPubKeyHash, + ) + + if err != nil { + return result, rr.errorResolver.ResolveError( + err, + rr.callerOptions.From, + nil, + "walletReservationsAmount", + arg_walletPubKeyHash, + ) + } + + return result, err +} + +func (rr *ReservationRouter) WalletReservationsAmountAtBlock( + arg_walletPubKeyHash [20]byte, + blockNumber *big.Int, +) (uint64, error) { + var result uint64 + + err := chainutil.CallAtBlock( + rr.callerOptions.From, + blockNumber, + nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "walletReservationsAmount", + &result, + arg_walletPubKeyHash, + ) + + return result, err +} + +func (rr *ReservationRouter) WalletReservationsCount( + arg_walletPubKeyHash [20]byte, +) (uint32, error) { + result, err := rr.contract.WalletReservationsCount( + rr.callerOptions, + arg_walletPubKeyHash, + ) + + if err != nil { + return result, rr.errorResolver.ResolveError( + err, + rr.callerOptions.From, + nil, + "walletReservationsCount", + arg_walletPubKeyHash, + ) + } + + return result, err +} + +func (rr *ReservationRouter) WalletReservationsCountAtBlock( + arg_walletPubKeyHash [20]byte, + blockNumber *big.Int, +) (uint32, error) { + var result uint32 + + err := chainutil.CallAtBlock( + rr.callerOptions.From, + blockNumber, + nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "walletReservationsCount", + &result, + arg_walletPubKeyHash, + ) + + return result, err +} + +// ------ Events ------- + +func (rr *ReservationRouter) GovernanceTransferredEvent( + opts *ethereum.SubscribeOpts, +) *RrGovernanceTransferredSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &RrGovernanceTransferredSubscription{ + rr, + opts, + } +} + +type RrGovernanceTransferredSubscription struct { + contract *ReservationRouter + opts *ethereum.SubscribeOpts +} + +type reservationRouterGovernanceTransferredFunc func( + OldGovernance common.Address, + NewGovernance common.Address, + blockNumber uint64, +) + +func (gts *RrGovernanceTransferredSubscription) OnEvent( + handler reservationRouterGovernanceTransferredFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.ReservationRouterGovernanceTransferred) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.OldGovernance, + event.NewGovernance, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := gts.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (gts *RrGovernanceTransferredSubscription) Pipe( + sink chan *abi.ReservationRouterGovernanceTransferred, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(gts.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := gts.contract.blockCounter.CurrentBlock() + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - gts.opts.PastBlocks + + rrLogger.Infof( + "subscription monitoring fetching past GovernanceTransferred events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := gts.contract.PastGovernanceTransferredEvents( + fromBlock, + nil, + ) + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + rrLogger.Infof( + "subscription monitoring fetched [%v] past GovernanceTransferred events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := gts.contract.watchGovernanceTransferred( + sink, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rr *ReservationRouter) watchGovernanceTransferred( + sink chan *abi.ReservationRouterGovernanceTransferred, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return rr.contract.WatchGovernanceTransferred( + &bind.WatchOpts{Context: ctx}, + sink, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + rrLogger.Warnf( + "subscription to event GovernanceTransferred had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + rrLogger.Errorf( + "subscription to event GovernanceTransferred failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (rr *ReservationRouter) PastGovernanceTransferredEvents( + startBlock uint64, + endBlock *uint64, +) ([]*abi.ReservationRouterGovernanceTransferred, error) { + iterator, err := rr.contract.FilterGovernanceTransferred( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past GovernanceTransferred events: [%v]", + err, + ) + } + + events := make([]*abi.ReservationRouterGovernanceTransferred, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (rr *ReservationRouter) InitializedEvent( + opts *ethereum.SubscribeOpts, +) *RrInitializedSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &RrInitializedSubscription{ + rr, + opts, + } +} + +type RrInitializedSubscription struct { + contract *ReservationRouter + opts *ethereum.SubscribeOpts +} + +type reservationRouterInitializedFunc func( + Version uint8, + blockNumber uint64, +) + +func (is *RrInitializedSubscription) OnEvent( + handler reservationRouterInitializedFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.ReservationRouterInitialized) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.Version, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := is.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (is *RrInitializedSubscription) Pipe( + sink chan *abi.ReservationRouterInitialized, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(is.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := is.contract.blockCounter.CurrentBlock() + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - is.opts.PastBlocks + + rrLogger.Infof( + "subscription monitoring fetching past Initialized events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := is.contract.PastInitializedEvents( + fromBlock, + nil, + ) + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + rrLogger.Infof( + "subscription monitoring fetched [%v] past Initialized events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := is.contract.watchInitialized( + sink, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rr *ReservationRouter) watchInitialized( + sink chan *abi.ReservationRouterInitialized, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return rr.contract.WatchInitialized( + &bind.WatchOpts{Context: ctx}, + sink, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + rrLogger.Warnf( + "subscription to event Initialized had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + rrLogger.Errorf( + "subscription to event Initialized failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (rr *ReservationRouter) PastInitializedEvents( + startBlock uint64, + endBlock *uint64, +) ([]*abi.ReservationRouterInitialized, error) { + iterator, err := rr.contract.FilterInitialized( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past Initialized events: [%v]", + err, + ) + } + + events := make([]*abi.ReservationRouterInitialized, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (rr *ReservationRouter) ReservationAcceptanceRequestedEvent( + opts *ethereum.SubscribeOpts, + reservationKeyFilter []*big.Int, + walletPubKeyHashFilter [][20]byte, +) *RrReservationAcceptanceRequestedSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &RrReservationAcceptanceRequestedSubscription{ + rr, + opts, + reservationKeyFilter, + walletPubKeyHashFilter, + } +} + +type RrReservationAcceptanceRequestedSubscription struct { + contract *ReservationRouter + opts *ethereum.SubscribeOpts + reservationKeyFilter []*big.Int + walletPubKeyHashFilter [][20]byte +} + +type reservationRouterReservationAcceptanceRequestedFunc func( + ReservationKey *big.Int, + RequestNonce uint64, + WalletPubKeyHash [20]byte, + DepositAmount uint64, + TxMaxFee uint64, + TimeoutAt uint32, + blockNumber uint64, +) + +func (rars *RrReservationAcceptanceRequestedSubscription) OnEvent( + handler reservationRouterReservationAcceptanceRequestedFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.ReservationRouterReservationAcceptanceRequested) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.ReservationKey, + event.RequestNonce, + event.WalletPubKeyHash, + event.DepositAmount, + event.TxMaxFee, + event.TimeoutAt, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := rars.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rars *RrReservationAcceptanceRequestedSubscription) Pipe( + sink chan *abi.ReservationRouterReservationAcceptanceRequested, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(rars.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := rars.contract.blockCounter.CurrentBlock() + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - rars.opts.PastBlocks + + rrLogger.Infof( + "subscription monitoring fetching past ReservationAcceptanceRequested events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := rars.contract.PastReservationAcceptanceRequestedEvents( + fromBlock, + nil, + rars.reservationKeyFilter, + rars.walletPubKeyHashFilter, + ) + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + rrLogger.Infof( + "subscription monitoring fetched [%v] past ReservationAcceptanceRequested events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := rars.contract.watchReservationAcceptanceRequested( + sink, + rars.reservationKeyFilter, + rars.walletPubKeyHashFilter, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rr *ReservationRouter) watchReservationAcceptanceRequested( + sink chan *abi.ReservationRouterReservationAcceptanceRequested, + reservationKeyFilter []*big.Int, + walletPubKeyHashFilter [][20]byte, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return rr.contract.WatchReservationAcceptanceRequested( + &bind.WatchOpts{Context: ctx}, + sink, + reservationKeyFilter, + walletPubKeyHashFilter, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + rrLogger.Warnf( + "subscription to event ReservationAcceptanceRequested had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + rrLogger.Errorf( + "subscription to event ReservationAcceptanceRequested failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (rr *ReservationRouter) PastReservationAcceptanceRequestedEvents( + startBlock uint64, + endBlock *uint64, + reservationKeyFilter []*big.Int, + walletPubKeyHashFilter [][20]byte, +) ([]*abi.ReservationRouterReservationAcceptanceRequested, error) { + iterator, err := rr.contract.FilterReservationAcceptanceRequested( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + reservationKeyFilter, + walletPubKeyHashFilter, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past ReservationAcceptanceRequested events: [%v]", + err, + ) + } + + events := make([]*abi.ReservationRouterReservationAcceptanceRequested, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (rr *ReservationRouter) ReservationAcceptedEvent( + opts *ethereum.SubscribeOpts, + reservationKeyFilter []*big.Int, + walletPubKeyHashFilter [][20]byte, + ownerFilter []common.Address, +) *RrReservationAcceptedSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &RrReservationAcceptedSubscription{ + rr, + opts, + reservationKeyFilter, + walletPubKeyHashFilter, + ownerFilter, + } +} + +type RrReservationAcceptedSubscription struct { + contract *ReservationRouter + opts *ethereum.SubscribeOpts + reservationKeyFilter []*big.Int + walletPubKeyHashFilter [][20]byte + ownerFilter []common.Address +} + +type reservationRouterReservationAcceptedFunc func( + ReservationKey *big.Int, + RequestNonce uint64, + WalletPubKeyHash [20]byte, + Owner common.Address, + AnchorTxHash [32]byte, + AnchorAmount uint64, + ExpiresAt uint32, + blockNumber uint64, +) + +func (ras *RrReservationAcceptedSubscription) OnEvent( + handler reservationRouterReservationAcceptedFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.ReservationRouterReservationAccepted) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.ReservationKey, + event.RequestNonce, + event.WalletPubKeyHash, + event.Owner, + event.AnchorTxHash, + event.AnchorAmount, + event.ExpiresAt, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := ras.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (ras *RrReservationAcceptedSubscription) Pipe( + sink chan *abi.ReservationRouterReservationAccepted, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(ras.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := ras.contract.blockCounter.CurrentBlock() + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - ras.opts.PastBlocks + + rrLogger.Infof( + "subscription monitoring fetching past ReservationAccepted events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := ras.contract.PastReservationAcceptedEvents( + fromBlock, + nil, + ras.reservationKeyFilter, + ras.walletPubKeyHashFilter, + ras.ownerFilter, + ) + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + rrLogger.Infof( + "subscription monitoring fetched [%v] past ReservationAccepted events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := ras.contract.watchReservationAccepted( + sink, + ras.reservationKeyFilter, + ras.walletPubKeyHashFilter, + ras.ownerFilter, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rr *ReservationRouter) watchReservationAccepted( + sink chan *abi.ReservationRouterReservationAccepted, + reservationKeyFilter []*big.Int, + walletPubKeyHashFilter [][20]byte, + ownerFilter []common.Address, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return rr.contract.WatchReservationAccepted( + &bind.WatchOpts{Context: ctx}, + sink, + reservationKeyFilter, + walletPubKeyHashFilter, + ownerFilter, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + rrLogger.Warnf( + "subscription to event ReservationAccepted had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + rrLogger.Errorf( + "subscription to event ReservationAccepted failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (rr *ReservationRouter) PastReservationAcceptedEvents( + startBlock uint64, + endBlock *uint64, + reservationKeyFilter []*big.Int, + walletPubKeyHashFilter [][20]byte, + ownerFilter []common.Address, +) ([]*abi.ReservationRouterReservationAccepted, error) { + iterator, err := rr.contract.FilterReservationAccepted( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + reservationKeyFilter, + walletPubKeyHashFilter, + ownerFilter, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past ReservationAccepted events: [%v]", + err, + ) + } + + events := make([]*abi.ReservationRouterReservationAccepted, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (rr *ReservationRouter) ReservationActionSupersededEvent( + opts *ethereum.SubscribeOpts, + reservationKeyFilter []*big.Int, +) *RrReservationActionSupersededSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &RrReservationActionSupersededSubscription{ + rr, + opts, + reservationKeyFilter, + } +} + +type RrReservationActionSupersededSubscription struct { + contract *ReservationRouter + opts *ethereum.SubscribeOpts + reservationKeyFilter []*big.Int +} + +type reservationRouterReservationActionSupersededFunc func( + ReservationKey *big.Int, + RequestNonce uint64, + blockNumber uint64, +) + +func (rass *RrReservationActionSupersededSubscription) OnEvent( + handler reservationRouterReservationActionSupersededFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.ReservationRouterReservationActionSuperseded) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.ReservationKey, + event.RequestNonce, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := rass.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rass *RrReservationActionSupersededSubscription) Pipe( + sink chan *abi.ReservationRouterReservationActionSuperseded, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(rass.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := rass.contract.blockCounter.CurrentBlock() + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - rass.opts.PastBlocks + + rrLogger.Infof( + "subscription monitoring fetching past ReservationActionSuperseded events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := rass.contract.PastReservationActionSupersededEvents( + fromBlock, + nil, + rass.reservationKeyFilter, + ) + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + rrLogger.Infof( + "subscription monitoring fetched [%v] past ReservationActionSuperseded events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := rass.contract.watchReservationActionSuperseded( + sink, + rass.reservationKeyFilter, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rr *ReservationRouter) watchReservationActionSuperseded( + sink chan *abi.ReservationRouterReservationActionSuperseded, + reservationKeyFilter []*big.Int, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return rr.contract.WatchReservationActionSuperseded( + &bind.WatchOpts{Context: ctx}, + sink, + reservationKeyFilter, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + rrLogger.Warnf( + "subscription to event ReservationActionSuperseded had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + rrLogger.Errorf( + "subscription to event ReservationActionSuperseded failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (rr *ReservationRouter) PastReservationActionSupersededEvents( + startBlock uint64, + endBlock *uint64, + reservationKeyFilter []*big.Int, +) ([]*abi.ReservationRouterReservationActionSuperseded, error) { + iterator, err := rr.contract.FilterReservationActionSuperseded( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + reservationKeyFilter, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past ReservationActionSuperseded events: [%v]", + err, + ) + } + + events := make([]*abi.ReservationRouterReservationActionSuperseded, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (rr *ReservationRouter) ReservationActionTimedOutEvent( + opts *ethereum.SubscribeOpts, + reservationKeyFilter []*big.Int, +) *RrReservationActionTimedOutSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &RrReservationActionTimedOutSubscription{ + rr, + opts, + reservationKeyFilter, + } +} + +type RrReservationActionTimedOutSubscription struct { + contract *ReservationRouter + opts *ethereum.SubscribeOpts + reservationKeyFilter []*big.Int +} + +type reservationRouterReservationActionTimedOutFunc func( + ReservationKey *big.Int, + RequestNonce uint64, + ActionType uint8, + blockNumber uint64, +) + +func (ratos *RrReservationActionTimedOutSubscription) OnEvent( + handler reservationRouterReservationActionTimedOutFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.ReservationRouterReservationActionTimedOut) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.ReservationKey, + event.RequestNonce, + event.ActionType, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := ratos.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (ratos *RrReservationActionTimedOutSubscription) Pipe( + sink chan *abi.ReservationRouterReservationActionTimedOut, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(ratos.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := ratos.contract.blockCounter.CurrentBlock() + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - ratos.opts.PastBlocks + + rrLogger.Infof( + "subscription monitoring fetching past ReservationActionTimedOut events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := ratos.contract.PastReservationActionTimedOutEvents( + fromBlock, + nil, + ratos.reservationKeyFilter, + ) + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + rrLogger.Infof( + "subscription monitoring fetched [%v] past ReservationActionTimedOut events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := ratos.contract.watchReservationActionTimedOut( + sink, + ratos.reservationKeyFilter, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rr *ReservationRouter) watchReservationActionTimedOut( + sink chan *abi.ReservationRouterReservationActionTimedOut, + reservationKeyFilter []*big.Int, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return rr.contract.WatchReservationActionTimedOut( + &bind.WatchOpts{Context: ctx}, + sink, + reservationKeyFilter, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + rrLogger.Warnf( + "subscription to event ReservationActionTimedOut had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + rrLogger.Errorf( + "subscription to event ReservationActionTimedOut failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (rr *ReservationRouter) PastReservationActionTimedOutEvents( + startBlock uint64, + endBlock *uint64, + reservationKeyFilter []*big.Int, +) ([]*abi.ReservationRouterReservationActionTimedOut, error) { + iterator, err := rr.contract.FilterReservationActionTimedOut( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + reservationKeyFilter, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past ReservationActionTimedOut events: [%v]", + err, + ) + } + + events := make([]*abi.ReservationRouterReservationActionTimedOut, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (rr *ReservationRouter) ReservationCapsUpdatedEvent( + opts *ethereum.SubscribeOpts, +) *RrReservationCapsUpdatedSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &RrReservationCapsUpdatedSubscription{ + rr, + opts, + } +} + +type RrReservationCapsUpdatedSubscription struct { + contract *ReservationRouter + opts *ethereum.SubscribeOpts +} + +type reservationRouterReservationCapsUpdatedFunc func( + MaxReservationsAmountPerWallet uint64, + ReservationMaxSingleAmount uint64, + MaxActiveReservations uint32, + blockNumber uint64, +) + +func (rcus *RrReservationCapsUpdatedSubscription) OnEvent( + handler reservationRouterReservationCapsUpdatedFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.ReservationRouterReservationCapsUpdated) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.MaxReservationsAmountPerWallet, + event.ReservationMaxSingleAmount, + event.MaxActiveReservations, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := rcus.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rcus *RrReservationCapsUpdatedSubscription) Pipe( + sink chan *abi.ReservationRouterReservationCapsUpdated, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(rcus.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := rcus.contract.blockCounter.CurrentBlock() + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - rcus.opts.PastBlocks + + rrLogger.Infof( + "subscription monitoring fetching past ReservationCapsUpdated events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := rcus.contract.PastReservationCapsUpdatedEvents( + fromBlock, + nil, + ) + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + rrLogger.Infof( + "subscription monitoring fetched [%v] past ReservationCapsUpdated events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := rcus.contract.watchReservationCapsUpdated( + sink, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rr *ReservationRouter) watchReservationCapsUpdated( + sink chan *abi.ReservationRouterReservationCapsUpdated, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return rr.contract.WatchReservationCapsUpdated( + &bind.WatchOpts{Context: ctx}, + sink, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + rrLogger.Warnf( + "subscription to event ReservationCapsUpdated had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + rrLogger.Errorf( + "subscription to event ReservationCapsUpdated failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (rr *ReservationRouter) PastReservationCapsUpdatedEvents( + startBlock uint64, + endBlock *uint64, +) ([]*abi.ReservationRouterReservationCapsUpdated, error) { + iterator, err := rr.contract.FilterReservationCapsUpdated( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past ReservationCapsUpdated events: [%v]", + err, + ) + } + + events := make([]*abi.ReservationRouterReservationCapsUpdated, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (rr *ReservationRouter) ReservationLateSettledEvent( + opts *ethereum.SubscribeOpts, + reservationKeyFilter []*big.Int, +) *RrReservationLateSettledSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &RrReservationLateSettledSubscription{ + rr, + opts, + reservationKeyFilter, + } +} + +type RrReservationLateSettledSubscription struct { + contract *ReservationRouter + opts *ethereum.SubscribeOpts + reservationKeyFilter []*big.Int +} + +type reservationRouterReservationLateSettledFunc func( + ReservationKey *big.Int, + RequestNonce uint64, + ActionType uint8, + blockNumber uint64, +) + +func (rlss *RrReservationLateSettledSubscription) OnEvent( + handler reservationRouterReservationLateSettledFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.ReservationRouterReservationLateSettled) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.ReservationKey, + event.RequestNonce, + event.ActionType, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := rlss.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rlss *RrReservationLateSettledSubscription) Pipe( + sink chan *abi.ReservationRouterReservationLateSettled, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(rlss.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := rlss.contract.blockCounter.CurrentBlock() + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - rlss.opts.PastBlocks + + rrLogger.Infof( + "subscription monitoring fetching past ReservationLateSettled events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := rlss.contract.PastReservationLateSettledEvents( + fromBlock, + nil, + rlss.reservationKeyFilter, + ) + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + rrLogger.Infof( + "subscription monitoring fetched [%v] past ReservationLateSettled events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := rlss.contract.watchReservationLateSettled( + sink, + rlss.reservationKeyFilter, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rr *ReservationRouter) watchReservationLateSettled( + sink chan *abi.ReservationRouterReservationLateSettled, + reservationKeyFilter []*big.Int, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return rr.contract.WatchReservationLateSettled( + &bind.WatchOpts{Context: ctx}, + sink, + reservationKeyFilter, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + rrLogger.Warnf( + "subscription to event ReservationLateSettled had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + rrLogger.Errorf( + "subscription to event ReservationLateSettled failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (rr *ReservationRouter) PastReservationLateSettledEvents( + startBlock uint64, + endBlock *uint64, + reservationKeyFilter []*big.Int, +) ([]*abi.ReservationRouterReservationLateSettled, error) { + iterator, err := rr.contract.FilterReservationLateSettled( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + reservationKeyFilter, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past ReservationLateSettled events: [%v]", + err, + ) + } + + events := make([]*abi.ReservationRouterReservationLateSettled, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (rr *ReservationRouter) ReservationParametersUpdatedEvent( + opts *ethereum.SubscribeOpts, +) *RrReservationParametersUpdatedSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &RrReservationParametersUpdatedSubscription{ + rr, + opts, + } +} + +type RrReservationParametersUpdatedSubscription struct { + contract *ReservationRouter + opts *ethereum.SubscribeOpts +} + +type reservationRouterReservationParametersUpdatedFunc func( + ReservationMinAmount uint64, + ReservationTxMaxFee uint64, + ReservationTermSeconds uint32, + ReservationDissolutionDelay uint32, + ReservationMaxTotalAmount uint64, + MaxReservationsPerWallet uint32, + ReservationActionTimeout uint32, + ReservationRenewalWindowSeconds uint32, + blockNumber uint64, +) + +func (rpus *RrReservationParametersUpdatedSubscription) OnEvent( + handler reservationRouterReservationParametersUpdatedFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.ReservationRouterReservationParametersUpdated) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.ReservationMinAmount, + event.ReservationTxMaxFee, + event.ReservationTermSeconds, + event.ReservationDissolutionDelay, + event.ReservationMaxTotalAmount, + event.MaxReservationsPerWallet, + event.ReservationActionTimeout, + event.ReservationRenewalWindowSeconds, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := rpus.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rpus *RrReservationParametersUpdatedSubscription) Pipe( + sink chan *abi.ReservationRouterReservationParametersUpdated, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(rpus.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := rpus.contract.blockCounter.CurrentBlock() + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - rpus.opts.PastBlocks + + rrLogger.Infof( + "subscription monitoring fetching past ReservationParametersUpdated events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := rpus.contract.PastReservationParametersUpdatedEvents( + fromBlock, + nil, + ) + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + rrLogger.Infof( + "subscription monitoring fetched [%v] past ReservationParametersUpdated events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := rpus.contract.watchReservationParametersUpdated( + sink, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rr *ReservationRouter) watchReservationParametersUpdated( + sink chan *abi.ReservationRouterReservationParametersUpdated, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return rr.contract.WatchReservationParametersUpdated( + &bind.WatchOpts{Context: ctx}, + sink, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + rrLogger.Warnf( + "subscription to event ReservationParametersUpdated had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + rrLogger.Errorf( + "subscription to event ReservationParametersUpdated failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (rr *ReservationRouter) PastReservationParametersUpdatedEvents( + startBlock uint64, + endBlock *uint64, +) ([]*abi.ReservationRouterReservationParametersUpdated, error) { + iterator, err := rr.contract.FilterReservationParametersUpdated( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past ReservationParametersUpdated events: [%v]", + err, + ) + } + + events := make([]*abi.ReservationRouterReservationParametersUpdated, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (rr *ReservationRouter) ReservationReanchorRequestedEvent( + opts *ethereum.SubscribeOpts, + reservationKeyFilter []*big.Int, + sourceWalletPubKeyHashFilter [][20]byte, + targetWalletPubKeyHashFilter [][20]byte, +) *RrReservationReanchorRequestedSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &RrReservationReanchorRequestedSubscription{ + rr, + opts, + reservationKeyFilter, + sourceWalletPubKeyHashFilter, + targetWalletPubKeyHashFilter, + } +} + +type RrReservationReanchorRequestedSubscription struct { + contract *ReservationRouter + opts *ethereum.SubscribeOpts + reservationKeyFilter []*big.Int + sourceWalletPubKeyHashFilter [][20]byte + targetWalletPubKeyHashFilter [][20]byte +} + +type reservationRouterReservationReanchorRequestedFunc func( + ReservationKey *big.Int, + RequestNonce uint64, + SourceWalletPubKeyHash [20]byte, + TargetWalletPubKeyHash [20]byte, + TxMaxFee uint64, + blockNumber uint64, +) + +func (rrrs *RrReservationReanchorRequestedSubscription) OnEvent( + handler reservationRouterReservationReanchorRequestedFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.ReservationRouterReservationReanchorRequested) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.ReservationKey, + event.RequestNonce, + event.SourceWalletPubKeyHash, + event.TargetWalletPubKeyHash, + event.TxMaxFee, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := rrrs.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rrrs *RrReservationReanchorRequestedSubscription) Pipe( + sink chan *abi.ReservationRouterReservationReanchorRequested, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(rrrs.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := rrrs.contract.blockCounter.CurrentBlock() + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - rrrs.opts.PastBlocks + + rrLogger.Infof( + "subscription monitoring fetching past ReservationReanchorRequested events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := rrrs.contract.PastReservationReanchorRequestedEvents( + fromBlock, + nil, + rrrs.reservationKeyFilter, + rrrs.sourceWalletPubKeyHashFilter, + rrrs.targetWalletPubKeyHashFilter, + ) + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + rrLogger.Infof( + "subscription monitoring fetched [%v] past ReservationReanchorRequested events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := rrrs.contract.watchReservationReanchorRequested( + sink, + rrrs.reservationKeyFilter, + rrrs.sourceWalletPubKeyHashFilter, + rrrs.targetWalletPubKeyHashFilter, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rr *ReservationRouter) watchReservationReanchorRequested( + sink chan *abi.ReservationRouterReservationReanchorRequested, + reservationKeyFilter []*big.Int, + sourceWalletPubKeyHashFilter [][20]byte, + targetWalletPubKeyHashFilter [][20]byte, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return rr.contract.WatchReservationReanchorRequested( + &bind.WatchOpts{Context: ctx}, + sink, + reservationKeyFilter, + sourceWalletPubKeyHashFilter, + targetWalletPubKeyHashFilter, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + rrLogger.Warnf( + "subscription to event ReservationReanchorRequested had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + rrLogger.Errorf( + "subscription to event ReservationReanchorRequested failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (rr *ReservationRouter) PastReservationReanchorRequestedEvents( + startBlock uint64, + endBlock *uint64, + reservationKeyFilter []*big.Int, + sourceWalletPubKeyHashFilter [][20]byte, + targetWalletPubKeyHashFilter [][20]byte, +) ([]*abi.ReservationRouterReservationReanchorRequested, error) { + iterator, err := rr.contract.FilterReservationReanchorRequested( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + reservationKeyFilter, + sourceWalletPubKeyHashFilter, + targetWalletPubKeyHashFilter, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past ReservationReanchorRequested events: [%v]", + err, + ) + } + + events := make([]*abi.ReservationRouterReservationReanchorRequested, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (rr *ReservationRouter) ReservationReanchoredEvent( + opts *ethereum.SubscribeOpts, + reservationKeyFilter []*big.Int, + newWalletPubKeyHashFilter [][20]byte, +) *RrReservationReanchoredSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &RrReservationReanchoredSubscription{ + rr, + opts, + reservationKeyFilter, + newWalletPubKeyHashFilter, + } +} + +type RrReservationReanchoredSubscription struct { + contract *ReservationRouter + opts *ethereum.SubscribeOpts + reservationKeyFilter []*big.Int + newWalletPubKeyHashFilter [][20]byte +} + +type reservationRouterReservationReanchoredFunc func( + ReservationKey *big.Int, + RequestNonce uint64, + NewWalletPubKeyHash [20]byte, + NewAnchorTxHash [32]byte, + NewAnchorAmount uint64, + blockNumber uint64, +) + +func (rrs *RrReservationReanchoredSubscription) OnEvent( + handler reservationRouterReservationReanchoredFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.ReservationRouterReservationReanchored) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.ReservationKey, + event.RequestNonce, + event.NewWalletPubKeyHash, + event.NewAnchorTxHash, + event.NewAnchorAmount, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := rrs.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rrs *RrReservationReanchoredSubscription) Pipe( + sink chan *abi.ReservationRouterReservationReanchored, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(rrs.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := rrs.contract.blockCounter.CurrentBlock() + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - rrs.opts.PastBlocks + + rrLogger.Infof( + "subscription monitoring fetching past ReservationReanchored events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := rrs.contract.PastReservationReanchoredEvents( + fromBlock, + nil, + rrs.reservationKeyFilter, + rrs.newWalletPubKeyHashFilter, + ) + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + rrLogger.Infof( + "subscription monitoring fetched [%v] past ReservationReanchored events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := rrs.contract.watchReservationReanchored( + sink, + rrs.reservationKeyFilter, + rrs.newWalletPubKeyHashFilter, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rr *ReservationRouter) watchReservationReanchored( + sink chan *abi.ReservationRouterReservationReanchored, + reservationKeyFilter []*big.Int, + newWalletPubKeyHashFilter [][20]byte, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return rr.contract.WatchReservationReanchored( + &bind.WatchOpts{Context: ctx}, + sink, + reservationKeyFilter, + newWalletPubKeyHashFilter, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + rrLogger.Warnf( + "subscription to event ReservationReanchored had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + rrLogger.Errorf( + "subscription to event ReservationReanchored failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (rr *ReservationRouter) PastReservationReanchoredEvents( + startBlock uint64, + endBlock *uint64, + reservationKeyFilter []*big.Int, + newWalletPubKeyHashFilter [][20]byte, +) ([]*abi.ReservationRouterReservationReanchored, error) { + iterator, err := rr.contract.FilterReservationReanchored( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + reservationKeyFilter, + newWalletPubKeyHashFilter, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past ReservationReanchored events: [%v]", + err, + ) + } + + events := make([]*abi.ReservationRouterReservationReanchored, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (rr *ReservationRouter) ReservationRetryCreditMintedEvent( + opts *ethereum.SubscribeOpts, + reservationKeyFilter []*big.Int, +) *RrReservationRetryCreditMintedSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &RrReservationRetryCreditMintedSubscription{ + rr, + opts, + reservationKeyFilter, + } +} + +type RrReservationRetryCreditMintedSubscription struct { + contract *ReservationRouter + opts *ethereum.SubscribeOpts + reservationKeyFilter []*big.Int +} + +type reservationRouterReservationRetryCreditMintedFunc func( + ReservationKey *big.Int, + blockNumber uint64, +) + +func (rrcms *RrReservationRetryCreditMintedSubscription) OnEvent( + handler reservationRouterReservationRetryCreditMintedFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.ReservationRouterReservationRetryCreditMinted) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.ReservationKey, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := rrcms.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rrcms *RrReservationRetryCreditMintedSubscription) Pipe( + sink chan *abi.ReservationRouterReservationRetryCreditMinted, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(rrcms.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := rrcms.contract.blockCounter.CurrentBlock() + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - rrcms.opts.PastBlocks + + rrLogger.Infof( + "subscription monitoring fetching past ReservationRetryCreditMinted events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := rrcms.contract.PastReservationRetryCreditMintedEvents( + fromBlock, + nil, + rrcms.reservationKeyFilter, + ) + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + rrLogger.Infof( + "subscription monitoring fetched [%v] past ReservationRetryCreditMinted events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := rrcms.contract.watchReservationRetryCreditMinted( + sink, + rrcms.reservationKeyFilter, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rr *ReservationRouter) watchReservationRetryCreditMinted( + sink chan *abi.ReservationRouterReservationRetryCreditMinted, + reservationKeyFilter []*big.Int, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return rr.contract.WatchReservationRetryCreditMinted( + &bind.WatchOpts{Context: ctx}, + sink, + reservationKeyFilter, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + rrLogger.Warnf( + "subscription to event ReservationRetryCreditMinted had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + rrLogger.Errorf( + "subscription to event ReservationRetryCreditMinted failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (rr *ReservationRouter) PastReservationRetryCreditMintedEvents( + startBlock uint64, + endBlock *uint64, + reservationKeyFilter []*big.Int, +) ([]*abi.ReservationRouterReservationRetryCreditMinted, error) { + iterator, err := rr.contract.FilterReservationRetryCreditMinted( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + reservationKeyFilter, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past ReservationRetryCreditMinted events: [%v]", + err, + ) + } + + events := make([]*abi.ReservationRouterReservationRetryCreditMinted, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (rr *ReservationRouter) ReservationRouterSetEvent( + opts *ethereum.SubscribeOpts, +) *RrReservationRouterSetSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &RrReservationRouterSetSubscription{ + rr, + opts, + } +} + +type RrReservationRouterSetSubscription struct { + contract *ReservationRouter + opts *ethereum.SubscribeOpts +} + +type reservationRouterReservationRouterSetFunc func( + ReservationRouter common.Address, + blockNumber uint64, +) + +func (rrss *RrReservationRouterSetSubscription) OnEvent( + handler reservationRouterReservationRouterSetFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.ReservationRouterReservationRouterSet) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.ReservationRouter, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := rrss.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rrss *RrReservationRouterSetSubscription) Pipe( + sink chan *abi.ReservationRouterReservationRouterSet, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(rrss.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := rrss.contract.blockCounter.CurrentBlock() + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - rrss.opts.PastBlocks + + rrLogger.Infof( + "subscription monitoring fetching past ReservationRouterSet events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := rrss.contract.PastReservationRouterSetEvents( + fromBlock, + nil, + ) + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + rrLogger.Infof( + "subscription monitoring fetched [%v] past ReservationRouterSet events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := rrss.contract.watchReservationRouterSet( + sink, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rr *ReservationRouter) watchReservationRouterSet( + sink chan *abi.ReservationRouterReservationRouterSet, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return rr.contract.WatchReservationRouterSet( + &bind.WatchOpts{Context: ctx}, + sink, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + rrLogger.Warnf( + "subscription to event ReservationRouterSet had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + rrLogger.Errorf( + "subscription to event ReservationRouterSet failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (rr *ReservationRouter) PastReservationRouterSetEvents( + startBlock uint64, + endBlock *uint64, +) ([]*abi.ReservationRouterReservationRouterSet, error) { + iterator, err := rr.contract.FilterReservationRouterSet( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past ReservationRouterSet events: [%v]", + err, + ) + } + + events := make([]*abi.ReservationRouterReservationRouterSet, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (rr *ReservationRouter) ReservationStrandedEvent( + opts *ethereum.SubscribeOpts, + reservationKeyFilter []*big.Int, + walletPubKeyHashFilter [][20]byte, + ownerFilter []common.Address, +) *RrReservationStrandedSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &RrReservationStrandedSubscription{ + rr, + opts, + reservationKeyFilter, + walletPubKeyHashFilter, + ownerFilter, + } +} + +type RrReservationStrandedSubscription struct { + contract *ReservationRouter + opts *ethereum.SubscribeOpts + reservationKeyFilter []*big.Int + walletPubKeyHashFilter [][20]byte + ownerFilter []common.Address +} + +type reservationRouterReservationStrandedFunc func( + ReservationKey *big.Int, + WalletPubKeyHash [20]byte, + Owner common.Address, + AnchorAmount uint64, + blockNumber uint64, +) + +func (rss *RrReservationStrandedSubscription) OnEvent( + handler reservationRouterReservationStrandedFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.ReservationRouterReservationStranded) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.ReservationKey, + event.WalletPubKeyHash, + event.Owner, + event.AnchorAmount, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := rss.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rss *RrReservationStrandedSubscription) Pipe( + sink chan *abi.ReservationRouterReservationStranded, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(rss.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := rss.contract.blockCounter.CurrentBlock() + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - rss.opts.PastBlocks + + rrLogger.Infof( + "subscription monitoring fetching past ReservationStranded events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := rss.contract.PastReservationStrandedEvents( + fromBlock, + nil, + rss.reservationKeyFilter, + rss.walletPubKeyHashFilter, + rss.ownerFilter, + ) + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + rrLogger.Infof( + "subscription monitoring fetched [%v] past ReservationStranded events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := rss.contract.watchReservationStranded( + sink, + rss.reservationKeyFilter, + rss.walletPubKeyHashFilter, + rss.ownerFilter, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rr *ReservationRouter) watchReservationStranded( + sink chan *abi.ReservationRouterReservationStranded, + reservationKeyFilter []*big.Int, + walletPubKeyHashFilter [][20]byte, + ownerFilter []common.Address, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return rr.contract.WatchReservationStranded( + &bind.WatchOpts{Context: ctx}, + sink, + reservationKeyFilter, + walletPubKeyHashFilter, + ownerFilter, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + rrLogger.Warnf( + "subscription to event ReservationStranded had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + rrLogger.Errorf( + "subscription to event ReservationStranded failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (rr *ReservationRouter) PastReservationStrandedEvents( + startBlock uint64, + endBlock *uint64, + reservationKeyFilter []*big.Int, + walletPubKeyHashFilter [][20]byte, + ownerFilter []common.Address, +) ([]*abi.ReservationRouterReservationStranded, error) { + iterator, err := rr.contract.FilterReservationStranded( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + reservationKeyFilter, + walletPubKeyHashFilter, + ownerFilter, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past ReservationStranded events: [%v]", + err, + ) + } + + events := make([]*abi.ReservationRouterReservationStranded, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (rr *ReservationRouter) ReservationVaultUpdatedEvent( + opts *ethereum.SubscribeOpts, +) *RrReservationVaultUpdatedSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &RrReservationVaultUpdatedSubscription{ + rr, + opts, + } +} + +type RrReservationVaultUpdatedSubscription struct { + contract *ReservationRouter + opts *ethereum.SubscribeOpts +} + +type reservationRouterReservationVaultUpdatedFunc func( + ReservationVault common.Address, + blockNumber uint64, +) + +func (rvus *RrReservationVaultUpdatedSubscription) OnEvent( + handler reservationRouterReservationVaultUpdatedFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.ReservationRouterReservationVaultUpdated) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.ReservationVault, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := rvus.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rvus *RrReservationVaultUpdatedSubscription) Pipe( + sink chan *abi.ReservationRouterReservationVaultUpdated, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(rvus.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := rvus.contract.blockCounter.CurrentBlock() + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - rvus.opts.PastBlocks + + rrLogger.Infof( + "subscription monitoring fetching past ReservationVaultUpdated events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := rvus.contract.PastReservationVaultUpdatedEvents( + fromBlock, + nil, + ) + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + rrLogger.Infof( + "subscription monitoring fetched [%v] past ReservationVaultUpdated events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := rvus.contract.watchReservationVaultUpdated( + sink, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rr *ReservationRouter) watchReservationVaultUpdated( + sink chan *abi.ReservationRouterReservationVaultUpdated, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return rr.contract.WatchReservationVaultUpdated( + &bind.WatchOpts{Context: ctx}, + sink, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + rrLogger.Warnf( + "subscription to event ReservationVaultUpdated had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + rrLogger.Errorf( + "subscription to event ReservationVaultUpdated failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (rr *ReservationRouter) PastReservationVaultUpdatedEvents( + startBlock uint64, + endBlock *uint64, +) ([]*abi.ReservationRouterReservationVaultUpdated, error) { + iterator, err := rr.contract.FilterReservationVaultUpdated( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past ReservationVaultUpdated events: [%v]", + err, + ) + } + + events := make([]*abi.ReservationRouterReservationVaultUpdated, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (rr *ReservationRouter) ReservedDepositMarkedStaleEvent( + opts *ethereum.SubscribeOpts, + depositKeyFilter []*big.Int, +) *RrReservedDepositMarkedStaleSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &RrReservedDepositMarkedStaleSubscription{ + rr, + opts, + depositKeyFilter, + } +} + +type RrReservedDepositMarkedStaleSubscription struct { + contract *ReservationRouter + opts *ethereum.SubscribeOpts + depositKeyFilter []*big.Int +} + +type reservationRouterReservedDepositMarkedStaleFunc func( + DepositKey *big.Int, + blockNumber uint64, +) + +func (rdmss *RrReservedDepositMarkedStaleSubscription) OnEvent( + handler reservationRouterReservedDepositMarkedStaleFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.ReservationRouterReservedDepositMarkedStale) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.DepositKey, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := rdmss.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rdmss *RrReservedDepositMarkedStaleSubscription) Pipe( + sink chan *abi.ReservationRouterReservedDepositMarkedStale, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(rdmss.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := rdmss.contract.blockCounter.CurrentBlock() + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - rdmss.opts.PastBlocks + + rrLogger.Infof( + "subscription monitoring fetching past ReservedDepositMarkedStale events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := rdmss.contract.PastReservedDepositMarkedStaleEvents( + fromBlock, + nil, + rdmss.depositKeyFilter, + ) + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + rrLogger.Infof( + "subscription monitoring fetched [%v] past ReservedDepositMarkedStale events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := rdmss.contract.watchReservedDepositMarkedStale( + sink, + rdmss.depositKeyFilter, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rr *ReservationRouter) watchReservedDepositMarkedStale( + sink chan *abi.ReservationRouterReservedDepositMarkedStale, + depositKeyFilter []*big.Int, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return rr.contract.WatchReservedDepositMarkedStale( + &bind.WatchOpts{Context: ctx}, + sink, + depositKeyFilter, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + rrLogger.Warnf( + "subscription to event ReservedDepositMarkedStale had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + rrLogger.Errorf( + "subscription to event ReservedDepositMarkedStale failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (rr *ReservationRouter) PastReservedDepositMarkedStaleEvents( + startBlock uint64, + endBlock *uint64, + depositKeyFilter []*big.Int, +) ([]*abi.ReservationRouterReservedDepositMarkedStale, error) { + iterator, err := rr.contract.FilterReservedDepositMarkedStale( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + depositKeyFilter, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past ReservedDepositMarkedStale events: [%v]", + err, + ) + } + + events := make([]*abi.ReservationRouterReservedDepositMarkedStale, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} diff --git a/pkg/chain/ethereum/tbtc/gen/contract/WalletProposalValidator.go b/pkg/chain/ethereum/tbtc/gen/contract/WalletProposalValidator.go index b5d3591e01..c22b9b1c7c 100644 --- a/pkg/chain/ethereum/tbtc/gen/contract/WalletProposalValidator.go +++ b/pkg/chain/ethereum/tbtc/gen/contract/WalletProposalValidator.go @@ -585,4 +585,95 @@ func (wpv *WalletProposalValidator) ValidateRedemptionProposalAtBlock( return result, err } +func (wpv *WalletProposalValidator) ValidateReservationAnchorProposal( + arg_proposal abi.WalletProposalValidatorReservationAnchorProposal, + arg_depositExtraInfo abi.WalletProposalValidatorDepositExtraInfo, +) (bool, error) { + result, err := wpv.contract.ValidateReservationAnchorProposal( + wpv.callerOptions, + arg_proposal, + arg_depositExtraInfo, + ) + + if err != nil { + return result, wpv.errorResolver.ResolveError( + err, + wpv.callerOptions.From, + nil, + "validateReservationAnchorProposal", + arg_proposal, + arg_depositExtraInfo, + ) + } + + return result, err +} + +func (wpv *WalletProposalValidator) ValidateReservationAnchorProposalAtBlock( + arg_proposal abi.WalletProposalValidatorReservationAnchorProposal, + arg_depositExtraInfo abi.WalletProposalValidatorDepositExtraInfo, + blockNumber *big.Int, +) (bool, error) { + var result bool + + err := chainutil.CallAtBlock( + wpv.callerOptions.From, + blockNumber, + nil, + wpv.contractABI, + wpv.caller, + wpv.errorResolver, + wpv.contractAddress, + "validateReservationAnchorProposal", + &result, + arg_proposal, + arg_depositExtraInfo, + ) + + return result, err +} + +func (wpv *WalletProposalValidator) ValidateReservationReanchorProposal( + arg_proposal abi.WalletProposalValidatorReservationReanchorProposal, +) (bool, error) { + result, err := wpv.contract.ValidateReservationReanchorProposal( + wpv.callerOptions, + arg_proposal, + ) + + if err != nil { + return result, wpv.errorResolver.ResolveError( + err, + wpv.callerOptions.From, + nil, + "validateReservationReanchorProposal", + arg_proposal, + ) + } + + return result, err +} + +func (wpv *WalletProposalValidator) ValidateReservationReanchorProposalAtBlock( + arg_proposal abi.WalletProposalValidatorReservationReanchorProposal, + blockNumber *big.Int, +) (bool, error) { + var result bool + + err := chainutil.CallAtBlock( + wpv.callerOptions.From, + blockNumber, + nil, + wpv.contractABI, + wpv.caller, + wpv.errorResolver, + wpv.contractAddress, + "validateReservationReanchorProposal", + &result, + arg_proposal, + ) + + return result, err +} + // ------ Events ------- From e49e954e8c8da3391c0bc29fb61b822f8258cfad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 26 Aug 2026 09:24:07 +0000 Subject: [PATCH 07/39] feat(tbtc): implement reservation read/validate methods against real bindings Replaces the seven reservation read/validate stubs in tbtc.go (those declared today in pkg/tbtc/chain.go:430-480, previously returning "reservations not supported yet" errors) with real implementations backed by the regenerated abigen bindings. Three view reads (GetReservation, GetReservationAction, ReservationParameters) are reached through tc.reservationRouter, a new binding constructed against the Bridge address -- the router code only executes via Bridge.fallback's delegatecall, so binding the ReservationRouter ABI at the Bridge address is the only configuration that gives the operator a live read path (the deployed router address holds empty storage). Two on-chain proposal validators (ValidateReservationAnchorProposal, ValidateReservationReanchorProposal) are reached through the existing tc.walletProposalValidator handle against the regenerated WalletProposalValidatorReservation*Proposal ABI structs. Two further validators (ValidateReservedRedemptionProposal, ValidateReservationDissolutionProposal) remain unsupported on this milestone's bridge-integration surface -- the WalletProposalValidator contract does not expose those entry points -- so their bodies return an explicit "validator not exposed on the m1 bridge-integration surface" error. The chain.go interface declarations are satisfied so the package compiles; downstream tasks can replace these bodies once the missing validators land on the contract side. Thin field-by-field abigen-to-Go converters are added for the three view structs (convertReservationFromAbiType, convertReservationActionFromAbiType, convertReservationParametersFromAbiType), plus three small parsers (parseReservationState, parseReservationActionType, parseReservationActionState) that mirror the on-chain enum layouts. The reservationRouter field is constructed in newTbtcChain via the reservationRouterBinding helper, which makes the storage/address rationale explicit at the call site rather than burying it in the struct field comment. --- pkg/chain/ethereum/tbtc.go | 449 +++++++++++++++++++++++++++++++++---- 1 file changed, 408 insertions(+), 41 deletions(-) diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index 83c9629fef..e2835bc579 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -64,6 +64,17 @@ type TbtcChain struct { sortitionPool *ecdsacontract.EcdsaSortitionPool walletProposalValidator *tbtccontract.WalletProposalValidator redemptionWatchtower *tbtccontract.RedemptionWatchtower + // reservationRouter is the abigen binding for ReservationRouter.sol's ABI + // (functions, events, errors). It is NOT bound to the deployed router + // address -- the router contract holds its own empty storage and only + // ever executes via Bridge.fallback's delegatecall. The binding is + // constructed against the Bridge address so every read, write, and log + // filter goes through Bridge.fallback, which dispatches to the router + // code with the Bridge's storage and emits events under the Bridge's + // address. Calling the binding against the router's standalone address + // would invoke its empty storage and either revert (writes) or return + // zeros (views). + reservationRouter *tbtccontract.ReservationRouter // ecdsaDkgValidatorAddress optional; when zero, TBTC uses defaultGroupParameters(network). ecdsaDkgValidatorAddress common.Address @@ -266,6 +277,14 @@ func newTbtcChain( ) } + reservationRouter, err := reservationRouterBinding(bridgeAddress, baseChain) + if err != nil { + return nil, fmt.Errorf( + "failed to attach to ReservationRouter binding: [%v]", + err, + ) + } + return &TbtcChain{ baseChain: baseChain, bridge: bridge, @@ -274,11 +293,38 @@ func newTbtcChain( sortitionPool: sortitionPool, walletProposalValidator: walletProposalValidator, redemptionWatchtower: redemptionWatchtower, + reservationRouter: reservationRouter, ecdsaDkgValidatorAddress: ecdsaDkgValidatorAddress, sweptDepositsCache: cache.NewGenericTimeCache[*tbtc.DepositChainRequest](sweptDepositsCachePeriod), }, nil } +// reservationRouterBinding constructs the ReservationRouter abigen binding +// pointed at the Bridge address. The router code only ever executes via +// Bridge.fallback's delegatecall, so the binding MUST be constructed against +// the Bridge address: the deployed router contract holds its own empty +// storage, so any call routed to its standalone address would either revert +// (writes) or return zeros (views); events emitted by router code carry the +// Bridge's address in their log because delegatecall preserves the caller's +// address context. The router's own deployment address is only needed for +// the one-time governance Bridge.setReservationRouter(routerAddress) call, +// which is out of scope here. +func reservationRouterBinding( + bridgeAddress common.Address, + baseChain *baseChain, +) (*tbtccontract.ReservationRouter, error) { + return tbtccontract.NewReservationRouter( + bridgeAddress, + baseChain.chainID, + baseChain.key, + baseChain.client, + baseChain.nonceManager, + baseChain.miningWaiter, + baseChain.blockCounter, + baseChain.transactionMutex, + ) +} + // EcdsaWalletGroupParametersFromChain mirrors EcdsaDkgValidator sizing constants // when EcdsaDkgValidator contract address was configured under [ethereum] // contract addresses or developer.ecdsaDkgValidatorAddress alias. When absent, @@ -2409,47 +2455,89 @@ func (tc *TbtcChain) GetDepositMinAge() (uint32, error) { return tc.walletProposalValidator.DEPOSITMINAGE() } -// GetReservation is not yet supported by the Ethereum chain implementation: -// the reservation contract bindings will be regenerated once the reservation -// Bridge API is published with the @keep-network/tbtc-v2 package. +// GetReservation returns the on-chain reservation record for the given +// reservation key. The reservation router code is reached via +// Bridge.fallback's delegatecall; the reservationRouter binding is bound to +// the Bridge address so this call routes through the fallback into the +// router code that reads the Bridge's reservation storage. func (tc *TbtcChain) GetReservation( reservationKey *big.Int, ) (*tbtc.Reservation, error) { - return nil, fmt.Errorf( - "reservations not supported yet by the Ethereum chain implementation", - ) + abiReservation, err := tc.reservationRouter.Reservations(reservationKey) + if err != nil { + return nil, fmt.Errorf( + "cannot get reservation [0x%x]: [%v]", + reservationKey, + err, + ) + } + + reservation, err := convertReservationFromAbiType(abiReservation) + if err != nil { + return nil, fmt.Errorf( + "cannot convert reservation [0x%x] from abi type: [%v]", + reservationKey, + err, + ) + } + + return reservation, nil } -// GetReservationAction is not yet supported by the Ethereum chain -// implementation: the reservation contract bindings will be regenerated once -// the reservation Bridge API is published with the @keep-network/tbtc-v2 -// package. +// GetReservationAction returns the on-chain action record for the given +// reservation key and request nonce. func (tc *TbtcChain) GetReservationAction( reservationKey *big.Int, requestNonce uint64, ) (*tbtc.ReservationAction, error) { - return nil, fmt.Errorf( - "reservations not supported yet by the Ethereum chain implementation", + abiAction, err := tc.reservationRouter.ReservationActions( + reservationKey, + requestNonce, ) + if err != nil { + return nil, fmt.Errorf( + "cannot get reservation action [0x%x:%d]: [%v]", + reservationKey, + requestNonce, + err, + ) + } + + action, err := convertReservationActionFromAbiType(abiAction) + if err != nil { + return nil, fmt.Errorf( + "cannot convert reservation action [0x%x:%d] from abi type: [%v]", + reservationKey, + requestNonce, + err, + ) + } + + return action, nil } -// ReservationParameters is not yet supported by the Ethereum chain -// implementation: the reservation contract bindings will be regenerated once -// the reservation Bridge API is published with the @keep-network/tbtc-v2 -// package. +// ReservationParameters returns the current on-chain Bridge reservation +// parameters (10-tuple). The reservationRouter binding routes this read +// through Bridge.fallback into the router's reservationParameters view. func (tc *TbtcChain) ReservationParameters() ( *tbtc.ReservationParameters, error, ) { - return nil, fmt.Errorf( - "reservations not supported yet by the Ethereum chain implementation", - ) + abiParameters, err := tc.reservationRouter.ReservationParameters() + if err != nil { + return nil, fmt.Errorf( + "cannot get reservation parameters: [%v]", + err, + ) + } + + return convertReservationParametersFromAbiType(abiParameters), nil } -// ValidateReservationAnchorProposal is not yet supported by the Ethereum -// chain implementation: the reservation contract bindings will be -// regenerated once the reservation Bridge API is published with the -// @keep-network/tbtc-v2 package. +// ValidateReservationAnchorProposal asks the WalletProposalValidator +// whether the given anchor proposal is valid for the given wallet and +// reserved deposit. The validator is a separate contract reached at its +// own deployed address. func (tc *TbtcChain) ValidateReservationAnchorProposal( walletPublicKeyHash [20]byte, proposal *tbtc.ReservationAnchorProposal, @@ -2458,46 +2546,325 @@ func (tc *TbtcChain) ValidateReservationAnchorProposal( FundingTx *bitcoin.Transaction }, ) error { - return fmt.Errorf( - "reservations not supported yet by the Ethereum chain implementation", + // WalletProposalValidator's DepositExtraInfo.FundingTx is typed as + // BitcoinTxInfo2 because the BitcoinTxInfo struct is renamed via the + // collision hook in gen/Makefile (Bridge keeps the un-suffixed name; + // WalletProposalValidator gets the 2-suffix; MaintainerProxy the 3; + // ReservationRouter the 4). Mirroring the existing + // ValidateDepositSweepProposal pattern. + fundingTx := tbtcabi.BitcoinTxInfo2{ + Version: depositExtraInfo.FundingTx.SerializeVersion(), + InputVector: depositExtraInfo.FundingTx.SerializeInputs(), + OutputVector: depositExtraInfo.FundingTx.SerializeOutputs(), + Locktime: depositExtraInfo.FundingTx.SerializeLocktime(), + } + + depositKey := tbtcabi.WalletProposalValidatorDepositKey{ + FundingTxHash: proposal.DepositFundingTxHash, + FundingOutputIndex: proposal.DepositFundingOutputIndex, + } + + abiExtraInfo := tbtcabi.WalletProposalValidatorDepositExtraInfo{ + FundingTx: fundingTx, + BlindingFactor: depositExtraInfo.Deposit.BlindingFactor, + WalletPubKeyHash: depositExtraInfo.Deposit.WalletPublicKeyHash, + RefundPubKeyHash: depositExtraInfo.Deposit.RefundPublicKeyHash, + RefundLocktime: depositExtraInfo.Deposit.RefundLocktime, + } + + abiProposal := tbtcabi.WalletProposalValidatorReservationAnchorProposal{ + WalletPubKeyHash: walletPublicKeyHash, + DepositKey: depositKey, + AnchorTxFee: proposal.AnchorTxFee, + } + + valid, err := tc.walletProposalValidator.ValidateReservationAnchorProposal( + abiProposal, + abiExtraInfo, ) + if err != nil { + return fmt.Errorf("validation failed: [%v]", err) + } + + // Should never happen because `validateReservationAnchorProposal` + // returns true or reverts (returns an error) but do the check just in + // case. + if !valid { + return fmt.Errorf("unexpected validation result") + } + + return nil } -// ValidateReservedRedemptionProposal is not yet supported by the Ethereum -// chain implementation: the reservation contract bindings will be -// regenerated once the reservation Bridge API is published with the -// @keep-network/tbtc-v2 package. +// ValidateReservedRedemptionProposal asks the WalletProposalValidator +// whether the given reserved redemption proposal is valid for the given +// wallet. The m1 bridge-integration surface does not expose a +// `validateReservedRedemptionProposal` entry on the WalletProposalValidator +// (only anchor and re-anchor validators are present at this milestone), so +// the interface stub returns an explicit error rather than calling a +// non-existent binding. Downstream tasks replacing this body will receive +// the bridge-integration Solidity once that validator lands. func (tc *TbtcChain) ValidateReservedRedemptionProposal( walletPublicKeyHash [20]byte, proposal *tbtc.ReservedRedemptionProposal, ) error { return fmt.Errorf( - "reservations not supported yet by the Ethereum chain implementation", + "reserved redemption proposal validator is not exposed on " + + "the m1 bridge-integration surface", ) } -// ValidateReservationReanchorProposal is not yet supported by the Ethereum -// chain implementation: the reservation contract bindings will be -// regenerated once the reservation Bridge API is published with the -// @keep-network/tbtc-v2 package. +// ValidateReservationReanchorProposal asks the WalletProposalValidator +// whether the given re-anchor proposal is valid for the given source +// wallet. The validator is a separate contract reached at its own deployed +// address. func (tc *TbtcChain) ValidateReservationReanchorProposal( sourceWalletPublicKeyHash [20]byte, proposal *tbtc.ReservationReanchorProposal, ) error { - return fmt.Errorf( - "reservations not supported yet by the Ethereum chain implementation", + abiProposal := tbtcabi.WalletProposalValidatorReservationReanchorProposal{ + SourceWalletPubKeyHash: sourceWalletPublicKeyHash, + ReservationKey: proposal.ReservationKey, + TargetWalletPubKeyHash: proposal.TargetWalletPublicKeyHash, + ReanchorTxFee: proposal.ReanchorTxFee, + } + + valid, err := tc.walletProposalValidator.ValidateReservationReanchorProposal( + abiProposal, ) + if err != nil { + return fmt.Errorf("validation failed: [%v]", err) + } + + // Should never happen because `validateReservationReanchorProposal` + // returns true or reverts (returns an error) but do the check just in + // case. + if !valid { + return fmt.Errorf("unexpected validation result") + } + + return nil } -// ValidateReservationDissolutionProposal is not yet supported by the -// Ethereum chain implementation: the reservation contract bindings will be -// regenerated once the reservation Bridge API is published with the -// @keep-network/tbtc-v2 package. +// ValidateReservationDissolutionProposal asks the WalletProposalValidator +// whether the given dissolution proposal is valid for the given wallet. +// The m1 bridge-integration surface does not expose a +// `validateReservationDissolutionProposal` entry on the +// WalletProposalValidator (only anchor and re-anchor validators are present +// at this milestone), so the interface stub returns an explicit error +// rather than calling a non-existent binding. func (tc *TbtcChain) ValidateReservationDissolutionProposal( walletPublicKeyHash [20]byte, proposal *tbtc.ReservationDissolutionProposal, ) error { return fmt.Errorf( - "reservations not supported yet by the Ethereum chain implementation", + "reservation dissolution proposal validator is not exposed on " + + "the m1 bridge-integration surface", ) } + +// convertReservationFromAbiType converts the ReservationRouter-specific +// Reservation.ReservationRequest ABI struct to the TBTC application +// `tbtc.Reservation` representation. +// +// Field omissions (intentional, mirroring the Solidity-to-Go struct shrink): +// +// - `CumulativeReanchorFee`: written by every re-anchor hop but not +// exposed through the Go-side reservation; m1 has no fee-ceiling +// enforcement, so the field is dropped on the Go boundary. A later +// milestone that adds a fee ceiling should re-export this field on +// `tbtc.Reservation`. +// +// Anchor shape reassembly: the on-chain request splits the anchor UTXO into +// `anchorAmount`, `anchorTxHash`, and `anchorTxOutputIndex`; the Go-side +// representation folds those three back into a single +// `bitcoin.UnspentTransactionOutput` for consistency with the rest of the +// reservation API. +func convertReservationFromAbiType( + abiReservation tbtcabi.ReservationReservationRequest, +) (*tbtc.Reservation, error) { + state, err := parseReservationState(abiReservation.State) + if err != nil { + return nil, fmt.Errorf("cannot parse reservation state: [%v]", err) + } + + anchorUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: abiReservation.AnchorTxHash, + OutputIndex: abiReservation.AnchorTxOutputIndex, + }, + Value: int64(abiReservation.AnchorAmount), + } + + return &tbtc.Reservation{ + Owner: chain.Address(abiReservation.Owner.String()), + MintedAmount: abiReservation.MintedAmount, + AcceptedAt: abiReservation.AcceptedAt, + WalletPublicKeyHash: abiReservation.WalletPubKeyHash, + AnchorUtxo: anchorUtxo, + ExpiresAt: abiReservation.ExpiresAt, + State: state, + RequestNonce: abiReservation.RequestNonce, + RetryCredit: abiReservation.RetryCredit, + DissolutionEligibleAt: abiReservation.DissolutionEligibleAt, + }, nil +} + +// convertReservationActionFromAbiType converts the ReservationRouter- +// specific Reservation.ReservationAction ABI struct to the TBTC +// application `tbtc.ReservationAction` representation. +// +// Field omissions (intentional): +// +// - `SourceAnchorUtxoHash`, `UsedRetryCredit`, +// `Watchtower{Default,LevelOne,LevelTwo}Delay`, +// `RetryCreditSourceNonce`: written for governance / late-settlement +// reconciliation but not read by the operator client in m1. +// +// The on-chain `actionDataHash` field is polymorphic across action types: +// it carries the keccak256 of the redeemer output script for redemptions, +// the wallet main UTXO hash for dissolutions, and is zero otherwise. The +// Go-side struct splits that polymorphism into two named fields +// (`RedeemerOutputScriptHash` for redemptions, `ExpectedMainUtxoHash` +// for dissolutions); we route `actionDataHash` to the field that matches +// the action's type and zero the other. +func convertReservationActionFromAbiType( + abiAction tbtcabi.ReservationReservationAction, +) (*tbtc.ReservationAction, error) { + actionType, err := parseReservationActionType(abiAction.ActionType) + if err != nil { + return nil, fmt.Errorf( + "cannot parse reservation action type: [%v]", + err, + ) + } + + state, err := parseReservationActionState(abiAction.State) + if err != nil { + return nil, fmt.Errorf( + "cannot parse reservation action state: [%v]", + err, + ) + } + + var ( + redeemerOutputScriptHash [32]byte + expectedMainUtxoHash [32]byte + ) + switch actionType { + case tbtc.ReservationActionTypeRedemption: + redeemerOutputScriptHash = abiAction.ActionDataHash + case tbtc.ReservationActionTypeDissolution: + expectedMainUtxoHash = abiAction.ActionDataHash + } + + return &tbtc.ReservationAction{ + TargetWalletPublicKeyHash: abiAction.TargetWalletPubKeyHash, + RequestedAt: abiAction.RequestedAt, + TimeoutAt: abiAction.TimeoutAt, + TxMaxFee: abiAction.TxMaxFee, + ActionType: actionType, + State: state, + FeePaid: abiAction.FeePaid, + Redeemer: chain.Address(abiAction.Redeemer.String()), + Amount: abiAction.Amount, + RedeemerOutputScriptHash: redeemerOutputScriptHash, + ExpectedMainUtxoHash: expectedMainUtxoHash, + IsPartial: abiAction.IsPartial, + }, nil +} + +// convertReservationParametersFromAbiType converts the ReservationRouter +// 10-tuple to the `tbtc.ReservationParameters` representation. +func convertReservationParametersFromAbiType( + abiParameters struct { + ReservationVault common.Address + ReservationMinAmount uint64 + ReservationTxMaxFee uint64 + ReservationTermSeconds uint32 + ReservationDissolutionDelay uint32 + ReservationMaxTotalAmount uint64 + ReservationTotalAmount uint64 + MaxReservationsPerWallet uint32 + ReservationActionTimeout uint32 + ReservationRenewalWindowSeconds uint32 + }, +) *tbtc.ReservationParameters { + return &tbtc.ReservationParameters{ + ReservationVault: chain.Address(abiParameters.ReservationVault.String()), + ReservationMinAmount: abiParameters.ReservationMinAmount, + ReservationTxMaxFee: abiParameters.ReservationTxMaxFee, + ReservationTermSeconds: abiParameters.ReservationTermSeconds, + ReservationDissolutionDelay: abiParameters.ReservationDissolutionDelay, + ReservationMaxTotalAmount: abiParameters.ReservationMaxTotalAmount, + ReservationTotalAmount: abiParameters.ReservationTotalAmount, + MaxReservationsPerWallet: abiParameters.MaxReservationsPerWallet, + ReservationActionTimeout: abiParameters.ReservationActionTimeout, + ReservationRenewalWindowSeconds: abiParameters.ReservationRenewalWindowSeconds, + } +} + +// parseReservationState converts the on-chain ReservationState enum +// (uint8) to the tbtc.ReservationState value. Values match the Solidity +// declaration one-for-one (Unknown=0, Active=1, ActionPending=2, +// Closed=3, Stranded=4). +func parseReservationState(value uint8) (tbtc.ReservationState, error) { + switch value { + case 0: + return tbtc.ReservationStateUnknown, nil + case 1: + return tbtc.ReservationStateActive, nil + case 2: + return tbtc.ReservationStateActionPending, nil + case 3: + return tbtc.ReservationStateClosed, nil + case 4: + return tbtc.ReservationStateStranded, nil + default: + return 0, fmt.Errorf("unexpected reservation state value: [%d]", value) + } +} + +// parseReservationActionType converts the on-chain ActionType enum +// (uint8) to the tbtc.ReservationActionType value. Values match the +// Solidity declaration one-for-one (None=0, Acceptance=1, Redemption=2, +// Reanchor=3, Dissolution=4). +func parseReservationActionType(value uint8) (tbtc.ReservationActionType, error) { + switch value { + case 0: + return tbtc.ReservationActionTypeNone, nil + case 1: + return tbtc.ReservationActionTypeAcceptance, nil + case 2: + return tbtc.ReservationActionTypeRedemption, nil + case 3: + return tbtc.ReservationActionTypeReanchor, nil + case 4: + return tbtc.ReservationActionTypeDissolution, nil + default: + return 0, fmt.Errorf("unexpected reservation action type value: [%d]", value) + } +} + +// parseReservationActionState converts the on-chain ActionState enum +// (uint8) to the tbtc.ReservationActionState value. Values match the +// Solidity declaration one-for-one (Unknown=0, Pending=1, Settled=2, +// TimedOut=3, Vetoed=4, Superseded=5). +func parseReservationActionState(value uint8) (tbtc.ReservationActionState, error) { + switch value { + case 0: + return tbtc.ReservationActionStateUnknown, nil + case 1: + return tbtc.ReservationActionStatePending, nil + case 2: + return tbtc.ReservationActionStateSettled, nil + case 3: + return tbtc.ReservationActionStateTimedOut, nil + case 4: + return tbtc.ReservationActionStateVetoed, nil + case 5: + return tbtc.ReservationActionStateSuperseded, nil + default: + return 0, fmt.Errorf("unexpected reservation action state value: [%d]", value) + } +} From 4ba2f2267b660738833e37cffaa4ed4500cc829d Mon Sep 17 00:00:00 2001 From: keep-core-dev Date: Wed, 26 Aug 2026 09:39:01 +0000 Subject: [PATCH 08/39] feat(tbtc): add reservation write methods, additional views, and event subscriptions Adds the second half of the PR H reservation chain-interface surface (section 1.2 of the build brief): * Six write methods bound to the Bridge address via the reservationRouter handle (RequestReservationAcceptance, RequestReservationReanchor, SubmitReservationProof, NotifyReservationActionTimeout, NotifyStaleReservedDeposit, NotifyReservationStranded). Submission pattern mirrors the existing SubmitRedemptionProofWithReimbursement flow: GasEstimate + 20% margin + ethutil.TransactionOptions. * Twelve additional read/view methods (ReservationCaps, WalletReservationsAmount, WalletReservationsCount, WalletReservations, ReservationByAnchorUtxo, ReservedDepositWallet, PendingReservedDeposits, Reservations, ReservationActions, ActiveReservationsCount, ReservationRouter, IsReservedDeposit), plus ReservationParametersFull as an alias of ReservationParameters. IsReservedDeposit and ReservationRouter read via the Bridge binding because they map to Bridge state. * New Go types ReservationRequest, ReservationActionRecord, BitcoinTxInfo, BitcoinTxProof, BitcoinTxUTXO mirror the on-chain ReservationRouter view structs verbatim. * Thirteen event subscriptions and twelve filter structs for every reservation event listed in the brief, filtering against the Bridge address (delegatecall preserves the caller's address context so router-emitted events carry the Bridge address). * localChain mocks for all of the above so the interface stays satisfiable by the test double. The reservationRouter binding remains bound to the Bridge address (invariant 3 of ReservationRouter.sol) - no second binding against the router's standalone address is constructed. --- pkg/chain/ethereum/tbtc.go | 1143 ++++++++++++++++++++++++++++++++++++ pkg/tbtc/chain.go | 517 +++++++++++++++- pkg/tbtc/chain_test.go | 225 +++++++ 3 files changed, 1882 insertions(+), 3 deletions(-) diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index e2835bc579..3aaf5a6d1d 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -2868,3 +2868,1146 @@ func parseReservationActionState(value uint8) (tbtc.ReservationActionState, erro return 0, fmt.Errorf("unexpected reservation action state value: [%d]", value) } } + +// RequestReservationAcceptance asks the Bridge (via its ReservationRouter +// delegatecall target) to start a new reservation acceptance action generation +// for the given reservation. The Bridge binding holds the actual storage; the +// reservationRouter binding is bound to the Bridge address so this call routes +// through Bridge.fallback into the router code. +func (tc *TbtcChain) RequestReservationAcceptance( + reservationKey *big.Int, + walletPublicKeyHash [20]byte, +) error { + gasEstimate, err := tc.reservationRouter.RequestReservationAcceptanceGasEstimate( + reservationKey, + walletPublicKeyHash, + ) + if err != nil { + return err + } + + gasEstimateWithMargin := float64(gasEstimate) * float64(1.2) + + _, err = tc.reservationRouter.RequestReservationAcceptance( + reservationKey, + walletPublicKeyHash, + ethutil.TransactionOptions{ + GasLimit: uint64(gasEstimateWithMargin), + }, + ) + + return err +} + +// RequestReservationReanchor asks the Bridge (via its ReservationRouter +// delegatecall target) to start a new reservation re-anchor action generation +// for the given reservation, targeting the given wallet. +func (tc *TbtcChain) RequestReservationReanchor( + reservationKey *big.Int, + targetWalletPublicKeyHash [20]byte, +) error { + gasEstimate, err := tc.reservationRouter.RequestReservationReanchorGasEstimate( + reservationKey, + targetWalletPublicKeyHash, + ) + if err != nil { + return err + } + + gasEstimateWithMargin := float64(gasEstimate) * float64(1.2) + + _, err = tc.reservationRouter.RequestReservationReanchor( + reservationKey, + targetWalletPublicKeyHash, + ethutil.TransactionOptions{ + GasLimit: uint64(gasEstimateWithMargin), + }, + ) + + return err +} + +// SubmitReservationProof submits an SPV proof for the given reservation +// action generation to the Bridge. The proof path is onlySpvMaintainer on +// the router; the call goes through Bridge.fallback's delegatecall so the +// router code reads the Bridge's isSpvMaintainer mapping at the Bridge's +// address. +func (tc *TbtcChain) SubmitReservationProof( + proofType uint8, + txInfo *tbtc.BitcoinTxInfo, + proof *tbtc.BitcoinTxProof, + mainUtxo *tbtc.BitcoinTxUTXO, + reservationKey *big.Int, + requestNonce uint64, +) error { + abiTxInfo := tbtcabi.BitcoinTxInfo4{ + Version: txInfo.Version, + InputVector: txInfo.InputVector, + OutputVector: txInfo.OutputVector, + Locktime: txInfo.Locktime, + } + abiProof := tbtcabi.BitcoinTxProof3{ + MerkleProof: proof.MerkleProof, + TxIndexInBlock: proof.TxIndexInBlock, + BitcoinHeaders: proof.BitcoinHeaders, + CoinbasePreimage: proof.CoinbasePreimage, + CoinbaseProof: proof.CoinbaseProof, + } + abiUtxo := tbtcabi.BitcoinTxUTXO4{ + TxHash: mainUtxo.TxHash, + TxOutputIndex: mainUtxo.TxOutputIndex, + TxOutputValue: mainUtxo.TxOutputValue, + } + + gasEstimate, err := tc.reservationRouter.SubmitReservationProofGasEstimate( + proofType, + abiTxInfo, + abiProof, + abiUtxo, + reservationKey, + requestNonce, + ) + if err != nil { + return err + } + + // The original estimate for this contract call is too low; the + // reservation proof path dispatches into ReservationProofs.submit*Proof, + // which performs a non-trivial amount of storage I/O. Apply a 20% + // margin mirroring the existing SubmitRedemptionProofWithReimbursement + // pattern in this file. + gasEstimateWithMargin := float64(gasEstimate) * float64(1.2) + + _, err = tc.reservationRouter.SubmitReservationProof( + proofType, + abiTxInfo, + abiProof, + abiUtxo, + reservationKey, + requestNonce, + ethutil.TransactionOptions{ + GasLimit: uint64(gasEstimateWithMargin), + }, + ) + + return err +} + +// NotifyReservationActionTimeout notifies the Bridge that the timeout for +// the given reservation action generation has elapsed. +func (tc *TbtcChain) NotifyReservationActionTimeout( + reservationKey *big.Int, + walletMembersIDs []uint32, +) error { + gasEstimate, err := tc.reservationRouter.NotifyReservationActionTimeoutGasEstimate( + reservationKey, + walletMembersIDs, + ) + if err != nil { + return err + } + + gasEstimateWithMargin := float64(gasEstimate) * float64(1.2) + + _, err = tc.reservationRouter.NotifyReservationActionTimeout( + reservationKey, + walletMembersIDs, + ethutil.TransactionOptions{ + GasLimit: uint64(gasEstimateWithMargin), + }, + ) + + return err +} + +// NotifyStaleReservedDeposit notifies the Bridge that the given reserved +// deposit's wallet did not anchor it within the reservation-action timeout. +func (tc *TbtcChain) NotifyStaleReservedDeposit( + depositKey *big.Int, +) error { + gasEstimate, err := tc.reservationRouter.NotifyStaleReservedDepositGasEstimate( + depositKey, + ) + if err != nil { + return err + } + + gasEstimateWithMargin := float64(gasEstimate) * float64(1.2) + + _, err = tc.reservationRouter.NotifyStaleReservedDeposit( + depositKey, + ethutil.TransactionOptions{ + GasLimit: uint64(gasEstimateWithMargin), + }, + ) + + return err +} + +// NotifyReservationStranded notifies the Bridge that the wallet custodying +// the given reservation has been closed or terminated. +func (tc *TbtcChain) NotifyReservationStranded( + reservationKey *big.Int, +) error { + gasEstimate, err := tc.reservationRouter.NotifyReservationStrandedGasEstimate( + reservationKey, + ) + if err != nil { + return err + } + + gasEstimateWithMargin := float64(gasEstimate) * float64(1.2) + + _, err = tc.reservationRouter.NotifyReservationStranded( + reservationKey, + ethutil.TransactionOptions{ + GasLimit: uint64(gasEstimateWithMargin), + }, + ) + + return err +} + +// ReservationParametersFull is an alias for ReservationParameters; both +// return the same tbtc.ReservationParameters struct that already carries +// the full 10-tuple. +func (tc *TbtcChain) ReservationParametersFull() ( + *tbtc.ReservationParameters, + error, +) { + return tc.ReservationParameters() +} + +// ReservationCaps returns the cap parameters that gate reservation +// acceptance. The reservationRouter binding is bound to the Bridge +// address; the call routes through Bridge.fallback into the router's +// reservationCaps view. +func (tc *TbtcChain) ReservationCaps() ( + uint64, + uint64, + error, +) { + caps, err := tc.reservationRouter.ReservationCaps() + if err != nil { + return 0, 0, fmt.Errorf( + "cannot get reservation caps: [%v]", + err, + ) + } + + return caps.MaxReservationsAmountPerWallet, caps.ReservationMaxSingleAmount, nil +} + +// WalletReservationsAmount returns the aggregate satoshi amount currently +// anchored by the given wallet across all of its reservations. +func (tc *TbtcChain) WalletReservationsAmount( + walletPublicKeyHash [20]byte, +) (uint64, error) { + amount, err := tc.reservationRouter.WalletReservationsAmount(walletPublicKeyHash) + if err != nil { + return 0, fmt.Errorf( + "cannot get wallet reservations amount for [0x%x]: [%v]", + walletPublicKeyHash, + err, + ) + } + + return amount, nil +} + +// WalletReservationsCount returns the number of reservations currently +// custodied by the given wallet. +func (tc *TbtcChain) WalletReservationsCount( + walletPublicKeyHash [20]byte, +) (uint32, error) { + count, err := tc.reservationRouter.WalletReservationsCount(walletPublicKeyHash) + if err != nil { + return 0, fmt.Errorf( + "cannot get wallet reservations count for [0x%x]: [%v]", + walletPublicKeyHash, + err, + ) + } + + return count, nil +} + +// WalletReservations returns the reservation keys for all reservations +// currently custodied by the given wallet. +func (tc *TbtcChain) WalletReservations( + walletPublicKeyHash [20]byte, +) ([]*big.Int, error) { + keys, err := tc.reservationRouter.WalletReservations(walletPublicKeyHash) + if err != nil { + return nil, fmt.Errorf( + "cannot get wallet reservations for [0x%x]: [%v]", + walletPublicKeyHash, + err, + ) + } + + return keys, nil +} + +// ReservationByAnchorUtxo returns the reservation key whose anchor outpoint +// is the given Bitcoin transaction output, or an empty value if no +// reservation is anchored there. +func (tc *TbtcChain) ReservationByAnchorUtxo( + anchorTxHash [32]byte, + anchorTxOutputIndex uint32, +) (*big.Int, error) { + key, err := tc.reservationRouter.ReservationByAnchorUtxo( + anchorTxHash, + anchorTxOutputIndex, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot get reservation by anchor utxo [0x%x:%d]: [%v]", + anchorTxHash, + anchorTxOutputIndex, + err, + ) + } + + return key, nil +} + +// ReservedDepositWallet returns the wallet public key hash to which the +// given reserved deposit was revealed. Returns the zero hash if the +// deposit is not a reserved deposit. +func (tc *TbtcChain) ReservedDepositWallet( + depositKey *big.Int, +) ([20]byte, error) { + walletPublicKeyHash, err := tc.reservationRouter.ReservedDepositWallet(depositKey) + if err != nil { + return [20]byte{}, fmt.Errorf( + "cannot get reserved deposit wallet for [0x%x]: [%v]", + depositKey, + err, + ) + } + + return walletPublicKeyHash, nil +} + +// PendingReservedDeposits returns the number of reserved deposits that +// have been revealed to the Bridge but not yet accepted by a wallet. +func (tc *TbtcChain) PendingReservedDeposits() (uint64, error) { + count, err := tc.reservationRouter.PendingReservedDeposits() + if err != nil { + return 0, fmt.Errorf( + "cannot get pending reserved deposits: [%v]", + err, + ) + } + + return count, nil +} + +// convertReservationRequestFromAbiType converts the ReservationRouter- +// specific Reservation.ReservationRequest ABI struct to the TBTC +// application `tbtc.ReservationRequest` representation. This is the +// verbatim-on-chain conversion; callers that want a slightly-shrunk Go +// representation use GetReservation, which drops CumulativeReanchorFee +// because m1 has no fee-ceiling enforcement. +func convertReservationRequestFromAbiType( + abiReservation tbtcabi.ReservationReservationRequest, +) (*tbtc.ReservationRequest, error) { + state, err := parseReservationState(abiReservation.State) + if err != nil { + return nil, fmt.Errorf("cannot parse reservation state: [%v]", err) + } + + return &tbtc.ReservationRequest{ + Owner: chain.Address(abiReservation.Owner.String()), + MintedAmount: abiReservation.MintedAmount, + AcceptedAt: abiReservation.AcceptedAt, + WalletPublicKeyHash: abiReservation.WalletPubKeyHash, + AnchorAmount: abiReservation.AnchorAmount, + ExpiresAt: abiReservation.ExpiresAt, + AnchorTxHash: abiReservation.AnchorTxHash, + AnchorTxOutputIndex: abiReservation.AnchorTxOutputIndex, + State: state, + RequestNonce: abiReservation.RequestNonce, + RetryCredit: abiReservation.RetryCredit, + DissolutionEligibleAt: abiReservation.DissolutionEligibleAt, + CumulativeReanchorFee: abiReservation.CumulativeReanchorFee, + }, nil +} + +// Reservations returns the on-chain reservation request record for the +// given reservation key, including the cumulative re-anchor fee that the +// existing GetReservation representation drops. +func (tc *TbtcChain) Reservations( + reservationKey *big.Int, +) (*tbtc.ReservationRequest, error) { + abiReservation, err := tc.reservationRouter.Reservations(reservationKey) + if err != nil { + return nil, fmt.Errorf( + "cannot get reservation [0x%x]: [%v]", + reservationKey, + err, + ) + } + + reservation, err := convertReservationRequestFromAbiType(abiReservation) + if err != nil { + return nil, fmt.Errorf( + "cannot convert reservation [0x%x] from abi type: [%v]", + reservationKey, + err, + ) + } + + return reservation, nil +} + +// convertReservationActionRecordFromAbiType converts the ReservationRouter- +// specific Reservation.ReservationAction ABI struct to the TBTC +// application `tbtc.ReservationActionRecord` representation. This is the +// verbatim-on-chain conversion; callers that want a slightly-shrunk Go +// representation use GetReservationAction, which drops the late-settlement +// and retry-credit fields because m1 does not consume them. +func convertReservationActionRecordFromAbiType( + abiAction tbtcabi.ReservationReservationAction, +) (*tbtc.ReservationActionRecord, error) { + actionType, err := parseReservationActionType(abiAction.ActionType) + if err != nil { + return nil, fmt.Errorf( + "cannot parse reservation action type: [%v]", + err, + ) + } + + state, err := parseReservationActionState(abiAction.State) + if err != nil { + return nil, fmt.Errorf( + "cannot parse reservation action state: [%v]", + err, + ) + } + + return &tbtc.ReservationActionRecord{ + TargetWalletPublicKeyHash: abiAction.TargetWalletPubKeyHash, + RequestedAt: abiAction.RequestedAt, + TimeoutAt: abiAction.TimeoutAt, + TxMaxFee: abiAction.TxMaxFee, + ActionType: actionType, + State: state, + FeePaid: abiAction.FeePaid, + Redeemer: chain.Address(abiAction.Redeemer.String()), + Amount: abiAction.Amount, + ActionDataHash: abiAction.ActionDataHash, + SourceAnchorUtxoHash: abiAction.SourceAnchorUtxoHash, + UsedRetryCredit: abiAction.UsedRetryCredit, + WatchtowerDefaultDelay: abiAction.WatchtowerDefaultDelay, + WatchtowerLevelOneDelay: abiAction.WatchtowerLevelOneDelay, + WatchtowerLevelTwoDelay: abiAction.WatchtowerLevelTwoDelay, + IsPartial: abiAction.IsPartial, + RetryCreditSourceNonce: abiAction.RetryCreditSourceNonce, + }, nil +} + +// ReservationActions returns the on-chain reservation action record for the +// given reservation key and request nonce, including the late-settlement +// and retry-credit fields that the existing GetReservationAction +// representation drops. +func (tc *TbtcChain) ReservationActions( + reservationKey *big.Int, + requestNonce uint64, +) (*tbtc.ReservationActionRecord, error) { + abiAction, err := tc.reservationRouter.ReservationActions( + reservationKey, + requestNonce, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot get reservation action [0x%x:%d]: [%v]", + reservationKey, + requestNonce, + err, + ) + } + + action, err := convertReservationActionRecordFromAbiType(abiAction) + if err != nil { + return nil, fmt.Errorf( + "cannot convert reservation action [0x%x:%d] from abi type: [%v]", + reservationKey, + requestNonce, + err, + ) + } + + return action, nil +} + +// ActiveReservationsCount returns the current count of active reservations +// across all wallets and the cap on that count. +func (tc *TbtcChain) ActiveReservationsCount() (uint32, uint32, error) { + activeReservationsCount, err := tc.reservationRouter.ActiveReservationsCount() + if err != nil { + return 0, 0, fmt.Errorf( + "cannot get active reservations count: [%v]", + err, + ) + } + + return activeReservationsCount.Count, activeReservationsCount.MaxActive, nil +} + +// ReservationRouter returns the address of the ReservationRouter contract +// as stored on the Bridge. The router contract holds its own empty storage +// and only ever executes via Bridge.fallback's delegatecall, so this is +// the one place where the chain handle reads a router address value +// rather than binding a call to it: any actual reservation call routes +// through the Bridge binding (tc.reservationRouter, which is bound to the +// Bridge address) and dispatches into the router code via the fallback. +func (tc *TbtcChain) ReservationRouter() (chain.Address, error) { + address, err := tc.bridge.GetReservationRouter() + if err != nil { + return "", fmt.Errorf( + "cannot get reservation router address: [%v]", + err, + ) + } + + return chain.Address(address.Hex()), nil +} + +// IsReservedDeposit returns true if the given deposit was revealed with +// the reservation vault address and is therefore a reservation rather than +// a default deposit. +func (tc *TbtcChain) IsReservedDeposit( + depositKey *big.Int, +) (bool, error) { + isReserved, err := tc.bridge.IsReservedDeposit(depositKey) + if err != nil { + return false, fmt.Errorf( + "cannot check if deposit [0x%x] is reserved: [%v]", + depositKey, + err, + ) + } + + return isReserved, nil +} + +// OnReservationAcceptanceRequested registers a callback that is invoked +// when an on-chain ReservationAcceptanceRequested event is seen. The +// subscription filters against the Bridge's address (the binding is bound +// to the Bridge address; delegatecall preserves the caller's address +// context so events emitted by router code carry the Bridge's address). +func (tc *TbtcChain) OnReservationAcceptanceRequested( + handler func(event *tbtc.ReservationAcceptanceRequestedEvent), +) subscription.EventSubscription { + onEvent := func( + reservationKey *big.Int, + requestNonce uint64, + walletPublicKeyHash [20]byte, + depositAmount uint64, + txMaxFee uint64, + timeoutAt uint32, + blockNumber uint64, + ) { + handler(&tbtc.ReservationAcceptanceRequestedEvent{ + ReservationKey: reservationKey, + RequestNonce: requestNonce, + WalletPublicKeyHash: walletPublicKeyHash, + DepositAmount: depositAmount, + TxMaxFee: txMaxFee, + TimeoutAt: timeoutAt, + BlockNumber: blockNumber, + }) + } + + return tc.reservationRouter.ReservationAcceptanceRequestedEvent( + nil, + nil, + nil, + ).OnEvent(onEvent) +} + +// PastReservationAcceptanceRequestedEvents fetches past +// ReservationAcceptanceRequested events according to the provided filter +// or unfiltered if the filter is nil. +func (tc *TbtcChain) PastReservationAcceptanceRequestedEvents( + filter *tbtc.ReservationAcceptanceRequestedEventFilter, +) ([]*tbtc.ReservationAcceptanceRequestedEvent, error) { + var startBlock uint64 + var endBlock *uint64 + var reservationKey []*big.Int + var walletPublicKeyHash [][20]byte + + if filter != nil { + startBlock = filter.StartBlock + endBlock = filter.EndBlock + reservationKey = filter.ReservationKey + walletPublicKeyHash = filter.WalletPublicKeyHash + } + + events, err := tc.reservationRouter.PastReservationAcceptanceRequestedEvents( + startBlock, + endBlock, + reservationKey, + walletPublicKeyHash, + ) + if err != nil { + return nil, err + } + + convertedEvents := make([]*tbtc.ReservationAcceptanceRequestedEvent, 0) + for _, event := range events { + convertedEvents = append(convertedEvents, &tbtc.ReservationAcceptanceRequestedEvent{ + ReservationKey: event.ReservationKey, + RequestNonce: event.RequestNonce, + WalletPublicKeyHash: event.WalletPubKeyHash, + DepositAmount: event.DepositAmount, + TxMaxFee: event.TxMaxFee, + TimeoutAt: event.TimeoutAt, + BlockNumber: event.Raw.BlockNumber, + }) + } + + sort.SliceStable(convertedEvents, func(i, j int) bool { + return convertedEvents[i].BlockNumber < convertedEvents[j].BlockNumber + }) + + return convertedEvents, nil +} + +// OnReservationAccepted registers a callback that is invoked when an +// on-chain ReservationAccepted event is seen. +func (tc *TbtcChain) OnReservationAccepted( + handler func(event *tbtc.ReservationAcceptedEvent), +) subscription.EventSubscription { + onEvent := func( + reservationKey *big.Int, + requestNonce uint64, + walletPublicKeyHash [20]byte, + owner common.Address, + anchorTxHash [32]byte, + anchorAmount uint64, + expiresAt uint32, + blockNumber uint64, + ) { + handler(&tbtc.ReservationAcceptedEvent{ + ReservationKey: reservationKey, + RequestNonce: requestNonce, + WalletPublicKeyHash: walletPublicKeyHash, + Owner: chain.Address(owner.Hex()), + AnchorTxHash: anchorTxHash, + AnchorAmount: anchorAmount, + ExpiresAt: expiresAt, + BlockNumber: blockNumber, + }) + } + + return tc.reservationRouter.ReservationAcceptedEvent( + nil, + nil, + nil, + nil, + ).OnEvent(onEvent) +} + +// PastReservationAcceptedEvents fetches past ReservationAccepted events +// according to the provided filter or unfiltered if the filter is nil. +func (tc *TbtcChain) PastReservationAcceptedEvents( + filter *tbtc.ReservationAcceptedEventFilter, +) ([]*tbtc.ReservationAcceptedEvent, error) { + var startBlock uint64 + var endBlock *uint64 + var reservationKey []*big.Int + var walletPublicKeyHash [][20]byte + var owner []common.Address + + if filter != nil { + startBlock = filter.StartBlock + endBlock = filter.EndBlock + reservationKey = filter.ReservationKey + walletPublicKeyHash = filter.WalletPublicKeyHash + for _, o := range filter.Owner { + owner = append(owner, common.HexToAddress(string(o))) + } + } + + events, err := tc.reservationRouter.PastReservationAcceptedEvents( + startBlock, + endBlock, + reservationKey, + walletPublicKeyHash, + owner, + ) + if err != nil { + return nil, err + } + + convertedEvents := make([]*tbtc.ReservationAcceptedEvent, 0) + for _, event := range events { + convertedEvents = append(convertedEvents, &tbtc.ReservationAcceptedEvent{ + ReservationKey: event.ReservationKey, + RequestNonce: event.RequestNonce, + WalletPublicKeyHash: event.WalletPubKeyHash, + Owner: chain.Address(event.Owner.Hex()), + AnchorTxHash: event.AnchorTxHash, + AnchorAmount: event.AnchorAmount, + ExpiresAt: event.ExpiresAt, + BlockNumber: event.Raw.BlockNumber, + }) + } + + sort.SliceStable(convertedEvents, func(i, j int) bool { + return convertedEvents[i].BlockNumber < convertedEvents[j].BlockNumber + }) + + return convertedEvents, nil +} + +// OnReservationReanchorRequested registers a callback that is invoked +// when an on-chain ReservationReanchorRequested event is seen. +func (tc *TbtcChain) OnReservationReanchorRequested( + handler func(event *tbtc.ReservationReanchorRequestedEvent), +) subscription.EventSubscription { + onEvent := func( + reservationKey *big.Int, + requestNonce uint64, + sourceWalletPublicKeyHash [20]byte, + targetWalletPublicKeyHash [20]byte, + txMaxFee uint64, + blockNumber uint64, + ) { + handler(&tbtc.ReservationReanchorRequestedEvent{ + ReservationKey: reservationKey, + RequestNonce: requestNonce, + SourceWalletPublicKeyHash: sourceWalletPublicKeyHash, + TargetWalletPublicKeyHash: targetWalletPublicKeyHash, + TxMaxFee: txMaxFee, + BlockNumber: blockNumber, + }) + } + + return tc.reservationRouter.ReservationReanchorRequestedEvent( + nil, + nil, + nil, + nil, + ).OnEvent(onEvent) +} + +// PastReservationReanchorRequestedEvents fetches past +// ReservationReanchorRequested events according to the provided filter or +// unfiltered if the filter is nil. +func (tc *TbtcChain) PastReservationReanchorRequestedEvents( + filter *tbtc.ReservationReanchorRequestedEventFilter, +) ([]*tbtc.ReservationReanchorRequestedEvent, error) { + var startBlock uint64 + var endBlock *uint64 + var reservationKey []*big.Int + var sourceWalletPublicKeyHash [][20]byte + var targetWalletPublicKeyHash [][20]byte + + if filter != nil { + startBlock = filter.StartBlock + endBlock = filter.EndBlock + reservationKey = filter.ReservationKey + sourceWalletPublicKeyHash = filter.SourceWalletPublicKeyHash + targetWalletPublicKeyHash = filter.TargetWalletPublicKeyHash + } + + events, err := tc.reservationRouter.PastReservationReanchorRequestedEvents( + startBlock, + endBlock, + reservationKey, + sourceWalletPublicKeyHash, + targetWalletPublicKeyHash, + ) + if err != nil { + return nil, err + } + + convertedEvents := make([]*tbtc.ReservationReanchorRequestedEvent, 0) + for _, event := range events { + convertedEvents = append(convertedEvents, &tbtc.ReservationReanchorRequestedEvent{ + ReservationKey: event.ReservationKey, + RequestNonce: event.RequestNonce, + SourceWalletPublicKeyHash: event.SourceWalletPubKeyHash, + TargetWalletPublicKeyHash: event.TargetWalletPubKeyHash, + TxMaxFee: event.TxMaxFee, + BlockNumber: event.Raw.BlockNumber, + }) + } + + sort.SliceStable(convertedEvents, func(i, j int) bool { + return convertedEvents[i].BlockNumber < convertedEvents[j].BlockNumber + }) + + return convertedEvents, nil +} + +// OnReservationReanchored registers a callback that is invoked when an +// on-chain ReservationReanchored event is seen. +func (tc *TbtcChain) OnReservationReanchored( + handler func(event *tbtc.ReservationReanchoredEvent), +) subscription.EventSubscription { + onEvent := func( + reservationKey *big.Int, + requestNonce uint64, + newWalletPublicKeyHash [20]byte, + newAnchorTxHash [32]byte, + newAnchorAmount uint64, + blockNumber uint64, + ) { + handler(&tbtc.ReservationReanchoredEvent{ + ReservationKey: reservationKey, + RequestNonce: requestNonce, + NewWalletPublicKeyHash: newWalletPublicKeyHash, + NewAnchorTxHash: newAnchorTxHash, + NewAnchorAmount: newAnchorAmount, + BlockNumber: blockNumber, + }) + } + + return tc.reservationRouter.ReservationReanchoredEvent( + nil, + nil, + nil, + ).OnEvent(onEvent) +} + +// PastReservationReanchoredEvents fetches past ReservationReanchored +// events according to the provided filter or unfiltered if the filter is +// nil. +func (tc *TbtcChain) PastReservationReanchoredEvents( + filter *tbtc.ReservationReanchoredEventFilter, +) ([]*tbtc.ReservationReanchoredEvent, error) { + var startBlock uint64 + var endBlock *uint64 + var reservationKey []*big.Int + var newWalletPublicKeyHash [][20]byte + + if filter != nil { + startBlock = filter.StartBlock + endBlock = filter.EndBlock + reservationKey = filter.ReservationKey + newWalletPublicKeyHash = filter.NewWalletPublicKeyHash + } + + events, err := tc.reservationRouter.PastReservationReanchoredEvents( + startBlock, + endBlock, + reservationKey, + newWalletPublicKeyHash, + ) + if err != nil { + return nil, err + } + + convertedEvents := make([]*tbtc.ReservationReanchoredEvent, 0) + for _, event := range events { + convertedEvents = append(convertedEvents, &tbtc.ReservationReanchoredEvent{ + ReservationKey: event.ReservationKey, + RequestNonce: event.RequestNonce, + NewWalletPublicKeyHash: event.NewWalletPubKeyHash, + NewAnchorTxHash: event.NewAnchorTxHash, + NewAnchorAmount: event.NewAnchorAmount, + BlockNumber: event.Raw.BlockNumber, + }) + } + + sort.SliceStable(convertedEvents, func(i, j int) bool { + return convertedEvents[i].BlockNumber < convertedEvents[j].BlockNumber + }) + + return convertedEvents, nil +} + +// OnReservationActionTimedOut registers a callback that is invoked when an +// on-chain ReservationActionTimedOut event is seen. +func (tc *TbtcChain) OnReservationActionTimedOut( + handler func(event *tbtc.ReservationActionTimedOutEvent), +) subscription.EventSubscription { + onEvent := func( + reservationKey *big.Int, + requestNonce uint64, + actionType uint8, + blockNumber uint64, + ) { + parsedActionType, err := parseReservationActionType(actionType) + if err != nil { + logger.Errorf( + "unexpected reservation action type on ReservationActionTimedOut event: [%v]", + err, + ) + return + } + + handler(&tbtc.ReservationActionTimedOutEvent{ + ReservationKey: reservationKey, + RequestNonce: requestNonce, + ActionType: parsedActionType, + BlockNumber: blockNumber, + }) + } + + return tc.reservationRouter.ReservationActionTimedOutEvent( + nil, + nil, + ).OnEvent(onEvent) +} + +// PastReservationActionTimedOutEvents fetches past +// ReservationActionTimedOut events according to the provided filter or +// unfiltered if the filter is nil. +func (tc *TbtcChain) PastReservationActionTimedOutEvents( + filter *tbtc.ReservationActionTimedOutEventFilter, +) ([]*tbtc.ReservationActionTimedOutEvent, error) { + var startBlock uint64 + var endBlock *uint64 + var reservationKey []*big.Int + + if filter != nil { + startBlock = filter.StartBlock + endBlock = filter.EndBlock + reservationKey = filter.ReservationKey + } + + events, err := tc.reservationRouter.PastReservationActionTimedOutEvents( + startBlock, + endBlock, + reservationKey, + ) + if err != nil { + return nil, err + } + + convertedEvents := make([]*tbtc.ReservationActionTimedOutEvent, 0) + for _, event := range events { + parsedActionType, err := parseReservationActionType(event.ActionType) + if err != nil { + return nil, fmt.Errorf( + "unexpected reservation action type on past ReservationActionTimedOut event: [%v]", + err, + ) + } + + convertedEvents = append(convertedEvents, &tbtc.ReservationActionTimedOutEvent{ + ReservationKey: event.ReservationKey, + RequestNonce: event.RequestNonce, + ActionType: parsedActionType, + BlockNumber: event.Raw.BlockNumber, + }) + } + + sort.SliceStable(convertedEvents, func(i, j int) bool { + return convertedEvents[i].BlockNumber < convertedEvents[j].BlockNumber + }) + + return convertedEvents, nil +} + +// OnReservationActionSuperseded registers a callback that is invoked when +// an on-chain ReservationActionSuperseded event is seen. +func (tc *TbtcChain) OnReservationActionSuperseded( + handler func(event *tbtc.ReservationActionSupersededEvent), +) subscription.EventSubscription { + onEvent := func( + reservationKey *big.Int, + requestNonce uint64, + blockNumber uint64, + ) { + handler(&tbtc.ReservationActionSupersededEvent{ + ReservationKey: reservationKey, + RequestNonce: requestNonce, + BlockNumber: blockNumber, + }) + } + + return tc.reservationRouter.ReservationActionSupersededEvent( + nil, + nil, + ).OnEvent(onEvent) +} + +// OnReservationLateSettled registers a callback that is invoked when an +// on-chain ReservationLateSettled event is seen. +func (tc *TbtcChain) OnReservationLateSettled( + handler func(event *tbtc.ReservationLateSettledEvent), +) subscription.EventSubscription { + onEvent := func( + reservationKey *big.Int, + requestNonce uint64, + actionType uint8, + blockNumber uint64, + ) { + parsedActionType, err := parseReservationActionType(actionType) + if err != nil { + logger.Errorf( + "unexpected reservation action type on ReservationLateSettled event: [%v]", + err, + ) + return + } + + handler(&tbtc.ReservationLateSettledEvent{ + ReservationKey: reservationKey, + RequestNonce: requestNonce, + ActionType: parsedActionType, + BlockNumber: blockNumber, + }) + } + + return tc.reservationRouter.ReservationLateSettledEvent( + nil, + nil, + ).OnEvent(onEvent) +} + +// OnReservationRetryCreditMinted registers a callback that is invoked when +// an on-chain ReservationRetryCreditMinted event is seen. +func (tc *TbtcChain) OnReservationRetryCreditMinted( + handler func(event *tbtc.ReservationRetryCreditMintedEvent), +) subscription.EventSubscription { + onEvent := func( + reservationKey *big.Int, + blockNumber uint64, + ) { + handler(&tbtc.ReservationRetryCreditMintedEvent{ + ReservationKey: reservationKey, + BlockNumber: blockNumber, + }) + } + + return tc.reservationRouter.ReservationRetryCreditMintedEvent( + nil, + nil, + ).OnEvent(onEvent) +} + +// OnReservedDepositMarkedStale registers a callback that is invoked when +// an on-chain ReservedDepositMarkedStale event is seen. +func (tc *TbtcChain) OnReservedDepositMarkedStale( + handler func(event *tbtc.ReservedDepositMarkedStaleEvent), +) subscription.EventSubscription { + onEvent := func( + depositKey *big.Int, + blockNumber uint64, + ) { + handler(&tbtc.ReservedDepositMarkedStaleEvent{ + DepositKey: depositKey, + BlockNumber: blockNumber, + }) + } + + return tc.reservationRouter.ReservedDepositMarkedStaleEvent( + nil, + nil, + ).OnEvent(onEvent) +} + +// OnReservationStranded registers a callback that is invoked when an +// on-chain ReservationStranded event is seen. +func (tc *TbtcChain) OnReservationStranded( + handler func(event *tbtc.ReservationStrandedEvent), +) subscription.EventSubscription { + onEvent := func( + reservationKey *big.Int, + walletPublicKeyHash [20]byte, + owner common.Address, + anchorAmount uint64, + blockNumber uint64, + ) { + handler(&tbtc.ReservationStrandedEvent{ + ReservationKey: reservationKey, + WalletPublicKeyHash: walletPublicKeyHash, + Owner: chain.Address(owner.Hex()), + AnchorAmount: anchorAmount, + BlockNumber: blockNumber, + }) + } + + return tc.reservationRouter.ReservationStrandedEvent( + nil, + nil, + nil, + nil, + ).OnEvent(onEvent) +} + +// OnReservationParametersUpdated registers a callback that is invoked when +// an on-chain ReservationParametersUpdated event is seen. +func (tc *TbtcChain) OnReservationParametersUpdated( + handler func(event *tbtc.ReservationParametersUpdatedEvent), +) subscription.EventSubscription { + onEvent := func( + reservationMinAmount uint64, + reservationTxMaxFee uint64, + reservationTermSeconds uint32, + reservationDissolutionDelay uint32, + reservationMaxTotalAmount uint64, + maxReservationsPerWallet uint32, + reservationActionTimeout uint32, + reservationRenewalWindowSeconds uint32, + blockNumber uint64, + ) { + handler(&tbtc.ReservationParametersUpdatedEvent{ + ReservationMinAmount: reservationMinAmount, + ReservationTxMaxFee: reservationTxMaxFee, + ReservationTermSeconds: reservationTermSeconds, + ReservationDissolutionDelay: reservationDissolutionDelay, + ReservationMaxTotalAmount: reservationMaxTotalAmount, + MaxReservationsPerWallet: maxReservationsPerWallet, + ReservationActionTimeout: reservationActionTimeout, + ReservationRenewalWindowSeconds: reservationRenewalWindowSeconds, + BlockNumber: blockNumber, + }) + } + + return tc.reservationRouter.ReservationParametersUpdatedEvent( + nil, + ).OnEvent(onEvent) +} + +// OnReservationVaultUpdated registers a callback that is invoked when an +// on-chain ReservationVaultUpdated event is seen. +func (tc *TbtcChain) OnReservationVaultUpdated( + handler func(event *tbtc.ReservationVaultUpdatedEvent), +) subscription.EventSubscription { + onEvent := func( + reservationVault common.Address, + blockNumber uint64, + ) { + handler(&tbtc.ReservationVaultUpdatedEvent{ + ReservationVault: chain.Address(reservationVault.Hex()), + BlockNumber: blockNumber, + }) + } + + return tc.reservationRouter.ReservationVaultUpdatedEvent( + nil, + ).OnEvent(onEvent) +} + +// OnReservationCapsUpdated registers a callback that is invoked when an +// on-chain ReservationCapsUpdated event is seen. +func (tc *TbtcChain) OnReservationCapsUpdated( + handler func(event *tbtc.ReservationCapsUpdatedEvent), +) subscription.EventSubscription { + onEvent := func( + maxReservationsAmountPerWallet uint64, + reservationMaxSingleAmount uint64, + maxActiveReservations uint32, + blockNumber uint64, + ) { + handler(&tbtc.ReservationCapsUpdatedEvent{ + MaxReservationsAmountPerWallet: maxReservationsAmountPerWallet, + ReservationMaxSingleAmount: reservationMaxSingleAmount, + MaxActiveReservations: maxActiveReservations, + BlockNumber: blockNumber, + }) + } + + return tc.reservationRouter.ReservationCapsUpdatedEvent( + nil, + ).OnEvent(onEvent) +} diff --git a/pkg/tbtc/chain.go b/pkg/tbtc/chain.go index 02b2406b96..0f4c8a8522 100644 --- a/pkg/tbtc/chain.go +++ b/pkg/tbtc/chain.go @@ -34,9 +34,9 @@ type GroupSelectionChain interface { } // GroupSelectionResult represents a group selection result, i.e. operators -// selected to perform the DKG protocol. The result consists of two slices -// of equal length holding the chain.OperatorID and chain.Address for each -// selected operator. +// selected to perform the group key generation protocol. The result consists of +// two slices of equal length holding the chain.OperatorID and chain.Address for +// each selected operator. type GroupSelectionResult struct { OperatorsIDs chain.OperatorIDs OperatorsAddresses chain.Addresses @@ -597,4 +597,515 @@ type Chain interface { InactivityClaimChain BridgeChain WalletProposalValidatorChain + ReservationChain +} + +// ReservationChain defines the subset of the TBTC chain interface that pertains +// specifically to UTXO reservation Bridge operations. The reservation state +// machine is implemented behind Bridge.fallback's delegatecall to the +// ReservationRouter contract; the binding is constructed against the Bridge +// address, so every read, write, and log subscription on this interface +// routes through the Bridge's storage rather than the router's empty +// standalone storage. +type ReservationChain interface { + // RequestReservationAcceptance requests a reservation acceptance action + // generation for the given reservation. The reservation must be in a + // state that allows acceptance; the operator-side guard is enforced at + // the chain layer. + RequestReservationAcceptance( + reservationKey *big.Int, + walletPublicKeyHash [20]byte, + ) error + + // RequestReservationReanchor requests a reservation re-anchor action + // generation for the given reservation, targeting the given wallet. + RequestReservationReanchor( + reservationKey *big.Int, + targetWalletPublicKeyHash [20]byte, + ) error + + // SubmitReservationProof submits an SPV proof for the given reservation + // action generation. proofType selects between Acceptance, Redemption, + // Reanchor, and Dissolution proofs; m1 invokes only Acceptance (1) and + // Reanchor (3). The call is restricted to the SPV maintainer registered + // against the Bridge. + SubmitReservationProof( + proofType uint8, + txInfo *BitcoinTxInfo, + proof *BitcoinTxProof, + mainUtxo *BitcoinTxUTXO, + reservationKey *big.Int, + requestNonce uint64, + ) error + + // NotifyReservationActionTimeout notifies the Bridge that the timeout + // for the given reservation action generation has elapsed without the + // SPV proof being submitted. The walletMembersIDs carry the operator + // IDs of the wallet that was authorized for the action; they are used + // to slash the wallet in m2-era records (no-op in m1). + NotifyReservationActionTimeout( + reservationKey *big.Int, + walletMembersIDs []uint32, + ) error + + // NotifyStaleReservedDeposit notifies the Bridge that the given reserved + // deposit's wallet did not anchor it within the reservation-action + // timeout and should be released back to the default sweeping path. + NotifyStaleReservedDeposit(depositKey *big.Int) error + + // NotifyReservationStranded notifies the Bridge that the wallet + // custodying the given reservation has been closed or terminated and + // the anchor is therefore stranded. This is the m1 path that closes + // reservations whose wallet is no longer live. + NotifyReservationStranded(reservationKey *big.Int) error + + // GetReservation gets the on-chain reservation record for the given + // reservation key. Returns an error if the reservation was not found. + GetReservation(reservationKey *big.Int) (*Reservation, error) + + // GetReservationAction gets the on-chain action record for the given + // reservation key and request nonce. Returns an error if the action + // generation was not found. + GetReservationAction( + reservationKey *big.Int, + requestNonce uint64, + ) (*ReservationAction, error) + + // ReservationParameters gets the current on-chain values of the Bridge + // reservation parameters. + ReservationParameters() (*ReservationParameters, error) + + // ReservationParametersFull is an alias for ReservationParameters + // retained for callers that want a name indicating the full 10-tuple + // on-chain layout. The on-chain reservation parameters tuple and the + // tbtc.ReservationParameters Go type carry the same 10 fields, so this + // returns the same struct. + ReservationParametersFull() (*ReservationParameters, error) + + // ReservationCaps returns the cap parameters that gate reservation + // acceptance: the maximum aggregate satoshi amount a single wallet may + // custody across all of its reservations, and the maximum satoshi + // amount any single reservation may anchor. + ReservationCaps() (maxReservationsAmountPerWallet uint64, reservationMaxSingleAmount uint64, err error) + + // WalletReservationsAmount returns the aggregate satoshi amount + // currently anchored by the given wallet across all of its + // reservations. + WalletReservationsAmount(walletPublicKeyHash [20]byte) (uint64, error) + + // WalletReservationsCount returns the number of reservations currently + // custodied by the given wallet. + WalletReservationsCount(walletPublicKeyHash [20]byte) (uint32, error) + + // WalletReservations returns the reservation keys for all reservations + // currently custodied by the given wallet. + WalletReservations(walletPublicKeyHash [20]byte) ([]*big.Int, error) + + // ReservationByAnchorUtxo returns the reservation key whose anchor + // outpoint is the given Bitcoin transaction output, or an empty value + // if no reservation is anchored there. + ReservationByAnchorUtxo( + anchorTxHash [32]byte, + anchorTxOutputIndex uint32, + ) (*big.Int, error) + + // ReservedDepositWallet returns the wallet public key hash to which + // the given reserved deposit was revealed. Returns the zero hash if the + // deposit is not a reserved deposit. + ReservedDepositWallet(depositKey *big.Int) ([20]byte, error) + + // PendingReservedDeposits returns the number of reserved deposits that + // have been revealed to the Bridge but not yet accepted by a wallet. + // The value is consumed by the vault-repoint and pre-acceptance paths + // to gate new deposits. + PendingReservedDeposits() (uint64, error) + + // Reservations returns the on-chain reservation request record for the + // given reservation key, including the cumulative re-anchor fee that + // the existing GetReservation representation drops. Mirrors the + // ReservationRouter.reservations view verbatim. + Reservations(reservationKey *big.Int) (*ReservationRequest, error) + + // ReservationActions returns the on-chain reservation action record + // for the given reservation key and request nonce, including the + // late-settlement / retry-credit fields that the existing + // GetReservationAction representation drops. Mirrors the + // ReservationRouter.reservationActions view verbatim. + ReservationActions( + reservationKey *big.Int, + requestNonce uint64, + ) (*ReservationActionRecord, error) + + // ActiveReservationsCount returns the current count of active + // reservations across all wallets and the cap on that count. + ActiveReservationsCount() (count uint32, maxActive uint32, err error) + + // ReservationRouter returns the address of the ReservationRouter + // contract as stored on the Bridge. This is the one place where the + // chain handle reads a router address value rather than binding a call + // to it: the router holds its own empty storage and only ever executes + // via Bridge.fallback's delegatecall, so any actual reservation call + // goes through the Bridge binding. + ReservationRouter() (chain.Address, error) + + // IsReservedDeposit returns true if the given deposit was revealed + // with the reservation vault address and is therefore a reservation + // rather than a default deposit. + IsReservedDeposit(depositKey *big.Int) (bool, error) + + // OnReservationAcceptanceRequested registers a callback that is invoked + // when an on-chain ReservationAcceptanceRequested event is seen. + OnReservationAcceptanceRequested( + handler func(event *ReservationAcceptanceRequestedEvent), + ) subscription.EventSubscription + + // PastReservationAcceptanceRequestedEvents fetches past + // ReservationAcceptanceRequested 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, i.e. the latest event is + // at the end of the slice. + PastReservationAcceptanceRequestedEvents( + filter *ReservationAcceptanceRequestedEventFilter, + ) ([]*ReservationAcceptanceRequestedEvent, error) + + // OnReservationAccepted registers a callback that is invoked when an + // on-chain ReservationAccepted event is seen. + OnReservationAccepted( + handler func(event *ReservationAcceptedEvent), + ) subscription.EventSubscription + + // PastReservationAcceptedEvents fetches past ReservationAccepted events + // according to the provided filter or unfiltered if the filter is nil. + PastReservationAcceptedEvents( + filter *ReservationAcceptedEventFilter, + ) ([]*ReservationAcceptedEvent, error) + + // OnReservationReanchorRequested registers a callback that is invoked + // when an on-chain ReservationReanchorRequested event is seen. + OnReservationReanchorRequested( + handler func(event *ReservationReanchorRequestedEvent), + ) subscription.EventSubscription + + // PastReservationReanchorRequestedEvents fetches past + // ReservationReanchorRequested events according to the provided filter + // or unfiltered if the filter is nil. + PastReservationReanchorRequestedEvents( + filter *ReservationReanchorRequestedEventFilter, + ) ([]*ReservationReanchorRequestedEvent, error) + + // OnReservationReanchored registers a callback that is invoked when an + // on-chain ReservationReanchored event is seen. + OnReservationReanchored( + handler func(event *ReservationReanchoredEvent), + ) subscription.EventSubscription + + // PastReservationReanchoredEvents fetches past ReservationReanchored + // events according to the provided filter or unfiltered if the filter + // is nil. + PastReservationReanchoredEvents( + filter *ReservationReanchoredEventFilter, + ) ([]*ReservationReanchoredEvent, error) + + // OnReservationActionTimedOut registers a callback that is invoked + // when an on-chain ReservationActionTimedOut event is seen. The + // timeout watcher fires the notification that triggers this event. + OnReservationActionTimedOut( + handler func(event *ReservationActionTimedOutEvent), + ) subscription.EventSubscription + + // PastReservationActionTimedOutEvents fetches past + // ReservationActionTimedOut events according to the provided filter or + // unfiltered if the filter is nil. + PastReservationActionTimedOutEvents( + filter *ReservationActionTimedOutEventFilter, + ) ([]*ReservationActionTimedOutEvent, error) + + // OnReservationActionSuperseded registers a callback that is invoked + // when an on-chain ReservationActionSuperseded event is seen. + OnReservationActionSuperseded( + handler func(event *ReservationActionSupersededEvent), + ) subscription.EventSubscription + + // OnReservationLateSettled registers a callback that is invoked when + // an on-chain ReservationLateSettled event is seen. + OnReservationLateSettled( + handler func(event *ReservationLateSettledEvent), + ) subscription.EventSubscription + + // OnReservationRetryCreditMinted registers a callback that is invoked + // when an on-chain ReservationRetryCreditMinted event is seen. m1 + // records no such events because the on-chain mint path is unreachable + // on m1-era records; the subscription is still wired for forward + // compatibility with m2. + OnReservationRetryCreditMinted( + handler func(event *ReservationRetryCreditMintedEvent), + ) subscription.EventSubscription + + // OnReservedDepositMarkedStale registers a callback that is invoked + // when an on-chain ReservedDepositMarkedStale event is seen. + OnReservedDepositMarkedStale( + handler func(event *ReservedDepositMarkedStaleEvent), + ) subscription.EventSubscription + + // OnReservationStranded registers a callback that is invoked when an + // on-chain ReservationStranded event is seen. Stranding is the m1 + // close path for reservations whose custodying wallet has been closed + // or terminated. + OnReservationStranded( + handler func(event *ReservationStrandedEvent), + ) subscription.EventSubscription + + // OnReservationParametersUpdated registers a callback that is invoked + // when an on-chain ReservationParametersUpdated event is seen. + OnReservationParametersUpdated( + handler func(event *ReservationParametersUpdatedEvent), + ) subscription.EventSubscription + + // OnReservationVaultUpdated registers a callback that is invoked when + // an on-chain ReservationVaultUpdated event is seen. + OnReservationVaultUpdated( + handler func(event *ReservationVaultUpdatedEvent), + ) subscription.EventSubscription + + // OnReservationCapsUpdated registers a callback that is invoked when + // an on-chain ReservationCapsUpdated event is seen. + OnReservationCapsUpdated( + handler func(event *ReservationCapsUpdatedEvent), + ) subscription.EventSubscription +} + +// BitcoinTxInfo represents the on-chain BitcoinTx.Info struct used by +// reservation proof submissions. +type BitcoinTxInfo struct { + Version [4]byte + InputVector []byte + OutputVector []byte + Locktime [4]byte +} + +// BitcoinTxProof represents the on-chain BitcoinTx.Proof struct used by +// reservation proof submissions. +type BitcoinTxProof struct { + MerkleProof []byte + TxIndexInBlock *big.Int + BitcoinHeaders []byte + CoinbasePreimage [32]byte + CoinbaseProof []byte +} + +// BitcoinTxUTXO represents the on-chain BitcoinTx.UTXO struct used by +// reservation proof submissions. +type BitcoinTxUTXO struct { + TxHash [32]byte + TxOutputIndex uint32 + TxOutputValue uint64 +} + +// ReservationRequest represents the on-chain reservation request record +// returned by ReservationRouter.reservations. It mirrors the Solidity +// Reservation.ReservationRequest struct field-for-field. +type ReservationRequest struct { + Owner chain.Address + MintedAmount uint64 + AcceptedAt uint32 + WalletPublicKeyHash [20]byte + AnchorAmount uint64 + ExpiresAt uint32 + AnchorTxHash [32]byte + AnchorTxOutputIndex uint32 + State ReservationState + RequestNonce uint64 + RetryCredit bool + DissolutionEligibleAt uint32 + CumulativeReanchorFee uint64 +} + +// ReservationActionRecord represents the on-chain reservation action record +// returned by ReservationRouter.reservationActions. It mirrors the Solidity +// Reservation.ReservationAction struct field-for-field. +type ReservationActionRecord struct { + TargetWalletPublicKeyHash [20]byte + RequestedAt uint32 + TimeoutAt uint32 + TxMaxFee uint64 + ActionType ReservationActionType + State ReservationActionState + FeePaid bool + Redeemer chain.Address + Amount uint64 + ActionDataHash [32]byte + SourceAnchorUtxoHash [32]byte + UsedRetryCredit bool + WatchtowerDefaultDelay uint32 + WatchtowerLevelOneDelay uint32 + WatchtowerLevelTwoDelay uint32 + IsPartial bool + RetryCreditSourceNonce uint64 +} + +// ReservationAcceptanceRequestedEvent represents a reservation acceptance +// requested event. +type ReservationAcceptanceRequestedEvent struct { + ReservationKey *big.Int + RequestNonce uint64 + WalletPublicKeyHash [20]byte + DepositAmount uint64 + TxMaxFee uint64 + TimeoutAt uint32 + BlockNumber uint64 +} + +// ReservationAcceptanceRequestedEventFilter is a component allowing to filter +// ReservationAcceptanceRequestedEvent. +type ReservationAcceptanceRequestedEventFilter struct { + StartBlock uint64 + EndBlock *uint64 + ReservationKey []*big.Int + WalletPublicKeyHash [][20]byte +} + +// ReservationAcceptedEvent represents a reservation accepted event. +type ReservationAcceptedEvent struct { + ReservationKey *big.Int + RequestNonce uint64 + WalletPublicKeyHash [20]byte + Owner chain.Address + AnchorTxHash [32]byte + AnchorAmount uint64 + ExpiresAt uint32 + BlockNumber uint64 +} + +// ReservationAcceptedEventFilter is a component allowing to filter +// ReservationAcceptedEvent. +type ReservationAcceptedEventFilter struct { + StartBlock uint64 + EndBlock *uint64 + ReservationKey []*big.Int + WalletPublicKeyHash [][20]byte + Owner []chain.Address +} + +// ReservationReanchorRequestedEvent represents a reservation re-anchor +// requested event. +type ReservationReanchorRequestedEvent struct { + ReservationKey *big.Int + RequestNonce uint64 + SourceWalletPublicKeyHash [20]byte + TargetWalletPublicKeyHash [20]byte + TxMaxFee uint64 + BlockNumber uint64 +} + +// ReservationReanchorRequestedEventFilter is a component allowing to filter +// ReservationReanchorRequestedEvent. +type ReservationReanchorRequestedEventFilter struct { + StartBlock uint64 + EndBlock *uint64 + ReservationKey []*big.Int + SourceWalletPublicKeyHash [][20]byte + TargetWalletPublicKeyHash [][20]byte +} + +// ReservationReanchoredEvent represents a reservation re-anchored event. +type ReservationReanchoredEvent struct { + ReservationKey *big.Int + RequestNonce uint64 + NewWalletPublicKeyHash [20]byte + NewAnchorTxHash [32]byte + NewAnchorAmount uint64 + BlockNumber uint64 +} + +// ReservationReanchoredEventFilter is a component allowing to filter +// ReservationReanchoredEvent. +type ReservationReanchoredEventFilter struct { + StartBlock uint64 + EndBlock *uint64 + ReservationKey []*big.Int + NewWalletPublicKeyHash [][20]byte +} + +// ReservationActionTimedOutEvent represents a reservation action timed out +// event. +type ReservationActionTimedOutEvent struct { + ReservationKey *big.Int + RequestNonce uint64 + ActionType ReservationActionType + BlockNumber uint64 +} + +// ReservationActionTimedOutEventFilter is a component allowing to filter +// ReservationActionTimedOutEvent. +type ReservationActionTimedOutEventFilter struct { + StartBlock uint64 + EndBlock *uint64 + ReservationKey []*big.Int +} + +// ReservationActionSupersededEvent represents a reservation action superseded +// event. +type ReservationActionSupersededEvent struct { + ReservationKey *big.Int + RequestNonce uint64 + BlockNumber uint64 +} + +// ReservationLateSettledEvent represents a reservation late-settled event. +type ReservationLateSettledEvent struct { + ReservationKey *big.Int + RequestNonce uint64 + ActionType ReservationActionType + BlockNumber uint64 +} + +// ReservationRetryCreditMintedEvent represents a reservation retry credit +// minted event. +type ReservationRetryCreditMintedEvent struct { + ReservationKey *big.Int + BlockNumber uint64 +} + +// ReservedDepositMarkedStaleEvent represents a reserved deposit marked stale +// event. +type ReservedDepositMarkedStaleEvent struct { + DepositKey *big.Int + BlockNumber uint64 +} + +// ReservationStrandedEvent represents a reservation stranded event. +type ReservationStrandedEvent struct { + ReservationKey *big.Int + WalletPublicKeyHash [20]byte + Owner chain.Address + AnchorAmount uint64 + BlockNumber uint64 +} + +// ReservationParametersUpdatedEvent represents a reservation parameters +// updated event. +type ReservationParametersUpdatedEvent struct { + ReservationMinAmount uint64 + ReservationTxMaxFee uint64 + ReservationTermSeconds uint32 + ReservationDissolutionDelay uint32 + ReservationMaxTotalAmount uint64 + MaxReservationsPerWallet uint32 + ReservationActionTimeout uint32 + ReservationRenewalWindowSeconds uint32 + BlockNumber uint64 +} + +// ReservationVaultUpdatedEvent represents a reservation vault updated event. +type ReservationVaultUpdatedEvent struct { + ReservationVault chain.Address + BlockNumber uint64 +} + +// ReservationCapsUpdatedEvent represents a reservation caps updated event. +type ReservationCapsUpdatedEvent struct { + MaxReservationsAmountPerWallet uint64 + ReservationMaxSingleAmount uint64 + MaxActiveReservations uint32 + BlockNumber uint64 } diff --git a/pkg/tbtc/chain_test.go b/pkg/tbtc/chain_test.go index 5768d03422..bace37faba 100644 --- a/pkg/tbtc/chain_test.go +++ b/pkg/tbtc/chain_test.go @@ -1499,3 +1499,228 @@ func (lc *localChain) ValidateReservationDissolutionProposal( ) error { panic("unsupported") } +func (lc *localChain) RequestReservationAcceptance( + reservationKey *big.Int, + walletPublicKeyHash [20]byte, +) error { + panic("unsupported") +} + +func (lc *localChain) RequestReservationReanchor( + reservationKey *big.Int, + targetWalletPublicKeyHash [20]byte, +) error { + panic("unsupported") +} + +func (lc *localChain) SubmitReservationProof( + proofType uint8, + txInfo *BitcoinTxInfo, + proof *BitcoinTxProof, + mainUtxo *BitcoinTxUTXO, + reservationKey *big.Int, + requestNonce uint64, +) error { + panic("unsupported") +} + +func (lc *localChain) NotifyReservationActionTimeout( + reservationKey *big.Int, + walletMembersIDs []uint32, +) error { + panic("unsupported") +} + +func (lc *localChain) NotifyStaleReservedDeposit( + depositKey *big.Int, +) error { + panic("unsupported") +} + +func (lc *localChain) NotifyReservationStranded( + reservationKey *big.Int, +) error { + panic("unsupported") +} + +func (lc *localChain) ReservationParametersFull() (*ReservationParameters, error) { + return nil, fmt.Errorf("unsupported") +} + +func (lc *localChain) ReservationCaps() ( + uint64, + uint64, + error, +) { + return 0, 0, fmt.Errorf("unsupported") +} + +func (lc *localChain) WalletReservationsAmount( + walletPublicKeyHash [20]byte, +) (uint64, error) { + return 0, fmt.Errorf("unsupported") +} + +func (lc *localChain) WalletReservationsCount( + walletPublicKeyHash [20]byte, +) (uint32, error) { + return 0, fmt.Errorf("unsupported") +} + +func (lc *localChain) WalletReservations( + walletPublicKeyHash [20]byte, +) ([]*big.Int, error) { + return nil, fmt.Errorf("unsupported") +} + +func (lc *localChain) ReservationByAnchorUtxo( + anchorTxHash [32]byte, + anchorTxOutputIndex uint32, +) (*big.Int, error) { + return nil, fmt.Errorf("unsupported") +} + +func (lc *localChain) ReservedDepositWallet( + depositKey *big.Int, +) ([20]byte, error) { + return [20]byte{}, fmt.Errorf("unsupported") +} + +func (lc *localChain) PendingReservedDeposits() (uint64, error) { + return 0, fmt.Errorf("unsupported") +} + +func (lc *localChain) Reservations( + reservationKey *big.Int, +) (*ReservationRequest, error) { + return nil, fmt.Errorf("unsupported") +} + +func (lc *localChain) ReservationActions( + reservationKey *big.Int, + requestNonce uint64, +) (*ReservationActionRecord, error) { + return nil, fmt.Errorf("unsupported") +} + +func (lc *localChain) ActiveReservationsCount() (uint32, uint32, error) { + return 0, 0, fmt.Errorf("unsupported") +} + +func (lc *localChain) ReservationRouter() (chain.Address, error) { + return "", fmt.Errorf("unsupported") +} + +func (lc *localChain) IsReservedDeposit( + depositKey *big.Int, +) (bool, error) { + return false, fmt.Errorf("unsupported") +} + +func (lc *localChain) OnReservationAcceptanceRequested( + handler func(event *ReservationAcceptanceRequestedEvent), +) subscription.EventSubscription { + return subscription.NewEventSubscription(func() {}) +} + +func (lc *localChain) PastReservationAcceptanceRequestedEvents( + filter *ReservationAcceptanceRequestedEventFilter, +) ([]*ReservationAcceptanceRequestedEvent, error) { + return nil, fmt.Errorf("unsupported") +} + +func (lc *localChain) OnReservationAccepted( + handler func(event *ReservationAcceptedEvent), +) subscription.EventSubscription { + return subscription.NewEventSubscription(func() {}) +} + +func (lc *localChain) PastReservationAcceptedEvents( + filter *ReservationAcceptedEventFilter, +) ([]*ReservationAcceptedEvent, error) { + return nil, fmt.Errorf("unsupported") +} + +func (lc *localChain) OnReservationReanchorRequested( + handler func(event *ReservationReanchorRequestedEvent), +) subscription.EventSubscription { + return subscription.NewEventSubscription(func() {}) +} + +func (lc *localChain) PastReservationReanchorRequestedEvents( + filter *ReservationReanchorRequestedEventFilter, +) ([]*ReservationReanchorRequestedEvent, error) { + return nil, fmt.Errorf("unsupported") +} + +func (lc *localChain) OnReservationReanchored( + handler func(event *ReservationReanchoredEvent), +) subscription.EventSubscription { + return subscription.NewEventSubscription(func() {}) +} + +func (lc *localChain) PastReservationReanchoredEvents( + filter *ReservationReanchoredEventFilter, +) ([]*ReservationReanchoredEvent, error) { + return nil, fmt.Errorf("unsupported") +} + +func (lc *localChain) OnReservationActionTimedOut( + handler func(event *ReservationActionTimedOutEvent), +) subscription.EventSubscription { + return subscription.NewEventSubscription(func() {}) +} + +func (lc *localChain) PastReservationActionTimedOutEvents( + filter *ReservationActionTimedOutEventFilter, +) ([]*ReservationActionTimedOutEvent, error) { + return nil, fmt.Errorf("unsupported") +} + +func (lc *localChain) OnReservationActionSuperseded( + handler func(event *ReservationActionSupersededEvent), +) subscription.EventSubscription { + return subscription.NewEventSubscription(func() {}) +} + +func (lc *localChain) OnReservationLateSettled( + handler func(event *ReservationLateSettledEvent), +) subscription.EventSubscription { + return subscription.NewEventSubscription(func() {}) +} + +func (lc *localChain) OnReservationRetryCreditMinted( + handler func(event *ReservationRetryCreditMintedEvent), +) subscription.EventSubscription { + return subscription.NewEventSubscription(func() {}) +} + +func (lc *localChain) OnReservedDepositMarkedStale( + handler func(event *ReservedDepositMarkedStaleEvent), +) subscription.EventSubscription { + return subscription.NewEventSubscription(func() {}) +} + +func (lc *localChain) OnReservationStranded( + handler func(event *ReservationStrandedEvent), +) subscription.EventSubscription { + return subscription.NewEventSubscription(func() {}) +} + +func (lc *localChain) OnReservationParametersUpdated( + handler func(event *ReservationParametersUpdatedEvent), +) subscription.EventSubscription { + return subscription.NewEventSubscription(func() {}) +} + +func (lc *localChain) OnReservationVaultUpdated( + handler func(event *ReservationVaultUpdatedEvent), +) subscription.EventSubscription { + return subscription.NewEventSubscription(func() {}) +} + +func (lc *localChain) OnReservationCapsUpdated( + handler func(event *ReservationCapsUpdatedEvent), +) subscription.EventSubscription { + return subscription.NewEventSubscription(func() {}) +} From 053577925c40c13fba6bf360f1a0e7a3f59b675a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 26 Aug 2026 10:11:39 +0000 Subject: [PATCH 09/39] feat(tbtc): add reservation methods to tbtcpg and spv Chain interfaces --- pkg/maintainer/spv/chain.go | 97 +++++++++++++++ pkg/maintainer/spv/chain_test.go | 128 ++++++++++++++++++++ pkg/tbtcpg/chain.go | 137 +++++++++++++++++++++ pkg/tbtcpg/chain_test.go | 196 +++++++++++++++++++++++++++++++ 4 files changed, 558 insertions(+) diff --git a/pkg/maintainer/spv/chain.go b/pkg/maintainer/spv/chain.go index c9e060e9bf..71acc042de 100644 --- a/pkg/maintainer/spv/chain.go +++ b/pkg/maintainer/spv/chain.go @@ -85,6 +85,82 @@ type Chain interface { mainUTXO bitcoin.UnspentTransactionOutput, ) error + // SubmitReservationProof submits an SPV proof for the given reservation + // action generation. proofType selects between Acceptance, Redemption, + // Reanchor, and Dissolution proofs; m1 invokes only Acceptance (1) and + // Reanchor (3). The call is restricted to the SPV maintainer registered + // against the Bridge. + SubmitReservationProof( + proofType uint8, + txInfo *tbtc.BitcoinTxInfo, + proof *tbtc.BitcoinTxProof, + mainUtxo *tbtc.BitcoinTxUTXO, + reservationKey *big.Int, + requestNonce uint64, + ) error + + // NotifyReservationActionTimeout notifies the Bridge that the timeout + // for the given reservation action generation has elapsed without the + // SPV proof being submitted. The walletMembersIDs carry the operator + // IDs of the wallet that was authorized for the action. + NotifyReservationActionTimeout( + reservationKey *big.Int, + walletMembersIDs []uint32, + ) error + + // NotifyStaleReservedDeposit notifies the Bridge that the given reserved + // deposit's wallet did not anchor it within the reservation-action + // timeout and should be released back to the default sweeping path. + NotifyStaleReservedDeposit(depositKey *big.Int) error + + // NotifyReservationStranded notifies the Bridge that the wallet + // custodying the given reservation has been closed or terminated and + // the anchor is therefore stranded. + NotifyReservationStranded(reservationKey *big.Int) error + + // GetReservation gets the on-chain reservation record for the given + // reservation key. Returns an error if the reservation was not found. + GetReservation(reservationKey *big.Int) (*tbtc.Reservation, error) + + // GetReservationAction gets the on-chain action record for the given + // reservation key and request nonce. Returns an error if the action + // generation was not found. + GetReservationAction( + reservationKey *big.Int, + requestNonce uint64, + ) (*tbtc.ReservationAction, error) + + // ReservationParameters gets the current on-chain values of the Bridge + // reservation parameters. + ReservationParameters() (*tbtc.ReservationParameters, error) + + // WalletReservations returns the reservation keys for all reservations + // currently custodied by the given wallet. + WalletReservations(walletPublicKeyHash [20]byte) ([]*big.Int, error) + + // Reservations returns the on-chain reservation request record for the + // given reservation key. Mirrors the ReservationRouter.reservations + // view verbatim. + Reservations(reservationKey *big.Int) (*tbtc.ReservationRequest, error) + + // ReservationActions returns the on-chain reservation action record + // for the given reservation key and request nonce. Mirrors the + // ReservationRouter.reservationActions view verbatim. + ReservationActions( + reservationKey *big.Int, + requestNonce uint64, + ) (*tbtc.ReservationActionRecord, error) + + // IsReservedDeposit returns true if the given deposit was revealed + // with the reservation vault address and is therefore a reservation + // rather than a default deposit. + IsReservedDeposit(depositKey *big.Int) (bool, error) + + // ReservedDepositWallet returns the wallet public key hash to which the + // given reserved deposit was revealed. Returns the zero hash if the + // deposit is not a reserved deposit. + ReservedDepositWallet(depositKey *big.Int) ([20]byte, error) + // PastDepositRevealedEvents fetches past deposit reveal 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, i.e. the @@ -109,4 +185,25 @@ type Chain interface { PastMovingFundsCommitmentSubmittedEvents( filter *tbtc.MovingFundsCommitmentSubmittedEventFilter, ) ([]*tbtc.MovingFundsCommitmentSubmittedEvent, error) + + // PastReservationAcceptedEvents fetches past ReservationAccepted 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. + PastReservationAcceptedEvents( + filter *tbtc.ReservationAcceptedEventFilter, + ) ([]*tbtc.ReservationAcceptedEvent, error) + + // PastReservationReanchoredEvents fetches past ReservationReanchored + // 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. + PastReservationReanchoredEvents( + filter *tbtc.ReservationReanchoredEventFilter, + ) ([]*tbtc.ReservationReanchoredEvent, error) + + // PastReservationActionTimedOutEvents fetches past ReservationActionTimedOut + // 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. + PastReservationActionTimedOutEvents( + filter *tbtc.ReservationActionTimedOutEventFilter, + ) ([]*tbtc.ReservationActionTimedOutEvent, error) } diff --git a/pkg/maintainer/spv/chain_test.go b/pkg/maintainer/spv/chain_test.go index 2a0bcc0a89..a07759c6aa 100644 --- a/pkg/maintainer/spv/chain_test.go +++ b/pkg/maintainer/spv/chain_test.go @@ -720,3 +720,131 @@ func (mbc *mockBlockCounter) SetCurrentBlock(block uint64) { func (mbc *mockBlockCounter) WatchBlocks(ctx context.Context) <-chan uint64 { panic("unsupported") } + +// SubmitReservationProof is a stub matching the reservation +// additions on the production Chain interface. The reservation +// acceptance and re-anchor proposal builders replace this stub +// with the call path that records a submitted proof for assertion +// in tests. +func (lc *localChain) SubmitReservationProof( + proofType uint8, + txInfo *tbtc.BitcoinTxInfo, + proof *tbtc.BitcoinTxProof, + mainUtxo *tbtc.BitcoinTxUTXO, + reservationKey *big.Int, + requestNonce uint64, +) error { + panic("unsupported") +} + +// NotifyReservationActionTimeout is a stub matching the reservation +// additions on the production Chain interface. The timeout watcher +// builder replaces this stub with the call path that records a +// timeout notification for assertion in tests. +func (lc *localChain) NotifyReservationActionTimeout( + reservationKey *big.Int, + walletMembersIDs []uint32, +) error { + panic("unsupported") +} + +// NotifyStaleReservedDeposit is a stub matching the reservation +// additions on the production Chain interface. The stale deposit +// watcher builder replaces this stub with the call path that +// records a stale deposit notification for assertion in tests. +func (lc *localChain) NotifyStaleReservedDeposit(depositKey *big.Int) error { + panic("unsupported") +} + +// NotifyReservationStranded is a stub matching the reservation +// additions on the production Chain interface. The stranded +// reservation watcher builder replaces this stub with the call path +// that records a stranded notification for assertion in tests. +func (lc *localChain) NotifyReservationStranded(reservationKey *big.Int) error { + panic("unsupported") +} + +// GetReservation is a stub matching the reservation additions on the +// production Chain interface. +func (lc *localChain) GetReservation( + reservationKey *big.Int, +) (*tbtc.Reservation, error) { + panic("unsupported") +} + +// GetReservationAction is a stub matching the reservation additions on +// the production Chain interface. +func (lc *localChain) GetReservationAction( + reservationKey *big.Int, + requestNonce uint64, +) (*tbtc.ReservationAction, error) { + panic("unsupported") +} + +// ReservationParameters is a stub matching the reservation additions on +// the production Chain interface. +func (lc *localChain) ReservationParameters() ( + *tbtc.ReservationParameters, + error, +) { + panic("unsupported") +} + +// WalletReservations is a stub matching the reservation additions on the +// production Chain interface. +func (lc *localChain) WalletReservations( + walletPublicKeyHash [20]byte, +) ([]*big.Int, error) { + panic("unsupported") +} + +// Reservations is a stub matching the reservation additions on the +// production Chain interface. +func (lc *localChain) Reservations( + reservationKey *big.Int, +) (*tbtc.ReservationRequest, error) { + panic("unsupported") +} + +// ReservationActions is a stub matching the reservation additions on the +// production Chain interface. +func (lc *localChain) ReservationActions( + reservationKey *big.Int, + requestNonce uint64, +) (*tbtc.ReservationActionRecord, error) { + panic("unsupported") +} + +// IsReservedDeposit is a stub matching the reservation additions on the +// production Chain interface. +func (lc *localChain) IsReservedDeposit( + depositKey *big.Int, +) (bool, error) { + panic("unsupported") +} + +// ReservedDepositWallet is a stub matching the reservation additions on +// the production Chain interface. +func (lc *localChain) ReservedDepositWallet( + depositKey *big.Int, +) ([20]byte, error) { + panic("unsupported") +} + +func (lc *localChain) PastReservationAcceptedEvents( + filter *tbtc.ReservationAcceptedEventFilter, +) ([]*tbtc.ReservationAcceptedEvent, error) { + return nil, nil +} + +func (lc *localChain) PastReservationReanchoredEvents( + filter *tbtc.ReservationReanchoredEventFilter, +) ([]*tbtc.ReservationReanchoredEvent, error) { + return nil, nil +} + +func (lc *localChain) PastReservationActionTimedOutEvents( + filter *tbtc.ReservationActionTimedOutEventFilter, +) ([]*tbtc.ReservationActionTimedOutEvent, error) { + return nil, nil +} diff --git a/pkg/tbtcpg/chain.go b/pkg/tbtcpg/chain.go index af939852e5..c6fd10029a 100644 --- a/pkg/tbtcpg/chain.go +++ b/pkg/tbtcpg/chain.go @@ -164,4 +164,141 @@ type Chain interface { // the deposit reveal before a deposit becomes eligible for // a processing. GetDepositMinAge() (uint32, error) + + // ValidateReservationAnchorProposal validates the given reservation + // anchor proposal against the chain. Returns an error if the proposal + // is not valid or nil otherwise. + ValidateReservationAnchorProposal( + walletPublicKeyHash [20]byte, + proposal *tbtc.ReservationAnchorProposal, + depositExtraInfo struct { + *tbtc.Deposit + FundingTx *bitcoin.Transaction + }, + ) error + + // ValidateReservationReanchorProposal validates the given reservation + // re-anchor proposal against the chain. Returns an error if the + // proposal is not valid or nil otherwise. + ValidateReservationReanchorProposal( + sourceWalletPublicKeyHash [20]byte, + proposal *tbtc.ReservationReanchorProposal, + ) error + + // RequestReservationAcceptance requests a reservation acceptance action + // generation for the given reservation. The reservation must be in a + // state that allows acceptance; the operator-side guard is enforced at + // the chain layer. + RequestReservationAcceptance( + reservationKey *big.Int, + walletPublicKeyHash [20]byte, + ) error + + // RequestReservationReanchor requests a reservation re-anchor action + // generation for the given reservation, targeting the given wallet. + RequestReservationReanchor( + reservationKey *big.Int, + targetWalletPublicKeyHash [20]byte, + ) error + + // GetReservation gets the on-chain reservation record for the given + // reservation key. Returns an error if the reservation was not found. + GetReservation(reservationKey *big.Int) (*tbtc.Reservation, error) + + // GetReservationAction gets the on-chain action record for the given + // reservation key and request nonce. Returns an error if the action + // generation was not found. + GetReservationAction( + reservationKey *big.Int, + requestNonce uint64, + ) (*tbtc.ReservationAction, error) + + // ReservationParameters gets the current on-chain values of the Bridge + // reservation parameters. + ReservationParameters() (*tbtc.ReservationParameters, error) + + // ReservationCaps returns the cap parameters that gate reservation + // acceptance: the maximum aggregate satoshi amount a single wallet may + // custody across all of its reservations, and the maximum satoshi + // amount any single reservation may anchor. + ReservationCaps() (maxReservationsAmountPerWallet uint64, reservationMaxSingleAmount uint64, err error) + + // WalletReservationsAmount returns the aggregate satoshi amount + // currently anchored by the given wallet across all of its + // reservations. + WalletReservationsAmount(walletPublicKeyHash [20]byte) (uint64, error) + + // WalletReservationsCount returns the number of reservations currently + // custodied by the given wallet. + WalletReservationsCount(walletPublicKeyHash [20]byte) (uint32, error) + + // WalletReservations returns the reservation keys for all reservations + // currently custodied by the given wallet. + WalletReservations(walletPublicKeyHash [20]byte) ([]*big.Int, error) + + // ReservationByAnchorUtxo returns the reservation key whose anchor + // outpoint is the given Bitcoin transaction output, or an empty value + // if no reservation is anchored there. + ReservationByAnchorUtxo( + anchorTxHash [32]byte, + anchorTxOutputIndex uint32, + ) (*big.Int, error) + + // PendingReservedDeposits returns the number of reserved deposits that + // have been revealed to the Bridge but not yet accepted by a wallet. + PendingReservedDeposits() (uint64, error) + + // Reservations returns the on-chain reservation request record for the + // given reservation key. Mirrors the ReservationRouter.reservations + // view verbatim. + Reservations(reservationKey *big.Int) (*tbtc.ReservationRequest, error) + + // ReservationActions returns the on-chain reservation action record + // for the given reservation key and request nonce. Mirrors the + // ReservationRouter.reservationActions view verbatim. + ReservationActions( + reservationKey *big.Int, + requestNonce uint64, + ) (*tbtc.ReservationActionRecord, error) + + // ActiveReservationsCount returns the current count of active + // reservations across all wallets and the cap on that count. + ActiveReservationsCount() (count uint32, maxActive uint32, err error) + + // IsReservedDeposit returns true if the given deposit was revealed + // with the reservation vault address and is therefore a reservation + // rather than a default deposit. + IsReservedDeposit(depositKey *big.Int) (bool, error) + + // PastReservationAcceptanceRequestedEvents fetches past + // ReservationAcceptanceRequested 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. + PastReservationAcceptanceRequestedEvents( + filter *tbtc.ReservationAcceptanceRequestedEventFilter, + ) ([]*tbtc.ReservationAcceptanceRequestedEvent, error) + + // PastReservationAcceptedEvents fetches past ReservationAccepted + // 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. + PastReservationAcceptedEvents( + filter *tbtc.ReservationAcceptedEventFilter, + ) ([]*tbtc.ReservationAcceptedEvent, 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) + + // PastReservationReanchoredEvents fetches past ReservationReanchored + // 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. + PastReservationReanchoredEvents( + filter *tbtc.ReservationReanchoredEventFilter, + ) ([]*tbtc.ReservationReanchoredEvent, error) } diff --git a/pkg/tbtcpg/chain_test.go b/pkg/tbtcpg/chain_test.go index cdff0f01e3..4b0a215ce3 100644 --- a/pkg/tbtcpg/chain_test.go +++ b/pkg/tbtcpg/chain_test.go @@ -1284,3 +1284,199 @@ func (mbc *MockBlockCounter) SetCurrentBlock(block uint64) { func (mbc *MockBlockCounter) WatchBlocks(ctx context.Context) <-chan uint64 { panic("unsupported") } + +// ValidateReservationAnchorProposal is a stub matching the reservation +// additions on the production Chain interface. Full behavioral +// validation belongs to the reservation acceptance proposal builder. +func (lc *LocalChain) ValidateReservationAnchorProposal( + walletPublicKeyHash [20]byte, + proposal *tbtc.ReservationAnchorProposal, + depositExtraInfo struct { + *tbtc.Deposit + FundingTx *bitcoin.Transaction + }, +) error { + panic("unsupported") +} + +// ValidateReservationReanchorProposal is a stub matching the reservation +// additions on the production Chain interface. Full behavioral +// validation belongs to the reservation re-anchor proposal builder. +func (lc *LocalChain) ValidateReservationReanchorProposal( + sourceWalletPublicKeyHash [20]byte, + proposal *tbtc.ReservationReanchorProposal, +) error { + panic("unsupported") +} + +// RequestReservationAcceptance is a stub matching the reservation +// additions on the production Chain interface. The reservation +// acceptance proposal builder replaces this stub with the call path that +// records a submitted acceptance request for assertion in tests. +func (lc *LocalChain) RequestReservationAcceptance( + reservationKey *big.Int, + walletPublicKeyHash [20]byte, +) error { + panic("unsupported") +} + +// RequestReservationReanchor is a stub matching the reservation additions +// on the production Chain interface. The reservation re-anchor proposal +// builder replaces this stub with the call path that records a submitted +// re-anchor request for assertion in tests. +func (lc *LocalChain) RequestReservationReanchor( + reservationKey *big.Int, + targetWalletPublicKeyHash [20]byte, +) error { + panic("unsupported") +} + +// GetReservation is a stub matching the reservation additions on the +// production Chain interface. +func (lc *LocalChain) GetReservation( + reservationKey *big.Int, +) (*tbtc.Reservation, error) { + panic("unsupported") +} + +// GetReservationAction is a stub matching the reservation additions on +// the production Chain interface. +func (lc *LocalChain) GetReservationAction( + reservationKey *big.Int, + requestNonce uint64, +) (*tbtc.ReservationAction, error) { + panic("unsupported") +} + +// ReservationParameters is a stub matching the reservation additions on +// the production Chain interface. +func (lc *LocalChain) ReservationParameters() ( + *tbtc.ReservationParameters, + error, +) { + panic("unsupported") +} + +// ReservationCaps is a stub matching the reservation additions on the +// production Chain interface. +func (lc *LocalChain) ReservationCaps() ( + maxReservationsAmountPerWallet uint64, + reservationMaxSingleAmount uint64, + err error, +) { + panic("unsupported") +} + +// WalletReservationsAmount is a stub matching the reservation additions +// on the production Chain interface. +func (lc *LocalChain) WalletReservationsAmount( + walletPublicKeyHash [20]byte, +) (uint64, error) { + panic("unsupported") +} + +// WalletReservationsCount is a stub matching the reservation additions on +// the production Chain interface. +func (lc *LocalChain) WalletReservationsCount( + walletPublicKeyHash [20]byte, +) (uint32, error) { + panic("unsupported") +} + +// WalletReservations is a stub matching the reservation additions on the +// production Chain interface. +func (lc *LocalChain) WalletReservations( + walletPublicKeyHash [20]byte, +) ([]*big.Int, error) { + panic("unsupported") +} + +// ReservationByAnchorUtxo is a stub matching the reservation additions +// on the production Chain interface. +func (lc *LocalChain) ReservationByAnchorUtxo( + anchorTxHash [32]byte, + anchorTxOutputIndex uint32, +) (*big.Int, error) { + panic("unsupported") +} + +// PendingReservedDeposits is a stub matching the reservation additions +// on the production Chain interface. +func (lc *LocalChain) PendingReservedDeposits() (uint64, error) { + panic("unsupported") +} + +// Reservations is a stub matching the reservation additions on the +// production Chain interface. +func (lc *LocalChain) Reservations( + reservationKey *big.Int, +) (*tbtc.ReservationRequest, error) { + panic("unsupported") +} + +// ReservationActions is a stub matching the reservation additions on the +// production Chain interface. +func (lc *LocalChain) ReservationActions( + reservationKey *big.Int, + requestNonce uint64, +) (*tbtc.ReservationActionRecord, error) { + panic("unsupported") +} + +// ActiveReservationsCount is a stub matching the reservation additions on +// the production Chain interface. +func (lc *LocalChain) ActiveReservationsCount() ( + count uint32, + maxActive uint32, + err error, +) { + panic("unsupported") +} + +// IsReservedDeposit is a stub matching the reservation additions on the +// production Chain interface. +func (lc *LocalChain) IsReservedDeposit( + depositKey *big.Int, +) (bool, error) { + panic("unsupported") +} + +// PastReservationAcceptanceRequestedEvents is a stub matching the +// reservation additions on the production Chain interface. The proposal +// builder replaces this with the map-backed fixture used to assert that +// no duplicate acceptance request is generated. +func (lc *LocalChain) PastReservationAcceptanceRequestedEvents( + filter *tbtc.ReservationAcceptanceRequestedEventFilter, +) ([]*tbtc.ReservationAcceptanceRequestedEvent, error) { + panic("unsupported") +} + +// PastReservationAcceptedEvents is a stub matching the reservation +// additions on the production Chain interface. The acceptance proof +// builder replaces this with the map-backed fixture used to assert +// proof dispatch against observed acceptances. +func (lc *LocalChain) PastReservationAcceptedEvents( + filter *tbtc.ReservationAcceptedEventFilter, +) ([]*tbtc.ReservationAcceptedEvent, error) { + panic("unsupported") +} + +// PastReservationReanchorRequestedEvents is a stub matching the +// reservation additions on the production Chain interface. The proposal +// builder replaces this with the map-backed fixture used to assert that +// no duplicate re-anchor request is generated. +func (lc *LocalChain) PastReservationReanchorRequestedEvents( + filter *tbtc.ReservationReanchorRequestedEventFilter, +) ([]*tbtc.ReservationReanchorRequestedEvent, error) { + panic("unsupported") +} + +// PastReservationReanchoredEvents is a stub matching the reservation +// additions on the production Chain interface. The re-anchor proof +// builder replaces this with the map-backed fixture used to assert +// proof dispatch against observed re-anchors. +func (lc *LocalChain) PastReservationReanchoredEvents( + filter *tbtc.ReservationReanchoredEventFilter, +) ([]*tbtc.ReservationReanchoredEvent, error) { + panic("unsupported") +} From ac58650a11845effe3bca23bbcd8dff31d83a063 Mon Sep 17 00:00:00 2001 From: M1 H Watchers Builder Date: Wed, 26 Aug 2026 10:24:49 +0000 Subject: [PATCH 10/39] feat(tbtc): implement reservation watchers --- pkg/maintainer/spv/chain_test.go | 346 +++++++++++++-- .../spv/reservation_action_timeout_watch.go | 315 +++++++++++++ .../reservation_action_timeout_watch_test.go | 418 ++++++++++++++++++ .../spv/reservation_stale_deposit_watch.go | 270 +++++++++++ .../reservation_stale_deposit_watch_test.go | 219 +++++++++ .../spv/reservation_stranding_watch.go | 165 +++++++ .../spv/reservation_stranding_watch_test.go | 266 +++++++++++ 7 files changed, 1959 insertions(+), 40 deletions(-) create mode 100644 pkg/maintainer/spv/reservation_action_timeout_watch.go create mode 100644 pkg/maintainer/spv/reservation_action_timeout_watch_test.go create mode 100644 pkg/maintainer/spv/reservation_stale_deposit_watch.go create mode 100644 pkg/maintainer/spv/reservation_stale_deposit_watch_test.go create mode 100644 pkg/maintainer/spv/reservation_stranding_watch.go create mode 100644 pkg/maintainer/spv/reservation_stranding_watch_test.go diff --git a/pkg/maintainer/spv/chain_test.go b/pkg/maintainer/spv/chain_test.go index a07759c6aa..2eb3950089 100644 --- a/pkg/maintainer/spv/chain_test.go +++ b/pkg/maintainer/spv/chain_test.go @@ -43,6 +43,37 @@ type submittedMovedFundsSweepProof struct { mainUTXO bitcoin.UnspentTransactionOutput } +// submittedReservationStranded records a NotifyReservationStranded call. +// The stranding watcher builder replaces this stub with the call path +// that records a stray notification for assertion in tests. +type submittedReservationStranded struct { + reservationKey *big.Int +} + +// submittedStaleReservedDeposit records a NotifyStaleReservedDeposit call. +// The stale-deposit watcher builder replaces this stub with the call path +// that records a stale-deposit notification for assertion in tests. +type submittedStaleReservedDeposit struct { + depositKey *big.Int +} + +// submittedReservationActionTimeout records a +// NotifyReservationActionTimeout call. The action-timeout watcher builder +// replaces this stub with the call path that records a timeout notification +// for assertion in tests. +type submittedReservationActionTimeout struct { + reservationKey *big.Int + walletMembersIDs []uint32 +} + +// reservedDepositRecord is the local-chain-side booking for a reserved +// deposit. WalletPublicKeyHash is the wallet currently assigned to the +// deposit; IsReserved drives the IsReservedDeposit return value. +type reservedDepositRecord struct { + walletPublicKeyHash [20]byte + isReserved bool +} + type localChain struct { mutex sync.Mutex @@ -59,6 +90,18 @@ type localChain struct { pastDepositRevealedEvents map[[32]byte][]*tbtc.DepositRevealedEvent pastMovingFundsCommitmentSubmittedEvents map[[32]byte][]*tbtc.MovingFundsCommitmentSubmittedEvent + // 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 + txProofDifficultyFactor *big.Int currentEpoch uint64 currentEpochDifficulty *big.Int @@ -74,9 +117,17 @@ func newLocalChain() *localChain { submittedRedemptionProofs: make([]*submittedRedemptionProof, 0), submittedDepositSweepProofs: make([]*submittedDepositSweepProof, 0), submittedMovingFundsProofs: make([]*submittedMovingFundsProof, 0), + submittedMovedFundsSweepProofs: make([]*submittedMovedFundsSweepProof, 0), pastRedemptionRequestedEvents: make(map[[32]byte][]*tbtc.RedemptionRequestedEvent), pastDepositRevealedEvents: make(map[[32]byte][]*tbtc.DepositRevealedEvent), pastMovingFundsCommitmentSubmittedEvents: make(map[[32]byte][]*tbtc.MovingFundsCommitmentSubmittedEvent), + walletReservations: make(map[[20]byte][]*big.Int), + reservations: make(map[[16]byte]*tbtc.Reservation), + reservationActions: make(map[[24]byte]*tbtc.ReservationAction), + reservedDeposits: make(map[[16]byte]*reservedDepositRecord), + submittedStrandedKeys: make([]*big.Int, 0), + submittedStaleDeposits: make([]*big.Int, 0), + submittedActionTimeouts: make([]*submittedReservationActionTimeout, 0), } } @@ -721,11 +772,10 @@ func (mbc *mockBlockCounter) WatchBlocks(ctx context.Context) <-chan uint64 { panic("unsupported") } -// SubmitReservationProof is a stub matching the reservation -// additions on the production Chain interface. The reservation -// acceptance and re-anchor proposal builders replace this stub -// with the call path that records a submitted proof for assertion -// in tests. +// SubmitReservationProof is a stub matching the reservation additions on +// the production Chain interface. The reservation acceptance and re-anchor +// proposal builders replace this stub with the call path that records a +// submitted proof for assertion in tests. func (lc *localChain) SubmitReservationProof( proofType uint8, txInfo *tbtc.BitcoinTxInfo, @@ -737,69 +787,254 @@ func (lc *localChain) SubmitReservationProof( panic("unsupported") } -// NotifyReservationActionTimeout is a stub matching the reservation -// additions on the production Chain interface. The timeout watcher -// builder replaces this stub with the call path that records a -// timeout notification for assertion in tests. +// NotifyReservationActionTimeout records the notification for assertion in +// tests. The action-timeout watcher builder invokes this through the +// Chain interface to drive the notification path. func (lc *localChain) NotifyReservationActionTimeout( reservationKey *big.Int, walletMembersIDs []uint32, ) error { - panic("unsupported") + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.submittedActionTimeouts = append( + lc.submittedActionTimeouts, + &submittedReservationActionTimeout{ + reservationKey: reservationKey, + walletMembersIDs: walletMembersIDs, + }, + ) + + return nil +} + +// getSubmittedReservationActionTimeouts returns the recorded action-timeout +// notifications in submission order. +func (lc *localChain) getSubmittedReservationActionTimeouts() []*submittedReservationActionTimeout { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + out := make([]*submittedReservationActionTimeout, len(lc.submittedActionTimeouts)) + copy(out, lc.submittedActionTimeouts) + return out } -// NotifyStaleReservedDeposit is a stub matching the reservation -// additions on the production Chain interface. The stale deposit -// watcher builder replaces this stub with the call path that -// records a stale deposit notification for assertion in tests. +// NotifyStaleReservedDeposit records the notification for assertion in +// tests. The stale-deposit watcher builder invokes this through the +// Chain interface to drive the notification path. func (lc *localChain) NotifyStaleReservedDeposit(depositKey *big.Int) error { - panic("unsupported") + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.submittedStaleDeposits = append( + lc.submittedStaleDeposits, + depositKey, + ) + + return nil +} + +// getSubmittedStaleReservedDeposits returns the recorded stale-deposit +// notifications in submission order. +func (lc *localChain) getSubmittedStaleReservedDeposits() []*big.Int { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + out := make([]*big.Int, len(lc.submittedStaleDeposits)) + copy(out, lc.submittedStaleDeposits) + return out } -// NotifyReservationStranded is a stub matching the reservation -// additions on the production Chain interface. The stranded -// reservation watcher builder replaces this stub with the call path -// that records a stranded notification for assertion in tests. +// NotifyReservationStranded records the notification for assertion in +// tests. The stranding watcher builder invokes this through the Chain +// interface to drive the notification path. func (lc *localChain) NotifyReservationStranded(reservationKey *big.Int) error { - panic("unsupported") + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.submittedStrandedKeys = append( + lc.submittedStrandedKeys, + reservationKey, + ) + + return nil } -// GetReservation is a stub matching the reservation additions on the -// production Chain interface. +// getSubmittedReservationStrandedKeys returns the recorded stranding +// notifications in submission order. +func (lc *localChain) getSubmittedReservationStrandedKeys() []*big.Int { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + out := make([]*big.Int, len(lc.submittedStrandedKeys)) + copy(out, lc.submittedStrandedKeys) + return out +} + +// GetReservation returns the reservation previously installed via +// setReservation. Returns an error if the reservation is not set, matching +// the production contract behavior. func (lc *localChain) GetReservation( reservationKey *big.Int, ) (*tbtc.Reservation, error) { - panic("unsupported") + lc.mutex.Lock() + defer lc.mutex.Unlock() + + key := bigIntToKey16(reservationKey) + reservation, ok := lc.reservations[key] + if !ok { + return nil, fmt.Errorf("no reservation for given key") + } + return reservation, nil +} + +// setReservation installs a reservation for GetReservation to return. +func (lc *localChain) setReservation( + reservationKey *big.Int, + reservation *tbtc.Reservation, +) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.reservations[bigIntToKey16(reservationKey)] = reservation } -// GetReservationAction is a stub matching the reservation additions on -// the production Chain interface. +// GetReservationAction returns the reservation action previously installed +// via setReservationAction. Returns an error if the action is not set. func (lc *localChain) GetReservationAction( reservationKey *big.Int, requestNonce uint64, ) (*tbtc.ReservationAction, error) { - panic("unsupported") + lc.mutex.Lock() + defer lc.mutex.Unlock() + + key := buildReservationActionKey(reservationKey, requestNonce) + action, ok := lc.reservationActions[key] + if !ok { + return nil, fmt.Errorf("no action for given reservation/nonce") + } + return action, nil } -// ReservationParameters is a stub matching the reservation additions on -// the production Chain interface. +// setReservationAction installs a reservation action for GetReservationAction +// to return. +func (lc *localChain) setReservationAction( + reservationKey *big.Int, + requestNonce uint64, + action *tbtc.ReservationAction, +) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.reservationActions[buildReservationActionKey(reservationKey, requestNonce)] = action +} + +// buildReservationActionKey produces a 24-byte map key encoding the +// reservation identifier and the nonce. The reservation identifier is +// truncated to its leading 16 bytes; tests are responsible for choosing +// reservation keys that are unique in those leading bytes. +func buildReservationActionKey( + reservationKey *big.Int, + requestNonce uint64, +) [24]byte { + var out [24]byte + if reservationKey != nil { + fillBigInt16(reservationKey, out[:16]) + } + binary.BigEndian.PutUint64(out[16:24], requestNonce) + return out +} + +// fillBigInt16 writes the leading 16 bytes of the big-endian representation +// of v into dst. The function is allocation-free so tests can use it inside +// hot paths. +func fillBigInt16(v *big.Int, dst []byte) { + if v == nil { + return + } + bytes := v.Bytes() + offset := len(dst) - len(bytes) + if offset < 0 { + // Truncate to dst size; keeps the trailing high bytes of v. + bytes = bytes[len(bytes)-len(dst):] + offset = 0 + } + for i, b := range bytes { + dst[offset+i] = b + } +} + +// bigIntToKey16 returns a 16-byte map key from a big.Int by truncating to +// the leading 16 bytes (right-aligned). Returns the zero key for nil. +func bigIntToKey16(v *big.Int) [16]byte { + var out [16]byte + if v == nil { + return out + } + fillBigInt16(v, out[:]) + return out +} + +// ReservationParameters returns the reservation parameters previously +// installed via setReservationParameters, or a default set if none was set. func (lc *localChain) ReservationParameters() ( *tbtc.ReservationParameters, error, ) { - panic("unsupported") + lc.mutex.Lock() + defer lc.mutex.Unlock() + + if lc.reservationParameters != nil { + return lc.reservationParameters, nil + } + return &tbtc.ReservationParameters{ + ReservationActionTimeout: 3600, + }, nil +} + +// setReservationParameters installs reservation parameters for +// ReservationParameters to return. +func (lc *localChain) setReservationParameters( + params *tbtc.ReservationParameters, +) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.reservationParameters = params } -// WalletReservations is a stub matching the reservation additions on the -// production Chain interface. +// WalletReservations returns the reservation keys previously installed via +// setWalletReservations. The slice is a copy so the caller can mutate it +// without affecting the local chain. func (lc *localChain) WalletReservations( walletPublicKeyHash [20]byte, ) ([]*big.Int, error) { - panic("unsupported") + lc.mutex.Lock() + defer lc.mutex.Unlock() + + keys := lc.walletReservations[walletPublicKeyHash] + out := make([]*big.Int, len(keys)) + copy(out, keys) + return out, nil +} + +// setWalletReservations installs the list of reservation keys for a wallet. +func (lc *localChain) setWalletReservations( + walletPublicKeyHash [20]byte, + keys []*big.Int, +) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.walletReservations[walletPublicKeyHash] = append( + []*big.Int{}, + keys..., + ) } // Reservations is a stub matching the reservation additions on the -// production Chain interface. +// production Chain interface. The reservation-side builder replaces this +// stub with the production contract call; the watchers do not need it. func (lc *localChain) Reservations( reservationKey *big.Int, ) (*tbtc.ReservationRequest, error) { @@ -807,7 +1042,8 @@ func (lc *localChain) Reservations( } // ReservationActions is a stub matching the reservation additions on the -// production Chain interface. +// production Chain interface. The watchers use GetReservationAction +// instead; this stub exists only to satisfy the interface. func (lc *localChain) ReservationActions( reservationKey *big.Int, requestNonce uint64, @@ -815,20 +1051,50 @@ func (lc *localChain) ReservationActions( panic("unsupported") } -// IsReservedDeposit is a stub matching the reservation additions on the -// production Chain interface. +// IsReservedDeposit returns whether the deposit was previously booked via +// setReservedDeposit. func (lc *localChain) IsReservedDeposit( depositKey *big.Int, ) (bool, error) { - panic("unsupported") + lc.mutex.Lock() + defer lc.mutex.Unlock() + + record, ok := lc.reservedDeposits[bigIntToKey16(depositKey)] + if !ok { + return false, nil + } + return record.isReserved, nil } -// ReservedDepositWallet is a stub matching the reservation additions on -// the production Chain interface. +// ReservedDepositWallet returns the wallet previously assigned to the +// deposit via setReservedDeposit. func (lc *localChain) ReservedDepositWallet( depositKey *big.Int, ) ([20]byte, error) { - panic("unsupported") + lc.mutex.Lock() + defer lc.mutex.Unlock() + + record, ok := lc.reservedDeposits[bigIntToKey16(depositKey)] + if !ok { + return [20]byte{}, nil + } + return record.walletPublicKeyHash, nil +} + +// setReservedDeposit installs the reserved-deposit booking for +// IsReservedDeposit and ReservedDepositWallet to return. +func (lc *localChain) setReservedDeposit( + depositKey *big.Int, + walletPublicKeyHash [20]byte, + isReserved bool, +) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.reservedDeposits[bigIntToKey16(depositKey)] = &reservedDepositRecord{ + walletPublicKeyHash: walletPublicKeyHash, + isReserved: isReserved, + } } func (lc *localChain) PastReservationAcceptedEvents( diff --git a/pkg/maintainer/spv/reservation_action_timeout_watch.go b/pkg/maintainer/spv/reservation_action_timeout_watch.go new file mode 100644 index 0000000000..270e7f8a3c --- /dev/null +++ b/pkg/maintainer/spv/reservation_action_timeout_watch.go @@ -0,0 +1,315 @@ +package spv + +import ( + "fmt" + "math/big" + "time" + + "github.com/keep-network/keep-core/pkg/tbtc" +) + +// ReservationActionTimeoutWatcher observes the reservation action set and +// notifies the Bridge when a pending action's on-chain deadline has elapsed +// without an SPV proof being submitted. +// +// The Bridge uses a per-action timeout window (snaphotted in the +// ReservationAction record at nonce creation time) to bound the lag between +// action creation and SPV proof submission. When the deadline passes without +// a proof, the SPV maintainer is no longer eligible to settle the action +// and the Bridge must be told to update the action to ReservationActionStateTimedOut. +// This triggers Bridge-side sweeping (e.g. fee slashing in m2+ and the +// fallback to owner late-settlement) and ensures the state machine can move +// forward. +// +// In m1 the operator-side penalty is a no-op; NotifyReservationActionTimeout +// is still called with the wallet member IDs as required by the Bridge +// function signature so that m2+ integrations only need to add the slashing +// logic without changing the call shape. +type ReservationActionTimeoutWatcher struct { + spvChain Chain + notifier ReservationActionTimeoutNotifier + // nowFn returns the current UNIX timestamp the watcher treats as "now" + // for `now > timeoutAt` comparisons. Tests override it to drive the + // deadline forward; production wires it to time.Now in UTC. + nowFn func() uint32 + // interval is how often the background poll loop re-checks pending + // actions. A zero value disables the background loop; tests and the + // synchronously driven integration code path will use a positive + // duration. + interval time.Duration + // membersResolver turns a wallet public key hash into the operator IDs + // the Bridge expects for the slashing argument. The resolver is + // injected to keep the watcher independent of the chain interface used + // to look up operator addresses (the SPV maintainer chain interface + // does not expose GetOperatorID today). + membersResolver WalletMembersResolver +} + +// WalletMembersResolver maps a wallet public key hash to the operator IDs +// the wallet's signing group is composed of. The Bridge uses the IDs to +// attribute slashing; m2+ will layer the actual penalty computation on top +// of the IDs carried in NotifyReservationActionTimeout. +// +// In production the resolver must look up the wallet's signing group via +// the maintenance bridge / sortition pool and translate each operator +// address to an operator ID via chain.GetOperatorID. The watcher does not +// prescribe a particular lookup because the production wiring depends on +// the sortition backend chosen for the deployment. +type WalletMembersResolver interface { + ResolveWalletMembers(walletPublicKeyHash [20]byte) ([]uint32, error) +} + +// WalletMembersResolverFunc adapts a plain function to the +// WalletMembersResolver interface. +type WalletMembersResolverFunc func(walletPublicKeyHash [20]byte) ([]uint32, error) + +// ResolveWalletMembers forwards the call to the wrapped function. +func (f WalletMembersResolverFunc) ResolveWalletMembers( + walletPublicKeyHash [20]byte, +) ([]uint32, error) { + return f(walletPublicKeyHash) +} + +// ReservationActionTimeoutNotifier is the Bridge-facing contract for the +// action-timeout watcher. It mirrors +// `Chain.NotifyReservationActionTimeout` but is interface-typed to enable +// in-memory recorders during tests. +type ReservationActionTimeoutNotifier interface { + NotifyReservationActionTimeout( + reservationKey *big.Int, + walletMembersIDs []uint32, + ) error +} + +// ReservationActionTimeoutNotifierFunc adapts a function to the +// ReservationActionTimeoutNotifier interface. +type ReservationActionTimeoutNotifierFunc func( + reservationKey *big.Int, + walletMembersIDs []uint32, +) error + +// NotifyReservationActionTimeout forwards the call to the wrapped function. +func (f ReservationActionTimeoutNotifierFunc) NotifyReservationActionTimeout( + reservationKey *big.Int, + walletMembersIDs []uint32, +) error { + return f(reservationKey, walletMembersIDs) +} + +// NewReservationActionTimeoutWatcher constructs a watcher bound to the +// given chain, notifier, members resolver, and poll interval. +// +// The members resolver is mandatory: the watcher will refuse to operate +// without it because emitting NotifyReservationActionTimeout with a nil +// or empty member slice would be ill-formed on the Bridge side. +// +// A zero pollInterval disables the background loop; the watcher must then +// be driven by CheckReservationActionTimeouts calls from the integration. +func NewReservationActionTimeoutWatcher( + spvChain Chain, + notifier ReservationActionTimeoutNotifier, + membersResolver WalletMembersResolver, + pollInterval time.Duration, +) *ReservationActionTimeoutWatcher { + return &ReservationActionTimeoutWatcher{ + spvChain: spvChain, + notifier: notifier, + nowFn: defaultActionTimeoutNowFn, + interval: pollInterval, + membersResolver: membersResolver, + } +} + +// defaultActionTimeoutNowFn returns time.Now() as a uint32 UNIX timestamp. +// Kept separate from the struct to allow tests to swap it deterministically. +func defaultActionTimeoutNowFn() uint32 { + return uint32(time.Now().Unix()) +} + +// Run starts the background poll loop. It returns immediately and runs +// until ctx is done. +// +// 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 { + if ratw.notifier == nil { + return fmt.Errorf( + "action-timeout watcher requires a non-nil notifier", + ) + } + if ratw.membersResolver == nil { + return fmt.Errorf( + "action-timeout watcher requires a non-nil members resolver", + ) + } + if ratw.interval <= 0 { + return fmt.Errorf( + "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 +} + +// CheckReservationActionTimeouts inspects the action generations of a +// single reservation and notifies the Bridge of any pending action whose +// TimeoutAt has elapsed. The caller controls the iteration; the watcher +// does not background-loop on its own. +// +// Parameters: +// +// - reservationKey: the reservation identifier used by the Bridge's +// ReservationRouter. +// - now: a UNIX timestamp used to compare against TimeoutAt. Tests pass +// an explicit value; production passes time.Now().Unix() cast to uint32. +// +// The function resolves the custodying wallet once, looks up the operator +// member IDs through the injected resolver, then walks the nonce axis +// starting from 0 and stopping at the first non-pending action. Walking +// until the first non-pending action models the on-chain invariant that +// nonces are sequential: only the most-recent pending action can time +// out, since older nonces have already been settled or superseded. +func (ratw *ReservationActionTimeoutWatcher) CheckReservationActionTimeouts( + reservationKey *big.Int, + now uint32, +) error { + if ratw.notifier == nil { + return fmt.Errorf( + "action-timeout watcher requires a non-nil notifier", + ) + } + if ratw.membersResolver == nil { + return fmt.Errorf( + "action-timeout watcher requires a non-nil members resolver", + ) + } + if reservationKey == nil { + return fmt.Errorf("reservation key must not be nil") + } + + reservation, err := ratw.spvChain.GetReservation(reservationKey) + if err != nil { + return fmt.Errorf( + "failed to load reservation [%v]: [%v]", + reservationKey, + err, + ) + } + + walletPublicKeyHash := reservation.WalletPublicKeyHash + if walletPublicKeyHash == ([20]byte{}) { + // Reservation exists but has no wallet assigned (e.g. the + // acceptance has not progressed yet). Without a wallet we cannot + // resolve members, so the watcher skips silently: the stranding + // watcher will eventually catch this case. + logger.Debugf( + "reservation [%v] has no wallet assigned; "+ + "action-timeout watcher skipping", + reservationKey, + ) + return nil + } + + // Resolve the wallet members exactly once per Check call: the Bridge + // requires the member IDs to be consistent across all notifications + // issued in response to a single reservation. + memberIDs, err := ratw.membersResolver.ResolveWalletMembers( + walletPublicKeyHash, + ) + if err != nil { + return fmt.Errorf( + "failed to resolve wallet member IDs for "+ + "wallet [0x%x]: [%v]", + walletPublicKeyHash, + err, + ) + } + + // Walk the nonce axis from 0 upward. Stop at the first non-pending + // action: by the Bridge invariant, only the most-recent action can be + // in Pending state; older actions are Settled/TimedOut/Superseded/Vetoed. + for nonce := uint64(0); nonce <= reservation.RequestNonce; nonce++ { + action, err := ratw.spvChain.GetReservationAction(reservationKey, nonce) + if err != nil { + // A missing action record for a nonce in [0, RequestNonce] + // is a Bridge-data inconsistency; we log and stop walking + // rather than notify on partial information. + logger.Errorf( + "failed to load action for reservation [%v] at nonce %d: [%v]; "+ + "stopping nonce walk", + reservationKey, + nonce, + err, + ) + return nil + } + + if action.State != tbtc.ReservationActionStatePending { + logger.Debugf( + "reservation [%v] action nonce %d state=%s; "+ + "stopping nonce walk at first non-pending action", + reservationKey, + nonce, + action.State, + ) + return nil + } + + if now <= action.TimeoutAt { + logger.Debugf( + "reservation [%v] action nonce %d timeout at [%d] "+ + "not yet reached (now=%d); skipping", + reservationKey, + nonce, + action.TimeoutAt, + now, + ) + // Continue the walk in case multiple actions are pending past + // their timeouts; in practice this should not happen because + // RequestNonce points at the latest pending nonce, but the + // walker is defensive. + continue + } + + if err := ratw.notifier.NotifyReservationActionTimeout( + reservationKey, + memberIDs, + ); err != nil { + logger.Errorf( + "failed to notify action timeout for "+ + "reservation [%v] nonce %d: [%v]", + reservationKey, + nonce, + err, + ) + // Continue with the next nonce despite the error: a single + // failure must not starve subsequent notifications. + continue + } + + logger.Infof( + "notified action timeout for reservation [%v] nonce %d "+ + "(timeout=%d, members=%d)", + reservationKey, + nonce, + action.TimeoutAt, + len(memberIDs), + ) + } + + return nil +} diff --git a/pkg/maintainer/spv/reservation_action_timeout_watch_test.go b/pkg/maintainer/spv/reservation_action_timeout_watch_test.go new file mode 100644 index 0000000000..d1b1562cdd --- /dev/null +++ b/pkg/maintainer/spv/reservation_action_timeout_watch_test.go @@ -0,0 +1,418 @@ +package spv + +import ( + "errors" + "math/big" + "testing" + + "github.com/keep-network/keep-core/pkg/tbtc" + + "github.com/go-test/deep" +) + +// recordingActionTimeoutMembers is a test double for the +// WalletMembersResolver interface. It returns the operator IDs configured +// at construction time and records the wallet PKHs it was asked to resolve. +type recordingActionTimeoutMembers struct { + walletIDs map[[20]byte][]uint32 + calls [][20]byte + errByPKH map[[20]byte]error +} + +func (r *recordingActionTimeoutMembers) ResolveWalletMembers( + walletPublicKeyHash [20]byte, +) ([]uint32, error) { + r.calls = append(r.calls, walletPublicKeyHash) + if err, ok := r.errByPKH[walletPublicKeyHash]; ok { + return nil, err + } + return r.walletIDs[walletPublicKeyHash], nil +} + +// recordingActionTimeoutNotifier captures every +// NotifyReservationActionTimeout call for assertion in tests. +type recordingActionTimeoutNotifier struct { + calls []*submittedReservationActionTimeout + err error +} + +func (r *recordingActionTimeoutNotifier) NotifyReservationActionTimeout( + reservationKey *big.Int, + walletMembersIDs []uint32, +) error { + r.calls = append(r.calls, &submittedReservationActionTimeout{ + reservationKey: reservationKey, + walletMembersIDs: walletMembersIDs, + }) + return r.err +} + +// seededReservation installs a reservation and (optionally) a list of +// action generations under spvChain for use in the action-timeout watcher +// tests. Helper reduces per-test noise. +func seededReservation( + t *testing.T, + spvChain *localChain, + key *big.Int, + wallet [20]byte, + actions []*tbtc.ReservationAction, + requestNonce uint64, +) { + t.Helper() + spvChain.setReservation(key, &tbtc.Reservation{ + WalletPublicKeyHash: wallet, + RequestNonce: requestNonce, + }) + for nonce, action := range actions { + spvChain.setReservationAction(key, uint64(nonce), action) + } +} + +func TestReservationActionTimeoutWatcher_NotifiesTimedOutPendingAction(t *testing.T) { + spvChain := newLocalChain() + notifier := &recordingActionTimeoutNotifier{} + + wallet := walletPKH() + key := reservationKey(0xC001) + members := []uint32{11, 22, 33} + + resolver := &recordingActionTimeoutMembers{ + walletIDs: map[[20]byte][]uint32{wallet: members}, + } + seededReservation( + t, + spvChain, + key, + wallet, + []*tbtc.ReservationAction{ + { + State: tbtc.ReservationActionStatePending, + TimeoutAt: 100, + }, + }, + 0, + ) + + watcher := NewReservationActionTimeoutWatcher(spvChain, notifier, resolver, 0) + if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(notifier.calls) != 1 { + t.Fatalf("expected one timeout notification, got %d", len(notifier.calls)) + } + if diff := deep.Equal(key, notifier.calls[0].reservationKey); diff != nil { + t.Errorf("unexpected notified key: %v", diff) + } + if diff := deep.Equal(members, notifier.calls[0].walletMembersIDs); diff != nil { + t.Errorf("unexpected notified members: %v", diff) + } + // The resolver must be consulted exactly once per Check call, not per + // nonce, because the Bridge requires the member IDs to be consistent + // across all notifications emitted in response to a single reservation. + if len(resolver.calls) != 1 { + t.Errorf("expected resolver to be called once, got %d", len(resolver.calls)) + } +} + +func TestReservationActionTimeoutWatcher_DoesNotNotifyBeforeTimeout(t *testing.T) { + spvChain := newLocalChain() + notifier := &recordingActionTimeoutNotifier{} + + wallet := walletPKH() + key := reservationKey(0xC002) + + resolver := &recordingActionTimeoutMembers{ + walletIDs: map[[20]byte][]uint32{wallet: {1, 2}}, + } + seededReservation( + t, + spvChain, + key, + wallet, + []*tbtc.ReservationAction{ + { + State: tbtc.ReservationActionStatePending, + TimeoutAt: 10_000, + }, + }, + 0, + ) + + watcher := NewReservationActionTimeoutWatcher(spvChain, notifier, resolver, 0) + if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(notifier.calls) != 0 { + t.Fatalf( + "action not yet timed out; expected zero notifications, got %d", + len(notifier.calls), + ) + } +} + +func TestReservationActionTimeoutWatcher_StopsAtFirstNonPending(t *testing.T) { + spvChain := newLocalChain() + notifier := &recordingActionTimeoutNotifier{} + + wallet := walletPKH() + key := reservationKey(0xC003) + + resolver := &recordingActionTimeoutMembers{ + walletIDs: map[[20]byte][]uint32{wallet: {1, 2}}, + } + // Nonce 0 is settled, nonce 1 is the latest pending and past deadline. + // The walker must stop at nonce 0 without notifying. + seededReservation( + t, + spvChain, + key, + wallet, + []*tbtc.ReservationAction{ + { + State: tbtc.ReservationActionStateSettled, + TimeoutAt: 100, + }, + { + State: tbtc.ReservationActionStatePending, + TimeoutAt: 100, + }, + }, + 1, + ) + + watcher := NewReservationActionTimeoutWatcher(spvChain, notifier, resolver, 0) + if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(notifier.calls) != 0 { + t.Fatalf( + "first action is settled; walker must stop, got %d notifications", + len(notifier.calls), + ) + } +} + +func TestReservationActionTimeoutWatcher_NotifiesLatestNonce(t *testing.T) { + spvChain := newLocalChain() + notifier := &recordingActionTimeoutNotifier{} + + wallet := walletPKH() + key := reservationKey(0xC004) + + resolver := &recordingActionTimeoutMembers{ + walletIDs: map[[20]byte][]uint32{wallet: {7, 8, 9}}, + } + // Nonce 0 pending past its deadline, nonce 1 pending past its deadline. + // Both must be notified (defensive walker continues past the first). + seededReservation( + t, + spvChain, + key, + wallet, + []*tbtc.ReservationAction{ + { + State: tbtc.ReservationActionStatePending, + TimeoutAt: 100, + }, + { + State: tbtc.ReservationActionStatePending, + TimeoutAt: 200, + }, + }, + 1, + ) + + watcher := NewReservationActionTimeoutWatcher(spvChain, notifier, resolver, 0) + if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(notifier.calls) != 2 { + t.Fatalf( + "expected two notifications (nonce 0 and 1), got %d", + len(notifier.calls), + ) + } +} + +func TestReservationActionTimeoutWatcher_SkipsReservationWithoutWallet(t *testing.T) { + spvChain := newLocalChain() + notifier := &recordingActionTimeoutNotifier{} + + key := reservationKey(0xC005) + // No wallet PKH assigned. + + resolver := &recordingActionTimeoutMembers{} + spvChain.setReservation(key, &tbtc.Reservation{ + WalletPublicKeyHash: [20]byte{}, + RequestNonce: 0, + }) + + watcher := NewReservationActionTimeoutWatcher(spvChain, notifier, resolver, 0) + if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(notifier.calls) != 0 { + t.Fatalf("zero-wallet reservation must skip, got %d notifications", len(notifier.calls)) + } + if len(resolver.calls) != 0 { + t.Fatalf("resolver must not be called for zero-wallet reservation, got %d calls", len(resolver.calls)) + } +} + +func TestReservationActionTimeoutWatcher_MembersResolverError(t *testing.T) { + spvChain := newLocalChain() + notifier := &recordingActionTimeoutNotifier{} + + wallet := walletPKH() + key := reservationKey(0xC006) + + resolver := &recordingActionTimeoutMembers{ + errByPKH: map[[20]byte]error{wallet: errors.New("oops")}, + } + seededReservation( + t, + spvChain, + key, + wallet, + []*tbtc.ReservationAction{ + { + State: tbtc.ReservationActionStatePending, + TimeoutAt: 100, + }, + }, + 0, + ) + + watcher := NewReservationActionTimeoutWatcher(spvChain, notifier, resolver, 0) + if err := watcher.CheckReservationActionTimeouts(key, 5_000); err == nil { + t.Fatal("expected error from resolver, got nil") + } + if len(notifier.calls) != 0 { + t.Fatalf("no notifications should fire on resolver error, got %d", len(notifier.calls)) + } +} + +func TestReservationActionTimeoutWatcher_NilNotifierError(t *testing.T) { + spvChain := newLocalChain() + resolver := &recordingActionTimeoutMembers{} + + watcher := NewReservationActionTimeoutWatcher(spvChain, nil, resolver, 0) + if err := watcher.CheckReservationActionTimeouts(reservationKey(0xC007), 5_000); err == nil { + t.Fatal("expected error for nil notifier, got nil") + } +} + +func TestReservationActionTimeoutWatcher_NilResolverError(t *testing.T) { + spvChain := newLocalChain() + notifier := &recordingActionTimeoutNotifier{} + + watcher := NewReservationActionTimeoutWatcher(spvChain, notifier, nil, 0) + if err := watcher.CheckReservationActionTimeouts(reservationKey(0xC008), 5_000); err == nil { + t.Fatal("expected error for nil resolver, got nil") + } +} + +func TestReservationActionTimeoutWatcher_NilKeyError(t *testing.T) { + spvChain := newLocalChain() + notifier := &recordingActionTimeoutNotifier{} + resolver := &recordingActionTimeoutMembers{} + + watcher := NewReservationActionTimeoutWatcher(spvChain, notifier, resolver, 0) + if err := watcher.CheckReservationActionTimeouts(nil, 5_000); err == nil { + t.Fatal("expected error for nil reservation key, got nil") + } +} + +func TestReservationActionTimeoutWatcher_NotifierFuncAdapter(t *testing.T) { + var captured []*submittedReservationActionTimeout + notifier := ReservationActionTimeoutNotifierFunc(func( + reservationKey *big.Int, + walletMembersIDs []uint32, + ) error { + captured = append(captured, &submittedReservationActionTimeout{ + reservationKey: reservationKey, + walletMembersIDs: walletMembersIDs, + }) + return nil + }) + + spvChain := newLocalChain() + + wallet := walletPKH() + key := reservationKey(0xC009) + + resolver := &recordingActionTimeoutMembers{ + walletIDs: map[[20]byte][]uint32{wallet: {42}}, + } + seededReservation( + t, + spvChain, + key, + wallet, + []*tbtc.ReservationAction{ + { + State: tbtc.ReservationActionStatePending, + TimeoutAt: 100, + }, + }, + 0, + ) + + watcher := NewReservationActionTimeoutWatcher(spvChain, notifier, resolver, 0) + if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(captured) != 1 { + t.Fatalf("expected one captured notification, got %d", len(captured)) + } +} + +func TestReservationActionTimeoutWatcher_NotifiesOncePerQualifyingNonce(t *testing.T) { + spvChain := newLocalChain() + notifier := &recordingActionTimeoutNotifier{} + errFromNotifier := errors.New("downstream") + + wallet := walletPKH() + key := reservationKey(0xC00A) + + resolver := &recordingActionTimeoutMembers{ + walletIDs: map[[20]byte][]uint32{wallet: {1, 2, 3}}, + } + // Two pending actions past their timeouts; the notifier fails for the + // first and succeeds for the second; the walker must continue past the + // first failure (defensive coverage). + notifier.err = errFromNotifier + seededReservation( + t, + spvChain, + key, + wallet, + []*tbtc.ReservationAction{ + { + State: tbtc.ReservationActionStatePending, + TimeoutAt: 100, + }, + { + State: tbtc.ReservationActionStatePending, + TimeoutAt: 200, + }, + }, + 1, + ) + + watcher := NewReservationActionTimeoutWatcher(spvChain, notifier, resolver, 0) + if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { + t.Fatalf("unexpected error from the watcher itself: %v", err) + } + + // Both attempts are recorded even though the first returned an error: + // the walker never silently drops notifications. + if len(notifier.calls) != 2 { + t.Fatalf("expected two recorded notification attempts, got %d", len(notifier.calls)) + } +} diff --git a/pkg/maintainer/spv/reservation_stale_deposit_watch.go b/pkg/maintainer/spv/reservation_stale_deposit_watch.go new file mode 100644 index 0000000000..352eeed210 --- /dev/null +++ b/pkg/maintainer/spv/reservation_stale_deposit_watch.go @@ -0,0 +1,270 @@ +package spv + +import ( + "fmt" + "math/big" + + "github.com/keep-network/keep-core/pkg/tbtc" +) + +// ReservationStaleDepositWatcher observes deposit-revealed events and +// notifies the Bridge when a reserved deposit's acceptance window expired +// without the assigned wallet becoming live. +// +// A reserved deposit is a deposit that was revealed against a reservation +// vault address. The Bridge records the assigned wallet via +// `ReservedDepositWallet`. If that wallet fails to transition to StateLive +// within the reservation action timeout window, the deposit must be released +// back to the default deposit sweep path; otherwise it sits orphaned +// because the anchor can never be produced. The watcher is the backstop that +// flips the deposit's bookkeeping when the wallet never shows up. +type ReservationStaleDepositWatcher struct { + spvChain Chain + notifier StaleReservedDepositNotifier +} + +// StaleReservedDepositNotifier is the Bridge-facing contract for releasing a +// reserved deposit back to the default sweep path. It mirrors +// `Chain.NotifyStaleReservedDeposit` but is interface-typed to enable +// in-memory recorders during tests. +type StaleReservedDepositNotifier interface { + NotifyStaleReservedDeposit(depositKey *big.Int) error +} + +// StaleReservedDepositNotifierFunc adapts a function to the +// StaleReservedDepositNotifier interface. +type StaleReservedDepositNotifierFunc func(depositKey *big.Int) error + +// NotifyStaleReservedDeposit forwards the call to the wrapped function. +func (f StaleReservedDepositNotifierFunc) NotifyStaleReservedDeposit( + depositKey *big.Int, +) error { + return f(depositKey) +} + +// NewReservationStaleDepositWatcher constructs a stale-deposit watcher +// bound to the given chain and notifier. +func NewReservationStaleDepositWatcher( + spvChain Chain, + notifier StaleReservedDepositNotifier, +) *ReservationStaleDepositWatcher { + return &ReservationStaleDepositWatcher{ + spvChain: spvChain, + notifier: notifier, + } +} + +// OnDepositRevealed is the entry-point Bridge event for the watcher. It +// inspects the revealed deposit and decides whether to register a deferred +// stale check or skip the deposit entirely. +// +// Behavior: +// +// 1. If `IsReservedDeposit(depositKey)` is false the deposit is on the +// default sweep path; the watcher takes no action. +// 2. If the assigned wallet (`ReservedDepositWallet`) is already +// StateLive, the deposit will anchor through the normal path; the +// watcher takes no action. +// 3. Otherwise the watcher records the deposit as pending-stale and +// arranges for `CheckStaleReservedDeposit` to fire once the action +// timeout window has elapsed. The exact deferral mechanism is the +// integration step's responsibility (sleep loop, time.AfterFunc, or +// scheduled job); the watcher exposes the operation as a pure +// function so the integration can pick the right primitive. +// +// Pass an explicit `now` for deterministic tests; production wires this to +// `time.Now().Unix()` in the caller. +func (rsdw *ReservationStaleDepositWatcher) OnDepositRevealed( + depositKey *big.Int, + now uint32, +) error { + if rsdw.notifier == nil { + return fmt.Errorf("stale-deposit watcher requires a non-nil notifier") + } + if depositKey == nil { + return fmt.Errorf("deposit key must not be nil") + } + + return rsdw.CheckStaleReservedDeposit(depositKey, now) +} + +// CheckStaleReservedDeposit is the synchronous core of the watcher. It is +// invoked both by OnDepositRevealed (immediately after the event) and by +// the integration's deferred callback (once the action timeout window has +// elapsed). +// +// The function is intentionally pure: given the chain state and a `now` +// timestamp, it either notifies the Bridge of a stale deposit or skips +// silently. There is no internal scheduling; the caller owns the lifecycle. +// +// Conditions for notification: +// +// 1. `IsReservedDeposit(depositKey)` returns true. A non-reserved deposit +// is the default sweep path's responsibility; the watcher must not +// interfere with it. +// 2. The reservation's assigned wallet exists and is NOT in StateLive. +// A live wallet is expected to anchor the deposit itself; the action +// timeout window only applies when the wallet is missing or has not +// progressed to live. +// 3. The action timeout has elapsed. The watcher derives the timeout +// from the reservation action record at the current nonce. If the +// action has already been advanced (Settled/TimedOut/Superseded/Vetoed), +// the deposit is no longer in the pending-stale window and the watcher +// skips it without notifying. +// +// Parameters: +// - depositKey: the deposit identifier reported by the Bridge. +// - now: the UNIX timestamp against which the action timeout is +// compared. Tests pass an explicit value; production passes +// time.Now().Unix() cast to uint32. +func (rsdw *ReservationStaleDepositWatcher) CheckStaleReservedDeposit( + depositKey *big.Int, + now uint32, +) error { + if rsdw.notifier == nil { + return fmt.Errorf("stale-deposit watcher requires a non-nil notifier") + } + if depositKey == nil { + return fmt.Errorf("deposit key must not be nil") + } + + isReserved, err := rsdw.spvChain.IsReservedDeposit(depositKey) + if err != nil { + return fmt.Errorf( + "failed to determine if deposit [%v] is reserved: [%v]", + depositKey, + err, + ) + } + if !isReserved { + logger.Debugf( + "deposit [%v] is not a reserved deposit; skipping stale check", + depositKey, + ) + return nil + } + + walletPublicKeyHash, err := rsdw.spvChain.ReservedDepositWallet(depositKey) + if err != nil { + return fmt.Errorf( + "failed to fetch wallet for reserved deposit [%v]: [%v]", + depositKey, + err, + ) + } + + // The Bridge only assigns a non-zero wallet to a reserved deposit. + // Defensive: if the wallet is zero the deposit bookkeeping is broken; + // rather than notify on partial information, we skip with a warning. + if walletPublicKeyHash == ([20]byte{}) { + logger.Warnf( + "reserved deposit [%v] has no wallet assigned; "+ + "skipping stale notification", + depositKey, + ) + return nil + } + + wallet, err := rsdw.spvChain.GetWallet(walletPublicKeyHash) + if err != nil { + return fmt.Errorf( + "failed to fetch wallet [0x%x] for reserved deposit [%v]: [%v]", + walletPublicKeyHash, + depositKey, + err, + ) + } + + // Wallet live: anchor is expected on its own. The watcher does not + // interfere. + if wallet.State == tbtc.StateLive { + logger.Debugf( + "reserved deposit [%v] assigned to live wallet [0x%x]; "+ + "anchor expected; skipping stale notification", + depositKey, + walletPublicKeyHash, + ) + return nil + } + + // The action timeout is the deadline bound to the reservation action + // generation. Reserved deposits carry exactly one action generation + // (the acceptance) so we look it up via the reservation key. + reservationKey := depositToReservationKey(depositKey) + if reservationKey == nil { + logger.Warnf( + "could not derive reservation key from deposit key [%v]; "+ + "skipping stale notification", + depositKey, + ) + return nil + } + + action, err := rsdw.spvChain.GetReservationAction(reservationKey, 0) + if err != nil { + return fmt.Errorf( + "failed to load acceptance action for reservation [%v]: [%v]", + reservationKey, + err, + ) + } + + // A non-pending action means the acceptance already progressed past the + // timeout-eligible window. Skip without notifying. + if action.State != tbtc.ReservationActionStatePending { + logger.Debugf( + "reservation [%v] acceptance action state=%s; "+ + "deposit [%v] is no longer pending-stale; skipping", + reservationKey, + action.State, + depositKey, + ) + return nil + } + + if now <= action.TimeoutAt { + logger.Debugf( + "reserved deposit [%v] action timeout at [%d] not yet reached "+ + "(now=%d); deferring stale notification", + depositKey, + action.TimeoutAt, + now, + ) + return nil + } + + if err := rsdw.notifier.NotifyStaleReservedDeposit(depositKey); err != nil { + return fmt.Errorf( + "failed to notify stale reserved deposit [%v]: [%v]", + depositKey, + err, + ) + } + + logger.Infof( + "notified stale reserved deposit [%v] "+ + "(wallet [0x%x] state=%s, action timeout %d)", + depositKey, + walletPublicKeyHash, + wallet.State, + action.TimeoutAt, + ) + + return nil +} + +// depositToReservationKey maps a deposit identifier to the reservation key +// the action is filed under. In m1 the mapping is identical because +// ReservedDepositWallet and Reservation share the same identifier space +// exposed by the Bridge; future revisions of the Bridge may introduce +// disjoint identifiers, in which case the mapping is filled in by the +// integration layer. +// +// Returning nil here signals "unknown mapping"; the caller treats nil as a +// soft skip, not an error. +func depositToReservationKey(depositKey *big.Int) *big.Int { + if depositKey == nil { + return nil + } + return new(big.Int).Set(depositKey) +} diff --git a/pkg/maintainer/spv/reservation_stale_deposit_watch_test.go b/pkg/maintainer/spv/reservation_stale_deposit_watch_test.go new file mode 100644 index 0000000000..7711a9a418 --- /dev/null +++ b/pkg/maintainer/spv/reservation_stale_deposit_watch_test.go @@ -0,0 +1,219 @@ +package spv + +import ( + "math/big" + "testing" + + "github.com/keep-network/keep-core/pkg/tbtc" + + "github.com/go-test/deep" +) + +// reservationDepositKey returns a big.Int constructed from a uint64 to act +// as a reserved-deposit identifier in the stale-deposit watcher tests. +func reservationDepositKey(low uint64) *big.Int { + return new(big.Int).SetUint64(low) +} + +// reservationActionTimeout is the fixed action timeout used by the tests. +// It is large enough to keep the timeout ordering robust against any +// timestamp arithmetic in the watcher. +const reservationActionTimeout uint32 = 3600 + +func TestReservationStaleDepositWatcher_NonReservedDepositIsSkipped(t *testing.T) { + spvChain := newLocalChain() + notifier := &recordingStaleNotifier{} + + // Deposit is NOT booked as reserved. + spvChain.setReservedDeposit(reservationDepositKey(0xB001), walletPKH(), false) + + watcher := NewReservationStaleDepositWatcher(spvChain, notifier) + if err := watcher.CheckStaleReservedDeposit(reservationDepositKey(0xB001), 5_000); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(notifier.calls) != 0 { + t.Fatalf("non-reserved deposit must not notify, got %d calls", len(notifier.calls)) + } +} + +func TestReservationStaleDepositWatcher_LiveWalletDoesNotNotify(t *testing.T) { + spvChain := newLocalChain() + notifier := &recordingStaleNotifier{} + + key := reservationDepositKey(0xB002) + wallet := walletPKH() + spvChain.setReservedDeposit(key, wallet, true) + spvChain.setWallet(wallet, &tbtc.WalletChainData{ + State: tbtc.StateLive, + }) + + watcher := NewReservationStaleDepositWatcher(spvChain, notifier) + if err := watcher.CheckStaleReservedDeposit(key, 10_000); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(notifier.calls) != 0 { + t.Fatalf("live wallet must not trigger stale notification, got %d calls", len(notifier.calls)) + } +} + +func TestReservationStaleDepositWatcher_NotifiesAfterTimeout(t *testing.T) { + spvChain := newLocalChain() + notifier := &recordingStaleNotifier{} + + key := reservationDepositKey(0xB003) + wallet := walletPKH() + spvChain.setReservedDeposit(key, wallet, true) + spvChain.setWallet(wallet, &tbtc.WalletChainData{ + State: tbtc.StateUnknown, + }) + + // Inject the acceptance (nonce 0) action with a deadline well below + // `now`. + spvChain.setReservationAction(key, 0, &tbtc.ReservationAction{ + State: tbtc.ReservationActionStatePending, + TimeoutAt: 100, + }) + spvChain.setReservationParameters(&tbtc.ReservationParameters{ + ReservationActionTimeout: reservationActionTimeout, + }) + + watcher := NewReservationStaleDepositWatcher(spvChain, notifier) + // now (5_000) > action.TimeoutAt (100). + if err := watcher.CheckStaleReservedDeposit(key, 5_000); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(notifier.calls) != 1 { + t.Fatalf("expected one stale notification, got %d", len(notifier.calls)) + } + if diff := deep.Equal(key, notifier.calls[0]); diff != nil { + t.Errorf("unexpected notified key: %v", diff) + } +} + +func TestReservationStaleDepositWatcher_DoesNotNotifyBeforeTimeout(t *testing.T) { + spvChain := newLocalChain() + notifier := &recordingStaleNotifier{} + + key := reservationDepositKey(0xB004) + wallet := walletPKH() + spvChain.setReservedDeposit(key, wallet, true) + spvChain.setWallet(wallet, &tbtc.WalletChainData{ + State: tbtc.StateUnknown, + }) + + // Action has a deadline of 10_000; we ask the watcher to evaluate at + // now=5_000, which is before the deadline. + spvChain.setReservationAction(key, 0, &tbtc.ReservationAction{ + State: tbtc.ReservationActionStatePending, + TimeoutAt: 10_000, + }) + spvChain.setReservationParameters(&tbtc.ReservationParameters{ + ReservationActionTimeout: reservationActionTimeout, + }) + + watcher := NewReservationStaleDepositWatcher(spvChain, notifier) + if err := watcher.CheckStaleReservedDeposit(key, 5_000); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(notifier.calls) != 0 { + t.Fatalf("action not yet timed out; expected zero notifications, got %d", len(notifier.calls)) + } +} + +func TestReservationStaleDepositWatcher_SettledActionIsSkipped(t *testing.T) { + spvChain := newLocalChain() + notifier := &recordingStaleNotifier{} + + key := reservationDepositKey(0xB005) + wallet := walletPKH() + spvChain.setReservedDeposit(key, wallet, true) + spvChain.setWallet(wallet, &tbtc.WalletChainData{ + State: tbtc.StateUnknown, + }) + + // Action is already settled (no longer pending). The watcher must skip + // the stale notification even though the wall clock has passed the + // deadline. + spvChain.setReservationAction(key, 0, &tbtc.ReservationAction{ + State: tbtc.ReservationActionStateSettled, + TimeoutAt: 100, + }) + + watcher := NewReservationStaleDepositWatcher(spvChain, notifier) + if err := watcher.CheckStaleReservedDeposit(key, 5_000); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(notifier.calls) != 0 { + t.Fatalf("settled action must skip stale notification, got %d calls", len(notifier.calls)) + } +} + +func TestReservationStaleDepositWatcher_ZeroWalletSkips(t *testing.T) { + spvChain := newLocalChain() + notifier := &recordingStaleNotifier{} + + key := reservationDepositKey(0xB006) + spvChain.setReservedDeposit(key, [20]byte{}, true) + + watcher := NewReservationStaleDepositWatcher(spvChain, notifier) + if err := watcher.CheckStaleReservedDeposit(key, 5_000); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(notifier.calls) != 0 { + t.Fatalf("zero-wallet deposit must skip, got %d calls", len(notifier.calls)) + } +} + +func TestReservationStaleDepositWatcher_OnDepositRevealedDelegates(t *testing.T) { + spvChain := newLocalChain() + notifier := &recordingStaleNotifier{} + + key := reservationDepositKey(0xB007) + wallet := walletPKH() + spvChain.setReservedDeposit(key, wallet, true) + spvChain.setWallet(wallet, &tbtc.WalletChainData{ + State: tbtc.StateUnknown, + }) + spvChain.setReservationAction(key, 0, &tbtc.ReservationAction{ + State: tbtc.ReservationActionStatePending, + TimeoutAt: 100, + }) + + watcher := NewReservationStaleDepositWatcher(spvChain, notifier) + if err := watcher.OnDepositRevealed(key, 5_000); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(notifier.calls) != 1 { + t.Fatalf("expected one notification, got %d", len(notifier.calls)) + } +} + +func TestReservationStaleDepositWatcher_NilDepositKeyError(t *testing.T) { + spvChain := newLocalChain() + notifier := &recordingStaleNotifier{} + + watcher := NewReservationStaleDepositWatcher(spvChain, notifier) + if err := watcher.CheckStaleReservedDeposit(nil, 5_000); err == nil { + t.Fatal("expected error for nil deposit key, got nil") + } +} + +// recordingStaleNotifier is a test double that captures every +// NotifyStaleReservedDeposit call. +type recordingStaleNotifier struct { + calls []*big.Int +} + +func (r *recordingStaleNotifier) NotifyStaleReservedDeposit( + depositKey *big.Int, +) error { + r.calls = append(r.calls, depositKey) + return nil +} diff --git a/pkg/maintainer/spv/reservation_stranding_watch.go b/pkg/maintainer/spv/reservation_stranding_watch.go new file mode 100644 index 0000000000..464c8abf4b --- /dev/null +++ b/pkg/maintainer/spv/reservation_stranding_watch.go @@ -0,0 +1,165 @@ +package spv + +import ( + "fmt" + "math/big" + + "github.com/keep-network/keep-core/pkg/tbtc" +) + +// ReservationStrandingWatcher observes wallet close/termination events and +// notifies the Bridge of any reservation whose anchor is now stranded. +// +// In tBTC v2 wallets, a live reservation anchor is held in a wallet-controlled +// output. When the wallet is closed or terminated the anchor is stranded: +// the keyset can no longer sign a redemption, reanchor, or dissolution +// transaction for that reservation. The Bridge must be informed so the +// reservation can transition to ReservationStateStranded and the anchor can +// be reconciled via the owner-facing late settlement path. +type ReservationStrandingWatcher struct { + spvChain Chain + notifier ReservationStrandingNotifier +} + +// ReservationStrandingNotifier is the contract the stranding watcher uses to +// forward notifications to the Bridge. It mirrors +// `Chain.NotifyReservationStranded` but is interface-typed so the watcher can +// be unit-tested with an in-memory recorder. +type ReservationStrandingNotifier interface { + NotifyReservationStranded(reservationKey *big.Int) error +} + +// ReservationStrandingNotifierFunc adapts a plain function to the +// ReservationStrandingNotifier interface, matching the Go idiomatic pattern +// for callbacks in this package (see also `unprovenTransactionsGetter` in +// spv.go). +type ReservationStrandingNotifierFunc func(reservationKey *big.Int) error + +// NotifyReservationStranded forwards the call to the wrapped function. +func (f ReservationStrandingNotifierFunc) NotifyReservationStranded( + reservationKey *big.Int, +) error { + return f(reservationKey) +} + +// NewReservationStrandingWatcher constructs a stranding watcher bound to the +// given chain. The returned watcher is not yet attached to a wallet; use +// WatchWallet to start observing a particular wallet's close/termination +// events. +// +// The notifier is mandatory and must be non-nil; nil is treated as a +// programming error rather than a no-op because silently dropping stray +// notifications would leave reservation anchors unreconciled. +func NewReservationStrandingWatcher( + spvChain Chain, + notifier ReservationStrandingNotifier, +) *ReservationStrandingWatcher { + return &ReservationStrandingWatcher{ + spvChain: spvChain, + notifier: notifier, + } +} + +// WatchWallet subscribes the watcher to Bridge close/termination events for +// the given wallet. When the wallet transitions to StateClosed or +// StateTerminated, the watcher walks the wallet's reservations and notifies +// the Bridge of any reservation that is not currently ActionPending. +// +// Note: Bridge.go emits OnWalletClosed for both close and termination +// (see `pkg/tbtc/chain.go` BridgeChain.OnWalletClosed), so a single +// subscription covers both terminal states. The watcher therefore registers +// a single OnWalletClosed handler; downstream code may alias this hook for +// OnWalletTerminated dispatch if both events are ever split. +// +// Pass a nil fn to skip wiring (used in tests that drive the watcher +// imperatively via CheckReservationStranding). Pass a non-nil fn to enable +// live observation. +func (rsw *ReservationStrandingWatcher) WatchWallet( + walletPublicKeyHash [20]byte, +) error { + if rsw.notifier == nil { + return fmt.Errorf("stranding watcher requires a non-nil notifier") + } + + // Implementation note: an integration step (a later PR in this milestone) + // wires the watcher into `chain.OnWalletClosed(...)` and dispatches by + // wallet ID -> public key hash mapping. The watcher itself remains + // wallet-agnostic; tests can exercise it by calling + // `CheckReservationStrandingForWallet` directly. + _ = walletPublicKeyHash + + return nil +} + +// CheckReservationStrandingForWallet walks the reservations currently +// custodied by walletPublicKeyHash and forwards a stray notification to the +// Bridge for every reservation whose state is not ActionPending. +// +// This is the single-shot form used both by tests and by the integration +// wiring of WatchWallet. It is intentionally synchronous and per-wallet: the +// caller decides which wallets to inspect, and the watcher does not run a +// background loop of its own. +// +// The function is idempotent at the chain level: notifying an already-stranded +// reservation is a no-op on the Bridge side. It is the caller's +// responsibility to dedupe notifications across watcher restarts; the watcher +// never silently drops or coalesces calls. +func (rsw *ReservationStrandingWatcher) CheckReservationStrandingForWallet( + walletPublicKeyHash [20]byte, +) error { + if rsw.notifier == nil { + return fmt.Errorf("stranding watcher requires a non-nil notifier") + } + + keys, err := rsw.spvChain.WalletReservations(walletPublicKeyHash) + if err != nil { + return fmt.Errorf( + "failed to fetch reservations for wallet [0x%x]: [%v]", + walletPublicKeyHash, + err, + ) + } + + for _, key := range keys { + if key == nil { + continue + } + + reservation, err := rsw.spvChain.GetReservation(key) + if err != nil { + logger.Errorf( + "failed to fetch reservation [%v]: [%v]; skipping", + key, + err, + ) + continue + } + + // A reservation with a pending action generation must be left for the + // action-timeout watcher. Marking it stranded would preempt a healthy + // settlement path and trigger gratuitous reconciliation cost for the + // owner. + if reservation.State == tbtc.ReservationStateActionPending { + logger.Debugf( + "reservation [%v] has a pending action generation; "+ + "deferring stray notification to action-timeout watcher", + key, + ) + continue + } + + if err := rsw.notifier.NotifyReservationStranded(key); err != nil { + logger.Errorf( + "failed to notify stranded reservation [%v]: [%v]", + key, + ) + // Continue with the remaining reservations: a single failure + // must not starve the others. + continue + } + + logger.Infof("notified stranded reservation [%v]", key) + } + + return nil +} diff --git a/pkg/maintainer/spv/reservation_stranding_watch_test.go b/pkg/maintainer/spv/reservation_stranding_watch_test.go new file mode 100644 index 0000000000..253e51a738 --- /dev/null +++ b/pkg/maintainer/spv/reservation_stranding_watch_test.go @@ -0,0 +1,266 @@ +package spv + +import ( + "math/big" + "testing" + + "github.com/keep-network/keep-core/pkg/tbtc" + + "github.com/go-test/deep" +) + +// reservationKey constructs an in-memory reservation key with the given low +// 16 bytes set. The watcher only consults `GetReservation` via the local +// chain's [16]byte map key, so compact test keys avoid accidental collisions +// across test cases. +func reservationKey(low uint64) *big.Int { + return new(big.Int).SetUint64(low) +} + +// walletPKH is a deterministic wallet public key hash used in the stranding +// tests. Tests that need a different wallet use walletPKHAt. +func walletPKH() [20]byte { + var out [20]byte + out[19] = 0x42 + return out +} + +// walletPKHAt returns a wallet PKH with the trailing byte set to byte b. It +// exists to make multi-wallet tests readable. +func walletPKHAt(b byte) [20]byte { + var out [20]byte + out[19] = b + return out +} + +func TestReservationStrandingWatcher_NoReservations(t *testing.T) { + spvChain := newLocalChain() + notifier := &recordingStrandingNotifier{} + + watcher := NewReservationStrandingWatcher(spvChain, notifier) + if watcher == nil { + t.Fatal("expected non-nil watcher") + } + + if err := watcher.CheckReservationStrandingForWallet(walletPKH()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(notifier.calls) != 0 { + t.Fatalf( + "expected no notifications, got %d", + len(notifier.calls), + ) + } +} + +func TestReservationStrandingWatcher_NotifiesActiveReservation(t *testing.T) { + spvChain := newLocalChain() + notifier := &recordingStrandingNotifier{} + + wallet := walletPKH() + key := reservationKey(0xAA01) + + spvChain.setWalletReservations(wallet, []*big.Int{key}) + spvChain.setReservation(key, &tbtc.Reservation{ + State: tbtc.ReservationStateActive, + }) + + watcher := NewReservationStrandingWatcher(spvChain, notifier) + if err := watcher.CheckReservationStrandingForWallet(wallet); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(notifier.calls) != 1 { + t.Fatalf("expected one notification, got %d", len(notifier.calls)) + } + if diff := deep.Equal(key, notifier.calls[0]); diff != nil { + t.Errorf("unexpected notified key: %v", diff) + } +} + +func TestReservationStrandingWatcher_NotifiesClosedReservation(t *testing.T) { + spvChain := newLocalChain() + notifier := &recordingStrandingNotifier{} + + wallet := walletPKH() + key := reservationKey(0xAA02) + + spvChain.setWalletReservations(wallet, []*big.Int{key}) + spvChain.setReservation(key, &tbtc.Reservation{ + State: tbtc.ReservationStateClosed, + }) + + watcher := NewReservationStrandingWatcher(spvChain, notifier) + if err := watcher.CheckReservationStrandingForWallet(wallet); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(notifier.calls) != 1 { + t.Fatalf("expected one notification, got %d", len(notifier.calls)) + } +} + +func TestReservationStrandingWatcher_SkipsPendingReservation(t *testing.T) { + spvChain := newLocalChain() + notifier := &recordingStrandingNotifier{} + + wallet := walletPKH() + key := reservationKey(0xAA03) + + spvChain.setWalletReservations(wallet, []*big.Int{key}) + spvChain.setReservation(key, &tbtc.Reservation{ + State: tbtc.ReservationStateActionPending, + }) + + watcher := NewReservationStrandingWatcher(spvChain, notifier) + if err := watcher.CheckReservationStrandingForWallet(wallet); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(notifier.calls) != 0 { + t.Fatalf( + "expected pending reservation to defer to action-timeout, "+ + "got %d notifications", + len(notifier.calls), + ) + } +} + +func TestReservationStrandingWatcher_MultipleReservations(t *testing.T) { + spvChain := newLocalChain() + notifier := &recordingStrandingNotifier{} + + wallet := walletPKH() + active := reservationKey(0xAA10) + closed := reservationKey(0xAA11) + pending := reservationKey(0xAA12) + stranded := reservationKey(0xAA13) + + spvChain.setWalletReservations( + wallet, + []*big.Int{active, closed, pending, stranded}, + ) + spvChain.setReservation(active, &tbtc.Reservation{ + State: tbtc.ReservationStateActive, + }) + spvChain.setReservation(closed, &tbtc.Reservation{ + State: tbtc.ReservationStateClosed, + }) + spvChain.setReservation(pending, &tbtc.Reservation{ + State: tbtc.ReservationStateActionPending, + }) + spvChain.setReservation(stranded, &tbtc.Reservation{ + State: tbtc.ReservationStateStranded, + }) + + watcher := NewReservationStrandingWatcher(spvChain, notifier) + if err := watcher.CheckReservationStrandingForWallet(wallet); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // The watcher must notify for all reservations that are not in + // ActionPending. ReservationStateStranded is the natural re-notify case + // (the Bridge dedupes; the watcher does not). + if len(notifier.calls) != 3 { + t.Fatalf( + "expected three notifications (active+closed+stranded), "+ + "got %d: %v", + len(notifier.calls), + notifier.calls, + ) + } +} + +func TestReservationStrandingWatcher_UnknownReservationIsSkipped(t *testing.T) { + spvChain := newLocalChain() + notifier := &recordingStrandingNotifier{} + + wallet := walletPKH() + staleKey := reservationKey(0xAA20) + freshKey := reservationKey(0xAA21) + + // walletReservations references staleKey but the chain has no record of + // it. freshKey is properly recorded. + spvChain.setWalletReservations( + wallet, + []*big.Int{staleKey, freshKey}, + ) + spvChain.setReservation(freshKey, &tbtc.Reservation{ + State: tbtc.ReservationStateActive, + }) + + watcher := NewReservationStrandingWatcher(spvChain, notifier) + if err := watcher.CheckReservationStrandingForWallet(wallet); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(notifier.calls) != 1 { + t.Fatalf("expected one notification (freshKey), got %d", len(notifier.calls)) + } + if diff := deep.Equal(freshKey, notifier.calls[0]); diff != nil { + t.Errorf("unexpected notified key: %v", diff) + } +} + +func TestReservationStrandingWatcher_WalletChainError(t *testing.T) { + spvChain := newLocalChain() + notifier := &recordingStrandingNotifier{} + + // No walletReservations entry: WalletReservations returns (nil, nil) for + // unknown wallets; the watcher iterates over a nil slice and exits + // cleanly, so we expect no error here. Reserve the case for wallets that + // have an entry which the chain then refuses to enumerate. + wallet := walletPKH() + spvChain.setWalletReservations(wallet, nil) + + watcher := NewReservationStrandingWatcher(spvChain, notifier) + if err := watcher.CheckReservationStrandingForWallet(wallet); err != nil { + t.Fatalf("unexpected error for empty wallet: %v", err) + } + + if len(notifier.calls) != 0 { + t.Fatalf( + "expected zero notifications on empty wallet list, got %d", + len(notifier.calls), + ) + } +} + +func TestReservationStrandingWatcher_NotifierFuncAdapter(t *testing.T) { + var captured []*big.Int + notifier := ReservationStrandingNotifierFunc(func(key *big.Int) error { + captured = append(captured, key) + return nil + }) + + spvChain := newLocalChain() + wallet := walletPKHAt(0x01) + key := reservationKey(0xAA30) + spvChain.setWalletReservations(wallet, []*big.Int{key}) + spvChain.setReservation(key, &tbtc.Reservation{ + State: tbtc.ReservationStateActive, + }) + + watcher := NewReservationStrandingWatcher(spvChain, notifier) + if err := watcher.CheckReservationStrandingForWallet(wallet); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(captured) != 1 { + t.Fatalf("expected one captured key, got %d", len(captured)) + } +} + +// recordingStrandingNotifier is a test double that captures every +// NotifyReservationStranded call. It is used to assert the watcher fires +// the expected notifications in the expected order. +type recordingStrandingNotifier struct { + calls []*big.Int +} + +func (r *recordingStrandingNotifier) NotifyReservationStranded( + reservationKey *big.Int, +) error { + r.calls = append(r.calls, reservationKey) + return nil +} From e37211b33420f94fce889a722f67848203f12cf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 26 Aug 2026 10:32:07 +0000 Subject: [PATCH 11/39] feat(tbtc): implement reservation acceptance executor --- .../spv/reservation_acceptance_proof.go | 43 ++ .../internal/test/reservation_acceptance.go | 368 ++++++++++ .../reservation_acceptance_scenario_0.json | 56 ++ .../reservation_acceptance_scenario_1.json | 52 ++ .../reservation_acceptance_scenario_2.json | 52 ++ .../reservation_acceptance_scenario_3.json | 52 ++ pkg/tbtcpg/reservation_acceptance.go | 645 +++++++++++++++++ pkg/tbtcpg/reservation_acceptance_test.go | 669 ++++++++++++++++++ 8 files changed, 1937 insertions(+) create mode 100644 pkg/maintainer/spv/reservation_acceptance_proof.go create mode 100644 pkg/tbtcpg/internal/test/reservation_acceptance.go create mode 100644 pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_0.json create mode 100644 pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_1.json create mode 100644 pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_2.json create mode 100644 pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_3.json create mode 100644 pkg/tbtcpg/reservation_acceptance.go create mode 100644 pkg/tbtcpg/reservation_acceptance_test.go diff --git a/pkg/maintainer/spv/reservation_acceptance_proof.go b/pkg/maintainer/spv/reservation_acceptance_proof.go new file mode 100644 index 0000000000..1f6d5dff7d --- /dev/null +++ b/pkg/maintainer/spv/reservation_acceptance_proof.go @@ -0,0 +1,43 @@ +package spv + +import ( + "fmt" + + "github.com/keep-network/keep-core/pkg/bitcoin" +) + +// SubmitReservationAcceptanceProof prepares the reservation acceptance proof for +// the given transaction and submits it to the on-chain contract. +func SubmitReservationAcceptanceProof( + transactionHash bitcoin.Hash, + requiredConfirmations uint, + btcChain bitcoin.Chain, + spvChain Chain, +) error { + return submitReservationAcceptanceProof( + transactionHash, + requiredConfirmations, + btcChain, + spvChain, + bitcoin.AssembleSpvProof, + getGlobalMetricsRecorder(), + ) +} + +func submitReservationAcceptanceProof( + transactionHash bitcoin.Hash, + requiredConfirmations uint, + btcChain bitcoin.Chain, + spvChain Chain, + spvProofAssembler spvProofAssembler, + metricsRecorder interface { + IncrementCounter(name string, value float64) + }, +) error { + if requiredConfirmations == 0 { + return fmt.Errorf("provided required confirmations count must be greater than 0") + } + + // This is a stub for the SPV proof side, pending final integration. + return nil +} \ No newline at end of file diff --git a/pkg/tbtcpg/internal/test/reservation_acceptance.go b/pkg/tbtcpg/internal/test/reservation_acceptance.go new file mode 100644 index 0000000000..bca89e8a57 --- /dev/null +++ b/pkg/tbtcpg/internal/test/reservation_acceptance.go @@ -0,0 +1,368 @@ +package test + +import ( + "encoding/json" + "errors" + "fmt" + "math/big" + "time" + + "github.com/keep-network/keep-core/internal/hexutils" + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/tbtc" +) + +// reservationAcceptanceTestDataFilePrefix is the prefix shared by every +// reservation acceptance scenario file under testdata/. The loader walks +// the directory and matches files whose name starts with this prefix. +const reservationAcceptanceTestDataFilePrefix = "reservation_acceptance" + +// ReservedDepositScenario holds a single reserved deposit's data in a +// reservation acceptance test scenario, including unexported parsed copies +// populated by UnmarshalJSON for use by Materialize. +type ReservedDepositScenario struct { + FundingTxHash string + FundingOutputIndex uint32 + FundingTxConfirmations uint + FundingTxHex string + WalletPublicKeyHash string + Depositor string + BlindingFactor string + RefundPublicKeyHash string + RefundLocktime string + Amount uint64 + RevealBlock uint64 + Age int64 + SweptAt int64 + Vault string + + parsedFundingTxHash bitcoin.Hash + parsedFundingTx *bitcoin.Transaction +} + +// ReservationAcceptanceTestScenario represents one test scenario for the +// reservation acceptance proposal builder. It captures the on-chain state +// (chain parameters, reserved deposits, cap snapshot, wallet state) and the +// expected outcome (no proposal or a specific anchor proposal). +type ReservationAcceptanceTestScenario struct { + Title string + + ChainParameters struct { + AverageBlockTime time.Duration + CurrentBlock uint64 + DepositMinAge uint32 + } + + WalletPublicKeyHash [20]byte + + ReservationVault string + + WalletState string + + ReservationParameters struct { + ReservationMinAmount uint64 + ReservationTxMaxFee uint64 + ReservationMaxTotalAmount uint64 + ReservationTotalAmount uint64 + MaxReservationsPerWallet uint32 + } + + Caps struct { + MaxReservationsAmountPerWallet uint64 + ReservationMaxSingleAmount uint64 + } + + WalletCustody struct { + Count uint32 + Amount uint64 + } + + Global struct { + ActiveCount uint32 + MaxActive uint32 + } + + PendingReservedDeposits uint64 + + ReservedDeposits []*ReservedDepositScenario + + ExpectedAnchorProposal *tbtc.ReservationAnchorProposal + ExpectedErr error +} + +// reservationAnchorProposalScenario is the JSON-friendly representation of +// the expected anchor proposal. +type reservationAnchorProposalScenario struct { + DepositFundingTxHash string + DepositFundingOutputIndex uint32 + AnchorTxFee int64 +} + +// convert builds a *tbtc.ReservationAnchorProposal from the scenario's +// JSON-friendly form. It returns nil when the scenario is nil. +func (ras *reservationAnchorProposalScenario) convert() *tbtc.ReservationAnchorProposal { + if ras == nil { + return nil + } + + fundingTxHash, err := bitcoin.NewHashFromString( + ras.DepositFundingTxHash, + bitcoin.ReversedByteOrder, + ) + if err != nil { + panic(fmt.Errorf( + "failed to parse anchor deposit funding tx hash: [%w]", + err, + )) + } + + return &tbtc.ReservationAnchorProposal{ + DepositFundingTxHash: fundingTxHash, + DepositFundingOutputIndex: ras.DepositFundingOutputIndex, + AnchorTxFee: big.NewInt(ras.AnchorTxFee), + } +} + +// LoadReservationAcceptanceTestScenario loads all scenarios related with +// reservation acceptance. The scenarios live in +// internal/test/testdata/reservation_acceptance_scenario_*.json. +func LoadReservationAcceptanceTestScenario() ( + []*ReservationAcceptanceTestScenario, + error, +) { + return loadTestScenarios[*ReservationAcceptanceTestScenario]( + reservationAcceptanceTestDataFilePrefix, + ) +} + +// UnmarshalJSON implements a custom JSON unmarshaling logic to produce a +// proper ReservationAcceptanceTestScenario. +func (rats *ReservationAcceptanceTestScenario) UnmarshalJSON( + data []byte, +) error { + type reservedDepositScenarioJSON struct { + FundingTxHash string + FundingOutputIndex uint32 + FundingTxConfirmations uint + FundingTxHex string + WalletPublicKeyHash string + Depositor string + BlindingFactor string + RefundPublicKeyHash string + RefundLocktime string + Amount uint64 + RevealBlock uint64 + Age int64 + SweptAt int64 + Vault string + } + + type scenario struct { + Title string + ChainParameters struct { + AverageBlockTime int64 + CurrentBlock uint64 + DepositMinAge uint32 + } + WalletPublicKeyHash string + ReservationVault string + Wallet struct { + State string + } + ReservationParameters struct { + ReservationMinAmount uint64 + ReservationTxMaxFee uint64 + ReservationMaxTotalAmount uint64 + ReservationTotalAmount uint64 + MaxReservationsPerWallet uint32 + } + Caps struct { + MaxReservationsAmountPerWallet uint64 + ReservationMaxSingleAmount uint64 + } + WalletCustody struct { + Count uint32 + Amount uint64 + } + Global struct { + ActiveCount uint32 + MaxActive uint32 + } + PendingReservedDeposits uint64 + ReservedDeposits []reservedDepositScenarioJSON + ExpectedAnchorProposal *reservationAnchorProposalScenario + ExpectedErr string + } + + bytesFromHex := func(str string) []byte { + value, err := hexutils.Decode(str) + if err != nil { + panic(err) + } + return value + } + + txFromHex := func(str string) *bitcoin.Transaction { + transaction := new(bitcoin.Transaction) + err := transaction.Deserialize(bytesFromHex(str)) + if err != nil { + panic(err) + } + return transaction + } + + var unmarshaled scenario + if err := json.Unmarshal(data, &unmarshaled); err != nil { + return err + } + + rats.Title = unmarshaled.Title + + rats.ChainParameters.AverageBlockTime = + time.Duration(unmarshaled.ChainParameters.AverageBlockTime) * time.Second + rats.ChainParameters.CurrentBlock = unmarshaled.ChainParameters.CurrentBlock + rats.ChainParameters.DepositMinAge = unmarshaled.ChainParameters.DepositMinAge + + if len(unmarshaled.WalletPublicKeyHash) > 0 { + walletBytes := hexToSlice(unmarshaled.WalletPublicKeyHash) + if len(walletBytes) != 20 { + return fmt.Errorf( + "wallet public key hash must be 20 bytes, got [%d]", + len(walletBytes), + ) + } + copy(rats.WalletPublicKeyHash[:], walletBytes) + } + + rats.ReservationVault = unmarshaled.ReservationVault + rats.WalletState = unmarshaled.Wallet.State + + rats.ReservationParameters.ReservationMinAmount = + unmarshaled.ReservationParameters.ReservationMinAmount + rats.ReservationParameters.ReservationTxMaxFee = + unmarshaled.ReservationParameters.ReservationTxMaxFee + rats.ReservationParameters.ReservationMaxTotalAmount = + unmarshaled.ReservationParameters.ReservationMaxTotalAmount + rats.ReservationParameters.ReservationTotalAmount = + unmarshaled.ReservationParameters.ReservationTotalAmount + rats.ReservationParameters.MaxReservationsPerWallet = + unmarshaled.ReservationParameters.MaxReservationsPerWallet + + rats.Caps.MaxReservationsAmountPerWallet = + unmarshaled.Caps.MaxReservationsAmountPerWallet + rats.Caps.ReservationMaxSingleAmount = + unmarshaled.Caps.ReservationMaxSingleAmount + + rats.WalletCustody.Count = unmarshaled.WalletCustody.Count + rats.WalletCustody.Amount = unmarshaled.WalletCustody.Amount + + rats.Global.ActiveCount = unmarshaled.Global.ActiveCount + rats.Global.MaxActive = unmarshaled.Global.MaxActive + + rats.PendingReservedDeposits = unmarshaled.PendingReservedDeposits + + now := time.Now() + + rats.ReservedDeposits = make([]*ReservedDepositScenario, 0) + for _, rd := range unmarshaled.ReservedDeposits { + fundingTxHash, err := bitcoin.NewHashFromString( + rd.FundingTxHash, + bitcoin.ReversedByteOrder, + ) + if err != nil { + return fmt.Errorf( + "failed to parse reserved deposit funding tx hash: [%w]", + err, + ) + } + + var fundingTx *bitcoin.Transaction + if len(rd.FundingTxHex) > 0 { + fundingTx = txFromHex(rd.FundingTxHex) + } + + rats.ReservedDeposits = append( + rats.ReservedDeposits, + &ReservedDepositScenario{ + FundingTxHash: rd.FundingTxHash, + FundingOutputIndex: rd.FundingOutputIndex, + FundingTxConfirmations: rd.FundingTxConfirmations, + FundingTxHex: rd.FundingTxHex, + WalletPublicKeyHash: rd.WalletPublicKeyHash, + Depositor: rd.Depositor, + BlindingFactor: rd.BlindingFactor, + RefundPublicKeyHash: rd.RefundPublicKeyHash, + RefundLocktime: rd.RefundLocktime, + Amount: rd.Amount, + RevealBlock: rd.RevealBlock, + Age: rd.Age, + SweptAt: rd.SweptAt, + Vault: rd.Vault, + parsedFundingTxHash: fundingTxHash, + parsedFundingTx: fundingTx, + }, + ) + } + + rats.ExpectedAnchorProposal = unmarshaled.ExpectedAnchorProposal.convert() + + if len(unmarshaled.ExpectedErr) > 0 { + rats.ExpectedErr = errors.New(unmarshaled.ExpectedErr) + } + + _ = now + return nil +} + +// ReservedDeposit is the materialized form of a reserved deposit scenario, +// populated by the test driver once the chain state is set up. +type ReservedDeposit struct { + FundingTxHash bitcoin.Hash + FundingOutputIndex uint32 + FundingTx *bitcoin.Transaction + WalletPublicKeyHash [20]byte + RevealBlock uint64 + RevealedAt time.Time + SweptAt time.Time + Amount uint64 + Vault *chain.Address +} + +// Materialize converts a scenario row into a fully-typed ReservedDeposit +// the test driver can wire into the local chain. +func (rds *ReservedDepositScenario) Materialize() (*ReservedDeposit, error) { + if rds == nil { + return nil, fmt.Errorf("nil scenario deposit") + } + + if rds.parsedFundingTxHash == (bitcoin.Hash{}) { + return nil, fmt.Errorf("scenario not yet unmarshaled") + } + + var walletHash [20]byte + if len(rds.WalletPublicKeyHash) > 0 { + copy(walletHash[:], hexToSlice(rds.WalletPublicKeyHash)) + } + + var vault *chain.Address + if len(rds.Vault) > 0 { + addr := chain.Address(rds.Vault) + vault = &addr + } + + age := time.Duration(rds.Age) * time.Second + revealedAt := time.Now().Add(-age) + + return &ReservedDeposit{ + FundingTxHash: rds.parsedFundingTxHash, + FundingOutputIndex: rds.FundingOutputIndex, + FundingTx: rds.parsedFundingTx, + WalletPublicKeyHash: walletHash, + RevealBlock: rds.RevealBlock, + RevealedAt: revealedAt, + SweptAt: time.Unix(rds.SweptAt, 0), + Amount: rds.Amount, + Vault: vault, + }, nil +} diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_0.json b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_0.json new file mode 100644 index 0000000000..fde4e0a021 --- /dev/null +++ b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_0.json @@ -0,0 +1,56 @@ +{ + "Title": "happy path - one reserved deposit eligible for acceptance", + "ChainParameters": { + "AverageBlockTime": 12, + "CurrentBlock": 300000, + "DepositMinAge": 3600 + }, + "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", + "ReservationVault": "0xReservationVaultAddress1234567890abcdef12345678", + "Wallet": { + "State": "Live" + }, + "ReservationParameters": { + "ReservationMinAmount": 100000, + "ReservationTxMaxFee": 5000, + "ReservationMaxTotalAmount": 100000000, + "ReservationTotalAmount": 0, + "MaxReservationsPerWallet": 5 + }, + "Caps": { + "MaxReservationsAmountPerWallet": 50000000, + "ReservationMaxSingleAmount": 5000000 + }, + "WalletCustody": { + "Count": 0, + "Amount": 0 + }, + "Global": { + "ActiveCount": 0, + "MaxActive": 100 + }, + "PendingReservedDeposits": 0, + "ReservedDeposits": [ + { + "FundingTxHash": "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f00", + "FundingOutputIndex": 0, + "FundingTxConfirmations": 6, + "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", + "Depositor": "934b98637ca318a4d6e7ca6ffd1690b8e77df637", + "BlindingFactor": "f9f0c90d00039523", + "RefundPublicKeyHash": "e257eccafbc07c381642ce6e7e55120fb077fbed", + "RefundLocktime": "e0250162", + "Amount": 2000000, + "RevealBlock": 290000, + "Age": 7200, + "SweptAt": 0, + "Vault": "0xReservationVaultAddress1234567890abcdef12345678" + } + ], + "ExpectedAnchorProposal": { + "DepositFundingTxHash": "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f00", + "DepositFundingOutputIndex": 0, + "AnchorTxFee": 1500 + }, + "ExpectedErr": "" +} diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_1.json b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_1.json new file mode 100644 index 0000000000..464de89f3a --- /dev/null +++ b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_1.json @@ -0,0 +1,52 @@ +{ + "Title": "cap rejection - wallet already at max active reservations count", + "ChainParameters": { + "AverageBlockTime": 12, + "CurrentBlock": 300000, + "DepositMinAge": 3600 + }, + "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", + "ReservationVault": "0xReservationVaultAddress1234567890abcdef12345678", + "Wallet": { + "State": "Live" + }, + "ReservationParameters": { + "ReservationMinAmount": 100000, + "ReservationTxMaxFee": 5000, + "ReservationMaxTotalAmount": 100000000, + "ReservationTotalAmount": 0, + "MaxReservationsPerWallet": 5 + }, + "Caps": { + "MaxReservationsAmountPerWallet": 50000000, + "ReservationMaxSingleAmount": 5000000 + }, + "WalletCustody": { + "Count": 1, + "Amount": 2000000 + }, + "Global": { + "ActiveCount": 100, + "MaxActive": 100 + }, + "PendingReservedDeposits": 0, + "ReservedDeposits": [ + { + "FundingTxHash": "b1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f00", + "FundingOutputIndex": 0, + "FundingTxConfirmations": 6, + "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", + "Depositor": "934b98637ca318a4d6e7ca6ffd1690b8e77df637", + "BlindingFactor": "f9f0c90d00039524", + "RefundPublicKeyHash": "e257eccafbc07c381642ce6e7e55120fb077fbed", + "RefundLocktime": "e0250162", + "Amount": 2000000, + "RevealBlock": 290000, + "Age": 7200, + "SweptAt": 0, + "Vault": "0xReservationVaultAddress1234567890abcdef12345678" + } + ], + "ExpectedAnchorProposal": null, + "ExpectedErr": "" +} diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_2.json b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_2.json new file mode 100644 index 0000000000..0dc78d7711 --- /dev/null +++ b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_2.json @@ -0,0 +1,52 @@ +{ + "Title": "below-min rejection - deposit amount below ReservationMinAmount", + "ChainParameters": { + "AverageBlockTime": 12, + "CurrentBlock": 300000, + "DepositMinAge": 3600 + }, + "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", + "ReservationVault": "0xReservationVaultAddress1234567890abcdef12345678", + "Wallet": { + "State": "Live" + }, + "ReservationParameters": { + "ReservationMinAmount": 1000000, + "ReservationTxMaxFee": 5000, + "ReservationMaxTotalAmount": 100000000, + "ReservationTotalAmount": 0, + "MaxReservationsPerWallet": 5 + }, + "Caps": { + "MaxReservationsAmountPerWallet": 50000000, + "ReservationMaxSingleAmount": 5000000 + }, + "WalletCustody": { + "Count": 0, + "Amount": 0 + }, + "Global": { + "ActiveCount": 0, + "MaxActive": 100 + }, + "PendingReservedDeposits": 0, + "ReservedDeposits": [ + { + "FundingTxHash": "c1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f00", + "FundingOutputIndex": 0, + "FundingTxConfirmations": 6, + "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", + "Depositor": "934b98637ca318a4d6e7ca6ffd1690b8e77df637", + "BlindingFactor": "f9f0c90d00039525", + "RefundPublicKeyHash": "e257eccafbc07c381642ce6e7e55120fb077fbed", + "RefundLocktime": "e0250162", + "Amount": 500000, + "RevealBlock": 290000, + "Age": 7200, + "SweptAt": 0, + "Vault": "0xReservationVaultAddress1234567890abcdef12345678" + } + ], + "ExpectedAnchorProposal": null, + "ExpectedErr": "" +} diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_3.json b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_3.json new file mode 100644 index 0000000000..6da58123e9 --- /dev/null +++ b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_3.json @@ -0,0 +1,52 @@ +{ + "Title": "stale-deposit rejection - wallet state is Closing, not Live", + "ChainParameters": { + "AverageBlockTime": 12, + "CurrentBlock": 300000, + "DepositMinAge": 3600 + }, + "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", + "ReservationVault": "0xReservationVaultAddress1234567890abcdef12345678", + "Wallet": { + "State": "Closing" + }, + "ReservationParameters": { + "ReservationMinAmount": 100000, + "ReservationTxMaxFee": 5000, + "ReservationMaxTotalAmount": 100000000, + "ReservationTotalAmount": 0, + "MaxReservationsPerWallet": 5 + }, + "Caps": { + "MaxReservationsAmountPerWallet": 50000000, + "ReservationMaxSingleAmount": 5000000 + }, + "WalletCustody": { + "Count": 0, + "Amount": 0 + }, + "Global": { + "ActiveCount": 0, + "MaxActive": 100 + }, + "PendingReservedDeposits": 0, + "ReservedDeposits": [ + { + "FundingTxHash": "d1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f00", + "FundingOutputIndex": 0, + "FundingTxConfirmations": 6, + "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", + "Depositor": "934b98637ca318a4d6e7ca6ffd1690b8e77df637", + "BlindingFactor": "f9f0c90d00039526", + "RefundPublicKeyHash": "e257eccafbc07c381642ce6e7e55120fb077fbed", + "RefundLocktime": "e0250162", + "Amount": 2000000, + "RevealBlock": 290000, + "Age": 7200, + "SweptAt": 0, + "Vault": "0xReservationVaultAddress1234567890abcdef12345678" + } + ], + "ExpectedAnchorProposal": null, + "ExpectedErr": "" +} diff --git a/pkg/tbtcpg/reservation_acceptance.go b/pkg/tbtcpg/reservation_acceptance.go new file mode 100644 index 0000000000..e976d07a73 --- /dev/null +++ b/pkg/tbtcpg/reservation_acceptance.go @@ -0,0 +1,645 @@ +package tbtcpg + +import ( + "fmt" + "math/big" + "strings" + "time" + + "github.com/ipfs/go-log/v2" + "go.uber.org/zap" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/tbtc" +) + +// ReservationAcceptanceLookBackBlocks is the look-back period in blocks used +// when searching for reservation candidate deposits. It mirrors the deposit +// sweep look-back window: 30 days at 12 seconds per block. +const ReservationAcceptanceLookBackBlocks = uint64(216000) + +// reservationAnchorFeeSat is the deterministic satoshi fee the operator +// charges for the 1-input-1-output anchor transaction. The transaction +// shape is fixed (one P2SH deposit input, one P2WPKH anchor output) so a +// constant estimate is appropriate. Operators can override this through +// governance if the network fee environment drifts. +const reservationAnchorFeeSat int64 = 1500 + +// ReservationAcceptanceTask is a task that may produce a reservation +// acceptance (anchor) proposal. It scans the chain for reserved deposits +// revealed to the operator's wallet, validates the wallet's eligibility +// against the active reservation caps, and emits a proposal whose resulting +// transaction is a 1-input-1-output anchor that disables the deposit's +// refund path. +type ReservationAcceptanceTask struct { + chain Chain + btcChain bitcoin.Chain +} + +// NewReservationAcceptanceTask constructs a ReservationAcceptanceTask. +func NewReservationAcceptanceTask( + chain Chain, + btcChain bitcoin.Chain, +) *ReservationAcceptanceTask { + return &ReservationAcceptanceTask{ + chain: chain, + btcChain: btcChain, + } +} + +// Run inspects the chain for an acceptance candidate reserved deposit and, +// if one passes the eligibility gate, returns the resulting anchor proposal. +// The task is a no-op (proposal == nil, shouldExecute == false) when no +// candidate exists. +func (rat *ReservationAcceptanceTask) Run(request *tbtc.CoordinationProposalRequest) ( + tbtc.CoordinationProposal, + bool, + error, +) { + walletPublicKeyHash := request.WalletPublicKeyHash + + taskLogger := logger.With( + zap.String("task", rat.ActionType().String()), + zap.String("walletPKH", fmt.Sprintf("0x%x", walletPublicKeyHash)), + ) + + candidate, err := rat.findReservationAcceptanceCandidate( + taskLogger, + walletPublicKeyHash, + ) + if err != nil { + return nil, false, fmt.Errorf( + "cannot find reservation acceptance candidate: [%w]", + err, + ) + } + if candidate == nil { + taskLogger.Info("no reservation acceptance candidate") + return nil, false, nil + } + + proposal, err := rat.proposeReservationAcceptance( + taskLogger, + walletPublicKeyHash, + candidate, + ) + if err != nil { + return nil, false, fmt.Errorf( + "cannot prepare reservation acceptance proposal: [%w]", + err, + ) + } + + return proposal, true, nil +} + +// ActionType returns the wallet action type this task proposes. +func (rat *ReservationAcceptanceTask) ActionType() tbtc.WalletActionType { + return tbtc.ActionReservationAnchor +} + +// reservationAcceptanceCandidate is the bundle a candidate reserved deposit +// for acceptance carries through the proposal builder. It captures the +// deposit's reveal context plus the on-chain cap snapshot taken at scan time. +type reservationAcceptanceCandidate struct { + Deposit *tbtc.Deposit + FundingTx *bitcoin.Transaction + RevealBlock uint64 + ReservationParameters *tbtc.ReservationParameters + WalletCap uint64 + SingleCap uint64 + ActiveCount uint32 + MaxActive uint32 + MaxPerWallet uint32 + PendingReserved uint64 + TxMaxFee uint64 +} + +// findReservationAcceptanceCandidate returns the first reserved deposit +// that the operator's wallet may accept, or nil when none qualifies. The +// function performs the look-back bounded scan over past +// DepositRevealedEvents, fetches each candidate's chain request to determine +// whether it is reserved, and applies the eligibility gate. +func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( + taskLogger log.StandardLogger, + walletPublicKeyHash [20]byte, +) (*reservationAcceptanceCandidate, error) { + if walletPublicKeyHash == [20]byte{} { + return nil, fmt.Errorf("wallet public key hash is required") + } + + reservationParameters, err := rat.chain.ReservationParameters() + if err != nil { + return nil, fmt.Errorf( + "failed to get reservation parameters: [%w]", + err, + ) + } + reservationVault := reservationParameters.ReservationVault + if reservationVault == "" { + // Reservation subsystem not active; no acceptance candidates. + taskLogger.Info("reservation vault not configured") + return nil, nil + } + + maxReservationsAmountPerWallet, reservationMaxSingleAmount, err := + rat.chain.ReservationCaps() + if err != nil { + return nil, fmt.Errorf( + "failed to get reservation caps: [%w]", + err, + ) + } + + walletReservationsCount, err := rat.chain.WalletReservationsCount( + walletPublicKeyHash, + ) + if err != nil { + return nil, fmt.Errorf( + "failed to get wallet reservations count: [%w]", + err, + ) + } + + walletReservationsAmount, err := rat.chain.WalletReservationsAmount( + walletPublicKeyHash, + ) + if err != nil { + return nil, fmt.Errorf( + "failed to get wallet reservations amount: [%w]", + err, + ) + } + + activeReservationsCount, maxActiveReservations, err := + rat.chain.ActiveReservationsCount() + if err != nil { + return nil, fmt.Errorf( + "failed to get active reservations count: [%w]", + err, + ) + } + + pendingReservedDeposits, err := rat.chain.PendingReservedDeposits() + if err != nil { + return nil, fmt.Errorf( + "failed to get pending reserved deposits count: [%w]", + err, + ) + } + + blockCounter, err := rat.chain.BlockCounter() + if err != nil { + return nil, fmt.Errorf("failed to get block counter: [%w]", err) + } + currentBlock, err := blockCounter.CurrentBlock() + if err != nil { + return nil, fmt.Errorf( + "failed to get current block: [%w]", + err, + ) + } + + filterStartBlock := uint64(0) + if currentBlock > ReservationAcceptanceLookBackBlocks { + filterStartBlock = currentBlock - ReservationAcceptanceLookBackBlocks + } + + filter := &tbtc.DepositRevealedEventFilter{ + StartBlock: filterStartBlock, + WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + } + + revealedEvents, err := rat.chain.PastDepositRevealedEvents(filter) + if err != nil { + return nil, fmt.Errorf( + "failed to get past deposit revealed events: [%w]", + err, + ) + } + + depositMinAgeSeconds, err := rat.chain.GetDepositMinAge() + if err != nil { + return nil, fmt.Errorf( + "failed to get deposit minimum age: [%w]", + err, + ) + } + depositMinAge := time.Duration(depositMinAgeSeconds) * time.Second + + now := time.Now() + + for _, event := range revealedEvents { + depositKey := rat.chain.BuildDepositKey( + event.FundingTxHash, + event.FundingOutputIndex, + ) + + isReserved, err := rat.chain.IsReservedDeposit(depositKey) + if err != nil { + taskLogger.Errorf( + "failed to check if deposit [%v] is reserved: [%v]", + depositKey, + err, + ) + continue + } + if !isReserved { + taskLogger.Infof("not reserved deposit [%v]", depositKey) + continue + } + + depositRequest, found, err := rat.chain.GetDepositRequest( + event.FundingTxHash, + event.FundingOutputIndex, + ) + if err != nil { + taskLogger.Errorf( + "failed to get deposit request for [%v]: [%v]", + depositKey, + err, + ) + continue + } + if !found { + taskLogger.Warnf( + "no deposit request for reserved deposit [%v]", + depositKey, + ) + continue + } + + matureAt := depositRequest.RevealedAt.Add(depositMinAge) + if !now.After(matureAt) { + taskLogger.Infof( + "reserved deposit [%v] is not old enough: now=%v, matureAt=%v", + depositKey, + now, matureAt, + ) + continue + } + + if depositRequest.SweptAt.Unix() != 0 { + taskLogger.Debugf( + "reserved deposit [%v] is already swept", + depositKey, + ) + continue + } + + if !depositTargetsReservationVault( + depositRequest.Vault, + reservationVault, + ) { + taskLogger.Debugf( + "reserved deposit [%v] vault does not match "+ + "the active reservation vault", + depositKey, + ) + continue + } + + candidate := &reservationAcceptanceCandidate{ + RevealBlock: event.BlockNumber, + ReservationParameters: reservationParameters, + WalletCap: maxReservationsAmountPerWallet, + SingleCap: reservationMaxSingleAmount, + ActiveCount: activeReservationsCount, + MaxActive: maxActiveReservations, + MaxPerWallet: reservationParameters.MaxReservationsPerWallet, + PendingReserved: pendingReservedDeposits, + TxMaxFee: reservationParameters.ReservationTxMaxFee, + } + + if !rat.checkReservationAcceptanceEligibility( + taskLogger, + walletPublicKeyHash, + depositRequest, + walletReservationsCount, + walletReservationsAmount, + activeReservationsCount, + maxActiveReservations, + pendingReservedDeposits, + maxReservationsAmountPerWallet, + reservationMaxSingleAmount, + reservationParameters, + ) { + taskLogger.Infof("not eligible: [%v]", depositKey) + continue + } + + fundingTx, err := rat.btcChain.GetTransaction(event.FundingTxHash) + if err != nil { + taskLogger.Errorf( + "failed to get funding tx for reserved deposit [%v]: [%v]", + depositKey, + err, + ) + continue + } + + confirmations, err := rat.btcChain.GetTransactionConfirmations( + event.FundingTxHash, + ) + if err != nil { + taskLogger.Errorf( + "failed to get funding tx confirmations for [%v]: [%v]", + depositKey, + err, + ) + continue + } + if confirmations < tbtc.DepositSweepRequiredFundingTxConfirmations { + taskLogger.Debugf( + "reserved deposit [%v] funding tx confirmations [%d/%d] below required", + depositKey, + confirmations, + tbtc.DepositSweepRequiredFundingTxConfirmations, + ) + continue + } + + candidate.Deposit = &tbtc.Deposit{ + Utxo: &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: event.FundingTxHash, + OutputIndex: event.FundingOutputIndex, + }, + Value: int64(depositRequest.Amount), + }, + Depositor: depositRequest.Depositor, + WalletPublicKeyHash: event.WalletPublicKeyHash, + Vault: depositRequest.Vault, + } + candidate.FundingTx = fundingTx + + taskLogger.Infof( + "selected reserved deposit [%v] for acceptance", + depositKey, + ) + + return candidate, nil + } + + return nil, nil +} + +// checkReservationAcceptanceEligibility returns true iff the wallet may +// accept a new reserved deposit given the current cap snapshot. The +// predicate is intentionally strict: a single failing rule rejects the +// candidate so the wallet never publishes a proposal that the Bridge would +// reject. +func (rat *ReservationAcceptanceTask) checkReservationAcceptanceEligibility( + taskLogger log.StandardLogger, + walletPublicKeyHash [20]byte, + depositRequest *tbtc.DepositChainRequest, + walletReservationsCount uint32, + walletReservationsAmount uint64, + activeReservationsCount uint32, + maxActiveReservations uint32, + pendingReservedDeposits uint64, + maxReservationsAmountPerWallet uint64, + reservationMaxSingleAmount uint64, + reservationParameters *tbtc.ReservationParameters, +) bool { + wallet, err := rat.chain.GetWallet(walletPublicKeyHash) + if err != nil { + taskLogger.Errorf( + "failed to load wallet chain data: [%v]", + err, + ) + return false + } + if wallet.State != tbtc.StateLive { + taskLogger.Infof( + "wallet is not live (state=%v); cannot accept reservation", + wallet.State, + ) + return false + } + + if walletReservationsCount >= reservationParameters.MaxReservationsPerWallet { + taskLogger.Infof( + "wallet reservations count [%d] already at max [%d]", + walletReservationsCount, + reservationParameters.MaxReservationsPerWallet, + ) + return false + } + + if maxActiveReservations > 0 && + activeReservationsCount >= maxActiveReservations { + taskLogger.Infof( + "active reservations count [%d] already at max [%d]", + activeReservationsCount, + maxActiveReservations, + ) + return false + } + + if depositRequest.Amount < reservationParameters.ReservationMinAmount { + taskLogger.Infof( + "deposit amount [%d] below reservation min [%d]", + depositRequest.Amount, + reservationParameters.ReservationMinAmount, + ) + return false + } + + if reservationMaxSingleAmount > 0 && + depositRequest.Amount > reservationMaxSingleAmount { + taskLogger.Infof( + "deposit amount [%d] exceeds reservation single cap [%d]", + depositRequest.Amount, + reservationMaxSingleAmount, + ) + return false + } + + newWalletTotal := walletReservationsAmount + depositRequest.Amount + if maxReservationsAmountPerWallet > 0 && + newWalletTotal > maxReservationsAmountPerWallet { + taskLogger.Infof( + "accepting would push wallet past aggregate cap "+ + "[current=%d, deposit=%d, cap=%d]", + walletReservationsAmount, + depositRequest.Amount, + maxReservationsAmountPerWallet, + ) + return false + } + + if reservationParameters.ReservationMaxTotalAmount > 0 { + newTotal := reservationParameters.ReservationTotalAmount + + depositRequest.Amount + if newTotal > reservationParameters.ReservationMaxTotalAmount { + taskLogger.Infof( + "global reservation total would exceed cap "+ + "[current=%d, deposit=%d, cap=%d]", + reservationParameters.ReservationTotalAmount, + depositRequest.Amount, + reservationParameters.ReservationMaxTotalAmount, + ) + return false + } + } + + if pendingReservedDeposits > 0 && + reservationParameters.ReservationTotalAmount+ + depositRequest.Amount > + reservationParameters.ReservationMaxTotalAmount { + taskLogger.Infof( + "pending reserved deposits queue [%d] would push global total "+ + "past cap; deferring", + pendingReservedDeposits, + ) + return false + } + + return true +} + +// proposeReservationAcceptance assembles the anchor transaction for the +// candidate reserved deposit and returns the on-chain proposal. +func (rat *ReservationAcceptanceTask) proposeReservationAcceptance( + taskLogger log.StandardLogger, + walletPublicKeyHash [20]byte, + candidate *reservationAcceptanceCandidate, +) (*tbtc.ReservationAnchorProposal, error) { + if candidate == nil || candidate.Deposit == nil { + return nil, fmt.Errorf("candidate is required") + } + + taskLogger.Infof("preparing a reservation acceptance proposal") + + anchorFee := reservationAnchorFeeSat + + anchorValue := candidate.Deposit.Utxo.Value - anchorFee + if anchorValue <= 0 { + return nil, fmt.Errorf( + "deposit value [%d] does not cover anchor fee [%d]", + candidate.Deposit.Utxo.Value, + anchorFee, + ) + } + + if uint64(anchorFee) > candidate.TxMaxFee { + return nil, fmt.Errorf( + "anchor fee [%d] exceeds the configured max [%d]", + anchorFee, + candidate.TxMaxFee, + ) + } + + taskLogger.Infof("anchor transaction fee: [%d]", anchorFee) + + if _, err := buildReservationAnchorTransaction( + rat.btcChain, + candidate.Deposit, + walletPublicKeyHash, + anchorFee, + ); err != nil { + return nil, fmt.Errorf( + "cannot assemble reservation anchor transaction: [%v]", + err, + ) + } + + proposal := &tbtc.ReservationAnchorProposal{ + DepositFundingTxHash: candidate.Deposit.Utxo.Outpoint.TransactionHash, + DepositFundingOutputIndex: candidate.Deposit.Utxo.Outpoint.OutputIndex, + AnchorTxFee: big.NewInt(anchorFee), + } + + taskLogger.Infof("validating the reservation anchor proposal") + + if err := rat.chain.ValidateReservationAnchorProposal( + walletPublicKeyHash, + proposal, + struct { + *tbtc.Deposit + FundingTx *bitcoin.Transaction + }{ + Deposit: candidate.Deposit, + FundingTx: candidate.FundingTx, + }, + ); err != nil { + return nil, fmt.Errorf( + "failed to verify reservation anchor proposal: %v", + err, + ) + } + + return proposal, nil +} + +// buildReservationAnchorTransaction constructs the unsigned reservation +// anchor transaction: a 1-input-1-output spend of the reserved deposit +// into a fresh output controlled by the given wallet. Mirrors the private +// helper in pkg/tbtc/reservation.go; the tbtcpg package cannot call the +// helper directly because it lives in a different package, so the assembly +// logic is duplicated here. Any change to the anchor transaction shape +// must be applied to both sites. +func buildReservationAnchorTransaction( + bitcoinChain bitcoin.Chain, + deposit *tbtc.Deposit, + walletPublicKeyHash [20]byte, + fee int64, +) (*bitcoin.TransactionBuilder, error) { + if deposit == nil { + return nil, fmt.Errorf("deposit is required") + } + + builder := bitcoin.NewTransactionBuilder(bitcoinChain) + + depositScript, err := deposit.Script() + if err != nil { + return nil, fmt.Errorf("cannot get deposit script: [%v]", err) + } + + err = builder.AddScriptHashInput(deposit.Utxo, depositScript) + if err != nil { + return nil, fmt.Errorf( + "cannot add input pointing to deposit UTXO: [%v]", + err, + ) + } + + anchorValue := deposit.Utxo.Value - fee + if anchorValue <= 0 { + return nil, fmt.Errorf( + "transaction fee exceeds the deposit value", + ) + } + + anchorScript, err := bitcoin.PayToWitnessPublicKeyHash( + walletPublicKeyHash, + ) + if err != nil { + return nil, fmt.Errorf("cannot compute anchor script: [%v]", err) + } + + builder.AddOutput(&bitcoin.TransactionOutput{ + Value: anchorValue, + PublicKeyScript: anchorScript, + }) + + return builder, nil +} + +// depositTargetsReservationVault returns true iff the deposit's vault field +// (nil when not set, or pointer to an address) matches the configured +// reservation vault. Address comparison is case-insensitive. +func depositTargetsReservationVault( + depositVault *chain.Address, + reservationVault chain.Address, +) bool { + if depositVault == nil { + return false + } + return strings.EqualFold( + string(*depositVault), + string(reservationVault), + ) +} diff --git a/pkg/tbtcpg/reservation_acceptance_test.go b/pkg/tbtcpg/reservation_acceptance_test.go new file mode 100644 index 0000000000..76f5c48963 --- /dev/null +++ b/pkg/tbtcpg/reservation_acceptance_test.go @@ -0,0 +1,669 @@ +package tbtcpg_test + +import ( + "fmt" + "math/big" + "testing" + "time" + + "github.com/go-test/deep" + "github.com/ipfs/go-log/v2" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/tbtc" + "github.com/keep-network/keep-core/pkg/tbtcpg" + "github.com/keep-network/keep-core/pkg/tbtcpg/internal/test" +) + +// reservationAcceptanceLocalChain is a test-only mock of tbtcpg.Chain that +// embeds the production LocalChain and adds reservation-specific behavior. +// It exists as a separate type so this test file does not need to edit the +// shared chain_test.go fixture used by sibling builders. +type reservationAcceptanceLocalChain struct { + *tbtcpg.LocalChain + + reservationParameters *tbtc.ReservationParameters + maxPerWalletAmount uint64 + maxSingleAmount uint64 + walletReservationsAmount uint64 + walletReservationsCount uint32 + activeCount uint32 + maxActive uint32 + pendingReserved uint64 + reservedDeposits map[string]bool + validateErr error +} + +func newReservationAcceptanceLocalChain() *reservationAcceptanceLocalChain { + lc := tbtcpg.NewLocalChain() + return &reservationAcceptanceLocalChain{ + LocalChain: lc, + reservedDeposits: make(map[string]bool), + } +} + +// PastDepositRevealedEvents overrides the embedded LocalChain +// implementation to return an empty slice (rather than an error) when no +// events are registered for the filter. A real chain returns an empty +// event list when no deposits match; the in-memory mock's panic-stub +// "no events for given filter" error is a fixture bug that this override +// papers over without touching shared test infrastructure. +func (ralc *reservationAcceptanceLocalChain) PastDepositRevealedEvents( + filter *tbtc.DepositRevealedEventFilter, +) ([]*tbtc.DepositRevealedEvent, error) { + events, err := ralc.LocalChain.PastDepositRevealedEvents(filter) + if err != nil { + return []*tbtc.DepositRevealedEvent{}, nil + } + return events, nil +} + +func (ralc *reservationAcceptanceLocalChain) ReservationParameters() ( + *tbtc.ReservationParameters, + error, +) { + return ralc.reservationParameters, nil +} + +func (ralc *reservationAcceptanceLocalChain) ReservationCaps() ( + uint64, + uint64, + error, +) { + return ralc.maxPerWalletAmount, ralc.maxSingleAmount, nil +} + +func (ralc *reservationAcceptanceLocalChain) WalletReservationsAmount( + walletPublicKeyHash [20]byte, +) (uint64, error) { + return ralc.walletReservationsAmount, nil +} + +func (ralc *reservationAcceptanceLocalChain) WalletReservationsCount( + walletPublicKeyHash [20]byte, +) (uint32, error) { + return ralc.walletReservationsCount, nil +} + +func (ralc *reservationAcceptanceLocalChain) ActiveReservationsCount() ( + uint32, + uint32, + error, +) { + return ralc.activeCount, ralc.maxActive, nil +} + +func (ralc *reservationAcceptanceLocalChain) PendingReservedDeposits() ( + uint64, + error, +) { + return ralc.pendingReserved, nil +} + +func (ralc *reservationAcceptanceLocalChain) IsReservedDeposit( + depositKey *big.Int, +) (bool, error) { + if depositKey == nil { + return false, nil + } + return ralc.reservedDeposits[depositKey.Text(16)], nil +} + +func (ralc *reservationAcceptanceLocalChain) ValidateReservationAnchorProposal( + walletPublicKeyHash [20]byte, + proposal *tbtc.ReservationAnchorProposal, + depositExtraInfo struct { + *tbtc.Deposit + FundingTx *bitcoin.Transaction + }, +) error { + return ralc.validateErr +} + +// scenarioReservationAcceptanceChain wires a scenario's on-chain state +// into the test mock chain. +func scenarioReservationAcceptanceChain( + t *testing.T, + scenario *test.ReservationAcceptanceTestScenario, +) *reservationAcceptanceLocalChain { + t.Helper() + + ralc := newReservationAcceptanceLocalChain() + + var reservationVault chain.Address + if len(scenario.ReservationVault) > 0 { + reservationVault = chain.Address(scenario.ReservationVault) + } + + ralc.reservationParameters = &tbtc.ReservationParameters{ + ReservationVault: reservationVault, + ReservationMinAmount: scenario.ReservationParameters.ReservationMinAmount, + ReservationTxMaxFee: scenario.ReservationParameters.ReservationTxMaxFee, + ReservationMaxTotalAmount: scenario.ReservationParameters.ReservationMaxTotalAmount, + ReservationTotalAmount: scenario.ReservationParameters.ReservationTotalAmount, + MaxReservationsPerWallet: scenario.ReservationParameters.MaxReservationsPerWallet, + } + + ralc.maxPerWalletAmount = scenario.Caps.MaxReservationsAmountPerWallet + ralc.maxSingleAmount = scenario.Caps.ReservationMaxSingleAmount + ralc.walletReservationsAmount = scenario.WalletCustody.Amount + ralc.walletReservationsCount = scenario.WalletCustody.Count + ralc.activeCount = scenario.Global.ActiveCount + ralc.maxActive = scenario.Global.MaxActive + ralc.pendingReserved = scenario.PendingReservedDeposits + + ralc.SetDepositMinAge(scenario.ChainParameters.DepositMinAge) + + blockCounter := tbtcpg.NewMockBlockCounter() + blockCounter.SetCurrentBlock(scenario.ChainParameters.CurrentBlock) + ralc.SetBlockCounter(blockCounter) + + // Map a WalletState string back to the tbtc constant. + var walletState tbtc.WalletState + switch scenario.WalletState { + case "Live": + walletState = tbtc.StateLive + case "Closing": + walletState = tbtc.StateClosing + case "Closed": + walletState = tbtc.StateClosed + case "Terminated": + walletState = tbtc.StateTerminated + default: + walletState = tbtc.StateLive + } + + ralc.SetWallet( + scenario.WalletPublicKeyHash, + &tbtc.WalletChainData{State: walletState}, + ) + + return ralc +} + +// registerReservedDeposits wires the scenario's reserved deposits into the +// mock chain as deposit requests and past DepositRevealedEvents. It also +// marks them as reserved via IsReservedDeposit. Bitcoin transaction +// registrations live on the btcChain mock. +func registerReservedDeposits( + t *testing.T, + scenario *test.ReservationAcceptanceTestScenario, + ralc *reservationAcceptanceLocalChain, + btcChain *tbtcpg.LocalBitcoinChain, +) { + t.Helper() + + filterStartBlock := uint64(0) + if scenario.ChainParameters.CurrentBlock > tbtcpg.ReservationAcceptanceLookBackBlocks { + filterStartBlock = scenario.ChainParameters.CurrentBlock - + tbtcpg.ReservationAcceptanceLookBackBlocks + } + + for _, rd := range scenario.ReservedDeposits { + materialized, err := rd.Materialize() + if err != nil { + t.Fatalf( + "failed to materialize reserved deposit scenario row: [%v]", + err, + ) + } + + ralc.SetDepositRequest( + materialized.FundingTxHash, + materialized.FundingOutputIndex, + &tbtc.DepositChainRequest{ + Depositor: chain.Address(rd.Depositor), + Amount: rd.Amount, + RevealedAt: materialized.RevealedAt, + SweptAt: materialized.SweptAt, + Vault: materialized.Vault, + }, + ) + + if materialized.FundingTx != nil { + btcChain.SetTransaction( + materialized.FundingTxHash, + materialized.FundingTx, + ) + } else { + dummyTx := &bitcoin.Transaction{ + Outputs: []*bitcoin.TransactionOutput{{ + Value: 0, + PublicKeyScript: append([]byte{0x00, 0x20}, make([]byte, 32)...), + }}, + } + btcChain.SetTransaction( + materialized.FundingTxHash, + dummyTx, + ) + } + btcChain.SetTransactionConfirmations( + materialized.FundingTxHash, + rd.FundingTxConfirmations, + ) + + err = ralc.AddPastDepositRevealedEvent( + &tbtc.DepositRevealedEventFilter{ + StartBlock: filterStartBlock, + WalletPublicKeyHash: [][20]byte{materialized.WalletPublicKeyHash}, + }, + &tbtc.DepositRevealedEvent{ + BlockNumber: materialized.RevealBlock, + WalletPublicKeyHash: materialized.WalletPublicKeyHash, + FundingTxHash: materialized.FundingTxHash, + FundingOutputIndex: materialized.FundingOutputIndex, + }, + ) + if err != nil { + t.Fatalf( + "failed to register past deposit revealed event: [%v]", + err, + ) + } + + depositKey := ralc.BuildDepositKey( + materialized.FundingTxHash, + materialized.FundingOutputIndex, + ) + ralc.reservedDeposits[depositKey.Text(16)] = true + } +} + +// expectedAnchorsEqual compares two proposal objects field-by-field. +func expectedAnchorsEqual( + expected, actual *tbtc.ReservationAnchorProposal, +) bool { + if expected == nil && actual == nil { + return true + } + if expected == nil || actual == nil { + return false + } + + if expected.DepositFundingTxHash != actual.DepositFundingTxHash { + return false + } + if expected.DepositFundingOutputIndex != actual.DepositFundingOutputIndex { + return false + } + if expected.AnchorTxFee == nil || actual.AnchorTxFee == nil { + return expected.AnchorTxFee == actual.AnchorTxFee + } + return expected.AnchorTxFee.Cmp(actual.AnchorTxFee) == 0 +} + +func TestReservationAcceptanceLookBackBlocks(t *testing.T) { + expectedValue := uint64(216000) + + if tbtcpg.ReservationAcceptanceLookBackBlocks != expectedValue { + t.Errorf( + "unexpected ReservationAcceptanceLookBackBlocks\n"+ + "expected: %d\n"+ + "actual: %d", + expectedValue, + tbtcpg.ReservationAcceptanceLookBackBlocks, + ) + } +} + +func TestReservationAcceptanceTask_ActionType(t *testing.T) { + task := tbtcpg.NewReservationAcceptanceTask( + newReservationAcceptanceLocalChain(), + tbtcpg.NewLocalBitcoinChain(), + ) + if task.ActionType() != tbtc.ActionReservationAnchor { + t.Errorf( + "unexpected action type\n"+ + "expected: %v\n"+ + "actual: %v", + tbtc.ActionReservationAnchor, + task.ActionType(), + ) + } +} + +func TestReservationAcceptanceTask_Run(t *testing.T) { + if err := log.SetLogLevel("*", "DEBUG"); err != nil { + t.Fatal(err) + } + + scenarios, err := test.LoadReservationAcceptanceTestScenario() + if err != nil { + t.Fatal(err) + } + + for _, scenario := range scenarios { + t.Run(scenario.Title, func(t *testing.T) { + ralc := scenarioReservationAcceptanceChain(t, scenario) + btcChain := tbtcpg.NewLocalBitcoinChain() + registerReservedDeposits(t, scenario, ralc, btcChain) + + task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + + request := &tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: scenario.WalletPublicKeyHash, + } + + proposal, shouldExecute, err := task.Run(request) + if err != nil { + if scenario.ExpectedErr == nil { + t.Fatalf("unexpected error: [%v]", err) + } + if scenario.ExpectedErr.Error() != err.Error() { + t.Fatalf( + "unexpected error message\n"+ + "expected: [%v]\n"+ + "actual: [%v]", + scenario.ExpectedErr, + err, + ) + } + return + } + if scenario.ExpectedErr != nil { + t.Fatalf("expected error [%v], got nil", scenario.ExpectedErr) + } + + expectedProposal := scenario.ExpectedAnchorProposal + + if expectedProposal == nil { + if shouldExecute { + t.Errorf( + "unexpected proposal returned when none expected", + ) + } + if proposal != nil { + t.Errorf( + "expected nil proposal, got [%+v]", + proposal, + ) + } + return + } + + if !shouldExecute { + t.Errorf("expected shouldExecute=true, got false") + } + if proposal == nil { + t.Fatal("expected proposal, got nil") + } + + actualProposal, ok := proposal.(*tbtc.ReservationAnchorProposal) + if !ok { + t.Fatalf("expected *ReservationAnchorProposal, got %T", proposal) + } + + if !expectedAnchorsEqual(expectedProposal, actualProposal) { + if diff := deep.Equal( + []*tbtc.ReservationAnchorProposal{expectedProposal}, + []*tbtc.ReservationAnchorProposal{actualProposal}, + ); diff != nil { + t.Errorf("invalid anchor proposal: %v", diff) + } + } + }) + } +} + +// TestReservationAcceptanceTask_NoCandidates verifies that the task is a +// no-op when the chain has no reserved deposits. +func TestReservationAcceptanceTask_NoCandidates(t *testing.T) { + ralc := newReservationAcceptanceLocalChain() + btcChain := tbtcpg.NewLocalBitcoinChain() + + walletPublicKeyHash := hexToByte20( + "8db50eb52063ea9d98b3eac91489a90f738986f6", + ) + + ralc.reservationParameters = &tbtc.ReservationParameters{ + ReservationVault: chain.Address( + "0xReservationVaultAddress1234567890abcdef12345678", + ), + } + ralc.maxPerWalletAmount = 1000000 + ralc.maxSingleAmount = 5000000 + ralc.maxActive = 100 + + ralc.SetDepositMinAge(3600) + ralc.SetWallet( + walletPublicKeyHash, + &tbtc.WalletChainData{State: tbtc.StateLive}, + ) + + blockCounter := tbtcpg.NewMockBlockCounter() + blockCounter.SetCurrentBlock(300000) + ralc.SetBlockCounter(blockCounter) + + task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + + request := &tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + } + + proposal, shouldExecute, err := task.Run(request) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + if shouldExecute { + t.Errorf("expected shouldExecute=false, got true") + } + if proposal != nil { + t.Errorf("expected nil proposal, got [%+v]", proposal) + } +} + +// TestReservationAcceptanceTask_BoundedLookback verifies that the bounded +// look-back window is applied when the current block exceeds it. +func TestReservationAcceptanceTask_BoundedLookback(t *testing.T) { + currentBlock := uint64(400000) + expectedStartBlock := currentBlock - + tbtcpg.ReservationAcceptanceLookBackBlocks + + ralc := newReservationAcceptanceLocalChain() + btcChain := tbtcpg.NewLocalBitcoinChain() + + walletPublicKeyHash := hexToByte20( + "8db50eb52063ea9d98b3eac91489a90f738986f6", + ) + + ralc.reservationParameters = &tbtc.ReservationParameters{ + ReservationVault: chain.Address( + "0xReservationVaultAddress1234567890abcdef12345678", + ), + ReservationMinAmount: 1000, + ReservationTxMaxFee: 5000, + MaxReservationsPerWallet: 5, + } + ralc.maxPerWalletAmount = 5000000 + ralc.maxSingleAmount = 5000000 + ralc.maxActive = 100 + + ralc.SetDepositMinAge(3600) + ralc.SetWallet( + walletPublicKeyHash, + &tbtc.WalletChainData{State: tbtc.StateLive}, + ) + + blockCounter := tbtcpg.NewMockBlockCounter() + blockCounter.SetCurrentBlock(currentBlock) + ralc.SetBlockCounter(blockCounter) + + // Event below the look-back start block must NOT be returned. + oldFundingTxHash := hashFromString( + "1111111111111111111111111111111111111111111111111111111111111111", + ) + if err := ralc.AddPastDepositRevealedEvent( + &tbtc.DepositRevealedEventFilter{ + StartBlock: 0, + WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + }, + &tbtc.DepositRevealedEvent{ + BlockNumber: 1, + WalletPublicKeyHash: walletPublicKeyHash, + FundingTxHash: oldFundingTxHash, + FundingOutputIndex: 0, + }, + ); err != nil { + t.Fatal(err) + } + + // Event at the look-back start block must be returned. Mark it as + // reserved and provide a deposit request. + fundingTxHash := hashFromString( + "2222222222222222222222222222222222222222222222222222222222222222", + ) + dummyTx := &bitcoin.Transaction{ + Outputs: []*bitcoin.TransactionOutput{{ + Value: 0, + PublicKeyScript: append([]byte{0x00, 0x20}, make([]byte, 32)...), + }}, + } + btcChain.SetTransaction(fundingTxHash, dummyTx) + btcChain.SetTransactionConfirmations( + fundingTxHash, + tbtc.DepositSweepRequiredFundingTxConfirmations, + ) + ralc.SetDepositRequest( + fundingTxHash, + 0, + &tbtc.DepositChainRequest{ + Depositor: chain.Address("934b98637ca318a4d6e7ca6ffd1690b8e77df637"), + Amount: 2000000, + RevealedAt: time.Now().Add(-2 * time.Hour), + SweptAt: time.Unix(0, 0), + Vault: &[]chain.Address{chain.Address( + "0xReservationVaultAddress1234567890abcdef12345678", + )}[0], + }, + ) + depositKey := ralc.BuildDepositKey(fundingTxHash, 0) + ralc.reservedDeposits[depositKey.Text(16)] = true + + if err := ralc.AddPastDepositRevealedEvent( + &tbtc.DepositRevealedEventFilter{ + StartBlock: expectedStartBlock, + WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + }, + &tbtc.DepositRevealedEvent{ + BlockNumber: expectedStartBlock, + WalletPublicKeyHash: walletPublicKeyHash, + FundingTxHash: fundingTxHash, + FundingOutputIndex: 0, + }, + ); err != nil { + t.Fatal(err) + } + + task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + + request := &tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + } + + proposal, shouldExecute, err := task.Run(request) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + if !shouldExecute { + t.Fatalf("expected shouldExecute=true, got false") + } + if proposal == nil { + t.Fatalf("expected proposal, got nil") + } + actualProposal, ok := proposal.(*tbtc.ReservationAnchorProposal) + if !ok { + t.Fatalf("expected *ReservationAnchorProposal, got %T", proposal) + } + if actualProposal.DepositFundingTxHash != fundingTxHash { + t.Errorf( + "unexpected deposit funding tx hash\n"+ + "expected: %s\n"+ + "actual: %s", + fundingTxHash.Hex(bitcoin.ReversedByteOrder), + actualProposal.DepositFundingTxHash.Hex( + bitcoin.ReversedByteOrder, + ), + ) + } +} + +// TestReservationAcceptanceTask_DepositNotReserved confirms that a deposit +// that fails IsReservedDeposit is filtered out. +func TestReservationAcceptanceTask_DepositNotReserved(t *testing.T) { + ralc := newReservationAcceptanceLocalChain() + btcChain := tbtcpg.NewLocalBitcoinChain() + + walletPublicKeyHash := hexToByte20( + "8db50eb52063ea9d98b3eac91489a90f738986f6", + ) + + ralc.reservationParameters = &tbtc.ReservationParameters{ + ReservationVault: chain.Address( + "0xReservationVaultAddress1234567890abcdef12345678", + ), + } + ralc.maxPerWalletAmount = 5000000 + ralc.maxSingleAmount = 5000000 + ralc.maxActive = 100 + + ralc.SetDepositMinAge(3600) + ralc.SetWallet( + walletPublicKeyHash, + &tbtc.WalletChainData{State: tbtc.StateLive}, + ) + + blockCounter := tbtcpg.NewMockBlockCounter() + blockCounter.SetCurrentBlock(300000) + ralc.SetBlockCounter(blockCounter) + + fundingTxHash := hashFromString( + "3333333333333333333333333333333333333333333333333333333333333333", + ) + btcChain.SetTransaction(fundingTxHash, &bitcoin.Transaction{}) + btcChain.SetTransactionConfirmations( + fundingTxHash, + tbtc.DepositSweepRequiredFundingTxConfirmations, + ) + ralc.SetDepositRequest( + fundingTxHash, + 0, + &tbtc.DepositChainRequest{ + Amount: 2000000, + RevealedAt: time.Now().Add(-2 * time.Hour), + }, + ) + if err := ralc.AddPastDepositRevealedEvent( + &tbtc.DepositRevealedEventFilter{ + StartBlock: 0, + WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + }, + &tbtc.DepositRevealedEvent{ + BlockNumber: 290000, + WalletPublicKeyHash: walletPublicKeyHash, + FundingTxHash: fundingTxHash, + FundingOutputIndex: 0, + }, + ); err != nil { + t.Fatal(err) + } + + task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + + request := &tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + } + + proposal, shouldExecute, err := task.Run(request) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + if shouldExecute { + t.Errorf("expected shouldExecute=false, got true") + } + if proposal != nil { + t.Errorf("expected nil proposal, got [%+v]", proposal) + } +} + +var _ = fmt.Sprintf From 603ad0d54254b11fee2c53d22725849fa753999e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 26 Aug 2026 10:38:17 +0000 Subject: [PATCH 12/39] feat(tbtc): implement reservation re-anchor executor --- pkg/maintainer/spv/chain_test.go | 85 +++- .../spv/reservation_reanchor_proof.go | 242 +++++++++ .../spv/reservation_reanchor_proof_test.go | 193 +++++++ pkg/tbtcpg/chain_test.go | 426 +++++++++++++--- pkg/tbtcpg/internal/test/marshaling.go | 185 +++++++ pkg/tbtcpg/internal/test/tbtcpgtest.go | 54 ++ .../reservation_reanchor_scenario_0.json | 34 ++ .../reservation_reanchor_scenario_1.json | 34 ++ .../reservation_reanchor_scenario_2.json | 29 ++ .../reservation_reanchor_scenario_3.json | 28 + pkg/tbtcpg/reservation_reanchor.go | 480 ++++++++++++++++++ pkg/tbtcpg/reservation_reanchor_test.go | 174 +++++++ 12 files changed, 1884 insertions(+), 80 deletions(-) create mode 100644 pkg/maintainer/spv/reservation_reanchor_proof.go create mode 100644 pkg/maintainer/spv/reservation_reanchor_proof_test.go create mode 100644 pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_0.json create mode 100644 pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_1.json create mode 100644 pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_2.json create mode 100644 pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_3.json create mode 100644 pkg/tbtcpg/reservation_reanchor.go create mode 100644 pkg/tbtcpg/reservation_reanchor_test.go diff --git a/pkg/maintainer/spv/chain_test.go b/pkg/maintainer/spv/chain_test.go index a07759c6aa..47214f0517 100644 --- a/pkg/maintainer/spv/chain_test.go +++ b/pkg/maintainer/spv/chain_test.go @@ -63,6 +63,16 @@ type localChain struct { currentEpoch uint64 currentEpochDifficulty *big.Int previousEpochDifficulty *big.Int + reservations map[string]*tbtc.Reservation + reservationActions map[string]*tbtc.ReservationAction + submitReservationProofHook func( + proofType uint8, + txInfo *tbtc.BitcoinTxInfo, + proof *tbtc.BitcoinTxProof, + mainUtxo *tbtc.BitcoinTxUTXO, + reservationKey *big.Int, + requestNonce uint64, + ) error } func newLocalChain() *localChain { @@ -734,6 +744,16 @@ func (lc *localChain) SubmitReservationProof( reservationKey *big.Int, requestNonce uint64, ) error { + if lc.submitReservationProofHook != nil { + return lc.submitReservationProofHook( + proofType, + txInfo, + proof, + mainUtxo, + reservationKey, + requestNonce, + ) + } panic("unsupported") } @@ -764,21 +784,37 @@ func (lc *localChain) NotifyReservationStranded(reservationKey *big.Int) error { panic("unsupported") } -// GetReservation is a stub matching the reservation additions on the -// production Chain interface. +// GetReservation returns the configured reservation record. func (lc *localChain) GetReservation( reservationKey *big.Int, ) (*tbtc.Reservation, error) { - panic("unsupported") + lc.mutex.Lock() + defer lc.mutex.Unlock() + + if lc.reservations != nil { + if reservation, ok := lc.reservations[reservationKey.String()]; ok { + return reservation, nil + } + } + return nil, fmt.Errorf("reservation not found") } -// GetReservationAction is a stub matching the reservation additions on -// the production Chain interface. +// GetReservationAction returns the configured reservation action record, or +// an error if not found. func (lc *localChain) GetReservationAction( reservationKey *big.Int, requestNonce uint64, ) (*tbtc.ReservationAction, error) { - panic("unsupported") + lc.mutex.Lock() + defer lc.mutex.Unlock() + + if lc.reservationActions != nil { + key := fmt.Sprintf("%s/%d", reservationKey.String(), requestNonce) + if action, ok := lc.reservationActions[key]; ok { + return action, nil + } + } + return nil, fmt.Errorf("reservation action not found") } // ReservationParameters is a stub matching the reservation additions on @@ -837,6 +873,43 @@ func (lc *localChain) PastReservationAcceptedEvents( return nil, nil } +// setReservation registers a reservation record on the local chain for tests. +func (lc *localChain) setReservation( + reservationKey *big.Int, + reservation *tbtc.Reservation, +) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + if lc.reservations == nil { + lc.reservations = make(map[string]*tbtc.Reservation) + } + lc.reservations[reservationKey.String()] = reservation +} + +// setReservationAction registers a reservation action record on the local +// chain for tests. +func (lc *localChain) setReservationAction( + reservationKey *big.Int, + requestNonce uint64, + action *tbtc.ReservationAction, +) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + if lc.reservationActions == nil { + lc.reservationActions = make(map[string]*tbtc.ReservationAction) + } + lc.reservationActions[fmt.Sprintf("%s/%d", reservationKey.String(), requestNonce)] = action +} + +// submitReservationProofHook, when non-nil, overrides the default panic +// stub and gives the test full control over SubmitReservationProof behavior. +var _ = func() bool { + _ = bytes.Equal + return true +}() + func (lc *localChain) PastReservationReanchoredEvents( filter *tbtc.ReservationReanchoredEventFilter, ) ([]*tbtc.ReservationReanchoredEvent, error) { diff --git a/pkg/maintainer/spv/reservation_reanchor_proof.go b/pkg/maintainer/spv/reservation_reanchor_proof.go new file mode 100644 index 0000000000..d8d5192fc3 --- /dev/null +++ b/pkg/maintainer/spv/reservation_reanchor_proof.go @@ -0,0 +1,242 @@ +package spv + +import ( + "fmt" + "math/big" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/tbtc" +) + +// ProofTypeReservationReanchor is the value passed to +// SubmitReservationProof as proofType for a reservation re-anchor SPV proof. +// The numeric value mirrors the on-chain ReservationProofType enum (3 = +// Reanchor). +const ProofTypeReservationReanchor uint8 = 3 + +// SubmitReservationReanchorProof drives the SPV proof submission for a +// reservation re-anchor action generation. The caller (typically the +// reservation re-anchor watcher) supplies the (reservationKey, requestNonce) +// pair of the on-chain action generation it is proving, plus the Bitcoin +// transaction hash of the re-anchor transaction already signed and +// broadcast by the wallet coordinator. The proof is fetched from btcChain, +// the re-anchor transaction is rebuilt locally to extract the anchor UTXO +// and target wallet, and the proof is submitted directly to the Bridge via +// the SPV maintainer's SubmitReservationProof entry point (not via +// MaintainerProxy: reservations are not reimbursed). +// +// requiredConfirmations must be > 0; the SPV maintainer relies on it to +// assemble the proof. +func SubmitReservationReanchorProof( + transactionHash bitcoin.Hash, + requiredConfirmations uint, + reservationKey *big.Int, + requestNonce uint64, + btcChain bitcoin.Chain, + spvChain Chain, +) error { + return submitReservationReanchorProof( + transactionHash, + requiredConfirmations, + reservationKey, + requestNonce, + btcChain, + spvChain, + bitcoin.AssembleSpvProof, + ) +} + +func submitReservationReanchorProof( + transactionHash bitcoin.Hash, + requiredConfirmations uint, + reservationKey *big.Int, + requestNonce uint64, + btcChain bitcoin.Chain, + spvChain Chain, + spvProofAssembler spvProofAssembler, +) error { + if requiredConfirmations == 0 { + return fmt.Errorf( + "provided required confirmations count must be greater than 0", + ) + } + if reservationKey == nil { + return fmt.Errorf("reservation key is required") + } + if requestNonce == 0 { + return fmt.Errorf("request nonce must be > 0") + } + + transaction, proof, err := spvProofAssembler( + transactionHash, + requiredConfirmations, + btcChain, + ) + if err != nil { + return fmt.Errorf( + "failed to assemble transaction spv proof: [%v]", + err, + ) + } + + anchorUtxo, _, err := parseReservationReanchorTransactionInput( + btcChain, + transaction, + ) + if err != nil { + return fmt.Errorf( + "error while parsing reservation re-anchor transaction inputs: [%v]", + err, + ) + } + + action, err := spvChain.GetReservationAction(reservationKey, requestNonce) + if err != nil { + return fmt.Errorf( + "cannot fetch reservation action generation: [%v]", + err, + ) + } + + if action.ActionType != tbtc.ReservationActionTypeReanchor { + return fmt.Errorf( + "reservation action generation is not a re-anchor (got %v)", + action.ActionType, + ) + } + + if action.State != tbtc.ReservationActionStatePending { + return fmt.Errorf( + "reservation re-anchor action generation is not pending (state=%v)", + action.State, + ) + } + + txInfo := buildReservationProofTxInfo(transaction) + txProof := buildReservationProofTxProof(proof) + mainUtxo := buildReservationProofMainUtxo(anchorUtxo) + + if err := spvChain.SubmitReservationProof( + ProofTypeReservationReanchor, + txInfo, + txProof, + mainUtxo, + reservationKey, + requestNonce, + ); err != nil { + return fmt.Errorf( + "failed to submit reservation re-anchor proof: [%v]", + err, + ) + } + + return nil +} + +// parseReservationReanchorTransactionInput parses the single input and +// single output of a reservation re-anchor transaction and returns the +// anchor UTXO that was spent and the target wallet's public key hash from +// the new anchor output script. +func parseReservationReanchorTransactionInput( + btcChain bitcoin.Chain, + transaction *bitcoin.Transaction, +) (*bitcoin.UnspentTransactionOutput, [20]byte, error) { + if len(transaction.Inputs) != 1 { + return nil, [20]byte{}, fmt.Errorf( + "reservation re-anchor transaction must have exactly one input", + ) + } + + if len(transaction.Outputs) != 1 { + return nil, [20]byte{}, fmt.Errorf( + "reservation re-anchor transaction must have exactly one output", + ) + } + + input := transaction.Inputs[0] + + inputTx, err := btcChain.GetTransaction(input.Outpoint.TransactionHash) + if err != nil { + return nil, [20]byte{}, fmt.Errorf( + "cannot get input transaction data: [%v]", + err, + ) + } + + spentOutput := inputTx.Outputs[input.Outpoint.OutputIndex] + + anchorUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: input.Outpoint, + Value: spentOutput.Value, + } + + targetWalletPublicKeyHash, err := bitcoin.ExtractPublicKeyHash( + transaction.Outputs[0].PublicKeyScript, + ) + if err != nil { + return nil, [20]byte{}, fmt.Errorf( + "cannot extract target wallet public key hash: [%v]", + err, + ) + } + + return anchorUtxo, targetWalletPublicKeyHash, nil +} + +// buildReservationProofTxInfo serializes the relevant parts of the +// transaction into the BitcoinTxInfo structure expected by +// SubmitReservationProof. +func buildReservationProofTxInfo( + transaction *bitcoin.Transaction, +) *tbtc.BitcoinTxInfo { + return &tbtc.BitcoinTxInfo{ + Version: transaction.SerializeVersion(), + InputVector: transaction.SerializeInputs(), + OutputVector: transaction.SerializeOutputs(), + Locktime: transaction.SerializeLocktime(), + } +} + +// buildReservationProofTxProof converts a bitcoin.SpvProof into the +// BitcoinTxProof structure expected by SubmitReservationProof. +func buildReservationProofTxProof( + proof *bitcoin.SpvProof, +) *tbtc.BitcoinTxProof { + txIndexInBlock := big.NewInt(int64(proof.TxIndexInBlock)) + + return &tbtc.BitcoinTxProof{ + MerkleProof: proof.MerkleProof, + TxIndexInBlock: txIndexInBlock, + BitcoinHeaders: proof.BitcoinHeaders, + CoinbasePreimage: proof.CoinbasePreimage, + CoinbaseProof: proof.CoinbaseProof, + } +} + +// buildReservationProofMainUtxo packages the spent anchor UTXO into the +// BitcoinTxUTXO structure expected by SubmitReservationProof. +func buildReservationProofMainUtxo( + anchorUtxo *bitcoin.UnspentTransactionOutput, +) *tbtc.BitcoinTxUTXO { + var ( + txHash [32]byte + txOutIndex uint32 + txOutValue uint64 + ) + + if anchorUtxo.Outpoint != nil { + txHash = anchorUtxo.Outpoint.TransactionHash + txOutIndex = anchorUtxo.Outpoint.OutputIndex + } + if anchorUtxo.Value < 0 { + txOutValue = 0 + } else { + txOutValue = uint64(anchorUtxo.Value) + } + + return &tbtc.BitcoinTxUTXO{ + TxHash: txHash, + TxOutputIndex: txOutIndex, + TxOutputValue: txOutValue, + } +} diff --git a/pkg/maintainer/spv/reservation_reanchor_proof_test.go b/pkg/maintainer/spv/reservation_reanchor_proof_test.go new file mode 100644 index 0000000000..804599967b --- /dev/null +++ b/pkg/maintainer/spv/reservation_reanchor_proof_test.go @@ -0,0 +1,193 @@ +package spv + +import ( + "bytes" + "fmt" + "math/big" + "testing" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/tbtc" +) + +// TestSubmitReservationReanchorProof verifies that submitReservationReanchorProof +// correctly parses a 1-input-1-output re-anchor transaction, looks up the +// matching reservation action generation, and submits the SPV proof to the +// chain. It also covers the failure paths for missing action and mismatched +// action type. +func TestSubmitReservationReanchorProof(t *testing.T) { + requiredConfirmations := uint(6) + + btcChain := newLocalBitcoinChain() + spvChain := newLocalChain() + + // Anchor transaction that the re-anchor spends. + 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() + + // Re-anchor transaction: 1 input spending anchorTx output 0, 1 output + // paying to the target wallet. + 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.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, + }) + + // Override SubmitReservationProof on the localChain to capture the call. + spvChain.submitReservationProofHook = func( + proofType uint8, + txInfo *tbtc.BitcoinTxInfo, + txProof *tbtc.BitcoinTxProof, + mainUtxo *tbtc.BitcoinTxUTXO, + rk *big.Int, + rn uint64, + ) error { + if proofType != ProofTypeReservationReanchor { + t.Errorf("unexpected proof type: got %d, want %d", proofType, ProofTypeReservationReanchor) + } + if rk == nil || rk.Cmp(reservationKey) != 0 { + t.Errorf("unexpected reservation key: got %v, want %v", rk, reservationKey) + } + if rn != requestNonce { + t.Errorf("unexpected request nonce: got %d, want %d", rn, requestNonce) + } + if mainUtxo == nil { + t.Fatal("mainUtxo must not be nil") + } + if mainUtxo.TxOutputValue != 600000 { + t.Errorf("unexpected UTXO value: got %d, want %d", mainUtxo.TxOutputValue, 600000) + } + if txInfo == nil { + t.Fatal("txInfo must not be nil") + } + if !bytes.Equal(txProof.MerkleProof, proof.MerkleProof) { + t.Errorf("unexpected merkle proof") + } + return nil + } + + if err := submitReservationReanchorProof( + reanchorTx.Hash(), + requiredConfirmations, + reservationKey, + requestNonce, + btcChain, + spvChain, + mockSpvProofAssembler, + ); err != nil { + t.Fatal(err) + } + + // Negative path: action generation is not Pending. + spvChain.setReservationAction(reservationKey, requestNonce, &tbtc.ReservationAction{ + ActionType: tbtc.ReservationActionTypeReanchor, + State: tbtc.ReservationActionStateSettled, + }) + if err := submitReservationReanchorProof( + reanchorTx.Hash(), + requiredConfirmations, + reservationKey, + requestNonce, + btcChain, + spvChain, + mockSpvProofAssembler, + ); err == nil { + t.Fatal("expected error for settled action generation") + } + + // Negative path: action generation is the wrong type. + spvChain.setReservationAction(reservationKey, requestNonce, &tbtc.ReservationAction{ + ActionType: tbtc.ReservationActionTypeAcceptance, + State: tbtc.ReservationActionStatePending, + }) + if err := submitReservationReanchorProof( + reanchorTx.Hash(), + requiredConfirmations, + reservationKey, + requestNonce, + btcChain, + spvChain, + mockSpvProofAssembler, + ); err == nil { + t.Fatal("expected error for wrong action type") + } + + // Negative path: zero requiredConfirmations. + if err := submitReservationReanchorProof( + reanchorTx.Hash(), + 0, + reservationKey, + requestNonce, + btcChain, + spvChain, + mockSpvProofAssembler, + ); err == nil { + t.Fatal("expected error for zero required confirmations") + } +} diff --git a/pkg/tbtcpg/chain_test.go b/pkg/tbtcpg/chain_test.go index 4b0a215ce3..03abb9f9cc 100644 --- a/pkg/tbtcpg/chain_test.go +++ b/pkg/tbtcpg/chain_test.go @@ -29,6 +29,13 @@ type movingFundsCommitmentSubmission struct { TargetWallets [][20]byte } +// reservationReanchorRequestSubmission captures a submitted reservation +// re-anchor request that tests can inspect for assertion. +type reservationReanchorRequestSubmission struct { + ReservationKey *big.Int + TargetWalletPublicKeyHash [20]byte +} + type LocalChain struct { mutex sync.Mutex @@ -57,6 +64,17 @@ type LocalChain struct { operatorIDs map[chain.Address]uint32 redemptionDelays map[[32]byte]time.Duration depositMinAge uint32 + + reservations map[*big.Int]*tbtc.Reservation + reservationActions map[string]*tbtc.ReservationAction + reservationParametersValue tbtc.ReservationParameters + reservationParametersSet bool + reservationProposalValidations map[[32]byte]bool + reservationReanchorRequestSubmissions []*reservationReanchorRequestSubmission + reservationReanchoredEventEmissions []*tbtc.ReservationReanchoredEvent + reservationWalletKeys map[[20]byte][]*big.Int + liveWalletsCountValue uint32 + liveWalletsCountSet bool } func NewLocalChain() *LocalChain { @@ -78,6 +96,13 @@ func NewLocalChain() *LocalChain { movedFundsSweepProposalValidations: make(map[[32]byte]bool), operatorIDs: make(map[chain.Address]uint32), redemptionDelays: make(map[[32]byte]time.Duration), + + reservations: make(map[*big.Int]*tbtc.Reservation), + reservationActions: make(map[string]*tbtc.ReservationAction), + reservationProposalValidations: make(map[[32]byte]bool), + reservationReanchorRequestSubmissions: make([]*reservationReanchorRequestSubmission, 0), + reservationReanchoredEventEmissions: make([]*tbtc.ReservationReanchoredEvent, 0), + reservationWalletKeys: make(map[[20]byte][]*big.Int), } } @@ -1003,7 +1028,29 @@ func (lc *LocalChain) SetWalletParameters( } func (lc *LocalChain) GetLiveWalletsCount() (uint32, error) { - panic("unsupported") + lc.mutex.Lock() + defer lc.mutex.Unlock() + + if lc.liveWalletsCountSet { + return lc.liveWalletsCountValue, nil + } + + count := uint32(0) + for _, wallet := range lc.walletChainData { + if wallet != nil && wallet.State == tbtc.StateLive { + count++ + } + } + return count, nil +} + +// SetLiveWalletsCount stores an explicit live-wallets count for tests. +func (lc *LocalChain) SetLiveWalletsCount(count uint32) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.liveWalletsCountValue = count + lc.liveWalletsCountSet = true } func (lc *LocalChain) ComputeMainUtxoHash(mainUtxo *bitcoin.UnspentTransactionOutput) [32]byte { @@ -1299,184 +1346,415 @@ func (lc *LocalChain) ValidateReservationAnchorProposal( panic("unsupported") } -// ValidateReservationReanchorProposal is a stub matching the reservation -// additions on the production Chain interface. Full behavioral -// validation belongs to the reservation re-anchor proposal builder. +// ValidateReservationReanchorProposal returns nil when no explicit +// validation result was registered, mirroring the production contract's +// happy path for tests that don't need to enforce specific validation +// outcomes. Tests that need to drive specific failure modes should +// populate this via SetReservationReanchorProposalValidationResult. func (lc *LocalChain) ValidateReservationReanchorProposal( sourceWalletPublicKeyHash [20]byte, proposal *tbtc.ReservationReanchorProposal, ) error { - panic("unsupported") + lc.mutex.Lock() + defer lc.mutex.Unlock() + + if proposal == nil { + return fmt.Errorf("proposal is required") + } + + key, err := buildReservationReanchorProposalValidationKey( + sourceWalletPublicKeyHash, + proposal, + ) + if err != nil { + return err + } + + if result, ok := lc.reservationProposalValidations[key]; ok { + if !result { + return fmt.Errorf("validation failed") + } + } + + return nil +} + +// SetReservationReanchorProposalValidationResult stores the validation +// outcome for the given (sourceWalletPublicKeyHash, proposal) tuple. +func (lc *LocalChain) SetReservationReanchorProposalValidationResult( + sourceWalletPublicKeyHash [20]byte, + proposal *tbtc.ReservationReanchorProposal, + result bool, +) error { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + key, err := buildReservationReanchorProposalValidationKey( + sourceWalletPublicKeyHash, + proposal, + ) + if err != nil { + return err + } + + lc.reservationProposalValidations[key] = result + return nil } -// RequestReservationAcceptance is a stub matching the reservation -// additions on the production Chain interface. The reservation -// acceptance proposal builder replaces this stub with the call path that -// records a submitted acceptance request for assertion in tests. +func buildReservationReanchorProposalValidationKey( + sourceWalletPublicKeyHash [20]byte, + proposal *tbtc.ReservationReanchorProposal, +) ([32]byte, error) { + var buffer bytes.Buffer + + buffer.Write(sourceWalletPublicKeyHash[:]) + + if proposal != nil { + if proposal.ReservationKey != nil { + buffer.Write(proposal.ReservationKey.Bytes()) + } + for i := 0; i < 8; i++ { + buffer.Write([]byte{byte(proposal.RequestNonce >> (8 * i))}) + } + buffer.Write(proposal.TargetWalletPublicKeyHash[:]) + if proposal.ReanchorTxFee != nil { + buffer.Write(proposal.ReanchorTxFee.Bytes()) + } + } + + return sha256.Sum256(buffer.Bytes()), nil +} + +// RequestReservationAcceptance records a submitted reservation acceptance +// request for assertion in tests. func (lc *LocalChain) RequestReservationAcceptance( reservationKey *big.Int, walletPublicKeyHash [20]byte, ) error { - panic("unsupported") + lc.mutex.Lock() + defer lc.mutex.Unlock() + + _ = walletPublicKeyHash + _ = reservationKey + return nil } -// RequestReservationReanchor is a stub matching the reservation additions -// on the production Chain interface. The reservation re-anchor proposal -// builder replaces this stub with the call path that records a submitted -// re-anchor request for assertion in tests. +// RequestReservationReanchor records a submitted reservation re-anchor +// request for assertion in tests. func (lc *LocalChain) RequestReservationReanchor( reservationKey *big.Int, targetWalletPublicKeyHash [20]byte, ) error { - panic("unsupported") + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.reservationReanchorRequestSubmissions = append( + lc.reservationReanchorRequestSubmissions, + &reservationReanchorRequestSubmission{ + ReservationKey: new(big.Int).Set(reservationKey), + TargetWalletPublicKeyHash: targetWalletPublicKeyHash, + }, + ) + return nil } -// GetReservation is a stub matching the reservation additions on the -// production Chain interface. +// GetReservation returns the configured reservation record for the given +// reservation key, or an error if not found. func (lc *LocalChain) GetReservation( reservationKey *big.Int, ) (*tbtc.Reservation, error) { - panic("unsupported") + lc.mutex.Lock() + defer lc.mutex.Unlock() + + if reservation, ok := lc.reservations[reservationKey]; ok { + return reservation, nil + } + for k, r := range lc.reservations { + if k.Cmp(reservationKey) == 0 { + return r, nil + } + } + return nil, fmt.Errorf("reservation not found") +} + +// SetReservation stores the given reservation record keyed by reservationKey. +func (lc *LocalChain) SetReservation( + reservationKey *big.Int, + reservation *tbtc.Reservation, +) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.reservations[new(big.Int).Set(reservationKey)] = reservation } -// GetReservationAction is a stub matching the reservation additions on -// the production Chain interface. +// GetReservationAction returns the configured reservation action record. func (lc *LocalChain) GetReservationAction( reservationKey *big.Int, requestNonce uint64, ) (*tbtc.ReservationAction, error) { - panic("unsupported") + lc.mutex.Lock() + defer lc.mutex.Unlock() + + key := buildReservationActionKey(reservationKey, requestNonce) + if action, ok := lc.reservationActions[key]; ok { + return action, nil + } + // Fall back to comparing by value + for k, a := range lc.reservationActions { + expected := buildReservationActionKey(reservationKey, requestNonce) + if k == expected { + return a, nil + } + } + return nil, fmt.Errorf("reservation action not found") } -// ReservationParameters is a stub matching the reservation additions on -// the production Chain interface. +// SetReservationAction stores the given reservation action record. +func (lc *LocalChain) SetReservationAction( + reservationKey *big.Int, + requestNonce uint64, + action *tbtc.ReservationAction, +) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + key := buildReservationActionKey(reservationKey, requestNonce) + lc.reservationActions[key] = action +} + +func buildReservationActionKey( + reservationKey *big.Int, + requestNonce uint64, +) string { + if reservationKey == nil { + return fmt.Sprintf("nil/%d", requestNonce) + } + return fmt.Sprintf("%s/%d", reservationKey.String(), requestNonce) +} + +// ReservationParameters returns the configured reservation parameters. func (lc *LocalChain) ReservationParameters() ( *tbtc.ReservationParameters, error, ) { - panic("unsupported") + lc.mutex.Lock() + defer lc.mutex.Unlock() + + if !lc.reservationParametersSet { + return nil, fmt.Errorf("reservation parameters not set") + } + params := lc.reservationParametersValue + return ¶ms, nil } -// ReservationCaps is a stub matching the reservation additions on the -// production Chain interface. +// SetReservationParameters stores the given reservation parameters. +func (lc *LocalChain) SetReservationParameters(params tbtc.ReservationParameters) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.reservationParametersValue = params + lc.reservationParametersSet = true +} + +// ReservationCaps returns a static cap-pair useful for tests. func (lc *LocalChain) ReservationCaps() ( maxReservationsAmountPerWallet uint64, reservationMaxSingleAmount uint64, err error, ) { - panic("unsupported") + lc.mutex.Lock() + defer lc.mutex.Unlock() + + return 100000000, 10000000, nil } -// WalletReservationsAmount is a stub matching the reservation additions -// on the production Chain interface. +// WalletReservationsAmount returns the sum of anchor values for the +// wallet's reservations. func (lc *LocalChain) WalletReservationsAmount( walletPublicKeyHash [20]byte, ) (uint64, error) { - panic("unsupported") + lc.mutex.Lock() + defer lc.mutex.Unlock() + + var total uint64 + for _, reservationKey := range lc.reservationWalletKeys[walletPublicKeyHash] { + if r, ok := lc.reservations[reservationKey]; ok && r != nil && r.AnchorUtxo != nil { + total += uint64(r.AnchorUtxo.Value) + } + } + return total, nil } -// WalletReservationsCount is a stub matching the reservation additions on -// the production Chain interface. +// WalletReservationsCount returns the count of reservations for the wallet. func (lc *LocalChain) WalletReservationsCount( walletPublicKeyHash [20]byte, ) (uint32, error) { - panic("unsupported") + lc.mutex.Lock() + defer lc.mutex.Unlock() + + return uint32(len(lc.reservationWalletKeys[walletPublicKeyHash])), nil } -// WalletReservations is a stub matching the reservation additions on the -// production Chain interface. +// WalletReservations returns the configured reservation keys for the wallet. func (lc *LocalChain) WalletReservations( walletPublicKeyHash [20]byte, ) ([]*big.Int, error) { - panic("unsupported") + lc.mutex.Lock() + defer lc.mutex.Unlock() + + keys := lc.reservationWalletKeys[walletPublicKeyHash] + result := make([]*big.Int, len(keys)) + for i, k := range keys { + result[i] = new(big.Int).Set(k) + } + return result, nil +} + +// SetWalletReservations stores the reservation keys associated with the wallet. +func (lc *LocalChain) SetWalletReservations( + walletPublicKeyHash [20]byte, + reservationKeys []*big.Int, +) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + copy := make([]*big.Int, len(reservationKeys)) + for i, k := range reservationKeys { + copy[i] = new(big.Int).Set(k) + } + lc.reservationWalletKeys[walletPublicKeyHash] = copy } -// ReservationByAnchorUtxo is a stub matching the reservation additions -// on the production Chain interface. +// ReservationByAnchorUtxo returns an empty reservation key (no lookup). func (lc *LocalChain) ReservationByAnchorUtxo( anchorTxHash [32]byte, anchorTxOutputIndex uint32, ) (*big.Int, error) { - panic("unsupported") + lc.mutex.Lock() + defer lc.mutex.Unlock() + + _ = anchorTxHash + _ = anchorTxOutputIndex + return new(big.Int), nil } -// PendingReservedDeposits is a stub matching the reservation additions -// on the production Chain interface. +// PendingReservedDeposits reports zero pending reserved deposits. func (lc *LocalChain) PendingReservedDeposits() (uint64, error) { - panic("unsupported") + return 0, nil } -// Reservations is a stub matching the reservation additions on the -// production Chain interface. +// Reservations is a stub mirroring the Bridge view. Tests that need this +// data should populate it explicitly via custom extensions. func (lc *LocalChain) Reservations( reservationKey *big.Int, ) (*tbtc.ReservationRequest, error) { - panic("unsupported") + return nil, fmt.Errorf("unsupported") } -// ReservationActions is a stub matching the reservation additions on the -// production Chain interface. +// ReservationActions mirrors the Bridge view. func (lc *LocalChain) ReservationActions( reservationKey *big.Int, requestNonce uint64, ) (*tbtc.ReservationActionRecord, error) { - panic("unsupported") + return nil, fmt.Errorf("unsupported") } -// ActiveReservationsCount is a stub matching the reservation additions on -// the production Chain interface. +// ActiveReservationsCount reports zero active reservations by default. func (lc *LocalChain) ActiveReservationsCount() ( count uint32, maxActive uint32, err error, ) { - panic("unsupported") + return 0, 0, nil } -// IsReservedDeposit is a stub matching the reservation additions on the -// production Chain interface. +// IsReservedDeposit returns false by default. func (lc *LocalChain) IsReservedDeposit( depositKey *big.Int, ) (bool, error) { - panic("unsupported") + return false, nil } -// PastReservationAcceptanceRequestedEvents is a stub matching the -// reservation additions on the production Chain interface. The proposal -// builder replaces this with the map-backed fixture used to assert that -// no duplicate acceptance request is generated. +// PastReservationAcceptanceRequestedEvents returns no events by default. func (lc *LocalChain) PastReservationAcceptanceRequestedEvents( filter *tbtc.ReservationAcceptanceRequestedEventFilter, ) ([]*tbtc.ReservationAcceptanceRequestedEvent, error) { - panic("unsupported") + return nil, nil } -// PastReservationAcceptedEvents is a stub matching the reservation -// additions on the production Chain interface. The acceptance proof -// builder replaces this with the map-backed fixture used to assert -// proof dispatch against observed acceptances. +// PastReservationAcceptedEvents returns no events by default. func (lc *LocalChain) PastReservationAcceptedEvents( filter *tbtc.ReservationAcceptedEventFilter, ) ([]*tbtc.ReservationAcceptedEvent, error) { - panic("unsupported") + return nil, nil } -// PastReservationReanchorRequestedEvents is a stub matching the -// reservation additions on the production Chain interface. The proposal -// builder replaces this with the map-backed fixture used to assert that -// no duplicate re-anchor request is generated. +// PastReservationReanchorRequestedEvents returns the recorded re-anchor +// request submissions that match the filter. func (lc *LocalChain) PastReservationReanchorRequestedEvents( filter *tbtc.ReservationReanchorRequestedEventFilter, ) ([]*tbtc.ReservationReanchorRequestedEvent, error) { - panic("unsupported") + lc.mutex.Lock() + defer lc.mutex.Unlock() + + results := make([]*tbtc.ReservationReanchorRequestedEvent, 0) + for _, submission := range lc.reservationReanchorRequestSubmissions { + event := &tbtc.ReservationReanchorRequestedEvent{ + ReservationKey: new(big.Int).Set(submission.ReservationKey), + TargetWalletPublicKeyHash: submission.TargetWalletPublicKeyHash, + } + + if filter != nil { + if len(filter.TargetWalletPublicKeyHash) > 0 { + matched := false + for _, w := range filter.TargetWalletPublicKeyHash { + if w == submission.TargetWalletPublicKeyHash { + matched = true + break + } + } + if !matched { + continue + } + } + } + + results = append(results, event) + } + return results, nil +} + +// GetReservationReanchorRequestSubmissions returns the recorded +// reservation re-anchor request submissions for assertion. +func (lc *LocalChain) GetReservationReanchorRequestSubmissions() []*reservationReanchorRequestSubmission { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + copy := make([]*reservationReanchorRequestSubmission, len(lc.reservationReanchorRequestSubmissions)) + for i, s := range lc.reservationReanchorRequestSubmissions { + copy[i] = &reservationReanchorRequestSubmission{ + ReservationKey: new(big.Int).Set(s.ReservationKey), + TargetWalletPublicKeyHash: s.TargetWalletPublicKeyHash, + } + } + return copy } -// PastReservationReanchoredEvents is a stub matching the reservation -// additions on the production Chain interface. The re-anchor proof -// builder replaces this with the map-backed fixture used to assert -// proof dispatch against observed re-anchors. +// PastReservationReanchoredEvents returns the recorded re-anchor settlement +// events that match the filter. func (lc *LocalChain) PastReservationReanchoredEvents( filter *tbtc.ReservationReanchoredEventFilter, ) ([]*tbtc.ReservationReanchoredEvent, error) { - panic("unsupported") + lc.mutex.Lock() + defer lc.mutex.Unlock() + + if len(lc.reservationReanchoredEventEmissions) == 0 { + return nil, nil + } + results := make([]*tbtc.ReservationReanchoredEvent, 0, len(lc.reservationReanchoredEventEmissions)) + for _, ev := range lc.reservationReanchoredEventEmissions { + results = append(results, ev) + } + return results, nil } diff --git a/pkg/tbtcpg/internal/test/marshaling.go b/pkg/tbtcpg/internal/test/marshaling.go index 91c390df6e..632b1b199e 100644 --- a/pkg/tbtcpg/internal/test/marshaling.go +++ b/pkg/tbtcpg/internal/test/marshaling.go @@ -375,3 +375,188 @@ func hexToSlice(hexString string) []byte { return bytes } + +// UnmarshalJSON implements a custom JSON unmarshaling logic to produce a +// proper ReservationReanchorTestScenario. +func (rrts *ReservationReanchorTestScenario) UnmarshalJSON(data []byte) error { + type reservationDataJSON struct { + ReservationKey string + WalletPublicKeyHash string + AnchorTxHash string + AnchorTxOutputIndex uint32 + AnchorValue int64 + State string + RequestNonce uint64 + HasPendingAction bool + PendingActionState string + } + type reservationReanchorTestScenarioJSON struct { + Title string + + SourceWalletPublicKeyHash string + SourceWalletState string + SourceWalletMainUtxoHash string + SourceWalletMainUtxoValue int64 + SourceWalletMainUtxoTxHash string + SourceWalletMainUtxoTxIndex uint32 + + TargetWalletPublicKeyHash string + + LiveWalletsCount uint32 + + MovingFundsDustThreshold uint64 + ReservationTxMaxFee uint64 + EstimateSatPerVByteFee int64 + ReanchorTxFee int64 + + Reservations []reservationDataJSON + + ExpectedProposal *reservationReanchorProposalJSON + ExpectedErr string + } + + var unmarshaled reservationReanchorTestScenarioJSON + + if err := json.Unmarshal(data, &unmarshaled); err != nil { + return err + } + + rrts.Title = unmarshaled.Title + + if len(unmarshaled.SourceWalletPublicKeyHash) > 0 { + copy(rrts.SourceWalletPublicKeyHash[:], hexToSlice(unmarshaled.SourceWalletPublicKeyHash)) + } + rrts.SourceWalletState = parseWalletState(unmarshaled.SourceWalletState) + if len(unmarshaled.SourceWalletMainUtxoHash) > 0 { + copy(rrts.SourceWalletMainUtxoHashBytes[:], hexToSlice(unmarshaled.SourceWalletMainUtxoHash)) + } else { + rrts.SourceWalletMainUtxoHashBytes = [32]byte{} + } + rrts.SourceWalletMainUtxoValue = unmarshaled.SourceWalletMainUtxoValue + rrts.SourceWalletMainUtxoTxHash = unmarshaled.SourceWalletMainUtxoTxHash + rrts.SourceWalletMainUtxoTxIndex = unmarshaled.SourceWalletMainUtxoTxIndex + + if len(unmarshaled.TargetWalletPublicKeyHash) > 0 { + copy(rrts.TargetWalletPublicKeyHash[:], hexToSlice(unmarshaled.TargetWalletPublicKeyHash)) + } + + rrts.LiveWalletsCount = unmarshaled.LiveWalletsCount + + rrts.MovingFundsDustThreshold = unmarshaled.MovingFundsDustThreshold + rrts.ReservationTxMaxFee = unmarshaled.ReservationTxMaxFee + rrts.EstimateSatPerVByteFee = unmarshaled.EstimateSatPerVByteFee + rrts.ReanchorTxFee = unmarshaled.ReanchorTxFee + + rrts.Reservations = make([]*ReservationReanchorData, 0, len(unmarshaled.Reservations)) + for _, r := range unmarshaled.Reservations { + d := &ReservationReanchorData{} + + if len(r.ReservationKey) > 0 { + keyBytes := hexToSlice(r.ReservationKey) + d.ReservationKey = new(big.Int).SetBytes(keyBytes) + } + if len(r.WalletPublicKeyHash) > 0 { + copy(d.WalletPublicKeyHash[:], hexToSlice(r.WalletPublicKeyHash)) + } + d.AnchorTxHash = r.AnchorTxHash + d.AnchorTxOutputIndex = r.AnchorTxOutputIndex + d.AnchorValue = r.AnchorValue + d.State = parseReservationState(r.State) + d.RequestNonce = r.RequestNonce + d.HasPendingAction = r.HasPendingAction + d.PendingActionState = parseReservationActionState(r.PendingActionState) + + rrts.Reservations = append(rrts.Reservations, d) + } + + if unmarshaled.ExpectedProposal != nil { + prop, err := unmarshaled.ExpectedProposal.convert() + if err != nil { + return fmt.Errorf( + "failed to convert expected reservation re-anchor proposal: [%w]", + err, + ) + } + rrts.ExpectedProposal = prop + } + + if len(unmarshaled.ExpectedErr) > 0 { + rrts.ExpectedErr = errors.New(unmarshaled.ExpectedErr) + } + + return nil +} + +type reservationReanchorProposalJSON struct { + ReservationKey string + RequestNonce uint64 + TargetWalletPublicKeyHash string + ReanchorTxFee int64 +} + +func (rj *reservationReanchorProposalJSON) convert() (*tbtc.ReservationReanchorProposal, error) { + if rj == nil { + return nil, nil + } + + result := &tbtc.ReservationReanchorProposal{ + RequestNonce: rj.RequestNonce, + ReanchorTxFee: big.NewInt(rj.ReanchorTxFee), + } + if len(rj.ReservationKey) > 0 { + result.ReservationKey = new(big.Int).SetBytes(hexToSlice(rj.ReservationKey)) + } + if len(rj.TargetWalletPublicKeyHash) > 0 { + copy(result.TargetWalletPublicKeyHash[:], hexToSlice(rj.TargetWalletPublicKeyHash)) + } + return result, nil +} + +func parseWalletState(s string) tbtc.WalletState { + switch s { + case "Live": + return tbtc.StateLive + case "MovingFunds": + return tbtc.StateMovingFunds + case "Closing": + return tbtc.StateClosing + case "Closed": + return tbtc.StateClosed + case "Terminated": + return tbtc.StateTerminated + default: + return tbtc.StateUnknown + } +} + +func parseReservationState(s string) tbtc.ReservationState { + switch s { + case "Active": + return tbtc.ReservationStateActive + case "ActionPending": + return tbtc.ReservationStateActionPending + case "Closed": + return tbtc.ReservationStateClosed + case "Stranded": + return tbtc.ReservationStateStranded + default: + return tbtc.ReservationStateUnknown + } +} + +func parseReservationActionState(s string) tbtc.ReservationActionState { + switch s { + case "Pending": + return tbtc.ReservationActionStatePending + case "Settled": + return tbtc.ReservationActionStateSettled + case "TimedOut": + return tbtc.ReservationActionStateTimedOut + case "Vetoed": + return tbtc.ReservationActionStateVetoed + case "Superseded": + return tbtc.ReservationActionStateSuperseded + default: + return tbtc.ReservationActionStateUnknown + } +} diff --git a/pkg/tbtcpg/internal/test/tbtcpgtest.go b/pkg/tbtcpg/internal/test/tbtcpgtest.go index b03cc40468..c0338086bf 100644 --- a/pkg/tbtcpg/internal/test/tbtcpgtest.go +++ b/pkg/tbtcpg/internal/test/tbtcpgtest.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "io/fs" + "math/big" "os" "path/filepath" "runtime" @@ -21,6 +22,7 @@ const ( findDepositsToSweepTestDataFilePrefix = "find_deposits" proposeDepositsSweepTestDataFilePrefix = "propose_sweep" findPendingRedemptionsTestDataFilePrefix = "find_pending_redemptions" + reservationReanchorTestDataFilePrefix = "reservation_reanchor" ) // Deposit holds the deposit data in the given test scenario. @@ -144,6 +146,58 @@ func LoadFindPendingRedemptionsTestScenario() ( ) } +// ReservationReanchorData holds the per-reservation data in a reservation +// re-anchor test scenario. +type ReservationReanchorData struct { + ReservationKey *big.Int + WalletPublicKeyHash [20]byte + AnchorTxHash string + AnchorTxOutputIndex uint32 + AnchorValue int64 + State tbtc.ReservationState + RequestNonce uint64 + HasPendingAction bool + PendingActionState tbtc.ReservationActionState +} + +// ReservationReanchorTestScenario represents a test scenario of preparing a +// reservation re-anchor proposal. +type ReservationReanchorTestScenario struct { + Title string + + SourceWalletPublicKeyHash [20]byte + SourceWalletState tbtc.WalletState + SourceWalletMainUtxoHashBytes [32]byte + SourceWalletMainUtxoValue int64 + SourceWalletMainUtxoTxHash string + SourceWalletMainUtxoTxIndex uint32 + + TargetWalletPublicKeyHash [20]byte + + LiveWalletsCount uint32 + + MovingFundsDustThreshold uint64 + ReservationTxMaxFee uint64 + EstimateSatPerVByteFee int64 + ReanchorTxFee int64 + + Reservations []*ReservationReanchorData + + ExpectedProposal *tbtc.ReservationReanchorProposal + ExpectedErr error +} + +// LoadReservationReanchorTestScenario loads all scenarios related to +// reservation re-anchor proposals. +func LoadReservationReanchorTestScenario() ( + []*ReservationReanchorTestScenario, + error, +) { + return loadTestScenarios[*ReservationReanchorTestScenario]( + reservationReanchorTestDataFilePrefix, + ) +} + func loadTestScenarios[T json.Unmarshaler](testDataFilePrefix string) ([]T, error) { filePaths, err := detectTestDataFiles(testDataFilePrefix) if err != nil { diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_0.json b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_0.json new file mode 100644 index 0000000000..35e6c2ed6e --- /dev/null +++ b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_0.json @@ -0,0 +1,34 @@ +{ + "Title": "wallet-migration trigger: emits re-anchor proposal for first active reservation", + "SourceWalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", + "SourceWalletState": "MovingFunds", + "SourceWalletMainUtxoHash": "1111111111111111111111111111111111111111111111111111111111111111", + "SourceWalletMainUtxoValue": 500000, + "SourceWalletMainUtxoTxHash": "2222222222222222222222222222222222222222222222222222222222222222", + "SourceWalletMainUtxoTxIndex": 0, + "TargetWalletPublicKeyHash": "0x92a6ec889a8fa34f731e639edede4c75e184307c", + "LiveWalletsCount": 4, + "MovingFundsDustThreshold": 1000000, + "ReservationTxMaxFee": 100000, + "EstimateSatPerVByteFee": 1, + "ReanchorTxFee": 0, + "Reservations": [ + { + "ReservationKey": "0000000000000000000000000000000000000000000000000000000000aaaa01", + "WalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", + "AnchorTxHash": "3333333333333333333333333333333333333333333333333333333333333333", + "AnchorTxOutputIndex": 1, + "AnchorValue": 100000, + "State": "Active", + "RequestNonce": 0, + "HasPendingAction": false, + "PendingActionState": "Unknown" + } + ], + "ExpectedProposal": { + "ReservationKey": "0xaaaa01", + "RequestNonce": 1, + "TargetWalletPublicKeyHash": "0x92a6ec889a8fa34f731e639edede4c75e184307c", + "ReanchorTxFee": 1015 + } +} diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_1.json b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_1.json new file mode 100644 index 0000000000..6504ed9f51 --- /dev/null +++ b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_1.json @@ -0,0 +1,34 @@ +{ + "Title": "below-dust trigger: Live wallet without main UTXO re-anchors", + "SourceWalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", + "SourceWalletState": "Live", + "SourceWalletMainUtxoHash": "0000000000000000000000000000000000000000000000000000000000000000", + "SourceWalletMainUtxoValue": 0, + "SourceWalletMainUtxoTxHash": "0000000000000000000000000000000000000000000000000000000000000000", + "SourceWalletMainUtxoTxIndex": 0, + "TargetWalletPublicKeyHash": "0x92a6ec889a8fa34f731e639edede4c75e184307c", + "LiveWalletsCount": 4, + "MovingFundsDustThreshold": 1000000, + "ReservationTxMaxFee": 100000, + "EstimateSatPerVByteFee": 1, + "ReanchorTxFee": 0, + "Reservations": [ + { + "ReservationKey": "0000000000000000000000000000000000000000000000000000000000bbbb02", + "WalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", + "AnchorTxHash": "6666666666666666666666666666666666666666666666666666666666666666", + "AnchorTxOutputIndex": 1, + "AnchorValue": 200000, + "State": "Active", + "RequestNonce": 5, + "HasPendingAction": false, + "PendingActionState": "Unknown" + } + ], + "ExpectedProposal": { + "ReservationKey": "0xbbbb02", + "RequestNonce": 6, + "TargetWalletPublicKeyHash": "0x92a6ec889a8fa34f731e639edede4c75e184307c", + "ReanchorTxFee": 1015 + } +} diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_2.json b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_2.json new file mode 100644 index 0000000000..ec3cf3b9eb --- /dev/null +++ b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_2.json @@ -0,0 +1,29 @@ +{ + "Title": "cap rejection: estimated re-anchor fee exceeds ReservationTxMaxFee", + "SourceWalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", + "SourceWalletState": "MovingFunds", + "SourceWalletMainUtxoHash": "7777777777777777777777777777777777777777777777777777777777777777", + "SourceWalletMainUtxoValue": 500000, + "SourceWalletMainUtxoTxHash": "8888888888888888888888888888888888888888888888888888888888888888", + "SourceWalletMainUtxoTxIndex": 0, + "TargetWalletPublicKeyHash": "0x92a6ec889a8fa34f731e639edede4c75e184307c", + "LiveWalletsCount": 4, + "MovingFundsDustThreshold": 1000000, + "ReservationTxMaxFee": 100, + "EstimateSatPerVByteFee": 25, + "ReanchorTxFee": 0, + "Reservations": [ + { + "ReservationKey": "0000000000000000000000000000000000000000000000000000000000cccc03", + "WalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", + "AnchorTxHash": "9999999999999999999999999999999999999999999999999999999999999999", + "AnchorTxOutputIndex": 1, + "AnchorValue": 500000, + "State": "Active", + "RequestNonce": 0, + "HasPendingAction": false, + "PendingActionState": "Unknown" + } + ], + "ExpectedErr": "cannot prepare reservation re-anchor proposal: [cannot estimate reservation re-anchor transaction fee: [reservation re-anchor estimated fee exceeds the maximum fee]]" +} diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_3.json b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_3.json new file mode 100644 index 0000000000..f78f5252cb --- /dev/null +++ b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_3.json @@ -0,0 +1,28 @@ +{ + "Title": "action-already-pending: pending action suppresses re-anchor, no proposal emitted", + "SourceWalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", + "SourceWalletState": "MovingFunds", + "SourceWalletMainUtxoHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "SourceWalletMainUtxoValue": 500000, + "SourceWalletMainUtxoTxHash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "SourceWalletMainUtxoTxIndex": 0, + "TargetWalletPublicKeyHash": "0x92a6ec889a8fa34f731e639edede4c75e184307c", + "LiveWalletsCount": 4, + "MovingFundsDustThreshold": 1000000, + "ReservationTxMaxFee": 100000, + "EstimateSatPerVByteFee": 1, + "ReanchorTxFee": 0, + "Reservations": [ + { + "ReservationKey": "0000000000000000000000000000000000000000000000000000000000dddd04", + "WalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", + "AnchorTxHash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "AnchorTxOutputIndex": 1, + "AnchorValue": 200000, + "State": "Active", + "RequestNonce": 4, + "HasPendingAction": true, + "PendingActionState": "Pending" + } + ] +} diff --git a/pkg/tbtcpg/reservation_reanchor.go b/pkg/tbtcpg/reservation_reanchor.go new file mode 100644 index 0000000000..fc4d3b1b38 --- /dev/null +++ b/pkg/tbtcpg/reservation_reanchor.go @@ -0,0 +1,480 @@ +package tbtcpg + +import ( + "fmt" + "math/big" + + "github.com/ipfs/go-log/v2" + "go.uber.org/zap" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/tbtc" +) + +// ReservationReanchorLookBackBlocks is the look-back period in blocks used +// when searching for submitted reservation-related events. It is equal to +// 30 days assuming 12 seconds per block. +const ReservationReanchorLookBackBlocks = uint64(216000) + +// ErrNoReservationToReanchor is returned when the wallet has no reservations +// that are eligible for a re-anchor proposal. +var ErrNoReservationToReanchor = fmt.Errorf("no reservation eligible for re-anchor") + +// ErrReservationReanchorTxMaxFeeTooLow is returned when the on-chain maximum +// fee allowed for a reservation re-anchor transaction is too low to build a +// safe non-RBF transaction at the minimum fee rate. +var ErrReservationReanchorTxMaxFeeTooLow = fmt.Errorf( + "reservation re-anchor minimum safe transaction fee exceeds the maximum fee", +) + +// ErrReservationReanchorTxFeeTooHigh is returned when the estimated fee for a +// reservation re-anchor transaction exceeds the on-chain maximum. +var ErrReservationReanchorTxFeeTooHigh = fmt.Errorf( + "reservation re-anchor estimated fee exceeds the maximum fee", +) + +// ReservationReanchorTask is a task that may produce a reservation re-anchor +// proposal. The wallet enters this task when the source wallet has begun a +// move to a new wallet (state StateMovingFunds) or when the source wallet's +// main UTXO has dropped below the moving funds dust threshold (below-dust +// re-anchor). For every reservation currently custodied by the wallet, the +// task picks a destination wallet and assembles a 1-input-1-output re-anchor +// transaction moving the anchor outpoint into that destination wallet. +type ReservationReanchorTask struct { + chain Chain + btcChain bitcoin.Chain +} + +// NewReservationReanchorTask returns a new ReservationReanchorTask bound to +// the given tbtc and Bitcoin chains. +func NewReservationReanchorTask( + chain Chain, + btcChain bitcoin.Chain, +) *ReservationReanchorTask { + return &ReservationReanchorTask{ + chain: chain, + btcChain: btcChain, + } +} + +// ActionType returns the type of wallet action this task produces. +func (rrt *ReservationReanchorTask) ActionType() tbtc.WalletActionType { + return tbtc.ActionReservationReanchor +} + +// Run evaluates whether the given wallet needs to re-anchor any of its +// reservations and returns a single ReservationReanchorProposal for the first +// reservation found to be re-anchorable. A wallet is a candidate for re-anchor +// when either: +// - it has entered the StateMovingFunds state (the wallet is migrating and +// reservations must be released to a live wallet), or +// - its main UTXO has dropped below the moving funds dust threshold (a +// re-anchor frees value from the wallet's main UTXO pool into a fresh +// reservation anchor held by another live wallet). +// +// Returns (nil, false, nil) when no reservation is eligible; callers should +// treat that as a benign no-op for the coordination window. +func (rrt *ReservationReanchorTask) Run( + request *tbtc.CoordinationProposalRequest, +) ( + tbtc.CoordinationProposal, + bool, + error, +) { + walletPublicKeyHash := request.WalletPublicKeyHash + + taskLogger := logger.With( + zap.String("task", rrt.ActionType().String()), + zap.String("walletPKH", fmt.Sprintf("0x%x", walletPublicKeyHash)), + ) + + walletChainData, err := rrt.chain.GetWallet(walletPublicKeyHash) + if err != nil { + return nil, false, fmt.Errorf( + "cannot get wallet chain data: [%w]", + err, + ) + } + + migrating := walletChainData.State == tbtc.StateMovingFunds + if !migrating { + // Check the below-dust trigger. Re-anchor also unlocks wallet value + // when the main UTXO has fallen below the moving funds dust + // threshold, so wallets in StateLive may still need to re-anchor + // before they enter the moving funds flow. + needs, err := rrt.isBelowMovingFundsDustThreshold( + taskLogger, + walletPublicKeyHash, + ) + if err != nil { + return nil, false, err + } + + if !needs { + taskLogger.Info("wallet is not eligible for reservation re-anchor") + return nil, false, nil + } + } + + reservationKeys, err := rrt.chain.WalletReservations(walletPublicKeyHash) + if err != nil { + return nil, false, fmt.Errorf( + "cannot list wallet reservations: [%w]", + err, + ) + } + + if len(reservationKeys) == 0 { + taskLogger.Info("wallet has no reservations to re-anchor") + return nil, false, nil + } + + liveWalletsCount, err := rrt.chain.GetLiveWalletsCount() + if err != nil { + return nil, false, fmt.Errorf( + "cannot get live wallets count: [%w]", + err, + ) + } + + if liveWalletsCount == 0 { + taskLogger.Info("no live wallets available for re-anchor target") + return nil, false, nil + } + + for _, reservationKey := range reservationKeys { + // Filter out reservations that already have a pending action: the + // Bridge will reject a duplicate re-anchor request while one is + // already in flight. + reservation, err := rrt.chain.GetReservation(reservationKey) + if err != nil { + taskLogger.Errorf( + "cannot get reservation [0x%x]: [%v]", + reservationKey, + err, + ) + continue + } + + if reservation.State != tbtc.ReservationStateActive { + taskLogger.Infof( + "reservation [0x%x] not in Active state (state=%v), skipping", + reservationKey, + reservation.State, + ) + continue + } + + if hasPendingAction(reservationKey, request, rrt.chain, taskLogger) { + continue + } + + targetWalletPublicKeyHash, err := rrt.findTargetWallet( + taskLogger, + walletPublicKeyHash, + ) + if err != nil { + return nil, false, fmt.Errorf( + "cannot pick re-anchor target wallet: [%w]", + err, + ) + } + + proposal, err := rrt.ProposeReservationReanchor( + taskLogger, + walletPublicKeyHash, + reservationKey, + reservation.RequestNonce+1, + targetWalletPublicKeyHash, + 0, + ) + if err != nil { + return nil, false, fmt.Errorf( + "cannot prepare reservation re-anchor proposal: [%w]", + err, + ) + } + + return proposal, true, nil + } + + taskLogger.Info("no reservations eligible for re-anchor") + return nil, false, nil +} + +// ProposeReservationReanchor assembles a single reservation re-anchor proposal +// for the given reservation, targeting the given wallet. The supplied fee may +// be 0 to trigger on-chain-driven fee estimation; the caller is responsible +// for providing a RequestNonce that is exactly current_request_nonce + 1 on +// the reservation's view (the action generation being authorized). +func (rrt *ReservationReanchorTask) ProposeReservationReanchor( + taskLogger log.StandardLogger, + sourceWalletPublicKeyHash [20]byte, + reservationKey *big.Int, + requestNonce uint64, + targetWalletPublicKeyHash [20]byte, + fee int64, +) (*tbtc.ReservationReanchorProposal, error) { + if reservationKey == nil { + return nil, fmt.Errorf("reservation key is required") + } + if requestNonce == 0 { + return nil, fmt.Errorf("request nonce must be > 0") + } + if targetWalletPublicKeyHash == [20]byte{} { + return nil, fmt.Errorf("target wallet public key hash is required") + } + + taskLogger.Infof( + "preparing a reservation re-anchor proposal for reservation [0x%x]", + reservationKey, + ) + + reservation, err := rrt.chain.GetReservation(reservationKey) + if err != nil { + return nil, fmt.Errorf( + "cannot get reservation [0x%x]: [%w]", + reservationKey, + err, + ) + } + + if reservation.AnchorUtxo == nil { + return nil, fmt.Errorf( + "reservation [0x%x] has no anchor UTXO", + reservationKey, + ) + } + + // Estimate fee if it's missing. The Bridge caps each reservation + // lifecycle transaction with its own ReservationTxMaxFee, not the + // moving-funds TxMaxTotalFee, so we use the reservation parameters + // directly. + if fee <= 0 { + taskLogger.Infof("estimating reservation re-anchor transaction fee") + + params, err := rrt.chain.ReservationParameters() + if err != nil { + return nil, fmt.Errorf( + "cannot get reservation parameters: [%w]", + err, + ) + } + + fee, err = estimateReservationReanchorFee( + rrt.btcChain, + params.ReservationTxMaxFee, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot estimate reservation re-anchor transaction fee: [%w]", + err, + ) + } + } + + taskLogger.Infof("reservation re-anchor transaction fee: [%d]", fee) + + proposal := &tbtc.ReservationReanchorProposal{ + ReservationKey: new(big.Int).Set(reservationKey), + RequestNonce: requestNonce, + TargetWalletPublicKeyHash: targetWalletPublicKeyHash, + ReanchorTxFee: big.NewInt(fee), + } + + if err := rrt.chain.ValidateReservationReanchorProposal( + sourceWalletPublicKeyHash, + proposal, + ); err != nil { + return nil, fmt.Errorf( + "failed to verify reservation re-anchor proposal: [%w]", + err, + ) + } + + return proposal, nil +} + +// findTargetWallet picks a live destination wallet from the on-chain wallet +// registry, mirroring the moving funds target selection. The new wallet must +// be in StateLive and must not be the source wallet itself. +func (rrt *ReservationReanchorTask) findTargetWallet( + taskLogger log.StandardLogger, + sourceWalletPublicKeyHash [20]byte, +) ([20]byte, error) { + events, err := rrt.chain.PastNewWalletRegisteredEvents(nil) + if err != nil { + return [20]byte{}, fmt.Errorf( + "failed to get past new wallet registered events: [%v]", + err, + ) + } + + for i := len(events) - 1; i >= 0; i-- { + walletPubKeyHash := events[i].WalletPublicKeyHash + if walletPubKeyHash == sourceWalletPublicKeyHash { + continue + } + + wallet, err := rrt.chain.GetWallet(walletPubKeyHash) + if err != nil { + taskLogger.Errorf( + "failed to get wallet data for wallet with PKH [0x%x]: [%v]", + walletPubKeyHash, + err, + ) + continue + } + + if wallet.State == tbtc.StateLive { + return walletPubKeyHash, nil + } + } + + return [20]byte{}, fmt.Errorf("no live wallet available for re-anchor target") +} + +// isBelowMovingFundsDustThreshold returns true when the wallet's main UTXO +// value is below the moving funds dust threshold. The threshold is sourced +// from the on-chain MovingFundsParameters. A wallet without a main UTXO is +// considered to have fallen below the threshold. +func (rrt *ReservationReanchorTask) isBelowMovingFundsDustThreshold( + taskLogger log.StandardLogger, + walletPublicKeyHash [20]byte, +) (bool, error) { + params, err := rrt.chain.GetMovingFundsParameters() + if err != nil { + return false, fmt.Errorf( + "cannot get moving funds parameters: [%w]", + err, + ) + } + + walletChainData, err := rrt.chain.GetWallet(walletPublicKeyHash) + if err != nil { + return false, fmt.Errorf( + "cannot get wallet chain data: [%w]", + err, + ) + } + + if walletChainData.MainUtxoHash == [32]byte{} { + // No main UTXO on-chain, the wallet has fully depleted its pool and + // must release any reservation anchors. + taskLogger.Info("wallet has no main UTXO; below dust threshold") + return true, nil + } + + walletMainUtxo, err := tbtc.DetermineWalletMainUtxo( + walletPublicKeyHash, + rrt.chain, + rrt.btcChain, + ) + if err != nil { + return false, fmt.Errorf( + "cannot determine wallet main UTXO: [%w]", + err, + ) + } + + if walletMainUtxo == nil { + taskLogger.Info("wallet has no resolvable main UTXO; below dust threshold") + return true, nil + } + + below := walletMainUtxo.Value < int64(params.DustThreshold) + if below { + taskLogger.Infof( + "wallet main UTXO value [%d] below moving funds dust threshold [%d]", + walletMainUtxo.Value, + params.DustThreshold, + ) + } + return below, nil +} + +// hasPendingAction reports whether the on-chain reservation action +// generation at the wallet's current request nonce (if any) is in a pending +// state. This guards against duplicate re-anchor requests: the Bridge rejects +// a new request while the previous generation is still in flight. +func hasPendingAction( + reservationKey *big.Int, + request *tbtc.CoordinationProposalRequest, + chain Chain, + taskLogger log.StandardLogger, +) bool { + reservation, err := chain.GetReservation(reservationKey) + if err != nil { + taskLogger.Errorf( + "cannot re-read reservation [0x%x] for action state check: [%v]", + reservationKey, + err, + ) + return false + } + + if reservation.RequestNonce == 0 { + return false + } + + action, err := chain.GetReservationAction( + reservationKey, + reservation.RequestNonce, + ) + if err != nil { + taskLogger.Errorf( + "cannot get reservation action for [0x%x] nonce [%d]: [%v]", + reservationKey, + reservation.RequestNonce, + err, + ) + return false + } + + // Suppress unused-parameter lint while keeping request available for + // future filtering against the executing operator's wallet membership. + _ = request + + return action.State == tbtc.ReservationActionStatePending +} + +// estimateReservationReanchorFee estimates the fee for a reservation +// re-anchor transaction. The transaction has one P2WPKH input (the +// reservation anchor) and one P2WPKH output (the new anchor under the +// target wallet), so its virtual size is fixed for any single re-anchor. +func estimateReservationReanchorFee( + btcChain bitcoin.Chain, + txMaxFee uint64, +) (int64, error) { + sizeEstimator := bitcoin.NewTransactionSizeEstimator(). + AddPublicKeyHashInputs(1, true). + AddPublicKeyHashOutputs(1, true) + + transactionSize, err := sizeEstimator.VirtualSize() + if err != nil { + return 0, fmt.Errorf( + "cannot estimate transaction virtual size: [%v]", + err, + ) + } + + feeEstimator := bitcoin.NewTransactionFeeEstimator(btcChain) + totalFee, err := feeEstimator.EstimateFee(transactionSize) + if err != nil { + return 0, fmt.Errorf("cannot estimate transaction fee: [%v]", err) + } + + if uint64(totalFee) > txMaxFee { + return 0, ErrReservationReanchorTxFeeTooHigh + } + + // Enforce the safe minimum fee rate and buffer so a non-RBF + // reservation re-anchor transaction is never broadcast below the + // floor where it could get stuck and jam the wallet. + totalFee, err = applyWalletTxFeeFloor(totalFee, transactionSize, txMaxFee) + if err != nil { + return 0, err + } + + return totalFee, nil +} diff --git a/pkg/tbtcpg/reservation_reanchor_test.go b/pkg/tbtcpg/reservation_reanchor_test.go new file mode 100644 index 0000000000..9c6792bf7e --- /dev/null +++ b/pkg/tbtcpg/reservation_reanchor_test.go @@ -0,0 +1,174 @@ +package tbtcpg_test + +import ( + "math/big" + "testing" + + "github.com/go-test/deep" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/tbtc" + "github.com/keep-network/keep-core/pkg/tbtcpg" + pkgtest "github.com/keep-network/keep-core/pkg/tbtcpg/internal/test" +) + +func TestReservationReanchorTask_Run(t *testing.T) { + scenarios, err := pkgtest.LoadReservationReanchorTestScenario() + if err != nil { + t.Fatal(err) + } + + for _, scenario := range scenarios { + t.Run(scenario.Title, func(t *testing.T) { + tbtcChain := tbtcpg.NewLocalChain() + btcChain := tbtcpg.NewLocalBitcoinChain() + + tbtcChain.SetWallet( + scenario.SourceWalletPublicKeyHash, + &tbtc.WalletChainData{ + State: scenario.SourceWalletState, + MainUtxoHash: scenario.SourceWalletMainUtxoHashBytes, + }, + ) + + tbtcChain.SetMovingFundsParameters( + 1000000, + scenario.MovingFundsDustThreshold, + 0, + 0, + nil, + 0, + 0, + 0, + 0, + nil, + 0, + ) + + reservationKeys := make([]*big.Int, 0, len(scenario.Reservations)) + for _, r := range scenario.Reservations { + reservationKeys = append(reservationKeys, r.ReservationKey) + + anchorTxHash, err := bitcoin.NewHashFromString( + r.AnchorTxHash, + bitcoin.ReversedByteOrder, + ) + if err != nil { + t.Fatal(err) + } + + btcChain.SetTransaction(anchorTxHash, &bitcoin.Transaction{ + Version: 1, + Outputs: []*bitcoin.TransactionOutput{{ + Value: r.AnchorValue, + PublicKeyScript: []byte{}, + }}, + }) + + tbtcChain.SetReservation(r.ReservationKey, &tbtc.Reservation{ + WalletPublicKeyHash: r.WalletPublicKeyHash, + AnchorUtxo: &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHash, + OutputIndex: r.AnchorTxOutputIndex, + }, + Value: r.AnchorValue, + }, + State: r.State, + RequestNonce: r.RequestNonce, + }) + + if r.HasPendingAction { + tbtcChain.SetReservationAction( + r.ReservationKey, + r.RequestNonce, + &tbtc.ReservationAction{ + ActionType: tbtc.ReservationActionTypeReanchor, + State: r.PendingActionState, + }, + ) + } + } + tbtcChain.SetWalletReservations( + scenario.SourceWalletPublicKeyHash, + reservationKeys, + ) + + tbtcChain.SetReservationParameters(tbtc.ReservationParameters{ + ReservationTxMaxFee: scenario.ReservationTxMaxFee, + }) + + btcChain.SetEstimateSatPerVByteFee(1, scenario.EstimateSatPerVByteFee) + + if scenario.TargetWalletPublicKeyHash != [20]byte{} { + err := tbtcChain.AddPastNewWalletRegisteredEvent( + nil, + &tbtc.NewWalletRegisteredEvent{ + WalletPublicKeyHash: scenario.TargetWalletPublicKeyHash, + }, + ) + if err != nil { + t.Fatal(err) + } + + tbtcChain.SetWallet( + scenario.TargetWalletPublicKeyHash, + &tbtc.WalletChainData{ + State: tbtc.StateLive, + }, + ) + } + + tbtcChain.SetLiveWalletsCount(scenario.LiveWalletsCount) + + task := tbtcpg.NewReservationReanchorTask(tbtcChain, btcChain) + + if scenario.ExpectedProposal != nil { + err := tbtcChain.SetReservationReanchorProposalValidationResult( + scenario.SourceWalletPublicKeyHash, + scenario.ExpectedProposal, + true, + ) + if err != nil { + t.Fatal(err) + } + } + + proposal, _, err := task.Run( + &tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: scenario.SourceWalletPublicKeyHash, + }, + ) + + expectedErrStr := "" + if scenario.ExpectedErr != nil { + expectedErrStr = scenario.ExpectedErr.Error() + } + actualErrStr := "" + if err != nil { + actualErrStr = err.Error() + } + if expectedErrStr != actualErrStr { + t.Errorf( + "unexpected error\nexpected: %v\nactual: %v", + scenario.ExpectedErr, + err, + ) + } + + var actualProposals []*tbtc.ReservationReanchorProposal + if p, ok := proposal.(*tbtc.ReservationReanchorProposal); ok && p != nil { + actualProposals = append(actualProposals, p) + } + + var expectedProposals []*tbtc.ReservationReanchorProposal + if p := scenario.ExpectedProposal; p != nil { + expectedProposals = append(expectedProposals, p) + } + + if diff := deep.Equal(actualProposals, expectedProposals); diff != nil { + t.Errorf("invalid reservation re-anchor proposal: %v", diff) + } + }) + } +} From 48985451d1e558b0834ed2dbf28f4f81def9d0f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 26 Aug 2026 11:08:18 +0000 Subject: [PATCH 13/39] feat(tbtc): wire reservation executors and watchers --- cmd/start.go | 15 ++ pkg/maintainer/spv/config.go | 14 ++ pkg/maintainer/spv/reservation_wiring.go | 211 +++++++++++++++++++++++ pkg/maintainer/spv/spv.go | 81 +++++++++ pkg/tbtc/tbtc.go | 46 +++++ pkg/tbtcpg/tbtcpg.go | 24 ++- 6 files changed, 390 insertions(+), 1 deletion(-) create mode 100644 pkg/maintainer/spv/reservation_wiring.go diff --git a/cmd/start.go b/cmd/start.go index c5bc8902f2..41b33cab7c 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -22,6 +22,7 @@ import ( "github.com/keep-network/keep-core/pkg/clientinfo" "github.com/keep-network/keep-core/pkg/firewall" "github.com/keep-network/keep-core/pkg/generator" + "github.com/keep-network/keep-core/pkg/maintainer/spv" "github.com/keep-network/keep-core/pkg/net" "github.com/keep-network/keep-core/pkg/net/libp2p" "github.com/keep-network/keep-core/pkg/net/retransmission" @@ -160,6 +161,19 @@ func start(cmd *cobra.Command) error { proposalGenerator := tbtcpg.NewProposalGenerator( tbtcChain, btcChain, + clientConfig.Tbtc.Reservations.Enabled, + ) + + // PR H: when reservations are enabled, hand tbtc.Initialize a + // wiring callback that constructs the reservation watchers in the + // spv package and subscribes them to the chain. The wiring lives + // in spv because that is where the watcher types are defined; the + // indirection keeps the tbtc package free of any static import of + // spv (which would cycle with spv's existing import of tbtc). + wireReservationWatchers := tbtc.ReservationWatchersWirer( + func(ctx context.Context, chain tbtc.Chain) error { + return spv.WireReservationWatchers(ctx, chain, tbtcChain) + }, ) err = tbtc.Initialize( @@ -175,6 +189,7 @@ func start(cmd *cobra.Command) error { clientInfoRegistry, perfMetrics, // Pass the existing performance metrics instance to avoid duplicate registrations clientConfig.Ethereum.Network, + wireReservationWatchers, ) if err != nil { return fmt.Errorf("error initializing TBTC: [%v]", err) diff --git a/pkg/maintainer/spv/config.go b/pkg/maintainer/spv/config.go index 49cdfe40d9..671c8a0046 100644 --- a/pkg/maintainer/spv/config.go +++ b/pkg/maintainer/spv/config.go @@ -65,4 +65,18 @@ type Config struct { // IdleBackoffTime is a wait time which should be applied when there are no // more transaction proofs to submit. IdleBackoffTime time.Duration + + // Reservations gates the m1 reservation feature within the SPV maintainer: + // reservation acceptance / re-anchor proof tasks and the stranding / + // stale-deposit / action-timeout watchers. When disabled the SPV maintainer + // constructs without any reservation plumbing. + Reservations ReservationsConfig +} + +// ReservationsConfig holds the reservation-related spv.Config fields. The +// structure mirrors tbtc.ReservationsConfig so an operator can keep the two +// flags in lockstep via configuration. +type ReservationsConfig struct { + // Enabled toggles reservation plumbing in the SPV maintainer. + Enabled bool } diff --git a/pkg/maintainer/spv/reservation_wiring.go b/pkg/maintainer/spv/reservation_wiring.go new file mode 100644 index 0000000000..55b58d6b1e --- /dev/null +++ b/pkg/maintainer/spv/reservation_wiring.go @@ -0,0 +1,211 @@ +package spv + +import ( + "context" + "math/big" + "time" + + "github.com/keep-network/keep-core/pkg/tbtc" + + "github.com/ipfs/go-log/v2" +) + +var reservationWiringLogger = log.Logger("keep-maintainer-spv-reservations") + +// DefaultReservationStaleDepositPollInterval is the default poll interval used +// by the stale-deposit watcher fallback loop. The Bridge does not expose a +// live subscription for DepositRevealed in m1, so the wiring layer falls back +// to PastDepositRevealedEvents on a coarse interval and dispatches each new +// reveal to the watcher. The interval mirrors the action-timeout poll +// cadence so a single tick covers both reservation timers. +const DefaultReservationStaleDepositPollInterval = 1 * time.Minute + +// DefaultReservationActionTimeoutPollInterval is the default poll interval +// for the action-timeout watcher's Run loop. It is intentionally conservative +// (1 minute) to limit Bridge load until the production wiring tightens the +// cadence. Operators can shorten the interval once the full integration ships. +const DefaultReservationActionTimeoutPollInterval = 1 * time.Minute + +// WireReservationWatchers is the integration entry point that the m1 PR H +// coordination layer calls when config.Reservations.Enabled is true. It +// constructs the three reservation watchers (stranding, stale-deposit, +// action-timeout), wires their Bridge-facing notifiers to the chain, and +// subscribes each watcher to its source event. +// +// The function lives in the spv package because that is where the watcher +// types live; the coordination layer invokes it via a callback supplied by +// cmd/start.go so that the tbtc package does not need a static import of spv +// (which would cycle with spv's existing import of tbtc). +// +// The wiring is intentionally tolerant of the m1 placeholder signal: each +// event handler stores the watcher and event for the production integration +// step rather than dispatching a real call. This keeps the gate semantics +// (`config.Reservations.Enabled` is the single switch) intact while leaving +// the heavy lifting to the follow-up PR that lands the live wiring. +// +// `chain` is the tbtc.Chain used both for event subscriptions (On*) and for +// the watcher notifiers (Notify*). `ctx` controls the goroutine lifetimes +// started by the wiring function. +func WireReservationWatchers( + ctx context.Context, + tbtcChain tbtc.Chain, + spvChain Chain, +) error { + // The watcher constructors require the SPV-specific Chain interface + // because they call into the SPV proof submission surface + // (GetReservation, GetReservationAction, etc.). The Bridge-facing + // On* event subscriptions require the broader tbtc.Chain interface + // because the SPV interface omits event subscriptions. Both chains + // point at the same underlying handle in production; this function + // threads them through to the right call sites. + chain := spvChain + strandingWatcher := NewReservationStrandingWatcher( + chain, + ReservationStrandingNotifierFunc( + func(reservationKey *big.Int) error { + return chain.NotifyReservationStranded(reservationKey) + }, + ), + ) + + staleDepositWatcher := NewReservationStaleDepositWatcher( + chain, + StaleReservedDepositNotifierFunc( + func(depositKey *big.Int) error { + return chain.NotifyStaleReservedDeposit(depositKey) + }, + ), + ) + + // The action-timeout watcher requires a wallet members resolver that the + // production sortition backend will provide. Until the integration lands + // the placeholder resolver returns an empty slice; the watcher treats + // empty membership as a no-op for the m1 bridge notification shape. + membersResolver := WalletMembersResolverFunc( + func(walletPublicKeyHash [20]byte) ([]uint32, error) { + return nil, nil + }, + ) + actionTimeoutWatcher := NewReservationActionTimeoutWatcher( + chain, + ReservationActionTimeoutNotifierFunc( + func(reservationKey *big.Int, walletMembersIDs []uint32) error { + return chain.NotifyReservationActionTimeout( + reservationKey, + walletMembersIDs, + ) + }, + ), + membersResolver, + DefaultReservationActionTimeoutPollInterval, + ) + + subscribeReservationWalletClosed(ctx, tbtcChain, strandingWatcher) + subscribeReservationActionTimedOut(ctx, tbtcChain, actionTimeoutWatcher) + startStaleDepositPoll(ctx, tbtcChain, staleDepositWatcher) + startActionTimeoutRun(ctx, actionTimeoutWatcher) + + return nil +} + +// subscribeReservationWalletClosed registers the stranding watcher against +// the chain's wallet close / termination events. The integration step that +// lands the wallet-ID -> public-key-hash mapping will dispatch the watcher +// here; for now we hold the watcher reference so the gate semantics are +// observable in the running process. +func subscribeReservationWalletClosed( + ctx context.Context, + tbtcChain tbtc.Chain, + watcher *ReservationStrandingWatcher, +) { + // Use the broader tbtc.Chain so the OnWalletClosed subscription is + // available; the SPV-specific Chain does not expose event + // subscriptions. + chain := tbtcChain + _ = chain.OnWalletClosed(func(event *tbtc.WalletClosedEvent) { + // PR H placeholder: the production wiring resolves the + // event.WalletID into the corresponding wallet public key + // hash and dispatches watcher.CheckReservationStrandingForWallet + // on a worker goroutine. The wiring step that adds the + // mapping is delivered by the follow-up integration PR. + _ = watcher + _ = event + reservationWiringLogger.Debug( + "received wallet closed event; stranding watcher integration " + + "is a placeholder in PR H", + ) + }) +} + +// subscribeReservationActionTimedOut registers the action-timeout watcher +// against the chain's on-chain ReservationActionTimedOut event. The +// production wiring dispatches watcher.CheckReservationActionTimeouts from +// here; for now we hold the watcher reference so the gate semantics are +// observable in the running process. +func subscribeReservationActionTimedOut( + ctx context.Context, + tbtcChain tbtc.Chain, + watcher *ReservationActionTimeoutWatcher, +) { + chain := tbtcChain + _ = chain.OnReservationActionTimedOut( + func(event *tbtc.ReservationActionTimedOutEvent) { + // PR H placeholder: the production wiring reads the + // reservation key from the event and dispatches + // watcher.CheckReservationActionTimeouts on a worker + // goroutine. + _ = watcher + _ = event + reservationWiringLogger.Debug( + "received reservation action timed out event; " + + "action-timeout watcher integration is a " + + "placeholder in PR H", + ) + }, + ) +} + +// startStaleDepositPoll runs the stale-deposit watcher integration as a +// polling loop over PastDepositRevealedEvents. The Bridge does not expose a +// live subscription for DepositRevealed in m1, so this loop is the +// placeholder source: each tick fetches the events since the last seen +// block and dispatches them to the watcher. +// +// The poller is intentionally tolerant of chain errors: a transient RPC +// failure logs and continues rather than aborting the wiring. +func startStaleDepositPoll( + ctx context.Context, + tbtcChain tbtc.Chain, + watcher *ReservationStaleDepositWatcher, +) { + chain := tbtcChain + // PR H placeholder: the live subscription integration lands in + // the follow-up PR; until then the wiring keeps the watcher alive + // but does not invoke OnDepositRevealed. Holding the watcher + // reference is enough to make the gate observable. + _ = watcher + _ = chain + _ = ctx + reservationWiringLogger.Debug( + "reservation stale-deposit watcher constructed; live polling " + + "integration is a placeholder in PR H", + ) +} + +// 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. +func startActionTimeoutRun( + ctx context.Context, + watcher *ReservationActionTimeoutWatcher, +) { + go func() { + if err := watcher.Run(); err != nil { + reservationWiringLogger.Errorf( + "failed to start reservation action-timeout watcher: [%v]", + err, + ) + } + }() +} diff --git a/pkg/maintainer/spv/spv.go b/pkg/maintainer/spv/spv.go index 133e2f48c8..b40014c84f 100644 --- a/pkg/maintainer/spv/spv.go +++ b/pkg/maintainer/spv/spv.go @@ -51,6 +51,35 @@ func Initialize( btcChain: btcChain, } + 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. + proofTypes[tbtc.ActionReservationAnchor] = struct { + unprovenTransactionsGetter unprovenTransactionsGetter + transactionProofSubmitter transactionProofSubmitter + }{ + unprovenTransactionsGetter: getUnprovenReservationAcceptanceTransactions, + transactionProofSubmitter: SubmitReservationAcceptanceProof, + } + proofTypes[tbtc.ActionReservationReanchor] = struct { + unprovenTransactionsGetter unprovenTransactionsGetter + 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, + } + } + go spvMaintainer.startControlLoop(ctx) } @@ -475,3 +504,55 @@ type spvProofAssembler func( requiredConfirmations uint, btcChain bitcoin.Chain, ) (*bitcoin.Transaction, *bitcoin.SpvProof, error) + + +// getUnprovenReservationAcceptanceTransactions is a placeholder for the +// reservation acceptance proof task. The production wiring for reservation +// acceptance proofs is delivered by the reservation watcher integration that +// translates wallet-side acceptance events into SPV proof submissions; until +// that wiring lands this getter returns no transactions so the generic proof +// loop skips reservation acceptance cleanly. +// +// Marked by PR H; the gate on config.Reservations.Enabled ensures the task is +// only attached to proofTypes when reservations are enabled. +func getUnprovenReservationAcceptanceTransactions( + historyDepth uint64, + transactionLimit int, + btcChain bitcoin.Chain, + spvChain Chain, +) ([]*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 +} diff --git a/pkg/tbtc/tbtc.go b/pkg/tbtc/tbtc.go index fa009348b9..c7f65795cb 100644 --- a/pkg/tbtc/tbtc.go +++ b/pkg/tbtc/tbtc.go @@ -96,11 +96,39 @@ type Config struct { PreParamsGenerationConcurrency int // Concurrency level for key-generation for tECDSA. KeyGenerationConcurrency int + // Reservations gates the m1 reservation feature (acceptance, re-anchor, + // stranding / stale / action-timeout watchers). When disabled the + // coordination layer constructs without any reservation plumbing, so + // non-reservation deployments stay side-effect free. + Reservations ReservationsConfig +} + +// ReservationsConfig holds the reservation-related tbtc.Config fields. It is +// a separate type so future reservation knobs (poll intervals, cap overrides) +// can be added without breaking the top-level Config layout. +type ReservationsConfig struct { + // Enabled toggles reservation acceptance / re-anchor proposal + // generation and reservation watcher wiring. Defaults to false so + // existing deployments opt in explicitly. + Enabled bool } // Initialize kicks off the TBTC by initializing internal state, ensuring // preconditions like staking are met, and then kicking off the internal TBTC // implementation. Returns an error if this failed. +// ReservationWatchersWirer is the contract the Initialize caller fulfils +// to gate the PR H reservation watcher wiring on config.Reservations.Enabled. +// The signature accepts the live tbtc.Chain so the wiring can subscribe to +// the On* event surface and forward Bridge notifications; production +// implementations live in pkg/maintainer/spv and are passed in by cmd/start.go +// to avoid a static tbtc -> spv import cycle. +// +// When config.Reservations.Enabled is true and the wirer is non-nil, +// Initialize invokes the wirer after the existing event subscriptions have +// been registered so the watcher event handlers see the same chain handle. +// When Reservations.Enabled is false the wirer is not invoked. +type ReservationWatchersWirer func(ctx context.Context, chain Chain) error + func Initialize( ctx context.Context, chain Chain, @@ -114,6 +142,7 @@ func Initialize( clientInfo *clientinfo.Registry, perfMetrics *clientinfo.PerformanceMetrics, ethereumNetwork ethereum.Network, + wireReservationWatchers ReservationWatchersWirer, ) error { groupParameters := defaultGroupParameters(ethereumNetwork) @@ -374,6 +403,23 @@ func Initialize( }() }) + if config.Reservations.Enabled && wireReservationWatchers != nil { + // PR H: wire reservation watchers (stranding, stale-deposit, + // action-timeout). The wiring function is supplied by the + // caller (cmd/start.go) so the tbtc package never imports spv + // directly. The wirer constructs the watchers and subscribes + // them to the chain; construction itself is gated on + // Reservations.Enabled inside spv. Failing to wire the watchers + // is fatal: the operator opted into reservations, so a missing + // watcher would silently strand anchors. + if err := wireReservationWatchers(ctx, chain); err != nil { + return fmt.Errorf( + "failed to wire reservation watchers: [%w]", + err, + ) + } + } + return nil } diff --git a/pkg/tbtcpg/tbtcpg.go b/pkg/tbtcpg/tbtcpg.go index 38e2be8628..6844117342 100644 --- a/pkg/tbtcpg/tbtcpg.go +++ b/pkg/tbtcpg/tbtcpg.go @@ -57,10 +57,18 @@ func (pg *ProposalGenerator) SetRedemptionMetricsRecorder(recorder interface { } } -// NewProposalGenerator returns a new proposal generator. +// NewProposalGenerator returns a new proposal generator. When +// reservationsEnabled is true the proposal generator appends the reservation +// acceptance (anchor) and re-anchor tasks to the standard task list so that +// wallets with reservations can produce anchor and re-anchor proposals in +// addition to the default sweep / redemption / heartbeat / moving-funds / +// moved-funds-sweep proposals. When reservationsEnabled is false the +// reservation tasks are skipped entirely; the proposal generator is safe to +// construct in either mode and the existing task ordering is preserved. func NewProposalGenerator( chain Chain, btcChain bitcoin.Chain, + reservationsEnabled bool, ) *ProposalGenerator { tasks := []ProposalTask{ NewDepositSweepTask(chain, btcChain), @@ -70,6 +78,20 @@ func NewProposalGenerator( NewMovedFundsSweepTask(chain, btcChain), } + if reservationsEnabled { + // PR H: reservation acceptance (anchor) and re-anchor tasks. + // These tasks only run when the operator has opted into the m1 + // reservation feature via config.Reservations.Enabled; the gate + // is applied at task registration so the coordination loop + // never even considers these actions on a non-reservation + // deployment. + tasks = append( + tasks, + NewReservationAcceptanceTask(chain, btcChain), + NewReservationReanchorTask(chain, btcChain), + ) + } + return &ProposalGenerator{ tasks: tasks, } From 5daf210149054c73ccd35bffb82b2c3e02816fc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Thu, 27 Aug 2026 11:30:11 +0000 Subject: [PATCH 14/39] fix: add context to GetTransactionConfirmations call in reservation acceptance task and fix gofmt --- pkg/chain/ethereum/tbtc.go | 38 ++++++++--------- pkg/maintainer/spv/chain_test.go | 9 ++-- .../spv/reservation_acceptance_proof.go | 2 +- .../reservation_action_timeout_watch_test.go | 2 +- .../spv/reservation_reanchor_proof.go | 6 +-- .../spv/reservation_stale_deposit_watch.go | 4 +- pkg/maintainer/spv/spv.go | 1 - pkg/tbtcpg/chain_test.go | 20 ++++----- pkg/tbtcpg/internal/test/marshaling.go | 10 ++--- .../internal/test/reservation_acceptance.go | 4 +- pkg/tbtcpg/internal/test/tbtcpgtest.go | 42 +++++++++---------- pkg/tbtcpg/reservation_acceptance.go | 2 + pkg/tbtcpg/reservation_acceptance_test.go | 2 +- pkg/tbtcpg/reservation_reanchor_test.go | 2 +- 14 files changed, 72 insertions(+), 72 deletions(-) diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index 3aaf5a6d1d..bb3ad344f6 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -2697,16 +2697,16 @@ func convertReservationFromAbiType( } return &tbtc.Reservation{ - Owner: chain.Address(abiReservation.Owner.String()), - MintedAmount: abiReservation.MintedAmount, - AcceptedAt: abiReservation.AcceptedAt, - WalletPublicKeyHash: abiReservation.WalletPubKeyHash, - AnchorUtxo: anchorUtxo, - ExpiresAt: abiReservation.ExpiresAt, - State: state, - RequestNonce: abiReservation.RequestNonce, - RetryCredit: abiReservation.RetryCredit, - DissolutionEligibleAt: abiReservation.DissolutionEligibleAt, + Owner: chain.Address(abiReservation.Owner.String()), + MintedAmount: abiReservation.MintedAmount, + AcceptedAt: abiReservation.AcceptedAt, + WalletPublicKeyHash: abiReservation.WalletPubKeyHash, + AnchorUtxo: anchorUtxo, + ExpiresAt: abiReservation.ExpiresAt, + State: state, + RequestNonce: abiReservation.RequestNonce, + RetryCredit: abiReservation.RetryCredit, + DissolutionEligibleAt: abiReservation.DissolutionEligibleAt, }, nil } @@ -2791,15 +2791,15 @@ func convertReservationParametersFromAbiType( }, ) *tbtc.ReservationParameters { return &tbtc.ReservationParameters{ - ReservationVault: chain.Address(abiParameters.ReservationVault.String()), - ReservationMinAmount: abiParameters.ReservationMinAmount, - ReservationTxMaxFee: abiParameters.ReservationTxMaxFee, - ReservationTermSeconds: abiParameters.ReservationTermSeconds, - ReservationDissolutionDelay: abiParameters.ReservationDissolutionDelay, - ReservationMaxTotalAmount: abiParameters.ReservationMaxTotalAmount, - ReservationTotalAmount: abiParameters.ReservationTotalAmount, - MaxReservationsPerWallet: abiParameters.MaxReservationsPerWallet, - ReservationActionTimeout: abiParameters.ReservationActionTimeout, + ReservationVault: chain.Address(abiParameters.ReservationVault.String()), + ReservationMinAmount: abiParameters.ReservationMinAmount, + ReservationTxMaxFee: abiParameters.ReservationTxMaxFee, + ReservationTermSeconds: abiParameters.ReservationTermSeconds, + ReservationDissolutionDelay: abiParameters.ReservationDissolutionDelay, + ReservationMaxTotalAmount: abiParameters.ReservationMaxTotalAmount, + ReservationTotalAmount: abiParameters.ReservationTotalAmount, + MaxReservationsPerWallet: abiParameters.MaxReservationsPerWallet, + ReservationActionTimeout: abiParameters.ReservationActionTimeout, ReservationRenewalWindowSeconds: abiParameters.ReservationRenewalWindowSeconds, } } diff --git a/pkg/maintainer/spv/chain_test.go b/pkg/maintainer/spv/chain_test.go index 0766bdfdcc..ceb92f17cd 100644 --- a/pkg/maintainer/spv/chain_test.go +++ b/pkg/maintainer/spv/chain_test.go @@ -102,10 +102,10 @@ type localChain struct { submittedActionTimeouts []*submittedReservationActionTimeout reservationParameters *tbtc.ReservationParameters - txProofDifficultyFactor *big.Int - currentEpoch uint64 - currentEpochDifficulty *big.Int - previousEpochDifficulty *big.Int + txProofDifficultyFactor *big.Int + currentEpoch uint64 + currentEpochDifficulty *big.Int + previousEpochDifficulty *big.Int submitReservationProofHook func( proofType uint8, txInfo *tbtc.BitcoinTxInfo, @@ -1121,7 +1121,6 @@ func (lc *localChain) PastReservationAcceptedEvents( return nil, nil } - // submitReservationProofHook, when non-nil, overrides the default panic // stub and gives the test full control over SubmitReservationProof behavior. var _ = func() bool { diff --git a/pkg/maintainer/spv/reservation_acceptance_proof.go b/pkg/maintainer/spv/reservation_acceptance_proof.go index 1f6d5dff7d..48f544d87e 100644 --- a/pkg/maintainer/spv/reservation_acceptance_proof.go +++ b/pkg/maintainer/spv/reservation_acceptance_proof.go @@ -40,4 +40,4 @@ func submitReservationAcceptanceProof( // This is a stub for the SPV proof side, pending final integration. return nil -} \ No newline at end of file +} diff --git a/pkg/maintainer/spv/reservation_action_timeout_watch_test.go b/pkg/maintainer/spv/reservation_action_timeout_watch_test.go index d1b1562cdd..5bc7744d35 100644 --- a/pkg/maintainer/spv/reservation_action_timeout_watch_test.go +++ b/pkg/maintainer/spv/reservation_action_timeout_watch_test.go @@ -16,7 +16,7 @@ import ( type recordingActionTimeoutMembers struct { walletIDs map[[20]byte][]uint32 calls [][20]byte - errByPKH map[[20]byte]error + errByPKH map[[20]byte]error } func (r *recordingActionTimeoutMembers) ResolveWalletMembers( diff --git a/pkg/maintainer/spv/reservation_reanchor_proof.go b/pkg/maintainer/spv/reservation_reanchor_proof.go index d8d5192fc3..ce9df2da8f 100644 --- a/pkg/maintainer/spv/reservation_reanchor_proof.go +++ b/pkg/maintainer/spv/reservation_reanchor_proof.go @@ -219,9 +219,9 @@ func buildReservationProofMainUtxo( anchorUtxo *bitcoin.UnspentTransactionOutput, ) *tbtc.BitcoinTxUTXO { var ( - txHash [32]byte - txOutIndex uint32 - txOutValue uint64 + txHash [32]byte + txOutIndex uint32 + txOutValue uint64 ) if anchorUtxo.Outpoint != nil { diff --git a/pkg/maintainer/spv/reservation_stale_deposit_watch.go b/pkg/maintainer/spv/reservation_stale_deposit_watch.go index 352eeed210..4c254fcc31 100644 --- a/pkg/maintainer/spv/reservation_stale_deposit_watch.go +++ b/pkg/maintainer/spv/reservation_stale_deposit_watch.go @@ -115,8 +115,8 @@ func (rsdw *ReservationStaleDepositWatcher) OnDepositRevealed( // Parameters: // - depositKey: the deposit identifier reported by the Bridge. // - now: the UNIX timestamp against which the action timeout is -// compared. Tests pass an explicit value; production passes -// time.Now().Unix() cast to uint32. +// compared. Tests pass an explicit value; production passes +// time.Now().Unix() cast to uint32. func (rsdw *ReservationStaleDepositWatcher) CheckStaleReservedDeposit( depositKey *big.Int, now uint32, diff --git a/pkg/maintainer/spv/spv.go b/pkg/maintainer/spv/spv.go index b40014c84f..b3e137d264 100644 --- a/pkg/maintainer/spv/spv.go +++ b/pkg/maintainer/spv/spv.go @@ -505,7 +505,6 @@ type spvProofAssembler func( btcChain bitcoin.Chain, ) (*bitcoin.Transaction, *bitcoin.SpvProof, error) - // getUnprovenReservationAcceptanceTransactions is a placeholder for the // reservation acceptance proof task. The production wiring for reservation // acceptance proofs is delivered by the reservation watcher integration that diff --git a/pkg/tbtcpg/chain_test.go b/pkg/tbtcpg/chain_test.go index 03abb9f9cc..9a783da8b9 100644 --- a/pkg/tbtcpg/chain_test.go +++ b/pkg/tbtcpg/chain_test.go @@ -65,16 +65,16 @@ type LocalChain struct { redemptionDelays map[[32]byte]time.Duration depositMinAge uint32 - reservations map[*big.Int]*tbtc.Reservation - reservationActions map[string]*tbtc.ReservationAction - reservationParametersValue tbtc.ReservationParameters - reservationParametersSet bool - reservationProposalValidations map[[32]byte]bool - reservationReanchorRequestSubmissions []*reservationReanchorRequestSubmission - reservationReanchoredEventEmissions []*tbtc.ReservationReanchoredEvent - reservationWalletKeys map[[20]byte][]*big.Int - liveWalletsCountValue uint32 - liveWalletsCountSet bool + reservations map[*big.Int]*tbtc.Reservation + reservationActions map[string]*tbtc.ReservationAction + reservationParametersValue tbtc.ReservationParameters + reservationParametersSet bool + reservationProposalValidations map[[32]byte]bool + reservationReanchorRequestSubmissions []*reservationReanchorRequestSubmission + reservationReanchoredEventEmissions []*tbtc.ReservationReanchoredEvent + reservationWalletKeys map[[20]byte][]*big.Int + liveWalletsCountValue uint32 + liveWalletsCountSet bool } func NewLocalChain() *LocalChain { diff --git a/pkg/tbtcpg/internal/test/marshaling.go b/pkg/tbtcpg/internal/test/marshaling.go index 632b1b199e..f27f431842 100644 --- a/pkg/tbtcpg/internal/test/marshaling.go +++ b/pkg/tbtcpg/internal/test/marshaling.go @@ -383,12 +383,12 @@ func (rrts *ReservationReanchorTestScenario) UnmarshalJSON(data []byte) error { ReservationKey string WalletPublicKeyHash string AnchorTxHash string - AnchorTxOutputIndex uint32 + AnchorTxOutputIndex uint32 AnchorValue int64 State string RequestNonce uint64 HasPendingAction bool - PendingActionState string + PendingActionState string } type reservationReanchorTestScenarioJSON struct { Title string @@ -402,7 +402,7 @@ func (rrts *ReservationReanchorTestScenario) UnmarshalJSON(data []byte) error { TargetWalletPublicKeyHash string - LiveWalletsCount uint32 + LiveWalletsCount uint32 MovingFundsDustThreshold uint64 ReservationTxMaxFee uint64 @@ -500,8 +500,8 @@ func (rj *reservationReanchorProposalJSON) convert() (*tbtc.ReservationReanchorP } result := &tbtc.ReservationReanchorProposal{ - RequestNonce: rj.RequestNonce, - ReanchorTxFee: big.NewInt(rj.ReanchorTxFee), + RequestNonce: rj.RequestNonce, + ReanchorTxFee: big.NewInt(rj.ReanchorTxFee), } if len(rj.ReservationKey) > 0 { result.ReservationKey = new(big.Int).SetBytes(hexToSlice(rj.ReservationKey)) diff --git a/pkg/tbtcpg/internal/test/reservation_acceptance.go b/pkg/tbtcpg/internal/test/reservation_acceptance.go index bca89e8a57..6f7c42f8e1 100644 --- a/pkg/tbtcpg/internal/test/reservation_acceptance.go +++ b/pkg/tbtcpg/internal/test/reservation_acceptance.go @@ -299,8 +299,8 @@ func (rats *ReservationAcceptanceTestScenario) UnmarshalJSON( Age: rd.Age, SweptAt: rd.SweptAt, Vault: rd.Vault, - parsedFundingTxHash: fundingTxHash, - parsedFundingTx: fundingTx, + parsedFundingTxHash: fundingTxHash, + parsedFundingTx: fundingTx, }, ) } diff --git a/pkg/tbtcpg/internal/test/tbtcpgtest.go b/pkg/tbtcpg/internal/test/tbtcpgtest.go index c0338086bf..19d2ced166 100644 --- a/pkg/tbtcpg/internal/test/tbtcpgtest.go +++ b/pkg/tbtcpg/internal/test/tbtcpgtest.go @@ -149,15 +149,15 @@ func LoadFindPendingRedemptionsTestScenario() ( // ReservationReanchorData holds the per-reservation data in a reservation // re-anchor test scenario. type ReservationReanchorData struct { - ReservationKey *big.Int - WalletPublicKeyHash [20]byte - AnchorTxHash string - AnchorTxOutputIndex uint32 - AnchorValue int64 - State tbtc.ReservationState - RequestNonce uint64 - HasPendingAction bool - PendingActionState tbtc.ReservationActionState + ReservationKey *big.Int + WalletPublicKeyHash [20]byte + AnchorTxHash string + AnchorTxOutputIndex uint32 + AnchorValue int64 + State tbtc.ReservationState + RequestNonce uint64 + HasPendingAction bool + PendingActionState tbtc.ReservationActionState } // ReservationReanchorTestScenario represents a test scenario of preparing a @@ -168,23 +168,23 @@ type ReservationReanchorTestScenario struct { SourceWalletPublicKeyHash [20]byte SourceWalletState tbtc.WalletState SourceWalletMainUtxoHashBytes [32]byte - SourceWalletMainUtxoValue int64 - SourceWalletMainUtxoTxHash string - SourceWalletMainUtxoTxIndex uint32 + SourceWalletMainUtxoValue int64 + SourceWalletMainUtxoTxHash string + SourceWalletMainUtxoTxIndex uint32 - TargetWalletPublicKeyHash [20]byte + TargetWalletPublicKeyHash [20]byte - LiveWalletsCount uint32 + LiveWalletsCount uint32 - MovingFundsDustThreshold uint64 - ReservationTxMaxFee uint64 - EstimateSatPerVByteFee int64 - ReanchorTxFee int64 + MovingFundsDustThreshold uint64 + ReservationTxMaxFee uint64 + EstimateSatPerVByteFee int64 + ReanchorTxFee int64 - Reservations []*ReservationReanchorData + Reservations []*ReservationReanchorData - ExpectedProposal *tbtc.ReservationReanchorProposal - ExpectedErr error + ExpectedProposal *tbtc.ReservationReanchorProposal + ExpectedErr error } // LoadReservationReanchorTestScenario loads all scenarios related to diff --git a/pkg/tbtcpg/reservation_acceptance.go b/pkg/tbtcpg/reservation_acceptance.go index e976d07a73..df4b888ec3 100644 --- a/pkg/tbtcpg/reservation_acceptance.go +++ b/pkg/tbtcpg/reservation_acceptance.go @@ -1,6 +1,7 @@ package tbtcpg import ( + "context" "fmt" "math/big" "strings" @@ -340,6 +341,7 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( } confirmations, err := rat.btcChain.GetTransactionConfirmations( + context.Background(), event.FundingTxHash, ) if err != nil { diff --git a/pkg/tbtcpg/reservation_acceptance_test.go b/pkg/tbtcpg/reservation_acceptance_test.go index 76f5c48963..d08a11e93e 100644 --- a/pkg/tbtcpg/reservation_acceptance_test.go +++ b/pkg/tbtcpg/reservation_acceptance_test.go @@ -229,7 +229,7 @@ func registerReservedDeposits( } else { dummyTx := &bitcoin.Transaction{ Outputs: []*bitcoin.TransactionOutput{{ - Value: 0, + Value: 0, PublicKeyScript: append([]byte{0x00, 0x20}, make([]byte, 32)...), }}, } diff --git a/pkg/tbtcpg/reservation_reanchor_test.go b/pkg/tbtcpg/reservation_reanchor_test.go index 9c6792bf7e..218fa643ed 100644 --- a/pkg/tbtcpg/reservation_reanchor_test.go +++ b/pkg/tbtcpg/reservation_reanchor_test.go @@ -26,7 +26,7 @@ func TestReservationReanchorTask_Run(t *testing.T) { tbtcChain.SetWallet( scenario.SourceWalletPublicKeyHash, &tbtc.WalletChainData{ - State: scenario.SourceWalletState, + State: scenario.SourceWalletState, MainUtxoHash: scenario.SourceWalletMainUtxoHashBytes, }, ) From 20cb160a60dc6295dd6fc43c7f39690883a6929d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 1 Sep 2026 14:37:34 +0000 Subject: [PATCH 15/39] fix(tbtc,spv): wire reservation watchers to real chain calls and add proof submission loop Resolves the no-op watcher/proof-submission cluster flagged in review: WireReservationWatchers previously subscribed stranding, stale-deposit, and action-timeout watchers to handlers that discarded their inputs, and submitReservationAcceptanceProof was an unimplemented stub. Both now call through to the real check/notify and proof-assembly paths. cmd/start.go now wires the reservation watchers directly against the Chain handle instead of threading them through tbtc.Initialize via the now-removed ReservationWatchersWirer callback type, since cmd/start.go already imports both tbtc and spv and there is no import-cycle reason for the indirection. clientinfo.NewPerformanceMetrics now takes the reservations-enabled flag so reservation action metrics are only registered when the feature is on. Also: - guard empty WalletMembersResolverFunc results before notifying watchers - fix nonce walk start in the action-timeout watcher - align nonce-base convention across SPV watchers (1-based) - fail-safe hasPendingAction on RPC error instead of assuming no action - remove dead depositToReservationKey identity-copy indirection - fix Errorf missing err argument in the stranding watcher - delete duplicate ReservationParametersFull declarations - add chain-error passthrough, notifier-error resilience, exact-timeout- boundary, and nil-notifier coverage for the stranding and stale-deposit watchers --- cmd/start.go | 40 +- config/config_test.go | 4 + pkg/maintainer/spv/chain.go | 27 + pkg/maintainer/spv/chain_test.go | 246 +++++++--- pkg/maintainer/spv/config.go | 16 +- .../spv/reservation_acceptance_proof.go | 192 +++++++- .../spv/reservation_action_timeout_watch.go | 287 +++++++---- .../reservation_action_timeout_watch_test.go | 78 +-- pkg/maintainer/spv/reservation_proof_loop.go | 463 ++++++++++++++++++ .../spv/reservation_reanchor_proof.go | 9 + .../spv/reservation_stale_deposit_watch.go | 47 +- .../reservation_stale_deposit_watch_test.go | 174 ++++++- .../spv/reservation_stranding_watch.go | 1 + .../spv/reservation_stranding_watch_test.go | 72 +++ pkg/maintainer/spv/reservation_wiring.go | 315 ++++++++---- pkg/maintainer/spv/spv.go | 86 +--- pkg/tbtc/chain.go | 23 - pkg/tbtc/chain_test.go | 4 - pkg/tbtc/coordination.go | 21 + pkg/tbtc/coordination_test.go | 1 + pkg/tbtc/node.go | 8 + pkg/tbtc/node_executors.go | 1 + pkg/tbtc/tbtc.go | 54 +- test/config.json | 5 +- test/config.toml | 3 + test/config.yaml | 2 + 26 files changed, 1714 insertions(+), 465 deletions(-) create mode 100644 pkg/maintainer/spv/reservation_proof_loop.go diff --git a/cmd/start.go b/cmd/start.go index 41b33cab7c..a938fefc1b 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -93,7 +93,11 @@ func start(cmd *cobra.Command) error { // Wire performance metrics into network provider if available var perfMetrics *clientinfo.PerformanceMetrics if clientInfoRegistry != nil { - perfMetrics = clientinfo.NewPerformanceMetrics(ctx, clientInfoRegistry) + perfMetrics = clientinfo.NewPerformanceMetrics( + ctx, + clientInfoRegistry, + clientConfig.Tbtc.Reservations.Enabled, + ) // Type assert to libp2p provider to set metrics recorder // The provider struct is not exported, so we use interface assertion if setter, ok := netProvider.(interface { @@ -164,18 +168,6 @@ func start(cmd *cobra.Command) error { clientConfig.Tbtc.Reservations.Enabled, ) - // PR H: when reservations are enabled, hand tbtc.Initialize a - // wiring callback that constructs the reservation watchers in the - // spv package and subscribes them to the chain. The wiring lives - // in spv because that is where the watcher types are defined; the - // indirection keeps the tbtc package free of any static import of - // spv (which would cycle with spv's existing import of tbtc). - wireReservationWatchers := tbtc.ReservationWatchersWirer( - func(ctx context.Context, chain tbtc.Chain) error { - return spv.WireReservationWatchers(ctx, chain, tbtcChain) - }, - ) - err = tbtc.Initialize( ctx, tbtcChain, @@ -189,11 +181,31 @@ func start(cmd *cobra.Command) error { clientInfoRegistry, perfMetrics, // Pass the existing performance metrics instance to avoid duplicate registrations clientConfig.Ethereum.Network, - wireReservationWatchers, ) if err != nil { return fmt.Errorf("error initializing TBTC: [%v]", err) } + + // Wire the reservation watchers (stranding, stale-deposit, + // action-timeout) directly against the same tbtcChain handle: + // cmd/start.go already imports both tbtc and spv, so there is no + // import-cycle reason to thread this through tbtc.Initialize via a + // callback type. Gated on the same flag that gates the reservation + // proposal generator tasks above. Failing to wire the watchers is + // fatal: the operator opted into reservations, so a missing + // watcher would silently strand anchors. + if clientConfig.Tbtc.Reservations.Enabled { + if err := spv.WireReservationWatchers( + ctx, + tbtcChain, + tbtcChain, + ); err != nil { + return fmt.Errorf( + "failed to wire reservation watchers: [%v]", + err, + ) + } + } } nodeHeader( diff --git a/config/config_test.go b/config/config_test.go index f8de558c4e..c4026903bb 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -233,6 +233,10 @@ func TestReadConfigFromFile(t *testing.T) { readValueFunc: func(c *Config) interface{} { return c.Maintainer.Spv.IdleBackoffTime }, expectedValue: 15 * time.Minute, }, + "Maintainer.Spv.Reservations.Enabled": { + readValueFunc: func(c *Config) interface{} { return c.Maintainer.Spv.Reservations.Enabled }, + expectedValue: true, + }, } for _, filePath := range filePaths { diff --git a/pkg/maintainer/spv/chain.go b/pkg/maintainer/spv/chain.go index 71acc042de..b30e2133e0 100644 --- a/pkg/maintainer/spv/chain.go +++ b/pkg/maintainer/spv/chain.go @@ -206,4 +206,31 @@ type Chain interface { PastReservationActionTimedOutEvents( filter *tbtc.ReservationActionTimedOutEventFilter, ) ([]*tbtc.ReservationActionTimedOutEvent, error) + + // PastReservationAcceptanceRequestedEvents fetches past + // ReservationAcceptanceRequested 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. + PastReservationAcceptanceRequestedEvents( + filter *tbtc.ReservationAcceptanceRequestedEventFilter, + ) ([]*tbtc.ReservationAcceptanceRequestedEvent, 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) + + // PastNewWalletRegisteredEvents fetches past NewWalletRegistered 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. + PastNewWalletRegisteredEvents( + filter *tbtc.NewWalletRegisteredEventFilter, + ) ([]*tbtc.NewWalletRegisteredEvent, error) + + // BuildDepositKey calculates the key used by the Bridge to store a + // deposit request, which is a unique identifier for a deposit on-chain. + BuildDepositKey(fundingTxHash bitcoin.Hash, fundingOutputIndex uint32) *big.Int } diff --git a/pkg/maintainer/spv/chain_test.go b/pkg/maintainer/spv/chain_test.go index ceb92f17cd..b1c109ee35 100644 --- a/pkg/maintainer/spv/chain_test.go +++ b/pkg/maintainer/spv/chain_test.go @@ -94,18 +94,35 @@ type localChain struct { // 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 + reservations map[string]*tbtc.Reservation + reservationActions map[string]*tbtc.ReservationAction + reservedDeposits map[string]*reservedDepositRecord submittedStrandedKeys []*big.Int submittedStaleDeposits []*big.Int submittedActionTimeouts []*submittedReservationActionTimeout reservationParameters *tbtc.ReservationParameters - txProofDifficultyFactor *big.Int - currentEpoch uint64 - currentEpochDifficulty *big.Int - previousEpochDifficulty *big.Int + // 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. + walletReservationsErr error + isReservedDepositErr error + reservedDepositWalletErr error + + // Wallet registration and pending-action-request event state for the + // watcher dispatch and reservation proof loop tests. + newWalletRegisteredEvents []*tbtc.NewWalletRegisteredEvent + reservationAcceptanceRequestedEvents []*tbtc.ReservationAcceptanceRequestedEvent + reservationReanchorRequestedEvents []*tbtc.ReservationReanchorRequestedEvent + + txProofDifficultyFactor *big.Int + currentEpoch uint64 + currentEpochDifficulty *big.Int + previousEpochDifficulty *big.Int + // submitReservationProofHook, when non-nil, overrides the default + // success stub and gives the test full control over + // SubmitReservationProof behavior (e.g. to assert arguments or return + // an error). submitReservationProofHook func( proofType uint8, txInfo *tbtc.BitcoinTxInfo, @@ -130,9 +147,9 @@ func newLocalChain() *localChain { pastDepositRevealedEvents: make(map[[32]byte][]*tbtc.DepositRevealedEvent), pastMovingFundsCommitmentSubmittedEvents: make(map[[32]byte][]*tbtc.MovingFundsCommitmentSubmittedEvent), walletReservations: make(map[[20]byte][]*big.Int), - reservations: make(map[[16]byte]*tbtc.Reservation), - reservationActions: make(map[[24]byte]*tbtc.ReservationAction), - reservedDeposits: make(map[[16]byte]*reservedDepositRecord), + reservations: make(map[string]*tbtc.Reservation), + reservationActions: make(map[string]*tbtc.ReservationAction), + reservedDeposits: make(map[string]*reservedDepositRecord), submittedStrandedKeys: make([]*big.Int, 0), submittedStaleDeposits: make([]*big.Int, 0), submittedActionTimeouts: make([]*submittedReservationActionTimeout, 0), @@ -898,7 +915,7 @@ func (lc *localChain) GetReservation( lc.mutex.Lock() defer lc.mutex.Unlock() - key := bigIntToKey16(reservationKey) + key := bigIntKey(reservationKey) reservation, ok := lc.reservations[key] if !ok { return nil, fmt.Errorf("no reservation for given key") @@ -914,7 +931,7 @@ func (lc *localChain) setReservation( lc.mutex.Lock() defer lc.mutex.Unlock() - lc.reservations[bigIntToKey16(reservationKey)] = reservation + lc.reservations[bigIntKey(reservationKey)] = reservation } // GetReservationAction returns the reservation action previously installed @@ -947,50 +964,24 @@ func (lc *localChain) setReservationAction( lc.reservationActions[buildReservationActionKey(reservationKey, requestNonce)] = action } -// buildReservationActionKey produces a 24-byte map key encoding the -// reservation identifier and the nonce. The reservation identifier is -// truncated to its leading 16 bytes; tests are responsible for choosing -// reservation keys that are unique in those leading bytes. +// buildReservationActionKey produces a map key encoding the reservation +// identifier and the nonce, using the big.Int's full base-16 text +// representation so distinct reservation keys can never collide. func buildReservationActionKey( reservationKey *big.Int, requestNonce uint64, -) [24]byte { - var out [24]byte - if reservationKey != nil { - fillBigInt16(reservationKey, out[:16]) - } - binary.BigEndian.PutUint64(out[16:24], requestNonce) - return out +) string { + return fmt.Sprintf("%s/%d", bigIntKey(reservationKey), requestNonce) } -// fillBigInt16 writes the leading 16 bytes of the big-endian representation -// of v into dst. The function is allocation-free so tests can use it inside -// hot paths. -func fillBigInt16(v *big.Int, dst []byte) { +// bigIntKey returns a map key string from a big.Int using its full base-16 +// text representation, so distinct values can never collide. Returns the +// empty string for nil. +func bigIntKey(v *big.Int) string { if v == nil { - return - } - bytes := v.Bytes() - offset := len(dst) - len(bytes) - if offset < 0 { - // Truncate to dst size; keeps the trailing high bytes of v. - bytes = bytes[len(bytes)-len(dst):] - offset = 0 + return "" } - for i, b := range bytes { - dst[offset+i] = b - } -} - -// bigIntToKey16 returns a 16-byte map key from a big.Int by truncating to -// the leading 16 bytes (right-aligned). Returns the zero key for nil. -func bigIntToKey16(v *big.Int) [16]byte { - var out [16]byte - if v == nil { - return out - } - fillBigInt16(v, out[:]) - return out + return v.Text(16) } // ReservationParameters returns the reservation parameters previously @@ -1030,6 +1021,10 @@ func (lc *localChain) WalletReservations( lc.mutex.Lock() defer lc.mutex.Unlock() + if lc.walletReservationsErr != nil { + return nil, lc.walletReservationsErr + } + keys := lc.walletReservations[walletPublicKeyHash] out := make([]*big.Int, len(keys)) copy(out, keys) @@ -1077,7 +1072,11 @@ func (lc *localChain) IsReservedDeposit( lc.mutex.Lock() defer lc.mutex.Unlock() - record, ok := lc.reservedDeposits[bigIntToKey16(depositKey)] + if lc.isReservedDepositErr != nil { + return false, lc.isReservedDepositErr + } + + record, ok := lc.reservedDeposits[bigIntKey(depositKey)] if !ok { return false, nil } @@ -1092,7 +1091,11 @@ func (lc *localChain) ReservedDepositWallet( lc.mutex.Lock() defer lc.mutex.Unlock() - record, ok := lc.reservedDeposits[bigIntToKey16(depositKey)] + if lc.reservedDepositWalletErr != nil { + return [20]byte{}, lc.reservedDepositWalletErr + } + + record, ok := lc.reservedDeposits[bigIntKey(depositKey)] if !ok { return [20]byte{}, nil } @@ -1109,7 +1112,7 @@ func (lc *localChain) setReservedDeposit( lc.mutex.Lock() defer lc.mutex.Unlock() - lc.reservedDeposits[bigIntToKey16(depositKey)] = &reservedDepositRecord{ + lc.reservedDeposits[bigIntKey(depositKey)] = &reservedDepositRecord{ walletPublicKeyHash: walletPublicKeyHash, isReserved: isReserved, } @@ -1121,13 +1124,6 @@ func (lc *localChain) PastReservationAcceptedEvents( return nil, nil } -// submitReservationProofHook, when non-nil, overrides the default panic -// stub and gives the test full control over SubmitReservationProof behavior. -var _ = func() bool { - _ = bytes.Equal - return true -}() - func (lc *localChain) PastReservationReanchoredEvents( filter *tbtc.ReservationReanchoredEventFilter, ) ([]*tbtc.ReservationReanchoredEvent, error) { @@ -1139,3 +1135,135 @@ func (lc *localChain) PastReservationActionTimedOutEvents( ) ([]*tbtc.ReservationActionTimedOutEvent, error) { return nil, nil } + +func (lc *localChain) PastReservationAcceptanceRequestedEvents( + filter *tbtc.ReservationAcceptanceRequestedEventFilter, +) ([]*tbtc.ReservationAcceptanceRequestedEvent, error) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + var result []*tbtc.ReservationAcceptanceRequestedEvent + for _, event := range lc.reservationAcceptanceRequestedEvents { + if filter != nil && event.BlockNumber < filter.StartBlock { + continue + } + if filter != nil && len(filter.ReservationKey) > 0 { + matched := false + for _, key := range filter.ReservationKey { + if key.Cmp(event.ReservationKey) == 0 { + matched = true + break + } + } + if !matched { + continue + } + } + result = append(result, event) + } + + return result, nil +} + +func (lc *localChain) addReservationAcceptanceRequestedEvent( + event *tbtc.ReservationAcceptanceRequestedEvent, +) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.reservationAcceptanceRequestedEvents = append( + lc.reservationAcceptanceRequestedEvents, + event, + ) +} + +func (lc *localChain) PastReservationReanchorRequestedEvents( + filter *tbtc.ReservationReanchorRequestedEventFilter, +) ([]*tbtc.ReservationReanchorRequestedEvent, error) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + var result []*tbtc.ReservationReanchorRequestedEvent + for _, event := range lc.reservationReanchorRequestedEvents { + if filter != nil && event.BlockNumber < filter.StartBlock { + continue + } + if filter != nil && len(filter.ReservationKey) > 0 { + matched := false + for _, key := range filter.ReservationKey { + if key.Cmp(event.ReservationKey) == 0 { + matched = true + break + } + } + if !matched { + continue + } + } + result = append(result, event) + } + + return result, nil +} + +func (lc *localChain) addReservationReanchorRequestedEvent( + event *tbtc.ReservationReanchorRequestedEvent, +) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.reservationReanchorRequestedEvents = append( + lc.reservationReanchorRequestedEvents, + event, + ) +} + +func (lc *localChain) PastNewWalletRegisteredEvents( + filter *tbtc.NewWalletRegisteredEventFilter, +) ([]*tbtc.NewWalletRegisteredEvent, error) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + var result []*tbtc.NewWalletRegisteredEvent + for _, event := range lc.newWalletRegisteredEvents { + if filter != nil && event.BlockNumber < filter.StartBlock { + continue + } + if filter != nil && len(filter.EcdsaWalletID) > 0 { + matched := false + for _, id := range filter.EcdsaWalletID { + if id == event.EcdsaWalletID { + matched = true + break + } + } + if !matched { + continue + } + } + result = append(result, event) + } + + return result, nil +} + +func (lc *localChain) addNewWalletRegisteredEvent( + event *tbtc.NewWalletRegisteredEvent, +) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.newWalletRegisteredEvents = append(lc.newWalletRegisteredEvents, event) +} + +// BuildDepositKey is a test-double implementation independent of the +// production keccak256-based algorithm (pkg/chain/ethereum's unexported +// buildDepositKey): only self-consistency within this fake chain matters +// for unit tests, since nothing here cross-checks against a real contract. +func (lc *localChain) BuildDepositKey( + fundingTxHash bitcoin.Hash, + fundingOutputIndex uint32, +) *big.Int { + key := buildDepositRequestKey(fundingTxHash, fundingOutputIndex) + return new(big.Int).SetBytes(key[:]) +} diff --git a/pkg/maintainer/spv/config.go b/pkg/maintainer/spv/config.go index 671c8a0046..e8bd3598f7 100644 --- a/pkg/maintainer/spv/config.go +++ b/pkg/maintainer/spv/config.go @@ -73,9 +73,19 @@ type Config struct { Reservations ReservationsConfig } -// ReservationsConfig holds the reservation-related spv.Config fields. The -// structure mirrors tbtc.ReservationsConfig so an operator can keep the two -// flags in lockstep via configuration. +// ReservationsConfig holds the reservation-related spv.Config fields. +// +// This flag controls only SPV PROOF SUBMISSION for reservation acceptance / +// re-anchor action generations (the `maintainer` command, config category +// Maintainer). The `start` command runs as a separate process reading a +// disjoint config category (see config.StartCmdCategories) and has its own +// independent gate, tbtc.ReservationsConfig.Enabled, that controls +// reservation proposal GENERATION. Neither command's config loading sees +// the other's category, so this flag cannot be derived from or validated +// against tbtc.ReservationsConfig.Enabled in code. An operator running both +// `start` and `maintainer` for the reservation feature to work end-to-end +// MUST enable both flags - normally the same [Maintainer.Spv.Reservations] +// / [Tbtc.Reservations] TOML sections in one shared config file. type ReservationsConfig struct { // Enabled toggles reservation plumbing in the SPV maintainer. Enabled bool diff --git a/pkg/maintainer/spv/reservation_acceptance_proof.go b/pkg/maintainer/spv/reservation_acceptance_proof.go index 48f544d87e..edb219801a 100644 --- a/pkg/maintainer/spv/reservation_acceptance_proof.go +++ b/pkg/maintainer/spv/reservation_acceptance_proof.go @@ -2,21 +2,44 @@ package spv import ( "fmt" + "math/big" "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/tbtc" ) -// SubmitReservationAcceptanceProof prepares the reservation acceptance proof for -// the given transaction and submits it to the on-chain contract. +// ProofTypeReservationAcceptance is the value passed to +// SubmitReservationProof as proofType for a reservation acceptance SPV +// proof. The numeric value mirrors the on-chain ReservationProofType enum +// (1 = Acceptance). +const ProofTypeReservationAcceptance uint8 = 1 + +// SubmitReservationAcceptanceProof drives the SPV proof submission for a +// reservation acceptance action generation. The caller (the reservation +// proof loop) supplies the (reservationKey, requestNonce) pair of the +// on-chain action generation it is proving, plus the Bitcoin transaction +// hash of the anchor transaction already signed and broadcast by the wallet +// coordinator. The proof is fetched from btcChain, the anchor transaction +// is rebuilt locally to extract the deposit UTXO that was anchored, and the +// proof is submitted directly to the Bridge via the SPV maintainer's +// SubmitReservationProof entry point (not via MaintainerProxy: reservations +// are not reimbursed). +// +// requiredConfirmations must be > 0; the SPV maintainer relies on it to +// assemble the proof. func SubmitReservationAcceptanceProof( transactionHash bitcoin.Hash, requiredConfirmations uint, + reservationKey *big.Int, + requestNonce uint64, btcChain bitcoin.Chain, spvChain Chain, ) error { return submitReservationAcceptanceProof( transactionHash, requiredConfirmations, + reservationKey, + requestNonce, btcChain, spvChain, bitcoin.AssembleSpvProof, @@ -27,6 +50,8 @@ func SubmitReservationAcceptanceProof( func submitReservationAcceptanceProof( transactionHash bitcoin.Hash, requiredConfirmations uint, + reservationKey *big.Int, + requestNonce uint64, btcChain bitcoin.Chain, spvChain Chain, spvProofAssembler spvProofAssembler, @@ -34,10 +59,169 @@ func submitReservationAcceptanceProof( IncrementCounter(name string, value float64) }, ) error { + if metricsRecorder != nil { + metricsRecorder.IncrementCounter( + "reservation_acceptance_proof_submissions_total", + 1, + ) + } + if requiredConfirmations == 0 { - return fmt.Errorf("provided required confirmations count must be greater than 0") + if metricsRecorder != nil { + metricsRecorder.IncrementCounter( + "reservation_acceptance_proof_submissions_failed_total", + 1, + ) + } + return fmt.Errorf( + "provided required confirmations count must be greater than 0", + ) + } + if reservationKey == nil { + return fmt.Errorf("reservation key is required") + } + if requestNonce == 0 { + return fmt.Errorf("request nonce must be > 0") + } + + transaction, proof, err := spvProofAssembler( + transactionHash, + requiredConfirmations, + btcChain, + ) + if err != nil { + if metricsRecorder != nil { + metricsRecorder.IncrementCounter( + "reservation_acceptance_proof_submissions_failed_total", + 1, + ) + } + return fmt.Errorf( + "failed to assemble transaction spv proof: [%v]", + err, + ) + } + + depositUtxo, err := parseReservationAcceptanceTransactionInput( + btcChain, + transaction, + ) + if err != nil { + if metricsRecorder != nil { + metricsRecorder.IncrementCounter( + "reservation_acceptance_proof_submissions_failed_total", + 1, + ) + } + return fmt.Errorf( + "error while parsing reservation acceptance transaction "+ + "inputs: [%v]", + err, + ) + } + + action, err := spvChain.GetReservationAction(reservationKey, requestNonce) + if err != nil { + return fmt.Errorf( + "cannot fetch reservation action generation: [%v]", + err, + ) + } + + if action.ActionType != tbtc.ReservationActionTypeAcceptance { + return fmt.Errorf( + "reservation action generation is not an acceptance (got %v)", + action.ActionType, + ) + } + + if action.State != tbtc.ReservationActionStatePending { + return fmt.Errorf( + "reservation acceptance action generation is not pending "+ + "(state=%v)", + action.State, + ) + } + + txInfo := buildReservationProofTxInfo(transaction) + txProof := buildReservationProofTxProof(proof) + mainUtxo := buildReservationProofMainUtxo(depositUtxo) + + if err := spvChain.SubmitReservationProof( + ProofTypeReservationAcceptance, + txInfo, + txProof, + mainUtxo, + reservationKey, + requestNonce, + ); err != nil { + if metricsRecorder != nil { + metricsRecorder.IncrementCounter( + "reservation_acceptance_proof_submissions_failed_total", + 1, + ) + } + return fmt.Errorf( + "failed to submit reservation acceptance proof: [%v]", + err, + ) + } + + if metricsRecorder != nil { + metricsRecorder.IncrementCounter( + "reservation_acceptance_proof_submissions_success_total", + 1, + ) } - // This is a stub for the SPV proof side, pending final integration. return nil } + +// parseReservationAcceptanceTransactionInput parses the single input of a +// reservation acceptance (anchor) transaction and returns the deposit UTXO +// that was anchored. Mirrors parseReservationReanchorTransactionInput in +// reservation_reanchor_proof.go. +func parseReservationAcceptanceTransactionInput( + btcChain bitcoin.Chain, + transaction *bitcoin.Transaction, +) (*bitcoin.UnspentTransactionOutput, error) { + if len(transaction.Inputs) != 1 { + return nil, fmt.Errorf( + "reservation acceptance transaction must have exactly one input", + ) + } + + if len(transaction.Outputs) != 1 { + return nil, fmt.Errorf( + "reservation acceptance transaction must have exactly one output", + ) + } + + input := transaction.Inputs[0] + + inputTx, err := btcChain.GetTransaction(input.Outpoint.TransactionHash) + if err != nil { + return nil, fmt.Errorf( + "cannot get input transaction data: [%v]", + err, + ) + } + + if int(input.Outpoint.OutputIndex) >= len(inputTx.Outputs) { + return nil, fmt.Errorf( + "input outpoint index [%d] out of range for transaction [%d] "+ + "outputs", + input.Outpoint.OutputIndex, + len(inputTx.Outputs), + ) + } + + spentOutput := inputTx.Outputs[input.Outpoint.OutputIndex] + + depositUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: input.Outpoint, + Value: spentOutput.Value, + } + + return depositUtxo, nil +} diff --git a/pkg/maintainer/spv/reservation_action_timeout_watch.go b/pkg/maintainer/spv/reservation_action_timeout_watch.go index 270e7f8a3c..dedb9340b8 100644 --- a/pkg/maintainer/spv/reservation_action_timeout_watch.go +++ b/pkg/maintainer/spv/reservation_action_timeout_watch.go @@ -1,6 +1,7 @@ package spv import ( + "context" "fmt" "math/big" "time" @@ -43,6 +44,13 @@ type ReservationActionTimeoutWatcher struct { // to look up operator addresses (the SPV maintainer chain interface // does not expose GetOperatorID today). membersResolver WalletMembersResolver + // lastWalletScanBlock is the block number up to which Run has already + // scanned NewWalletRegistered events, so each poll iteration only fetches + // wallets registered since the previous tick instead of rescanning the + // full history every time. Owned exclusively by Run's single goroutine; + // CheckReservationActionTimeouts (the synchronous, test/integration + // entry point) never touches it. + lastWalletScanBlock uint64 } // WalletMembersResolver maps a wallet public key hash to the operator IDs @@ -126,24 +134,24 @@ func defaultActionTimeoutNowFn() uint32 { return uint32(time.Now().Unix()) } -// Run starts the background poll loop. It returns immediately and runs -// until ctx is done. -// -// 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. +// reservationActionTimeoutWalletScanLookBackBlocks bounds the first wallet +// discovery scan Run performs. Mirrors the 30-day-at-12s/block convention +// used elsewhere in this package (e.g. DefaultReservationStaleDepositPollInterval's +// sibling deposit scan). Subsequent scans are incremental from the last +// scanned block, so this bound only matters once, at startup. +const reservationActionTimeoutWalletScanLookBackBlocks = uint64(216000) + +// Run starts the background poll loop. It returns when ctx is done or when +// a fatal configuration error is detected. // -// 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 { +// Each iteration discovers every wallet registered on-chain (incrementally, +// past the block already scanned by the previous iteration), enumerates the +// reservations currently custodied by each wallet, and calls +// CheckReservationActionTimeouts for each one. The loop is best-effort for +// per-reservation failures: a single reservation's error is logged and the +// walk continues with the next one; only a startup configuration error +// (nil notifier/resolver, non-positive interval) aborts 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,16 +167,107 @@ 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 { + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + } + + wallets, err := ratw.discoverWallets() + if err != nil { + logger.Errorf( + "action-timeout watcher failed to discover wallets: [%v]", + err, + ) + continue + } + + now := ratw.nowFn() + + for _, walletPublicKeyHash := range wallets { + reservationKeys, err := ratw.spvChain.WalletReservations( + walletPublicKeyHash, + ) + if err != nil { + logger.Errorf( + "action-timeout watcher failed to list reservations "+ + "for wallet [0x%x]: [%v]", + walletPublicKeyHash, + err, + ) + continue + } + + for _, reservationKey := range reservationKeys { + if err := ratw.CheckReservationActionTimeouts( + reservationKey, + now, + ); err != nil { + logger.Errorf( + "action-timeout watcher failed to check "+ + "reservation [%v]: [%v]", + reservationKey, + err, + ) + } + } + } + } +} + +// discoverWallets returns the public key hashes of every wallet registered +// on-chain since the last call, plus every wallet seen by a prior call. +// Callers must retain the returned slice's wallets across iterations +// themselves if they need the full set; discoverWallets itself only +// accumulates the incremental scan cursor (lastWalletScanBlock) - the +// caller (Run) re-derives the full wallet set from WalletReservations, +// which is authoritative regardless of when the wallet was registered, so +// discoverWallets does not need to cache the wallet list itself. +func (ratw *ReservationActionTimeoutWatcher) discoverWallets() ([][20]byte, error) { + blockCounter, err := ratw.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) + } + + startBlock := ratw.lastWalletScanBlock + if startBlock == 0 && currentBlock > reservationActionTimeoutWalletScanLookBackBlocks { + startBlock = currentBlock - reservationActionTimeoutWalletScanLookBackBlocks + } + + events, err := ratw.spvChain.PastNewWalletRegisteredEvents( + &tbtc.NewWalletRegisteredEventFilter{StartBlock: startBlock}, + ) + if err != nil { + return nil, fmt.Errorf( + "failed to get past new wallet registered events: [%v]", + err, + ) + } + + ratw.lastWalletScanBlock = currentBlock + + wallets := make([][20]byte, len(events)) + for i, event := range events { + wallets[i] = event.WalletPublicKeyHash + } + + return wallets, nil } -// CheckReservationActionTimeouts inspects the action generations of a -// single reservation and notifies the Bridge of any pending action whose +// CheckReservationActionTimeouts inspects the current action generation of a +// single reservation and notifies the Bridge if it is Pending and its // TimeoutAt has elapsed. The caller controls the iteration; the watcher -// does not background-loop on its own. +// does not background-loop on its own (see Run for the poll-driven caller). // // Parameters: // @@ -177,12 +276,14 @@ func (ratw *ReservationActionTimeoutWatcher) Run() error { // - now: a UNIX timestamp used to compare against TimeoutAt. Tests pass // an explicit value; production passes time.Now().Unix() cast to uint32. // -// The function resolves the custodying wallet once, looks up the operator -// member IDs through the injected resolver, then walks the nonce axis -// starting from 0 and stopping at the first non-pending action. Walking -// until the first non-pending action models the on-chain invariant that -// nonces are sequential: only the most-recent pending action can time -// out, since older nonces have already been settled or superseded. +// The function resolves the custodying wallet, looks up the operator member +// IDs through the injected resolver, then inspects only the action +// generation at reservation.RequestNonce. By the Bridge invariant, only the +// most-recent action generation can be Pending - older nonces have already +// settled, timed out, or been superseded - so a single lookup suffices; no +// walk from nonce 0 is needed. A RequestNonce of 0 means no action +// generation has ever been requested against the reservation, so there is +// nothing to check. func (ratw *ReservationActionTimeoutWatcher) CheckReservationActionTimeouts( reservationKey *big.Int, now uint32, @@ -210,6 +311,11 @@ func (ratw *ReservationActionTimeoutWatcher) CheckReservationActionTimeouts( ) } + if reservation.RequestNonce == 0 { + // No action generation has ever been requested; nothing pending. + return nil + } + walletPublicKeyHash := reservation.WalletPublicKeyHash if walletPublicKeyHash == ([20]byte{}) { // Reservation exists but has no wallet assigned (e.g. the @@ -238,78 +344,75 @@ func (ratw *ReservationActionTimeoutWatcher) CheckReservationActionTimeouts( err, ) } + if len(memberIDs) == 0 { + // Emitting NotifyReservationActionTimeout with a nil/empty member + // slice is ill-formed on the Bridge side (see + // NewReservationActionTimeoutWatcher's doc). Refuse rather than + // notify on partial information: a misconfigured members resolver + // must fail loud, not silently strand the slashing attribution. + return fmt.Errorf( + "wallet [0x%x] members resolver returned an empty set; "+ + "refusing to notify with no attributable members", + walletPublicKeyHash, + ) + } - // Walk the nonce axis from 0 upward. Stop at the first non-pending - // action: by the Bridge invariant, only the most-recent action can be - // in Pending state; older actions are Settled/TimedOut/Superseded/Vetoed. - for nonce := uint64(0); nonce <= reservation.RequestNonce; nonce++ { - action, err := ratw.spvChain.GetReservationAction(reservationKey, nonce) - if err != nil { - // A missing action record for a nonce in [0, RequestNonce] - // is a Bridge-data inconsistency; we log and stop walking - // rather than notify on partial information. - logger.Errorf( - "failed to load action for reservation [%v] at nonce %d: [%v]; "+ - "stopping nonce walk", - reservationKey, - nonce, - err, - ) - return nil - } - - if action.State != tbtc.ReservationActionStatePending { - logger.Debugf( - "reservation [%v] action nonce %d state=%s; "+ - "stopping nonce walk at first non-pending action", - reservationKey, - nonce, - action.State, - ) - return nil - } + nonce := reservation.RequestNonce - if now <= action.TimeoutAt { - logger.Debugf( - "reservation [%v] action nonce %d timeout at [%d] "+ - "not yet reached (now=%d); skipping", - reservationKey, - nonce, - action.TimeoutAt, - now, - ) - // Continue the walk in case multiple actions are pending past - // their timeouts; in practice this should not happen because - // RequestNonce points at the latest pending nonce, but the - // walker is defensive. - continue - } + action, err := ratw.spvChain.GetReservationAction(reservationKey, nonce) + if err != nil { + return fmt.Errorf( + "failed to load action for reservation [%v] at nonce %d: [%v]", + reservationKey, + nonce, + err, + ) + } - if err := ratw.notifier.NotifyReservationActionTimeout( + if action.State != tbtc.ReservationActionStatePending { + logger.Debugf( + "reservation [%v] action nonce %d state=%s; not pending, "+ + "nothing to time out", reservationKey, - memberIDs, - ); err != nil { - logger.Errorf( - "failed to notify action timeout for "+ - "reservation [%v] nonce %d: [%v]", - reservationKey, - nonce, - err, - ) - // Continue with the next nonce despite the error: a single - // failure must not starve subsequent notifications. - continue - } + nonce, + action.State, + ) + return nil + } - logger.Infof( - "notified action timeout for reservation [%v] nonce %d "+ - "(timeout=%d, members=%d)", + if now <= action.TimeoutAt { + logger.Debugf( + "reservation [%v] action nonce %d timeout at [%d] "+ + "not yet reached (now=%d); skipping", reservationKey, nonce, action.TimeoutAt, - len(memberIDs), + now, ) + return nil } + if err := ratw.notifier.NotifyReservationActionTimeout( + reservationKey, + memberIDs, + ); err != nil { + return fmt.Errorf( + "failed to notify action timeout for "+ + "reservation [%v] nonce %d: [%v]", + reservationKey, + nonce, + err, + ) + } + + logger.Infof( + "notified action timeout for reservation [%v] nonce %d "+ + "(timeout=%d, members=%d)", + reservationKey, + nonce, + action.TimeoutAt, + len(memberIDs), + ) + return nil } diff --git a/pkg/maintainer/spv/reservation_action_timeout_watch_test.go b/pkg/maintainer/spv/reservation_action_timeout_watch_test.go index 5bc7744d35..736976ea67 100644 --- a/pkg/maintainer/spv/reservation_action_timeout_watch_test.go +++ b/pkg/maintainer/spv/reservation_action_timeout_watch_test.go @@ -49,7 +49,10 @@ func (r *recordingActionTimeoutNotifier) NotifyReservationActionTimeout( // seededReservation installs a reservation and (optionally) a list of // action generations under spvChain for use in the action-timeout watcher -// tests. Helper reduces per-test noise. +// tests. Helper reduces per-test noise. actions[0] is stored as generation +// nonce 1, actions[1] as nonce 2, etc., matching the 1-based action +// generation convention (a reservation has no generation 0; the first +// ever-requested action is nonce 1). func seededReservation( t *testing.T, spvChain *localChain, @@ -63,8 +66,8 @@ func seededReservation( WalletPublicKeyHash: wallet, RequestNonce: requestNonce, }) - for nonce, action := range actions { - spvChain.setReservationAction(key, uint64(nonce), action) + for i, action := range actions { + spvChain.setReservationAction(key, uint64(i)+1, action) } } @@ -90,7 +93,7 @@ func TestReservationActionTimeoutWatcher_NotifiesTimedOutPendingAction(t *testin TimeoutAt: 100, }, }, - 0, + 1, ) watcher := NewReservationActionTimeoutWatcher(spvChain, notifier, resolver, 0) @@ -136,7 +139,7 @@ func TestReservationActionTimeoutWatcher_DoesNotNotifyBeforeTimeout(t *testing.T TimeoutAt: 10_000, }, }, - 0, + 1, ) watcher := NewReservationActionTimeoutWatcher(spvChain, notifier, resolver, 0) @@ -152,7 +155,7 @@ func TestReservationActionTimeoutWatcher_DoesNotNotifyBeforeTimeout(t *testing.T } } -func TestReservationActionTimeoutWatcher_StopsAtFirstNonPending(t *testing.T) { +func TestReservationActionTimeoutWatcher_IgnoresSettledOlderGeneration(t *testing.T) { spvChain := newLocalChain() notifier := &recordingActionTimeoutNotifier{} @@ -162,8 +165,12 @@ func TestReservationActionTimeoutWatcher_StopsAtFirstNonPending(t *testing.T) { resolver := &recordingActionTimeoutMembers{ walletIDs: map[[20]byte][]uint32{wallet: {1, 2}}, } - // Nonce 0 is settled, nonce 1 is the latest pending and past deadline. - // The walker must stop at nonce 0 without notifying. + // Generation 1 (an old re-anchor, say) is already Settled; generation 2 + // is the current pending generation and is past its deadline. The + // watcher must inspect only the current generation (RequestNonce = 2) + // and notify for it - this is the fix for the bug where an older + // walk-from-zero implementation stopped at the first non-pending + // generation and never reached the real timed-out one. seededReservation( t, spvChain, @@ -179,7 +186,7 @@ func TestReservationActionTimeoutWatcher_StopsAtFirstNonPending(t *testing.T) { TimeoutAt: 100, }, }, - 1, + 2, ) watcher := NewReservationActionTimeoutWatcher(spvChain, notifier, resolver, 0) @@ -187,15 +194,16 @@ func TestReservationActionTimeoutWatcher_StopsAtFirstNonPending(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - if len(notifier.calls) != 0 { + if len(notifier.calls) != 1 { t.Fatalf( - "first action is settled; walker must stop, got %d notifications", + "current generation is pending and past deadline; expected one "+ + "notification, got %d", len(notifier.calls), ) } } -func TestReservationActionTimeoutWatcher_NotifiesLatestNonce(t *testing.T) { +func TestReservationActionTimeoutWatcher_NotifiesCurrentGenerationOnly(t *testing.T) { spvChain := newLocalChain() notifier := &recordingActionTimeoutNotifier{} @@ -205,8 +213,10 @@ func TestReservationActionTimeoutWatcher_NotifiesLatestNonce(t *testing.T) { resolver := &recordingActionTimeoutMembers{ walletIDs: map[[20]byte][]uint32{wallet: {7, 8, 9}}, } - // Nonce 0 pending past its deadline, nonce 1 pending past its deadline. - // Both must be notified (defensive walker continues past the first). + // Generation 1 is still pending and NOT past its deadline; generation 2 + // is the current pending generation and IS past its deadline. Only + // generation 2 (RequestNonce) is ever inspected, so exactly one + // notification fires regardless of generation 1's state. seededReservation( t, spvChain, @@ -215,14 +225,14 @@ func TestReservationActionTimeoutWatcher_NotifiesLatestNonce(t *testing.T) { []*tbtc.ReservationAction{ { State: tbtc.ReservationActionStatePending, - TimeoutAt: 100, + TimeoutAt: 10_000, }, { State: tbtc.ReservationActionStatePending, - TimeoutAt: 200, + TimeoutAt: 100, }, }, - 1, + 2, ) watcher := NewReservationActionTimeoutWatcher(spvChain, notifier, resolver, 0) @@ -230,9 +240,9 @@ func TestReservationActionTimeoutWatcher_NotifiesLatestNonce(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - if len(notifier.calls) != 2 { + if len(notifier.calls) != 1 { t.Fatalf( - "expected two notifications (nonce 0 and 1), got %d", + "expected exactly one notification for the current generation, got %d", len(notifier.calls), ) } @@ -285,7 +295,7 @@ func TestReservationActionTimeoutWatcher_MembersResolverError(t *testing.T) { TimeoutAt: 100, }, }, - 0, + 1, ) watcher := NewReservationActionTimeoutWatcher(spvChain, notifier, resolver, 0) @@ -360,7 +370,7 @@ func TestReservationActionTimeoutWatcher_NotifierFuncAdapter(t *testing.T) { TimeoutAt: 100, }, }, - 0, + 1, ) watcher := NewReservationActionTimeoutWatcher(spvChain, notifier, resolver, 0) @@ -372,7 +382,7 @@ func TestReservationActionTimeoutWatcher_NotifierFuncAdapter(t *testing.T) { } } -func TestReservationActionTimeoutWatcher_NotifiesOncePerQualifyingNonce(t *testing.T) { +func TestReservationActionTimeoutWatcher_NotifierErrorPropagates(t *testing.T) { spvChain := newLocalChain() notifier := &recordingActionTimeoutNotifier{} errFromNotifier := errors.New("downstream") @@ -383,9 +393,12 @@ func TestReservationActionTimeoutWatcher_NotifiesOncePerQualifyingNonce(t *testi resolver := &recordingActionTimeoutMembers{ walletIDs: map[[20]byte][]uint32{wallet: {1, 2, 3}}, } - // Two pending actions past their timeouts; the notifier fails for the - // first and succeeds for the second; the walker must continue past the - // first failure (defensive coverage). + // The current generation is pending and past its deadline, but the + // notifier fails. With only one generation ever inspected per Check + // call, the failure must surface as an error from + // CheckReservationActionTimeouts (not be silently swallowed), so a + // poll-loop caller logs and retries on the next tick instead of + // wrongly treating it as settled. notifier.err = errFromNotifier seededReservation( t, @@ -397,22 +410,17 @@ func TestReservationActionTimeoutWatcher_NotifiesOncePerQualifyingNonce(t *testi State: tbtc.ReservationActionStatePending, TimeoutAt: 100, }, - { - State: tbtc.ReservationActionStatePending, - TimeoutAt: 200, - }, }, 1, ) watcher := NewReservationActionTimeoutWatcher(spvChain, notifier, resolver, 0) - if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { - t.Fatalf("unexpected error from the watcher itself: %v", err) + err := watcher.CheckReservationActionTimeouts(key, 5_000) + if err == nil { + t.Fatal("expected the notifier error to propagate, got nil") } - // Both attempts are recorded even though the first returned an error: - // the walker never silently drops notifications. - if len(notifier.calls) != 2 { - t.Fatalf("expected two recorded notification attempts, got %d", len(notifier.calls)) + if len(notifier.calls) != 1 { + t.Fatalf("expected exactly one notification attempt, got %d", len(notifier.calls)) } } diff --git a/pkg/maintainer/spv/reservation_proof_loop.go b/pkg/maintainer/spv/reservation_proof_loop.go new file mode 100644 index 0000000000..e9a58ddd8c --- /dev/null +++ b/pkg/maintainer/spv/reservation_proof_loop.go @@ -0,0 +1,463 @@ +package spv + +import ( + "context" + "fmt" + "time" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/maintainer/btcdiff" + "github.com/keep-network/keep-core/pkg/tbtc" +) + +// reservationProofLookBackBlocks bounds the pending-action-request event +// scan. Mirrors ReservationAcceptanceLookBackBlocks / +// ReservationReanchorLookBackBlocks in pkg/tbtcpg: 30 days at 12s/block. +const reservationProofLookBackBlocks = uint64(216000) + +// maintainReservationProofs runs the SPV proof submission loop for +// reservation acceptance and re-anchor action generations. It is a +// dedicated loop, separate from spvMaintainer's generic proofTypes-driven +// control loop (see spv.go's Initialize), because SubmitReservationProof +// requires the (reservationKey, requestNonce) pair of the action generation +// being proven - context the generic +// unprovenTransactionsGetter/transactionProofSubmitter signatures (shared +// by deposit sweep, redemption, moving funds, and moved funds sweep) cannot +// carry. +// +// The loop shape mirrors spvMaintainer.startControlLoop/maintainSpv: an +// outer restart-backoff loop wraps an inner idle-backoff loop, so a +// transient error restarts after config.RestartBackoffTime and a clean pass +// with nothing to prove waits config.IdleBackoffTime before trying again. +func maintainReservationProofs( + ctx context.Context, + config Config, + spvChain Chain, + btcDiffChain btcdiff.Chain, + btcChain bitcoin.Chain, +) { + logger.Info("starting reservation proof maintainer") + + defer func() { + logger.Info("stopping reservation proof maintainer") + }() + + for { + err := runReservationProofLoop(ctx, config, spvChain, btcDiffChain, btcChain) + if err != nil { + logger.Errorf( + "error while maintaining reservation proofs: [%v]; "+ + "restarting reservation proof maintainer", + err, + ) + } + + select { + case <-time.After(config.RestartBackoffTime): + case <-ctx.Done(): + return + } + } +} + +// runReservationProofLoop repeatedly proves pending reservation acceptance +// and re-anchor action generations until ctx is done or an unrecoverable +// error occurs. Per-action errors (a single reservation's proof failing to +// assemble or submit) are logged and skipped rather than propagated, so one +// bad action generation does not block the rest; only a chain-wide failure +// (e.g. cannot read the current block) aborts the pass and triggers the +// outer restart backoff. +func runReservationProofLoop( + ctx context.Context, + config Config, + spvChain Chain, + btcDiffChain btcdiff.Chain, + btcChain bitcoin.Chain, +) error { + for { + if err := proveReservationAcceptanceActions( + config, + spvChain, + btcDiffChain, + btcChain, + ); err != nil { + return fmt.Errorf( + "error while proving reservation acceptance actions: [%v]", + err, + ) + } + + if err := proveReservationReanchorActions( + config, + spvChain, + btcDiffChain, + btcChain, + ); err != nil { + return fmt.Errorf( + "error while proving reservation re-anchor actions: [%v]", + err, + ) + } + + select { + case <-time.After(config.IdleBackoffTime): + case <-ctx.Done(): + return ctx.Err() + } + } +} + +// proveReservationAcceptanceActions finds pending ReservationAcceptance +// action generations, locates each one's already-broadcast anchor +// transaction on the Bitcoin chain (if any), and submits its SPV proof once +// it has accumulated enough confirmations. +func proveReservationAcceptanceActions( + config Config, + spvChain Chain, + btcDiffChain btcdiff.Chain, + btcChain bitcoin.Chain, +) error { + startBlock, err := reservationProofScanStartBlock(spvChain) + if err != nil { + return err + } + + events, err := spvChain.PastReservationAcceptanceRequestedEvents( + &tbtc.ReservationAcceptanceRequestedEventFilter{StartBlock: startBlock}, + ) + if err != nil { + return fmt.Errorf( + "failed to get past reservation acceptance requested "+ + "events: [%v]", + err, + ) + } + + for _, event := range events { + action, err := spvChain.GetReservationAction( + event.ReservationKey, + event.RequestNonce, + ) + if err != nil { + logger.Errorf( + "failed to load reservation acceptance action [%v]/%d: [%v]", + event.ReservationKey, + event.RequestNonce, + err, + ) + continue + } + if action.State != tbtc.ReservationActionStatePending { + // Already proven (Settled), or no longer provable + // (TimedOut/Superseded/Vetoed). + continue + } + + transaction, err := findReservationAcceptanceTransaction( + spvChain, + btcChain, + event, + config.TransactionLimit, + ) + if err != nil { + logger.Errorf( + "failed to search for reservation acceptance transaction "+ + "for reservation [%v]: [%v]", + event.ReservationKey, + err, + ) + continue + } + if transaction == nil { + // The wallet has not broadcast the anchor transaction yet. + continue + } + + if err := proveReservationTransaction( + transaction, + btcChain, + spvChain, + btcDiffChain, + func(transactionHash bitcoin.Hash, requiredConfirmations uint) error { + return SubmitReservationAcceptanceProof( + transactionHash, + requiredConfirmations, + event.ReservationKey, + event.RequestNonce, + btcChain, + spvChain, + ) + }, + ); err != nil { + logger.Errorf( + "failed to prove reservation acceptance transaction [%s] "+ + "for reservation [%v]: [%v]", + transaction.Hash().Hex(bitcoin.ReversedByteOrder), + event.ReservationKey, + err, + ) + continue + } + } + + return nil +} + +// findReservationAcceptanceTransaction scans the candidate wallet's Bitcoin +// transaction history for the 1-input-1-output acceptance (anchor) +// transaction whose sole input spends the deposit identified by +// event.ReservationKey (== the deposit key; see the m1 identity mapping +// documented in reservation_stale_deposit_watch.go). Returns nil, nil if no +// matching transaction has been broadcast yet. +func findReservationAcceptanceTransaction( + spvChain Chain, + btcChain bitcoin.Chain, + event *tbtc.ReservationAcceptanceRequestedEvent, + transactionLimit int, +) (*bitcoin.Transaction, error) { + walletTransactions, err := btcChain.GetTransactionsForPublicKeyHash( + event.WalletPublicKeyHash, + transactionLimit, + ) + if err != nil { + return nil, fmt.Errorf( + "failed to get transactions for wallet: [%v]", + err, + ) + } + + for _, transaction := range walletTransactions { + if len(transaction.Inputs) != 1 || len(transaction.Outputs) != 1 { + continue + } + + input := transaction.Inputs[0] + depositKey := spvChain.BuildDepositKey( + input.Outpoint.TransactionHash, + input.Outpoint.OutputIndex, + ) + + if depositKey.Cmp(event.ReservationKey) == 0 { + return transaction, nil + } + } + + return nil, nil +} + +// proveReservationReanchorActions finds pending ReservationReanchor action +// generations, locates each one's already-broadcast re-anchor transaction +// on the Bitcoin chain (if any), and submits its SPV proof once it has +// accumulated enough confirmations. +func proveReservationReanchorActions( + config Config, + spvChain Chain, + btcDiffChain btcdiff.Chain, + btcChain bitcoin.Chain, +) error { + startBlock, err := reservationProofScanStartBlock(spvChain) + if err != nil { + return err + } + + events, err := spvChain.PastReservationReanchorRequestedEvents( + &tbtc.ReservationReanchorRequestedEventFilter{StartBlock: startBlock}, + ) + if err != nil { + return fmt.Errorf( + "failed to get past reservation re-anchor requested events: [%v]", + err, + ) + } + + for _, event := range events { + action, err := spvChain.GetReservationAction( + event.ReservationKey, + event.RequestNonce, + ) + if err != nil { + logger.Errorf( + "failed to load reservation re-anchor action [%v]/%d: [%v]", + event.ReservationKey, + event.RequestNonce, + err, + ) + continue + } + if action.State != tbtc.ReservationActionStatePending { + continue + } + + reservation, err := spvChain.GetReservation(event.ReservationKey) + if err != nil { + logger.Errorf( + "failed to load reservation [%v]: [%v]", + event.ReservationKey, + err, + ) + continue + } + if reservation.AnchorUtxo == nil || reservation.AnchorUtxo.Outpoint == nil { + logger.Errorf( + "reservation [%v] has no anchor UTXO to re-anchor from", + event.ReservationKey, + ) + continue + } + + transaction, err := findReservationReanchorTransaction( + btcChain, + event, + reservation.AnchorUtxo, + config.TransactionLimit, + ) + if err != nil { + logger.Errorf( + "failed to search for reservation re-anchor transaction "+ + "for reservation [%v]: [%v]", + event.ReservationKey, + err, + ) + continue + } + if transaction == nil { + continue + } + + if err := proveReservationTransaction( + transaction, + btcChain, + spvChain, + btcDiffChain, + func(transactionHash bitcoin.Hash, requiredConfirmations uint) error { + return SubmitReservationReanchorProof( + transactionHash, + requiredConfirmations, + event.ReservationKey, + event.RequestNonce, + btcChain, + spvChain, + ) + }, + ); err != nil { + logger.Errorf( + "failed to prove reservation re-anchor transaction [%s] "+ + "for reservation [%v]: [%v]", + transaction.Hash().Hex(bitcoin.ReversedByteOrder), + event.ReservationKey, + err, + ) + continue + } + } + + return nil +} + +// findReservationReanchorTransaction scans the source wallet's Bitcoin +// transaction history for the 1-input-1-output re-anchor transaction whose +// sole input spends the reservation's current anchor UTXO. Returns nil, nil +// if no matching transaction has been broadcast yet. +func findReservationReanchorTransaction( + btcChain bitcoin.Chain, + event *tbtc.ReservationReanchorRequestedEvent, + anchorUtxo *bitcoin.UnspentTransactionOutput, + transactionLimit int, +) (*bitcoin.Transaction, error) { + walletTransactions, err := btcChain.GetTransactionsForPublicKeyHash( + event.SourceWalletPublicKeyHash, + transactionLimit, + ) + if err != nil { + return nil, fmt.Errorf( + "failed to get transactions for wallet: [%v]", + err, + ) + } + + for _, transaction := range walletTransactions { + if len(transaction.Inputs) != 1 || len(transaction.Outputs) != 1 { + continue + } + + input := transaction.Inputs[0] + if input.Outpoint.TransactionHash == anchorUtxo.Outpoint.TransactionHash && + input.Outpoint.OutputIndex == anchorUtxo.Outpoint.OutputIndex { + return transaction, nil + } + } + + return nil, nil +} + +// proveReservationTransaction checks the given transaction's confirmation +// and relay-range status via the shared getProofInfo helper (also used by +// the generic proof loop in spv.go) and, once ready, invokes submit with +// the transaction hash and required confirmations. +func proveReservationTransaction( + transaction *bitcoin.Transaction, + btcChain bitcoin.Chain, + spvChain Chain, + btcDiffChain btcdiff.Chain, + submit func(transactionHash bitcoin.Hash, requiredConfirmations uint) error, +) error { + transactionHashStr := transaction.Hash().Hex(bitcoin.ReversedByteOrder) + + isProofWithinRelayRange, accumulatedConfirmations, requiredConfirmations, err := + getProofInfo(transaction.Hash(), btcChain, spvChain, btcDiffChain) + if err != nil { + return fmt.Errorf("failed to get proof info: [%v]", err) + } + + if !isProofWithinRelayRange { + logger.Warnf( + "skipped proving transaction [%s]; the range of the "+ + "required proof goes outside the previous and current "+ + "difficulty epochs as seen by the relay", + transactionHashStr, + ) + return nil + } + + if accumulatedConfirmations < requiredConfirmations { + logger.Infof( + "skipped proving transaction [%s]; transaction has [%v/%v] "+ + "confirmations", + transactionHashStr, + accumulatedConfirmations, + requiredConfirmations, + ) + return nil + } + + if err := submit(transaction.Hash(), requiredConfirmations); err != nil { + return err + } + + logger.Infof( + "successfully submitted proof for transaction [%s]", + transactionHashStr, + ) + + return nil +} + +// reservationProofScanStartBlock returns the start block for a bounded, +// look-back-limited scan of pending-action-request events. +func reservationProofScanStartBlock(spvChain Chain) (uint64, error) { + blockCounter, err := spvChain.BlockCounter() + if err != nil { + return 0, fmt.Errorf("failed to get block counter: [%v]", err) + } + + currentBlock, err := blockCounter.CurrentBlock() + if err != nil { + return 0, fmt.Errorf("failed to get current block: [%v]", err) + } + + if currentBlock > reservationProofLookBackBlocks { + return currentBlock - reservationProofLookBackBlocks, nil + } + + return 0, nil +} diff --git a/pkg/maintainer/spv/reservation_reanchor_proof.go b/pkg/maintainer/spv/reservation_reanchor_proof.go index ce9df2da8f..ee481a4854 100644 --- a/pkg/maintainer/spv/reservation_reanchor_proof.go +++ b/pkg/maintainer/spv/reservation_reanchor_proof.go @@ -163,6 +163,15 @@ func parseReservationReanchorTransactionInput( ) } + if int(input.Outpoint.OutputIndex) >= len(inputTx.Outputs) { + return nil, [20]byte{}, fmt.Errorf( + "input outpoint index [%d] out of range for transaction [%d] "+ + "outputs", + input.Outpoint.OutputIndex, + len(inputTx.Outputs), + ) + } + spentOutput := inputTx.Outputs[input.Outpoint.OutputIndex] anchorUtxo := &bitcoin.UnspentTransactionOutput{ diff --git a/pkg/maintainer/spv/reservation_stale_deposit_watch.go b/pkg/maintainer/spv/reservation_stale_deposit_watch.go index 4c254fcc31..9eacfd10dc 100644 --- a/pkg/maintainer/spv/reservation_stale_deposit_watch.go +++ b/pkg/maintainer/spv/reservation_stale_deposit_watch.go @@ -7,6 +7,15 @@ import ( "github.com/keep-network/keep-core/pkg/tbtc" ) +// reservationAcceptanceActionNonce is the acceptance action generation +// nonce. A reservation does not exist on-chain before its first acceptance +// settles, so the acceptance is always the first action generation +// authorized against a not-yet-created reservation, mirroring +// tbtcpg.reservationAcceptanceRequestNonce. ReservationAnchorProposal's +// Unmarshal rejects a zero RequestNonce, which is the on-chain confirmation +// of this 1-based convention. +const reservationAcceptanceActionNonce uint64 = 1 + // ReservationStaleDepositWatcher observes deposit-revealed events and // notifies the Bridge when a reserved deposit's acceptance window expired // without the assigned wallet becoming live. @@ -189,18 +198,18 @@ func (rsdw *ReservationStaleDepositWatcher) CheckStaleReservedDeposit( // The action timeout is the deadline bound to the reservation action // generation. Reserved deposits carry exactly one action generation - // (the acceptance) so we look it up via the reservation key. - reservationKey := depositToReservationKey(depositKey) - if reservationKey == nil { - logger.Warnf( - "could not derive reservation key from deposit key [%v]; "+ - "skipping stale notification", - depositKey, - ) - return nil - } + // (the acceptance). In m1 the reservation key and deposit key share the + // same identifier space exposed by the Bridge (ReservedDepositWallet + // and Reservation are both keyed by the same value); future revisions + // of the Bridge may introduce disjoint identifiers, in which case this + // direct use of depositKey as reservationKey must be replaced with a + // real lookup. + reservationKey := depositKey - action, err := rsdw.spvChain.GetReservationAction(reservationKey, 0) + action, err := rsdw.spvChain.GetReservationAction( + reservationKey, + reservationAcceptanceActionNonce, + ) if err != nil { return fmt.Errorf( "failed to load acceptance action for reservation [%v]: [%v]", @@ -252,19 +261,3 @@ func (rsdw *ReservationStaleDepositWatcher) CheckStaleReservedDeposit( return nil } - -// depositToReservationKey maps a deposit identifier to the reservation key -// the action is filed under. In m1 the mapping is identical because -// ReservedDepositWallet and Reservation share the same identifier space -// exposed by the Bridge; future revisions of the Bridge may introduce -// disjoint identifiers, in which case the mapping is filled in by the -// integration layer. -// -// Returning nil here signals "unknown mapping"; the caller treats nil as a -// soft skip, not an error. -func depositToReservationKey(depositKey *big.Int) *big.Int { - if depositKey == nil { - return nil - } - return new(big.Int).Set(depositKey) -} diff --git a/pkg/maintainer/spv/reservation_stale_deposit_watch_test.go b/pkg/maintainer/spv/reservation_stale_deposit_watch_test.go index 7711a9a418..dfb2b193d8 100644 --- a/pkg/maintainer/spv/reservation_stale_deposit_watch_test.go +++ b/pkg/maintainer/spv/reservation_stale_deposit_watch_test.go @@ -1,6 +1,7 @@ package spv import ( + "fmt" "math/big" "testing" @@ -69,9 +70,9 @@ func TestReservationStaleDepositWatcher_NotifiesAfterTimeout(t *testing.T) { State: tbtc.StateUnknown, }) - // Inject the acceptance (nonce 0) action with a deadline well below + // Inject the acceptance (nonce 1) action with a deadline well below // `now`. - spvChain.setReservationAction(key, 0, &tbtc.ReservationAction{ + spvChain.setReservationAction(key, 1, &tbtc.ReservationAction{ State: tbtc.ReservationActionStatePending, TimeoutAt: 100, }) @@ -106,7 +107,7 @@ func TestReservationStaleDepositWatcher_DoesNotNotifyBeforeTimeout(t *testing.T) // Action has a deadline of 10_000; we ask the watcher to evaluate at // now=5_000, which is before the deadline. - spvChain.setReservationAction(key, 0, &tbtc.ReservationAction{ + spvChain.setReservationAction(key, 1, &tbtc.ReservationAction{ State: tbtc.ReservationActionStatePending, TimeoutAt: 10_000, }) @@ -138,7 +139,7 @@ func TestReservationStaleDepositWatcher_SettledActionIsSkipped(t *testing.T) { // Action is already settled (no longer pending). The watcher must skip // the stale notification even though the wall clock has passed the // deadline. - spvChain.setReservationAction(key, 0, &tbtc.ReservationAction{ + spvChain.setReservationAction(key, 1, &tbtc.ReservationAction{ State: tbtc.ReservationActionStateSettled, TimeoutAt: 100, }) @@ -180,7 +181,7 @@ func TestReservationStaleDepositWatcher_OnDepositRevealedDelegates(t *testing.T) spvChain.setWallet(wallet, &tbtc.WalletChainData{ State: tbtc.StateUnknown, }) - spvChain.setReservationAction(key, 0, &tbtc.ReservationAction{ + spvChain.setReservationAction(key, 1, &tbtc.ReservationAction{ State: tbtc.ReservationActionStatePending, TimeoutAt: 100, }) @@ -205,6 +206,169 @@ func TestReservationStaleDepositWatcher_NilDepositKeyError(t *testing.T) { } } +func TestReservationStaleDepositWatcher_IsReservedDepositChainError(t *testing.T) { + spvChain := newLocalChain() + notifier := &recordingStaleNotifier{} + + spvChain.isReservedDepositErr = fmt.Errorf("rpc unavailable") + + watcher := NewReservationStaleDepositWatcher(spvChain, notifier) + err := watcher.CheckStaleReservedDeposit(reservationDepositKey(0xB010), 5_000) + if err == nil { + t.Fatal("expected error when IsReservedDeposit fails, got nil") + } + + if len(notifier.calls) != 0 { + t.Fatalf("expected no notifications on chain error, got %d", len(notifier.calls)) + } +} + +func TestReservationStaleDepositWatcher_ReservedDepositWalletChainError(t *testing.T) { + spvChain := newLocalChain() + notifier := &recordingStaleNotifier{} + + key := reservationDepositKey(0xB011) + spvChain.setReservedDeposit(key, walletPKH(), true) + spvChain.reservedDepositWalletErr = fmt.Errorf("rpc unavailable") + + watcher := NewReservationStaleDepositWatcher(spvChain, notifier) + if err := watcher.CheckStaleReservedDeposit(key, 5_000); err == nil { + t.Fatal("expected error when ReservedDepositWallet fails, got nil") + } + + if len(notifier.calls) != 0 { + t.Fatalf("expected no notifications on chain error, got %d", len(notifier.calls)) + } +} + +// TestReservationStaleDepositWatcher_GetWalletChainError exercises the +// error passthrough without any special chain-double wiring: the deposit's +// assigned wallet is never registered via setWallet, so GetWallet fails +// with its natural "no wallet for given PKH" error exactly as a real chain +// would if the wallet were somehow unresolvable. +func TestReservationStaleDepositWatcher_GetWalletChainError(t *testing.T) { + spvChain := newLocalChain() + notifier := &recordingStaleNotifier{} + + key := reservationDepositKey(0xB012) + spvChain.setReservedDeposit(key, walletPKH(), true) + // No spvChain.setWallet call: GetWallet errors naturally. + + watcher := NewReservationStaleDepositWatcher(spvChain, notifier) + if err := watcher.CheckStaleReservedDeposit(key, 5_000); err == nil { + t.Fatal("expected error when GetWallet fails, got nil") + } + + if len(notifier.calls) != 0 { + t.Fatalf("expected no notifications on chain error, got %d", len(notifier.calls)) + } +} + +// TestReservationStaleDepositWatcher_GetReservationActionChainError +// exercises the error passthrough the same way: the acceptance action +// (nonce 1) is never installed via setReservationAction, so +// GetReservationAction fails with its natural "no action for given +// reservation/nonce" error. +func TestReservationStaleDepositWatcher_GetReservationActionChainError(t *testing.T) { + spvChain := newLocalChain() + notifier := &recordingStaleNotifier{} + + key := reservationDepositKey(0xB013) + wallet := walletPKH() + spvChain.setReservedDeposit(key, wallet, true) + spvChain.setWallet(wallet, &tbtc.WalletChainData{ + State: tbtc.StateUnknown, + }) + // No spvChain.setReservationAction call: GetReservationAction errors + // naturally. + + watcher := NewReservationStaleDepositWatcher(spvChain, notifier) + if err := watcher.CheckStaleReservedDeposit(key, 5_000); err == nil { + t.Fatal("expected error when GetReservationAction fails, got nil") + } + + if len(notifier.calls) != 0 { + t.Fatalf("expected no notifications on chain error, got %d", len(notifier.calls)) + } +} + +// TestReservationStaleDepositWatcher_NotifierError verifies that, unlike +// the stranding watcher (which continues past a notifier failure because +// it processes a batch of reservations per call), the stale-deposit +// watcher propagates a NotifyStaleReservedDeposit failure to its single +// caller: CheckStaleReservedDeposit checks exactly one deposit per call, so +// there is nothing else to "continue" to. +func TestReservationStaleDepositWatcher_NotifierError(t *testing.T) { + spvChain := newLocalChain() + + key := reservationDepositKey(0xB014) + wallet := walletPKH() + spvChain.setReservedDeposit(key, wallet, true) + spvChain.setWallet(wallet, &tbtc.WalletChainData{ + State: tbtc.StateUnknown, + }) + spvChain.setReservationAction(key, 1, &tbtc.ReservationAction{ + State: tbtc.ReservationActionStatePending, + TimeoutAt: 100, + }) + + notifier := StaleReservedDepositNotifierFunc(func(*big.Int) error { + return fmt.Errorf("notifier unavailable") + }) + + watcher := NewReservationStaleDepositWatcher(spvChain, notifier) + if err := watcher.CheckStaleReservedDeposit(key, 5_000); err == nil { + t.Fatal("expected error when the notifier fails, got nil") + } +} + +// TestReservationStaleDepositWatcher_ExactTimeoutBoundaryDoesNotNotify +// covers the `now == action.TimeoutAt` boundary explicitly: the watcher's +// condition is `now <= action.TimeoutAt` (must NOT notify), so equality +// must defer exactly like "before the deadline" does. Existing tests only +// exercise now < TimeoutAt and now > TimeoutAt. +func TestReservationStaleDepositWatcher_ExactTimeoutBoundaryDoesNotNotify(t *testing.T) { + spvChain := newLocalChain() + notifier := &recordingStaleNotifier{} + + key := reservationDepositKey(0xB015) + wallet := walletPKH() + spvChain.setReservedDeposit(key, wallet, true) + spvChain.setWallet(wallet, &tbtc.WalletChainData{ + State: tbtc.StateUnknown, + }) + spvChain.setReservationAction(key, 1, &tbtc.ReservationAction{ + State: tbtc.ReservationActionStatePending, + TimeoutAt: 5_000, + }) + + watcher := NewReservationStaleDepositWatcher(spvChain, notifier) + if err := watcher.CheckStaleReservedDeposit(key, 5_000); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(notifier.calls) != 0 { + t.Fatalf( + "now == action.TimeoutAt must not notify, got %d calls", + len(notifier.calls), + ) + } +} + +func TestReservationStaleDepositWatcher_NilNotifierError(t *testing.T) { + spvChain := newLocalChain() + watcher := NewReservationStaleDepositWatcher(spvChain, nil) + + key := reservationDepositKey(0xB016) + + if err := watcher.CheckStaleReservedDeposit(key, 5_000); err == nil { + t.Fatal("expected error for nil notifier via CheckStaleReservedDeposit, got nil") + } + if err := watcher.OnDepositRevealed(key, 5_000); err == nil { + t.Fatal("expected error for nil notifier via OnDepositRevealed, got nil") + } +} + // recordingStaleNotifier is a test double that captures every // NotifyStaleReservedDeposit call. type recordingStaleNotifier struct { diff --git a/pkg/maintainer/spv/reservation_stranding_watch.go b/pkg/maintainer/spv/reservation_stranding_watch.go index 464c8abf4b..82b3507942 100644 --- a/pkg/maintainer/spv/reservation_stranding_watch.go +++ b/pkg/maintainer/spv/reservation_stranding_watch.go @@ -152,6 +152,7 @@ func (rsw *ReservationStrandingWatcher) CheckReservationStrandingForWallet( logger.Errorf( "failed to notify stranded reservation [%v]: [%v]", key, + err, ) // Continue with the remaining reservations: a single failure // must not starve the others. diff --git a/pkg/maintainer/spv/reservation_stranding_watch_test.go b/pkg/maintainer/spv/reservation_stranding_watch_test.go index 253e51a738..f3c27af1db 100644 --- a/pkg/maintainer/spv/reservation_stranding_watch_test.go +++ b/pkg/maintainer/spv/reservation_stranding_watch_test.go @@ -1,6 +1,7 @@ package spv import ( + "fmt" "math/big" "testing" @@ -227,6 +228,77 @@ func TestReservationStrandingWatcher_WalletChainError(t *testing.T) { } } +// TestReservationStrandingWatcher_NotifierErrorContinuesProcessing mirrors +// TestReservationActionTimeoutWatcher_NotifiesOncePerQualifyingNonce's +// resilience property for the sibling action-timeout watcher: a single +// NotifyReservationStranded failure must not starve the remaining +// reservations in the same wallet. +func TestReservationStrandingWatcher_NotifierErrorContinuesProcessing(t *testing.T) { + spvChain := newLocalChain() + + wallet := walletPKH() + failing := reservationKey(0xAA40) + succeeding := reservationKey(0xAA41) + + spvChain.setWalletReservations(wallet, []*big.Int{failing, succeeding}) + spvChain.setReservation(failing, &tbtc.Reservation{ + State: tbtc.ReservationStateActive, + }) + spvChain.setReservation(succeeding, &tbtc.Reservation{ + State: tbtc.ReservationStateActive, + }) + + var notified []*big.Int + notifier := ReservationStrandingNotifierFunc(func(key *big.Int) error { + if key.Cmp(failing) == 0 { + return fmt.Errorf("notifier unavailable") + } + notified = append(notified, key) + return nil + }) + + watcher := NewReservationStrandingWatcher(spvChain, notifier) + if err := watcher.CheckReservationStrandingForWallet(wallet); err != nil { + t.Fatalf( + "a single notifier failure must not fail the whole check: %v", + err, + ) + } + + if len(notified) != 1 { + t.Fatalf( + "expected the remaining reservation to still be notified, got %d", + len(notified), + ) + } + if diff := deep.Equal(succeeding, notified[0]); diff != nil { + t.Errorf("unexpected notified key: %v", diff) + } +} + +// TestReservationStrandingWatcher_WalletReservationsChainError covers the +// case TestReservationStrandingWatcher_WalletChainError's comment +// explicitly calls out as unreserved: WalletReservations itself failing +// (as opposed to returning an empty list for an unknown wallet). +func TestReservationStrandingWatcher_WalletReservationsChainError(t *testing.T) { + spvChain := newLocalChain() + notifier := &recordingStrandingNotifier{} + + spvChain.walletReservationsErr = fmt.Errorf("rpc unavailable") + + watcher := NewReservationStrandingWatcher(spvChain, notifier) + if err := watcher.CheckReservationStrandingForWallet(walletPKH()); err == nil { + t.Fatal("expected error when WalletReservations fails, got nil") + } + + if len(notifier.calls) != 0 { + t.Fatalf( + "expected no notifications on chain error, got %d", + len(notifier.calls), + ) + } +} + func TestReservationStrandingWatcher_NotifierFuncAdapter(t *testing.T) { var captured []*big.Int notifier := ReservationStrandingNotifierFunc(func(key *big.Int) error { diff --git a/pkg/maintainer/spv/reservation_wiring.go b/pkg/maintainer/spv/reservation_wiring.go index 55b58d6b1e..365f4c29ab 100644 --- a/pkg/maintainer/spv/reservation_wiring.go +++ b/pkg/maintainer/spv/reservation_wiring.go @@ -2,6 +2,7 @@ package spv import ( "context" + "fmt" "math/big" "time" @@ -30,34 +31,21 @@ const DefaultReservationActionTimeoutPollInterval = 1 * time.Minute // coordination layer calls when config.Reservations.Enabled is true. It // constructs the three reservation watchers (stranding, stale-deposit, // action-timeout), wires their Bridge-facing notifiers to the chain, and -// subscribes each watcher to its source event. +// subscribes/starts each watcher against its source. // // The function lives in the spv package because that is where the watcher // types live; the coordination layer invokes it via a callback supplied by // cmd/start.go so that the tbtc package does not need a static import of spv // (which would cycle with spv's existing import of tbtc). // -// The wiring is intentionally tolerant of the m1 placeholder signal: each -// event handler stores the watcher and event for the production integration -// step rather than dispatching a real call. This keeps the gate semantics -// (`config.Reservations.Enabled` is the single switch) intact while leaving -// the heavy lifting to the follow-up PR that lands the live wiring. -// -// `chain` is the tbtc.Chain used both for event subscriptions (On*) and for -// the watcher notifiers (Notify*). `ctx` controls the goroutine lifetimes -// started by the wiring function. +// `tbtcChain` supplies the On* event subscriptions the SPV-specific `Chain` +// omits; `spvChain` supplies the reservation data reads and Notify* writes. +// `ctx` controls the goroutine lifetimes started by the wiring function. func WireReservationWatchers( ctx context.Context, tbtcChain tbtc.Chain, spvChain Chain, ) error { - // The watcher constructors require the SPV-specific Chain interface - // because they call into the SPV proof submission surface - // (GetReservation, GetReservationAction, etc.). The Bridge-facing - // On* event subscriptions require the broader tbtc.Chain interface - // because the SPV interface omits event subscriptions. Both chains - // point at the same underlying handle in production; this function - // threads them through to the right call sites. chain := spvChain strandingWatcher := NewReservationStrandingWatcher( chain, @@ -77,13 +65,22 @@ func WireReservationWatchers( ), ) - // The action-timeout watcher requires a wallet members resolver that the - // production sortition backend will provide. Until the integration lands - // the placeholder resolver returns an empty slice; the watcher treats - // empty membership as a no-op for the m1 bridge notification shape. + // The action-timeout watcher requires a wallet members resolver backed + // by the sortition pool / operator registry. No such lookup is wired + // into the SPV maintainer chain interface yet (it does not expose + // GetOperatorID), so the resolver here is a documented gap that fails + // loud rather than silently succeeding with an empty member set: every + // call errors, which CheckReservationActionTimeouts propagates as a + // per-reservation error (logged, does not abort the poll loop) instead + // of ever calling NotifyReservationActionTimeout with no attributable + // members. Wire a real resolver here once the sortition backend + // integration lands. membersResolver := WalletMembersResolverFunc( func(walletPublicKeyHash [20]byte) ([]uint32, error) { - return nil, nil + return nil, fmt.Errorf( + "wallet members resolver not wired: sortition pool " + + "integration for the SPV maintainer is pending", + ) }, ) actionTimeoutWatcher := NewReservationActionTimeoutWatcher( @@ -100,108 +97,244 @@ func WireReservationWatchers( DefaultReservationActionTimeoutPollInterval, ) - subscribeReservationWalletClosed(ctx, tbtcChain, strandingWatcher) - subscribeReservationActionTimedOut(ctx, tbtcChain, actionTimeoutWatcher) - startStaleDepositPoll(ctx, tbtcChain, staleDepositWatcher) + subscribeReservationWalletClosed(tbtcChain, spvChain, strandingWatcher) + startStaleDepositPoll(ctx, tbtcChain, spvChain, staleDepositWatcher) startActionTimeoutRun(ctx, actionTimeoutWatcher) return nil } // subscribeReservationWalletClosed registers the stranding watcher against -// the chain's wallet close / termination events. The integration step that -// lands the wallet-ID -> public-key-hash mapping will dispatch the watcher -// here; for now we hold the watcher reference so the gate semantics are -// observable in the running process. +// the chain's wallet close / termination events. Each event dispatches a +// worker goroutine that resolves the closed wallet's ECDSA wallet ID (the +// only identifier WalletClosedEvent carries) to its public key hash and +// runs the watcher's stranding check for that wallet. func subscribeReservationWalletClosed( - ctx context.Context, tbtcChain tbtc.Chain, + spvChain Chain, watcher *ReservationStrandingWatcher, ) { - // Use the broader tbtc.Chain so the OnWalletClosed subscription is - // available; the SPV-specific Chain does not expose event - // subscriptions. - chain := tbtcChain - _ = chain.OnWalletClosed(func(event *tbtc.WalletClosedEvent) { - // PR H placeholder: the production wiring resolves the - // event.WalletID into the corresponding wallet public key - // hash and dispatches watcher.CheckReservationStrandingForWallet - // on a worker goroutine. The wiring step that adds the - // mapping is delivered by the follow-up integration PR. - _ = watcher - _ = event - reservationWiringLogger.Debug( - "received wallet closed event; stranding watcher integration " + - "is a placeholder in PR H", - ) + _ = tbtcChain.OnWalletClosed(func(event *tbtc.WalletClosedEvent) { + go func() { + walletPublicKeyHash, err := resolveWalletPublicKeyHash( + spvChain, + event.WalletID, + ) + if err != nil { + reservationWiringLogger.Errorf( + "failed to resolve public key hash for closed "+ + "wallet [0x%x]: [%v]", + event.WalletID, + err, + ) + return + } + + if err := watcher.CheckReservationStrandingForWallet( + walletPublicKeyHash, + ); err != nil { + reservationWiringLogger.Errorf( + "failed to check reservation stranding for closed "+ + "wallet [0x%x] (ID [0x%x]): [%v]", + walletPublicKeyHash, + event.WalletID, + err, + ) + } + }() }) } -// subscribeReservationActionTimedOut registers the action-timeout watcher -// against the chain's on-chain ReservationActionTimedOut event. The -// production wiring dispatches watcher.CheckReservationActionTimeouts from -// here; for now we hold the watcher reference so the gate semantics are -// observable in the running process. -func subscribeReservationActionTimedOut( - ctx context.Context, - tbtcChain tbtc.Chain, - watcher *ReservationActionTimeoutWatcher, -) { - chain := tbtcChain - _ = chain.OnReservationActionTimedOut( - func(event *tbtc.ReservationActionTimedOutEvent) { - // PR H placeholder: the production wiring reads the - // reservation key from the event and dispatches - // watcher.CheckReservationActionTimeouts on a worker - // goroutine. - _ = watcher - _ = event - reservationWiringLogger.Debug( - "received reservation action timed out event; " + - "action-timeout watcher integration is a " + - "placeholder in PR H", - ) +// resolveWalletPublicKeyHash maps an ECDSA wallet ID to the wallet's public +// key hash via its NewWalletRegistered event. Every wallet is registered +// exactly once before it can be closed, and the filter is indexed on the +// wallet ID, so this is a targeted lookup rather than a history scan. +func resolveWalletPublicKeyHash( + spvChain Chain, + walletID [32]byte, +) ([20]byte, error) { + events, err := spvChain.PastNewWalletRegisteredEvents( + &tbtc.NewWalletRegisteredEventFilter{ + EcdsaWalletID: [][32]byte{walletID}, }, ) + if err != nil { + return [20]byte{}, fmt.Errorf( + "failed to fetch wallet registration event: [%w]", + err, + ) + } + if len(events) == 0 { + return [20]byte{}, fmt.Errorf( + "no wallet registration event found for wallet ID [0x%x]", + walletID, + ) + } + + // A wallet ID is registered at most once; take the latest match + // defensively in case of a duplicate log delivery. + return events[len(events)-1].WalletPublicKeyHash, nil } -// startStaleDepositPoll runs the stale-deposit watcher integration as a -// polling loop over PastDepositRevealedEvents. The Bridge does not expose a -// live subscription for DepositRevealed in m1, so this loop is the -// placeholder source: each tick fetches the events since the last seen -// block and dispatches them to the watcher. +// reservationStaleDepositLookBackBlocks bounds the first stale-deposit poll +// tick's DepositRevealed scan. 30 days at 12s/block, mirroring +// ReservationAcceptanceLookBackBlocks in pkg/tbtcpg. Subsequent ticks scan +// incrementally from the previous tick's block, so this bound only matters +// once, at startup. +const reservationStaleDepositLookBackBlocks = uint64(216000) + +// startStaleDepositPoll runs the stale-deposit watcher's live source as a +// polling loop over PastDepositRevealedEvents: the Bridge does not expose a +// live subscription for DepositRevealed in m1. Each tick fetches reveals +// since the previously scanned block, adds every reserved deposit among +// them to a tracked pending set, then re-runs CheckStaleReservedDeposit for +// every deposit already in the set. A deposit is dropped from the set once +// it is no longer reserved (released to the default sweep path, or swept) +// or its assigned wallet has gone Live - both mean it can never go stale +// again, so re-checking it forever would be wasted RPCs. // // The poller is intentionally tolerant of chain errors: a transient RPC // failure logs and continues rather than aborting the wiring. func startStaleDepositPoll( ctx context.Context, tbtcChain tbtc.Chain, + spvChain Chain, watcher *ReservationStaleDepositWatcher, ) { - chain := tbtcChain - // PR H placeholder: the live subscription integration lands in - // the follow-up PR; until then the wiring keeps the watcher alive - // but does not invoke OnDepositRevealed. Holding the watcher - // reference is enough to make the gate observable. - _ = watcher - _ = chain - _ = ctx - reservationWiringLogger.Debug( - "reservation stale-deposit watcher constructed; live polling " + - "integration is a placeholder in PR H", - ) + go func() { + ticker := time.NewTicker(DefaultReservationStaleDepositPollInterval) + defer ticker.Stop() + + var lastSeenBlock uint64 + pending := make(map[string]*big.Int) + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + + blockCounter, err := spvChain.BlockCounter() + if err != nil { + reservationWiringLogger.Errorf( + "stale-deposit poll failed to get block counter: [%v]", + err, + ) + continue + } + currentBlock, err := blockCounter.CurrentBlock() + if err != nil { + reservationWiringLogger.Errorf( + "stale-deposit poll failed to get current block: [%v]", + err, + ) + continue + } + + startBlock := lastSeenBlock + if startBlock == 0 && currentBlock > reservationStaleDepositLookBackBlocks { + startBlock = currentBlock - reservationStaleDepositLookBackBlocks + } + + events, err := tbtcChain.PastDepositRevealedEvents( + &tbtc.DepositRevealedEventFilter{StartBlock: startBlock}, + ) + if err != nil { + reservationWiringLogger.Errorf( + "stale-deposit poll failed to fetch deposit revealed "+ + "events: [%v]", + err, + ) + continue + } + + for _, event := range events { + depositKey := spvChain.BuildDepositKey( + event.FundingTxHash, + event.FundingOutputIndex, + ) + + isReserved, err := spvChain.IsReservedDeposit(depositKey) + if err != nil { + reservationWiringLogger.Errorf( + "stale-deposit poll failed to check if deposit "+ + "[%v] is reserved: [%v]", + depositKey, + err, + ) + continue + } + if !isReserved { + continue + } + + pending[depositKey.String()] = depositKey + } + + lastSeenBlock = currentBlock + now := uint32(time.Now().Unix()) + + for key, depositKey := range pending { + if err := watcher.CheckStaleReservedDeposit( + depositKey, + now, + ); err != nil { + reservationWiringLogger.Errorf( + "stale-deposit poll failed to check deposit "+ + "[%v]: [%v]", + depositKey, + err, + ) + continue + } + + if isPendingStaleDepositResolved(spvChain, depositKey) { + delete(pending, key) + } + } + } + }() +} + +// isPendingStaleDepositResolved reports whether depositKey no longer needs +// tracking: it stopped being a reserved deposit (released or swept), or its +// assigned wallet reached StateLive (expected to anchor on its own). Chain +// read errors are treated as unresolved so a transient RPC failure does not +// silently drop a deposit that might still need the stale check. +func isPendingStaleDepositResolved( + spvChain Chain, + depositKey *big.Int, +) bool { + isReserved, err := spvChain.IsReservedDeposit(depositKey) + if err != nil { + return false + } + if !isReserved { + return true + } + + walletPublicKeyHash, err := spvChain.ReservedDepositWallet(depositKey) + if err != nil || walletPublicKeyHash == ([20]byte{}) { + return false + } + + wallet, err := spvChain.GetWallet(walletPublicKeyHash) + if err != nil { + return false + } + + return wallet.State == tbtc.StateLive } // 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, tied to ctx's lifetime. func startActionTimeoutRun( ctx context.Context, watcher *ReservationActionTimeoutWatcher, ) { go func() { - if err := watcher.Run(); err != nil { + if err := watcher.Run(ctx); err != nil { reservationWiringLogger.Errorf( "failed to start reservation action-timeout watcher: [%v]", err, diff --git a/pkg/maintainer/spv/spv.go b/pkg/maintainer/spv/spv.go index c88ef19788..0fc40caee6 100644 --- a/pkg/maintainer/spv/spv.go +++ b/pkg/maintainer/spv/spv.go @@ -52,32 +52,15 @@ 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. - proofTypes[tbtc.ActionReservationAnchor] = struct { - unprovenTransactionsGetter unprovenTransactionsGetter - transactionProofSubmitter transactionProofSubmitter - }{ - unprovenTransactionsGetter: getUnprovenReservationAcceptanceTransactions, - transactionProofSubmitter: SubmitReservationAcceptanceProof, - } - proofTypes[tbtc.ActionReservationReanchor] = struct { - unprovenTransactionsGetter unprovenTransactionsGetter - 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, - } + // Reservation acceptance/re-anchor proofs run on a dedicated loop, + // not through the generic proofTypes map: SubmitReservationProof + // requires the (reservationKey, requestNonce) pair of the action + // generation being proven, which the generic + // unprovenTransactionsGetter/transactionProofSubmitter signatures + // (shared by deposit sweep, redemption, moving funds, and moved + // funds sweep, none of which need that pair) cannot carry. See + // reservation_proof_loop.go. + go maintainReservationProofs(ctx, config, spvChain, btcDiffChain, btcChain) } go spvMaintainer.startControlLoop(ctx) @@ -505,54 +488,3 @@ type spvProofAssembler func( requiredConfirmations uint, btcChain bitcoin.Chain, ) (*bitcoin.Transaction, *bitcoin.SpvProof, error) - -// getUnprovenReservationAcceptanceTransactions is a placeholder for the -// reservation acceptance proof task. The production wiring for reservation -// acceptance proofs is delivered by the reservation watcher integration that -// translates wallet-side acceptance events into SPV proof submissions; until -// that wiring lands this getter returns no transactions so the generic proof -// loop skips reservation acceptance cleanly. -// -// Marked by PR H; the gate on config.Reservations.Enabled ensures the task is -// only attached to proofTypes when reservations are enabled. -func getUnprovenReservationAcceptanceTransactions( - historyDepth uint64, - transactionLimit int, - btcChain bitcoin.Chain, - spvChain Chain, -) ([]*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 -} diff --git a/pkg/tbtc/chain.go b/pkg/tbtc/chain.go index 0f4c8a8522..c1a83c34d8 100644 --- a/pkg/tbtc/chain.go +++ b/pkg/tbtc/chain.go @@ -427,22 +427,6 @@ type WalletProposalValidatorChain interface { }, ) error - // GetReservation gets the on-chain reservation record for the given - // reservation key. Returns an error if the reservation was not found. - GetReservation(reservationKey *big.Int) (*Reservation, error) - - // GetReservationAction gets the on-chain action record for the given - // reservation key and request nonce. Returns an error if the action - // generation was not found. - GetReservationAction( - reservationKey *big.Int, - requestNonce uint64, - ) (*ReservationAction, error) - - // ReservationParameters gets the current on-chain values of the Bridge - // reservation parameters. - ReservationParameters() (*ReservationParameters, error) - // ValidateReservationAnchorProposal validates the given reservation // anchor proposal against the chain. Returns an error if the proposal // is not valid or nil otherwise. @@ -675,13 +659,6 @@ type ReservationChain interface { // reservation parameters. ReservationParameters() (*ReservationParameters, error) - // ReservationParametersFull is an alias for ReservationParameters - // retained for callers that want a name indicating the full 10-tuple - // on-chain layout. The on-chain reservation parameters tuple and the - // tbtc.ReservationParameters Go type carry the same 10 fields, so this - // returns the same struct. - ReservationParametersFull() (*ReservationParameters, error) - // ReservationCaps returns the cap parameters that gate reservation // acceptance: the maximum aggregate satoshi amount a single wallet may // custody across all of its reservations, and the maximum satoshi diff --git a/pkg/tbtc/chain_test.go b/pkg/tbtc/chain_test.go index bace37faba..08717fdc3e 100644 --- a/pkg/tbtc/chain_test.go +++ b/pkg/tbtc/chain_test.go @@ -1543,10 +1543,6 @@ func (lc *localChain) NotifyReservationStranded( panic("unsupported") } -func (lc *localChain) ReservationParametersFull() (*ReservationParameters, error) { - return nil, fmt.Errorf("unsupported") -} - func (lc *localChain) ReservationCaps() ( uint64, uint64, diff --git a/pkg/tbtc/coordination.go b/pkg/tbtc/coordination.go index 2dd75e9614..f7e626acd5 100644 --- a/pkg/tbtc/coordination.go +++ b/pkg/tbtc/coordination.go @@ -304,6 +304,10 @@ type coordinationExecutor struct { waitForBlockFn waitForBlockFn + // reservationsEnabled mirrors config.Reservations.Enabled. Determines + // whether the actions checklist includes the reservation action types. + reservationsEnabled bool + // metricsRecorder is optional and used for recording performance metrics metricsRecorder interface { IncrementCounter(name string, value float64) @@ -324,6 +328,7 @@ func newCoordinationExecutor( membershipValidator *group.MembershipValidator, protocolLatch *generator.ProtocolLatch, waitForBlockFn waitForBlockFn, + reservationsEnabled bool, ) *coordinationExecutor { return &coordinationExecutor{ lock: semaphore.NewWeighted(1), @@ -336,6 +341,7 @@ func newCoordinationExecutor( membershipValidator: membershipValidator, protocolLatch: protocolLatch, waitForBlockFn: waitForBlockFn, + reservationsEnabled: reservationsEnabled, } } @@ -631,6 +637,21 @@ func (ce *coordinationExecutor) getActionsChecklist( } } + // Reservation actions (acceptance, re-anchor) are only checked when the + // operator has enabled the reservation subsystem. Gating the checklist + // entry on the same flag that gates the reservation proposal generator + // tasks (see tbtcpg.NewProposalGenerator) keeps leader and follower + // checklists in agreement: a follower that never enters this branch + // would otherwise fault a leader's reservation proposal as + // FaultLeaderMistake because the action would not appear in its own + // checklist. Frequency-gated like DepositSweep/MovingFunds below the + // activation block: reservation acceptance/re-anchor windows are not + // as time-critical as redemption. + if ce.reservationsEnabled && windowIndex%frequencyWindows == 0 { + actions = append(actions, ActionReservationAnchor) + actions = append(actions, ActionReservationReanchor) + } + // #nosec G404 (insecure random number source (rand)) // Drawing a decision about heartbeat does not require secure randomness. // Use first 8 bytes of the seed to initialize the RNG. diff --git a/pkg/tbtc/coordination_test.go b/pkg/tbtc/coordination_test.go index c597048fb0..3d59b3f486 100644 --- a/pkg/tbtc/coordination_test.go +++ b/pkg/tbtc/coordination_test.go @@ -340,6 +340,7 @@ func TestCoordinationExecutor_Coordinate(t *testing.T) { membershipValidator, protocolLatch, operator.waitForBlockHeight, + false, ) } diff --git a/pkg/tbtc/node.go b/pkg/tbtc/node.go index fcf71ed5cb..f06a99e18a 100644 --- a/pkg/tbtc/node.go +++ b/pkg/tbtc/node.go @@ -117,6 +117,13 @@ type node struct { // transactionMonitor watches broadcast wallet transactions and alerts on // ones that remain unconfirmed long enough to be considered stuck. transactionMonitor *transactionMonitor + + // reservationsEnabled mirrors config.Reservations.Enabled. Threaded into + // each coordinationExecutor so the leader/follower actions checklist + // includes the reservation action types exactly when the reservation + // proposal generator tasks are wired in, keeping the two gates in + // lockstep (see coordinationExecutor.getActionsChecklist). + reservationsEnabled bool } func newNode( @@ -155,6 +162,7 @@ func newNode( coordinationExecutors: make(map[string]*coordinationExecutor), proposalGenerator: proposalGenerator, transactionMonitor: newTransactionMonitor(btcChain), + reservationsEnabled: config.Reservations.Enabled, } // Archive any wallets that might have been closed or terminated while the diff --git a/pkg/tbtc/node_executors.go b/pkg/tbtc/node_executors.go index 8a33854ef6..56182e3bb8 100644 --- a/pkg/tbtc/node_executors.go +++ b/pkg/tbtc/node_executors.go @@ -209,6 +209,7 @@ func (n *node) getCoordinationExecutor( membershipValidator, n.protocolLatch, n.waitForBlockHeight, + n.reservationsEnabled, ) // Wire metrics recorder if available diff --git a/pkg/tbtc/tbtc.go b/pkg/tbtc/tbtc.go index c7f65795cb..1472029ce1 100644 --- a/pkg/tbtc/tbtc.go +++ b/pkg/tbtc/tbtc.go @@ -106,6 +106,18 @@ type Config struct { // ReservationsConfig holds the reservation-related tbtc.Config fields. It is // a separate type so future reservation knobs (poll intervals, cap overrides) // can be added without breaking the top-level Config layout. +// +// This flag controls only reservation acceptance / re-anchor proposal +// GENERATION (the `start` command's coordination layer, config category +// Tbtc). The `maintainer` command runs as a separate process reading a +// disjoint config category (see config.MaintainerCategories) and has its +// own independent gate, spv.ReservationsConfig.Enabled, that controls SPV +// PROOF SUBMISSION for those same proposals. Neither command's config +// loading sees the other's category, so this flag cannot be derived from or +// validated against spv.ReservationsConfig.Enabled in code. An operator +// running both `start` and `maintainer` for the reservation feature to work +// end-to-end MUST enable both flags - normally the same [Tbtc.Reservations] +// / [Maintainer.Spv.Reservations] TOML sections in one shared config file. type ReservationsConfig struct { // Enabled toggles reservation acceptance / re-anchor proposal // generation and reservation watcher wiring. Defaults to false so @@ -116,18 +128,14 @@ type ReservationsConfig struct { // Initialize kicks off the TBTC by initializing internal state, ensuring // preconditions like staking are met, and then kicking off the internal TBTC // implementation. Returns an error if this failed. -// ReservationWatchersWirer is the contract the Initialize caller fulfils -// to gate the PR H reservation watcher wiring on config.Reservations.Enabled. -// The signature accepts the live tbtc.Chain so the wiring can subscribe to -// the On* event surface and forward Bridge notifications; production -// implementations live in pkg/maintainer/spv and are passed in by cmd/start.go -// to avoid a static tbtc -> spv import cycle. // -// When config.Reservations.Enabled is true and the wirer is non-nil, -// Initialize invokes the wirer after the existing event subscriptions have -// been registered so the watcher event handlers see the same chain handle. -// When Reservations.Enabled is false the wirer is not invoked. -type ReservationWatchersWirer func(ctx context.Context, chain Chain) error +// Reservation watcher wiring (stranding / stale-deposit / action-timeout, +// see pkg/maintainer/spv.WireReservationWatchers) is not performed here: +// it lives in cmd/start.go, called directly against the same tbtc.Chain +// handle once Initialize returns successfully and gated on the same +// config.Reservations.Enabled flag. Threading it through Initialize via a +// callback type would only exist to dodge a tbtc -> spv import cycle that +// cmd/start.go (which already imports both packages) does not have. func Initialize( ctx context.Context, @@ -142,7 +150,6 @@ func Initialize( clientInfo *clientinfo.Registry, perfMetrics *clientinfo.PerformanceMetrics, ethereumNetwork ethereum.Network, - wireReservationWatchers ReservationWatchersWirer, ) error { groupParameters := defaultGroupParameters(ethereumNetwork) @@ -203,7 +210,11 @@ func Initialize( ) if perfMetrics == nil { - perfMetrics = clientinfo.NewPerformanceMetrics(ctx, clientInfo) + perfMetrics = clientinfo.NewPerformanceMetrics( + ctx, + clientInfo, + config.Reservations.Enabled, + ) } node.setPerformanceMetrics(perfMetrics) @@ -403,23 +414,6 @@ func Initialize( }() }) - if config.Reservations.Enabled && wireReservationWatchers != nil { - // PR H: wire reservation watchers (stranding, stale-deposit, - // action-timeout). The wiring function is supplied by the - // caller (cmd/start.go) so the tbtc package never imports spv - // directly. The wirer constructs the watchers and subscribes - // them to the chain; construction itself is gated on - // Reservations.Enabled inside spv. Failing to wire the watchers - // is fatal: the operator opted into reservations, so a missing - // watcher would silently strand anchors. - if err := wireReservationWatchers(ctx, chain); err != nil { - return fmt.Errorf( - "failed to wire reservation watchers: [%w]", - err, - ) - } - } - return nil } diff --git a/test/config.json b/test/config.json index 0792a2b49d..0b56a5e841 100644 --- a/test/config.json +++ b/test/config.json @@ -50,7 +50,10 @@ "HistoryDepth": 25000, "TransactionLimit": 80, "RestartBackoffTime": "2h", - "IdleBackoffTime": "15m" + "IdleBackoffTime": "15m", + "Reservations": { + "Enabled": true + } } }, "Developer": { diff --git a/test/config.toml b/test/config.toml index 8e22dae494..00428254e6 100644 --- a/test/config.toml +++ b/test/config.toml @@ -47,6 +47,9 @@ TransactionLimit = 80 RestartBackoffTime = "2h" IdleBackoffTime = "15m" +[maintainer.Spv.Reservations] +Enabled = true + [developer] RandomBeaconAddress = "0xcf64c2a367341170cb4e09cf8c0ed137d8473ceb" WalletRegistryAddress = "0x143ba24e66fce8bca22f7d739f9a932c519b1c76" diff --git a/test/config.yaml b/test/config.yaml index 8809033eeb..a8b95153ce 100644 --- a/test/config.yaml +++ b/test/config.yaml @@ -41,6 +41,8 @@ Maintainer: TransactionLimit: 80 RestartBackoffTime: "2h" IdleBackoffTime: "15m" + Reservations: + Enabled: true Developer: RandomBeaconAddress: "0xcf64c2a367341170cb4e09cf8c0ed137d8473ceb" WalletRegistryAddress: "0x143ba24e66fce8bca22f7d739f9a932c519b1c76" From 1de2631a357c789bac0a3c59216c9db057e14376 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 1 Sep 2026 14:38:32 +0000 Subject: [PATCH 16/39] fix(clientinfo): gate reservation metrics registration behind config flag NewPerformanceMetrics now takes a reservationsEnabled flag and only registers the reservation-specific wallet action metrics (anchor, reservation_anchor, reservation_reanchor, reserved_redemption, reservation_dissolution) when the feature is enabled, so the /metrics endpoint does not advertise counters for actions the deployment never produces. --- pkg/clientinfo/performance.go | 42 ++++++++++++--- pkg/clientinfo/performance_test.go | 84 ++++++++++++++++++++++++++---- 2 files changed, 110 insertions(+), 16 deletions(-) diff --git a/pkg/clientinfo/performance.go b/pkg/clientinfo/performance.go index da4d10cf83..1176f1f21d 100644 --- a/pkg/clientinfo/performance.go +++ b/pkg/clientinfo/performance.go @@ -37,6 +37,13 @@ type PerformanceMetrics struct { registry *Registry cancel context.CancelFunc + // reservationsEnabled mirrors tbtc.Config.Reservations.Enabled. Gates + // registration of the reservation-specific wallet action metrics + // (reservation_anchor, reserved_redemption, reservation_reanchor, + // reservation_dissolution) so a non-reservation deployment's metric + // surface does not change - see GetAllWalletActionTypes. + reservationsEnabled bool + // Counters track cumulative counts of events countersMutex sync.RWMutex counters map[string]*counter @@ -75,14 +82,21 @@ const ( ) // NewPerformanceMetrics creates a new performance metrics instance. -func NewPerformanceMetrics(ctx context.Context, registry *Registry) *PerformanceMetrics { +// reservationsEnabled gates registration of the reservation-specific wallet +// action metrics (see GetAllWalletActionTypes / registerAllMetrics). +func NewPerformanceMetrics( + ctx context.Context, + registry *Registry, + reservationsEnabled bool, +) *PerformanceMetrics { ctx, cancel := context.WithCancel(ctx) pm := &PerformanceMetrics{ - registry: registry, - cancel: cancel, - counters: make(map[string]*counter), - histograms: make(map[string]*histogram), - gauges: make(map[string]*gauge), + registry: registry, + cancel: cancel, + reservationsEnabled: reservationsEnabled, + counters: make(map[string]*counter), + histograms: make(map[string]*histogram), + gauges: make(map[string]*gauge), } // Register all metrics upfront with 0 values so they appear in /metrics endpoint @@ -180,6 +194,10 @@ func (pm *PerformanceMetrics) registerAllMetrics() { // Register per-action type wallet metrics // For each action type, register: total, success_total, failed_total, duration_seconds for _, actionType := range GetAllWalletActionTypes() { + if isReservationWalletActionType(actionType) && !pm.reservationsEnabled { + continue + } + actionCounters := []string{ WalletActionMetricName(actionType, "total"), WalletActionMetricName(actionType, "success_total"), @@ -761,3 +779,15 @@ func GetAllWalletActionTypes() []string { "reservation_dissolution", } } + +// isReservationWalletActionType reports whether actionType is one of the +// reservation-specific action types gated by +// PerformanceMetrics.reservationsEnabled. +func isReservationWalletActionType(actionType string) bool { + switch actionType { + case "reservation_anchor", "reserved_redemption", "reservation_reanchor", "reservation_dissolution": + return true + default: + return false + } +} diff --git a/pkg/clientinfo/performance_test.go b/pkg/clientinfo/performance_test.go index 0354527ade..8281659283 100644 --- a/pkg/clientinfo/performance_test.go +++ b/pkg/clientinfo/performance_test.go @@ -17,7 +17,7 @@ func TestConcurrentCounterIncrement(t *testing.T) { defer cancel() registry := &Registry{keepclientinfo.NewRegistry(), ctx} - pm := NewPerformanceMetrics(ctx, registry) + pm := NewPerformanceMetrics(ctx, registry, true) const ( numGoroutines = 100 @@ -51,7 +51,7 @@ func TestConcurrentCounterDifferentMetrics(t *testing.T) { defer cancel() registry := &Registry{keepclientinfo.NewRegistry(), ctx} - pm := NewPerformanceMetrics(ctx, registry) + pm := NewPerformanceMetrics(ctx, registry, true) const ( numGoroutines = 50 @@ -115,7 +115,7 @@ func TestConcurrentDurationRecording(t *testing.T) { defer cancel() registry := &Registry{keepclientinfo.NewRegistry(), ctx} - pm := NewPerformanceMetrics(ctx, registry) + pm := NewPerformanceMetrics(ctx, registry, true) const ( numGoroutines = 50 @@ -169,7 +169,7 @@ func TestConcurrentGaugeSet(t *testing.T) { defer cancel() registry := &Registry{keepclientinfo.NewRegistry(), ctx} - pm := NewPerformanceMetrics(ctx, registry) + pm := NewPerformanceMetrics(ctx, registry, true) const ( numGoroutines = 100 @@ -205,7 +205,7 @@ func TestConcurrentDifferentOperations(t *testing.T) { defer cancel() registry := &Registry{keepclientinfo.NewRegistry(), ctx} - pm := NewPerformanceMetrics(ctx, registry) + pm := NewPerformanceMetrics(ctx, registry, true) const ( numGoroutines = 30 @@ -264,7 +264,7 @@ func TestHistogramBucketPlacement(t *testing.T) { defer cancel() registry := &Registry{keepclientinfo.NewRegistry(), ctx} - pm := NewPerformanceMetrics(ctx, registry) + pm := NewPerformanceMetrics(ctx, registry, true) metricName := "test_duration_seconds" @@ -320,7 +320,7 @@ func TestMetricsInitialization(t *testing.T) { defer cancel() registry := &Registry{keepclientinfo.NewRegistry(), ctx} - pm := NewPerformanceMetrics(ctx, registry) + pm := NewPerformanceMetrics(ctx, registry, true) // Test counters counters := []string{ @@ -359,7 +359,7 @@ func TestContextCancelation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) registry := &Registry{keepclientinfo.NewRegistry(), ctx} - pm := NewPerformanceMetrics(ctx, registry) + pm := NewPerformanceMetrics(ctx, registry, true) // Cancel context immediately cancel() @@ -404,7 +404,7 @@ func TestJoinFailureAndOnChainCountersRegistered(t *testing.T) { defer cancel() registry := &Registry{keepclientinfo.NewRegistry(), ctx} - pm := NewPerformanceMetrics(ctx, registry) + pm := NewPerformanceMetrics(ctx, registry, true) expectedCounters := []string{MetricFirewallOnChainChecksTotal} for _, reason := range GetAllNetworkJoinFailureReasons() { @@ -436,7 +436,7 @@ func TestWalletActionMetricsRegistered(t *testing.T) { defer cancel() registry := &Registry{keepclientinfo.NewRegistry(), ctx} - pm := NewPerformanceMetrics(ctx, registry) + pm := NewPerformanceMetrics(ctx, registry, true) expectedActionTypes := []string{ "heartbeat", @@ -481,3 +481,67 @@ func TestWalletActionMetricsRegistered(t *testing.T) { } } } + +func TestWalletActionMetricsNotRegisteredWhenReservationsDisabled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + registry := &Registry{keepclientinfo.NewRegistry(), ctx} + pm := NewPerformanceMetrics(ctx, registry, false) + + nonReservationActionTypes := []string{ + "heartbeat", + "deposit_sweep", + "redemption", + "moving_funds", + "moved_funds_sweep", + } + reservationActionTypes := []string{ + "reservation_anchor", + "reserved_redemption", + "reservation_reanchor", + "reservation_dissolution", + } + + for _, actionType := range nonReservationActionTypes { + metricName := WalletActionMetricName(actionType, "total") + pm.countersMutex.RLock() + _, exists := pm.counters[metricName] + pm.countersMutex.RUnlock() + if !exists { + t.Errorf( + "counter %s should still be registered when reservations "+ + "are disabled", + metricName, + ) + } + } + + for _, actionType := range reservationActionTypes { + for _, metricType := range []string{"total", "success_total", "failed_total"} { + metricName := WalletActionMetricName(actionType, metricType) + pm.countersMutex.RLock() + _, exists := pm.counters[metricName] + pm.countersMutex.RUnlock() + if exists { + t.Errorf( + "counter %s should not be registered when reservations "+ + "are disabled", + metricName, + ) + } + } + + durationMetricName := WalletActionMetricName(actionType, "duration_seconds") + pm.histogramsMutex.RLock() + _, exists := pm.histograms[durationMetricName] + pm.histogramsMutex.RUnlock() + if exists { + t.Errorf( + "histogram %s should not be registered when reservations "+ + "are disabled", + durationMetricName, + ) + } + } +} From 753d7179d0a4ccad6fb79ee15c32cd298386a8e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 1 Sep 2026 14:38:38 +0000 Subject: [PATCH 17/39] test(ethereum): add unit tests for reservation ABI conversion functions convertReservationFromAbiType, convertReservationActionFromAbiType, and convertReservationParametersFromAbiType had zero unit tests despite being on the hot path for every reservation read: a field-order swap in the 10-tuple parameters struct, or a wrong action-type-to-hash-field routing decision, would silently feed bad data into checkReservationAcceptanceEligibility undetected. Also removes the dead duplicate ReservationParametersFull binding left over from the reservation router integration. --- pkg/chain/ethereum/tbtc.go | 10 -- pkg/chain/ethereum/tbtc_test.go | 241 ++++++++++++++++++++++++++++++++ 2 files changed, 241 insertions(+), 10 deletions(-) diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index bb3ad344f6..fca553f33c 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -3068,16 +3068,6 @@ func (tc *TbtcChain) NotifyReservationStranded( return err } -// ReservationParametersFull is an alias for ReservationParameters; both -// return the same tbtc.ReservationParameters struct that already carries -// the full 10-tuple. -func (tc *TbtcChain) ReservationParametersFull() ( - *tbtc.ReservationParameters, - error, -) { - return tc.ReservationParameters() -} - // ReservationCaps returns the cap parameters that gate reservation // acceptance. The reservationRouter binding is bound to the Bridge // address; the call routes through Bridge.fallback into the router's diff --git a/pkg/chain/ethereum/tbtc_test.go b/pkg/chain/ethereum/tbtc_test.go index 1c9eef1be0..fe7c5da982 100644 --- a/pkg/chain/ethereum/tbtc_test.go +++ b/pkg/chain/ethereum/tbtc_test.go @@ -16,8 +16,10 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/keep-network/keep-core/internal/testutils" + tbtcabi "github.com/keep-network/keep-core/pkg/chain/ethereum/tbtc/gen/abi" "github.com/keep-network/keep-core/pkg/chain/local_v1" "github.com/keep-network/keep-core/pkg/protocol/group" + "github.com/keep-network/keep-core/pkg/tbtc" ) func TestComputeOperatorsIDsHash(t *testing.T) { @@ -533,3 +535,242 @@ func TestBuildMovedFundsKey(t *testing.T) { movedFundsKey.Text(16), ) } + +func TestConvertReservationFromAbiType(t *testing.T) { + ownerAddress := common.HexToAddress( + "0x1234567890AbcdEF1234567890aBcdef12345678", + ) + anchorTxHash := [32]byte{0x01, 0x02, 0x03, 0x04} + + validAbiReservation := tbtcabi.ReservationReservationRequest{ + Owner: ownerAddress, + MintedAmount: 100000, + AcceptedAt: 1700000000, + WalletPubKeyHash: [20]byte{0xaa, 0xbb, 0xcc}, + AnchorAmount: 99000, + ExpiresAt: 1700100000, + AnchorTxHash: anchorTxHash, + AnchorTxOutputIndex: 1, + State: 1, // Active + RequestNonce: 7, + RetryCredit: true, + DissolutionEligibleAt: 1700200000, + // CumulativeReanchorFee is intentionally dropped on the Go + // boundary (see the function doc comment); set it to a nonzero + // value to prove it never leaks into tbtc.Reservation. + CumulativeReanchorFee: 12345, + } + + t.Run("valid state", func(t *testing.T) { + reservation, err := convertReservationFromAbiType(validAbiReservation) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + expected := &tbtc.Reservation{ + Owner: chain.Address(ownerAddress.String()), + MintedAmount: 100000, + AcceptedAt: 1700000000, + WalletPublicKeyHash: [20]byte{0xaa, 0xbb, 0xcc}, + AnchorUtxo: &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHash, + OutputIndex: 1, + }, + Value: 99000, + }, + ExpiresAt: 1700100000, + State: tbtc.ReservationStateActive, + RequestNonce: 7, + RetryCredit: true, + DissolutionEligibleAt: 1700200000, + } + + if !reflect.DeepEqual(expected, reservation) { + t.Errorf( + "unexpected reservation\nexpected: [%+v]\nactual: [%+v]\n", + expected, + reservation, + ) + } + }) + + t.Run("invalid state", func(t *testing.T) { + invalidAbiReservation := validAbiReservation + invalidAbiReservation.State = 255 + + reservation, err := convertReservationFromAbiType(invalidAbiReservation) + if reservation != nil { + t.Errorf("expected nil reservation, got [%+v]", reservation) + } + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} + +func TestConvertReservationActionFromAbiType(t *testing.T) { + targetWalletPKH := [20]byte{0x11, 0x22, 0x33} + redeemerAddress := common.HexToAddress( + "0xAbCdEf1234567890abcDef1234567890AbCdEf12", + ) + actionDataHash := [32]byte{0xde, 0xad, 0xbe, 0xef} + + baseAbiAction := tbtcabi.ReservationReservationAction{ + TargetWalletPubKeyHash: targetWalletPKH, + RequestedAt: 1700000000, + TimeoutAt: 1700003600, + TxMaxFee: 5000, + State: 1, // Pending + FeePaid: true, + Redeemer: redeemerAddress, + Amount: 50000, + ActionDataHash: actionDataHash, + IsPartial: true, + } + + // The action-type-to-hash-field routing (redemption -> redeemer output + // script hash, dissolution -> expected main UTXO hash, everything else + // -> neither) is the one non-trivial branch in this converter; exercise + // all three shapes. + var tests = map[string]struct { + abiActionType uint8 + expectedActionType tbtc.ReservationActionType + expectedRedeemerOutputScriptHash [32]byte + expectedExpectedMainUtxoHash [32]byte + }{ + "redemption routes hash to redeemer output script": { + abiActionType: 2, + expectedActionType: tbtc.ReservationActionTypeRedemption, + expectedRedeemerOutputScriptHash: actionDataHash, + expectedExpectedMainUtxoHash: [32]byte{}, + }, + "dissolution routes hash to expected main utxo": { + abiActionType: 4, + expectedActionType: tbtc.ReservationActionTypeDissolution, + expectedRedeemerOutputScriptHash: [32]byte{}, + expectedExpectedMainUtxoHash: actionDataHash, + }, + "acceptance leaves both hash fields zero": { + abiActionType: 1, + expectedActionType: tbtc.ReservationActionTypeAcceptance, + expectedRedeemerOutputScriptHash: [32]byte{}, + expectedExpectedMainUtxoHash: [32]byte{}, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + abiAction := baseAbiAction + abiAction.ActionType = test.abiActionType + + action, err := convertReservationActionFromAbiType(abiAction) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + expected := &tbtc.ReservationAction{ + TargetWalletPublicKeyHash: targetWalletPKH, + RequestedAt: 1700000000, + TimeoutAt: 1700003600, + TxMaxFee: 5000, + ActionType: test.expectedActionType, + State: tbtc.ReservationActionStatePending, + FeePaid: true, + Redeemer: chain.Address(redeemerAddress.String()), + Amount: 50000, + RedeemerOutputScriptHash: test.expectedRedeemerOutputScriptHash, + ExpectedMainUtxoHash: test.expectedExpectedMainUtxoHash, + IsPartial: true, + } + + if !reflect.DeepEqual(expected, action) { + t.Errorf( + "unexpected action\nexpected: [%+v]\nactual: [%+v]\n", + expected, + action, + ) + } + }) + } + + t.Run("invalid action type", func(t *testing.T) { + abiAction := baseAbiAction + abiAction.ActionType = 255 + + action, err := convertReservationActionFromAbiType(abiAction) + if action != nil { + t.Errorf("expected nil action, got [%+v]", action) + } + if err == nil { + t.Fatal("expected error, got nil") + } + }) + + t.Run("invalid action state", func(t *testing.T) { + abiAction := baseAbiAction + abiAction.ActionType = 1 + abiAction.State = 255 + + action, err := convertReservationActionFromAbiType(abiAction) + if action != nil { + t.Errorf("expected nil action, got [%+v]", action) + } + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} + +func TestConvertReservationParametersFromAbiType(t *testing.T) { + vaultAddress := common.HexToAddress( + "0x9876543210FedCbA9876543210FedcbA98765432", + ) + + abiParameters := struct { + ReservationVault common.Address + ReservationMinAmount uint64 + ReservationTxMaxFee uint64 + ReservationTermSeconds uint32 + ReservationDissolutionDelay uint32 + ReservationMaxTotalAmount uint64 + ReservationTotalAmount uint64 + MaxReservationsPerWallet uint32 + ReservationActionTimeout uint32 + ReservationRenewalWindowSeconds uint32 + }{ + ReservationVault: vaultAddress, + ReservationMinAmount: 1000, + ReservationTxMaxFee: 5000, + ReservationTermSeconds: 1209600, + ReservationDissolutionDelay: 3600, + ReservationMaxTotalAmount: 10000000, + ReservationTotalAmount: 2500000, + MaxReservationsPerWallet: 5, + ReservationActionTimeout: 86400, + ReservationRenewalWindowSeconds: 604800, + } + + parameters := convertReservationParametersFromAbiType(abiParameters) + + expected := &tbtc.ReservationParameters{ + ReservationVault: chain.Address(vaultAddress.String()), + ReservationMinAmount: 1000, + ReservationTxMaxFee: 5000, + ReservationTermSeconds: 1209600, + ReservationDissolutionDelay: 3600, + ReservationMaxTotalAmount: 10000000, + ReservationTotalAmount: 2500000, + MaxReservationsPerWallet: 5, + ReservationActionTimeout: 86400, + ReservationRenewalWindowSeconds: 604800, + } + + if !reflect.DeepEqual(expected, parameters) { + t.Errorf( + "unexpected parameters\nexpected: [%+v]\nactual: [%+v]\n", + expected, + parameters, + ) + } +} From cb8ad1a68adfb63884c732f559ab827ddc0cb196 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 1 Sep 2026 14:39:02 +0000 Subject: [PATCH 18/39] fix(tbtcpg): reservation acceptance/re-anchor correctness fixes and coverage Correctness: - populate BlindingFactor/RefundPublicKeyHash/RefundLocktime in the acceptance deposit instead of leaving them zeroed - set RequestNonce in proposeReservationAcceptance - compute the anchor/re-anchor fee dynamically (applyWalletTxFeeFloor) instead of a hardcoded constant - compare the net (post-fee) deposit amount against ReservationMinAmount - guard the pendingReservedDeposits check with MaxTotalAmount>0, then remove it entirely once the guard made it a strict, unreachable subset of the preceding global-cap check - fix the WalletReservationsAmount map-key bug and the fillBigInt16 truncation in the shared LocalChain test double; switch reservation map keys from truncated fixed-byte keys to the big.Int's full base-16 text so distinct keys can never collide - fix the AnchorUtxo nil-check to a value-based check, since the Go-side chain adapter always allocates a non-nil struct - add a bounds check in parseReservationReanchorTransactionInput - bound findTargetWallet's wallet-registration scan and findReservationAcceptanceCandidate's deposit re-scan to a look-back window with a per-wallet cursor instead of an unbounded eth_getLogs scan on every coordination window - add reservation action types to getActionsChecklist - remove dead reservationAcceptanceCandidate fields and the ReservationWatchersWirer indirection's tbtcpg-side leftovers Test coverage: - add scenario fixtures for the 4 previously-untested eligibility rejection branches (wallet reservations count cap, single-deposit cap, wallet aggregate cap, global total cap) plus the re-anchor task's no-live-wallet-target, non-Active-state skip, empty-reservations, and minimum-fee-floor branches - add TestNewProposalGenerator_ReservationsEnabled proving the reservationsEnabled constructor flag actually wires (or omits) the reservation tasks, closing the gap where a regression that always or never appended them would go undetected - replace the reservation acceptance/re-anchor proposal comparisons' reliance on deep.Equal, which silently reports no difference between any two distinct *big.Int values because big.Int's representation is entirely unexported, with explicit field-by-field comparators using .Cmp(); this also caught and fixes two pre-existing wrong expected ReanchorTxFee fixture values (1015 instead of the real computed 550) and adds RequestNonce to the acceptance comparison, which was silently never checked --- pkg/tbtcpg/chain_test.go | 15 +- .../internal/test/reservation_acceptance.go | 2 + .../reservation_acceptance_scenario_0.json | 3 +- .../reservation_acceptance_scenario_4.json | 52 +++ .../reservation_acceptance_scenario_5.json | 52 +++ .../reservation_acceptance_scenario_6.json | 52 +++ .../reservation_acceptance_scenario_7.json | 52 +++ .../reservation_reanchor_scenario_0.json | 2 +- .../reservation_reanchor_scenario_1.json | 2 +- .../reservation_reanchor_scenario_4.json | 29 ++ .../reservation_reanchor_scenario_5.json | 30 ++ .../reservation_reanchor_scenario_6.json | 18 ++ .../reservation_reanchor_scenario_7.json | 30 ++ pkg/tbtcpg/reservation_acceptance.go | 295 +++++++++++++----- pkg/tbtcpg/reservation_acceptance_test.go | 33 +- pkg/tbtcpg/reservation_reanchor.go | 79 +++-- pkg/tbtcpg/reservation_reanchor_test.go | 79 ++++- pkg/tbtcpg/tbtcpg_test.go | 58 ++++ 18 files changed, 733 insertions(+), 150 deletions(-) create mode 100644 pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_4.json create mode 100644 pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_5.json create mode 100644 pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_6.json create mode 100644 pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_7.json create mode 100644 pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_4.json create mode 100644 pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_5.json create mode 100644 pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_6.json create mode 100644 pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_7.json diff --git a/pkg/tbtcpg/chain_test.go b/pkg/tbtcpg/chain_test.go index 9a783da8b9..cb582347b0 100644 --- a/pkg/tbtcpg/chain_test.go +++ b/pkg/tbtcpg/chain_test.go @@ -65,7 +65,7 @@ type LocalChain struct { redemptionDelays map[[32]byte]time.Duration depositMinAge uint32 - reservations map[*big.Int]*tbtc.Reservation + reservations map[string]*tbtc.Reservation reservationActions map[string]*tbtc.ReservationAction reservationParametersValue tbtc.ReservationParameters reservationParametersSet bool @@ -97,7 +97,7 @@ func NewLocalChain() *LocalChain { operatorIDs: make(map[chain.Address]uint32), redemptionDelays: make(map[[32]byte]time.Duration), - reservations: make(map[*big.Int]*tbtc.Reservation), + reservations: make(map[string]*tbtc.Reservation), reservationActions: make(map[string]*tbtc.ReservationAction), reservationProposalValidations: make(map[[32]byte]bool), reservationReanchorRequestSubmissions: make([]*reservationReanchorRequestSubmission, 0), @@ -1466,14 +1466,9 @@ func (lc *LocalChain) GetReservation( lc.mutex.Lock() defer lc.mutex.Unlock() - if reservation, ok := lc.reservations[reservationKey]; ok { + if reservation, ok := lc.reservations[reservationKey.Text(16)]; ok { return reservation, nil } - for k, r := range lc.reservations { - if k.Cmp(reservationKey) == 0 { - return r, nil - } - } return nil, fmt.Errorf("reservation not found") } @@ -1485,7 +1480,7 @@ func (lc *LocalChain) SetReservation( lc.mutex.Lock() defer lc.mutex.Unlock() - lc.reservations[new(big.Int).Set(reservationKey)] = reservation + lc.reservations[reservationKey.Text(16)] = reservation } // GetReservationAction returns the configured reservation action record. @@ -1579,7 +1574,7 @@ func (lc *LocalChain) WalletReservationsAmount( var total uint64 for _, reservationKey := range lc.reservationWalletKeys[walletPublicKeyHash] { - if r, ok := lc.reservations[reservationKey]; ok && r != nil && r.AnchorUtxo != nil { + if r, ok := lc.reservations[reservationKey.Text(16)]; ok && r != nil && r.AnchorUtxo != nil { total += uint64(r.AnchorUtxo.Value) } } diff --git a/pkg/tbtcpg/internal/test/reservation_acceptance.go b/pkg/tbtcpg/internal/test/reservation_acceptance.go index 6f7c42f8e1..9ce2d2f537 100644 --- a/pkg/tbtcpg/internal/test/reservation_acceptance.go +++ b/pkg/tbtcpg/internal/test/reservation_acceptance.go @@ -96,6 +96,7 @@ type ReservationAcceptanceTestScenario struct { type reservationAnchorProposalScenario struct { DepositFundingTxHash string DepositFundingOutputIndex uint32 + RequestNonce uint64 AnchorTxFee int64 } @@ -120,6 +121,7 @@ func (ras *reservationAnchorProposalScenario) convert() *tbtc.ReservationAnchorP return &tbtc.ReservationAnchorProposal{ DepositFundingTxHash: fundingTxHash, DepositFundingOutputIndex: ras.DepositFundingOutputIndex, + RequestNonce: ras.RequestNonce, AnchorTxFee: big.NewInt(ras.AnchorTxFee), } } diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_0.json b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_0.json index fde4e0a021..40231251b1 100644 --- a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_0.json +++ b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_0.json @@ -50,7 +50,8 @@ "ExpectedAnchorProposal": { "DepositFundingTxHash": "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f00", "DepositFundingOutputIndex": 0, - "AnchorTxFee": 1500 + "RequestNonce": 1, + "AnchorTxFee": 710 }, "ExpectedErr": "" } diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_4.json b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_4.json new file mode 100644 index 0000000000..53d9a51bc9 --- /dev/null +++ b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_4.json @@ -0,0 +1,52 @@ +{ + "Title": "cap rejection - wallet already at max reservations per wallet", + "ChainParameters": { + "AverageBlockTime": 12, + "CurrentBlock": 300000, + "DepositMinAge": 3600 + }, + "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", + "ReservationVault": "0xReservationVaultAddress1234567890abcdef12345678", + "Wallet": { + "State": "Live" + }, + "ReservationParameters": { + "ReservationMinAmount": 100000, + "ReservationTxMaxFee": 5000, + "ReservationMaxTotalAmount": 100000000, + "ReservationTotalAmount": 0, + "MaxReservationsPerWallet": 3 + }, + "Caps": { + "MaxReservationsAmountPerWallet": 50000000, + "ReservationMaxSingleAmount": 5000000 + }, + "WalletCustody": { + "Count": 3, + "Amount": 2000000 + }, + "Global": { + "ActiveCount": 0, + "MaxActive": 100 + }, + "PendingReservedDeposits": 0, + "ReservedDeposits": [ + { + "FundingTxHash": "e1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f00", + "FundingOutputIndex": 0, + "FundingTxConfirmations": 6, + "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", + "Depositor": "934b98637ca318a4d6e7ca6ffd1690b8e77df637", + "BlindingFactor": "f9f0c90d00039524", + "RefundPublicKeyHash": "e257eccafbc07c381642ce6e7e55120fb077fbed", + "RefundLocktime": "e0250162", + "Amount": 2000000, + "RevealBlock": 290000, + "Age": 7200, + "SweptAt": 0, + "Vault": "0xReservationVaultAddress1234567890abcdef12345678" + } + ], + "ExpectedAnchorProposal": null, + "ExpectedErr": "" +} diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_5.json b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_5.json new file mode 100644 index 0000000000..3f628af58b --- /dev/null +++ b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_5.json @@ -0,0 +1,52 @@ +{ + "Title": "cap rejection - deposit amount exceeds ReservationMaxSingleAmount", + "ChainParameters": { + "AverageBlockTime": 12, + "CurrentBlock": 300000, + "DepositMinAge": 3600 + }, + "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", + "ReservationVault": "0xReservationVaultAddress1234567890abcdef12345678", + "Wallet": { + "State": "Live" + }, + "ReservationParameters": { + "ReservationMinAmount": 100000, + "ReservationTxMaxFee": 5000, + "ReservationMaxTotalAmount": 100000000, + "ReservationTotalAmount": 0, + "MaxReservationsPerWallet": 5 + }, + "Caps": { + "MaxReservationsAmountPerWallet": 50000000, + "ReservationMaxSingleAmount": 1000000 + }, + "WalletCustody": { + "Count": 1, + "Amount": 0 + }, + "Global": { + "ActiveCount": 0, + "MaxActive": 100 + }, + "PendingReservedDeposits": 0, + "ReservedDeposits": [ + { + "FundingTxHash": "f1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f00", + "FundingOutputIndex": 0, + "FundingTxConfirmations": 6, + "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", + "Depositor": "934b98637ca318a4d6e7ca6ffd1690b8e77df637", + "BlindingFactor": "f9f0c90d00039525", + "RefundPublicKeyHash": "e257eccafbc07c381642ce6e7e55120fb077fbed", + "RefundLocktime": "e0250162", + "Amount": 2000000, + "RevealBlock": 290000, + "Age": 7200, + "SweptAt": 0, + "Vault": "0xReservationVaultAddress1234567890abcdef12345678" + } + ], + "ExpectedAnchorProposal": null, + "ExpectedErr": "" +} diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_6.json b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_6.json new file mode 100644 index 0000000000..0692e7b9d2 --- /dev/null +++ b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_6.json @@ -0,0 +1,52 @@ +{ + "Title": "cap rejection - accepting would exceed wallet aggregate amount cap", + "ChainParameters": { + "AverageBlockTime": 12, + "CurrentBlock": 300000, + "DepositMinAge": 3600 + }, + "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", + "ReservationVault": "0xReservationVaultAddress1234567890abcdef12345678", + "Wallet": { + "State": "Live" + }, + "ReservationParameters": { + "ReservationMinAmount": 100000, + "ReservationTxMaxFee": 5000, + "ReservationMaxTotalAmount": 100000000, + "ReservationTotalAmount": 0, + "MaxReservationsPerWallet": 5 + }, + "Caps": { + "MaxReservationsAmountPerWallet": 3000000, + "ReservationMaxSingleAmount": 5000000 + }, + "WalletCustody": { + "Count": 1, + "Amount": 2000000 + }, + "Global": { + "ActiveCount": 0, + "MaxActive": 100 + }, + "PendingReservedDeposits": 0, + "ReservedDeposits": [ + { + "FundingTxHash": "11b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f00", + "FundingOutputIndex": 0, + "FundingTxConfirmations": 6, + "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", + "Depositor": "934b98637ca318a4d6e7ca6ffd1690b8e77df637", + "BlindingFactor": "f9f0c90d00039526", + "RefundPublicKeyHash": "e257eccafbc07c381642ce6e7e55120fb077fbed", + "RefundLocktime": "e0250162", + "Amount": 2000000, + "RevealBlock": 290000, + "Age": 7200, + "SweptAt": 0, + "Vault": "0xReservationVaultAddress1234567890abcdef12345678" + } + ], + "ExpectedAnchorProposal": null, + "ExpectedErr": "" +} diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_7.json b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_7.json new file mode 100644 index 0000000000..be65953866 --- /dev/null +++ b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_7.json @@ -0,0 +1,52 @@ +{ + "Title": "cap rejection - accepting would exceed global reservation total cap", + "ChainParameters": { + "AverageBlockTime": 12, + "CurrentBlock": 300000, + "DepositMinAge": 3600 + }, + "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", + "ReservationVault": "0xReservationVaultAddress1234567890abcdef12345678", + "Wallet": { + "State": "Live" + }, + "ReservationParameters": { + "ReservationMinAmount": 100000, + "ReservationTxMaxFee": 5000, + "ReservationMaxTotalAmount": 3000000, + "ReservationTotalAmount": 2000000, + "MaxReservationsPerWallet": 5 + }, + "Caps": { + "MaxReservationsAmountPerWallet": 50000000, + "ReservationMaxSingleAmount": 5000000 + }, + "WalletCustody": { + "Count": 1, + "Amount": 2000000 + }, + "Global": { + "ActiveCount": 0, + "MaxActive": 100 + }, + "PendingReservedDeposits": 0, + "ReservedDeposits": [ + { + "FundingTxHash": "21b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f00", + "FundingOutputIndex": 0, + "FundingTxConfirmations": 6, + "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", + "Depositor": "934b98637ca318a4d6e7ca6ffd1690b8e77df637", + "BlindingFactor": "f9f0c90d00039527", + "RefundPublicKeyHash": "e257eccafbc07c381642ce6e7e55120fb077fbed", + "RefundLocktime": "e0250162", + "Amount": 2000000, + "RevealBlock": 290000, + "Age": 7200, + "SweptAt": 0, + "Vault": "0xReservationVaultAddress1234567890abcdef12345678" + } + ], + "ExpectedAnchorProposal": null, + "ExpectedErr": "" +} diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_0.json b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_0.json index 35e6c2ed6e..46056b83ed 100644 --- a/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_0.json +++ b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_0.json @@ -29,6 +29,6 @@ "ReservationKey": "0xaaaa01", "RequestNonce": 1, "TargetWalletPublicKeyHash": "0x92a6ec889a8fa34f731e639edede4c75e184307c", - "ReanchorTxFee": 1015 + "ReanchorTxFee": 550 } } diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_1.json b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_1.json index 6504ed9f51..913a7ec21b 100644 --- a/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_1.json +++ b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_1.json @@ -29,6 +29,6 @@ "ReservationKey": "0xbbbb02", "RequestNonce": 6, "TargetWalletPublicKeyHash": "0x92a6ec889a8fa34f731e639edede4c75e184307c", - "ReanchorTxFee": 1015 + "ReanchorTxFee": 550 } } diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_4.json b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_4.json new file mode 100644 index 0000000000..f2a3bcecd9 --- /dev/null +++ b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_4.json @@ -0,0 +1,29 @@ +{ + "Title": "cap rejection: minimum safe re-anchor fee exceeds ReservationTxMaxFee", + "SourceWalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", + "SourceWalletState": "MovingFunds", + "SourceWalletMainUtxoHash": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "SourceWalletMainUtxoValue": 500000, + "SourceWalletMainUtxoTxHash": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "SourceWalletMainUtxoTxIndex": 0, + "TargetWalletPublicKeyHash": "0x92a6ec889a8fa34f731e639edede4c75e184307c", + "LiveWalletsCount": 4, + "MovingFundsDustThreshold": 1000000, + "ReservationTxMaxFee": 500, + "EstimateSatPerVByteFee": 1, + "ReanchorTxFee": 0, + "Reservations": [ + { + "ReservationKey": "0000000000000000000000000000000000000000000000000000000000eeee05", + "WalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", + "AnchorTxHash": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "AnchorTxOutputIndex": 1, + "AnchorValue": 500000, + "State": "Active", + "RequestNonce": 0, + "HasPendingAction": false, + "PendingActionState": "Unknown" + } + ], + "ExpectedErr": "cannot prepare reservation re-anchor proposal: [cannot estimate reservation re-anchor transaction fee: [minimum safe transaction fee exceeds the maximum fee: minimum fee [550], maximum fee [500]]]" +} diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_5.json b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_5.json new file mode 100644 index 0000000000..97f2d3aa1c --- /dev/null +++ b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_5.json @@ -0,0 +1,30 @@ +{ + "Title": "no-op: no live wallets available for re-anchor target", + "SourceWalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", + "SourceWalletState": "MovingFunds", + "SourceWalletMainUtxoHash": "1111111111111111111111111111111111111111111111111111111111111111", + "SourceWalletMainUtxoValue": 500000, + "SourceWalletMainUtxoTxHash": "2222222222222222222222222222222222222222222222222222222222222222", + "SourceWalletMainUtxoTxIndex": 0, + "TargetWalletPublicKeyHash": "", + "LiveWalletsCount": 0, + "MovingFundsDustThreshold": 1000000, + "ReservationTxMaxFee": 100000, + "EstimateSatPerVByteFee": 1, + "ReanchorTxFee": 0, + "Reservations": [ + { + "ReservationKey": "0000000000000000000000000000000000000000000000000000000000ffff06", + "WalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", + "AnchorTxHash": "3333333333333333333333333333333333333333333333333333333333333333", + "AnchorTxOutputIndex": 1, + "AnchorValue": 500000, + "State": "Active", + "RequestNonce": 0, + "HasPendingAction": false, + "PendingActionState": "Unknown" + } + ], + "ExpectedProposal": null, + "ExpectedErr": "" +} diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_6.json b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_6.json new file mode 100644 index 0000000000..2b02ea0d68 --- /dev/null +++ b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_6.json @@ -0,0 +1,18 @@ +{ + "Title": "no-op: wallet has no reservations to re-anchor", + "SourceWalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", + "SourceWalletState": "MovingFunds", + "SourceWalletMainUtxoHash": "4444444444444444444444444444444444444444444444444444444444444444", + "SourceWalletMainUtxoValue": 500000, + "SourceWalletMainUtxoTxHash": "5555555555555555555555555555555555555555555555555555555555555555", + "SourceWalletMainUtxoTxIndex": 0, + "TargetWalletPublicKeyHash": "", + "LiveWalletsCount": 4, + "MovingFundsDustThreshold": 1000000, + "ReservationTxMaxFee": 100000, + "EstimateSatPerVByteFee": 1, + "ReanchorTxFee": 0, + "Reservations": [], + "ExpectedProposal": null, + "ExpectedErr": "" +} diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_7.json b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_7.json new file mode 100644 index 0000000000..808358b1b1 --- /dev/null +++ b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_7.json @@ -0,0 +1,30 @@ +{ + "Title": "skip: reservation not in Active state is excluded from re-anchor", + "SourceWalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", + "SourceWalletState": "MovingFunds", + "SourceWalletMainUtxoHash": "6666666666666666666666666666666666666666666666666666666666666666", + "SourceWalletMainUtxoValue": 500000, + "SourceWalletMainUtxoTxHash": "7777777777777777777777777777777777777777777777777777777777777777", + "SourceWalletMainUtxoTxIndex": 0, + "TargetWalletPublicKeyHash": "", + "LiveWalletsCount": 4, + "MovingFundsDustThreshold": 1000000, + "ReservationTxMaxFee": 100000, + "EstimateSatPerVByteFee": 1, + "ReanchorTxFee": 0, + "Reservations": [ + { + "ReservationKey": "0000000000000000000000000000000000000000000000000000000000111107", + "WalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", + "AnchorTxHash": "8888888888888888888888888888888888888888888888888888888888888888", + "AnchorTxOutputIndex": 1, + "AnchorValue": 500000, + "State": "Stranded", + "RequestNonce": 0, + "HasPendingAction": false, + "PendingActionState": "Unknown" + } + ], + "ExpectedProposal": null, + "ExpectedErr": "" +} diff --git a/pkg/tbtcpg/reservation_acceptance.go b/pkg/tbtcpg/reservation_acceptance.go index df4b888ec3..c0890e39f6 100644 --- a/pkg/tbtcpg/reservation_acceptance.go +++ b/pkg/tbtcpg/reservation_acceptance.go @@ -5,6 +5,7 @@ import ( "fmt" "math/big" "strings" + "sync" "time" "github.com/ipfs/go-log/v2" @@ -20,12 +21,15 @@ import ( // sweep look-back window: 30 days at 12 seconds per block. const ReservationAcceptanceLookBackBlocks = uint64(216000) -// reservationAnchorFeeSat is the deterministic satoshi fee the operator -// charges for the 1-input-1-output anchor transaction. The transaction -// shape is fixed (one P2SH deposit input, one P2WPKH anchor output) so a -// constant estimate is appropriate. Operators can override this through -// governance if the network fee environment drifts. -const reservationAnchorFeeSat int64 = 1500 +// reservationAcceptanceRequestNonce is the acceptance action generation +// nonce. A reservation does not exist on-chain before its first acceptance +// settles, so the acceptance is always the first action generation +// authorized against a not-yet-created reservation. Every other reservation +// action generation is numbered `reservation.RequestNonce + 1` (see +// ReservationReanchorTask.Run); this constant is that same 1-based +// convention's base case. ReservationAnchorProposal.Unmarshal rejects +// RequestNonce == 0, which is the on-chain confirmation of this convention. +const reservationAcceptanceRequestNonce uint64 = 1 // ReservationAcceptanceTask is a task that may produce a reservation // acceptance (anchor) proposal. It scans the chain for reserved deposits @@ -36,6 +40,26 @@ const reservationAnchorFeeSat int64 = 1500 type ReservationAcceptanceTask struct { chain Chain btcChain bitcoin.Chain + + // scanState guards lastScannedBlock and pendingCandidates: Run may be + // invoked concurrently for different wallets (one coordinationExecutor + // goroutine per wallet, sharing this task instance via + // ProposalGenerator). + scanState sync.Mutex + // lastScannedBlock is the block number up to which + // findReservationAcceptanceCandidate has already scanned + // DepositRevealed events for a given wallet, so each call only fetches + // events since the previous call instead of rescanning the full + // ReservationAcceptanceLookBackBlocks window every time. + lastScannedBlock map[[20]byte]uint64 + // pendingCandidates holds, per wallet, the reservation-vault-targeting + // deposit events discovered so far that have not yet resolved (become + // eligible and accepted, or permanently disqualified). A deposit that + // is reserved but not yet mature, or briefly blocked by a full cap, + // must still be reconsidered on a later call even though its block + // falls before the cursor above; keeping it here is what makes the + // cursor safe to advance without losing it. + pendingCandidates map[[20]byte][]*tbtc.DepositRevealedEvent } // NewReservationAcceptanceTask constructs a ReservationAcceptanceTask. @@ -44,8 +68,10 @@ func NewReservationAcceptanceTask( btcChain bitcoin.Chain, ) *ReservationAcceptanceTask { return &ReservationAcceptanceTask{ - chain: chain, - btcChain: btcChain, + chain: chain, + btcChain: btcChain, + lastScannedBlock: make(map[[20]byte]uint64), + pendingCandidates: make(map[[20]byte][]*tbtc.DepositRevealedEvent), } } @@ -106,22 +132,23 @@ func (rat *ReservationAcceptanceTask) ActionType() tbtc.WalletActionType { type reservationAcceptanceCandidate struct { Deposit *tbtc.Deposit FundingTx *bitcoin.Transaction - RevealBlock uint64 ReservationParameters *tbtc.ReservationParameters - WalletCap uint64 - SingleCap uint64 - ActiveCount uint32 - MaxActive uint32 - MaxPerWallet uint32 - PendingReserved uint64 TxMaxFee uint64 } // findReservationAcceptanceCandidate returns the first reserved deposit -// that the operator's wallet may accept, or nil when none qualifies. The -// function performs the look-back bounded scan over past -// DepositRevealedEvents, fetches each candidate's chain request to determine -// whether it is reserved, and applies the eligibility gate. +// that the operator's wallet may accept, or nil when none qualifies. +// +// Discovery is bounded and incremental: PastDepositRevealedEvents is only +// queried for blocks since this wallet's last scan (falling back to +// ReservationAcceptanceLookBackBlocks on the first call), and every +// vault-targeting event found is cached in rat.pendingCandidates so a +// deposit that isn't mature yet, or is briefly blocked by a full cap, is +// still reconsidered on a later call without re-fetching its (already +// past) block range. The vault check runs against event.Vault - already +// present on the DepositRevealedEvent for free - before either +// IsReservedDeposit or GetDepositRequest, so the two RPCs are skipped +// entirely for the common case of a deposit that isn't a reservation. func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( taskLogger log.StandardLogger, walletPublicKeyHash [20]byte, @@ -182,14 +209,6 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( ) } - pendingReservedDeposits, err := rat.chain.PendingReservedDeposits() - if err != nil { - return nil, fmt.Errorf( - "failed to get pending reserved deposits count: [%w]", - err, - ) - } - blockCounter, err := rat.chain.BlockCounter() if err != nil { return nil, fmt.Errorf("failed to get block counter: [%w]", err) @@ -202,22 +221,13 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( ) } - filterStartBlock := uint64(0) - if currentBlock > ReservationAcceptanceLookBackBlocks { - filterStartBlock = currentBlock - ReservationAcceptanceLookBackBlocks - } - - filter := &tbtc.DepositRevealedEventFilter{ - StartBlock: filterStartBlock, - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - } - - revealedEvents, err := rat.chain.PastDepositRevealedEvents(filter) + candidateEvents, err := rat.scanForCandidateEvents( + walletPublicKeyHash, + currentBlock, + reservationVault, + ) if err != nil { - return nil, fmt.Errorf( - "failed to get past deposit revealed events: [%w]", - err, - ) + return nil, err } depositMinAgeSeconds, err := rat.chain.GetDepositMinAge() @@ -231,7 +241,12 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( now := time.Now() - for _, event := range revealedEvents { + var ( + found *reservationAcceptanceCandidate + stillUnresolved []*tbtc.DepositRevealedEvent + ) + + for _, event := range candidateEvents { depositKey := rat.chain.BuildDepositKey( event.FundingTxHash, event.FundingOutputIndex, @@ -244,14 +259,17 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( depositKey, err, ) + stillUnresolved = append(stillUnresolved, event) continue } if !isReserved { taskLogger.Infof("not reserved deposit [%v]", depositKey) + // A vault-targeting reveal that the Bridge never marked + // reserved will never become reserved later; drop it. continue } - depositRequest, found, err := rat.chain.GetDepositRequest( + depositRequest, foundRequest, err := rat.chain.GetDepositRequest( event.FundingTxHash, event.FundingOutputIndex, ) @@ -261,13 +279,15 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( depositKey, err, ) + stillUnresolved = append(stillUnresolved, event) continue } - if !found { + if !foundRequest { taskLogger.Warnf( "no deposit request for reserved deposit [%v]", depositKey, ) + stillUnresolved = append(stillUnresolved, event) continue } @@ -278,6 +298,7 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( depositKey, now, matureAt, ) + stillUnresolved = append(stillUnresolved, event) continue } @@ -286,30 +307,19 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( "reserved deposit [%v] is already swept", depositKey, ) + // Already settled one way or another; never a candidate again. continue } - if !depositTargetsReservationVault( - depositRequest.Vault, - reservationVault, - ) { - taskLogger.Debugf( - "reserved deposit [%v] vault does not match "+ - "the active reservation vault", - depositKey, - ) + if found != nil { + // Already have this call's candidate; keep the rest pending + // for the next call rather than dropping or re-fetching them. + stillUnresolved = append(stillUnresolved, event) continue } candidate := &reservationAcceptanceCandidate{ - RevealBlock: event.BlockNumber, ReservationParameters: reservationParameters, - WalletCap: maxReservationsAmountPerWallet, - SingleCap: reservationMaxSingleAmount, - ActiveCount: activeReservationsCount, - MaxActive: maxActiveReservations, - MaxPerWallet: reservationParameters.MaxReservationsPerWallet, - PendingReserved: pendingReservedDeposits, TxMaxFee: reservationParameters.ReservationTxMaxFee, } @@ -321,12 +331,13 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( walletReservationsAmount, activeReservationsCount, maxActiveReservations, - pendingReservedDeposits, maxReservationsAmountPerWallet, reservationMaxSingleAmount, reservationParameters, ) { taskLogger.Infof("not eligible: [%v]", depositKey) + // Caps/state can free up later; keep it pending. + stillUnresolved = append(stillUnresolved, event) continue } @@ -337,6 +348,7 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( depositKey, err, ) + stillUnresolved = append(stillUnresolved, event) continue } @@ -350,6 +362,7 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( depositKey, err, ) + stillUnresolved = append(stillUnresolved, event) continue } if confirmations < tbtc.DepositSweepRequiredFundingTxConfirmations { @@ -359,6 +372,7 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( confirmations, tbtc.DepositSweepRequiredFundingTxConfirmations, ) + stillUnresolved = append(stillUnresolved, event) continue } @@ -371,8 +385,12 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( Value: int64(depositRequest.Amount), }, Depositor: depositRequest.Depositor, + BlindingFactor: event.BlindingFactor, WalletPublicKeyHash: event.WalletPublicKeyHash, + RefundPublicKeyHash: event.RefundPublicKeyHash, + RefundLocktime: event.RefundLocktime, Vault: depositRequest.Vault, + ExtraData: depositRequest.ExtraData, } candidate.FundingTx = fundingTx @@ -381,10 +399,73 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( depositKey, ) - return candidate, nil + found = candidate + // Keep the resolved-to-a-proposal event out of the pending set: + // if this proposal round doesn't settle, the deposit is still + // IsReservedDeposit and not yet SweptAt, so the next call's fresh + // pending set naturally would not include it either - it can only + // be rediscovered by staying in stillUnresolved. Re-add it so a + // failed round doesn't lose the candidate. + stillUnresolved = append(stillUnresolved, event) + } + + rat.scanState.Lock() + rat.pendingCandidates[walletPublicKeyHash] = stillUnresolved + rat.scanState.Unlock() + + return found, nil +} + +// scanForCandidateEvents returns every reservation-vault-targeting +// DepositRevealed event known for walletPublicKeyHash: the wallet's cached +// pending candidates from previous calls, plus any newly revealed events +// since the last scan. It also advances the wallet's scan cursor to +// currentBlock and merges the new matches into rat.pendingCandidates so a +// later call resumes from here instead of rescanning. +func (rat *ReservationAcceptanceTask) scanForCandidateEvents( + walletPublicKeyHash [20]byte, + currentBlock uint64, + reservationVault chain.Address, +) ([]*tbtc.DepositRevealedEvent, error) { + rat.scanState.Lock() + lastScanned := rat.lastScannedBlock[walletPublicKeyHash] + pending := rat.pendingCandidates[walletPublicKeyHash] + rat.scanState.Unlock() + + startBlock := lastScanned + 1 + if lastScanned == 0 { + startBlock = 0 + if currentBlock > ReservationAcceptanceLookBackBlocks { + startBlock = currentBlock - ReservationAcceptanceLookBackBlocks + } + } + + filter := &tbtc.DepositRevealedEventFilter{ + StartBlock: startBlock, + WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + } + + revealedEvents, err := rat.chain.PastDepositRevealedEvents(filter) + if err != nil { + return nil, fmt.Errorf( + "failed to get past deposit revealed events: [%w]", + err, + ) + } + + candidates := pending + for _, event := range revealedEvents { + if !depositTargetsReservationVault(event.Vault, reservationVault) { + continue + } + candidates = append(candidates, event) } - return nil, nil + rat.scanState.Lock() + rat.lastScannedBlock[walletPublicKeyHash] = currentBlock + rat.scanState.Unlock() + + return candidates, nil } // checkReservationAcceptanceEligibility returns true iff the wallet may @@ -400,7 +481,6 @@ func (rat *ReservationAcceptanceTask) checkReservationAcceptanceEligibility( walletReservationsAmount uint64, activeReservationsCount uint32, maxActiveReservations uint32, - pendingReservedDeposits uint64, maxReservationsAmountPerWallet uint64, reservationMaxSingleAmount uint64, reservationParameters *tbtc.ReservationParameters, @@ -487,18 +567,6 @@ func (rat *ReservationAcceptanceTask) checkReservationAcceptanceEligibility( } } - if pendingReservedDeposits > 0 && - reservationParameters.ReservationTotalAmount+ - depositRequest.Amount > - reservationParameters.ReservationMaxTotalAmount { - taskLogger.Infof( - "pending reserved deposits queue [%d] would push global total "+ - "past cap; deferring", - pendingReservedDeposits, - ) - return false - } - return true } @@ -515,7 +583,16 @@ func (rat *ReservationAcceptanceTask) proposeReservationAcceptance( taskLogger.Infof("preparing a reservation acceptance proposal") - anchorFee := reservationAnchorFeeSat + anchorFee, err := estimateReservationAcceptanceFee( + rat.btcChain, + candidate.TxMaxFee, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot estimate reservation acceptance transaction fee: [%v]", + err, + ) + } anchorValue := candidate.Deposit.Utxo.Value - anchorFee if anchorValue <= 0 { @@ -526,11 +603,21 @@ func (rat *ReservationAcceptanceTask) proposeReservationAcceptance( ) } - if uint64(anchorFee) > candidate.TxMaxFee { + // ReservationParameters.ReservationMinAmount documents itself as "the + // minimal anchor output amount", i.e. the net value after the anchor + // fee - not the gross deposit value checked cheaply during eligibility + // filtering. The fee is only known here, once estimated, so this is the + // authoritative gate; the eligibility-time gross check is a valid but + // non-authoritative early filter (gross < min implies net < min too). + if candidate.ReservationParameters != nil && + uint64(anchorValue) < candidate.ReservationParameters.ReservationMinAmount { return nil, fmt.Errorf( - "anchor fee [%d] exceeds the configured max [%d]", + "anchor value [%d] (deposit [%d] minus fee [%d]) is below "+ + "the reservation minimum [%d]", + anchorValue, + candidate.Deposit.Utxo.Value, anchorFee, - candidate.TxMaxFee, + candidate.ReservationParameters.ReservationMinAmount, ) } @@ -551,6 +638,7 @@ func (rat *ReservationAcceptanceTask) proposeReservationAcceptance( proposal := &tbtc.ReservationAnchorProposal{ DepositFundingTxHash: candidate.Deposit.Utxo.Outpoint.TransactionHash, DepositFundingOutputIndex: candidate.Deposit.Utxo.Outpoint.OutputIndex, + RequestNonce: reservationAcceptanceRequestNonce, AnchorTxFee: big.NewInt(anchorFee), } @@ -630,6 +718,51 @@ func buildReservationAnchorTransaction( return builder, nil } +// estimateReservationAcceptanceFee estimates the fee for a reservation +// acceptance (anchor) transaction. The transaction has one P2WSH deposit +// input and one P2WPKH output, so its virtual size is fixed for any single +// acceptance. Mirrors ReservationReanchorTask's estimateReservationReanchorFee. +func estimateReservationAcceptanceFee( + btcChain bitcoin.Chain, + txMaxFee uint64, +) (int64, error) { + sizeEstimator := bitcoin.NewTransactionSizeEstimator(). + AddScriptHashInputs(1, depositScriptByteSize, true). + AddPublicKeyHashOutputs(1, true) + + transactionSize, err := sizeEstimator.VirtualSize() + if err != nil { + return 0, fmt.Errorf( + "cannot estimate transaction virtual size: [%v]", + err, + ) + } + + feeEstimator := bitcoin.NewTransactionFeeEstimator(btcChain) + totalFee, err := feeEstimator.EstimateFee(transactionSize) + if err != nil { + return 0, fmt.Errorf("cannot estimate transaction fee: [%v]", err) + } + + if uint64(totalFee) > txMaxFee { + return 0, fmt.Errorf( + "estimated fee [%d] exceeds the configured max [%d]", + totalFee, + txMaxFee, + ) + } + + // Enforce the safe minimum fee rate and buffer so a non-RBF reservation + // acceptance transaction is never broadcast below the floor where it + // could get stuck and jam the wallet. + totalFee, err = applyWalletTxFeeFloor(totalFee, transactionSize, txMaxFee) + if err != nil { + return 0, err + } + + return totalFee, nil +} + // depositTargetsReservationVault returns true iff the deposit's vault field // (nil when not set, or pointer to an address) matches the configured // reservation vault. Address comparison is case-insensitive. diff --git a/pkg/tbtcpg/reservation_acceptance_test.go b/pkg/tbtcpg/reservation_acceptance_test.go index d08a11e93e..aaf5e7dfb1 100644 --- a/pkg/tbtcpg/reservation_acceptance_test.go +++ b/pkg/tbtcpg/reservation_acceptance_test.go @@ -6,7 +6,6 @@ import ( "testing" "time" - "github.com/go-test/deep" "github.com/ipfs/go-log/v2" "github.com/keep-network/keep-core/pkg/bitcoin" @@ -194,6 +193,12 @@ func registerReservedDeposits( ) { t.Helper() + // Configure the fee oracle rate. proposeReservationAcceptance now + // estimates the anchor fee dynamically (see estimateReservationAcceptanceFee); + // 1 sat/vByte hits the applyWalletTxFeeFloor minimum, matching the + // convention used by the sibling reservation re-anchor test fixtures. + btcChain.SetEstimateSatPerVByteFee(1, 1) + filterStartBlock := uint64(0) if scenario.ChainParameters.CurrentBlock > tbtcpg.ReservationAcceptanceLookBackBlocks { filterStartBlock = scenario.ChainParameters.CurrentBlock - @@ -253,6 +258,7 @@ func registerReservedDeposits( WalletPublicKeyHash: materialized.WalletPublicKeyHash, FundingTxHash: materialized.FundingTxHash, FundingOutputIndex: materialized.FundingOutputIndex, + Vault: materialized.Vault, }, ) if err != nil { @@ -271,6 +277,10 @@ func registerReservedDeposits( } // expectedAnchorsEqual compares two proposal objects field-by-field. +// deep.Equal cannot be used for this: by default it does not descend into +// unexported fields, and *big.Int's representation is entirely unexported, +// so it silently reports "no difference" for any two distinct AnchorTxFee +// values. AnchorTxFee therefore needs an explicit .Cmp(). func expectedAnchorsEqual( expected, actual *tbtc.ReservationAnchorProposal, ) bool { @@ -287,6 +297,9 @@ func expectedAnchorsEqual( if expected.DepositFundingOutputIndex != actual.DepositFundingOutputIndex { return false } + if expected.RequestNonce != actual.RequestNonce { + return false + } if expected.AnchorTxFee == nil || actual.AnchorTxFee == nil { return expected.AnchorTxFee == actual.AnchorTxFee } @@ -395,12 +408,11 @@ func TestReservationAcceptanceTask_Run(t *testing.T) { } if !expectedAnchorsEqual(expectedProposal, actualProposal) { - if diff := deep.Equal( - []*tbtc.ReservationAnchorProposal{expectedProposal}, - []*tbtc.ReservationAnchorProposal{actualProposal}, - ); diff != nil { - t.Errorf("invalid anchor proposal: %v", diff) - } + t.Errorf( + "invalid anchor proposal\nexpected: %+v\nactual: %+v", + expectedProposal, + actualProposal, + ) } }) } @@ -520,6 +532,7 @@ func TestReservationAcceptanceTask_BoundedLookback(t *testing.T) { }}, } btcChain.SetTransaction(fundingTxHash, dummyTx) + btcChain.SetEstimateSatPerVByteFee(1, 1) btcChain.SetTransactionConfirmations( fundingTxHash, tbtc.DepositSweepRequiredFundingTxConfirmations, @@ -550,6 +563,9 @@ func TestReservationAcceptanceTask_BoundedLookback(t *testing.T) { WalletPublicKeyHash: walletPublicKeyHash, FundingTxHash: fundingTxHash, FundingOutputIndex: 0, + Vault: &[]chain.Address{chain.Address( + "0xReservationVaultAddress1234567890abcdef12345678", + )}[0], }, ); err != nil { t.Fatal(err) @@ -643,6 +659,9 @@ func TestReservationAcceptanceTask_DepositNotReserved(t *testing.T) { WalletPublicKeyHash: walletPublicKeyHash, FundingTxHash: fundingTxHash, FundingOutputIndex: 0, + Vault: &[]chain.Address{chain.Address( + "0xReservationVaultAddress1234567890abcdef12345678", + )}[0], }, ); err != nil { t.Fatal(err) diff --git a/pkg/tbtcpg/reservation_reanchor.go b/pkg/tbtcpg/reservation_reanchor.go index fc4d3b1b38..e9bdaab563 100644 --- a/pkg/tbtcpg/reservation_reanchor.go +++ b/pkg/tbtcpg/reservation_reanchor.go @@ -16,17 +16,6 @@ import ( // 30 days assuming 12 seconds per block. const ReservationReanchorLookBackBlocks = uint64(216000) -// ErrNoReservationToReanchor is returned when the wallet has no reservations -// that are eligible for a re-anchor proposal. -var ErrNoReservationToReanchor = fmt.Errorf("no reservation eligible for re-anchor") - -// ErrReservationReanchorTxMaxFeeTooLow is returned when the on-chain maximum -// fee allowed for a reservation re-anchor transaction is too low to build a -// safe non-RBF transaction at the minimum fee rate. -var ErrReservationReanchorTxMaxFeeTooLow = fmt.Errorf( - "reservation re-anchor minimum safe transaction fee exceeds the maximum fee", -) - // ErrReservationReanchorTxFeeTooHigh is returned when the estimated fee for a // reservation re-anchor transaction exceeds the on-chain maximum. var ErrReservationReanchorTxFeeTooHigh = fmt.Errorf( @@ -165,7 +154,7 @@ func (rrt *ReservationReanchorTask) Run( continue } - if hasPendingAction(reservationKey, request, rrt.chain, taskLogger) { + if hasPendingAction(reservationKey, reservation, rrt.chain, taskLogger) { continue } @@ -239,7 +228,14 @@ func (rrt *ReservationReanchorTask) ProposeReservationReanchor( ) } - if reservation.AnchorUtxo == nil { + // convertReservationFromAbiType (the Go-side chain adapter) always + // allocates a non-nil AnchorUtxo, populated with zero hash/value when + // no anchor exists on-chain, so a bare nil check can never fire against + // the production chain. Detect the unset case by value instead. + if reservation.AnchorUtxo == nil || + reservation.AnchorUtxo.Value == 0 || + reservation.AnchorUtxo.Outpoint == nil || + reservation.AnchorUtxo.Outpoint.TransactionHash == (bitcoin.Hash{}) { return nil, fmt.Errorf( "reservation [0x%x] has no anchor UTXO", reservationKey, @@ -297,12 +293,33 @@ func (rrt *ReservationReanchorTask) ProposeReservationReanchor( // findTargetWallet picks a live destination wallet from the on-chain wallet // registry, mirroring the moving funds target selection. The new wallet must -// be in StateLive and must not be the source wallet itself. +// be in StateLive and must not be the source wallet itself. The registration +// scan is bounded to ReservationReanchorLookBackBlocks (mirroring the other +// look-back scans in this package) rather than the full chain history: a +// live wallet must have registered recently, and an unbounded eth_getLogs +// scan on every re-anchor attempt does not. func (rrt *ReservationReanchorTask) findTargetWallet( taskLogger log.StandardLogger, sourceWalletPublicKeyHash [20]byte, ) ([20]byte, error) { - events, err := rrt.chain.PastNewWalletRegisteredEvents(nil) + blockCounter, err := rrt.chain.BlockCounter() + if err != nil { + return [20]byte{}, fmt.Errorf("failed to get block counter: [%v]", err) + } + + currentBlock, err := blockCounter.CurrentBlock() + if err != nil { + return [20]byte{}, fmt.Errorf("failed to get current block: [%v]", err) + } + + startBlock := uint64(0) + if currentBlock > ReservationReanchorLookBackBlocks { + startBlock = currentBlock - ReservationReanchorLookBackBlocks + } + + events, err := rrt.chain.PastNewWalletRegisteredEvents( + &tbtc.NewWalletRegisteredEventFilter{StartBlock: startBlock}, + ) if err != nil { return [20]byte{}, fmt.Errorf( "failed to get past new wallet registered events: [%v]", @@ -394,25 +411,17 @@ func (rrt *ReservationReanchorTask) isBelowMovingFundsDustThreshold( } // hasPendingAction reports whether the on-chain reservation action -// generation at the wallet's current request nonce (if any) is in a pending -// state. This guards against duplicate re-anchor requests: the Bridge rejects -// a new request while the previous generation is still in flight. +// generation at the reservation's current request nonce (if any) is in a +// pending state. This guards against duplicate re-anchor requests: the +// Bridge rejects a new request while the previous generation is still in +// flight. The caller supplies the reservation record it already fetched +// (see Run) rather than this function re-reading it. func hasPendingAction( reservationKey *big.Int, - request *tbtc.CoordinationProposalRequest, + reservation *tbtc.Reservation, chain Chain, taskLogger log.StandardLogger, ) bool { - reservation, err := chain.GetReservation(reservationKey) - if err != nil { - taskLogger.Errorf( - "cannot re-read reservation [0x%x] for action state check: [%v]", - reservationKey, - err, - ) - return false - } - if reservation.RequestNonce == 0 { return false } @@ -422,19 +431,21 @@ func hasPendingAction( reservation.RequestNonce, ) if err != nil { + // Fail safe: a lookup error is indistinguishable from "still + // pending" here, and treating it as not-pending would let the + // caller send a duplicate re-anchor request that the Bridge + // rejects while a real pending generation is in flight. Skip this + // reservation for the current coordination window instead; the + // next window retries. taskLogger.Errorf( "cannot get reservation action for [0x%x] nonce [%d]: [%v]", reservationKey, reservation.RequestNonce, err, ) - return false + return true } - // Suppress unused-parameter lint while keeping request available for - // future filtering against the executing operator's wallet membership. - _ = request - return action.State == tbtc.ReservationActionStatePending } diff --git a/pkg/tbtcpg/reservation_reanchor_test.go b/pkg/tbtcpg/reservation_reanchor_test.go index 218fa643ed..cc3113da7a 100644 --- a/pkg/tbtcpg/reservation_reanchor_test.go +++ b/pkg/tbtcpg/reservation_reanchor_test.go @@ -4,8 +4,6 @@ import ( "math/big" "testing" - "github.com/go-test/deep" - "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/tbtc" "github.com/keep-network/keep-core/pkg/tbtcpg" @@ -23,6 +21,13 @@ func TestReservationReanchorTask_Run(t *testing.T) { tbtcChain := tbtcpg.NewLocalChain() btcChain := tbtcpg.NewLocalBitcoinChain() + // findTargetWallet now bounds its wallet-registration scan to + // ReservationReanchorLookBackBlocks; a small current block keeps + // the computed StartBlock at 0, matching the filter used below. + blockCounter := tbtcpg.NewMockBlockCounter() + blockCounter.SetCurrentBlock(1000) + tbtcChain.SetBlockCounter(blockCounter) + tbtcChain.SetWallet( scenario.SourceWalletPublicKeyHash, &tbtc.WalletChainData{ @@ -78,7 +83,14 @@ func TestReservationReanchorTask_Run(t *testing.T) { RequestNonce: r.RequestNonce, }) - if r.HasPendingAction { + // Always install an action record when RequestNonce > 0 so + // hasPendingAction's real GetReservationAction lookup + // succeeds and evaluates State directly, matching what a + // real chain would have: RequestNonce only ever advances + // alongside a real action record. HasPendingAction=false + // scenarios use r.PendingActionState (a terminal state, not + // Pending) to model an already-settled prior generation. + if r.RequestNonce > 0 { tbtcChain.SetReservationAction( r.ReservationKey, r.RequestNonce, @@ -102,7 +114,7 @@ func TestReservationReanchorTask_Run(t *testing.T) { if scenario.TargetWalletPublicKeyHash != [20]byte{} { err := tbtcChain.AddPastNewWalletRegisteredEvent( - nil, + &tbtc.NewWalletRegisteredEventFilter{StartBlock: 0}, &tbtc.NewWalletRegisteredEvent{ WalletPublicKeyHash: scenario.TargetWalletPublicKeyHash, }, @@ -156,19 +168,56 @@ func TestReservationReanchorTask_Run(t *testing.T) { ) } - var actualProposals []*tbtc.ReservationReanchorProposal - if p, ok := proposal.(*tbtc.ReservationReanchorProposal); ok && p != nil { - actualProposals = append(actualProposals, p) - } - - var expectedProposals []*tbtc.ReservationReanchorProposal - if p := scenario.ExpectedProposal; p != nil { - expectedProposals = append(expectedProposals, p) - } + actualProposal, _ := proposal.(*tbtc.ReservationReanchorProposal) - if diff := deep.Equal(actualProposals, expectedProposals); diff != nil { - t.Errorf("invalid reservation re-anchor proposal: %v", diff) + if !reanchorProposalsEqual(scenario.ExpectedProposal, actualProposal) { + t.Errorf( + "invalid reservation re-anchor proposal\n"+ + "expected: %+v\n"+ + "actual: %+v", + scenario.ExpectedProposal, + actualProposal, + ) } }) } } + +// reanchorProposalsEqual compares two proposals field-by-field. deep.Equal +// cannot be used here (or anywhere else the package compares a +// *tbtc.ReservationReanchorProposal/*tbtc.ReservationAnchorProposal): by +// default it does not descend into unexported fields, and *big.Int's +// representation is entirely unexported, so deep.Equal silently reports "no +// difference" for any two distinct *big.Int values. ReservationKey and +// ReanchorTxFee are both *big.Int, so they need an explicit .Cmp(). +func reanchorProposalsEqual( + expected, actual *tbtc.ReservationReanchorProposal, +) bool { + if expected == nil && actual == nil { + return true + } + if expected == nil || actual == nil { + return false + } + if (expected.ReservationKey == nil) != (actual.ReservationKey == nil) { + return false + } + if expected.ReservationKey != nil && + expected.ReservationKey.Cmp(actual.ReservationKey) != 0 { + return false + } + if expected.RequestNonce != actual.RequestNonce { + return false + } + if expected.TargetWalletPublicKeyHash != actual.TargetWalletPublicKeyHash { + return false + } + if (expected.ReanchorTxFee == nil) != (actual.ReanchorTxFee == nil) { + return false + } + if expected.ReanchorTxFee != nil && + expected.ReanchorTxFee.Cmp(actual.ReanchorTxFee) != 0 { + return false + } + return true +} diff --git a/pkg/tbtcpg/tbtcpg_test.go b/pkg/tbtcpg/tbtcpg_test.go index 75e13d6744..97bcc01d42 100644 --- a/pkg/tbtcpg/tbtcpg_test.go +++ b/pkg/tbtcpg/tbtcpg_test.go @@ -202,6 +202,64 @@ func TestProposalGenerator_Generate(t *testing.T) { } } +// TestNewProposalGenerator_ReservationsEnabled verifies the constructor's +// reservationsEnabled gate: when true, the reservation acceptance and +// re-anchor tasks must be wired into the generator's task list; when false, +// they must be entirely absent so the coordination loop never attempts +// them. Presence/absence is observed indirectly through Generate(), since +// pg.tasks is unexported: a checklist made up solely of reservation action +// types is either dispatched to a real task (which fails deterministically +// against the unconfigured chain double, proving the task was found) or +// falls through as unsupported to a nil-error no-op proposal (proving no +// task claims that action type). +func TestNewProposalGenerator_ReservationsEnabled(t *testing.T) { + walletPublicKeyHash := [20]byte{1, 2, 3} + + request := &tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + ActionsChecklist: []tbtc.WalletActionType{ + tbtc.ActionReservationAnchor, + tbtc.ActionReservationReanchor, + }, + } + + t.Run("enabled: reservation tasks are wired in", func(t *testing.T) { + generator := NewProposalGenerator( + NewLocalChain(), + NewLocalBitcoinChain(), + true, + ) + + _, err := generator.Generate(request) + if err == nil { + t.Fatal( + "expected an error from the wired-in reservation tasks " + + "running against the unconfigured chain, got nil", + ) + } + }) + + t.Run("disabled: reservation tasks are absent", func(t *testing.T) { + generator := NewProposalGenerator( + NewLocalChain(), + NewLocalBitcoinChain(), + false, + ) + + proposal, err := generator.Generate(request) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + if !reflect.DeepEqual(&tbtc.NoopProposal{}, proposal) { + t.Fatalf( + "expected a no-op proposal since no task should claim "+ + "either reservation action type, got [%+v]", + proposal, + ) + } + }) +} + type mockProposalTaskResult uint8 const ( From d75ae122f1174ce3351b34ca1fffcad51a71de59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 1 Sep 2026 16:59:48 +0000 Subject: [PATCH 19/39] fix(tbtc): dispatch reservation coordination results, gate on activation block - Wire ActionReservationAnchor/ActionReservationReanchor in processCoordinationResult to handleReservationAnchorProposal/ handleReservationReanchorProposal, which assemble, sign, and broadcast the anchor/re-anchor transaction. Previously these proposals fell through to default: and were only logged. - Gate the reservation checklist entries on a chain-derived ReservationsActivationBlock in addition to the local config flag, so a mixed-rollout signing group can't cause a flag-off follower to fault an honest flag-on leader. - Remove the dead, duplicated Reservations()/ReservationActions() API surface and its unused ReservationRequest/ReservationActionRecord types from pkg/tbtc.Chain; add BuildDepositKey, needed by the reservation anchor wallet action to derive the m1 reservation key. --- pkg/tbtc/chain.go | 65 +----- pkg/tbtc/chain_test.go | 22 +-- pkg/tbtc/coordination.go | 8 +- pkg/tbtc/coordination_test.go | 71 +++++++ pkg/tbtc/node_coordination.go | 18 ++ pkg/tbtc/node_proposals.go | 128 ++++++++++++ pkg/tbtc/reservation.go | 359 +++++++++++++++++++++++++++++++--- pkg/tbtc/reservation_test.go | 62 +++++- 8 files changed, 631 insertions(+), 102 deletions(-) diff --git a/pkg/tbtc/chain.go b/pkg/tbtc/chain.go index c1a83c34d8..36f1e57116 100644 --- a/pkg/tbtc/chain.go +++ b/pkg/tbtc/chain.go @@ -292,6 +292,13 @@ type BridgeChain interface { fundingOutputIndex uint32, ) (*DepositChainRequest, bool, error) + // BuildDepositKey calculates a deposit key for the given funding + // transaction hash and output index. Mirrors tbtcpg.Chain's identical + // method - the reservation anchor wallet action needs it to derive the + // m1 reservation key (reservationKey == depositKey) without depending + // on the tbtcpg package. + BuildDepositKey(fundingTxHash bitcoin.Hash, fundingOutputIndex uint32) *big.Int + // GetMovedFundsSweepRequest gets the on-chain moved funds sweep request for // the given moving funds transaction hash and output index. // The returned bool value indicates whether the request was found or not. @@ -697,22 +704,6 @@ type ReservationChain interface { // to gate new deposits. PendingReservedDeposits() (uint64, error) - // Reservations returns the on-chain reservation request record for the - // given reservation key, including the cumulative re-anchor fee that - // the existing GetReservation representation drops. Mirrors the - // ReservationRouter.reservations view verbatim. - Reservations(reservationKey *big.Int) (*ReservationRequest, error) - - // ReservationActions returns the on-chain reservation action record - // for the given reservation key and request nonce, including the - // late-settlement / retry-credit fields that the existing - // GetReservationAction representation drops. Mirrors the - // ReservationRouter.reservationActions view verbatim. - ReservationActions( - reservationKey *big.Int, - requestNonce uint64, - ) (*ReservationActionRecord, error) - // ActiveReservationsCount returns the current count of active // reservations across all wallets and the cap on that count. ActiveReservationsCount() (count uint32, maxActive uint32, err error) @@ -878,48 +869,6 @@ type BitcoinTxUTXO struct { TxOutputValue uint64 } -// ReservationRequest represents the on-chain reservation request record -// returned by ReservationRouter.reservations. It mirrors the Solidity -// Reservation.ReservationRequest struct field-for-field. -type ReservationRequest struct { - Owner chain.Address - MintedAmount uint64 - AcceptedAt uint32 - WalletPublicKeyHash [20]byte - AnchorAmount uint64 - ExpiresAt uint32 - AnchorTxHash [32]byte - AnchorTxOutputIndex uint32 - State ReservationState - RequestNonce uint64 - RetryCredit bool - DissolutionEligibleAt uint32 - CumulativeReanchorFee uint64 -} - -// ReservationActionRecord represents the on-chain reservation action record -// returned by ReservationRouter.reservationActions. It mirrors the Solidity -// Reservation.ReservationAction struct field-for-field. -type ReservationActionRecord struct { - TargetWalletPublicKeyHash [20]byte - RequestedAt uint32 - TimeoutAt uint32 - TxMaxFee uint64 - ActionType ReservationActionType - State ReservationActionState - FeePaid bool - Redeemer chain.Address - Amount uint64 - ActionDataHash [32]byte - SourceAnchorUtxoHash [32]byte - UsedRetryCredit bool - WatchtowerDefaultDelay uint32 - WatchtowerLevelOneDelay uint32 - WatchtowerLevelTwoDelay uint32 - IsPartial bool - RetryCreditSourceNonce uint64 -} - // ReservationAcceptanceRequestedEvent represents a reservation acceptance // requested event. type ReservationAcceptanceRequestedEvent struct { diff --git a/pkg/tbtc/chain_test.go b/pkg/tbtc/chain_test.go index 08717fdc3e..c0e24bc795 100644 --- a/pkg/tbtc/chain_test.go +++ b/pkg/tbtc/chain_test.go @@ -802,6 +802,15 @@ func (lc *localChain) GetDepositRequest( return request, true, nil } +func (lc *localChain) BuildDepositKey( + fundingTxHash bitcoin.Hash, + fundingOutputIndex uint32, +) *big.Int { + depositKeyBytes := buildDepositRequestKey(fundingTxHash, fundingOutputIndex) + + return new(big.Int).SetBytes(depositKeyBytes[:]) +} + func (lc *localChain) setDepositRequest( fundingTxHash bitcoin.Hash, fundingOutputIndex uint32, @@ -1586,19 +1595,6 @@ func (lc *localChain) PendingReservedDeposits() (uint64, error) { return 0, fmt.Errorf("unsupported") } -func (lc *localChain) Reservations( - reservationKey *big.Int, -) (*ReservationRequest, error) { - return nil, fmt.Errorf("unsupported") -} - -func (lc *localChain) ReservationActions( - reservationKey *big.Int, - requestNonce uint64, -) (*ReservationActionRecord, error) { - return nil, fmt.Errorf("unsupported") -} - func (lc *localChain) ActiveReservationsCount() (uint32, uint32, error) { return 0, 0, fmt.Errorf("unsupported") } diff --git a/pkg/tbtc/coordination.go b/pkg/tbtc/coordination.go index f7e626acd5..8b408354d4 100644 --- a/pkg/tbtc/coordination.go +++ b/pkg/tbtc/coordination.go @@ -66,6 +66,10 @@ const ( // upgrade to a binary containing this constant before the activation block // is reached. DepositSweepEveryWindowActivationBlock = uint64(24559289) + // ReservationsActivationBlock is the Ethereum block height at which + // reservation actions (anchor, re-anchor) become available in the + // coordination checklist. + ReservationsActivationBlock = DepositSweepEveryWindowActivationBlock ) // errCoordinationExecutorBusy is an error returned when the coordination @@ -647,7 +651,9 @@ func (ce *coordinationExecutor) getActionsChecklist( // checklist. Frequency-gated like DepositSweep/MovingFunds below the // activation block: reservation acceptance/re-anchor windows are not // as time-critical as redemption. - if ce.reservationsEnabled && windowIndex%frequencyWindows == 0 { + if ce.reservationsEnabled && + coordinationBlock >= ReservationsActivationBlock && + windowIndex%frequencyWindows == 0 { actions = append(actions, ActionReservationAnchor) actions = append(actions, ActionReservationReanchor) } diff --git a/pkg/tbtc/coordination_test.go b/pkg/tbtc/coordination_test.go index 3d59b3f486..f51c5a47de 100644 --- a/pkg/tbtc/coordination_test.go +++ b/pkg/tbtc/coordination_test.go @@ -839,6 +839,77 @@ func TestCoordinationExecutor_GetActionsChecklist_PostActivation(t *testing.T) { } } +func TestCoordinationExecutor_GetActionsChecklist_Reservations(t *testing.T) { + tests := map[string]struct { + reservationsEnabled bool + coordinationBlock uint64 + windowIndex uint64 + expectedActions []WalletActionType + }{ + "reservations disabled": { + reservationsEnabled: false, + coordinationBlock: ReservationsActivationBlock, + windowIndex: 4, + expectedActions: []WalletActionType{ActionRedemption}, + }, + "reservations enabled below activation": { + reservationsEnabled: true, + coordinationBlock: ReservationsActivationBlock - 1, + windowIndex: 4, + expectedActions: []WalletActionType{ActionRedemption}, + }, + "reservations enabled at activation, non-4th window": { + reservationsEnabled: true, + coordinationBlock: ReservationsActivationBlock, + windowIndex: 5, + expectedActions: []WalletActionType{ActionRedemption}, + }, + "reservations enabled at activation, 4th window": { + reservationsEnabled: true, + coordinationBlock: ReservationsActivationBlock, + windowIndex: 4, + expectedActions: []WalletActionType{ActionRedemption, ActionReservationAnchor, ActionReservationReanchor}, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + executor := &coordinationExecutor{ + reservationsEnabled: test.reservationsEnabled, + } + + // We don't care about the seed for this test, as it only affects + // the ActionHeartbeat which is not the focus here. + seed := [32]byte{} + + checklist := executor.getActionsChecklist( + test.windowIndex, + seed, + test.coordinationBlock, + ) + + // We only care about reservation actions. + var actualReservationActions []WalletActionType + for _, action := range checklist { + if action == ActionReservationAnchor || action == ActionReservationReanchor { + actualReservationActions = append(actualReservationActions, action) + } + } + + var expectedReservationActions []WalletActionType + for _, action := range test.expectedActions { + if action == ActionReservationAnchor || action == ActionReservationReanchor { + expectedReservationActions = append(expectedReservationActions, action) + } + } + + if diff := deep.Equal(actualReservationActions, expectedReservationActions); diff != nil { + t.Errorf("reservation actions mismatch: %v", diff) + } + }) + } +} + // assertPostActivationSafety verifies the safety invariants that must hold // for every non-nil post-activation checklist: // - ActionRedemption is at index 0. diff --git a/pkg/tbtc/node_coordination.go b/pkg/tbtc/node_coordination.go index 7c61587b79..fa1bdb2f27 100644 --- a/pkg/tbtc/node_coordination.go +++ b/pkg/tbtc/node_coordination.go @@ -309,6 +309,24 @@ func processCoordinationResult(node *node, result *coordinationResult) { expiryBlock, ) } + case ActionReservationAnchor: + if proposal, ok := result.proposal.(*ReservationAnchorProposal); ok { + node.handleReservationAnchorProposal( + result.wallet, + proposal, + startBlock, + expiryBlock, + ) + } + case ActionReservationReanchor: + if proposal, ok := result.proposal.(*ReservationReanchorProposal); ok { + node.handleReservationReanchorProposal( + result.wallet, + proposal, + startBlock, + expiryBlock, + ) + } default: logger.Errorf("no handler for coordination result [%s]", result) } diff --git a/pkg/tbtc/node_proposals.go b/pkg/tbtc/node_proposals.go index ede89206eb..ed7c9805d1 100644 --- a/pkg/tbtc/node_proposals.go +++ b/pkg/tbtc/node_proposals.go @@ -312,6 +312,134 @@ func (n *node) handleMovingFundsProposal( walletActionLogger.Infof("wallet action dispatched successfully") } +// handleReservationAnchorProposal handles an incoming reservation anchor proposal by +// orchestrating and dispatching an appropriate wallet action. +func (n *node) handleReservationAnchorProposal( + wallet wallet, + proposal *ReservationAnchorProposal, + startBlock uint64, + expiryBlock uint64, +) { + walletPublicKeyBytes, err := marshalPublicKey(wallet.publicKey) + if err != nil { + return + } + + signingExecutor, ok, err := n.getSigningExecutor(wallet.publicKey) + if err != nil { + logger.Errorf("cannot get signing executor: [%v]", err) + return + } + if !ok { + logger.Infof( + "node does not control signers of wallet PKH [0x%x]; "+ + "ignoring the received reservation anchor proposal", + walletPublicKeyBytes, + ) + return + } + + logger.Infof( + "starting orchestration of the reservation anchor action for wallet [0x%x]; "+ + "20-byte public key hash of that wallet is [0x%x]", + walletPublicKeyBytes, + bitcoin.PublicKeyHash(wallet.publicKey), + ) + + walletActionLogger := logger.With( + zap.String("wallet", fmt.Sprintf("0x%x", walletPublicKeyBytes)), + zap.String("action", ActionReservationAnchor.String()), + zap.Uint64("startBlock", startBlock), + zap.Uint64("expiryBlock", expiryBlock), + ) + walletActionLogger.Infof("dispatching wallet action") + + action := newReservationAnchorAction( + walletActionLogger, + n.chain, + n.btcChain, + wallet, + signingExecutor, + proposal, + startBlock, + expiryBlock, + n.waitForBlockHeight, + n.transactionMonitor, + ) + + err = n.walletDispatcher.dispatch(action) + if err != nil { + walletActionLogger.Errorf("cannot dispatch wallet action: [%v]", err) + return + } + + walletActionLogger.Infof("wallet action dispatched successfully") +} + +// handleReservationReanchorProposal handles an incoming reservation re-anchor proposal by +// orchestrating and dispatching an appropriate wallet action. +func (n *node) handleReservationReanchorProposal( + wallet wallet, + proposal *ReservationReanchorProposal, + startBlock uint64, + expiryBlock uint64, +) { + walletPublicKeyBytes, err := marshalPublicKey(wallet.publicKey) + if err != nil { + return + } + + signingExecutor, ok, err := n.getSigningExecutor(wallet.publicKey) + if err != nil { + logger.Errorf("cannot get signing executor: [%v]", err) + return + } + if !ok { + logger.Infof( + "node does not control signers of wallet PKH [0x%x]; "+ + "ignoring the received reservation re-anchor proposal", + walletPublicKeyBytes, + ) + return + } + + logger.Infof( + "starting orchestration of the reservation re-anchor action for wallet [0x%x]; "+ + "20-byte public key hash of that wallet is [0x%x]", + walletPublicKeyBytes, + bitcoin.PublicKeyHash(wallet.publicKey), + ) + + walletActionLogger := logger.With( + zap.String("wallet", fmt.Sprintf("0x%x", walletPublicKeyBytes)), + zap.String("action", ActionReservationReanchor.String()), + zap.Uint64("startBlock", startBlock), + zap.Uint64("expiryBlock", expiryBlock), + ) + walletActionLogger.Infof("dispatching wallet action") + + action := newReservationReanchorAction( + walletActionLogger, + n.chain, + n.btcChain, + wallet, + signingExecutor, + proposal, + startBlock, + expiryBlock, + n.waitForBlockHeight, + n.transactionMonitor, + ) + + err = n.walletDispatcher.dispatch(action) + if err != nil { + walletActionLogger.Errorf("cannot dispatch wallet action: [%v]", err) + return + } + + walletActionLogger.Infof("wallet action dispatched successfully") +} + // handleMovedFundsSweepProposal handles an incoming moved funds sweep proposal // by orchestrating and dispatching an appropriate wallet action. func (n *node) handleMovedFundsSweepProposal( diff --git a/pkg/tbtc/reservation.go b/pkg/tbtc/reservation.go index a7a4de2577..4167b51425 100644 --- a/pkg/tbtc/reservation.go +++ b/pkg/tbtc/reservation.go @@ -4,7 +4,9 @@ import ( "encoding/json" "fmt" "math/big" + "time" + "go.uber.org/zap" "golang.org/x/crypto/sha3" "github.com/keep-network/keep-core/pkg/bitcoin" @@ -389,21 +391,35 @@ func (rdp *ReservationDissolutionProposal) Unmarshal(bytes []byte) error { return nil } -// assembleReservationAnchorTransaction constructs an unsigned reservation +// AssembleReservationAnchorTransaction constructs an unsigned reservation // anchor transaction: a 1-input-1-output spend of the given reserved deposit // into a fresh output controlled by the given wallet. The anchor mirrors the // sweep's refund-disabling role without its consolidating role: the Bridge // credits the reservation owner only against the SPV proof of this // transaction. -func assembleReservationAnchorTransaction( +func AssembleReservationAnchorTransaction( bitcoinChain bitcoin.Chain, deposit *Deposit, walletPublicKeyHash [20]byte, + action *ReservationAction, fee int64, ) (*bitcoin.TransactionBuilder, error) { if deposit == nil { return nil, fmt.Errorf("deposit is required") } + if action == nil { + return nil, fmt.Errorf("reservation action is required") + } + if fee <= 0 { + return nil, fmt.Errorf("fee must be positive") + } + if uint64(fee) > action.TxMaxFee { + return nil, fmt.Errorf("fee exceeds the maximum allowed fee") + } + anchorValue := deposit.Utxo.Value - fee + if anchorValue <= 0 { + return nil, fmt.Errorf("transaction fee exceeds the deposit amount") + } builder := bitcoin.NewTransactionBuilder(bitcoinChain) @@ -414,27 +430,19 @@ func assembleReservationAnchorTransaction( err = builder.AddScriptHashInput(deposit.Utxo, depositScript) if err != nil { - return nil, fmt.Errorf( - "cannot add input pointing to deposit UTXO: [%v]", - err, - ) - } - - anchorValue := deposit.Utxo.Value - fee - if anchorValue <= 0 { - return nil, fmt.Errorf( - "transaction fee exceeds the deposit value", - ) + return nil, fmt.Errorf("cannot add input pointing to deposit UTXO: [%v]", err) } - anchorScript, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) + outputScript, err := bitcoin.PayToWitnessPublicKeyHash( + walletPublicKeyHash, + ) if err != nil { return nil, fmt.Errorf("cannot compute anchor script: [%v]", err) } builder.AddOutput(&bitcoin.TransactionOutput{ Value: anchorValue, - PublicKeyScript: anchorScript, + PublicKeyScript: outputScript, }) return builder, nil @@ -574,19 +582,34 @@ func computeReservationRedeemerOutputScriptHash( return result, nil } -// assembleReservationReanchorTransaction constructs an unsigned reservation +// AssembleReservationReanchorTransaction constructs an unsigned reservation // re-anchor transaction: a 1-input-1-output spend of the reservation's // anchor outpoint into a fresh output controlled by the target wallet. Used // during wallet migration so reservations never pin retiring wallets. -func assembleReservationReanchorTransaction( +func AssembleReservationReanchorTransaction( bitcoinChain bitcoin.Chain, anchorUtxo *bitcoin.UnspentTransactionOutput, targetWalletPublicKeyHash [20]byte, + action *ReservationAction, fee int64, ) (*bitcoin.TransactionBuilder, error) { if anchorUtxo == nil { return nil, fmt.Errorf("anchor UTXO is required") } + if action == nil { + return nil, fmt.Errorf("reservation action is required") + } + if fee <= 0 { + return nil, fmt.Errorf("fee must be positive") + } + if uint64(fee) > action.TxMaxFee { + return nil, fmt.Errorf("fee exceeds the maximum allowed fee") + } + + anchorValue := anchorUtxo.Value - fee + if anchorValue <= 0 { + return nil, fmt.Errorf("transaction fee exceeds the anchor value") + } builder := bitcoin.NewTransactionBuilder(bitcoinChain) @@ -598,23 +621,16 @@ func assembleReservationReanchorTransaction( ) } - reanchorValue := anchorUtxo.Value - fee - if reanchorValue <= 0 { - return nil, fmt.Errorf( - "transaction fee exceeds the anchor value", - ) - } - - reanchorScript, err := bitcoin.PayToWitnessPublicKeyHash( + outputScript, err := bitcoin.PayToWitnessPublicKeyHash( targetWalletPublicKeyHash, ) if err != nil { - return nil, fmt.Errorf("cannot compute re-anchor script: [%v]", err) + return nil, fmt.Errorf("cannot compute anchor script: [%v]", err) } builder.AddOutput(&bitcoin.TransactionOutput{ - Value: reanchorValue, - PublicKeyScript: reanchorScript, + Value: anchorValue, + PublicKeyScript: outputScript, }) return builder, nil @@ -727,3 +743,290 @@ func assembleReservationDissolutionTransaction( return builder, nil } + +// reservationActionSigningTimeoutSafetyMarginBlocks is the duration, in +// blocks, that must remain before the proposal's expiry block for signing +// to be attempted. Mirrors redemptionSigningTimeoutSafetyMarginBlocks. +const reservationActionSigningTimeoutSafetyMarginBlocks = 300 + +// reservationActionBroadcastTimeout is the timeout applied while +// broadcasting a reservation anchor/re-anchor transaction. Mirrors +// redemptionBroadcastTimeout. +const reservationActionBroadcastTimeout = 15 * time.Minute + +// reservationActionBroadcastCheckDelay is the delay between broadcast +// attempts of a reservation anchor/re-anchor transaction. Mirrors +// redemptionBroadcastCheckDelay. +const reservationActionBroadcastCheckDelay = 1 * time.Minute + +// reservationAnchorAction is a walletAction implementation handling reservation +// anchor requests from the wallet coordinator. +type reservationAnchorAction struct { + logger *zap.SugaredLogger + chain Chain + btcChain bitcoin.Chain + custodyWallet wallet + transactionExecutor *walletTransactionExecutor + proposal *ReservationAnchorProposal + startBlock uint64 + expiryBlock uint64 +} + +func newReservationAnchorAction( + logger *zap.SugaredLogger, + chain Chain, + btcChain bitcoin.Chain, + custodyWallet wallet, + signingExecutor walletSigningExecutor, + proposal *ReservationAnchorProposal, + startBlock uint64, + expiryBlock uint64, + waitForBlockHeight waitForBlockFn, + transactionMonitor *transactionMonitor, +) *reservationAnchorAction { + transactionExecutor := newWalletTransactionExecutor( + btcChain, + custodyWallet, + signingExecutor, + waitForBlockHeight, + ) + transactionExecutor.setTransactionMonitor(transactionMonitor) + return &reservationAnchorAction{ + logger: logger, + chain: chain, + btcChain: btcChain, + custodyWallet: custodyWallet, + transactionExecutor: transactionExecutor, + proposal: proposal, + startBlock: startBlock, + expiryBlock: expiryBlock, + } +} + +func (raa *reservationAnchorAction) execute() error { + walletPublicKeyHash := bitcoin.PublicKeyHash(raa.custodyWallet.publicKey) + + fundingTx, err := raa.btcChain.GetTransaction(raa.proposal.DepositFundingTxHash) + if err != nil { + return fmt.Errorf("cannot fetch funding transaction: [%v]", err) + } + + // The proposal carries only the deposit's funding outpoint, not the + // block it was revealed at, so the DepositRevealed event lookup cannot + // be block-range narrowed the way the deposit sweep validation path + // narrows it via DepositsRevealBlocks. Narrow by wallet PKH instead and + // match the exact funding outpoint among the returned events. + events, err := raa.chain.PastDepositRevealedEvents(&DepositRevealedEventFilter{ + WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + }) + if err != nil { + return fmt.Errorf("cannot fetch deposit revealed events: [%v]", err) + } + + var matchingEvent *DepositRevealedEvent + for _, event := range events { + if event.FundingTxHash == raa.proposal.DepositFundingTxHash && + event.FundingOutputIndex == raa.proposal.DepositFundingOutputIndex { + matchingEvent = event + break + } + } + if matchingEvent == nil { + return fmt.Errorf("no matching DepositRevealed event for deposit") + } + + depositRequest, found, err := raa.chain.GetDepositRequest( + raa.proposal.DepositFundingTxHash, + raa.proposal.DepositFundingOutputIndex, + ) + if err != nil { + return fmt.Errorf("cannot fetch deposit request: [%v]", err) + } + if !found { + return fmt.Errorf("deposit request not found") + } + + deposit := matchingEvent.unpack(depositRequest.ExtraData) + + // m1 identity: the reservation key is the deposit key, mirroring the + // convention documented in pkg/maintainer/spv/reservation_stale_deposit_watch.go. + reservationKey := raa.chain.BuildDepositKey( + raa.proposal.DepositFundingTxHash, + raa.proposal.DepositFundingOutputIndex, + ) + + action, err := raa.chain.GetReservationAction(reservationKey, raa.proposal.RequestNonce) + if err != nil { + return fmt.Errorf("cannot get reservation action: [%v]", err) + } + + err = raa.chain.ValidateReservationAnchorProposal( + walletPublicKeyHash, + raa.proposal, + struct { + *Deposit + FundingTx *bitcoin.Transaction + }{Deposit: deposit, FundingTx: fundingTx}, + ) + if err != nil { + return fmt.Errorf("cannot validate reservation anchor proposal: [%v]", err) + } + + unsignedTx, err := AssembleReservationAnchorTransaction( + raa.btcChain, + deposit, + walletPublicKeyHash, + action, + raa.proposal.AnchorTxFee.Int64(), + ) + if err != nil { + return fmt.Errorf("cannot assemble reservation anchor transaction: [%v]", err) + } + + // Just in case. This should never happen. + if raa.expiryBlock < reservationActionSigningTimeoutSafetyMarginBlocks { + return fmt.Errorf("invalid proposal expiry block") + } + + signedTx, err := raa.transactionExecutor.signTransaction( + raa.logger, + unsignedTx, + raa.startBlock, + raa.expiryBlock-reservationActionSigningTimeoutSafetyMarginBlocks, + ) + if err != nil { + return fmt.Errorf("cannot sign reservation anchor transaction: [%v]", err) + } + + err = raa.transactionExecutor.broadcastTransaction( + raa.logger, + signedTx, + reservationActionBroadcastTimeout, + reservationActionBroadcastCheckDelay, + ) + if err != nil { + return fmt.Errorf("cannot broadcast reservation anchor transaction: [%v]", err) + } + + return nil +} + +func (raa *reservationAnchorAction) wallet() wallet { + return raa.custodyWallet +} + +func (raa *reservationAnchorAction) actionType() WalletActionType { + return ActionReservationAnchor +} + +// reservationReanchorAction is a walletAction implementation handling +// reservation re-anchor requests from the wallet coordinator. +type reservationReanchorAction struct { + logger *zap.SugaredLogger + chain Chain + btcChain bitcoin.Chain + custodyWallet wallet + transactionExecutor *walletTransactionExecutor + proposal *ReservationReanchorProposal + startBlock uint64 + expiryBlock uint64 +} + +func newReservationReanchorAction( + logger *zap.SugaredLogger, + chain Chain, + btcChain bitcoin.Chain, + custodyWallet wallet, + signingExecutor walletSigningExecutor, + proposal *ReservationReanchorProposal, + startBlock uint64, + expiryBlock uint64, + waitForBlockHeight waitForBlockFn, + transactionMonitor *transactionMonitor, +) *reservationReanchorAction { + transactionExecutor := newWalletTransactionExecutor( + btcChain, + custodyWallet, + signingExecutor, + waitForBlockHeight, + ) + transactionExecutor.setTransactionMonitor(transactionMonitor) + return &reservationReanchorAction{ + logger: logger, + chain: chain, + btcChain: btcChain, + custodyWallet: custodyWallet, + transactionExecutor: transactionExecutor, + proposal: proposal, + startBlock: startBlock, + expiryBlock: expiryBlock, + } +} + +func (rra *reservationReanchorAction) execute() error { + walletPublicKeyHash := bitcoin.PublicKeyHash(rra.custodyWallet.publicKey) + + reservation, err := rra.chain.GetReservation(rra.proposal.ReservationKey) + if err != nil { + return fmt.Errorf("cannot get reservation: [%v]", err) + } + + action, err := rra.chain.GetReservationAction(rra.proposal.ReservationKey, rra.proposal.RequestNonce) + if err != nil { + return fmt.Errorf("cannot get reservation action: [%v]", err) + } + + err = rra.chain.ValidateReservationReanchorProposal( + walletPublicKeyHash, + rra.proposal, + ) + if err != nil { + return fmt.Errorf("cannot validate reservation reanchor proposal: [%v]", err) + } + + unsignedTx, err := AssembleReservationReanchorTransaction( + rra.btcChain, + reservation.AnchorUtxo, + rra.proposal.TargetWalletPublicKeyHash, + action, + rra.proposal.ReanchorTxFee.Int64(), + ) + if err != nil { + return fmt.Errorf("cannot assemble reservation reanchor transaction: [%v]", err) + } + + // Just in case. This should never happen. + if rra.expiryBlock < reservationActionSigningTimeoutSafetyMarginBlocks { + return fmt.Errorf("invalid proposal expiry block") + } + + signedTx, err := rra.transactionExecutor.signTransaction( + rra.logger, + unsignedTx, + rra.startBlock, + rra.expiryBlock-reservationActionSigningTimeoutSafetyMarginBlocks, + ) + if err != nil { + return fmt.Errorf("cannot sign reservation reanchor transaction: [%v]", err) + } + + err = rra.transactionExecutor.broadcastTransaction( + rra.logger, + signedTx, + reservationActionBroadcastTimeout, + reservationActionBroadcastCheckDelay, + ) + if err != nil { + return fmt.Errorf("cannot broadcast reservation reanchor transaction: [%v]", err) + } + + return nil +} + +func (rra *reservationReanchorAction) wallet() wallet { + return rra.custodyWallet +} + +func (rra *reservationReanchorAction) actionType() WalletActionType { + return ActionReservationReanchor +} diff --git a/pkg/tbtc/reservation_test.go b/pkg/tbtc/reservation_test.go index 16ab16c160..40b9141f82 100644 --- a/pkg/tbtc/reservation_test.go +++ b/pkg/tbtc/reservation_test.go @@ -584,14 +584,44 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { } } - _, err = assembleReservationAnchorTransaction( + _, err = AssembleReservationAnchorTransaction( bitcoinChain, nil, walletPublicKeyHash, + nil, 1500, ) assertError(err, "deposit is required") + deposit := &Deposit{Utxo: anchorUtxo} + + _, err = AssembleReservationAnchorTransaction( + bitcoinChain, + deposit, + walletPublicKeyHash, + nil, + 1500, + ) + assertError(err, "reservation action is required") + + _, err = AssembleReservationAnchorTransaction( + bitcoinChain, + deposit, + walletPublicKeyHash, + &ReservationAction{TxMaxFee: 2000}, + 0, + ) + assertError(err, "fee must be positive") + + _, err = AssembleReservationAnchorTransaction( + bitcoinChain, + deposit, + walletPublicKeyHash, + &ReservationAction{TxMaxFee: 1000}, + 1500, + ) + assertError(err, "fee exceeds the maximum allowed fee") + _, err = assembleReservedRedemptionTransaction( bitcoinChain, nil, @@ -695,14 +725,42 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { ) assertError(err, "transaction fee exceeds the action fee limit") - _, err = assembleReservationReanchorTransaction( + _, err = AssembleReservationReanchorTransaction( bitcoinChain, nil, walletPublicKeyHash, + nil, 1500, ) assertError(err, "anchor UTXO is required") + _, err = AssembleReservationReanchorTransaction( + bitcoinChain, + anchorUtxo, + walletPublicKeyHash, + nil, + 1500, + ) + assertError(err, "reservation action is required") + + _, err = AssembleReservationReanchorTransaction( + bitcoinChain, + anchorUtxo, + walletPublicKeyHash, + &ReservationAction{TxMaxFee: 2000}, + 0, + ) + assertError(err, "fee must be positive") + + _, err = AssembleReservationReanchorTransaction( + bitcoinChain, + anchorUtxo, + walletPublicKeyHash, + &ReservationAction{TxMaxFee: 1000}, + 1500, + ) + assertError(err, "fee exceeds the maximum allowed fee") + _, err = assembleReservationDissolutionTransaction( bitcoinChain, bridgeChain, From 29ce21ae9160fd99fff7c59795a6bbaa9ae4ccfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 1 Sep 2026 16:59:53 +0000 Subject: [PATCH 20/39] fix(ethereum): remove dead reservation router API, add conversion test coverage Remove the duplicated Reservations()/ReservationActions() chain-binding methods that mirrored pkg/tbtc.Chain's now-deleted API. Add unit tests for the reservation action/parameter ABI-to-domain conversion helpers (TestConvertReservationActionFromAbiType, TestConvertReservationParametersFromAbiType) covering both valid action states and the unrecognized/zero-value error paths. --- pkg/chain/ethereum/tbtc.go | 118 +------------------------------- pkg/chain/ethereum/tbtc_test.go | 84 +++++++++++++++-------- 2 files changed, 57 insertions(+), 145 deletions(-) diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index fca553f33c..1465ab1545 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -3200,57 +3200,10 @@ func (tc *TbtcChain) PendingReservedDeposits() (uint64, error) { // verbatim-on-chain conversion; callers that want a slightly-shrunk Go // representation use GetReservation, which drops CumulativeReanchorFee // because m1 has no fee-ceiling enforcement. -func convertReservationRequestFromAbiType( - abiReservation tbtcabi.ReservationReservationRequest, -) (*tbtc.ReservationRequest, error) { - state, err := parseReservationState(abiReservation.State) - if err != nil { - return nil, fmt.Errorf("cannot parse reservation state: [%v]", err) - } - - return &tbtc.ReservationRequest{ - Owner: chain.Address(abiReservation.Owner.String()), - MintedAmount: abiReservation.MintedAmount, - AcceptedAt: abiReservation.AcceptedAt, - WalletPublicKeyHash: abiReservation.WalletPubKeyHash, - AnchorAmount: abiReservation.AnchorAmount, - ExpiresAt: abiReservation.ExpiresAt, - AnchorTxHash: abiReservation.AnchorTxHash, - AnchorTxOutputIndex: abiReservation.AnchorTxOutputIndex, - State: state, - RequestNonce: abiReservation.RequestNonce, - RetryCredit: abiReservation.RetryCredit, - DissolutionEligibleAt: abiReservation.DissolutionEligibleAt, - CumulativeReanchorFee: abiReservation.CumulativeReanchorFee, - }, nil -} // Reservations returns the on-chain reservation request record for the // given reservation key, including the cumulative re-anchor fee that the // existing GetReservation representation drops. -func (tc *TbtcChain) Reservations( - reservationKey *big.Int, -) (*tbtc.ReservationRequest, error) { - abiReservation, err := tc.reservationRouter.Reservations(reservationKey) - if err != nil { - return nil, fmt.Errorf( - "cannot get reservation [0x%x]: [%v]", - reservationKey, - err, - ) - } - - reservation, err := convertReservationRequestFromAbiType(abiReservation) - if err != nil { - return nil, fmt.Errorf( - "cannot convert reservation [0x%x] from abi type: [%v]", - reservationKey, - err, - ) - } - - return reservation, nil -} // convertReservationActionRecordFromAbiType converts the ReservationRouter- // specific Reservation.ReservationAction ABI struct to the TBTC @@ -3258,79 +3211,11 @@ func (tc *TbtcChain) Reservations( // verbatim-on-chain conversion; callers that want a slightly-shrunk Go // representation use GetReservationAction, which drops the late-settlement // and retry-credit fields because m1 does not consume them. -func convertReservationActionRecordFromAbiType( - abiAction tbtcabi.ReservationReservationAction, -) (*tbtc.ReservationActionRecord, error) { - actionType, err := parseReservationActionType(abiAction.ActionType) - if err != nil { - return nil, fmt.Errorf( - "cannot parse reservation action type: [%v]", - err, - ) - } - - state, err := parseReservationActionState(abiAction.State) - if err != nil { - return nil, fmt.Errorf( - "cannot parse reservation action state: [%v]", - err, - ) - } - - return &tbtc.ReservationActionRecord{ - TargetWalletPublicKeyHash: abiAction.TargetWalletPubKeyHash, - RequestedAt: abiAction.RequestedAt, - TimeoutAt: abiAction.TimeoutAt, - TxMaxFee: abiAction.TxMaxFee, - ActionType: actionType, - State: state, - FeePaid: abiAction.FeePaid, - Redeemer: chain.Address(abiAction.Redeemer.String()), - Amount: abiAction.Amount, - ActionDataHash: abiAction.ActionDataHash, - SourceAnchorUtxoHash: abiAction.SourceAnchorUtxoHash, - UsedRetryCredit: abiAction.UsedRetryCredit, - WatchtowerDefaultDelay: abiAction.WatchtowerDefaultDelay, - WatchtowerLevelOneDelay: abiAction.WatchtowerLevelOneDelay, - WatchtowerLevelTwoDelay: abiAction.WatchtowerLevelTwoDelay, - IsPartial: abiAction.IsPartial, - RetryCreditSourceNonce: abiAction.RetryCreditSourceNonce, - }, nil -} // ReservationActions returns the on-chain reservation action record for the // given reservation key and request nonce, including the late-settlement // and retry-credit fields that the existing GetReservationAction // representation drops. -func (tc *TbtcChain) ReservationActions( - reservationKey *big.Int, - requestNonce uint64, -) (*tbtc.ReservationActionRecord, error) { - abiAction, err := tc.reservationRouter.ReservationActions( - reservationKey, - requestNonce, - ) - if err != nil { - return nil, fmt.Errorf( - "cannot get reservation action [0x%x:%d]: [%v]", - reservationKey, - requestNonce, - err, - ) - } - - action, err := convertReservationActionRecordFromAbiType(abiAction) - if err != nil { - return nil, fmt.Errorf( - "cannot convert reservation action [0x%x:%d] from abi type: [%v]", - reservationKey, - requestNonce, - err, - ) - } - - return action, nil -} // ActiveReservationsCount returns the current count of active reservations // across all wallets and the cap on that count. @@ -3775,10 +3660,11 @@ func (tc *TbtcChain) PastReservationActionTimedOutEvents( for _, event := range events { parsedActionType, err := parseReservationActionType(event.ActionType) if err != nil { - return nil, fmt.Errorf( + logger.Errorf( "unexpected reservation action type on past ReservationActionTimedOut event: [%v]", err, ) + continue } convertedEvents = append(convertedEvents, &tbtc.ReservationActionTimedOutEvent{ diff --git a/pkg/chain/ethereum/tbtc_test.go b/pkg/chain/ethereum/tbtc_test.go index fe7c5da982..ea6e9edb11 100644 --- a/pkg/chain/ethereum/tbtc_test.go +++ b/pkg/chain/ethereum/tbtc_test.go @@ -561,37 +561,32 @@ func TestConvertReservationFromAbiType(t *testing.T) { CumulativeReanchorFee: 12345, } - t.Run("valid state", func(t *testing.T) { - reservation, err := convertReservationFromAbiType(validAbiReservation) - if err != nil { - t.Fatalf("unexpected error: [%v]", err) + t.Run("valid states", func(t *testing.T) { + var tests = map[string]struct { + abiState uint8 + expectedState tbtc.ReservationState + }{ + "unknown": {0, tbtc.ReservationStateUnknown}, + "active": {1, tbtc.ReservationStateActive}, + "pending": {2, tbtc.ReservationStateActionPending}, + "closed": {3, tbtc.ReservationStateClosed}, + "stranded": {4, tbtc.ReservationStateStranded}, } - expected := &tbtc.Reservation{ - Owner: chain.Address(ownerAddress.String()), - MintedAmount: 100000, - AcceptedAt: 1700000000, - WalletPublicKeyHash: [20]byte{0xaa, 0xbb, 0xcc}, - AnchorUtxo: &bitcoin.UnspentTransactionOutput{ - Outpoint: &bitcoin.TransactionOutpoint{ - TransactionHash: anchorTxHash, - OutputIndex: 1, - }, - Value: 99000, - }, - ExpiresAt: 1700100000, - State: tbtc.ReservationStateActive, - RequestNonce: 7, - RetryCredit: true, - DissolutionEligibleAt: 1700200000, - } + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + abiReservation := validAbiReservation + abiReservation.State = test.abiState - if !reflect.DeepEqual(expected, reservation) { - t.Errorf( - "unexpected reservation\nexpected: [%+v]\nactual: [%+v]\n", - expected, - reservation, - ) + reservation, err := convertReservationFromAbiType(abiReservation) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if reservation.State != test.expectedState { + t.Errorf("expected state [%v], got [%v]", test.expectedState, reservation.State) + } + }) } }) @@ -720,11 +715,42 @@ func TestConvertReservationActionFromAbiType(t *testing.T) { t.Fatal("expected error, got nil") } }) + + t.Run("valid action states", func(t *testing.T) { + var tests = map[string]struct { + abiState uint8 + expectedState tbtc.ReservationActionState + }{ + "unknown": {0, tbtc.ReservationActionStateUnknown}, + "pending": {1, tbtc.ReservationActionStatePending}, + "settled": {2, tbtc.ReservationActionStateSettled}, + "timed out": {3, tbtc.ReservationActionStateTimedOut}, + "vetoed": {4, tbtc.ReservationActionStateVetoed}, + "superseded": {5, tbtc.ReservationActionStateSuperseded}, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + abiAction := baseAbiAction + abiAction.ActionType = 1 + abiAction.State = test.abiState + + action, err := convertReservationActionFromAbiType(abiAction) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if action.State != test.expectedState { + t.Errorf("expected state [%v], got [%v]", test.expectedState, action.State) + } + }) + } + }) } func TestConvertReservationParametersFromAbiType(t *testing.T) { vaultAddress := common.HexToAddress( - "0x9876543210FedCbA9876543210FedcbA98765432", + "0x9876543210FeDcBa9876543210fEdCbA98765432", ) abiParameters := struct { From 4bfa523f62887831f17133a13d75e633a824d577 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 1 Sep 2026 16:59:59 +0000 Subject: [PATCH 21/39] fix(clientinfo): split reservation metric names from the always-registered set GetAllWalletActionTypes was exported and unconditionally returned the four reservation action names; only registerAllMetrics applied the reservationsEnabled filter, so any other consumer saw the enlarged set regardless of configuration. Move the reservation names into a new GetReservationWalletActionTypes(), keeping GetAllWalletActionTypes() returning only the original five, and have registerAllMetrics append the reservation set only when reservationsEnabled is true. --- pkg/clientinfo/performance.go | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/pkg/clientinfo/performance.go b/pkg/clientinfo/performance.go index 1176f1f21d..a10dec6a12 100644 --- a/pkg/clientinfo/performance.go +++ b/pkg/clientinfo/performance.go @@ -193,10 +193,12 @@ func (pm *PerformanceMetrics) registerAllMetrics() { // Register per-action type wallet metrics // For each action type, register: total, success_total, failed_total, duration_seconds - for _, actionType := range GetAllWalletActionTypes() { - if isReservationWalletActionType(actionType) && !pm.reservationsEnabled { - continue - } + actionTypes := GetAllWalletActionTypes() + if pm.reservationsEnabled { + actionTypes = append(actionTypes, GetReservationWalletActionTypes()...) + } + + for _, actionType := range actionTypes { actionCounters := []string{ WalletActionMetricName(actionType, "total"), @@ -764,8 +766,8 @@ func GetAllNetworkJoinFailureReasons() []string { } } -// GetAllWalletActionTypes returns all wallet action types that should be tracked. -// ActionNoop is excluded as it's a no-op action. +// GetAllWalletActionTypes returns all non-reservation wallet action types that +// should be tracked. ActionNoop is excluded as it's a no-op action. func GetAllWalletActionTypes() []string { return []string{ "heartbeat", @@ -773,6 +775,13 @@ func GetAllWalletActionTypes() []string { "redemption", "moving_funds", "moved_funds_sweep", + } +} + +// GetReservationWalletActionTypes returns all reservation-specific wallet +// action types that should be tracked. +func GetReservationWalletActionTypes() []string { + return []string{ "reservation_anchor", "reserved_redemption", "reservation_reanchor", From 3eda0588b81a3477a278f63a51b2b07258a407f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 1 Sep 2026 17:00:09 +0000 Subject: [PATCH 22/39] fix(spv): fix reservation watcher notifications, proof loop, and wiring; add coverage - Delete the ReservationParametersFull struct and the duplicated WalletProposalValidator declarations; fix wallet-members resolver callback wiring to dodge an import cycle. - Fix reservation reanchor loop error handling and add the reservation proof loop's driver (scan, match, submit) with its own test coverage (TestReservationProofScanStartBlock, TestFindReservationAcceptance/ ReanchorTransaction, TestProveReservationTransaction). - Add unit tests for resolveWalletPublicKeyHash (found/not-found/ chain-error) and isPendingStaleDepositResolved (still-reserved, released/swept, wallet-now-live) in reservation_wiring.go, plus a chain-error injection field for PastNewWalletRegisteredEvents on the localChain test double. - Add TestSubmitReservationAcceptanceProof covering the zero- required-confirmations error path. - Add config test coverage and a [Tbtc.Reservations] Enabled entry to all three sample config files. --- config/config_test.go | 4 + pkg/maintainer/spv/chain.go | 13 - pkg/maintainer/spv/chain_test.go | 44 ++- pkg/maintainer/spv/config.go | 7 +- .../spv/reservation_acceptance_proof.go | 170 ++------- .../spv/reservation_acceptance_proof_test.go | 206 +++++++++++ .../spv/reservation_action_timeout_watch.go | 42 ++- .../reservation_action_timeout_watch_test.go | 150 +++----- pkg/maintainer/spv/reservation_proof_loop.go | 342 +++++++++-------- .../spv/reservation_proof_loop_test.go | 347 ++++++++++++++++++ .../spv/reservation_reanchor_proof.go | 245 ++++++++----- .../spv/reservation_reanchor_proof_test.go | 19 +- .../spv/reservation_stale_deposit_watch.go | 134 ++++--- .../reservation_stale_deposit_watch_test.go | 285 +++++++++----- .../spv/reservation_stranding_watch.go | 62 +--- .../spv/reservation_stranding_watch_test.go | 118 ++---- pkg/maintainer/spv/reservation_wiring.go | 195 +++++++--- pkg/maintainer/spv/reservation_wiring_test.go | 144 ++++++++ test/config.json | 6 + test/config.toml | 3 + test/config.yaml | 4 + 21 files changed, 1639 insertions(+), 901 deletions(-) create mode 100644 pkg/maintainer/spv/reservation_acceptance_proof_test.go create mode 100644 pkg/maintainer/spv/reservation_proof_loop_test.go create mode 100644 pkg/maintainer/spv/reservation_wiring_test.go diff --git a/config/config_test.go b/config/config_test.go index c4026903bb..117c0dbe5f 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -237,6 +237,10 @@ func TestReadConfigFromFile(t *testing.T) { readValueFunc: func(c *Config) interface{} { return c.Maintainer.Spv.Reservations.Enabled }, expectedValue: true, }, + "Tbtc.Reservations.Enabled": { + readValueFunc: func(c *Config) interface{} { return c.Tbtc.Reservations.Enabled }, + expectedValue: true, + }, } for _, filePath := range filePaths { diff --git a/pkg/maintainer/spv/chain.go b/pkg/maintainer/spv/chain.go index b30e2133e0..41dd8fc259 100644 --- a/pkg/maintainer/spv/chain.go +++ b/pkg/maintainer/spv/chain.go @@ -138,19 +138,6 @@ type Chain interface { // currently custodied by the given wallet. WalletReservations(walletPublicKeyHash [20]byte) ([]*big.Int, error) - // Reservations returns the on-chain reservation request record for the - // given reservation key. Mirrors the ReservationRouter.reservations - // view verbatim. - Reservations(reservationKey *big.Int) (*tbtc.ReservationRequest, error) - - // ReservationActions returns the on-chain reservation action record - // for the given reservation key and request nonce. Mirrors the - // ReservationRouter.reservationActions view verbatim. - ReservationActions( - reservationKey *big.Int, - requestNonce uint64, - ) (*tbtc.ReservationActionRecord, error) - // IsReservedDeposit returns true if the given deposit was revealed // with the reservation vault address and is therefore a reservation // rather than a default deposit. diff --git a/pkg/maintainer/spv/chain_test.go b/pkg/maintainer/spv/chain_test.go index b1c109ee35..ba44e6a30b 100644 --- a/pkg/maintainer/spv/chain_test.go +++ b/pkg/maintainer/spv/chain_test.go @@ -105,9 +105,13 @@ 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. - walletReservationsErr error - isReservedDepositErr error - reservedDepositWalletErr error + walletReservationsErr error + isReservedDepositErr error + reservedDepositWalletErr error + notifyReservationActionTimeoutErr error + notifyStaleReservedDepositErr error + pastNewWalletRegisteredEventsErr error + notifyReservationStrandedErrByKey map[string]error // Wallet registration and pending-action-request event state for the // watcher dispatch and reservation proof loop tests. @@ -840,7 +844,7 @@ func (lc *localChain) NotifyReservationActionTimeout( }, ) - return nil + return lc.notifyReservationActionTimeoutErr } // getSubmittedReservationActionTimeouts returns the recorded action-timeout @@ -866,7 +870,7 @@ func (lc *localChain) NotifyStaleReservedDeposit(depositKey *big.Int) error { depositKey, ) - return nil + return lc.notifyStaleReservedDepositErr } // getSubmittedStaleReservedDeposits returns the recorded stale-deposit @@ -887,6 +891,10 @@ func (lc *localChain) NotifyReservationStranded(reservationKey *big.Int) error { lc.mutex.Lock() defer lc.mutex.Unlock() + if err, ok := lc.notifyReservationStrandedErrByKey[reservationKey.String()]; ok { + return err + } + lc.submittedStrandedKeys = append( lc.submittedStrandedKeys, reservationKey, @@ -1048,21 +1056,6 @@ func (lc *localChain) setWalletReservations( // Reservations is a stub matching the reservation additions on the // production Chain interface. The reservation-side builder replaces this // stub with the production contract call; the watchers do not need it. -func (lc *localChain) Reservations( - reservationKey *big.Int, -) (*tbtc.ReservationRequest, error) { - panic("unsupported") -} - -// ReservationActions is a stub matching the reservation additions on the -// production Chain interface. The watchers use GetReservationAction -// instead; this stub exists only to satisfy the interface. -func (lc *localChain) ReservationActions( - reservationKey *big.Int, - requestNonce uint64, -) (*tbtc.ReservationActionRecord, error) { - panic("unsupported") -} // IsReservedDeposit returns whether the deposit was previously booked via // setReservedDeposit. @@ -1224,6 +1217,10 @@ func (lc *localChain) PastNewWalletRegisteredEvents( lc.mutex.Lock() defer lc.mutex.Unlock() + if lc.pastNewWalletRegisteredEventsErr != nil { + return nil, lc.pastNewWalletRegisteredEventsErr + } + var result []*tbtc.NewWalletRegisteredEvent for _, event := range lc.newWalletRegisteredEvents { if filter != nil && event.BlockNumber < filter.StartBlock { @@ -1256,6 +1253,13 @@ func (lc *localChain) addNewWalletRegisteredEvent( lc.newWalletRegisteredEvents = append(lc.newWalletRegisteredEvents, event) } +func (lc *localChain) setPastNewWalletRegisteredEventsErr(err error) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.pastNewWalletRegisteredEventsErr = err +} + // BuildDepositKey is a test-double implementation independent of the // production keccak256-based algorithm (pkg/chain/ethereum's unexported // buildDepositKey): only self-consistency within this fake chain matters diff --git a/pkg/maintainer/spv/config.go b/pkg/maintainer/spv/config.go index e8bd3598f7..1c3cd4dd11 100644 --- a/pkg/maintainer/spv/config.go +++ b/pkg/maintainer/spv/config.go @@ -66,10 +66,9 @@ type Config struct { // more transaction proofs to submit. IdleBackoffTime time.Duration - // Reservations gates the m1 reservation feature within the SPV maintainer: - // reservation acceptance / re-anchor proof tasks and the stranding / - // stale-deposit / action-timeout watchers. When disabled the SPV maintainer - // constructs without any reservation plumbing. + // Reservations controls the SPV proof submission for reservation + // acceptance / re-anchor action generations. The reservation watchers are + // gated by the separate Tbtc.Reservations.Enabled flag. Reservations ReservationsConfig } diff --git a/pkg/maintainer/spv/reservation_acceptance_proof.go b/pkg/maintainer/spv/reservation_acceptance_proof.go index edb219801a..e2da7e1dcf 100644 --- a/pkg/maintainer/spv/reservation_acceptance_proof.go +++ b/pkg/maintainer/spv/reservation_acceptance_proof.go @@ -59,169 +59,51 @@ func submitReservationAcceptanceProof( IncrementCounter(name string, value float64) }, ) error { - if metricsRecorder != nil { - metricsRecorder.IncrementCounter( - "reservation_acceptance_proof_submissions_total", - 1, - ) - } - - if requiredConfirmations == 0 { - if metricsRecorder != nil { - metricsRecorder.IncrementCounter( - "reservation_acceptance_proof_submissions_failed_total", - 1, - ) - } - return fmt.Errorf( - "provided required confirmations count must be greater than 0", - ) - } - if reservationKey == nil { - return fmt.Errorf("reservation key is required") - } - if requestNonce == 0 { - return fmt.Errorf("request nonce must be > 0") - } - - transaction, proof, err := spvProofAssembler( + return submitReservationActionProof( transactionHash, requiredConfirmations, - btcChain, - ) - if err != nil { - if metricsRecorder != nil { - metricsRecorder.IncrementCounter( - "reservation_acceptance_proof_submissions_failed_total", - 1, - ) - } - return fmt.Errorf( - "failed to assemble transaction spv proof: [%v]", - err, - ) - } - - depositUtxo, err := parseReservationAcceptanceTransactionInput( - btcChain, - transaction, - ) - if err != nil { - if metricsRecorder != nil { - metricsRecorder.IncrementCounter( - "reservation_acceptance_proof_submissions_failed_total", - 1, - ) - } - return fmt.Errorf( - "error while parsing reservation acceptance transaction "+ - "inputs: [%v]", - err, - ) - } - - action, err := spvChain.GetReservationAction(reservationKey, requestNonce) - if err != nil { - return fmt.Errorf( - "cannot fetch reservation action generation: [%v]", - err, - ) - } - - if action.ActionType != tbtc.ReservationActionTypeAcceptance { - return fmt.Errorf( - "reservation action generation is not an acceptance (got %v)", - action.ActionType, - ) - } - - if action.State != tbtc.ReservationActionStatePending { - return fmt.Errorf( - "reservation acceptance action generation is not pending "+ - "(state=%v)", - action.State, - ) - } - - txInfo := buildReservationProofTxInfo(transaction) - txProof := buildReservationProofTxProof(proof) - mainUtxo := buildReservationProofMainUtxo(depositUtxo) - - if err := spvChain.SubmitReservationProof( - ProofTypeReservationAcceptance, - txInfo, - txProof, - mainUtxo, reservationKey, requestNonce, - ); err != nil { - if metricsRecorder != nil { - metricsRecorder.IncrementCounter( - "reservation_acceptance_proof_submissions_failed_total", - 1, - ) - } - return fmt.Errorf( - "failed to submit reservation acceptance proof: [%v]", - err, - ) - } - - if metricsRecorder != nil { - metricsRecorder.IncrementCounter( - "reservation_acceptance_proof_submissions_success_total", - 1, - ) - } - - return nil + btcChain, + spvChain, + spvProofAssembler, + metricsRecorder, + ProofTypeReservationAcceptance, + "reservation_acceptance_proof", + tbtc.ReservationActionTypeAcceptance, + parseReservationAcceptanceTransactionInput, + ) } -// parseReservationAcceptanceTransactionInput parses the single input of a -// reservation acceptance (anchor) transaction and returns the deposit UTXO -// that was anchored. Mirrors parseReservationReanchorTransactionInput in -// reservation_reanchor_proof.go. +// parseReservationAcceptanceTransactionInput parses the single input and +// single output of a reservation acceptance (anchor) transaction and +// returns the deposit UTXO that was anchored and the wallet's public key +// hash from the new anchor output script. Mirrors +// parseReservationReanchorTransactionInput in reservation_reanchor_proof.go. func parseReservationAcceptanceTransactionInput( btcChain bitcoin.Chain, transaction *bitcoin.Transaction, -) (*bitcoin.UnspentTransactionOutput, error) { - if len(transaction.Inputs) != 1 { - return nil, fmt.Errorf( - "reservation acceptance transaction must have exactly one input", - ) +) (*bitcoin.UnspentTransactionOutput, [20]byte, error) { + depositUtxo, err := spentOutputAsUtxo(btcChain, transaction) + if err != nil { + return nil, [20]byte{}, err } if len(transaction.Outputs) != 1 { - return nil, fmt.Errorf( + return nil, [20]byte{}, fmt.Errorf( "reservation acceptance transaction must have exactly one output", ) } - input := transaction.Inputs[0] - - inputTx, err := btcChain.GetTransaction(input.Outpoint.TransactionHash) + walletPublicKeyHash, err := bitcoin.ExtractPublicKeyHash( + transaction.Outputs[0].PublicKeyScript, + ) if err != nil { - return nil, fmt.Errorf( - "cannot get input transaction data: [%v]", + return nil, [20]byte{}, fmt.Errorf( + "cannot extract wallet public key hash: [%v]", err, ) } - if int(input.Outpoint.OutputIndex) >= len(inputTx.Outputs) { - return nil, fmt.Errorf( - "input outpoint index [%d] out of range for transaction [%d] "+ - "outputs", - input.Outpoint.OutputIndex, - len(inputTx.Outputs), - ) - } - - spentOutput := inputTx.Outputs[input.Outpoint.OutputIndex] - - depositUtxo := &bitcoin.UnspentTransactionOutput{ - Outpoint: input.Outpoint, - Value: spentOutput.Value, - } - - return depositUtxo, nil + return depositUtxo, walletPublicKeyHash, nil } diff --git a/pkg/maintainer/spv/reservation_acceptance_proof_test.go b/pkg/maintainer/spv/reservation_acceptance_proof_test.go new file mode 100644 index 0000000000..500c41cd93 --- /dev/null +++ b/pkg/maintainer/spv/reservation_acceptance_proof_test.go @@ -0,0 +1,206 @@ +package spv + +import ( + "bytes" + "fmt" + "math/big" + "testing" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/tbtc" +) + +// TestSubmitReservationAcceptanceProof verifies that +// submitReservationAcceptanceProof correctly parses a 1-input-1-output +// reservation acceptance (anchor) transaction, looks up the matching +// reservation action generation, and submits the SPV proof to the chain. +// It also covers the failure paths for missing action, mismatched action +// type, and wrong target wallet. +func TestSubmitReservationAcceptanceProof(t *testing.T) { + requiredConfirmations := uint(6) + + btcChain := newLocalBitcoinChain() + spvChain := newLocalChain() + + // Funding transaction that the anchor transaction spends (the reserved + // deposit's own UTXO). + fundingTx := &bitcoin.Transaction{ + Version: 1, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 600000, + PublicKeyScript: []byte{}, + }}, + } + if err := btcChain.BroadcastTransaction(fundingTx); err != nil { + t.Fatal(err) + } + fundingTxHash := fundingTx.Hash() + + // Anchor transaction: 1 input spending the deposit's funding UTXO, 1 + // output paying the accepting wallet. + walletPKH := [20]byte{0x92, 0xa6, 0xec, 0x88, 0x9a, 0x8f, 0xa3, 0x4f, 0x73, 0x1e, 0x63, 0x9e, 0xde, 0xde, 0x4c, 0x75, 0xe1, 0x84, 0x30, 0x7c} + walletScript, err := bitcoin.PayToWitnessPublicKeyHash(walletPKH) + if err != nil { + t.Fatal(err) + } + + anchorTx := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTxHash, + OutputIndex: 0, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 590000, + PublicKeyScript: walletScript, + }}, + } + if err := btcChain.BroadcastTransaction(anchorTx); 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 == anchorTx.Hash() && confirmations == requiredConfirmations { + return anchorTx, proof, nil + } + return nil, nil, fmt.Errorf("unexpected proof assembly request") + } + + reservationKey := big.NewInt(43) + requestNonce := uint64(1) + + spvChain.setReservationAction(reservationKey, requestNonce, &tbtc.ReservationAction{ + ActionType: tbtc.ReservationActionTypeAcceptance, + State: tbtc.ReservationActionStatePending, + TargetWalletPublicKeyHash: walletPKH, + }) + + spvChain.submitReservationProofHook = func( + proofType uint8, + txInfo *tbtc.BitcoinTxInfo, + txProof *tbtc.BitcoinTxProof, + mainUtxo *tbtc.BitcoinTxUTXO, + rk *big.Int, + rn uint64, + ) error { + if proofType != ProofTypeReservationAcceptance { + t.Errorf("unexpected proof type: got %d, want %d", proofType, ProofTypeReservationAcceptance) + } + if rk == nil || rk.Cmp(reservationKey) != 0 { + t.Errorf("unexpected reservation key: got %v, want %v", rk, reservationKey) + } + if rn != requestNonce { + t.Errorf("unexpected request nonce: got %d, want %d", rn, requestNonce) + } + if mainUtxo == nil { + t.Fatal("mainUtxo must not be nil") + } + if mainUtxo.TxOutputValue != 600000 { + t.Errorf("unexpected UTXO value: got %d, want %d", mainUtxo.TxOutputValue, 600000) + } + if txInfo == nil { + t.Fatal("txInfo must not be nil") + } + if !bytes.Equal(txProof.MerkleProof, proof.MerkleProof) { + t.Errorf("unexpected merkle proof") + } + return nil + } + + if err := submitReservationAcceptanceProof( + anchorTx.Hash(), + requiredConfirmations, + reservationKey, + requestNonce, + btcChain, + spvChain, + mockSpvProofAssembler, + nil, + ); err != nil { + t.Fatal(err) + } + + // Negative path: action generation is not Pending. + spvChain.setReservationAction(reservationKey, requestNonce, &tbtc.ReservationAction{ + ActionType: tbtc.ReservationActionTypeAcceptance, + State: tbtc.ReservationActionStateSettled, + TargetWalletPublicKeyHash: walletPKH, + }) + if err := submitReservationAcceptanceProof( + anchorTx.Hash(), + requiredConfirmations, + reservationKey, + requestNonce, + btcChain, + spvChain, + mockSpvProofAssembler, + nil, + ); err == nil { + t.Fatal("expected error for settled action generation") + } + + // Negative path: action generation is the wrong type. + spvChain.setReservationAction(reservationKey, requestNonce, &tbtc.ReservationAction{ + ActionType: tbtc.ReservationActionTypeReanchor, + State: tbtc.ReservationActionStatePending, + TargetWalletPublicKeyHash: walletPKH, + }) + if err := submitReservationAcceptanceProof( + anchorTx.Hash(), + requiredConfirmations, + reservationKey, + requestNonce, + btcChain, + spvChain, + mockSpvProofAssembler, + nil, + ); err == nil { + t.Fatal("expected error for wrong action type") + } + + // Negative path: target wallet public key hash mismatch - the anchor + // output pays a different wallet than the action authorized. + spvChain.setReservationAction(reservationKey, requestNonce, &tbtc.ReservationAction{ + ActionType: tbtc.ReservationActionTypeAcceptance, + State: tbtc.ReservationActionStatePending, + TargetWalletPublicKeyHash: [20]byte{0xff}, + }) + if err := submitReservationAcceptanceProof( + anchorTx.Hash(), + requiredConfirmations, + reservationKey, + requestNonce, + btcChain, + spvChain, + mockSpvProofAssembler, + nil, + ); err == nil { + t.Fatal("expected error for target wallet public key hash mismatch") + } + + // Negative path: zero requiredConfirmations. + if err := submitReservationAcceptanceProof( + anchorTx.Hash(), + 0, + reservationKey, + requestNonce, + btcChain, + spvChain, + mockSpvProofAssembler, + nil, + ); err == nil { + t.Fatal("expected error for zero required confirmations") + } +} diff --git a/pkg/maintainer/spv/reservation_action_timeout_watch.go b/pkg/maintainer/spv/reservation_action_timeout_watch.go index dedb9340b8..85718a7efa 100644 --- a/pkg/maintainer/spv/reservation_action_timeout_watch.go +++ b/pkg/maintainer/spv/reservation_action_timeout_watch.go @@ -28,7 +28,6 @@ import ( // logic without changing the call shape. type ReservationActionTimeoutWatcher struct { spvChain Chain - notifier ReservationActionTimeoutNotifier // nowFn returns the current UNIX timestamp the watcher treats as "now" // for `now > timeoutAt` comparisons. Tests override it to drive the // deadline forward; production wires it to time.Now in UTC. @@ -51,6 +50,10 @@ type ReservationActionTimeoutWatcher struct { // CheckReservationActionTimeouts (the synchronous, test/integration // entry point) never touches it. lastWalletScanBlock uint64 + // knownWallets tracks the full history of registered wallets so that + // every action timeout check iterates the entire set of wallets ever + // discovered, not just those registered in the most recent poll interval. + knownWallets map[[20]byte]struct{} } // WalletMembersResolver maps a wallet public key hash to the operator IDs @@ -115,16 +118,15 @@ func (f ReservationActionTimeoutNotifierFunc) NotifyReservationActionTimeout( // be driven by CheckReservationActionTimeouts calls from the integration. func NewReservationActionTimeoutWatcher( spvChain Chain, - notifier ReservationActionTimeoutNotifier, membersResolver WalletMembersResolver, pollInterval time.Duration, ) *ReservationActionTimeoutWatcher { return &ReservationActionTimeoutWatcher{ spvChain: spvChain, - notifier: notifier, nowFn: defaultActionTimeoutNowFn, interval: pollInterval, membersResolver: membersResolver, + knownWallets: make(map[[20]byte]struct{}), } } @@ -150,13 +152,8 @@ const reservationActionTimeoutWalletScanLookBackBlocks = uint64(216000) // CheckReservationActionTimeouts for each one. The loop is best-effort for // per-reservation failures: a single reservation's error is logged and the // walk continues with the next one; only a startup configuration error -// (nil notifier/resolver, non-positive interval) aborts the loop. +// (nil resolver, non-positive interval) aborts 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", - ) - } if ratw.membersResolver == nil { return fmt.Errorf( "action-timeout watcher requires a non-nil members resolver", @@ -245,7 +242,15 @@ func (ratw *ReservationActionTimeoutWatcher) discoverWallets() ([][20]byte, erro } events, err := ratw.spvChain.PastNewWalletRegisteredEvents( - &tbtc.NewWalletRegisteredEventFilter{StartBlock: startBlock}, + &tbtc.NewWalletRegisteredEventFilter{ + StartBlock: func() uint64 { + if ratw.lastWalletScanBlock != 0 { + return ratw.lastWalletScanBlock + 1 + } + return startBlock + }(), + EndBlock: ¤tBlock, + }, ) if err != nil { return nil, fmt.Errorf( @@ -256,9 +261,13 @@ func (ratw *ReservationActionTimeoutWatcher) discoverWallets() ([][20]byte, erro ratw.lastWalletScanBlock = currentBlock - wallets := make([][20]byte, len(events)) - for i, event := range events { - wallets[i] = event.WalletPublicKeyHash + for _, event := range events { + ratw.knownWallets[event.WalletPublicKeyHash] = struct{}{} + } + + wallets := make([][20]byte, 0, len(ratw.knownWallets)) + for wallet := range ratw.knownWallets { + wallets = append(wallets, wallet) } return wallets, nil @@ -288,11 +297,6 @@ func (ratw *ReservationActionTimeoutWatcher) CheckReservationActionTimeouts( reservationKey *big.Int, now uint32, ) error { - if ratw.notifier == nil { - return fmt.Errorf( - "action-timeout watcher requires a non-nil notifier", - ) - } if ratw.membersResolver == nil { return fmt.Errorf( "action-timeout watcher requires a non-nil members resolver", @@ -392,7 +396,7 @@ func (ratw *ReservationActionTimeoutWatcher) CheckReservationActionTimeouts( return nil } - if err := ratw.notifier.NotifyReservationActionTimeout( + if err := ratw.spvChain.NotifyReservationActionTimeout( reservationKey, memberIDs, ); err != nil { diff --git a/pkg/maintainer/spv/reservation_action_timeout_watch_test.go b/pkg/maintainer/spv/reservation_action_timeout_watch_test.go index 736976ea67..5775e19a82 100644 --- a/pkg/maintainer/spv/reservation_action_timeout_watch_test.go +++ b/pkg/maintainer/spv/reservation_action_timeout_watch_test.go @@ -29,24 +29,6 @@ func (r *recordingActionTimeoutMembers) ResolveWalletMembers( return r.walletIDs[walletPublicKeyHash], nil } -// recordingActionTimeoutNotifier captures every -// NotifyReservationActionTimeout call for assertion in tests. -type recordingActionTimeoutNotifier struct { - calls []*submittedReservationActionTimeout - err error -} - -func (r *recordingActionTimeoutNotifier) NotifyReservationActionTimeout( - reservationKey *big.Int, - walletMembersIDs []uint32, -) error { - r.calls = append(r.calls, &submittedReservationActionTimeout{ - reservationKey: reservationKey, - walletMembersIDs: walletMembersIDs, - }) - return r.err -} - // seededReservation installs a reservation and (optionally) a list of // action generations under spvChain for use in the action-timeout watcher // tests. Helper reduces per-test noise. actions[0] is stored as generation @@ -73,7 +55,6 @@ func seededReservation( func TestReservationActionTimeoutWatcher_NotifiesTimedOutPendingAction(t *testing.T) { spvChain := newLocalChain() - notifier := &recordingActionTimeoutNotifier{} wallet := walletPKH() key := reservationKey(0xC001) @@ -96,18 +77,19 @@ func TestReservationActionTimeoutWatcher_NotifiesTimedOutPendingAction(t *testin 1, ) - watcher := NewReservationActionTimeoutWatcher(spvChain, notifier, resolver, 0) + watcher := NewReservationActionTimeoutWatcher(spvChain, resolver, 0) if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { t.Fatalf("unexpected error: %v", err) } - if len(notifier.calls) != 1 { - t.Fatalf("expected one timeout notification, got %d", len(notifier.calls)) + calls := spvChain.getSubmittedReservationActionTimeouts() + if len(calls) != 1 { + t.Fatalf("expected one timeout notification, got %d", len(calls)) } - if diff := deep.Equal(key, notifier.calls[0].reservationKey); diff != nil { + if diff := deep.Equal(key, calls[0].reservationKey); diff != nil { t.Errorf("unexpected notified key: %v", diff) } - if diff := deep.Equal(members, notifier.calls[0].walletMembersIDs); diff != nil { + if diff := deep.Equal(members, calls[0].walletMembersIDs); diff != nil { t.Errorf("unexpected notified members: %v", diff) } // The resolver must be consulted exactly once per Check call, not per @@ -120,7 +102,6 @@ func TestReservationActionTimeoutWatcher_NotifiesTimedOutPendingAction(t *testin func TestReservationActionTimeoutWatcher_DoesNotNotifyBeforeTimeout(t *testing.T) { spvChain := newLocalChain() - notifier := &recordingActionTimeoutNotifier{} wallet := walletPKH() key := reservationKey(0xC002) @@ -142,22 +123,21 @@ func TestReservationActionTimeoutWatcher_DoesNotNotifyBeforeTimeout(t *testing.T 1, ) - watcher := NewReservationActionTimeoutWatcher(spvChain, notifier, resolver, 0) + watcher := NewReservationActionTimeoutWatcher(spvChain, resolver, 0) if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { t.Fatalf("unexpected error: %v", err) } - if len(notifier.calls) != 0 { + if calls := spvChain.getSubmittedReservationActionTimeouts(); len(calls) != 0 { t.Fatalf( "action not yet timed out; expected zero notifications, got %d", - len(notifier.calls), + len(calls), ) } } func TestReservationActionTimeoutWatcher_IgnoresSettledOlderGeneration(t *testing.T) { spvChain := newLocalChain() - notifier := &recordingActionTimeoutNotifier{} wallet := walletPKH() key := reservationKey(0xC003) @@ -189,23 +169,22 @@ func TestReservationActionTimeoutWatcher_IgnoresSettledOlderGeneration(t *testin 2, ) - watcher := NewReservationActionTimeoutWatcher(spvChain, notifier, resolver, 0) + watcher := NewReservationActionTimeoutWatcher(spvChain, resolver, 0) if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { t.Fatalf("unexpected error: %v", err) } - if len(notifier.calls) != 1 { + if calls := spvChain.getSubmittedReservationActionTimeouts(); len(calls) != 1 { t.Fatalf( "current generation is pending and past deadline; expected one "+ "notification, got %d", - len(notifier.calls), + len(calls), ) } } func TestReservationActionTimeoutWatcher_NotifiesCurrentGenerationOnly(t *testing.T) { spvChain := newLocalChain() - notifier := &recordingActionTimeoutNotifier{} wallet := walletPKH() key := reservationKey(0xC004) @@ -235,22 +214,21 @@ func TestReservationActionTimeoutWatcher_NotifiesCurrentGenerationOnly(t *testin 2, ) - watcher := NewReservationActionTimeoutWatcher(spvChain, notifier, resolver, 0) + watcher := NewReservationActionTimeoutWatcher(spvChain, resolver, 0) if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { t.Fatalf("unexpected error: %v", err) } - if len(notifier.calls) != 1 { + if calls := spvChain.getSubmittedReservationActionTimeouts(); len(calls) != 1 { t.Fatalf( "expected exactly one notification for the current generation, got %d", - len(notifier.calls), + len(calls), ) } } func TestReservationActionTimeoutWatcher_SkipsReservationWithoutWallet(t *testing.T) { spvChain := newLocalChain() - notifier := &recordingActionTimeoutNotifier{} key := reservationKey(0xC005) // No wallet PKH assigned. @@ -261,13 +239,13 @@ func TestReservationActionTimeoutWatcher_SkipsReservationWithoutWallet(t *testin RequestNonce: 0, }) - watcher := NewReservationActionTimeoutWatcher(spvChain, notifier, resolver, 0) + watcher := NewReservationActionTimeoutWatcher(spvChain, resolver, 0) if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { t.Fatalf("unexpected error: %v", err) } - if len(notifier.calls) != 0 { - t.Fatalf("zero-wallet reservation must skip, got %d notifications", len(notifier.calls)) + if calls := spvChain.getSubmittedReservationActionTimeouts(); len(calls) != 0 { + t.Fatalf("zero-wallet reservation must skip, got %d notifications", len(calls)) } if len(resolver.calls) != 0 { t.Fatalf("resolver must not be called for zero-wallet reservation, got %d calls", len(resolver.calls)) @@ -276,7 +254,6 @@ func TestReservationActionTimeoutWatcher_SkipsReservationWithoutWallet(t *testin func TestReservationActionTimeoutWatcher_MembersResolverError(t *testing.T) { spvChain := newLocalChain() - notifier := &recordingActionTimeoutNotifier{} wallet := walletPKH() key := reservationKey(0xC006) @@ -298,30 +275,19 @@ func TestReservationActionTimeoutWatcher_MembersResolverError(t *testing.T) { 1, ) - watcher := NewReservationActionTimeoutWatcher(spvChain, notifier, resolver, 0) + watcher := NewReservationActionTimeoutWatcher(spvChain, resolver, 0) if err := watcher.CheckReservationActionTimeouts(key, 5_000); err == nil { t.Fatal("expected error from resolver, got nil") } - if len(notifier.calls) != 0 { - t.Fatalf("no notifications should fire on resolver error, got %d", len(notifier.calls)) - } -} - -func TestReservationActionTimeoutWatcher_NilNotifierError(t *testing.T) { - spvChain := newLocalChain() - resolver := &recordingActionTimeoutMembers{} - - watcher := NewReservationActionTimeoutWatcher(spvChain, nil, resolver, 0) - if err := watcher.CheckReservationActionTimeouts(reservationKey(0xC007), 5_000); err == nil { - t.Fatal("expected error for nil notifier, got nil") + if calls := spvChain.getSubmittedReservationActionTimeouts(); len(calls) != 0 { + t.Fatalf("no notifications should fire on resolver error, got %d", len(calls)) } } func TestReservationActionTimeoutWatcher_NilResolverError(t *testing.T) { spvChain := newLocalChain() - notifier := &recordingActionTimeoutNotifier{} - watcher := NewReservationActionTimeoutWatcher(spvChain, notifier, nil, 0) + watcher := NewReservationActionTimeoutWatcher(spvChain, nil, 0) if err := watcher.CheckReservationActionTimeouts(reservationKey(0xC008), 5_000); err == nil { t.Fatal("expected error for nil resolver, got nil") } @@ -329,62 +295,46 @@ func TestReservationActionTimeoutWatcher_NilResolverError(t *testing.T) { func TestReservationActionTimeoutWatcher_NilKeyError(t *testing.T) { spvChain := newLocalChain() - notifier := &recordingActionTimeoutNotifier{} resolver := &recordingActionTimeoutMembers{} - watcher := NewReservationActionTimeoutWatcher(spvChain, notifier, resolver, 0) + watcher := NewReservationActionTimeoutWatcher(spvChain, resolver, 0) if err := watcher.CheckReservationActionTimeouts(nil, 5_000); err == nil { t.Fatal("expected error for nil reservation key, got nil") } } -func TestReservationActionTimeoutWatcher_NotifierFuncAdapter(t *testing.T) { - var captured []*submittedReservationActionTimeout - notifier := ReservationActionTimeoutNotifierFunc(func( - reservationKey *big.Int, - walletMembersIDs []uint32, - ) error { - captured = append(captured, &submittedReservationActionTimeout{ - reservationKey: reservationKey, - walletMembersIDs: walletMembersIDs, - }) - return nil - }) - +func TestReservationActionTimeoutWatcher_SkipsWalletZeroBranch(t *testing.T) { + // Isolates the wallet-zero skip branch from the RequestNonce == 0 skip + // branch: RequestNonce is nonzero (a real action generation exists) but + // WalletPublicKeyHash is zero, so the reservation exists yet has no + // wallet assigned. This must skip via the wallet-zero check, not be + // short-circuited by the (separate) RequestNonce == 0 check that an + // earlier version of this test file conflated by zeroing both fields + // together. spvChain := newLocalChain() + resolver := &recordingActionTimeoutMembers{} - wallet := walletPKH() - key := reservationKey(0xC009) - - resolver := &recordingActionTimeoutMembers{ - walletIDs: map[[20]byte][]uint32{wallet: {42}}, - } - seededReservation( - t, - spvChain, - key, - wallet, - []*tbtc.ReservationAction{ - { - State: tbtc.ReservationActionStatePending, - TimeoutAt: 100, - }, - }, - 1, - ) + key := reservationKey(0xC00B) + spvChain.setReservation(key, &tbtc.Reservation{ + WalletPublicKeyHash: [20]byte{}, + RequestNonce: 1, + }) - watcher := NewReservationActionTimeoutWatcher(spvChain, notifier, resolver, 0) + watcher := NewReservationActionTimeoutWatcher(spvChain, resolver, 0) if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { t.Fatalf("unexpected error: %v", err) } - if len(captured) != 1 { - t.Fatalf("expected one captured notification, got %d", len(captured)) + + if calls := spvChain.getSubmittedReservationActionTimeouts(); len(calls) != 0 { + t.Fatalf("zero-wallet reservation must skip, got %d notifications", len(calls)) + } + if len(resolver.calls) != 0 { + t.Fatalf("resolver must not be called for zero-wallet reservation, got %d calls", len(resolver.calls)) } } func TestReservationActionTimeoutWatcher_NotifierErrorPropagates(t *testing.T) { spvChain := newLocalChain() - notifier := &recordingActionTimeoutNotifier{} errFromNotifier := errors.New("downstream") wallet := walletPKH() @@ -394,12 +344,12 @@ func TestReservationActionTimeoutWatcher_NotifierErrorPropagates(t *testing.T) { walletIDs: map[[20]byte][]uint32{wallet: {1, 2, 3}}, } // The current generation is pending and past its deadline, but the - // notifier fails. With only one generation ever inspected per Check - // call, the failure must surface as an error from + // Bridge notify call fails. With only one generation ever inspected per + // Check call, the failure must surface as an error from // CheckReservationActionTimeouts (not be silently swallowed), so a // poll-loop caller logs and retries on the next tick instead of // wrongly treating it as settled. - notifier.err = errFromNotifier + spvChain.notifyReservationActionTimeoutErr = errFromNotifier seededReservation( t, spvChain, @@ -414,13 +364,13 @@ func TestReservationActionTimeoutWatcher_NotifierErrorPropagates(t *testing.T) { 1, ) - watcher := NewReservationActionTimeoutWatcher(spvChain, notifier, resolver, 0) + watcher := NewReservationActionTimeoutWatcher(spvChain, resolver, 0) err := watcher.CheckReservationActionTimeouts(key, 5_000) if err == nil { t.Fatal("expected the notifier error to propagate, got nil") } - if len(notifier.calls) != 1 { - t.Fatalf("expected exactly one notification attempt, got %d", len(notifier.calls)) + if calls := spvChain.getSubmittedReservationActionTimeouts(); len(calls) != 1 { + t.Fatalf("expected exactly one notification attempt, got %d", len(calls)) } } diff --git a/pkg/maintainer/spv/reservation_proof_loop.go b/pkg/maintainer/spv/reservation_proof_loop.go index e9a58ddd8c..5bfefe1c4a 100644 --- a/pkg/maintainer/spv/reservation_proof_loop.go +++ b/pkg/maintainer/spv/reservation_proof_loop.go @@ -15,6 +15,28 @@ import ( // ReservationReanchorLookBackBlocks in pkg/tbtcpg: 30 days at 12s/block. const reservationProofLookBackBlocks = uint64(216000) +// uniqueReservationWalletPublicKeyHashes deduplicates a list of reservation +// proof-loop events by wallet public key hash, extracted via +// walletPublicKeyHashOf since the acceptance and re-anchor event types name +// their wallet field differently (WalletPublicKeyHash vs +// SourceWalletPublicKeyHash) and therefore cannot share the walletEvent +// interface used by uniqueWalletPublicKeyHashes in spv.go. +func uniqueReservationWalletPublicKeyHashes[T any]( + items []T, + walletPublicKeyHashOf func(T) [20]byte, +) [][20]byte { + seen := make(map[[20]byte]struct{}) + var result [][20]byte + for _, item := range items { + pkh := walletPublicKeyHashOf(item) + if _, ok := seen[pkh]; !ok { + seen[pkh] = struct{}{} + result = append(result, pkh) + } + } + return result +} + // maintainReservationProofs runs the SPV proof submission loop for // reservation acceptance and re-anchor action generations. It is a // dedicated loop, separate from spvMaintainer's generic proofTypes-driven @@ -133,70 +155,90 @@ func proveReservationAcceptanceActions( ) } - for _, event := range events { - action, err := spvChain.GetReservationAction( - event.ReservationKey, - event.RequestNonce, - ) - if err != nil { - logger.Errorf( - "failed to load reservation acceptance action [%v]/%d: [%v]", - event.ReservationKey, - event.RequestNonce, - err, - ) - continue - } - if action.State != tbtc.ReservationActionStatePending { - // Already proven (Settled), or no longer provable - // (TimedOut/Superseded/Vetoed). - continue - } + // There will often be multiple events emitted for a single wallet. Prepare + // a list of unique wallet public key hashes. + walletPublicKeyHashes := uniqueReservationWalletPublicKeyHashes( + events, + func(e *tbtc.ReservationAcceptanceRequestedEvent) [20]byte { + return e.WalletPublicKeyHash + }, + ) - transaction, err := findReservationAcceptanceTransaction( - spvChain, - btcChain, - event, + for _, walletPublicKeyHash := range walletPublicKeyHashes { + walletTransactions, err := btcChain.GetTransactionsForPublicKeyHash( + walletPublicKeyHash, config.TransactionLimit, ) if err != nil { - logger.Errorf( - "failed to search for reservation acceptance transaction "+ - "for reservation [%v]: [%v]", - event.ReservationKey, - err, - ) - continue - } - if transaction == nil { - // The wallet has not broadcast the anchor transaction yet. + logger.Errorf("failed to get transactions for wallet: [%v]", err) continue } - if err := proveReservationTransaction( - transaction, - btcChain, - spvChain, - btcDiffChain, - func(transactionHash bitcoin.Hash, requiredConfirmations uint) error { - return SubmitReservationAcceptanceProof( - transactionHash, - requiredConfirmations, + for _, event := range events { + if event.WalletPublicKeyHash != walletPublicKeyHash { + continue + } + + action, err := spvChain.GetReservationAction( + event.ReservationKey, + event.RequestNonce, + ) + if err != nil { + logger.Errorf( + "failed to load reservation acceptance action [%v]/%d: [%v]", event.ReservationKey, event.RequestNonce, - btcChain, - spvChain, + err, ) - }, - ); err != nil { - logger.Errorf( - "failed to prove reservation acceptance transaction [%s] "+ - "for reservation [%v]: [%v]", - transaction.Hash().Hex(bitcoin.ReversedByteOrder), - event.ReservationKey, - err, + continue + } + if action.State != tbtc.ReservationActionStatePending { + continue + } + + transaction, err := findReservationAcceptanceTransaction( + spvChain, + event, + walletTransactions, ) - continue + if err != nil { + logger.Errorf( + "failed to search for reservation acceptance transaction "+ + "for reservation [%v]: [%v]", + event.ReservationKey, + err, + ) + continue + } + if transaction == nil { + continue + } + + if err := proveReservationTransaction( + transaction, + btcChain, + spvChain, + btcDiffChain, + func(transactionHash bitcoin.Hash, requiredConfirmations uint) error { + return SubmitReservationAcceptanceProof( + transactionHash, + requiredConfirmations, + event.ReservationKey, + event.RequestNonce, + btcChain, + spvChain, + ) + }, + ); err != nil { + logger.Errorf( + "failed to prove reservation acceptance transaction [%s] "+ + "for reservation [%v]: [%v]", + transaction.Hash().Hex(bitcoin.ReversedByteOrder), + event.ReservationKey, + err, + ) + continue + } } } @@ -211,21 +253,9 @@ func proveReservationAcceptanceActions( // matching transaction has been broadcast yet. func findReservationAcceptanceTransaction( spvChain Chain, - btcChain bitcoin.Chain, event *tbtc.ReservationAcceptanceRequestedEvent, - transactionLimit int, + walletTransactions []*bitcoin.Transaction, ) (*bitcoin.Transaction, error) { - walletTransactions, err := btcChain.GetTransactionsForPublicKeyHash( - event.WalletPublicKeyHash, - transactionLimit, - ) - if err != nil { - return nil, fmt.Errorf( - "failed to get transactions for wallet: [%v]", - err, - ) - } - for _, transaction := range walletTransactions { if len(transaction.Inputs) != 1 || len(transaction.Outputs) != 1 { continue @@ -270,84 +300,113 @@ func proveReservationReanchorActions( ) } - for _, event := range events { - action, err := spvChain.GetReservationAction( - event.ReservationKey, - event.RequestNonce, + // There will often be multiple events emitted for a single source + // wallet. Prepare a list of unique wallet public key hashes so the + // transaction history is fetched once per wallet instead of once per + // event, mirroring the acceptance loop above and the sibling + // getUnprovenDepositSweepTransactions convention. + walletPublicKeyHashes := uniqueReservationWalletPublicKeyHashes( + events, + func(e *tbtc.ReservationReanchorRequestedEvent) [20]byte { + return e.SourceWalletPublicKeyHash + }, + ) + + for _, walletPublicKeyHash := range walletPublicKeyHashes { + walletTransactions, err := btcChain.GetTransactionsForPublicKeyHash( + walletPublicKeyHash, + config.TransactionLimit, ) if err != nil { - logger.Errorf( - "failed to load reservation re-anchor action [%v]/%d: [%v]", - event.ReservationKey, - event.RequestNonce, - err, - ) - continue - } - if action.State != tbtc.ReservationActionStatePending { + logger.Errorf("failed to get transactions for wallet: [%v]", err) continue } - reservation, err := spvChain.GetReservation(event.ReservationKey) - if err != nil { - logger.Errorf( - "failed to load reservation [%v]: [%v]", - event.ReservationKey, - err, - ) - continue - } - if reservation.AnchorUtxo == nil || reservation.AnchorUtxo.Outpoint == nil { - logger.Errorf( - "reservation [%v] has no anchor UTXO to re-anchor from", - event.ReservationKey, - ) - continue - } + for _, event := range events { + if event.SourceWalletPublicKeyHash != walletPublicKeyHash { + continue + } - transaction, err := findReservationReanchorTransaction( - btcChain, - event, - reservation.AnchorUtxo, - config.TransactionLimit, - ) - if err != nil { - logger.Errorf( - "failed to search for reservation re-anchor transaction "+ - "for reservation [%v]: [%v]", + action, err := spvChain.GetReservationAction( event.ReservationKey, - err, + event.RequestNonce, ) - continue - } - if transaction == nil { - continue - } - - if err := proveReservationTransaction( - transaction, - btcChain, - spvChain, - btcDiffChain, - func(transactionHash bitcoin.Hash, requiredConfirmations uint) error { - return SubmitReservationReanchorProof( - transactionHash, - requiredConfirmations, + if err != nil { + logger.Errorf( + "failed to load reservation re-anchor action [%v]/%d: [%v]", event.ReservationKey, event.RequestNonce, - btcChain, - spvChain, + err, ) - }, - ); err != nil { - logger.Errorf( - "failed to prove reservation re-anchor transaction [%s] "+ - "for reservation [%v]: [%v]", - transaction.Hash().Hex(bitcoin.ReversedByteOrder), - event.ReservationKey, - err, + continue + } + if action.State != tbtc.ReservationActionStatePending { + continue + } + + reservation, err := spvChain.GetReservation(event.ReservationKey) + if err != nil { + logger.Errorf( + "failed to load reservation [%v]: [%v]", + event.ReservationKey, + err, + ) + continue + } + if reservation.AnchorUtxo == nil || + reservation.AnchorUtxo.Value == 0 || + reservation.AnchorUtxo.Outpoint == nil || + reservation.AnchorUtxo.Outpoint.TransactionHash == (bitcoin.Hash{}) { + logger.Errorf( + "reservation [%v] has no anchor UTXO to re-anchor from", + event.ReservationKey, + ) + continue + } + + transaction, err := findReservationReanchorTransaction( + event, + reservation.AnchorUtxo, + walletTransactions, ) - continue + if err != nil { + logger.Errorf( + "failed to search for reservation re-anchor transaction "+ + "for reservation [%v]: [%v]", + event.ReservationKey, + err, + ) + continue + } + if transaction == nil { + continue + } + + if err := proveReservationTransaction( + transaction, + btcChain, + spvChain, + btcDiffChain, + func(transactionHash bitcoin.Hash, requiredConfirmations uint) error { + return SubmitReservationReanchorProof( + transactionHash, + requiredConfirmations, + event.ReservationKey, + event.RequestNonce, + btcChain, + spvChain, + ) + }, + ); err != nil { + logger.Errorf( + "failed to prove reservation re-anchor transaction [%s] "+ + "for reservation [%v]: [%v]", + transaction.Hash().Hex(bitcoin.ReversedByteOrder), + event.ReservationKey, + err, + ) + continue + } } } @@ -359,22 +418,10 @@ func proveReservationReanchorActions( // sole input spends the reservation's current anchor UTXO. Returns nil, nil // if no matching transaction has been broadcast yet. func findReservationReanchorTransaction( - btcChain bitcoin.Chain, event *tbtc.ReservationReanchorRequestedEvent, anchorUtxo *bitcoin.UnspentTransactionOutput, - transactionLimit int, + walletTransactions []*bitcoin.Transaction, ) (*bitcoin.Transaction, error) { - walletTransactions, err := btcChain.GetTransactionsForPublicKeyHash( - event.SourceWalletPublicKeyHash, - transactionLimit, - ) - if err != nil { - return nil, fmt.Errorf( - "failed to get transactions for wallet: [%v]", - err, - ) - } - for _, transaction := range walletTransactions { if len(transaction.Inputs) != 1 || len(transaction.Outputs) != 1 { continue @@ -389,11 +436,6 @@ func findReservationReanchorTransaction( return nil, nil } - -// proveReservationTransaction checks the given transaction's confirmation -// and relay-range status via the shared getProofInfo helper (also used by -// the generic proof loop in spv.go) and, once ready, invokes submit with -// the transaction hash and required confirmations. func proveReservationTransaction( transaction *bitcoin.Transaction, btcChain bitcoin.Chain, diff --git a/pkg/maintainer/spv/reservation_proof_loop_test.go b/pkg/maintainer/spv/reservation_proof_loop_test.go new file mode 100644 index 0000000000..ebd5b77902 --- /dev/null +++ b/pkg/maintainer/spv/reservation_proof_loop_test.go @@ -0,0 +1,347 @@ +package spv + +import ( + "fmt" + "math/big" + "testing" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/tbtc" +) + +// TestReservationProofScanStartBlock covers the bounded look-back arithmetic: +// the very first scan (current block below the look-back window) starts at +// block 0, while a later scan is bounded to exactly +// reservationProofLookBackBlocks behind the current block. +func TestReservationProofScanStartBlock(t *testing.T) { + tests := map[string]struct { + currentBlock uint64 + expectedStart uint64 + }{ + "current block below the look-back window": { + currentBlock: 1000, + expectedStart: 0, + }, + "current block at the look-back window boundary": { + currentBlock: reservationProofLookBackBlocks, + expectedStart: 0, + }, + "current block beyond the look-back window": { + currentBlock: reservationProofLookBackBlocks + 500, + expectedStart: 500, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + spvChain := newLocalChain() + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(test.currentBlock) + spvChain.setBlockCounter(blockCounter) + + startBlock, err := reservationProofScanStartBlock(spvChain) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if startBlock != test.expectedStart { + t.Errorf( + "unexpected start block\nexpected: %v\nactual: %v", + test.expectedStart, + startBlock, + ) + } + }) + } +} + +// TestFindReservationAcceptanceTransaction verifies the acceptance +// transaction matcher: it must find the 1-input-1-output transaction whose +// sole input spends the deposit UTXO identified by event.ReservationKey (via +// BuildDepositKey), skip transactions with the wrong shape, and return nil +// when nothing matches. +func TestFindReservationAcceptanceTransaction(t *testing.T) { + spvChain := newLocalChain() + + fundingTxHash, err := bitcoin.NewHashFromString( + "585b6699f42291d1a9d0776b75f04c295ea203f83504349db11e94fdae7d1b2c", + bitcoin.InternalByteOrder, + ) + if err != nil { + t.Fatal(err) + } + reservationKey := spvChain.BuildDepositKey(fundingTxHash, 0) + + matchingTx := &bitcoin.Transaction{ + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTxHash, + OutputIndex: 0, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{Value: 100}}, + } + + // Wrong shape: two outputs, must be skipped even though it otherwise + // spends the right outpoint. + wrongShapeTx := &bitcoin.Transaction{ + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTxHash, + OutputIndex: 0, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{Value: 100}, {Value: 200}}, + } + + // Non-matching: correct shape, different outpoint. + otherTxHash, err := bitcoin.NewHashFromString( + "7cff663e3e08847a5579913f6a66bc6c01f5f48c6ae1783be77418ed188021e6", + bitcoin.InternalByteOrder, + ) + if err != nil { + t.Fatal(err) + } + nonMatchingTx := &bitcoin.Transaction{ + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: otherTxHash, + OutputIndex: 0, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{Value: 100}}, + } + + event := &tbtc.ReservationAcceptanceRequestedEvent{ + ReservationKey: reservationKey, + } + + t.Run("finds the matching transaction among candidates", func(t *testing.T) { + found, err := findReservationAcceptanceTransaction( + spvChain, + event, + []*bitcoin.Transaction{wrongShapeTx, nonMatchingTx, matchingTx}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if found != matchingTx { + t.Errorf("expected to find the matching transaction, got %v", found) + } + }) + + t.Run("returns nil when nothing matches", func(t *testing.T) { + found, err := findReservationAcceptanceTransaction( + spvChain, + event, + []*bitcoin.Transaction{wrongShapeTx, nonMatchingTx}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if found != nil { + t.Errorf("expected nil, got %v", found) + } + }) + + t.Run("returns nil for an empty candidate list", func(t *testing.T) { + found, err := findReservationAcceptanceTransaction( + spvChain, + event, + nil, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if found != nil { + t.Errorf("expected nil, got %v", found) + } + }) +} + +// TestFindReservationReanchorTransaction verifies the re-anchor transaction +// matcher: it must find the 1-input-1-output transaction whose sole input +// spends the reservation's current anchor UTXO outpoint exactly, skip +// wrong-shape transactions, and return nil when nothing matches. +func TestFindReservationReanchorTransaction(t *testing.T) { + anchorTxHash, err := bitcoin.NewHashFromString( + "2222222222222222222222222222222222222222222222222222222222222222", + bitcoin.InternalByteOrder, + ) + if err != nil { + t.Fatal(err) + } + anchorUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHash, + OutputIndex: 1, + }, + Value: 600000, + } + + matchingTx := &bitcoin.Transaction{ + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHash, + OutputIndex: 1, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{Value: 590000}}, + } + + // Same transaction hash, wrong output index: must not match. + wrongIndexTx := &bitcoin.Transaction{ + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHash, + OutputIndex: 0, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{Value: 590000}}, + } + + wrongShapeTx := &bitcoin.Transaction{ + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHash, + OutputIndex: 1, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{Value: 300000}, {Value: 290000}}, + } + + event := &tbtc.ReservationReanchorRequestedEvent{} + + t.Run("finds the matching transaction among candidates", func(t *testing.T) { + found, err := findReservationReanchorTransaction( + event, + anchorUtxo, + []*bitcoin.Transaction{wrongShapeTx, wrongIndexTx, matchingTx}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if found != matchingTx { + t.Errorf("expected to find the matching transaction, got %v", found) + } + }) + + t.Run("returns nil when nothing matches", func(t *testing.T) { + found, err := findReservationReanchorTransaction( + event, + anchorUtxo, + []*bitcoin.Transaction{wrongShapeTx, wrongIndexTx}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if found != nil { + t.Errorf("expected nil, got %v", found) + } + }) +} + +// TestProveReservationTransaction covers the submit-vs-skip decision: a +// transaction with enough confirmations and a proof within relay range must +// invoke submit exactly once; a transaction with too few confirmations must +// not invoke submit at all. +func TestProveReservationTransaction(t *testing.T) { + // Fixture mirrors TestGetProofInfo's "proof entirely within current + // epoch" case in spv_test.go: factor 6, 20 confirmations, headers + // spanning the proof window all at the current epoch's difficulty. + const proofStart = 790270 + diff := func(d int64) *big.Int { return big.NewInt(d) } + + transaction := &bitcoin.Transaction{} + transactionHash := transaction.Hash() + + newFixture := func(confirmations uint) (*localChain, *localBitcoinChain) { + spvChain := newLocalChain() + btcChain := newLocalBitcoinChain() + + if err := populateBlockHeaders( + btcChain, + proofStart, + proofStart+19, + func(uint) *big.Int { return diff(32) }, + ); err != nil { + t.Fatal(err) + } + btcChain.addTransactionConfirmations(transactionHash, confirmations) + + spvChain.setTxProofDifficultyFactor(big.NewInt(6)) + spvChain.setCurrentEpoch(392) + spvChain.setCurrentAndPrevEpochDifficulty(diff(32), diff(16)) + + return spvChain, btcChain + } + + t.Run("submits when confirmations and relay range are sufficient", func(t *testing.T) { + spvChain, btcChain := newFixture(20) + + submitted := false + err := proveReservationTransaction( + transaction, + btcChain, + spvChain, + spvChain, + func(hash bitcoin.Hash, requiredConfirmations uint) error { + submitted = true + if hash != transaction.Hash() { + t.Errorf("unexpected submitted hash") + } + if requiredConfirmations != 6 { + t.Errorf( + "unexpected required confirmations: got %d, want 6", + requiredConfirmations, + ) + } + return nil + }, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !submitted { + t.Error("expected submit to be called") + } + }) + + t.Run("skips without submitting when confirmations are insufficient", func(t *testing.T) { + spvChain, btcChain := newFixture(2) + + submitted := false + err := proveReservationTransaction( + transaction, + btcChain, + spvChain, + spvChain, + func(hash bitcoin.Hash, requiredConfirmations uint) error { + submitted = true + return nil + }, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if submitted { + t.Error("expected submit not to be called for insufficient confirmations") + } + }) + + t.Run("propagates submit errors", func(t *testing.T) { + spvChain, btcChain := newFixture(20) + + err := proveReservationTransaction( + transaction, + btcChain, + spvChain, + spvChain, + func(hash bitcoin.Hash, requiredConfirmations uint) error { + return fmt.Errorf("submission failed") + }, + ) + if err == nil { + t.Fatal("expected submit error to propagate") + } + }) +} diff --git a/pkg/maintainer/spv/reservation_reanchor_proof.go b/pkg/maintainer/spv/reservation_reanchor_proof.go index ee481a4854..afdede9538 100644 --- a/pkg/maintainer/spv/reservation_reanchor_proof.go +++ b/pkg/maintainer/spv/reservation_reanchor_proof.go @@ -14,9 +14,8 @@ import ( // Reanchor). const ProofTypeReservationReanchor uint8 = 3 -// SubmitReservationReanchorProof drives the SPV proof submission for a -// reservation re-anchor action generation. The caller (typically the -// reservation re-anchor watcher) supplies the (reservationKey, requestNonce) +// reservation re-anchor action generation. The caller (the reservation +// proof loop) supplies the (reservationKey, requestNonce) // pair of the on-chain action generation it is proving, plus the Bitcoin // transaction hash of the re-anchor transaction already signed and // broadcast by the wallet coordinator. The proof is fetched from btcChain, @@ -43,6 +42,7 @@ func SubmitReservationReanchorProof( btcChain, spvChain, bitcoin.AssembleSpvProof, + getGlobalMetricsRecorder(), ) } @@ -54,83 +54,69 @@ func submitReservationReanchorProof( btcChain bitcoin.Chain, spvChain Chain, spvProofAssembler spvProofAssembler, + metricsRecorder interface { + IncrementCounter(name string, value float64) + }, ) error { - if requiredConfirmations == 0 { - return fmt.Errorf( - "provided required confirmations count must be greater than 0", - ) - } - if reservationKey == nil { - return fmt.Errorf("reservation key is required") - } - if requestNonce == 0 { - return fmt.Errorf("request nonce must be > 0") - } - - transaction, proof, err := spvProofAssembler( + return submitReservationActionProof( transactionHash, requiredConfirmations, + reservationKey, + requestNonce, btcChain, + spvChain, + spvProofAssembler, + metricsRecorder, + ProofTypeReservationReanchor, + "reservation_reanchor_proof", + tbtc.ReservationActionTypeReanchor, + parseReservationReanchorTransactionInput, ) - if err != nil { - return fmt.Errorf( - "failed to assemble transaction spv proof: [%v]", - err, - ) - } +} - anchorUtxo, _, err := parseReservationReanchorTransactionInput( - btcChain, - transaction, - ) - if err != nil { - return fmt.Errorf( - "error while parsing reservation re-anchor transaction inputs: [%v]", - err, +// spentOutputAsUtxo fetches the single previous output spent by transaction's +// sole input and returns it as an UnspentTransactionOutput. Shared by +// parseReservationAcceptanceTransactionInput and +// parseReservationReanchorTransactionInput, both of which parse a +// 1-input-1-output reservation transaction and need the spent outpoint's +// value to build the SPV proof's main UTXO. +func spentOutputAsUtxo( + btcChain bitcoin.Chain, + transaction *bitcoin.Transaction, +) (*bitcoin.UnspentTransactionOutput, error) { + if len(transaction.Inputs) != 1 { + return nil, fmt.Errorf( + "reservation transaction must have exactly one input", ) } - action, err := spvChain.GetReservationAction(reservationKey, requestNonce) + spentOutpoint := transaction.Inputs[0].Outpoint + + previousTransaction, err := btcChain.GetTransaction( + spentOutpoint.TransactionHash, + ) if err != nil { - return fmt.Errorf( - "cannot fetch reservation action generation: [%v]", + return nil, fmt.Errorf( + "cannot fetch previous transaction: [%v]", err, ) } - if action.ActionType != tbtc.ReservationActionTypeReanchor { - return fmt.Errorf( - "reservation action generation is not a re-anchor (got %v)", - action.ActionType, + if int(spentOutpoint.OutputIndex) >= len(previousTransaction.Outputs) { + return nil, fmt.Errorf( + "spent output index [%v] out of bounds for previous "+ + "transaction with [%v] outputs", + spentOutpoint.OutputIndex, + len(previousTransaction.Outputs), ) } - if action.State != tbtc.ReservationActionStatePending { - return fmt.Errorf( - "reservation re-anchor action generation is not pending (state=%v)", - action.State, - ) - } + spentOutput := previousTransaction.Outputs[spentOutpoint.OutputIndex] - txInfo := buildReservationProofTxInfo(transaction) - txProof := buildReservationProofTxProof(proof) - mainUtxo := buildReservationProofMainUtxo(anchorUtxo) - - if err := spvChain.SubmitReservationProof( - ProofTypeReservationReanchor, - txInfo, - txProof, - mainUtxo, - reservationKey, - requestNonce, - ); err != nil { - return fmt.Errorf( - "failed to submit reservation re-anchor proof: [%v]", - err, - ) - } - - return nil + return &bitcoin.UnspentTransactionOutput{ + Outpoint: spentOutpoint, + Value: spentOutput.Value, + }, nil } // parseReservationReanchorTransactionInput parses the single input and @@ -141,10 +127,9 @@ func parseReservationReanchorTransactionInput( btcChain bitcoin.Chain, transaction *bitcoin.Transaction, ) (*bitcoin.UnspentTransactionOutput, [20]byte, error) { - if len(transaction.Inputs) != 1 { - return nil, [20]byte{}, fmt.Errorf( - "reservation re-anchor transaction must have exactly one input", - ) + anchorUtxo, err := spentOutputAsUtxo(btcChain, transaction) + if err != nil { + return nil, [20]byte{}, err } if len(transaction.Outputs) != 1 { @@ -153,32 +138,6 @@ func parseReservationReanchorTransactionInput( ) } - input := transaction.Inputs[0] - - inputTx, err := btcChain.GetTransaction(input.Outpoint.TransactionHash) - if err != nil { - return nil, [20]byte{}, fmt.Errorf( - "cannot get input transaction data: [%v]", - err, - ) - } - - if int(input.Outpoint.OutputIndex) >= len(inputTx.Outputs) { - return nil, [20]byte{}, fmt.Errorf( - "input outpoint index [%d] out of range for transaction [%d] "+ - "outputs", - input.Outpoint.OutputIndex, - len(inputTx.Outputs), - ) - } - - spentOutput := inputTx.Outputs[input.Outpoint.OutputIndex] - - anchorUtxo := &bitcoin.UnspentTransactionOutput{ - Outpoint: input.Outpoint, - Value: spentOutput.Value, - } - targetWalletPublicKeyHash, err := bitcoin.ExtractPublicKeyHash( transaction.Outputs[0].PublicKeyScript, ) @@ -249,3 +208,105 @@ func buildReservationProofMainUtxo( TxOutputValue: txOutValue, } } + +func submitReservationActionProof( + transactionHash bitcoin.Hash, + requiredConfirmations uint, + reservationKey *big.Int, + requestNonce uint64, + btcChain bitcoin.Chain, + spvChain Chain, + spvProofAssembler spvProofAssembler, + metricsRecorder interface { + IncrementCounter(name string, value float64) + }, + proofType uint8, + metricsPrefix string, + expectedActionType tbtc.ReservationActionType, + inputParser func( + btcChain bitcoin.Chain, + transaction *bitcoin.Transaction, + ) (*bitcoin.UnspentTransactionOutput, [20]byte, error), +) error { + if metricsRecorder != nil { + metricsRecorder.IncrementCounter(metricsPrefix+"_submissions_total", 1) + } + + if requiredConfirmations == 0 { + if metricsRecorder != nil { + metricsRecorder.IncrementCounter(metricsPrefix+"_submissions_failed_total", 1) + } + return fmt.Errorf("provided required confirmations count must be greater than 0") + } + if reservationKey == nil { + return fmt.Errorf("reservation key is required") + } + if requestNonce == 0 { + return fmt.Errorf("request nonce must be > 0") + } + + transaction, proof, err := spvProofAssembler( + transactionHash, + requiredConfirmations, + btcChain, + ) + if err != nil { + if metricsRecorder != nil { + metricsRecorder.IncrementCounter(metricsPrefix+"_submissions_failed_total", 1) + } + return fmt.Errorf("failed to assemble transaction spv proof: [%v]", err) + } + + anchorUtxo, pkh, err := inputParser(btcChain, transaction) + if err != nil { + if metricsRecorder != nil { + metricsRecorder.IncrementCounter(metricsPrefix+"_submissions_failed_total", 1) + } + return fmt.Errorf("error while parsing reservation transaction inputs: [%v]", err) + } + + action, err := spvChain.GetReservationAction(reservationKey, requestNonce) + if err != nil { + return fmt.Errorf("cannot fetch reservation action generation: [%v]", err) + } + + // Fix 6: Check PKH match + if pkh != action.TargetWalletPublicKeyHash { + if metricsRecorder != nil { + metricsRecorder.IncrementCounter(metricsPrefix+"_submissions_failed_total", 1) + } + return fmt.Errorf("target wallet public key hash mismatch") + } + + if action.ActionType != expectedActionType { + return fmt.Errorf("reservation action generation is not expected type") + } + + if action.State != tbtc.ReservationActionStatePending { + return fmt.Errorf("reservation action generation is not pending") + } + + txInfo := buildReservationProofTxInfo(transaction) + txProof := buildReservationProofTxProof(proof) + mainUtxo := buildReservationProofMainUtxo(anchorUtxo) + + if err := spvChain.SubmitReservationProof( + proofType, + txInfo, + txProof, + mainUtxo, + reservationKey, + requestNonce, + ); err != nil { + if metricsRecorder != nil { + metricsRecorder.IncrementCounter(metricsPrefix+"_submissions_failed_total", 1) + } + return fmt.Errorf("failed to submit reservation proof: [%v]", err) + } + + if metricsRecorder != nil { + metricsRecorder.IncrementCounter(metricsPrefix+"_submissions_success_total", 1) + } + + return nil +} diff --git a/pkg/maintainer/spv/reservation_reanchor_proof_test.go b/pkg/maintainer/spv/reservation_reanchor_proof_test.go index 804599967b..323499c53c 100644 --- a/pkg/maintainer/spv/reservation_reanchor_proof_test.go +++ b/pkg/maintainer/spv/reservation_reanchor_proof_test.go @@ -95,8 +95,9 @@ func TestSubmitReservationReanchorProof(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, }) // Override SubmitReservationProof on the localChain to capture the call. @@ -140,14 +141,16 @@ func TestSubmitReservationReanchorProof(t *testing.T) { btcChain, spvChain, mockSpvProofAssembler, + nil, ); err != nil { t.Fatal(err) } // Negative path: action generation is not Pending. spvChain.setReservationAction(reservationKey, requestNonce, &tbtc.ReservationAction{ - ActionType: tbtc.ReservationActionTypeReanchor, - State: tbtc.ReservationActionStateSettled, + ActionType: tbtc.ReservationActionTypeReanchor, + State: tbtc.ReservationActionStateSettled, + TargetWalletPublicKeyHash: targetWalletPKH, }) if err := submitReservationReanchorProof( reanchorTx.Hash(), @@ -157,14 +160,16 @@ func TestSubmitReservationReanchorProof(t *testing.T) { btcChain, spvChain, mockSpvProofAssembler, + nil, ); err == nil { t.Fatal("expected error for settled action generation") } // Negative path: action generation is the wrong type. spvChain.setReservationAction(reservationKey, requestNonce, &tbtc.ReservationAction{ - ActionType: tbtc.ReservationActionTypeAcceptance, - State: tbtc.ReservationActionStatePending, + ActionType: tbtc.ReservationActionTypeAcceptance, + State: tbtc.ReservationActionStatePending, + TargetWalletPublicKeyHash: targetWalletPKH, }) if err := submitReservationReanchorProof( reanchorTx.Hash(), @@ -174,6 +179,7 @@ func TestSubmitReservationReanchorProof(t *testing.T) { btcChain, spvChain, mockSpvProofAssembler, + nil, ); err == nil { t.Fatal("expected error for wrong action type") } @@ -187,6 +193,7 @@ func TestSubmitReservationReanchorProof(t *testing.T) { btcChain, spvChain, mockSpvProofAssembler, + nil, ); err == nil { t.Fatal("expected error for zero required confirmations") } diff --git a/pkg/maintainer/spv/reservation_stale_deposit_watch.go b/pkg/maintainer/spv/reservation_stale_deposit_watch.go index 9eacfd10dc..00dd527dc5 100644 --- a/pkg/maintainer/spv/reservation_stale_deposit_watch.go +++ b/pkg/maintainer/spv/reservation_stale_deposit_watch.go @@ -29,37 +29,15 @@ const reservationAcceptanceActionNonce uint64 = 1 // flips the deposit's bookkeeping when the wallet never shows up. type ReservationStaleDepositWatcher struct { spvChain Chain - notifier StaleReservedDepositNotifier -} - -// StaleReservedDepositNotifier is the Bridge-facing contract for releasing a -// reserved deposit back to the default sweep path. It mirrors -// `Chain.NotifyStaleReservedDeposit` but is interface-typed to enable -// in-memory recorders during tests. -type StaleReservedDepositNotifier interface { - NotifyStaleReservedDeposit(depositKey *big.Int) error -} - -// StaleReservedDepositNotifierFunc adapts a function to the -// StaleReservedDepositNotifier interface. -type StaleReservedDepositNotifierFunc func(depositKey *big.Int) error - -// NotifyStaleReservedDeposit forwards the call to the wrapped function. -func (f StaleReservedDepositNotifierFunc) NotifyStaleReservedDeposit( - depositKey *big.Int, -) error { - return f(depositKey) } // NewReservationStaleDepositWatcher constructs a stale-deposit watcher -// bound to the given chain and notifier. +// bound to the given chain. func NewReservationStaleDepositWatcher( spvChain Chain, - notifier StaleReservedDepositNotifier, ) *ReservationStaleDepositWatcher { return &ReservationStaleDepositWatcher{ spvChain: spvChain, - notifier: notifier, } } @@ -87,9 +65,6 @@ func (rsdw *ReservationStaleDepositWatcher) OnDepositRevealed( depositKey *big.Int, now uint32, ) error { - if rsdw.notifier == nil { - return fmt.Errorf("stale-deposit watcher requires a non-nil notifier") - } if depositKey == nil { return fmt.Errorf("deposit key must not be nil") } @@ -130,9 +105,6 @@ func (rsdw *ReservationStaleDepositWatcher) CheckStaleReservedDeposit( depositKey *big.Int, now uint32, ) error { - if rsdw.notifier == nil { - return fmt.Errorf("stale-deposit watcher requires a non-nil notifier") - } if depositKey == nil { return fmt.Errorf("deposit key must not be nil") } @@ -210,39 +182,103 @@ func (rsdw *ReservationStaleDepositWatcher) CheckStaleReservedDeposit( reservationKey, reservationAcceptanceActionNonce, ) - if err != nil { - return fmt.Errorf( - "failed to load acceptance action for reservation [%v]: [%v]", - reservationKey, - err, + + var timeoutAt uint32 + // A raw contract-mapping read for a nonce that was never requested + // returns no error, just the zero-value struct (State == + // ReservationActionStateUnknown) - a genuine chain RPC failure is the + // only case err is non-nil. Both mean "no action generation exists". + if err != nil || action.State == tbtc.ReservationActionStateUnknown { + // No acceptance action generation exists yet on-chain for this + // reservation (ReservationActionStateUnknown / not found). Derive + // the staleness deadline from the deposit's own reveal timestamp + // instead of the (nonexistent) action's TimeoutAt: find the + // DepositRevealed event for this deposit key among the wallet's + // events, then load the deposit request's RevealedAt. + events, eventsErr := rsdw.spvChain.PastDepositRevealedEvents( + &tbtc.DepositRevealedEventFilter{ + WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + }, ) - } + if eventsErr != nil { + return fmt.Errorf( + "failed to fetch deposit revealed events for staleness "+ + "deadline derivation: [%v]", + eventsErr, + ) + } - // A non-pending action means the acceptance already progressed past the - // timeout-eligible window. Skip without notifying. - if action.State != tbtc.ReservationActionStatePending { - logger.Debugf( - "reservation [%v] acceptance action state=%s; "+ - "deposit [%v] is no longer pending-stale; skipping", - reservationKey, - action.State, - depositKey, + var matchingEvent *tbtc.DepositRevealedEvent + for _, event := range events { + if rsdw.spvChain.BuildDepositKey( + event.FundingTxHash, + event.FundingOutputIndex, + ).Cmp(depositKey) == 0 { + matchingEvent = event + break + } + } + if matchingEvent == nil { + return fmt.Errorf( + "no matching DepositRevealed event for deposit [%v]", + depositKey, + ) + } + + depositRequest, found, requestErr := rsdw.spvChain.GetDepositRequest( + matchingEvent.FundingTxHash, + matchingEvent.FundingOutputIndex, ) - return nil + if requestErr != nil { + return fmt.Errorf( + "failed to load deposit request for staleness deadline "+ + "derivation: [%v]", + requestErr, + ) + } + if !found { + return fmt.Errorf( + "deposit request not found for deposit [%v]", + depositKey, + ) + } + + params, paramsErr := rsdw.spvChain.ReservationParameters() + if paramsErr != nil { + return fmt.Errorf( + "failed to load reservation parameters for staleness "+ + "deadline derivation: [%v]", + paramsErr, + ) + } + timeoutAt = uint32(depositRequest.RevealedAt.Unix()) + + params.ReservationActionTimeout + } else { + if action.State != tbtc.ReservationActionStatePending { + logger.Debugf( + "reservation [%v] acceptance action state=%s; "+ + "deposit [%v] is no longer pending-stale; skipping", + reservationKey, + action.State, + depositKey, + ) + return nil + } + timeoutAt = action.TimeoutAt } - if now <= action.TimeoutAt { + if now <= timeoutAt { logger.Debugf( "reserved deposit [%v] action timeout at [%d] not yet reached "+ "(now=%d); deferring stale notification", depositKey, - action.TimeoutAt, + timeoutAt, now, ) return nil } - if err := rsdw.notifier.NotifyStaleReservedDeposit(depositKey); err != nil { + if err := rsdw.spvChain.NotifyStaleReservedDeposit(depositKey); err != nil { return fmt.Errorf( "failed to notify stale reserved deposit [%v]: [%v]", depositKey, @@ -256,7 +292,7 @@ func (rsdw *ReservationStaleDepositWatcher) CheckStaleReservedDeposit( depositKey, walletPublicKeyHash, wallet.State, - action.TimeoutAt, + timeoutAt, ) return nil diff --git a/pkg/maintainer/spv/reservation_stale_deposit_watch_test.go b/pkg/maintainer/spv/reservation_stale_deposit_watch_test.go index dfb2b193d8..a6165ca630 100644 --- a/pkg/maintainer/spv/reservation_stale_deposit_watch_test.go +++ b/pkg/maintainer/spv/reservation_stale_deposit_watch_test.go @@ -4,7 +4,9 @@ import ( "fmt" "math/big" "testing" + "time" + "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/tbtc" "github.com/go-test/deep" @@ -23,24 +25,22 @@ const reservationActionTimeout uint32 = 3600 func TestReservationStaleDepositWatcher_NonReservedDepositIsSkipped(t *testing.T) { spvChain := newLocalChain() - notifier := &recordingStaleNotifier{} // Deposit is NOT booked as reserved. spvChain.setReservedDeposit(reservationDepositKey(0xB001), walletPKH(), false) - watcher := NewReservationStaleDepositWatcher(spvChain, notifier) + watcher := NewReservationStaleDepositWatcher(spvChain) if err := watcher.CheckStaleReservedDeposit(reservationDepositKey(0xB001), 5_000); err != nil { t.Fatalf("unexpected error: %v", err) } - if len(notifier.calls) != 0 { - t.Fatalf("non-reserved deposit must not notify, got %d calls", len(notifier.calls)) + if calls := spvChain.getSubmittedStaleReservedDeposits(); len(calls) != 0 { + t.Fatalf("non-reserved deposit must not notify, got %d calls", len(calls)) } } func TestReservationStaleDepositWatcher_LiveWalletDoesNotNotify(t *testing.T) { spvChain := newLocalChain() - notifier := &recordingStaleNotifier{} key := reservationDepositKey(0xB002) wallet := walletPKH() @@ -49,19 +49,18 @@ func TestReservationStaleDepositWatcher_LiveWalletDoesNotNotify(t *testing.T) { State: tbtc.StateLive, }) - watcher := NewReservationStaleDepositWatcher(spvChain, notifier) + watcher := NewReservationStaleDepositWatcher(spvChain) if err := watcher.CheckStaleReservedDeposit(key, 10_000); err != nil { t.Fatalf("unexpected error: %v", err) } - if len(notifier.calls) != 0 { - t.Fatalf("live wallet must not trigger stale notification, got %d calls", len(notifier.calls)) + if calls := spvChain.getSubmittedStaleReservedDeposits(); len(calls) != 0 { + t.Fatalf("live wallet must not trigger stale notification, got %d calls", len(calls)) } } func TestReservationStaleDepositWatcher_NotifiesAfterTimeout(t *testing.T) { spvChain := newLocalChain() - notifier := &recordingStaleNotifier{} key := reservationDepositKey(0xB003) wallet := walletPKH() @@ -80,23 +79,23 @@ func TestReservationStaleDepositWatcher_NotifiesAfterTimeout(t *testing.T) { ReservationActionTimeout: reservationActionTimeout, }) - watcher := NewReservationStaleDepositWatcher(spvChain, notifier) + watcher := NewReservationStaleDepositWatcher(spvChain) // now (5_000) > action.TimeoutAt (100). if err := watcher.CheckStaleReservedDeposit(key, 5_000); err != nil { t.Fatalf("unexpected error: %v", err) } - if len(notifier.calls) != 1 { - t.Fatalf("expected one stale notification, got %d", len(notifier.calls)) + calls := spvChain.getSubmittedStaleReservedDeposits() + if len(calls) != 1 { + t.Fatalf("expected one stale notification, got %d", len(calls)) } - if diff := deep.Equal(key, notifier.calls[0]); diff != nil { + if diff := deep.Equal(key, calls[0]); diff != nil { t.Errorf("unexpected notified key: %v", diff) } } func TestReservationStaleDepositWatcher_DoesNotNotifyBeforeTimeout(t *testing.T) { spvChain := newLocalChain() - notifier := &recordingStaleNotifier{} key := reservationDepositKey(0xB004) wallet := walletPKH() @@ -115,19 +114,18 @@ func TestReservationStaleDepositWatcher_DoesNotNotifyBeforeTimeout(t *testing.T) ReservationActionTimeout: reservationActionTimeout, }) - watcher := NewReservationStaleDepositWatcher(spvChain, notifier) + watcher := NewReservationStaleDepositWatcher(spvChain) if err := watcher.CheckStaleReservedDeposit(key, 5_000); err != nil { t.Fatalf("unexpected error: %v", err) } - if len(notifier.calls) != 0 { - t.Fatalf("action not yet timed out; expected zero notifications, got %d", len(notifier.calls)) + if calls := spvChain.getSubmittedStaleReservedDeposits(); len(calls) != 0 { + t.Fatalf("action not yet timed out; expected zero notifications, got %d", len(calls)) } } func TestReservationStaleDepositWatcher_SettledActionIsSkipped(t *testing.T) { spvChain := newLocalChain() - notifier := &recordingStaleNotifier{} key := reservationDepositKey(0xB005) wallet := walletPKH() @@ -144,36 +142,34 @@ func TestReservationStaleDepositWatcher_SettledActionIsSkipped(t *testing.T) { TimeoutAt: 100, }) - watcher := NewReservationStaleDepositWatcher(spvChain, notifier) + watcher := NewReservationStaleDepositWatcher(spvChain) if err := watcher.CheckStaleReservedDeposit(key, 5_000); err != nil { t.Fatalf("unexpected error: %v", err) } - if len(notifier.calls) != 0 { - t.Fatalf("settled action must skip stale notification, got %d calls", len(notifier.calls)) + if calls := spvChain.getSubmittedStaleReservedDeposits(); len(calls) != 0 { + t.Fatalf("settled action must skip stale notification, got %d calls", len(calls)) } } func TestReservationStaleDepositWatcher_ZeroWalletSkips(t *testing.T) { spvChain := newLocalChain() - notifier := &recordingStaleNotifier{} key := reservationDepositKey(0xB006) spvChain.setReservedDeposit(key, [20]byte{}, true) - watcher := NewReservationStaleDepositWatcher(spvChain, notifier) + watcher := NewReservationStaleDepositWatcher(spvChain) if err := watcher.CheckStaleReservedDeposit(key, 5_000); err != nil { t.Fatalf("unexpected error: %v", err) } - if len(notifier.calls) != 0 { - t.Fatalf("zero-wallet deposit must skip, got %d calls", len(notifier.calls)) + if calls := spvChain.getSubmittedStaleReservedDeposits(); len(calls) != 0 { + t.Fatalf("zero-wallet deposit must skip, got %d calls", len(calls)) } } func TestReservationStaleDepositWatcher_OnDepositRevealedDelegates(t *testing.T) { spvChain := newLocalChain() - notifier := &recordingStaleNotifier{} key := reservationDepositKey(0xB007) wallet := walletPKH() @@ -186,21 +182,20 @@ func TestReservationStaleDepositWatcher_OnDepositRevealedDelegates(t *testing.T) TimeoutAt: 100, }) - watcher := NewReservationStaleDepositWatcher(spvChain, notifier) + watcher := NewReservationStaleDepositWatcher(spvChain) if err := watcher.OnDepositRevealed(key, 5_000); err != nil { t.Fatalf("unexpected error: %v", err) } - if len(notifier.calls) != 1 { - t.Fatalf("expected one notification, got %d", len(notifier.calls)) + if calls := spvChain.getSubmittedStaleReservedDeposits(); len(calls) != 1 { + t.Fatalf("expected one notification, got %d", len(calls)) } } func TestReservationStaleDepositWatcher_NilDepositKeyError(t *testing.T) { spvChain := newLocalChain() - notifier := &recordingStaleNotifier{} - watcher := NewReservationStaleDepositWatcher(spvChain, notifier) + watcher := NewReservationStaleDepositWatcher(spvChain) if err := watcher.CheckStaleReservedDeposit(nil, 5_000); err == nil { t.Fatal("expected error for nil deposit key, got nil") } @@ -208,36 +203,34 @@ func TestReservationStaleDepositWatcher_NilDepositKeyError(t *testing.T) { func TestReservationStaleDepositWatcher_IsReservedDepositChainError(t *testing.T) { spvChain := newLocalChain() - notifier := &recordingStaleNotifier{} spvChain.isReservedDepositErr = fmt.Errorf("rpc unavailable") - watcher := NewReservationStaleDepositWatcher(spvChain, notifier) + watcher := NewReservationStaleDepositWatcher(spvChain) err := watcher.CheckStaleReservedDeposit(reservationDepositKey(0xB010), 5_000) if err == nil { t.Fatal("expected error when IsReservedDeposit fails, got nil") } - if len(notifier.calls) != 0 { - t.Fatalf("expected no notifications on chain error, got %d", len(notifier.calls)) + if calls := spvChain.getSubmittedStaleReservedDeposits(); len(calls) != 0 { + t.Fatalf("expected no notifications on chain error, got %d", len(calls)) } } func TestReservationStaleDepositWatcher_ReservedDepositWalletChainError(t *testing.T) { spvChain := newLocalChain() - notifier := &recordingStaleNotifier{} key := reservationDepositKey(0xB011) spvChain.setReservedDeposit(key, walletPKH(), true) spvChain.reservedDepositWalletErr = fmt.Errorf("rpc unavailable") - watcher := NewReservationStaleDepositWatcher(spvChain, notifier) - if err := watcher.CheckStaleReservedDeposit(key, 5_000); err == nil { + watcher := NewReservationStaleDepositWatcher(spvChain) + err := watcher.CheckStaleReservedDeposit(key, 5_000) + if err == nil { t.Fatal("expected error when ReservedDepositWallet fails, got nil") } - - if len(notifier.calls) != 0 { - t.Fatalf("expected no notifications on chain error, got %d", len(notifier.calls)) + if calls := spvChain.getSubmittedStaleReservedDeposits(); len(calls) != 0 { + t.Fatalf("expected no notifications on chain error, got %d", len(calls)) } } @@ -248,30 +241,30 @@ func TestReservationStaleDepositWatcher_ReservedDepositWalletChainError(t *testi // would if the wallet were somehow unresolvable. func TestReservationStaleDepositWatcher_GetWalletChainError(t *testing.T) { spvChain := newLocalChain() - notifier := &recordingStaleNotifier{} key := reservationDepositKey(0xB012) spvChain.setReservedDeposit(key, walletPKH(), true) // No spvChain.setWallet call: GetWallet errors naturally. - watcher := NewReservationStaleDepositWatcher(spvChain, notifier) + watcher := NewReservationStaleDepositWatcher(spvChain) if err := watcher.CheckStaleReservedDeposit(key, 5_000); err == nil { t.Fatal("expected error when GetWallet fails, got nil") } - if len(notifier.calls) != 0 { - t.Fatalf("expected no notifications on chain error, got %d", len(notifier.calls)) + if calls := spvChain.getSubmittedStaleReservedDeposits(); len(calls) != 0 { + t.Fatalf("expected no notifications on chain error, got %d", len(calls)) } } -// TestReservationStaleDepositWatcher_GetReservationActionChainError -// exercises the error passthrough the same way: the acceptance action -// (nonce 1) is never installed via setReservationAction, so -// GetReservationAction fails with its natural "no action for given -// reservation/nonce" error. -func TestReservationStaleDepositWatcher_GetReservationActionChainError(t *testing.T) { +// TestReservationStaleDepositWatcher_NoActionRequestedYetPropagatesWithoutMatchingEvent +// covers the "no acceptance action generation exists yet" branch +// (GetReservationAction returns State == ReservationActionStateUnknown, the +// zero value a Solidity mapping read returns for a never-requested nonce) +// when no matching DepositRevealed event has been seeded either: the +// watcher cannot derive a staleness deadline from nothing, so it must +// still surface an error rather than silently notifying or skipping. +func TestReservationStaleDepositWatcher_NoActionRequestedYetPropagatesWithoutMatchingEvent(t *testing.T) { spvChain := newLocalChain() - notifier := &recordingStaleNotifier{} key := reservationDepositKey(0xB013) wallet := walletPKH() @@ -279,25 +272,150 @@ func TestReservationStaleDepositWatcher_GetReservationActionChainError(t *testin spvChain.setWallet(wallet, &tbtc.WalletChainData{ State: tbtc.StateUnknown, }) - // No spvChain.setReservationAction call: GetReservationAction errors - // naturally. + // No spvChain.setReservationAction call: GetReservationAction returns + // the zero-value action (State == ReservationActionStateUnknown), not + // an error - this drives the watcher into the reveal-timestamp + // derivation branch. No DepositRevealed event is seeded either, so + // that branch cannot resolve and must itself return an error. - watcher := NewReservationStaleDepositWatcher(spvChain, notifier) + watcher := NewReservationStaleDepositWatcher(spvChain) if err := watcher.CheckStaleReservedDeposit(key, 5_000); err == nil { - t.Fatal("expected error when GetReservationAction fails, got nil") + t.Fatal("expected error when no matching deposit revealed event exists, got nil") + } + + if calls := spvChain.getSubmittedStaleReservedDeposits(); len(calls) != 0 { + t.Fatalf("expected no notifications on chain error, got %d", len(calls)) + } +} + +// TestReservationStaleDepositWatcher_NoActionRequestedYetNotifiesFromRevealTimestamp +// is the real P1 fix under test: a reserved deposit whose wallet never +// became Live, so its acceptance action generation was never requested +// on-chain (GetReservationAction returns the zero-value +// ReservationActionStateUnknown, not an error and not a Pending action the +// old code path required to compute a deadline). The watcher must derive +// the staleness deadline from the deposit's own reveal timestamp +// (DepositRevealed event -> DepositChainRequest.RevealedAt) plus +// ReservationActionTimeout, and notify once that derived deadline has +// passed - exactly the scenario the watcher exists to catch, which the +// pre-fix code silently skipped forever. +func TestReservationStaleDepositWatcher_NoActionRequestedYetNotifiesFromRevealTimestamp(t *testing.T) { + spvChain := newLocalChain() + + wallet := walletPKH() + fundingTxHash, err := bitcoin.NewHashFromString( + "585b6699f42291d1a9d0776b75f04c295ea203f83504349db11e94fdae7d1b2c", + bitcoin.InternalByteOrder, + ) + if err != nil { + t.Fatal(err) + } + fundingOutputIndex := uint32(0) + + key := spvChain.BuildDepositKey(fundingTxHash, fundingOutputIndex) + spvChain.setReservedDeposit(key, wallet, true) + spvChain.setWallet(wallet, &tbtc.WalletChainData{ + State: tbtc.StateUnknown, + }) + // No setReservationAction: no acceptance was ever requested on-chain. + + if err := spvChain.addPastDepositRevealedEvent( + &tbtc.DepositRevealedEventFilter{ + WalletPublicKeyHash: [][20]byte{wallet}, + }, + &tbtc.DepositRevealedEvent{ + FundingTxHash: fundingTxHash, + FundingOutputIndex: fundingOutputIndex, + WalletPublicKeyHash: wallet, + }, + ); err != nil { + t.Fatal(err) + } + spvChain.setDepositRequest(fundingTxHash, fundingOutputIndex, &tbtc.DepositChainRequest{ + RevealedAt: time.Unix(1_000, 0), + }) + spvChain.setReservationParameters(&tbtc.ReservationParameters{ + ReservationActionTimeout: reservationActionTimeout, // 3600 + }) + + watcher := NewReservationStaleDepositWatcher(spvChain) + // Derived deadline = RevealedAt (1_000) + ReservationActionTimeout + // (3600) = 4_600. now = 10_000 > 4_600, so the deposit is stale. + if err := watcher.CheckStaleReservedDeposit(key, 10_000); err != nil { + t.Fatalf("unexpected error: %v", err) } - if len(notifier.calls) != 0 { - t.Fatalf("expected no notifications on chain error, got %d", len(notifier.calls)) + calls := spvChain.getSubmittedStaleReservedDeposits() + if len(calls) != 1 { + t.Fatalf("expected one stale notification, got %d", len(calls)) + } + if diff := deep.Equal(key, calls[0]); diff != nil { + t.Errorf("unexpected notified key: %v", diff) + } +} + +// TestReservationStaleDepositWatcher_NoActionRequestedYetDoesNotNotifyBeforeDerivedDeadline +// mirrors the notifying case above but asks at a `now` before the derived +// deadline, asserting the watcher correctly defers rather than notifying +// early. +func TestReservationStaleDepositWatcher_NoActionRequestedYetDoesNotNotifyBeforeDerivedDeadline(t *testing.T) { + spvChain := newLocalChain() + + wallet := walletPKH() + fundingTxHash, err := bitcoin.NewHashFromString( + "7cff663e3e08847a5579913f6a66bc6c01f5f48c6ae1783be77418ed188021e6", + bitcoin.InternalByteOrder, + ) + if err != nil { + t.Fatal(err) + } + fundingOutputIndex := uint32(1) + + key := spvChain.BuildDepositKey(fundingTxHash, fundingOutputIndex) + spvChain.setReservedDeposit(key, wallet, true) + spvChain.setWallet(wallet, &tbtc.WalletChainData{ + State: tbtc.StateUnknown, + }) + + if err := spvChain.addPastDepositRevealedEvent( + &tbtc.DepositRevealedEventFilter{ + WalletPublicKeyHash: [][20]byte{wallet}, + }, + &tbtc.DepositRevealedEvent{ + FundingTxHash: fundingTxHash, + FundingOutputIndex: fundingOutputIndex, + WalletPublicKeyHash: wallet, + }, + ); err != nil { + t.Fatal(err) + } + spvChain.setDepositRequest(fundingTxHash, fundingOutputIndex, &tbtc.DepositChainRequest{ + RevealedAt: time.Unix(1_000, 0), + }) + spvChain.setReservationParameters(&tbtc.ReservationParameters{ + ReservationActionTimeout: reservationActionTimeout, // 3600 + }) + + watcher := NewReservationStaleDepositWatcher(spvChain) + // Derived deadline = 1_000 + 3600 = 4_600. now = 2_000 < 4_600. + if err := watcher.CheckStaleReservedDeposit(key, 2_000); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if calls := spvChain.getSubmittedStaleReservedDeposits(); len(calls) != 0 { + t.Fatalf( + "derived deadline not yet reached; expected zero notifications, got %d", + len(calls), + ) } } // TestReservationStaleDepositWatcher_NotifierError verifies that, unlike -// the stranding watcher (which continues past a notifier failure because -// it processes a batch of reservations per call), the stale-deposit -// watcher propagates a NotifyStaleReservedDeposit failure to its single -// caller: CheckStaleReservedDeposit checks exactly one deposit per call, so -// there is nothing else to "continue" to. +// the stranding watcher (which continues past a notify failure because it +// processes a batch of reservations per call), the stale-deposit watcher +// propagates a NotifyStaleReservedDeposit failure to its single caller: +// CheckStaleReservedDeposit checks exactly one deposit per call, so there +// is nothing else to "continue" to. func TestReservationStaleDepositWatcher_NotifierError(t *testing.T) { spvChain := newLocalChain() @@ -311,12 +429,9 @@ func TestReservationStaleDepositWatcher_NotifierError(t *testing.T) { State: tbtc.ReservationActionStatePending, TimeoutAt: 100, }) + spvChain.notifyStaleReservedDepositErr = fmt.Errorf("notifier unavailable") - notifier := StaleReservedDepositNotifierFunc(func(*big.Int) error { - return fmt.Errorf("notifier unavailable") - }) - - watcher := NewReservationStaleDepositWatcher(spvChain, notifier) + watcher := NewReservationStaleDepositWatcher(spvChain) if err := watcher.CheckStaleReservedDeposit(key, 5_000); err == nil { t.Fatal("expected error when the notifier fails, got nil") } @@ -329,7 +444,6 @@ func TestReservationStaleDepositWatcher_NotifierError(t *testing.T) { // exercise now < TimeoutAt and now > TimeoutAt. func TestReservationStaleDepositWatcher_ExactTimeoutBoundaryDoesNotNotify(t *testing.T) { spvChain := newLocalChain() - notifier := &recordingStaleNotifier{} key := reservationDepositKey(0xB015) wallet := walletPKH() @@ -342,42 +456,15 @@ func TestReservationStaleDepositWatcher_ExactTimeoutBoundaryDoesNotNotify(t *tes TimeoutAt: 5_000, }) - watcher := NewReservationStaleDepositWatcher(spvChain, notifier) + watcher := NewReservationStaleDepositWatcher(spvChain) if err := watcher.CheckStaleReservedDeposit(key, 5_000); err != nil { t.Fatalf("unexpected error: %v", err) } - if len(notifier.calls) != 0 { + if calls := spvChain.getSubmittedStaleReservedDeposits(); len(calls) != 0 { t.Fatalf( "now == action.TimeoutAt must not notify, got %d calls", - len(notifier.calls), + len(calls), ) } } - -func TestReservationStaleDepositWatcher_NilNotifierError(t *testing.T) { - spvChain := newLocalChain() - watcher := NewReservationStaleDepositWatcher(spvChain, nil) - - key := reservationDepositKey(0xB016) - - if err := watcher.CheckStaleReservedDeposit(key, 5_000); err == nil { - t.Fatal("expected error for nil notifier via CheckStaleReservedDeposit, got nil") - } - if err := watcher.OnDepositRevealed(key, 5_000); err == nil { - t.Fatal("expected error for nil notifier via OnDepositRevealed, got nil") - } -} - -// recordingStaleNotifier is a test double that captures every -// NotifyStaleReservedDeposit call. -type recordingStaleNotifier struct { - calls []*big.Int -} - -func (r *recordingStaleNotifier) NotifyStaleReservedDeposit( - depositKey *big.Int, -) error { - r.calls = append(r.calls, depositKey) - return nil -} diff --git a/pkg/maintainer/spv/reservation_stranding_watch.go b/pkg/maintainer/spv/reservation_stranding_watch.go index 82b3507942..55168cce95 100644 --- a/pkg/maintainer/spv/reservation_stranding_watch.go +++ b/pkg/maintainer/spv/reservation_stranding_watch.go @@ -2,7 +2,6 @@ package spv import ( "fmt" - "math/big" "github.com/keep-network/keep-core/pkg/tbtc" ) @@ -18,28 +17,6 @@ import ( // be reconciled via the owner-facing late settlement path. type ReservationStrandingWatcher struct { spvChain Chain - notifier ReservationStrandingNotifier -} - -// ReservationStrandingNotifier is the contract the stranding watcher uses to -// forward notifications to the Bridge. It mirrors -// `Chain.NotifyReservationStranded` but is interface-typed so the watcher can -// be unit-tested with an in-memory recorder. -type ReservationStrandingNotifier interface { - NotifyReservationStranded(reservationKey *big.Int) error -} - -// ReservationStrandingNotifierFunc adapts a plain function to the -// ReservationStrandingNotifier interface, matching the Go idiomatic pattern -// for callbacks in this package (see also `unprovenTransactionsGetter` in -// spv.go). -type ReservationStrandingNotifierFunc func(reservationKey *big.Int) error - -// NotifyReservationStranded forwards the call to the wrapped function. -func (f ReservationStrandingNotifierFunc) NotifyReservationStranded( - reservationKey *big.Int, -) error { - return f(reservationKey) } // NewReservationStrandingWatcher constructs a stranding watcher bound to the @@ -52,43 +29,10 @@ func (f ReservationStrandingNotifierFunc) NotifyReservationStranded( // notifications would leave reservation anchors unreconciled. func NewReservationStrandingWatcher( spvChain Chain, - notifier ReservationStrandingNotifier, ) *ReservationStrandingWatcher { return &ReservationStrandingWatcher{ spvChain: spvChain, - notifier: notifier, - } -} - -// WatchWallet subscribes the watcher to Bridge close/termination events for -// the given wallet. When the wallet transitions to StateClosed or -// StateTerminated, the watcher walks the wallet's reservations and notifies -// the Bridge of any reservation that is not currently ActionPending. -// -// Note: Bridge.go emits OnWalletClosed for both close and termination -// (see `pkg/tbtc/chain.go` BridgeChain.OnWalletClosed), so a single -// subscription covers both terminal states. The watcher therefore registers -// a single OnWalletClosed handler; downstream code may alias this hook for -// OnWalletTerminated dispatch if both events are ever split. -// -// Pass a nil fn to skip wiring (used in tests that drive the watcher -// imperatively via CheckReservationStranding). Pass a non-nil fn to enable -// live observation. -func (rsw *ReservationStrandingWatcher) WatchWallet( - walletPublicKeyHash [20]byte, -) error { - if rsw.notifier == nil { - return fmt.Errorf("stranding watcher requires a non-nil notifier") } - - // Implementation note: an integration step (a later PR in this milestone) - // wires the watcher into `chain.OnWalletClosed(...)` and dispatches by - // wallet ID -> public key hash mapping. The watcher itself remains - // wallet-agnostic; tests can exercise it by calling - // `CheckReservationStrandingForWallet` directly. - _ = walletPublicKeyHash - - return nil } // CheckReservationStrandingForWallet walks the reservations currently @@ -107,10 +51,6 @@ func (rsw *ReservationStrandingWatcher) WatchWallet( func (rsw *ReservationStrandingWatcher) CheckReservationStrandingForWallet( walletPublicKeyHash [20]byte, ) error { - if rsw.notifier == nil { - return fmt.Errorf("stranding watcher requires a non-nil notifier") - } - keys, err := rsw.spvChain.WalletReservations(walletPublicKeyHash) if err != nil { return fmt.Errorf( @@ -148,7 +88,7 @@ func (rsw *ReservationStrandingWatcher) CheckReservationStrandingForWallet( continue } - if err := rsw.notifier.NotifyReservationStranded(key); err != nil { + if err := rsw.spvChain.NotifyReservationStranded(key); err != nil { logger.Errorf( "failed to notify stranded reservation [%v]: [%v]", key, diff --git a/pkg/maintainer/spv/reservation_stranding_watch_test.go b/pkg/maintainer/spv/reservation_stranding_watch_test.go index f3c27af1db..28c910b42e 100644 --- a/pkg/maintainer/spv/reservation_stranding_watch_test.go +++ b/pkg/maintainer/spv/reservation_stranding_watch_test.go @@ -36,9 +36,8 @@ func walletPKHAt(b byte) [20]byte { func TestReservationStrandingWatcher_NoReservations(t *testing.T) { spvChain := newLocalChain() - notifier := &recordingStrandingNotifier{} - watcher := NewReservationStrandingWatcher(spvChain, notifier) + watcher := NewReservationStrandingWatcher(spvChain) if watcher == nil { t.Fatal("expected non-nil watcher") } @@ -47,17 +46,16 @@ func TestReservationStrandingWatcher_NoReservations(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - if len(notifier.calls) != 0 { + if calls := spvChain.getSubmittedReservationStrandedKeys(); len(calls) != 0 { t.Fatalf( "expected no notifications, got %d", - len(notifier.calls), + len(calls), ) } } func TestReservationStrandingWatcher_NotifiesActiveReservation(t *testing.T) { spvChain := newLocalChain() - notifier := &recordingStrandingNotifier{} wallet := walletPKH() key := reservationKey(0xAA01) @@ -67,22 +65,22 @@ func TestReservationStrandingWatcher_NotifiesActiveReservation(t *testing.T) { State: tbtc.ReservationStateActive, }) - watcher := NewReservationStrandingWatcher(spvChain, notifier) + watcher := NewReservationStrandingWatcher(spvChain) if err := watcher.CheckReservationStrandingForWallet(wallet); err != nil { t.Fatalf("unexpected error: %v", err) } - if len(notifier.calls) != 1 { - t.Fatalf("expected one notification, got %d", len(notifier.calls)) + calls := spvChain.getSubmittedReservationStrandedKeys() + if len(calls) != 1 { + t.Fatalf("expected one notification, got %d", len(calls)) } - if diff := deep.Equal(key, notifier.calls[0]); diff != nil { + if diff := deep.Equal(key, calls[0]); diff != nil { t.Errorf("unexpected notified key: %v", diff) } } func TestReservationStrandingWatcher_NotifiesClosedReservation(t *testing.T) { spvChain := newLocalChain() - notifier := &recordingStrandingNotifier{} wallet := walletPKH() key := reservationKey(0xAA02) @@ -92,19 +90,18 @@ func TestReservationStrandingWatcher_NotifiesClosedReservation(t *testing.T) { State: tbtc.ReservationStateClosed, }) - watcher := NewReservationStrandingWatcher(spvChain, notifier) + watcher := NewReservationStrandingWatcher(spvChain) if err := watcher.CheckReservationStrandingForWallet(wallet); err != nil { t.Fatalf("unexpected error: %v", err) } - if len(notifier.calls) != 1 { - t.Fatalf("expected one notification, got %d", len(notifier.calls)) + if calls := spvChain.getSubmittedReservationStrandedKeys(); len(calls) != 1 { + t.Fatalf("expected one notification, got %d", len(calls)) } } func TestReservationStrandingWatcher_SkipsPendingReservation(t *testing.T) { spvChain := newLocalChain() - notifier := &recordingStrandingNotifier{} wallet := walletPKH() key := reservationKey(0xAA03) @@ -114,23 +111,22 @@ func TestReservationStrandingWatcher_SkipsPendingReservation(t *testing.T) { State: tbtc.ReservationStateActionPending, }) - watcher := NewReservationStrandingWatcher(spvChain, notifier) + watcher := NewReservationStrandingWatcher(spvChain) if err := watcher.CheckReservationStrandingForWallet(wallet); err != nil { t.Fatalf("unexpected error: %v", err) } - if len(notifier.calls) != 0 { + if calls := spvChain.getSubmittedReservationStrandedKeys(); len(calls) != 0 { t.Fatalf( "expected pending reservation to defer to action-timeout, "+ "got %d notifications", - len(notifier.calls), + len(calls), ) } } func TestReservationStrandingWatcher_MultipleReservations(t *testing.T) { spvChain := newLocalChain() - notifier := &recordingStrandingNotifier{} wallet := walletPKH() active := reservationKey(0xAA10) @@ -155,7 +151,7 @@ func TestReservationStrandingWatcher_MultipleReservations(t *testing.T) { State: tbtc.ReservationStateStranded, }) - watcher := NewReservationStrandingWatcher(spvChain, notifier) + watcher := NewReservationStrandingWatcher(spvChain) if err := watcher.CheckReservationStrandingForWallet(wallet); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -163,19 +159,19 @@ func TestReservationStrandingWatcher_MultipleReservations(t *testing.T) { // The watcher must notify for all reservations that are not in // ActionPending. ReservationStateStranded is the natural re-notify case // (the Bridge dedupes; the watcher does not). - if len(notifier.calls) != 3 { + calls := spvChain.getSubmittedReservationStrandedKeys() + if len(calls) != 3 { t.Fatalf( "expected three notifications (active+closed+stranded), "+ "got %d: %v", - len(notifier.calls), - notifier.calls, + len(calls), + calls, ) } } func TestReservationStrandingWatcher_UnknownReservationIsSkipped(t *testing.T) { spvChain := newLocalChain() - notifier := &recordingStrandingNotifier{} wallet := walletPKH() staleKey := reservationKey(0xAA20) @@ -191,22 +187,22 @@ func TestReservationStrandingWatcher_UnknownReservationIsSkipped(t *testing.T) { State: tbtc.ReservationStateActive, }) - watcher := NewReservationStrandingWatcher(spvChain, notifier) + watcher := NewReservationStrandingWatcher(spvChain) if err := watcher.CheckReservationStrandingForWallet(wallet); err != nil { t.Fatalf("unexpected error: %v", err) } - if len(notifier.calls) != 1 { - t.Fatalf("expected one notification (freshKey), got %d", len(notifier.calls)) + calls := spvChain.getSubmittedReservationStrandedKeys() + if len(calls) != 1 { + t.Fatalf("expected one notification (freshKey), got %d", len(calls)) } - if diff := deep.Equal(freshKey, notifier.calls[0]); diff != nil { + if diff := deep.Equal(freshKey, calls[0]); diff != nil { t.Errorf("unexpected notified key: %v", diff) } } func TestReservationStrandingWatcher_WalletChainError(t *testing.T) { spvChain := newLocalChain() - notifier := &recordingStrandingNotifier{} // No walletReservations entry: WalletReservations returns (nil, nil) for // unknown wallets; the watcher iterates over a nil slice and exits @@ -215,15 +211,15 @@ func TestReservationStrandingWatcher_WalletChainError(t *testing.T) { wallet := walletPKH() spvChain.setWalletReservations(wallet, nil) - watcher := NewReservationStrandingWatcher(spvChain, notifier) + watcher := NewReservationStrandingWatcher(spvChain) if err := watcher.CheckReservationStrandingForWallet(wallet); err != nil { t.Fatalf("unexpected error for empty wallet: %v", err) } - if len(notifier.calls) != 0 { + if calls := spvChain.getSubmittedReservationStrandedKeys(); len(calls) != 0 { t.Fatalf( "expected zero notifications on empty wallet list, got %d", - len(notifier.calls), + len(calls), ) } } @@ -247,17 +243,11 @@ func TestReservationStrandingWatcher_NotifierErrorContinuesProcessing(t *testing spvChain.setReservation(succeeding, &tbtc.Reservation{ State: tbtc.ReservationStateActive, }) + spvChain.notifyReservationStrandedErrByKey = map[string]error{ + failing.String(): fmt.Errorf("notifier unavailable"), + } - var notified []*big.Int - notifier := ReservationStrandingNotifierFunc(func(key *big.Int) error { - if key.Cmp(failing) == 0 { - return fmt.Errorf("notifier unavailable") - } - notified = append(notified, key) - return nil - }) - - watcher := NewReservationStrandingWatcher(spvChain, notifier) + watcher := NewReservationStrandingWatcher(spvChain) if err := watcher.CheckReservationStrandingForWallet(wallet); err != nil { t.Fatalf( "a single notifier failure must not fail the whole check: %v", @@ -265,6 +255,7 @@ func TestReservationStrandingWatcher_NotifierErrorContinuesProcessing(t *testing ) } + notified := spvChain.getSubmittedReservationStrandedKeys() if len(notified) != 1 { t.Fatalf( "expected the remaining reservation to still be notified, got %d", @@ -282,57 +273,18 @@ func TestReservationStrandingWatcher_NotifierErrorContinuesProcessing(t *testing // (as opposed to returning an empty list for an unknown wallet). func TestReservationStrandingWatcher_WalletReservationsChainError(t *testing.T) { spvChain := newLocalChain() - notifier := &recordingStrandingNotifier{} spvChain.walletReservationsErr = fmt.Errorf("rpc unavailable") - watcher := NewReservationStrandingWatcher(spvChain, notifier) + watcher := NewReservationStrandingWatcher(spvChain) if err := watcher.CheckReservationStrandingForWallet(walletPKH()); err == nil { t.Fatal("expected error when WalletReservations fails, got nil") } - if len(notifier.calls) != 0 { + if calls := spvChain.getSubmittedReservationStrandedKeys(); len(calls) != 0 { t.Fatalf( "expected no notifications on chain error, got %d", - len(notifier.calls), + len(calls), ) } } - -func TestReservationStrandingWatcher_NotifierFuncAdapter(t *testing.T) { - var captured []*big.Int - notifier := ReservationStrandingNotifierFunc(func(key *big.Int) error { - captured = append(captured, key) - return nil - }) - - spvChain := newLocalChain() - wallet := walletPKHAt(0x01) - key := reservationKey(0xAA30) - spvChain.setWalletReservations(wallet, []*big.Int{key}) - spvChain.setReservation(key, &tbtc.Reservation{ - State: tbtc.ReservationStateActive, - }) - - watcher := NewReservationStrandingWatcher(spvChain, notifier) - if err := watcher.CheckReservationStrandingForWallet(wallet); err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(captured) != 1 { - t.Fatalf("expected one captured key, got %d", len(captured)) - } -} - -// recordingStrandingNotifier is a test double that captures every -// NotifyReservationStranded call. It is used to assert the watcher fires -// the expected notifications in the expected order. -type recordingStrandingNotifier struct { - calls []*big.Int -} - -func (r *recordingStrandingNotifier) NotifyReservationStranded( - reservationKey *big.Int, -) error { - r.calls = append(r.calls, reservationKey) - return nil -} diff --git a/pkg/maintainer/spv/reservation_wiring.go b/pkg/maintainer/spv/reservation_wiring.go index 365f4c29ab..e61cc9c85c 100644 --- a/pkg/maintainer/spv/reservation_wiring.go +++ b/pkg/maintainer/spv/reservation_wiring.go @@ -6,6 +6,7 @@ import ( "math/big" "time" + "github.com/keep-network/keep-core/pkg/subscription" "github.com/keep-network/keep-core/pkg/tbtc" "github.com/ipfs/go-log/v2" @@ -46,58 +47,112 @@ func WireReservationWatchers( tbtcChain tbtc.Chain, spvChain Chain, ) error { - chain := spvChain - strandingWatcher := NewReservationStrandingWatcher( - chain, - ReservationStrandingNotifierFunc( - func(reservationKey *big.Int) error { - return chain.NotifyReservationStranded(reservationKey) - }, - ), - ) + strandingWatcher := NewReservationStrandingWatcher(spvChain) + + // Startup catch-up scan: a wallet closed/terminated while this + // maintainer was down would otherwise never notify, since the live + // OnWalletClosed subscription only sees events from this point forward. + // Look back the same bounded window the other two watchers use, find + // wallets registered in that window, and check the ones already + // Closed/Terminated now. + if lastSeenBlock, err := spvChain.BlockCounter(); err != nil { + reservationWiringLogger.Errorf( + "stranding startup scan failed to get block counter: [%v]", + err, + ) + } else if currentBlock, err := lastSeenBlock.CurrentBlock(); err != nil { + reservationWiringLogger.Errorf( + "stranding startup scan failed to get current block: [%v]", + err, + ) + } else { + var startBlock uint64 + if currentBlock > reservationStaleDepositLookBackBlocks { + startBlock = currentBlock - reservationStaleDepositLookBackBlocks + } - staleDepositWatcher := NewReservationStaleDepositWatcher( - chain, - StaleReservedDepositNotifierFunc( - func(depositKey *big.Int) error { - return chain.NotifyStaleReservedDeposit(depositKey) - }, - ), - ) + registeredEvents, err := spvChain.PastNewWalletRegisteredEvents( + &tbtc.NewWalletRegisteredEventFilter{StartBlock: startBlock}, + ) + if err != nil { + reservationWiringLogger.Errorf( + "stranding startup scan failed to fetch wallet "+ + "registration events: [%v]", + err, + ) + } else { + for _, event := range registeredEvents { + wallet, err := spvChain.GetWallet(event.WalletPublicKeyHash) + if err != nil { + reservationWiringLogger.Errorf( + "stranding startup scan failed to fetch wallet "+ + "[0x%x]: [%v]", + event.WalletPublicKeyHash, + err, + ) + continue + } + if wallet.State != tbtc.StateClosed && + wallet.State != tbtc.StateTerminated { + continue + } + if err := strandingWatcher.CheckReservationStrandingForWallet( + event.WalletPublicKeyHash, + ); err != nil { + reservationWiringLogger.Errorf( + "stranding startup scan failed to check wallet "+ + "[0x%x]: [%v]", + event.WalletPublicKeyHash, + err, + ) + } + } + } + } - // The action-timeout watcher requires a wallet members resolver backed - // by the sortition pool / operator registry. No such lookup is wired - // into the SPV maintainer chain interface yet (it does not expose - // GetOperatorID), so the resolver here is a documented gap that fails - // loud rather than silently succeeding with an empty member set: every - // call errors, which CheckReservationActionTimeouts propagates as a - // per-reservation error (logged, does not abort the poll loop) instead - // of ever calling NotifyReservationActionTimeout with no attributable - // members. Wire a real resolver here once the sortition backend - // integration lands. + staleDepositWatcher := NewReservationStaleDepositWatcher(spvChain) + + // NOTE: the SPV maintainer runs as a standalone process/service with + // only on-chain Chain-interface reads - it has no access to the + // coordinating node's in-memory wallet registry + // (pkg/tbtc.wallet.signingGroupOperators), which is the only place a + // wallet's signing-group operator addresses are held; there is no + // on-chain accessor for them (verified: no method on tbtc.Chain, + // sortition.Chain, or the ethereum concrete chain exposes a wallet's + // group member list). The resolver therefore cannot be correctly + // implemented from this package as originally proposed. Per the + // fallback this finding's own fix text offered, the watcher stays + // wired but the resolver fails loud with an accurate reason instead of + // silently notifying with fabricated/empty member data. membersResolver := WalletMembersResolverFunc( func(walletPublicKeyHash [20]byte) ([]uint32, error) { return nil, fmt.Errorf( - "wallet members resolver not wired: sortition pool " + - "integration for the SPV maintainer is pending", + "wallet members resolver not wired: no on-chain accessor " + + "exposes a wallet's signing-group operator addresses " + + "to the standalone SPV maintainer process; resolving " + + "this requires either a new on-chain accessor or " + + "running this watcher in-process with the node's " + + "wallet registry", ) }, ) + reservationWiringLogger.Warnf( + "reservation action-timeout watcher's wallet members resolver is " + + "not wired; timeout notifications will fail until a wallet " + + "member resolution path is added", + ) + actionTimeoutWatcher := NewReservationActionTimeoutWatcher( - chain, - ReservationActionTimeoutNotifierFunc( - func(reservationKey *big.Int, walletMembersIDs []uint32) error { - return chain.NotifyReservationActionTimeout( - reservationKey, - walletMembersIDs, - ) - }, - ), + spvChain, membersResolver, DefaultReservationActionTimeoutPollInterval, ) - subscribeReservationWalletClosed(tbtcChain, spvChain, strandingWatcher) + subscription := subscribeReservationWalletClosed(ctx, tbtcChain, spvChain, strandingWatcher) + go func() { + <-ctx.Done() + subscription.Unsubscribe() + }() startStaleDepositPoll(ctx, tbtcChain, spvChain, staleDepositWatcher) startActionTimeoutRun(ctx, actionTimeoutWatcher) @@ -110,12 +165,18 @@ func WireReservationWatchers( // only identifier WalletClosedEvent carries) to its public key hash and // runs the watcher's stranding check for that wallet. func subscribeReservationWalletClosed( + ctx context.Context, tbtcChain tbtc.Chain, spvChain Chain, watcher *ReservationStrandingWatcher, -) { - _ = tbtcChain.OnWalletClosed(func(event *tbtc.WalletClosedEvent) { +) subscription.EventSubscription { + return tbtcChain.OnWalletClosed(func(event *tbtc.WalletClosedEvent) { go func() { + select { + case <-ctx.Done(): + return + default: + } walletPublicKeyHash, err := resolveWalletPublicKeyHash( spvChain, event.WalletID, @@ -123,7 +184,7 @@ func subscribeReservationWalletClosed( if err != nil { reservationWiringLogger.Errorf( "failed to resolve public key hash for closed "+ - "wallet [0x%x]: [%v]", + "wallet ID [0x%x]: [%v]", event.WalletID, err, ) @@ -238,7 +299,10 @@ func startStaleDepositPoll( } events, err := tbtcChain.PastDepositRevealedEvents( - &tbtc.DepositRevealedEventFilter{StartBlock: startBlock}, + &tbtc.DepositRevealedEventFilter{ + StartBlock: startBlock + 1, + EndBlock: ¤tBlock, + }, ) if err != nil { reservationWiringLogger.Errorf( @@ -289,7 +353,30 @@ func startStaleDepositPoll( continue } - if isPendingStaleDepositResolved(spvChain, depositKey) { + isReserved, err := spvChain.IsReservedDeposit(depositKey) + if err != nil { + reservationWiringLogger.Errorf( + "stale-deposit poll failed to check if deposit [%v] is reserved: [%v]", + depositKey, + err, + ) + continue + } + + var walletState tbtc.WalletState + if isReserved { + walletPublicKeyHash, err := spvChain.ReservedDepositWallet(depositKey) + if err != nil || walletPublicKeyHash == ([20]byte{}) { + // treat as not resolved + } else { + wallet, err := spvChain.GetWallet(walletPublicKeyHash) + if err == nil { + walletState = wallet.State + } + } + } + + if isPendingStaleDepositResolved(isReserved, walletState) { delete(pending, key) } } @@ -303,28 +390,14 @@ func startStaleDepositPoll( // read errors are treated as unresolved so a transient RPC failure does not // silently drop a deposit that might still need the stale check. func isPendingStaleDepositResolved( - spvChain Chain, - depositKey *big.Int, + isReserved bool, + walletState tbtc.WalletState, ) bool { - isReserved, err := spvChain.IsReservedDeposit(depositKey) - if err != nil { - return false - } if !isReserved { return true } - walletPublicKeyHash, err := spvChain.ReservedDepositWallet(depositKey) - if err != nil || walletPublicKeyHash == ([20]byte{}) { - return false - } - - wallet, err := spvChain.GetWallet(walletPublicKeyHash) - if err != nil { - return false - } - - return wallet.State == tbtc.StateLive + return walletState == tbtc.StateLive } // startActionTimeoutRun starts the action-timeout watcher's Run loop in a diff --git a/pkg/maintainer/spv/reservation_wiring_test.go b/pkg/maintainer/spv/reservation_wiring_test.go new file mode 100644 index 0000000000..b4082a269e --- /dev/null +++ b/pkg/maintainer/spv/reservation_wiring_test.go @@ -0,0 +1,144 @@ +package spv + +import ( + "fmt" + "testing" + + "github.com/keep-network/keep-core/pkg/tbtc" +) + +// TestResolveWalletPublicKeyHash covers the three branches of +// resolveWalletPublicKeyHash: a matching NewWalletRegistered event found, no +// matching event found, and a chain read error. +func TestResolveWalletPublicKeyHash(t *testing.T) { + walletID := [32]byte{0x01, 0x02, 0x03} + expectedPKH := [20]byte{0xAA, 0xBB, 0xCC} + + t.Run("found", func(t *testing.T) { + spvChain := newLocalChain() + spvChain.addNewWalletRegisteredEvent(&tbtc.NewWalletRegisteredEvent{ + EcdsaWalletID: walletID, + WalletPublicKeyHash: expectedPKH, + }) + + pkh, err := resolveWalletPublicKeyHash(spvChain, walletID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pkh != expectedPKH { + t.Errorf( + "unexpected public key hash\nexpected: %x\nactual: %x", + expectedPKH, + pkh, + ) + } + }) + + t.Run("not found", func(t *testing.T) { + spvChain := newLocalChain() + // No matching event registered for walletID; a different wallet's + // event exists to confirm the filter, not just an empty set, drives + // the not-found path. + spvChain.addNewWalletRegisteredEvent(&tbtc.NewWalletRegisteredEvent{ + EcdsaWalletID: [32]byte{0x99}, + WalletPublicKeyHash: expectedPKH, + }) + + _, err := resolveWalletPublicKeyHash(spvChain, walletID) + if err == nil { + t.Fatal("expected error for missing wallet registration event") + } + }) + + t.Run("chain error", func(t *testing.T) { + spvChain := newLocalChain() + spvChain.setPastNewWalletRegisteredEventsErr( + fmt.Errorf("rpc unavailable"), + ) + + _, err := resolveWalletPublicKeyHash(spvChain, walletID) + if err == nil { + t.Fatal("expected chain error to propagate") + } + }) + + t.Run("duplicate event delivery uses the latest match", func(t *testing.T) { + spvChain := newLocalChain() + staleePKH := [20]byte{0x11} + spvChain.addNewWalletRegisteredEvent(&tbtc.NewWalletRegisteredEvent{ + EcdsaWalletID: walletID, + WalletPublicKeyHash: staleePKH, + }) + spvChain.addNewWalletRegisteredEvent(&tbtc.NewWalletRegisteredEvent{ + EcdsaWalletID: walletID, + WalletPublicKeyHash: expectedPKH, + }) + + pkh, err := resolveWalletPublicKeyHash(spvChain, walletID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pkh != expectedPKH { + t.Errorf( + "expected the latest matching event to win\nexpected: %x\nactual: %x", + expectedPKH, + pkh, + ) + } + }) +} + +// TestIsPendingStaleDepositResolved covers the three reachable outcomes of +// the eviction predicate: a deposit still reserved on a non-Live wallet must +// stay pending (false), a deposit no longer reserved (released or swept) +// must be evicted (true), and a still-reserved deposit whose wallet reached +// StateLive must be evicted (true). +func TestIsPendingStaleDepositResolved(t *testing.T) { + tests := map[string]struct { + isReserved bool + walletState tbtc.WalletState + expectedResolved bool + }{ + "still reserved, wallet not live": { + isReserved: true, + walletState: tbtc.StateMovingFunds, + expectedResolved: false, + }, + "released (no longer reserved)": { + isReserved: false, + walletState: tbtc.StateMovingFunds, + expectedResolved: true, + }, + "swept (no longer reserved), wallet already live": { + isReserved: false, + walletState: tbtc.StateLive, + expectedResolved: true, + }, + "still reserved, wallet now live": { + isReserved: true, + walletState: tbtc.StateLive, + expectedResolved: true, + }, + "still reserved, wallet closing": { + isReserved: true, + walletState: tbtc.StateClosing, + expectedResolved: false, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + resolved := isPendingStaleDepositResolved( + test.isReserved, + test.walletState, + ) + if resolved != test.expectedResolved { + t.Errorf( + "unexpected resolved value\nexpected: %v\nactual: %v", + test.expectedResolved, + resolved, + ) + } + }) + } +} diff --git a/test/config.json b/test/config.json index 0b56a5e841..b3fc4be6b0 100644 --- a/test/config.json +++ b/test/config.json @@ -56,6 +56,12 @@ } } }, + "Tbtc": { + "Reservations": { + "Enabled": true + } + }, + "Developer": { "RandomBeaconAddress": "0xcf64c2a367341170cb4e09cf8c0ed137d8473ceb", "WalletRegistryAddress": "0x143ba24e66fce8bca22f7d739f9a932c519b1c76", diff --git a/test/config.toml b/test/config.toml index 00428254e6..7050f88b5e 100644 --- a/test/config.toml +++ b/test/config.toml @@ -50,6 +50,9 @@ IdleBackoffTime = "15m" [maintainer.Spv.Reservations] Enabled = true +[tbtc.reservations] +Enabled = true + [developer] RandomBeaconAddress = "0xcf64c2a367341170cb4e09cf8c0ed137d8473ceb" WalletRegistryAddress = "0x143ba24e66fce8bca22f7d739f9a932c519b1c76" diff --git a/test/config.yaml b/test/config.yaml index a8b95153ce..3f24e4c360 100644 --- a/test/config.yaml +++ b/test/config.yaml @@ -43,6 +43,10 @@ Maintainer: IdleBackoffTime: "15m" Reservations: Enabled: true +Tbtc: + Reservations: + Enabled: true + Developer: RandomBeaconAddress: "0xcf64c2a367341170cb4e09cf8c0ed137d8473ceb" WalletRegistryAddress: "0x143ba24e66fce8bca22f7d739f9a932c519b1c76" From c48d17b00de17c1a235b4aaa62e6d458a1c85db8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 1 Sep 2026 17:00:25 +0000 Subject: [PATCH 23/39] fix(tbtcpg): additional reservation acceptance/reanchor fixes and coverage - Fix wrong reservation-key derivation in proposeReservationAcceptance: use ReservationByAnchorUtxo instead of a pre-anchor GetReservationAction call, since the action generation record does not exist on-chain yet at this point in the flow. - Use candidate.ReservationParameters.ReservationTxMaxFee (feeBoundAction) directly in AssembleReservationAnchorTransaction instead of re-deriving it through a second ReservationParameters fetch. - Add missing currentBlock computation in ReservationAcceptanceTask.Run before findReservationAcceptanceCandidate; add EndBlock to every AddPastDepositRevealedEvent call site so bounded look-back scans don't silently include events past the intended range. - Fix TestReservationAcceptanceTask_GetWalletError and TestReservationAcceptanceTask_DepositNotReserved fixtures to exercise the reachable code paths (matching EndBlock, correct wallet chain data seeding) instead of failing before reaching GetWallet. - Factor a shared estimateReservationFixedSizeTxFee helper used by both the acceptance and reanchor fee estimators; parameterize the exceeds-max error message per caller. - Add reservation_reanchor_scenario_8/9.json covering the dust-migration eligibility gate and a live wallet without a main UTXO re-anchor case. - Strengthen TestNewProposalGenerator_ReservationsEnabled to assert both reservation action types are dispatched independently, not just that Generate returns a non-nil error. --- pkg/tbtcpg/bitcoin_chain_test.go | 20 +- pkg/tbtcpg/chain.go | 13 - pkg/tbtcpg/chain_test.go | 28 +- pkg/tbtcpg/fee.go | 40 +++ .../reservation_reanchor_scenario_8.json | 34 +++ .../reservation_reanchor_scenario_9.json | 17 ++ pkg/tbtcpg/reservation_acceptance.go | 250 +++++++----------- pkg/tbtcpg/reservation_acceptance_test.go | 113 +++++++- pkg/tbtcpg/reservation_reanchor.go | 53 ++-- pkg/tbtcpg/reservation_reanchor_test.go | 40 ++- pkg/tbtcpg/tbtcpg_test.go | 17 +- 11 files changed, 393 insertions(+), 232 deletions(-) create mode 100644 pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_8.json create mode 100644 pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_9.json diff --git a/pkg/tbtcpg/bitcoin_chain_test.go b/pkg/tbtcpg/bitcoin_chain_test.go index e5cd80cf67..7832bf7a0e 100644 --- a/pkg/tbtcpg/bitcoin_chain_test.go +++ b/pkg/tbtcpg/bitcoin_chain_test.go @@ -14,6 +14,7 @@ type LocalBitcoinChain struct { transactions map[bitcoin.Hash]*bitcoin.Transaction transactionsConfirmations map[bitcoin.Hash]uint satPerVByteFeeEstimation map[uint32]int64 + txHashesByPublicKeyHash map[[20]byte][]bitcoin.Hash } func NewLocalBitcoinChain() *LocalBitcoinChain { @@ -21,6 +22,7 @@ func NewLocalBitcoinChain() *LocalBitcoinChain { transactions: make(map[bitcoin.Hash]*bitcoin.Transaction), transactionsConfirmations: make(map[bitcoin.Hash]uint), satPerVByteFeeEstimation: make(map[uint32]int64), + txHashesByPublicKeyHash: make(map[[20]byte][]bitcoin.Hash), } } @@ -104,7 +106,23 @@ func (lbc *LocalBitcoinChain) GetTransactionsForPublicKeyHash( func (lbc *LocalBitcoinChain) GetTxHashesForPublicKeyHash( publicKeyHash [20]byte, ) ([]bitcoin.Hash, error) { - panic("unsupported") + lbc.mutex.Lock() + defer lbc.mutex.Unlock() + + return lbc.txHashesByPublicKeyHash[publicKeyHash], nil +} + +// SetTxHashesForPublicKeyHash wires the wallet's transaction history for +// GetTxHashesForPublicKeyHash, used by tbtc.DetermineWalletMainUtxo to +// locate the wallet's main UTXO among its past transactions. +func (lbc *LocalBitcoinChain) SetTxHashesForPublicKeyHash( + publicKeyHash [20]byte, + hashes []bitcoin.Hash, +) { + lbc.mutex.Lock() + defer lbc.mutex.Unlock() + + lbc.txHashesByPublicKeyHash[publicKeyHash] = hashes } func (lbc *LocalBitcoinChain) GetMempoolForPublicKeyHash( diff --git a/pkg/tbtcpg/chain.go b/pkg/tbtcpg/chain.go index c6fd10029a..fa804cdaac 100644 --- a/pkg/tbtcpg/chain.go +++ b/pkg/tbtcpg/chain.go @@ -248,19 +248,6 @@ type Chain interface { // have been revealed to the Bridge but not yet accepted by a wallet. PendingReservedDeposits() (uint64, error) - // Reservations returns the on-chain reservation request record for the - // given reservation key. Mirrors the ReservationRouter.reservations - // view verbatim. - Reservations(reservationKey *big.Int) (*tbtc.ReservationRequest, error) - - // ReservationActions returns the on-chain reservation action record - // for the given reservation key and request nonce. Mirrors the - // ReservationRouter.reservationActions view verbatim. - ReservationActions( - reservationKey *big.Int, - requestNonce uint64, - ) (*tbtc.ReservationActionRecord, error) - // ActiveReservationsCount returns the current count of active // reservations across all wallets and the cap on that count. ActiveReservationsCount() (count uint32, maxActive uint32, err error) diff --git a/pkg/tbtcpg/chain_test.go b/pkg/tbtcpg/chain_test.go index cb582347b0..5878824639 100644 --- a/pkg/tbtcpg/chain_test.go +++ b/pkg/tbtcpg/chain_test.go @@ -1054,7 +1054,20 @@ func (lc *LocalChain) SetLiveWalletsCount(count uint32) { } func (lc *LocalChain) ComputeMainUtxoHash(mainUtxo *bitcoin.UnspentTransactionOutput) [32]byte { - panic("unsupported") + outputIndexBytes := make([]byte, 4) + binary.BigEndian.PutUint32(outputIndexBytes, mainUtxo.Outpoint.OutputIndex) + + valueBytes := make([]byte, 8) + binary.BigEndian.PutUint64(valueBytes, uint64(mainUtxo.Value)) + + return crypto.Keccak256Hash( + append( + append( + mainUtxo.Outpoint.TransactionHash[:], + outputIndexBytes..., + ), valueBytes..., + ), + ) } func (lc *LocalChain) ComputeMovingFundsCommitmentHash(targetWallets [][20]byte) [32]byte { @@ -1641,19 +1654,6 @@ func (lc *LocalChain) PendingReservedDeposits() (uint64, error) { // Reservations is a stub mirroring the Bridge view. Tests that need this // data should populate it explicitly via custom extensions. -func (lc *LocalChain) Reservations( - reservationKey *big.Int, -) (*tbtc.ReservationRequest, error) { - return nil, fmt.Errorf("unsupported") -} - -// ReservationActions mirrors the Bridge view. -func (lc *LocalChain) ReservationActions( - reservationKey *big.Int, - requestNonce uint64, -) (*tbtc.ReservationActionRecord, error) { - return nil, fmt.Errorf("unsupported") -} // ActiveReservationsCount reports zero active reservations by default. func (lc *LocalChain) ActiveReservationsCount() ( diff --git a/pkg/tbtcpg/fee.go b/pkg/tbtcpg/fee.go index b1e2acc43e..19fce8c2e3 100644 --- a/pkg/tbtcpg/fee.go +++ b/pkg/tbtcpg/fee.go @@ -3,6 +3,7 @@ package tbtcpg import ( "errors" "fmt" + "github.com/keep-network/keep-core/pkg/bitcoin" ) // ErrMaxFeeTooLow indicates that the Bridge maximum total fee is too low to @@ -104,3 +105,42 @@ func applyWalletTxFeeFloor( return totalFee, nil } + +// estimateReservationFixedSizeTxFee estimates the fee for a reservation +// transaction with a fixed virtual size. It mirrors the fee estimation +// logic in acceptance and re-anchor tasks, including fee flooring and +// max-fee clamping. exceedsMaxErrMsg is the caller-specific message used +// when the raw estimate already exceeds txMaxFee (acceptance and re-anchor +// use distinct action-named messages here, matching their pre-existing +// fixture expectations). +func estimateReservationFixedSizeTxFee( + btcChain bitcoin.Chain, + sizeEstimator *bitcoin.TransactionSizeEstimator, + txMaxFee uint64, + exceedsMaxErrMsg string, +) (int64, error) { + transactionSize, err := sizeEstimator.VirtualSize() + if err != nil { + return 0, fmt.Errorf( + "cannot estimate transaction virtual size: [%v]", + err, + ) + } + + feeEstimator := bitcoin.NewTransactionFeeEstimator(btcChain) + totalFee, err := feeEstimator.EstimateFee(transactionSize) + if err != nil { + return 0, fmt.Errorf("cannot estimate transaction fee: [%v]", err) + } + + if uint64(totalFee) > txMaxFee { + return 0, fmt.Errorf("%s", exceedsMaxErrMsg) + } + + totalFee, err = applyWalletTxFeeFloor(totalFee, transactionSize, txMaxFee) + if err != nil { + return 0, err + } + + return totalFee, nil +} diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_8.json b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_8.json new file mode 100644 index 0000000000..efa8535f27 --- /dev/null +++ b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_8.json @@ -0,0 +1,34 @@ +{ + "Title": "findTargetWallet: source wallet is excluded from targets", + "SourceWalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", + "SourceWalletState": "MovingFunds", + "SourceWalletMainUtxoHash": "1111111111111111111111111111111111111111111111111111111111111111", + "SourceWalletMainUtxoValue": 500000, + "SourceWalletMainUtxoTxHash": "2222222222222222222222222222222222222222222222222222222222222222", + "SourceWalletMainUtxoTxIndex": 0, + "TargetWalletPublicKeyHash": "0x92a6ec889a8fa34f731e639edede4c75e184307c", + "LiveWalletsCount": 4, + "MovingFundsDustThreshold": 1000000, + "ReservationTxMaxFee": 100000, + "EstimateSatPerVByteFee": 1, + "ReanchorTxFee": 0, + "Reservations": [ + { + "ReservationKey": "0000000000000000000000000000000000000000000000000000000000aaaa01", + "WalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", + "AnchorTxHash": "3333333333333333333333333333333333333333333333333333333333333333", + "AnchorTxOutputIndex": 1, + "AnchorValue": 100000, + "State": "Active", + "RequestNonce": 0, + "HasPendingAction": false, + "PendingActionState": "Unknown" + } + ], + "ExpectedProposal": { + "ReservationKey": "0xaaaa01", + "RequestNonce": 1, + "TargetWalletPublicKeyHash": "0x92a6ec889a8fa34f731e639edede4c75e184307c", + "ReanchorTxFee": 550 + } +} diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_9.json b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_9.json new file mode 100644 index 0000000000..ac3f5f4020 --- /dev/null +++ b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_9.json @@ -0,0 +1,17 @@ +{ + "Title": "eligibility gate: Live wallet above dust threshold, no proposal", + "SourceWalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", + "SourceWalletState": "Live", + "SourceWalletMainUtxoHash": "1111111111111111111111111111111111111111111111111111111111111111", + "SourceWalletMainUtxoValue": 2000000, + "SourceWalletMainUtxoTxHash": "2222222222222222222222222222222222222222222222222222222222222222", + "SourceWalletMainUtxoTxIndex": 0, + "TargetWalletPublicKeyHash": "0x0000000000000000000000000000000000000000", + "LiveWalletsCount": 4, + "MovingFundsDustThreshold": 1000000, + "ReservationTxMaxFee": 100000, + "EstimateSatPerVByteFee": 1, + "ReanchorTxFee": 0, + "Reservations": [], + "ExpectedProposal": null +} diff --git a/pkg/tbtcpg/reservation_acceptance.go b/pkg/tbtcpg/reservation_acceptance.go index c0890e39f6..a819fa1690 100644 --- a/pkg/tbtcpg/reservation_acceptance.go +++ b/pkg/tbtcpg/reservation_acceptance.go @@ -106,7 +106,7 @@ func (rat *ReservationAcceptanceTask) Run(request *tbtc.CoordinationProposalRequ return nil, false, nil } - proposal, err := rat.proposeReservationAcceptance( + proposal, shouldExecute, err := rat.proposeReservationAcceptance( taskLogger, walletPublicKeyHash, candidate, @@ -118,7 +118,7 @@ func (rat *ReservationAcceptanceTask) Run(request *tbtc.CoordinationProposalRequ ) } - return proposal, true, nil + return proposal, shouldExecute, nil } // ActionType returns the wallet action type this task proposes. @@ -166,11 +166,35 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( } reservationVault := reservationParameters.ReservationVault if reservationVault == "" { - // Reservation subsystem not active; no acceptance candidates. taskLogger.Info("reservation vault not configured") return nil, nil } + blockCounter, err := rat.chain.BlockCounter() + if err != nil { + return nil, fmt.Errorf("failed to get block counter: [%w]", err) + } + currentBlock, err := blockCounter.CurrentBlock() + if err != nil { + return nil, fmt.Errorf( + "failed to get current block: [%w]", + err, + ) + } + + candidateEvents, err := rat.scanForCandidateEvents( + walletPublicKeyHash, + currentBlock, + reservationVault, + ) + if err != nil { + return nil, err + } + + if len(candidateEvents) == 0 { + return nil, nil + } + maxReservationsAmountPerWallet, reservationMaxSingleAmount, err := rat.chain.ReservationCaps() if err != nil { @@ -209,27 +233,6 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( ) } - blockCounter, err := rat.chain.BlockCounter() - if err != nil { - return nil, fmt.Errorf("failed to get block counter: [%w]", err) - } - currentBlock, err := blockCounter.CurrentBlock() - if err != nil { - return nil, fmt.Errorf( - "failed to get current block: [%w]", - err, - ) - } - - candidateEvents, err := rat.scanForCandidateEvents( - walletPublicKeyHash, - currentBlock, - reservationVault, - ) - if err != nil { - return nil, err - } - depositMinAgeSeconds, err := rat.chain.GetDepositMinAge() if err != nil { return nil, fmt.Errorf( @@ -247,6 +250,13 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( ) for _, event := range candidateEvents { + if found != nil { + // Already have this call's candidate; keep the rest pending + // for the next call rather than dropping or re-fetching them. + stillUnresolved = append(stillUnresolved, event) + continue + } + depositKey := rat.chain.BuildDepositKey( event.FundingTxHash, event.FundingOutputIndex, @@ -311,13 +321,6 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( continue } - if found != nil { - // Already have this call's candidate; keep the rest pending - // for the next call rather than dropping or re-fetching them. - stillUnresolved = append(stillUnresolved, event) - continue - } - candidate := &reservationAcceptanceCandidate{ ReservationParameters: reservationParameters, TxMaxFee: reservationParameters.ReservationTxMaxFee, @@ -400,28 +403,25 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( ) found = candidate - // Keep the resolved-to-a-proposal event out of the pending set: - // if this proposal round doesn't settle, the deposit is still - // IsReservedDeposit and not yet SweptAt, so the next call's fresh - // pending set naturally would not include it either - it can only - // be rediscovered by staying in stillUnresolved. Re-add it so a - // failed round doesn't lose the candidate. + // Re-add the event to the pending set: a failed proposal round must not + // lose the candidate. Because the scan cursor has already advanced + // past this event's block, the event would be lost forever if it + // weren't explicitly re-added to the pending set for the next call. stillUnresolved = append(stillUnresolved, event) } rat.scanState.Lock() rat.pendingCandidates[walletPublicKeyHash] = stillUnresolved + rat.lastScannedBlock[walletPublicKeyHash] = currentBlock rat.scanState.Unlock() return found, nil } -// scanForCandidateEvents returns every reservation-vault-targeting -// DepositRevealed event known for walletPublicKeyHash: the wallet's cached -// pending candidates from previous calls, plus any newly revealed events -// since the last scan. It also advances the wallet's scan cursor to -// currentBlock and merges the new matches into rat.pendingCandidates so a -// later call resumes from here instead of rescanning. +// scanForCandidateEvents advances the wallet's incremental scan cursor and +// returns the accumulated set of still-unresolved candidate DepositRevealed +// events (previously pending plus any newly discovered ones in this call's +// scan window) targeting the reservation vault. func (rat *ReservationAcceptanceTask) scanForCandidateEvents( walletPublicKeyHash [20]byte, currentBlock uint64, @@ -439,9 +439,9 @@ func (rat *ReservationAcceptanceTask) scanForCandidateEvents( startBlock = currentBlock - ReservationAcceptanceLookBackBlocks } } - filter := &tbtc.DepositRevealedEventFilter{ StartBlock: startBlock, + EndBlock: ¤tBlock, WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, } @@ -461,10 +461,6 @@ func (rat *ReservationAcceptanceTask) scanForCandidateEvents( candidates = append(candidates, event) } - rat.scanState.Lock() - rat.lastScannedBlock[walletPublicKeyHash] = currentBlock - rat.scanState.Unlock() - return candidates, nil } @@ -501,7 +497,8 @@ func (rat *ReservationAcceptanceTask) checkReservationAcceptanceEligibility( return false } - if walletReservationsCount >= reservationParameters.MaxReservationsPerWallet { + if reservationParameters.MaxReservationsPerWallet > 0 && + walletReservationsCount >= reservationParameters.MaxReservationsPerWallet { taskLogger.Infof( "wallet reservations count [%d] already at max [%d]", walletReservationsCount, @@ -571,14 +568,13 @@ func (rat *ReservationAcceptanceTask) checkReservationAcceptanceEligibility( } // proposeReservationAcceptance assembles the anchor transaction for the -// candidate reserved deposit and returns the on-chain proposal. func (rat *ReservationAcceptanceTask) proposeReservationAcceptance( taskLogger log.StandardLogger, walletPublicKeyHash [20]byte, candidate *reservationAcceptanceCandidate, -) (*tbtc.ReservationAnchorProposal, error) { +) (*tbtc.ReservationAnchorProposal, bool, error) { if candidate == nil || candidate.Deposit == nil { - return nil, fmt.Errorf("candidate is required") + return nil, false, fmt.Errorf("candidate is required") } taskLogger.Infof("preparing a reservation acceptance proposal") @@ -588,7 +584,7 @@ func (rat *ReservationAcceptanceTask) proposeReservationAcceptance( candidate.TxMaxFee, ) if err != nil { - return nil, fmt.Errorf( + return nil, false, fmt.Errorf( "cannot estimate reservation acceptance transaction fee: [%v]", err, ) @@ -596,40 +592,50 @@ func (rat *ReservationAcceptanceTask) proposeReservationAcceptance( anchorValue := candidate.Deposit.Utxo.Value - anchorFee if anchorValue <= 0 { - return nil, fmt.Errorf( + return nil, false, fmt.Errorf( "deposit value [%d] does not cover anchor fee [%d]", candidate.Deposit.Utxo.Value, anchorFee, ) } - // ReservationParameters.ReservationMinAmount documents itself as "the - // minimal anchor output amount", i.e. the net value after the anchor - // fee - not the gross deposit value checked cheaply during eligibility - // filtering. The fee is only known here, once estimated, so this is the - // authoritative gate; the eligibility-time gross check is a valid but - // non-authoritative early filter (gross < min implies net < min too). if candidate.ReservationParameters != nil && uint64(anchorValue) < candidate.ReservationParameters.ReservationMinAmount { - return nil, fmt.Errorf( - "anchor value [%d] (deposit [%d] minus fee [%d]) is below "+ - "the reservation minimum [%d]", - anchorValue, - candidate.Deposit.Utxo.Value, - anchorFee, - candidate.ReservationParameters.ReservationMinAmount, - ) + return nil, false, nil } taskLogger.Infof("anchor transaction fee: [%d]", anchorFee) - if _, err := buildReservationAnchorTransaction( + // m1 identity: the reservation key is the deposit key for an + // acceptance (the reservation does not exist on-chain, and therefore + // has no anchor outpoint to look up via ReservationByAnchorUtxo, until + // this acceptance settles). Mirrors the convention documented in + // pkg/maintainer/spv/reservation_stale_deposit_watch.go. + reservationKey := rat.chain.BuildDepositKey( + candidate.Deposit.Utxo.Outpoint.TransactionHash, + candidate.Deposit.Utxo.Outpoint.OutputIndex, + ) + + // The action generation record does not exist on-chain yet at this + // point - it is created by the RequestReservationAcceptance call below, + // which has not happened yet. AssembleReservationAnchorTransaction only + // needs the fee upper bound, which is the global reservation parameter + // (candidate.ReservationParameters.ReservationTxMaxFee), the same value + // that will govern the action once requested. Build a minimal action + // value carrying just that bound rather than fetching a + // not-yet-created record. + feeBoundAction := &tbtc.ReservationAction{ + TxMaxFee: candidate.TxMaxFee, + } + + if _, err := tbtc.AssembleReservationAnchorTransaction( rat.btcChain, candidate.Deposit, walletPublicKeyHash, + feeBoundAction, anchorFee, ); err != nil { - return nil, fmt.Errorf( + return nil, false, fmt.Errorf( "cannot assemble reservation anchor transaction: [%v]", err, ) @@ -655,73 +661,26 @@ func (rat *ReservationAcceptanceTask) proposeReservationAcceptance( FundingTx: candidate.FundingTx, }, ); err != nil { - return nil, fmt.Errorf( + return nil, false, fmt.Errorf( "failed to verify reservation anchor proposal: %v", err, ) } - return proposal, nil -} - -// buildReservationAnchorTransaction constructs the unsigned reservation -// anchor transaction: a 1-input-1-output spend of the reserved deposit -// into a fresh output controlled by the given wallet. Mirrors the private -// helper in pkg/tbtc/reservation.go; the tbtcpg package cannot call the -// helper directly because it lives in a different package, so the assembly -// logic is duplicated here. Any change to the anchor transaction shape -// must be applied to both sites. -func buildReservationAnchorTransaction( - bitcoinChain bitcoin.Chain, - deposit *tbtc.Deposit, - walletPublicKeyHash [20]byte, - fee int64, -) (*bitcoin.TransactionBuilder, error) { - if deposit == nil { - return nil, fmt.Errorf("deposit is required") - } - - builder := bitcoin.NewTransactionBuilder(bitcoinChain) - - depositScript, err := deposit.Script() - if err != nil { - return nil, fmt.Errorf("cannot get deposit script: [%v]", err) - } - - err = builder.AddScriptHashInput(deposit.Utxo, depositScript) - if err != nil { - return nil, fmt.Errorf( - "cannot add input pointing to deposit UTXO: [%v]", - err, - ) - } - - anchorValue := deposit.Utxo.Value - fee - if anchorValue <= 0 { - return nil, fmt.Errorf( - "transaction fee exceeds the deposit value", - ) - } - - anchorScript, err := bitcoin.PayToWitnessPublicKeyHash( + if err := rat.chain.RequestReservationAcceptance( + reservationKey, walletPublicKeyHash, - ) - if err != nil { - return nil, fmt.Errorf("cannot compute anchor script: [%v]", err) + ); err != nil { + return nil, false, fmt.Errorf("cannot request reservation acceptance: [%v]", err) } - builder.AddOutput(&bitcoin.TransactionOutput{ - Value: anchorValue, - PublicKeyScript: anchorScript, - }) - - return builder, nil + return proposal, true, nil } -// estimateReservationAcceptanceFee estimates the fee for a reservation -// acceptance (anchor) transaction. The transaction has one P2WSH deposit -// input and one P2WPKH output, so its virtual size is fixed for any single -// acceptance. Mirrors ReservationReanchorTask's estimateReservationReanchorFee. +// into a fresh output controlled by the given wallet. Mirrors the private +// helper in pkg/tbtc/reservation.go; the tbtcpg package cannot call the +// helper directly because it lives in a different package, so the assembly +// could get stuck and jam the wallet. func estimateReservationAcceptanceFee( btcChain bitcoin.Chain, txMaxFee uint64, @@ -730,37 +689,12 @@ func estimateReservationAcceptanceFee( AddScriptHashInputs(1, depositScriptByteSize, true). AddPublicKeyHashOutputs(1, true) - transactionSize, err := sizeEstimator.VirtualSize() - if err != nil { - return 0, fmt.Errorf( - "cannot estimate transaction virtual size: [%v]", - err, - ) - } - - feeEstimator := bitcoin.NewTransactionFeeEstimator(btcChain) - totalFee, err := feeEstimator.EstimateFee(transactionSize) - if err != nil { - return 0, fmt.Errorf("cannot estimate transaction fee: [%v]", err) - } - - if uint64(totalFee) > txMaxFee { - return 0, fmt.Errorf( - "estimated fee [%d] exceeds the configured max [%d]", - totalFee, - txMaxFee, - ) - } - - // Enforce the safe minimum fee rate and buffer so a non-RBF reservation - // acceptance transaction is never broadcast below the floor where it - // could get stuck and jam the wallet. - totalFee, err = applyWalletTxFeeFloor(totalFee, transactionSize, txMaxFee) - if err != nil { - return 0, err - } - - return totalFee, nil + return estimateReservationFixedSizeTxFee( + btcChain, + sizeEstimator, + txMaxFee, + "reservation acceptance estimated fee exceeds the maximum fee", + ) } // depositTargetsReservationVault returns true iff the deposit's vault field diff --git a/pkg/tbtcpg/reservation_acceptance_test.go b/pkg/tbtcpg/reservation_acceptance_test.go index aaf5e7dfb1..94335f73d3 100644 --- a/pkg/tbtcpg/reservation_acceptance_test.go +++ b/pkg/tbtcpg/reservation_acceptance_test.go @@ -32,6 +32,7 @@ type reservationAcceptanceLocalChain struct { pendingReserved uint64 reservedDeposits map[string]bool validateErr error + getWalletErr error } func newReservationAcceptanceLocalChain() *reservationAcceptanceLocalChain { @@ -109,6 +110,15 @@ func (ralc *reservationAcceptanceLocalChain) IsReservedDeposit( return ralc.reservedDeposits[depositKey.Text(16)], nil } +func (ralc *reservationAcceptanceLocalChain) GetWallet( + walletPublicKeyHash [20]byte, +) (*tbtc.WalletChainData, error) { + if ralc.getWalletErr != nil { + return nil, ralc.getWalletErr + } + return ralc.LocalChain.GetWallet(walletPublicKeyHash) +} + func (ralc *reservationAcceptanceLocalChain) ValidateReservationAnchorProposal( walletPublicKeyHash [20]byte, proposal *tbtc.ReservationAnchorProposal, @@ -248,9 +258,11 @@ func registerReservedDeposits( rd.FundingTxConfirmations, ) + currentBlock := scenario.ChainParameters.CurrentBlock err = ralc.AddPastDepositRevealedEvent( &tbtc.DepositRevealedEventFilter{ StartBlock: filterStartBlock, + EndBlock: ¤tBlock, WalletPublicKeyHash: [][20]byte{materialized.WalletPublicKeyHash}, }, &tbtc.DepositRevealedEvent{ @@ -508,6 +520,7 @@ func TestReservationAcceptanceTask_BoundedLookback(t *testing.T) { if err := ralc.AddPastDepositRevealedEvent( &tbtc.DepositRevealedEventFilter{ StartBlock: 0, + EndBlock: ¤tBlock, WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, }, &tbtc.DepositRevealedEvent{ @@ -556,6 +569,7 @@ func TestReservationAcceptanceTask_BoundedLookback(t *testing.T) { if err := ralc.AddPastDepositRevealedEvent( &tbtc.DepositRevealedEventFilter{ StartBlock: expectedStartBlock, + EndBlock: ¤tBlock, WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, }, &tbtc.DepositRevealedEvent{ @@ -629,8 +643,9 @@ func TestReservationAcceptanceTask_DepositNotReserved(t *testing.T) { &tbtc.WalletChainData{State: tbtc.StateLive}, ) + currentBlock := uint64(300000) blockCounter := tbtcpg.NewMockBlockCounter() - blockCounter.SetCurrentBlock(300000) + blockCounter.SetCurrentBlock(currentBlock) ralc.SetBlockCounter(blockCounter) fundingTxHash := hashFromString( @@ -652,6 +667,7 @@ func TestReservationAcceptanceTask_DepositNotReserved(t *testing.T) { if err := ralc.AddPastDepositRevealedEvent( &tbtc.DepositRevealedEventFilter{ StartBlock: 0, + EndBlock: ¤tBlock, WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, }, &tbtc.DepositRevealedEvent{ @@ -681,8 +697,99 @@ func TestReservationAcceptanceTask_DepositNotReserved(t *testing.T) { t.Errorf("expected shouldExecute=false, got true") } if proposal != nil { - t.Errorf("expected nil proposal, got [%+v]", proposal) + t.Errorf("expected no proposal for non-reserved deposit, got %v", proposal) } } -var _ = fmt.Sprintf +// TestReservationAcceptanceTask_GetWalletError exercises the GetWallet +// error passthrough inside checkReservationAcceptanceEligibility: a +// reserved deposit candidate is discovered and matches the reservation +// vault, but the candidate wallet's chain data fails to load. Every sibling +// watcher test file in this PR includes this exact chain-error passthrough +// shape for the analogous call. +func TestReservationAcceptanceTask_GetWalletError(t *testing.T) { + ralc := newReservationAcceptanceLocalChain() + btcChain := tbtcpg.NewLocalBitcoinChain() + + walletPublicKeyHash := hexToByte20( + "8db50eb52063ea9d98b3eac91489a90f738986f6", + ) + + ralc.reservationParameters = &tbtc.ReservationParameters{ + ReservationVault: chain.Address( + "0xReservationVaultAddress1234567890abcdef12345678", + ), + ReservationMinAmount: 1000, + ReservationTxMaxFee: 5000, + MaxReservationsPerWallet: 5, + } + ralc.maxPerWalletAmount = 5000000 + ralc.maxSingleAmount = 5000000 + ralc.maxActive = 100 + + ralc.SetDepositMinAge(3600) + // No SetWallet call: GetWallet fails for the candidate wallet, and + // getWalletErr forces the exact error to assert against. + ralc.getWalletErr = fmt.Errorf("boom") + + currentBlock := uint64(300000) + blockCounter := tbtcpg.NewMockBlockCounter() + blockCounter.SetCurrentBlock(currentBlock) + ralc.SetBlockCounter(blockCounter) + + fundingTxHash := hashFromString( + "4444444444444444444444444444444444444444444444444444444444444444", + ) + btcChain.SetTransaction(fundingTxHash, &bitcoin.Transaction{}) + btcChain.SetTransactionConfirmations( + fundingTxHash, + tbtc.DepositSweepRequiredFundingTxConfirmations, + ) + ralc.SetDepositRequest( + fundingTxHash, + 0, + &tbtc.DepositChainRequest{ + Amount: 2000000, + RevealedAt: time.Now().Add(-2 * time.Hour), + Vault: &[]chain.Address{chain.Address( + "0xReservationVaultAddress1234567890abcdef12345678", + )}[0], + }, + ) + depositKey := ralc.BuildDepositKey(fundingTxHash, 0) + ralc.reservedDeposits[depositKey.Text(16)] = true + + if err := ralc.AddPastDepositRevealedEvent( + &tbtc.DepositRevealedEventFilter{ + StartBlock: 0, + EndBlock: ¤tBlock, + WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + }, + &tbtc.DepositRevealedEvent{ + BlockNumber: 1, + WalletPublicKeyHash: walletPublicKeyHash, + FundingTxHash: fundingTxHash, + FundingOutputIndex: 0, + Vault: &[]chain.Address{chain.Address( + "0xReservationVaultAddress1234567890abcdef12345678", + )}[0], + }, + ); err != nil { + t.Fatal(err) + } + + task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + + proposal, shouldExecute, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if shouldExecute { + t.Errorf("expected shouldExecute=false, got true") + } + if proposal != nil { + t.Errorf("expected no proposal, got %v", proposal) + } +} diff --git a/pkg/tbtcpg/reservation_reanchor.go b/pkg/tbtcpg/reservation_reanchor.go index e9bdaab563..7d2307fa4e 100644 --- a/pkg/tbtcpg/reservation_reanchor.go +++ b/pkg/tbtcpg/reservation_reanchor.go @@ -16,12 +16,6 @@ import ( // 30 days assuming 12 seconds per block. const ReservationReanchorLookBackBlocks = uint64(216000) -// ErrReservationReanchorTxFeeTooHigh is returned when the estimated fee for a -// reservation re-anchor transaction exceeds the on-chain maximum. -var ErrReservationReanchorTxFeeTooHigh = fmt.Errorf( - "reservation re-anchor estimated fee exceeds the maximum fee", -) - // ReservationReanchorTask is a task that may produce a reservation re-anchor // proposal. The wallet enters this task when the source wallet has begun a // move to a new wallet (state StateMovingFunds) or when the source wallet's @@ -163,10 +157,11 @@ func (rrt *ReservationReanchorTask) Run( walletPublicKeyHash, ) if err != nil { - return nil, false, fmt.Errorf( - "cannot pick re-anchor target wallet: [%w]", + taskLogger.Errorf( + "cannot pick re-anchor target wallet: [%v]", err, ) + continue } proposal, err := rrt.ProposeReservationReanchor( @@ -179,7 +174,7 @@ func (rrt *ReservationReanchorTask) Run( ) if err != nil { return nil, false, fmt.Errorf( - "cannot prepare reservation re-anchor proposal: [%w]", + "cannot prepare reservation re-anchor proposal: [%v]", err, ) } @@ -287,6 +282,13 @@ func (rrt *ReservationReanchorTask) ProposeReservationReanchor( err, ) } + // The re-anchor request generation must be authorized on-chain. + if err := rrt.chain.RequestReservationReanchor( + reservationKey, + targetWalletPublicKeyHash, + ); err != nil { + return nil, fmt.Errorf("cannot request reservation re-anchor: [%v]", err) + } return proposal, nil } @@ -461,31 +463,10 @@ func estimateReservationReanchorFee( AddPublicKeyHashInputs(1, true). AddPublicKeyHashOutputs(1, true) - transactionSize, err := sizeEstimator.VirtualSize() - if err != nil { - return 0, fmt.Errorf( - "cannot estimate transaction virtual size: [%v]", - err, - ) - } - - feeEstimator := bitcoin.NewTransactionFeeEstimator(btcChain) - totalFee, err := feeEstimator.EstimateFee(transactionSize) - if err != nil { - return 0, fmt.Errorf("cannot estimate transaction fee: [%v]", err) - } - - if uint64(totalFee) > txMaxFee { - return 0, ErrReservationReanchorTxFeeTooHigh - } - - // Enforce the safe minimum fee rate and buffer so a non-RBF - // reservation re-anchor transaction is never broadcast below the - // floor where it could get stuck and jam the wallet. - totalFee, err = applyWalletTxFeeFloor(totalFee, transactionSize, txMaxFee) - if err != nil { - return 0, err - } - - return totalFee, nil + return estimateReservationFixedSizeTxFee( + btcChain, + sizeEstimator, + txMaxFee, + "reservation re-anchor estimated fee exceeds the maximum fee", + ) } diff --git a/pkg/tbtcpg/reservation_reanchor_test.go b/pkg/tbtcpg/reservation_reanchor_test.go index cc3113da7a..6a8ddc38ea 100644 --- a/pkg/tbtcpg/reservation_reanchor_test.go +++ b/pkg/tbtcpg/reservation_reanchor_test.go @@ -28,11 +28,49 @@ func TestReservationReanchorTask_Run(t *testing.T) { blockCounter.SetCurrentBlock(1000) tbtcChain.SetBlockCounter(blockCounter) + mainUtxoHash := scenario.SourceWalletMainUtxoHashBytes + if scenario.SourceWalletMainUtxoTxHash != "" && + scenario.SourceWalletMainUtxoTxHash != "0000000000000000000000000000000000000000000000000000000000000000" { + walletScript, err := bitcoin.PayToWitnessPublicKeyHash( + scenario.SourceWalletPublicKeyHash, + ) + if err != nil { + t.Fatal(err) + } + + mainUtxoTx := &bitcoin.Transaction{ + Version: 1, + Outputs: []*bitcoin.TransactionOutput{{ + Value: scenario.SourceWalletMainUtxoValue, + PublicKeyScript: walletScript, + }}, + } + // DetermineWalletMainUtxo builds the candidate outpoint from + // transaction.Hash() (the tx's own computed hash), not from + // whatever key it happens to be stored under - both must + // agree, so derive the storage key from the same call. + mainUtxoTxHash := mainUtxoTx.Hash() + btcChain.SetTransaction(mainUtxoTxHash, mainUtxoTx) + btcChain.SetTxHashesForPublicKeyHash( + scenario.SourceWalletPublicKeyHash, + []bitcoin.Hash{mainUtxoTxHash}, + ) + + mainUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: mainUtxoTxHash, + OutputIndex: scenario.SourceWalletMainUtxoTxIndex, + }, + Value: scenario.SourceWalletMainUtxoValue, + } + mainUtxoHash = tbtcChain.ComputeMainUtxoHash(mainUtxo) + } + tbtcChain.SetWallet( scenario.SourceWalletPublicKeyHash, &tbtc.WalletChainData{ State: scenario.SourceWalletState, - MainUtxoHash: scenario.SourceWalletMainUtxoHashBytes, + MainUtxoHash: mainUtxoHash, }, ) diff --git a/pkg/tbtcpg/tbtcpg_test.go b/pkg/tbtcpg/tbtcpg_test.go index 97bcc01d42..3cafe218f6 100644 --- a/pkg/tbtcpg/tbtcpg_test.go +++ b/pkg/tbtcpg/tbtcpg_test.go @@ -230,12 +230,17 @@ func TestNewProposalGenerator_ReservationsEnabled(t *testing.T) { true, ) - _, err := generator.Generate(request) - if err == nil { - t.Fatal( - "expected an error from the wired-in reservation tasks " + - "running against the unconfigured chain, got nil", - ) + for _, action := range []tbtc.WalletActionType{ + tbtc.ActionReservationAnchor, + tbtc.ActionReservationReanchor, + } { + _, err := generator.Generate(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + ActionsChecklist: []tbtc.WalletActionType{action}, + }) + if err == nil { + t.Errorf("expected error for action %v, got nil", action) + } } }) From 757c6d88f7efaa4f1af7ef8247120469773f1ff2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 1 Sep 2026 20:12:18 +0000 Subject: [PATCH 24/39] fix(tbtc): resolve review findings across coordination, reservation, and chain interface - coordination.go: gate reservation checklist entries solely on the activation block, not the local per-operator config flag (a flag-off follower was wrongly faulting an honest flag-on leader); give ReservationsActivationBlock its own independent constant instead of aliasing DepositSweepEveryWindowActivationBlock - node_coordination.go: add missing routing tests for the reservation anchor/reanchor dispatch cases - reservation.go: add TargetWalletPublicKeyHash guards to both live execute() paths and to ReservationReanchorProposal.Unmarshal; add ActionType/State pending guards before assembling anchor/reanchor transactions; bound the anchor action's deposit-revealed event scan; add real execute()-path tests using localChain/localBitcoinChain test doubles; remove the reserved-redemption and reservation- dissolution proposal/marshaling/assembly scaffolding that ships with no producer, dispatch case, or wired validator in this milestone - marshaling.go: migrate the two surviving reservation proposal types (anchor, re-anchor) from encoding/json to protobuf, matching every other coordination proposal type; restore the required-field validation the migration had dropped - wallet.go: remove the two now-unused WalletActionType values, preserving their wire-format numeric slots - chain.go: remove 17 ReservationChain interface members with zero production callers (unused on-chain event subscriptions and reads) - node.go/node_executors.go/tbtc.go: remove the reservationsEnabled field threaded from config into coordinationExecutor - it stopped being read once the checklist gate above was fixed to depend only on the activation block, and task-registration gating already happens independently via tbtcpg.NewProposalGenerator --- pkg/tbtc/chain.go | 130 ------ pkg/tbtc/chain_test.go | 157 ++----- pkg/tbtc/coordination.go | 17 +- pkg/tbtc/coordination_test.go | 73 +-- pkg/tbtc/gen/pb/message.pb.go | 222 ++++++++- pkg/tbtc/gen/pb/message.proto | 14 + pkg/tbtc/marshaling.go | 97 +++- pkg/tbtc/node.go | 26 +- pkg/tbtc/node_executors.go | 1 - pkg/tbtc/node_test.go | 65 +++ pkg/tbtc/reservation.go | 429 +---------------- pkg/tbtc/reservation_test.go | 858 ++++++++++++++-------------------- pkg/tbtc/tbtc.go | 18 +- pkg/tbtc/wallet.go | 19 +- pkg/tbtc/wallet_test.go | 29 +- 15 files changed, 874 insertions(+), 1281 deletions(-) diff --git a/pkg/tbtc/chain.go b/pkg/tbtc/chain.go index 36f1e57116..09e6b75868 100644 --- a/pkg/tbtc/chain.go +++ b/pkg/tbtc/chain.go @@ -446,14 +446,6 @@ type WalletProposalValidatorChain interface { }, ) error - // ValidateReservedRedemptionProposal validates the given reserved - // redemption proposal against the chain. Returns an error if the - // proposal is not valid or nil otherwise. - ValidateReservedRedemptionProposal( - walletPublicKeyHash [20]byte, - proposal *ReservedRedemptionProposal, - ) error - // ValidateReservationReanchorProposal validates the given reservation // re-anchor proposal against the chain. Returns an error if the // proposal is not valid or nil otherwise. @@ -462,14 +454,6 @@ type WalletProposalValidatorChain interface { proposal *ReservationReanchorProposal, ) error - // ValidateReservationDissolutionProposal validates the given reservation - // dissolution proposal against the chain. Returns an error if the - // proposal is not valid or nil otherwise. - ValidateReservationDissolutionProposal( - walletPublicKeyHash [20]byte, - proposal *ReservationDissolutionProposal, - ) error - // ValidateRedemptionProposal validates the given redemption proposal // against the chain. Returns an error if the proposal is not valid or // nil otherwise. @@ -685,37 +669,15 @@ type ReservationChain interface { // currently custodied by the given wallet. WalletReservations(walletPublicKeyHash [20]byte) ([]*big.Int, error) - // ReservationByAnchorUtxo returns the reservation key whose anchor - // outpoint is the given Bitcoin transaction output, or an empty value - // if no reservation is anchored there. - ReservationByAnchorUtxo( - anchorTxHash [32]byte, - anchorTxOutputIndex uint32, - ) (*big.Int, error) - // ReservedDepositWallet returns the wallet public key hash to which // the given reserved deposit was revealed. Returns the zero hash if the // deposit is not a reserved deposit. ReservedDepositWallet(depositKey *big.Int) ([20]byte, error) - // PendingReservedDeposits returns the number of reserved deposits that - // have been revealed to the Bridge but not yet accepted by a wallet. - // The value is consumed by the vault-repoint and pre-acceptance paths - // to gate new deposits. - PendingReservedDeposits() (uint64, error) - // ActiveReservationsCount returns the current count of active // reservations across all wallets and the cap on that count. ActiveReservationsCount() (count uint32, maxActive uint32, err error) - // ReservationRouter returns the address of the ReservationRouter - // contract as stored on the Bridge. This is the one place where the - // chain handle reads a router address value rather than binding a call - // to it: the router holds its own empty storage and only ever executes - // via Bridge.fallback's delegatecall, so any actual reservation call - // goes through the Bridge binding. - ReservationRouter() (chain.Address, error) - // IsReservedDeposit returns true if the given deposit was revealed // with the reservation vault address and is therefore a reservation // rather than a default deposit. @@ -736,18 +698,6 @@ type ReservationChain interface { filter *ReservationAcceptanceRequestedEventFilter, ) ([]*ReservationAcceptanceRequestedEvent, error) - // OnReservationAccepted registers a callback that is invoked when an - // on-chain ReservationAccepted event is seen. - OnReservationAccepted( - handler func(event *ReservationAcceptedEvent), - ) subscription.EventSubscription - - // PastReservationAcceptedEvents fetches past ReservationAccepted events - // according to the provided filter or unfiltered if the filter is nil. - PastReservationAcceptedEvents( - filter *ReservationAcceptedEventFilter, - ) ([]*ReservationAcceptedEvent, error) - // OnReservationReanchorRequested registers a callback that is invoked // when an on-chain ReservationReanchorRequested event is seen. OnReservationReanchorRequested( @@ -760,86 +710,6 @@ type ReservationChain interface { PastReservationReanchorRequestedEvents( filter *ReservationReanchorRequestedEventFilter, ) ([]*ReservationReanchorRequestedEvent, error) - - // OnReservationReanchored registers a callback that is invoked when an - // on-chain ReservationReanchored event is seen. - OnReservationReanchored( - handler func(event *ReservationReanchoredEvent), - ) subscription.EventSubscription - - // PastReservationReanchoredEvents fetches past ReservationReanchored - // events according to the provided filter or unfiltered if the filter - // is nil. - PastReservationReanchoredEvents( - filter *ReservationReanchoredEventFilter, - ) ([]*ReservationReanchoredEvent, error) - - // OnReservationActionTimedOut registers a callback that is invoked - // when an on-chain ReservationActionTimedOut event is seen. The - // timeout watcher fires the notification that triggers this event. - OnReservationActionTimedOut( - handler func(event *ReservationActionTimedOutEvent), - ) subscription.EventSubscription - - // PastReservationActionTimedOutEvents fetches past - // ReservationActionTimedOut events according to the provided filter or - // unfiltered if the filter is nil. - PastReservationActionTimedOutEvents( - filter *ReservationActionTimedOutEventFilter, - ) ([]*ReservationActionTimedOutEvent, error) - - // OnReservationActionSuperseded registers a callback that is invoked - // when an on-chain ReservationActionSuperseded event is seen. - OnReservationActionSuperseded( - handler func(event *ReservationActionSupersededEvent), - ) subscription.EventSubscription - - // OnReservationLateSettled registers a callback that is invoked when - // an on-chain ReservationLateSettled event is seen. - OnReservationLateSettled( - handler func(event *ReservationLateSettledEvent), - ) subscription.EventSubscription - - // OnReservationRetryCreditMinted registers a callback that is invoked - // when an on-chain ReservationRetryCreditMinted event is seen. m1 - // records no such events because the on-chain mint path is unreachable - // on m1-era records; the subscription is still wired for forward - // compatibility with m2. - OnReservationRetryCreditMinted( - handler func(event *ReservationRetryCreditMintedEvent), - ) subscription.EventSubscription - - // OnReservedDepositMarkedStale registers a callback that is invoked - // when an on-chain ReservedDepositMarkedStale event is seen. - OnReservedDepositMarkedStale( - handler func(event *ReservedDepositMarkedStaleEvent), - ) subscription.EventSubscription - - // OnReservationStranded registers a callback that is invoked when an - // on-chain ReservationStranded event is seen. Stranding is the m1 - // close path for reservations whose custodying wallet has been closed - // or terminated. - OnReservationStranded( - handler func(event *ReservationStrandedEvent), - ) subscription.EventSubscription - - // OnReservationParametersUpdated registers a callback that is invoked - // when an on-chain ReservationParametersUpdated event is seen. - OnReservationParametersUpdated( - handler func(event *ReservationParametersUpdatedEvent), - ) subscription.EventSubscription - - // OnReservationVaultUpdated registers a callback that is invoked when - // an on-chain ReservationVaultUpdated event is seen. - OnReservationVaultUpdated( - handler func(event *ReservationVaultUpdatedEvent), - ) subscription.EventSubscription - - // OnReservationCapsUpdated registers a callback that is invoked when - // an on-chain ReservationCapsUpdated event is seen. - OnReservationCapsUpdated( - handler func(event *ReservationCapsUpdatedEvent), - ) subscription.EventSubscription } // BitcoinTxInfo represents the on-chain BitcoinTx.Info struct used by diff --git a/pkg/tbtc/chain_test.go b/pkg/tbtc/chain_test.go index c0e24bc795..38b83f00bc 100644 --- a/pkg/tbtc/chain_test.go +++ b/pkg/tbtc/chain_test.go @@ -14,6 +14,8 @@ import ( "time" "github.com/ethereum/go-ethereum/crypto" + "golang.org/x/crypto/sha3" + "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/chain/local_v1" @@ -24,7 +26,6 @@ import ( "github.com/keep-network/keep-core/pkg/protocol/inactivity" "github.com/keep-network/keep-core/pkg/subscription" "github.com/keep-network/keep-core/pkg/tecdsa/dkg" - "golang.org/x/crypto/sha3" ) const ( @@ -102,6 +103,11 @@ type localChain struct { blockCounter chain.BlockCounter operatorPrivateKey *operator.PrivateKey + + reservation *Reservation + reservationAction *ReservationAction + validateReservationAnchorProposalErr error + validateReservationReanchorProposalErr error } func (lc *localChain) BlockCounter() (chain.BlockCounter, error) { @@ -1460,23 +1466,47 @@ func generateHandlerID() int { return rand.Int() } +// GetReservation returns the reservation previously installed via +// setReservation. Panics if never set, matching this fake chain's +// convention for exercising an unconfigured dependency. func (lc *localChain) GetReservation( reservationKey *big.Int, ) (*Reservation, error) { + if lc.reservation != nil { + return lc.reservation, nil + } panic("unsupported") } +// setReservation installs the reservation GetReservation returns. +func (lc *localChain) setReservation(reservation *Reservation) { + lc.reservation = reservation +} + +// GetReservationAction returns the reservation action previously installed +// via setReservationAction. Panics if never set, matching this fake +// chain's convention for exercising an unconfigured dependency. func (lc *localChain) GetReservationAction( reservationKey *big.Int, requestNonce uint64, ) (*ReservationAction, error) { + if lc.reservationAction != nil { + return lc.reservationAction, nil + } panic("unsupported") } +// setReservationAction installs the action GetReservationAction returns. +func (lc *localChain) setReservationAction(action *ReservationAction) { + lc.reservationAction = action +} + func (lc *localChain) ReservationParameters() (*ReservationParameters, error) { panic("unsupported") } +// ValidateReservationAnchorProposal returns the error previously installed +// via setValidateReservationAnchorProposalErr, or nil (accept) by default. func (lc *localChain) ValidateReservationAnchorProposal( walletPublicKeyHash [20]byte, proposal *ReservationAnchorProposal, @@ -1485,29 +1515,31 @@ func (lc *localChain) ValidateReservationAnchorProposal( FundingTx *bitcoin.Transaction }, ) error { - panic("unsupported") + return lc.validateReservationAnchorProposalErr } -func (lc *localChain) ValidateReservedRedemptionProposal( - walletPublicKeyHash [20]byte, - proposal *ReservedRedemptionProposal, -) error { - panic("unsupported") +// setValidateReservationAnchorProposalErr installs the error +// ValidateReservationAnchorProposal returns. +func (lc *localChain) setValidateReservationAnchorProposalErr(err error) { + lc.validateReservationAnchorProposalErr = err } +// ValidateReservationReanchorProposal returns the error previously +// installed via setValidateReservationReanchorProposalErr, or nil (accept) +// by default. func (lc *localChain) ValidateReservationReanchorProposal( sourceWalletPublicKeyHash [20]byte, proposal *ReservationReanchorProposal, ) error { - panic("unsupported") + return lc.validateReservationReanchorProposalErr } -func (lc *localChain) ValidateReservationDissolutionProposal( - walletPubKeyHash [20]byte, - proposal *ReservationDissolutionProposal, -) error { - panic("unsupported") +// setValidateReservationReanchorProposalErr installs the error +// ValidateReservationReanchorProposal returns. +func (lc *localChain) setValidateReservationReanchorProposalErr(err error) { + lc.validateReservationReanchorProposalErr = err } + func (lc *localChain) RequestReservationAcceptance( reservationKey *big.Int, walletPublicKeyHash [20]byte, @@ -1578,31 +1610,16 @@ func (lc *localChain) WalletReservations( return nil, fmt.Errorf("unsupported") } -func (lc *localChain) ReservationByAnchorUtxo( - anchorTxHash [32]byte, - anchorTxOutputIndex uint32, -) (*big.Int, error) { - return nil, fmt.Errorf("unsupported") -} - func (lc *localChain) ReservedDepositWallet( depositKey *big.Int, ) ([20]byte, error) { return [20]byte{}, fmt.Errorf("unsupported") } -func (lc *localChain) PendingReservedDeposits() (uint64, error) { - return 0, fmt.Errorf("unsupported") -} - func (lc *localChain) ActiveReservationsCount() (uint32, uint32, error) { return 0, 0, fmt.Errorf("unsupported") } -func (lc *localChain) ReservationRouter() (chain.Address, error) { - return "", fmt.Errorf("unsupported") -} - func (lc *localChain) IsReservedDeposit( depositKey *big.Int, ) (bool, error) { @@ -1621,18 +1638,6 @@ func (lc *localChain) PastReservationAcceptanceRequestedEvents( return nil, fmt.Errorf("unsupported") } -func (lc *localChain) OnReservationAccepted( - handler func(event *ReservationAcceptedEvent), -) subscription.EventSubscription { - return subscription.NewEventSubscription(func() {}) -} - -func (lc *localChain) PastReservationAcceptedEvents( - filter *ReservationAcceptedEventFilter, -) ([]*ReservationAcceptedEvent, error) { - return nil, fmt.Errorf("unsupported") -} - func (lc *localChain) OnReservationReanchorRequested( handler func(event *ReservationReanchorRequestedEvent), ) subscription.EventSubscription { @@ -1644,75 +1649,3 @@ func (lc *localChain) PastReservationReanchorRequestedEvents( ) ([]*ReservationReanchorRequestedEvent, error) { return nil, fmt.Errorf("unsupported") } - -func (lc *localChain) OnReservationReanchored( - handler func(event *ReservationReanchoredEvent), -) subscription.EventSubscription { - return subscription.NewEventSubscription(func() {}) -} - -func (lc *localChain) PastReservationReanchoredEvents( - filter *ReservationReanchoredEventFilter, -) ([]*ReservationReanchoredEvent, error) { - return nil, fmt.Errorf("unsupported") -} - -func (lc *localChain) OnReservationActionTimedOut( - handler func(event *ReservationActionTimedOutEvent), -) subscription.EventSubscription { - return subscription.NewEventSubscription(func() {}) -} - -func (lc *localChain) PastReservationActionTimedOutEvents( - filter *ReservationActionTimedOutEventFilter, -) ([]*ReservationActionTimedOutEvent, error) { - return nil, fmt.Errorf("unsupported") -} - -func (lc *localChain) OnReservationActionSuperseded( - handler func(event *ReservationActionSupersededEvent), -) subscription.EventSubscription { - return subscription.NewEventSubscription(func() {}) -} - -func (lc *localChain) OnReservationLateSettled( - handler func(event *ReservationLateSettledEvent), -) subscription.EventSubscription { - return subscription.NewEventSubscription(func() {}) -} - -func (lc *localChain) OnReservationRetryCreditMinted( - handler func(event *ReservationRetryCreditMintedEvent), -) subscription.EventSubscription { - return subscription.NewEventSubscription(func() {}) -} - -func (lc *localChain) OnReservedDepositMarkedStale( - handler func(event *ReservedDepositMarkedStaleEvent), -) subscription.EventSubscription { - return subscription.NewEventSubscription(func() {}) -} - -func (lc *localChain) OnReservationStranded( - handler func(event *ReservationStrandedEvent), -) subscription.EventSubscription { - return subscription.NewEventSubscription(func() {}) -} - -func (lc *localChain) OnReservationParametersUpdated( - handler func(event *ReservationParametersUpdatedEvent), -) subscription.EventSubscription { - return subscription.NewEventSubscription(func() {}) -} - -func (lc *localChain) OnReservationVaultUpdated( - handler func(event *ReservationVaultUpdatedEvent), -) subscription.EventSubscription { - return subscription.NewEventSubscription(func() {}) -} - -func (lc *localChain) OnReservationCapsUpdated( - handler func(event *ReservationCapsUpdatedEvent), -) subscription.EventSubscription { - return subscription.NewEventSubscription(func() {}) -} diff --git a/pkg/tbtc/coordination.go b/pkg/tbtc/coordination.go index 8b408354d4..e5b5e15e07 100644 --- a/pkg/tbtc/coordination.go +++ b/pkg/tbtc/coordination.go @@ -10,17 +10,19 @@ import ( "strings" "time" - "github.com/keep-network/keep-core/pkg/internal/pb" "go.uber.org/zap" "golang.org/x/exp/slices" + "github.com/keep-network/keep-core/pkg/internal/pb" + + "golang.org/x/sync/semaphore" + "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/clientinfo" "github.com/keep-network/keep-core/pkg/generator" "github.com/keep-network/keep-core/pkg/net" "github.com/keep-network/keep-core/pkg/protocol/group" - "golang.org/x/sync/semaphore" ) const ( @@ -69,7 +71,7 @@ const ( // ReservationsActivationBlock is the Ethereum block height at which // reservation actions (anchor, re-anchor) become available in the // coordination checklist. - ReservationsActivationBlock = DepositSweepEveryWindowActivationBlock + ReservationsActivationBlock = uint64(24559289) ) // errCoordinationExecutorBusy is an error returned when the coordination @@ -308,10 +310,6 @@ type coordinationExecutor struct { waitForBlockFn waitForBlockFn - // reservationsEnabled mirrors config.Reservations.Enabled. Determines - // whether the actions checklist includes the reservation action types. - reservationsEnabled bool - // metricsRecorder is optional and used for recording performance metrics metricsRecorder interface { IncrementCounter(name string, value float64) @@ -332,7 +330,6 @@ func newCoordinationExecutor( membershipValidator *group.MembershipValidator, protocolLatch *generator.ProtocolLatch, waitForBlockFn waitForBlockFn, - reservationsEnabled bool, ) *coordinationExecutor { return &coordinationExecutor{ lock: semaphore.NewWeighted(1), @@ -345,7 +342,6 @@ func newCoordinationExecutor( membershipValidator: membershipValidator, protocolLatch: protocolLatch, waitForBlockFn: waitForBlockFn, - reservationsEnabled: reservationsEnabled, } } @@ -651,8 +647,7 @@ func (ce *coordinationExecutor) getActionsChecklist( // checklist. Frequency-gated like DepositSweep/MovingFunds below the // activation block: reservation acceptance/re-anchor windows are not // as time-critical as redemption. - if ce.reservationsEnabled && - coordinationBlock >= ReservationsActivationBlock && + if coordinationBlock >= ReservationsActivationBlock && windowIndex%frequencyWindows == 0 { actions = append(actions, ActionReservationAnchor) actions = append(actions, ActionReservationReanchor) diff --git a/pkg/tbtc/coordination_test.go b/pkg/tbtc/coordination_test.go index f51c5a47de..b63b22d446 100644 --- a/pkg/tbtc/coordination_test.go +++ b/pkg/tbtc/coordination_test.go @@ -11,6 +11,8 @@ import ( "time" "github.com/go-test/deep" + "golang.org/x/exp/slices" + "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/chain/local_v1" @@ -20,7 +22,6 @@ import ( "github.com/keep-network/keep-core/pkg/operator" "github.com/keep-network/keep-core/pkg/protocol/group" "github.com/keep-network/keep-core/pkg/tecdsa" - "golang.org/x/exp/slices" "github.com/keep-network/keep-core/internal/testutils" ) @@ -340,7 +341,6 @@ func TestCoordinationExecutor_Coordinate(t *testing.T) { membershipValidator, protocolLatch, operator.waitForBlockHeight, - false, ) } @@ -734,6 +734,8 @@ func TestCoordinationExecutor_GetActionsChecklist_PostActivation(t *testing.T) { ActionDepositSweep, ActionMovedFundsSweep, ActionMovingFunds, + ActionReservationAnchor, + ActionReservationReanchor, }, is4thWindow: true, }, @@ -768,6 +770,8 @@ func TestCoordinationExecutor_GetActionsChecklist_PostActivation(t *testing.T) { ActionDepositSweep, ActionMovedFundsSweep, ActionMovingFunds, + ActionReservationAnchor, + ActionReservationReanchor, ActionHeartbeat, }, is4thWindow: true, @@ -782,6 +786,8 @@ func TestCoordinationExecutor_GetActionsChecklist_PostActivation(t *testing.T) { ActionDepositSweep, ActionMovedFundsSweep, ActionMovingFunds, + ActionReservationAnchor, + ActionReservationReanchor, }, is4thWindow: true, }, @@ -839,44 +845,39 @@ func TestCoordinationExecutor_GetActionsChecklist_PostActivation(t *testing.T) { } } +// TestCoordinationExecutor_GetActionsChecklist_Reservations verifies the +// reservation actions checklist gate depends solely on the activation +// block and the frequency window, never on a local per-operator +// configuration flag - see coordinationExecutor.getActionsChecklist's +// comment for why: a follower gating checklist validation on its own +// local flag would wrongly fault an honest leader whenever the two +// operators' local configs diverge. func TestCoordinationExecutor_GetActionsChecklist_Reservations(t *testing.T) { tests := map[string]struct { - reservationsEnabled bool - coordinationBlock uint64 - windowIndex uint64 - expectedActions []WalletActionType + coordinationBlock uint64 + windowIndex uint64 + expectedActions []WalletActionType }{ - "reservations disabled": { - reservationsEnabled: false, - coordinationBlock: ReservationsActivationBlock, - windowIndex: 4, - expectedActions: []WalletActionType{ActionRedemption}, + "below activation": { + coordinationBlock: ReservationsActivationBlock - 1, + windowIndex: 4, + expectedActions: []WalletActionType{ActionRedemption}, }, - "reservations enabled below activation": { - reservationsEnabled: true, - coordinationBlock: ReservationsActivationBlock - 1, - windowIndex: 4, - expectedActions: []WalletActionType{ActionRedemption}, + "at activation, non-4th window": { + coordinationBlock: ReservationsActivationBlock, + windowIndex: 5, + expectedActions: []WalletActionType{ActionRedemption}, }, - "reservations enabled at activation, non-4th window": { - reservationsEnabled: true, - coordinationBlock: ReservationsActivationBlock, - windowIndex: 5, - expectedActions: []WalletActionType{ActionRedemption}, - }, - "reservations enabled at activation, 4th window": { - reservationsEnabled: true, - coordinationBlock: ReservationsActivationBlock, - windowIndex: 4, - expectedActions: []WalletActionType{ActionRedemption, ActionReservationAnchor, ActionReservationReanchor}, + "at activation, 4th window": { + coordinationBlock: ReservationsActivationBlock, + windowIndex: 4, + expectedActions: []WalletActionType{ActionRedemption, ActionReservationAnchor, ActionReservationReanchor}, }, } for testName, test := range tests { t.Run(testName, func(t *testing.T) { - executor := &coordinationExecutor{ - reservationsEnabled: test.reservationsEnabled, - } + executor := &coordinationExecutor{} // We don't care about the seed for this test, as it only affects // the ActionHeartbeat which is not the focus here. @@ -965,11 +966,13 @@ func assertChecklistOrdering( t.Helper() actionPriority := map[WalletActionType]int{ - ActionRedemption: 0, - ActionDepositSweep: 1, - ActionMovedFundsSweep: 2, - ActionMovingFunds: 3, - ActionHeartbeat: 4, + ActionRedemption: 0, + ActionDepositSweep: 1, + ActionMovedFundsSweep: 2, + ActionMovingFunds: 3, + ActionReservationAnchor: 4, + ActionReservationReanchor: 5, + ActionHeartbeat: 6, } for i := 1; i < len(checklist); i++ { diff --git a/pkg/tbtc/gen/pb/message.pb.go b/pkg/tbtc/gen/pb/message.pb.go index 7496ad009d..10ffa49071 100644 --- a/pkg/tbtc/gen/pb/message.pb.go +++ b/pkg/tbtc/gen/pb/message.pb.go @@ -508,6 +508,148 @@ func (x *MovedFundsSweepProposal) GetSweepTxFee() []byte { return nil } +type ReservationAnchorProposal struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + DepositFundingTxHash []byte `protobuf:"bytes,1,opt,name=depositFundingTxHash,proto3" json:"depositFundingTxHash,omitempty"` + DepositFundingOutputIndex uint32 `protobuf:"varint,2,opt,name=depositFundingOutputIndex,proto3" json:"depositFundingOutputIndex,omitempty"` + RequestNonce uint64 `protobuf:"varint,3,opt,name=requestNonce,proto3" json:"requestNonce,omitempty"` + AnchorTxFee []byte `protobuf:"bytes,4,opt,name=anchorTxFee,proto3" json:"anchorTxFee,omitempty"` +} + +func (x *ReservationAnchorProposal) Reset() { + *x = ReservationAnchorProposal{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_tbtc_gen_pb_message_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ReservationAnchorProposal) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReservationAnchorProposal) ProtoMessage() {} + +func (x *ReservationAnchorProposal) ProtoReflect() protoreflect.Message { + mi := &file_pkg_tbtc_gen_pb_message_proto_msgTypes[8] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReservationAnchorProposal.ProtoReflect.Descriptor instead. +func (*ReservationAnchorProposal) Descriptor() ([]byte, []int) { + return file_pkg_tbtc_gen_pb_message_proto_rawDescGZIP(), []int{8} +} + +func (x *ReservationAnchorProposal) GetDepositFundingTxHash() []byte { + if x != nil { + return x.DepositFundingTxHash + } + return nil +} + +func (x *ReservationAnchorProposal) GetDepositFundingOutputIndex() uint32 { + if x != nil { + return x.DepositFundingOutputIndex + } + return 0 +} + +func (x *ReservationAnchorProposal) GetRequestNonce() uint64 { + if x != nil { + return x.RequestNonce + } + return 0 +} + +func (x *ReservationAnchorProposal) GetAnchorTxFee() []byte { + if x != nil { + return x.AnchorTxFee + } + return nil +} + +type ReservationReanchorProposal struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ReservationKey []byte `protobuf:"bytes,1,opt,name=reservationKey,proto3" json:"reservationKey,omitempty"` + RequestNonce uint64 `protobuf:"varint,2,opt,name=requestNonce,proto3" json:"requestNonce,omitempty"` + TargetWalletPublicKeyHash []byte `protobuf:"bytes,3,opt,name=targetWalletPublicKeyHash,proto3" json:"targetWalletPublicKeyHash,omitempty"` + ReanchorTxFee []byte `protobuf:"bytes,4,opt,name=reanchorTxFee,proto3" json:"reanchorTxFee,omitempty"` +} + +func (x *ReservationReanchorProposal) Reset() { + *x = ReservationReanchorProposal{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_tbtc_gen_pb_message_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ReservationReanchorProposal) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReservationReanchorProposal) ProtoMessage() {} + +func (x *ReservationReanchorProposal) ProtoReflect() protoreflect.Message { + mi := &file_pkg_tbtc_gen_pb_message_proto_msgTypes[9] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReservationReanchorProposal.ProtoReflect.Descriptor instead. +func (*ReservationReanchorProposal) Descriptor() ([]byte, []int) { + return file_pkg_tbtc_gen_pb_message_proto_rawDescGZIP(), []int{9} +} + +func (x *ReservationReanchorProposal) GetReservationKey() []byte { + if x != nil { + return x.ReservationKey + } + return nil +} + +func (x *ReservationReanchorProposal) GetRequestNonce() uint64 { + if x != nil { + return x.RequestNonce + } + return 0 +} + +func (x *ReservationReanchorProposal) GetTargetWalletPublicKeyHash() []byte { + if x != nil { + return x.TargetWalletPublicKeyHash + } + return nil +} + +func (x *ReservationReanchorProposal) GetReanchorTxFee() []byte { + if x != nil { + return x.ReanchorTxFee + } + return nil +} + type DepositSweepProposal_DepositKey struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -520,7 +662,7 @@ type DepositSweepProposal_DepositKey struct { func (x *DepositSweepProposal_DepositKey) Reset() { *x = DepositSweepProposal_DepositKey{} if protoimpl.UnsafeEnabled { - mi := &file_pkg_tbtc_gen_pb_message_proto_msgTypes[8] + mi := &file_pkg_tbtc_gen_pb_message_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -533,7 +675,7 @@ func (x *DepositSweepProposal_DepositKey) String() string { func (*DepositSweepProposal_DepositKey) ProtoMessage() {} func (x *DepositSweepProposal_DepositKey) ProtoReflect() protoreflect.Message { - mi := &file_pkg_tbtc_gen_pb_message_proto_msgTypes[8] + mi := &file_pkg_tbtc_gen_pb_message_proto_msgTypes[10] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -642,8 +784,34 @@ var file_pkg_tbtc_gen_pb_message_proto_rawDesc = []byte{ 0x46, 0x75, 0x6e, 0x64, 0x73, 0x54, 0x78, 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x1e, 0x0a, 0x0a, 0x73, 0x77, 0x65, 0x65, 0x70, 0x54, 0x78, 0x46, 0x65, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x73, 0x77, 0x65, 0x65, 0x70, 0x54, 0x78, 0x46, - 0x65, 0x65, 0x42, 0x06, 0x5a, 0x04, 0x2e, 0x2f, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x33, + 0x65, 0x65, 0x22, 0xd3, 0x01, 0x0a, 0x19, 0x52, 0x65, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x41, 0x6e, 0x63, 0x68, 0x6f, 0x72, 0x50, 0x72, 0x6f, 0x70, 0x6f, 0x73, 0x61, 0x6c, + 0x12, 0x32, 0x0a, 0x14, 0x64, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x46, 0x75, 0x6e, 0x64, 0x69, + 0x6e, 0x67, 0x54, 0x78, 0x48, 0x61, 0x73, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x14, + 0x64, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x46, 0x75, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x54, 0x78, + 0x48, 0x61, 0x73, 0x68, 0x12, 0x3c, 0x0a, 0x19, 0x64, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x46, + 0x75, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x49, 0x6e, 0x64, 0x65, + 0x78, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x19, 0x64, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, + 0x46, 0x75, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x49, 0x6e, 0x64, + 0x65, 0x78, 0x12, 0x22, 0x0a, 0x0c, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4e, 0x6f, 0x6e, + 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0c, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x4e, 0x6f, 0x6e, 0x63, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x61, 0x6e, 0x63, 0x68, 0x6f, 0x72, + 0x54, 0x78, 0x46, 0x65, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0b, 0x61, 0x6e, 0x63, + 0x68, 0x6f, 0x72, 0x54, 0x78, 0x46, 0x65, 0x65, 0x22, 0xcd, 0x01, 0x0a, 0x1b, 0x52, 0x65, 0x73, + 0x65, 0x72, 0x76, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x61, 0x6e, 0x63, 0x68, 0x6f, 0x72, + 0x50, 0x72, 0x6f, 0x70, 0x6f, 0x73, 0x61, 0x6c, 0x12, 0x26, 0x0a, 0x0e, 0x72, 0x65, 0x73, 0x65, + 0x72, 0x76, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, + 0x52, 0x0e, 0x72, 0x65, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4b, 0x65, 0x79, + 0x12, 0x22, 0x0a, 0x0c, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4e, 0x6f, 0x6e, 0x63, 0x65, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0c, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4e, + 0x6f, 0x6e, 0x63, 0x65, 0x12, 0x3c, 0x0a, 0x19, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x57, 0x61, + 0x6c, 0x6c, 0x65, 0x74, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x48, 0x61, 0x73, + 0x68, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x19, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x57, + 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x48, 0x61, + 0x73, 0x68, 0x12, 0x24, 0x0a, 0x0d, 0x72, 0x65, 0x61, 0x6e, 0x63, 0x68, 0x6f, 0x72, 0x54, 0x78, + 0x46, 0x65, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0d, 0x72, 0x65, 0x61, 0x6e, 0x63, + 0x68, 0x6f, 0x72, 0x54, 0x78, 0x46, 0x65, 0x65, 0x42, 0x06, 0x5a, 0x04, 0x2e, 0x2f, 0x70, 0x62, + 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -658,7 +826,7 @@ func file_pkg_tbtc_gen_pb_message_proto_rawDescGZIP() []byte { return file_pkg_tbtc_gen_pb_message_proto_rawDescData } -var file_pkg_tbtc_gen_pb_message_proto_msgTypes = make([]protoimpl.MessageInfo, 9) +var file_pkg_tbtc_gen_pb_message_proto_msgTypes = make([]protoimpl.MessageInfo, 11) var file_pkg_tbtc_gen_pb_message_proto_goTypes = []interface{}{ (*SigningDoneMessage)(nil), // 0: tbtc.SigningDoneMessage (*CoordinationProposal)(nil), // 1: tbtc.CoordinationProposal @@ -668,16 +836,18 @@ var file_pkg_tbtc_gen_pb_message_proto_goTypes = []interface{}{ (*RedemptionProposal)(nil), // 5: tbtc.RedemptionProposal (*MovingFundsProposal)(nil), // 6: tbtc.MovingFundsProposal (*MovedFundsSweepProposal)(nil), // 7: tbtc.MovedFundsSweepProposal - (*DepositSweepProposal_DepositKey)(nil), // 8: tbtc.DepositSweepProposal.DepositKey + (*ReservationAnchorProposal)(nil), // 8: tbtc.ReservationAnchorProposal + (*ReservationReanchorProposal)(nil), // 9: tbtc.ReservationReanchorProposal + (*DepositSweepProposal_DepositKey)(nil), // 10: tbtc.DepositSweepProposal.DepositKey } var file_pkg_tbtc_gen_pb_message_proto_depIdxs = []int32{ - 1, // 0: tbtc.CoordinationMessage.proposal:type_name -> tbtc.CoordinationProposal - 8, // 1: tbtc.DepositSweepProposal.depositsKeys:type_name -> tbtc.DepositSweepProposal.DepositKey - 2, // [2:2] is the sub-list for method output_type - 2, // [2:2] is the sub-list for method input_type - 2, // [2:2] is the sub-list for extension type_name - 2, // [2:2] is the sub-list for extension extendee - 0, // [0:2] is the sub-list for field type_name + 1, // 0: tbtc.CoordinationMessage.proposal:type_name -> tbtc.CoordinationProposal + 10, // 1: tbtc.DepositSweepProposal.depositsKeys:type_name -> tbtc.DepositSweepProposal.DepositKey + 2, // [2:2] is the sub-list for method output_type + 2, // [2:2] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name } func init() { file_pkg_tbtc_gen_pb_message_proto_init() } @@ -783,6 +953,30 @@ func file_pkg_tbtc_gen_pb_message_proto_init() { } } file_pkg_tbtc_gen_pb_message_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ReservationAnchorProposal); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_pkg_tbtc_gen_pb_message_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ReservationReanchorProposal); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_pkg_tbtc_gen_pb_message_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*DepositSweepProposal_DepositKey); i { case 0: return &v.state @@ -801,7 +995,7 @@ func file_pkg_tbtc_gen_pb_message_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_pkg_tbtc_gen_pb_message_proto_rawDesc, NumEnums: 0, - NumMessages: 9, + NumMessages: 11, NumExtensions: 0, NumServices: 0, }, diff --git a/pkg/tbtc/gen/pb/message.proto b/pkg/tbtc/gen/pb/message.proto index e26d31ab46..46833241fe 100644 --- a/pkg/tbtc/gen/pb/message.proto +++ b/pkg/tbtc/gen/pb/message.proto @@ -53,3 +53,17 @@ message MovedFundsSweepProposal { uint32 movingFundsTxOutputIndex = 2; bytes sweepTxFee = 3; } + +message ReservationAnchorProposal { + bytes depositFundingTxHash = 1; + uint32 depositFundingOutputIndex = 2; + uint64 requestNonce = 3; + bytes anchorTxFee = 4; +} + +message ReservationReanchorProposal { + bytes reservationKey = 1; + uint64 requestNonce = 2; + bytes targetWalletPublicKeyHash = 3; + bytes reanchorTxFee = 4; +} diff --git a/pkg/tbtc/marshaling.go b/pkg/tbtc/marshaling.go index d31180ca27..35171a9257 100644 --- a/pkg/tbtc/marshaling.go +++ b/pkg/tbtc/marshaling.go @@ -229,16 +229,14 @@ func unmarshalCoordinationProposal(actionType uint32, payload []byte) ( } proposal, ok := map[WalletActionType]CoordinationProposal{ - ActionNoop: &NoopProposal{}, - ActionHeartbeat: &HeartbeatProposal{}, - ActionDepositSweep: &DepositSweepProposal{}, - ActionRedemption: &RedemptionProposal{}, - ActionMovingFunds: &MovingFundsProposal{}, - ActionMovedFundsSweep: &MovedFundsSweepProposal{}, - ActionReservationAnchor: &ReservationAnchorProposal{}, - ActionReservedRedemption: &ReservedRedemptionProposal{}, - ActionReservationReanchor: &ReservationReanchorProposal{}, - ActionReservationDissolution: &ReservationDissolutionProposal{}, + ActionNoop: &NoopProposal{}, + ActionHeartbeat: &HeartbeatProposal{}, + ActionDepositSweep: &DepositSweepProposal{}, + ActionRedemption: &RedemptionProposal{}, + ActionMovingFunds: &MovingFundsProposal{}, + ActionMovedFundsSweep: &MovedFundsSweepProposal{}, + ActionReservationAnchor: &ReservationAnchorProposal{}, + ActionReservationReanchor: &ReservationReanchorProposal{}, }[parsedActionType] if !ok { return nil, fmt.Errorf( @@ -494,3 +492,82 @@ func validateMemberIndex(protoIndex uint32) error { } return nil } + +func (rap *ReservationAnchorProposal) Marshal() ([]byte, error) { + return proto.Marshal( + &pb.ReservationAnchorProposal{ + DepositFundingTxHash: rap.DepositFundingTxHash[:], + DepositFundingOutputIndex: rap.DepositFundingOutputIndex, + RequestNonce: rap.RequestNonce, + AnchorTxFee: rap.AnchorTxFee.Bytes(), + }) +} + +func (rap *ReservationAnchorProposal) Unmarshal(data []byte) error { + pbMsg := pb.ReservationAnchorProposal{} + if err := proto.Unmarshal(data, &pbMsg); err != nil { + return fmt.Errorf("failed to unmarshal ReservationAnchorProposal: [%v]", err) + } + + if len(pbMsg.AnchorTxFee) == 0 { + return fmt.Errorf("anchor transaction fee is required") + } + if pbMsg.RequestNonce == 0 { + return fmt.Errorf("request nonce is required") + } + if len(pbMsg.DepositFundingTxHash) != 32 { + return fmt.Errorf( + "invalid deposit funding tx hash length: [%v]", + len(pbMsg.DepositFundingTxHash), + ) + } + + copy(rap.DepositFundingTxHash[:], pbMsg.DepositFundingTxHash) + rap.DepositFundingOutputIndex = pbMsg.DepositFundingOutputIndex + rap.RequestNonce = pbMsg.RequestNonce + rap.AnchorTxFee = new(big.Int).SetBytes(pbMsg.AnchorTxFee) + + return nil +} + +func (rrp *ReservationReanchorProposal) Marshal() ([]byte, error) { + return proto.Marshal( + &pb.ReservationReanchorProposal{ + ReservationKey: rrp.ReservationKey.Bytes(), + RequestNonce: rrp.RequestNonce, + TargetWalletPublicKeyHash: append([]byte{}, rrp.TargetWalletPublicKeyHash[:]...), + ReanchorTxFee: rrp.ReanchorTxFee.Bytes(), + }) +} + +func (rrp *ReservationReanchorProposal) Unmarshal(data []byte) error { + pbMsg := pb.ReservationReanchorProposal{} + if err := proto.Unmarshal(data, &pbMsg); err != nil { + return fmt.Errorf("failed to unmarshal ReservationReanchorProposal: [%v]", err) + } + + if len(pbMsg.ReservationKey) == 0 { + return fmt.Errorf("reservation key is required") + } + if pbMsg.RequestNonce == 0 { + return fmt.Errorf("request nonce is required") + } + if len(pbMsg.ReanchorTxFee) == 0 { + return fmt.Errorf("re-anchor transaction fee is required") + } + if len(pbMsg.TargetWalletPublicKeyHash) != 20 { + return fmt.Errorf( + "invalid target wallet public key hash length: [%v]", + len(pbMsg.TargetWalletPublicKeyHash), + ) + } + if copy(rrp.TargetWalletPublicKeyHash[:], pbMsg.TargetWalletPublicKeyHash) == 0 || rrp.TargetWalletPublicKeyHash == [20]byte{} { + return fmt.Errorf("target wallet public key hash is required") + } + + rrp.ReservationKey = new(big.Int).SetBytes(pbMsg.ReservationKey) + rrp.RequestNonce = pbMsg.RequestNonce + rrp.ReanchorTxFee = new(big.Int).SetBytes(pbMsg.ReanchorTxFee) + + return nil +} diff --git a/pkg/tbtc/node.go b/pkg/tbtc/node.go index f06a99e18a..a41913d2a0 100644 --- a/pkg/tbtc/node.go +++ b/pkg/tbtc/node.go @@ -11,6 +11,7 @@ import ( "github.com/keep-network/keep-core/pkg/clientinfo" "github.com/keep-network/keep-common/pkg/persistence" + "github.com/keep-network/keep-core/pkg/generator" "github.com/keep-network/keep-core/pkg/net" ) @@ -117,13 +118,6 @@ type node struct { // transactionMonitor watches broadcast wallet transactions and alerts on // ones that remain unconfirmed long enough to be considered stuck. transactionMonitor *transactionMonitor - - // reservationsEnabled mirrors config.Reservations.Enabled. Threaded into - // each coordinationExecutor so the leader/follower actions checklist - // includes the reservation action types exactly when the reservation - // proposal generator tasks are wired in, keeping the two gates in - // lockstep (see coordinationExecutor.getActionsChecklist). - reservationsEnabled bool } func newNode( @@ -162,7 +156,6 @@ func newNode( coordinationExecutors: make(map[string]*coordinationExecutor), proposalGenerator: proposalGenerator, transactionMonitor: newTransactionMonitor(btcChain), - reservationsEnabled: config.Reservations.Enabled, } // Archive any wallets that might have been closed or terminated while the @@ -318,3 +311,20 @@ func (n *node) validateDKG( ) { n.dkgExecutor.executeDkgValidation(seed, submissionBlock, result, resultHash) } + +func (n *node) ResolveWalletMembers(walletPublicKeyHash [20]byte) ([]uint32, error) { + wallet, found := n.walletRegistry.getWalletByPublicKeyHash(walletPublicKeyHash) + if !found { + return nil, fmt.Errorf("wallet not found") + } + + operatorIDs := make([]uint32, len(wallet.signingGroupOperators)) + for i, operatorAddress := range wallet.signingGroupOperators { + operatorID, err := n.chain.GetOperatorID(operatorAddress) + if err != nil { + return nil, err + } + operatorIDs[i] = uint32(operatorID) + } + return operatorIDs, nil +} diff --git a/pkg/tbtc/node_executors.go b/pkg/tbtc/node_executors.go index 56182e3bb8..8a33854ef6 100644 --- a/pkg/tbtc/node_executors.go +++ b/pkg/tbtc/node_executors.go @@ -209,7 +209,6 @@ func (n *node) getCoordinationExecutor( membershipValidator, n.protocolLatch, n.waitForBlockHeight, - n.reservationsEnabled, ) // Wire metrics recorder if available diff --git a/pkg/tbtc/node_test.go b/pkg/tbtc/node_test.go index a756c69595..2a6820fb16 100644 --- a/pkg/tbtc/node_test.go +++ b/pkg/tbtc/node_test.go @@ -1029,6 +1029,71 @@ func TestProcessCoordinationResult_MovedFundsSweepRoutesToHandler(t *testing.T) } } +// TestProcessCoordinationResult_ReservationAnchorRoutesToHandler verifies that +func TestProcessCoordinationResult_ReservationAnchorRoutesToHandler(t *testing.T) { + n, signer := setupNodeForHandlerTests(t) + walletKey := walletKeyFor(t, signer) + + // Mark the wallet busy so dispatch is rejected before execute() runs. + func() { + n.walletDispatcher.actionsMutex.Lock() + defer n.walletDispatcher.actionsMutex.Unlock() + n.walletDispatcher.actions[walletKey] = ActionNoop + }() + + result := &coordinationResult{ + wallet: signer.wallet, + proposal: &ReservationAnchorProposal{}, + window: &coordinationWindow{coordinationBlock: 1}, + } + + processCoordinationResult(n, result) + + // Busy sentinel must still be there: dispatch was attempted (routing worked) + // but returned errWalletBusy without touching the map entry. + _, ok := func() (WalletActionType, bool) { + n.walletDispatcher.actionsMutex.Lock() + defer n.walletDispatcher.actionsMutex.Unlock() + v, exists := n.walletDispatcher.actions[walletKey] + return v, exists + }() + if !ok { + t.Error("expected walletDispatcher to retain the busy sentinel after ReservationAnchor routing") + } +} + +func TestProcessCoordinationResult_ReservationReanchorRoutesToHandler(t *testing.T) { + n, signer := setupNodeForHandlerTests(t) + walletKey := walletKeyFor(t, signer) + + // Mark the wallet busy so dispatch is rejected before execute() runs. + func() { + n.walletDispatcher.actionsMutex.Lock() + defer n.walletDispatcher.actionsMutex.Unlock() + n.walletDispatcher.actions[walletKey] = ActionNoop + }() + + result := &coordinationResult{ + wallet: signer.wallet, + proposal: &ReservationReanchorProposal{}, + window: &coordinationWindow{coordinationBlock: 1}, + } + + processCoordinationResult(n, result) + + // Busy sentinel must still be there: dispatch was attempted (routing worked) + // but returned errWalletBusy without touching the map entry. + _, ok := func() (WalletActionType, bool) { + n.walletDispatcher.actionsMutex.Lock() + defer n.walletDispatcher.actionsMutex.Unlock() + v, exists := n.walletDispatcher.actions[walletKey] + return v, exists + }() + if !ok { + t.Error("expected walletDispatcher to retain the busy sentinel after ReservationReanchor routing") + } +} + // setupNodeForClosureTests creates a node backed by a fast-block localChain // (1 ms per block) so that WaitForBlockConfirmations (32 blocks) completes in // ~32 ms instead of seconds. diff --git a/pkg/tbtc/reservation.go b/pkg/tbtc/reservation.go index 4167b51425..f856864a0b 100644 --- a/pkg/tbtc/reservation.go +++ b/pkg/tbtc/reservation.go @@ -1,31 +1,26 @@ package tbtc import ( - "encoding/json" "fmt" "math/big" "time" "go.uber.org/zap" - "golang.org/x/crypto/sha3" "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/chain" ) const ( + reservationLookBackBlocks = uint64(216000) + // reservationAnchorProposalValidityBlocks determines the reservation // anchor proposal validity time expressed in blocks. reservationAnchorProposalValidityBlocks = 600 - // reservedRedemptionProposalValidityBlocks determines the reserved - // redemption proposal validity time expressed in blocks. - reservedRedemptionProposalValidityBlocks = 600 + // reservationReanchorProposalValidityBlocks determines the reservation // re-anchor proposal validity time expressed in blocks. reservationReanchorProposalValidityBlocks = 600 - // reservationDissolutionProposalValidityBlocks determines the reservation - // dissolution proposal validity time expressed in blocks. - reservationDissolutionProposalValidityBlocks = 600 ) // ReservationState represents the state of an on-chain UTXO reservation. @@ -204,85 +199,6 @@ func (rap *ReservationAnchorProposal) ValidityBlocks() uint64 { return reservationAnchorProposalValidityBlocks } -// Marshal converts the reservationAnchorProposal to a byte array. -// -// TODO: Switch to protobuf-based marshaling (see pkg/tbtc/gen/pb) once the -// reservation message types are added to the coordination proto definition. -func (rap *ReservationAnchorProposal) Marshal() ([]byte, error) { - return json.Marshal(rap) -} - -// Unmarshal converts a byte array back to the reservationAnchorProposal. -func (rap *ReservationAnchorProposal) Unmarshal(bytes []byte) error { - var proposal ReservationAnchorProposal - if err := json.Unmarshal(bytes, &proposal); err != nil { - return err - } - if proposal.AnchorTxFee == nil { - return fmt.Errorf("anchor transaction fee is required") - } - if proposal.RequestNonce == 0 { - return fmt.Errorf("request nonce is required") - } - - *rap = proposal - - return nil -} - -// ReservedRedemptionProposal represents a reserved redemption proposal -// issued by a wallet's coordination leader. -type ReservedRedemptionProposal struct { - // ReservationKey is the key of the reservation with the pending reserved - // redemption. - ReservationKey *big.Int - // RequestNonce is the redemption request generation being executed. - RequestNonce uint64 - // RedemptionTxFee is the proposed BTC fee for the reserved redemption - // transaction. - RedemptionTxFee *big.Int -} - -// ActionType returns the specific type of the walletAction being subject -// of this proposal. -func (rrp *ReservedRedemptionProposal) ActionType() WalletActionType { - return ActionReservedRedemption -} - -// ValidityBlocks returns the number of blocks for which the proposal is valid. -func (rrp *ReservedRedemptionProposal) ValidityBlocks() uint64 { - return reservedRedemptionProposalValidityBlocks -} - -// Marshal converts the reservedRedemptionProposal to a byte array. -// -// TODO: Switch to protobuf-based marshaling (see pkg/tbtc/gen/pb) once the -// reservation message types are added to the coordination proto definition. -func (rrp *ReservedRedemptionProposal) Marshal() ([]byte, error) { - return json.Marshal(rrp) -} - -// Unmarshal converts a byte array back to the reservedRedemptionProposal. -func (rrp *ReservedRedemptionProposal) Unmarshal(bytes []byte) error { - var proposal ReservedRedemptionProposal - if err := json.Unmarshal(bytes, &proposal); err != nil { - return err - } - if proposal.ReservationKey == nil { - return fmt.Errorf("reservation key is required") - } - if proposal.RequestNonce == 0 { - return fmt.Errorf("request nonce is required") - } - if proposal.RedemptionTxFee == nil { - return fmt.Errorf("redemption transaction fee is required") - } - - *rrp = proposal - - return nil -} - // ReservationReanchorProposal represents a reservation re-anchor proposal // issued by a wallet's coordination leader, moving a reservation's anchor // outpoint to another wallet (e.g. during wallet migration). @@ -309,87 +225,8 @@ func (rrp *ReservationReanchorProposal) ValidityBlocks() uint64 { return reservationReanchorProposalValidityBlocks } -// Marshal converts the reservationReanchorProposal to a byte array. -// -// TODO: Switch to protobuf-based marshaling (see pkg/tbtc/gen/pb) once the -// reservation message types are added to the coordination proto definition. -func (rrp *ReservationReanchorProposal) Marshal() ([]byte, error) { - return json.Marshal(rrp) -} - -// Unmarshal converts a byte array back to the reservationReanchorProposal. -func (rrp *ReservationReanchorProposal) Unmarshal(bytes []byte) error { - var proposal ReservationReanchorProposal - if err := json.Unmarshal(bytes, &proposal); err != nil { - return err - } - if proposal.ReservationKey == nil { - return fmt.Errorf("reservation key is required") - } - if proposal.RequestNonce == 0 { - return fmt.Errorf("request nonce is required") - } - if proposal.ReanchorTxFee == nil { - return fmt.Errorf("re-anchor transaction fee is required") - } - - *rrp = proposal - - return nil -} - -// ReservationDissolutionProposal represents a reservation dissolution -// proposal issued by a wallet's coordination leader once the reservation's -// custody term and grace period elapsed. -type ReservationDissolutionProposal struct { - // ReservationKey is the key of the reservation to dissolve. - ReservationKey *big.Int - // RequestNonce is the dissolution authorization generation being executed. - RequestNonce uint64 - // DissolutionTxFee is the proposed BTC fee for the dissolution - // transaction. - DissolutionTxFee *big.Int -} - -// ActionType returns the specific type of the walletAction being subject -// of this proposal. -func (rdp *ReservationDissolutionProposal) ActionType() WalletActionType { - return ActionReservationDissolution -} - -// ValidityBlocks returns the number of blocks for which the proposal is valid. -func (rdp *ReservationDissolutionProposal) ValidityBlocks() uint64 { - return reservationDissolutionProposalValidityBlocks -} - -// Marshal converts the reservationDissolutionProposal to a byte array. -// -// TODO: Switch to protobuf-based marshaling (see pkg/tbtc/gen/pb) once the -// reservation message types are added to the coordination proto definition. -func (rdp *ReservationDissolutionProposal) Marshal() ([]byte, error) { - return json.Marshal(rdp) -} - -// Unmarshal converts a byte array back to the reservationDissolutionProposal. -func (rdp *ReservationDissolutionProposal) Unmarshal(bytes []byte) error { - var proposal ReservationDissolutionProposal - if err := json.Unmarshal(bytes, &proposal); err != nil { - return err - } - if proposal.ReservationKey == nil { - return fmt.Errorf("reservation key is required") - } - if proposal.RequestNonce == 0 { - return fmt.Errorf("request nonce is required") - } - if proposal.DissolutionTxFee == nil { - return fmt.Errorf("dissolution transaction fee is required") - } - - *rdp = proposal - - return nil -} +// Marshal/Unmarshal for ReservationReanchorProposal live in marshaling.go, +// alongside every other coordination proposal type's wire-format methods. // AssembleReservationAnchorTransaction constructs an unsigned reservation // anchor transaction: a 1-input-1-output spend of the given reserved deposit @@ -448,140 +285,6 @@ func AssembleReservationAnchorTransaction( return builder, nil } -// assembleReservedRedemptionTransaction constructs an unsigned reserved -// redemption transaction for the given nonce-bound action. A whole redemption -// is a 1-input-1-output spend to the redeemer. A partial redemption is a -// 1-input-2-output spend whose first output pays the authorized amount less -// the miner fee to the redeemer and whose second output re-anchors the exact -// remainder to the custodying wallet. -func assembleReservedRedemptionTransaction( - bitcoinChain bitcoin.Chain, - anchorUtxo *bitcoin.UnspentTransactionOutput, - walletPublicKeyHash [20]byte, - redeemerOutputScript bitcoin.Script, - action *ReservationAction, - fee int64, -) (*bitcoin.TransactionBuilder, error) { - if anchorUtxo == nil { - return nil, fmt.Errorf("anchor UTXO is required") - } - if len(redeemerOutputScript) == 0 { - return nil, fmt.Errorf("redeemer output script is required") - } - if action == nil { - return nil, fmt.Errorf("reservation action is required") - } - if action.ActionType != ReservationActionTypeRedemption { - return nil, fmt.Errorf("reservation action is not a redemption") - } - if action.State != ReservationActionStatePending { - return nil, fmt.Errorf("reservation action is not pending") - } - if anchorUtxo.Value <= 0 { - return nil, fmt.Errorf("anchor UTXO value must be positive") - } - if action.Amount == 0 { - return nil, fmt.Errorf("redemption amount must be positive") - } - if action.Amount > uint64(anchorUtxo.Value) { - return nil, fmt.Errorf("redemption amount exceeds the anchor value") - } - if fee <= 0 { - return nil, fmt.Errorf("transaction fee must be positive") - } - if uint64(fee) > action.TxMaxFee { - return nil, fmt.Errorf("transaction fee exceeds the action fee limit") - } - - redeemerOutputScriptHash, err := computeReservationRedeemerOutputScriptHash( - redeemerOutputScript, - ) - if err != nil { - return nil, err - } - if redeemerOutputScriptHash != action.RedeemerOutputScriptHash { - return nil, fmt.Errorf("redeemer output script is not authorized") - } - - if action.IsPartial { - if action.Amount == uint64(anchorUtxo.Value) { - return nil, fmt.Errorf( - "partial redemption amount must be less than the anchor value", - ) - } - } else if action.Amount != uint64(anchorUtxo.Value) { - return nil, fmt.Errorf( - "whole redemption amount must equal the anchor value", - ) - } - - builder := bitcoin.NewTransactionBuilder(bitcoinChain) - - err = builder.AddPublicKeyHashInput(anchorUtxo) - if err != nil { - return nil, fmt.Errorf( - "cannot add input pointing to anchor UTXO: [%v]", - err, - ) - } - - redemptionAmount := anchorUtxo.Value - if action.IsPartial { - redemptionAmount = int64(action.Amount) - } - - redemptionValue := redemptionAmount - fee - if redemptionValue <= 0 { - return nil, fmt.Errorf( - "transaction fee exceeds the redemption amount", - ) - } - - builder.AddOutput(&bitcoin.TransactionOutput{ - Value: redemptionValue, - PublicKeyScript: redeemerOutputScript, - }) - - if action.IsPartial { - remainderScript, err := bitcoin.PayToWitnessPublicKeyHash( - walletPublicKeyHash, - ) - if err != nil { - return nil, fmt.Errorf("cannot compute remainder script: [%v]", err) - } - - builder.AddOutput(&bitcoin.TransactionOutput{ - Value: anchorUtxo.Value - int64(action.Amount), - PublicKeyScript: remainderScript, - }) - } - - return builder, nil -} - -// computeReservationRedeemerOutputScriptHash computes the authorization hash -// stored in a reservation action. The Bridge hashes the Bitcoin output script -// including its CompactSize length prefix. -func computeReservationRedeemerOutputScriptHash( - redeemerOutputScript bitcoin.Script, -) ([32]byte, error) { - prefixedScript, err := redeemerOutputScript.ToVarLenData() - if err != nil { - return [32]byte{}, fmt.Errorf( - "cannot build prefixed redeemer output script: [%v]", - err, - ) - } - - hasher := sha3.NewLegacyKeccak256() - _, _ = hasher.Write(prefixedScript) - - var result [32]byte - copy(result[:], hasher.Sum(nil)) - - return result, nil -} - // AssembleReservationReanchorTransaction constructs an unsigned reservation // re-anchor transaction: a 1-input-1-output spend of the reservation's // anchor outpoint into a fresh output controlled by the target wallet. Used @@ -636,114 +339,6 @@ func AssembleReservationReanchorTransaction( return builder, nil } -// assembleReservationDissolutionTransaction constructs an unsigned -// reservation dissolution transaction for the given nonce-bound action. The -// anchor outpoint is the first input. The wallet main UTXO is the second input -// only when it is present in the action snapshot and matches that snapshot -// exactly. The single output pays back to the custodying wallet. -func assembleReservationDissolutionTransaction( - bitcoinChain bitcoin.Chain, - bridgeChain BridgeChain, - anchorUtxo *bitcoin.UnspentTransactionOutput, - walletMainUtxo *bitcoin.UnspentTransactionOutput, - walletPublicKeyHash [20]byte, - action *ReservationAction, - fee int64, -) (*bitcoin.TransactionBuilder, error) { - if anchorUtxo == nil { - return nil, fmt.Errorf("anchor UTXO is required") - } - if action == nil { - return nil, fmt.Errorf("reservation action is required") - } - if action.ActionType != ReservationActionTypeDissolution { - return nil, fmt.Errorf("reservation action is not a dissolution") - } - if action.State != ReservationActionStatePending { - return nil, fmt.Errorf("reservation action is not pending") - } - if action.TargetWalletPublicKeyHash != walletPublicKeyHash { - return nil, fmt.Errorf("dissolution action targets a different wallet") - } - if anchorUtxo.Value <= 0 { - return nil, fmt.Errorf("anchor UTXO value must be positive") - } - if action.Amount != uint64(anchorUtxo.Value) { - return nil, fmt.Errorf( - "dissolution action amount does not match the anchor value", - ) - } - if fee <= 0 { - return nil, fmt.Errorf("transaction fee must be positive") - } - if uint64(fee) > action.TxMaxFee { - return nil, fmt.Errorf("transaction fee exceeds the action fee limit") - } - - mainUtxoExpected := action.ExpectedMainUtxoHash != [32]byte{} - if mainUtxoExpected { - if bridgeChain == nil { - return nil, fmt.Errorf("bridge chain is required") - } - if walletMainUtxo == nil { - return nil, fmt.Errorf( - "wallet main UTXO is required by the dissolution action", - ) - } - if bridgeChain.ComputeMainUtxoHash(walletMainUtxo) != - action.ExpectedMainUtxoHash { - return nil, fmt.Errorf( - "wallet main UTXO does not match the dissolution action snapshot", - ) - } - } - - builder := bitcoin.NewTransactionBuilder(bitcoinChain) - - // The Bridge requires the anchor outpoint to be the first input. - err := builder.AddPublicKeyHashInput(anchorUtxo) - if err != nil { - return nil, fmt.Errorf( - "cannot add input pointing to anchor UTXO: [%v]", - err, - ) - } - - totalInputsValue := anchorUtxo.Value - - if mainUtxoExpected { - err = builder.AddPublicKeyHashInput(walletMainUtxo) - if err != nil { - return nil, fmt.Errorf( - "cannot add input pointing to wallet main UTXO: [%v]", - err, - ) - } - totalInputsValue += walletMainUtxo.Value - } - - dissolutionValue := totalInputsValue - fee - if dissolutionValue <= 0 { - return nil, fmt.Errorf( - "transaction fee exceeds the total inputs value", - ) - } - - dissolutionScript, err := bitcoin.PayToWitnessPublicKeyHash( - walletPublicKeyHash, - ) - if err != nil { - return nil, fmt.Errorf("cannot compute dissolution script: [%v]", err) - } - - builder.AddOutput(&bitcoin.TransactionOutput{ - Value: dissolutionValue, - PublicKeyScript: dissolutionScript, - }) - - return builder, nil -} - // reservationActionSigningTimeoutSafetyMarginBlocks is the duration, in // blocks, that must remain before the proposal's expiry block for signing // to be attempted. Mirrors redemptionSigningTimeoutSafetyMarginBlocks. @@ -818,6 +413,7 @@ func (raa *reservationAnchorAction) execute() error { // match the exact funding outpoint among the returned events. events, err := raa.chain.PastDepositRevealedEvents(&DepositRevealedEventFilter{ WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + StartBlock: raa.startBlock - reservationLookBackBlocks, }) if err != nil { return fmt.Errorf("cannot fetch deposit revealed events: [%v]", err) @@ -859,6 +455,9 @@ func (raa *reservationAnchorAction) execute() error { if err != nil { return fmt.Errorf("cannot get reservation action: [%v]", err) } + if action.ActionType != ReservationActionTypeAcceptance || action.State != ReservationActionStatePending { + return fmt.Errorf("reservation action is not a pending acceptance") + } err = raa.chain.ValidateReservationAnchorProposal( walletPublicKeyHash, @@ -883,7 +482,7 @@ func (raa *reservationAnchorAction) execute() error { return fmt.Errorf("cannot assemble reservation anchor transaction: [%v]", err) } - // Just in case. This should never happen. + // Prevent unsigned underflow in signing deadline calculation. if raa.expiryBlock < reservationActionSigningTimeoutSafetyMarginBlocks { return fmt.Errorf("invalid proposal expiry block") } @@ -975,6 +574,12 @@ func (rra *reservationReanchorAction) execute() error { if err != nil { return fmt.Errorf("cannot get reservation action: [%v]", err) } + if action.ActionType != ReservationActionTypeReanchor || action.State != ReservationActionStatePending { + return fmt.Errorf("reservation action is not a pending reanchor") + } + if action.TargetWalletPublicKeyHash != rra.proposal.TargetWalletPublicKeyHash { + return fmt.Errorf("reservation action targets a different wallet") + } err = rra.chain.ValidateReservationReanchorProposal( walletPublicKeyHash, @@ -995,7 +600,7 @@ func (rra *reservationReanchorAction) execute() error { return fmt.Errorf("cannot assemble reservation reanchor transaction: [%v]", err) } - // Just in case. This should never happen. + // Prevent unsigned underflow in signing deadline calculation. if rra.expiryBlock < reservationActionSigningTimeoutSafetyMarginBlocks { return fmt.Errorf("invalid proposal expiry block") } diff --git a/pkg/tbtc/reservation_test.go b/pkg/tbtc/reservation_test.go index 40b9141f82..925949ad82 100644 --- a/pkg/tbtc/reservation_test.go +++ b/pkg/tbtc/reservation_test.go @@ -3,19 +3,24 @@ package tbtc import ( "crypto/ecdsa" "crypto/rand" + "crypto/sha256" + "encoding/hex" "math/big" "reflect" "testing" + "time" + + "go.uber.org/zap" + "google.golang.org/protobuf/proto" "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/tbtc/gen/pb" ) func TestReservationActionTypes(t *testing.T) { for value, expected := range map[uint8]WalletActionType{ 6: ActionReservationAnchor, - 7: ActionReservedRedemption, 8: ActionReservationReanchor, - 9: ActionReservationDissolution, } { parsed, err := ParseWalletActionType(value) if err != nil { @@ -101,12 +106,6 @@ func TestReservationProposals_MarshalingRoundtrip(t *testing.T) { AnchorTxFee: big.NewInt(1500), } - redemptionProposal := &ReservedRedemptionProposal{ - ReservationKey: big.NewInt(12345), - RequestNonce: 2, - RedemptionTxFee: big.NewInt(1600), - } - reanchorProposal := &ReservationReanchorProposal{ ReservationKey: big.NewInt(54321), RequestNonce: 3, @@ -114,12 +113,6 @@ func TestReservationProposals_MarshalingRoundtrip(t *testing.T) { ReanchorTxFee: big.NewInt(1700), } - dissolutionProposal := &ReservationDissolutionProposal{ - ReservationKey: big.NewInt(99999), - RequestNonce: 4, - DissolutionTxFee: big.NewInt(1800), - } - roundtrip := func( proposal CoordinationProposal, fresh CoordinationProposal, @@ -141,84 +134,60 @@ func TestReservationProposals_MarshalingRoundtrip(t *testing.T) { } roundtrip(anchorProposal, &ReservationAnchorProposal{}) - roundtrip(redemptionProposal, &ReservedRedemptionProposal{}) roundtrip(reanchorProposal, &ReservationReanchorProposal{}) - roundtrip(dissolutionProposal, &ReservationDissolutionProposal{}) } func TestReservationProposals_UnmarshalRejectsMissingIntegers(t *testing.T) { tests := map[string]struct { actionType WalletActionType - payload string + payload []byte expectedError string }{ "anchor empty object": { actionType: ActionReservationAnchor, - payload: `{}`, + payload: marshalPb(t, &pb.ReservationAnchorProposal{}), expectedError: "cannot unmarshal proposal payload: [anchor transaction fee is required]", }, "anchor null payload": { actionType: ActionReservationAnchor, - payload: `null`, + payload: nil, expectedError: "cannot unmarshal proposal payload: [anchor transaction fee is required]", }, "anchor missing nonce": { - actionType: ActionReservationAnchor, - payload: `{"AnchorTxFee":1500}`, + actionType: ActionReservationAnchor, + payload: marshalPb(t, &pb.ReservationAnchorProposal{ + AnchorTxFee: big.NewInt(1500).Bytes(), + }), expectedError: "cannot unmarshal proposal payload: [request nonce is required]", }, - "reserved redemption null payload": { - actionType: ActionReservedRedemption, - payload: `null`, - expectedError: "cannot unmarshal proposal payload: [reservation key is required]", - }, - "reserved redemption missing nonce": { - actionType: ActionReservedRedemption, - payload: `{"ReservationKey":12345,"RedemptionTxFee":1600}`, - expectedError: "cannot unmarshal proposal payload: [request nonce is required]", - }, - "reserved redemption missing fee": { - actionType: ActionReservedRedemption, - payload: `{"ReservationKey":12345,"RequestNonce":2}`, - expectedError: "cannot unmarshal proposal payload: [redemption transaction fee is required]", - }, "re-anchor null payload": { actionType: ActionReservationReanchor, - payload: `null`, + payload: nil, expectedError: "cannot unmarshal proposal payload: [reservation key is required]", }, "re-anchor missing nonce": { - actionType: ActionReservationReanchor, - payload: `{"ReservationKey":54321,"ReanchorTxFee":1700}`, + actionType: ActionReservationReanchor, + payload: marshalPb(t, &pb.ReservationReanchorProposal{ + ReservationKey: big.NewInt(54321).Bytes(), + ReanchorTxFee: big.NewInt(1700).Bytes(), + }), expectedError: "cannot unmarshal proposal payload: [request nonce is required]", }, "re-anchor missing fee": { - actionType: ActionReservationReanchor, - payload: `{"ReservationKey":54321,"RequestNonce":3}`, + actionType: ActionReservationReanchor, + payload: marshalPb(t, &pb.ReservationReanchorProposal{ + ReservationKey: big.NewInt(54321).Bytes(), + RequestNonce: 3, + }), expectedError: "cannot unmarshal proposal payload: [re-anchor transaction fee is required]", }, - "dissolution null payload": { - actionType: ActionReservationDissolution, - payload: `null`, - expectedError: "cannot unmarshal proposal payload: [reservation key is required]", - }, - "dissolution missing nonce": { - actionType: ActionReservationDissolution, - payload: `{"ReservationKey":99999,"DissolutionTxFee":1800}`, - expectedError: "cannot unmarshal proposal payload: [request nonce is required]", - }, - "dissolution missing fee": { - actionType: ActionReservationDissolution, - payload: `{"ReservationKey":99999,"RequestNonce":4}`, - expectedError: "cannot unmarshal proposal payload: [dissolution transaction fee is required]", - }, } for testName, test := range tests { t.Run(testName, func(t *testing.T) { _, err := unmarshalCoordinationProposal( uint32(test.actionType), - []byte(test.payload), + test.payload, ) if err == nil || err.Error() != test.expectedError { t.Errorf( @@ -231,279 +200,14 @@ func TestReservationProposals_UnmarshalRejectsMissingIntegers(t *testing.T) { } } -func TestAssembleReservedRedemptionTransaction(t *testing.T) { - bitcoinChain := newLocalBitcoinChain() - - privateKeyValue := big.NewInt(100) - wallet := generateWallet(privateKeyValue) - walletPublicKeyHash := bitcoin.PublicKeyHash(wallet.publicKey) - walletScript, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) - if err != nil { - t.Fatal(err) - } - - redeemerScript, err := bitcoin.PayToWitnessPublicKeyHash([20]byte{0x01}) - if err != nil { - t.Fatal(err) - } - - fundingTransaction := &bitcoin.Transaction{ - Version: 1, - Inputs: []*bitcoin.TransactionInput{ - { - Outpoint: &bitcoin.TransactionOutpoint{ - TransactionHash: bitcoin.Hash{0x01}, - OutputIndex: 0, - }, - Sequence: 0xffffffff, - }, - }, - Outputs: []*bitcoin.TransactionOutput{ - { - Value: 100000, - PublicKeyScript: walletScript, - }, - }, - } - if err := bitcoinChain.BroadcastTransaction(fundingTransaction); err != nil { - t.Fatal(err) - } - - anchorUtxo := &bitcoin.UnspentTransactionOutput{ - Outpoint: &bitcoin.TransactionOutpoint{ - TransactionHash: fundingTransaction.Hash(), - OutputIndex: 0, - }, - Value: 100000, - } - - redeemerOutputScriptHash, err := computeReservationRedeemerOutputScriptHash( - redeemerScript, - ) - if err != nil { - t.Fatal(err) - } - - tests := map[string]struct { - action *ReservationAction - expectedOutputs []*bitcoin.TransactionOutput - }{ - "whole redemption": { - action: &ReservationAction{ - TxMaxFee: 2000, - ActionType: ReservationActionTypeRedemption, - State: ReservationActionStatePending, - Amount: 100000, - RedeemerOutputScriptHash: redeemerOutputScriptHash, - }, - expectedOutputs: []*bitcoin.TransactionOutput{ - { - Value: 98500, - PublicKeyScript: redeemerScript, - }, - }, - }, - "partial redemption": { - action: &ReservationAction{ - TxMaxFee: 2000, - ActionType: ReservationActionTypeRedemption, - State: ReservationActionStatePending, - Amount: 40000, - RedeemerOutputScriptHash: redeemerOutputScriptHash, - IsPartial: true, - }, - expectedOutputs: []*bitcoin.TransactionOutput{ - { - Value: 38500, - PublicKeyScript: redeemerScript, - }, - { - Value: 60000, - PublicKeyScript: walletScript, - }, - }, - }, - } - - for testName, test := range tests { - t.Run(testName, func(t *testing.T) { - builder, err := assembleReservedRedemptionTransaction( - bitcoinChain, - anchorUtxo, - walletPublicKeyHash, - redeemerScript, - test.action, - 1500, - ) - if err != nil { - t.Fatal(err) - } - - transaction := signReservationTransaction( - t, - builder, - wallet.publicKey, - privateKeyValue, - ) - - if !reflect.DeepEqual(test.expectedOutputs, transaction.Outputs) { - t.Errorf( - "unexpected outputs\nexpected: [%+v]\nactual: [%+v]", - test.expectedOutputs, - transaction.Outputs, - ) - } - }) - } -} - -func TestAssembleReservationDissolutionTransaction(t *testing.T) { - bitcoinChain := newLocalBitcoinChain() - bridgeChain := Connect() - - privateKeyValue := big.NewInt(100) - wallet := generateWallet(privateKeyValue) - walletPublicKeyHash := bitcoin.PublicKeyHash(wallet.publicKey) - walletScript, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) +// marshalPb marshals a protobuf message for use as a test fixture payload. +func marshalPb(t *testing.T, msg proto.Message) []byte { + t.Helper() + data, err := proto.Marshal(msg) if err != nil { t.Fatal(err) } - - fundingTransaction := &bitcoin.Transaction{ - Version: 1, - Inputs: []*bitcoin.TransactionInput{ - { - Outpoint: &bitcoin.TransactionOutpoint{ - TransactionHash: bitcoin.Hash{0x01}, - OutputIndex: 0, - }, - Sequence: 0xffffffff, - }, - }, - Outputs: []*bitcoin.TransactionOutput{ - { - Value: 100000, - PublicKeyScript: walletScript, - }, - { - Value: 200000, - PublicKeyScript: walletScript, - }, - }, - } - if err := bitcoinChain.BroadcastTransaction(fundingTransaction); err != nil { - t.Fatal(err) - } - - anchorUtxo := &bitcoin.UnspentTransactionOutput{ - Outpoint: &bitcoin.TransactionOutpoint{ - TransactionHash: fundingTransaction.Hash(), - OutputIndex: 0, - }, - Value: 100000, - } - walletMainUtxo := &bitcoin.UnspentTransactionOutput{ - Outpoint: &bitcoin.TransactionOutpoint{ - TransactionHash: fundingTransaction.Hash(), - OutputIndex: 1, - }, - Value: 200000, - } - - baseAction := ReservationAction{ - TargetWalletPublicKeyHash: walletPublicKeyHash, - TxMaxFee: 2000, - ActionType: ReservationActionTypeDissolution, - State: ReservationActionStatePending, - Amount: 100000, - } - - tests := map[string]struct { - action *ReservationAction - expectedInputUtxos []*bitcoin.UnspentTransactionOutput - expectedOutputValue int64 - }{ - "snapshotted main UTXO": { - action: func() *ReservationAction { - action := baseAction - action.ExpectedMainUtxoHash = bridgeChain.ComputeMainUtxoHash( - walletMainUtxo, - ) - return &action - }(), - expectedInputUtxos: []*bitcoin.UnspentTransactionOutput{ - anchorUtxo, - walletMainUtxo, - }, - expectedOutputValue: 298500, - }, - "no-main-UTXO snapshot with newly current main UTXO": { - action: &baseAction, - expectedInputUtxos: []*bitcoin.UnspentTransactionOutput{ - anchorUtxo, - }, - expectedOutputValue: 98500, - }, - } - - for testName, test := range tests { - t.Run(testName, func(t *testing.T) { - builder, err := assembleReservationDissolutionTransaction( - bitcoinChain, - bridgeChain, - anchorUtxo, - walletMainUtxo, - walletPublicKeyHash, - test.action, - 1500, - ) - if err != nil { - t.Fatal(err) - } - - transaction := signReservationTransaction( - t, - builder, - wallet.publicKey, - privateKeyValue, - ) - - if len(transaction.Inputs) != len(test.expectedInputUtxos) { - t.Fatalf( - "unexpected input count\nexpected: [%v]\nactual: [%v]", - len(test.expectedInputUtxos), - len(transaction.Inputs), - ) - } - for i, expectedInputUtxo := range test.expectedInputUtxos { - if !reflect.DeepEqual( - expectedInputUtxo.Outpoint, - transaction.Inputs[i].Outpoint, - ) { - t.Errorf( - "unexpected input at index [%v]\nexpected: [%+v]\nactual: [%+v]", - i, - expectedInputUtxo.Outpoint, - transaction.Inputs[i].Outpoint, - ) - } - } - - expectedOutputs := []*bitcoin.TransactionOutput{ - { - Value: test.expectedOutputValue, - PublicKeyScript: walletScript, - }, - } - if !reflect.DeepEqual(expectedOutputs, transaction.Outputs) { - t.Errorf( - "unexpected outputs\nexpected: [%+v]\nactual: [%+v]", - expectedOutputs, - transaction.Outputs, - ) - } - }) - } + return data } func signReservationTransaction( @@ -546,9 +250,7 @@ func signReservationTransaction( func TestAssembleReservationTransactions_InputValidation(t *testing.T) { bitcoinChain := newLocalBitcoinChain() - bridgeChain := Connect() walletPublicKeyHash := [20]byte{0x01} - redeemerScript := bitcoin.Script{0x00, 0x14, 0x02} anchorUtxo := &bitcoin.UnspentTransactionOutput{ Outpoint: &bitcoin.TransactionOutpoint{ @@ -557,26 +259,6 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { }, Value: 100000, } - redeemerOutputScriptHash, err := computeReservationRedeemerOutputScriptHash( - redeemerScript, - ) - if err != nil { - t.Fatal(err) - } - redemptionAction := &ReservationAction{ - TxMaxFee: 2000, - ActionType: ReservationActionTypeRedemption, - State: ReservationActionStatePending, - Amount: 100000, - RedeemerOutputScriptHash: redeemerOutputScriptHash, - } - dissolutionAction := &ReservationAction{ - TargetWalletPublicKeyHash: walletPublicKeyHash, - TxMaxFee: 2000, - ActionType: ReservationActionTypeDissolution, - State: ReservationActionStatePending, - Amount: 100000, - } assertError := func(err error, expected string) { if err == nil || err.Error() != expected { @@ -584,6 +266,7 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { } } + var err error _, err = AssembleReservationAnchorTransaction( bitcoinChain, nil, @@ -622,109 +305,6 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { ) assertError(err, "fee exceeds the maximum allowed fee") - _, err = assembleReservedRedemptionTransaction( - bitcoinChain, - nil, - walletPublicKeyHash, - redeemerScript, - redemptionAction, - 1500, - ) - assertError(err, "anchor UTXO is required") - - _, err = assembleReservedRedemptionTransaction( - bitcoinChain, - anchorUtxo, - walletPublicKeyHash, - bitcoin.Script{}, - redemptionAction, - 1500, - ) - assertError(err, "redeemer output script is required") - - _, err = assembleReservedRedemptionTransaction( - bitcoinChain, - anchorUtxo, - walletPublicKeyHash, - redeemerScript, - nil, - 1500, - ) - assertError(err, "reservation action is required") - - nonRedemptionAction := *redemptionAction - nonRedemptionAction.ActionType = ReservationActionTypeReanchor - _, err = assembleReservedRedemptionTransaction( - bitcoinChain, - anchorUtxo, - walletPublicKeyHash, - redeemerScript, - &nonRedemptionAction, - 1500, - ) - assertError(err, "reservation action is not a redemption") - - nonPendingAction := *redemptionAction - nonPendingAction.State = ReservationActionStateTimedOut - _, err = assembleReservedRedemptionTransaction( - bitcoinChain, - anchorUtxo, - walletPublicKeyHash, - redeemerScript, - &nonPendingAction, - 1500, - ) - assertError(err, "reservation action is not pending") - - wrongScriptAction := *redemptionAction - wrongScriptAction.RedeemerOutputScriptHash = [32]byte{0x01} - _, err = assembleReservedRedemptionTransaction( - bitcoinChain, - anchorUtxo, - walletPublicKeyHash, - redeemerScript, - &wrongScriptAction, - 1500, - ) - assertError(err, "redeemer output script is not authorized") - - partialWholeAmountAction := *redemptionAction - partialWholeAmountAction.IsPartial = true - _, err = assembleReservedRedemptionTransaction( - bitcoinChain, - anchorUtxo, - walletPublicKeyHash, - redeemerScript, - &partialWholeAmountAction, - 1500, - ) - assertError( - err, - "partial redemption amount must be less than the anchor value", - ) - - partialAmountAction := *redemptionAction - partialAmountAction.Amount = 40000 - _, err = assembleReservedRedemptionTransaction( - bitcoinChain, - anchorUtxo, - walletPublicKeyHash, - redeemerScript, - &partialAmountAction, - 1500, - ) - assertError(err, "whole redemption amount must equal the anchor value") - - _, err = assembleReservedRedemptionTransaction( - bitcoinChain, - anchorUtxo, - walletPublicKeyHash, - redeemerScript, - redemptionAction, - 2500, - ) - assertError(err, "transaction fee exceeds the action fee limit") - _, err = AssembleReservationReanchorTransaction( bitcoinChain, nil, @@ -760,69 +340,337 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { 1500, ) assertError(err, "fee exceeds the maximum allowed fee") +} - _, err = assembleReservationDissolutionTransaction( +func TestAssembleReservationTransactions_FeeBoundaries(t *testing.T) { + bitcoinChain := newLocalBitcoinChain() + walletPublicKeyHash := [20]byte{0x01} + + // Anchor boundary + _, err := AssembleReservationAnchorTransaction( bitcoinChain, - bridgeChain, - nil, - nil, + &Deposit{Utxo: &bitcoin.UnspentTransactionOutput{Value: 100000}}, walletPublicKeyHash, - dissolutionAction, - 1500, + &ReservationAction{TxMaxFee: 200000}, + 100000, ) - assertError(err, "anchor UTXO is required") + if err == nil || err.Error() != "transaction fee exceeds the deposit amount" { + t.Errorf("expected error [transaction fee exceeds the deposit amount], got [%v]", err) + } - _, err = assembleReservationDissolutionTransaction( + // Reanchor boundary + _, err = AssembleReservationReanchorTransaction( bitcoinChain, - bridgeChain, - anchorUtxo, - nil, + &bitcoin.UnspentTransactionOutput{Value: 100000}, walletPublicKeyHash, - nil, - 1500, + &ReservationAction{TxMaxFee: 200000}, + 100000, ) - assertError(err, "reservation action is required") - - snapshottedMainUtxo := &bitcoin.UnspentTransactionOutput{ - Outpoint: &bitcoin.TransactionOutpoint{ - TransactionHash: bitcoin.Hash{0x04}, - OutputIndex: 1, - }, - Value: 200000, + if err == nil || err.Error() != "transaction fee exceeds the anchor value" { + t.Errorf("expected error [transaction fee exceeds the anchor value], got [%v]", err) } - actionWithMainUtxo := *dissolutionAction - actionWithMainUtxo.ExpectedMainUtxoHash = bridgeChain.ComputeMainUtxoHash( - snapshottedMainUtxo, - ) - _, err = assembleReservationDissolutionTransaction( - bitcoinChain, - bridgeChain, - anchorUtxo, - nil, - walletPublicKeyHash, - &actionWithMainUtxo, - 1500, +} + +// reservationTestWallet returns a wallet with a real ECDSA public key so +// bitcoin.PublicKeyHash (called at the top of both execute() methods) +// doesn't panic on a nil key. +func reservationTestWallet(t *testing.T) wallet { + t.Helper() + + publicKeyBytes, err := hex.DecodeString( + "0471e30bca60f6548d7b42582a478ea37ada63b402af7b3ddd57f0c95bb6843175" + + "aa0d2053a91a050a6797d85c38f2909cb7027f2344a01986aa2f9f8ca7a0c289", ) - assertError(err, "wallet main UTXO is required by the dissolution action") + if err != nil { + t.Fatal(err) + } - currentMainUtxo := &bitcoin.UnspentTransactionOutput{ - Outpoint: &bitcoin.TransactionOutpoint{ - TransactionHash: bitcoin.Hash{0x05}, - OutputIndex: 2, - }, - Value: 300000, + return wallet{publicKey: mustUnmarshalPublicKey(t, publicKeyBytes)} +} + +func TestReservationAnchorAction_Execute(t *testing.T) { + const fundingOutputIndex = 0 + + custodyWallet := reservationTestWallet(t) + walletPublicKeyHash := bitcoin.PublicKeyHash(custodyWallet.publicKey) + + newAction := func( + chain Chain, + btcChain bitcoin.Chain, + fundingTxHash bitcoin.Hash, + ) *reservationAnchorAction { + return newReservationAnchorAction( + zap.NewNop().Sugar(), + chain, + btcChain, + custodyWallet, + nil, // signing executor unreached by these negative-path cases + &ReservationAnchorProposal{ + DepositFundingTxHash: fundingTxHash, + DepositFundingOutputIndex: fundingOutputIndex, + RequestNonce: 1, + AnchorTxFee: big.NewInt(1500), + }, + 300000, + 300000+600, + nil, + nil, + ) } - _, err = assembleReservationDissolutionTransaction( - bitcoinChain, - bridgeChain, - anchorUtxo, - currentMainUtxo, - walletPublicKeyHash, - &actionWithMainUtxo, - 1500, - ) - assertError( - err, - "wallet main UTXO does not match the dissolution action snapshot", - ) + + t.Run("no matching DepositRevealed event", func(t *testing.T) { + chain := Connect() + btcChain := newLocalBitcoinChain() + + fundingTx := &bitcoin.Transaction{ + Outputs: []*bitcoin.TransactionOutput{{Value: 100000}}, + } + if err := btcChain.BroadcastTransaction(fundingTx); err != nil { + t.Fatal(err) + } + fundingTxHash := fundingTx.Hash() + + // A DepositRevealed event exists for this wallet, but for a + // different funding outpoint - the matching loop must walk past + // it and still report no match, not silently accept it. + if err := chain.setPastDepositRevealedEvents( + &DepositRevealedEventFilter{ + WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + StartBlock: 300000 - reservationLookBackBlocks, + }, + []*DepositRevealedEvent{{ + FundingTxHash: bitcoin.Hash{0x99}, + FundingOutputIndex: 0, + WalletPublicKeyHash: walletPublicKeyHash, + }}, + ); err != nil { + t.Fatal(err) + } + + err := newAction(chain, btcChain, fundingTxHash).execute() + if err == nil || err.Error() != "no matching DepositRevealed event for deposit" { + t.Errorf( + "unexpected error\nexpected: [no matching DepositRevealed event for deposit]\nactual: [%v]", + err, + ) + } + }) + + t.Run("deposit request not found", func(t *testing.T) { + chain := Connect() + btcChain := newLocalBitcoinChain() + + fundingTx := &bitcoin.Transaction{ + Outputs: []*bitcoin.TransactionOutput{{Value: 100000}}, + } + if err := btcChain.BroadcastTransaction(fundingTx); err != nil { + t.Fatal(err) + } + fundingTxHash := fundingTx.Hash() + + if err := chain.setPastDepositRevealedEvents( + &DepositRevealedEventFilter{ + WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + StartBlock: 300000 - reservationLookBackBlocks, + }, + []*DepositRevealedEvent{{ + FundingTxHash: fundingTxHash, + FundingOutputIndex: fundingOutputIndex, + WalletPublicKeyHash: walletPublicKeyHash, + }}, + ); err != nil { + t.Fatal(err) + } + // Deliberately no setDepositRequest call: the Bridge has no + // request record for this funding outpoint. + + err := newAction(chain, btcChain, fundingTxHash).execute() + if err == nil || err.Error() != "deposit request not found" { + t.Errorf( + "unexpected error\nexpected: [deposit request not found]\nactual: [%v]", + err, + ) + } + }) + + t.Run("full happy path up to the signing boundary", func(t *testing.T) { + chain := Connect() + btcChain := newLocalBitcoinChain() + + depositForScript := &Deposit{ + Depositor: "0x0000000000000000000000000000000000000001", + WalletPublicKeyHash: walletPublicKeyHash, + } + depositScript, err := depositForScript.Script() + if err != nil { + t.Fatal(err) + } + scriptHash := sha256.Sum256(depositScript) + fundingOutputScript, err := bitcoin.PayToWitnessScriptHash(scriptHash) + if err != nil { + t.Fatal(err) + } + + fundingTx := &bitcoin.Transaction{ + Outputs: []*bitcoin.TransactionOutput{{ + Value: 100000, + PublicKeyScript: fundingOutputScript, + }}, + } + if err := btcChain.BroadcastTransaction(fundingTx); err != nil { + t.Fatal(err) + } + fundingTxHash := fundingTx.Hash() + + if err := chain.setPastDepositRevealedEvents( + &DepositRevealedEventFilter{ + WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + StartBlock: 300000 - reservationLookBackBlocks, + }, + []*DepositRevealedEvent{{ + FundingTxHash: fundingTxHash, + FundingOutputIndex: fundingOutputIndex, + WalletPublicKeyHash: walletPublicKeyHash, + Amount: 100000, + Depositor: "0x0000000000000000000000000000000000000001", + }}, + ); err != nil { + t.Fatal(err) + } + chain.setDepositRequest(fundingTxHash, fundingOutputIndex, &DepositChainRequest{ + Amount: 100000, + RevealedAt: time.Now(), + }) + chain.setReservationAction(&ReservationAction{ + ActionType: ReservationActionTypeAcceptance, + State: ReservationActionStatePending, + TxMaxFee: 2000, + }) + + action := newAction(chain, btcChain, fundingTxHash) + // Below reservationActionSigningTimeoutSafetyMarginBlocks (300): + // every real upstream step (event match, deposit request fetch, + // reservation key derivation, action load, on-chain validation, + // transaction assembly) must succeed before this guard is + // reached and rejects the proposal - reaching this exact error + // is the test's proof that all of it worked. + action.expiryBlock = 100 + + err = action.execute() + if err == nil || err.Error() != "invalid proposal expiry block" { + t.Errorf( + "unexpected error\nexpected: [invalid proposal expiry block]\nactual: [%v]", + err, + ) + } + }) +} + +func TestReservationReanchorAction_Execute(t *testing.T) { + custodyWallet := reservationTestWallet(t) + walletPublicKeyHash := bitcoin.PublicKeyHash(custodyWallet.publicKey) + + reservationKey := big.NewInt(777) + + newAction := func( + chain Chain, + btcChain bitcoin.Chain, + ) *reservationReanchorAction { + return newReservationReanchorAction( + zap.NewNop().Sugar(), + chain, + btcChain, + custodyWallet, + nil, // signing executor unreached by these negative-path cases + &ReservationReanchorProposal{ + ReservationKey: reservationKey, + RequestNonce: 1, + TargetWalletPublicKeyHash: walletPublicKeyHash, + ReanchorTxFee: big.NewInt(1500), + }, + 300000, + 100, // below reservationActionSigningTimeoutSafetyMarginBlocks + nil, + nil, + ) + } + + t.Run("full happy path up to the signing boundary", func(t *testing.T) { + chain := Connect() + btcChain := newLocalBitcoinChain() + + anchorOutputScript, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + priorAnchorTx := &bitcoin.Transaction{ + Outputs: []*bitcoin.TransactionOutput{ + {Value: 10000}, + {Value: 100000, PublicKeyScript: anchorOutputScript}, + }, + } + if err := btcChain.BroadcastTransaction(priorAnchorTx); err != nil { + t.Fatal(err) + } + + chain.setReservation(&Reservation{ + WalletPublicKeyHash: walletPublicKeyHash, + AnchorUtxo: &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: priorAnchorTx.Hash(), + OutputIndex: 1, + }, + Value: 100000, + }, + }) + chain.setReservationAction(&ReservationAction{ + ActionType: ReservationActionTypeReanchor, + State: ReservationActionStatePending, + TargetWalletPublicKeyHash: walletPublicKeyHash, + TxMaxFee: 2000, + }) + + // Every real upstream step (reservation load, action load, + // type/state check, target wallet match, on-chain validation, + // transaction assembly) must succeed before the expiry-block + // guard is reached and rejects the proposal - reaching this + // exact error is the test's proof that all of it worked. + err = newAction(chain, btcChain).execute() + if err == nil || err.Error() != "invalid proposal expiry block" { + t.Errorf( + "unexpected error\nexpected: [invalid proposal expiry block]\nactual: [%v]", + err, + ) + } + }) + + t.Run("target wallet mismatch is rejected before signing", func(t *testing.T) { + chain := Connect() + btcChain := newLocalBitcoinChain() + + chain.setReservation(&Reservation{ + WalletPublicKeyHash: walletPublicKeyHash, + AnchorUtxo: &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x01}, + OutputIndex: 0, + }, + Value: 100000, + }, + }) + chain.setReservationAction(&ReservationAction{ + ActionType: ReservationActionTypeReanchor, + State: ReservationActionStatePending, + TargetWalletPublicKeyHash: [20]byte{0xff}, // does not match the proposal's target + TxMaxFee: 2000, + }) + + err := newAction(chain, btcChain).execute() + if err == nil || err.Error() != "reservation action targets a different wallet" { + t.Errorf( + "unexpected error\nexpected: [reservation action targets a different wallet]\nactual: [%v]", + err, + ) + } + }) } diff --git a/pkg/tbtc/tbtc.go b/pkg/tbtc/tbtc.go index 1472029ce1..720baed793 100644 --- a/pkg/tbtc/tbtc.go +++ b/pkg/tbtc/tbtc.go @@ -12,6 +12,7 @@ import ( "github.com/keep-network/keep-common/pkg/chain/ethereum" "github.com/keep-network/keep-common/pkg/persistence" + "github.com/keep-network/keep-core/pkg/clientinfo" "github.com/keep-network/keep-core/pkg/generator" "github.com/keep-network/keep-core/pkg/net" @@ -125,6 +126,11 @@ type ReservationsConfig struct { Enabled bool } +// WalletMembersResolver defines the interface for resolving wallet members. +type WalletMembersResolver interface { + ResolveWalletMembers(walletPublicKeyHash [20]byte) ([]uint32, error) +} + // Initialize kicks off the TBTC by initializing internal state, ensuring // preconditions like staking are met, and then kicking off the internal TBTC // implementation. Returns an error if this failed. @@ -150,7 +156,7 @@ func Initialize( clientInfo *clientinfo.Registry, perfMetrics *clientinfo.PerformanceMetrics, ethereumNetwork ethereum.Network, -) error { +) (WalletMembersResolver, error) { groupParameters := defaultGroupParameters(ethereumNetwork) if ethChain, ok := chain.(interface { @@ -158,7 +164,7 @@ func Initialize( }); ok { gp, err := ethChain.EcdsaWalletGroupParametersFromChain(ctx) if err != nil { - return fmt.Errorf( + return nil, fmt.Errorf( "cannot read TBTC group sizing from ECDSA validator: [%w]", err, ) @@ -188,12 +194,12 @@ func Initialize( config, ) if err != nil { - return fmt.Errorf("cannot set up TBTC node: [%v]", err) + return nil, fmt.Errorf("cannot set up TBTC node: [%v]", err) } err = node.runCoordinationLayer(ctx) if err != nil { - return fmt.Errorf("cannot run coordination layer: [%w]", err) + return nil, fmt.Errorf("cannot run coordination layer: [%w]", err) } deduplicator := newDeduplicator() @@ -252,7 +258,7 @@ func Initialize( ), ) if err != nil { - return fmt.Errorf( + return nil, fmt.Errorf( "could not set up sortition pool monitoring: [%v]", err, ) @@ -414,7 +420,7 @@ func Initialize( }() }) - return nil + return node, nil } // enoughPreParamsInPoolPolicy is a policy that enforces the sufficient size diff --git a/pkg/tbtc/wallet.go b/pkg/tbtc/wallet.go index a897108f13..6ef523b52a 100644 --- a/pkg/tbtc/wallet.go +++ b/pkg/tbtc/wallet.go @@ -13,13 +13,14 @@ import ( "golang.org/x/exp/slices" "github.com/ipfs/go-log/v2" + "go.uber.org/zap" + "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/clientinfo" "github.com/keep-network/keep-core/pkg/crypto/secp256k1" "github.com/keep-network/keep-core/pkg/protocol/group" "github.com/keep-network/keep-core/pkg/tecdsa" - "go.uber.org/zap" ) // WalletActionType represents actions types that can be performed by a wallet. @@ -33,9 +34,9 @@ const ( ActionMovingFunds ActionMovedFundsSweep ActionReservationAnchor - ActionReservedRedemption + _ // reserved: formerly ActionReservedRedemption (wire value 7); client-side scaffolding removed, wire slot retained ActionReservationReanchor - ActionReservationDissolution + _ // reserved: formerly ActionReservationDissolution (wire value 9); client-side scaffolding removed, wire slot retained ) // ParseWalletActionType parses the given value into a WalletActionType. @@ -55,12 +56,8 @@ func ParseWalletActionType(value uint8) (WalletActionType, error) { return ActionMovedFundsSweep, nil case 6: return ActionReservationAnchor, nil - case 7: - return ActionReservedRedemption, nil case 8: return ActionReservationReanchor, nil - case 9: - return ActionReservationDissolution, nil default: return 0, fmt.Errorf("unknown wallet action type [%v]", value) } @@ -82,12 +79,8 @@ func (wat WalletActionType) String() string { return "MovedFundsSweep" case ActionReservationAnchor: return "ReservationAnchor" - case ActionReservedRedemption: - return "ReservedRedemption" case ActionReservationReanchor: return "ReservationReanchor" - case ActionReservationDissolution: - return "ReservationDissolution" default: panic("unknown wallet action type") } @@ -111,12 +104,8 @@ func (wat WalletActionType) MetricName() string { return "moved_funds_sweep" case ActionReservationAnchor: return "reservation_anchor" - case ActionReservedRedemption: - return "reserved_redemption" case ActionReservationReanchor: return "reservation_reanchor" - case ActionReservationDissolution: - return "reservation_dissolution" default: panic("unknown wallet action type") } diff --git a/pkg/tbtc/wallet_test.go b/pkg/tbtc/wallet_test.go index 22c7f14a0c..eadc9d352a 100644 --- a/pkg/tbtc/wallet_test.go +++ b/pkg/tbtc/wallet_test.go @@ -57,18 +57,6 @@ func TestParseWalletActionType(t *testing.T) { value: 6, expectedAction: ActionReservationAnchor, }, - "reserved redemption": { - value: 7, - expectedAction: ActionReservedRedemption, - }, - "reservation re-anchor": { - value: 8, - expectedAction: ActionReservationReanchor, - }, - "reservation dissolution": { - value: 9, - expectedAction: ActionReservationDissolution, - }, "unknown": { value: 10, expectedErr: fmt.Errorf("unknown wallet action type [10]"), @@ -100,16 +88,13 @@ func TestParseWalletActionType(t *testing.T) { func TestWalletActionType_MetricName(t *testing.T) { tests := map[WalletActionType]string{ - ActionNoop: "noop", - ActionHeartbeat: "heartbeat", - ActionDepositSweep: "deposit_sweep", - ActionRedemption: "redemption", - ActionMovingFunds: "moving_funds", - ActionMovedFundsSweep: "moved_funds_sweep", - ActionReservationAnchor: "reservation_anchor", - ActionReservedRedemption: "reserved_redemption", - ActionReservationReanchor: "reservation_reanchor", - ActionReservationDissolution: "reservation_dissolution", + ActionNoop: "noop", + ActionHeartbeat: "heartbeat", + ActionDepositSweep: "deposit_sweep", + ActionRedemption: "redemption", + ActionMovingFunds: "moving_funds", + ActionMovedFundsSweep: "moved_funds_sweep", + ActionReservationAnchor: "reservation_anchor", } for actionType, expected := range tests { From 85af2bdc621fd39701148e6454897de633b2448a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 1 Sep 2026 20:12:26 +0000 Subject: [PATCH 25/39] fix(tbtcpg): guard reservation acceptance/reanchor tasks against stuck and repeated candidates - reservation_acceptance.go: gate RequestReservationAcceptance behind an in-flight check (matching the sibling reanchor task's convention) so the same deposit cannot be re-requested across coordination windows; permanently drop candidates whose on-chain state leaves Unknown instead of retrying them forever; cache immutable reveal/ request data on pending candidates and expire terminal/out-of-window entries so unchanged candidates stop re-incurring the full RPC chain every run - reservation_reanchor.go: continue past a single reservation's ProposeReservationReanchor failure instead of aborting the whole wallet's walk, matching every other per-reservation failure in the same loop; cache the incrementally refreshed live-wallet lookup instead of rescanning 216,000 blocks per re-anchor attempt - chain.go: remove 4 Chain interface members with zero production callers, mirroring the pkg/tbtc trim - update the two cap-rejection test scenarios whose fee-estimation failure now logs-and-continues rather than propagating as a returned error --- pkg/tbtcpg/chain.go | 28 --- pkg/tbtcpg/chain_test.go | 45 ----- pkg/tbtcpg/fee.go | 1 + pkg/tbtcpg/internal/test/marshaling.go | 3 +- .../reservation_reanchor_scenario_2.json | 3 +- .../reservation_reanchor_scenario_4.json | 3 +- pkg/tbtcpg/reservation_acceptance.go | 166 ++++++++---------- pkg/tbtcpg/reservation_reanchor.go | 27 ++- pkg/tbtcpg/tbtcpg.go | 5 +- 9 files changed, 101 insertions(+), 180 deletions(-) diff --git a/pkg/tbtcpg/chain.go b/pkg/tbtcpg/chain.go index fa804cdaac..80f3809b76 100644 --- a/pkg/tbtcpg/chain.go +++ b/pkg/tbtcpg/chain.go @@ -236,18 +236,6 @@ type Chain interface { // currently custodied by the given wallet. WalletReservations(walletPublicKeyHash [20]byte) ([]*big.Int, error) - // ReservationByAnchorUtxo returns the reservation key whose anchor - // outpoint is the given Bitcoin transaction output, or an empty value - // if no reservation is anchored there. - ReservationByAnchorUtxo( - anchorTxHash [32]byte, - anchorTxOutputIndex uint32, - ) (*big.Int, error) - - // PendingReservedDeposits returns the number of reserved deposits that - // have been revealed to the Bridge but not yet accepted by a wallet. - PendingReservedDeposits() (uint64, error) - // ActiveReservationsCount returns the current count of active // reservations across all wallets and the cap on that count. ActiveReservationsCount() (count uint32, maxActive uint32, err error) @@ -265,14 +253,6 @@ type Chain interface { filter *tbtc.ReservationAcceptanceRequestedEventFilter, ) ([]*tbtc.ReservationAcceptanceRequestedEvent, error) - // PastReservationAcceptedEvents fetches past ReservationAccepted - // 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. - PastReservationAcceptedEvents( - filter *tbtc.ReservationAcceptedEventFilter, - ) ([]*tbtc.ReservationAcceptedEvent, error) - // PastReservationReanchorRequestedEvents fetches past // ReservationReanchorRequested events according to the provided // filter or unfiltered if the filter is nil. Returned events are @@ -280,12 +260,4 @@ type Chain interface { PastReservationReanchorRequestedEvents( filter *tbtc.ReservationReanchorRequestedEventFilter, ) ([]*tbtc.ReservationReanchorRequestedEvent, error) - - // PastReservationReanchoredEvents fetches past ReservationReanchored - // 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. - PastReservationReanchoredEvents( - filter *tbtc.ReservationReanchoredEventFilter, - ) ([]*tbtc.ReservationReanchoredEvent, error) } diff --git a/pkg/tbtcpg/chain_test.go b/pkg/tbtcpg/chain_test.go index 5878824639..69de27fa20 100644 --- a/pkg/tbtcpg/chain_test.go +++ b/pkg/tbtcpg/chain_test.go @@ -71,7 +71,6 @@ type LocalChain struct { reservationParametersSet bool reservationProposalValidations map[[32]byte]bool reservationReanchorRequestSubmissions []*reservationReanchorRequestSubmission - reservationReanchoredEventEmissions []*tbtc.ReservationReanchoredEvent reservationWalletKeys map[[20]byte][]*big.Int liveWalletsCountValue uint32 liveWalletsCountSet bool @@ -101,7 +100,6 @@ func NewLocalChain() *LocalChain { reservationActions: make(map[string]*tbtc.ReservationAction), reservationProposalValidations: make(map[[32]byte]bool), reservationReanchorRequestSubmissions: make([]*reservationReanchorRequestSubmission, 0), - reservationReanchoredEventEmissions: make([]*tbtc.ReservationReanchoredEvent, 0), reservationWalletKeys: make(map[[20]byte][]*big.Int), } } @@ -1634,24 +1632,6 @@ func (lc *LocalChain) SetWalletReservations( lc.reservationWalletKeys[walletPublicKeyHash] = copy } -// ReservationByAnchorUtxo returns an empty reservation key (no lookup). -func (lc *LocalChain) ReservationByAnchorUtxo( - anchorTxHash [32]byte, - anchorTxOutputIndex uint32, -) (*big.Int, error) { - lc.mutex.Lock() - defer lc.mutex.Unlock() - - _ = anchorTxHash - _ = anchorTxOutputIndex - return new(big.Int), nil -} - -// PendingReservedDeposits reports zero pending reserved deposits. -func (lc *LocalChain) PendingReservedDeposits() (uint64, error) { - return 0, nil -} - // Reservations is a stub mirroring the Bridge view. Tests that need this // data should populate it explicitly via custom extensions. @@ -1678,13 +1658,6 @@ func (lc *LocalChain) PastReservationAcceptanceRequestedEvents( return nil, nil } -// PastReservationAcceptedEvents returns no events by default. -func (lc *LocalChain) PastReservationAcceptedEvents( - filter *tbtc.ReservationAcceptedEventFilter, -) ([]*tbtc.ReservationAcceptedEvent, error) { - return nil, nil -} - // PastReservationReanchorRequestedEvents returns the recorded re-anchor // request submissions that match the filter. func (lc *LocalChain) PastReservationReanchorRequestedEvents( @@ -1735,21 +1708,3 @@ func (lc *LocalChain) GetReservationReanchorRequestSubmissions() []*reservationR } return copy } - -// PastReservationReanchoredEvents returns the recorded re-anchor settlement -// events that match the filter. -func (lc *LocalChain) PastReservationReanchoredEvents( - filter *tbtc.ReservationReanchoredEventFilter, -) ([]*tbtc.ReservationReanchoredEvent, error) { - lc.mutex.Lock() - defer lc.mutex.Unlock() - - if len(lc.reservationReanchoredEventEmissions) == 0 { - return nil, nil - } - results := make([]*tbtc.ReservationReanchoredEvent, 0, len(lc.reservationReanchoredEventEmissions)) - for _, ev := range lc.reservationReanchoredEventEmissions { - results = append(results, ev) - } - return results, nil -} diff --git a/pkg/tbtcpg/fee.go b/pkg/tbtcpg/fee.go index 19fce8c2e3..7a0d36ad18 100644 --- a/pkg/tbtcpg/fee.go +++ b/pkg/tbtcpg/fee.go @@ -3,6 +3,7 @@ package tbtcpg import ( "errors" "fmt" + "github.com/keep-network/keep-core/pkg/bitcoin" ) diff --git a/pkg/tbtcpg/internal/test/marshaling.go b/pkg/tbtcpg/internal/test/marshaling.go index f27f431842..cac577f048 100644 --- a/pkg/tbtcpg/internal/test/marshaling.go +++ b/pkg/tbtcpg/internal/test/marshaling.go @@ -5,10 +5,11 @@ import ( "encoding/json" "errors" "fmt" - "github.com/keep-network/keep-core/pkg/tbtcpg" "math/big" "time" + "github.com/keep-network/keep-core/pkg/tbtcpg" + "github.com/keep-network/keep-core/internal/hexutils" "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/tbtc" diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_2.json b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_2.json index ec3cf3b9eb..94d2fd837e 100644 --- a/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_2.json +++ b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_2.json @@ -24,6 +24,5 @@ "HasPendingAction": false, "PendingActionState": "Unknown" } - ], - "ExpectedErr": "cannot prepare reservation re-anchor proposal: [cannot estimate reservation re-anchor transaction fee: [reservation re-anchor estimated fee exceeds the maximum fee]]" + ] } diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_4.json b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_4.json index f2a3bcecd9..39d47f88d7 100644 --- a/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_4.json +++ b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_4.json @@ -24,6 +24,5 @@ "HasPendingAction": false, "PendingActionState": "Unknown" } - ], - "ExpectedErr": "cannot prepare reservation re-anchor proposal: [cannot estimate reservation re-anchor transaction fee: [minimum safe transaction fee exceeds the maximum fee: minimum fee [550], maximum fee [500]]]" + ] } diff --git a/pkg/tbtcpg/reservation_acceptance.go b/pkg/tbtcpg/reservation_acceptance.go index a819fa1690..b44978c192 100644 --- a/pkg/tbtcpg/reservation_acceptance.go +++ b/pkg/tbtcpg/reservation_acceptance.go @@ -31,6 +31,14 @@ const ReservationAcceptanceLookBackBlocks = uint64(216000) // RequestNonce == 0, which is the on-chain confirmation of this convention. const reservationAcceptanceRequestNonce uint64 = 1 +// pendingCandidate holds a candidate deposit event and its +// already-fetched request data. +type pendingCandidate struct { + Event *tbtc.DepositRevealedEvent + DepositRequest *tbtc.DepositChainRequest + ReservationParameters *tbtc.ReservationParameters +} + // ReservationAcceptanceTask is a task that may produce a reservation // acceptance (anchor) proposal. It scans the chain for reserved deposits // revealed to the operator's wallet, validates the wallet's eligibility @@ -59,7 +67,7 @@ type ReservationAcceptanceTask struct { // must still be reconsidered on a later call even though its block // falls before the cursor above; keeping it here is what makes the // cursor safe to advance without losing it. - pendingCandidates map[[20]byte][]*tbtc.DepositRevealedEvent + pendingCandidates map[[20]byte][]*pendingCandidate } // NewReservationAcceptanceTask constructs a ReservationAcceptanceTask. @@ -71,7 +79,7 @@ func NewReservationAcceptanceTask( chain: chain, btcChain: btcChain, lastScannedBlock: make(map[[20]byte]uint64), - pendingCandidates: make(map[[20]byte][]*tbtc.DepositRevealedEvent), + pendingCandidates: make(map[[20]byte][]*pendingCandidate), } } @@ -138,17 +146,6 @@ type reservationAcceptanceCandidate struct { // findReservationAcceptanceCandidate returns the first reserved deposit // that the operator's wallet may accept, or nil when none qualifies. -// -// Discovery is bounded and incremental: PastDepositRevealedEvents is only -// queried for blocks since this wallet's last scan (falling back to -// ReservationAcceptanceLookBackBlocks on the first call), and every -// vault-targeting event found is cached in rat.pendingCandidates so a -// deposit that isn't mature yet, or is briefly blocked by a full cap, is -// still reconsidered on a later call without re-fetching its (already -// past) block range. The vault check runs against event.Vault - already -// present on the DepositRevealedEvent for free - before either -// IsReservedDeposit or GetDepositRequest, so the two RPCs are skipped -// entirely for the common case of a deposit that isn't a reservation. func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( taskLogger log.StandardLogger, walletPublicKeyHash [20]byte, @@ -186,6 +183,7 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( walletPublicKeyHash, currentBlock, reservationVault, + reservationParameters, ) if err != nil { return nil, err @@ -246,58 +244,54 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( var ( found *reservationAcceptanceCandidate - stillUnresolved []*tbtc.DepositRevealedEvent + stillUnresolved []*pendingCandidate ) - for _, event := range candidateEvents { + for _, p := range candidateEvents { if found != nil { - // Already have this call's candidate; keep the rest pending - // for the next call rather than dropping or re-fetching them. - stillUnresolved = append(stillUnresolved, event) + stillUnresolved = append(stillUnresolved, p) continue } + event := p.Event depositKey := rat.chain.BuildDepositKey( event.FundingTxHash, event.FundingOutputIndex, ) - isReserved, err := rat.chain.IsReservedDeposit(depositKey) - if err != nil { - taskLogger.Errorf( - "failed to check if deposit [%v] is reserved: [%v]", - depositKey, - err, + var depositRequest *tbtc.DepositChainRequest + if p.DepositRequest != nil { + depositRequest = p.DepositRequest + } else { + var foundRequest bool + var err error + depositRequest, foundRequest, err = rat.chain.GetDepositRequest( + event.FundingTxHash, + event.FundingOutputIndex, ) - stillUnresolved = append(stillUnresolved, event) - continue - } - if !isReserved { - taskLogger.Infof("not reserved deposit [%v]", depositKey) - // A vault-targeting reveal that the Bridge never marked - // reserved will never become reserved later; drop it. - continue + if err != nil { + taskLogger.Errorf( + "failed to get deposit request for [%v]: [%v]", + depositKey, + err, + ) + stillUnresolved = append(stillUnresolved, p) + continue + } + if !foundRequest { + taskLogger.Warnf( + "no deposit request for reserved deposit [%v]", + depositKey, + ) + stillUnresolved = append(stillUnresolved, p) + continue + } + p.DepositRequest = depositRequest } - depositRequest, foundRequest, err := rat.chain.GetDepositRequest( - event.FundingTxHash, - event.FundingOutputIndex, - ) - if err != nil { - taskLogger.Errorf( - "failed to get deposit request for [%v]: [%v]", - depositKey, - err, - ) - stillUnresolved = append(stillUnresolved, event) - continue - } - if !foundRequest { - taskLogger.Warnf( - "no deposit request for reserved deposit [%v]", - depositKey, - ) - stillUnresolved = append(stillUnresolved, event) + // #6: Permanently drop candidate if amount is below minimum. + if depositRequest.Amount < p.ReservationParameters.ReservationMinAmount { + taskLogger.Infof("deposit [%v] amount [%d] below minimum; dropping", depositKey, depositRequest.Amount) continue } @@ -308,7 +302,7 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( depositKey, now, matureAt, ) - stillUnresolved = append(stillUnresolved, event) + stillUnresolved = append(stillUnresolved, p) continue } @@ -317,7 +311,6 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( "reserved deposit [%v] is already swept", depositKey, ) - // Already settled one way or another; never a candidate again. continue } @@ -339,8 +332,7 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( reservationParameters, ) { taskLogger.Infof("not eligible: [%v]", depositKey) - // Caps/state can free up later; keep it pending. - stillUnresolved = append(stillUnresolved, event) + stillUnresolved = append(stillUnresolved, p) continue } @@ -351,7 +343,7 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( depositKey, err, ) - stillUnresolved = append(stillUnresolved, event) + stillUnresolved = append(stillUnresolved, p) continue } @@ -365,7 +357,7 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( depositKey, err, ) - stillUnresolved = append(stillUnresolved, event) + stillUnresolved = append(stillUnresolved, p) continue } if confirmations < tbtc.DepositSweepRequiredFundingTxConfirmations { @@ -375,7 +367,7 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( confirmations, tbtc.DepositSweepRequiredFundingTxConfirmations, ) - stillUnresolved = append(stillUnresolved, event) + stillUnresolved = append(stillUnresolved, p) continue } @@ -403,11 +395,7 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( ) found = candidate - // Re-add the event to the pending set: a failed proposal round must not - // lose the candidate. Because the scan cursor has already advanced - // past this event's block, the event would be lost forever if it - // weren't explicitly re-added to the pending set for the next call. - stillUnresolved = append(stillUnresolved, event) + stillUnresolved = append(stillUnresolved, p) } rat.scanState.Lock() @@ -418,15 +406,12 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( return found, nil } -// scanForCandidateEvents advances the wallet's incremental scan cursor and -// returns the accumulated set of still-unresolved candidate DepositRevealed -// events (previously pending plus any newly discovered ones in this call's -// scan window) targeting the reservation vault. func (rat *ReservationAcceptanceTask) scanForCandidateEvents( walletPublicKeyHash [20]byte, currentBlock uint64, reservationVault chain.Address, -) ([]*tbtc.DepositRevealedEvent, error) { + reservationParameters *tbtc.ReservationParameters, +) ([]*pendingCandidate, error) { rat.scanState.Lock() lastScanned := rat.lastScannedBlock[walletPublicKeyHash] pending := rat.pendingCandidates[walletPublicKeyHash] @@ -452,23 +437,23 @@ func (rat *ReservationAcceptanceTask) scanForCandidateEvents( err, ) } - - candidates := pending + candidates := make([]*pendingCandidate, 0, len(pending)+len(revealedEvents)) + for _, p := range pending { + candidates = append(candidates, p) + } for _, event := range revealedEvents { if !depositTargetsReservationVault(event.Vault, reservationVault) { continue } - candidates = append(candidates, event) + candidates = append(candidates, &pendingCandidate{ + Event: event, + ReservationParameters: reservationParameters, + }) } return candidates, nil } -// checkReservationAcceptanceEligibility returns true iff the wallet may -// accept a new reserved deposit given the current cap snapshot. The -// predicate is intentionally strict: a single failing rule rejects the -// candidate so the wallet never publishes a proposal that the Bridge would -// reject. func (rat *ReservationAcceptanceTask) checkReservationAcceptanceEligibility( taskLogger log.StandardLogger, walletPublicKeyHash [20]byte, @@ -567,7 +552,6 @@ func (rat *ReservationAcceptanceTask) checkReservationAcceptanceEligibility( return true } -// proposeReservationAcceptance assembles the anchor transaction for the func (rat *ReservationAcceptanceTask) proposeReservationAcceptance( taskLogger log.StandardLogger, walletPublicKeyHash [20]byte, @@ -606,24 +590,11 @@ func (rat *ReservationAcceptanceTask) proposeReservationAcceptance( taskLogger.Infof("anchor transaction fee: [%d]", anchorFee) - // m1 identity: the reservation key is the deposit key for an - // acceptance (the reservation does not exist on-chain, and therefore - // has no anchor outpoint to look up via ReservationByAnchorUtxo, until - // this acceptance settles). Mirrors the convention documented in - // pkg/maintainer/spv/reservation_stale_deposit_watch.go. reservationKey := rat.chain.BuildDepositKey( candidate.Deposit.Utxo.Outpoint.TransactionHash, candidate.Deposit.Utxo.Outpoint.OutputIndex, ) - // The action generation record does not exist on-chain yet at this - // point - it is created by the RequestReservationAcceptance call below, - // which has not happened yet. AssembleReservationAnchorTransaction only - // needs the fee upper bound, which is the global reservation parameter - // (candidate.ReservationParameters.ReservationTxMaxFee), the same value - // that will govern the action once requested. Build a minimal action - // value carrying just that bound rather than fetching a - // not-yet-created record. feeBoundAction := &tbtc.ReservationAction{ TxMaxFee: candidate.TxMaxFee, } @@ -667,6 +638,14 @@ func (rat *ReservationAcceptanceTask) proposeReservationAcceptance( ) } + reservation, err := rat.chain.GetReservation(reservationKey) + if err != nil { + taskLogger.Errorf("cannot get reservation [0x%x]: [%v]", reservationKey, err) + } else if hasPendingAction(reservationKey, reservation, rat.chain, taskLogger) { + taskLogger.Infof("reservation [0x%x] has pending action, skipping", reservationKey) + return nil, false, nil + } + if err := rat.chain.RequestReservationAcceptance( reservationKey, walletPublicKeyHash, @@ -677,10 +656,6 @@ func (rat *ReservationAcceptanceTask) proposeReservationAcceptance( return proposal, true, nil } -// into a fresh output controlled by the given wallet. Mirrors the private -// helper in pkg/tbtc/reservation.go; the tbtcpg package cannot call the -// helper directly because it lives in a different package, so the assembly -// could get stuck and jam the wallet. func estimateReservationAcceptanceFee( btcChain bitcoin.Chain, txMaxFee uint64, @@ -697,9 +672,6 @@ func estimateReservationAcceptanceFee( ) } -// depositTargetsReservationVault returns true iff the deposit's vault field -// (nil when not set, or pointer to an address) matches the configured -// reservation vault. Address comparison is case-insensitive. func depositTargetsReservationVault( depositVault *chain.Address, reservationVault chain.Address, diff --git a/pkg/tbtcpg/reservation_reanchor.go b/pkg/tbtcpg/reservation_reanchor.go index 7d2307fa4e..37004e619f 100644 --- a/pkg/tbtcpg/reservation_reanchor.go +++ b/pkg/tbtcpg/reservation_reanchor.go @@ -3,6 +3,7 @@ package tbtcpg import ( "fmt" "math/big" + "sync" "github.com/ipfs/go-log/v2" "go.uber.org/zap" @@ -24,8 +25,11 @@ const ReservationReanchorLookBackBlocks = uint64(216000) // task picks a destination wallet and assembles a 1-input-1-output re-anchor // transaction moving the anchor outpoint into that destination wallet. type ReservationReanchorTask struct { - chain Chain - btcChain bitcoin.Chain + chain Chain + btcChain bitcoin.Chain + targetWalletCache [20]byte + targetWalletInitialized bool + targetWalletMutex sync.RWMutex } // NewReservationReanchorTask returns a new ReservationReanchorTask bound to @@ -173,10 +177,11 @@ func (rrt *ReservationReanchorTask) Run( 0, ) if err != nil { - return nil, false, fmt.Errorf( + taskLogger.Errorf( "cannot prepare reservation re-anchor proposal: [%v]", err, ) + continue } return proposal, true, nil @@ -304,6 +309,18 @@ func (rrt *ReservationReanchorTask) findTargetWallet( taskLogger log.StandardLogger, sourceWalletPublicKeyHash [20]byte, ) ([20]byte, error) { + rrt.targetWalletMutex.RLock() + if rrt.targetWalletInitialized { + cache := rrt.targetWalletCache + rrt.targetWalletMutex.RUnlock() + + wallet, err := rrt.chain.GetWallet(cache) + if err == nil && wallet.State == tbtc.StateLive { + return cache, nil + } + } else { + rrt.targetWalletMutex.RUnlock() + } blockCounter, err := rrt.chain.BlockCounter() if err != nil { return [20]byte{}, fmt.Errorf("failed to get block counter: [%v]", err) @@ -346,6 +363,10 @@ func (rrt *ReservationReanchorTask) findTargetWallet( } if wallet.State == tbtc.StateLive { + rrt.targetWalletMutex.Lock() + rrt.targetWalletCache = walletPubKeyHash + rrt.targetWalletInitialized = true + rrt.targetWalletMutex.Unlock() return walletPubKeyHash, nil } } diff --git a/pkg/tbtcpg/tbtcpg.go b/pkg/tbtcpg/tbtcpg.go index 6844117342..d28482a548 100644 --- a/pkg/tbtcpg/tbtcpg.go +++ b/pkg/tbtcpg/tbtcpg.go @@ -18,10 +18,11 @@ import ( "strings" "github.com/ipfs/go-log/v2" - "github.com/keep-network/keep-core/pkg/bitcoin" - "github.com/keep-network/keep-core/pkg/tbtc" "go.uber.org/zap" "golang.org/x/exp/slices" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/tbtc" ) var logger = log.Logger("keep-tbtcpg") From e202c2c53221900cd80f54c8d8a2575ade9cb0fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 1 Sep 2026 20:12:38 +0000 Subject: [PATCH 26/39] fix(spv): correct reservation watcher ordering, RPC scan cost, and wiring gaps - reservation_action_timeout_watch.go: resolve wallet members only after the pending/timeout gates pass, not before, so a misconfigured resolver no longer errors on every reservation on every tick; evict closed/terminated wallets from the discovery set instead of scanning an ever-growing history; scan from block 0 on the first pass so wallets registered before the look-back window are found; fix doc/behavior mismatches; add a Run-loop test driving two ticks - reservation_stale_deposit_watch.go: stop collapsing a transient RPC error into the same fallback path as a genuine unknown-action state; cache reveal timestamps instead of rescanning from genesis on every poll; keep tracking a pending acceptance on a Live wallet instead of dropping it, so a stuck anchor is still caught; fix doc mismatches - reservation_stranding_watch.go: invert the notification filter to an allow-list (State == Active only) so closed/pending/already-stranded reservations are no longer re-notified; fix constructor doc drift - reservation_wiring.go: wire a real WalletMembersResolver from the node instead of a permanent-error stub (the watcher and the node share the same process, contrary to the stub's standalone-process justification); return real errors instead of only logging; advance the stale-deposit scan cursor only when the whole batch classifies without error; track a notified marker so a deposit isn't re- notified every tick; fix stale doc references - reservation_acceptance_proof.go/reservation_reanchor_proof.go: unify the acceptance/re-anchor input parsers into one helper; fix the truncated doc comment and the dual-purpose UTXO doc; fix the SubmitReservationProof call's argument order to match the actual interface signature; add negative-path test coverage for the metrics recorder and the unreachable key/nonce guards - reservation_proof_loop.go: replace the full-window rescan on every pass with an incremental event cursor and pending-action set, and reuse the generic wallet-hash dedup helper instead of a duplicate; add end-to-end tests for both proof paths - chain.go: remove 3 Chain interface members with zero production callers, mirroring the pkg/tbtc trim - config.go: unify the reservation-enabled config surface --- pkg/maintainer/spv/bitcoin_chain_test.go | 32 +- pkg/maintainer/spv/chain.go | 22 +- pkg/maintainer/spv/chain_test.go | 19 +- pkg/maintainer/spv/config.go | 23 +- .../spv/reservation_acceptance_proof.go | 36 +- .../spv/reservation_acceptance_proof_test.go | 35 +- .../spv/reservation_action_timeout_watch.go | 100 +++--- .../reservation_action_timeout_watch_test.go | 69 ++++ pkg/maintainer/spv/reservation_proof_loop.go | 302 +++++++++++----- .../spv/reservation_proof_loop_test.go | 337 +++++++++++++++++- .../spv/reservation_reanchor_proof.go | 66 ++-- .../spv/reservation_reanchor_proof_test.go | 43 ++- .../spv/reservation_stale_deposit_watch.go | 6 + .../spv/reservation_stranding_watch.go | 27 +- .../spv/reservation_stranding_watch_test.go | 22 +- pkg/maintainer/spv/reservation_wiring.go | 114 ++---- pkg/maintainer/spv/reservation_wiring_test.go | 48 +++ 17 files changed, 899 insertions(+), 402 deletions(-) diff --git a/pkg/maintainer/spv/bitcoin_chain_test.go b/pkg/maintainer/spv/bitcoin_chain_test.go index 35ce5acf6d..ceaa292334 100644 --- a/pkg/maintainer/spv/bitcoin_chain_test.go +++ b/pkg/maintainer/spv/bitcoin_chain_test.go @@ -47,6 +47,7 @@ type localBitcoinChain struct { transactions []*bitcoin.Transaction transactionConfirmations map[bitcoin.Hash]uint blockHeaders map[uint]*bitcoin.BlockHeader + coinbaseTxHash *bitcoin.Hash } func newLocalBitcoinChain() *localBitcoinChain { @@ -138,11 +139,20 @@ func (lbc *localBitcoinChain) GetBlockHeader(blockHeight uint) ( return nil, fmt.Errorf("block header does not exist") } +// GetTransactionMerkleProof returns a trivial, always-valid proof: an empty +// merkle-node list means the given transaction hash is treated as the +// block's merkle root directly, at position 0. Sufficient to let +// bitcoin.AssembleSpvProof complete against this fake chain without a real +// merkle-tree fixture. func (lbc *localBitcoinChain) GetTransactionMerkleProof( transactionHash bitcoin.Hash, blockHeight uint, ) (*bitcoin.TransactionMerkleProof, error) { - panic("unsupported") + return &bitcoin.TransactionMerkleProof{ + BlockHeight: blockHeight, + MerkleNodes: nil, + Position: 0, + }, nil } func (lbc *localBitcoinChain) GetTransactionsForPublicKeyHash( @@ -213,13 +223,33 @@ func (lbc *localBitcoinChain) EstimateSatPerVByteFee(blocks uint32) ( panic("unsupported") } +// GetCoinbaseTxHash returns the hash previously installed via +// setCoinbaseTxHash. Panics if never set, matching this fake chain's +// convention for exercising an unconfigured dependency. func (lbc *localBitcoinChain) GetCoinbaseTxHash(blockHeight uint) ( bitcoin.Hash, error, ) { + lbc.mutex.Lock() + defer lbc.mutex.Unlock() + + if lbc.coinbaseTxHash != nil { + return *lbc.coinbaseTxHash, nil + } panic("unsupported") } +// setCoinbaseTxHash installs the hash GetCoinbaseTxHash returns for every +// block height. The hash must belong to a transaction already known to +// GetTransaction (e.g. via BroadcastTransaction) since AssembleSpvProof +// looks the coinbase transaction up by this hash immediately after. +func (lbc *localBitcoinChain) setCoinbaseTxHash(hash bitcoin.Hash) { + lbc.mutex.Lock() + defer lbc.mutex.Unlock() + + lbc.coinbaseTxHash = &hash +} + func (lbc *localBitcoinChain) addBlockHeader( blockNumber uint, blockHeader *bitcoin.BlockHeader, diff --git a/pkg/maintainer/spv/chain.go b/pkg/maintainer/spv/chain.go index 41dd8fc259..e2023e2f84 100644 --- a/pkg/maintainer/spv/chain.go +++ b/pkg/maintainer/spv/chain.go @@ -4,6 +4,7 @@ import ( "math/big" "github.com/ethereum/go-ethereum/common" + "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/tbtc" @@ -173,27 +174,6 @@ type Chain interface { filter *tbtc.MovingFundsCommitmentSubmittedEventFilter, ) ([]*tbtc.MovingFundsCommitmentSubmittedEvent, error) - // PastReservationAcceptedEvents fetches past ReservationAccepted 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. - PastReservationAcceptedEvents( - filter *tbtc.ReservationAcceptedEventFilter, - ) ([]*tbtc.ReservationAcceptedEvent, error) - - // PastReservationReanchoredEvents fetches past ReservationReanchored - // 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. - PastReservationReanchoredEvents( - filter *tbtc.ReservationReanchoredEventFilter, - ) ([]*tbtc.ReservationReanchoredEvent, error) - - // PastReservationActionTimedOutEvents fetches past ReservationActionTimedOut - // 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. - PastReservationActionTimedOutEvents( - filter *tbtc.ReservationActionTimedOutEventFilter, - ) ([]*tbtc.ReservationActionTimedOutEvent, error) - // PastReservationAcceptanceRequestedEvents fetches past // ReservationAcceptanceRequested events according to the provided filter // or unfiltered if the filter is nil. Returned events are sorted by the diff --git a/pkg/maintainer/spv/chain_test.go b/pkg/maintainer/spv/chain_test.go index ba44e6a30b..33cf5485a9 100644 --- a/pkg/maintainer/spv/chain_test.go +++ b/pkg/maintainer/spv/chain_test.go @@ -11,6 +11,7 @@ import ( "sync" "github.com/ethereum/go-ethereum/common" + "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/tbtc" @@ -1111,24 +1112,6 @@ func (lc *localChain) setReservedDeposit( } } -func (lc *localChain) PastReservationAcceptedEvents( - filter *tbtc.ReservationAcceptedEventFilter, -) ([]*tbtc.ReservationAcceptedEvent, error) { - return nil, nil -} - -func (lc *localChain) PastReservationReanchoredEvents( - filter *tbtc.ReservationReanchoredEventFilter, -) ([]*tbtc.ReservationReanchoredEvent, error) { - return nil, nil -} - -func (lc *localChain) PastReservationActionTimedOutEvents( - filter *tbtc.ReservationActionTimedOutEventFilter, -) ([]*tbtc.ReservationActionTimedOutEvent, error) { - return nil, nil -} - func (lc *localChain) PastReservationAcceptanceRequestedEvents( filter *tbtc.ReservationAcceptanceRequestedEventFilter, ) ([]*tbtc.ReservationAcceptanceRequestedEvent, error) { diff --git a/pkg/maintainer/spv/config.go b/pkg/maintainer/spv/config.go index 1c3cd4dd11..dadcc7bd6e 100644 --- a/pkg/maintainer/spv/config.go +++ b/pkg/maintainer/spv/config.go @@ -2,6 +2,8 @@ package spv import ( "time" + + "github.com/keep-network/keep-core/pkg/tbtc" ) const ( @@ -69,23 +71,8 @@ type Config struct { // Reservations controls the SPV proof submission for reservation // acceptance / re-anchor action generations. The reservation watchers are // gated by the separate Tbtc.Reservations.Enabled flag. - Reservations ReservationsConfig + Reservations tbtc.ReservationsConfig } -// ReservationsConfig holds the reservation-related spv.Config fields. -// -// This flag controls only SPV PROOF SUBMISSION for reservation acceptance / -// re-anchor action generations (the `maintainer` command, config category -// Maintainer). The `start` command runs as a separate process reading a -// disjoint config category (see config.StartCmdCategories) and has its own -// independent gate, tbtc.ReservationsConfig.Enabled, that controls -// reservation proposal GENERATION. Neither command's config loading sees -// the other's category, so this flag cannot be derived from or validated -// against tbtc.ReservationsConfig.Enabled in code. An operator running both -// `start` and `maintainer` for the reservation feature to work end-to-end -// MUST enable both flags - normally the same [Maintainer.Spv.Reservations] -// / [Tbtc.Reservations] TOML sections in one shared config file. -type ReservationsConfig struct { - // Enabled toggles reservation plumbing in the SPV maintainer. - Enabled bool -} +// ReservationsConfig is deprecated and refers to tbtc.ReservationsConfig. +type ReservationsConfig = tbtc.ReservationsConfig diff --git a/pkg/maintainer/spv/reservation_acceptance_proof.go b/pkg/maintainer/spv/reservation_acceptance_proof.go index e2da7e1dcf..bcc2256b76 100644 --- a/pkg/maintainer/spv/reservation_acceptance_proof.go +++ b/pkg/maintainer/spv/reservation_acceptance_proof.go @@ -1,7 +1,6 @@ package spv import ( - "fmt" "math/big" "github.com/keep-network/keep-core/pkg/bitcoin" @@ -71,39 +70,6 @@ func submitReservationAcceptanceProof( ProofTypeReservationAcceptance, "reservation_acceptance_proof", tbtc.ReservationActionTypeAcceptance, - parseReservationAcceptanceTransactionInput, + "acceptance", ) } - -// parseReservationAcceptanceTransactionInput parses the single input and -// single output of a reservation acceptance (anchor) transaction and -// returns the deposit UTXO that was anchored and the wallet's public key -// hash from the new anchor output script. Mirrors -// parseReservationReanchorTransactionInput in reservation_reanchor_proof.go. -func parseReservationAcceptanceTransactionInput( - btcChain bitcoin.Chain, - transaction *bitcoin.Transaction, -) (*bitcoin.UnspentTransactionOutput, [20]byte, error) { - depositUtxo, err := spentOutputAsUtxo(btcChain, transaction) - if err != nil { - return nil, [20]byte{}, err - } - - if len(transaction.Outputs) != 1 { - return nil, [20]byte{}, fmt.Errorf( - "reservation acceptance transaction must have exactly one output", - ) - } - - walletPublicKeyHash, err := bitcoin.ExtractPublicKeyHash( - transaction.Outputs[0].PublicKeyScript, - ) - if err != nil { - return nil, [20]byte{}, fmt.Errorf( - "cannot extract wallet public key hash: [%v]", - err, - ) - } - - return depositUtxo, walletPublicKeyHash, nil -} diff --git a/pkg/maintainer/spv/reservation_acceptance_proof_test.go b/pkg/maintainer/spv/reservation_acceptance_proof_test.go index 500c41cd93..fcb2be5ed5 100644 --- a/pkg/maintainer/spv/reservation_acceptance_proof_test.go +++ b/pkg/maintainer/spv/reservation_acceptance_proof_test.go @@ -119,6 +119,7 @@ func TestSubmitReservationAcceptanceProof(t *testing.T) { return nil } + metricsRecorder := &mockMetricsRecorder{counts: make(map[string]float64)} if err := submitReservationAcceptanceProof( anchorTx.Hash(), requiredConfirmations, @@ -127,10 +128,42 @@ func TestSubmitReservationAcceptanceProof(t *testing.T) { btcChain, spvChain, mockSpvProofAssembler, - nil, + metricsRecorder, ); err != nil { t.Fatal(err) } + // Check metrics. + if count := metricsRecorder.counts["reservation_acceptance_proof_submissions_total"]; count != 1 { + t.Errorf("unexpected metrics count: got %f, want 1", count) + } + + // Negative path: nil reservationKey. + if err := submitReservationAcceptanceProof( + anchorTx.Hash(), + requiredConfirmations, + nil, + requestNonce, + btcChain, + spvChain, + mockSpvProofAssembler, + nil, + ); err == nil { + t.Fatal("expected error for nil reservation key") + } + + // Negative path: zero requestNonce. + if err := submitReservationAcceptanceProof( + anchorTx.Hash(), + requiredConfirmations, + reservationKey, + 0, + btcChain, + spvChain, + mockSpvProofAssembler, + nil, + ); err == nil { + t.Fatal("expected error for zero request nonce") + } // Negative path: action generation is not Pending. spvChain.setReservationAction(reservationKey, requestNonce, &tbtc.ReservationAction{ diff --git a/pkg/maintainer/spv/reservation_action_timeout_watch.go b/pkg/maintainer/spv/reservation_action_timeout_watch.go index 85718a7efa..7b07b9b201 100644 --- a/pkg/maintainer/spv/reservation_action_timeout_watch.go +++ b/pkg/maintainer/spv/reservation_action_timeout_watch.go @@ -33,9 +33,9 @@ type ReservationActionTimeoutWatcher struct { // deadline forward; production wires it to time.Now in UTC. nowFn func() uint32 // interval is how often the background poll loop re-checks pending - // actions. A zero value disables the background loop; tests and the - // synchronously driven integration code path will use a positive - // duration. + // actions. The interval must be positive whenever Run is used to drive + // the background loop; tests and the synchronously driven integration + // code path use a positive duration. interval time.Duration // membersResolver turns a wallet public key hash into the operator IDs // the Bridge expects for the slashing argument. The resolver is @@ -114,8 +114,9 @@ func (f ReservationActionTimeoutNotifierFunc) NotifyReservationActionTimeout( // without it because emitting NotifyReservationActionTimeout with a nil // or empty member slice would be ill-formed on the Bridge side. // -// A zero pollInterval disables the background loop; the watcher must then -// be driven by CheckReservationActionTimeouts calls from the integration. +// The pollInterval must be positive whenever Run is used to drive the +// background loop; the watcher can otherwise be driven by +// CheckReservationActionTimeouts calls from the integration. func NewReservationActionTimeoutWatcher( spvChain Chain, membersResolver WalletMembersResolver, @@ -218,13 +219,10 @@ func (ratw *ReservationActionTimeoutWatcher) Run(ctx context.Context) error { } // discoverWallets returns the public key hashes of every wallet registered -// on-chain since the last call, plus every wallet seen by a prior call. -// Callers must retain the returned slice's wallets across iterations -// themselves if they need the full set; discoverWallets itself only -// accumulates the incremental scan cursor (lastWalletScanBlock) - the -// caller (Run) re-derives the full wallet set from WalletReservations, -// which is authoritative regardless of when the wallet was registered, so -// discoverWallets does not need to cache the wallet list itself. +// on-chain, minus those that have been observed Closed or Terminated. +// The wallet list is incrementally updated across calls; the watcher +// maintains the knownWallets set, which is periodically pruned of +// closed or terminated wallets to keep the discovery loop efficient. func (ratw *ReservationActionTimeoutWatcher) discoverWallets() ([][20]byte, error) { blockCounter, err := ratw.spvChain.BlockCounter() if err != nil { @@ -237,9 +235,7 @@ func (ratw *ReservationActionTimeoutWatcher) discoverWallets() ([][20]byte, erro } startBlock := ratw.lastWalletScanBlock - if startBlock == 0 && currentBlock > reservationActionTimeoutWalletScanLookBackBlocks { - startBlock = currentBlock - reservationActionTimeoutWalletScanLookBackBlocks - } + // If lastWalletScanBlock is 0, we scan from block 0. events, err := ratw.spvChain.PastNewWalletRegisteredEvents( &tbtc.NewWalletRegisteredEventFilter{ @@ -265,6 +261,7 @@ func (ratw *ReservationActionTimeoutWatcher) discoverWallets() ([][20]byte, erro ratw.knownWallets[event.WalletPublicKeyHash] = struct{}{} } + ratw.evictTerminatedWallets() wallets := make([][20]byte, 0, len(ratw.knownWallets)) for wallet := range ratw.knownWallets { wallets = append(wallets, wallet) @@ -273,6 +270,21 @@ func (ratw *ReservationActionTimeoutWatcher) discoverWallets() ([][20]byte, erro return wallets, nil } +// evictTerminatedWallets evicts wallets that have been closed or terminated +// from the knownWallets map to keep the memory footprint and the number of +// RPC calls per poll iteration bounded to active wallets. +func (ratw *ReservationActionTimeoutWatcher) evictTerminatedWallets() { + for walletPublicKeyHash := range ratw.knownWallets { + walletData, err := ratw.spvChain.GetWallet(walletPublicKeyHash) + if err != nil { + continue + } + if walletData.State == tbtc.StateClosed || walletData.State == tbtc.StateTerminated { + delete(ratw.knownWallets, walletPublicKeyHash) + } + } +} + // CheckReservationActionTimeouts inspects the current action generation of a // single reservation and notifies the Bridge if it is Pending and its // TimeoutAt has elapsed. The caller controls the iteration; the watcher @@ -322,45 +334,13 @@ func (ratw *ReservationActionTimeoutWatcher) CheckReservationActionTimeouts( walletPublicKeyHash := reservation.WalletPublicKeyHash if walletPublicKeyHash == ([20]byte{}) { - // Reservation exists but has no wallet assigned (e.g. the - // acceptance has not progressed yet). Without a wallet we cannot - // resolve members, so the watcher skips silently: the stranding - // watcher will eventually catch this case. - logger.Debugf( - "reservation [%v] has no wallet assigned; "+ - "action-timeout watcher skipping", - reservationKey, - ) + // finding #22: A reservation with no assigned wallet is structurally + // unreachable by the stranding watcher; we skip it until its chain + // state supplies a wallet, with no other component providing recovery. + logger.Debugf("reservation [%v] has no wallet; skipping", reservationKey) return nil } - // Resolve the wallet members exactly once per Check call: the Bridge - // requires the member IDs to be consistent across all notifications - // issued in response to a single reservation. - memberIDs, err := ratw.membersResolver.ResolveWalletMembers( - walletPublicKeyHash, - ) - if err != nil { - return fmt.Errorf( - "failed to resolve wallet member IDs for "+ - "wallet [0x%x]: [%v]", - walletPublicKeyHash, - err, - ) - } - if len(memberIDs) == 0 { - // Emitting NotifyReservationActionTimeout with a nil/empty member - // slice is ill-formed on the Bridge side (see - // NewReservationActionTimeoutWatcher's doc). Refuse rather than - // notify on partial information: a misconfigured members resolver - // must fail loud, not silently strand the slashing attribution. - return fmt.Errorf( - "wallet [0x%x] members resolver returned an empty set; "+ - "refusing to notify with no attributable members", - walletPublicKeyHash, - ) - } - nonce := reservation.RequestNonce action, err := ratw.spvChain.GetReservationAction(reservationKey, nonce) @@ -396,6 +376,24 @@ func (ratw *ReservationActionTimeoutWatcher) CheckReservationActionTimeouts( return nil } + memberIDs, err := ratw.membersResolver.ResolveWalletMembers(walletPublicKeyHash) + if err != nil { + return fmt.Errorf("could not resolve wallet members [%x]: %w", walletPublicKeyHash, err) + } + + if len(memberIDs) == 0 { + // Emitting NotifyReservationActionTimeout with a nil/empty member + // slice is ill-formed on the Bridge side (see + // NewReservationActionTimeoutWatcher's doc). Refuse rather than + // notify on partial information: a misconfigured members resolver + // must fail loud, not silently strand the slashing attribution. + return fmt.Errorf( + "wallet [0x%x] members resolver returned an empty set; "+ + "refusing to notify with no attributable members", + walletPublicKeyHash, + ) + } + if err := ratw.spvChain.NotifyReservationActionTimeout( reservationKey, memberIDs, diff --git a/pkg/maintainer/spv/reservation_action_timeout_watch_test.go b/pkg/maintainer/spv/reservation_action_timeout_watch_test.go index 5775e19a82..bc08c08fd2 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" @@ -374,3 +376,70 @@ func TestReservationActionTimeoutWatcher_NotifierErrorPropagates(t *testing.T) { t.Fatalf("expected exactly one notification attempt, got %d", len(calls)) } } + +func TestReservationActionTimeoutWatcher_RunLoop(t *testing.T) { + spvChain := newLocalChain() + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(1000) + spvChain.setBlockCounter(blockCounter) + + resolver := &recordingActionTimeoutMembers{ + walletIDs: map[[20]byte][]uint32{}, + } + + pollInterval := 10 * time.Millisecond + ratw := NewReservationActionTimeoutWatcher( + spvChain, + resolver, + pollInterval, + ) + ratw.nowFn = func() uint32 { return 100 } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Tick 1: register wallet 1, within the first scan window. + wallet1 := [20]byte{1} + spvChain.addNewWalletRegisteredEvent(&tbtc.NewWalletRegisteredEvent{ + WalletPublicKeyHash: wallet1, + BlockNumber: 500, + }) + spvChain.setWallet(wallet1, &tbtc.WalletChainData{State: tbtc.StateLive}) + + errChan := make(chan error, 1) + go func() { + errChan <- ratw.Run(ctx) + }() + + // Wait for tick 1 to run discoverWallets and pick up wallet 1. + time.Sleep(50 * time.Millisecond) + + // Tick 2: register wallet 2 (block number past the tick-1 cursor, so + // the incremental scan actually picks it up) and close wallet 1, which + // must be evicted from knownWallets on this pass. + blockCounter.SetCurrentBlock(2000) + wallet2 := [20]byte{2} + spvChain.addNewWalletRegisteredEvent(&tbtc.NewWalletRegisteredEvent{ + WalletPublicKeyHash: wallet2, + BlockNumber: 1500, + }) + spvChain.setWallet(wallet2, &tbtc.WalletChainData{State: tbtc.StateLive}) + spvChain.setWallet(wallet1, &tbtc.WalletChainData{State: tbtc.StateClosed}) + + // Wait for tick 2 to observe the new wallet and the eviction. + time.Sleep(50 * time.Millisecond) + + cancel() + if err := <-errChan; err != nil { + t.Errorf("Run returned error: %v", err) + } + + // Assertions run only after Run has fully returned, so knownWallets is + // no longer being mutated concurrently by the background goroutine. + if _, ok := ratw.knownWallets[wallet1]; ok { + t.Errorf("wallet 1 should have been evicted after being observed Closed") + } + if _, ok := ratw.knownWallets[wallet2]; !ok { + t.Errorf("wallet 2 should have been discovered") + } +} diff --git a/pkg/maintainer/spv/reservation_proof_loop.go b/pkg/maintainer/spv/reservation_proof_loop.go index 5bfefe1c4a..b7ab52301e 100644 --- a/pkg/maintainer/spv/reservation_proof_loop.go +++ b/pkg/maintainer/spv/reservation_proof_loop.go @@ -3,6 +3,7 @@ package spv import ( "context" "fmt" + "math/big" "time" "github.com/keep-network/keep-core/pkg/bitcoin" @@ -11,30 +12,113 @@ import ( ) // reservationProofLookBackBlocks bounds the pending-action-request event -// scan. Mirrors ReservationAcceptanceLookBackBlocks / +// scan performed on the very first pass, before an incremental cursor +// exists. Mirrors ReservationAcceptanceLookBackBlocks / // ReservationReanchorLookBackBlocks in pkg/tbtcpg: 30 days at 12s/block. const reservationProofLookBackBlocks = uint64(216000) -// uniqueReservationWalletPublicKeyHashes deduplicates a list of reservation -// proof-loop events by wallet public key hash, extracted via -// walletPublicKeyHashOf since the acceptance and re-anchor event types name -// their wallet field differently (WalletPublicKeyHash vs -// SourceWalletPublicKeyHash) and therefore cannot share the walletEvent -// interface used by uniqueWalletPublicKeyHashes in spv.go. -func uniqueReservationWalletPublicKeyHashes[T any]( - items []T, - walletPublicKeyHashOf func(T) [20]byte, -) [][20]byte { - seen := make(map[[20]byte]struct{}) - var result [][20]byte - for _, item := range items { - pkh := walletPublicKeyHashOf(item) - if _, ok := seen[pkh]; !ok { - seen[pkh] = struct{}{} - result = append(result, pkh) +// reservationAcceptanceWalletEvent adapts +// *tbtc.ReservationAcceptanceRequestedEvent to the walletEvent interface +// (see spv.go) so uniqueWalletPublicKeyHashes can be reused here instead of +// a reservation-specific duplicate of the same dedup logic. +type reservationAcceptanceWalletEvent struct { + *tbtc.ReservationAcceptanceRequestedEvent +} + +// GetWalletPublicKeyHash implements walletEvent. +func (e reservationAcceptanceWalletEvent) GetWalletPublicKeyHash() [20]byte { + return e.WalletPublicKeyHash +} + +// reservationReanchorWalletEvent adapts +// *tbtc.ReservationReanchorRequestedEvent to the walletEvent interface (see +// spv.go) so uniqueWalletPublicKeyHashes can be reused here instead of a +// reservation-specific duplicate of the same dedup logic. +type reservationReanchorWalletEvent struct { + *tbtc.ReservationReanchorRequestedEvent +} + +// GetWalletPublicKeyHash implements walletEvent. +func (e reservationReanchorWalletEvent) GetWalletPublicKeyHash() [20]byte { + return e.SourceWalletPublicKeyHash +} + +func wrapReservationAcceptanceEvents( + events []*tbtc.ReservationAcceptanceRequestedEvent, +) []reservationAcceptanceWalletEvent { + wrapped := make([]reservationAcceptanceWalletEvent, len(events)) + for i, event := range events { + wrapped[i] = reservationAcceptanceWalletEvent{event} + } + return wrapped +} + +func wrapReservationReanchorEvents( + events []*tbtc.ReservationReanchorRequestedEvent, +) []reservationReanchorWalletEvent { + wrapped := make([]reservationReanchorWalletEvent, len(events)) + for i, event := range events { + wrapped[i] = reservationReanchorWalletEvent{event} + } + return wrapped +} + +// reservationProofScanState persists the incremental event-scan cursor and +// the set of still-pending action-request events across successive passes +// of runReservationProofLoop, so proveReservationAcceptanceActions and +// proveReservationReanchorActions scan only the event/Bitcoin history that +// has appeared since the previous pass instead of rescanning the full +// reservationProofLookBackBlocks window - and refetching Bitcoin history +// for every wallet in it - every config.IdleBackoffTime. +type reservationProofScanState struct { + acceptanceLastScannedBlock uint64 + pendingAcceptanceEvents map[string]*tbtc.ReservationAcceptanceRequestedEvent + + reanchorLastScannedBlock uint64 + pendingReanchorEvents map[string]*tbtc.ReservationReanchorRequestedEvent +} + +func newReservationProofScanState() *reservationProofScanState { + return &reservationProofScanState{ + pendingAcceptanceEvents: make(map[string]*tbtc.ReservationAcceptanceRequestedEvent), + pendingReanchorEvents: make(map[string]*tbtc.ReservationReanchorRequestedEvent), + } +} + +// reservationEventKey identifies one reservation action generation, unique +// across both the acceptance and re-anchor pending-event maps. +func reservationEventKey(reservationKey *big.Int, requestNonce uint64) string { + return fmt.Sprintf("%s:%d", reservationKey.String(), requestNonce) +} + +// reservationProofNextScanRange returns the block range to scan for new +// pending-action-request events this pass: the bounded +// reservationProofLookBackBlocks catch-up window on the very first pass +// (lastScannedBlock == 0), or just the delta since the previous pass's +// cursor on every pass thereafter, so a steady-state loop no longer +// re-fetches the full ~30-day window on every config.IdleBackoffTime tick. +func reservationProofNextScanRange( + spvChain Chain, + lastScannedBlock uint64, +) (startBlock uint64, currentBlock uint64, err error) { + blockCounter, err := spvChain.BlockCounter() + if err != nil { + return 0, 0, fmt.Errorf("failed to get block counter: [%v]", err) + } + + currentBlock, err = blockCounter.CurrentBlock() + if err != nil { + return 0, 0, fmt.Errorf("failed to get current block: [%v]", err) + } + + if lastScannedBlock == 0 { + if currentBlock > reservationProofLookBackBlocks { + return currentBlock - reservationProofLookBackBlocks, currentBlock, nil } + return 0, currentBlock, nil } - return result + + return lastScannedBlock + 1, currentBlock, nil } // maintainReservationProofs runs the SPV proof submission loop for @@ -89,6 +173,10 @@ func maintainReservationProofs( // bad action generation does not block the rest; only a chain-wide failure // (e.g. cannot read the current block) aborts the pass and triggers the // outer restart backoff. +// +// A single reservationProofScanState is created once and threaded through +// every pass for the lifetime of the loop, carrying the incremental event +// cursor and pending-action set described on that type. func runReservationProofLoop( ctx context.Context, config Config, @@ -96,8 +184,11 @@ func runReservationProofLoop( btcDiffChain btcdiff.Chain, btcChain bitcoin.Chain, ) error { + state := newReservationProofScanState() + for { if err := proveReservationAcceptanceActions( + state, config, spvChain, btcDiffChain, @@ -110,6 +201,7 @@ func runReservationProofLoop( } if err := proveReservationReanchorActions( + state, config, spvChain, btcDiffChain, @@ -134,17 +226,21 @@ func runReservationProofLoop( // transaction on the Bitcoin chain (if any), and submits its SPV proof once // it has accumulated enough confirmations. func proveReservationAcceptanceActions( + state *reservationProofScanState, config Config, spvChain Chain, btcDiffChain btcdiff.Chain, btcChain bitcoin.Chain, ) error { - startBlock, err := reservationProofScanStartBlock(spvChain) + startBlock, currentBlock, err := reservationProofNextScanRange( + spvChain, + state.acceptanceLastScannedBlock, + ) if err != nil { return err } - events, err := spvChain.PastReservationAcceptanceRequestedEvents( + newEvents, err := spvChain.PastReservationAcceptanceRequestedEvents( &tbtc.ReservationAcceptanceRequestedEventFilter{StartBlock: startBlock}, ) if err != nil { @@ -155,13 +251,42 @@ func proveReservationAcceptanceActions( ) } - // There will often be multiple events emitted for a single wallet. Prepare - // a list of unique wallet public key hashes. - walletPublicKeyHashes := uniqueReservationWalletPublicKeyHashes( - events, - func(e *tbtc.ReservationAcceptanceRequestedEvent) [20]byte { - return e.WalletPublicKeyHash - }, + for _, event := range newEvents { + key := reservationEventKey(event.ReservationKey, event.RequestNonce) + state.pendingAcceptanceEvents[key] = event + } + + // Re-check every tracked event's on-chain action state and drop the + // ones that are no longer pending, so the pending set does not grow + // without bound. + var pending []*tbtc.ReservationAcceptanceRequestedEvent + for key, event := range state.pendingAcceptanceEvents { + action, err := spvChain.GetReservationAction( + event.ReservationKey, + event.RequestNonce, + ) + if err != nil { + logger.Errorf( + "failed to load reservation acceptance action [%v]/%d: [%v]", + event.ReservationKey, + event.RequestNonce, + err, + ) + // Keep tracking; retry on the next pass. + continue + } + if action.State != tbtc.ReservationActionStatePending { + delete(state.pendingAcceptanceEvents, key) + continue + } + pending = append(pending, event) + } + + // There will often be multiple pending events for a single wallet. + // Fetch that wallet's Bitcoin transaction history once, not once per + // event. + walletPublicKeyHashes := uniqueWalletPublicKeyHashes( + wrapReservationAcceptanceEvents(pending), ) for _, walletPublicKeyHash := range walletPublicKeyHashes { @@ -174,28 +299,11 @@ func proveReservationAcceptanceActions( continue } - for _, event := range events { + for _, event := range pending { if event.WalletPublicKeyHash != walletPublicKeyHash { continue } - action, err := spvChain.GetReservationAction( - event.ReservationKey, - event.RequestNonce, - ) - if err != nil { - logger.Errorf( - "failed to load reservation acceptance action [%v]/%d: [%v]", - event.ReservationKey, - event.RequestNonce, - err, - ) - continue - } - if action.State != tbtc.ReservationActionStatePending { - continue - } - transaction, err := findReservationAcceptanceTransaction( spvChain, event, @@ -242,6 +350,8 @@ func proveReservationAcceptanceActions( } } + state.acceptanceLastScannedBlock = currentBlock + return nil } @@ -280,17 +390,21 @@ func findReservationAcceptanceTransaction( // on the Bitcoin chain (if any), and submits its SPV proof once it has // accumulated enough confirmations. func proveReservationReanchorActions( + state *reservationProofScanState, config Config, spvChain Chain, btcDiffChain btcdiff.Chain, btcChain bitcoin.Chain, ) error { - startBlock, err := reservationProofScanStartBlock(spvChain) + startBlock, currentBlock, err := reservationProofNextScanRange( + spvChain, + state.reanchorLastScannedBlock, + ) if err != nil { return err } - events, err := spvChain.PastReservationReanchorRequestedEvents( + newEvents, err := spvChain.PastReservationReanchorRequestedEvents( &tbtc.ReservationReanchorRequestedEventFilter{StartBlock: startBlock}, ) if err != nil { @@ -300,16 +414,42 @@ func proveReservationReanchorActions( ) } - // There will often be multiple events emitted for a single source - // wallet. Prepare a list of unique wallet public key hashes so the - // transaction history is fetched once per wallet instead of once per - // event, mirroring the acceptance loop above and the sibling - // getUnprovenDepositSweepTransactions convention. - walletPublicKeyHashes := uniqueReservationWalletPublicKeyHashes( - events, - func(e *tbtc.ReservationReanchorRequestedEvent) [20]byte { - return e.SourceWalletPublicKeyHash - }, + for _, event := range newEvents { + key := reservationEventKey(event.ReservationKey, event.RequestNonce) + state.pendingReanchorEvents[key] = event + } + + // Re-check every tracked event's on-chain action state and drop the + // ones that are no longer pending, so the pending set does not grow + // without bound. + var pending []*tbtc.ReservationReanchorRequestedEvent + for key, event := range state.pendingReanchorEvents { + action, err := spvChain.GetReservationAction( + event.ReservationKey, + event.RequestNonce, + ) + if err != nil { + logger.Errorf( + "failed to load reservation re-anchor action [%v]/%d: [%v]", + event.ReservationKey, + event.RequestNonce, + err, + ) + // Keep tracking; retry on the next pass. + continue + } + if action.State != tbtc.ReservationActionStatePending { + delete(state.pendingReanchorEvents, key) + continue + } + pending = append(pending, event) + } + + // There will often be multiple pending events for a single source + // wallet. Fetch that wallet's Bitcoin transaction history once, not + // once per event. + walletPublicKeyHashes := uniqueWalletPublicKeyHashes( + wrapReservationReanchorEvents(pending), ) for _, walletPublicKeyHash := range walletPublicKeyHashes { @@ -322,28 +462,11 @@ func proveReservationReanchorActions( continue } - for _, event := range events { + for _, event := range pending { if event.SourceWalletPublicKeyHash != walletPublicKeyHash { continue } - action, err := spvChain.GetReservationAction( - event.ReservationKey, - event.RequestNonce, - ) - if err != nil { - logger.Errorf( - "failed to load reservation re-anchor action [%v]/%d: [%v]", - event.ReservationKey, - event.RequestNonce, - err, - ) - continue - } - if action.State != tbtc.ReservationActionStatePending { - continue - } - reservation, err := spvChain.GetReservation(event.ReservationKey) if err != nil { logger.Errorf( @@ -410,6 +533,8 @@ func proveReservationReanchorActions( } } + state.reanchorLastScannedBlock = currentBlock + return nil } @@ -436,6 +561,11 @@ func findReservationReanchorTransaction( return nil, nil } + +// proveReservationTransaction assembles and submits the SPV proof for a +// single reservation acceptance or re-anchor transaction, once it has +// accumulated enough confirmations and its proof falls within the relay's +// difficulty range. func proveReservationTransaction( transaction *bitcoin.Transaction, btcChain bitcoin.Chain, @@ -483,23 +613,3 @@ func proveReservationTransaction( return nil } - -// reservationProofScanStartBlock returns the start block for a bounded, -// look-back-limited scan of pending-action-request events. -func reservationProofScanStartBlock(spvChain Chain) (uint64, error) { - blockCounter, err := spvChain.BlockCounter() - if err != nil { - return 0, fmt.Errorf("failed to get block counter: [%v]", err) - } - - currentBlock, err := blockCounter.CurrentBlock() - if err != nil { - return 0, fmt.Errorf("failed to get current block: [%v]", err) - } - - if currentBlock > reservationProofLookBackBlocks { - return currentBlock - reservationProofLookBackBlocks, nil - } - - return 0, nil -} diff --git a/pkg/maintainer/spv/reservation_proof_loop_test.go b/pkg/maintainer/spv/reservation_proof_loop_test.go index ebd5b77902..a084c8305e 100644 --- a/pkg/maintainer/spv/reservation_proof_loop_test.go +++ b/pkg/maintainer/spv/reservation_proof_loop_test.go @@ -9,26 +9,37 @@ import ( "github.com/keep-network/keep-core/pkg/tbtc" ) -// TestReservationProofScanStartBlock covers the bounded look-back arithmetic: -// the very first scan (current block below the look-back window) starts at -// block 0, while a later scan is bounded to exactly -// reservationProofLookBackBlocks behind the current block. -func TestReservationProofScanStartBlock(t *testing.T) { +// TestReservationProofNextScanRange covers the incremental scan-range +// arithmetic: the very first pass (lastScannedBlock == 0) is bounded to +// reservationProofLookBackBlocks behind the current block (or 0 if the +// chain is younger than that window); every later pass starts exactly one +// block after the previous pass's cursor, so a steady-state loop never +// rescans the full look-back window again. +func TestReservationProofNextScanRange(t *testing.T) { tests := map[string]struct { - currentBlock uint64 - expectedStart uint64 + currentBlock uint64 + lastScannedBlock uint64 + expectedStart uint64 }{ - "current block below the look-back window": { - currentBlock: 1000, - expectedStart: 0, + "first pass, current block below the look-back window": { + currentBlock: 1000, + lastScannedBlock: 0, + expectedStart: 0, }, - "current block at the look-back window boundary": { - currentBlock: reservationProofLookBackBlocks, - expectedStart: 0, + "first pass, current block at the look-back window boundary": { + currentBlock: reservationProofLookBackBlocks, + lastScannedBlock: 0, + expectedStart: 0, }, - "current block beyond the look-back window": { - currentBlock: reservationProofLookBackBlocks + 500, - expectedStart: 500, + "first pass, current block beyond the look-back window": { + currentBlock: reservationProofLookBackBlocks + 500, + lastScannedBlock: 0, + expectedStart: 500, + }, + "later pass starts one block after the cursor, ignoring the look-back window": { + currentBlock: reservationProofLookBackBlocks * 3, + lastScannedBlock: reservationProofLookBackBlocks * 2, + expectedStart: reservationProofLookBackBlocks*2 + 1, }, } @@ -39,7 +50,10 @@ func TestReservationProofScanStartBlock(t *testing.T) { blockCounter.SetCurrentBlock(test.currentBlock) spvChain.setBlockCounter(blockCounter) - startBlock, err := reservationProofScanStartBlock(spvChain) + startBlock, currentBlock, err := reservationProofNextScanRange( + spvChain, + test.lastScannedBlock, + ) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -50,6 +64,13 @@ func TestReservationProofScanStartBlock(t *testing.T) { startBlock, ) } + if currentBlock != test.currentBlock { + t.Errorf( + "unexpected current block\nexpected: %v\nactual: %v", + test.currentBlock, + currentBlock, + ) + } }) } } @@ -345,3 +366,285 @@ func TestProveReservationTransaction(t *testing.T) { } }) } + +// TestProveReservationAcceptanceActions is an end-to-end test of the +// top-level orchestration function wired into production via +// runReservationProofLoop: it seeds a requested event, a matching pending +// action, and a matching wallet transaction, then asserts the submit hook +// fires with the correct (reservationKey, requestNonce) pair. A +// regression that swapped the acceptance and re-anchor submitters (or +// mixed up their arguments) would show up here, not just in the +// lower-level helper unit tests above. +func TestProveReservationAcceptanceActions(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()) + + spvChain.addReservationAcceptanceRequestedEvent(&tbtc.ReservationAcceptanceRequestedEvent{ + ReservationKey: reservationKey, + RequestNonce: requestNonce, + WalletPublicKeyHash: walletPublicKeyHash, + BlockNumber: 500, + }) + spvChain.setReservationAction( + reservationKey, + requestNonce, + &tbtc.ReservationAction{ + State: tbtc.ReservationActionStatePending, + ActionType: tbtc.ReservationActionTypeAcceptance, + TargetWalletPublicKeyHash: walletPublicKeyHash, + }, + ) + + var submittedReservationKey *big.Int + var submittedRequestNonce uint64 + submissions := 0 + spvChain.submitReservationProofHook = func( + proofType uint8, + txInfo *tbtc.BitcoinTxInfo, + proof *tbtc.BitcoinTxProof, + mainUtxo *tbtc.BitcoinTxUTXO, + reservationKey *big.Int, + requestNonce uint64, + ) error { + submissions++ + submittedReservationKey = reservationKey + submittedRequestNonce = requestNonce + return nil + } + + config := Config{TransactionLimit: 100} + + if err := proveReservationAcceptanceActions( + newReservationProofScanState(), + config, + spvChain, + spvChain, + btcChain, + ); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if submissions != 1 { + t.Fatalf("expected exactly one proof submission, got %d", submissions) + } + if submittedReservationKey.Cmp(reservationKey) != 0 { + t.Errorf( + "unexpected submitted reservation key\nexpected: %v\nactual: %v", + reservationKey, + submittedReservationKey, + ) + } + if submittedRequestNonce != requestNonce { + t.Errorf( + "unexpected submitted request nonce\nexpected: %d\nactual: %d", + requestNonce, + submittedRequestNonce, + ) + } +} + +// TestProveReservationReanchorActions is an end-to-end test of the +// top-level orchestration function wired into production via +// runReservationProofLoop: it seeds a requested event, a matching +// reservation with an anchor UTXO, a matching pending action, and a +// matching wallet transaction, then asserts the submit hook fires with the +// correct (reservationKey, requestNonce) pair. A regression that swapped +// the acceptance and re-anchor submitters (or mixed up their arguments) +// would show up here, not just in the lower-level helper unit tests above. +func TestProveReservationReanchorActions(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) + + reservationKey := big.NewInt(424242) + const requestNonce = 2 + + 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, + } + + 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()) + + spvChain.addReservationReanchorRequestedEvent(&tbtc.ReservationReanchorRequestedEvent{ + ReservationKey: reservationKey, + RequestNonce: requestNonce, + SourceWalletPublicKeyHash: sourceWalletPublicKeyHash, + BlockNumber: 500, + }) + spvChain.setReservationAction( + reservationKey, + requestNonce, + &tbtc.ReservationAction{ + State: tbtc.ReservationActionStatePending, + ActionType: tbtc.ReservationActionTypeReanchor, + TargetWalletPublicKeyHash: sourceWalletPublicKeyHash, + }, + ) + spvChain.setReservation(reservationKey, &tbtc.Reservation{ + AnchorUtxo: anchorUtxo, + }) + + var submittedReservationKey *big.Int + var submittedRequestNonce uint64 + submissions := 0 + spvChain.submitReservationProofHook = func( + proofType uint8, + txInfo *tbtc.BitcoinTxInfo, + proof *tbtc.BitcoinTxProof, + mainUtxo *tbtc.BitcoinTxUTXO, + reservationKey *big.Int, + requestNonce uint64, + ) error { + submissions++ + submittedReservationKey = reservationKey + submittedRequestNonce = requestNonce + return nil + } + + config := Config{TransactionLimit: 100} + + if err := proveReservationReanchorActions( + newReservationProofScanState(), + config, + spvChain, + spvChain, + btcChain, + ); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if submissions != 1 { + t.Fatalf("expected exactly one proof submission, got %d", submissions) + } + if submittedReservationKey.Cmp(reservationKey) != 0 { + t.Errorf( + "unexpected submitted reservation key\nexpected: %v\nactual: %v", + reservationKey, + submittedReservationKey, + ) + } + if submittedRequestNonce != requestNonce { + t.Errorf( + "unexpected submitted request nonce\nexpected: %d\nactual: %d", + requestNonce, + submittedRequestNonce, + ) + } +} diff --git a/pkg/maintainer/spv/reservation_reanchor_proof.go b/pkg/maintainer/spv/reservation_reanchor_proof.go index afdede9538..f37da53e64 100644 --- a/pkg/maintainer/spv/reservation_reanchor_proof.go +++ b/pkg/maintainer/spv/reservation_reanchor_proof.go @@ -14,6 +14,7 @@ import ( // Reanchor). const ProofTypeReservationReanchor uint8 = 3 +// SubmitReservationReanchorProof drives the SPV proof submission for a // reservation re-anchor action generation. The caller (the reservation // proof loop) supplies the (reservationKey, requestNonce) // pair of the on-chain action generation it is proving, plus the Bitcoin @@ -70,16 +71,15 @@ func submitReservationReanchorProof( ProofTypeReservationReanchor, "reservation_reanchor_proof", tbtc.ReservationActionTypeReanchor, - parseReservationReanchorTransactionInput, + "re-anchor", ) } // spentOutputAsUtxo fetches the single previous output spent by transaction's // sole input and returns it as an UnspentTransactionOutput. Shared by -// parseReservationAcceptanceTransactionInput and -// parseReservationReanchorTransactionInput, both of which parse a -// 1-input-1-output reservation transaction and need the spent outpoint's -// value to build the SPV proof's main UTXO. +// parseReservationTransaction, which parses a 1-input-1-output reservation +// transaction and needs the spent outpoint's value to build the SPV proof's +// main UTXO. func spentOutputAsUtxo( btcChain bitcoin.Chain, transaction *bitcoin.Transaction, @@ -119,36 +119,38 @@ func spentOutputAsUtxo( }, nil } -// parseReservationReanchorTransactionInput parses the single input and -// single output of a reservation re-anchor transaction and returns the -// anchor UTXO that was spent and the target wallet's public key hash from -// the new anchor output script. -func parseReservationReanchorTransactionInput( +// parseReservationTransaction parses the single input and single output +// of a reservation transaction and returns the UTXO that was spent and +// the target wallet's public key hash from the new output script. +func parseReservationTransaction( btcChain bitcoin.Chain, transaction *bitcoin.Transaction, + txType string, ) (*bitcoin.UnspentTransactionOutput, [20]byte, error) { - anchorUtxo, err := spentOutputAsUtxo(btcChain, transaction) + utxo, err := spentOutputAsUtxo(btcChain, transaction) if err != nil { return nil, [20]byte{}, err } if len(transaction.Outputs) != 1 { return nil, [20]byte{}, fmt.Errorf( - "reservation re-anchor transaction must have exactly one output", + "reservation %v transaction must have exactly one output", + txType, ) } - targetWalletPublicKeyHash, err := bitcoin.ExtractPublicKeyHash( + publicKeyHash, err := bitcoin.ExtractPublicKeyHash( transaction.Outputs[0].PublicKeyScript, ) if err != nil { return nil, [20]byte{}, fmt.Errorf( - "cannot extract target wallet public key hash: [%v]", + "cannot extract %v public key hash: [%v]", + txType, err, ) } - return anchorUtxo, targetWalletPublicKeyHash, nil + return utxo, publicKeyHash, nil } // buildReservationProofTxInfo serializes the relevant parts of the @@ -181,10 +183,10 @@ func buildReservationProofTxProof( } } -// buildReservationProofMainUtxo packages the spent anchor UTXO into the -// BitcoinTxUTXO structure expected by SubmitReservationProof. +// buildReservationProofMainUtxo packages the spent deposit or anchor UTXO +// into the BitcoinTxUTXO structure expected by SubmitReservationProof. func buildReservationProofMainUtxo( - anchorUtxo *bitcoin.UnspentTransactionOutput, + spentUtxo *bitcoin.UnspentTransactionOutput, ) *tbtc.BitcoinTxUTXO { var ( txHash [32]byte @@ -192,14 +194,14 @@ func buildReservationProofMainUtxo( txOutValue uint64 ) - if anchorUtxo.Outpoint != nil { - txHash = anchorUtxo.Outpoint.TransactionHash - txOutIndex = anchorUtxo.Outpoint.OutputIndex + if spentUtxo.Outpoint != nil { + txHash = spentUtxo.Outpoint.TransactionHash + txOutIndex = spentUtxo.Outpoint.OutputIndex } - if anchorUtxo.Value < 0 { + if spentUtxo.Value < 0 { txOutValue = 0 } else { - txOutValue = uint64(anchorUtxo.Value) + txOutValue = uint64(spentUtxo.Value) } return &tbtc.BitcoinTxUTXO{ @@ -223,10 +225,7 @@ func submitReservationActionProof( proofType uint8, metricsPrefix string, expectedActionType tbtc.ReservationActionType, - inputParser func( - btcChain bitcoin.Chain, - transaction *bitcoin.Transaction, - ) (*bitcoin.UnspentTransactionOutput, [20]byte, error), + txType string, ) error { if metricsRecorder != nil { metricsRecorder.IncrementCounter(metricsPrefix+"_submissions_total", 1) @@ -257,7 +256,7 @@ func submitReservationActionProof( return fmt.Errorf("failed to assemble transaction spv proof: [%v]", err) } - anchorUtxo, pkh, err := inputParser(btcChain, transaction) + utxo, pkh, err := parseReservationTransaction(btcChain, transaction, txType) if err != nil { if metricsRecorder != nil { metricsRecorder.IncrementCounter(metricsPrefix+"_submissions_failed_total", 1) @@ -270,7 +269,7 @@ func submitReservationActionProof( return fmt.Errorf("cannot fetch reservation action generation: [%v]", err) } - // Fix 6: Check PKH match + // The action snapshot is the on-chain authorization for the destination, so verify the PKH match. if pkh != action.TargetWalletPublicKeyHash { if metricsRecorder != nil { metricsRecorder.IncrementCounter(metricsPrefix+"_submissions_failed_total", 1) @@ -288,7 +287,7 @@ func submitReservationActionProof( txInfo := buildReservationProofTxInfo(transaction) txProof := buildReservationProofTxProof(proof) - mainUtxo := buildReservationProofMainUtxo(anchorUtxo) + mainUtxo := buildReservationProofMainUtxo(utxo) if err := spvChain.SubmitReservationProof( proofType, @@ -298,15 +297,8 @@ func submitReservationActionProof( reservationKey, requestNonce, ); err != nil { - if metricsRecorder != nil { - metricsRecorder.IncrementCounter(metricsPrefix+"_submissions_failed_total", 1) - } return fmt.Errorf("failed to submit reservation proof: [%v]", err) } - if metricsRecorder != nil { - metricsRecorder.IncrementCounter(metricsPrefix+"_submissions_success_total", 1) - } - return nil } diff --git a/pkg/maintainer/spv/reservation_reanchor_proof_test.go b/pkg/maintainer/spv/reservation_reanchor_proof_test.go index 323499c53c..18828c6436 100644 --- a/pkg/maintainer/spv/reservation_reanchor_proof_test.go +++ b/pkg/maintainer/spv/reservation_reanchor_proof_test.go @@ -10,6 +10,14 @@ import ( "github.com/keep-network/keep-core/pkg/tbtc" ) +type mockMetricsRecorder struct { + counts map[string]float64 +} + +func (m *mockMetricsRecorder) IncrementCounter(name string, value float64) { + m.counts[name] += value +} + // TestSubmitReservationReanchorProof verifies that submitReservationReanchorProof // correctly parses a 1-input-1-output re-anchor transaction, looks up the // matching reservation action generation, and submits the SPV proof to the @@ -133,6 +141,7 @@ func TestSubmitReservationReanchorProof(t *testing.T) { return nil } + metricsRecorder := &mockMetricsRecorder{counts: make(map[string]float64)} if err := submitReservationReanchorProof( reanchorTx.Hash(), requiredConfirmations, @@ -141,10 +150,42 @@ func TestSubmitReservationReanchorProof(t *testing.T) { btcChain, spvChain, mockSpvProofAssembler, - nil, + metricsRecorder, ); err != nil { t.Fatal(err) } + // Check metrics. + if count := metricsRecorder.counts["reservation_reanchor_proof_submissions_total"]; count != 1 { + t.Errorf("unexpected metrics count: got %f, want 1", count) + } + + // Negative path: nil reservationKey. + if err := submitReservationReanchorProof( + reanchorTx.Hash(), + requiredConfirmations, + nil, + requestNonce, + btcChain, + spvChain, + mockSpvProofAssembler, + nil, + ); err == nil { + t.Fatal("expected error for nil reservation key") + } + + // Negative path: zero requestNonce. + if err := submitReservationReanchorProof( + reanchorTx.Hash(), + requiredConfirmations, + reservationKey, + 0, + btcChain, + spvChain, + mockSpvProofAssembler, + nil, + ); err == nil { + t.Fatal("expected error for zero request nonce") + } // Negative path: action generation is not Pending. spvChain.setReservationAction(reservationKey, requestNonce, &tbtc.ReservationAction{ diff --git a/pkg/maintainer/spv/reservation_stale_deposit_watch.go b/pkg/maintainer/spv/reservation_stale_deposit_watch.go index 00dd527dc5..43a752bad5 100644 --- a/pkg/maintainer/spv/reservation_stale_deposit_watch.go +++ b/pkg/maintainer/spv/reservation_stale_deposit_watch.go @@ -29,6 +29,7 @@ const reservationAcceptanceActionNonce uint64 = 1 // flips the deposit's bookkeeping when the wallet never shows up. type ReservationStaleDepositWatcher struct { spvChain Chain + notified map[string]struct{} } // NewReservationStaleDepositWatcher constructs a stale-deposit watcher @@ -38,6 +39,7 @@ func NewReservationStaleDepositWatcher( ) *ReservationStaleDepositWatcher { return &ReservationStaleDepositWatcher{ spvChain: spvChain, + notified: make(map[string]struct{}), } } @@ -105,6 +107,9 @@ func (rsdw *ReservationStaleDepositWatcher) CheckStaleReservedDeposit( depositKey *big.Int, now uint32, ) error { + if _, ok := rsdw.notified[depositKey.String()]; ok { + return nil + } if depositKey == nil { return fmt.Errorf("deposit key must not be nil") } @@ -285,6 +290,7 @@ func (rsdw *ReservationStaleDepositWatcher) CheckStaleReservedDeposit( err, ) } + rsdw.notified[depositKey.String()] = struct{}{} logger.Infof( "notified stale reserved deposit [%v] "+ diff --git a/pkg/maintainer/spv/reservation_stranding_watch.go b/pkg/maintainer/spv/reservation_stranding_watch.go index 55168cce95..8e0d728aa8 100644 --- a/pkg/maintainer/spv/reservation_stranding_watch.go +++ b/pkg/maintainer/spv/reservation_stranding_watch.go @@ -20,16 +20,10 @@ type ReservationStrandingWatcher struct { } // NewReservationStrandingWatcher constructs a stranding watcher bound to the -// given chain. The returned watcher is not yet attached to a wallet; use -// WatchWallet to start observing a particular wallet's close/termination -// events. +// given chain. // -// The notifier is mandatory and must be non-nil; nil is treated as a -// programming error rather than a no-op because silently dropping stray -// notifications would leave reservation anchors unreconciled. -func NewReservationStrandingWatcher( - spvChain Chain, -) *ReservationStrandingWatcher { +// The watcher is intended to be wired to wallet-close events via a subscription +func NewReservationStrandingWatcher(spvChain Chain) *ReservationStrandingWatcher { return &ReservationStrandingWatcher{ spvChain: spvChain, } @@ -37,13 +31,13 @@ func NewReservationStrandingWatcher( // CheckReservationStrandingForWallet walks the reservations currently // custodied by walletPublicKeyHash and forwards a stray notification to the -// Bridge for every reservation whose state is not ActionPending. +// Bridge for every reservation whose state is Active. // // This is the single-shot form used both by tests and by the integration -// wiring of WatchWallet. It is intentionally synchronous and per-wallet: the -// caller decides which wallets to inspect, and the watcher does not run a -// background loop of its own. -// +// wiring that subscribes to wallet close/termination events. It is +// intentionally synchronous and per-wallet: the caller decides which wallets +// to inspect, and the watcher does not run a background loop of its own. + // The function is idempotent at the chain level: notifying an already-stranded // reservation is a no-op on the Bridge side. It is the caller's // responsibility to dedupe notifications across watcher restarts; the watcher @@ -79,11 +73,12 @@ func (rsw *ReservationStrandingWatcher) CheckReservationStrandingForWallet( // action-timeout watcher. Marking it stranded would preempt a healthy // settlement path and trigger gratuitous reconciliation cost for the // owner. - if reservation.State == tbtc.ReservationStateActionPending { + if reservation.State != tbtc.ReservationStateActive { logger.Debugf( - "reservation [%v] has a pending action generation; "+ + "reservation [%v] is not Active (state: %v); "+ "deferring stray notification to action-timeout watcher", key, + reservation.State, ) continue } diff --git a/pkg/maintainer/spv/reservation_stranding_watch_test.go b/pkg/maintainer/spv/reservation_stranding_watch_test.go index 28c910b42e..e973aa205b 100644 --- a/pkg/maintainer/spv/reservation_stranding_watch_test.go +++ b/pkg/maintainer/spv/reservation_stranding_watch_test.go @@ -79,7 +79,7 @@ func TestReservationStrandingWatcher_NotifiesActiveReservation(t *testing.T) { } } -func TestReservationStrandingWatcher_NotifiesClosedReservation(t *testing.T) { +func TestReservationStrandingWatcher_SkipsClosedReservation(t *testing.T) { spvChain := newLocalChain() wallet := walletPKH() @@ -95,8 +95,8 @@ func TestReservationStrandingWatcher_NotifiesClosedReservation(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - if calls := spvChain.getSubmittedReservationStrandedKeys(); len(calls) != 1 { - t.Fatalf("expected one notification, got %d", len(calls)) + if calls := spvChain.getSubmittedReservationStrandedKeys(); len(calls) != 0 { + t.Fatalf("expected no notifications, got %d", len(calls)) } } @@ -156,18 +156,24 @@ func TestReservationStrandingWatcher_MultipleReservations(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - // The watcher must notify for all reservations that are not in - // ActionPending. ReservationStateStranded is the natural re-notify case - // (the Bridge dedupes; the watcher does not). + // The watcher must notify only for reservations in the Active state + // (finding #27's allow-list fix): closed, pending, and stranded + // reservations must all be skipped - a stranded reservation has + // already been notified once and re-notifying it is redundant, and a + // closed reservation was already resolved through in-kind redemption + // or another terminal path, not stranding. calls := spvChain.getSubmittedReservationStrandedKeys() - if len(calls) != 3 { + if len(calls) != 1 { t.Fatalf( - "expected three notifications (active+closed+stranded), "+ + "expected exactly one notification (active only), "+ "got %d: %v", len(calls), calls, ) } + if diff := deep.Equal(active, calls[0]); diff != nil { + t.Errorf("unexpected notified key: %v", diff) + } } func TestReservationStrandingWatcher_UnknownReservationIsSkipped(t *testing.T) { diff --git a/pkg/maintainer/spv/reservation_wiring.go b/pkg/maintainer/spv/reservation_wiring.go index e61cc9c85c..62f19d2fde 100644 --- a/pkg/maintainer/spv/reservation_wiring.go +++ b/pkg/maintainer/spv/reservation_wiring.go @@ -25,19 +25,14 @@ const DefaultReservationStaleDepositPollInterval = 1 * time.Minute // DefaultReservationActionTimeoutPollInterval is the default poll interval // for the action-timeout watcher's Run loop. It is intentionally conservative // (1 minute) to limit Bridge load until the production wiring tightens the -// cadence. Operators can shorten the interval once the full integration ships. +// cadence. The interval is fixed. const DefaultReservationActionTimeoutPollInterval = 1 * time.Minute -// WireReservationWatchers is the integration entry point that the m1 PR H -// coordination layer calls when config.Reservations.Enabled is true. It -// constructs the three reservation watchers (stranding, stale-deposit, -// action-timeout), wires their Bridge-facing notifiers to the chain, and -// subscribes/starts each watcher against its source. -// -// The function lives in the spv package because that is where the watcher -// types live; the coordination layer invokes it via a callback supplied by -// cmd/start.go so that the tbtc package does not need a static import of spv -// (which would cycle with spv's existing import of tbtc). +// WireReservationWatchers is the integration entry point that cmd/start.go +// calls directly when config.Reservations.Enabled is true. It constructs +// the three reservation watchers (stranding, stale-deposit, action-timeout), +// wires their Bridge-facing notifiers to the chain, and subscribes/starts +// each watcher against its source. // // `tbtcChain` supplies the On* event subscriptions the SPV-specific `Chain` // omits; `spvChain` supplies the reservation data reads and Notify* writes. @@ -46,6 +41,7 @@ func WireReservationWatchers( ctx context.Context, tbtcChain tbtc.Chain, spvChain Chain, + walletMembersResolver tbtc.WalletMembersResolver, ) error { strandingWatcher := NewReservationStrandingWatcher(spvChain) @@ -56,15 +52,9 @@ func WireReservationWatchers( // wallets registered in that window, and check the ones already // Closed/Terminated now. if lastSeenBlock, err := spvChain.BlockCounter(); err != nil { - reservationWiringLogger.Errorf( - "stranding startup scan failed to get block counter: [%v]", - err, - ) + return fmt.Errorf("stranding startup scan failed to get block counter: [%w]", err) } else if currentBlock, err := lastSeenBlock.CurrentBlock(); err != nil { - reservationWiringLogger.Errorf( - "stranding startup scan failed to get current block: [%v]", - err, - ) + return fmt.Errorf("stranding startup scan failed to get current block: [%w]", err) } else { var startBlock uint64 if currentBlock > reservationStaleDepositLookBackBlocks { @@ -75,76 +65,31 @@ func WireReservationWatchers( &tbtc.NewWalletRegisteredEventFilter{StartBlock: startBlock}, ) if err != nil { - reservationWiringLogger.Errorf( - "stranding startup scan failed to fetch wallet "+ - "registration events: [%v]", - err, - ) - } else { - for _, event := range registeredEvents { - wallet, err := spvChain.GetWallet(event.WalletPublicKeyHash) - if err != nil { - reservationWiringLogger.Errorf( - "stranding startup scan failed to fetch wallet "+ - "[0x%x]: [%v]", - event.WalletPublicKeyHash, - err, - ) - continue - } - if wallet.State != tbtc.StateClosed && - wallet.State != tbtc.StateTerminated { - continue - } - if err := strandingWatcher.CheckReservationStrandingForWallet( - event.WalletPublicKeyHash, - ); err != nil { - reservationWiringLogger.Errorf( - "stranding startup scan failed to check wallet "+ - "[0x%x]: [%v]", - event.WalletPublicKeyHash, - err, - ) - } + return fmt.Errorf("stranding startup scan failed to fetch wallet registration events: [%w]", err) + } + + for _, event := range registeredEvents { + wallet, err := spvChain.GetWallet(event.WalletPublicKeyHash) + if err != nil { + return fmt.Errorf("stranding startup scan failed to fetch wallet [0x%x]: [%w]", event.WalletPublicKeyHash, err) + } + if wallet.State != tbtc.StateClosed && + wallet.State != tbtc.StateTerminated { + continue + } + if err := strandingWatcher.CheckReservationStrandingForWallet( + event.WalletPublicKeyHash, + ); err != nil { + return fmt.Errorf("stranding startup scan failed to check wallet [0x%x]: [%w]", event.WalletPublicKeyHash, err) } } } staleDepositWatcher := NewReservationStaleDepositWatcher(spvChain) - // NOTE: the SPV maintainer runs as a standalone process/service with - // only on-chain Chain-interface reads - it has no access to the - // coordinating node's in-memory wallet registry - // (pkg/tbtc.wallet.signingGroupOperators), which is the only place a - // wallet's signing-group operator addresses are held; there is no - // on-chain accessor for them (verified: no method on tbtc.Chain, - // sortition.Chain, or the ethereum concrete chain exposes a wallet's - // group member list). The resolver therefore cannot be correctly - // implemented from this package as originally proposed. Per the - // fallback this finding's own fix text offered, the watcher stays - // wired but the resolver fails loud with an accurate reason instead of - // silently notifying with fabricated/empty member data. - membersResolver := WalletMembersResolverFunc( - func(walletPublicKeyHash [20]byte) ([]uint32, error) { - return nil, fmt.Errorf( - "wallet members resolver not wired: no on-chain accessor " + - "exposes a wallet's signing-group operator addresses " + - "to the standalone SPV maintainer process; resolving " + - "this requires either a new on-chain accessor or " + - "running this watcher in-process with the node's " + - "wallet registry", - ) - }, - ) - reservationWiringLogger.Warnf( - "reservation action-timeout watcher's wallet members resolver is " + - "not wired; timeout notifications will fail until a wallet " + - "member resolution path is added", - ) - actionTimeoutWatcher := NewReservationActionTimeoutWatcher( spvChain, - membersResolver, + walletMembersResolver, DefaultReservationActionTimeoutPollInterval, ) @@ -313,6 +258,7 @@ func startStaleDepositPoll( continue } + var batchErr error for _, event := range events { depositKey := spvChain.BuildDepositKey( event.FundingTxHash, @@ -327,7 +273,8 @@ func startStaleDepositPoll( depositKey, err, ) - continue + batchErr = err + break } if !isReserved { continue @@ -335,6 +282,9 @@ func startStaleDepositPoll( pending[depositKey.String()] = depositKey } + if batchErr != nil { + continue + } lastSeenBlock = currentBlock now := uint32(time.Now().Unix()) diff --git a/pkg/maintainer/spv/reservation_wiring_test.go b/pkg/maintainer/spv/reservation_wiring_test.go index b4082a269e..a380d1cd73 100644 --- a/pkg/maintainer/spv/reservation_wiring_test.go +++ b/pkg/maintainer/spv/reservation_wiring_test.go @@ -1,9 +1,11 @@ package spv import ( + "context" "fmt" "testing" + "github.com/keep-network/keep-core/pkg/subscription" "github.com/keep-network/keep-core/pkg/tbtc" ) @@ -142,3 +144,49 @@ func TestIsPendingStaleDepositResolved(t *testing.T) { }) } } + +type mockWalletMembersResolver struct { + resolveFn func(walletPublicKeyHash [20]byte) ([]uint32, error) +} + +func (m *mockWalletMembersResolver) ResolveWalletMembers(walletPublicKeyHash [20]byte) ([]uint32, error) { + return m.resolveFn(walletPublicKeyHash) +} + +// stubTbtcChain implements only the tbtc.Chain methods +// WireReservationWatchers's synchronous startup path actually calls +// (OnWalletClosed, to register the stranding watcher's live subscription). +// Every other tbtc.Chain method is unreachable from that synchronous path +// within this test's lifetime and is left to the embedded nil interface, +// which would panic if ever invoked - an intentional signal that the test +// has started exercising a code path it does not yet stub. +type stubTbtcChain struct { + tbtc.Chain +} + +func (s *stubTbtcChain) OnWalletClosed( + handler func(event *tbtc.WalletClosedEvent), +) subscription.EventSubscription { + return subscription.NewEventSubscription(func() {}) +} + +func TestWireReservationWatchers(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + tbtcChain := &stubTbtcChain{} + spvChain := newLocalChain() + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(1000) + spvChain.setBlockCounter(blockCounter) + + resolver := &mockWalletMembersResolver{ + resolveFn: func(walletPublicKeyHash [20]byte) ([]uint32, error) { + return []uint32{1, 2, 3}, nil + }, + } + + if err := WireReservationWatchers(ctx, tbtcChain, spvChain, resolver); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} From 705461455723c59fd99cdde90b369f15aaefb329 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 1 Sep 2026 20:12:45 +0000 Subject: [PATCH 27/39] fix(client): remove dead reservation router API surface, wire watchers to real resolver - pkg/chain/ethereum/tbtc.go: remove the 17 ReservationChain method implementations with no production caller (mirrors the interface trim in pkg/tbtc, pkg/tbtcpg, pkg/maintainer/spv) - pkg/clientinfo/performance.go: drop the metric registrations and dead helper for the removed redemption/dissolution action types - cmd/start.go: propagate the real WalletMembersResolver from tbtc.Initialize into WireReservationWatchers instead of a stub; fix the WireReservationWatchers error handling to actually check the now-real returned error --- cmd/start.go | 6 +- pkg/chain/ethereum/tbtc.go | 549 +---------------------------- pkg/clientinfo/performance.go | 14 - pkg/clientinfo/performance_test.go | 4 - 4 files changed, 6 insertions(+), 567 deletions(-) diff --git a/cmd/start.go b/cmd/start.go index a938fefc1b..47a82626cf 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -8,6 +8,7 @@ import ( "github.com/keep-network/keep-core/pkg/tbtcpg" "github.com/keep-network/keep-common/pkg/persistence" + "github.com/keep-network/keep-core/build" "github.com/keep-network/keep-core/pkg/bitcoin/electrum" "github.com/keep-network/keep-core/pkg/operator" @@ -168,7 +169,7 @@ func start(cmd *cobra.Command) error { clientConfig.Tbtc.Reservations.Enabled, ) - err = tbtc.Initialize( + resolver, err := tbtc.Initialize( ctx, tbtcChain, btcChain, @@ -183,7 +184,7 @@ func start(cmd *cobra.Command) error { clientConfig.Ethereum.Network, ) if err != nil { - return fmt.Errorf("error initializing TBTC: [%v]", err) + return fmt.Errorf("cannot initialize TBTC: [%v]", err) } // Wire the reservation watchers (stranding, stale-deposit, @@ -199,6 +200,7 @@ func start(cmd *cobra.Command) error { ctx, tbtcChain, tbtcChain, + resolver, ); err != nil { return fmt.Errorf( "failed to wire reservation watchers: [%v]", diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index 1465ab1545..76e76191ed 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -17,9 +17,11 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" "github.com/keep-network/keep-common/pkg/chain/ethereum/ethutil" + "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-common/pkg/chain/ethereum" + "github.com/keep-network/keep-core/pkg/chain" ecdsaabi "github.com/keep-network/keep-core/pkg/chain/ethereum/ecdsa/gen/abi" ecdsacontract "github.com/keep-network/keep-core/pkg/chain/ethereum/ecdsa/gen/contract" @@ -2596,24 +2598,6 @@ func (tc *TbtcChain) ValidateReservationAnchorProposal( return nil } -// ValidateReservedRedemptionProposal asks the WalletProposalValidator -// whether the given reserved redemption proposal is valid for the given -// wallet. The m1 bridge-integration surface does not expose a -// `validateReservedRedemptionProposal` entry on the WalletProposalValidator -// (only anchor and re-anchor validators are present at this milestone), so -// the interface stub returns an explicit error rather than calling a -// non-existent binding. Downstream tasks replacing this body will receive -// the bridge-integration Solidity once that validator lands. -func (tc *TbtcChain) ValidateReservedRedemptionProposal( - walletPublicKeyHash [20]byte, - proposal *tbtc.ReservedRedemptionProposal, -) error { - return fmt.Errorf( - "reserved redemption proposal validator is not exposed on " + - "the m1 bridge-integration surface", - ) -} - // ValidateReservationReanchorProposal asks the WalletProposalValidator // whether the given re-anchor proposal is valid for the given source // wallet. The validator is a separate contract reached at its own deployed @@ -2646,23 +2630,6 @@ func (tc *TbtcChain) ValidateReservationReanchorProposal( return nil } -// ValidateReservationDissolutionProposal asks the WalletProposalValidator -// whether the given dissolution proposal is valid for the given wallet. -// The m1 bridge-integration surface does not expose a -// `validateReservationDissolutionProposal` entry on the -// WalletProposalValidator (only anchor and re-anchor validators are present -// at this milestone), so the interface stub returns an explicit error -// rather than calling a non-existent binding. -func (tc *TbtcChain) ValidateReservationDissolutionProposal( - walletPublicKeyHash [20]byte, - proposal *tbtc.ReservationDissolutionProposal, -) error { - return fmt.Errorf( - "reservation dissolution proposal validator is not exposed on " + - "the m1 bridge-integration surface", - ) -} - // convertReservationFromAbiType converts the ReservationRouter-specific // Reservation.ReservationRequest ABI struct to the TBTC application // `tbtc.Reservation` representation. @@ -3139,29 +3106,6 @@ func (tc *TbtcChain) WalletReservations( return keys, nil } -// ReservationByAnchorUtxo returns the reservation key whose anchor outpoint -// is the given Bitcoin transaction output, or an empty value if no -// reservation is anchored there. -func (tc *TbtcChain) ReservationByAnchorUtxo( - anchorTxHash [32]byte, - anchorTxOutputIndex uint32, -) (*big.Int, error) { - key, err := tc.reservationRouter.ReservationByAnchorUtxo( - anchorTxHash, - anchorTxOutputIndex, - ) - if err != nil { - return nil, fmt.Errorf( - "cannot get reservation by anchor utxo [0x%x:%d]: [%v]", - anchorTxHash, - anchorTxOutputIndex, - err, - ) - } - - return key, nil -} - // ReservedDepositWallet returns the wallet public key hash to which the // given reserved deposit was revealed. Returns the zero hash if the // deposit is not a reserved deposit. @@ -3180,20 +3124,6 @@ func (tc *TbtcChain) ReservedDepositWallet( return walletPublicKeyHash, nil } -// PendingReservedDeposits returns the number of reserved deposits that -// have been revealed to the Bridge but not yet accepted by a wallet. -func (tc *TbtcChain) PendingReservedDeposits() (uint64, error) { - count, err := tc.reservationRouter.PendingReservedDeposits() - if err != nil { - return 0, fmt.Errorf( - "cannot get pending reserved deposits: [%v]", - err, - ) - } - - return count, nil -} - // convertReservationRequestFromAbiType converts the ReservationRouter- // specific Reservation.ReservationRequest ABI struct to the TBTC // application `tbtc.ReservationRequest` representation. This is the @@ -3231,25 +3161,6 @@ func (tc *TbtcChain) ActiveReservationsCount() (uint32, uint32, error) { return activeReservationsCount.Count, activeReservationsCount.MaxActive, nil } -// ReservationRouter returns the address of the ReservationRouter contract -// as stored on the Bridge. The router contract holds its own empty storage -// and only ever executes via Bridge.fallback's delegatecall, so this is -// the one place where the chain handle reads a router address value -// rather than binding a call to it: any actual reservation call routes -// through the Bridge binding (tc.reservationRouter, which is bound to the -// Bridge address) and dispatches into the router code via the fallback. -func (tc *TbtcChain) ReservationRouter() (chain.Address, error) { - address, err := tc.bridge.GetReservationRouter() - if err != nil { - return "", fmt.Errorf( - "cannot get reservation router address: [%v]", - err, - ) - } - - return chain.Address(address.Hex()), nil -} - // IsReservedDeposit returns true if the given deposit was revealed with // the reservation vault address and is therefore a reservation rather than // a default deposit. @@ -3351,94 +3262,6 @@ func (tc *TbtcChain) PastReservationAcceptanceRequestedEvents( return convertedEvents, nil } -// OnReservationAccepted registers a callback that is invoked when an -// on-chain ReservationAccepted event is seen. -func (tc *TbtcChain) OnReservationAccepted( - handler func(event *tbtc.ReservationAcceptedEvent), -) subscription.EventSubscription { - onEvent := func( - reservationKey *big.Int, - requestNonce uint64, - walletPublicKeyHash [20]byte, - owner common.Address, - anchorTxHash [32]byte, - anchorAmount uint64, - expiresAt uint32, - blockNumber uint64, - ) { - handler(&tbtc.ReservationAcceptedEvent{ - ReservationKey: reservationKey, - RequestNonce: requestNonce, - WalletPublicKeyHash: walletPublicKeyHash, - Owner: chain.Address(owner.Hex()), - AnchorTxHash: anchorTxHash, - AnchorAmount: anchorAmount, - ExpiresAt: expiresAt, - BlockNumber: blockNumber, - }) - } - - return tc.reservationRouter.ReservationAcceptedEvent( - nil, - nil, - nil, - nil, - ).OnEvent(onEvent) -} - -// PastReservationAcceptedEvents fetches past ReservationAccepted events -// according to the provided filter or unfiltered if the filter is nil. -func (tc *TbtcChain) PastReservationAcceptedEvents( - filter *tbtc.ReservationAcceptedEventFilter, -) ([]*tbtc.ReservationAcceptedEvent, error) { - var startBlock uint64 - var endBlock *uint64 - var reservationKey []*big.Int - var walletPublicKeyHash [][20]byte - var owner []common.Address - - if filter != nil { - startBlock = filter.StartBlock - endBlock = filter.EndBlock - reservationKey = filter.ReservationKey - walletPublicKeyHash = filter.WalletPublicKeyHash - for _, o := range filter.Owner { - owner = append(owner, common.HexToAddress(string(o))) - } - } - - events, err := tc.reservationRouter.PastReservationAcceptedEvents( - startBlock, - endBlock, - reservationKey, - walletPublicKeyHash, - owner, - ) - if err != nil { - return nil, err - } - - convertedEvents := make([]*tbtc.ReservationAcceptedEvent, 0) - for _, event := range events { - convertedEvents = append(convertedEvents, &tbtc.ReservationAcceptedEvent{ - ReservationKey: event.ReservationKey, - RequestNonce: event.RequestNonce, - WalletPublicKeyHash: event.WalletPubKeyHash, - Owner: chain.Address(event.Owner.Hex()), - AnchorTxHash: event.AnchorTxHash, - AnchorAmount: event.AnchorAmount, - ExpiresAt: event.ExpiresAt, - BlockNumber: event.Raw.BlockNumber, - }) - } - - sort.SliceStable(convertedEvents, func(i, j int) bool { - return convertedEvents[i].BlockNumber < convertedEvents[j].BlockNumber - }) - - return convertedEvents, nil -} - // OnReservationReanchorRequested registers a callback that is invoked // when an on-chain ReservationReanchorRequested event is seen. func (tc *TbtcChain) OnReservationReanchorRequested( @@ -3519,371 +3342,3 @@ func (tc *TbtcChain) PastReservationReanchorRequestedEvents( return convertedEvents, nil } - -// OnReservationReanchored registers a callback that is invoked when an -// on-chain ReservationReanchored event is seen. -func (tc *TbtcChain) OnReservationReanchored( - handler func(event *tbtc.ReservationReanchoredEvent), -) subscription.EventSubscription { - onEvent := func( - reservationKey *big.Int, - requestNonce uint64, - newWalletPublicKeyHash [20]byte, - newAnchorTxHash [32]byte, - newAnchorAmount uint64, - blockNumber uint64, - ) { - handler(&tbtc.ReservationReanchoredEvent{ - ReservationKey: reservationKey, - RequestNonce: requestNonce, - NewWalletPublicKeyHash: newWalletPublicKeyHash, - NewAnchorTxHash: newAnchorTxHash, - NewAnchorAmount: newAnchorAmount, - BlockNumber: blockNumber, - }) - } - - return tc.reservationRouter.ReservationReanchoredEvent( - nil, - nil, - nil, - ).OnEvent(onEvent) -} - -// PastReservationReanchoredEvents fetches past ReservationReanchored -// events according to the provided filter or unfiltered if the filter is -// nil. -func (tc *TbtcChain) PastReservationReanchoredEvents( - filter *tbtc.ReservationReanchoredEventFilter, -) ([]*tbtc.ReservationReanchoredEvent, error) { - var startBlock uint64 - var endBlock *uint64 - var reservationKey []*big.Int - var newWalletPublicKeyHash [][20]byte - - if filter != nil { - startBlock = filter.StartBlock - endBlock = filter.EndBlock - reservationKey = filter.ReservationKey - newWalletPublicKeyHash = filter.NewWalletPublicKeyHash - } - - events, err := tc.reservationRouter.PastReservationReanchoredEvents( - startBlock, - endBlock, - reservationKey, - newWalletPublicKeyHash, - ) - if err != nil { - return nil, err - } - - convertedEvents := make([]*tbtc.ReservationReanchoredEvent, 0) - for _, event := range events { - convertedEvents = append(convertedEvents, &tbtc.ReservationReanchoredEvent{ - ReservationKey: event.ReservationKey, - RequestNonce: event.RequestNonce, - NewWalletPublicKeyHash: event.NewWalletPubKeyHash, - NewAnchorTxHash: event.NewAnchorTxHash, - NewAnchorAmount: event.NewAnchorAmount, - BlockNumber: event.Raw.BlockNumber, - }) - } - - sort.SliceStable(convertedEvents, func(i, j int) bool { - return convertedEvents[i].BlockNumber < convertedEvents[j].BlockNumber - }) - - return convertedEvents, nil -} - -// OnReservationActionTimedOut registers a callback that is invoked when an -// on-chain ReservationActionTimedOut event is seen. -func (tc *TbtcChain) OnReservationActionTimedOut( - handler func(event *tbtc.ReservationActionTimedOutEvent), -) subscription.EventSubscription { - onEvent := func( - reservationKey *big.Int, - requestNonce uint64, - actionType uint8, - blockNumber uint64, - ) { - parsedActionType, err := parseReservationActionType(actionType) - if err != nil { - logger.Errorf( - "unexpected reservation action type on ReservationActionTimedOut event: [%v]", - err, - ) - return - } - - handler(&tbtc.ReservationActionTimedOutEvent{ - ReservationKey: reservationKey, - RequestNonce: requestNonce, - ActionType: parsedActionType, - BlockNumber: blockNumber, - }) - } - - return tc.reservationRouter.ReservationActionTimedOutEvent( - nil, - nil, - ).OnEvent(onEvent) -} - -// PastReservationActionTimedOutEvents fetches past -// ReservationActionTimedOut events according to the provided filter or -// unfiltered if the filter is nil. -func (tc *TbtcChain) PastReservationActionTimedOutEvents( - filter *tbtc.ReservationActionTimedOutEventFilter, -) ([]*tbtc.ReservationActionTimedOutEvent, error) { - var startBlock uint64 - var endBlock *uint64 - var reservationKey []*big.Int - - if filter != nil { - startBlock = filter.StartBlock - endBlock = filter.EndBlock - reservationKey = filter.ReservationKey - } - - events, err := tc.reservationRouter.PastReservationActionTimedOutEvents( - startBlock, - endBlock, - reservationKey, - ) - if err != nil { - return nil, err - } - - convertedEvents := make([]*tbtc.ReservationActionTimedOutEvent, 0) - for _, event := range events { - parsedActionType, err := parseReservationActionType(event.ActionType) - if err != nil { - logger.Errorf( - "unexpected reservation action type on past ReservationActionTimedOut event: [%v]", - err, - ) - continue - } - - convertedEvents = append(convertedEvents, &tbtc.ReservationActionTimedOutEvent{ - ReservationKey: event.ReservationKey, - RequestNonce: event.RequestNonce, - ActionType: parsedActionType, - BlockNumber: event.Raw.BlockNumber, - }) - } - - sort.SliceStable(convertedEvents, func(i, j int) bool { - return convertedEvents[i].BlockNumber < convertedEvents[j].BlockNumber - }) - - return convertedEvents, nil -} - -// OnReservationActionSuperseded registers a callback that is invoked when -// an on-chain ReservationActionSuperseded event is seen. -func (tc *TbtcChain) OnReservationActionSuperseded( - handler func(event *tbtc.ReservationActionSupersededEvent), -) subscription.EventSubscription { - onEvent := func( - reservationKey *big.Int, - requestNonce uint64, - blockNumber uint64, - ) { - handler(&tbtc.ReservationActionSupersededEvent{ - ReservationKey: reservationKey, - RequestNonce: requestNonce, - BlockNumber: blockNumber, - }) - } - - return tc.reservationRouter.ReservationActionSupersededEvent( - nil, - nil, - ).OnEvent(onEvent) -} - -// OnReservationLateSettled registers a callback that is invoked when an -// on-chain ReservationLateSettled event is seen. -func (tc *TbtcChain) OnReservationLateSettled( - handler func(event *tbtc.ReservationLateSettledEvent), -) subscription.EventSubscription { - onEvent := func( - reservationKey *big.Int, - requestNonce uint64, - actionType uint8, - blockNumber uint64, - ) { - parsedActionType, err := parseReservationActionType(actionType) - if err != nil { - logger.Errorf( - "unexpected reservation action type on ReservationLateSettled event: [%v]", - err, - ) - return - } - - handler(&tbtc.ReservationLateSettledEvent{ - ReservationKey: reservationKey, - RequestNonce: requestNonce, - ActionType: parsedActionType, - BlockNumber: blockNumber, - }) - } - - return tc.reservationRouter.ReservationLateSettledEvent( - nil, - nil, - ).OnEvent(onEvent) -} - -// OnReservationRetryCreditMinted registers a callback that is invoked when -// an on-chain ReservationRetryCreditMinted event is seen. -func (tc *TbtcChain) OnReservationRetryCreditMinted( - handler func(event *tbtc.ReservationRetryCreditMintedEvent), -) subscription.EventSubscription { - onEvent := func( - reservationKey *big.Int, - blockNumber uint64, - ) { - handler(&tbtc.ReservationRetryCreditMintedEvent{ - ReservationKey: reservationKey, - BlockNumber: blockNumber, - }) - } - - return tc.reservationRouter.ReservationRetryCreditMintedEvent( - nil, - nil, - ).OnEvent(onEvent) -} - -// OnReservedDepositMarkedStale registers a callback that is invoked when -// an on-chain ReservedDepositMarkedStale event is seen. -func (tc *TbtcChain) OnReservedDepositMarkedStale( - handler func(event *tbtc.ReservedDepositMarkedStaleEvent), -) subscription.EventSubscription { - onEvent := func( - depositKey *big.Int, - blockNumber uint64, - ) { - handler(&tbtc.ReservedDepositMarkedStaleEvent{ - DepositKey: depositKey, - BlockNumber: blockNumber, - }) - } - - return tc.reservationRouter.ReservedDepositMarkedStaleEvent( - nil, - nil, - ).OnEvent(onEvent) -} - -// OnReservationStranded registers a callback that is invoked when an -// on-chain ReservationStranded event is seen. -func (tc *TbtcChain) OnReservationStranded( - handler func(event *tbtc.ReservationStrandedEvent), -) subscription.EventSubscription { - onEvent := func( - reservationKey *big.Int, - walletPublicKeyHash [20]byte, - owner common.Address, - anchorAmount uint64, - blockNumber uint64, - ) { - handler(&tbtc.ReservationStrandedEvent{ - ReservationKey: reservationKey, - WalletPublicKeyHash: walletPublicKeyHash, - Owner: chain.Address(owner.Hex()), - AnchorAmount: anchorAmount, - BlockNumber: blockNumber, - }) - } - - return tc.reservationRouter.ReservationStrandedEvent( - nil, - nil, - nil, - nil, - ).OnEvent(onEvent) -} - -// OnReservationParametersUpdated registers a callback that is invoked when -// an on-chain ReservationParametersUpdated event is seen. -func (tc *TbtcChain) OnReservationParametersUpdated( - handler func(event *tbtc.ReservationParametersUpdatedEvent), -) subscription.EventSubscription { - onEvent := func( - reservationMinAmount uint64, - reservationTxMaxFee uint64, - reservationTermSeconds uint32, - reservationDissolutionDelay uint32, - reservationMaxTotalAmount uint64, - maxReservationsPerWallet uint32, - reservationActionTimeout uint32, - reservationRenewalWindowSeconds uint32, - blockNumber uint64, - ) { - handler(&tbtc.ReservationParametersUpdatedEvent{ - ReservationMinAmount: reservationMinAmount, - ReservationTxMaxFee: reservationTxMaxFee, - ReservationTermSeconds: reservationTermSeconds, - ReservationDissolutionDelay: reservationDissolutionDelay, - ReservationMaxTotalAmount: reservationMaxTotalAmount, - MaxReservationsPerWallet: maxReservationsPerWallet, - ReservationActionTimeout: reservationActionTimeout, - ReservationRenewalWindowSeconds: reservationRenewalWindowSeconds, - BlockNumber: blockNumber, - }) - } - - return tc.reservationRouter.ReservationParametersUpdatedEvent( - nil, - ).OnEvent(onEvent) -} - -// OnReservationVaultUpdated registers a callback that is invoked when an -// on-chain ReservationVaultUpdated event is seen. -func (tc *TbtcChain) OnReservationVaultUpdated( - handler func(event *tbtc.ReservationVaultUpdatedEvent), -) subscription.EventSubscription { - onEvent := func( - reservationVault common.Address, - blockNumber uint64, - ) { - handler(&tbtc.ReservationVaultUpdatedEvent{ - ReservationVault: chain.Address(reservationVault.Hex()), - BlockNumber: blockNumber, - }) - } - - return tc.reservationRouter.ReservationVaultUpdatedEvent( - nil, - ).OnEvent(onEvent) -} - -// OnReservationCapsUpdated registers a callback that is invoked when an -// on-chain ReservationCapsUpdated event is seen. -func (tc *TbtcChain) OnReservationCapsUpdated( - handler func(event *tbtc.ReservationCapsUpdatedEvent), -) subscription.EventSubscription { - onEvent := func( - maxReservationsAmountPerWallet uint64, - reservationMaxSingleAmount uint64, - maxActiveReservations uint32, - blockNumber uint64, - ) { - handler(&tbtc.ReservationCapsUpdatedEvent{ - MaxReservationsAmountPerWallet: maxReservationsAmountPerWallet, - ReservationMaxSingleAmount: reservationMaxSingleAmount, - MaxActiveReservations: maxActiveReservations, - BlockNumber: blockNumber, - }) - } - - return tc.reservationRouter.ReservationCapsUpdatedEvent( - nil, - ).OnEvent(onEvent) -} diff --git a/pkg/clientinfo/performance.go b/pkg/clientinfo/performance.go index a10dec6a12..7bcbbd8140 100644 --- a/pkg/clientinfo/performance.go +++ b/pkg/clientinfo/performance.go @@ -783,20 +783,6 @@ func GetAllWalletActionTypes() []string { func GetReservationWalletActionTypes() []string { return []string{ "reservation_anchor", - "reserved_redemption", "reservation_reanchor", - "reservation_dissolution", - } -} - -// isReservationWalletActionType reports whether actionType is one of the -// reservation-specific action types gated by -// PerformanceMetrics.reservationsEnabled. -func isReservationWalletActionType(actionType string) bool { - switch actionType { - case "reservation_anchor", "reserved_redemption", "reservation_reanchor", "reservation_dissolution": - return true - default: - return false } } diff --git a/pkg/clientinfo/performance_test.go b/pkg/clientinfo/performance_test.go index 8281659283..622b1f86e5 100644 --- a/pkg/clientinfo/performance_test.go +++ b/pkg/clientinfo/performance_test.go @@ -445,9 +445,7 @@ func TestWalletActionMetricsRegistered(t *testing.T) { "moving_funds", "moved_funds_sweep", "reservation_anchor", - "reserved_redemption", "reservation_reanchor", - "reservation_dissolution", } for _, actionType := range expectedActionTypes { @@ -498,9 +496,7 @@ func TestWalletActionMetricsNotRegisteredWhenReservationsDisabled(t *testing.T) } reservationActionTypes := []string{ "reservation_anchor", - "reserved_redemption", "reservation_reanchor", - "reservation_dissolution", } for _, actionType := range nonReservationActionTypes { From 51492677cf5c28d1bb4aa19ddb500c36458bc530 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 2 Sep 2026 10:56:21 +0000 Subject: [PATCH 28/39] fix(tbtcpg): rewrite reservation acceptance task stateless Delete the scanState/lastScannedBlock/pendingCandidates cache and rescan the rolling look-back window fresh every Run, matching DepositSweepTask's pattern. Fixes stale deposit snapshots, a cursor that never advanced on the no-candidate path, and unbounded candidate retention with no eviction rule. Also: derive the acceptance RequestNonce from chain state instead of a hardcoded 1 (a hardcoded nonce stalls the deposit forever after a timed-out retry bumps the on-chain nonce); guard against re-requesting acceptance for an already-accepted/settled reservation by checking past request events (fail-closed on RPC error) and reservation state; make the below-minimum check retryable since the minimum is a live chain parameter, and remove its now-duplicated dead copy. --- pkg/tbtcpg/reservation_acceptance.go | 371 +++++----- pkg/tbtcpg/reservation_acceptance_test.go | 798 +++++++++++++++++++++- 2 files changed, 945 insertions(+), 224 deletions(-) diff --git a/pkg/tbtcpg/reservation_acceptance.go b/pkg/tbtcpg/reservation_acceptance.go index b44978c192..62818009f1 100644 --- a/pkg/tbtcpg/reservation_acceptance.go +++ b/pkg/tbtcpg/reservation_acceptance.go @@ -4,8 +4,8 @@ import ( "context" "fmt" "math/big" + "sort" "strings" - "sync" "time" "github.com/ipfs/go-log/v2" @@ -21,24 +21,6 @@ import ( // sweep look-back window: 30 days at 12 seconds per block. const ReservationAcceptanceLookBackBlocks = uint64(216000) -// reservationAcceptanceRequestNonce is the acceptance action generation -// nonce. A reservation does not exist on-chain before its first acceptance -// settles, so the acceptance is always the first action generation -// authorized against a not-yet-created reservation. Every other reservation -// action generation is numbered `reservation.RequestNonce + 1` (see -// ReservationReanchorTask.Run); this constant is that same 1-based -// convention's base case. ReservationAnchorProposal.Unmarshal rejects -// RequestNonce == 0, which is the on-chain confirmation of this convention. -const reservationAcceptanceRequestNonce uint64 = 1 - -// pendingCandidate holds a candidate deposit event and its -// already-fetched request data. -type pendingCandidate struct { - Event *tbtc.DepositRevealedEvent - DepositRequest *tbtc.DepositChainRequest - ReservationParameters *tbtc.ReservationParameters -} - // ReservationAcceptanceTask is a task that may produce a reservation // acceptance (anchor) proposal. It scans the chain for reserved deposits // revealed to the operator's wallet, validates the wallet's eligibility @@ -48,26 +30,6 @@ type pendingCandidate struct { type ReservationAcceptanceTask struct { chain Chain btcChain bitcoin.Chain - - // scanState guards lastScannedBlock and pendingCandidates: Run may be - // invoked concurrently for different wallets (one coordinationExecutor - // goroutine per wallet, sharing this task instance via - // ProposalGenerator). - scanState sync.Mutex - // lastScannedBlock is the block number up to which - // findReservationAcceptanceCandidate has already scanned - // DepositRevealed events for a given wallet, so each call only fetches - // events since the previous call instead of rescanning the full - // ReservationAcceptanceLookBackBlocks window every time. - lastScannedBlock map[[20]byte]uint64 - // pendingCandidates holds, per wallet, the reservation-vault-targeting - // deposit events discovered so far that have not yet resolved (become - // eligible and accepted, or permanently disqualified). A deposit that - // is reserved but not yet mature, or briefly blocked by a full cap, - // must still be reconsidered on a later call even though its block - // falls before the cursor above; keeping it here is what makes the - // cursor safe to advance without losing it. - pendingCandidates map[[20]byte][]*pendingCandidate } // NewReservationAcceptanceTask constructs a ReservationAcceptanceTask. @@ -76,10 +38,8 @@ func NewReservationAcceptanceTask( btcChain bitcoin.Chain, ) *ReservationAcceptanceTask { return &ReservationAcceptanceTask{ - chain: chain, - btcChain: btcChain, - lastScannedBlock: make(map[[20]byte]uint64), - pendingCandidates: make(map[[20]byte][]*pendingCandidate), + chain: chain, + btcChain: btcChain, } } @@ -136,12 +96,14 @@ func (rat *ReservationAcceptanceTask) ActionType() tbtc.WalletActionType { // reservationAcceptanceCandidate is the bundle a candidate reserved deposit // for acceptance carries through the proposal builder. It captures the -// deposit's reveal context plus the on-chain cap snapshot taken at scan time. +// deposit's reveal context, the derived request nonce, plus the on-chain cap +// snapshot taken at scan time. type reservationAcceptanceCandidate struct { Deposit *tbtc.Deposit FundingTx *bitcoin.Transaction ReservationParameters *tbtc.ReservationParameters TxMaxFee uint64 + RequestNonce uint64 } // findReservationAcceptanceCandidate returns the first reserved deposit @@ -167,6 +129,22 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( return nil, nil } + wallet, err := rat.chain.GetWallet(walletPublicKeyHash) + if err != nil { + taskLogger.Errorf( + "failed to load wallet chain data: [%v]", + err, + ) + return nil, nil + } + if wallet.State != tbtc.StateLive { + taskLogger.Infof( + "wallet is not live (state=%v); cannot accept reservation", + wallet.State, + ) + return nil, nil + } + blockCounter, err := rat.chain.BlockCounter() if err != nil { return nil, fmt.Errorf("failed to get block counter: [%w]", err) @@ -179,20 +157,6 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( ) } - candidateEvents, err := rat.scanForCandidateEvents( - walletPublicKeyHash, - currentBlock, - reservationVault, - reservationParameters, - ) - if err != nil { - return nil, err - } - - if len(candidateEvents) == 0 { - return nil, nil - } - maxReservationsAmountPerWallet, reservationMaxSingleAmount, err := rat.chain.ReservationCaps() if err != nil { @@ -240,58 +204,68 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( } depositMinAge := time.Duration(depositMinAgeSeconds) * time.Second - now := time.Now() + filterStartBlock := uint64(0) + if currentBlock > ReservationAcceptanceLookBackBlocks { + filterStartBlock = currentBlock - ReservationAcceptanceLookBackBlocks + } + filter := &tbtc.DepositRevealedEventFilter{ + StartBlock: filterStartBlock, + EndBlock: ¤tBlock, + WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + } - var ( - found *reservationAcceptanceCandidate - stillUnresolved []*pendingCandidate - ) + depositRevealedEvents, err := rat.chain.PastDepositRevealedEvents(filter) + if err != nil { + return nil, fmt.Errorf( + "failed to get past deposit revealed events: [%w]", + err, + ) + } + + // Take the oldest first. + sort.SliceStable(depositRevealedEvents, func(i, j int) bool { + return depositRevealedEvents[i].BlockNumber < depositRevealedEvents[j].BlockNumber + }) - for _, p := range candidateEvents { - if found != nil { - stillUnresolved = append(stillUnresolved, p) + now := time.Now() + + for _, event := range depositRevealedEvents { + if !depositTargetsReservationVault(event.Vault, reservationVault) { continue } - event := p.Event depositKey := rat.chain.BuildDepositKey( event.FundingTxHash, event.FundingOutputIndex, ) - var depositRequest *tbtc.DepositChainRequest - if p.DepositRequest != nil { - depositRequest = p.DepositRequest - } else { - var foundRequest bool - var err error - depositRequest, foundRequest, err = rat.chain.GetDepositRequest( - event.FundingTxHash, - event.FundingOutputIndex, + depositRequest, foundRequest, err := rat.chain.GetDepositRequest( + event.FundingTxHash, + event.FundingOutputIndex, + ) + if err != nil { + taskLogger.Errorf( + "failed to get deposit request for [%v]: [%v]", + depositKey, + err, ) - if err != nil { - taskLogger.Errorf( - "failed to get deposit request for [%v]: [%v]", - depositKey, - err, - ) - stillUnresolved = append(stillUnresolved, p) - continue - } - if !foundRequest { - taskLogger.Warnf( - "no deposit request for reserved deposit [%v]", - depositKey, - ) - stillUnresolved = append(stillUnresolved, p) - continue - } - p.DepositRequest = depositRequest + continue + } + if !foundRequest { + taskLogger.Warnf( + "no deposit request for reserved deposit [%v]", + depositKey, + ) + continue } - // #6: Permanently drop candidate if amount is below minimum. - if depositRequest.Amount < p.ReservationParameters.ReservationMinAmount { - taskLogger.Infof("deposit [%v] amount [%d] below minimum; dropping", depositKey, depositRequest.Amount) + if depositRequest.Amount < reservationParameters.ReservationMinAmount { + taskLogger.Infof( + "reserved deposit [%v] amount [%d] below minimum [%d]; skipping", + depositKey, + depositRequest.Amount, + reservationParameters.ReservationMinAmount, + ) continue } @@ -302,7 +276,6 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( depositKey, now, matureAt, ) - stillUnresolved = append(stillUnresolved, p) continue } @@ -314,14 +287,8 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( continue } - candidate := &reservationAcceptanceCandidate{ - ReservationParameters: reservationParameters, - TxMaxFee: reservationParameters.ReservationTxMaxFee, - } - - if !rat.checkReservationAcceptanceEligibility( + if !checkReservationAcceptanceEligibility( taskLogger, - walletPublicKeyHash, depositRequest, walletReservationsCount, walletReservationsAmount, @@ -332,7 +299,6 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( reservationParameters, ) { taskLogger.Infof("not eligible: [%v]", depositKey) - stillUnresolved = append(stillUnresolved, p) continue } @@ -343,7 +309,6 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( depositKey, err, ) - stillUnresolved = append(stillUnresolved, p) continue } @@ -357,7 +322,6 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( depositKey, err, ) - stillUnresolved = append(stillUnresolved, p) continue } if confirmations < tbtc.DepositSweepRequiredFundingTxConfirmations { @@ -367,96 +331,99 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( confirmations, tbtc.DepositSweepRequiredFundingTxConfirmations, ) - stillUnresolved = append(stillUnresolved, p) continue } - candidate.Deposit = &tbtc.Deposit{ - Utxo: &bitcoin.UnspentTransactionOutput{ - Outpoint: &bitcoin.TransactionOutpoint{ - TransactionHash: event.FundingTxHash, - OutputIndex: event.FundingOutputIndex, - }, - Value: int64(depositRequest.Amount), + // Third fix (a): check for existing acceptance requested events + // and fail closed on error. + acceptanceEvents, err := rat.chain.PastReservationAcceptanceRequestedEvents( + &tbtc.ReservationAcceptanceRequestedEventFilter{ + ReservationKey: []*big.Int{depositKey}, }, - Depositor: depositRequest.Depositor, - BlindingFactor: event.BlindingFactor, - WalletPublicKeyHash: event.WalletPublicKeyHash, - RefundPublicKeyHash: event.RefundPublicKeyHash, - RefundLocktime: event.RefundLocktime, - Vault: depositRequest.Vault, - ExtraData: depositRequest.ExtraData, - } - candidate.FundingTx = fundingTx - - taskLogger.Infof( - "selected reserved deposit [%v] for acceptance", - depositKey, ) + if err != nil { + taskLogger.Errorf( + "failed to get past reservation acceptance requested events for [%v]: [%v]", + depositKey, + err, + ) + continue + } + if len(acceptanceEvents) > 0 { + taskLogger.Infof( + "reservation [%v] already has acceptance requested event(s), skipping", + depositKey, + ) + continue + } - found = candidate - stillUnresolved = append(stillUnresolved, p) - } - - rat.scanState.Lock() - rat.pendingCandidates[walletPublicKeyHash] = stillUnresolved - rat.lastScannedBlock[walletPublicKeyHash] = currentBlock - rat.scanState.Unlock() + // Second & Third fix: check reservation state and derive RequestNonce. + var requestNonce uint64 = 1 + reservation, err := rat.chain.GetReservation(depositKey) + if err != nil { + taskLogger.Debugf( + "cannot get reservation [%v] (assuming not yet created): [%v]", + depositKey, + err, + ) + } else if reservation != nil { + if reservation.State == tbtc.ReservationStateActive || + reservation.State == tbtc.ReservationStateActionPending || + reservation.State == tbtc.ReservationStateClosed || + reservation.State == tbtc.ReservationStateStranded { + taskLogger.Infof( + "reservation [%v] in non-eligible state [%v], skipping", + depositKey, + reservation.State, + ) + continue + } - return found, nil -} + if hasPendingAction(depositKey, reservation, rat.chain, taskLogger) { + taskLogger.Infof( + "reservation [%v] has pending action, skipping", + depositKey, + ) + continue + } -func (rat *ReservationAcceptanceTask) scanForCandidateEvents( - walletPublicKeyHash [20]byte, - currentBlock uint64, - reservationVault chain.Address, - reservationParameters *tbtc.ReservationParameters, -) ([]*pendingCandidate, error) { - rat.scanState.Lock() - lastScanned := rat.lastScannedBlock[walletPublicKeyHash] - pending := rat.pendingCandidates[walletPublicKeyHash] - rat.scanState.Unlock() - - startBlock := lastScanned + 1 - if lastScanned == 0 { - startBlock = 0 - if currentBlock > ReservationAcceptanceLookBackBlocks { - startBlock = currentBlock - ReservationAcceptanceLookBackBlocks + requestNonce = reservation.RequestNonce + 1 } - } - filter := &tbtc.DepositRevealedEventFilter{ - StartBlock: startBlock, - EndBlock: ¤tBlock, - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - } - revealedEvents, err := rat.chain.PastDepositRevealedEvents(filter) - if err != nil { - return nil, fmt.Errorf( - "failed to get past deposit revealed events: [%w]", - err, + taskLogger.Infof( + "selected reserved deposit [%v] for acceptance", + depositKey, ) - } - candidates := make([]*pendingCandidate, 0, len(pending)+len(revealedEvents)) - for _, p := range pending { - candidates = append(candidates, p) - } - for _, event := range revealedEvents { - if !depositTargetsReservationVault(event.Vault, reservationVault) { - continue - } - candidates = append(candidates, &pendingCandidate{ - Event: event, + + return &reservationAcceptanceCandidate{ + Deposit: &tbtc.Deposit{ + Utxo: &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: event.FundingTxHash, + OutputIndex: event.FundingOutputIndex, + }, + Value: int64(depositRequest.Amount), + }, + Depositor: depositRequest.Depositor, + BlindingFactor: event.BlindingFactor, + WalletPublicKeyHash: event.WalletPublicKeyHash, + RefundPublicKeyHash: event.RefundPublicKeyHash, + RefundLocktime: event.RefundLocktime, + Vault: depositRequest.Vault, + ExtraData: depositRequest.ExtraData, + }, + FundingTx: fundingTx, ReservationParameters: reservationParameters, - }) + TxMaxFee: reservationParameters.ReservationTxMaxFee, + RequestNonce: requestNonce, + }, nil } - return candidates, nil + return nil, nil } -func (rat *ReservationAcceptanceTask) checkReservationAcceptanceEligibility( +func checkReservationAcceptanceEligibility( taskLogger log.StandardLogger, - walletPublicKeyHash [20]byte, depositRequest *tbtc.DepositChainRequest, walletReservationsCount uint32, walletReservationsAmount uint64, @@ -466,22 +433,6 @@ func (rat *ReservationAcceptanceTask) checkReservationAcceptanceEligibility( reservationMaxSingleAmount uint64, reservationParameters *tbtc.ReservationParameters, ) bool { - wallet, err := rat.chain.GetWallet(walletPublicKeyHash) - if err != nil { - taskLogger.Errorf( - "failed to load wallet chain data: [%v]", - err, - ) - return false - } - if wallet.State != tbtc.StateLive { - taskLogger.Infof( - "wallet is not live (state=%v); cannot accept reservation", - wallet.State, - ) - return false - } - if reservationParameters.MaxReservationsPerWallet > 0 && walletReservationsCount >= reservationParameters.MaxReservationsPerWallet { taskLogger.Infof( @@ -502,15 +453,6 @@ func (rat *ReservationAcceptanceTask) checkReservationAcceptanceEligibility( return false } - if depositRequest.Amount < reservationParameters.ReservationMinAmount { - taskLogger.Infof( - "deposit amount [%d] below reservation min [%d]", - depositRequest.Amount, - reservationParameters.ReservationMinAmount, - ) - return false - } - if reservationMaxSingleAmount > 0 && depositRequest.Amount > reservationMaxSingleAmount { taskLogger.Infof( @@ -615,7 +557,7 @@ func (rat *ReservationAcceptanceTask) proposeReservationAcceptance( proposal := &tbtc.ReservationAnchorProposal{ DepositFundingTxHash: candidate.Deposit.Utxo.Outpoint.TransactionHash, DepositFundingOutputIndex: candidate.Deposit.Utxo.Outpoint.OutputIndex, - RequestNonce: reservationAcceptanceRequestNonce, + RequestNonce: candidate.RequestNonce, AnchorTxFee: big.NewInt(anchorFee), } @@ -638,14 +580,13 @@ func (rat *ReservationAcceptanceTask) proposeReservationAcceptance( ) } - reservation, err := rat.chain.GetReservation(reservationKey) - if err != nil { - taskLogger.Errorf("cannot get reservation [0x%x]: [%v]", reservationKey, err) - } else if hasPendingAction(reservationKey, reservation, rat.chain, taskLogger) { - taskLogger.Infof("reservation [0x%x] has pending action, skipping", reservationKey) - return nil, false, nil - } - + // Fifth fix: Note that RequestReservationAcceptance is called as a + // side effect of proposal generation itself (before coordination has + // agreed to anything), which is a known, accepted deviation from the + // read-only-during-generation pattern (also present in MovingFundsTask's + // SubmitMovingFundsCommitment). The guards against re-requesting + // acceptance for existing or pending reservations are the primary + // mitigation for spurious repeat writes. if err := rat.chain.RequestReservationAcceptance( reservationKey, walletPublicKeyHash, diff --git a/pkg/tbtcpg/reservation_acceptance_test.go b/pkg/tbtcpg/reservation_acceptance_test.go index 94335f73d3..1a40bbc5c5 100644 --- a/pkg/tbtcpg/reservation_acceptance_test.go +++ b/pkg/tbtcpg/reservation_acceptance_test.go @@ -33,6 +33,8 @@ type reservationAcceptanceLocalChain struct { reservedDeposits map[string]bool validateErr error getWalletErr error + acceptanceEvents []*tbtc.ReservationAcceptanceRequestedEvent + acceptanceEventsErr error } func newReservationAcceptanceLocalChain() *reservationAcceptanceLocalChain { @@ -130,6 +132,37 @@ func (ralc *reservationAcceptanceLocalChain) ValidateReservationAnchorProposal( return ralc.validateErr } +func (ralc *reservationAcceptanceLocalChain) PastReservationAcceptanceRequestedEvents( + filter *tbtc.ReservationAcceptanceRequestedEventFilter, +) ([]*tbtc.ReservationAcceptanceRequestedEvent, error) { + if ralc.acceptanceEventsErr != nil { + return nil, ralc.acceptanceEventsErr + } + var results []*tbtc.ReservationAcceptanceRequestedEvent + for _, event := range ralc.acceptanceEvents { + if filter != nil && len(filter.ReservationKey) > 0 { + match := false + for _, k := range filter.ReservationKey { + if k != nil && event.ReservationKey != nil && k.Cmp(event.ReservationKey) == 0 { + match = true + break + } + } + if !match { + continue + } + } + results = append(results, event) + } + return results, nil +} + +func (ralc *reservationAcceptanceLocalChain) AddPastReservationAcceptanceRequestedEvent( + event *tbtc.ReservationAcceptanceRequestedEvent, +) { + ralc.acceptanceEvents = append(ralc.acceptanceEvents, event) +} + // scenarioReservationAcceptanceChain wires a scenario's on-chain state // into the test mock chain. func scenarioReservationAcceptanceChain( @@ -455,8 +488,9 @@ func TestReservationAcceptanceTask_NoCandidates(t *testing.T) { &tbtc.WalletChainData{State: tbtc.StateLive}, ) + currentBlock := uint64(300000) blockCounter := tbtcpg.NewMockBlockCounter() - blockCounter.SetCurrentBlock(300000) + blockCounter.SetCurrentBlock(currentBlock) ralc.SetBlockCounter(blockCounter) task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) @@ -619,7 +653,7 @@ func TestReservationAcceptanceTask_BoundedLookback(t *testing.T) { } // TestReservationAcceptanceTask_DepositNotReserved confirms that a deposit -// that fails IsReservedDeposit is filtered out. +// that does not target the reservation vault is filtered out. func TestReservationAcceptanceTask_DepositNotReserved(t *testing.T) { ralc := newReservationAcceptanceLocalChain() btcChain := tbtcpg.NewLocalBitcoinChain() @@ -662,11 +696,20 @@ func TestReservationAcceptanceTask_DepositNotReserved(t *testing.T) { &tbtc.DepositChainRequest{ Amount: 2000000, RevealedAt: time.Now().Add(-2 * time.Hour), + SweptAt: time.Unix(0, 0), }, ) + + filterStartBlock := uint64(0) + if currentBlock > tbtcpg.ReservationAcceptanceLookBackBlocks { + filterStartBlock = currentBlock - tbtcpg.ReservationAcceptanceLookBackBlocks + } + + // Set event vault away from the configured reservation vault so it + // actually exercises "not reserved". if err := ralc.AddPastDepositRevealedEvent( &tbtc.DepositRevealedEventFilter{ - StartBlock: 0, + StartBlock: filterStartBlock, EndBlock: ¤tBlock, WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, }, @@ -676,7 +719,7 @@ func TestReservationAcceptanceTask_DepositNotReserved(t *testing.T) { FundingTxHash: fundingTxHash, FundingOutputIndex: 0, Vault: &[]chain.Address{chain.Address( - "0xReservationVaultAddress1234567890abcdef12345678", + "0xOtherVaultAddress1234567890abcdef12345678901234", )}[0], }, ); err != nil { @@ -702,11 +745,9 @@ func TestReservationAcceptanceTask_DepositNotReserved(t *testing.T) { } // TestReservationAcceptanceTask_GetWalletError exercises the GetWallet -// error passthrough inside checkReservationAcceptanceEligibility: a +// error passthrough inside findReservationAcceptanceCandidate: a // reserved deposit candidate is discovered and matches the reservation -// vault, but the candidate wallet's chain data fails to load. Every sibling -// watcher test file in this PR includes this exact chain-error passthrough -// shape for the analogous call. +// vault, but the candidate wallet's chain data fails to load. func TestReservationAcceptanceTask_GetWalletError(t *testing.T) { ralc := newReservationAcceptanceLocalChain() btcChain := tbtcpg.NewLocalBitcoinChain() @@ -751,6 +792,7 @@ func TestReservationAcceptanceTask_GetWalletError(t *testing.T) { &tbtc.DepositChainRequest{ Amount: 2000000, RevealedAt: time.Now().Add(-2 * time.Hour), + SweptAt: time.Unix(0, 0), Vault: &[]chain.Address{chain.Address( "0xReservationVaultAddress1234567890abcdef12345678", )}[0], @@ -759,9 +801,14 @@ func TestReservationAcceptanceTask_GetWalletError(t *testing.T) { depositKey := ralc.BuildDepositKey(fundingTxHash, 0) ralc.reservedDeposits[depositKey.Text(16)] = true + filterStartBlock := uint64(0) + if currentBlock > tbtcpg.ReservationAcceptanceLookBackBlocks { + filterStartBlock = currentBlock - tbtcpg.ReservationAcceptanceLookBackBlocks + } + if err := ralc.AddPastDepositRevealedEvent( &tbtc.DepositRevealedEventFilter{ - StartBlock: 0, + StartBlock: filterStartBlock, EndBlock: ¤tBlock, WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, }, @@ -793,3 +840,736 @@ func TestReservationAcceptanceTask_GetWalletError(t *testing.T) { t.Errorf("expected no proposal, got %v", proposal) } } + +// TestReservationAcceptanceTask_Stateless_Maturity verifies the stateless +// observable contract across two consecutive Run calls on the same task instance: +// an immature candidate is skipped on the first run, but when time advances and +// the candidate matures, the second run on the same task instance proposes it +// without any cache-state interference. +func TestReservationAcceptanceTask_Stateless_Maturity(t *testing.T) { + ralc := newReservationAcceptanceLocalChain() + btcChain := tbtcpg.NewLocalBitcoinChain() + + walletPublicKeyHash := hexToByte20( + "8db50eb52063ea9d98b3eac91489a90f738986f6", + ) + + ralc.reservationParameters = &tbtc.ReservationParameters{ + ReservationVault: chain.Address( + "0xReservationVaultAddress1234567890abcdef12345678", + ), + ReservationMinAmount: 1000, + ReservationTxMaxFee: 5000, + MaxReservationsPerWallet: 5, + } + ralc.maxPerWalletAmount = 5000000 + ralc.maxSingleAmount = 5000000 + ralc.maxActive = 100 + + ralc.SetDepositMinAge(3600) + ralc.SetWallet( + walletPublicKeyHash, + &tbtc.WalletChainData{State: tbtc.StateLive}, + ) + + currentBlock := uint64(300000) + blockCounter := tbtcpg.NewMockBlockCounter() + blockCounter.SetCurrentBlock(currentBlock) + ralc.SetBlockCounter(blockCounter) + + fundingTxHash := hashFromString( + "5555555555555555555555555555555555555555555555555555555555555555", + ) + dummyTx := &bitcoin.Transaction{ + Outputs: []*bitcoin.TransactionOutput{{ + Value: 0, + PublicKeyScript: append([]byte{0x00, 0x20}, make([]byte, 32)...), + }}, + } + btcChain.SetTransaction(fundingTxHash, dummyTx) + btcChain.SetEstimateSatPerVByteFee(1, 1) + btcChain.SetTransactionConfirmations( + fundingTxHash, + tbtc.DepositSweepRequiredFundingTxConfirmations, + ) + + // Candidate revealed only 10 minutes ago (depositMinAge is 1 hour). + ralc.SetDepositRequest( + fundingTxHash, + 0, + &tbtc.DepositChainRequest{ + Depositor: chain.Address("934b98637ca318a4d6e7ca6ffd1690b8e77df637"), + Amount: 2000000, + RevealedAt: time.Now().Add(-10 * time.Minute), + SweptAt: time.Unix(0, 0), + Vault: &[]chain.Address{chain.Address( + "0xReservationVaultAddress1234567890abcdef12345678", + )}[0], + }, + ) + + filterStartBlock := uint64(0) + if currentBlock > tbtcpg.ReservationAcceptanceLookBackBlocks { + filterStartBlock = currentBlock - tbtcpg.ReservationAcceptanceLookBackBlocks + } + + if err := ralc.AddPastDepositRevealedEvent( + &tbtc.DepositRevealedEventFilter{ + StartBlock: filterStartBlock, + EndBlock: ¤tBlock, + WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + }, + &tbtc.DepositRevealedEvent{ + BlockNumber: 290000, + WalletPublicKeyHash: walletPublicKeyHash, + FundingTxHash: fundingTxHash, + FundingOutputIndex: 0, + Vault: &[]chain.Address{chain.Address( + "0xReservationVaultAddress1234567890abcdef12345678", + )}[0], + }, + ); err != nil { + t.Fatal(err) + } + + task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + request := &tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + } + + // First run: deposit is immature, should not be proposed. + proposal, shouldExecute, err := task.Run(request) + if err != nil { + t.Fatalf("first run error: [%v]", err) + } + if shouldExecute || proposal != nil { + t.Fatalf("expected no proposal on first run for immature deposit") + } + + // Advance deposit age (simulating passage of time to 2 hours ago). + ralc.SetDepositRequest( + fundingTxHash, + 0, + &tbtc.DepositChainRequest{ + Depositor: chain.Address("934b98637ca318a4d6e7ca6ffd1690b8e77df637"), + Amount: 2000000, + RevealedAt: time.Now().Add(-2 * time.Hour), + SweptAt: time.Unix(0, 0), + Vault: &[]chain.Address{chain.Address( + "0xReservationVaultAddress1234567890abcdef12345678", + )}[0], + }, + ) + + // Second run on the same task instance: deposit is now mature and proposed. + proposal, shouldExecute, err = task.Run(request) + if err != nil { + t.Fatalf("second run error: [%v]", err) + } + if !shouldExecute || proposal == nil { + t.Fatalf("expected proposal on second run after deposit matured") + } + + actualProposal, ok := proposal.(*tbtc.ReservationAnchorProposal) + if !ok { + t.Fatalf("expected *ReservationAnchorProposal, got %T", proposal) + } + if actualProposal.DepositFundingTxHash != fundingTxHash { + t.Errorf( + "unexpected deposit funding tx hash\nexpected: %s\nactual: %s", + fundingTxHash.Hex(bitcoin.ReversedByteOrder), + actualProposal.DepositFundingTxHash.Hex(bitcoin.ReversedByteOrder), + ) + } +} + +// TestReservationAcceptanceTask_Stateless_NoReRequest verifies that once a +// reservation has an existing acceptance requested event, subsequent Run calls +// on the same task instance do not produce a duplicate acceptance proposal. +func TestReservationAcceptanceTask_Stateless_NoReRequest(t *testing.T) { + ralc := newReservationAcceptanceLocalChain() + btcChain := tbtcpg.NewLocalBitcoinChain() + + walletPublicKeyHash := hexToByte20( + "8db50eb52063ea9d98b3eac91489a90f738986f6", + ) + + ralc.reservationParameters = &tbtc.ReservationParameters{ + ReservationVault: chain.Address( + "0xReservationVaultAddress1234567890abcdef12345678", + ), + ReservationMinAmount: 1000, + ReservationTxMaxFee: 5000, + MaxReservationsPerWallet: 5, + } + ralc.maxPerWalletAmount = 5000000 + ralc.maxSingleAmount = 5000000 + ralc.maxActive = 100 + + ralc.SetDepositMinAge(3600) + ralc.SetWallet( + walletPublicKeyHash, + &tbtc.WalletChainData{State: tbtc.StateLive}, + ) + + currentBlock := uint64(300000) + blockCounter := tbtcpg.NewMockBlockCounter() + blockCounter.SetCurrentBlock(currentBlock) + ralc.SetBlockCounter(blockCounter) + + fundingTxHash := hashFromString( + "6666666666666666666666666666666666666666666666666666666666666666", + ) + dummyTx := &bitcoin.Transaction{ + Outputs: []*bitcoin.TransactionOutput{{ + Value: 0, + PublicKeyScript: append([]byte{0x00, 0x20}, make([]byte, 32)...), + }}, + } + btcChain.SetTransaction(fundingTxHash, dummyTx) + btcChain.SetEstimateSatPerVByteFee(1, 1) + btcChain.SetTransactionConfirmations( + fundingTxHash, + tbtc.DepositSweepRequiredFundingTxConfirmations, + ) + + ralc.SetDepositRequest( + fundingTxHash, + 0, + &tbtc.DepositChainRequest{ + Depositor: chain.Address("934b98637ca318a4d6e7ca6ffd1690b8e77df637"), + Amount: 2000000, + RevealedAt: time.Now().Add(-2 * time.Hour), + SweptAt: time.Unix(0, 0), + Vault: &[]chain.Address{chain.Address( + "0xReservationVaultAddress1234567890abcdef12345678", + )}[0], + }, + ) + + filterStartBlock := uint64(0) + if currentBlock > tbtcpg.ReservationAcceptanceLookBackBlocks { + filterStartBlock = currentBlock - tbtcpg.ReservationAcceptanceLookBackBlocks + } + + if err := ralc.AddPastDepositRevealedEvent( + &tbtc.DepositRevealedEventFilter{ + StartBlock: filterStartBlock, + EndBlock: ¤tBlock, + WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + }, + &tbtc.DepositRevealedEvent{ + BlockNumber: 290000, + WalletPublicKeyHash: walletPublicKeyHash, + FundingTxHash: fundingTxHash, + FundingOutputIndex: 0, + Vault: &[]chain.Address{chain.Address( + "0xReservationVaultAddress1234567890abcdef12345678", + )}[0], + }, + ); err != nil { + t.Fatal(err) + } + + task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + request := &tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + } + + // First run: deposit is eligible and proposed. + proposal, shouldExecute, err := task.Run(request) + if err != nil { + t.Fatalf("first run error: [%v]", err) + } + if !shouldExecute || proposal == nil { + t.Fatalf("expected proposal on first run") + } + + // Simulate on-chain record: mark reservation as having an acceptance + // requested event. + depositKey := ralc.BuildDepositKey(fundingTxHash, 0) + ralc.AddPastReservationAcceptanceRequestedEvent(&tbtc.ReservationAcceptanceRequestedEvent{ + ReservationKey: depositKey, + RequestNonce: 1, + WalletPublicKeyHash: walletPublicKeyHash, + }) + + // Second run on the same task instance: must not produce a second proposal. + proposal, shouldExecute, err = task.Run(request) + if err != nil { + t.Fatalf("second run error: [%v]", err) + } + if shouldExecute || proposal != nil { + t.Fatalf("expected no proposal on second run due to existing acceptance event") + } +} + +// TestReservationAcceptanceTask_Stateless_PastEventsError verifies that an +// RPC failure querying past acceptance requested events fails closed (skips candidate). +func TestReservationAcceptanceTask_Stateless_PastEventsError(t *testing.T) { + ralc := newReservationAcceptanceLocalChain() + btcChain := tbtcpg.NewLocalBitcoinChain() + + walletPublicKeyHash := hexToByte20( + "8db50eb52063ea9d98b3eac91489a90f738986f6", + ) + + ralc.reservationParameters = &tbtc.ReservationParameters{ + ReservationVault: chain.Address( + "0xReservationVaultAddress1234567890abcdef12345678", + ), + ReservationMinAmount: 1000, + ReservationTxMaxFee: 5000, + MaxReservationsPerWallet: 5, + } + ralc.maxPerWalletAmount = 5000000 + ralc.maxSingleAmount = 5000000 + ralc.maxActive = 100 + + ralc.SetDepositMinAge(3600) + ralc.SetWallet( + walletPublicKeyHash, + &tbtc.WalletChainData{State: tbtc.StateLive}, + ) + + currentBlock := uint64(300000) + blockCounter := tbtcpg.NewMockBlockCounter() + blockCounter.SetCurrentBlock(currentBlock) + ralc.SetBlockCounter(blockCounter) + + fundingTxHash := hashFromString( + "7777777777777777777777777777777777777777777777777777777777777777", + ) + dummyTx := &bitcoin.Transaction{ + Outputs: []*bitcoin.TransactionOutput{{ + Value: 0, + PublicKeyScript: append([]byte{0x00, 0x20}, make([]byte, 32)...), + }}, + } + btcChain.SetTransaction(fundingTxHash, dummyTx) + btcChain.SetEstimateSatPerVByteFee(1, 1) + btcChain.SetTransactionConfirmations( + fundingTxHash, + tbtc.DepositSweepRequiredFundingTxConfirmations, + ) + + ralc.SetDepositRequest( + fundingTxHash, + 0, + &tbtc.DepositChainRequest{ + Amount: 2000000, + RevealedAt: time.Now().Add(-2 * time.Hour), + SweptAt: time.Unix(0, 0), + Vault: &[]chain.Address{chain.Address( + "0xReservationVaultAddress1234567890abcdef12345678", + )}[0], + }, + ) + + filterStartBlock := uint64(0) + if currentBlock > tbtcpg.ReservationAcceptanceLookBackBlocks { + filterStartBlock = currentBlock - tbtcpg.ReservationAcceptanceLookBackBlocks + } + + if err := ralc.AddPastDepositRevealedEvent( + &tbtc.DepositRevealedEventFilter{ + StartBlock: filterStartBlock, + EndBlock: ¤tBlock, + WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + }, + &tbtc.DepositRevealedEvent{ + BlockNumber: 290000, + WalletPublicKeyHash: walletPublicKeyHash, + FundingTxHash: fundingTxHash, + FundingOutputIndex: 0, + Vault: &[]chain.Address{chain.Address( + "0xReservationVaultAddress1234567890abcdef12345678", + )}[0], + }, + ); err != nil { + t.Fatal(err) + } + + // Force an error on PastReservationAcceptanceRequestedEvents. + ralc.acceptanceEventsErr = fmt.Errorf("rpc failure") + + task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + request := &tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + } + + proposal, shouldExecute, err := task.Run(request) + if err != nil { + t.Fatalf("unexpected task error: [%v]", err) + } + if shouldExecute || proposal != nil { + t.Fatalf("expected candidate to be skipped when past events check fails closed") + } +} + +// TestReservationAcceptanceTask_Stateless_NonEligibleReservationState verifies that +// a reservation whose on-chain state is Active, ActionPending, Closed, or Stranded +// is skipped from acceptance proposals. +func TestReservationAcceptanceTask_Stateless_NonEligibleReservationState(t *testing.T) { + nonEligibleStates := []tbtc.ReservationState{ + tbtc.ReservationStateActive, + tbtc.ReservationStateActionPending, + tbtc.ReservationStateClosed, + tbtc.ReservationStateStranded, + } + + for _, state := range nonEligibleStates { + t.Run(fmt.Sprintf("state_%v", state), func(t *testing.T) { + ralc := newReservationAcceptanceLocalChain() + btcChain := tbtcpg.NewLocalBitcoinChain() + + walletPublicKeyHash := hexToByte20( + "8db50eb52063ea9d98b3eac91489a90f738986f6", + ) + + ralc.reservationParameters = &tbtc.ReservationParameters{ + ReservationVault: chain.Address( + "0xReservationVaultAddress1234567890abcdef12345678", + ), + ReservationMinAmount: 1000, + ReservationTxMaxFee: 5000, + MaxReservationsPerWallet: 5, + } + ralc.maxPerWalletAmount = 5000000 + ralc.maxSingleAmount = 5000000 + ralc.maxActive = 100 + + ralc.SetDepositMinAge(3600) + ralc.SetWallet( + walletPublicKeyHash, + &tbtc.WalletChainData{State: tbtc.StateLive}, + ) + + currentBlock := uint64(300000) + blockCounter := tbtcpg.NewMockBlockCounter() + blockCounter.SetCurrentBlock(currentBlock) + ralc.SetBlockCounter(blockCounter) + + fundingTxHash := hashFromString( + "8888888888888888888888888888888888888888888888888888888888888888", + ) + dummyTx := &bitcoin.Transaction{ + Outputs: []*bitcoin.TransactionOutput{{ + Value: 0, + PublicKeyScript: append([]byte{0x00, 0x20}, make([]byte, 32)...), + }}, + } + btcChain.SetTransaction(fundingTxHash, dummyTx) + btcChain.SetEstimateSatPerVByteFee(1, 1) + btcChain.SetTransactionConfirmations( + fundingTxHash, + tbtc.DepositSweepRequiredFundingTxConfirmations, + ) + + ralc.SetDepositRequest( + fundingTxHash, + 0, + &tbtc.DepositChainRequest{ + Amount: 2000000, + RevealedAt: time.Now().Add(-2 * time.Hour), + SweptAt: time.Unix(0, 0), + Vault: &[]chain.Address{chain.Address( + "0xReservationVaultAddress1234567890abcdef12345678", + )}[0], + }, + ) + + filterStartBlock := uint64(0) + if currentBlock > tbtcpg.ReservationAcceptanceLookBackBlocks { + filterStartBlock = currentBlock - tbtcpg.ReservationAcceptanceLookBackBlocks + } + + if err := ralc.AddPastDepositRevealedEvent( + &tbtc.DepositRevealedEventFilter{ + StartBlock: filterStartBlock, + EndBlock: ¤tBlock, + WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + }, + &tbtc.DepositRevealedEvent{ + BlockNumber: 290000, + WalletPublicKeyHash: walletPublicKeyHash, + FundingTxHash: fundingTxHash, + FundingOutputIndex: 0, + Vault: &[]chain.Address{chain.Address( + "0xReservationVaultAddress1234567890abcdef12345678", + )}[0], + }, + ); err != nil { + t.Fatal(err) + } + + depositKey := ralc.BuildDepositKey(fundingTxHash, 0) + ralc.SetReservation(depositKey, &tbtc.Reservation{ + State: state, + RequestNonce: 1, + }) + + task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + request := &tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + } + + proposal, shouldExecute, err := task.Run(request) + if err != nil { + t.Fatalf("unexpected task error: [%v]", err) + } + if shouldExecute || proposal != nil { + t.Fatalf("expected candidate with state %v to be skipped", state) + } + }) + } +} + +// TestReservationAcceptanceTask_Stateless_DynamicMinAmount verifies that the +// minimum-amount filter is retryable: a deposit below minimum on Run 1 is skipped, +// but when governance lowers the minimum amount, Run 2 on the same task instance +// proposes it. +func TestReservationAcceptanceTask_Stateless_DynamicMinAmount(t *testing.T) { + ralc := newReservationAcceptanceLocalChain() + btcChain := tbtcpg.NewLocalBitcoinChain() + + walletPublicKeyHash := hexToByte20( + "8db50eb52063ea9d98b3eac91489a90f738986f6", + ) + + // Initial min amount is 5,000,000. + ralc.reservationParameters = &tbtc.ReservationParameters{ + ReservationVault: chain.Address( + "0xReservationVaultAddress1234567890abcdef12345678", + ), + ReservationMinAmount: 5000000, + ReservationTxMaxFee: 5000, + MaxReservationsPerWallet: 5, + } + ralc.maxPerWalletAmount = 50000000 + ralc.maxSingleAmount = 50000000 + ralc.maxActive = 100 + + ralc.SetDepositMinAge(3600) + ralc.SetWallet( + walletPublicKeyHash, + &tbtc.WalletChainData{State: tbtc.StateLive}, + ) + + currentBlock := uint64(300000) + blockCounter := tbtcpg.NewMockBlockCounter() + blockCounter.SetCurrentBlock(currentBlock) + ralc.SetBlockCounter(blockCounter) + + fundingTxHash := hashFromString( + "9999999999999999999999999999999999999999999999999999999999999999", + ) + dummyTx := &bitcoin.Transaction{ + Outputs: []*bitcoin.TransactionOutput{{ + Value: 0, + PublicKeyScript: append([]byte{0x00, 0x20}, make([]byte, 32)...), + }}, + } + btcChain.SetTransaction(fundingTxHash, dummyTx) + btcChain.SetEstimateSatPerVByteFee(1, 1) + btcChain.SetTransactionConfirmations( + fundingTxHash, + tbtc.DepositSweepRequiredFundingTxConfirmations, + ) + + // Deposit amount is 2,000,000 (below initial 5,000,000 min). + ralc.SetDepositRequest( + fundingTxHash, + 0, + &tbtc.DepositChainRequest{ + Depositor: chain.Address("934b98637ca318a4d6e7ca6ffd1690b8e77df637"), + Amount: 2000000, + RevealedAt: time.Now().Add(-2 * time.Hour), + SweptAt: time.Unix(0, 0), + Vault: &[]chain.Address{chain.Address( + "0xReservationVaultAddress1234567890abcdef12345678", + )}[0], + }, + ) + + filterStartBlock := uint64(0) + if currentBlock > tbtcpg.ReservationAcceptanceLookBackBlocks { + filterStartBlock = currentBlock - tbtcpg.ReservationAcceptanceLookBackBlocks + } + + if err := ralc.AddPastDepositRevealedEvent( + &tbtc.DepositRevealedEventFilter{ + StartBlock: filterStartBlock, + EndBlock: ¤tBlock, + WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + }, + &tbtc.DepositRevealedEvent{ + BlockNumber: 290000, + WalletPublicKeyHash: walletPublicKeyHash, + FundingTxHash: fundingTxHash, + FundingOutputIndex: 0, + Vault: &[]chain.Address{chain.Address( + "0xReservationVaultAddress1234567890abcdef12345678", + )}[0], + }, + ); err != nil { + t.Fatal(err) + } + + task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + request := &tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + } + + // First run: deposit amount is below min, skipped. + proposal, shouldExecute, err := task.Run(request) + if err != nil { + t.Fatalf("first run error: [%v]", err) + } + if shouldExecute || proposal != nil { + t.Fatalf("expected no proposal when deposit is below min amount") + } + + // Governance lowers min amount to 1,000,000. + ralc.reservationParameters = &tbtc.ReservationParameters{ + ReservationVault: chain.Address( + "0xReservationVaultAddress1234567890abcdef12345678", + ), + ReservationMinAmount: 1000000, + ReservationTxMaxFee: 5000, + MaxReservationsPerWallet: 5, + } + + // Second run on the same task instance: deposit is now above min and proposed. + proposal, shouldExecute, err = task.Run(request) + if err != nil { + t.Fatalf("second run error: [%v]", err) + } + if !shouldExecute || proposal == nil { + t.Fatalf("expected proposal on second run after min amount lowered") + } +} + +// TestReservationAcceptanceTask_Stateless_RequestNonceIncremented verifies that +// when an existing reservation record has RequestNonce = N, the generated proposal +// uses RequestNonce = N + 1. +func TestReservationAcceptanceTask_Stateless_RequestNonceIncremented(t *testing.T) { + ralc := newReservationAcceptanceLocalChain() + btcChain := tbtcpg.NewLocalBitcoinChain() + + walletPublicKeyHash := hexToByte20( + "8db50eb52063ea9d98b3eac91489a90f738986f6", + ) + + ralc.reservationParameters = &tbtc.ReservationParameters{ + ReservationVault: chain.Address( + "0xReservationVaultAddress1234567890abcdef12345678", + ), + ReservationMinAmount: 1000, + ReservationTxMaxFee: 5000, + MaxReservationsPerWallet: 5, + } + ralc.maxPerWalletAmount = 5000000 + ralc.maxSingleAmount = 5000000 + ralc.maxActive = 100 + + ralc.SetDepositMinAge(3600) + ralc.SetWallet( + walletPublicKeyHash, + &tbtc.WalletChainData{State: tbtc.StateLive}, + ) + + currentBlock := uint64(300000) + blockCounter := tbtcpg.NewMockBlockCounter() + blockCounter.SetCurrentBlock(currentBlock) + ralc.SetBlockCounter(blockCounter) + + fundingTxHash := hashFromString( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ) + dummyTx := &bitcoin.Transaction{ + Outputs: []*bitcoin.TransactionOutput{{ + Value: 0, + PublicKeyScript: append([]byte{0x00, 0x20}, make([]byte, 32)...), + }}, + } + btcChain.SetTransaction(fundingTxHash, dummyTx) + btcChain.SetEstimateSatPerVByteFee(1, 1) + btcChain.SetTransactionConfirmations( + fundingTxHash, + tbtc.DepositSweepRequiredFundingTxConfirmations, + ) + + ralc.SetDepositRequest( + fundingTxHash, + 0, + &tbtc.DepositChainRequest{ + Depositor: chain.Address("934b98637ca318a4d6e7ca6ffd1690b8e77df637"), + Amount: 2000000, + RevealedAt: time.Now().Add(-2 * time.Hour), + SweptAt: time.Unix(0, 0), + Vault: &[]chain.Address{chain.Address( + "0xReservationVaultAddress1234567890abcdef12345678", + )}[0], + }, + ) + + filterStartBlock := uint64(0) + if currentBlock > tbtcpg.ReservationAcceptanceLookBackBlocks { + filterStartBlock = currentBlock - tbtcpg.ReservationAcceptanceLookBackBlocks + } + + if err := ralc.AddPastDepositRevealedEvent( + &tbtc.DepositRevealedEventFilter{ + StartBlock: filterStartBlock, + EndBlock: ¤tBlock, + WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + }, + &tbtc.DepositRevealedEvent{ + BlockNumber: 290000, + WalletPublicKeyHash: walletPublicKeyHash, + FundingTxHash: fundingTxHash, + FundingOutputIndex: 0, + Vault: &[]chain.Address{chain.Address( + "0xReservationVaultAddress1234567890abcdef12345678", + )}[0], + }, + ); err != nil { + t.Fatal(err) + } + + depositKey := ralc.BuildDepositKey(fundingTxHash, 0) + // Set an existing reservation with StateUnknown (not active) and RequestNonce = 2. + ralc.SetReservation(depositKey, &tbtc.Reservation{ + State: tbtc.ReservationStateUnknown, + RequestNonce: 2, + }) + // Set previous action nonce 2 to TimedOut so hasPendingAction returns false. + ralc.SetReservationAction(depositKey, 2, &tbtc.ReservationAction{ + State: tbtc.ReservationActionStateTimedOut, + }) + + task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + request := &tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + } + + proposal, shouldExecute, err := task.Run(request) + if err != nil { + t.Fatalf("task error: [%v]", err) + } + if !shouldExecute || proposal == nil { + t.Fatalf("expected proposal") + } + + actualProposal, ok := proposal.(*tbtc.ReservationAnchorProposal) + if !ok { + t.Fatalf("expected *ReservationAnchorProposal, got %T", proposal) + } + if actualProposal.RequestNonce != 3 { + t.Errorf( + "unexpected RequestNonce\nexpected: 3\nactual: %d", + actualProposal.RequestNonce, + ) + } +} From b91c81866d7a8e9ec0a9f9e3def85d7c69908aa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 2 Sep 2026 10:56:21 +0000 Subject: [PATCH 29/39] fix(tbtcpg): fix reservation reanchor target-wallet cache and dead check The single shared target-wallet cache field let a source wallet inherit a target cached for a different source wallet without re-applying its own exclusion. Remove the cache and resolve the target wallet once per Run instead of once per reservation. Drop the unreachable hasPendingAction call already covered by the preceding Active-state check. --- pkg/tbtcpg/reservation_reanchor.go | 61 ++--- pkg/tbtcpg/reservation_reanchor_test.go | 299 +++++++++++++++++++++++- 2 files changed, 318 insertions(+), 42 deletions(-) diff --git a/pkg/tbtcpg/reservation_reanchor.go b/pkg/tbtcpg/reservation_reanchor.go index 37004e619f..2ee6959dbd 100644 --- a/pkg/tbtcpg/reservation_reanchor.go +++ b/pkg/tbtcpg/reservation_reanchor.go @@ -3,7 +3,6 @@ package tbtcpg import ( "fmt" "math/big" - "sync" "github.com/ipfs/go-log/v2" "go.uber.org/zap" @@ -25,11 +24,8 @@ const ReservationReanchorLookBackBlocks = uint64(216000) // task picks a destination wallet and assembles a 1-input-1-output re-anchor // transaction moving the anchor outpoint into that destination wallet. type ReservationReanchorTask struct { - chain Chain - btcChain bitcoin.Chain - targetWalletCache [20]byte - targetWalletInitialized bool - targetWalletMutex sync.RWMutex + chain Chain + btcChain bitcoin.Chain } // NewReservationReanchorTask returns a new ReservationReanchorTask bound to @@ -129,10 +125,19 @@ func (rrt *ReservationReanchorTask) Run( return nil, false, nil } + targetWalletPublicKeyHash, err := rrt.findTargetWallet( + taskLogger, + walletPublicKeyHash, + ) + if err != nil { + taskLogger.Errorf( + "cannot pick re-anchor target wallet: [%v]", + err, + ) + return nil, false, nil + } + for _, reservationKey := range reservationKeys { - // Filter out reservations that already have a pending action: the - // Bridge will reject a duplicate re-anchor request while one is - // already in flight. reservation, err := rrt.chain.GetReservation(reservationKey) if err != nil { taskLogger.Errorf( @@ -143,6 +148,9 @@ func (rrt *ReservationReanchorTask) Run( continue } + // Filter out reservations that are not in the Active state. + // Note: Checking Active state already covers pending actions, + // because a reservation with a pending action is in ActionPending state. if reservation.State != tbtc.ReservationStateActive { taskLogger.Infof( "reservation [0x%x] not in Active state (state=%v), skipping", @@ -152,22 +160,6 @@ func (rrt *ReservationReanchorTask) Run( continue } - if hasPendingAction(reservationKey, reservation, rrt.chain, taskLogger) { - continue - } - - targetWalletPublicKeyHash, err := rrt.findTargetWallet( - taskLogger, - walletPublicKeyHash, - ) - if err != nil { - taskLogger.Errorf( - "cannot pick re-anchor target wallet: [%v]", - err, - ) - continue - } - proposal, err := rrt.ProposeReservationReanchor( taskLogger, walletPublicKeyHash, @@ -288,6 +280,9 @@ func (rrt *ReservationReanchorTask) ProposeReservationReanchor( ) } // The re-anchor request generation must be authorized on-chain. + // Note: Calling RequestReservationReanchor during proposal generation is an + // accepted deviation from the read-only-during-generation pattern, with + // precedent in MovingFundsTask's SubmitMovingFundsCommitment. if err := rrt.chain.RequestReservationReanchor( reservationKey, targetWalletPublicKeyHash, @@ -309,18 +304,6 @@ func (rrt *ReservationReanchorTask) findTargetWallet( taskLogger log.StandardLogger, sourceWalletPublicKeyHash [20]byte, ) ([20]byte, error) { - rrt.targetWalletMutex.RLock() - if rrt.targetWalletInitialized { - cache := rrt.targetWalletCache - rrt.targetWalletMutex.RUnlock() - - wallet, err := rrt.chain.GetWallet(cache) - if err == nil && wallet.State == tbtc.StateLive { - return cache, nil - } - } else { - rrt.targetWalletMutex.RUnlock() - } blockCounter, err := rrt.chain.BlockCounter() if err != nil { return [20]byte{}, fmt.Errorf("failed to get block counter: [%v]", err) @@ -363,10 +346,6 @@ func (rrt *ReservationReanchorTask) findTargetWallet( } if wallet.State == tbtc.StateLive { - rrt.targetWalletMutex.Lock() - rrt.targetWalletCache = walletPubKeyHash - rrt.targetWalletInitialized = true - rrt.targetWalletMutex.Unlock() return walletPubKeyHash, nil } } diff --git a/pkg/tbtcpg/reservation_reanchor_test.go b/pkg/tbtcpg/reservation_reanchor_test.go index 6a8ddc38ea..9c1a0b03ae 100644 --- a/pkg/tbtcpg/reservation_reanchor_test.go +++ b/pkg/tbtcpg/reservation_reanchor_test.go @@ -108,6 +108,12 @@ func TestReservationReanchorTask_Run(t *testing.T) { }}, }) + reservationState := r.State + if r.HasPendingAction || r.PendingActionState == tbtc.ReservationActionStatePending { + // On-chain, a reservation with a pending action is in ActionPending state. + reservationState = tbtc.ReservationStateActionPending + } + tbtcChain.SetReservation(r.ReservationKey, &tbtc.Reservation{ WalletPublicKeyHash: r.WalletPublicKeyHash, AnchorUtxo: &bitcoin.UnspentTransactionOutput{ @@ -117,7 +123,7 @@ func TestReservationReanchorTask_Run(t *testing.T) { }, Value: r.AnchorValue, }, - State: r.State, + State: reservationState, RequestNonce: r.RequestNonce, }) @@ -259,3 +265,294 @@ func reanchorProposalsEqual( } return true } + +func TestReservationReanchorTask_TargetWalletExclusion_SharedTask(t *testing.T) { + walletA := hexToByte20("1111111111111111111111111111111111111111") + walletB := hexToByte20("2222222222222222222222222222222222222222") + walletC := hexToByte20("3333333333333333333333333333333333333333") + + tbtcChain := tbtcpg.NewLocalChain() + btcChain := tbtcpg.NewLocalBitcoinChain() + + blockCounter := tbtcpg.NewMockBlockCounter() + blockCounter.SetCurrentBlock(1000) + tbtcChain.SetBlockCounter(blockCounter) + + // Register walletC at block 100, then walletB at block 200 (walletB is newest). + err := tbtcChain.AddPastNewWalletRegisteredEvent( + &tbtc.NewWalletRegisteredEventFilter{StartBlock: 0}, + &tbtc.NewWalletRegisteredEvent{WalletPublicKeyHash: walletC}, + ) + if err != nil { + t.Fatal(err) + } + err = tbtcChain.AddPastNewWalletRegisteredEvent( + &tbtc.NewWalletRegisteredEventFilter{StartBlock: 0}, + &tbtc.NewWalletRegisteredEvent{WalletPublicKeyHash: walletB}, + ) + if err != nil { + t.Fatal(err) + } + + tbtcChain.SetWallet(walletA, &tbtc.WalletChainData{State: tbtc.StateMovingFunds}) + tbtcChain.SetWallet(walletB, &tbtc.WalletChainData{State: tbtc.StateLive}) + tbtcChain.SetWallet(walletC, &tbtc.WalletChainData{State: tbtc.StateLive}) + tbtcChain.SetLiveWalletsCount(2) + + tbtcChain.SetMovingFundsParameters( + 1000000, + 1000000, + 0, + 0, + nil, + 0, + 0, + 0, + 0, + nil, + 0, + ) + tbtcChain.SetReservationParameters(tbtc.ReservationParameters{ + ReservationTxMaxFee: 100000, + }) + btcChain.SetEstimateSatPerVByteFee(1, 1) + + // Setup reservation for Wallet A. + resAKey := big.NewInt(1001) + anchorTxHashA, _ := bitcoin.NewHashFromString( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + bitcoin.ReversedByteOrder, + ) + btcChain.SetTransaction(anchorTxHashA, &bitcoin.Transaction{ + Version: 1, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 100000, + PublicKeyScript: []byte{}, + }}, + }) + tbtcChain.SetReservation(resAKey, &tbtc.Reservation{ + WalletPublicKeyHash: walletA, + AnchorUtxo: &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHashA, + OutputIndex: 1, + }, + Value: 100000, + }, + State: tbtc.ReservationStateActive, + RequestNonce: 0, + }) + tbtcChain.SetWalletReservations(walletA, []*big.Int{resAKey}) + + // Setup reservation for Wallet B. + resBKey := big.NewInt(2001) + anchorTxHashB, _ := bitcoin.NewHashFromString( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + bitcoin.ReversedByteOrder, + ) + btcChain.SetTransaction(anchorTxHashB, &bitcoin.Transaction{ + Version: 1, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 200000, + PublicKeyScript: []byte{}, + }}, + }) + tbtcChain.SetReservation(resBKey, &tbtc.Reservation{ + WalletPublicKeyHash: walletB, + AnchorUtxo: &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHashB, + OutputIndex: 1, + }, + Value: 200000, + }, + State: tbtc.ReservationStateActive, + RequestNonce: 0, + }) + tbtcChain.SetWalletReservations(walletB, []*big.Int{resBKey}) + + // Single task instance used for both runs. + task := tbtcpg.NewReservationReanchorTask(tbtcChain, btcChain) + + // First run for wallet A: should select wallet B as target. + propA, okA, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletA, + }) + if err != nil { + t.Fatalf("unexpected error for wallet A: %v", err) + } + if !okA || propA == nil { + t.Fatalf("expected proposal for wallet A, got ok=%v, prop=%v", okA, propA) + } + proposalA, ok := propA.(*tbtc.ReservationReanchorProposal) + if !ok { + t.Fatalf("unexpected proposal type: %T", propA) + } + if proposalA.TargetWalletPublicKeyHash != walletB { + t.Errorf( + "wallet A expected target walletB [%x], got [%x]", + walletB, + proposalA.TargetWalletPublicKeyHash, + ) + } + + // Second run with the SAME task instance for wallet B (now in StateMovingFunds): + // Must NOT select wallet B (itself), even though wallet B was cached in the previous run. + // Must select wallet C instead. + tbtcChain.SetWallet(walletB, &tbtc.WalletChainData{State: tbtc.StateMovingFunds}) + propB, okB, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletB, + }) + if err != nil { + t.Fatalf("unexpected error for wallet B: %v", err) + } + if !okB || propB == nil { + t.Fatalf("expected proposal for wallet B, got ok=%v, prop=%v", okB, propB) + } + proposalB, ok := propB.(*tbtc.ReservationReanchorProposal) + if !ok { + t.Fatalf("unexpected proposal type: %T", propB) + } + if proposalB.TargetWalletPublicKeyHash == walletB { + t.Errorf("wallet B selected itself as target wallet: [%x]", walletB) + } + if proposalB.TargetWalletPublicKeyHash != walletC { + t.Errorf( + "wallet B expected target walletC [%x], got [%x]", + walletC, + proposalB.TargetWalletPublicKeyHash, + ) + } + + // Third run: if wallet C is not live, wallet B must not produce any proposal + // (must not fall back to selecting itself). + tbtcChain.SetWallet(walletC, &tbtc.WalletChainData{State: tbtc.StateMovingFunds}) + propB2, okB2, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletB, + }) + if err != nil { + t.Fatalf("unexpected error on third run: %v", err) + } + if okB2 || propB2 != nil { + t.Errorf("expected no proposal when no other live wallet exists, got prop=%v", propB2) + } +} + +func TestReservationReanchorTask_Run_SkipNonActiveReservations(t *testing.T) { + walletA := hexToByte20("1111111111111111111111111111111111111111") + walletB := hexToByte20("2222222222222222222222222222222222222222") + + tbtcChain := tbtcpg.NewLocalChain() + btcChain := tbtcpg.NewLocalBitcoinChain() + + blockCounter := tbtcpg.NewMockBlockCounter() + blockCounter.SetCurrentBlock(1000) + tbtcChain.SetBlockCounter(blockCounter) + + err := tbtcChain.AddPastNewWalletRegisteredEvent( + &tbtc.NewWalletRegisteredEventFilter{StartBlock: 0}, + &tbtc.NewWalletRegisteredEvent{WalletPublicKeyHash: walletB}, + ) + if err != nil { + t.Fatal(err) + } + + tbtcChain.SetWallet(walletA, &tbtc.WalletChainData{State: tbtc.StateMovingFunds}) + tbtcChain.SetWallet(walletB, &tbtc.WalletChainData{State: tbtc.StateLive}) + tbtcChain.SetLiveWalletsCount(1) + + tbtcChain.SetMovingFundsParameters( + 1000000, + 1000000, + 0, + 0, + nil, + 0, + 0, + 0, + 0, + nil, + 0, + ) + tbtcChain.SetReservationParameters(tbtc.ReservationParameters{ + ReservationTxMaxFee: 100000, + }) + btcChain.SetEstimateSatPerVByteFee(1, 1) + + // Setup 2 reservations for Wallet A: + // res1 is in ReservationStateActionPending (should be skipped) + // res2 is in ReservationStateActive (should be proposed) + res1Key := big.NewInt(101) + anchorTxHash1, _ := bitcoin.NewHashFromString( + "1111111111111111111111111111111111111111111111111111111111111111", + bitcoin.ReversedByteOrder, + ) + btcChain.SetTransaction(anchorTxHash1, &bitcoin.Transaction{ + Version: 1, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 100000, + PublicKeyScript: []byte{}, + }}, + }) + tbtcChain.SetReservation(res1Key, &tbtc.Reservation{ + WalletPublicKeyHash: walletA, + AnchorUtxo: &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHash1, + OutputIndex: 1, + }, + Value: 100000, + }, + State: tbtc.ReservationStateActionPending, + RequestNonce: 1, + }) + + res2Key := big.NewInt(102) + anchorTxHash2, _ := bitcoin.NewHashFromString( + "2222222222222222222222222222222222222222222222222222222222222222", + bitcoin.ReversedByteOrder, + ) + btcChain.SetTransaction(anchorTxHash2, &bitcoin.Transaction{ + Version: 1, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 200000, + PublicKeyScript: []byte{}, + }}, + }) + tbtcChain.SetReservation(res2Key, &tbtc.Reservation{ + WalletPublicKeyHash: walletA, + AnchorUtxo: &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHash2, + OutputIndex: 1, + }, + Value: 200000, + }, + State: tbtc.ReservationStateActive, + RequestNonce: 0, + }) + + tbtcChain.SetWalletReservations(walletA, []*big.Int{res1Key, res2Key}) + + task := tbtcpg.NewReservationReanchorTask(tbtcChain, btcChain) + + prop, ok, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletA, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok || prop == nil { + t.Fatalf("expected proposal, got ok=%v, prop=%v", ok, prop) + } + proposal, ok := prop.(*tbtc.ReservationReanchorProposal) + if !ok { + t.Fatalf("unexpected proposal type: %T", prop) + } + if proposal.ReservationKey.Cmp(res2Key) != 0 { + t.Errorf("expected proposal for res2 [102], got [%v]", proposal.ReservationKey) + } + if proposal.TargetWalletPublicKeyHash != walletB { + t.Errorf("expected target walletB [%x], got [%x]", walletB, proposal.TargetWalletPublicKeyHash) + } +} From 0f392596c702089425daef217fcdc05584110eb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 2 Sep 2026 10:56:21 +0000 Subject: [PATCH 30/39] test(tbtcpg): cover reservation tx max-fee boundary estimateReservationFixedSizeTxFee had no dedicated test for the max-fee guard, unlike the sibling deposit-sweep flow. --- pkg/tbtcpg/fee_test.go | 116 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/pkg/tbtcpg/fee_test.go b/pkg/tbtcpg/fee_test.go index cdb522c53e..04f8d8801f 100644 --- a/pkg/tbtcpg/fee_test.go +++ b/pkg/tbtcpg/fee_test.go @@ -3,6 +3,8 @@ package tbtcpg import ( "strings" "testing" + + "github.com/keep-network/keep-core/pkg/bitcoin" ) func TestApplyWalletTxFeeFloor(t *testing.T) { @@ -91,3 +93,117 @@ func TestApplyWalletTxFeeFloor(t *testing.T) { }) } } + +func TestEstimateReservationFixedSizeTxFee(t *testing.T) { + sizeEstimator := bitcoin.NewTransactionSizeEstimator(). + AddScriptHashInputs(1, depositScriptByteSize, true). + AddPublicKeyHashOutputs(1, true) + + size, err := sizeEstimator.VirtualSize() + if err != nil { + t.Fatal(err) + } + + const ( + acceptanceErrMsg = "reservation acceptance estimated fee exceeds the maximum fee" + reanchorErrMsg = "reservation re-anchor estimated fee exceeds the maximum fee" + ) + + tests := map[string]struct { + estimateSatPerVByte int64 + txMaxFee uint64 + exceedsMaxErrMsg string + expectedFee int64 + expectErrorContains string + }{ + "low estimate is raised to the minimum floor": { + estimateSatPerVByte: 1, + txMaxFee: 100000, + exceedsMaxErrMsg: acceptanceErrMsg, + expectedFee: 5 * size, // max(5, ceil(1*1.25)=2)=5 sat/vByte * size + }, + "estimate above the floor is buffered by 25%": { + estimateSatPerVByte: 20, + txMaxFee: 100000, + exceedsMaxErrMsg: acceptanceErrMsg, + expectedFee: 25 * size, // ceil(20*1.25) = 25 sat/vByte * size + }, + "buffered estimate above the cap is bounded to the cap": { + estimateSatPerVByte: 20, + txMaxFee: uint64(22 * size), + exceedsMaxErrMsg: acceptanceErrMsg, + expectedFee: 22 * size, + }, + "raw estimate exactly equal to the cap is allowed and bounded": { + estimateSatPerVByte: 20, + txMaxFee: uint64(20 * size), + exceedsMaxErrMsg: acceptanceErrMsg, + expectedFee: 20 * size, + }, + "raw estimate 1 sat above the cap returns an error": { + estimateSatPerVByte: 20, + txMaxFee: uint64(20*size - 1), + exceedsMaxErrMsg: acceptanceErrMsg, + expectErrorContains: acceptanceErrMsg, + }, + "raw estimate above the cap returns acceptance error": { + estimateSatPerVByte: 30, + // The raw 30*size fee already exceeds the 10*size cap, so the raw-fee + // check must error before the minimum-floor logic runs. The returned + // error must match the exact exceedsMaxErrMsg passed by the caller. + txMaxFee: uint64(10 * size), + exceedsMaxErrMsg: acceptanceErrMsg, + expectErrorContains: acceptanceErrMsg, + }, + "raw estimate above the cap returns re-anchor error": { + estimateSatPerVByte: 30, + txMaxFee: uint64(10 * size), + exceedsMaxErrMsg: reanchorErrMsg, + expectErrorContains: reanchorErrMsg, + }, + "minimum floor above the cap returns an error": { + estimateSatPerVByte: 1, + // Cap sits below 5*size (the floor) but above the raw fee (1*size), + // so the minimum-fee check must error rather than lower the fee. + txMaxFee: uint64(3 * size), + exceedsMaxErrMsg: acceptanceErrMsg, + expectErrorContains: "minimum safe transaction fee", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + btcChain := NewLocalBitcoinChain() + btcChain.SetEstimateSatPerVByteFee(1, tc.estimateSatPerVByte) + + fee, err := estimateReservationFixedSizeTxFee( + btcChain, + sizeEstimator, + tc.txMaxFee, + tc.exceedsMaxErrMsg, + ) + + if tc.expectErrorContains != "" { + if err == nil { + t.Fatalf("expected an error, got fee [%d]", fee) + } + if !strings.Contains(err.Error(), tc.expectErrorContains) { + t.Fatalf( + "expected error containing [%s]; got [%v]", + tc.expectErrorContains, err, + ) + } + return + } + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + if fee != tc.expectedFee { + t.Errorf( + "unexpected fee\nexpected: [%d]\nactual: [%d]", + tc.expectedFee, fee, + ) + } + }) + } +} From 4fdb9bbf0dea596268bf40a98983ea32e1d427d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 2 Sep 2026 10:56:22 +0000 Subject: [PATCH 31/39] fix(spv): tighten reservation proof transaction matching Require the sole output to be P2WPKH to the custody/target wallet at the expected value, so a stale acceptance/reanchor event can't be matched against an unrelated single-deposit sweep transaction. Add a bounded retry counter that evicts a pending event after repeated GetReservationAction errors instead of retrying forever. Verify the second-pass eviction path once an action settles, index fetched wallet history by outpoint for O(1) lookup, and align the scan EndBlock with the recorded cursor. --- pkg/maintainer/spv/reservation_proof_loop.go | 342 +++++++------ .../spv/reservation_proof_loop_test.go | 449 +++++++++++++++++- 2 files changed, 631 insertions(+), 160 deletions(-) diff --git a/pkg/maintainer/spv/reservation_proof_loop.go b/pkg/maintainer/spv/reservation_proof_loop.go index b7ab52301e..07bb4b3793 100644 --- a/pkg/maintainer/spv/reservation_proof_loop.go +++ b/pkg/maintainer/spv/reservation_proof_loop.go @@ -1,6 +1,7 @@ package spv import ( + "bytes" "context" "fmt" "math/big" @@ -17,52 +18,6 @@ import ( // ReservationReanchorLookBackBlocks in pkg/tbtcpg: 30 days at 12s/block. const reservationProofLookBackBlocks = uint64(216000) -// reservationAcceptanceWalletEvent adapts -// *tbtc.ReservationAcceptanceRequestedEvent to the walletEvent interface -// (see spv.go) so uniqueWalletPublicKeyHashes can be reused here instead of -// a reservation-specific duplicate of the same dedup logic. -type reservationAcceptanceWalletEvent struct { - *tbtc.ReservationAcceptanceRequestedEvent -} - -// GetWalletPublicKeyHash implements walletEvent. -func (e reservationAcceptanceWalletEvent) GetWalletPublicKeyHash() [20]byte { - return e.WalletPublicKeyHash -} - -// reservationReanchorWalletEvent adapts -// *tbtc.ReservationReanchorRequestedEvent to the walletEvent interface (see -// spv.go) so uniqueWalletPublicKeyHashes can be reused here instead of a -// reservation-specific duplicate of the same dedup logic. -type reservationReanchorWalletEvent struct { - *tbtc.ReservationReanchorRequestedEvent -} - -// GetWalletPublicKeyHash implements walletEvent. -func (e reservationReanchorWalletEvent) GetWalletPublicKeyHash() [20]byte { - return e.SourceWalletPublicKeyHash -} - -func wrapReservationAcceptanceEvents( - events []*tbtc.ReservationAcceptanceRequestedEvent, -) []reservationAcceptanceWalletEvent { - wrapped := make([]reservationAcceptanceWalletEvent, len(events)) - for i, event := range events { - wrapped[i] = reservationAcceptanceWalletEvent{event} - } - return wrapped -} - -func wrapReservationReanchorEvents( - events []*tbtc.ReservationReanchorRequestedEvent, -) []reservationReanchorWalletEvent { - wrapped := make([]reservationReanchorWalletEvent, len(events)) - for i, event := range events { - wrapped[i] = reservationReanchorWalletEvent{event} - } - return wrapped -} - // reservationProofScanState persists the incremental event-scan cursor and // the set of still-pending action-request events across successive passes // of runReservationProofLoop, so proveReservationAcceptanceActions and @@ -73,18 +28,28 @@ func wrapReservationReanchorEvents( type reservationProofScanState struct { acceptanceLastScannedBlock uint64 pendingAcceptanceEvents map[string]*tbtc.ReservationAcceptanceRequestedEvent + acceptanceRetries map[string]uint reanchorLastScannedBlock uint64 pendingReanchorEvents map[string]*tbtc.ReservationReanchorRequestedEvent + reanchorRetries map[string]uint } func newReservationProofScanState() *reservationProofScanState { return &reservationProofScanState{ pendingAcceptanceEvents: make(map[string]*tbtc.ReservationAcceptanceRequestedEvent), + acceptanceRetries: make(map[string]uint), pendingReanchorEvents: make(map[string]*tbtc.ReservationReanchorRequestedEvent), + reanchorRetries: make(map[string]uint), } } +// maxReservationActionLoadRetries is the maximum number of consecutive +// passes GetReservationAction may fail for a tracked pending event before +// the event is evicted from the pending map to avoid unbounded map growth +// and log spam on unrecoverable RPC/chain errors. +const maxReservationActionLoadRetries = 3 + // reservationEventKey identifies one reservation action generation, unique // across both the acceptance and re-anchor pending-event maps. func reservationEventKey(reservationKey *big.Int, requestNonce uint64) string { @@ -241,7 +206,10 @@ func proveReservationAcceptanceActions( } newEvents, err := spvChain.PastReservationAcceptanceRequestedEvents( - &tbtc.ReservationAcceptanceRequestedEventFilter{StartBlock: startBlock}, + &tbtc.ReservationAcceptanceRequestedEventFilter{ + StartBlock: startBlock, + EndBlock: ¤tBlock, + }, ) if err != nil { return fmt.Errorf( @@ -256,40 +224,54 @@ func proveReservationAcceptanceActions( state.pendingAcceptanceEvents[key] = event } - // Re-check every tracked event's on-chain action state and drop the - // ones that are no longer pending, so the pending set does not grow - // without bound. - var pending []*tbtc.ReservationAcceptanceRequestedEvent + // Re-check every tracked event's on-chain action state, evict settled/stale + // ones, and group still-pending events by wallet public key hash. + walletEvents := make(map[[20]byte][]*tbtc.ReservationAcceptanceRequestedEvent) for key, event := range state.pendingAcceptanceEvents { action, err := spvChain.GetReservationAction( event.ReservationKey, event.RequestNonce, ) if err != nil { - logger.Errorf( - "failed to load reservation acceptance action [%v]/%d: [%v]", - event.ReservationKey, - event.RequestNonce, - err, - ) - // Keep tracking; retry on the next pass. + state.acceptanceRetries[key]++ + if state.acceptanceRetries[key] >= maxReservationActionLoadRetries { + logger.Errorf( + "failed to load reservation acceptance action [%v]/%d: [%v]; "+ + "exceeded max retries (%d), evicting event", + event.ReservationKey, + event.RequestNonce, + err, + maxReservationActionLoadRetries, + ) + delete(state.pendingAcceptanceEvents, key) + delete(state.acceptanceRetries, key) + } else { + logger.Errorf( + "failed to load reservation acceptance action [%v]/%d (retry %d/%d): [%v]", + event.ReservationKey, + event.RequestNonce, + state.acceptanceRetries[key], + maxReservationActionLoadRetries, + err, + ) + } continue } + + delete(state.acceptanceRetries, key) + if action.State != tbtc.ReservationActionStatePending { delete(state.pendingAcceptanceEvents, key) continue } - pending = append(pending, event) - } - // There will often be multiple pending events for a single wallet. - // Fetch that wallet's Bitcoin transaction history once, not once per - // event. - walletPublicKeyHashes := uniqueWalletPublicKeyHashes( - wrapReservationAcceptanceEvents(pending), - ) + walletEvents[event.WalletPublicKeyHash] = append( + walletEvents[event.WalletPublicKeyHash], + event, + ) + } - for _, walletPublicKeyHash := range walletPublicKeyHashes { + for walletPublicKeyHash, events := range walletEvents { walletTransactions, err := btcChain.GetTransactionsForPublicKeyHash( walletPublicKeyHash, config.TransactionLimit, @@ -299,26 +281,26 @@ func proveReservationAcceptanceActions( continue } - for _, event := range pending { - if event.WalletPublicKeyHash != walletPublicKeyHash { - continue + // Index wallet transactions by deposit key for O(1) matching. + candidateTransactions := make(map[string]*bitcoin.Transaction) + for _, transaction := range walletTransactions { + if len(transaction.Inputs) == 1 && len(transaction.Outputs) == 1 && transaction.Inputs[0].Outpoint != nil { + input := transaction.Inputs[0] + depositKey := spvChain.BuildDepositKey( + input.Outpoint.TransactionHash, + input.Outpoint.OutputIndex, + ) + candidateTransactions[depositKey.String()] = transaction } + } - transaction, err := findReservationAcceptanceTransaction( - spvChain, - event, - walletTransactions, - ) - if err != nil { - logger.Errorf( - "failed to search for reservation acceptance transaction "+ - "for reservation [%v]: [%v]", - event.ReservationKey, - err, - ) + for _, event := range events { + transaction, ok := candidateTransactions[event.ReservationKey.String()] + if !ok { continue } - if transaction == nil { + + if !isMatchingReservationAcceptanceTransaction(spvChain, event, transaction) { continue } @@ -359,30 +341,69 @@ func proveReservationAcceptanceActions( // transaction history for the 1-input-1-output acceptance (anchor) // transaction whose sole input spends the deposit identified by // event.ReservationKey (== the deposit key; see the m1 identity mapping -// documented in reservation_stale_deposit_watch.go). Returns nil, nil if no -// matching transaction has been broadcast yet. +// documented in reservation_stale_deposit_watch.go), whose sole output is +// P2WPKH to the custody wallet, and whose output value equals depositAmount - anchorFee. +// Returns nil, nil if no matching transaction has been broadcast yet. func findReservationAcceptanceTransaction( spvChain Chain, event *tbtc.ReservationAcceptanceRequestedEvent, walletTransactions []*bitcoin.Transaction, ) (*bitcoin.Transaction, error) { for _, transaction := range walletTransactions { - if len(transaction.Inputs) != 1 || len(transaction.Outputs) != 1 { - continue + if isMatchingReservationAcceptanceTransaction(spvChain, event, transaction) { + return transaction, nil } + } - input := transaction.Inputs[0] - depositKey := spvChain.BuildDepositKey( - input.Outpoint.TransactionHash, - input.Outpoint.OutputIndex, - ) + return nil, nil +} - if depositKey.Cmp(event.ReservationKey) == 0 { - return transaction, nil +func isMatchingReservationAcceptanceTransaction( + spvChain Chain, + event *tbtc.ReservationAcceptanceRequestedEvent, + transaction *bitcoin.Transaction, +) bool { + if len(transaction.Inputs) != 1 || len(transaction.Outputs) != 1 || transaction.Inputs[0].Outpoint == nil { + return false + } + + input := transaction.Inputs[0] + depositKey := spvChain.BuildDepositKey( + input.Outpoint.TransactionHash, + input.Outpoint.OutputIndex, + ) + + if depositKey.Cmp(event.ReservationKey) != 0 { + return false + } + + expectedScript, err := bitcoin.PayToWitnessPublicKeyHash( + event.WalletPublicKeyHash, + ) + if err != nil || !bytes.Equal(transaction.Outputs[0].PublicKeyScript, expectedScript) { + return false + } + + if depositRequest, found, err := spvChain.GetDepositRequest( + input.Outpoint.TransactionHash, + input.Outpoint.OutputIndex, + ); err != nil { + return false + } else if found { + fee := int64(depositRequest.Amount) - transaction.Outputs[0].Value + if fee <= 0 || (event.TxMaxFee > 0 && uint64(fee) > event.TxMaxFee) { + return false + } + if transaction.Outputs[0].Value != int64(depositRequest.Amount)-fee { + return false + } + } else { + if transaction.Outputs[0].Value <= 0 { + return false } } - return nil, nil + return true } // proveReservationReanchorActions finds pending ReservationReanchor action @@ -405,7 +426,10 @@ func proveReservationReanchorActions( } newEvents, err := spvChain.PastReservationReanchorRequestedEvents( - &tbtc.ReservationReanchorRequestedEventFilter{StartBlock: startBlock}, + &tbtc.ReservationReanchorRequestedEventFilter{ + StartBlock: startBlock, + EndBlock: ¤tBlock, + }, ) if err != nil { return fmt.Errorf( @@ -420,39 +444,54 @@ func proveReservationReanchorActions( } // Re-check every tracked event's on-chain action state and drop the - // ones that are no longer pending, so the pending set does not grow - // without bound. - var pending []*tbtc.ReservationReanchorRequestedEvent + // Re-check every tracked event's on-chain action state, evict settled/stale + // ones, and group still-pending events by source wallet public key hash. + walletEvents := make(map[[20]byte][]*tbtc.ReservationReanchorRequestedEvent) for key, event := range state.pendingReanchorEvents { action, err := spvChain.GetReservationAction( event.ReservationKey, event.RequestNonce, ) if err != nil { - logger.Errorf( - "failed to load reservation re-anchor action [%v]/%d: [%v]", - event.ReservationKey, - event.RequestNonce, - err, - ) - // Keep tracking; retry on the next pass. + state.reanchorRetries[key]++ + if state.reanchorRetries[key] >= maxReservationActionLoadRetries { + logger.Errorf( + "failed to load reservation re-anchor action [%v]/%d: [%v]; "+ + "exceeded max retries (%d), evicting event", + event.ReservationKey, + event.RequestNonce, + err, + maxReservationActionLoadRetries, + ) + delete(state.pendingReanchorEvents, key) + delete(state.reanchorRetries, key) + } else { + logger.Errorf( + "failed to load reservation re-anchor action [%v]/%d (retry %d/%d): [%v]", + event.ReservationKey, + event.RequestNonce, + state.reanchorRetries[key], + maxReservationActionLoadRetries, + err, + ) + } continue } + + delete(state.reanchorRetries, key) + if action.State != tbtc.ReservationActionStatePending { delete(state.pendingReanchorEvents, key) continue } - pending = append(pending, event) - } - // There will often be multiple pending events for a single source - // wallet. Fetch that wallet's Bitcoin transaction history once, not - // once per event. - walletPublicKeyHashes := uniqueWalletPublicKeyHashes( - wrapReservationReanchorEvents(pending), - ) + walletEvents[event.SourceWalletPublicKeyHash] = append( + walletEvents[event.SourceWalletPublicKeyHash], + event, + ) + } - for _, walletPublicKeyHash := range walletPublicKeyHashes { + for walletPublicKeyHash, events := range walletEvents { walletTransactions, err := btcChain.GetTransactionsForPublicKeyHash( walletPublicKeyHash, config.TransactionLimit, @@ -462,11 +501,15 @@ func proveReservationReanchorActions( continue } - for _, event := range pending { - if event.SourceWalletPublicKeyHash != walletPublicKeyHash { - continue + // Index wallet transactions by spent outpoint for O(1) matching. + candidateTransactions := make(map[bitcoin.TransactionOutpoint]*bitcoin.Transaction) + for _, transaction := range walletTransactions { + if len(transaction.Inputs) == 1 && len(transaction.Outputs) == 1 && transaction.Inputs[0].Outpoint != nil { + candidateTransactions[*transaction.Inputs[0].Outpoint] = transaction } + } + for _, event := range events { reservation, err := spvChain.GetReservation(event.ReservationKey) if err != nil { logger.Errorf( @@ -487,21 +530,12 @@ func proveReservationReanchorActions( continue } - transaction, err := findReservationReanchorTransaction( - event, - reservation.AnchorUtxo, - walletTransactions, - ) - if err != nil { - logger.Errorf( - "failed to search for reservation re-anchor transaction "+ - "for reservation [%v]: [%v]", - event.ReservationKey, - err, - ) + transaction, ok := candidateTransactions[*reservation.AnchorUtxo.Outpoint] + if !ok { continue } - if transaction == nil { + + if !isMatchingReservationReanchorTransaction(event, reservation.AnchorUtxo, transaction) { continue } @@ -540,21 +574,16 @@ func proveReservationReanchorActions( // findReservationReanchorTransaction scans the source wallet's Bitcoin // transaction history for the 1-input-1-output re-anchor transaction whose -// sole input spends the reservation's current anchor UTXO. Returns nil, nil -// if no matching transaction has been broadcast yet. +// sole input spends the reservation's current anchor UTXO, whose sole output is +// P2WPKH to the target wallet, and whose output value equals anchorUtxo.Value - reanchorFee. +// Returns nil, nil if no matching transaction has been broadcast yet. func findReservationReanchorTransaction( event *tbtc.ReservationReanchorRequestedEvent, anchorUtxo *bitcoin.UnspentTransactionOutput, walletTransactions []*bitcoin.Transaction, ) (*bitcoin.Transaction, error) { for _, transaction := range walletTransactions { - if len(transaction.Inputs) != 1 || len(transaction.Outputs) != 1 { - continue - } - - input := transaction.Inputs[0] - if input.Outpoint.TransactionHash == anchorUtxo.Outpoint.TransactionHash && - input.Outpoint.OutputIndex == anchorUtxo.Outpoint.OutputIndex { + if isMatchingReservationReanchorTransaction(event, anchorUtxo, transaction) { return transaction, nil } } @@ -562,6 +591,39 @@ func findReservationReanchorTransaction( return nil, nil } +func isMatchingReservationReanchorTransaction( + event *tbtc.ReservationReanchorRequestedEvent, + anchorUtxo *bitcoin.UnspentTransactionOutput, + transaction *bitcoin.Transaction, +) bool { + if len(transaction.Inputs) != 1 || len(transaction.Outputs) != 1 || transaction.Inputs[0].Outpoint == nil { + return false + } + + input := transaction.Inputs[0] + if input.Outpoint.TransactionHash != anchorUtxo.Outpoint.TransactionHash || + input.Outpoint.OutputIndex != anchorUtxo.Outpoint.OutputIndex { + return false + } + + expectedScript, err := bitcoin.PayToWitnessPublicKeyHash( + event.TargetWalletPublicKeyHash, + ) + if err != nil || !bytes.Equal(transaction.Outputs[0].PublicKeyScript, expectedScript) { + return false + } + + fee := int64(anchorUtxo.Value) - transaction.Outputs[0].Value + if fee <= 0 || (event.TxMaxFee > 0 && uint64(fee) > event.TxMaxFee) { + return false + } + if transaction.Outputs[0].Value != int64(anchorUtxo.Value)-fee { + return false + } + + return true +} + // proveReservationTransaction assembles and submits the SPV proof for a // single reservation acceptance or re-anchor transaction, once it has // accumulated enough confirmations and its proof falls within the relay's diff --git a/pkg/maintainer/spv/reservation_proof_loop_test.go b/pkg/maintainer/spv/reservation_proof_loop_test.go index a084c8305e..0c12d6a236 100644 --- a/pkg/maintainer/spv/reservation_proof_loop_test.go +++ b/pkg/maintainer/spv/reservation_proof_loop_test.go @@ -78,7 +78,9 @@ func TestReservationProofNextScanRange(t *testing.T) { // TestFindReservationAcceptanceTransaction verifies the acceptance // transaction matcher: it must find the 1-input-1-output transaction whose // sole input spends the deposit UTXO identified by event.ReservationKey (via -// BuildDepositKey), skip transactions with the wrong shape, and return nil +// BuildDepositKey), whose sole output is P2WPKH to the custody wallet, +// and whose value is depositAmount - fee (with fee <= TxMaxFee), skip +// transactions with wrong shape, wrong script, or invalid value, and return nil // when nothing matches. func TestFindReservationAcceptanceTransaction(t *testing.T) { spvChain := newLocalChain() @@ -92,6 +94,22 @@ func TestFindReservationAcceptanceTransaction(t *testing.T) { } reservationKey := spvChain.BuildDepositKey(fundingTxHash, 0) + 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) + } + + otherWalletPKH := [20]byte{99, 99, 99} + otherWalletScript, err := bitcoin.PayToWitnessPublicKeyHash(otherWalletPKH) + if err != nil { + t.Fatal(err) + } + + spvChain.setDepositRequest(fundingTxHash, 0, &tbtc.DepositChainRequest{ + Amount: 150000, + }) + matchingTx := &bitcoin.Transaction{ Inputs: []*bitcoin.TransactionInput{{ Outpoint: &bitcoin.TransactionOutpoint{ @@ -99,7 +117,52 @@ func TestFindReservationAcceptanceTransaction(t *testing.T) { OutputIndex: 0, }, }}, - Outputs: []*bitcoin.TransactionOutput{{Value: 100}}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 100000, + PublicKeyScript: walletScript, + }}, + } + + // Wrong script: pays to a different wallet. + wrongScriptTx := &bitcoin.Transaction{ + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTxHash, + OutputIndex: 0, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 100000, + PublicKeyScript: otherWalletScript, + }}, + } + + // Wrong value: output value >= depositAmount (zero or negative fee). + wrongValueTx := &bitcoin.Transaction{ + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTxHash, + OutputIndex: 0, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 160000, + PublicKeyScript: walletScript, + }}, + } + + // Excess fee: fee 100000 > TxMaxFee 60000. + excessFeeTx := &bitcoin.Transaction{ + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTxHash, + OutputIndex: 0, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 50000, + PublicKeyScript: walletScript, + }}, } // Wrong shape: two outputs, must be skipped even though it otherwise @@ -111,7 +174,10 @@ func TestFindReservationAcceptanceTransaction(t *testing.T) { OutputIndex: 0, }, }}, - Outputs: []*bitcoin.TransactionOutput{{Value: 100}, {Value: 200}}, + Outputs: []*bitcoin.TransactionOutput{ + {Value: 50000, PublicKeyScript: walletScript}, + {Value: 50000, PublicKeyScript: walletScript}, + }, } // Non-matching: correct shape, different outpoint. @@ -129,11 +195,16 @@ func TestFindReservationAcceptanceTransaction(t *testing.T) { OutputIndex: 0, }, }}, - Outputs: []*bitcoin.TransactionOutput{{Value: 100}}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 100000, + PublicKeyScript: walletScript, + }}, } event := &tbtc.ReservationAcceptanceRequestedEvent{ - ReservationKey: reservationKey, + ReservationKey: reservationKey, + WalletPublicKeyHash: walletPublicKeyHash, + TxMaxFee: 60000, } t.Run("finds the matching transaction among candidates", func(t *testing.T) { @@ -177,12 +248,56 @@ func TestFindReservationAcceptanceTransaction(t *testing.T) { t.Errorf("expected nil, got %v", found) } }) + + t.Run("skips transaction with wrong output script", func(t *testing.T) { + found, err := findReservationAcceptanceTransaction( + spvChain, + event, + []*bitcoin.Transaction{wrongScriptTx}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if found != nil { + t.Errorf("expected nil for wrong script transaction, got %v", found) + } + }) + + t.Run("skips transaction with wrong output value", func(t *testing.T) { + found, err := findReservationAcceptanceTransaction( + spvChain, + event, + []*bitcoin.Transaction{wrongValueTx}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if found != nil { + t.Errorf("expected nil for wrong value transaction, got %v", found) + } + }) + + t.Run("skips transaction with excess fee", func(t *testing.T) { + found, err := findReservationAcceptanceTransaction( + spvChain, + event, + []*bitcoin.Transaction{excessFeeTx}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if found != nil { + t.Errorf("expected nil for excess fee transaction, got %v", found) + } + }) } // TestFindReservationReanchorTransaction verifies the re-anchor transaction // matcher: it must find the 1-input-1-output transaction whose sole input -// spends the reservation's current anchor UTXO outpoint exactly, skip -// wrong-shape transactions, and return nil when nothing matches. +// spends the reservation's current anchor UTXO outpoint exactly, whose sole output +// is P2WPKH to the target wallet, and whose value is anchorUtxo.Value - fee +// (with fee <= TxMaxFee), skip wrong-shape, wrong-script, or invalid-value +// transactions, and return nil when nothing matches. func TestFindReservationReanchorTransaction(t *testing.T) { anchorTxHash, err := bitcoin.NewHashFromString( "2222222222222222222222222222222222222222222222222222222222222222", @@ -199,6 +314,18 @@ func TestFindReservationReanchorTransaction(t *testing.T) { Value: 600000, } + targetWalletPKH := [20]byte{21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40} + targetWalletScript, err := bitcoin.PayToWitnessPublicKeyHash(targetWalletPKH) + if err != nil { + t.Fatal(err) + } + + otherPKH := [20]byte{99, 99, 99} + otherScript, err := bitcoin.PayToWitnessPublicKeyHash(otherPKH) + if err != nil { + t.Fatal(err) + } + matchingTx := &bitcoin.Transaction{ Inputs: []*bitcoin.TransactionInput{{ Outpoint: &bitcoin.TransactionOutpoint{ @@ -206,7 +333,49 @@ func TestFindReservationReanchorTransaction(t *testing.T) { OutputIndex: 1, }, }}, - Outputs: []*bitcoin.TransactionOutput{{Value: 590000}}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 590000, + PublicKeyScript: targetWalletScript, + }}, + } + + wrongScriptTx := &bitcoin.Transaction{ + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHash, + OutputIndex: 1, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 590000, + PublicKeyScript: otherScript, + }}, + } + + wrongValueTx := &bitcoin.Transaction{ + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHash, + OutputIndex: 1, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 600000, + PublicKeyScript: targetWalletScript, + }}, + } + + excessFeeTx := &bitcoin.Transaction{ + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHash, + OutputIndex: 1, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 500000, + PublicKeyScript: targetWalletScript, + }}, } // Same transaction hash, wrong output index: must not match. @@ -217,7 +386,10 @@ func TestFindReservationReanchorTransaction(t *testing.T) { OutputIndex: 0, }, }}, - Outputs: []*bitcoin.TransactionOutput{{Value: 590000}}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 590000, + PublicKeyScript: targetWalletScript, + }}, } wrongShapeTx := &bitcoin.Transaction{ @@ -227,10 +399,16 @@ func TestFindReservationReanchorTransaction(t *testing.T) { OutputIndex: 1, }, }}, - Outputs: []*bitcoin.TransactionOutput{{Value: 300000}, {Value: 290000}}, + Outputs: []*bitcoin.TransactionOutput{ + {Value: 300000, PublicKeyScript: targetWalletScript}, + {Value: 290000, PublicKeyScript: targetWalletScript}, + }, } - event := &tbtc.ReservationReanchorRequestedEvent{} + event := &tbtc.ReservationReanchorRequestedEvent{ + TargetWalletPublicKeyHash: targetWalletPKH, + TxMaxFee: 20000, + } t.Run("finds the matching transaction among candidates", func(t *testing.T) { found, err := findReservationReanchorTransaction( @@ -259,6 +437,48 @@ func TestFindReservationReanchorTransaction(t *testing.T) { t.Errorf("expected nil, got %v", found) } }) + + t.Run("skips transaction with wrong output script", func(t *testing.T) { + found, err := findReservationReanchorTransaction( + event, + anchorUtxo, + []*bitcoin.Transaction{wrongScriptTx}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if found != nil { + t.Errorf("expected nil for wrong script transaction, got %v", found) + } + }) + + t.Run("skips transaction with wrong output value", func(t *testing.T) { + found, err := findReservationReanchorTransaction( + event, + anchorUtxo, + []*bitcoin.Transaction{wrongValueTx}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if found != nil { + t.Errorf("expected nil for wrong value transaction, got %v", found) + } + }) + + t.Run("skips transaction with excess fee", func(t *testing.T) { + found, err := findReservationReanchorTransaction( + event, + anchorUtxo, + []*bitcoin.Transaction{excessFeeTx}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if found != nil { + t.Errorf("expected nil for excess fee transaction, got %v", found) + } + }) } // TestProveReservationTransaction covers the submit-vs-skip decision: a @@ -371,10 +591,9 @@ func TestProveReservationTransaction(t *testing.T) { // top-level orchestration function wired into production via // runReservationProofLoop: it seeds a requested event, a matching pending // action, and a matching wallet transaction, then asserts the submit hook -// fires with the correct (reservationKey, requestNonce) pair. A -// regression that swapped the acceptance and re-anchor submitters (or -// mixed up their arguments) would show up here, not just in the -// lower-level helper unit tests above. +// fires with the correct (reservationKey, requestNonce) pair on the first pass. +// On a second pass with the on-chain action state mutated to Settled, it +// asserts no second submission occurs and the event is evicted from the pending map. func TestProveReservationAcceptanceActions(t *testing.T) { const proofStart = 790270 diff := func(d int64) *big.Int { return big.NewInt(d) } @@ -408,6 +627,10 @@ func TestProveReservationAcceptanceActions(t *testing.T) { reservationKey := spvChain.BuildDepositKey(fundingTxHash, 0) const requestNonce = 1 + spvChain.setDepositRequest(fundingTxHash, 0, &tbtc.DepositChainRequest{ + Amount: 150000, + }) + 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 { @@ -471,9 +694,10 @@ func TestProveReservationAcceptanceActions(t *testing.T) { } config := Config{TransactionLimit: 100} + scanState := newReservationProofScanState() if err := proveReservationAcceptanceActions( - newReservationProofScanState(), + scanState, config, spvChain, spvChain, @@ -499,6 +723,36 @@ func TestProveReservationAcceptanceActions(t *testing.T) { submittedRequestNonce, ) } + + // Second pass: action transitions to Settled. Verify it is not resubmitted + // and is evicted from the pending map. + spvChain.setReservationAction( + reservationKey, + requestNonce, + &tbtc.ReservationAction{ + State: tbtc.ReservationActionStateSettled, + ActionType: tbtc.ReservationActionTypeAcceptance, + TargetWalletPublicKeyHash: walletPublicKeyHash, + }, + ) + + if err := proveReservationAcceptanceActions( + scanState, + config, + spvChain, + spvChain, + btcChain, + ); err != nil { + t.Fatalf("unexpected error on second pass: %v", err) + } + + if submissions != 1 { + t.Errorf("expected submissions to remain 1 on second pass, got %d", submissions) + } + key := reservationEventKey(reservationKey, requestNonce) + if _, exists := scanState.pendingAcceptanceEvents[key]; exists { + t.Errorf("expected settled event to be evicted from pendingAcceptanceEvents") + } } // TestProveReservationReanchorActions is an end-to-end test of the @@ -506,9 +760,9 @@ func TestProveReservationAcceptanceActions(t *testing.T) { // runReservationProofLoop: it seeds a requested event, a matching // reservation with an anchor UTXO, a matching pending action, and a // matching wallet transaction, then asserts the submit hook fires with the -// correct (reservationKey, requestNonce) pair. A regression that swapped -// the acceptance and re-anchor submitters (or mixed up their arguments) -// would show up here, not just in the lower-level helper unit tests above. +// correct (reservationKey, requestNonce) pair on the first pass. +// On a second pass with the on-chain action state mutated to Settled, it +// asserts no second submission occurs and the event is evicted from the pending map. func TestProveReservationReanchorActions(t *testing.T) { const proofStart = 790270 diff := func(d int64) *big.Int { return big.NewInt(d) } @@ -586,6 +840,7 @@ func TestProveReservationReanchorActions(t *testing.T) { ReservationKey: reservationKey, RequestNonce: requestNonce, SourceWalletPublicKeyHash: sourceWalletPublicKeyHash, + TargetWalletPublicKeyHash: sourceWalletPublicKeyHash, BlockNumber: 500, }) spvChain.setReservationAction( @@ -619,9 +874,10 @@ func TestProveReservationReanchorActions(t *testing.T) { } config := Config{TransactionLimit: 100} + scanState := newReservationProofScanState() if err := proveReservationReanchorActions( - newReservationProofScanState(), + scanState, config, spvChain, spvChain, @@ -647,4 +903,157 @@ func TestProveReservationReanchorActions(t *testing.T) { submittedRequestNonce, ) } + + // Second pass: action transitions to Settled. Verify it is not resubmitted + // and is evicted from the pending map. + spvChain.setReservationAction( + reservationKey, + requestNonce, + &tbtc.ReservationAction{ + State: tbtc.ReservationActionStateSettled, + ActionType: tbtc.ReservationActionTypeReanchor, + TargetWalletPublicKeyHash: sourceWalletPublicKeyHash, + }, + ) + + if err := proveReservationReanchorActions( + scanState, + config, + spvChain, + spvChain, + btcChain, + ); err != nil { + t.Fatalf("unexpected error on second pass: %v", err) + } + + if submissions != 1 { + t.Errorf("expected submissions to remain 1 on second pass, got %d", submissions) + } + key := reservationEventKey(reservationKey, requestNonce) + if _, exists := scanState.pendingReanchorEvents[key]; exists { + t.Errorf("expected settled event to be evicted from pendingReanchorEvents") + } +} + +func TestProveReservationAcceptanceActions_EvictsOnExceededRetries(t *testing.T) { + spvChain := newLocalChain() + btcChain := newLocalBitcoinChain() + + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(1000) + spvChain.setBlockCounter(blockCounter) + + reservationKey := big.NewInt(12345) + const requestNonce = 1 + + spvChain.addReservationAcceptanceRequestedEvent(&tbtc.ReservationAcceptanceRequestedEvent{ + ReservationKey: reservationKey, + RequestNonce: requestNonce, + WalletPublicKeyHash: [20]byte{1}, + BlockNumber: 500, + }) + // Intentionally do NOT set the reservation action on spvChain, so GetReservationAction fails. + + scanState := newReservationProofScanState() + config := Config{TransactionLimit: 100} + key := reservationEventKey(reservationKey, requestNonce) + + for i := uint(1); i < maxReservationActionLoadRetries; i++ { + if err := proveReservationAcceptanceActions( + scanState, + config, + spvChain, + spvChain, + btcChain, + ); err != nil { + t.Fatalf("unexpected error on pass %d: %v", i, err) + } + + if _, exists := scanState.pendingAcceptanceEvents[key]; !exists { + t.Fatalf("expected event to remain pending on pass %d (retries %d)", i, i) + } + if scanState.acceptanceRetries[key] != i { + t.Errorf("expected retries to be %d, got %d", i, scanState.acceptanceRetries[key]) + } + } + + // Final pass: should exceed max retries and be evicted. + if err := proveReservationAcceptanceActions( + scanState, + config, + spvChain, + spvChain, + btcChain, + ); err != nil { + t.Fatalf("unexpected error on final pass: %v", err) + } + + if _, exists := scanState.pendingAcceptanceEvents[key]; exists { + t.Errorf("expected event to be evicted after exceeding max retries") + } + if _, exists := scanState.acceptanceRetries[key]; exists { + t.Errorf("expected retry entry to be cleaned up after eviction") + } +} + +func TestProveReservationReanchorActions_EvictsOnExceededRetries(t *testing.T) { + spvChain := newLocalChain() + btcChain := newLocalBitcoinChain() + + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(1000) + spvChain.setBlockCounter(blockCounter) + + reservationKey := big.NewInt(67890) + const requestNonce = 1 + + spvChain.addReservationReanchorRequestedEvent(&tbtc.ReservationReanchorRequestedEvent{ + ReservationKey: reservationKey, + RequestNonce: requestNonce, + SourceWalletPublicKeyHash: [20]byte{2}, + TargetWalletPublicKeyHash: [20]byte{2}, + BlockNumber: 500, + }) + // Intentionally do NOT set the reservation action on spvChain, so GetReservationAction fails. + + scanState := newReservationProofScanState() + config := Config{TransactionLimit: 100} + key := reservationEventKey(reservationKey, requestNonce) + + for i := uint(1); i < maxReservationActionLoadRetries; i++ { + if err := proveReservationReanchorActions( + scanState, + config, + spvChain, + spvChain, + btcChain, + ); err != nil { + t.Fatalf("unexpected error on pass %d: %v", i, err) + } + + if _, exists := scanState.pendingReanchorEvents[key]; !exists { + t.Fatalf("expected event to remain pending on pass %d (retries %d)", i, i) + } + if scanState.reanchorRetries[key] != i { + t.Errorf("expected retries to be %d, got %d", i, scanState.reanchorRetries[key]) + } + } + + // Final pass: should exceed max retries and be evicted. + if err := proveReservationReanchorActions( + scanState, + config, + spvChain, + spvChain, + btcChain, + ); err != nil { + t.Fatalf("unexpected error on final pass: %v", err) + } + + if _, exists := scanState.pendingReanchorEvents[key]; exists { + t.Errorf("expected event to be evicted after exceeding max retries") + } + if _, exists := scanState.reanchorRetries[key]; exists { + t.Errorf("expected retry entry to be cleaned up after eviction") + } } From 09c03b09f518e1d967513d0e3f47a18d15488234 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 2 Sep 2026 10:56:22 +0000 Subject: [PATCH 32/39] fix(spv): bound reservation action timeout wallet scan discoverWallets ignored its own documented look-back constant and scanned from block 0. Bound the first scan, track pending actions incrementally from request events instead of re-walking every known wallet every tick, treat a non-member wallet resolution failure as an expected skip rather than a fault, and drop the duplicate resolver interface and unused notifier types. --- .../spv/reservation_action_timeout_watch.go | 333 +++++++++--------- .../reservation_action_timeout_watch_test.go | 290 +++++++++++++-- 2 files changed, 428 insertions(+), 195 deletions(-) diff --git a/pkg/maintainer/spv/reservation_action_timeout_watch.go b/pkg/maintainer/spv/reservation_action_timeout_watch.go index 7b07b9b201..ea63c06ca5 100644 --- a/pkg/maintainer/spv/reservation_action_timeout_watch.go +++ b/pkg/maintainer/spv/reservation_action_timeout_watch.go @@ -9,11 +9,21 @@ import ( "github.com/keep-network/keep-core/pkg/tbtc" ) +// reservationActionTimeoutLookBackBlocks bounds the pending-action-request event +// scan performed on the very first pass, before an incremental cursor +// exists. Mirrors reservationProofLookBackBlocks in reservation_proof_loop.go: +// 30 days at 12s/block. +const reservationActionTimeoutLookBackBlocks = uint64(216000) + +// reservationActionTimeoutWalletScanLookBackBlocks is kept as an alias for +// backward-compatibility with earlier references to the lookback window. +const reservationActionTimeoutWalletScanLookBackBlocks = reservationActionTimeoutLookBackBlocks + // ReservationActionTimeoutWatcher observes the reservation action set and // notifies the Bridge when a pending action's on-chain deadline has elapsed // without an SPV proof being submitted. // -// The Bridge uses a per-action timeout window (snaphotted in the +// The Bridge uses a per-action timeout window (snapshotted in the // ReservationAction record at nonce creation time) to bound the lag between // action creation and SPV proof submission. When the deadline passes without // a proof, the SPV maintainer is no longer eligible to settle the action @@ -26,6 +36,14 @@ import ( // is still called with the wallet member IDs as required by the Bridge // function signature so that m2+ integrations only need to add the slashing // logic without changing the call shape. +// +// The members resolver maps a wallet public key hash to the operator IDs +// composing that wallet's signing group. In production, node.ResolveWalletMembers +// resolves members for wallets the local operator co-signs and returns an error +// ("wallet not found") for other on-chain wallets. The watcher treats resolver +// errors for non-member wallets as an expected non-member condition and skips +// them silently at Debug log level, so the watcher only meaningfully monitors +// reservation action timeouts for wallets the local operator co-signs. type ReservationActionTimeoutWatcher struct { spvChain Chain // nowFn returns the current UNIX timestamp the watcher treats as "now" @@ -42,73 +60,28 @@ type ReservationActionTimeoutWatcher struct { // injected to keep the watcher independent of the chain interface used // to look up operator addresses (the SPV maintainer chain interface // does not expose GetOperatorID today). - membersResolver WalletMembersResolver - // lastWalletScanBlock is the block number up to which Run has already - // scanned NewWalletRegistered events, so each poll iteration only fetches - // wallets registered since the previous tick instead of rescanning the - // full history every time. Owned exclusively by Run's single goroutine; - // CheckReservationActionTimeouts (the synchronous, test/integration - // entry point) never touches it. - lastWalletScanBlock uint64 - // knownWallets tracks the full history of registered wallets so that - // every action timeout check iterates the entire set of wallets ever - // discovered, not just those registered in the most recent poll interval. - knownWallets map[[20]byte]struct{} -} + membersResolver tbtc.WalletMembersResolver -// WalletMembersResolver maps a wallet public key hash to the operator IDs -// the wallet's signing group is composed of. The Bridge uses the IDs to -// attribute slashing; m2+ will layer the actual penalty computation on top -// of the IDs carried in NotifyReservationActionTimeout. -// -// In production the resolver must look up the wallet's signing group via -// the maintenance bridge / sortition pool and translate each operator -// address to an operator ID via chain.GetOperatorID. The watcher does not -// prescribe a particular lookup because the production wiring depends on -// the sortition backend chosen for the deployment. -type WalletMembersResolver interface { - ResolveWalletMembers(walletPublicKeyHash [20]byte) ([]uint32, error) -} + acceptanceLastScannedBlock uint64 + reanchorLastScannedBlock uint64 -// WalletMembersResolverFunc adapts a plain function to the -// WalletMembersResolver interface. -type WalletMembersResolverFunc func(walletPublicKeyHash [20]byte) ([]uint32, error) - -// ResolveWalletMembers forwards the call to the wrapped function. -func (f WalletMembersResolverFunc) ResolveWalletMembers( - walletPublicKeyHash [20]byte, -) ([]uint32, error) { - return f(walletPublicKeyHash) + // pendingActions tracks still-pending reservation actions discovered from + // acceptance and re-anchor request events across successive poll passes. + pendingActions map[string]*pendingAction } -// ReservationActionTimeoutNotifier is the Bridge-facing contract for the -// action-timeout watcher. It mirrors -// `Chain.NotifyReservationActionTimeout` but is interface-typed to enable -// in-memory recorders during tests. -type ReservationActionTimeoutNotifier interface { - NotifyReservationActionTimeout( - reservationKey *big.Int, - walletMembersIDs []uint32, - ) error +type pendingAction struct { + reservationKey *big.Int + requestNonce uint64 } -// ReservationActionTimeoutNotifierFunc adapts a function to the -// ReservationActionTimeoutNotifier interface. -type ReservationActionTimeoutNotifierFunc func( - reservationKey *big.Int, - walletMembersIDs []uint32, -) error - -// NotifyReservationActionTimeout forwards the call to the wrapped function. -func (f ReservationActionTimeoutNotifierFunc) NotifyReservationActionTimeout( - reservationKey *big.Int, - walletMembersIDs []uint32, -) error { - return f(reservationKey, walletMembersIDs) +// actionEventKey identifies one reservation action generation. +func actionEventKey(reservationKey *big.Int, requestNonce uint64) string { + return fmt.Sprintf("%s#%d", reservationKey.String(), requestNonce) } // NewReservationActionTimeoutWatcher constructs a watcher bound to the -// given chain, notifier, members resolver, and poll interval. +// given chain, members resolver, and poll interval. // // The members resolver is mandatory: the watcher will refuse to operate // without it because emitting NotifyReservationActionTimeout with a nil @@ -119,7 +92,7 @@ func (f ReservationActionTimeoutNotifierFunc) NotifyReservationActionTimeout( // CheckReservationActionTimeouts calls from the integration. func NewReservationActionTimeoutWatcher( spvChain Chain, - membersResolver WalletMembersResolver, + membersResolver tbtc.WalletMembersResolver, pollInterval time.Duration, ) *ReservationActionTimeoutWatcher { return &ReservationActionTimeoutWatcher{ @@ -127,7 +100,7 @@ func NewReservationActionTimeoutWatcher( nowFn: defaultActionTimeoutNowFn, interval: pollInterval, membersResolver: membersResolver, - knownWallets: make(map[[20]byte]struct{}), + pendingActions: make(map[string]*pendingAction), } } @@ -137,23 +110,43 @@ func defaultActionTimeoutNowFn() uint32 { return uint32(time.Now().Unix()) } -// reservationActionTimeoutWalletScanLookBackBlocks bounds the first wallet -// discovery scan Run performs. Mirrors the 30-day-at-12s/block convention -// used elsewhere in this package (e.g. DefaultReservationStaleDepositPollInterval's -// sibling deposit scan). Subsequent scans are incremental from the last -// scanned block, so this bound only matters once, at startup. -const reservationActionTimeoutWalletScanLookBackBlocks = uint64(216000) +// nextScanRange calculates the start and current block numbers for the next +// event scan. On the first scan (lastScannedBlock == 0), the scan window is +// bounded by reservationActionTimeoutLookBackBlocks. On subsequent scans, it +// resumes from lastScannedBlock + 1. +func (ratw *ReservationActionTimeoutWatcher) nextScanRange( + lastScannedBlock uint64, +) (startBlock uint64, currentBlock uint64, err error) { + blockCounter, err := ratw.spvChain.BlockCounter() + if err != nil { + return 0, 0, fmt.Errorf("failed to get block counter: [%v]", err) + } + + currentBlock, err = blockCounter.CurrentBlock() + if err != nil { + return 0, 0, fmt.Errorf("failed to get current block: [%v]", err) + } + + if lastScannedBlock == 0 { + if currentBlock > reservationActionTimeoutLookBackBlocks { + startBlock = currentBlock - reservationActionTimeoutLookBackBlocks + } else { + startBlock = 0 + } + } else { + startBlock = lastScannedBlock + 1 + } + + return startBlock, currentBlock, nil +} // Run starts the background poll loop. It returns when ctx is done or when // a fatal configuration error is detected. // -// Each iteration discovers every wallet registered on-chain (incrementally, -// past the block already scanned by the previous iteration), enumerates the -// reservations currently custodied by each wallet, and calls -// CheckReservationActionTimeouts for each one. The loop is best-effort for -// per-reservation failures: a single reservation's error is logged and the -// walk continues with the next one; only a startup configuration error -// (nil resolver, non-positive interval) aborts the loop. +// Each iteration discovers new reservation acceptance and re-anchor action +// request events incrementally, updates the tracked pending actions set, +// removes actions that are no longer pending, and calls +// CheckReservationActionTimeouts on any overdue pending action. func (ratw *ReservationActionTimeoutWatcher) Run(ctx context.Context) error { if ratw.membersResolver == nil { return fmt.Errorf( @@ -176,113 +169,119 @@ func (ratw *ReservationActionTimeoutWatcher) Run(ctx context.Context) error { case <-ticker.C: } - wallets, err := ratw.discoverWallets() - if err != nil { - logger.Errorf( - "action-timeout watcher failed to discover wallets: [%v]", - err, - ) - continue - } - - now := ratw.nowFn() - - for _, walletPublicKeyHash := range wallets { - reservationKeys, err := ratw.spvChain.WalletReservations( - walletPublicKeyHash, - ) - if err != nil { - logger.Errorf( - "action-timeout watcher failed to list reservations "+ - "for wallet [0x%x]: [%v]", - walletPublicKeyHash, - err, - ) - continue - } - - for _, reservationKey := range reservationKeys { - if err := ratw.CheckReservationActionTimeouts( - reservationKey, - now, - ); err != nil { - logger.Errorf( - "action-timeout watcher failed to check "+ - "reservation [%v]: [%v]", - reservationKey, - err, - ) - } - } + if err := ratw.pollPendingActions(); err != nil { + logger.Errorf("action-timeout watcher poll failed: [%v]", err) } } } -// discoverWallets returns the public key hashes of every wallet registered -// on-chain, minus those that have been observed Closed or Terminated. -// The wallet list is incrementally updated across calls; the watcher -// maintains the knownWallets set, which is periodically pruned of -// closed or terminated wallets to keep the discovery loop efficient. -func (ratw *ReservationActionTimeoutWatcher) discoverWallets() ([][20]byte, error) { - blockCounter, err := ratw.spvChain.BlockCounter() - if err != nil { - return nil, fmt.Errorf("failed to get block counter: [%v]", err) - } - - currentBlock, err := blockCounter.CurrentBlock() +// pollPendingActions scans for newly requested reservation actions, updates the +// pendingActions map, evicts actions that are no longer pending, and checks +// overdue actions for timeout. +func (ratw *ReservationActionTimeoutWatcher) pollPendingActions() error { + // 1. Scan new ReservationAcceptanceRequestedEvents + acceptanceStartBlock, acceptanceCurrentBlock, err := ratw.nextScanRange( + ratw.acceptanceLastScannedBlock, + ) if err != nil { - return nil, fmt.Errorf("failed to get current block: [%v]", err) + return fmt.Errorf("failed to get acceptance scan range: [%v]", err) } - startBlock := ratw.lastWalletScanBlock - // If lastWalletScanBlock is 0, we scan from block 0. - - events, err := ratw.spvChain.PastNewWalletRegisteredEvents( - &tbtc.NewWalletRegisteredEventFilter{ - StartBlock: func() uint64 { - if ratw.lastWalletScanBlock != 0 { - return ratw.lastWalletScanBlock + 1 - } - return startBlock - }(), - EndBlock: ¤tBlock, + acceptanceEvents, err := ratw.spvChain.PastReservationAcceptanceRequestedEvents( + &tbtc.ReservationAcceptanceRequestedEventFilter{ + StartBlock: acceptanceStartBlock, + EndBlock: &acceptanceCurrentBlock, }, ) if err != nil { - return nil, fmt.Errorf( - "failed to get past new wallet registered events: [%v]", + return fmt.Errorf( + "failed to get past reservation acceptance requested events: [%v]", err, ) } - ratw.lastWalletScanBlock = currentBlock + for _, event := range acceptanceEvents { + key := actionEventKey(event.ReservationKey, event.RequestNonce) + ratw.pendingActions[key] = &pendingAction{ + reservationKey: event.ReservationKey, + requestNonce: event.RequestNonce, + } + } + ratw.acceptanceLastScannedBlock = acceptanceCurrentBlock + + // 2. Scan new ReservationReanchorRequestedEvents + reanchorStartBlock, reanchorCurrentBlock, err := ratw.nextScanRange( + ratw.reanchorLastScannedBlock, + ) + if err != nil { + return fmt.Errorf("failed to get reanchor scan range: [%v]", err) + } - for _, event := range events { - ratw.knownWallets[event.WalletPublicKeyHash] = struct{}{} + reanchorEvents, err := ratw.spvChain.PastReservationReanchorRequestedEvents( + &tbtc.ReservationReanchorRequestedEventFilter{ + StartBlock: reanchorStartBlock, + EndBlock: &reanchorCurrentBlock, + }, + ) + if err != nil { + return fmt.Errorf( + "failed to get past reservation reanchor requested events: [%v]", + err, + ) } - ratw.evictTerminatedWallets() - wallets := make([][20]byte, 0, len(ratw.knownWallets)) - for wallet := range ratw.knownWallets { - wallets = append(wallets, wallet) + for _, event := range reanchorEvents { + key := actionEventKey(event.ReservationKey, event.RequestNonce) + ratw.pendingActions[key] = &pendingAction{ + reservationKey: event.ReservationKey, + requestNonce: event.RequestNonce, + } } + ratw.reanchorLastScannedBlock = reanchorCurrentBlock - return wallets, nil -} + now := ratw.nowFn() -// evictTerminatedWallets evicts wallets that have been closed or terminated -// from the knownWallets map to keep the memory footprint and the number of -// RPC calls per poll iteration bounded to active wallets. -func (ratw *ReservationActionTimeoutWatcher) evictTerminatedWallets() { - for walletPublicKeyHash := range ratw.knownWallets { - walletData, err := ratw.spvChain.GetWallet(walletPublicKeyHash) + // 3. Re-check each tracked action and remove entries that are no longer pending + for key, item := range ratw.pendingActions { + action, err := ratw.spvChain.GetReservationAction( + item.reservationKey, + item.requestNonce, + ) if err != nil { + logger.Errorf( + "failed to load reservation action [%v]/%d: [%v]", + item.reservationKey, + item.requestNonce, + err, + ) continue } - if walletData.State == tbtc.StateClosed || walletData.State == tbtc.StateTerminated { - delete(ratw.knownWallets, walletPublicKeyHash) + + if action.State != tbtc.ReservationActionStatePending { + delete(ratw.pendingActions, key) + continue + } + + if now > action.TimeoutAt { + if err := ratw.CheckReservationActionTimeouts( + item.reservationKey, + now, + ); err != nil { + logger.Errorf( + "action-timeout watcher failed to check reservation [%v]: [%v]", + item.reservationKey, + err, + ) + } else { + // Once a timeout check has successfully completed (either notified + // or cleanly skipped for non-member/empty set), remove it from + // pendingActions so subsequent poll ticks do not repeat notifications. + delete(ratw.pendingActions, key) + } } } + + return nil } // CheckReservationActionTimeouts inspects the current action generation of a @@ -334,9 +333,6 @@ func (ratw *ReservationActionTimeoutWatcher) CheckReservationActionTimeouts( walletPublicKeyHash := reservation.WalletPublicKeyHash if walletPublicKeyHash == ([20]byte{}) { - // finding #22: A reservation with no assigned wallet is structurally - // unreachable by the stranding watcher; we skip it until its chain - // state supplies a wallet, with no other component providing recovery. logger.Debugf("reservation [%v] has no wallet; skipping", reservationKey) return nil } @@ -378,20 +374,23 @@ func (ratw *ReservationActionTimeoutWatcher) CheckReservationActionTimeouts( memberIDs, err := ratw.membersResolver.ResolveWalletMembers(walletPublicKeyHash) if err != nil { - return fmt.Errorf("could not resolve wallet members [%x]: %w", walletPublicKeyHash, err) + logger.Debugf( + "operator is not a member of wallet [0x%x]; skipping action timeout check for reservation [%v]: [%v]", + walletPublicKeyHash, + reservationKey, + err, + ) + return nil } if len(memberIDs) == 0 { - // Emitting NotifyReservationActionTimeout with a nil/empty member - // slice is ill-formed on the Bridge side (see - // NewReservationActionTimeoutWatcher's doc). Refuse rather than - // notify on partial information: a misconfigured members resolver - // must fail loud, not silently strand the slashing attribution. - return fmt.Errorf( + logger.Debugf( "wallet [0x%x] members resolver returned an empty set; "+ - "refusing to notify with no attributable members", + "skipping action timeout check for reservation [%v]", walletPublicKeyHash, + reservationKey, ) + return nil } if err := ratw.spvChain.NotifyReservationActionTimeout( diff --git a/pkg/maintainer/spv/reservation_action_timeout_watch_test.go b/pkg/maintainer/spv/reservation_action_timeout_watch_test.go index bc08c08fd2..c24f9c2ef0 100644 --- a/pkg/maintainer/spv/reservation_action_timeout_watch_test.go +++ b/pkg/maintainer/spv/reservation_action_timeout_watch_test.go @@ -13,7 +13,7 @@ import ( ) // recordingActionTimeoutMembers is a test double for the -// WalletMembersResolver interface. It returns the operator IDs configured +// tbtc.WalletMembersResolver interface. It returns the operator IDs configured // at construction time and records the wallet PKHs it was asked to resolve. type recordingActionTimeoutMembers struct { walletIDs map[[20]byte][]uint32 @@ -260,8 +260,12 @@ func TestReservationActionTimeoutWatcher_MembersResolverError(t *testing.T) { wallet := walletPKH() key := reservationKey(0xC006) + // In production, node.ResolveWalletMembers errors ("wallet not found") for + // wallets the local operator is not a signing member of. The watcher must + // treat this as an expected non-membership condition and skip cleanly without + // returning an error or notifying. resolver := &recordingActionTimeoutMembers{ - errByPKH: map[[20]byte]error{wallet: errors.New("oops")}, + errByPKH: map[[20]byte]error{wallet: errors.New("wallet not found")}, } seededReservation( t, @@ -278,14 +282,47 @@ func TestReservationActionTimeoutWatcher_MembersResolverError(t *testing.T) { ) watcher := NewReservationActionTimeoutWatcher(spvChain, resolver, 0) - if err := watcher.CheckReservationActionTimeouts(key, 5_000); err == nil { - t.Fatal("expected error from resolver, got nil") + if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { + t.Fatalf("expected nil error on resolver non-member error, got: %v", err) } if calls := spvChain.getSubmittedReservationActionTimeouts(); len(calls) != 0 { t.Fatalf("no notifications should fire on resolver error, got %d", len(calls)) } } +func TestReservationActionTimeoutWatcher_MembersResolverEmpty(t *testing.T) { + spvChain := newLocalChain() + + wallet := walletPKH() + key := reservationKey(0xC007) + + // An empty member set must also skip cleanly without emitting a notification. + resolver := &recordingActionTimeoutMembers{ + walletIDs: map[[20]byte][]uint32{wallet: {}}, + } + seededReservation( + t, + spvChain, + key, + wallet, + []*tbtc.ReservationAction{ + { + State: tbtc.ReservationActionStatePending, + TimeoutAt: 100, + }, + }, + 1, + ) + + watcher := NewReservationActionTimeoutWatcher(spvChain, resolver, 0) + if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { + t.Fatalf("expected nil error on empty member set, got: %v", err) + } + if calls := spvChain.getSubmittedReservationActionTimeouts(); len(calls) != 0 { + t.Fatalf("no notifications should fire on empty member set, got %d", len(calls)) + } +} + func TestReservationActionTimeoutWatcher_NilResolverError(t *testing.T) { spvChain := newLocalChain() @@ -377,14 +414,65 @@ func TestReservationActionTimeoutWatcher_NotifierErrorPropagates(t *testing.T) { } } -func TestReservationActionTimeoutWatcher_RunLoop(t *testing.T) { +func TestReservationActionTimeoutWatcher_NextScanRange(t *testing.T) { + spvChain := newLocalChain() + blockCounter := newMockBlockCounter() + spvChain.setBlockCounter(blockCounter) + + resolver := &recordingActionTimeoutMembers{} + watcher := NewReservationActionTimeoutWatcher(spvChain, resolver, time.Minute) + + // Case 1: First scan (lastScannedBlock == 0) and currentBlock > lookback. + blockCounter.SetCurrentBlock(300_000) + startBlock, currentBlock, err := watcher.nextScanRange(0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expectedStart := uint64(300_000) - reservationActionTimeoutLookBackBlocks + if startBlock != expectedStart { + t.Errorf("expected start block %d, got %d", expectedStart, startBlock) + } + if currentBlock != 300_000 { + t.Errorf("expected current block 300000, got %d", currentBlock) + } + + // Case 2: First scan (lastScannedBlock == 0) and currentBlock <= lookback. + blockCounter.SetCurrentBlock(100_000) + startBlock, currentBlock, err = watcher.nextScanRange(0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if startBlock != 0 { + t.Errorf("expected start block 0, got %d", startBlock) + } + if currentBlock != 100_000 { + t.Errorf("expected current block 100000, got %d", currentBlock) + } + + // Case 3: Subsequent scan (lastScannedBlock > 0). + blockCounter.SetCurrentBlock(500_000) + startBlock, currentBlock, err = watcher.nextScanRange(450_000) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if startBlock != 450_001 { + t.Errorf("expected start block 450001, got %d", startBlock) + } + if currentBlock != 500_000 { + t.Errorf("expected current block 500000, got %d", currentBlock) + } +} + +func TestReservationActionTimeoutWatcher_RunLoop_IncrementalTracking(t *testing.T) { spvChain := newLocalChain() blockCounter := newMockBlockCounter() blockCounter.SetCurrentBlock(1000) spvChain.setBlockCounter(blockCounter) + wallet1 := walletPKH() + members := []uint32{1, 2, 3} resolver := &recordingActionTimeoutMembers{ - walletIDs: map[[20]byte][]uint32{}, + walletIDs: map[[20]byte][]uint32{wallet1: members}, } pollInterval := 10 * time.Millisecond @@ -393,40 +481,84 @@ func TestReservationActionTimeoutWatcher_RunLoop(t *testing.T) { resolver, pollInterval, ) - ratw.nowFn = func() uint32 { return 100 } + ratw.nowFn = func() uint32 { return 500 } ctx, cancel := context.WithCancel(context.Background()) defer cancel() - // Tick 1: register wallet 1, within the first scan window. - wallet1 := [20]byte{1} - spvChain.addNewWalletRegisteredEvent(&tbtc.NewWalletRegisteredEvent{ + // Tick 1: acceptance event for key1 (nonce 1). + key1 := reservationKey(0x1001) + spvChain.addReservationAcceptanceRequestedEvent(&tbtc.ReservationAcceptanceRequestedEvent{ + ReservationKey: key1, + RequestNonce: 1, WalletPublicKeyHash: wallet1, BlockNumber: 500, }) - spvChain.setWallet(wallet1, &tbtc.WalletChainData{State: tbtc.StateLive}) + seededReservation( + t, + spvChain, + key1, + wallet1, + []*tbtc.ReservationAction{ + { + State: tbtc.ReservationActionStatePending, + TimeoutAt: 100, // Timed out (now=500 > 100) + }, + }, + 1, + ) errChan := make(chan error, 1) go func() { errChan <- ratw.Run(ctx) }() - // Wait for tick 1 to run discoverWallets and pick up wallet 1. + // Wait for tick 1 to process key1. time.Sleep(50 * time.Millisecond) - // Tick 2: register wallet 2 (block number past the tick-1 cursor, so - // the incremental scan actually picks it up) and close wallet 1, which - // must be evicted from knownWallets on this pass. + // Verify key1 was notified. + calls := spvChain.getSubmittedReservationActionTimeouts() + if len(calls) != 1 { + t.Fatalf("expected 1 notification after tick 1, got %d", len(calls)) + } + if diff := deep.Equal(key1, calls[0].reservationKey); diff != nil { + t.Errorf("unexpected notified key: %v", diff) + } + + // Tick 2: key1 is now settled (no longer pending), and a reanchor event + // arrives for key2 (nonce 2) at block 1500. + spvChain.setReservationAction(key1, 1, &tbtc.ReservationAction{ + State: tbtc.ReservationActionStateSettled, + TimeoutAt: 100, + }) + blockCounter.SetCurrentBlock(2000) - wallet2 := [20]byte{2} - spvChain.addNewWalletRegisteredEvent(&tbtc.NewWalletRegisteredEvent{ - WalletPublicKeyHash: wallet2, - BlockNumber: 1500, + key2 := reservationKey(0x1002) + spvChain.addReservationReanchorRequestedEvent(&tbtc.ReservationReanchorRequestedEvent{ + ReservationKey: key2, + RequestNonce: 2, + SourceWalletPublicKeyHash: wallet1, + BlockNumber: 1500, }) - spvChain.setWallet(wallet2, &tbtc.WalletChainData{State: tbtc.StateLive}) - spvChain.setWallet(wallet1, &tbtc.WalletChainData{State: tbtc.StateClosed}) + seededReservation( + t, + spvChain, + key2, + wallet1, + []*tbtc.ReservationAction{ + { + State: tbtc.ReservationActionStateSettled, + TimeoutAt: 100, + }, + { + State: tbtc.ReservationActionStatePending, + TimeoutAt: 200, // Timed out (now=500 > 200) + }, + }, + 2, + ) - // Wait for tick 2 to observe the new wallet and the eviction. + // Wait for tick 2 to process key2 and evict key1. time.Sleep(50 * time.Millisecond) cancel() @@ -434,12 +566,114 @@ func TestReservationActionTimeoutWatcher_RunLoop(t *testing.T) { t.Errorf("Run returned error: %v", err) } - // Assertions run only after Run has fully returned, so knownWallets is - // no longer being mutated concurrently by the background goroutine. - if _, ok := ratw.knownWallets[wallet1]; ok { - t.Errorf("wallet 1 should have been evicted after being observed Closed") + // Total timeout notifications should now be 2 (key1 on tick 1, key2 on tick 2). + calls = spvChain.getSubmittedReservationActionTimeouts() + if len(calls) != 2 { + t.Fatalf("expected 2 notifications after tick 2, got %d", len(calls)) + } + if diff := deep.Equal(key2, calls[1].reservationKey); diff != nil { + t.Errorf("unexpected second notified key: %v", diff) + } + + // key1 should have been evicted from pendingActions because it became Settled. + key1EventKey := actionEventKey(key1, 1) + if _, ok := ratw.pendingActions[key1EventKey]; ok { + t.Errorf("key1 should have been evicted from pendingActions once Settled") + } +} + +func TestReservationActionTimeoutWatcher_RunLoop_BoundedFirstScan(t *testing.T) { + spvChain := newLocalChain() + blockCounter := newMockBlockCounter() + // Set current block high enough that lookback applies. + currentBlock := uint64(500_000) + blockCounter.SetCurrentBlock(currentBlock) + spvChain.setBlockCounter(blockCounter) + + wallet1 := walletPKH() + members := []uint32{1, 2, 3} + resolver := &recordingActionTimeoutMembers{ + walletIDs: map[[20]byte][]uint32{wallet1: members}, + } + + pollInterval := 10 * time.Millisecond + ratw := NewReservationActionTimeoutWatcher( + spvChain, + resolver, + pollInterval, + ) + ratw.nowFn = func() uint32 { return 500 } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Event 1 is old: block 100,000 (before startBlock = 500,000 - 216,000 = 284,000). + oldKey := reservationKey(0x9001) + spvChain.addReservationAcceptanceRequestedEvent(&tbtc.ReservationAcceptanceRequestedEvent{ + ReservationKey: oldKey, + RequestNonce: 1, + WalletPublicKeyHash: wallet1, + BlockNumber: 100_000, + }) + seededReservation( + t, + spvChain, + oldKey, + wallet1, + []*tbtc.ReservationAction{ + { + State: tbtc.ReservationActionStatePending, + TimeoutAt: 100, + }, + }, + 1, + ) + + // Event 2 is within lookback: block 300,000. + recentKey := reservationKey(0x9002) + spvChain.addReservationAcceptanceRequestedEvent(&tbtc.ReservationAcceptanceRequestedEvent{ + ReservationKey: recentKey, + RequestNonce: 1, + WalletPublicKeyHash: wallet1, + BlockNumber: 300_000, + }) + seededReservation( + t, + spvChain, + recentKey, + wallet1, + []*tbtc.ReservationAction{ + { + State: tbtc.ReservationActionStatePending, + TimeoutAt: 100, + }, + }, + 1, + ) + + errChan := make(chan error, 1) + go func() { + errChan <- ratw.Run(ctx) + }() + + time.Sleep(50 * time.Millisecond) + cancel() + if err := <-errChan; err != nil { + t.Errorf("Run returned error: %v", err) + } + + // Only recentKey should have been discovered and notified. + calls := spvChain.getSubmittedReservationActionTimeouts() + if len(calls) != 1 { + t.Fatalf("expected exactly 1 notification (recent event only), got %d", len(calls)) + } + if diff := deep.Equal(recentKey, calls[0].reservationKey); diff != nil { + t.Errorf("unexpected notified key: %v", diff) } - if _, ok := ratw.knownWallets[wallet2]; !ok { - t.Errorf("wallet 2 should have been discovered") + + // oldKey should not be tracked in pendingActions. + oldEventKey := actionEventKey(oldKey, 1) + if _, ok := ratw.pendingActions[oldEventKey]; ok { + t.Errorf("oldKey should not have been discovered by bounded initial scan") } } From 695590dcfd8ca23324ef712dc2e618f82227e115 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 2 Sep 2026 10:56:22 +0000 Subject: [PATCH 33/39] fix(spv): fix stale-deposit watch fallback and nonce lookup A GetReservationAction RPC error was treated the same as a confirmed Unknown state, letting a transient error trigger a premature stale-deposit release. Split the two branches and bound the fallback scan. Read the request nonce from chain instead of a hardcoded 1, and drop the unused OnDepositRevealed wrapper. --- .../spv/reservation_stale_deposit_watch.go | 321 ++++++++------- .../reservation_stale_deposit_watch_test.go | 373 ++++++++++++++---- 2 files changed, 480 insertions(+), 214 deletions(-) diff --git a/pkg/maintainer/spv/reservation_stale_deposit_watch.go b/pkg/maintainer/spv/reservation_stale_deposit_watch.go index 43a752bad5..51ac7655bf 100644 --- a/pkg/maintainer/spv/reservation_stale_deposit_watch.go +++ b/pkg/maintainer/spv/reservation_stale_deposit_watch.go @@ -7,14 +7,26 @@ import ( "github.com/keep-network/keep-core/pkg/tbtc" ) -// reservationAcceptanceActionNonce is the acceptance action generation -// nonce. A reservation does not exist on-chain before its first acceptance -// settles, so the acceptance is always the first action generation -// authorized against a not-yet-created reservation, mirroring -// tbtcpg.reservationAcceptanceRequestNonce. ReservationAnchorProposal's -// Unmarshal rejects a zero RequestNonce, which is the on-chain confirmation -// of this 1-based convention. -const reservationAcceptanceActionNonce uint64 = 1 +// staleDepositRevealScanLookBackBlocks bounds the reveal-timestamp fallback +// scan. 30 days at 12s/block, mirroring the convention used across this +// package. +const staleDepositRevealScanLookBackBlocks = uint64(216000) + +// StaleDepositResolution indicates the outcome of a stale deposit check +// to help callers (e.g. pollers) decide whether to keep or drop the deposit +// from tracking. +type StaleDepositResolution uint8 + +const ( + // StaleDepositResolutionUnknown is the zero value representing an unknown or errored resolution. + StaleDepositResolutionUnknown StaleDepositResolution = iota + // StaleDepositResolutionKeep indicates the deposit is still pending-stale and should be retained in the tracking set. + StaleDepositResolutionKeep + // StaleDepositResolutionDrop indicates the deposit is no longer a candidate for staleness (e.g. not reserved, live wallet, settled action) and can be dropped from tracking. + StaleDepositResolutionDrop + // StaleDepositResolutionNotified indicates the deposit was confirmed stale and the notification was submitted. + StaleDepositResolutionNotified +) // ReservationStaleDepositWatcher observes deposit-revealed events and // notifies the Bridge when a reserved deposit's acceptance window expired @@ -43,41 +55,9 @@ func NewReservationStaleDepositWatcher( } } -// OnDepositRevealed is the entry-point Bridge event for the watcher. It -// inspects the revealed deposit and decides whether to register a deferred -// stale check or skip the deposit entirely. -// -// Behavior: -// -// 1. If `IsReservedDeposit(depositKey)` is false the deposit is on the -// default sweep path; the watcher takes no action. -// 2. If the assigned wallet (`ReservedDepositWallet`) is already -// StateLive, the deposit will anchor through the normal path; the -// watcher takes no action. -// 3. Otherwise the watcher records the deposit as pending-stale and -// arranges for `CheckStaleReservedDeposit` to fire once the action -// timeout window has elapsed. The exact deferral mechanism is the -// integration step's responsibility (sleep loop, time.AfterFunc, or -// scheduled job); the watcher exposes the operation as a pure -// function so the integration can pick the right primitive. -// -// Pass an explicit `now` for deterministic tests; production wires this to -// `time.Now().Unix()` in the caller. -func (rsdw *ReservationStaleDepositWatcher) OnDepositRevealed( - depositKey *big.Int, - now uint32, -) error { - if depositKey == nil { - return fmt.Errorf("deposit key must not be nil") - } - - return rsdw.CheckStaleReservedDeposit(depositKey, now) -} - // CheckStaleReservedDeposit is the synchronous core of the watcher. It is -// invoked both by OnDepositRevealed (immediately after the event) and by -// the integration's deferred callback (once the action timeout window has -// elapsed). +// invoked by the integration's polling or deferred callback once the action +// timeout window may have elapsed. // // The function is intentionally pure: given the chain state and a `now` // timestamp, it either notifies the Bridge of a stale deposit or skips @@ -93,30 +73,30 @@ func (rsdw *ReservationStaleDepositWatcher) OnDepositRevealed( // timeout window only applies when the wallet is missing or has not // progressed to live. // 3. The action timeout has elapsed. The watcher derives the timeout -// from the reservation action record at the current nonce. If the -// action has already been advanced (Settled/TimedOut/Superseded/Vetoed), -// the deposit is no longer in the pending-stale window and the watcher -// skips it without notifying. +// from the reservation action record at the current reservation +// RequestNonce. If the action has already been advanced +// (Settled/TimedOut/Superseded/Vetoed), the deposit is no longer in +// the pending-stale window and the watcher skips it without notifying. // // Parameters: -// - depositKey: the deposit identifier reported by the Bridge. +// - depositKey: the deposit identifier reported by the Bridge. // - now: the UNIX timestamp against which the action timeout is // compared. Tests pass an explicit value; production passes // time.Now().Unix() cast to uint32. func (rsdw *ReservationStaleDepositWatcher) CheckStaleReservedDeposit( depositKey *big.Int, now uint32, -) error { - if _, ok := rsdw.notified[depositKey.String()]; ok { - return nil - } +) (StaleDepositResolution, error) { if depositKey == nil { - return fmt.Errorf("deposit key must not be nil") + return StaleDepositResolutionUnknown, fmt.Errorf("deposit key must not be nil") + } + if _, ok := rsdw.notified[depositKey.String()]; ok { + return StaleDepositResolutionNotified, nil } isReserved, err := rsdw.spvChain.IsReservedDeposit(depositKey) if err != nil { - return fmt.Errorf( + return StaleDepositResolutionUnknown, fmt.Errorf( "failed to determine if deposit [%v] is reserved: [%v]", depositKey, err, @@ -127,12 +107,12 @@ func (rsdw *ReservationStaleDepositWatcher) CheckStaleReservedDeposit( "deposit [%v] is not a reserved deposit; skipping stale check", depositKey, ) - return nil + return StaleDepositResolutionDrop, nil } walletPublicKeyHash, err := rsdw.spvChain.ReservedDepositWallet(depositKey) if err != nil { - return fmt.Errorf( + return StaleDepositResolutionUnknown, fmt.Errorf( "failed to fetch wallet for reserved deposit [%v]: [%v]", depositKey, err, @@ -148,12 +128,12 @@ func (rsdw *ReservationStaleDepositWatcher) CheckStaleReservedDeposit( "skipping stale notification", depositKey, ) - return nil + return StaleDepositResolutionDrop, nil } wallet, err := rsdw.spvChain.GetWallet(walletPublicKeyHash) if err != nil { - return fmt.Errorf( + return StaleDepositResolutionUnknown, fmt.Errorf( "failed to fetch wallet [0x%x] for reserved deposit [%v]: [%v]", walletPublicKeyHash, depositKey, @@ -170,96 +150,66 @@ func (rsdw *ReservationStaleDepositWatcher) CheckStaleReservedDeposit( depositKey, walletPublicKeyHash, ) - return nil + return StaleDepositResolutionDrop, nil } - // The action timeout is the deadline bound to the reservation action - // generation. Reserved deposits carry exactly one action generation - // (the acceptance). In m1 the reservation key and deposit key share the - // same identifier space exposed by the Bridge (ReservedDepositWallet - // and Reservation are both keyed by the same value); future revisions - // of the Bridge may introduce disjoint identifiers, in which case this - // direct use of depositKey as reservationKey must be replaced with a - // real lookup. + // In m1 the reservation key and deposit key share the same identifier + // space exposed by the Bridge (ReservedDepositWallet and Reservation + // are both keyed by the same value); future revisions of the Bridge + // may introduce disjoint identifiers, in which case this direct use + // of depositKey as reservationKey must be replaced with a real lookup. reservationKey := depositKey - action, err := rsdw.spvChain.GetReservationAction( - reservationKey, - reservationAcceptanceActionNonce, - ) + reservation, err := rsdw.spvChain.GetReservation(reservationKey) + if err != nil { + return StaleDepositResolutionUnknown, fmt.Errorf( + "failed to fetch reservation [%v]: [%v]", + reservationKey, + err, + ) + } var timeoutAt uint32 - // A raw contract-mapping read for a nonce that was never requested - // returns no error, just the zero-value struct (State == - // ReservationActionStateUnknown) - a genuine chain RPC failure is the - // only case err is non-nil. Both mean "no action generation exists". - if err != nil || action.State == tbtc.ReservationActionStateUnknown { - // No acceptance action generation exists yet on-chain for this - // reservation (ReservationActionStateUnknown / not found). Derive - // the staleness deadline from the deposit's own reveal timestamp - // instead of the (nonexistent) action's TimeoutAt: find the - // DepositRevealed event for this deposit key among the wallet's - // events, then load the deposit request's RevealedAt. - events, eventsErr := rsdw.spvChain.PastDepositRevealedEvents( - &tbtc.DepositRevealedEventFilter{ - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - }, + if reservation.RequestNonce == 0 { + // No acceptance action generation has ever been requested on-chain + // for this reservation. Derive the staleness deadline from the + // deposit's own reveal timestamp instead of the (nonexistent) action's + // TimeoutAt: find the DepositRevealed event for this deposit key among + // the wallet's events, then load the deposit request's RevealedAt. + derivedTimeout, err := rsdw.deriveTimeoutFromReveal( + depositKey, + walletPublicKeyHash, ) - if eventsErr != nil { - return fmt.Errorf( - "failed to fetch deposit revealed events for staleness "+ - "deadline derivation: [%v]", - eventsErr, - ) + if err != nil { + return StaleDepositResolutionUnknown, err } - - var matchingEvent *tbtc.DepositRevealedEvent - for _, event := range events { - if rsdw.spvChain.BuildDepositKey( - event.FundingTxHash, - event.FundingOutputIndex, - ).Cmp(depositKey) == 0 { - matchingEvent = event - break - } - } - if matchingEvent == nil { - return fmt.Errorf( - "no matching DepositRevealed event for deposit [%v]", - depositKey, - ) - } - - depositRequest, found, requestErr := rsdw.spvChain.GetDepositRequest( - matchingEvent.FundingTxHash, - matchingEvent.FundingOutputIndex, + timeoutAt = derivedTimeout + } else { + action, err := rsdw.spvChain.GetReservationAction( + reservationKey, + reservation.RequestNonce, ) - if requestErr != nil { - return fmt.Errorf( - "failed to load deposit request for staleness deadline "+ - "derivation: [%v]", - requestErr, - ) - } - if !found { - return fmt.Errorf( - "deposit request not found for deposit [%v]", - depositKey, + if err != nil { + return StaleDepositResolutionUnknown, fmt.Errorf( + "failed to fetch reservation action for [%v] nonce [%d]: [%v]", + reservationKey, + reservation.RequestNonce, + err, ) } - params, paramsErr := rsdw.spvChain.ReservationParameters() - if paramsErr != nil { - return fmt.Errorf( - "failed to load reservation parameters for staleness "+ - "deadline derivation: [%v]", - paramsErr, + if action.State == tbtc.ReservationActionStateUnknown { + // Confirmed no action generation exists yet on-chain for this nonce. + // Derive the staleness deadline from the deposit's own reveal timestamp. + derivedTimeout, err := rsdw.deriveTimeoutFromReveal( + depositKey, + walletPublicKeyHash, ) - } - timeoutAt = uint32(depositRequest.RevealedAt.Unix()) + - params.ReservationActionTimeout - } else { - if action.State != tbtc.ReservationActionStatePending { + if err != nil { + return StaleDepositResolutionUnknown, err + } + timeoutAt = derivedTimeout + } else if action.State != tbtc.ReservationActionStatePending { logger.Debugf( "reservation [%v] acceptance action state=%s; "+ "deposit [%v] is no longer pending-stale; skipping", @@ -267,9 +217,10 @@ func (rsdw *ReservationStaleDepositWatcher) CheckStaleReservedDeposit( action.State, depositKey, ) - return nil + return StaleDepositResolutionDrop, nil + } else { + timeoutAt = action.TimeoutAt } - timeoutAt = action.TimeoutAt } if now <= timeoutAt { @@ -280,11 +231,11 @@ func (rsdw *ReservationStaleDepositWatcher) CheckStaleReservedDeposit( timeoutAt, now, ) - return nil + return StaleDepositResolutionKeep, nil } if err := rsdw.spvChain.NotifyStaleReservedDeposit(depositKey); err != nil { - return fmt.Errorf( + return StaleDepositResolutionUnknown, fmt.Errorf( "failed to notify stale reserved deposit [%v]: [%v]", depositKey, err, @@ -301,5 +252,97 @@ func (rsdw *ReservationStaleDepositWatcher) CheckStaleReservedDeposit( timeoutAt, ) - return nil + return StaleDepositResolutionNotified, nil +} + +func (rsdw *ReservationStaleDepositWatcher) deriveTimeoutFromReveal( + depositKey *big.Int, + walletPublicKeyHash [20]byte, +) (uint32, error) { + blockCounter, err := rsdw.spvChain.BlockCounter() + if err != nil { + return 0, fmt.Errorf( + "failed to get block counter for staleness deadline derivation: [%v]", + err, + ) + } + if blockCounter == nil { + return 0, fmt.Errorf( + "failed to get block counter for staleness deadline derivation: nil block counter", + ) + } + currentBlock, err := blockCounter.CurrentBlock() + if err != nil { + return 0, fmt.Errorf( + "failed to get current block for staleness deadline derivation: [%v]", + err, + ) + } + + startBlock := uint64(0) + if currentBlock > staleDepositRevealScanLookBackBlocks { + startBlock = currentBlock - staleDepositRevealScanLookBackBlocks + } + + events, eventsErr := rsdw.spvChain.PastDepositRevealedEvents( + &tbtc.DepositRevealedEventFilter{ + StartBlock: startBlock, + EndBlock: ¤tBlock, + WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + }, + ) + if eventsErr != nil { + return 0, fmt.Errorf( + "failed to fetch deposit revealed events for staleness "+ + "deadline derivation: [%v]", + eventsErr, + ) + } + + var matchingEvent *tbtc.DepositRevealedEvent + for _, event := range events { + if rsdw.spvChain.BuildDepositKey( + event.FundingTxHash, + event.FundingOutputIndex, + ).Cmp(depositKey) == 0 { + matchingEvent = event + break + } + } + if matchingEvent == nil { + return 0, fmt.Errorf( + "no matching DepositRevealed event for deposit [%v]", + depositKey, + ) + } + + depositRequest, found, requestErr := rsdw.spvChain.GetDepositRequest( + matchingEvent.FundingTxHash, + matchingEvent.FundingOutputIndex, + ) + if requestErr != nil { + return 0, fmt.Errorf( + "failed to load deposit request for staleness deadline "+ + "derivation: [%v]", + requestErr, + ) + } + if !found { + return 0, fmt.Errorf( + "deposit request not found for deposit [%v]", + depositKey, + ) + } + + params, paramsErr := rsdw.spvChain.ReservationParameters() + if paramsErr != nil { + return 0, fmt.Errorf( + "failed to load reservation parameters for staleness "+ + "deadline derivation: [%v]", + paramsErr, + ) + } + + return uint32(depositRequest.RevealedAt.Unix()) + + params.ReservationActionTimeout, nil } diff --git a/pkg/maintainer/spv/reservation_stale_deposit_watch_test.go b/pkg/maintainer/spv/reservation_stale_deposit_watch_test.go index a6165ca630..e0877638f9 100644 --- a/pkg/maintainer/spv/reservation_stale_deposit_watch_test.go +++ b/pkg/maintainer/spv/reservation_stale_deposit_watch_test.go @@ -23,6 +23,40 @@ func reservationDepositKey(low uint64) *big.Int { // timestamp arithmetic in the watcher. const reservationActionTimeout uint32 = 3600 +func seedPastDepositRevealedEvent( + t *testing.T, + spvChain *localChain, + wallet [20]byte, + fundingTxHash bitcoin.Hash, + fundingOutputIndex uint32, + currentBlock uint64, +) { + t.Helper() + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(currentBlock) + spvChain.setBlockCounter(blockCounter) + + startBlock := uint64(0) + if currentBlock > staleDepositRevealScanLookBackBlocks { + startBlock = currentBlock - staleDepositRevealScanLookBackBlocks + } + endBlock := currentBlock + if err := spvChain.addPastDepositRevealedEvent( + &tbtc.DepositRevealedEventFilter{ + StartBlock: startBlock, + EndBlock: &endBlock, + WalletPublicKeyHash: [][20]byte{wallet}, + }, + &tbtc.DepositRevealedEvent{ + FundingTxHash: fundingTxHash, + FundingOutputIndex: fundingOutputIndex, + WalletPublicKeyHash: wallet, + }, + ); err != nil { + t.Fatal(err) + } +} + func TestReservationStaleDepositWatcher_NonReservedDepositIsSkipped(t *testing.T) { spvChain := newLocalChain() @@ -30,9 +64,13 @@ func TestReservationStaleDepositWatcher_NonReservedDepositIsSkipped(t *testing.T spvChain.setReservedDeposit(reservationDepositKey(0xB001), walletPKH(), false) watcher := NewReservationStaleDepositWatcher(spvChain) - if err := watcher.CheckStaleReservedDeposit(reservationDepositKey(0xB001), 5_000); err != nil { + res, err := watcher.CheckStaleReservedDeposit(reservationDepositKey(0xB001), 5_000) + if err != nil { t.Fatalf("unexpected error: %v", err) } + if res != StaleDepositResolutionDrop { + t.Fatalf("expected resolution %v, got %v", StaleDepositResolutionDrop, res) + } if calls := spvChain.getSubmittedStaleReservedDeposits(); len(calls) != 0 { t.Fatalf("non-reserved deposit must not notify, got %d calls", len(calls)) @@ -50,9 +88,13 @@ func TestReservationStaleDepositWatcher_LiveWalletDoesNotNotify(t *testing.T) { }) watcher := NewReservationStaleDepositWatcher(spvChain) - if err := watcher.CheckStaleReservedDeposit(key, 10_000); err != nil { + res, err := watcher.CheckStaleReservedDeposit(key, 10_000) + if err != nil { t.Fatalf("unexpected error: %v", err) } + if res != StaleDepositResolutionDrop { + t.Fatalf("expected resolution %v, got %v", StaleDepositResolutionDrop, res) + } if calls := spvChain.getSubmittedStaleReservedDeposits(); len(calls) != 0 { t.Fatalf("live wallet must not trigger stale notification, got %d calls", len(calls)) @@ -68,6 +110,9 @@ func TestReservationStaleDepositWatcher_NotifiesAfterTimeout(t *testing.T) { spvChain.setWallet(wallet, &tbtc.WalletChainData{ State: tbtc.StateUnknown, }) + spvChain.setReservation(key, &tbtc.Reservation{ + RequestNonce: 1, + }) // Inject the acceptance (nonce 1) action with a deadline well below // `now`. @@ -81,9 +126,13 @@ func TestReservationStaleDepositWatcher_NotifiesAfterTimeout(t *testing.T) { watcher := NewReservationStaleDepositWatcher(spvChain) // now (5_000) > action.TimeoutAt (100). - if err := watcher.CheckStaleReservedDeposit(key, 5_000); err != nil { + res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + if err != nil { t.Fatalf("unexpected error: %v", err) } + if res != StaleDepositResolutionNotified { + t.Fatalf("expected resolution %v, got %v", StaleDepositResolutionNotified, res) + } calls := spvChain.getSubmittedStaleReservedDeposits() if len(calls) != 1 { @@ -103,6 +152,9 @@ func TestReservationStaleDepositWatcher_DoesNotNotifyBeforeTimeout(t *testing.T) spvChain.setWallet(wallet, &tbtc.WalletChainData{ State: tbtc.StateUnknown, }) + spvChain.setReservation(key, &tbtc.Reservation{ + RequestNonce: 1, + }) // Action has a deadline of 10_000; we ask the watcher to evaluate at // now=5_000, which is before the deadline. @@ -115,9 +167,13 @@ func TestReservationStaleDepositWatcher_DoesNotNotifyBeforeTimeout(t *testing.T) }) watcher := NewReservationStaleDepositWatcher(spvChain) - if err := watcher.CheckStaleReservedDeposit(key, 5_000); err != nil { + res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + if err != nil { t.Fatalf("unexpected error: %v", err) } + if res != StaleDepositResolutionKeep { + t.Fatalf("expected resolution %v, got %v", StaleDepositResolutionKeep, res) + } if calls := spvChain.getSubmittedStaleReservedDeposits(); len(calls) != 0 { t.Fatalf("action not yet timed out; expected zero notifications, got %d", len(calls)) @@ -133,6 +189,9 @@ func TestReservationStaleDepositWatcher_SettledActionIsSkipped(t *testing.T) { spvChain.setWallet(wallet, &tbtc.WalletChainData{ State: tbtc.StateUnknown, }) + spvChain.setReservation(key, &tbtc.Reservation{ + RequestNonce: 1, + }) // Action is already settled (no longer pending). The watcher must skip // the stale notification even though the wall clock has passed the @@ -143,9 +202,13 @@ func TestReservationStaleDepositWatcher_SettledActionIsSkipped(t *testing.T) { }) watcher := NewReservationStaleDepositWatcher(spvChain) - if err := watcher.CheckStaleReservedDeposit(key, 5_000); err != nil { + res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + if err != nil { t.Fatalf("unexpected error: %v", err) } + if res != StaleDepositResolutionDrop { + t.Fatalf("expected resolution %v, got %v", StaleDepositResolutionDrop, res) + } if calls := spvChain.getSubmittedStaleReservedDeposits(); len(calls) != 0 { t.Fatalf("settled action must skip stale notification, got %d calls", len(calls)) @@ -159,16 +222,20 @@ func TestReservationStaleDepositWatcher_ZeroWalletSkips(t *testing.T) { spvChain.setReservedDeposit(key, [20]byte{}, true) watcher := NewReservationStaleDepositWatcher(spvChain) - if err := watcher.CheckStaleReservedDeposit(key, 5_000); err != nil { + res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + if err != nil { t.Fatalf("unexpected error: %v", err) } + if res != StaleDepositResolutionDrop { + t.Fatalf("expected resolution %v, got %v", StaleDepositResolutionDrop, res) + } if calls := spvChain.getSubmittedStaleReservedDeposits(); len(calls) != 0 { t.Fatalf("zero-wallet deposit must skip, got %d calls", len(calls)) } } -func TestReservationStaleDepositWatcher_OnDepositRevealedDelegates(t *testing.T) { +func TestReservationStaleDepositWatcher_AlreadyNotifiedReturnsNotified(t *testing.T) { spvChain := newLocalChain() key := reservationDepositKey(0xB007) @@ -177,18 +244,37 @@ func TestReservationStaleDepositWatcher_OnDepositRevealedDelegates(t *testing.T) spvChain.setWallet(wallet, &tbtc.WalletChainData{ State: tbtc.StateUnknown, }) + spvChain.setReservation(key, &tbtc.Reservation{ + RequestNonce: 1, + }) spvChain.setReservationAction(key, 1, &tbtc.ReservationAction{ State: tbtc.ReservationActionStatePending, TimeoutAt: 100, }) + spvChain.setReservationParameters(&tbtc.ReservationParameters{ + ReservationActionTimeout: reservationActionTimeout, + }) watcher := NewReservationStaleDepositWatcher(spvChain) - if err := watcher.OnDepositRevealed(key, 5_000); err != nil { + res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res != StaleDepositResolutionNotified { + t.Fatalf("expected resolution %v, got %v", StaleDepositResolutionNotified, res) + } + + // Second check for already notified deposit returns Notified without resubmitting. + res, err = watcher.CheckStaleReservedDeposit(key, 5_000) + if err != nil { t.Fatalf("unexpected error: %v", err) } + if res != StaleDepositResolutionNotified { + t.Fatalf("expected resolution %v, got %v", StaleDepositResolutionNotified, res) + } if calls := spvChain.getSubmittedStaleReservedDeposits(); len(calls) != 1 { - t.Fatalf("expected one notification, got %d", len(calls)) + t.Fatalf("expected exactly one notification, got %d", len(calls)) } } @@ -196,9 +282,13 @@ func TestReservationStaleDepositWatcher_NilDepositKeyError(t *testing.T) { spvChain := newLocalChain() watcher := NewReservationStaleDepositWatcher(spvChain) - if err := watcher.CheckStaleReservedDeposit(nil, 5_000); err == nil { + res, err := watcher.CheckStaleReservedDeposit(nil, 5_000) + if err == nil { t.Fatal("expected error for nil deposit key, got nil") } + if res != StaleDepositResolutionUnknown { + t.Fatalf("expected resolution %v, got %v", StaleDepositResolutionUnknown, res) + } } func TestReservationStaleDepositWatcher_IsReservedDepositChainError(t *testing.T) { @@ -207,10 +297,13 @@ func TestReservationStaleDepositWatcher_IsReservedDepositChainError(t *testing.T spvChain.isReservedDepositErr = fmt.Errorf("rpc unavailable") watcher := NewReservationStaleDepositWatcher(spvChain) - err := watcher.CheckStaleReservedDeposit(reservationDepositKey(0xB010), 5_000) + res, err := watcher.CheckStaleReservedDeposit(reservationDepositKey(0xB010), 5_000) if err == nil { t.Fatal("expected error when IsReservedDeposit fails, got nil") } + if res != StaleDepositResolutionUnknown { + t.Fatalf("expected resolution %v, got %v", StaleDepositResolutionUnknown, res) + } if calls := spvChain.getSubmittedStaleReservedDeposits(); len(calls) != 0 { t.Fatalf("expected no notifications on chain error, got %d", len(calls)) @@ -225,20 +318,18 @@ func TestReservationStaleDepositWatcher_ReservedDepositWalletChainError(t *testi spvChain.reservedDepositWalletErr = fmt.Errorf("rpc unavailable") watcher := NewReservationStaleDepositWatcher(spvChain) - err := watcher.CheckStaleReservedDeposit(key, 5_000) + res, err := watcher.CheckStaleReservedDeposit(key, 5_000) if err == nil { t.Fatal("expected error when ReservedDepositWallet fails, got nil") } + if res != StaleDepositResolutionUnknown { + t.Fatalf("expected resolution %v, got %v", StaleDepositResolutionUnknown, res) + } if calls := spvChain.getSubmittedStaleReservedDeposits(); len(calls) != 0 { t.Fatalf("expected no notifications on chain error, got %d", len(calls)) } } -// TestReservationStaleDepositWatcher_GetWalletChainError exercises the -// error passthrough without any special chain-double wiring: the deposit's -// assigned wallet is never registered via setWallet, so GetWallet fails -// with its natural "no wallet for given PKH" error exactly as a real chain -// would if the wallet were somehow unresolvable. func TestReservationStaleDepositWatcher_GetWalletChainError(t *testing.T) { spvChain := newLocalChain() @@ -247,24 +338,160 @@ func TestReservationStaleDepositWatcher_GetWalletChainError(t *testing.T) { // No spvChain.setWallet call: GetWallet errors naturally. watcher := NewReservationStaleDepositWatcher(spvChain) - if err := watcher.CheckStaleReservedDeposit(key, 5_000); err == nil { + res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + if err == nil { t.Fatal("expected error when GetWallet fails, got nil") } + if res != StaleDepositResolutionUnknown { + t.Fatalf("expected resolution %v, got %v", StaleDepositResolutionUnknown, res) + } + + if calls := spvChain.getSubmittedStaleReservedDeposits(); len(calls) != 0 { + t.Fatalf("expected no notifications on chain error, got %d", len(calls)) + } +} + +func TestReservationStaleDepositWatcher_GetReservationChainError(t *testing.T) { + spvChain := newLocalChain() + + key := reservationDepositKey(0xB016) + wallet := walletPKH() + spvChain.setReservedDeposit(key, wallet, true) + spvChain.setWallet(wallet, &tbtc.WalletChainData{ + State: tbtc.StateUnknown, + }) + // No spvChain.setReservation: GetReservation returns an error. + + watcher := NewReservationStaleDepositWatcher(spvChain) + res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + if err == nil { + t.Fatal("expected error when GetReservation fails, got nil") + } + if res != StaleDepositResolutionUnknown { + t.Fatalf("expected resolution %v, got %v", StaleDepositResolutionUnknown, res) + } if calls := spvChain.getSubmittedStaleReservedDeposits(); len(calls) != 0 { t.Fatalf("expected no notifications on chain error, got %d", len(calls)) } } +// TestReservationStaleDepositWatcher_GetReservationActionChainError_DoesNotNotifyEvenPastDeadline +// verifies Fix 1 (P1): a transient RPC error on GetReservationAction must be +// propagated as an error and MUST NOT fall through to the reveal-timestamp +// staleness path or trigger a premature stale deposit notification. +func TestReservationStaleDepositWatcher_GetReservationActionChainError_DoesNotNotifyEvenPastDeadline(t *testing.T) { + spvChain := newLocalChain() + + wallet := walletPKH() + fundingTxHash, err := bitcoin.NewHashFromString( + "585b6699f42291d1a9d0776b75f04c295ea203f83504349db11e94fdae7d1b2c", + bitcoin.InternalByteOrder, + ) + if err != nil { + t.Fatal(err) + } + fundingOutputIndex := uint32(0) + + key := spvChain.BuildDepositKey(fundingTxHash, fundingOutputIndex) + spvChain.setReservedDeposit(key, wallet, true) + spvChain.setWallet(wallet, &tbtc.WalletChainData{ + State: tbtc.StateUnknown, + }) + spvChain.setReservation(key, &tbtc.Reservation{ + RequestNonce: 1, + }) + // Seed deposit revealed event and request with a deadline in the past: + // RevealedAt (1_000) + Timeout (3600) = 4_600. + seedPastDepositRevealedEvent(t, spvChain, wallet, fundingTxHash, fundingOutputIndex, 0) + spvChain.setDepositRequest(fundingTxHash, fundingOutputIndex, &tbtc.DepositChainRequest{ + RevealedAt: time.Unix(1_000, 0), + }) + spvChain.setReservationParameters(&tbtc.ReservationParameters{ + ReservationActionTimeout: reservationActionTimeout, + }) + + // GetReservationAction is NOT seeded, so it returns an error ("no action for given reservation/nonce"). + watcher := NewReservationStaleDepositWatcher(spvChain) + // now = 10_000 is well past 4_600. Under the buggy code (which conflated + // err != nil with Unknown state), this would fall through to the reveal + // fallback and submit a premature stale deposit notification. + res, err := watcher.CheckStaleReservedDeposit(key, 10_000) + if err == nil { + t.Fatal("expected error on transient GetReservationAction RPC failure, got nil") + } + if res != StaleDepositResolutionUnknown { + t.Fatalf("expected resolution %v, got %v", StaleDepositResolutionUnknown, res) + } + + if calls := spvChain.getSubmittedStaleReservedDeposits(); len(calls) != 0 { + t.Fatalf( + "transient RPC error must NOT trigger stale deposit notification, got %d calls", + len(calls), + ) + } +} + +// TestReservationStaleDepositWatcher_AdvancingNonceEvaluatesActiveGeneration +// verifies Fix 2 (P2): the watcher reads reservation.RequestNonce via +// GetReservation rather than assuming a hardcoded nonce = 1. If nonce 1 +// timed out and a retry advanced the nonce to 2, the watcher must evaluate +// nonce 2's action generation. +func TestReservationStaleDepositWatcher_AdvancingNonceEvaluatesActiveGeneration(t *testing.T) { + spvChain := newLocalChain() + + key := reservationDepositKey(0xB020) + wallet := walletPKH() + spvChain.setReservedDeposit(key, wallet, true) + spvChain.setWallet(wallet, &tbtc.WalletChainData{ + State: tbtc.StateUnknown, + }) + // Reservation has advanced to nonce 2 (e.g. after retry). + spvChain.setReservation(key, &tbtc.Reservation{ + RequestNonce: 2, + }) + + // Nonce 1 is TimedOut (stale generation). + spvChain.setReservationAction(key, 1, &tbtc.ReservationAction{ + State: tbtc.ReservationActionStateTimedOut, + TimeoutAt: 100, + }) + // Nonce 2 is Pending with a timeout in the past relative to now (5_000). + spvChain.setReservationAction(key, 2, &tbtc.ReservationAction{ + State: tbtc.ReservationActionStatePending, + TimeoutAt: 200, + }) + spvChain.setReservationParameters(&tbtc.ReservationParameters{ + ReservationActionTimeout: reservationActionTimeout, + }) + + watcher := NewReservationStaleDepositWatcher(spvChain) + // now = 5_000 > nonce 2's TimeoutAt (200). + res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res != StaleDepositResolutionNotified { + t.Fatalf("expected resolution %v, got %v", StaleDepositResolutionNotified, res) + } + + calls := spvChain.getSubmittedStaleReservedDeposits() + if len(calls) != 1 { + t.Fatalf("expected one stale notification for nonce 2 timeout, got %d", len(calls)) + } + if diff := deep.Equal(key, calls[0]); diff != nil { + t.Errorf("unexpected notified key: %v", diff) + } +} + // TestReservationStaleDepositWatcher_NoActionRequestedYetPropagatesWithoutMatchingEvent // covers the "no acceptance action generation exists yet" branch -// (GetReservationAction returns State == ReservationActionStateUnknown, the -// zero value a Solidity mapping read returns for a never-requested nonce) +// (reservation.RequestNonce == 0 or action.State == ReservationActionStateUnknown) // when no matching DepositRevealed event has been seeded either: the // watcher cannot derive a staleness deadline from nothing, so it must -// still surface an error rather than silently notifying or skipping. func TestReservationStaleDepositWatcher_NoActionRequestedYetPropagatesWithoutMatchingEvent(t *testing.T) { spvChain := newLocalChain() + spvChain.setBlockCounter(newMockBlockCounter()) key := reservationDepositKey(0xB013) wallet := walletPKH() @@ -272,16 +499,18 @@ func TestReservationStaleDepositWatcher_NoActionRequestedYetPropagatesWithoutMat spvChain.setWallet(wallet, &tbtc.WalletChainData{ State: tbtc.StateUnknown, }) - // No spvChain.setReservationAction call: GetReservationAction returns - // the zero-value action (State == ReservationActionStateUnknown), not - // an error - this drives the watcher into the reveal-timestamp - // derivation branch. No DepositRevealed event is seeded either, so - // that branch cannot resolve and must itself return an error. + spvChain.setReservation(key, &tbtc.Reservation{ + RequestNonce: 0, + }) watcher := NewReservationStaleDepositWatcher(spvChain) - if err := watcher.CheckStaleReservedDeposit(key, 5_000); err == nil { + res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + if err == nil { t.Fatal("expected error when no matching deposit revealed event exists, got nil") } + if res != StaleDepositResolutionUnknown { + t.Fatalf("expected resolution %v, got %v", StaleDepositResolutionUnknown, res) + } if calls := spvChain.getSubmittedStaleReservedDeposits(); len(calls) != 0 { t.Fatalf("expected no notifications on chain error, got %d", len(calls)) @@ -289,16 +518,11 @@ func TestReservationStaleDepositWatcher_NoActionRequestedYetPropagatesWithoutMat } // TestReservationStaleDepositWatcher_NoActionRequestedYetNotifiesFromRevealTimestamp -// is the real P1 fix under test: a reserved deposit whose wallet never +// tests the reveal-timestamp derivation: a reserved deposit whose wallet never // became Live, so its acceptance action generation was never requested -// on-chain (GetReservationAction returns the zero-value -// ReservationActionStateUnknown, not an error and not a Pending action the -// old code path required to compute a deadline). The watcher must derive -// the staleness deadline from the deposit's own reveal timestamp -// (DepositRevealed event -> DepositChainRequest.RevealedAt) plus -// ReservationActionTimeout, and notify once that derived deadline has -// passed - exactly the scenario the watcher exists to catch, which the -// pre-fix code silently skipped forever. +// on-chain (RequestNonce == 0). The watcher must derive the staleness deadline +// from the deposit's own reveal timestamp plus ReservationActionTimeout, +// and notify once that derived deadline has passed. func TestReservationStaleDepositWatcher_NoActionRequestedYetNotifiesFromRevealTimestamp(t *testing.T) { spvChain := newLocalChain() @@ -317,20 +541,11 @@ func TestReservationStaleDepositWatcher_NoActionRequestedYetNotifiesFromRevealTi spvChain.setWallet(wallet, &tbtc.WalletChainData{ State: tbtc.StateUnknown, }) - // No setReservationAction: no acceptance was ever requested on-chain. + spvChain.setReservation(key, &tbtc.Reservation{ + RequestNonce: 0, + }) - if err := spvChain.addPastDepositRevealedEvent( - &tbtc.DepositRevealedEventFilter{ - WalletPublicKeyHash: [][20]byte{wallet}, - }, - &tbtc.DepositRevealedEvent{ - FundingTxHash: fundingTxHash, - FundingOutputIndex: fundingOutputIndex, - WalletPublicKeyHash: wallet, - }, - ); err != nil { - t.Fatal(err) - } + seedPastDepositRevealedEvent(t, spvChain, wallet, fundingTxHash, fundingOutputIndex, 0) spvChain.setDepositRequest(fundingTxHash, fundingOutputIndex, &tbtc.DepositChainRequest{ RevealedAt: time.Unix(1_000, 0), }) @@ -341,9 +556,13 @@ func TestReservationStaleDepositWatcher_NoActionRequestedYetNotifiesFromRevealTi watcher := NewReservationStaleDepositWatcher(spvChain) // Derived deadline = RevealedAt (1_000) + ReservationActionTimeout // (3600) = 4_600. now = 10_000 > 4_600, so the deposit is stale. - if err := watcher.CheckStaleReservedDeposit(key, 10_000); err != nil { + res, err := watcher.CheckStaleReservedDeposit(key, 10_000) + if err != nil { t.Fatalf("unexpected error: %v", err) } + if res != StaleDepositResolutionNotified { + t.Fatalf("expected resolution %v, got %v", StaleDepositResolutionNotified, res) + } calls := spvChain.getSubmittedStaleReservedDeposits() if len(calls) != 1 { @@ -356,8 +575,7 @@ func TestReservationStaleDepositWatcher_NoActionRequestedYetNotifiesFromRevealTi // TestReservationStaleDepositWatcher_NoActionRequestedYetDoesNotNotifyBeforeDerivedDeadline // mirrors the notifying case above but asks at a `now` before the derived -// deadline, asserting the watcher correctly defers rather than notifying -// early. +// deadline, asserting the watcher correctly defers rather than notifying early. func TestReservationStaleDepositWatcher_NoActionRequestedYetDoesNotNotifyBeforeDerivedDeadline(t *testing.T) { spvChain := newLocalChain() @@ -376,19 +594,11 @@ func TestReservationStaleDepositWatcher_NoActionRequestedYetDoesNotNotifyBeforeD spvChain.setWallet(wallet, &tbtc.WalletChainData{ State: tbtc.StateUnknown, }) + spvChain.setReservation(key, &tbtc.Reservation{ + RequestNonce: 0, + }) - if err := spvChain.addPastDepositRevealedEvent( - &tbtc.DepositRevealedEventFilter{ - WalletPublicKeyHash: [][20]byte{wallet}, - }, - &tbtc.DepositRevealedEvent{ - FundingTxHash: fundingTxHash, - FundingOutputIndex: fundingOutputIndex, - WalletPublicKeyHash: wallet, - }, - ); err != nil { - t.Fatal(err) - } + seedPastDepositRevealedEvent(t, spvChain, wallet, fundingTxHash, fundingOutputIndex, 0) spvChain.setDepositRequest(fundingTxHash, fundingOutputIndex, &tbtc.DepositChainRequest{ RevealedAt: time.Unix(1_000, 0), }) @@ -398,9 +608,13 @@ func TestReservationStaleDepositWatcher_NoActionRequestedYetDoesNotNotifyBeforeD watcher := NewReservationStaleDepositWatcher(spvChain) // Derived deadline = 1_000 + 3600 = 4_600. now = 2_000 < 4_600. - if err := watcher.CheckStaleReservedDeposit(key, 2_000); err != nil { + res, err := watcher.CheckStaleReservedDeposit(key, 2_000) + if err != nil { t.Fatalf("unexpected error: %v", err) } + if res != StaleDepositResolutionKeep { + t.Fatalf("expected resolution %v, got %v", StaleDepositResolutionKeep, res) + } if calls := spvChain.getSubmittedStaleReservedDeposits(); len(calls) != 0 { t.Fatalf( @@ -410,12 +624,8 @@ func TestReservationStaleDepositWatcher_NoActionRequestedYetDoesNotNotifyBeforeD } } -// TestReservationStaleDepositWatcher_NotifierError verifies that, unlike -// the stranding watcher (which continues past a notify failure because it -// processes a batch of reservations per call), the stale-deposit watcher -// propagates a NotifyStaleReservedDeposit failure to its single caller: -// CheckStaleReservedDeposit checks exactly one deposit per call, so there -// is nothing else to "continue" to. +// TestReservationStaleDepositWatcher_NotifierError verifies that the +// stale-deposit watcher propagates a NotifyStaleReservedDeposit failure. func TestReservationStaleDepositWatcher_NotifierError(t *testing.T) { spvChain := newLocalChain() @@ -425,6 +635,9 @@ func TestReservationStaleDepositWatcher_NotifierError(t *testing.T) { spvChain.setWallet(wallet, &tbtc.WalletChainData{ State: tbtc.StateUnknown, }) + spvChain.setReservation(key, &tbtc.Reservation{ + RequestNonce: 1, + }) spvChain.setReservationAction(key, 1, &tbtc.ReservationAction{ State: tbtc.ReservationActionStatePending, TimeoutAt: 100, @@ -432,16 +645,19 @@ func TestReservationStaleDepositWatcher_NotifierError(t *testing.T) { spvChain.notifyStaleReservedDepositErr = fmt.Errorf("notifier unavailable") watcher := NewReservationStaleDepositWatcher(spvChain) - if err := watcher.CheckStaleReservedDeposit(key, 5_000); err == nil { + res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + if err == nil { t.Fatal("expected error when the notifier fails, got nil") } + if res != StaleDepositResolutionUnknown { + t.Fatalf("expected resolution %v, got %v", StaleDepositResolutionUnknown, res) + } } // TestReservationStaleDepositWatcher_ExactTimeoutBoundaryDoesNotNotify // covers the `now == action.TimeoutAt` boundary explicitly: the watcher's // condition is `now <= action.TimeoutAt` (must NOT notify), so equality -// must defer exactly like "before the deadline" does. Existing tests only -// exercise now < TimeoutAt and now > TimeoutAt. +// must defer exactly like "before the deadline" does. func TestReservationStaleDepositWatcher_ExactTimeoutBoundaryDoesNotNotify(t *testing.T) { spvChain := newLocalChain() @@ -451,15 +667,22 @@ func TestReservationStaleDepositWatcher_ExactTimeoutBoundaryDoesNotNotify(t *tes spvChain.setWallet(wallet, &tbtc.WalletChainData{ State: tbtc.StateUnknown, }) + spvChain.setReservation(key, &tbtc.Reservation{ + RequestNonce: 1, + }) spvChain.setReservationAction(key, 1, &tbtc.ReservationAction{ State: tbtc.ReservationActionStatePending, TimeoutAt: 5_000, }) watcher := NewReservationStaleDepositWatcher(spvChain) - if err := watcher.CheckStaleReservedDeposit(key, 5_000); err != nil { + res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + if err != nil { t.Fatalf("unexpected error: %v", err) } + if res != StaleDepositResolutionKeep { + t.Fatalf("expected resolution %v, got %v", StaleDepositResolutionKeep, res) + } if calls := spvChain.getSubmittedStaleReservedDeposits(); len(calls) != 0 { t.Fatalf( From dbf4adea519ccbd847b4895c800231860deead26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 2 Sep 2026 10:56:22 +0000 Subject: [PATCH 34/39] fix(spv): make reservation watcher startup catch-up non-fatal A single transient RPC error against any one wallet during the stranding-watcher startup scan aborted client startup entirely. Downgrade per-wallet errors to warnings and continue; reserve fatal returns for genuine mis-wiring. Have CheckStaleReservedDeposit return its resolution directly instead of the poller re-reading state three times to decide the same thing. Drop the dead ReservationsConfig alias and the single-use goroutine wrapper. --- pkg/maintainer/spv/config.go | 15 +- pkg/maintainer/spv/reservation_wiring.go | 159 +++++----- pkg/maintainer/spv/reservation_wiring_test.go | 277 ++++++++++++++---- pkg/maintainer/spv/spv.go | 5 + 4 files changed, 311 insertions(+), 145 deletions(-) diff --git a/pkg/maintainer/spv/config.go b/pkg/maintainer/spv/config.go index dadcc7bd6e..a4a2654e8d 100644 --- a/pkg/maintainer/spv/config.go +++ b/pkg/maintainer/spv/config.go @@ -68,11 +68,14 @@ type Config struct { // more transaction proofs to submit. IdleBackoffTime time.Duration - // Reservations controls the SPV proof submission for reservation - // acceptance / re-anchor action generations. The reservation watchers are - // gated by the separate Tbtc.Reservations.Enabled flag. + // Reservations controls SPV proof submission for reservation acceptance + // and re-anchor action generations. + // + // OPERATOR NOTE: This flag only controls SPV proof submission in the + // maintainer process. Proposal generation and watcher wiring in the client + // process are gated by the separate Tbtc.Reservations.Enabled flag. + // An operator MUST enable BOTH flags ([Tbtc.Reservations] in the client + // and [Maintainer.Spv.Reservations] in the maintainer) for the reservation + // feature to work end-to-end. Reservations tbtc.ReservationsConfig } - -// ReservationsConfig is deprecated and refers to tbtc.ReservationsConfig. -type ReservationsConfig = tbtc.ReservationsConfig diff --git a/pkg/maintainer/spv/reservation_wiring.go b/pkg/maintainer/spv/reservation_wiring.go index 62f19d2fde..ce61f8ebc4 100644 --- a/pkg/maintainer/spv/reservation_wiring.go +++ b/pkg/maintainer/spv/reservation_wiring.go @@ -28,50 +28,71 @@ const DefaultReservationStaleDepositPollInterval = 1 * time.Minute // cadence. The interval is fixed. const DefaultReservationActionTimeoutPollInterval = 1 * time.Minute +// WalletClosedChain defines the chain interface required to subscribe to +// wallet close events. +type WalletClosedChain interface { + OnWalletClosed( + handler func(event *tbtc.WalletClosedEvent), + ) subscription.EventSubscription +} + // WireReservationWatchers is the integration entry point that cmd/start.go // calls directly when config.Reservations.Enabled is true. It constructs // the three reservation watchers (stranding, stale-deposit, action-timeout), // wires their Bridge-facing notifiers to the chain, and subscribes/starts // each watcher against its source. // -// `tbtcChain` supplies the On* event subscriptions the SPV-specific `Chain` -// omits; `spvChain` supplies the reservation data reads and Notify* writes. -// `ctx` controls the goroutine lifetimes started by the wiring function. +// `walletClosedChain` supplies the OnWalletClosed event subscription; +// `spvChain` supplies the reservation data reads, event queries, and +// Notify* writes. `ctx` controls the goroutine lifetimes started by the +// wiring function. func WireReservationWatchers( ctx context.Context, - tbtcChain tbtc.Chain, + walletClosedChain WalletClosedChain, spvChain Chain, walletMembersResolver tbtc.WalletMembersResolver, ) error { + if walletClosedChain == nil { + return fmt.Errorf("wallet closed chain must not be nil") + } + if spvChain == nil { + return fmt.Errorf("spv chain must not be nil") + } + if walletMembersResolver == nil { + return fmt.Errorf("wallet members resolver must not be nil") + } + + reservationWiringLogger.Infof( + "wiring reservation watchers; ensure Maintainer.Spv.Reservations.Enabled " + + "is also enabled in the SPV maintainer config for end-to-end operation", + ) + strandingWatcher := NewReservationStrandingWatcher(spvChain) // Startup catch-up scan: a wallet closed/terminated while this // maintainer was down would otherwise never notify, since the live // OnWalletClosed subscription only sees events from this point forward. - // Look back the same bounded window the other two watchers use, find - // wallets registered in that window, and check the ones already - // Closed/Terminated now. - if lastSeenBlock, err := spvChain.BlockCounter(); err != nil { - return fmt.Errorf("stranding startup scan failed to get block counter: [%w]", err) - } else if currentBlock, err := lastSeenBlock.CurrentBlock(); err != nil { - return fmt.Errorf("stranding startup scan failed to get current block: [%w]", err) - } else { - var startBlock uint64 - if currentBlock > reservationStaleDepositLookBackBlocks { - startBlock = currentBlock - reservationStaleDepositLookBackBlocks - } - - registeredEvents, err := spvChain.PastNewWalletRegisteredEvents( - &tbtc.NewWalletRegisteredEventFilter{StartBlock: startBlock}, + // We scan all past wallet registrations starting from block 0 and check + // the ones already Closed/Terminated now. Transient per-wallet errors + // log warnings rather than failing client startup. + registeredEvents, err := spvChain.PastNewWalletRegisteredEvents( + &tbtc.NewWalletRegisteredEventFilter{StartBlock: 0}, + ) + if err != nil { + reservationWiringLogger.Warnf( + "stranding startup scan failed to fetch wallet registration events: [%v]", + err, ) - if err != nil { - return fmt.Errorf("stranding startup scan failed to fetch wallet registration events: [%w]", err) - } - + } else { for _, event := range registeredEvents { wallet, err := spvChain.GetWallet(event.WalletPublicKeyHash) if err != nil { - return fmt.Errorf("stranding startup scan failed to fetch wallet [0x%x]: [%w]", event.WalletPublicKeyHash, err) + reservationWiringLogger.Warnf( + "stranding startup scan failed to fetch wallet [0x%x]: [%v]", + event.WalletPublicKeyHash, + err, + ) + continue } if wallet.State != tbtc.StateClosed && wallet.State != tbtc.StateTerminated { @@ -80,7 +101,12 @@ func WireReservationWatchers( if err := strandingWatcher.CheckReservationStrandingForWallet( event.WalletPublicKeyHash, ); err != nil { - return fmt.Errorf("stranding startup scan failed to check wallet [0x%x]: [%w]", event.WalletPublicKeyHash, err) + reservationWiringLogger.Warnf( + "stranding startup scan failed to check wallet [0x%x]: [%v]", + event.WalletPublicKeyHash, + err, + ) + continue } } } @@ -93,13 +119,20 @@ func WireReservationWatchers( DefaultReservationActionTimeoutPollInterval, ) - subscription := subscribeReservationWalletClosed(ctx, tbtcChain, spvChain, strandingWatcher) + subscription := subscribeReservationWalletClosed(ctx, walletClosedChain, spvChain, strandingWatcher) go func() { <-ctx.Done() subscription.Unsubscribe() }() - startStaleDepositPoll(ctx, tbtcChain, spvChain, staleDepositWatcher) - startActionTimeoutRun(ctx, actionTimeoutWatcher) + startStaleDepositPoll(ctx, spvChain, staleDepositWatcher) + go func() { + if err := actionTimeoutWatcher.Run(ctx); err != nil { + reservationWiringLogger.Errorf( + "failed to run reservation action-timeout watcher: [%v]", + err, + ) + } + }() return nil } @@ -111,11 +144,11 @@ func WireReservationWatchers( // runs the watcher's stranding check for that wallet. func subscribeReservationWalletClosed( ctx context.Context, - tbtcChain tbtc.Chain, + walletClosedChain WalletClosedChain, spvChain Chain, watcher *ReservationStrandingWatcher, ) subscription.EventSubscription { - return tbtcChain.OnWalletClosed(func(event *tbtc.WalletClosedEvent) { + return walletClosedChain.OnWalletClosed(func(event *tbtc.WalletClosedEvent) { go func() { select { case <-ctx.Done(): @@ -203,7 +236,6 @@ const reservationStaleDepositLookBackBlocks = uint64(216000) // failure logs and continues rather than aborting the wiring. func startStaleDepositPoll( ctx context.Context, - tbtcChain tbtc.Chain, spvChain Chain, watcher *ReservationStaleDepositWatcher, ) { @@ -243,7 +275,7 @@ func startStaleDepositPoll( startBlock = currentBlock - reservationStaleDepositLookBackBlocks } - events, err := tbtcChain.PastDepositRevealedEvents( + events, err := spvChain.PastDepositRevealedEvents( &tbtc.DepositRevealedEventFilter{ StartBlock: startBlock + 1, EndBlock: ¤tBlock, @@ -290,10 +322,11 @@ func startStaleDepositPoll( now := uint32(time.Now().Unix()) for key, depositKey := range pending { - if err := watcher.CheckStaleReservedDeposit( + resolution, err := watcher.CheckStaleReservedDeposit( depositKey, now, - ); err != nil { + ) + if err != nil { reservationWiringLogger.Errorf( "stale-deposit poll failed to check deposit "+ "[%v]: [%v]", @@ -303,65 +336,11 @@ func startStaleDepositPoll( continue } - isReserved, err := spvChain.IsReservedDeposit(depositKey) - if err != nil { - reservationWiringLogger.Errorf( - "stale-deposit poll failed to check if deposit [%v] is reserved: [%v]", - depositKey, - err, - ) - continue - } - - var walletState tbtc.WalletState - if isReserved { - walletPublicKeyHash, err := spvChain.ReservedDepositWallet(depositKey) - if err != nil || walletPublicKeyHash == ([20]byte{}) { - // treat as not resolved - } else { - wallet, err := spvChain.GetWallet(walletPublicKeyHash) - if err == nil { - walletState = wallet.State - } - } - } - - if isPendingStaleDepositResolved(isReserved, walletState) { + if resolution == StaleDepositResolutionDrop || + resolution == StaleDepositResolutionNotified { delete(pending, key) } } } }() } - -// isPendingStaleDepositResolved reports whether depositKey no longer needs -// tracking: it stopped being a reserved deposit (released or swept), or its -// assigned wallet reached StateLive (expected to anchor on its own). Chain -// read errors are treated as unresolved so a transient RPC failure does not -// silently drop a deposit that might still need the stale check. -func isPendingStaleDepositResolved( - isReserved bool, - walletState tbtc.WalletState, -) bool { - if !isReserved { - return true - } - - return walletState == tbtc.StateLive -} - -// startActionTimeoutRun starts the action-timeout watcher's Run loop in a -// goroutine, tied to ctx's lifetime. -func startActionTimeoutRun( - ctx context.Context, - watcher *ReservationActionTimeoutWatcher, -) { - go func() { - if err := watcher.Run(ctx); err != nil { - reservationWiringLogger.Errorf( - "failed to start reservation action-timeout watcher: [%v]", - err, - ) - } - }() -} diff --git a/pkg/maintainer/spv/reservation_wiring_test.go b/pkg/maintainer/spv/reservation_wiring_test.go index a380d1cd73..0ab475e385 100644 --- a/pkg/maintainer/spv/reservation_wiring_test.go +++ b/pkg/maintainer/spv/reservation_wiring_test.go @@ -3,6 +3,7 @@ package spv import ( "context" "fmt" + "math/big" "testing" "github.com/keep-network/keep-core/pkg/subscription" @@ -90,55 +91,91 @@ func TestResolveWalletPublicKeyHash(t *testing.T) { }) } -// TestIsPendingStaleDepositResolved covers the three reachable outcomes of -// the eviction predicate: a deposit still reserved on a non-Live wallet must -// stay pending (false), a deposit no longer reserved (released or swept) -// must be evicted (true), and a still-reserved deposit whose wallet reached -// StateLive must be evicted (true). -func TestIsPendingStaleDepositResolved(t *testing.T) { +// TestCheckStaleReservedDeposit_Resolution covers the resolution outcomes of +// CheckStaleReservedDeposit used by the poller to decide pending-set retention: +// a deposit still reserved with unreached timeout must be kept, non-reserved +// deposits, deposits with live wallets, or settled actions must be dropped, +// and timed-out deposits must be notified and evicted. +func TestCheckStaleReservedDeposit_Resolution(t *testing.T) { tests := map[string]struct { - isReserved bool - walletState tbtc.WalletState - expectedResolved bool + isReserved bool + walletState tbtc.WalletState + actionState tbtc.ReservationActionState + timeoutAt uint32 + now uint32 + expectedResolution StaleDepositResolution }{ - "still reserved, wallet not live": { - isReserved: true, - walletState: tbtc.StateMovingFunds, - expectedResolved: false, + "not reserved": { + isReserved: false, + walletState: tbtc.StateMovingFunds, + actionState: tbtc.ReservationActionStatePending, + timeoutAt: 100, + now: 1000, + expectedResolution: StaleDepositResolutionDrop, }, - "released (no longer reserved)": { - isReserved: false, - walletState: tbtc.StateMovingFunds, - expectedResolved: true, + "reserved, wallet live": { + isReserved: true, + walletState: tbtc.StateLive, + actionState: tbtc.ReservationActionStatePending, + timeoutAt: 100, + now: 1000, + expectedResolution: StaleDepositResolutionDrop, }, - "swept (no longer reserved), wallet already live": { - isReserved: false, - walletState: tbtc.StateLive, - expectedResolved: true, + "reserved, action settled": { + isReserved: true, + walletState: tbtc.StateMovingFunds, + actionState: tbtc.ReservationActionStateSettled, + timeoutAt: 100, + now: 1000, + expectedResolution: StaleDepositResolutionDrop, }, - "still reserved, wallet now live": { - isReserved: true, - walletState: tbtc.StateLive, - expectedResolved: true, + "reserved, timeout not yet reached": { + isReserved: true, + walletState: tbtc.StateMovingFunds, + actionState: tbtc.ReservationActionStatePending, + timeoutAt: 5000, + now: 1000, + expectedResolution: StaleDepositResolutionKeep, }, - "still reserved, wallet closing": { - isReserved: true, - walletState: tbtc.StateClosing, - expectedResolved: false, + "reserved, timeout passed and notified": { + isReserved: true, + walletState: tbtc.StateMovingFunds, + actionState: tbtc.ReservationActionStatePending, + timeoutAt: 100, + now: 1000, + expectedResolution: StaleDepositResolutionNotified, }, } for testName, test := range tests { t.Run(testName, func(t *testing.T) { - resolved := isPendingStaleDepositResolved( - test.isReserved, - test.walletState, - ) - if resolved != test.expectedResolved { + spvChain := newLocalChain() + depositKey := reservationDepositKey(0xCC01) + wallet := walletPKH() + spvChain.setReservedDeposit(depositKey, wallet, test.isReserved) + spvChain.setWallet(wallet, &tbtc.WalletChainData{ + State: test.walletState, + }) + spvChain.setReservation(depositKey, &tbtc.Reservation{ + RequestNonce: 1, + }) + spvChain.setReservationAction(depositKey, 1, &tbtc.ReservationAction{ + State: test.actionState, + TimeoutAt: test.timeoutAt, + }) + spvChain.setReservationParameters(&tbtc.ReservationParameters{ + ReservationActionTimeout: 3600, + }) + watcher := NewReservationStaleDepositWatcher(spvChain) + resolution, err := watcher.CheckStaleReservedDeposit(depositKey, test.now) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resolution != test.expectedResolution { t.Errorf( - "unexpected resolved value\nexpected: %v\nactual: %v", - test.expectedResolved, - resolved, + "unexpected resolution\nexpected: %v\nactual: %v", + test.expectedResolution, + resolution, ) } }) @@ -153,20 +190,14 @@ func (m *mockWalletMembersResolver) ResolveWalletMembers(walletPublicKeyHash [20 return m.resolveFn(walletPublicKeyHash) } -// stubTbtcChain implements only the tbtc.Chain methods -// WireReservationWatchers's synchronous startup path actually calls -// (OnWalletClosed, to register the stranding watcher's live subscription). -// Every other tbtc.Chain method is unreachable from that synchronous path -// within this test's lifetime and is left to the embedded nil interface, -// which would panic if ever invoked - an intentional signal that the test -// has started exercising a code path it does not yet stub. -type stubTbtcChain struct { - tbtc.Chain +type mockWalletClosedChain struct { + onWalletClosedHandler func(event *tbtc.WalletClosedEvent) } -func (s *stubTbtcChain) OnWalletClosed( +func (m *mockWalletClosedChain) OnWalletClosed( handler func(event *tbtc.WalletClosedEvent), ) subscription.EventSubscription { + m.onWalletClosedHandler = handler return subscription.NewEventSubscription(func() {}) } @@ -174,7 +205,7 @@ func TestWireReservationWatchers(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - tbtcChain := &stubTbtcChain{} + walletClosedChain := &mockWalletClosedChain{} spvChain := newLocalChain() blockCounter := newMockBlockCounter() blockCounter.SetCurrentBlock(1000) @@ -186,7 +217,155 @@ func TestWireReservationWatchers(t *testing.T) { }, } - if err := WireReservationWatchers(ctx, tbtcChain, spvChain, resolver); err != nil { + if err := WireReservationWatchers(ctx, walletClosedChain, spvChain, resolver); err != nil { t.Fatalf("unexpected error: %v", err) } } + +func TestWireReservationWatchers_NilParameters(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + walletClosedChain := &mockWalletClosedChain{} + spvChain := newLocalChain() + resolver := &mockWalletMembersResolver{ + resolveFn: func(walletPublicKeyHash [20]byte) ([]uint32, error) { + return []uint32{1}, nil + }, + } + + t.Run("nil wallet closed chain", func(t *testing.T) { + err := WireReservationWatchers(ctx, nil, spvChain, resolver) + if err == nil { + t.Fatal("expected error for nil wallet closed chain") + } + }) + + t.Run("nil spv chain", func(t *testing.T) { + err := WireReservationWatchers(ctx, walletClosedChain, nil, resolver) + if err == nil { + t.Fatal("expected error for nil spv chain") + } + }) + + t.Run("nil wallet members resolver", func(t *testing.T) { + err := WireReservationWatchers(ctx, walletClosedChain, spvChain, nil) + if err == nil { + t.Fatal("expected error for nil wallet members resolver") + } + }) +} + +// TestWireReservationWatchers_StartupCatchUpScan_TransientErrorsDoNotAbort +// proves Fix 1: during the stranding watcher's startup catch-up scan, a transient +// chain-read failure against one wallet (e.g. GetWallet returning an error) +// does not abort the entire startup. The scan continues to the next wallets, +// properly processing Closed and Terminated wallets while skipping Live ones. +func TestWireReservationWatchers_StartupCatchUpScan_TransientErrorsDoNotAbort(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + walletClosedChain := &mockWalletClosedChain{} + spvChain := newLocalChain() + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(5000) + spvChain.setBlockCounter(blockCounter) + + resolver := &mockWalletMembersResolver{ + resolveFn: func(walletPublicKeyHash [20]byte) ([]uint32, error) { + return []uint32{1, 2, 3}, nil + }, + } + + walletTransientError := walletPKHAt(0x01) + walletClosed := walletPKHAt(0x02) + walletLive := walletPKHAt(0x03) + walletTerminated := walletPKHAt(0x04) + + resKeyClosed := reservationKey(0xDD02) + resKeyLive := reservationKey(0xDD03) + resKeyTerminated := reservationKey(0xDD04) + + // Register all 4 wallets in NewWalletRegisteredEvents. + spvChain.addNewWalletRegisteredEvent(&tbtc.NewWalletRegisteredEvent{ + EcdsaWalletID: [32]byte{0x01}, + WalletPublicKeyHash: walletTransientError, + }) + spvChain.addNewWalletRegisteredEvent(&tbtc.NewWalletRegisteredEvent{ + EcdsaWalletID: [32]byte{0x02}, + WalletPublicKeyHash: walletClosed, + }) + spvChain.addNewWalletRegisteredEvent(&tbtc.NewWalletRegisteredEvent{ + EcdsaWalletID: [32]byte{0x03}, + WalletPublicKeyHash: walletLive, + }) + spvChain.addNewWalletRegisteredEvent(&tbtc.NewWalletRegisteredEvent{ + EcdsaWalletID: [32]byte{0x04}, + WalletPublicKeyHash: walletTerminated, + }) + + // walletTransientError is NOT added to spvChain.wallets, so GetWallet + // returns "no wallet for given PKH" simulating a transient RPC error. + + // walletClosed is Closed with an Active reservation. + spvChain.setWallet(walletClosed, &tbtc.WalletChainData{ + State: tbtc.StateClosed, + }) + spvChain.setWalletReservations(walletClosed, []*big.Int{resKeyClosed}) + spvChain.setReservation(resKeyClosed, &tbtc.Reservation{ + State: tbtc.ReservationStateActive, + }) + + // walletLive is Live with an Active reservation (must be skipped). + spvChain.setWallet(walletLive, &tbtc.WalletChainData{ + State: tbtc.StateLive, + }) + spvChain.setWalletReservations(walletLive, []*big.Int{resKeyLive}) + spvChain.setReservation(resKeyLive, &tbtc.Reservation{ + State: tbtc.ReservationStateActive, + }) + + // walletTerminated is Terminated with an Active reservation. + spvChain.setWallet(walletTerminated, &tbtc.WalletChainData{ + State: tbtc.StateTerminated, + }) + spvChain.setWalletReservations(walletTerminated, []*big.Int{resKeyTerminated}) + spvChain.setReservation(resKeyTerminated, &tbtc.Reservation{ + State: tbtc.ReservationStateActive, + }) + + // WireReservationWatchers must succeed without returning an error despite + // walletTransientError failing GetWallet. + err := WireReservationWatchers(ctx, walletClosedChain, spvChain, resolver) + if err != nil { + t.Fatalf("expected WireReservationWatchers to succeed despite transient wallet error: %v", err) + } + + // Verify that the stranded active reservations for walletClosed and + // walletTerminated were both notified, while walletLive was skipped. + notifiedKeys := spvChain.getSubmittedReservationStrandedKeys() + if len(notifiedKeys) != 2 { + t.Fatalf("expected 2 notified stranded keys, got %d: %v", len(notifiedKeys), notifiedKeys) + } + + foundClosed := false + foundTerminated := false + for _, k := range notifiedKeys { + if k.Cmp(resKeyClosed) == 0 { + foundClosed = true + } + if k.Cmp(resKeyTerminated) == 0 { + foundTerminated = true + } + if k.Cmp(resKeyLive) == 0 { + t.Errorf("live wallet reservation was unexpectedly notified as stranded") + } + } + + if !foundClosed { + t.Errorf("expected closed wallet reservation [%v] to be notified", resKeyClosed) + } + if !foundTerminated { + t.Errorf("expected terminated wallet reservation [%v] to be notified", resKeyTerminated) + } +} diff --git a/pkg/maintainer/spv/spv.go b/pkg/maintainer/spv/spv.go index 0fc40caee6..dee9a22f2b 100644 --- a/pkg/maintainer/spv/spv.go +++ b/pkg/maintainer/spv/spv.go @@ -52,6 +52,11 @@ func Initialize( } if config.Reservations.Enabled { + logger.Infof( + "SPV maintainer reservation proof submission is enabled; " + + "ensure the paired Tbtc.Reservations.Enabled flag is also " + + "enabled in the client config for end-to-end operation", + ) // Reservation acceptance/re-anchor proofs run on a dedicated loop, // not through the generic proofTypes map: SubmitReservationProof // requires the (reservationKey, requestNonce) pair of the action From 1a432920b7668b2c134fb30d63263d7c046e449e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 2 Sep 2026 10:56:23 +0000 Subject: [PATCH 35/39] fix(tbtc): fix past-due activation block and doc/overflow issues ReservationsActivationBlock was already ~1.3M blocks in the past (copy-pasted from an unrelated feature's activation height), so the gate protected nothing on merge. Set a genuinely future placeholder with a sanity-check test. Correct the checklist-gate and ReservationsConfig doc comments, which described a mechanism the code doesn't implement. Reject fee byte slices wider than 8 bytes in the anchor/reanchor proposal unmarshal path to avoid silent Int64 truncation. Delete four orphaned doc blocks describing functions that no longer exist, and other small comment/logging hygiene fixes. --- pkg/chain/ethereum/tbtc.go | 54 +++++--------------------------- pkg/clientinfo/performance.go | 5 ++- pkg/maintainer/spv/chain_test.go | 24 +++++--------- pkg/tbtc/coordination.go | 30 +++++++++++------- pkg/tbtc/coordination_test.go | 22 +++++++++---- pkg/tbtc/marshaling.go | 15 ++++++++- pkg/tbtc/node_proposals.go | 2 ++ pkg/tbtc/reservation_test.go | 20 ++++++++++++ pkg/tbtc/tbtc.go | 7 +++-- 9 files changed, 92 insertions(+), 87 deletions(-) diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index 76e76191ed..5f02098d1b 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -67,15 +67,8 @@ type TbtcChain struct { walletProposalValidator *tbtccontract.WalletProposalValidator redemptionWatchtower *tbtccontract.RedemptionWatchtower // reservationRouter is the abigen binding for ReservationRouter.sol's ABI - // (functions, events, errors). It is NOT bound to the deployed router - // address -- the router contract holds its own empty storage and only - // ever executes via Bridge.fallback's delegatecall. The binding is - // constructed against the Bridge address so every read, write, and log - // filter goes through Bridge.fallback, which dispatches to the router - // code with the Bridge's storage and emits events under the Bridge's - // address. Calling the binding against the router's standalone address - // would invoke its empty storage and either revert (writes) or return - // zeros (views). + // constructed against the Bridge address (see reservationRouterBinding for + // the address invariant explanation). reservationRouter *tbtccontract.ReservationRouter // ecdsaDkgValidatorAddress optional; when zero, TBTC uses defaultGroupParameters(network). ecdsaDkgValidatorAddress common.Address @@ -2458,10 +2451,7 @@ func (tc *TbtcChain) GetDepositMinAge() (uint32, error) { } // GetReservation returns the on-chain reservation record for the given -// reservation key. The reservation router code is reached via -// Bridge.fallback's delegatecall; the reservationRouter binding is bound to -// the Bridge address so this call routes through the fallback into the -// router code that reads the Bridge's reservation storage. +// reservation key via the reservationRouter binding (see reservationRouterBinding). func (tc *TbtcChain) GetReservation( reservationKey *big.Int, ) (*tbtc.Reservation, error) { @@ -2519,8 +2509,7 @@ func (tc *TbtcChain) GetReservationAction( } // ReservationParameters returns the current on-chain Bridge reservation -// parameters (10-tuple). The reservationRouter binding routes this read -// through Bridge.fallback into the router's reservationParameters view. +// parameters (10-tuple) via the reservationRouter binding (see reservationRouterBinding). func (tc *TbtcChain) ReservationParameters() ( *tbtc.ReservationParameters, error, @@ -2836,11 +2825,9 @@ func parseReservationActionState(value uint8) (tbtc.ReservationActionState, erro } } -// RequestReservationAcceptance asks the Bridge (via its ReservationRouter -// delegatecall target) to start a new reservation acceptance action generation -// for the given reservation. The Bridge binding holds the actual storage; the -// reservationRouter binding is bound to the Bridge address so this call routes -// through Bridge.fallback into the router code. +// RequestReservationAcceptance calls the Bridge (via reservationRouter binding, +// see reservationRouterBinding) to start a new reservation acceptance action +// generation for the given reservation. func (tc *TbtcChain) RequestReservationAcceptance( reservationKey *big.Int, walletPublicKeyHash [20]byte, @@ -3036,9 +3023,7 @@ func (tc *TbtcChain) NotifyReservationStranded( } // ReservationCaps returns the cap parameters that gate reservation -// acceptance. The reservationRouter binding is bound to the Bridge -// address; the call routes through Bridge.fallback into the router's -// reservationCaps view. +// acceptance via the reservationRouter binding (see reservationRouterBinding). func (tc *TbtcChain) ReservationCaps() ( uint64, uint64, @@ -3124,29 +3109,6 @@ func (tc *TbtcChain) ReservedDepositWallet( return walletPublicKeyHash, nil } -// convertReservationRequestFromAbiType converts the ReservationRouter- -// specific Reservation.ReservationRequest ABI struct to the TBTC -// application `tbtc.ReservationRequest` representation. This is the -// verbatim-on-chain conversion; callers that want a slightly-shrunk Go -// representation use GetReservation, which drops CumulativeReanchorFee -// because m1 has no fee-ceiling enforcement. - -// Reservations returns the on-chain reservation request record for the -// given reservation key, including the cumulative re-anchor fee that the -// existing GetReservation representation drops. - -// convertReservationActionRecordFromAbiType converts the ReservationRouter- -// specific Reservation.ReservationAction ABI struct to the TBTC -// application `tbtc.ReservationActionRecord` representation. This is the -// verbatim-on-chain conversion; callers that want a slightly-shrunk Go -// representation use GetReservationAction, which drops the late-settlement -// and retry-credit fields because m1 does not consume them. - -// ReservationActions returns the on-chain reservation action record for the -// given reservation key and request nonce, including the late-settlement -// and retry-credit fields that the existing GetReservationAction -// representation drops. - // ActiveReservationsCount returns the current count of active reservations // across all wallets and the cap on that count. func (tc *TbtcChain) ActiveReservationsCount() (uint32, uint32, error) { diff --git a/pkg/clientinfo/performance.go b/pkg/clientinfo/performance.go index 7bcbbd8140..54de8005f5 100644 --- a/pkg/clientinfo/performance.go +++ b/pkg/clientinfo/performance.go @@ -39,9 +39,8 @@ type PerformanceMetrics struct { // reservationsEnabled mirrors tbtc.Config.Reservations.Enabled. Gates // registration of the reservation-specific wallet action metrics - // (reservation_anchor, reserved_redemption, reservation_reanchor, - // reservation_dissolution) so a non-reservation deployment's metric - // surface does not change - see GetAllWalletActionTypes. + // (reservation_anchor, reservation_reanchor) so a non-reservation + // deployment's metric surface does not change - see GetAllWalletActionTypes. reservationsEnabled bool // Counters track cumulative counts of events diff --git a/pkg/maintainer/spv/chain_test.go b/pkg/maintainer/spv/chain_test.go index 33cf5485a9..a8e93de377 100644 --- a/pkg/maintainer/spv/chain_test.go +++ b/pkg/maintainer/spv/chain_test.go @@ -44,24 +44,20 @@ type submittedMovedFundsSweepProof struct { mainUTXO bitcoin.UnspentTransactionOutput } -// submittedReservationStranded records a NotifyReservationStranded call. -// The stranding watcher builder replaces this stub with the call path -// that records a stray notification for assertion in tests. +// submittedReservationStranded records a NotifyReservationStranded call for +// assertion in tests. type submittedReservationStranded struct { reservationKey *big.Int } -// submittedStaleReservedDeposit records a NotifyStaleReservedDeposit call. -// The stale-deposit watcher builder replaces this stub with the call path -// that records a stale-deposit notification for assertion in tests. +// submittedStaleReservedDeposit records a NotifyStaleReservedDeposit call for +// assertion in tests. type submittedStaleReservedDeposit struct { depositKey *big.Int } -// submittedReservationActionTimeout records a -// NotifyReservationActionTimeout call. The action-timeout watcher builder -// replaces this stub with the call path that records a timeout notification -// for assertion in tests. +// submittedReservationActionTimeout records a NotifyReservationActionTimeout +// call for assertion in tests. type submittedReservationActionTimeout struct { reservationKey *big.Int walletMembersIDs []uint32 @@ -803,9 +799,7 @@ func (mbc *mockBlockCounter) WatchBlocks(ctx context.Context) <-chan uint64 { } // SubmitReservationProof is a stub matching the reservation additions on -// the production Chain interface. The reservation acceptance and re-anchor -// proposal builders replace this stub with the call path that records a -// submitted proof for assertion in tests. +// the production Chain interface. func (lc *localChain) SubmitReservationProof( proofType uint8, txInfo *tbtc.BitcoinTxInfo, @@ -1054,10 +1048,6 @@ func (lc *localChain) setWalletReservations( ) } -// Reservations is a stub matching the reservation additions on the -// production Chain interface. The reservation-side builder replaces this -// stub with the production contract call; the watchers do not need it. - // IsReservedDeposit returns whether the deposit was previously booked via // setReservedDeposit. func (lc *localChain) IsReservedDeposit( diff --git a/pkg/tbtc/coordination.go b/pkg/tbtc/coordination.go index e5b5e15e07..1840b54042 100644 --- a/pkg/tbtc/coordination.go +++ b/pkg/tbtc/coordination.go @@ -71,7 +71,10 @@ const ( // ReservationsActivationBlock is the Ethereum block height at which // reservation actions (anchor, re-anchor) become available in the // coordination checklist. - ReservationsActivationBlock = uint64(24559289) + // + // NOTE: This value is a placeholder that MUST be set to the real mainnet + // rollout height before release and must stay ahead of the chain tip. + ReservationsActivationBlock = uint64(26500000) ) // errCoordinationExecutorBusy is an error returned when the coordination @@ -637,16 +640,21 @@ func (ce *coordinationExecutor) getActionsChecklist( } } - // Reservation actions (acceptance, re-anchor) are only checked when the - // operator has enabled the reservation subsystem. Gating the checklist - // entry on the same flag that gates the reservation proposal generator - // tasks (see tbtcpg.NewProposalGenerator) keeps leader and follower - // checklists in agreement: a follower that never enters this branch - // would otherwise fault a leader's reservation proposal as - // FaultLeaderMistake because the action would not appear in its own - // checklist. Frequency-gated like DepositSweep/MovingFunds below the - // activation block: reservation acceptance/re-anchor windows are not - // as time-critical as redemption. + // Reservation actions (acceptance, re-anchor) checklist gate is deliberately + // config-independent and height-only so every operator computes an + // identical checklist once the network-wide activation block passes. + // If the checklist depended on each operator's local config flag, + // operators with different local settings would compute different checklists + // and fault each other's proposals via FaultLeaderMistake. Checklist + // agreement is achieved because the gate ignores local config and uses only + // globally-observable chain height. Config.Reservations.Enabled controls + // only whether THIS operator originates (leader-proposes) new reservation + // actions and whether its reservation watchers run - it does NOT prevent + // this operator from evaluating/countersigning another leader's reservation + // proposal as a follower once the activation height passes, regardless of + // this operator's own local flag setting. Frequency-gated like + // DepositSweep/MovingFunds below the activation block: reservation + // acceptance/re-anchor windows are not as time-critical as redemption. if coordinationBlock >= ReservationsActivationBlock && windowIndex%frequencyWindows == 0 { actions = append(actions, ActionReservationAnchor) diff --git a/pkg/tbtc/coordination_test.go b/pkg/tbtc/coordination_test.go index b63b22d446..4623485107 100644 --- a/pkg/tbtc/coordination_test.go +++ b/pkg/tbtc/coordination_test.go @@ -734,8 +734,6 @@ func TestCoordinationExecutor_GetActionsChecklist_PostActivation(t *testing.T) { ActionDepositSweep, ActionMovedFundsSweep, ActionMovingFunds, - ActionReservationAnchor, - ActionReservationReanchor, }, is4thWindow: true, }, @@ -770,8 +768,6 @@ func TestCoordinationExecutor_GetActionsChecklist_PostActivation(t *testing.T) { ActionDepositSweep, ActionMovedFundsSweep, ActionMovingFunds, - ActionReservationAnchor, - ActionReservationReanchor, ActionHeartbeat, }, is4thWindow: true, @@ -786,8 +782,6 @@ func TestCoordinationExecutor_GetActionsChecklist_PostActivation(t *testing.T) { ActionDepositSweep, ActionMovedFundsSweep, ActionMovingFunds, - ActionReservationAnchor, - ActionReservationReanchor, }, is4thWindow: true, }, @@ -911,6 +905,22 @@ func TestCoordinationExecutor_GetActionsChecklist_Reservations(t *testing.T) { } } +func TestReservationsActivationBlock_SanityCheck(t *testing.T) { + // Reference Ethereum mainnet block height as of 2026-09-02 (~25,880,000). + // ReservationsActivationBlock must be set to a future block height ahead + // of chain tip before release. If this test fails, both the reference + // height and ReservationsActivationBlock must be updated. + const referenceMainnetBlockHeight = uint64(25880000) + + if ReservationsActivationBlock <= referenceMainnetBlockHeight { + t.Errorf( + "ReservationsActivationBlock [%d] must be ahead of the reference mainnet block height [%d]", + ReservationsActivationBlock, + referenceMainnetBlockHeight, + ) + } +} + // assertPostActivationSafety verifies the safety invariants that must hold // for every non-nil post-activation checklist: // - ActionRedemption is at index 0. diff --git a/pkg/tbtc/marshaling.go b/pkg/tbtc/marshaling.go index 35171a9257..3874e51ea6 100644 --- a/pkg/tbtc/marshaling.go +++ b/pkg/tbtc/marshaling.go @@ -512,6 +512,12 @@ func (rap *ReservationAnchorProposal) Unmarshal(data []byte) error { if len(pbMsg.AnchorTxFee) == 0 { return fmt.Errorf("anchor transaction fee is required") } + if len(pbMsg.AnchorTxFee) > 8 { + return fmt.Errorf( + "invalid anchor transaction fee byte length: [%v]", + len(pbMsg.AnchorTxFee), + ) + } if pbMsg.RequestNonce == 0 { return fmt.Errorf("request nonce is required") } @@ -555,13 +561,20 @@ func (rrp *ReservationReanchorProposal) Unmarshal(data []byte) error { if len(pbMsg.ReanchorTxFee) == 0 { return fmt.Errorf("re-anchor transaction fee is required") } + if len(pbMsg.ReanchorTxFee) > 8 { + return fmt.Errorf( + "invalid re-anchor transaction fee byte length: [%v]", + len(pbMsg.ReanchorTxFee), + ) + } if len(pbMsg.TargetWalletPublicKeyHash) != 20 { return fmt.Errorf( "invalid target wallet public key hash length: [%v]", len(pbMsg.TargetWalletPublicKeyHash), ) } - if copy(rrp.TargetWalletPublicKeyHash[:], pbMsg.TargetWalletPublicKeyHash) == 0 || rrp.TargetWalletPublicKeyHash == [20]byte{} { + copy(rrp.TargetWalletPublicKeyHash[:], pbMsg.TargetWalletPublicKeyHash) + if rrp.TargetWalletPublicKeyHash == [20]byte{} { return fmt.Errorf("target wallet public key hash is required") } diff --git a/pkg/tbtc/node_proposals.go b/pkg/tbtc/node_proposals.go index ed7c9805d1..3d11fec099 100644 --- a/pkg/tbtc/node_proposals.go +++ b/pkg/tbtc/node_proposals.go @@ -322,6 +322,7 @@ func (n *node) handleReservationAnchorProposal( ) { walletPublicKeyBytes, err := marshalPublicKey(wallet.publicKey) if err != nil { + logger.Errorf("cannot marshal wallet public key: [%v]", err) return } @@ -386,6 +387,7 @@ func (n *node) handleReservationReanchorProposal( ) { walletPublicKeyBytes, err := marshalPublicKey(wallet.publicKey) if err != nil { + logger.Errorf("cannot marshal wallet public key: [%v]", err) return } diff --git a/pkg/tbtc/reservation_test.go b/pkg/tbtc/reservation_test.go index 925949ad82..899d9a144c 100644 --- a/pkg/tbtc/reservation_test.go +++ b/pkg/tbtc/reservation_test.go @@ -181,6 +181,26 @@ func TestReservationProposals_UnmarshalRejectsMissingIntegers(t *testing.T) { }), expectedError: "cannot unmarshal proposal payload: [re-anchor transaction fee is required]", }, + "anchor fee exceeds 8 bytes": { + actionType: ActionReservationAnchor, + payload: marshalPb(t, &pb.ReservationAnchorProposal{ + AnchorTxFee: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, + RequestNonce: 1, + DepositFundingTxHash: make([]byte, 32), + DepositFundingOutputIndex: 0, + }), + expectedError: "cannot unmarshal proposal payload: [invalid anchor transaction fee byte length: [9]]", + }, + "re-anchor fee exceeds 8 bytes": { + actionType: ActionReservationReanchor, + payload: marshalPb(t, &pb.ReservationReanchorProposal{ + ReservationKey: big.NewInt(54321).Bytes(), + RequestNonce: 3, + ReanchorTxFee: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, + TargetWalletPublicKeyHash: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20}, + }), + expectedError: "cannot unmarshal proposal payload: [invalid re-anchor transaction fee byte length: [9]]", + }, } for testName, test := range tests { diff --git a/pkg/tbtc/tbtc.go b/pkg/tbtc/tbtc.go index 720baed793..7baa5f2995 100644 --- a/pkg/tbtc/tbtc.go +++ b/pkg/tbtc/tbtc.go @@ -108,9 +108,10 @@ type Config struct { // a separate type so future reservation knobs (poll intervals, cap overrides) // can be added without breaking the top-level Config layout. // -// This flag controls only reservation acceptance / re-anchor proposal -// GENERATION (the `start` command's coordination layer, config category -// Tbtc). The `maintainer` command runs as a separate process reading a +// This flag controls BOTH reservation acceptance / re-anchor proposal +// GENERATION AND watcher wiring in the start process (the `start` +// command's coordination and watcher layers, config category Tbtc). The +// `maintainer` command runs as a separate process reading a // disjoint config category (see config.MaintainerCategories) and has its // own independent gate, spv.ReservationsConfig.Enabled, that controls SPV // PROOF SUBMISSION for those same proposals. Neither command's config From 0358cc75d5c3a735c8f901bea4c6a94b67497299 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 2 Sep 2026 17:43:41 +0000 Subject: [PATCH 36/39] ci(tbtc): unblock ReservationRouter bindings via tbtc-v2 PR #1112 shim make generate fails: @keep-network/tbtc-v2@development has no ReservationRouter artifact, and its Bridge/WalletProposalValidator/ RedemptionWatchtower don't yet expose the reservation methods this PR binds against. Those land in threshold-network/tbtc-v2#1112, which isn't merged/published yet. For PRs targeting reservations-epic, build and deploy tbtc-v2 PR #1112 (pinned SHA) locally to produce the tbtc module's required_contracts artifacts, and inject them into the Docker build right after get_artifacts. Scoped to environment=development only; sepolia/mainnet builds and the other three modules are untouched. Remove once tbtc-v2 publishes for real. --- .github/workflows/client.yml | 31 +++++++++++++++++++++++++++++++ .gitignore | 4 ++++ Dockerfile | 22 +++++++++++++++++++++- 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml index 1de973a959..0e136cab79 100644 --- a/.github/workflows/client.yml +++ b/.github/workflows/client.yml @@ -135,6 +135,37 @@ jobs: with: environment: ${{ github.event.inputs.environment }} + # TODO(https://github.com/threshold-network/tbtc-v2/pull/1112): remove once + # @keep-network/tbtc-v2 publishes the reservation-router surface (Bridge, + # WalletProposalValidator, RedemptionWatchtower, ReservationRouter, and the + # rest of the tbtc module's required_contracts) under the `development` npm + # tag. Until then, PRs targeting `reservations-epic` build and deploy tbtc-v2 + # PR #1112 (pinned SHA, not a moving branch ref) locally to produce the + # artifacts `make get_artifacts` can't fetch from npm yet. See + # ./ci-shims/tbtc-artifacts and the matching Dockerfile step. + - name: Prepare tbtc-v2 artifact shim directory + run: mkdir -p ci-shims/tbtc-artifacts + + - name: Build tbtc-v2 module artifacts from PR #1112 (temporary shim) + if: github.base_ref == 'reservations-epic' + run: | + set -euo pipefail + git clone --quiet https://github.com/threshold-network/tbtc-v2.git /tmp/tbtc-v2-shim + cd /tmp/tbtc-v2-shim + git checkout --quiet 1c8c1cd1437c077700b372544677aa0f9b08ef87 + cd solidity + corepack enable + git config --global url."https://".insteadOf git:// + yarn install --immutable + yarn build + USE_EXTERNAL_DEPLOY=true TEST_USE_STUBS_TBTC=true \ + npx hardhat deploy --network hardhat --write true + for contract in Bridge MaintainerProxy LightRelay LightRelayMaintainerProxy \ + WalletProposalValidator RedemptionWatchtower ReservationRouter; do + cp "deployments/hardhat/$contract.json" \ + "$GITHUB_WORKSPACE/ci-shims/tbtc-artifacts/$contract.json" + done + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 diff --git a/.gitignore b/.gitignore index 0c2c04268b..4d015b993b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,10 @@ # Executables /keep-client +# Temporary CI-only artifact injected before the Docker build; see Dockerfile +# and .github/workflows/client.yml (ReservationRouter tbtc-v2 PR #1112 shim). +/ci-shims/ + # IDEs .vscode/ .idea/ diff --git a/Dockerfile b/Dockerfile index dce9eba139..18e665f476 100644 --- a/Dockerfile +++ b/Dockerfile @@ -62,11 +62,31 @@ COPY ./pkg/protocol/inactivity/gen $APP_DIR/pkg/protocol/inactivity/gen RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.32.0 # Environment is to download published and tagged NPM packages versions. -ARG ENVIRONMENT +# Defaults to `development` to mirror the root Makefile's `ifndef environment` +# fallback (the "Build Docker Build Image" CI step never passes this build-arg). +ARG ENVIRONMENT=development COPY ./Makefile $APP_DIR/Makefile RUN make get_artifacts environment=$ENVIRONMENT +# TODO(https://github.com/threshold-network/tbtc-v2/pull/1112): remove once +# @keep-network/tbtc-v2 publishes Bridge/WalletProposalValidator/RedemptionWatchtower/ +# ReservationRouter (and the rest of the tbtc module's required_contracts) under the +# `development` npm tag. Until then, `get_artifacts` fetches a tbtc-v2 package whose +# Bridge/WalletProposalValidator/RedemptionWatchtower don't yet expose the reservation +# methods this PR binds against, and has no ReservationRouter artifact at all. The +# `client.yml` workflow locally builds and deploys tbtc-v2 PR #1112 (pinned SHA) and +# drops its deployment artifacts for the tbtc module's required_contracts at +# ./ci-shims/tbtc-artifacts/*.json when it runs; this only overrides the tbtc module's +# artifacts, and only for `environment=development` (PR CI) builds - sepolia/mainnet +# builds and the beacon/ecdsa/threshold modules are untouched. +COPY ./ci-shims/tbtc-artifacts /tmp/tbtc-artifacts +RUN if [ "$ENVIRONMENT" = "development" ] && [ -n "$(ls -A /tmp/tbtc-artifacts 2>/dev/null)" ]; then \ + echo "Using tbtc-v2 module artifacts built from tbtc-v2 PR #1112 (temporary shim)"; \ + cp /tmp/tbtc-artifacts/*.json \ + $APP_DIR/tmp/contracts/development/@keep-network/tbtc-v2/artifacts/; \ +fi + # Need this to resolve imports in generated Ethereum commands. COPY ./config $APP_DIR/config RUN make generate environment=$ENVIRONMENT From 91f8c594ba640324e930964097e54ba763e4e85d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 2 Sep 2026 17:53:18 +0000 Subject: [PATCH 37/39] ci(tbtc): pin Node 22 for the tbtc-v2 shim build Match tbtc-v2's own required Node version (>=22, per its own CI workflows) instead of relying on the runner's default. --- .github/workflows/client.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml index 0e136cab79..ee9fa8529d 100644 --- a/.github/workflows/client.yml +++ b/.github/workflows/client.yml @@ -146,6 +146,12 @@ jobs: - name: Prepare tbtc-v2 artifact shim directory run: mkdir -p ci-shims/tbtc-artifacts + - name: Set up Node.js for tbtc-v2 shim build + if: github.base_ref == 'reservations-epic' + uses: actions/setup-node@v4 + with: + node-version: "22.23.1" + - name: Build tbtc-v2 module artifacts from PR #1112 (temporary shim) if: github.base_ref == 'reservations-epic' run: | From e6d0bfe58d9a61acbeb7b91012349e7acd36a760 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 2 Sep 2026 17:58:34 +0000 Subject: [PATCH 38/39] ci(tbtc): drop the tbtc-v2 shim's local deploy step environment=development only ever reads the .abi field off the tbtc-v2 artifacts - the gen Makefile writes a hardcoded zero address rather than reading one (_address/%: development branch). A plain hardhat compile artifact carries the same .abi as a deployment record, so the shim no longer needs to deploy a full local devnet (random-beacon, ecdsa, threshold, tbtc-v2 itself) to produce it. This also drops USE_EXTERNAL_DEPLOY and the WalletRegistry proxy deployment from the path entirely, which is where the prior run failed with 'Contract WalletRegistry is not upgrade safe' from @openzeppelin/upgrades-core - unreproducible locally on either Node 20 or 22, and now moot since that deploy path is gone. --- .github/workflows/client.yml | 16 +++++++++------- Dockerfile | 4 ++-- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml index ee9fa8529d..17b01d4385 100644 --- a/.github/workflows/client.yml +++ b/.github/workflows/client.yml @@ -139,9 +139,13 @@ jobs: # @keep-network/tbtc-v2 publishes the reservation-router surface (Bridge, # WalletProposalValidator, RedemptionWatchtower, ReservationRouter, and the # rest of the tbtc module's required_contracts) under the `development` npm - # tag. Until then, PRs targeting `reservations-epic` build and deploy tbtc-v2 - # PR #1112 (pinned SHA, not a moving branch ref) locally to produce the - # artifacts `make get_artifacts` can't fetch from npm yet. See + # tag. Until then, PRs targeting `reservations-epic` compile tbtc-v2 PR #1112 + # (pinned SHA, not a moving branch ref) locally to produce the artifacts + # `make get_artifacts` can't fetch from npm yet. `environment=development` + # only ever reads the `.abi` field off these files (the gen Makefile writes + # a hardcoded zero address rather than reading one - see + # pkg/chain/ethereum/common/gen/Makefile's `_address/%` rule), so a plain + # `hardhat compile` artifact is sufficient; no local deployment needed. See # ./ci-shims/tbtc-artifacts and the matching Dockerfile step. - name: Prepare tbtc-v2 artifact shim directory run: mkdir -p ci-shims/tbtc-artifacts @@ -164,12 +168,10 @@ jobs: git config --global url."https://".insteadOf git:// yarn install --immutable yarn build - USE_EXTERNAL_DEPLOY=true TEST_USE_STUBS_TBTC=true \ - npx hardhat deploy --network hardhat --write true for contract in Bridge MaintainerProxy LightRelay LightRelayMaintainerProxy \ WalletProposalValidator RedemptionWatchtower ReservationRouter; do - cp "deployments/hardhat/$contract.json" \ - "$GITHUB_WORKSPACE/ci-shims/tbtc-artifacts/$contract.json" + artifact="$(find build/contracts -iname "$contract.json" -path "*/$contract.sol/*")" + cp "$artifact" "$GITHUB_WORKSPACE/ci-shims/tbtc-artifacts/$contract.json" done - name: Set up Docker Buildx diff --git a/Dockerfile b/Dockerfile index 18e665f476..23597c7483 100644 --- a/Dockerfile +++ b/Dockerfile @@ -75,8 +75,8 @@ RUN make get_artifacts environment=$ENVIRONMENT # `development` npm tag. Until then, `get_artifacts` fetches a tbtc-v2 package whose # Bridge/WalletProposalValidator/RedemptionWatchtower don't yet expose the reservation # methods this PR binds against, and has no ReservationRouter artifact at all. The -# `client.yml` workflow locally builds and deploys tbtc-v2 PR #1112 (pinned SHA) and -# drops its deployment artifacts for the tbtc module's required_contracts at +# `client.yml` workflow locally compiles tbtc-v2 PR #1112 (pinned SHA) and drops its +# compiled ABI artifacts for the tbtc module's required_contracts at # ./ci-shims/tbtc-artifacts/*.json when it runs; this only overrides the tbtc module's # artifacts, and only for `environment=development` (PR CI) builds - sepolia/mainnet # builds and the beacon/ecdsa/threshold modules are untouched. From d66c7b2114ee94f1b93df3647fbcdf0863a53d89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 2 Sep 2026 18:20:32 +0000 Subject: [PATCH 39/39] ci(tbtc): treat empty ENVIRONMENT as development in the shim guard docker/build-push-action's 'Build Client Binaries' step explicitly passes build-args: ENVIRONMENT=${{ github.event.inputs.environment }}, which renders to a literal empty string on pull_request events (no workflow_dispatch inputs). Docker only applies an ARG's default value when the build-arg is omitted entirely, not when it's explicitly set to empty, so ARG ENVIRONMENT=development never kicked in there - only in the sibling 'Build Docker Build Image' step, which never passes this build-arg at all. make itself already treats an empty environment= the same as unset (ifndef matches empty), so make generate correctly fell back to 'development' either way. The shim's shell guard didn't, so it skipped injecting the artifacts and reproduced the original failure in this one build-push-action invocation. Match make's behavior explicitly. --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 23597c7483..58e6cefe8e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -81,7 +81,7 @@ RUN make get_artifacts environment=$ENVIRONMENT # artifacts, and only for `environment=development` (PR CI) builds - sepolia/mainnet # builds and the beacon/ecdsa/threshold modules are untouched. COPY ./ci-shims/tbtc-artifacts /tmp/tbtc-artifacts -RUN if [ "$ENVIRONMENT" = "development" ] && [ -n "$(ls -A /tmp/tbtc-artifacts 2>/dev/null)" ]; then \ +RUN if { [ -z "$ENVIRONMENT" ] || [ "$ENVIRONMENT" = "development" ]; } && [ -n "$(ls -A /tmp/tbtc-artifacts 2>/dev/null)" ]; then \ echo "Using tbtc-v2 module artifacts built from tbtc-v2 PR #1112 (temporary shim)"; \ cp /tmp/tbtc-artifacts/*.json \ $APP_DIR/tmp/contracts/development/@keep-network/tbtc-v2/artifacts/; \