diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index 50e42be20e..83c9629fef 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -2408,3 +2408,96 @@ 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", + ) +} + +// 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 +// 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/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/chain.go b/pkg/tbtc/chain.go index a58599f273..02b2406b96 100644 --- a/pkg/tbtc/chain.go +++ b/pkg/tbtc/chain.go @@ -427,6 +427,58 @@ 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. + 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..5768d03422 100644 --- a/pkg/tbtc/chain_test.go +++ b/pkg/tbtc/chain_test.go @@ -1450,3 +1450,52 @@ 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) GetReservationAction( + reservationKey *big.Int, + requestNonce uint64, +) (*ReservationAction, 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..a7a4de2577 --- /dev/null +++ b/pkg/tbtc/reservation.go @@ -0,0 +1,729 @@ +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 = 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 with no action in flight. + ReservationStateActive + // 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, 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 +// 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 + // 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 + // 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 + // 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 +// 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 +// 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 + // RequestNonce is the acceptance authorization generation being executed. + RequestNonce uint64 + // 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 { + var proposal ReservationAnchorProposal + if err := json.Unmarshal(bytes, &proposal); err != nil { + return err + } + if proposal.AnchorTxFee == nil { + return fmt.Errorf("anchor transaction fee is required") + } + if proposal.RequestNonce == 0 { + return fmt.Errorf("request nonce is required") + } + + *rap = proposal + + return nil +} + +// ReservedRedemptionProposal represents a reserved redemption proposal +// issued by a wallet's coordination leader. +type ReservedRedemptionProposal struct { + // ReservationKey is the key of the reservation with the pending reserved + // redemption. + ReservationKey *big.Int + // RequestNonce is the redemption request generation being executed. + RequestNonce uint64 + // RedemptionTxFee is the proposed BTC fee for the reserved redemption + // transaction. + RedemptionTxFee *big.Int +} + +// ActionType returns the specific type of the walletAction being subject +// of this proposal. +func (rrp *ReservedRedemptionProposal) ActionType() WalletActionType { + return ActionReservedRedemption +} + +// ValidityBlocks returns the number of blocks for which the proposal is valid. +func (rrp *ReservedRedemptionProposal) ValidityBlocks() uint64 { + return reservedRedemptionProposalValidityBlocks +} + +// Marshal converts the reservedRedemptionProposal to a byte array. +// +// TODO: Switch to protobuf-based marshaling (see pkg/tbtc/gen/pb) once the +// reservation message types are added to the coordination proto definition. +func (rrp *ReservedRedemptionProposal) Marshal() ([]byte, error) { + return json.Marshal(rrp) +} + +// Unmarshal converts a byte array back to the reservedRedemptionProposal. +func (rrp *ReservedRedemptionProposal) Unmarshal(bytes []byte) error { + var proposal ReservedRedemptionProposal + if err := json.Unmarshal(bytes, &proposal); err != nil { + return err + } + if proposal.ReservationKey == nil { + return fmt.Errorf("reservation key is required") + } + if proposal.RequestNonce == 0 { + return fmt.Errorf("request nonce is required") + } + if proposal.RedemptionTxFee == nil { + return fmt.Errorf("redemption transaction fee is required") + } + + *rrp = proposal + + return nil +} + +// ReservationReanchorProposal represents a reservation re-anchor proposal +// issued by a wallet's coordination leader, moving a reservation's anchor +// outpoint to another wallet (e.g. during wallet migration). +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 + // 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 { + var proposal ReservationReanchorProposal + if err := json.Unmarshal(bytes, &proposal); err != nil { + return err + } + if proposal.ReservationKey == nil { + return fmt.Errorf("reservation key is required") + } + if proposal.RequestNonce == 0 { + return fmt.Errorf("request nonce is required") + } + if proposal.ReanchorTxFee == nil { + return fmt.Errorf("re-anchor transaction fee is required") + } + + *rrp = proposal + + return nil +} + +// ReservationDissolutionProposal represents a reservation dissolution +// proposal issued by a wallet's coordination leader once the reservation's +// custody term and grace period elapsed. +type ReservationDissolutionProposal struct { + // ReservationKey is the key of the reservation to dissolve. + ReservationKey *big.Int + // RequestNonce is the dissolution authorization generation being executed. + RequestNonce uint64 + // DissolutionTxFee is the proposed BTC fee for the dissolution + // transaction. + DissolutionTxFee *big.Int +} + +// ActionType returns the specific type of the walletAction being subject +// of this proposal. +func (rdp *ReservationDissolutionProposal) ActionType() WalletActionType { + return ActionReservationDissolution +} + +// ValidityBlocks returns the number of blocks for which the proposal is valid. +func (rdp *ReservationDissolutionProposal) ValidityBlocks() uint64 { + return reservationDissolutionProposalValidityBlocks +} + +// Marshal converts the reservationDissolutionProposal to a byte array. +// +// TODO: Switch to protobuf-based marshaling (see pkg/tbtc/gen/pb) once the +// reservation message types are added to the coordination proto definition. +func (rdp *ReservationDissolutionProposal) Marshal() ([]byte, error) { + return json.Marshal(rdp) +} + +// Unmarshal converts a byte array back to the reservationDissolutionProposal. +func (rdp *ReservationDissolutionProposal) Unmarshal(bytes []byte) error { + var proposal ReservationDissolutionProposal + if err := json.Unmarshal(bytes, &proposal); err != nil { + return err + } + if proposal.ReservationKey == nil { + return fmt.Errorf("reservation key is required") + } + if proposal.RequestNonce == 0 { + return fmt.Errorf("request nonce is required") + } + if proposal.DissolutionTxFee == nil { + return fmt.Errorf("dissolution transaction fee is required") + } + + *rdp = proposal + + return nil +} + +// 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 for the given nonce-bound action. A whole redemption +// is a 1-input-1-output spend to the redeemer. A partial redemption is a +// 1-input-2-output spend whose first output pays the authorized amount less +// the miner fee to the redeemer and whose second output re-anchors the exact +// remainder to the custodying wallet. +func assembleReservedRedemptionTransaction( + bitcoinChain bitcoin.Chain, + anchorUtxo *bitcoin.UnspentTransactionOutput, + walletPublicKeyHash [20]byte, + redeemerOutputScript bitcoin.Script, + action *ReservationAction, + fee int64, +) (*bitcoin.TransactionBuilder, error) { + if anchorUtxo == nil { + return nil, fmt.Errorf("anchor UTXO is required") + } + if len(redeemerOutputScript) == 0 { + return nil, fmt.Errorf("redeemer output script is required") + } + if action == nil { + return nil, fmt.Errorf("reservation action is required") + } + if action.ActionType != ReservationActionTypeRedemption { + return nil, fmt.Errorf("reservation action is not a redemption") + } + if action.State != ReservationActionStatePending { + return nil, fmt.Errorf("reservation action is not pending") + } + if anchorUtxo.Value <= 0 { + return nil, fmt.Errorf("anchor UTXO value must be positive") + } + if action.Amount == 0 { + return nil, fmt.Errorf("redemption amount must be positive") + } + if action.Amount > uint64(anchorUtxo.Value) { + return nil, fmt.Errorf("redemption amount exceeds the anchor value") + } + if fee <= 0 { + return nil, fmt.Errorf("transaction fee must be positive") + } + if uint64(fee) > action.TxMaxFee { + return nil, fmt.Errorf("transaction fee exceeds the action fee limit") + } + + redeemerOutputScriptHash, err := computeReservationRedeemerOutputScriptHash( + redeemerOutputScript, + ) + if err != nil { + return nil, err + } + if redeemerOutputScriptHash != action.RedeemerOutputScriptHash { + return nil, fmt.Errorf("redeemer output script is not authorized") + } + + if action.IsPartial { + if action.Amount == uint64(anchorUtxo.Value) { + return nil, fmt.Errorf( + "partial redemption amount must be less than the anchor value", + ) + } + } else if action.Amount != uint64(anchorUtxo.Value) { + return nil, fmt.Errorf( + "whole redemption amount must equal the anchor value", + ) + } + + builder := bitcoin.NewTransactionBuilder(bitcoinChain) + + err = builder.AddPublicKeyHashInput(anchorUtxo) + if err != nil { + return nil, fmt.Errorf( + "cannot add input pointing to anchor UTXO: [%v]", + err, + ) + } + + redemptionAmount := anchorUtxo.Value + if action.IsPartial { + redemptionAmount = int64(action.Amount) + } + + redemptionValue := redemptionAmount - fee + if redemptionValue <= 0 { + return nil, fmt.Errorf( + "transaction fee exceeds the redemption amount", + ) + } + + builder.AddOutput(&bitcoin.TransactionOutput{ + Value: redemptionValue, + PublicKeyScript: redeemerOutputScript, + }) + + if action.IsPartial { + remainderScript, err := bitcoin.PayToWitnessPublicKeyHash( + walletPublicKeyHash, + ) + if err != nil { + return nil, fmt.Errorf("cannot compute remainder script: [%v]", err) + } + + builder.AddOutput(&bitcoin.TransactionOutput{ + Value: anchorUtxo.Value - int64(action.Amount), + PublicKeyScript: remainderScript, + }) + } + + return builder, nil +} + +// computeReservationRedeemerOutputScriptHash computes the authorization hash +// stored in a reservation action. The Bridge hashes the Bitcoin output script +// including its CompactSize length prefix. +func computeReservationRedeemerOutputScriptHash( + redeemerOutputScript bitcoin.Script, +) ([32]byte, error) { + prefixedScript, err := redeemerOutputScript.ToVarLenData() + if err != nil { + return [32]byte{}, fmt.Errorf( + "cannot build prefixed redeemer output script: [%v]", + err, + ) + } + + hasher := sha3.NewLegacyKeccak256() + _, _ = hasher.Write(prefixedScript) + + var result [32]byte + copy(result[:], hasher.Sum(nil)) + + return result, nil +} + +// assembleReservationReanchorTransaction constructs an unsigned reservation +// re-anchor transaction: a 1-input-1-output spend of the reservation's +// anchor outpoint into a fresh output controlled by the target wallet. Used +// 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 for the given nonce-bound action. The +// anchor outpoint is the first input. The wallet main UTXO is the second input +// only when it is present in the action snapshot and matches that snapshot +// exactly. The single output pays back to the custodying wallet. +func assembleReservationDissolutionTransaction( + bitcoinChain bitcoin.Chain, + bridgeChain BridgeChain, + anchorUtxo *bitcoin.UnspentTransactionOutput, + walletMainUtxo *bitcoin.UnspentTransactionOutput, + walletPublicKeyHash [20]byte, + action *ReservationAction, + fee int64, +) (*bitcoin.TransactionBuilder, error) { + if anchorUtxo == nil { + return nil, fmt.Errorf("anchor UTXO is required") + } + if action == nil { + return nil, fmt.Errorf("reservation action is required") + } + if action.ActionType != ReservationActionTypeDissolution { + return nil, fmt.Errorf("reservation action is not a dissolution") + } + if action.State != ReservationActionStatePending { + return nil, fmt.Errorf("reservation action is not pending") + } + if action.TargetWalletPublicKeyHash != walletPublicKeyHash { + return nil, fmt.Errorf("dissolution action targets a different wallet") + } + if anchorUtxo.Value <= 0 { + return nil, fmt.Errorf("anchor UTXO value must be positive") + } + if action.Amount != uint64(anchorUtxo.Value) { + return nil, fmt.Errorf( + "dissolution action amount does not match the anchor value", + ) + } + if fee <= 0 { + return nil, fmt.Errorf("transaction fee must be positive") + } + if uint64(fee) > action.TxMaxFee { + return nil, fmt.Errorf("transaction fee exceeds the action fee limit") + } + + mainUtxoExpected := action.ExpectedMainUtxoHash != [32]byte{} + if mainUtxoExpected { + if bridgeChain == nil { + return nil, fmt.Errorf("bridge chain is required") + } + if walletMainUtxo == nil { + return nil, fmt.Errorf( + "wallet main UTXO is required by the dissolution action", + ) + } + if bridgeChain.ComputeMainUtxoHash(walletMainUtxo) != + action.ExpectedMainUtxoHash { + return nil, fmt.Errorf( + "wallet main UTXO does not match the dissolution action snapshot", + ) + } + } + + builder := bitcoin.NewTransactionBuilder(bitcoinChain) + + // The Bridge requires the anchor outpoint to be the first input. + err := builder.AddPublicKeyHashInput(anchorUtxo) + if err != nil { + return nil, fmt.Errorf( + "cannot add input pointing to anchor UTXO: [%v]", + err, + ) + } + + totalInputsValue := anchorUtxo.Value + + if mainUtxoExpected { + err = builder.AddPublicKeyHashInput(walletMainUtxo) + if err != nil { + return nil, fmt.Errorf( + "cannot add input pointing to wallet main UTXO: [%v]", + err, + ) + } + totalInputsValue += walletMainUtxo.Value + } + + dissolutionValue := totalInputsValue - fee + if dissolutionValue <= 0 { + return nil, fmt.Errorf( + "transaction fee exceeds the total inputs value", + ) + } + + dissolutionScript, err := bitcoin.PayToWitnessPublicKeyHash( + walletPublicKeyHash, + ) + if err != nil { + return nil, fmt.Errorf("cannot compute dissolution script: [%v]", err) + } + + builder.AddOutput(&bitcoin.TransactionOutput{ + Value: dissolutionValue, + PublicKeyScript: dissolutionScript, + }) + + return builder, nil +} diff --git a/pkg/tbtc/reservation_test.go b/pkg/tbtc/reservation_test.go new file mode 100644 index 0000000000..16ab16c160 --- /dev/null +++ b/pkg/tbtc/reservation_test.go @@ -0,0 +1,770 @@ +package tbtc + +import ( + "crypto/ecdsa" + "crypto/rand" + "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 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), + } + + 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 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]", + }, + "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,"RequestNonce":2}`, + 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 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,"RequestNonce":3}`, + expectedError: "cannot unmarshal proposal payload: [re-anchor transaction fee is required]", + }, + "dissolution null payload": { + actionType: ActionReservationDissolution, + payload: `null`, + expectedError: "cannot unmarshal proposal payload: [reservation key is required]", + }, + "dissolution missing nonce": { + actionType: ActionReservationDissolution, + payload: `{"ReservationKey":99999,"DissolutionTxFee":1800}`, + expectedError: "cannot unmarshal proposal payload: [request nonce is required]", + }, + "dissolution missing fee": { + actionType: ActionReservationDissolution, + payload: `{"ReservationKey":99999,"RequestNonce":4}`, + expectedError: "cannot unmarshal proposal payload: [dissolution transaction fee is required]", + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + _, err := unmarshalCoordinationProposal( + uint32(test.actionType), + []byte(test.payload), + ) + if err == nil || err.Error() != test.expectedError { + t.Errorf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + test.expectedError, + err, + ) + } + }) + } +} + +func TestAssembleReservedRedemptionTransaction(t *testing.T) { + bitcoinChain := newLocalBitcoinChain() + + privateKeyValue := big.NewInt(100) + wallet := generateWallet(privateKeyValue) + walletPublicKeyHash := bitcoin.PublicKeyHash(wallet.publicKey) + walletScript, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + + redeemerScript, err := bitcoin.PayToWitnessPublicKeyHash([20]byte{0x01}) + if err != nil { + t.Fatal(err) + } + + fundingTransaction := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x01}, + OutputIndex: 0, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 100000, + PublicKeyScript: walletScript, + }, + }, + } + if err := bitcoinChain.BroadcastTransaction(fundingTransaction); err != nil { + t.Fatal(err) + } + + anchorUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTransaction.Hash(), + OutputIndex: 0, + }, + Value: 100000, + } + + redeemerOutputScriptHash, err := computeReservationRedeemerOutputScriptHash( + redeemerScript, + ) + if err != nil { + t.Fatal(err) + } + + tests := map[string]struct { + action *ReservationAction + expectedOutputs []*bitcoin.TransactionOutput + }{ + "whole redemption": { + action: &ReservationAction{ + TxMaxFee: 2000, + ActionType: ReservationActionTypeRedemption, + State: ReservationActionStatePending, + Amount: 100000, + RedeemerOutputScriptHash: redeemerOutputScriptHash, + }, + expectedOutputs: []*bitcoin.TransactionOutput{ + { + Value: 98500, + PublicKeyScript: redeemerScript, + }, + }, + }, + "partial redemption": { + action: &ReservationAction{ + TxMaxFee: 2000, + ActionType: ReservationActionTypeRedemption, + State: ReservationActionStatePending, + Amount: 40000, + RedeemerOutputScriptHash: redeemerOutputScriptHash, + IsPartial: true, + }, + expectedOutputs: []*bitcoin.TransactionOutput{ + { + Value: 38500, + PublicKeyScript: redeemerScript, + }, + { + Value: 60000, + PublicKeyScript: walletScript, + }, + }, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + builder, err := assembleReservedRedemptionTransaction( + bitcoinChain, + anchorUtxo, + walletPublicKeyHash, + redeemerScript, + test.action, + 1500, + ) + if err != nil { + t.Fatal(err) + } + + transaction := signReservationTransaction( + t, + builder, + wallet.publicKey, + privateKeyValue, + ) + + if !reflect.DeepEqual(test.expectedOutputs, transaction.Outputs) { + t.Errorf( + "unexpected outputs\nexpected: [%+v]\nactual: [%+v]", + test.expectedOutputs, + transaction.Outputs, + ) + } + }) + } +} + +func TestAssembleReservationDissolutionTransaction(t *testing.T) { + bitcoinChain := newLocalBitcoinChain() + bridgeChain := Connect() + + privateKeyValue := big.NewInt(100) + wallet := generateWallet(privateKeyValue) + walletPublicKeyHash := bitcoin.PublicKeyHash(wallet.publicKey) + walletScript, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + + fundingTransaction := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x01}, + OutputIndex: 0, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 100000, + PublicKeyScript: walletScript, + }, + { + Value: 200000, + PublicKeyScript: walletScript, + }, + }, + } + if err := bitcoinChain.BroadcastTransaction(fundingTransaction); err != nil { + t.Fatal(err) + } + + anchorUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTransaction.Hash(), + OutputIndex: 0, + }, + Value: 100000, + } + walletMainUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTransaction.Hash(), + OutputIndex: 1, + }, + Value: 200000, + } + + baseAction := ReservationAction{ + TargetWalletPublicKeyHash: walletPublicKeyHash, + TxMaxFee: 2000, + ActionType: ReservationActionTypeDissolution, + State: ReservationActionStatePending, + Amount: 100000, + } + + tests := map[string]struct { + action *ReservationAction + expectedInputUtxos []*bitcoin.UnspentTransactionOutput + expectedOutputValue int64 + }{ + "snapshotted main UTXO": { + action: func() *ReservationAction { + action := baseAction + action.ExpectedMainUtxoHash = bridgeChain.ComputeMainUtxoHash( + walletMainUtxo, + ) + return &action + }(), + expectedInputUtxos: []*bitcoin.UnspentTransactionOutput{ + anchorUtxo, + walletMainUtxo, + }, + expectedOutputValue: 298500, + }, + "no-main-UTXO snapshot with newly current main UTXO": { + action: &baseAction, + expectedInputUtxos: []*bitcoin.UnspentTransactionOutput{ + anchorUtxo, + }, + expectedOutputValue: 98500, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + builder, err := assembleReservationDissolutionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + walletMainUtxo, + walletPublicKeyHash, + test.action, + 1500, + ) + if err != nil { + t.Fatal(err) + } + + transaction := signReservationTransaction( + t, + builder, + wallet.publicKey, + privateKeyValue, + ) + + if len(transaction.Inputs) != len(test.expectedInputUtxos) { + t.Fatalf( + "unexpected input count\nexpected: [%v]\nactual: [%v]", + len(test.expectedInputUtxos), + len(transaction.Inputs), + ) + } + for i, expectedInputUtxo := range test.expectedInputUtxos { + if !reflect.DeepEqual( + expectedInputUtxo.Outpoint, + transaction.Inputs[i].Outpoint, + ) { + t.Errorf( + "unexpected input at index [%v]\nexpected: [%+v]\nactual: [%+v]", + i, + expectedInputUtxo.Outpoint, + transaction.Inputs[i].Outpoint, + ) + } + } + + expectedOutputs := []*bitcoin.TransactionOutput{ + { + Value: test.expectedOutputValue, + PublicKeyScript: walletScript, + }, + } + if !reflect.DeepEqual(expectedOutputs, transaction.Outputs) { + t.Errorf( + "unexpected outputs\nexpected: [%+v]\nactual: [%+v]", + expectedOutputs, + transaction.Outputs, + ) + } + }) + } +} + +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} + + anchorUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x03}, + OutputIndex: 0, + }, + Value: 100000, + } + redeemerOutputScriptHash, err := computeReservationRedeemerOutputScriptHash( + redeemerScript, + ) + if err != nil { + t.Fatal(err) + } + redemptionAction := &ReservationAction{ + TxMaxFee: 2000, + ActionType: ReservationActionTypeRedemption, + State: ReservationActionStatePending, + Amount: 100000, + RedeemerOutputScriptHash: redeemerOutputScriptHash, + } + dissolutionAction := &ReservationAction{ + TargetWalletPublicKeyHash: walletPublicKeyHash, + TxMaxFee: 2000, + ActionType: ReservationActionTypeDissolution, + State: ReservationActionStatePending, + Amount: 100000, + } + + assertError := func(err error, expected string) { + if err == nil || err.Error() != expected { + t.Errorf("expected error [%v], got [%v]", expected, err) + } + } + + _, err = assembleReservationAnchorTransaction( + bitcoinChain, + nil, + walletPublicKeyHash, + 1500, + ) + assertError(err, "deposit is required") + + _, err = assembleReservedRedemptionTransaction( + bitcoinChain, + nil, + walletPublicKeyHash, + redeemerScript, + redemptionAction, + 1500, + ) + assertError(err, "anchor UTXO is required") + + _, err = assembleReservedRedemptionTransaction( + bitcoinChain, + anchorUtxo, + walletPublicKeyHash, + bitcoin.Script{}, + redemptionAction, + 1500, + ) + assertError(err, "redeemer output script is required") + + _, err = assembleReservedRedemptionTransaction( + bitcoinChain, + anchorUtxo, + walletPublicKeyHash, + redeemerScript, + nil, + 1500, + ) + assertError(err, "reservation action is required") + + nonRedemptionAction := *redemptionAction + nonRedemptionAction.ActionType = ReservationActionTypeReanchor + _, err = assembleReservedRedemptionTransaction( + bitcoinChain, + anchorUtxo, + walletPublicKeyHash, + redeemerScript, + &nonRedemptionAction, + 1500, + ) + assertError(err, "reservation action is not a redemption") + + nonPendingAction := *redemptionAction + nonPendingAction.State = ReservationActionStateTimedOut + _, err = assembleReservedRedemptionTransaction( + bitcoinChain, + anchorUtxo, + walletPublicKeyHash, + redeemerScript, + &nonPendingAction, + 1500, + ) + assertError(err, "reservation action is not pending") + + wrongScriptAction := *redemptionAction + wrongScriptAction.RedeemerOutputScriptHash = [32]byte{0x01} + _, err = assembleReservedRedemptionTransaction( + bitcoinChain, + anchorUtxo, + walletPublicKeyHash, + redeemerScript, + &wrongScriptAction, + 1500, + ) + assertError(err, "redeemer output script is not authorized") + + partialWholeAmountAction := *redemptionAction + partialWholeAmountAction.IsPartial = true + _, err = assembleReservedRedemptionTransaction( + bitcoinChain, + anchorUtxo, + walletPublicKeyHash, + redeemerScript, + &partialWholeAmountAction, + 1500, + ) + assertError( + err, + "partial redemption amount must be less than the anchor value", + ) + + partialAmountAction := *redemptionAction + partialAmountAction.Amount = 40000 + _, err = assembleReservedRedemptionTransaction( + bitcoinChain, + anchorUtxo, + walletPublicKeyHash, + redeemerScript, + &partialAmountAction, + 1500, + ) + assertError(err, "whole redemption amount must equal the anchor value") + + _, err = assembleReservedRedemptionTransaction( + bitcoinChain, + anchorUtxo, + walletPublicKeyHash, + redeemerScript, + redemptionAction, + 2500, + ) + assertError(err, "transaction fee exceeds the action fee limit") + + _, err = assembleReservationReanchorTransaction( + bitcoinChain, + nil, + walletPublicKeyHash, + 1500, + ) + assertError(err, "anchor UTXO is required") + + _, 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", + ) +} diff --git a/pkg/tbtc/wallet.go b/pkg/tbtc/wallet.go index ca346dec69..ef4303302f 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") } @@ -89,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 9ef4e41576..7d5a086414 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]"), }, } @@ -82,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() 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,