From 2e3570ed34e5227cad78383dfb7ac5bd19cdcf25 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 7 Aug 2026 16:32:07 -0400 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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", + ) }