From 2e3570ed34e5227cad78383dfb7ac5bd19cdcf25 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 7 Aug 2026 16:32:07 -0400 Subject: [PATCH 01/22] 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/22] 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/22] 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/22] 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/22] 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 434ba9d687e3a3d6885e407ceb63c49d0f8da32a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 1 Sep 2026 12:49:07 +0000 Subject: [PATCH 06/22] fix(tbtc): harden reservation lane validation and wire redeemer hash through chain Addresses confirmed findings from a multi-agent review of the reservation wallet-side foundations: - assembleReservationAnchorTransaction/assembleReservationReanchorTransaction now take an action snapshot and enforce the action's TxMaxFee ceiling and the reservation minimum amount floor, mirroring the redemption/dissolution assemblers. - assembleReservedRedemptionTransaction and assembleReservationReanchorTransaction enforce the reservation minimum amount floor on their remainder/re-anchor outputs. - computeReservationRedeemerOutputScriptHash moved off pkg/tbtc (a host-chain-agnostic package) onto BridgeChain.ComputeReservationRedeemerOutputScriptHash, matching how ComputeMainUtxoHash is already delegated through the chain abstraction; the Ethereum implementation shares its keccak step with buildRedemptionKey. - assembleReservationDissolutionTransaction rejects a wallet main UTXO that wasn't part of the action's snapshot instead of silently discarding it, and requires a bridge chain unconditionally; its bridgeChain parameter is now a narrow inline interface instead of the full BridgeChain. - ReservationReanchorProposal.Unmarshal rejects a zero target wallet public key hash; ReservationAnchorProposal.Unmarshal rejects a zero deposit funding tx hash; all four proposal Unmarshal methods reject a non-positive or out-of-int64-range fee. - ReservedRedemptionProposal carries its redeemer output script on the wire (previously unconstructible - no source supplied it). - GetReservation/GetReservationAction/ReservationParameters moved from WalletProposalValidatorChain to BridgeChain, matching their Bridge-state-read peers. - The 7 Ethereum reservation stubs return a wrapped sentinel error so callers can errors.Is() them; the mismatched dissolution localChain stub parameter name now matches its siblings. - Marshal/Unmarshal for the four reservation proposals moved to marshaling.go alongside the other proposal marshalers; the reservation validity-block constants and the action type/state enums gained rationale and per-value doc comments. Test coverage: happy-path and fee/value boundary tests for the anchor and re-anchor assemblers (previously untested beyond a nil-input guard), dissolution's action-amount/target-wallet mismatch checks, the fee-exceeds-redemption-amount and partial-amount-exceeds-anchor-value guards, and fuzz coverage for all four proposals' Unmarshal methods. --- pkg/chain/ethereum/tbtc.go | 79 +++-- pkg/tbtc/chain.go | 37 ++- pkg/tbtc/chain_test.go | 27 +- pkg/tbtc/marshaling.go | 142 +++++++++ pkg/tbtc/marshaling_test.go | 55 ++++ pkg/tbtc/reservation.go | 300 +++++++----------- pkg/tbtc/reservation_test.go | 596 ++++++++++++++++++++++++++++++++--- 7 files changed, 942 insertions(+), 294 deletions(-) diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index 83c9629fef..b641c09d9c 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -50,6 +50,8 @@ const ( EcdsaDkgValidatorContractName = "EcdsaDkgValidator" ) +var errReservationsUnsupported = errors.New("reservations not supported yet by the Ethereum chain implementation") + const ( sweptDepositsCachePeriod = 7 * 24 * time.Hour ) @@ -1544,6 +1546,35 @@ func (tc *TbtcChain) ComputeMainUtxoHash( ) [32]byte { return computeMainUtxoHash(mainUtxo) } +func (tc *TbtcChain) ComputeReservationRedeemerOutputScriptHash( + redeemerOutputScript bitcoin.Script, +) ([32]byte, error) { + return reservationRedeemerOutputScriptHash(redeemerOutputScript) +} + +func reservationRedeemerOutputScriptHash(redeemerOutputScript bitcoin.Script) ([32]byte, error) { + prefixedRedeemerOutputScript, err := redeemerOutputScript.ToVarLenData() + if err != nil { + return [32]byte{}, fmt.Errorf("cannot build prefixed redeemer output script: [%v]", err) + } + return crypto.Keccak256Hash(prefixedRedeemerOutputScript), nil +} + +func buildRedemptionKey( + walletPublicKeyHash [20]byte, + redeemerOutputScript bitcoin.Script, +) (*big.Int, error) { + redeemerOutputScriptHash, err := reservationRedeemerOutputScriptHash(redeemerOutputScript) + if err != nil { + return nil, fmt.Errorf("cannot compute redeemer output script hash: [%v]", err) + } + + redemptionKey := crypto.Keccak256Hash( + append(redeemerOutputScriptHash[:], walletPublicKeyHash[:]...), + ) + + return redemptionKey.Big(), nil +} func computeMainUtxoHash(mainUtxo *bitcoin.UnspentTransactionOutput) [32]byte { outputIndexBytes := make([]byte, 4) @@ -1698,26 +1729,6 @@ func (tc *TbtcChain) SubmitRedemptionProofWithReimbursement( return err } -func buildRedemptionKey( - walletPublicKeyHash [20]byte, - redeemerOutputScript bitcoin.Script, -) (*big.Int, error) { - // The Bridge contract builds the redemption key using the length-prefixed - // redeemer output script. - prefixedRedeemerOutputScript, err := redeemerOutputScript.ToVarLenData() - if err != nil { - return nil, fmt.Errorf("cannot build prefixed redeemer output script: [%v]", err) - } - - redeemerOutputScriptHash := crypto.Keccak256Hash(prefixedRedeemerOutputScript) - - redemptionKey := crypto.Keccak256Hash( - append(redeemerOutputScriptHash[:], walletPublicKeyHash[:]...), - ) - - return redemptionKey.Big(), nil -} - func (tc *TbtcChain) TxProofDifficultyFactor() (*big.Int, error) { return tc.bridge.TxProofDifficultyFactor() } @@ -2415,9 +2426,7 @@ func (tc *TbtcChain) GetDepositMinAge() (uint32, error) { func (tc *TbtcChain) GetReservation( reservationKey *big.Int, ) (*tbtc.Reservation, error) { - return nil, fmt.Errorf( - "reservations not supported yet by the Ethereum chain implementation", - ) + return nil, fmt.Errorf("%w", errReservationsUnsupported) } // GetReservationAction is not yet supported by the Ethereum chain @@ -2428,9 +2437,7 @@ 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", - ) + return nil, fmt.Errorf("%w", errReservationsUnsupported) } // ReservationParameters is not yet supported by the Ethereum chain @@ -2441,9 +2448,7 @@ func (tc *TbtcChain) ReservationParameters() ( *tbtc.ReservationParameters, error, ) { - return nil, fmt.Errorf( - "reservations not supported yet by the Ethereum chain implementation", - ) + return nil, fmt.Errorf("%w", errReservationsUnsupported) } // ValidateReservationAnchorProposal is not yet supported by the Ethereum @@ -2458,9 +2463,7 @@ func (tc *TbtcChain) ValidateReservationAnchorProposal( FundingTx *bitcoin.Transaction }, ) error { - return fmt.Errorf( - "reservations not supported yet by the Ethereum chain implementation", - ) + return fmt.Errorf("%w", errReservationsUnsupported) } // ValidateReservedRedemptionProposal is not yet supported by the Ethereum @@ -2471,9 +2474,7 @@ func (tc *TbtcChain) ValidateReservedRedemptionProposal( walletPublicKeyHash [20]byte, proposal *tbtc.ReservedRedemptionProposal, ) error { - return fmt.Errorf( - "reservations not supported yet by the Ethereum chain implementation", - ) + return fmt.Errorf("%w", errReservationsUnsupported) } // ValidateReservationReanchorProposal is not yet supported by the Ethereum @@ -2484,9 +2485,7 @@ func (tc *TbtcChain) ValidateReservationReanchorProposal( sourceWalletPublicKeyHash [20]byte, proposal *tbtc.ReservationReanchorProposal, ) error { - return fmt.Errorf( - "reservations not supported yet by the Ethereum chain implementation", - ) + return fmt.Errorf("%w", errReservationsUnsupported) } // ValidateReservationDissolutionProposal is not yet supported by the @@ -2497,7 +2496,5 @@ func (tc *TbtcChain) ValidateReservationDissolutionProposal( walletPublicKeyHash [20]byte, proposal *tbtc.ReservationDissolutionProposal, ) error { - return fmt.Errorf( - "reservations not supported yet by the Ethereum chain implementation", - ) + return fmt.Errorf("%w", errReservationsUnsupported) } diff --git a/pkg/tbtc/chain.go b/pkg/tbtc/chain.go index 02b2406b96..c26f1f6f40 100644 --- a/pkg/tbtc/chain.go +++ b/pkg/tbtc/chain.go @@ -267,6 +267,27 @@ type BridgeChain interface { // ComputeMainUtxoHash computes the hash of the provided main UTXO // according to the on-chain Bridge rules. ComputeMainUtxoHash(mainUtxo *bitcoin.UnspentTransactionOutput) [32]byte + // ComputeReservationRedeemerOutputScriptHash computes the keccak256 hash of + // the length-prefixed redeemer output script, per the on-chain Bridge rules + // used to authorize a reserved redemption. + ComputeReservationRedeemerOutputScriptHash(redeemerOutputScript bitcoin.Script) ([32]byte, error) + + // GetReservation gets the on-chain reservation record for the given + // reservation key. Returns a zero-valued record with State == ReservationStateUnknown + // 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) // PastDepositRevealedEvents fetches past deposit reveal events according // to the provided filter or unfiltered if the filter is nil. Returned @@ -427,22 +448,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. diff --git a/pkg/tbtc/chain_test.go b/pkg/tbtc/chain_test.go index 5768d03422..5e8060141f 100644 --- a/pkg/tbtc/chain_test.go +++ b/pkg/tbtc/chain_test.go @@ -927,6 +927,26 @@ func (lc *localChain) ComputeMainUtxoHash( return mainUtxoHash } +func (lc *localChain) 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, + ) + } + + return sha256.Sum256(prefixedScript), nil +} + +func (lc *localChain) ValidateReservationDissolutionProposal( + walletPublicKeyHash [20]byte, + proposal *ReservationDissolutionProposal, +) error { + panic("unsupported") +} func (lc *localChain) ComputeMovingFundsCommitmentHash(targetWallets [][20]byte) [32]byte { packedWallets := []byte{} @@ -1492,10 +1512,3 @@ func (lc *localChain) ValidateReservationReanchorProposal( ) 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 d31180ca27..aa9255d2e4 100644 --- a/pkg/tbtc/marshaling.go +++ b/pkg/tbtc/marshaling.go @@ -2,6 +2,7 @@ package tbtc import ( "crypto/ecdsa" + "encoding/json" "fmt" "math" "math/big" @@ -494,3 +495,144 @@ func validateMemberIndex(protoIndex uint32) error { } return nil } + +// 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.DepositFundingTxHash == (bitcoin.Hash{}) { + return fmt.Errorf("deposit funding transaction hash is required") + } + if proposal.RequestNonce == 0 { + return fmt.Errorf("request nonce is required") + } + if proposal.AnchorTxFee == nil { + return fmt.Errorf("anchor transaction fee is required") + } + if proposal.AnchorTxFee.Sign() <= 0 { + return fmt.Errorf("anchor transaction fee must be positive") + } + if !proposal.AnchorTxFee.IsInt64() { + return fmt.Errorf("anchor transaction fee is out of range") + } + *rap = proposal + return nil +} + +// 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.RedeemerOutputScript == nil || len(proposal.RedeemerOutputScript) == 0 { + return fmt.Errorf("redeemer output script is required") + } + if proposal.RedemptionTxFee == nil { + return fmt.Errorf("redemption transaction fee is required") + } + if proposal.RedemptionTxFee.Sign() <= 0 { + return fmt.Errorf("redemption transaction fee must be positive") + } + if !proposal.RedemptionTxFee.IsInt64() { + return fmt.Errorf("redemption transaction fee is out of range") + } + *rrp = proposal + return nil +} + +// 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.TargetWalletPublicKeyHash == [20]byte{} { + return fmt.Errorf("target wallet public key hash is required") + } + if proposal.ReanchorTxFee == nil { + return fmt.Errorf("re-anchor transaction fee is required") + } + if proposal.ReanchorTxFee.Sign() <= 0 { + return fmt.Errorf("re-anchor transaction fee must be positive") + } + if !proposal.ReanchorTxFee.IsInt64() { + return fmt.Errorf("re-anchor transaction fee is out of range") + } + *rrp = proposal + return nil +} + +// 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") + } + if proposal.DissolutionTxFee.Sign() <= 0 { + return fmt.Errorf("dissolution transaction fee must be positive") + } + if !proposal.DissolutionTxFee.IsInt64() { + return fmt.Errorf("dissolution transaction fee is out of range") + } + if proposal.ReservationKey == nil { + return fmt.Errorf("reservation key is required") + } + *rdp = proposal + return nil +} diff --git a/pkg/tbtc/marshaling_test.go b/pkg/tbtc/marshaling_test.go index 32b6977f0a..47a09fc568 100644 --- a/pkg/tbtc/marshaling_test.go +++ b/pkg/tbtc/marshaling_test.go @@ -434,3 +434,58 @@ func TestFuzzCoordinationMessage_MarshalingRoundtrip_WithNoopProposal(t *testing func TestFuzzCoordinationMessage_Unmarshaler(t *testing.T) { pbutils.FuzzUnmarshaler(&coordinationMessage{}) } + +func FuzzReservationAnchorProposal_Unmarshal(f *testing.F) { + proposal := &ReservationAnchorProposal{ + DepositFundingTxHash: bitcoin.Hash{0x01}, + DepositFundingOutputIndex: 1, + RequestNonce: 1, + AnchorTxFee: big.NewInt(1000), + } + bytes, _ := proposal.Marshal() + f.Add(bytes) + f.Fuzz(func(t *testing.T, data []byte) { + _ = (&ReservationAnchorProposal{}).Unmarshal(data) + }) +} + +func FuzzReservedRedemptionProposal_Unmarshal(f *testing.F) { + proposal := &ReservedRedemptionProposal{ + ReservationKey: big.NewInt(12345), + RequestNonce: 1, + RedeemerOutputScript: bitcoin.Script{0x01}, + RedemptionTxFee: big.NewInt(1000), + } + bytes, _ := proposal.Marshal() + f.Add(bytes) + f.Fuzz(func(t *testing.T, data []byte) { + _ = (&ReservedRedemptionProposal{}).Unmarshal(data) + }) +} + +func FuzzReservationReanchorProposal_Unmarshal(f *testing.F) { + proposal := &ReservationReanchorProposal{ + ReservationKey: big.NewInt(12345), + RequestNonce: 1, + TargetWalletPublicKeyHash: [20]byte{0x01}, + ReanchorTxFee: big.NewInt(1000), + } + bytes, _ := proposal.Marshal() + f.Add(bytes) + f.Fuzz(func(t *testing.T, data []byte) { + _ = (&ReservationReanchorProposal{}).Unmarshal(data) + }) +} + +func FuzzReservationDissolutionProposal_Unmarshal(f *testing.F) { + proposal := &ReservationDissolutionProposal{ + ReservationKey: big.NewInt(12345), + RequestNonce: 1, + DissolutionTxFee: big.NewInt(1000), + } + bytes, _ := proposal.Marshal() + f.Add(bytes) + f.Fuzz(func(t *testing.T, data []byte) { + _ = (&ReservationDissolutionProposal{}).Unmarshal(data) + }) +} diff --git a/pkg/tbtc/reservation.go b/pkg/tbtc/reservation.go index a7a4de2577..37249bd4ba 100644 --- a/pkg/tbtc/reservation.go +++ b/pkg/tbtc/reservation.go @@ -1,28 +1,37 @@ package tbtc import ( - "encoding/json" "fmt" "math/big" - "golang.org/x/crypto/sha3" - "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 determines the reservation anchor + // proposal validity time expressed in blocks. In other words, this is the + // worst-case time for a reservation anchor during which the wallet is busy + // and cannot take another action. The value of 600 blocks is roughly 2 + // hours, assuming 12 seconds per block. reservationAnchorProposalValidityBlocks = 600 // reservedRedemptionProposalValidityBlocks determines the reserved - // redemption proposal validity time expressed in blocks. + // redemption proposal validity time expressed in blocks. In other words, + // this is the worst-case time for a reserved redemption during which the + // wallet is busy and cannot take another action. The value of 600 blocks + // is roughly 2 hours, assuming 12 seconds per block. reservedRedemptionProposalValidityBlocks = 600 // reservationReanchorProposalValidityBlocks determines the reservation - // re-anchor proposal validity time expressed in blocks. + // re-anchor proposal validity time expressed in blocks. In other words, + // this is the worst-case time for a reservation re-anchor during which the + // wallet is busy and cannot take another action. The value of 600 blocks + // is roughly 2 hours, assuming 12 seconds per block. reservationReanchorProposalValidityBlocks = 600 // reservationDissolutionProposalValidityBlocks determines the reservation - // dissolution proposal validity time expressed in blocks. + // dissolution proposal validity time expressed in blocks. In other words, + // this is the worst-case time for a reservation dissolution during which + // the wallet is busy and cannot take another action. The value of 600 + // blocks is roughly 2 hours, assuming 12 seconds per block. reservationDissolutionProposalValidityBlocks = 600 ) @@ -52,7 +61,9 @@ const ( // 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. +// redeemable in-kind by the reservation owner. Dissolution is the sole +// terminal exception: it returns the anchor value to the wallet's pooled +// main UTXO, ending the reservation. type Reservation struct { // Owner is the reservation owner's address on the host chain. Owner chain.Address @@ -86,11 +97,11 @@ type Reservation struct { type ReservationActionType uint8 const ( - ReservationActionTypeNone ReservationActionType = iota - ReservationActionTypeAcceptance - ReservationActionTypeRedemption - ReservationActionTypeReanchor - ReservationActionTypeDissolution + ReservationActionTypeNone ReservationActionType = iota // No action in flight. + ReservationActionTypeAcceptance // The anchor/acceptance action. + ReservationActionTypeRedemption // A reserved redemption. + ReservationActionTypeReanchor // A wallet-migration re-anchor. + ReservationActionTypeDissolution // The terminal dissolution. ) // ReservationActionState represents the settlement state of a reservation @@ -98,12 +109,12 @@ const ( type ReservationActionState uint8 const ( - ReservationActionStateUnknown ReservationActionState = iota - ReservationActionStatePending - ReservationActionStateSettled - ReservationActionStateTimedOut - ReservationActionStateVetoed - ReservationActionStateSuperseded + ReservationActionStateUnknown ReservationActionState = iota // No action generation exists yet. + ReservationActionStatePending // Awaiting settlement. + ReservationActionStateSettled // Confirmed on-chain. + ReservationActionStateTimedOut // Expired without settlement. + ReservationActionStateVetoed // Rejected. + ReservationActionStateSuperseded // Replaced by a later generation. ) // ReservationAction represents one nonce-bound generation of a reservation @@ -129,7 +140,9 @@ type ReservationAction struct { // 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 is the satoshi amount associated with the action generation - it + // tracks the current anchor value being acted on, not Reservation.MintedAmount's + // gross minted claim. Amount uint64 // RedeemerOutputScriptHash is the keccak256 hash of the length-prefixed // output script authorized for a redemption. @@ -181,14 +194,14 @@ type ReservationParameters struct { type ReservationAnchorProposal struct { // DepositFundingTxHash is the funding transaction hash of the reserved // deposit to anchor. - DepositFundingTxHash bitcoin.Hash + DepositFundingTxHash bitcoin.Hash `json:"depositFundingTxHash"` // DepositFundingOutputIndex is the funding output index of the reserved // deposit to anchor. - DepositFundingOutputIndex uint32 - // RequestNonce is the acceptance authorization generation being executed. - RequestNonce uint64 + DepositFundingOutputIndex uint32 `json:"depositFundingOutputIndex"` + // RequestNonce is the anchor authorization generation being executed. + RequestNonce uint64 `json:"requestNonce"` // AnchorTxFee is the proposed BTC fee for the anchor transaction. - AnchorTxFee *big.Int + AnchorTxFee *big.Int `json:"anchorTxFee"` } // ActionType returns the specific type of the walletAction being subject @@ -202,43 +215,19 @@ 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 + ReservationKey *big.Int `json:"reservationKey"` // RequestNonce is the redemption request generation being executed. - RequestNonce uint64 + RequestNonce uint64 `json:"requestNonce"` + // RedeemerOutputScript is the Bitcoin output script the redemption pays to. + RedeemerOutputScript bitcoin.Script `json:"redeemerOutputScript"` // RedemptionTxFee is the proposed BTC fee for the reserved redemption // transaction. - RedemptionTxFee *big.Int + RedemptionTxFee *big.Int `json:"redemptionTxFee"` } // ActionType returns the specific type of the walletAction being subject @@ -252,48 +241,19 @@ 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). type ReservationReanchorProposal struct { // ReservationKey is the key of the reservation to re-anchor. - ReservationKey *big.Int + ReservationKey *big.Int `json:"reservationKey"` // RequestNonce is the re-anchor authorization generation being executed. - RequestNonce uint64 + RequestNonce uint64 `json:"requestNonce"` // TargetWalletPublicKeyHash is the 20-byte public key hash of the wallet // receiving the anchor. - TargetWalletPublicKeyHash [20]byte + TargetWalletPublicKeyHash [20]byte `json:"targetWalletPublicKeyHash"` // ReanchorTxFee is the proposed BTC fee for the re-anchor transaction. - ReanchorTxFee *big.Int + ReanchorTxFee *big.Int `json:"reanchorTxFee"` } // ActionType returns the specific type of the walletAction being subject @@ -307,46 +267,17 @@ 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 + ReservationKey *big.Int `json:"reservationKey"` // RequestNonce is the dissolution authorization generation being executed. - RequestNonce uint64 + RequestNonce uint64 `json:"requestNonce"` // DissolutionTxFee is the proposed BTC fee for the dissolution // transaction. - DissolutionTxFee *big.Int + DissolutionTxFee *big.Int `json:"dissolutionTxFee"` } // ActionType returns the specific type of the walletAction being subject @@ -360,35 +291,6 @@ 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 -} - // 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 @@ -399,8 +301,19 @@ func assembleReservationAnchorTransaction( bitcoinChain bitcoin.Chain, deposit *Deposit, walletPublicKeyHash [20]byte, + action *ReservationAction, + reservationMinAmount uint64, fee int64, ) (*bitcoin.TransactionBuilder, error) { + if action == nil { + return nil, fmt.Errorf("reservation action is required") + } + 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") + } if deposit == nil { return nil, fmt.Errorf("deposit is required") } @@ -422,9 +335,10 @@ func assembleReservationAnchorTransaction( anchorValue := deposit.Utxo.Value - fee if anchorValue <= 0 { - return nil, fmt.Errorf( - "transaction fee exceeds the deposit value", - ) + return nil, fmt.Errorf("transaction fee exceeds the deposit value") + } + if anchorValue < int64(reservationMinAmount) { + return nil, fmt.Errorf("anchor value is below the reservation minimum amount") } anchorScript, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) @@ -448,12 +362,17 @@ func assembleReservationAnchorTransaction( // remainder to the custodying wallet. func assembleReservedRedemptionTransaction( bitcoinChain bitcoin.Chain, + bridgeChain BridgeChain, anchorUtxo *bitcoin.UnspentTransactionOutput, walletPublicKeyHash [20]byte, redeemerOutputScript bitcoin.Script, action *ReservationAction, + reservationMinAmount uint64, fee int64, ) (*bitcoin.TransactionBuilder, error) { + if bridgeChain == nil { + return nil, fmt.Errorf("bridge chain is required") + } if anchorUtxo == nil { return nil, fmt.Errorf("anchor UTXO is required") } @@ -485,12 +404,11 @@ func assembleReservedRedemptionTransaction( return nil, fmt.Errorf("transaction fee exceeds the action fee limit") } - redeemerOutputScriptHash, err := computeReservationRedeemerOutputScriptHash( - redeemerOutputScript, - ) + redeemerOutputScriptHash, err := bridgeChain.ComputeReservationRedeemerOutputScriptHash(redeemerOutputScript) if err != nil { - return nil, err + return nil, fmt.Errorf("cannot compute redeemer output script hash: [%v]", err) } + if redeemerOutputScriptHash != action.RedeemerOutputScriptHash { return nil, fmt.Errorf("redeemer output script is not authorized") } @@ -542,8 +460,13 @@ func assembleReservedRedemptionTransaction( return nil, fmt.Errorf("cannot compute remainder script: [%v]", err) } + remainderValue := anchorUtxo.Value - int64(action.Amount) + if remainderValue < int64(reservationMinAmount) { + return nil, fmt.Errorf("remainder value is below the reservation minimum amount") + } + builder.AddOutput(&bitcoin.TransactionOutput{ - Value: anchorUtxo.Value - int64(action.Amount), + Value: remainderValue, PublicKeyScript: remainderScript, }) } @@ -551,29 +474,6 @@ func assembleReservedRedemptionTransaction( 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 @@ -582,12 +482,31 @@ func assembleReservationReanchorTransaction( bitcoinChain bitcoin.Chain, anchorUtxo *bitcoin.UnspentTransactionOutput, targetWalletPublicKeyHash [20]byte, + action *ReservationAction, + reservationMinAmount uint64, 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 != ReservationActionTypeReanchor { + return nil, fmt.Errorf("reservation action is not a re-anchor") + } + if action.State != ReservationActionStatePending { + return nil, fmt.Errorf("reservation action is not pending") + } + if action.TargetWalletPublicKeyHash != targetWalletPublicKeyHash { + return nil, fmt.Errorf("reanchor action targets a different wallet") + } + 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") + } builder := bitcoin.NewTransactionBuilder(bitcoinChain) err := builder.AddPublicKeyHashInput(anchorUtxo) @@ -600,9 +519,10 @@ func assembleReservationReanchorTransaction( reanchorValue := anchorUtxo.Value - fee if reanchorValue <= 0 { - return nil, fmt.Errorf( - "transaction fee exceeds the anchor value", - ) + return nil, fmt.Errorf("transaction fee exceeds the anchor value") + } + if reanchorValue < int64(reservationMinAmount) { + return nil, fmt.Errorf("re-anchor value is below the reservation minimum amount") } reanchorScript, err := bitcoin.PayToWitnessPublicKeyHash( @@ -627,13 +547,18 @@ func assembleReservationReanchorTransaction( // exactly. The single output pays back to the custodying wallet. func assembleReservationDissolutionTransaction( bitcoinChain bitcoin.Chain, - bridgeChain BridgeChain, + bridgeChain interface { + ComputeMainUtxoHash(mainUtxo *bitcoin.UnspentTransactionOutput) [32]byte + }, anchorUtxo *bitcoin.UnspentTransactionOutput, walletMainUtxo *bitcoin.UnspentTransactionOutput, walletPublicKeyHash [20]byte, action *ReservationAction, fee int64, ) (*bitcoin.TransactionBuilder, error) { + if bridgeChain == nil { + return nil, fmt.Errorf("bridge chain is required") + } if anchorUtxo == nil { return nil, fmt.Errorf("anchor UTXO is required") } @@ -666,9 +591,6 @@ func assembleReservationDissolutionTransaction( 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", @@ -680,6 +602,8 @@ func assembleReservationDissolutionTransaction( "wallet main UTXO does not match the dissolution action snapshot", ) } + } else if walletMainUtxo != nil { + return nil, fmt.Errorf("wallet main UTXO must not be provided when the dissolution action has no expected main UTXO snapshot") } builder := bitcoin.NewTransactionBuilder(bitcoinChain) diff --git a/pkg/tbtc/reservation_test.go b/pkg/tbtc/reservation_test.go index 16ab16c160..6235432858 100644 --- a/pkg/tbtc/reservation_test.go +++ b/pkg/tbtc/reservation_test.go @@ -3,11 +3,14 @@ package tbtc import ( "crypto/ecdsa" "crypto/rand" + "crypto/sha256" + "encoding/json" "math/big" "reflect" "testing" "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/chain" ) func TestReservationActionTypes(t *testing.T) { @@ -100,20 +103,18 @@ func TestReservationProposals_MarshalingRoundtrip(t *testing.T) { RequestNonce: 1, AnchorTxFee: big.NewInt(1500), } - redemptionProposal := &ReservedRedemptionProposal{ - ReservationKey: big.NewInt(12345), - RequestNonce: 2, - RedemptionTxFee: big.NewInt(1600), + ReservationKey: big.NewInt(12345), + RequestNonce: 2, + RedeemerOutputScript: bitcoin.Script{0x00, 0x14, 0x02}, + 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, @@ -147,69 +148,154 @@ func TestReservationProposals_MarshalingRoundtrip(t *testing.T) { } func TestReservationProposals_UnmarshalRejectsMissingIntegers(t *testing.T) { + marshalJSON := func(t *testing.T, v interface{}) string { + t.Helper() + bytes, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return string(bytes) + } + tests := map[string]struct { actionType WalletActionType - payload string + payload func(t *testing.T) 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]", + payload: func(t *testing.T) string { return "null" }, + expectedError: "cannot unmarshal proposal payload: [deposit funding transaction hash is required]", + }, + "anchor missing deposit funding tx hash": { + actionType: ActionReservationAnchor, + payload: func(t *testing.T) string { + return marshalJSON(t, &ReservationAnchorProposal{ + RequestNonce: 1, + AnchorTxFee: big.NewInt(1500), + }) + }, + expectedError: "cannot unmarshal proposal payload: [deposit funding transaction hash is required]", }, "anchor missing nonce": { - actionType: ActionReservationAnchor, - payload: `{"AnchorTxFee":1500}`, + actionType: ActionReservationAnchor, + payload: func(t *testing.T) string { + return marshalJSON(t, &ReservationAnchorProposal{ + DepositFundingTxHash: bitcoin.Hash{0x01}, + AnchorTxFee: big.NewInt(1500), + }) + }, expectedError: "cannot unmarshal proposal payload: [request nonce is required]", }, + "anchor missing fee": { + actionType: ActionReservationAnchor, + payload: func(t *testing.T) string { + return marshalJSON(t, &ReservationAnchorProposal{ + DepositFundingTxHash: bitcoin.Hash{0x01}, + RequestNonce: 1, + }) + }, + expectedError: "cannot unmarshal proposal payload: [anchor transaction fee is required]", + }, "reserved redemption null payload": { actionType: ActionReservedRedemption, - payload: `null`, + payload: func(t *testing.T) string { return "null" }, expectedError: "cannot unmarshal proposal payload: [reservation key is required]", }, "reserved redemption missing nonce": { - actionType: ActionReservedRedemption, - payload: `{"ReservationKey":12345,"RedemptionTxFee":1600}`, + actionType: ActionReservedRedemption, + payload: func(t *testing.T) string { + return marshalJSON(t, &ReservedRedemptionProposal{ + ReservationKey: big.NewInt(12345), + RedeemerOutputScript: bitcoin.Script{0x00, 0x14, 0x02}, + RedemptionTxFee: big.NewInt(1600), + }) + }, expectedError: "cannot unmarshal proposal payload: [request nonce is required]", }, + "reserved redemption missing redeemer output script": { + actionType: ActionReservedRedemption, + payload: func(t *testing.T) string { + return marshalJSON(t, &ReservedRedemptionProposal{ + ReservationKey: big.NewInt(12345), + RequestNonce: 2, + RedemptionTxFee: big.NewInt(1600), + }) + }, + expectedError: "cannot unmarshal proposal payload: [redeemer output script is required]", + }, "reserved redemption missing fee": { - actionType: ActionReservedRedemption, - payload: `{"ReservationKey":12345,"RequestNonce":2}`, + actionType: ActionReservedRedemption, + payload: func(t *testing.T) string { + return marshalJSON(t, &ReservedRedemptionProposal{ + ReservationKey: big.NewInt(12345), + RequestNonce: 2, + RedeemerOutputScript: bitcoin.Script{0x00, 0x14, 0x02}, + }) + }, expectedError: "cannot unmarshal proposal payload: [redemption transaction fee is required]", }, "re-anchor null payload": { actionType: ActionReservationReanchor, - payload: `null`, + payload: func(t *testing.T) string { return "null" }, expectedError: "cannot unmarshal proposal payload: [reservation key is required]", }, "re-anchor missing nonce": { - actionType: ActionReservationReanchor, - payload: `{"ReservationKey":54321,"ReanchorTxFee":1700}`, + actionType: ActionReservationReanchor, + payload: func(t *testing.T) string { + return marshalJSON(t, &ReservationReanchorProposal{ + ReservationKey: big.NewInt(54321), + TargetWalletPublicKeyHash: [20]byte{0xaa, 0xbb}, + ReanchorTxFee: big.NewInt(1700), + }) + }, expectedError: "cannot unmarshal proposal payload: [request nonce is required]", }, + "re-anchor missing target wallet public key hash": { + actionType: ActionReservationReanchor, + payload: func(t *testing.T) string { + return marshalJSON(t, &ReservationReanchorProposal{ + ReservationKey: big.NewInt(54321), + RequestNonce: 3, + ReanchorTxFee: big.NewInt(1700), + }) + }, + expectedError: "cannot unmarshal proposal payload: [target wallet public key hash is required]", + }, "re-anchor missing fee": { - actionType: ActionReservationReanchor, - payload: `{"ReservationKey":54321,"RequestNonce":3}`, + actionType: ActionReservationReanchor, + payload: func(t *testing.T) string { + return marshalJSON(t, &ReservationReanchorProposal{ + ReservationKey: big.NewInt(54321), + RequestNonce: 3, + TargetWalletPublicKeyHash: [20]byte{0xaa, 0xbb}, + }) + }, expectedError: "cannot unmarshal proposal payload: [re-anchor transaction fee is required]", }, "dissolution null payload": { actionType: ActionReservationDissolution, - payload: `null`, + payload: func(t *testing.T) string { return "null" }, expectedError: "cannot unmarshal proposal payload: [reservation key is required]", }, "dissolution missing nonce": { - actionType: ActionReservationDissolution, - payload: `{"ReservationKey":99999,"DissolutionTxFee":1800}`, + actionType: ActionReservationDissolution, + payload: func(t *testing.T) string { + return marshalJSON(t, &ReservationDissolutionProposal{ + ReservationKey: big.NewInt(99999), + DissolutionTxFee: big.NewInt(1800), + }) + }, expectedError: "cannot unmarshal proposal payload: [request nonce is required]", }, "dissolution missing fee": { - actionType: ActionReservationDissolution, - payload: `{"ReservationKey":99999,"RequestNonce":4}`, + actionType: ActionReservationDissolution, + payload: func(t *testing.T) string { + return marshalJSON(t, &ReservationDissolutionProposal{ + ReservationKey: big.NewInt(99999), + RequestNonce: 4, + }) + }, expectedError: "cannot unmarshal proposal payload: [dissolution transaction fee is required]", }, } @@ -218,7 +304,7 @@ func TestReservationProposals_UnmarshalRejectsMissingIntegers(t *testing.T) { t.Run(testName, func(t *testing.T) { _, err := unmarshalCoordinationProposal( uint32(test.actionType), - []byte(test.payload), + []byte(test.payload(t)), ) if err == nil || err.Error() != test.expectedError { t.Errorf( @@ -233,7 +319,7 @@ func TestReservationProposals_UnmarshalRejectsMissingIntegers(t *testing.T) { func TestAssembleReservedRedemptionTransaction(t *testing.T) { bitcoinChain := newLocalBitcoinChain() - + bridgeChain := Connect() privateKeyValue := big.NewInt(100) wallet := generateWallet(privateKeyValue) walletPublicKeyHash := bitcoin.PublicKeyHash(wallet.publicKey) @@ -277,7 +363,7 @@ func TestAssembleReservedRedemptionTransaction(t *testing.T) { Value: 100000, } - redeemerOutputScriptHash, err := computeReservationRedeemerOutputScriptHash( + redeemerOutputScriptHash, err := bridgeChain.ComputeReservationRedeemerOutputScriptHash( redeemerScript, ) if err != nil { @@ -287,6 +373,8 @@ func TestAssembleReservedRedemptionTransaction(t *testing.T) { tests := map[string]struct { action *ReservationAction expectedOutputs []*bitcoin.TransactionOutput + fee int64 + expectedError string }{ "whole redemption": { action: &ReservationAction{ @@ -302,6 +390,7 @@ func TestAssembleReservedRedemptionTransaction(t *testing.T) { PublicKeyScript: redeemerScript, }, }, + fee: 1500, }, "partial redemption": { action: &ReservationAction{ @@ -322,6 +411,30 @@ func TestAssembleReservedRedemptionTransaction(t *testing.T) { PublicKeyScript: walletScript, }, }, + fee: 1500, + }, + "fee exceeds redemption amount": { + action: &ReservationAction{ + TxMaxFee: 150000, + ActionType: ReservationActionTypeRedemption, + State: ReservationActionStatePending, + Amount: 100000, + RedeemerOutputScriptHash: redeemerOutputScriptHash, + }, + fee: 100000, + expectedError: "transaction fee exceeds the redemption amount", + }, + "partial amount exceeds anchor value": { + action: &ReservationAction{ + TxMaxFee: 2000, + ActionType: ReservationActionTypeRedemption, + State: ReservationActionStatePending, + Amount: 150000, + RedeemerOutputScriptHash: redeemerOutputScriptHash, + IsPartial: true, + }, + fee: 1500, + expectedError: "redemption amount exceeds the anchor value", }, } @@ -329,12 +442,22 @@ func TestAssembleReservedRedemptionTransaction(t *testing.T) { t.Run(testName, func(t *testing.T) { builder, err := assembleReservedRedemptionTransaction( bitcoinChain, + bridgeChain, anchorUtxo, walletPublicKeyHash, redeemerScript, test.action, - 1500, + 0, + test.fee, ) + + if test.expectedError != "" { + if err == nil || err.Error() != test.expectedError { + t.Fatalf("expected error: [%s], got: [%v]", test.expectedError, err) + } + return + } + if err != nil { t.Fatal(err) } @@ -422,6 +545,7 @@ func TestAssembleReservationDissolutionTransaction(t *testing.T) { action *ReservationAction expectedInputUtxos []*bitcoin.UnspentTransactionOutput expectedOutputValue int64 + expectedError string }{ "snapshotted main UTXO": { action: func() *ReservationAction { @@ -438,11 +562,24 @@ func TestAssembleReservationDissolutionTransaction(t *testing.T) { expectedOutputValue: 298500, }, "no-main-UTXO snapshot with newly current main UTXO": { - action: &baseAction, - expectedInputUtxos: []*bitcoin.UnspentTransactionOutput{ - anchorUtxo, - }, - expectedOutputValue: 98500, + action: &baseAction, + expectedError: "wallet main UTXO must not be provided when the dissolution action has no expected main UTXO snapshot", + }, + "mismatched action amount": { + action: func() *ReservationAction { + action := baseAction + action.Amount = 99999 + return &action + }(), + expectedError: "dissolution action amount does not match the anchor value", + }, + "mismatched target wallet": { + action: func() *ReservationAction { + action := baseAction + action.TargetWalletPublicKeyHash = [20]byte{0x02} + return &action + }(), + expectedError: "dissolution action targets a different wallet", }, } @@ -457,6 +594,14 @@ func TestAssembleReservationDissolutionTransaction(t *testing.T) { test.action, 1500, ) + + if test.expectedError != "" { + if err == nil || err.Error() != test.expectedError { + t.Fatalf("expected error: [%s], got: [%v]", test.expectedError, err) + } + return + } + if err != nil { t.Fatal(err) } @@ -550,14 +695,40 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { walletPublicKeyHash := [20]byte{0x01} redeemerScript := bitcoin.Script{0x00, 0x14, 0x02} + 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{0x03}, + 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: bitcoin.Hash{0x03}, + TransactionHash: fundingTransaction.Hash(), OutputIndex: 0, }, Value: 100000, } - redeemerOutputScriptHash, err := computeReservationRedeemerOutputScriptHash( + redeemerOutputScriptHash, err := bridgeChain.ComputeReservationRedeemerOutputScriptHash( redeemerScript, ) if err != nil { @@ -584,40 +755,53 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { } } + anchorAction := &ReservationAction{ + ActionType: ReservationActionTypeAcceptance, + State: ReservationActionStatePending, + TxMaxFee: 2000, + } _, err = assembleReservationAnchorTransaction( bitcoinChain, nil, walletPublicKeyHash, + anchorAction, + 0, 1500, ) assertError(err, "deposit is required") _, err = assembleReservedRedemptionTransaction( bitcoinChain, + bridgeChain, nil, walletPublicKeyHash, redeemerScript, redemptionAction, + 0, 1500, ) assertError(err, "anchor UTXO is required") _, err = assembleReservedRedemptionTransaction( bitcoinChain, + bridgeChain, anchorUtxo, walletPublicKeyHash, bitcoin.Script{}, redemptionAction, + 0, 1500, ) assertError(err, "redeemer output script is required") _, err = assembleReservedRedemptionTransaction( bitcoinChain, + bridgeChain, anchorUtxo, walletPublicKeyHash, redeemerScript, nil, + 0, 1500, ) assertError(err, "reservation action is required") @@ -626,10 +810,12 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { nonRedemptionAction.ActionType = ReservationActionTypeReanchor _, err = assembleReservedRedemptionTransaction( bitcoinChain, + bridgeChain, anchorUtxo, walletPublicKeyHash, redeemerScript, &nonRedemptionAction, + 0, 1500, ) assertError(err, "reservation action is not a redemption") @@ -638,10 +824,12 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { nonPendingAction.State = ReservationActionStateTimedOut _, err = assembleReservedRedemptionTransaction( bitcoinChain, + bridgeChain, anchorUtxo, walletPublicKeyHash, redeemerScript, &nonPendingAction, + 0, 1500, ) assertError(err, "reservation action is not pending") @@ -650,10 +838,12 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { wrongScriptAction.RedeemerOutputScriptHash = [32]byte{0x01} _, err = assembleReservedRedemptionTransaction( bitcoinChain, + bridgeChain, anchorUtxo, walletPublicKeyHash, redeemerScript, &wrongScriptAction, + 0, 1500, ) assertError(err, "redeemer output script is not authorized") @@ -662,10 +852,12 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { partialWholeAmountAction.IsPartial = true _, err = assembleReservedRedemptionTransaction( bitcoinChain, + bridgeChain, anchorUtxo, walletPublicKeyHash, redeemerScript, &partialWholeAmountAction, + 0, 1500, ) assertError( @@ -677,20 +869,24 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { partialAmountAction.Amount = 40000 _, err = assembleReservedRedemptionTransaction( bitcoinChain, + bridgeChain, anchorUtxo, walletPublicKeyHash, redeemerScript, &partialAmountAction, + 0, 1500, ) assertError(err, "whole redemption amount must equal the anchor value") _, err = assembleReservedRedemptionTransaction( bitcoinChain, + bridgeChain, anchorUtxo, walletPublicKeyHash, redeemerScript, redemptionAction, + 0, 2500, ) assertError(err, "transaction fee exceeds the action fee limit") @@ -699,6 +895,8 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { bitcoinChain, nil, walletPublicKeyHash, + anchorAction, + 0, 1500, ) assertError(err, "anchor UTXO is required") @@ -725,6 +923,60 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { ) assertError(err, "reservation action is required") + // (a) ActionType != Dissolution + invalidDissolutionAction := *dissolutionAction + invalidDissolutionAction.ActionType = ReservationActionTypeReanchor + _, err = assembleReservationDissolutionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + nil, + walletPublicKeyHash, + &invalidDissolutionAction, + 1500, + ) + assertError(err, "reservation action is not a dissolution") + + // (b) State != Pending + nonPendingDissolutionAction := *dissolutionAction + nonPendingDissolutionAction.State = ReservationActionStateTimedOut + _, err = assembleReservationDissolutionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + nil, + walletPublicKeyHash, + &nonPendingDissolutionAction, + 1500, + ) + assertError(err, "reservation action is not pending") + + // (c) fee > TxMaxFee + _, err = assembleReservationDissolutionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + nil, + walletPublicKeyHash, + dissolutionAction, + 2500, + ) + assertError(err, "transaction fee exceeds the action fee limit") + + // (d) totalInputsValue - fee <= 0 + highFeeLimitDissolutionAction := *dissolutionAction + highFeeLimitDissolutionAction.TxMaxFee = 150000 + _, err = assembleReservationDissolutionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + nil, + walletPublicKeyHash, + &highFeeLimitDissolutionAction, + 100000, + ) + assertError(err, "transaction fee exceeds the total inputs value") + snapshottedMainUtxo := &bitcoin.UnspentTransactionOutput{ Outpoint: &bitcoin.TransactionOutpoint{ TransactionHash: bitcoin.Hash{0x04}, @@ -732,6 +984,34 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { }, Value: 200000, } + + // (e) non-nil walletMainUtxo + no ExpectedMainUtxoHash + _, err = assembleReservationDissolutionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + snapshottedMainUtxo, + walletPublicKeyHash, + dissolutionAction, + 1500, + ) + assertError( + err, + "wallet main UTXO must not be provided when the dissolution action has no expected main UTXO snapshot", + ) + + // (f) bridgeChain == nil + _, err = assembleReservationDissolutionTransaction( + bitcoinChain, + nil, + anchorUtxo, + nil, + walletPublicKeyHash, + dissolutionAction, + 1500, + ) + assertError(err, "bridge chain is required") + actionWithMainUtxo := *dissolutionAction actionWithMainUtxo.ExpectedMainUtxoHash = bridgeChain.ComputeMainUtxoHash( snapshottedMainUtxo, @@ -768,3 +1048,235 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { "wallet main UTXO does not match the dissolution action snapshot", ) } +func TestAssembleReservationAnchorTransaction(t *testing.T) { + bitcoinChain := newLocalBitcoinChain() + walletPublicKeyHash := [20]byte{0x01} + anchorAction := &ReservationAction{ + ActionType: ReservationActionTypeAcceptance, + State: ReservationActionStatePending, + TxMaxFee: 2000, + } + + deposit := &Deposit{ + Depositor: chain.Address("0x1111111111111111111111111111111111111111"), + WalletPublicKeyHash: walletPublicKeyHash, + } + depositScript, err := deposit.Script() + if err != nil { + t.Fatal(err) + } + depositScriptHash := sha256.Sum256(depositScript) + depositLockingScript, err := bitcoin.PayToWitnessScriptHash(depositScriptHash) + 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: depositLockingScript, + }, + }, + } + if err := bitcoinChain.BroadcastTransaction(fundingTransaction); err != nil { + t.Fatal(err) + } + + deposit.Utxo = &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTransaction.Hash(), + OutputIndex: 0, + }, + Value: 100000, + } + + // (a) happy path + _, err = assembleReservationAnchorTransaction( + bitcoinChain, + deposit, + walletPublicKeyHash, + anchorAction, + 0, + 1500, + ) + if err != nil { + t.Fatalf("expected no error, got: [%v]", err) + } + + // (b) fee > TxMaxFee + _, err = assembleReservationAnchorTransaction( + bitcoinChain, + deposit, + walletPublicKeyHash, + anchorAction, + 0, + 2500, + ) + if err == nil || err.Error() != "transaction fee exceeds the action fee limit" { + t.Fatalf("expected error, got: [%v]", err) + } + + // (c) anchorValue < reservationMinAmount + _, err = assembleReservationAnchorTransaction( + bitcoinChain, + deposit, + walletPublicKeyHash, + anchorAction, + 99500, + 1000, + ) + if err == nil || err.Error() != "anchor value is below the reservation minimum amount" { + t.Fatalf("expected error, got: [%v]", err) + } +} +func TestAssembleReservationReanchorTransaction(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) + } + + targetWalletPublicKeyHash := [20]byte{0x02} + reanchorAction := &ReservationAction{ + ActionType: ReservationActionTypeReanchor, + State: ReservationActionStatePending, + TxMaxFee: 2000, + TargetWalletPublicKeyHash: targetWalletPublicKeyHash, + } + + 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, + } + + // (a) happy path + builder, err := assembleReservationReanchorTransaction( + bitcoinChain, + anchorUtxo, + targetWalletPublicKeyHash, + reanchorAction, + 0, + 1500, + ) + if err != nil { + t.Fatalf("expected no error, got: [%v]", err) + } + transaction := signReservationTransaction( + t, + builder, + wallet.publicKey, + privateKeyValue, + ) + if transaction.Outputs[0].Value != 98500 { + t.Errorf("expected output value 98500, got: [%v]", transaction.Outputs[0].Value) + } + + // (b) action type not reanchor + invalidAction := *reanchorAction + invalidAction.ActionType = ReservationActionTypeAcceptance + _, err = assembleReservationReanchorTransaction( + bitcoinChain, + anchorUtxo, + targetWalletPublicKeyHash, + &invalidAction, + 0, + 1500, + ) + if err == nil || err.Error() != "reservation action is not a re-anchor" { + t.Fatalf("expected error, got: [%v]", err) + } + + // (c) action state not pending + invalidStateAction := *reanchorAction + invalidStateAction.State = ReservationActionStateTimedOut + _, err = assembleReservationReanchorTransaction( + bitcoinChain, + anchorUtxo, + targetWalletPublicKeyHash, + &invalidStateAction, + 0, + 1500, + ) + if err == nil || err.Error() != "reservation action is not pending" { + t.Fatalf("expected error, got: [%v]", err) + } + + // (d) mismatched wallet + _, err = assembleReservationReanchorTransaction( + bitcoinChain, + anchorUtxo, + [20]byte{0x03}, + reanchorAction, + 0, + 1500, + ) + if err == nil || err.Error() != "reanchor action targets a different wallet" { + t.Fatalf("expected error, got: [%v]", err) + } + + // (e) fee > TxMaxFee + _, err = assembleReservationReanchorTransaction( + bitcoinChain, + anchorUtxo, + targetWalletPublicKeyHash, + reanchorAction, + 0, + 2500, + ) + if err == nil || err.Error() != "transaction fee exceeds the action fee limit" { + t.Fatalf("expected error, got: [%v]", err) + } + + // (f) reanchorValue < reservationMinAmount + _, err = assembleReservationReanchorTransaction( + bitcoinChain, + anchorUtxo, + targetWalletPublicKeyHash, + reanchorAction, + 99500, + 1000, + ) + if err == nil || err.Error() != "re-anchor value is below the reservation minimum amount" { + t.Fatalf("expected error, got: [%v]", err) + } +} From 9fd8dd3fef6bfdaeddc03bd4efc5b850c038a595 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 1 Sep 2026 12:49:12 +0000 Subject: [PATCH 07/22] test(tbtc): assert wallet action metric names cover all types MetricName() and clientinfo.GetAllWalletActionTypes() are two hand-maintained lists that must stay in sync; this pins that invariant so drift fails the test suite instead of silently degrading metrics. --- pkg/tbtc/wallet_test.go | 49 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/pkg/tbtc/wallet_test.go b/pkg/tbtc/wallet_test.go index 7d5a086414..e917094e38 100644 --- a/pkg/tbtc/wallet_test.go +++ b/pkg/tbtc/wallet_test.go @@ -11,6 +11,7 @@ import ( "fmt" "math/big" "reflect" + "sort" "strings" "sync" "testing" @@ -19,6 +20,7 @@ import ( "github.com/keep-network/keep-core/internal/testutils" "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/protocol/group" "github.com/keep-network/keep-core/pkg/tecdsa" ) @@ -124,6 +126,31 @@ func TestWalletActionType_MetricName(t *testing.T) { } } +func TestWalletActionType_MetricNameConsistency(t *testing.T) { + expected := clientinfo.GetAllWalletActionTypes() + + actual := make([]string, 0) + for i := uint8(0); i <= 9; i++ { + wat, err := ParseWalletActionType(i) + if err != nil { + t.Fatalf("failed to parse wallet action type [%v]: %v", i, err) + } + + if wat == ActionNoop { + continue + } + + actual = append(actual, wat.MetricName()) + } + + sort.Strings(expected) + sort.Strings(actual) + + if !reflect.DeepEqual(expected, actual) { + t.Errorf("Metric names mismatch between WalletActionType and clientinfo.GetAllWalletActionTypes\nexpected: %v\nactual: %v", expected, actual) + } +} + func TestWalletDispatcher_Dispatch(t *testing.T) { walletDispatcher := newWalletDispatcher() @@ -998,3 +1025,25 @@ func TestEnsureWalletSyncedBetweenChains_MainUtxoSpent(t *testing.T) { t.Error("expected error when main UTXO has been spent on Bitcoin, got nil") } } +func TestWalletActionType_String(t *testing.T) { + tests := map[WalletActionType]string{ + ActionNoop: "Noop", + ActionHeartbeat: "Heartbeat", + ActionDepositSweep: "DepositSweep", + ActionRedemption: "Redemption", + ActionMovingFunds: "MovingFunds", + ActionMovedFundsSweep: "MovedFundsSweep", + ActionReservationAnchor: "ReservationAnchor", + ActionReservedRedemption: "ReservedRedemption", + ActionReservationReanchor: "ReservationReanchor", + ActionReservationDissolution: "ReservationDissolution", + } + + for actionType, expected := range tests { + t.Run(expected, func(t *testing.T) { + if actionType.String() != expected { + t.Errorf("expected string [%s], got [%s]", expected, actionType.String()) + } + }) + } +} From 3cfd3bc67ef5b691603cede2f9616fc7504405ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 1 Sep 2026 12:49:13 +0000 Subject: [PATCH 08/22] chore: gitignore agent review workspace agent-docs/ holds review scratch output and should never be committed. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 0c2c04268b..f3d148bd4d 100644 --- a/.gitignore +++ b/.gitignore @@ -82,3 +82,4 @@ dist/ # Yarn 4 local state (not for zero-installs) .yarn/install-state.gz +agent-docs/ From 55c6a0d6a5689eefcf9b7ab2cfcd293afbce2a64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 1 Sep 2026 13:05:52 +0000 Subject: [PATCH 09/22] fix(tbtcpg): implement Chain reservation methods on LocalChain test double LocalChain (pkg/tbtcpg's Chain test double) was missing the reservation methods added to the BridgeChain/WalletProposalValidatorChain interfaces, breaking staticcheck's compile of pkg/tbtcpg's tests. Adds panic-stub implementations matching this file's existing convention for chain functionality its tests don't exercise (e.g. ComputeMainUtxoHash). --- pkg/tbtcpg/chain_test.go | 53 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/pkg/tbtcpg/chain_test.go b/pkg/tbtcpg/chain_test.go index cdff0f01e3..275a2e4655 100644 --- a/pkg/tbtcpg/chain_test.go +++ b/pkg/tbtcpg/chain_test.go @@ -1010,6 +1010,59 @@ func (lc *LocalChain) ComputeMainUtxoHash(mainUtxo *bitcoin.UnspentTransactionOu panic("unsupported") } +func (lc *LocalChain) ComputeReservationRedeemerOutputScriptHash( + redeemerOutputScript bitcoin.Script, +) ([32]byte, error) { + panic("unsupported") +} + +func (lc *LocalChain) GetReservation(reservationKey *big.Int) (*tbtc.Reservation, error) { + panic("unsupported") +} + +func (lc *LocalChain) GetReservationAction( + reservationKey *big.Int, + requestNonce uint64, +) (*tbtc.ReservationAction, error) { + panic("unsupported") +} + +func (lc *LocalChain) ReservationParameters() (*tbtc.ReservationParameters, error) { + panic("unsupported") +} + +func (lc *LocalChain) ValidateReservationAnchorProposal( + walletPublicKeyHash [20]byte, + proposal *tbtc.ReservationAnchorProposal, + depositExtraInfo struct { + *tbtc.Deposit + FundingTx *bitcoin.Transaction + }, +) error { + panic("unsupported") +} + +func (lc *LocalChain) ValidateReservedRedemptionProposal( + walletPublicKeyHash [20]byte, + proposal *tbtc.ReservedRedemptionProposal, +) error { + panic("unsupported") +} + +func (lc *LocalChain) ValidateReservationReanchorProposal( + sourceWalletPublicKeyHash [20]byte, + proposal *tbtc.ReservationReanchorProposal, +) error { + panic("unsupported") +} + +func (lc *LocalChain) ValidateReservationDissolutionProposal( + walletPublicKeyHash [20]byte, + proposal *tbtc.ReservationDissolutionProposal, +) error { + panic("unsupported") +} + func (lc *LocalChain) ComputeMovingFundsCommitmentHash(targetWallets [][20]byte) [32]byte { packedWallets := []byte{} From dc24db3d3d3e90f08efd6c362947b2f4bb658f40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 1 Sep 2026 13:37:11 +0000 Subject: [PATCH 10/22] fix(tbtc): gate anchor assembly on action type and settlement state assembleReservationAnchorTransaction was missing the action.ActionType == Acceptance and action.State == Pending guards that its redemption/ dissolution siblings both have (and that F1's original fix called for by name). A stale or wrong-type action snapshot previously passed straight through to fee/value validation instead of being rejected up front. --- pkg/tbtc/reservation.go | 6 ++++++ pkg/tbtc/reservation_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/pkg/tbtc/reservation.go b/pkg/tbtc/reservation.go index 37249bd4ba..30ef9ad1c1 100644 --- a/pkg/tbtc/reservation.go +++ b/pkg/tbtc/reservation.go @@ -308,6 +308,12 @@ func assembleReservationAnchorTransaction( if action == nil { return nil, fmt.Errorf("reservation action is required") } + if action.ActionType != ReservationActionTypeAcceptance { + return nil, fmt.Errorf("reservation action is not an acceptance") + } + if action.State != ReservationActionStatePending { + return nil, fmt.Errorf("reservation action is not pending") + } if fee <= 0 { return nil, fmt.Errorf("transaction fee must be positive") } diff --git a/pkg/tbtc/reservation_test.go b/pkg/tbtc/reservation_test.go index 6235432858..f1f6663c29 100644 --- a/pkg/tbtc/reservation_test.go +++ b/pkg/tbtc/reservation_test.go @@ -1114,6 +1114,35 @@ func TestAssembleReservationAnchorTransaction(t *testing.T) { t.Fatalf("expected no error, got: [%v]", err) } + // (a1) action type not acceptance + invalidTypeAction := *anchorAction + invalidTypeAction.ActionType = ReservationActionTypeRedemption + _, err = assembleReservationAnchorTransaction( + bitcoinChain, + deposit, + walletPublicKeyHash, + &invalidTypeAction, + 0, + 1500, + ) + if err == nil || err.Error() != "reservation action is not an acceptance" { + t.Fatalf("expected error, got: [%v]", err) + } + + // (a2) action state not pending + invalidStateAction := *anchorAction + invalidStateAction.State = ReservationActionStateTimedOut + _, err = assembleReservationAnchorTransaction( + bitcoinChain, + deposit, + walletPublicKeyHash, + &invalidStateAction, + 0, + 1500, + ) + if err == nil || err.Error() != "reservation action is not pending" { + t.Fatalf("expected error, got: [%v]", err) + } // (b) fee > TxMaxFee _, err = assembleReservationAnchorTransaction( bitcoinChain, From 4a0678d7fc3748421d4a008299fb595e2a3047d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 1 Sep 2026 15:42:18 +0000 Subject: [PATCH 11/22] fix(tbtc): enforce whole-redemption lineage and reservation wallet binding - delete the undeclared partial-redemption capability from assembleReservedRedemptionTransaction, restoring the strict 1-input-1-output shape this PR's own body and the companion contracts spec both claim - add the missing TargetWalletPublicKeyHash check to assembleReservationAnchorTransaction, matching the guard already enforced by its re-anchor and dissolution siblings - move ReservationParameters to parameters.go alongside its siblings, drop the field-name stutter, and drop ReservationTotalAmount in favor of a dedicated accessor - extract requireReservationAction/requireValidActionFee to remove the duplicated nil/type/state/fee guard blocks across all four assemblers - narrow assembleReservedRedemptionTransaction's bridgeChain parameter to the single method it calls, matching the dissolution assembler's existing convention - drop the interface-doc restatements on the four new proposal types' ActionType/ValidityBlocks methods - add missing boundary test coverage (zero/negative fee, zero anchor value, zero redemption amount, nil action) and a fundUtxo test helper to de-duplicate funding-transaction setup --- pkg/tbtc/parameters.go | 36 +++- pkg/tbtc/reservation.go | 188 ++++++-------------- pkg/tbtc/reservation_test.go | 322 ++++++++++++++++++----------------- 3 files changed, 249 insertions(+), 297 deletions(-) diff --git a/pkg/tbtc/parameters.go b/pkg/tbtc/parameters.go index 18a1074744..386f65a0e5 100644 --- a/pkg/tbtc/parameters.go +++ b/pkg/tbtc/parameters.go @@ -1,6 +1,10 @@ package tbtc -import "math/big" +import ( + "math/big" + + "github.com/keep-network/keep-core/pkg/chain" +) // MovingFundsParameters holds the current value of the on-chain parameters // relevant to the moving funds and moved funds sweep processes. It replaces a @@ -51,3 +55,33 @@ type RedemptionParameters struct { TimeoutSlashingAmount *big.Int TimeoutNotifierRewardMultiplier uint32 } + +// ReservationParameters holds the current value of the on-chain parameters +// relevant to UTXO reservations. +type ReservationParameters struct { + // Vault is the address of the reservation vault. Deposits revealed with + // this vault address are treated as UTXO reservations. + Vault chain.Address + // MinAmount is the minimal anchor output amount in satoshi accepted for + // a reservation. + MinAmount uint64 + // TxMaxFee is the maximum transaction fee in satoshi for a single + // reservation lifecycle transaction. + TxMaxFee uint64 + // TermSeconds is the custody term length in seconds. + TermSeconds uint32 + // DissolutionDelay is the delay snapshotted after term expiry before a + // reservation becomes dissolvable. + DissolutionDelay uint32 + // MaxTotalAmount is the maximum total amount of all active reservations + // in satoshi. + MaxTotalAmount uint64 + // MaxReservationsPerWallet is the maximum number of reservations a + // wallet may custody. + MaxReservationsPerWallet uint32 + // ActionTimeout is the timeout for reservation actions in seconds. + ActionTimeout uint32 + // RenewalWindowSeconds is the period before expiry during which a + // reservation can be renewed. + RenewalWindowSeconds uint32 +} diff --git a/pkg/tbtc/reservation.go b/pkg/tbtc/reservation.go index 30ef9ad1c1..4a9313b161 100644 --- a/pkg/tbtc/reservation.go +++ b/pkg/tbtc/reservation.go @@ -150,43 +150,6 @@ type ReservationAction struct { // 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 -// 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 - // 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 @@ -204,13 +167,10 @@ type ReservationAnchorProposal struct { AnchorTxFee *big.Int `json:"anchorTxFee"` } -// 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 } @@ -230,13 +190,10 @@ type ReservedRedemptionProposal struct { RedemptionTxFee *big.Int `json:"redemptionTxFee"` } -// 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 } @@ -256,13 +213,10 @@ type ReservationReanchorProposal struct { ReanchorTxFee *big.Int `json:"reanchorTxFee"` } -// 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 } @@ -280,17 +234,37 @@ type ReservationDissolutionProposal struct { DissolutionTxFee *big.Int `json:"dissolutionTxFee"` } -// 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 } +func requireReservationAction(action *ReservationAction, expectedType ReservationActionType, label string) error { + if action == nil { + return fmt.Errorf("reservation action is required") + } + if action.ActionType != expectedType { + return fmt.Errorf("reservation action is not %s", label) + } + if action.State != ReservationActionStatePending { + return fmt.Errorf("reservation action is not pending") + } + return nil +} + +func requireValidActionFee(fee int64, maxFee uint64) error { + if fee <= 0 { + return fmt.Errorf("transaction fee must be positive") + } + if uint64(fee) > maxFee { + return fmt.Errorf("transaction fee exceeds the action fee limit") + } + return nil +} + // 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 @@ -305,20 +279,14 @@ func assembleReservationAnchorTransaction( reservationMinAmount uint64, fee int64, ) (*bitcoin.TransactionBuilder, error) { - if action == nil { - return nil, fmt.Errorf("reservation action is required") - } - if action.ActionType != ReservationActionTypeAcceptance { - return nil, fmt.Errorf("reservation action is not an acceptance") - } - if action.State != ReservationActionStatePending { - return nil, fmt.Errorf("reservation action is not pending") + if err := requireReservationAction(action, ReservationActionTypeAcceptance, "an acceptance"); err != nil { + return nil, err } - if fee <= 0 { - return nil, fmt.Errorf("transaction fee must be positive") + if action.TargetWalletPublicKeyHash != walletPublicKeyHash { + return nil, fmt.Errorf("acceptance action targets a different wallet") } - if uint64(fee) > action.TxMaxFee { - return nil, fmt.Errorf("transaction fee exceeds the action fee limit") + if err := requireValidActionFee(fee, action.TxMaxFee); err != nil { + return nil, err } if deposit == nil { return nil, fmt.Errorf("deposit is required") @@ -361,19 +329,16 @@ func assembleReservationAnchorTransaction( } // 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. +// redemption transaction for the given nonce-bound action: a 1-input-1-output +// spend of the anchor UTXO to the redeemer output script. func assembleReservedRedemptionTransaction( bitcoinChain bitcoin.Chain, - bridgeChain BridgeChain, + bridgeChain interface { + ComputeReservationRedeemerOutputScriptHash(redeemerOutputScript bitcoin.Script) ([32]byte, error) + }, anchorUtxo *bitcoin.UnspentTransactionOutput, - walletPublicKeyHash [20]byte, redeemerOutputScript bitcoin.Script, action *ReservationAction, - reservationMinAmount uint64, fee int64, ) (*bitcoin.TransactionBuilder, error) { if bridgeChain == nil { @@ -385,14 +350,8 @@ 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 err := requireReservationAction(action, ReservationActionTypeRedemption, "a redemption"); err != nil { + return nil, err } if anchorUtxo.Value <= 0 { return nil, fmt.Errorf("anchor UTXO value must be positive") @@ -403,11 +362,8 @@ func assembleReservedRedemptionTransaction( 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") + if err := requireValidActionFee(fee, action.TxMaxFee); err != nil { + return nil, err } redeemerOutputScriptHash, err := bridgeChain.ComputeReservationRedeemerOutputScriptHash(redeemerOutputScript) @@ -419,13 +375,7 @@ func assembleReservedRedemptionTransaction( 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) { + if action.Amount != uint64(anchorUtxo.Value) { return nil, fmt.Errorf( "whole redemption amount must equal the anchor value", ) @@ -441,12 +391,7 @@ func assembleReservedRedemptionTransaction( ) } - redemptionAmount := anchorUtxo.Value - if action.IsPartial { - redemptionAmount = int64(action.Amount) - } - - redemptionValue := redemptionAmount - fee + redemptionValue := anchorUtxo.Value - fee if redemptionValue <= 0 { return nil, fmt.Errorf( "transaction fee exceeds the redemption amount", @@ -458,25 +403,6 @@ 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) - } - - remainderValue := anchorUtxo.Value - int64(action.Amount) - if remainderValue < int64(reservationMinAmount) { - return nil, fmt.Errorf("remainder value is below the reservation minimum amount") - } - - builder.AddOutput(&bitcoin.TransactionOutput{ - Value: remainderValue, - PublicKeyScript: remainderScript, - }) - } - return builder, nil } @@ -495,23 +421,14 @@ func assembleReservationReanchorTransaction( 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 != ReservationActionTypeReanchor { - return nil, fmt.Errorf("reservation action is not a re-anchor") - } - if action.State != ReservationActionStatePending { - return nil, fmt.Errorf("reservation action is not pending") + if err := requireReservationAction(action, ReservationActionTypeReanchor, "a re-anchor"); err != nil { + return nil, err } if action.TargetWalletPublicKeyHash != targetWalletPublicKeyHash { return nil, fmt.Errorf("reanchor action targets a different wallet") } - 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") + if err := requireValidActionFee(fee, action.TxMaxFee); err != nil { + return nil, err } builder := bitcoin.NewTransactionBuilder(bitcoinChain) @@ -568,14 +485,8 @@ func assembleReservationDissolutionTransaction( 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 err := requireReservationAction(action, ReservationActionTypeDissolution, "a dissolution"); err != nil { + return nil, err } if action.TargetWalletPublicKeyHash != walletPublicKeyHash { return nil, fmt.Errorf("dissolution action targets a different wallet") @@ -588,11 +499,8 @@ func assembleReservationDissolutionTransaction( "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") + if err := requireValidActionFee(fee, action.TxMaxFee); err != nil { + return nil, err } mainUtxoExpected := action.ExpectedMainUtxoHash != [32]byte{} diff --git a/pkg/tbtc/reservation_test.go b/pkg/tbtc/reservation_test.go index f1f6663c29..a5f728e2a4 100644 --- a/pkg/tbtc/reservation_test.go +++ b/pkg/tbtc/reservation_test.go @@ -317,20 +317,20 @@ func TestReservationProposals_UnmarshalRejectsMissingIntegers(t *testing.T) { } } -func TestAssembleReservedRedemptionTransaction(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) - } +func fundUtxo( + t *testing.T, + bitcoinChain bitcoin.Chain, + script bitcoin.Script, + values ...int64, +) []*bitcoin.UnspentTransactionOutput { + t.Helper() - redeemerScript, err := bitcoin.PayToWitnessPublicKeyHash([20]byte{0x01}) - if err != nil { - t.Fatal(err) + outputs := make([]*bitcoin.TransactionOutput, len(values)) + for i, value := range values { + outputs[i] = &bitcoin.TransactionOutput{ + Value: value, + PublicKeyScript: script, + } } fundingTransaction := &bitcoin.Transaction{ @@ -344,25 +344,43 @@ func TestAssembleReservedRedemptionTransaction(t *testing.T) { Sequence: 0xffffffff, }, }, - Outputs: []*bitcoin.TransactionOutput{ - { - Value: 100000, - PublicKeyScript: walletScript, - }, - }, + Outputs: outputs, } if err := bitcoinChain.BroadcastTransaction(fundingTransaction); err != nil { t.Fatal(err) } - anchorUtxo := &bitcoin.UnspentTransactionOutput{ - Outpoint: &bitcoin.TransactionOutpoint{ - TransactionHash: fundingTransaction.Hash(), - OutputIndex: 0, - }, - Value: 100000, + utxos := make([]*bitcoin.UnspentTransactionOutput, len(values)) + for i, value := range values { + utxos[i] = &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTransaction.Hash(), + OutputIndex: uint32(i), + }, + Value: value, + } + } + return utxos +} + +func TestAssembleReservedRedemptionTransaction(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) + } + + redeemerScript, err := bitcoin.PayToWitnessPublicKeyHash([20]byte{0x01}) + if err != nil { + t.Fatal(err) } + anchorUtxo := fundUtxo(t, bitcoinChain, walletScript, 100000)[0] + redeemerOutputScriptHash, err := bridgeChain.ComputeReservationRedeemerOutputScriptHash( redeemerScript, ) @@ -392,27 +410,6 @@ func TestAssembleReservedRedemptionTransaction(t *testing.T) { }, fee: 1500, }, - "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, - }, - }, - fee: 1500, - }, "fee exceeds redemption amount": { action: &ReservationAction{ TxMaxFee: 150000, @@ -424,17 +421,16 @@ func TestAssembleReservedRedemptionTransaction(t *testing.T) { fee: 100000, expectedError: "transaction fee exceeds the redemption amount", }, - "partial amount exceeds anchor value": { + "fee must be positive": { action: &ReservationAction{ TxMaxFee: 2000, ActionType: ReservationActionTypeRedemption, State: ReservationActionStatePending, - Amount: 150000, + Amount: 100000, RedeemerOutputScriptHash: redeemerOutputScriptHash, - IsPartial: true, }, - fee: 1500, - expectedError: "redemption amount exceeds the anchor value", + fee: 0, + expectedError: "transaction fee must be positive", }, } @@ -444,10 +440,8 @@ func TestAssembleReservedRedemptionTransaction(t *testing.T) { bitcoinChain, bridgeChain, anchorUtxo, - walletPublicKeyHash, redeemerScript, test.action, - 0, test.fee, ) @@ -492,46 +486,9 @@ func TestAssembleReservationDissolutionTransaction(t *testing.T) { 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, - } + utxos := fundUtxo(t, bitcoinChain, walletScript, 100000, 200000) + anchorUtxo := utxos[0] + walletMainUtxo := utxos[1] baseAction := ReservationAction{ TargetWalletPublicKeyHash: walletPublicKeyHash, @@ -756,9 +713,10 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { } anchorAction := &ReservationAction{ - ActionType: ReservationActionTypeAcceptance, - State: ReservationActionStatePending, - TxMaxFee: 2000, + TargetWalletPublicKeyHash: walletPublicKeyHash, + ActionType: ReservationActionTypeAcceptance, + State: ReservationActionStatePending, + TxMaxFee: 2000, } _, err = assembleReservationAnchorTransaction( bitcoinChain, @@ -770,14 +728,34 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { ) assertError(err, "deposit is required") + mismatchedWalletAnchorAction := *anchorAction + mismatchedWalletAnchorAction.TargetWalletPublicKeyHash = [20]byte{0x02} + _, err = assembleReservationAnchorTransaction( + bitcoinChain, + nil, + walletPublicKeyHash, + &mismatchedWalletAnchorAction, + 0, + 1500, + ) + assertError(err, "acceptance action targets a different wallet") + + _, err = assembleReservationAnchorTransaction( + bitcoinChain, + nil, + walletPublicKeyHash, + anchorAction, + 0, + 0, + ) + assertError(err, "transaction fee must be positive") + _, err = assembleReservedRedemptionTransaction( bitcoinChain, bridgeChain, nil, - walletPublicKeyHash, redeemerScript, redemptionAction, - 0, 1500, ) assertError(err, "anchor UTXO is required") @@ -786,10 +764,8 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { bitcoinChain, bridgeChain, anchorUtxo, - walletPublicKeyHash, bitcoin.Script{}, redemptionAction, - 0, 1500, ) assertError(err, "redeemer output script is required") @@ -798,10 +774,8 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { bitcoinChain, bridgeChain, anchorUtxo, - walletPublicKeyHash, redeemerScript, nil, - 0, 1500, ) assertError(err, "reservation action is required") @@ -812,10 +786,8 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { bitcoinChain, bridgeChain, anchorUtxo, - walletPublicKeyHash, redeemerScript, &nonRedemptionAction, - 0, 1500, ) assertError(err, "reservation action is not a redemption") @@ -826,10 +798,8 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { bitcoinChain, bridgeChain, anchorUtxo, - walletPublicKeyHash, redeemerScript, &nonPendingAction, - 0, 1500, ) assertError(err, "reservation action is not pending") @@ -840,41 +810,46 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { bitcoinChain, bridgeChain, anchorUtxo, - walletPublicKeyHash, redeemerScript, &wrongScriptAction, - 0, 1500, ) assertError(err, "redeemer output script is not authorized") - partialWholeAmountAction := *redemptionAction - partialWholeAmountAction.IsPartial = true + zeroValueAnchorUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: anchorUtxo.Outpoint, + Value: 0, + } _, err = assembleReservedRedemptionTransaction( bitcoinChain, bridgeChain, - anchorUtxo, - walletPublicKeyHash, + zeroValueAnchorUtxo, redeemerScript, - &partialWholeAmountAction, - 0, + redemptionAction, 1500, ) - assertError( - err, - "partial redemption amount must be less than the anchor value", + assertError(err, "anchor UTXO value must be positive") + + zeroAmountAction := *redemptionAction + zeroAmountAction.Amount = 0 + _, err = assembleReservedRedemptionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + redeemerScript, + &zeroAmountAction, + 1500, ) + assertError(err, "redemption amount must be positive") - partialAmountAction := *redemptionAction - partialAmountAction.Amount = 40000 + wrongAmountAction := *redemptionAction + wrongAmountAction.Amount = 40000 _, err = assembleReservedRedemptionTransaction( bitcoinChain, bridgeChain, anchorUtxo, - walletPublicKeyHash, redeemerScript, - &partialAmountAction, - 0, + &wrongAmountAction, 1500, ) assertError(err, "whole redemption amount must equal the anchor value") @@ -883,14 +858,22 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { bitcoinChain, bridgeChain, anchorUtxo, - walletPublicKeyHash, redeemerScript, redemptionAction, - 0, 2500, ) assertError(err, "transaction fee exceeds the action fee limit") + _, err = assembleReservedRedemptionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + redeemerScript, + redemptionAction, + 0, + ) + assertError(err, "transaction fee must be positive") + _, err = assembleReservationReanchorTransaction( bitcoinChain, nil, @@ -901,6 +884,22 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { ) assertError(err, "anchor UTXO is required") + zeroFeeReanchorAction := &ReservationAction{ + TargetWalletPublicKeyHash: walletPublicKeyHash, + ActionType: ReservationActionTypeReanchor, + State: ReservationActionStatePending, + TxMaxFee: 2000, + } + _, err = assembleReservationReanchorTransaction( + bitcoinChain, + anchorUtxo, + walletPublicKeyHash, + zeroFeeReanchorAction, + 0, + 0, + ) + assertError(err, "transaction fee must be positive") + _, err = assembleReservationDissolutionTransaction( bitcoinChain, bridgeChain, @@ -963,6 +962,18 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { ) assertError(err, "transaction fee exceeds the action fee limit") + // (c1) fee must be positive + _, err = assembleReservationDissolutionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + nil, + walletPublicKeyHash, + dissolutionAction, + 0, + ) + assertError(err, "transaction fee must be positive") + // (d) totalInputsValue - fee <= 0 highFeeLimitDissolutionAction := *dissolutionAction highFeeLimitDissolutionAction.TxMaxFee = 150000 @@ -1052,9 +1063,10 @@ func TestAssembleReservationAnchorTransaction(t *testing.T) { bitcoinChain := newLocalBitcoinChain() walletPublicKeyHash := [20]byte{0x01} anchorAction := &ReservationAction{ - ActionType: ReservationActionTypeAcceptance, - State: ReservationActionStatePending, - TxMaxFee: 2000, + TargetWalletPublicKeyHash: walletPublicKeyHash, + ActionType: ReservationActionTypeAcceptance, + State: ReservationActionStatePending, + TxMaxFee: 2000, } deposit := &Deposit{ @@ -1168,6 +1180,19 @@ func TestAssembleReservationAnchorTransaction(t *testing.T) { if err == nil || err.Error() != "anchor value is below the reservation minimum amount" { t.Fatalf("expected error, got: [%v]", err) } + + // (d) nil action + _, err = assembleReservationAnchorTransaction( + bitcoinChain, + deposit, + walletPublicKeyHash, + nil, + 0, + 1500, + ) + if err == nil || err.Error() != "reservation action is required" { + t.Fatalf("expected error, got: [%v]", err) + } } func TestAssembleReservationReanchorTransaction(t *testing.T) { bitcoinChain := newLocalBitcoinChain() @@ -1188,35 +1213,7 @@ func TestAssembleReservationReanchorTransaction(t *testing.T) { TargetWalletPublicKeyHash: targetWalletPublicKeyHash, } - 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, - } + anchorUtxo := fundUtxo(t, bitcoinChain, walletScript, 100000)[0] // (a) happy path builder, err := assembleReservationReanchorTransaction( @@ -1308,4 +1305,17 @@ func TestAssembleReservationReanchorTransaction(t *testing.T) { if err == nil || err.Error() != "re-anchor value is below the reservation minimum amount" { t.Fatalf("expected error, got: [%v]", err) } + + // (g) nil action + _, err = assembleReservationReanchorTransaction( + bitcoinChain, + anchorUtxo, + targetWalletPublicKeyHash, + nil, + 0, + 1500, + ) + if err == nil || err.Error() != "reservation action is required" { + t.Fatalf("expected error, got: [%v]", err) + } } From 3416be928d2a8f1f8635e76f2edcbd99932a1dc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 1 Sep 2026 15:42:26 +0000 Subject: [PATCH 12/22] fix(tbtc): standardize reservation chain interface conventions - change GetReservation to the (*Reservation, bool, error) found-flag convention already used by GetPendingRedemptionRequest, resolving the doc contradiction with GetReservationAction's error-on-not-found convention - rename ReservationParameters() to GetReservationParameters(), returning by value like every sibling parameter getter, and add a dedicated GetReservationTotalAmount() accessor - rename the shared redemption-key hashing helper from the reservation-only reservationRedeemerOutputScriptHash to redeemerOutputScriptHash since it names a rule shared by both buildRedemptionKey and the new Compute method, and restore the length-prefix doc comment dropped during extraction - replace the %w-wrapped sentinel returns on the reservation stubs with direct returns, matching this file's existing stub convention - add test coverage for the seven reservation stub methods and the renamed redeemer-output-script-hash helper - clarify the source/target wallet split in ValidateReservationReanchorProposal's doc comment - restore the blank line before ComputeReservationRedeemerOutputScriptHash's doc comment, matching the interface's blank-line-between-methods convention --- pkg/chain/ethereum/tbtc.go | 44 +++++++++----------- pkg/chain/ethereum/tbtc_test.go | 73 ++++++++++++++++++++++++++++++++- pkg/tbtc/chain.go | 19 ++++++--- pkg/tbtc/chain_test.go | 8 +++- pkg/tbtcpg/chain_test.go | 8 +++- 5 files changed, 116 insertions(+), 36 deletions(-) diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index b641c09d9c..73938a7a85 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -1549,10 +1549,12 @@ func (tc *TbtcChain) ComputeMainUtxoHash( func (tc *TbtcChain) ComputeReservationRedeemerOutputScriptHash( redeemerOutputScript bitcoin.Script, ) ([32]byte, error) { - return reservationRedeemerOutputScriptHash(redeemerOutputScript) + return redeemerOutputScriptHash(redeemerOutputScript) } -func reservationRedeemerOutputScriptHash(redeemerOutputScript bitcoin.Script) ([32]byte, error) { +// The Bridge contract builds the redemption key using the length-prefixed +// redeemer output script. +func redeemerOutputScriptHash(redeemerOutputScript bitcoin.Script) ([32]byte, error) { prefixedRedeemerOutputScript, err := redeemerOutputScript.ToVarLenData() if err != nil { return [32]byte{}, fmt.Errorf("cannot build prefixed redeemer output script: [%v]", err) @@ -1564,7 +1566,7 @@ func buildRedemptionKey( walletPublicKeyHash [20]byte, redeemerOutputScript bitcoin.Script, ) (*big.Int, error) { - redeemerOutputScriptHash, err := reservationRedeemerOutputScriptHash(redeemerOutputScript) + redeemerOutputScriptHash, err := redeemerOutputScriptHash(redeemerOutputScript) if err != nil { return nil, fmt.Errorf("cannot compute redeemer output script hash: [%v]", err) } @@ -2421,40 +2423,36 @@ func (tc *TbtcChain) GetDepositMinAge() (uint32, error) { } // 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("%w", errReservationsUnsupported) +) (*tbtc.Reservation, bool, error) { + return nil, false, errReservationsUnsupported } // 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("%w", errReservationsUnsupported) + return nil, errReservationsUnsupported } // 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, +func (tc *TbtcChain) GetReservationParameters() ( + tbtc.ReservationParameters, error, ) { - return nil, fmt.Errorf("%w", errReservationsUnsupported) + return tbtc.ReservationParameters{}, errReservationsUnsupported +} + +func (tc *TbtcChain) GetReservationTotalAmount() (uint64, error) { + return 0, errReservationsUnsupported } // 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, @@ -2463,7 +2461,7 @@ func (tc *TbtcChain) ValidateReservationAnchorProposal( FundingTx *bitcoin.Transaction }, ) error { - return fmt.Errorf("%w", errReservationsUnsupported) + return errReservationsUnsupported } // ValidateReservedRedemptionProposal is not yet supported by the Ethereum @@ -2474,27 +2472,23 @@ func (tc *TbtcChain) ValidateReservedRedemptionProposal( walletPublicKeyHash [20]byte, proposal *tbtc.ReservedRedemptionProposal, ) error { - return fmt.Errorf("%w", errReservationsUnsupported) + return errReservationsUnsupported } // 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("%w", errReservationsUnsupported) + return errReservationsUnsupported } // 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("%w", errReservationsUnsupported) + return errReservationsUnsupported } diff --git a/pkg/chain/ethereum/tbtc_test.go b/pkg/chain/ethereum/tbtc_test.go index 1c9eef1be0..3988328535 100644 --- a/pkg/chain/ethereum/tbtc_test.go +++ b/pkg/chain/ethereum/tbtc_test.go @@ -1,17 +1,20 @@ package ethereum import ( + "testing" "bytes" "crypto/ecdsa" "encoding/hex" "fmt" "math/big" "reflect" - "testing" + "errors" + "github.com/keep-network/keep-core/pkg/chain" "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/ethereum/go-ethereum/common" @@ -533,3 +536,69 @@ func TestBuildMovedFundsKey(t *testing.T) { movedFundsKey.Text(16), ) } + +func TestTbtcChainReservationStubs(t *testing.T) { + tc := &TbtcChain{} + + reservationKey := big.NewInt(1) + _, _, err := tc.GetReservation(reservationKey) + if !errors.Is(err, errReservationsUnsupported) { + t.Errorf("GetReservation: expected errReservationsUnsupported, got %v", err) + } + + _, err = tc.GetReservationAction(reservationKey, 1) + if !errors.Is(err, errReservationsUnsupported) { + t.Errorf("GetReservationAction: expected errReservationsUnsupported, got %v", err) + } + + _, err = tc.GetReservationParameters() + if !errors.Is(err, errReservationsUnsupported) { + t.Errorf("GetReservationParameters: expected errReservationsUnsupported, got %v", err) + } + + _, err = tc.GetReservationTotalAmount() + if !errors.Is(err, errReservationsUnsupported) { + t.Errorf("GetReservationTotalAmount: expected errReservationsUnsupported, got %v", err) + } + + var wpkHash [20]byte + + err = tc.ValidateReservationAnchorProposal(wpkHash, &tbtc.ReservationAnchorProposal{}, struct { + *tbtc.Deposit + FundingTx *bitcoin.Transaction + }{}) + if !errors.Is(err, errReservationsUnsupported) { + t.Errorf("ValidateReservationAnchorProposal: expected errReservationsUnsupported, got %v", err) + } + + err = tc.ValidateReservedRedemptionProposal(wpkHash, &tbtc.ReservedRedemptionProposal{}) + if !errors.Is(err, errReservationsUnsupported) { + t.Errorf("ValidateReservedRedemptionProposal: expected errReservationsUnsupported, got %v", err) + } + + err = tc.ValidateReservationReanchorProposal(wpkHash, &tbtc.ReservationReanchorProposal{}) + if !errors.Is(err, errReservationsUnsupported) { + t.Errorf("ValidateReservationReanchorProposal: expected errReservationsUnsupported, got %v", err) + } + + err = tc.ValidateReservationDissolutionProposal(wpkHash, &tbtc.ReservationDissolutionProposal{}) + if !errors.Is(err, errReservationsUnsupported) { + t.Errorf("ValidateReservationDissolutionProposal: expected errReservationsUnsupported, got %v", err) + } +} + +func TestRedeemerOutputScriptHash(t *testing.T) { + // Example script: "76a9144130879211c54df460e484ddf9aac009cb38ee7488ac" + script := bitcoin.Script{0x76, 0xa9, 0x14, 0x41, 0x30, 0x87, 0x92, 0x11, 0xc5, 0x4d, 0xf4, 0x60, 0xe, 0x48, 0x4d, 0xdf, 0xf9, 0xaa, 0xc0, 0x09, 0xcb, 0x38, 0xee, 0x74, 0x88, 0xac} + + hash, err := redeemerOutputScriptHash(script) + if err != nil { + t.Fatal(err) + } + + // Known hash + expectedHash := "b04b97d4aaec109acf3af12994b0c088d2cbb96d3cdb8cdfba66c5a1cf9ca86f" + if hex.EncodeToString(hash[:]) != expectedHash { + t.Errorf("expected hash %s, got %s", expectedHash, hex.EncodeToString(hash[:])) + } +} diff --git a/pkg/tbtc/chain.go b/pkg/tbtc/chain.go index c26f1f6f40..41372a74c2 100644 --- a/pkg/tbtc/chain.go +++ b/pkg/tbtc/chain.go @@ -267,15 +267,16 @@ type BridgeChain interface { // ComputeMainUtxoHash computes the hash of the provided main UTXO // according to the on-chain Bridge rules. ComputeMainUtxoHash(mainUtxo *bitcoin.UnspentTransactionOutput) [32]byte + // ComputeReservationRedeemerOutputScriptHash computes the keccak256 hash of // the length-prefixed redeemer output script, per the on-chain Bridge rules // used to authorize a reserved redemption. ComputeReservationRedeemerOutputScriptHash(redeemerOutputScript bitcoin.Script) ([32]byte, error) // GetReservation gets the on-chain reservation record for the given - // reservation key. Returns a zero-valued record with State == ReservationStateUnknown - // if the reservation was not found. - GetReservation(reservationKey *big.Int) (*Reservation, error) + // reservation key. The returned bool value indicates whether the + // reservation was found or not. + GetReservation(reservationKey *big.Int) (*Reservation, bool, error) // GetReservationAction gets the on-chain action record for the given // reservation key and request nonce. Returns an error if the action @@ -285,9 +286,13 @@ type BridgeChain interface { requestNonce uint64, ) (*ReservationAction, error) - // ReservationParameters gets the current on-chain values of the Bridge + // GetReservationParameters gets the current on-chain value of the // reservation parameters. - ReservationParameters() (*ReservationParameters, error) + GetReservationParameters() (ReservationParameters, error) + + // GetReservationTotalAmount gets the current total amount of all active + // reservations in satoshi. + GetReservationTotalAmount() (uint64, error) // PastDepositRevealedEvents fetches past deposit reveal events according // to the provided filter or unfiltered if the filter is nil. Returned @@ -471,6 +476,10 @@ type WalletProposalValidatorChain interface { // ValidateReservationReanchorProposal validates the given reservation // re-anchor proposal against the chain. Returns an error if the // proposal is not valid or nil otherwise. + // + // sourceWalletPublicKeyHash identifies the wallet currently custodying + // the reservation being moved; the destination wallet is given by + // proposal.TargetWalletPublicKeyHash. ValidateReservationReanchorProposal( sourceWalletPublicKeyHash [20]byte, proposal *ReservationReanchorProposal, diff --git a/pkg/tbtc/chain_test.go b/pkg/tbtc/chain_test.go index 5e8060141f..6cdd850bc3 100644 --- a/pkg/tbtc/chain_test.go +++ b/pkg/tbtc/chain_test.go @@ -1473,7 +1473,7 @@ func generateHandlerID() int { func (lc *localChain) GetReservation( reservationKey *big.Int, -) (*Reservation, error) { +) (*Reservation, bool, error) { panic("unsupported") } @@ -1484,7 +1484,11 @@ func (lc *localChain) GetReservationAction( panic("unsupported") } -func (lc *localChain) ReservationParameters() (*ReservationParameters, error) { +func (lc *localChain) GetReservationParameters() (ReservationParameters, error) { + panic("unsupported") +} + +func (lc *localChain) GetReservationTotalAmount() (uint64, error) { panic("unsupported") } diff --git a/pkg/tbtcpg/chain_test.go b/pkg/tbtcpg/chain_test.go index 275a2e4655..23a2bebbc6 100644 --- a/pkg/tbtcpg/chain_test.go +++ b/pkg/tbtcpg/chain_test.go @@ -1016,7 +1016,7 @@ func (lc *LocalChain) ComputeReservationRedeemerOutputScriptHash( panic("unsupported") } -func (lc *LocalChain) GetReservation(reservationKey *big.Int) (*tbtc.Reservation, error) { +func (lc *LocalChain) GetReservation(reservationKey *big.Int) (*tbtc.Reservation, bool, error) { panic("unsupported") } @@ -1027,7 +1027,11 @@ func (lc *LocalChain) GetReservationAction( panic("unsupported") } -func (lc *LocalChain) ReservationParameters() (*tbtc.ReservationParameters, error) { +func (lc *LocalChain) GetReservationParameters() (tbtc.ReservationParameters, error) { + panic("unsupported") +} + +func (lc *LocalChain) GetReservationTotalAmount() (uint64, error) { panic("unsupported") } From f9323139bb10cbbac3fccddca0eb7c8ab71d8f44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 1 Sep 2026 15:42:33 +0000 Subject: [PATCH 13/22] fix(tbtc): dedupe reservation proposal marshaling validation - remove the dead, verbatim-duplicate ReservationKey nil check in ReservationDissolutionProposal.Unmarshal - extract validateProposalNonceAndFee to remove the duplicated request-nonce/fee validation sequence across all four proposal Unmarshal methods - add deterministic malformed-input tests (truncated JSON, negative fee, zero nonce) for each of the four new proposal types, since the existing fuzz targets only seed one well-formed input and exercise nothing under plain go test --- pkg/tbtc/marshaling.go | 82 +++++++++--------------- pkg/tbtc/marshaling_test.go | 124 +++++++++++++++++++++++++++++++++++- 2 files changed, 152 insertions(+), 54 deletions(-) diff --git a/pkg/tbtc/marshaling.go b/pkg/tbtc/marshaling.go index aa9255d2e4..4ec3be0efc 100644 --- a/pkg/tbtc/marshaling.go +++ b/pkg/tbtc/marshaling.go @@ -488,14 +488,29 @@ func unmarshalPublicKey(bytes []byte) (*ecdsa.PublicKey, error) { } func validateMemberIndex(protoIndex uint32) error { - // Protobuf does not have uint8 type, so we are using uint32. When - // unmarshalling message, we need to make sure we do not overflow. - if protoIndex > group.MaxMemberIndex { - return fmt.Errorf("invalid member index value: [%v]", protoIndex) + if protoIndex == 0 { + return fmt.Errorf("member index must be greater than 0") } return nil } +func validateProposalNonceAndFee(nonce uint64, fee *big.Int, label string) error { + if nonce == 0 { + return fmt.Errorf("request nonce is required") + } + if fee == nil { + return fmt.Errorf("%s is required", label) + } + if fee.Sign() <= 0 { + return fmt.Errorf("%s must be positive", label) + } + if !fee.IsInt64() { + return fmt.Errorf("%s is out of range", label) + } + return nil +} + + // Marshal converts the reservationAnchorProposal to a byte array. // // TODO: Switch to protobuf-based marshaling (see pkg/tbtc/gen/pb) once the @@ -513,18 +528,10 @@ func (rap *ReservationAnchorProposal) Unmarshal(bytes []byte) error { if proposal.DepositFundingTxHash == (bitcoin.Hash{}) { return fmt.Errorf("deposit funding transaction hash is required") } - if proposal.RequestNonce == 0 { - return fmt.Errorf("request nonce is required") - } - if proposal.AnchorTxFee == nil { - return fmt.Errorf("anchor transaction fee is required") - } - if proposal.AnchorTxFee.Sign() <= 0 { - return fmt.Errorf("anchor transaction fee must be positive") - } - if !proposal.AnchorTxFee.IsInt64() { - return fmt.Errorf("anchor transaction fee is out of range") + if err := validateProposalNonceAndFee(proposal.RequestNonce, proposal.AnchorTxFee, "anchor transaction fee"); err != nil { + return err } + *rap = proposal return nil } @@ -546,21 +553,13 @@ 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.RedeemerOutputScript == nil || len(proposal.RedeemerOutputScript) == 0 { return fmt.Errorf("redeemer output script is required") } - if proposal.RedemptionTxFee == nil { - return fmt.Errorf("redemption transaction fee is required") - } - if proposal.RedemptionTxFee.Sign() <= 0 { - return fmt.Errorf("redemption transaction fee must be positive") - } - if !proposal.RedemptionTxFee.IsInt64() { - return fmt.Errorf("redemption transaction fee is out of range") + if err := validateProposalNonceAndFee(proposal.RequestNonce, proposal.RedemptionTxFee, "redemption transaction fee"); err != nil { + return err } + *rrp = proposal return nil } @@ -582,21 +581,13 @@ 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.TargetWalletPublicKeyHash == [20]byte{} { return fmt.Errorf("target wallet public key hash is required") } - if proposal.ReanchorTxFee == nil { - return fmt.Errorf("re-anchor transaction fee is required") - } - if proposal.ReanchorTxFee.Sign() <= 0 { - return fmt.Errorf("re-anchor transaction fee must be positive") - } - if !proposal.ReanchorTxFee.IsInt64() { - return fmt.Errorf("re-anchor transaction fee is out of range") + if err := validateProposalNonceAndFee(proposal.RequestNonce, proposal.ReanchorTxFee, "re-anchor transaction fee"); err != nil { + return err } + *rrp = proposal return nil } @@ -618,21 +609,10 @@ 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") - } - if proposal.DissolutionTxFee.Sign() <= 0 { - return fmt.Errorf("dissolution transaction fee must be positive") - } - if !proposal.DissolutionTxFee.IsInt64() { - return fmt.Errorf("dissolution transaction fee is out of range") - } - if proposal.ReservationKey == nil { - return fmt.Errorf("reservation key is required") + if err := validateProposalNonceAndFee(proposal.RequestNonce, proposal.DissolutionTxFee, "dissolution transaction fee"); err != nil { + return err } + *rdp = proposal return nil } diff --git a/pkg/tbtc/marshaling_test.go b/pkg/tbtc/marshaling_test.go index 47a09fc568..12412ded76 100644 --- a/pkg/tbtc/marshaling_test.go +++ b/pkg/tbtc/marshaling_test.go @@ -4,16 +4,15 @@ import ( "crypto/ecdsa" "crypto/elliptic" "encoding/hex" + "encoding/json" "math/big" "reflect" "strings" "testing" - "github.com/keep-network/keep-core/pkg/bitcoin" - fuzz "github.com/google/gofuzz" - "github.com/keep-network/keep-core/internal/testutils" + "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/internal/pbutils" "github.com/keep-network/keep-core/pkg/protocol/group" "github.com/keep-network/keep-core/pkg/tbtc/gen/pb" @@ -489,3 +488,122 @@ func FuzzReservationDissolutionProposal_Unmarshal(f *testing.F) { _ = (&ReservationDissolutionProposal{}).Unmarshal(data) }) } +func TestReservationAnchorProposal_UnmarshalRejectsMalformedInput(t *testing.T) { + t.Run("truncated json", func(t *testing.T) { + if err := (&ReservationAnchorProposal{}).Unmarshal([]byte(`{"depositFundingTxHash":`)); err == nil { + t.Fatal("expected error for truncated json") + } + }) + + t.Run("zero request nonce", func(t *testing.T) { + data, _ := json.Marshal(ReservationAnchorProposal{ + DepositFundingTxHash: bitcoin.Hash{0x01}, + RequestNonce: 0, + AnchorTxFee: big.NewInt(100), + }) + if err := (&ReservationAnchorProposal{}).Unmarshal(data); err == nil { + t.Fatal("expected error for zero request nonce") + } + }) + + t.Run("negative fee", func(t *testing.T) { + data, _ := json.Marshal(ReservationAnchorProposal{ + DepositFundingTxHash: bitcoin.Hash{0x01}, + RequestNonce: 1, + AnchorTxFee: big.NewInt(-100), + }) + if err := (&ReservationAnchorProposal{}).Unmarshal(data); err == nil { + t.Fatal("expected error for negative fee") + } + }) +} + +func TestReservedRedemptionProposal_UnmarshalRejectsMalformedInput(t *testing.T) { + t.Run("truncated json", func(t *testing.T) { + if err := (&ReservedRedemptionProposal{}).Unmarshal([]byte(`{"reservationKey":`)); err == nil { + t.Fatal("expected error for truncated json") + } + }) + + t.Run("zero request nonce", func(t *testing.T) { + data, _ := json.Marshal(ReservedRedemptionProposal{ + ReservationKey: big.NewInt(12345), + RequestNonce: 0, + RedemptionTxFee: big.NewInt(100), + }) + if err := (&ReservedRedemptionProposal{}).Unmarshal(data); err == nil { + t.Fatal("expected error for zero request nonce") + } + }) + + t.Run("negative fee", func(t *testing.T) { + data, _ := json.Marshal(ReservedRedemptionProposal{ + ReservationKey: big.NewInt(12345), + RequestNonce: 1, + RedemptionTxFee: big.NewInt(-100), + }) + if err := (&ReservedRedemptionProposal{}).Unmarshal(data); err == nil { + t.Fatal("expected error for negative fee") + } + }) +} + +func TestReservationReanchorProposal_UnmarshalRejectsMalformedInput(t *testing.T) { + t.Run("truncated json", func(t *testing.T) { + if err := (&ReservationReanchorProposal{}).Unmarshal([]byte(`{"reservationKey":`)); err == nil { + t.Fatal("expected error for truncated json") + } + }) + + t.Run("zero request nonce", func(t *testing.T) { + data, _ := json.Marshal(ReservationReanchorProposal{ + ReservationKey: big.NewInt(12345), + RequestNonce: 0, + ReanchorTxFee: big.NewInt(100), + }) + if err := (&ReservationReanchorProposal{}).Unmarshal(data); err == nil { + t.Fatal("expected error for zero request nonce") + } + }) + + t.Run("negative fee", func(t *testing.T) { + data, _ := json.Marshal(ReservationReanchorProposal{ + ReservationKey: big.NewInt(12345), + RequestNonce: 1, + ReanchorTxFee: big.NewInt(-100), + }) + if err := (&ReservationReanchorProposal{}).Unmarshal(data); err == nil { + t.Fatal("expected error for negative fee") + } + }) +} + +func TestReservationDissolutionProposal_UnmarshalRejectsMalformedInput(t *testing.T) { + t.Run("truncated json", func(t *testing.T) { + if err := (&ReservationDissolutionProposal{}).Unmarshal([]byte(`{"reservationKey":`)); err == nil { + t.Fatal("expected error for truncated json") + } + }) + + t.Run("zero request nonce", func(t *testing.T) { + data, _ := json.Marshal(ReservationDissolutionProposal{ + ReservationKey: big.NewInt(12345), + RequestNonce: 0, + DissolutionTxFee: big.NewInt(100), + }) + if err := (&ReservationDissolutionProposal{}).Unmarshal(data); err == nil { + t.Fatal("expected error for zero request nonce") + } + }) + + t.Run("negative fee", func(t *testing.T) { + data, _ := json.Marshal(ReservationDissolutionProposal{ + ReservationKey: big.NewInt(12345), + RequestNonce: 1, + DissolutionTxFee: big.NewInt(-100), + }) + if err := (&ReservationDissolutionProposal{}).Unmarshal(data); err == nil { + t.Fatal("expected error for negative fee") + } + }) +} From db0115d8fe5f95462f551314b0a730346801406c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 1 Sep 2026 15:42:34 +0000 Subject: [PATCH 14/22] chore(tbtc): drop unrelated gitignore hunk and derive action-type test bound - drop the undisclosed agent-docs/ .gitignore addition, out of scope for this PR - derive TestWalletActionType_MetricNameConsistency's loop bound from ParseWalletActionType's own domain instead of a hardcoded upper bound, so a future action type added without a clientinfo entry can't silently pass --- .gitignore | 1 - pkg/tbtc/wallet_test.go | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index f3d148bd4d..0c2c04268b 100644 --- a/.gitignore +++ b/.gitignore @@ -82,4 +82,3 @@ dist/ # Yarn 4 local state (not for zero-installs) .yarn/install-state.gz -agent-docs/ diff --git a/pkg/tbtc/wallet_test.go b/pkg/tbtc/wallet_test.go index e917094e38..7fe44704fb 100644 --- a/pkg/tbtc/wallet_test.go +++ b/pkg/tbtc/wallet_test.go @@ -130,10 +130,10 @@ func TestWalletActionType_MetricNameConsistency(t *testing.T) { expected := clientinfo.GetAllWalletActionTypes() actual := make([]string, 0) - for i := uint8(0); i <= 9; i++ { + for i := uint8(0); ; i++ { wat, err := ParseWalletActionType(i) if err != nil { - t.Fatalf("failed to parse wallet action type [%v]: %v", i, err) + break } if wat == ActionNoop { From 0297289dedd1ec2e7d804659738b1b5c5179f69a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 1 Sep 2026 16:09:10 +0000 Subject: [PATCH 15/22] style(tbtc): gofmt pkg/chain/ethereum/tbtc_test.go and pkg/tbtc/marshaling.go --- pkg/chain/ethereum/tbtc_test.go | 13 ++++++------- pkg/tbtc/marshaling.go | 1 - 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/pkg/chain/ethereum/tbtc_test.go b/pkg/chain/ethereum/tbtc_test.go index 3988328535..fc54c7dfab 100644 --- a/pkg/chain/ethereum/tbtc_test.go +++ b/pkg/chain/ethereum/tbtc_test.go @@ -1,18 +1,17 @@ package ethereum import ( - "testing" "bytes" "crypto/ecdsa" "encoding/hex" + "errors" "fmt" "math/big" "reflect" - "errors" + "testing" - "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/bitcoin" - + "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/tbtc" @@ -562,7 +561,7 @@ func TestTbtcChainReservationStubs(t *testing.T) { } var wpkHash [20]byte - + err = tc.ValidateReservationAnchorProposal(wpkHash, &tbtc.ReservationAnchorProposal{}, struct { *tbtc.Deposit FundingTx *bitcoin.Transaction @@ -590,12 +589,12 @@ func TestTbtcChainReservationStubs(t *testing.T) { func TestRedeemerOutputScriptHash(t *testing.T) { // Example script: "76a9144130879211c54df460e484ddf9aac009cb38ee7488ac" script := bitcoin.Script{0x76, 0xa9, 0x14, 0x41, 0x30, 0x87, 0x92, 0x11, 0xc5, 0x4d, 0xf4, 0x60, 0xe, 0x48, 0x4d, 0xdf, 0xf9, 0xaa, 0xc0, 0x09, 0xcb, 0x38, 0xee, 0x74, 0x88, 0xac} - + hash, err := redeemerOutputScriptHash(script) if err != nil { t.Fatal(err) } - + // Known hash expectedHash := "b04b97d4aaec109acf3af12994b0c088d2cbb96d3cdb8cdfba66c5a1cf9ca86f" if hex.EncodeToString(hash[:]) != expectedHash { diff --git a/pkg/tbtc/marshaling.go b/pkg/tbtc/marshaling.go index 4ec3be0efc..1be12ce0fb 100644 --- a/pkg/tbtc/marshaling.go +++ b/pkg/tbtc/marshaling.go @@ -510,7 +510,6 @@ func validateProposalNonceAndFee(nonce uint64, fee *big.Int, label string) error return nil } - // Marshal converts the reservationAnchorProposal to a byte array. // // TODO: Switch to protobuf-based marshaling (see pkg/tbtc/gen/pb) once the From 985cd985f5a4d3898299613cf7d898f7a17c99b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 1 Sep 2026 18:28:28 +0000 Subject: [PATCH 16/22] fix(tbtc): address review findings on UTXO reservation assemblers - reanchor: bind action.Amount and the anchor outpoint, closing the only lane with no value/outpoint authorization (P1) - anchor happy-path test: assert constructed inputs/outputs instead of just the returned error (P1) - anchor/dissolution: derive the destination script from the wallet's own public key instead of trusting an RPC-supplied hash, matching every other self-paying assembler in the package - redemption/dissolution/reanchor: thread the reservation's authoritative anchor outpoint through and reject on mismatch, instead of relying on a value-only check - enforce ReservationAction.TimeoutAt in requireReservationAction - reject an all-zero wallet/target public key hash in the anchor, reanchor, and dissolution assemblers to prevent a silent burn output - restore validateMemberIndex's uint32->uint8 overflow guard (marshaling.go), dropped as unrelated collateral in this PR; keep the new zero-check alongside it - range-check ReservationKey (sign, bit length) on the three JSON-unmarshaled reservation proposals - simplify a redundant nil||len() check in ReservedRedemptionProposal.Unmarshal - complete six truncated/missing doc comments on the new Ethereum chain reservation stubs; fix a misnamed and a misplaced comment - dissolution: use builder.TotalInputsValue() instead of a hand-rolled sum; document the wallet-action enum as append-only - add missing structural test coverage for reanchor/redemption inputs and five previously-untested fee/value boundary branches - note the dissolution input-order assumption against the unmerged tbtc-v2#1088 Bridge contract as a tracked TODO --- pkg/chain/ethereum/tbtc.go | 35 +++- pkg/tbtc/marshaling.go | 31 +++- pkg/tbtc/marshaling_test.go | 99 +++++++++++ pkg/tbtc/reservation.go | 99 +++++++++-- pkg/tbtc/reservation_test.go | 314 +++++++++++++++++++++++++++++++---- pkg/tbtc/wallet.go | 4 + 6 files changed, 523 insertions(+), 59 deletions(-) diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index 73938a7a85..6bdb65f1cd 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -1546,14 +1546,21 @@ func (tc *TbtcChain) ComputeMainUtxoHash( ) [32]byte { return computeMainUtxoHash(mainUtxo) } + +// ComputeReservationRedeemerOutputScriptHash computes the keccak256 hash of the +// length-prefixed redeemer output script, as required by the on-chain Bridge +// rules. See also redeemerOutputScriptHash. func (tc *TbtcChain) ComputeReservationRedeemerOutputScriptHash( redeemerOutputScript bitcoin.Script, ) ([32]byte, error) { return redeemerOutputScriptHash(redeemerOutputScript) } -// The Bridge contract builds the redemption key using the length-prefixed -// redeemer output script. +// redeemerOutputScriptHash computes the keccak256 hash of the length-prefixed +// redeemer output script, as required by the on-chain Bridge rules. It is a +// building block for both the legacy redemption key (see buildRedemptionKey) +// and reservation redemption authorization (see +// ComputeReservationRedeemerOutputScriptHash). func redeemerOutputScriptHash(redeemerOutputScript bitcoin.Script) ([32]byte, error) { prefixedRedeemerOutputScript, err := redeemerOutputScript.ToVarLenData() if err != nil { @@ -1562,6 +1569,8 @@ func redeemerOutputScriptHash(redeemerOutputScript bitcoin.Script) ([32]byte, er return crypto.Keccak256Hash(prefixedRedeemerOutputScript), nil } +// buildRedemptionKey builds the redemption key by hashing the concatenation +// of the redeemer output script hash and the wallet public key hash. func buildRedemptionKey( walletPublicKeyHash [20]byte, redeemerOutputScript bitcoin.Script, @@ -2418,11 +2427,9 @@ func (tc *TbtcChain) GetRedemptionDelay( return time.Duration(delay) * time.Second, nil } -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, bool, error) { @@ -2431,6 +2438,8 @@ 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, @@ -2438,8 +2447,10 @@ func (tc *TbtcChain) GetReservationAction( return nil, errReservationsUnsupported } -// ReservationParameters is not yet supported by the Ethereum chain +// GetReservationParameters 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) GetReservationParameters() ( tbtc.ReservationParameters, error, @@ -2447,12 +2458,18 @@ func (tc *TbtcChain) GetReservationParameters() ( return tbtc.ReservationParameters{}, errReservationsUnsupported } +// GetReservationTotalAmount 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) GetReservationTotalAmount() (uint64, error) { return 0, errReservationsUnsupported } // 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, @@ -2477,6 +2494,8 @@ func (tc *TbtcChain) ValidateReservedRedemptionProposal( // 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, @@ -2486,6 +2505,8 @@ func (tc *TbtcChain) ValidateReservationReanchorProposal( // 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, diff --git a/pkg/tbtc/marshaling.go b/pkg/tbtc/marshaling.go index 1be12ce0fb..938f573781 100644 --- a/pkg/tbtc/marshaling.go +++ b/pkg/tbtc/marshaling.go @@ -488,8 +488,21 @@ func unmarshalPublicKey(bytes []byte) (*ecdsa.PublicKey, error) { } func validateMemberIndex(protoIndex uint32) error { - if protoIndex == 0 { - return fmt.Errorf("member index must be greater than 0") + // Protobuf does not have uint8 type, so we are using uint32. When + // unmarshalling message, we need to make sure we do not overflow, + // and that a valid (non-zero) member index was provided. + if protoIndex == 0 || protoIndex > uint32(group.MaxMemberIndex) { + return fmt.Errorf("invalid member index value: [%v]", protoIndex) + } + return nil +} + +func validateReservationKey(key *big.Int) error { + if key == nil { + return fmt.Errorf("reservation key is required") + } + if key.Sign() <= 0 || key.BitLen() > 256 { + return fmt.Errorf("reservation key is out of range") } return nil } @@ -549,10 +562,10 @@ func (rrp *ReservedRedemptionProposal) Unmarshal(bytes []byte) error { if err := json.Unmarshal(bytes, &proposal); err != nil { return err } - if proposal.ReservationKey == nil { - return fmt.Errorf("reservation key is required") + if err := validateReservationKey(proposal.ReservationKey); err != nil { + return err } - if proposal.RedeemerOutputScript == nil || len(proposal.RedeemerOutputScript) == 0 { + if len(proposal.RedeemerOutputScript) == 0 { return fmt.Errorf("redeemer output script is required") } if err := validateProposalNonceAndFee(proposal.RequestNonce, proposal.RedemptionTxFee, "redemption transaction fee"); err != nil { @@ -577,8 +590,8 @@ func (rrp *ReservationReanchorProposal) Unmarshal(bytes []byte) error { if err := json.Unmarshal(bytes, &proposal); err != nil { return err } - if proposal.ReservationKey == nil { - return fmt.Errorf("reservation key is required") + if err := validateReservationKey(proposal.ReservationKey); err != nil { + return err } if proposal.TargetWalletPublicKeyHash == [20]byte{} { return fmt.Errorf("target wallet public key hash is required") @@ -605,8 +618,8 @@ func (rdp *ReservationDissolutionProposal) Unmarshal(bytes []byte) error { if err := json.Unmarshal(bytes, &proposal); err != nil { return err } - if proposal.ReservationKey == nil { - return fmt.Errorf("reservation key is required") + if err := validateReservationKey(proposal.ReservationKey); err != nil { + return err } if err := validateProposalNonceAndFee(proposal.RequestNonce, proposal.DissolutionTxFee, "dissolution transaction fee"); err != nil { return err diff --git a/pkg/tbtc/marshaling_test.go b/pkg/tbtc/marshaling_test.go index 12412ded76..6ecb650f55 100644 --- a/pkg/tbtc/marshaling_test.go +++ b/pkg/tbtc/marshaling_test.go @@ -20,6 +20,42 @@ import ( "google.golang.org/protobuf/proto" ) +func TestValidateMemberIndex(t *testing.T) { + tests := map[string]struct { + protoIndex uint32 + wantErr bool + }{ + "valid index 1": { + protoIndex: 1, + wantErr: false, + }, + "valid index 255": { + protoIndex: 255, + wantErr: false, + }, + "invalid index 0": { + protoIndex: 0, + wantErr: true, + }, + "invalid index 256": { + protoIndex: 256, + wantErr: true, + }, + "invalid index 300": { + protoIndex: 300, + wantErr: true, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + err := validateMemberIndex(test.protoIndex) + if (err != nil) != test.wantErr { + t.Errorf("validateMemberIndex() error = %v, wantErr %v", err, test.wantErr) + } + }) + } +} func TestSignerMarshalling(t *testing.T) { marshaled := createMockSigner(t) @@ -546,6 +582,27 @@ func TestReservedRedemptionProposal_UnmarshalRejectsMalformedInput(t *testing.T) t.Fatal("expected error for negative fee") } }) + t.Run("negative reservation key", func(t *testing.T) { + data, _ := json.Marshal(ReservedRedemptionProposal{ + ReservationKey: big.NewInt(-1), + RequestNonce: 1, + RedemptionTxFee: big.NewInt(100), + }) + if err := (&ReservedRedemptionProposal{}).Unmarshal(data); err == nil { + t.Fatal("expected error for negative reservation key") + } + }) + + t.Run("oversized reservation key", func(t *testing.T) { + data, _ := json.Marshal(ReservedRedemptionProposal{ + ReservationKey: new(big.Int).Lsh(big.NewInt(1), 256), // > 256 bits + RequestNonce: 1, + RedemptionTxFee: big.NewInt(100), + }) + if err := (&ReservedRedemptionProposal{}).Unmarshal(data); err == nil { + t.Fatal("expected error for oversized reservation key") + } + }) } func TestReservationReanchorProposal_UnmarshalRejectsMalformedInput(t *testing.T) { @@ -576,6 +633,27 @@ func TestReservationReanchorProposal_UnmarshalRejectsMalformedInput(t *testing.T t.Fatal("expected error for negative fee") } }) + t.Run("negative reservation key", func(t *testing.T) { + data, _ := json.Marshal(ReservationReanchorProposal{ + ReservationKey: big.NewInt(-1), + RequestNonce: 1, + ReanchorTxFee: big.NewInt(100), + }) + if err := (&ReservationReanchorProposal{}).Unmarshal(data); err == nil { + t.Fatal("expected error for negative reservation key") + } + }) + + t.Run("oversized reservation key", func(t *testing.T) { + data, _ := json.Marshal(ReservationReanchorProposal{ + ReservationKey: new(big.Int).Lsh(big.NewInt(1), 256), // > 256 bits + RequestNonce: 1, + ReanchorTxFee: big.NewInt(100), + }) + if err := (&ReservationReanchorProposal{}).Unmarshal(data); err == nil { + t.Fatal("expected error for oversized reservation key") + } + }) } func TestReservationDissolutionProposal_UnmarshalRejectsMalformedInput(t *testing.T) { @@ -606,4 +684,25 @@ func TestReservationDissolutionProposal_UnmarshalRejectsMalformedInput(t *testin t.Fatal("expected error for negative fee") } }) + t.Run("negative reservation key", func(t *testing.T) { + data, _ := json.Marshal(ReservationDissolutionProposal{ + ReservationKey: big.NewInt(-1), + RequestNonce: 1, + DissolutionTxFee: big.NewInt(100), + }) + if err := (&ReservationDissolutionProposal{}).Unmarshal(data); err == nil { + t.Fatal("expected error for negative reservation key") + } + }) + + t.Run("oversized reservation key", func(t *testing.T) { + data, _ := json.Marshal(ReservationDissolutionProposal{ + ReservationKey: new(big.Int).Lsh(big.NewInt(1), 256), // > 256 bits + RequestNonce: 1, + DissolutionTxFee: big.NewInt(100), + }) + if err := (&ReservationDissolutionProposal{}).Unmarshal(data); err == nil { + t.Fatal("expected error for oversized reservation key") + } + }) } diff --git a/pkg/tbtc/reservation.go b/pkg/tbtc/reservation.go index 4a9313b161..7969d689b4 100644 --- a/pkg/tbtc/reservation.go +++ b/pkg/tbtc/reservation.go @@ -1,6 +1,7 @@ package tbtc import ( + "crypto/ecdsa" "fmt" "math/big" @@ -242,7 +243,12 @@ func (rdp *ReservationDissolutionProposal) ValidityBlocks() uint64 { return reservationDissolutionProposalValidityBlocks } -func requireReservationAction(action *ReservationAction, expectedType ReservationActionType, label string) error { +func requireReservationAction( + action *ReservationAction, + expectedType ReservationActionType, + now uint32, + label string, +) error { if action == nil { return fmt.Errorf("reservation action is required") } @@ -250,7 +256,10 @@ func requireReservationAction(action *ReservationAction, expectedType Reservatio return fmt.Errorf("reservation action is not %s", label) } if action.State != ReservationActionStatePending { - return fmt.Errorf("reservation action is not pending") + return fmt.Errorf("reservation action has already been settled") + } + if action.TimeoutAt != 0 && now >= action.TimeoutAt { + return fmt.Errorf("reservation action has timed out") } return nil } @@ -274,17 +283,25 @@ func requireValidActionFee(fee int64, maxFee uint64) error { func assembleReservationAnchorTransaction( bitcoinChain bitcoin.Chain, deposit *Deposit, - walletPublicKeyHash [20]byte, + walletPublicKey *ecdsa.PublicKey, action *ReservationAction, reservationMinAmount uint64, fee int64, + now uint32, ) (*bitcoin.TransactionBuilder, error) { - if err := requireReservationAction(action, ReservationActionTypeAcceptance, "an acceptance"); err != nil { + if walletPublicKey == nil { + return nil, fmt.Errorf("wallet public key is required") + } + walletPublicKeyHash := bitcoin.PublicKeyHash(walletPublicKey) + if err := requireReservationAction(action, ReservationActionTypeAcceptance, now, "an acceptance"); err != nil { return nil, err } if action.TargetWalletPublicKeyHash != walletPublicKeyHash { return nil, fmt.Errorf("acceptance action targets a different wallet") } + if action.TargetWalletPublicKeyHash == [20]byte{} { + return nil, fmt.Errorf("target wallet public key hash is required") + } if err := requireValidActionFee(fee, action.TxMaxFee); err != nil { return nil, err } @@ -340,6 +357,8 @@ func assembleReservedRedemptionTransaction( redeemerOutputScript bitcoin.Script, action *ReservationAction, fee int64, + now uint32, + expectedAnchorOutpoint *bitcoin.TransactionOutpoint, ) (*bitcoin.TransactionBuilder, error) { if bridgeChain == nil { return nil, fmt.Errorf("bridge chain is required") @@ -347,10 +366,19 @@ func assembleReservedRedemptionTransaction( if anchorUtxo == nil { return nil, fmt.Errorf("anchor UTXO is required") } + if expectedAnchorOutpoint == nil { + return nil, fmt.Errorf("expected anchor outpoint is required") + } + if anchorUtxo.Outpoint == nil { + return nil, fmt.Errorf("anchor UTXO outpoint is required") + } + if *anchorUtxo.Outpoint != *expectedAnchorOutpoint { + return nil, fmt.Errorf("anchor UTXO outpoint does not match the action snapshot") + } if len(redeemerOutputScript) == 0 { return nil, fmt.Errorf("redeemer output script is required") } - if err := requireReservationAction(action, ReservationActionTypeRedemption, "a redemption"); err != nil { + if err := requireReservationAction(action, ReservationActionTypeRedemption, now, "a redemption"); err != nil { return nil, err } if anchorUtxo.Value <= 0 { @@ -417,19 +445,41 @@ func assembleReservationReanchorTransaction( action *ReservationAction, reservationMinAmount uint64, fee int64, + now uint32, + expectedAnchorOutpoint *bitcoin.TransactionOutpoint, ) (*bitcoin.TransactionBuilder, error) { if anchorUtxo == nil { return nil, fmt.Errorf("anchor UTXO is required") } - if err := requireReservationAction(action, ReservationActionTypeReanchor, "a re-anchor"); err != nil { + if expectedAnchorOutpoint == nil { + return nil, fmt.Errorf("expected anchor outpoint is required") + } + if anchorUtxo.Outpoint == nil { + return nil, fmt.Errorf("anchor UTXO outpoint is required") + } + if *anchorUtxo.Outpoint != *expectedAnchorOutpoint { + return nil, fmt.Errorf("anchor UTXO outpoint does not match the action snapshot") + } + if err := requireReservationAction(action, ReservationActionTypeReanchor, now, "a re-anchor"); err != nil { return nil, err } if action.TargetWalletPublicKeyHash != targetWalletPublicKeyHash { return nil, fmt.Errorf("reanchor action targets a different wallet") } + if action.TargetWalletPublicKeyHash == [20]byte{} { + return nil, fmt.Errorf("target wallet public key hash is required") + } if err := requireValidActionFee(fee, action.TxMaxFee); err != nil { return nil, err } + + if anchorUtxo.Value <= 0 { + return nil, fmt.Errorf("anchor UTXO value must be positive") + } + if action.Amount != uint64(anchorUtxo.Value) { + return nil, fmt.Errorf("reanchor action amount does not match the anchor value") + } + builder := bitcoin.NewTransactionBuilder(bitcoinChain) err := builder.AddPublicKeyHashInput(anchorUtxo) @@ -468,6 +518,16 @@ func assembleReservationReanchorTransaction( // 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. +// +// TODO: the anchor-first, main-UTXO-second input ordering below is a +// load-bearing assumption about the reservation Bridge contract's SPV proof +// validation (see the companion contracts PR, threshold-network/tbtc-v2#1088, +// for the authoritative rule). This is not independently verified from this +// repo. If the merged Bridge contract requires a different order, a broadcast +// dissolution transaction here would be irreversible and unrecognized by the +// Bridge. Confirm the exact input-order clause in tbtc-v2#1088 before this +// assembler is wired into the coordination executor, and remove this TODO +// once confirmed (or fix the ordering if it turns out to be wrong). func assembleReservationDissolutionTransaction( bitcoinChain bitcoin.Chain, bridgeChain interface { @@ -475,22 +535,40 @@ func assembleReservationDissolutionTransaction( }, anchorUtxo *bitcoin.UnspentTransactionOutput, walletMainUtxo *bitcoin.UnspentTransactionOutput, - walletPublicKeyHash [20]byte, + walletPublicKey *ecdsa.PublicKey, action *ReservationAction, fee int64, + now uint32, + expectedAnchorOutpoint *bitcoin.TransactionOutpoint, ) (*bitcoin.TransactionBuilder, error) { + if walletPublicKey == nil { + return nil, fmt.Errorf("wallet public key is required") + } + walletPublicKeyHash := bitcoin.PublicKeyHash(walletPublicKey) if bridgeChain == nil { return nil, fmt.Errorf("bridge chain is required") } if anchorUtxo == nil { return nil, fmt.Errorf("anchor UTXO is required") } - if err := requireReservationAction(action, ReservationActionTypeDissolution, "a dissolution"); err != nil { + if expectedAnchorOutpoint == nil { + return nil, fmt.Errorf("expected anchor outpoint is required") + } + if anchorUtxo.Outpoint == nil { + return nil, fmt.Errorf("anchor UTXO outpoint is required") + } + if *anchorUtxo.Outpoint != *expectedAnchorOutpoint { + return nil, fmt.Errorf("anchor UTXO outpoint does not match the action snapshot") + } + if err := requireReservationAction(action, ReservationActionTypeDissolution, now, "a dissolution"); err != nil { return nil, err } if action.TargetWalletPublicKeyHash != walletPublicKeyHash { return nil, fmt.Errorf("dissolution action targets a different wallet") } + if action.TargetWalletPublicKeyHash == [20]byte{} { + return nil, fmt.Errorf("target wallet public key hash is required") + } if anchorUtxo.Value <= 0 { return nil, fmt.Errorf("anchor UTXO value must be positive") } @@ -531,8 +609,6 @@ func assembleReservationDissolutionTransaction( ) } - totalInputsValue := anchorUtxo.Value - if mainUtxoExpected { err = builder.AddPublicKeyHashInput(walletMainUtxo) if err != nil { @@ -541,10 +617,9 @@ func assembleReservationDissolutionTransaction( err, ) } - totalInputsValue += walletMainUtxo.Value } - dissolutionValue := totalInputsValue - fee + dissolutionValue := builder.TotalInputsValue() - fee if dissolutionValue <= 0 { return nil, fmt.Errorf( "transaction fee exceeds the total inputs value", diff --git a/pkg/tbtc/reservation_test.go b/pkg/tbtc/reservation_test.go index a5f728e2a4..e07963b9e5 100644 --- a/pkg/tbtc/reservation_test.go +++ b/pkg/tbtc/reservation_test.go @@ -2,6 +2,7 @@ package tbtc import ( "crypto/ecdsa" + "crypto/elliptic" "crypto/rand" "crypto/sha256" "encoding/json" @@ -443,6 +444,8 @@ func TestAssembleReservedRedemptionTransaction(t *testing.T) { redeemerScript, test.action, test.fee, + 0, + anchorUtxo.Outpoint, ) if test.expectedError != "" { @@ -547,9 +550,11 @@ func TestAssembleReservationDissolutionTransaction(t *testing.T) { bridgeChain, anchorUtxo, walletMainUtxo, - walletPublicKeyHash, + wallet.publicKey, test.action, 1500, + 0, + anchorUtxo.Outpoint, ) if test.expectedError != "" { @@ -649,7 +654,9 @@ func signReservationTransaction( func TestAssembleReservationTransactions_InputValidation(t *testing.T) { bitcoinChain := newLocalBitcoinChain() bridgeChain := Connect() - walletPublicKeyHash := [20]byte{0x01} + walletPrivateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + walletPublicKey := &walletPrivateKey.PublicKey + walletPublicKeyHash := bitcoin.PublicKeyHash(walletPublicKey) redeemerScript := bitcoin.Script{0x00, 0x14, 0x02} walletScript, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) @@ -721,10 +728,11 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { _, err = assembleReservationAnchorTransaction( bitcoinChain, nil, - walletPublicKeyHash, + walletPublicKey, anchorAction, 0, 1500, + 0, ) assertError(err, "deposit is required") @@ -733,20 +741,22 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { _, err = assembleReservationAnchorTransaction( bitcoinChain, nil, - walletPublicKeyHash, + walletPublicKey, &mismatchedWalletAnchorAction, 0, 1500, + 0, ) assertError(err, "acceptance action targets a different wallet") _, err = assembleReservationAnchorTransaction( bitcoinChain, nil, - walletPublicKeyHash, + walletPublicKey, anchorAction, 0, 0, + 0, ) assertError(err, "transaction fee must be positive") @@ -757,6 +767,8 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { redeemerScript, redemptionAction, 1500, + 0, + &bitcoin.TransactionOutpoint{}, ) assertError(err, "anchor UTXO is required") @@ -767,6 +779,8 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { bitcoin.Script{}, redemptionAction, 1500, + 0, + anchorUtxo.Outpoint, ) assertError(err, "redeemer output script is required") @@ -777,6 +791,8 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { redeemerScript, nil, 1500, + 0, + anchorUtxo.Outpoint, ) assertError(err, "reservation action is required") @@ -789,6 +805,8 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { redeemerScript, &nonRedemptionAction, 1500, + 0, + anchorUtxo.Outpoint, ) assertError(err, "reservation action is not a redemption") @@ -801,8 +819,10 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { redeemerScript, &nonPendingAction, 1500, + 0, + anchorUtxo.Outpoint, ) - assertError(err, "reservation action is not pending") + assertError(err, "reservation action has already been settled") wrongScriptAction := *redemptionAction wrongScriptAction.RedeemerOutputScriptHash = [32]byte{0x01} @@ -813,6 +833,8 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { redeemerScript, &wrongScriptAction, 1500, + 0, + anchorUtxo.Outpoint, ) assertError(err, "redeemer output script is not authorized") @@ -827,6 +849,8 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { redeemerScript, redemptionAction, 1500, + 0, + zeroValueAnchorUtxo.Outpoint, ) assertError(err, "anchor UTXO value must be positive") @@ -839,6 +863,8 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { redeemerScript, &zeroAmountAction, 1500, + 0, + anchorUtxo.Outpoint, ) assertError(err, "redemption amount must be positive") @@ -851,6 +877,8 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { redeemerScript, &wrongAmountAction, 1500, + 0, + anchorUtxo.Outpoint, ) assertError(err, "whole redemption amount must equal the anchor value") @@ -861,6 +889,8 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { redeemerScript, redemptionAction, 2500, + 0, + anchorUtxo.Outpoint, ) assertError(err, "transaction fee exceeds the action fee limit") @@ -871,6 +901,8 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { redeemerScript, redemptionAction, 0, + 0, + anchorUtxo.Outpoint, ) assertError(err, "transaction fee must be positive") @@ -881,6 +913,8 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { anchorAction, 0, 1500, + 0, + &bitcoin.TransactionOutpoint{}, ) assertError(err, "anchor UTXO is required") @@ -897,6 +931,8 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { zeroFeeReanchorAction, 0, 0, + 0, + anchorUtxo.Outpoint, ) assertError(err, "transaction fee must be positive") @@ -905,9 +941,11 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { bridgeChain, nil, nil, - walletPublicKeyHash, + walletPublicKey, dissolutionAction, 1500, + 0, + &bitcoin.TransactionOutpoint{}, ) assertError(err, "anchor UTXO is required") @@ -916,9 +954,11 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { bridgeChain, anchorUtxo, nil, - walletPublicKeyHash, + walletPublicKey, nil, 1500, + 0, + anchorUtxo.Outpoint, ) assertError(err, "reservation action is required") @@ -930,9 +970,11 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { bridgeChain, anchorUtxo, nil, - walletPublicKeyHash, + walletPublicKey, &invalidDissolutionAction, 1500, + 0, + anchorUtxo.Outpoint, ) assertError(err, "reservation action is not a dissolution") @@ -944,11 +986,13 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { bridgeChain, anchorUtxo, nil, - walletPublicKeyHash, + walletPublicKey, &nonPendingDissolutionAction, 1500, + 0, + anchorUtxo.Outpoint, ) - assertError(err, "reservation action is not pending") + assertError(err, "reservation action has already been settled") // (c) fee > TxMaxFee _, err = assembleReservationDissolutionTransaction( @@ -956,9 +1000,11 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { bridgeChain, anchorUtxo, nil, - walletPublicKeyHash, + walletPublicKey, dissolutionAction, 2500, + 0, + anchorUtxo.Outpoint, ) assertError(err, "transaction fee exceeds the action fee limit") @@ -968,9 +1014,11 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { bridgeChain, anchorUtxo, nil, - walletPublicKeyHash, + walletPublicKey, dissolutionAction, 0, + 0, + anchorUtxo.Outpoint, ) assertError(err, "transaction fee must be positive") @@ -982,9 +1030,11 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { bridgeChain, anchorUtxo, nil, - walletPublicKeyHash, + walletPublicKey, &highFeeLimitDissolutionAction, - 100000, + 150000, + 0, + anchorUtxo.Outpoint, ) assertError(err, "transaction fee exceeds the total inputs value") @@ -1002,9 +1052,11 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { bridgeChain, anchorUtxo, snapshottedMainUtxo, - walletPublicKeyHash, + walletPublicKey, dissolutionAction, 1500, + 0, + anchorUtxo.Outpoint, ) assertError( err, @@ -1017,9 +1069,11 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { nil, anchorUtxo, nil, - walletPublicKeyHash, + walletPublicKey, dissolutionAction, 1500, + 0, + &bitcoin.TransactionOutpoint{}, ) assertError(err, "bridge chain is required") @@ -1032,9 +1086,11 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { bridgeChain, anchorUtxo, nil, - walletPublicKeyHash, + walletPublicKey, &actionWithMainUtxo, 1500, + 0, + anchorUtxo.Outpoint, ) assertError(err, "wallet main UTXO is required by the dissolution action") @@ -1050,9 +1106,11 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { bridgeChain, anchorUtxo, currentMainUtxo, - walletPublicKeyHash, + walletPublicKey, &actionWithMainUtxo, 1500, + 0, + anchorUtxo.Outpoint, ) assertError( err, @@ -1061,7 +1119,9 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { } func TestAssembleReservationAnchorTransaction(t *testing.T) { bitcoinChain := newLocalBitcoinChain() - walletPublicKeyHash := [20]byte{0x01} + walletPrivateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + walletPublicKey := &walletPrivateKey.PublicKey + walletPublicKeyHash := bitcoin.PublicKeyHash(walletPublicKey) anchorAction := &ReservationAction{ TargetWalletPublicKeyHash: walletPublicKeyHash, ActionType: ReservationActionTypeAcceptance, @@ -1114,28 +1174,66 @@ func TestAssembleReservationAnchorTransaction(t *testing.T) { } // (a) happy path - _, err = assembleReservationAnchorTransaction( + builder, err := assembleReservationAnchorTransaction( bitcoinChain, deposit, - walletPublicKeyHash, + walletPublicKey, anchorAction, 0, 1500, + 0, ) if err != nil { t.Fatalf("expected no error, got: [%v]", err) } + transaction := signReservationTransaction( + t, + builder, + walletPublicKey, + walletPrivateKey.D, + ) + + if len(transaction.Inputs) != 1 { + t.Fatalf("expected 1 input, got %v", len(transaction.Inputs)) + } + if !reflect.DeepEqual(transaction.Inputs[0].Outpoint, deposit.Utxo.Outpoint) { + t.Errorf( + "unexpected input outpoint\nexpected: [%+v]\nactual: [%+v]", + deposit.Utxo.Outpoint, + transaction.Inputs[0].Outpoint, + ) + } + + expectedAnchorValue := int64(100000 - 1500) + expectedScript, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + expectedOutputs := []*bitcoin.TransactionOutput{ + { + Value: expectedAnchorValue, + PublicKeyScript: expectedScript, + }, + } + if !reflect.DeepEqual(expectedOutputs, transaction.Outputs) { + t.Errorf( + "unexpected outputs\nexpected: [%+v]\nactual: [%+v]", + expectedOutputs, + transaction.Outputs, + ) + } // (a1) action type not acceptance invalidTypeAction := *anchorAction invalidTypeAction.ActionType = ReservationActionTypeRedemption _, err = assembleReservationAnchorTransaction( bitcoinChain, deposit, - walletPublicKeyHash, + walletPublicKey, &invalidTypeAction, 0, 1500, + 0, ) if err == nil || err.Error() != "reservation action is not an acceptance" { t.Fatalf("expected error, got: [%v]", err) @@ -1147,22 +1245,24 @@ func TestAssembleReservationAnchorTransaction(t *testing.T) { _, err = assembleReservationAnchorTransaction( bitcoinChain, deposit, - walletPublicKeyHash, + walletPublicKey, &invalidStateAction, 0, 1500, + 0, ) - if err == nil || err.Error() != "reservation action is not pending" { + if err == nil || err.Error() != "reservation action has already been settled" { t.Fatalf("expected error, got: [%v]", err) } // (b) fee > TxMaxFee _, err = assembleReservationAnchorTransaction( bitcoinChain, deposit, - walletPublicKeyHash, + walletPublicKey, anchorAction, 0, 2500, + 0, ) if err == nil || err.Error() != "transaction fee exceeds the action fee limit" { t.Fatalf("expected error, got: [%v]", err) @@ -1172,10 +1272,11 @@ func TestAssembleReservationAnchorTransaction(t *testing.T) { _, err = assembleReservationAnchorTransaction( bitcoinChain, deposit, - walletPublicKeyHash, + walletPublicKey, anchorAction, 99500, 1000, + 0, ) if err == nil || err.Error() != "anchor value is below the reservation minimum amount" { t.Fatalf("expected error, got: [%v]", err) @@ -1185,10 +1286,11 @@ func TestAssembleReservationAnchorTransaction(t *testing.T) { _, err = assembleReservationAnchorTransaction( bitcoinChain, deposit, - walletPublicKeyHash, + walletPublicKey, nil, 0, 1500, + 0, ) if err == nil || err.Error() != "reservation action is required" { t.Fatalf("expected error, got: [%v]", err) @@ -1206,15 +1308,16 @@ func TestAssembleReservationReanchorTransaction(t *testing.T) { } targetWalletPublicKeyHash := [20]byte{0x02} + anchorUtxo := fundUtxo(t, bitcoinChain, walletScript, 100000)[0] + reanchorAction := &ReservationAction{ ActionType: ReservationActionTypeReanchor, State: ReservationActionStatePending, TxMaxFee: 2000, TargetWalletPublicKeyHash: targetWalletPublicKeyHash, + Amount: uint64(anchorUtxo.Value), } - anchorUtxo := fundUtxo(t, bitcoinChain, walletScript, 100000)[0] - // (a) happy path builder, err := assembleReservationReanchorTransaction( bitcoinChain, @@ -1223,6 +1326,8 @@ func TestAssembleReservationReanchorTransaction(t *testing.T) { reanchorAction, 0, 1500, + 0, + anchorUtxo.Outpoint, ) if err != nil { t.Fatalf("expected no error, got: [%v]", err) @@ -1247,6 +1352,8 @@ func TestAssembleReservationReanchorTransaction(t *testing.T) { &invalidAction, 0, 1500, + 0, + anchorUtxo.Outpoint, ) if err == nil || err.Error() != "reservation action is not a re-anchor" { t.Fatalf("expected error, got: [%v]", err) @@ -1262,8 +1369,10 @@ func TestAssembleReservationReanchorTransaction(t *testing.T) { &invalidStateAction, 0, 1500, + 0, + anchorUtxo.Outpoint, ) - if err == nil || err.Error() != "reservation action is not pending" { + if err == nil || err.Error() != "reservation action has already been settled" { t.Fatalf("expected error, got: [%v]", err) } @@ -1275,6 +1384,8 @@ func TestAssembleReservationReanchorTransaction(t *testing.T) { reanchorAction, 0, 1500, + 0, + anchorUtxo.Outpoint, ) if err == nil || err.Error() != "reanchor action targets a different wallet" { t.Fatalf("expected error, got: [%v]", err) @@ -1288,6 +1399,8 @@ func TestAssembleReservationReanchorTransaction(t *testing.T) { reanchorAction, 0, 2500, + 0, + anchorUtxo.Outpoint, ) if err == nil || err.Error() != "transaction fee exceeds the action fee limit" { t.Fatalf("expected error, got: [%v]", err) @@ -1301,6 +1414,8 @@ func TestAssembleReservationReanchorTransaction(t *testing.T) { reanchorAction, 99500, 1000, + 0, + anchorUtxo.Outpoint, ) if err == nil || err.Error() != "re-anchor value is below the reservation minimum amount" { t.Fatalf("expected error, got: [%v]", err) @@ -1314,8 +1429,145 @@ func TestAssembleReservationReanchorTransaction(t *testing.T) { nil, 0, 1500, + 0, + anchorUtxo.Outpoint, ) if err == nil || err.Error() != "reservation action is required" { t.Fatalf("expected error, got: [%v]", err) } + + // (h) expected anchor outpoint mismatch + wrongOutpoint := &bitcoin.TransactionOutpoint{ + TransactionHash: anchorUtxo.Outpoint.TransactionHash, + OutputIndex: anchorUtxo.Outpoint.OutputIndex + 1, + } + _, err = assembleReservationReanchorTransaction( + bitcoinChain, + anchorUtxo, + targetWalletPublicKeyHash, + reanchorAction, + 0, + 1500, + 0, + wrongOutpoint, + ) + if err == nil || err.Error() != "anchor UTXO outpoint does not match the action snapshot" { + t.Fatalf("expected error, got: [%v]", err) + } +} + +func TestAssembleReservationReanchorTransaction_AmountMismatch(t *testing.T) { + // Setup + bitcoinChain := newLocalBitcoinChain() + anchorUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x01}, + OutputIndex: 0, + }, + Value: 100000, + } + action := &ReservationAction{ + TargetWalletPublicKeyHash: [20]byte{0x01}, + ActionType: ReservationActionTypeReanchor, + State: ReservationActionStatePending, + TxMaxFee: 2000, + Amount: 50000, // Mismatch + } + + // Execute + _, err := assembleReservationReanchorTransaction( + bitcoinChain, + anchorUtxo, + [20]byte{0x01}, + action, + 0, + 1000, + 0, + anchorUtxo.Outpoint, + ) + + // Assert + if err == nil || err.Error() != "reanchor action amount does not match the anchor value" { + t.Errorf("expected error [reanchor action amount does not match the anchor value], got [%v]", err) + } +} + +func TestAssembleReservationTransactions_BoundaryErrors(t *testing.T) { + bitcoinChain := newLocalBitcoinChain() + bridgeChain := Connect() + walletPrivateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + walletPublicKey := &walletPrivateKey.PublicKey + + walletPublicKeyHash := bitcoin.PublicKeyHash(walletPublicKey) + walletScript, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + anchorUtxo := fundUtxo(t, bitcoinChain, walletScript, 100000)[0] + + // 1. Anchor's deposit-value-exceeded + deposit := &Deposit{ + Depositor: chain.Address("0x1111111111111111111111111111111111111111"), + WalletPublicKeyHash: walletPublicKeyHash, + } + depositScript, err := deposit.Script() + if err != nil { + t.Fatal(err) + } + depositScriptHash := sha256.Sum256(depositScript) + depositLockingScript, err := bitcoin.PayToWitnessScriptHash(depositScriptHash) + if err != nil { + t.Fatal(err) + } + deposit.Utxo = fundUtxo(t, bitcoinChain, depositLockingScript, 1000)[0] + _, err = assembleReservationAnchorTransaction(bitcoinChain, deposit, walletPublicKey, &ReservationAction{TargetWalletPublicKeyHash: walletPublicKeyHash, ActionType: ReservationActionTypeAcceptance, State: ReservationActionStatePending, TxMaxFee: 2000}, 0, 2000, 0) + if err == nil || err.Error() != "transaction fee exceeds the deposit value" { + t.Errorf("expected error [transaction fee exceeds the deposit value], got [%v]", err) + } + + // 2. Redemption's bridge-chain-nil + _, err = assembleReservedRedemptionTransaction(bitcoinChain, nil, anchorUtxo, bitcoin.Script{0x00}, &ReservationAction{}, 100, 0, anchorUtxo.Outpoint) + if err == nil || err.Error() != "bridge chain is required" { + t.Errorf("expected error [bridge chain is required], got [%v]", err) + } + + // 3. Redemption's amount-exceeds-anchor + _, err = assembleReservedRedemptionTransaction(bitcoinChain, bridgeChain, anchorUtxo, bitcoin.Script{0x00}, &ReservationAction{ActionType: ReservationActionTypeRedemption, State: ReservationActionStatePending, Amount: 200000}, 100, 0, anchorUtxo.Outpoint) + if err == nil || err.Error() != "redemption amount exceeds the anchor value" { + t.Errorf("expected error [redemption amount exceeds the anchor value], got [%v]", err) + } + + // 4. Reanchor's fee-exceeds-anchor-value + _, err = assembleReservationReanchorTransaction(bitcoinChain, anchorUtxo, [20]byte{0x01}, &ReservationAction{TargetWalletPublicKeyHash: [20]byte{0x01}, ActionType: ReservationActionTypeReanchor, State: ReservationActionStatePending, TxMaxFee: 200000, Amount: 100000}, 0, 200000, 0, anchorUtxo.Outpoint) + if err == nil || err.Error() != "transaction fee exceeds the anchor value" { + t.Errorf("expected error [transaction fee exceeds the anchor value], got [%v]", err) + } + + // 5. Dissolution's zero-value-anchor + zeroValueAnchorUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x09}, + OutputIndex: 0, + }, + Value: 0, + } + _, err = assembleReservationDissolutionTransaction( + bitcoinChain, + bridgeChain, + zeroValueAnchorUtxo, + nil, + walletPublicKey, + &ReservationAction{ + TargetWalletPublicKeyHash: walletPublicKeyHash, + ActionType: ReservationActionTypeDissolution, + State: ReservationActionStatePending, + TxMaxFee: 200, + }, + 100, + 0, + zeroValueAnchorUtxo.Outpoint, + ) + if err == nil || err.Error() != "anchor UTXO value must be positive" { + t.Errorf("expected error [anchor UTXO value must be positive], got [%v]", err) + } } diff --git a/pkg/tbtc/wallet.go b/pkg/tbtc/wallet.go index ef4303302f..dc54ae3c76 100644 --- a/pkg/tbtc/wallet.go +++ b/pkg/tbtc/wallet.go @@ -23,6 +23,10 @@ import ( ) // WalletActionType represents actions types that can be performed by a wallet. +// +// Append-only: values are part of the wire-serialized wallet action +// protocol. Do not reorder or insert new values in the middle of the +// existing block; always append new action types at the end. type WalletActionType uint8 const ( From d5b83e8331ed4bb9e5e45510c8ec4fd8065baa13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 1 Sep 2026 18:37:25 +0000 Subject: [PATCH 17/22] fix(tbtc): restore GetDepositMinAge dropped while completing stub doc comments Silently deleted during the doc-comment fix for the reservation Ethereum chain stubs, breaking TbtcChain's tbtcpg.Chain interface implementation for FindDeposits/EstimateDepositsSweepFee/ NewProposalGenerator call sites in cmd/. Caught by CI (client-vet, client-scan), not by 'go build ./pkg/...' alone since cmd/ isn't under pkg/. Restored verbatim from the pre-fix commit. --- pkg/chain/ethereum/tbtc.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index 6bdb65f1cd..89e58ee6d7 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -2427,6 +2427,10 @@ func (tc *TbtcChain) GetRedemptionDelay( return time.Duration(delay) * time.Second, nil } +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. From 76baf176fce45d262b0ac30fdd0404754655e2e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 2 Sep 2026 11:04:49 +0000 Subject: [PATCH 18/22] fix(tbtc): prevent reservation anchor from deadlocking wallet sync EnsureWalletSyncedBetweenChains treated any 1-input-1-output transaction spending a revealed deposit as an unproven first deposit sweep and hard-errored. A reservation anchor transaction has the exact same shape but deliberately never becomes the wallet's main UTXO, permanently deadlocking wallet sync for any wallet holding a reservation anchor. Distinguish an anchor from a genuine unproven sweep by checking whether the spent deposit's vault matches the reservation vault (mirrors the existing sweep-vs-reservation check used elsewhere). Treat GetReservationParameters failing (not yet implemented on every chain backend) or an unset vault as "not a reservation" rather than propagating the error, so ordinary deposit sweeps are unaffected. --- pkg/tbtc/chain_test.go | 15 ++++- pkg/tbtc/wallet.go | 23 +++++++- pkg/tbtc/wallet_test.go | 128 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 2 deletions(-) diff --git a/pkg/tbtc/chain_test.go b/pkg/tbtc/chain_test.go index 6cdd850bc3..eb848bc375 100644 --- a/pkg/tbtc/chain_test.go +++ b/pkg/tbtc/chain_test.go @@ -97,6 +97,9 @@ type localChain struct { movingFundsParametersMutex sync.Mutex movingFundsParameters MovingFundsParameters + reservationParametersMutex sync.Mutex + reservationParameters ReservationParameters + eligibleStakesMutex sync.Mutex eligibleStakes map[chain.Address]*big.Int @@ -1485,7 +1488,17 @@ func (lc *localChain) GetReservationAction( } func (lc *localChain) GetReservationParameters() (ReservationParameters, error) { - panic("unsupported") + lc.reservationParametersMutex.Lock() + defer lc.reservationParametersMutex.Unlock() + + return lc.reservationParameters, nil +} + +func (lc *localChain) SetReservationParameters(params ReservationParameters) { + lc.reservationParametersMutex.Lock() + defer lc.reservationParametersMutex.Unlock() + + lc.reservationParameters = params } func (lc *localChain) GetReservationTotalAmount() (uint64, error) { diff --git a/pkg/tbtc/wallet.go b/pkg/tbtc/wallet.go index dc54ae3c76..e61d84e006 100644 --- a/pkg/tbtc/wallet.go +++ b/pkg/tbtc/wallet.go @@ -734,7 +734,7 @@ func EnsureWalletSyncedBetweenChains( ) } input := transaction.Inputs[0] - _, isDeposit, err := bridgeChain.GetDepositRequest( + depositRequest, isDeposit, err := bridgeChain.GetDepositRequest( input.Outpoint.TransactionHash, input.Outpoint.OutputIndex, ) @@ -752,6 +752,27 @@ func EnsureWalletSyncedBetweenChains( // If that's the case, the wallet has already created a deposit // sweep as their first Bitcoin transaction and the Bridge is // awaiting the SPV proof. + // + // A reservation anchor transaction spends a revealed deposit + // too, matching this exact 1-in-1-out shape, but it targets + // the reservation vault and deliberately never becomes the + // wallet's main UTXO. Treat that case as a legitimate + // terminal wallet output instead of an unproven sweep. + if depositRequest.Vault != nil { + // GetReservationParameters is not yet implemented on + // every chain backend (see errReservationsUnsupported); + // treat that, or an unset reservation vault, as "this + // deposit is not a reservation anchor" and fall through + // to the unproven-sweep error below, rather than failing + // wallet sync for ordinary (non-reservation) deposits. + reservationParameters, err := bridgeChain.GetReservationParameters() + if err == nil && + reservationParameters.Vault != "" && + *depositRequest.Vault == reservationParameters.Vault { + continue + } + } + return fmt.Errorf("wallet already produced their first " + "Bitcoin transaction (deposit sweep); Bridge is probably " + "awaiting the SPV proof", diff --git a/pkg/tbtc/wallet_test.go b/pkg/tbtc/wallet_test.go index 7fe44704fb..b7904e6264 100644 --- a/pkg/tbtc/wallet_test.go +++ b/pkg/tbtc/wallet_test.go @@ -970,6 +970,134 @@ func TestEnsureWalletSyncedBetweenChains_FreshWalletDepositSweepFirstTx(t *testi } } +func TestEnsureWalletSyncedBetweenChains_FreshWalletReservationAnchorFirstTx(t *testing.T) { + var walletPKH [20]byte + walletPKH[0] = 0xaa + + depositFundingTxHash := bitcoin.Hash{0xdd} + var depositFundingOutputIndex uint32 = 0 + + // A reservation anchor transaction. Its single input spends a deposit + // revealed with the reservation vault; its single output pays the + // wallet and deliberately never becomes the main UTXO. + anchorTx := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: depositFundingTxHash, + OutputIndex: depositFundingOutputIndex, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + {Value: 9000, PublicKeyScript: []byte{0x51}}, + }, + } + + anchorUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTx.Hash(), + OutputIndex: 0, + }, + Value: 9000, + } + + reservationVault := chain.Address("0x2222222222222222222222222222222222222222") + + localChain := Connect() + localChain.SetReservationParameters(ReservationParameters{ + Vault: reservationVault, + }) + localChain.setDepositRequest( + depositFundingTxHash, + depositFundingOutputIndex, + &DepositChainRequest{ + Vault: &reservationVault, + }, + ) + + btcChain := &walletSyncBtcChain{ + utxos: []*bitcoin.UnspentTransactionOutput{anchorUtxo}, + mempool: []*bitcoin.UnspentTransactionOutput{}, + txs: map[bitcoin.Hash]*bitcoin.Transaction{anchorTx.Hash(): anchorTx}, + } + + // EnsureWalletSyncedBetweenChains should NOT return an error for a + // reservation anchor: it spends a deposit revealed with the reservation + // vault, so it is a legitimate terminal wallet output rather than an + // unproven deposit sweep. + err := EnsureWalletSyncedBetweenChains(walletPKH, nil, localChain, btcChain) + + if err != nil { + t.Errorf("expected no error for reservation anchor, got [%v]", err) + } +} + +func TestEnsureWalletSyncedBetweenChains_FreshWalletDepositVaultMismatchFirstTx(t *testing.T) { + var walletPKH [20]byte + walletPKH[0] = 0xaa + + depositFundingTxHash := bitcoin.Hash{0xdd} + var depositFundingOutputIndex uint32 = 0 + + // Same 1-in-1-out shape as a reservation anchor, but the deposit's + // vault does not match the reservation vault (e.g. an ordinary + // TBTCVault deposit). This must still be treated as an unproven + // deposit sweep, not silently accepted as a reservation anchor. + sweepTx := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: depositFundingTxHash, + OutputIndex: depositFundingOutputIndex, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + {Value: 9000, PublicKeyScript: []byte{0x51}}, + }, + } + + sweepUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: sweepTx.Hash(), + OutputIndex: 0, + }, + Value: 9000, + } + + reservationVault := chain.Address("0x2222222222222222222222222222222222222222") + ordinaryVault := chain.Address("0x3333333333333333333333333333333333333333") + + localChain := Connect() + localChain.SetReservationParameters(ReservationParameters{ + Vault: reservationVault, + }) + localChain.setDepositRequest( + depositFundingTxHash, + depositFundingOutputIndex, + &DepositChainRequest{ + Vault: &ordinaryVault, + }, + ) + + btcChain := &walletSyncBtcChain{ + utxos: []*bitcoin.UnspentTransactionOutput{sweepUtxo}, + mempool: []*bitcoin.UnspentTransactionOutput{}, + txs: map[bitcoin.Hash]*bitcoin.Transaction{sweepTx.Hash(): sweepTx}, + } + + err := EnsureWalletSyncedBetweenChains(walletPKH, nil, localChain, btcChain) + + if err == nil { + t.Error("expected error for a non-reservation deposit sweep, got nil") + } +} + func TestEnsureWalletSyncedBetweenChains_MainUtxoInSync(t *testing.T) { var walletPKH [20]byte walletPKH[0] = 0xbb From 8037b3031335d985c2ee0fa02b5dedb076837448 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 2 Sep 2026 11:05:00 +0000 Subject: [PATCH 19/22] fix(tbtc): close reservation action validation gaps and add test coverage - requireReservationAction now rejects a zero TimeoutAt as malformed instead of silently bypassing the timeout guard; adds test coverage for both the malformed-timeout and timed-out-action branches, which previously had none. - Adds missing test coverage for the anchor-outpoint-mismatch guard in the redemption and dissolution assemblers (only re-anchor was tested), the nil-outpoint guards in all three UTXO-consuming assemblers, the wallet-public-key nil guard in the anchor and dissolution assemblers, the reachable target-wallet-hash guard in the re-anchor assembler, and the 1-input (no main UTXO) dissolution success path. - Moves the unreachable TargetWalletPublicKeyHash zero-check in the anchor and dissolution assemblers ahead of the equality check it was shadowed by, so it provides real defense-in-depth. - Replaces the dissolution assembler's stale TODO and inline comment asserting an unverified anchor-first input-order requirement: the companion Bridge contract (threshold-network/tbtc-v2#1088) accepts either input order by matching outpoint hashes, not position. - Documents that the nonce-keyed GetReservationAction lookup and the terminal ReservationActionState values model the anticipated two-phase authorize-then-prove settlement redesign, not the currently-reviewed single-phase contract. - Removes a redundant action-type parse duplicate of TestParseWalletActionType and decorative scenario comments that only restated the assertion below them. --- pkg/tbtc/chain.go | 5 + pkg/tbtc/reservation.go | 56 +++--- pkg/tbtc/reservation_test.go | 342 ++++++++++++++++++++++++++++------- 3 files changed, 313 insertions(+), 90 deletions(-) diff --git a/pkg/tbtc/chain.go b/pkg/tbtc/chain.go index 41372a74c2..a920a47520 100644 --- a/pkg/tbtc/chain.go +++ b/pkg/tbtc/chain.go @@ -281,6 +281,11 @@ type BridgeChain interface { // 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. + // + // This models the anticipated two-phase authorize-then-prove redesign + // tracked in tbtc-v2#1088's own review findings, not the + // currently-reviewed single-phase contract; the signature may change + // before Ethereum bindings are implemented. GetReservationAction( reservationKey *big.Int, requestNonce uint64, diff --git a/pkg/tbtc/reservation.go b/pkg/tbtc/reservation.go index 7969d689b4..231c428bbf 100644 --- a/pkg/tbtc/reservation.go +++ b/pkg/tbtc/reservation.go @@ -106,7 +106,10 @@ const ( ) // ReservationActionState represents the settlement state of a reservation -// action generation. +// action generation (models the anticipated two-phase authorize-then-prove +// redesign tracked in tbtc-v2#1088's own review findings, not the +// currently-reviewed single-phase contract; the enum values may change +// before Ethereum bindings are implemented). type ReservationActionState uint8 const ( @@ -121,13 +124,19 @@ const ( // 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. +// +// The nonce-keyed lookup (see chain.go's GetReservationAction) and the +// terminal ReservationActionState values below model the anticipated +// two-phase authorize-then-prove redesign tracked in tbtc-v2#1088's own +// review findings, not the currently-reviewed single-phase contract; this +// interface may change before Ethereum bindings are implemented. 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 is the UNIX timestamp at or after which the action times out. TimeoutAt uint32 // TxMaxFee is the snapshotted maximum Bitcoin transaction fee in satoshi. TxMaxFee uint64 @@ -258,7 +267,10 @@ func requireReservationAction( if action.State != ReservationActionStatePending { return fmt.Errorf("reservation action has already been settled") } - if action.TimeoutAt != 0 && now >= action.TimeoutAt { + if action.TimeoutAt == 0 { + return fmt.Errorf("reservation action timeout is required") + } + if now >= action.TimeoutAt { return fmt.Errorf("reservation action has timed out") } return nil @@ -296,12 +308,12 @@ func assembleReservationAnchorTransaction( if err := requireReservationAction(action, ReservationActionTypeAcceptance, now, "an acceptance"); err != nil { return nil, err } - if action.TargetWalletPublicKeyHash != walletPublicKeyHash { - return nil, fmt.Errorf("acceptance action targets a different wallet") - } if action.TargetWalletPublicKeyHash == [20]byte{} { return nil, fmt.Errorf("target wallet public key hash is required") } + if action.TargetWalletPublicKeyHash != walletPublicKeyHash { + return nil, fmt.Errorf("acceptance action targets a different wallet") + } if err := requireValidActionFee(fee, action.TxMaxFee); err != nil { return nil, err } @@ -512,22 +524,17 @@ 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. +// 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. // -// TODO: the anchor-first, main-UTXO-second input ordering below is a -// load-bearing assumption about the reservation Bridge contract's SPV proof -// validation (see the companion contracts PR, threshold-network/tbtc-v2#1088, -// for the authoritative rule). This is not independently verified from this -// repo. If the merged Bridge contract requires a different order, a broadcast -// dissolution transaction here would be irreversible and unrecognized by the -// Bridge. Confirm the exact input-order clause in tbtc-v2#1088 before this -// assembler is wired into the coordination executor, and remove this TODO -// once confirmed (or fix the ordering if it turns out to be wrong). +// The Bridge's dissolution proof accepts either input order (see +// Reservation.sol's dissolution-input verification, which matches by +// outpoint hash, not position); this assembler's anchor-first ordering +// is one of the two accepted orders. func assembleReservationDissolutionTransaction( bitcoinChain bitcoin.Chain, bridgeChain interface { @@ -563,12 +570,12 @@ func assembleReservationDissolutionTransaction( if err := requireReservationAction(action, ReservationActionTypeDissolution, now, "a dissolution"); err != nil { return nil, err } - if action.TargetWalletPublicKeyHash != walletPublicKeyHash { - return nil, fmt.Errorf("dissolution action targets a different wallet") - } if action.TargetWalletPublicKeyHash == [20]byte{} { return nil, fmt.Errorf("target wallet public key hash is required") } + 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") } @@ -600,7 +607,8 @@ func assembleReservationDissolutionTransaction( builder := bitcoin.NewTransactionBuilder(bitcoinChain) - // The Bridge requires the anchor outpoint to be the first input. + // The anchor outpoint is added first (one of the two input orders the + // Bridge's dissolution proof accepts; see the function doc comment). err := builder.AddPublicKeyHashInput(anchorUtxo) if err != nil { return nil, fmt.Errorf( diff --git a/pkg/tbtc/reservation_test.go b/pkg/tbtc/reservation_test.go index e07963b9e5..ed445760af 100644 --- a/pkg/tbtc/reservation_test.go +++ b/pkg/tbtc/reservation_test.go @@ -14,27 +14,6 @@ import ( "github.com/keep-network/keep-core/pkg/chain" ) -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 TestReservationStateValues(t *testing.T) { tests := map[ReservationState]uint8{ @@ -318,6 +297,70 @@ func TestReservationProposals_UnmarshalRejectsMissingIntegers(t *testing.T) { } } +func TestRequireReservationAction_Timeout(t *testing.T) { + tests := map[string]struct { + action *ReservationAction + now uint32 + expectedError string + }{ + "zero TimeoutAt is rejected as malformed": { + action: &ReservationAction{ + ActionType: ReservationActionTypeAcceptance, + State: ReservationActionStatePending, + TimeoutAt: 0, + }, + now: 50, + expectedError: "reservation action timeout is required", + }, + "now at or after TimeoutAt has timed out": { + action: &ReservationAction{ + ActionType: ReservationActionTypeAcceptance, + State: ReservationActionStatePending, + TimeoutAt: 100, + }, + now: 100, + expectedError: "reservation action has timed out", + }, + "now past TimeoutAt has timed out": { + action: &ReservationAction{ + ActionType: ReservationActionTypeAcceptance, + State: ReservationActionStatePending, + TimeoutAt: 100, + }, + now: 150, + expectedError: "reservation action has timed out", + }, + "now before TimeoutAt is not timed out": { + action: &ReservationAction{ + ActionType: ReservationActionTypeAcceptance, + State: ReservationActionStatePending, + TimeoutAt: 100, + }, + now: 99, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + err := requireReservationAction( + test.action, + ReservationActionTypeAcceptance, + test.now, + "an acceptance", + ) + if test.expectedError != "" { + if err == nil || err.Error() != test.expectedError { + t.Fatalf("expected error [%s], got [%v]", test.expectedError, err) + } + return + } + if err != nil { + t.Fatalf("expected no error, got [%v]", err) + } + }) + } +} + func fundUtxo( t *testing.T, bitcoinChain bitcoin.Chain, @@ -400,6 +443,7 @@ func TestAssembleReservedRedemptionTransaction(t *testing.T) { TxMaxFee: 2000, ActionType: ReservationActionTypeRedemption, State: ReservationActionStatePending, + TimeoutAt: 1000, Amount: 100000, RedeemerOutputScriptHash: redeemerOutputScriptHash, }, @@ -416,6 +460,7 @@ func TestAssembleReservedRedemptionTransaction(t *testing.T) { TxMaxFee: 150000, ActionType: ReservationActionTypeRedemption, State: ReservationActionStatePending, + TimeoutAt: 1000, Amount: 100000, RedeemerOutputScriptHash: redeemerOutputScriptHash, }, @@ -427,6 +472,7 @@ func TestAssembleReservedRedemptionTransaction(t *testing.T) { TxMaxFee: 2000, ActionType: ReservationActionTypeRedemption, State: ReservationActionStatePending, + TimeoutAt: 1000, Amount: 100000, RedeemerOutputScriptHash: redeemerOutputScriptHash, }, @@ -475,6 +521,49 @@ func TestAssembleReservedRedemptionTransaction(t *testing.T) { } }) } + t.Run("anchor outpoint mismatch", func(t *testing.T) { + _, err := assembleReservedRedemptionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + redeemerScript, + &ReservationAction{ + TxMaxFee: 2000, + ActionType: ReservationActionTypeRedemption, + State: ReservationActionStatePending, + Amount: 100000, + RedeemerOutputScriptHash: redeemerOutputScriptHash, + }, + 1500, + 50, + &bitcoin.TransactionOutpoint{TransactionHash: bitcoin.Hash{0x01}}, + ) + if err == nil || err.Error() != "anchor UTXO outpoint does not match the action snapshot" { + t.Fatalf("expected error [anchor UTXO outpoint does not match the action snapshot], got [%v]", err) + } + }) + + t.Run("nil anchor UTXO guard", func(t *testing.T) { + _, err := assembleReservedRedemptionTransaction( + bitcoinChain, + bridgeChain, + nil, + redeemerScript, + &ReservationAction{ + TxMaxFee: 2000, + ActionType: ReservationActionTypeRedemption, + State: ReservationActionStatePending, + Amount: 100000, + RedeemerOutputScriptHash: redeemerOutputScriptHash, + }, + 1500, + 50, + anchorUtxo.Outpoint, + ) + if err == nil || err.Error() != "anchor UTXO is required" { + t.Fatalf("expected error [anchor UTXO is required], got [%v]", err) + } + }) } func TestAssembleReservationDissolutionTransaction(t *testing.T) { @@ -498,12 +587,14 @@ func TestAssembleReservationDissolutionTransaction(t *testing.T) { TxMaxFee: 2000, ActionType: ReservationActionTypeDissolution, State: ReservationActionStatePending, + TimeoutAt: 100, Amount: 100000, } tests := map[string]struct { action *ReservationAction expectedInputUtxos []*bitcoin.UnspentTransactionOutput + walletMainUtxo *bitcoin.UnspentTransactionOutput expectedOutputValue int64 expectedError string }{ @@ -519,10 +610,12 @@ func TestAssembleReservationDissolutionTransaction(t *testing.T) { anchorUtxo, walletMainUtxo, }, + walletMainUtxo: walletMainUtxo, expectedOutputValue: 298500, }, "no-main-UTXO snapshot with newly current main UTXO": { action: &baseAction, + walletMainUtxo: walletMainUtxo, expectedError: "wallet main UTXO must not be provided when the dissolution action has no expected main UTXO snapshot", }, "mismatched action amount": { @@ -531,6 +624,7 @@ func TestAssembleReservationDissolutionTransaction(t *testing.T) { action.Amount = 99999 return &action }(), + walletMainUtxo: walletMainUtxo, expectedError: "dissolution action amount does not match the anchor value", }, "mismatched target wallet": { @@ -539,6 +633,7 @@ func TestAssembleReservationDissolutionTransaction(t *testing.T) { action.TargetWalletPublicKeyHash = [20]byte{0x02} return &action }(), + walletMainUtxo: walletMainUtxo, expectedError: "dissolution action targets a different wallet", }, } @@ -549,7 +644,7 @@ func TestAssembleReservationDissolutionTransaction(t *testing.T) { bitcoinChain, bridgeChain, anchorUtxo, - walletMainUtxo, + test.walletMainUtxo, wallet.publicKey, test.action, 1500, @@ -611,6 +706,79 @@ func TestAssembleReservationDissolutionTransaction(t *testing.T) { } }) } + t.Run("anchor outpoint mismatch", func(t *testing.T) { + _, err := assembleReservationDissolutionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + nil, + wallet.publicKey, + &baseAction, + 1500, + 50, + &bitcoin.TransactionOutpoint{TransactionHash: bitcoin.Hash{0x01}}, + ) + if err == nil || err.Error() != "anchor UTXO outpoint does not match the action snapshot" { + t.Fatalf("expected error [anchor UTXO outpoint does not match the action snapshot], got [%v]", err) + } + }) + t.Run("1-input dissolution", func(t *testing.T) { + builder, err := assembleReservationDissolutionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + nil, + wallet.publicKey, + &baseAction, + 1500, + 50, + anchorUtxo.Outpoint, + ) + if err != nil { + t.Fatal(err) + } + transaction := signReservationTransaction(t, builder, wallet.publicKey, privateKeyValue) + if len(transaction.Inputs) != 1 { + t.Fatalf("expected 1 input, got %v", len(transaction.Inputs)) + } + if transaction.Outputs[0].Value != 98500 { + t.Fatalf("expected output value 98500, got %v", transaction.Outputs[0].Value) + } + }) + + t.Run("nil anchor UTXO guard", func(t *testing.T) { + _, err := assembleReservationDissolutionTransaction( + bitcoinChain, + bridgeChain, + nil, + nil, + wallet.publicKey, + &baseAction, + 1500, + 50, + anchorUtxo.Outpoint, + ) + if err == nil || err.Error() != "anchor UTXO is required" { + t.Fatalf("expected error [anchor UTXO is required], got [%v]", err) + } + }) + + t.Run("wallet public key nil guard", func(t *testing.T) { + _, err := assembleReservationDissolutionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + nil, + nil, + &baseAction, + 1500, + 50, + anchorUtxo.Outpoint, + ) + if err == nil || err.Error() != "wallet public key is required" { + t.Fatalf("expected error [wallet public key is required], got [%v]", err) + } + }) } func signReservationTransaction( @@ -702,6 +870,7 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { TxMaxFee: 2000, ActionType: ReservationActionTypeRedemption, State: ReservationActionStatePending, + TimeoutAt: 1000, Amount: 100000, RedeemerOutputScriptHash: redeemerOutputScriptHash, } @@ -710,6 +879,7 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { TxMaxFee: 2000, ActionType: ReservationActionTypeDissolution, State: ReservationActionStatePending, + TimeoutAt: 1000, Amount: 100000, } @@ -723,6 +893,7 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { TargetWalletPublicKeyHash: walletPublicKeyHash, ActionType: ReservationActionTypeAcceptance, State: ReservationActionStatePending, + TimeoutAt: 100, TxMaxFee: 2000, } _, err = assembleReservationAnchorTransaction( @@ -732,12 +903,13 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { anchorAction, 0, 1500, - 0, + 50, ) assertError(err, "deposit is required") mismatchedWalletAnchorAction := *anchorAction mismatchedWalletAnchorAction.TargetWalletPublicKeyHash = [20]byte{0x02} + mismatchedWalletAnchorAction.TimeoutAt = 100 _, err = assembleReservationAnchorTransaction( bitcoinChain, nil, @@ -745,7 +917,7 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { &mismatchedWalletAnchorAction, 0, 1500, - 0, + 50, ) assertError(err, "acceptance action targets a different wallet") @@ -756,7 +928,7 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { anchorAction, 0, 0, - 0, + 50, ) assertError(err, "transaction fee must be positive") @@ -767,7 +939,7 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { redeemerScript, redemptionAction, 1500, - 0, + 50, &bitcoin.TransactionOutpoint{}, ) assertError(err, "anchor UTXO is required") @@ -779,7 +951,7 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { bitcoin.Script{}, redemptionAction, 1500, - 0, + 50, anchorUtxo.Outpoint, ) assertError(err, "redeemer output script is required") @@ -791,7 +963,7 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { redeemerScript, nil, 1500, - 0, + 50, anchorUtxo.Outpoint, ) assertError(err, "reservation action is required") @@ -805,7 +977,7 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { redeemerScript, &nonRedemptionAction, 1500, - 0, + 50, anchorUtxo.Outpoint, ) assertError(err, "reservation action is not a redemption") @@ -819,7 +991,7 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { redeemerScript, &nonPendingAction, 1500, - 0, + 50, anchorUtxo.Outpoint, ) assertError(err, "reservation action has already been settled") @@ -833,7 +1005,7 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { redeemerScript, &wrongScriptAction, 1500, - 0, + 50, anchorUtxo.Outpoint, ) assertError(err, "redeemer output script is not authorized") @@ -849,7 +1021,7 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { redeemerScript, redemptionAction, 1500, - 0, + 50, zeroValueAnchorUtxo.Outpoint, ) assertError(err, "anchor UTXO value must be positive") @@ -863,7 +1035,7 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { redeemerScript, &zeroAmountAction, 1500, - 0, + 50, anchorUtxo.Outpoint, ) assertError(err, "redemption amount must be positive") @@ -877,7 +1049,7 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { redeemerScript, &wrongAmountAction, 1500, - 0, + 50, anchorUtxo.Outpoint, ) assertError(err, "whole redemption amount must equal the anchor value") @@ -889,7 +1061,7 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { redeemerScript, redemptionAction, 2500, - 0, + 50, anchorUtxo.Outpoint, ) assertError(err, "transaction fee exceeds the action fee limit") @@ -901,7 +1073,7 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { redeemerScript, redemptionAction, 0, - 0, + 50, anchorUtxo.Outpoint, ) assertError(err, "transaction fee must be positive") @@ -913,7 +1085,7 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { anchorAction, 0, 1500, - 0, + 50, &bitcoin.TransactionOutpoint{}, ) assertError(err, "anchor UTXO is required") @@ -922,6 +1094,7 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { TargetWalletPublicKeyHash: walletPublicKeyHash, ActionType: ReservationActionTypeReanchor, State: ReservationActionStatePending, + TimeoutAt: 1000, TxMaxFee: 2000, } _, err = assembleReservationReanchorTransaction( @@ -944,7 +1117,7 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { walletPublicKey, dissolutionAction, 1500, - 0, + 50, &bitcoin.TransactionOutpoint{}, ) assertError(err, "anchor UTXO is required") @@ -957,12 +1130,11 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { walletPublicKey, nil, 1500, - 0, + 50, anchorUtxo.Outpoint, ) assertError(err, "reservation action is required") - // (a) ActionType != Dissolution invalidDissolutionAction := *dissolutionAction invalidDissolutionAction.ActionType = ReservationActionTypeReanchor _, err = assembleReservationDissolutionTransaction( @@ -973,12 +1145,11 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { walletPublicKey, &invalidDissolutionAction, 1500, - 0, + 50, anchorUtxo.Outpoint, ) assertError(err, "reservation action is not a dissolution") - // (b) State != Pending nonPendingDissolutionAction := *dissolutionAction nonPendingDissolutionAction.State = ReservationActionStateTimedOut _, err = assembleReservationDissolutionTransaction( @@ -989,12 +1160,11 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { walletPublicKey, &nonPendingDissolutionAction, 1500, - 0, + 50, anchorUtxo.Outpoint, ) assertError(err, "reservation action has already been settled") - // (c) fee > TxMaxFee _, err = assembleReservationDissolutionTransaction( bitcoinChain, bridgeChain, @@ -1008,7 +1178,6 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { ) assertError(err, "transaction fee exceeds the action fee limit") - // (c1) fee must be positive _, err = assembleReservationDissolutionTransaction( bitcoinChain, bridgeChain, @@ -1022,7 +1191,6 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { ) assertError(err, "transaction fee must be positive") - // (d) totalInputsValue - fee <= 0 highFeeLimitDissolutionAction := *dissolutionAction highFeeLimitDissolutionAction.TxMaxFee = 150000 _, err = assembleReservationDissolutionTransaction( @@ -1046,7 +1214,6 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { Value: 200000, } - // (e) non-nil walletMainUtxo + no ExpectedMainUtxoHash _, err = assembleReservationDissolutionTransaction( bitcoinChain, bridgeChain, @@ -1063,7 +1230,6 @@ func TestAssembleReservationTransactions_InputValidation(t *testing.T) { "wallet main UTXO must not be provided when the dissolution action has no expected main UTXO snapshot", ) - // (f) bridgeChain == nil _, err = assembleReservationDissolutionTransaction( bitcoinChain, nil, @@ -1126,6 +1292,7 @@ func TestAssembleReservationAnchorTransaction(t *testing.T) { TargetWalletPublicKeyHash: walletPublicKeyHash, ActionType: ReservationActionTypeAcceptance, State: ReservationActionStatePending, + TimeoutAt: 1000, TxMaxFee: 2000, } @@ -1173,7 +1340,6 @@ func TestAssembleReservationAnchorTransaction(t *testing.T) { Value: 100000, } - // (a) happy path builder, err := assembleReservationAnchorTransaction( bitcoinChain, deposit, @@ -1186,6 +1352,21 @@ func TestAssembleReservationAnchorTransaction(t *testing.T) { if err != nil { t.Fatalf("expected no error, got: [%v]", err) } + t.Run("wallet public key nil guard", func(t *testing.T) { + // Use a valid deposit for this test + _, err := assembleReservationAnchorTransaction( + bitcoinChain, + deposit, + nil, + anchorAction, + 0, + 1500, + 50, + ) + if err == nil || err.Error() != "wallet public key is required" { + t.Fatalf("expected error [wallet public key is required], got [%v]", err) + } + }) transaction := signReservationTransaction( t, @@ -1223,7 +1404,6 @@ func TestAssembleReservationAnchorTransaction(t *testing.T) { transaction.Outputs, ) } - // (a1) action type not acceptance invalidTypeAction := *anchorAction invalidTypeAction.ActionType = ReservationActionTypeRedemption _, err = assembleReservationAnchorTransaction( @@ -1239,7 +1419,6 @@ func TestAssembleReservationAnchorTransaction(t *testing.T) { t.Fatalf("expected error, got: [%v]", err) } - // (a2) action state not pending invalidStateAction := *anchorAction invalidStateAction.State = ReservationActionStateTimedOut _, err = assembleReservationAnchorTransaction( @@ -1254,7 +1433,6 @@ func TestAssembleReservationAnchorTransaction(t *testing.T) { if err == nil || err.Error() != "reservation action has already been settled" { t.Fatalf("expected error, got: [%v]", err) } - // (b) fee > TxMaxFee _, err = assembleReservationAnchorTransaction( bitcoinChain, deposit, @@ -1268,7 +1446,6 @@ func TestAssembleReservationAnchorTransaction(t *testing.T) { t.Fatalf("expected error, got: [%v]", err) } - // (c) anchorValue < reservationMinAmount _, err = assembleReservationAnchorTransaction( bitcoinChain, deposit, @@ -1282,7 +1459,6 @@ func TestAssembleReservationAnchorTransaction(t *testing.T) { t.Fatalf("expected error, got: [%v]", err) } - // (d) nil action _, err = assembleReservationAnchorTransaction( bitcoinChain, deposit, @@ -1313,12 +1489,12 @@ func TestAssembleReservationReanchorTransaction(t *testing.T) { reanchorAction := &ReservationAction{ ActionType: ReservationActionTypeReanchor, State: ReservationActionStatePending, + TimeoutAt: 1000, TxMaxFee: 2000, TargetWalletPublicKeyHash: targetWalletPublicKeyHash, Amount: uint64(anchorUtxo.Value), } - // (a) happy path builder, err := assembleReservationReanchorTransaction( bitcoinChain, anchorUtxo, @@ -1342,7 +1518,6 @@ func TestAssembleReservationReanchorTransaction(t *testing.T) { t.Errorf("expected output value 98500, got: [%v]", transaction.Outputs[0].Value) } - // (b) action type not reanchor invalidAction := *reanchorAction invalidAction.ActionType = ReservationActionTypeAcceptance _, err = assembleReservationReanchorTransaction( @@ -1359,7 +1534,6 @@ func TestAssembleReservationReanchorTransaction(t *testing.T) { t.Fatalf("expected error, got: [%v]", err) } - // (c) action state not pending invalidStateAction := *reanchorAction invalidStateAction.State = ReservationActionStateTimedOut _, err = assembleReservationReanchorTransaction( @@ -1376,7 +1550,6 @@ func TestAssembleReservationReanchorTransaction(t *testing.T) { t.Fatalf("expected error, got: [%v]", err) } - // (d) mismatched wallet _, err = assembleReservationReanchorTransaction( bitcoinChain, anchorUtxo, @@ -1391,7 +1564,6 @@ func TestAssembleReservationReanchorTransaction(t *testing.T) { t.Fatalf("expected error, got: [%v]", err) } - // (e) fee > TxMaxFee _, err = assembleReservationReanchorTransaction( bitcoinChain, anchorUtxo, @@ -1406,7 +1578,6 @@ func TestAssembleReservationReanchorTransaction(t *testing.T) { t.Fatalf("expected error, got: [%v]", err) } - // (f) reanchorValue < reservationMinAmount _, err = assembleReservationReanchorTransaction( bitcoinChain, anchorUtxo, @@ -1421,7 +1592,6 @@ func TestAssembleReservationReanchorTransaction(t *testing.T) { t.Fatalf("expected error, got: [%v]", err) } - // (g) nil action _, err = assembleReservationReanchorTransaction( bitcoinChain, anchorUtxo, @@ -1436,7 +1606,6 @@ func TestAssembleReservationReanchorTransaction(t *testing.T) { t.Fatalf("expected error, got: [%v]", err) } - // (h) expected anchor outpoint mismatch wrongOutpoint := &bitcoin.TransactionOutpoint{ TransactionHash: anchorUtxo.Outpoint.TransactionHash, OutputIndex: anchorUtxo.Outpoint.OutputIndex + 1, @@ -1448,12 +1617,51 @@ func TestAssembleReservationReanchorTransaction(t *testing.T) { reanchorAction, 0, 1500, - 0, + 50, wrongOutpoint, ) if err == nil || err.Error() != "anchor UTXO outpoint does not match the action snapshot" { t.Fatalf("expected error, got: [%v]", err) } + + t.Run("nil anchor UTXO guard", func(t *testing.T) { + _, err := assembleReservationReanchorTransaction( + bitcoinChain, + nil, + targetWalletPublicKeyHash, + reanchorAction, + 0, + 1500, + 50, + anchorUtxo.Outpoint, + ) + if err == nil || err.Error() != "anchor UTXO is required" { + t.Fatalf("expected error [anchor UTXO is required], got [%v]", err) + } + }) + + t.Run("target wallet public key hash zero guard", func(t *testing.T) { + _, err := assembleReservationReanchorTransaction( + bitcoinChain, + anchorUtxo, + [20]byte{}, + &ReservationAction{ + ActionType: ReservationActionTypeReanchor, + State: ReservationActionStatePending, + TimeoutAt: 1000, + TxMaxFee: 2000, + TargetWalletPublicKeyHash: [20]byte{}, + Amount: uint64(anchorUtxo.Value), + }, + 0, + 1500, + 50, + anchorUtxo.Outpoint, + ) + if err == nil || err.Error() != "target wallet public key hash is required" { + t.Fatalf("expected error [target wallet public key hash is required], got [%v]", err) + } + }) } func TestAssembleReservationReanchorTransaction_AmountMismatch(t *testing.T) { @@ -1470,6 +1678,7 @@ func TestAssembleReservationReanchorTransaction_AmountMismatch(t *testing.T) { TargetWalletPublicKeyHash: [20]byte{0x01}, ActionType: ReservationActionTypeReanchor, State: ReservationActionStatePending, + TimeoutAt: 1000, TxMaxFee: 2000, Amount: 50000, // Mismatch } @@ -1520,7 +1729,7 @@ func TestAssembleReservationTransactions_BoundaryErrors(t *testing.T) { t.Fatal(err) } deposit.Utxo = fundUtxo(t, bitcoinChain, depositLockingScript, 1000)[0] - _, err = assembleReservationAnchorTransaction(bitcoinChain, deposit, walletPublicKey, &ReservationAction{TargetWalletPublicKeyHash: walletPublicKeyHash, ActionType: ReservationActionTypeAcceptance, State: ReservationActionStatePending, TxMaxFee: 2000}, 0, 2000, 0) + _, err = assembleReservationAnchorTransaction(bitcoinChain, deposit, walletPublicKey, &ReservationAction{TargetWalletPublicKeyHash: walletPublicKeyHash, ActionType: ReservationActionTypeAcceptance, State: ReservationActionStatePending, TimeoutAt: 1000, TxMaxFee: 2000}, 0, 2000, 0) if err == nil || err.Error() != "transaction fee exceeds the deposit value" { t.Errorf("expected error [transaction fee exceeds the deposit value], got [%v]", err) } @@ -1532,13 +1741,13 @@ func TestAssembleReservationTransactions_BoundaryErrors(t *testing.T) { } // 3. Redemption's amount-exceeds-anchor - _, err = assembleReservedRedemptionTransaction(bitcoinChain, bridgeChain, anchorUtxo, bitcoin.Script{0x00}, &ReservationAction{ActionType: ReservationActionTypeRedemption, State: ReservationActionStatePending, Amount: 200000}, 100, 0, anchorUtxo.Outpoint) + _, err = assembleReservedRedemptionTransaction(bitcoinChain, bridgeChain, anchorUtxo, bitcoin.Script{0x00}, &ReservationAction{ActionType: ReservationActionTypeRedemption, State: ReservationActionStatePending, TimeoutAt: 1000, Amount: 200000}, 100, 0, anchorUtxo.Outpoint) if err == nil || err.Error() != "redemption amount exceeds the anchor value" { t.Errorf("expected error [redemption amount exceeds the anchor value], got [%v]", err) } // 4. Reanchor's fee-exceeds-anchor-value - _, err = assembleReservationReanchorTransaction(bitcoinChain, anchorUtxo, [20]byte{0x01}, &ReservationAction{TargetWalletPublicKeyHash: [20]byte{0x01}, ActionType: ReservationActionTypeReanchor, State: ReservationActionStatePending, TxMaxFee: 200000, Amount: 100000}, 0, 200000, 0, anchorUtxo.Outpoint) + _, err = assembleReservationReanchorTransaction(bitcoinChain, anchorUtxo, [20]byte{0x01}, &ReservationAction{TargetWalletPublicKeyHash: [20]byte{0x01}, ActionType: ReservationActionTypeReanchor, State: ReservationActionStatePending, TimeoutAt: 1000, TxMaxFee: 200000, Amount: 100000}, 0, 200000, 0, anchorUtxo.Outpoint) if err == nil || err.Error() != "transaction fee exceeds the anchor value" { t.Errorf("expected error [transaction fee exceeds the anchor value], got [%v]", err) } @@ -1561,6 +1770,7 @@ func TestAssembleReservationTransactions_BoundaryErrors(t *testing.T) { TargetWalletPublicKeyHash: walletPublicKeyHash, ActionType: ReservationActionTypeDissolution, State: ReservationActionStatePending, + TimeoutAt: 1000, TxMaxFee: 200, }, 100, From e9e77aae8b440a13e7a82f7dbadb9096c15daafe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 2 Sep 2026 11:05:06 +0000 Subject: [PATCH 20/22] fix(tbtc): reject malformed reservation proposal JSON The four reservation proposal Unmarshal implementations decoded DepositFundingTxHash and TargetWalletPublicKeyHash into fixed-size byte arrays directly, so a wrong-length JSON array silently zero-fills or truncates instead of erroring (unlike the existing protobuf unmarshalers, which reject a bad length explicitly). Unmarshal into an intermediate []byte field first and reject a present-but-wrong-length value before copying into the fixed array; an absent field still falls through to the existing zero-value "required" check unchanged. Also closes the equivalent oversized-fee gap already covered for ReservationKey, and extends each proposal's protobuf-migration TODO with the sequencing constraint: it must land before any code that generates these proposals on the wire. --- pkg/tbtc/marshaling.go | 62 ++++++++++++++++++-- pkg/tbtc/marshaling_test.go | 113 ++++++++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 6 deletions(-) diff --git a/pkg/tbtc/marshaling.go b/pkg/tbtc/marshaling.go index 938f573781..790ed6e842 100644 --- a/pkg/tbtc/marshaling.go +++ b/pkg/tbtc/marshaling.go @@ -527,24 +527,46 @@ func validateProposalNonceAndFee(nonce uint64, fee *big.Int, label string) error // // TODO: Switch to protobuf-based marshaling (see pkg/tbtc/gen/pb) once the // reservation message types are added to the coordination proto definition. +// This protobuf migration MUST land before (or in the same release as) any +// code that generates these proposals on the wire, i.e. before the deferred +// coordination-executor wiring activates these action types. 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 + var proposal struct { + DepositFundingTxHash []byte `json:"depositFundingTxHash"` + DepositFundingOutputIndex uint32 `json:"depositFundingOutputIndex"` + RequestNonce uint64 `json:"requestNonce"` + AnchorTxFee *big.Int `json:"anchorTxFee"` + } if err := json.Unmarshal(bytes, &proposal); err != nil { return err } - if proposal.DepositFundingTxHash == (bitcoin.Hash{}) { + if len(proposal.DepositFundingTxHash) != 0 && + len(proposal.DepositFundingTxHash) != bitcoin.HashByteLength { + return fmt.Errorf( + "invalid deposit funding transaction hash length: [%v]", + len(proposal.DepositFundingTxHash), + ) + } + + var depositFundingTxHash bitcoin.Hash + copy(depositFundingTxHash[:], proposal.DepositFundingTxHash) + + if depositFundingTxHash == (bitcoin.Hash{}) { return fmt.Errorf("deposit funding transaction hash is required") } if err := validateProposalNonceAndFee(proposal.RequestNonce, proposal.AnchorTxFee, "anchor transaction fee"); err != nil { return err } - *rap = proposal + rap.DepositFundingTxHash = depositFundingTxHash + rap.DepositFundingOutputIndex = proposal.DepositFundingOutputIndex + rap.RequestNonce = proposal.RequestNonce + rap.AnchorTxFee = proposal.AnchorTxFee return nil } @@ -552,6 +574,9 @@ func (rap *ReservationAnchorProposal) Unmarshal(bytes []byte) error { // // TODO: Switch to protobuf-based marshaling (see pkg/tbtc/gen/pb) once the // reservation message types are added to the coordination proto definition. +// This protobuf migration MUST land before (or in the same release as) any +// code that generates these proposals on the wire, i.e. before the deferred +// coordination-executor wiring activates these action types. func (rrp *ReservedRedemptionProposal) Marshal() ([]byte, error) { return json.Marshal(rrp) } @@ -580,27 +605,49 @@ func (rrp *ReservedRedemptionProposal) Unmarshal(bytes []byte) error { // // TODO: Switch to protobuf-based marshaling (see pkg/tbtc/gen/pb) once the // reservation message types are added to the coordination proto definition. +// This protobuf migration MUST land before (or in the same release as) any +// code that generates these proposals on the wire, i.e. before the deferred +// coordination-executor wiring activates these action types. 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 + var proposal struct { + ReservationKey *big.Int `json:"reservationKey"` + RequestNonce uint64 `json:"requestNonce"` + TargetWalletPublicKeyHash []byte `json:"targetWalletPublicKeyHash"` + ReanchorTxFee *big.Int `json:"reanchorTxFee"` + } if err := json.Unmarshal(bytes, &proposal); err != nil { return err } + if len(proposal.TargetWalletPublicKeyHash) != 0 && + len(proposal.TargetWalletPublicKeyHash) != 20 { + return fmt.Errorf( + "invalid target wallet public key hash length: [%v]", + len(proposal.TargetWalletPublicKeyHash), + ) + } if err := validateReservationKey(proposal.ReservationKey); err != nil { return err } - if proposal.TargetWalletPublicKeyHash == [20]byte{} { + + var targetWalletPublicKeyHash [20]byte + copy(targetWalletPublicKeyHash[:], proposal.TargetWalletPublicKeyHash) + + if targetWalletPublicKeyHash == [20]byte{} { return fmt.Errorf("target wallet public key hash is required") } if err := validateProposalNonceAndFee(proposal.RequestNonce, proposal.ReanchorTxFee, "re-anchor transaction fee"); err != nil { return err } - *rrp = proposal + rrp.ReservationKey = proposal.ReservationKey + rrp.RequestNonce = proposal.RequestNonce + rrp.TargetWalletPublicKeyHash = targetWalletPublicKeyHash + rrp.ReanchorTxFee = proposal.ReanchorTxFee return nil } @@ -608,6 +655,9 @@ func (rrp *ReservationReanchorProposal) Unmarshal(bytes []byte) error { // // TODO: Switch to protobuf-based marshaling (see pkg/tbtc/gen/pb) once the // reservation message types are added to the coordination proto definition. +// This protobuf migration MUST land before (or in the same release as) any +// code that generates these proposals on the wire, i.e. before the deferred +// coordination-executor wiring activates these action types. func (rdp *ReservationDissolutionProposal) Marshal() ([]byte, error) { return json.Marshal(rdp) } diff --git a/pkg/tbtc/marshaling_test.go b/pkg/tbtc/marshaling_test.go index 6ecb650f55..9efe3aa0e9 100644 --- a/pkg/tbtc/marshaling_test.go +++ b/pkg/tbtc/marshaling_test.go @@ -552,6 +552,54 @@ func TestReservationAnchorProposal_UnmarshalRejectsMalformedInput(t *testing.T) t.Fatal("expected error for negative fee") } }) + t.Run("oversized fee", func(t *testing.T) { + data, _ := json.Marshal(ReservationAnchorProposal{ + DepositFundingTxHash: bitcoin.Hash{0x01}, + RequestNonce: 1, + AnchorTxFee: new(big.Int).Lsh(big.NewInt(1), 64), + }) + if err := (&ReservationAnchorProposal{}).Unmarshal(data); err == nil { + t.Fatal("expected error for oversized fee") + } + }) + + t.Run("wrong-length deposit funding transaction hash", func(t *testing.T) { + // Real wire format (see Marshal, which json.Marshals the [32]byte + // field directly): a JSON array of byte values, not a base64 + // string. Length 3 instead of 32 must be rejected explicitly, + // not silently zero-filled the way [32]byte unmarshal would. + data, err := json.Marshal(map[string]interface{}{ + "depositFundingTxHash": []int{0x01, 0x02, 0x03}, + "depositFundingOutputIndex": 0, + "requestNonce": 1, + "anchorTxFee": 100, + }) + if err != nil { + t.Fatal(err) + } + err = (&ReservationAnchorProposal{}).Unmarshal(data) + if err == nil || err.Error() != "invalid deposit funding transaction hash length: [3]" { + t.Fatalf("expected wrong-length error, got [%v]", err) + } + }) + + t.Run("full-length deposit funding transaction hash round-trips", func(t *testing.T) { + data, err := (&ReservationAnchorProposal{ + DepositFundingTxHash: bitcoin.Hash{0x01}, + RequestNonce: 1, + AnchorTxFee: big.NewInt(100), + }).Marshal() + if err != nil { + t.Fatal(err) + } + var proposal ReservationAnchorProposal + if err := proposal.Unmarshal(data); err != nil { + t.Fatalf("expected no error, got [%v]", err) + } + if proposal.DepositFundingTxHash != (bitcoin.Hash{0x01}) { + t.Fatalf("unexpected hash: [%v]", proposal.DepositFundingTxHash) + } + }) } func TestReservedRedemptionProposal_UnmarshalRejectsMalformedInput(t *testing.T) { @@ -603,6 +651,16 @@ func TestReservedRedemptionProposal_UnmarshalRejectsMalformedInput(t *testing.T) t.Fatal("expected error for oversized reservation key") } }) + t.Run("oversized fee", func(t *testing.T) { + data, _ := json.Marshal(ReservedRedemptionProposal{ + ReservationKey: big.NewInt(12345), + RequestNonce: 1, + RedemptionTxFee: new(big.Int).Lsh(big.NewInt(1), 64), + }) + if err := (&ReservedRedemptionProposal{}).Unmarshal(data); err == nil { + t.Fatal("expected error for oversized fee") + } + }) } func TestReservationReanchorProposal_UnmarshalRejectsMalformedInput(t *testing.T) { @@ -654,6 +712,51 @@ func TestReservationReanchorProposal_UnmarshalRejectsMalformedInput(t *testing.T t.Fatal("expected error for oversized reservation key") } }) + t.Run("oversized fee", func(t *testing.T) { + data, _ := json.Marshal(ReservationReanchorProposal{ + ReservationKey: big.NewInt(12345), + RequestNonce: 1, + ReanchorTxFee: new(big.Int).Lsh(big.NewInt(1), 64), + }) + if err := (&ReservationReanchorProposal{}).Unmarshal(data); err == nil { + t.Fatal("expected error for oversized fee") + } + }) + + t.Run("wrong-length target wallet public key hash", func(t *testing.T) { + data, err := json.Marshal(map[string]interface{}{ + "reservationKey": 12345, + "requestNonce": 1, + "targetWalletPublicKeyHash": []int{0x01, 0x02, 0x03}, + "reanchorTxFee": 100, + }) + if err != nil { + t.Fatal(err) + } + err = (&ReservationReanchorProposal{}).Unmarshal(data) + if err == nil || err.Error() != "invalid target wallet public key hash length: [3]" { + t.Fatalf("expected wrong-length error, got [%v]", err) + } + }) + + t.Run("full-length target wallet public key hash round-trips", func(t *testing.T) { + data, err := (&ReservationReanchorProposal{ + ReservationKey: big.NewInt(12345), + RequestNonce: 1, + TargetWalletPublicKeyHash: [20]byte{0x01}, + ReanchorTxFee: big.NewInt(100), + }).Marshal() + if err != nil { + t.Fatal(err) + } + var proposal ReservationReanchorProposal + if err := proposal.Unmarshal(data); err != nil { + t.Fatalf("expected no error, got [%v]", err) + } + if proposal.TargetWalletPublicKeyHash != ([20]byte{0x01}) { + t.Fatalf("unexpected hash: [%v]", proposal.TargetWalletPublicKeyHash) + } + }) } func TestReservationDissolutionProposal_UnmarshalRejectsMalformedInput(t *testing.T) { @@ -705,4 +808,14 @@ func TestReservationDissolutionProposal_UnmarshalRejectsMalformedInput(t *testin t.Fatal("expected error for oversized reservation key") } }) + t.Run("oversized fee", func(t *testing.T) { + data, _ := json.Marshal(ReservationDissolutionProposal{ + ReservationKey: big.NewInt(12345), + RequestNonce: 1, + DissolutionTxFee: new(big.Int).Lsh(big.NewInt(1), 64), + }) + if err := (&ReservationDissolutionProposal{}).Unmarshal(data); err == nil { + t.Fatal("expected error for oversized fee") + } + }) } From e72dea8fee1f925673f2057c65d6fbeb13f33b12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 2 Sep 2026 11:05:11 +0000 Subject: [PATCH 21/22] docs(tbtc): clarify reservation parameter and stub doc comments Documents the 0-means-disabled convention on the launch-throttle fields (MinAmount, MaxTotalAmount, MaxReservationsPerWallet), and replaces the repeated 'pending unpublished Bridge API' narrative on the eight Ethereum reservation stub methods with a concise statement of their current sentinel-error behavior. --- pkg/chain/ethereum/tbtc.go | 51 +++++++++++++++----------------------- pkg/tbtc/parameters.go | 6 ++--- 2 files changed, 23 insertions(+), 34 deletions(-) diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index 89e58ee6d7..31fd73cb55 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -2431,19 +2431,16 @@ 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 errReservationsUnsupported because the generated +// Ethereum bindings do not yet expose the reservation Bridge API. func (tc *TbtcChain) GetReservation( reservationKey *big.Int, ) (*tbtc.Reservation, bool, error) { return nil, false, errReservationsUnsupported } -// 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 errReservationsUnsupported because the +// generated Ethereum bindings do not yet expose the reservation Bridge API. func (tc *TbtcChain) GetReservationAction( reservationKey *big.Int, requestNonce uint64, @@ -2451,10 +2448,8 @@ func (tc *TbtcChain) GetReservationAction( return nil, errReservationsUnsupported } -// GetReservationParameters 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. +// GetReservationParameters returns errReservationsUnsupported because the +// generated Ethereum bindings do not yet expose the reservation Bridge API. func (tc *TbtcChain) GetReservationParameters() ( tbtc.ReservationParameters, error, @@ -2462,18 +2457,15 @@ func (tc *TbtcChain) GetReservationParameters() ( return tbtc.ReservationParameters{}, errReservationsUnsupported } -// GetReservationTotalAmount 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. +// GetReservationTotalAmount returns errReservationsUnsupported because the +// generated Ethereum bindings do not yet expose the reservation Bridge API. func (tc *TbtcChain) GetReservationTotalAmount() (uint64, error) { return 0, errReservationsUnsupported } -// 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 returns errReservationsUnsupported +// because the generated Ethereum bindings do not yet expose the reservation +// Bridge API. func (tc *TbtcChain) ValidateReservationAnchorProposal( walletPublicKeyHash [20]byte, proposal *tbtc.ReservationAnchorProposal, @@ -2485,10 +2477,9 @@ func (tc *TbtcChain) ValidateReservationAnchorProposal( return errReservationsUnsupported } -// 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 returns errReservationsUnsupported +// because the generated Ethereum bindings do not yet expose the reservation +// Bridge API. func (tc *TbtcChain) ValidateReservedRedemptionProposal( walletPublicKeyHash [20]byte, proposal *tbtc.ReservedRedemptionProposal, @@ -2496,10 +2487,9 @@ func (tc *TbtcChain) ValidateReservedRedemptionProposal( return errReservationsUnsupported } -// 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 returns errReservationsUnsupported +// because the generated Ethereum bindings do not yet expose the reservation +// Bridge API. func (tc *TbtcChain) ValidateReservationReanchorProposal( sourceWalletPublicKeyHash [20]byte, proposal *tbtc.ReservationReanchorProposal, @@ -2507,10 +2497,9 @@ func (tc *TbtcChain) ValidateReservationReanchorProposal( return errReservationsUnsupported } -// 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 returns errReservationsUnsupported +// because the generated Ethereum bindings do not yet expose the reservation +// Bridge API. func (tc *TbtcChain) ValidateReservationDissolutionProposal( walletPublicKeyHash [20]byte, proposal *tbtc.ReservationDissolutionProposal, diff --git a/pkg/tbtc/parameters.go b/pkg/tbtc/parameters.go index 386f65a0e5..525d312669 100644 --- a/pkg/tbtc/parameters.go +++ b/pkg/tbtc/parameters.go @@ -63,7 +63,7 @@ type ReservationParameters struct { // this vault address are treated as UTXO reservations. Vault chain.Address // MinAmount is the minimal anchor output amount in satoshi accepted for - // a reservation. + // a reservation. A value of 0 means the check is disabled. MinAmount uint64 // TxMaxFee is the maximum transaction fee in satoshi for a single // reservation lifecycle transaction. @@ -74,10 +74,10 @@ type ReservationParameters struct { // reservation becomes dissolvable. DissolutionDelay uint32 // MaxTotalAmount is the maximum total amount of all active reservations - // in satoshi. + // in satoshi. A value of 0 means the check is disabled. MaxTotalAmount uint64 // MaxReservationsPerWallet is the maximum number of reservations a - // wallet may custody. + // wallet may custody. A value of 0 means the check is disabled. MaxReservationsPerWallet uint32 // ActionTimeout is the timeout for reservation actions in seconds. ActionTimeout uint32 From 0577a8fc99a080624068de1e8b413df54ee95079 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 2 Sep 2026 11:23:54 +0000 Subject: [PATCH 22/22] style(tbtc): gofmt reservation.go and reservation_test.go --- pkg/tbtc/reservation.go | 1 + pkg/tbtc/reservation_test.go | 9 ++++----- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/tbtc/reservation.go b/pkg/tbtc/reservation.go index 231c428bbf..0f99c98dac 100644 --- a/pkg/tbtc/reservation.go +++ b/pkg/tbtc/reservation.go @@ -524,6 +524,7 @@ 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 diff --git a/pkg/tbtc/reservation_test.go b/pkg/tbtc/reservation_test.go index ed445760af..1ab561dd10 100644 --- a/pkg/tbtc/reservation_test.go +++ b/pkg/tbtc/reservation_test.go @@ -14,7 +14,6 @@ import ( "github.com/keep-network/keep-core/pkg/chain" ) - func TestReservationStateValues(t *testing.T) { tests := map[ReservationState]uint8{ ReservationStateUnknown: 0, @@ -614,9 +613,9 @@ func TestAssembleReservationDissolutionTransaction(t *testing.T) { expectedOutputValue: 298500, }, "no-main-UTXO snapshot with newly current main UTXO": { - action: &baseAction, + action: &baseAction, walletMainUtxo: walletMainUtxo, - expectedError: "wallet main UTXO must not be provided when the dissolution action has no expected main UTXO snapshot", + expectedError: "wallet main UTXO must not be provided when the dissolution action has no expected main UTXO snapshot", }, "mismatched action amount": { action: func() *ReservationAction { @@ -625,7 +624,7 @@ func TestAssembleReservationDissolutionTransaction(t *testing.T) { return &action }(), walletMainUtxo: walletMainUtxo, - expectedError: "dissolution action amount does not match the anchor value", + expectedError: "dissolution action amount does not match the anchor value", }, "mismatched target wallet": { action: func() *ReservationAction { @@ -634,7 +633,7 @@ func TestAssembleReservationDissolutionTransaction(t *testing.T) { return &action }(), walletMainUtxo: walletMainUtxo, - expectedError: "dissolution action targets a different wallet", + expectedError: "dissolution action targets a different wallet", }, }