diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index 50e42be20e..31fd73cb55 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -50,6 +50,8 @@ const ( EcdsaDkgValidatorContractName = "EcdsaDkgValidator" ) +var errReservationsUnsupported = errors.New("reservations not supported yet by the Ethereum chain implementation") + const ( sweptDepositsCachePeriod = 7 * 24 * time.Hour ) @@ -1545,6 +1547,46 @@ func (tc *TbtcChain) ComputeMainUtxoHash( return computeMainUtxoHash(mainUtxo) } +// ComputeReservationRedeemerOutputScriptHash computes the keccak256 hash of the +// length-prefixed redeemer output script, as required by the on-chain Bridge +// rules. See also redeemerOutputScriptHash. +func (tc *TbtcChain) ComputeReservationRedeemerOutputScriptHash( + redeemerOutputScript bitcoin.Script, +) ([32]byte, error) { + return redeemerOutputScriptHash(redeemerOutputScript) +} + +// redeemerOutputScriptHash computes the keccak256 hash of the length-prefixed +// redeemer output script, as required by the on-chain Bridge rules. It is a +// building block for both the legacy redemption key (see buildRedemptionKey) +// and reservation redemption authorization (see +// ComputeReservationRedeemerOutputScriptHash). +func redeemerOutputScriptHash(redeemerOutputScript bitcoin.Script) ([32]byte, error) { + prefixedRedeemerOutputScript, err := redeemerOutputScript.ToVarLenData() + if err != nil { + return [32]byte{}, fmt.Errorf("cannot build prefixed redeemer output script: [%v]", err) + } + return crypto.Keccak256Hash(prefixedRedeemerOutputScript), nil +} + +// buildRedemptionKey builds the redemption key by hashing the concatenation +// of the redeemer output script hash and the wallet public key hash. +func buildRedemptionKey( + walletPublicKeyHash [20]byte, + redeemerOutputScript bitcoin.Script, +) (*big.Int, error) { + redeemerOutputScriptHash, err := redeemerOutputScriptHash(redeemerOutputScript) + if err != nil { + return nil, fmt.Errorf("cannot compute redeemer output script hash: [%v]", err) + } + + redemptionKey := crypto.Keccak256Hash( + append(redeemerOutputScriptHash[:], walletPublicKeyHash[:]...), + ) + + return redemptionKey.Big(), nil +} + func computeMainUtxoHash(mainUtxo *bitcoin.UnspentTransactionOutput) [32]byte { outputIndexBytes := make([]byte, 4) binary.BigEndian.PutUint32(outputIndexBytes, mainUtxo.Outpoint.OutputIndex) @@ -1698,26 +1740,6 @@ func (tc *TbtcChain) SubmitRedemptionProofWithReimbursement( return err } -func buildRedemptionKey( - walletPublicKeyHash [20]byte, - redeemerOutputScript bitcoin.Script, -) (*big.Int, error) { - // The Bridge contract builds the redemption key using the length-prefixed - // redeemer output script. - prefixedRedeemerOutputScript, err := redeemerOutputScript.ToVarLenData() - if err != nil { - return nil, fmt.Errorf("cannot build prefixed redeemer output script: [%v]", err) - } - - redeemerOutputScriptHash := crypto.Keccak256Hash(prefixedRedeemerOutputScript) - - redemptionKey := crypto.Keccak256Hash( - append(redeemerOutputScriptHash[:], walletPublicKeyHash[:]...), - ) - - return redemptionKey.Big(), nil -} - func (tc *TbtcChain) TxProofDifficultyFactor() (*big.Int, error) { return tc.bridge.TxProofDifficultyFactor() } @@ -2408,3 +2430,79 @@ func (tc *TbtcChain) GetRedemptionDelay( func (tc *TbtcChain) GetDepositMinAge() (uint32, error) { return tc.walletProposalValidator.DEPOSITMINAGE() } + +// GetReservation returns errReservationsUnsupported because the generated +// Ethereum bindings do not yet expose the reservation Bridge API. +func (tc *TbtcChain) GetReservation( + reservationKey *big.Int, +) (*tbtc.Reservation, bool, error) { + return nil, false, errReservationsUnsupported +} + +// GetReservationAction returns errReservationsUnsupported because the +// generated Ethereum bindings do not yet expose the reservation Bridge API. +func (tc *TbtcChain) GetReservationAction( + reservationKey *big.Int, + requestNonce uint64, +) (*tbtc.ReservationAction, error) { + return nil, errReservationsUnsupported +} + +// GetReservationParameters returns errReservationsUnsupported because the +// generated Ethereum bindings do not yet expose the reservation Bridge API. +func (tc *TbtcChain) GetReservationParameters() ( + tbtc.ReservationParameters, + error, +) { + return tbtc.ReservationParameters{}, errReservationsUnsupported +} + +// GetReservationTotalAmount returns errReservationsUnsupported because the +// generated Ethereum bindings do not yet expose the reservation Bridge API. +func (tc *TbtcChain) GetReservationTotalAmount() (uint64, error) { + return 0, errReservationsUnsupported +} + +// ValidateReservationAnchorProposal returns errReservationsUnsupported +// because the generated Ethereum bindings do not yet expose the reservation +// Bridge API. +func (tc *TbtcChain) ValidateReservationAnchorProposal( + walletPublicKeyHash [20]byte, + proposal *tbtc.ReservationAnchorProposal, + depositExtraInfo struct { + *tbtc.Deposit + FundingTx *bitcoin.Transaction + }, +) error { + return errReservationsUnsupported +} + +// ValidateReservedRedemptionProposal returns errReservationsUnsupported +// because the generated Ethereum bindings do not yet expose the reservation +// Bridge API. +func (tc *TbtcChain) ValidateReservedRedemptionProposal( + walletPublicKeyHash [20]byte, + proposal *tbtc.ReservedRedemptionProposal, +) error { + return errReservationsUnsupported +} + +// ValidateReservationReanchorProposal returns errReservationsUnsupported +// because the generated Ethereum bindings do not yet expose the reservation +// Bridge API. +func (tc *TbtcChain) ValidateReservationReanchorProposal( + sourceWalletPublicKeyHash [20]byte, + proposal *tbtc.ReservationReanchorProposal, +) error { + return errReservationsUnsupported +} + +// ValidateReservationDissolutionProposal returns errReservationsUnsupported +// because the generated Ethereum bindings do not yet expose the reservation +// Bridge API. +func (tc *TbtcChain) ValidateReservationDissolutionProposal( + walletPublicKeyHash [20]byte, + proposal *tbtc.ReservationDissolutionProposal, +) error { + return errReservationsUnsupported +} diff --git a/pkg/chain/ethereum/tbtc_test.go b/pkg/chain/ethereum/tbtc_test.go index 1c9eef1be0..fc54c7dfab 100644 --- a/pkg/chain/ethereum/tbtc_test.go +++ b/pkg/chain/ethereum/tbtc_test.go @@ -4,15 +4,17 @@ import ( "bytes" "crypto/ecdsa" "encoding/hex" + "errors" "fmt" "math/big" "reflect" "testing" "github.com/keep-network/keep-core/pkg/bitcoin" - "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/tbtc" + "github.com/ethereum/go-ethereum/common" "github.com/keep-network/keep-core/internal/testutils" @@ -533,3 +535,69 @@ func TestBuildMovedFundsKey(t *testing.T) { movedFundsKey.Text(16), ) } + +func TestTbtcChainReservationStubs(t *testing.T) { + tc := &TbtcChain{} + + reservationKey := big.NewInt(1) + _, _, err := tc.GetReservation(reservationKey) + if !errors.Is(err, errReservationsUnsupported) { + t.Errorf("GetReservation: expected errReservationsUnsupported, got %v", err) + } + + _, err = tc.GetReservationAction(reservationKey, 1) + if !errors.Is(err, errReservationsUnsupported) { + t.Errorf("GetReservationAction: expected errReservationsUnsupported, got %v", err) + } + + _, err = tc.GetReservationParameters() + if !errors.Is(err, errReservationsUnsupported) { + t.Errorf("GetReservationParameters: expected errReservationsUnsupported, got %v", err) + } + + _, err = tc.GetReservationTotalAmount() + if !errors.Is(err, errReservationsUnsupported) { + t.Errorf("GetReservationTotalAmount: expected errReservationsUnsupported, got %v", err) + } + + var wpkHash [20]byte + + err = tc.ValidateReservationAnchorProposal(wpkHash, &tbtc.ReservationAnchorProposal{}, struct { + *tbtc.Deposit + FundingTx *bitcoin.Transaction + }{}) + if !errors.Is(err, errReservationsUnsupported) { + t.Errorf("ValidateReservationAnchorProposal: expected errReservationsUnsupported, got %v", err) + } + + err = tc.ValidateReservedRedemptionProposal(wpkHash, &tbtc.ReservedRedemptionProposal{}) + if !errors.Is(err, errReservationsUnsupported) { + t.Errorf("ValidateReservedRedemptionProposal: expected errReservationsUnsupported, got %v", err) + } + + err = tc.ValidateReservationReanchorProposal(wpkHash, &tbtc.ReservationReanchorProposal{}) + if !errors.Is(err, errReservationsUnsupported) { + t.Errorf("ValidateReservationReanchorProposal: expected errReservationsUnsupported, got %v", err) + } + + err = tc.ValidateReservationDissolutionProposal(wpkHash, &tbtc.ReservationDissolutionProposal{}) + if !errors.Is(err, errReservationsUnsupported) { + t.Errorf("ValidateReservationDissolutionProposal: expected errReservationsUnsupported, got %v", err) + } +} + +func TestRedeemerOutputScriptHash(t *testing.T) { + // Example script: "76a9144130879211c54df460e484ddf9aac009cb38ee7488ac" + script := bitcoin.Script{0x76, 0xa9, 0x14, 0x41, 0x30, 0x87, 0x92, 0x11, 0xc5, 0x4d, 0xf4, 0x60, 0xe, 0x48, 0x4d, 0xdf, 0xf9, 0xaa, 0xc0, 0x09, 0xcb, 0x38, 0xee, 0x74, 0x88, 0xac} + + hash, err := redeemerOutputScriptHash(script) + if err != nil { + t.Fatal(err) + } + + // Known hash + expectedHash := "b04b97d4aaec109acf3af12994b0c088d2cbb96d3cdb8cdfba66c5a1cf9ca86f" + if hex.EncodeToString(hash[:]) != expectedHash { + t.Errorf("expected hash %s, got %s", expectedHash, hex.EncodeToString(hash[:])) + } +} diff --git a/pkg/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..a920a47520 100644 --- a/pkg/tbtc/chain.go +++ b/pkg/tbtc/chain.go @@ -268,6 +268,37 @@ type BridgeChain interface { // according to the on-chain Bridge rules. ComputeMainUtxoHash(mainUtxo *bitcoin.UnspentTransactionOutput) [32]byte + // ComputeReservationRedeemerOutputScriptHash computes the keccak256 hash of + // the length-prefixed redeemer output script, per the on-chain Bridge rules + // used to authorize a reserved redemption. + ComputeReservationRedeemerOutputScriptHash(redeemerOutputScript bitcoin.Script) ([32]byte, error) + + // GetReservation gets the on-chain reservation record for the given + // reservation key. The returned bool value indicates whether the + // reservation was found or not. + GetReservation(reservationKey *big.Int) (*Reservation, bool, error) + + // GetReservationAction gets the on-chain action record for the given + // reservation key and request nonce. Returns an error if the action + // generation was not found. + // + // This models the anticipated two-phase authorize-then-prove redesign + // tracked in tbtc-v2#1088's own review findings, not the + // currently-reviewed single-phase contract; the signature may change + // before Ethereum bindings are implemented. + GetReservationAction( + reservationKey *big.Int, + requestNonce uint64, + ) (*ReservationAction, error) + + // GetReservationParameters gets the current on-chain value of the + // reservation parameters. + GetReservationParameters() (ReservationParameters, error) + + // GetReservationTotalAmount gets the current total amount of all active + // reservations in satoshi. + GetReservationTotalAmount() (uint64, error) + // PastDepositRevealedEvents fetches past deposit reveal events according // to the provided filter or unfiltered if the filter is nil. Returned // events are sorted by the block number in the ascending order, i.e. the @@ -427,6 +458,46 @@ type WalletProposalValidatorChain interface { }, ) 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. + // + // sourceWalletPublicKeyHash identifies the wallet currently custodying + // the reservation being moved; the destination wallet is given by + // proposal.TargetWalletPublicKeyHash. + 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..eb848bc375 100644 --- a/pkg/tbtc/chain_test.go +++ b/pkg/tbtc/chain_test.go @@ -97,6 +97,9 @@ type localChain struct { movingFundsParametersMutex sync.Mutex movingFundsParameters MovingFundsParameters + reservationParametersMutex sync.Mutex + reservationParameters ReservationParameters + eligibleStakesMutex sync.Mutex eligibleStakes map[chain.Address]*big.Int @@ -927,6 +930,26 @@ func (lc *localChain) ComputeMainUtxoHash( return mainUtxoHash } +func (lc *localChain) ComputeReservationRedeemerOutputScriptHash( + redeemerOutputScript bitcoin.Script, +) ([32]byte, error) { + prefixedScript, err := redeemerOutputScript.ToVarLenData() + if err != nil { + return [32]byte{}, fmt.Errorf( + "cannot build prefixed redeemer output script: [%v]", + err, + ) + } + + return sha256.Sum256(prefixedScript), nil +} + +func (lc *localChain) ValidateReservationDissolutionProposal( + walletPublicKeyHash [20]byte, + proposal *ReservationDissolutionProposal, +) error { + panic("unsupported") +} func (lc *localChain) ComputeMovingFundsCommitmentHash(targetWallets [][20]byte) [32]byte { packedWallets := []byte{} @@ -1450,3 +1473,59 @@ func generateHandlerID() int { // Local chain implementation doesn't require secure randomness. return rand.Int() } + +func (lc *localChain) GetReservation( + reservationKey *big.Int, +) (*Reservation, bool, error) { + panic("unsupported") +} + +func (lc *localChain) GetReservationAction( + reservationKey *big.Int, + requestNonce uint64, +) (*ReservationAction, error) { + panic("unsupported") +} + +func (lc *localChain) GetReservationParameters() (ReservationParameters, error) { + lc.reservationParametersMutex.Lock() + defer lc.reservationParametersMutex.Unlock() + + return lc.reservationParameters, nil +} + +func (lc *localChain) SetReservationParameters(params ReservationParameters) { + lc.reservationParametersMutex.Lock() + defer lc.reservationParametersMutex.Unlock() + + lc.reservationParameters = params +} + +func (lc *localChain) GetReservationTotalAmount() (uint64, error) { + 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") +} diff --git a/pkg/tbtc/marshaling.go b/pkg/tbtc/marshaling.go index 02b5195e45..790ed6e842 100644 --- a/pkg/tbtc/marshaling.go +++ b/pkg/tbtc/marshaling.go @@ -2,6 +2,7 @@ package tbtc import ( "crypto/ecdsa" + "encoding/json" "fmt" "math" "math/big" @@ -229,12 +230,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( @@ -484,9 +489,192 @@ func unmarshalPublicKey(bytes []byte) (*ecdsa.PublicKey, error) { func validateMemberIndex(protoIndex uint32) error { // Protobuf does not have uint8 type, so we are using uint32. When - // unmarshalling message, we need to make sure we do not overflow. - if protoIndex > group.MaxMemberIndex { + // unmarshalling message, we need to make sure we do not overflow, + // and that a valid (non-zero) member index was provided. + if protoIndex == 0 || protoIndex > uint32(group.MaxMemberIndex) { return fmt.Errorf("invalid member index value: [%v]", protoIndex) } return nil } + +func validateReservationKey(key *big.Int) error { + if key == nil { + return fmt.Errorf("reservation key is required") + } + if key.Sign() <= 0 || key.BitLen() > 256 { + return fmt.Errorf("reservation key is out of range") + } + return nil +} + +func validateProposalNonceAndFee(nonce uint64, fee *big.Int, label string) error { + if nonce == 0 { + return fmt.Errorf("request nonce is required") + } + if fee == nil { + return fmt.Errorf("%s is required", label) + } + if fee.Sign() <= 0 { + return fmt.Errorf("%s must be positive", label) + } + if !fee.IsInt64() { + return fmt.Errorf("%s is out of range", label) + } + return nil +} + +// Marshal converts the reservationAnchorProposal to a byte array. +// +// TODO: Switch to protobuf-based marshaling (see pkg/tbtc/gen/pb) once the +// reservation message types are added to the coordination proto definition. +// This protobuf migration MUST land before (or in the same release as) any +// code that generates these proposals on the wire, i.e. before the deferred +// coordination-executor wiring activates these action types. +func (rap *ReservationAnchorProposal) Marshal() ([]byte, error) { + return json.Marshal(rap) +} + +// Unmarshal converts a byte array back to the reservationAnchorProposal. +func (rap *ReservationAnchorProposal) Unmarshal(bytes []byte) error { + var proposal struct { + DepositFundingTxHash []byte `json:"depositFundingTxHash"` + DepositFundingOutputIndex uint32 `json:"depositFundingOutputIndex"` + RequestNonce uint64 `json:"requestNonce"` + AnchorTxFee *big.Int `json:"anchorTxFee"` + } + if err := json.Unmarshal(bytes, &proposal); err != nil { + return err + } + if len(proposal.DepositFundingTxHash) != 0 && + len(proposal.DepositFundingTxHash) != bitcoin.HashByteLength { + return fmt.Errorf( + "invalid deposit funding transaction hash length: [%v]", + len(proposal.DepositFundingTxHash), + ) + } + + var depositFundingTxHash bitcoin.Hash + copy(depositFundingTxHash[:], proposal.DepositFundingTxHash) + + if depositFundingTxHash == (bitcoin.Hash{}) { + return fmt.Errorf("deposit funding transaction hash is required") + } + if err := validateProposalNonceAndFee(proposal.RequestNonce, proposal.AnchorTxFee, "anchor transaction fee"); err != nil { + return err + } + + rap.DepositFundingTxHash = depositFundingTxHash + rap.DepositFundingOutputIndex = proposal.DepositFundingOutputIndex + rap.RequestNonce = proposal.RequestNonce + rap.AnchorTxFee = proposal.AnchorTxFee + return nil +} + +// Marshal converts the reservedRedemptionProposal to a byte array. +// +// TODO: Switch to protobuf-based marshaling (see pkg/tbtc/gen/pb) once the +// reservation message types are added to the coordination proto definition. +// This protobuf migration MUST land before (or in the same release as) any +// code that generates these proposals on the wire, i.e. before the deferred +// coordination-executor wiring activates these action types. +func (rrp *ReservedRedemptionProposal) Marshal() ([]byte, error) { + return json.Marshal(rrp) +} + +// 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 err := validateReservationKey(proposal.ReservationKey); err != nil { + return err + } + if len(proposal.RedeemerOutputScript) == 0 { + return fmt.Errorf("redeemer output script is required") + } + if err := validateProposalNonceAndFee(proposal.RequestNonce, proposal.RedemptionTxFee, "redemption transaction fee"); err != nil { + return err + } + + *rrp = proposal + return nil +} + +// Marshal converts the reservationReanchorProposal to a byte array. +// +// TODO: Switch to protobuf-based marshaling (see pkg/tbtc/gen/pb) once the +// reservation message types are added to the coordination proto definition. +// This protobuf migration MUST land before (or in the same release as) any +// code that generates these proposals on the wire, i.e. before the deferred +// coordination-executor wiring activates these action types. +func (rrp *ReservationReanchorProposal) Marshal() ([]byte, error) { + return json.Marshal(rrp) +} + +// Unmarshal converts a byte array back to the reservationReanchorProposal. +func (rrp *ReservationReanchorProposal) Unmarshal(bytes []byte) error { + var proposal struct { + ReservationKey *big.Int `json:"reservationKey"` + RequestNonce uint64 `json:"requestNonce"` + TargetWalletPublicKeyHash []byte `json:"targetWalletPublicKeyHash"` + ReanchorTxFee *big.Int `json:"reanchorTxFee"` + } + if err := json.Unmarshal(bytes, &proposal); err != nil { + return err + } + if len(proposal.TargetWalletPublicKeyHash) != 0 && + len(proposal.TargetWalletPublicKeyHash) != 20 { + return fmt.Errorf( + "invalid target wallet public key hash length: [%v]", + len(proposal.TargetWalletPublicKeyHash), + ) + } + if err := validateReservationKey(proposal.ReservationKey); err != nil { + return err + } + + var targetWalletPublicKeyHash [20]byte + copy(targetWalletPublicKeyHash[:], proposal.TargetWalletPublicKeyHash) + + if targetWalletPublicKeyHash == [20]byte{} { + return fmt.Errorf("target wallet public key hash is required") + } + if err := validateProposalNonceAndFee(proposal.RequestNonce, proposal.ReanchorTxFee, "re-anchor transaction fee"); err != nil { + return err + } + + rrp.ReservationKey = proposal.ReservationKey + rrp.RequestNonce = proposal.RequestNonce + rrp.TargetWalletPublicKeyHash = targetWalletPublicKeyHash + rrp.ReanchorTxFee = proposal.ReanchorTxFee + return nil +} + +// Marshal converts the reservationDissolutionProposal to a byte array. +// +// TODO: Switch to protobuf-based marshaling (see pkg/tbtc/gen/pb) once the +// reservation message types are added to the coordination proto definition. +// This protobuf migration MUST land before (or in the same release as) any +// code that generates these proposals on the wire, i.e. before the deferred +// coordination-executor wiring activates these action types. +func (rdp *ReservationDissolutionProposal) Marshal() ([]byte, error) { + return json.Marshal(rdp) +} + +// 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 err := validateReservationKey(proposal.ReservationKey); err != nil { + return err + } + if err := validateProposalNonceAndFee(proposal.RequestNonce, proposal.DissolutionTxFee, "dissolution transaction fee"); err != nil { + return err + } + + *rdp = proposal + return nil +} diff --git a/pkg/tbtc/marshaling_test.go b/pkg/tbtc/marshaling_test.go index 32b6977f0a..9efe3aa0e9 100644 --- a/pkg/tbtc/marshaling_test.go +++ b/pkg/tbtc/marshaling_test.go @@ -4,16 +4,15 @@ import ( "crypto/ecdsa" "crypto/elliptic" "encoding/hex" + "encoding/json" "math/big" "reflect" "strings" "testing" - "github.com/keep-network/keep-core/pkg/bitcoin" - fuzz "github.com/google/gofuzz" - "github.com/keep-network/keep-core/internal/testutils" + "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/internal/pbutils" "github.com/keep-network/keep-core/pkg/protocol/group" "github.com/keep-network/keep-core/pkg/tbtc/gen/pb" @@ -21,6 +20,42 @@ import ( "google.golang.org/protobuf/proto" ) +func TestValidateMemberIndex(t *testing.T) { + tests := map[string]struct { + protoIndex uint32 + wantErr bool + }{ + "valid index 1": { + protoIndex: 1, + wantErr: false, + }, + "valid index 255": { + protoIndex: 255, + wantErr: false, + }, + "invalid index 0": { + protoIndex: 0, + wantErr: true, + }, + "invalid index 256": { + protoIndex: 256, + wantErr: true, + }, + "invalid index 300": { + protoIndex: 300, + wantErr: true, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + err := validateMemberIndex(test.protoIndex) + if (err != nil) != test.wantErr { + t.Errorf("validateMemberIndex() error = %v, wantErr %v", err, test.wantErr) + } + }) + } +} func TestSignerMarshalling(t *testing.T) { marshaled := createMockSigner(t) @@ -434,3 +469,353 @@ func TestFuzzCoordinationMessage_MarshalingRoundtrip_WithNoopProposal(t *testing func TestFuzzCoordinationMessage_Unmarshaler(t *testing.T) { pbutils.FuzzUnmarshaler(&coordinationMessage{}) } + +func FuzzReservationAnchorProposal_Unmarshal(f *testing.F) { + proposal := &ReservationAnchorProposal{ + DepositFundingTxHash: bitcoin.Hash{0x01}, + DepositFundingOutputIndex: 1, + RequestNonce: 1, + AnchorTxFee: big.NewInt(1000), + } + bytes, _ := proposal.Marshal() + f.Add(bytes) + f.Fuzz(func(t *testing.T, data []byte) { + _ = (&ReservationAnchorProposal{}).Unmarshal(data) + }) +} + +func FuzzReservedRedemptionProposal_Unmarshal(f *testing.F) { + proposal := &ReservedRedemptionProposal{ + ReservationKey: big.NewInt(12345), + RequestNonce: 1, + RedeemerOutputScript: bitcoin.Script{0x01}, + RedemptionTxFee: big.NewInt(1000), + } + bytes, _ := proposal.Marshal() + f.Add(bytes) + f.Fuzz(func(t *testing.T, data []byte) { + _ = (&ReservedRedemptionProposal{}).Unmarshal(data) + }) +} + +func FuzzReservationReanchorProposal_Unmarshal(f *testing.F) { + proposal := &ReservationReanchorProposal{ + ReservationKey: big.NewInt(12345), + RequestNonce: 1, + TargetWalletPublicKeyHash: [20]byte{0x01}, + ReanchorTxFee: big.NewInt(1000), + } + bytes, _ := proposal.Marshal() + f.Add(bytes) + f.Fuzz(func(t *testing.T, data []byte) { + _ = (&ReservationReanchorProposal{}).Unmarshal(data) + }) +} + +func FuzzReservationDissolutionProposal_Unmarshal(f *testing.F) { + proposal := &ReservationDissolutionProposal{ + ReservationKey: big.NewInt(12345), + RequestNonce: 1, + DissolutionTxFee: big.NewInt(1000), + } + bytes, _ := proposal.Marshal() + f.Add(bytes) + f.Fuzz(func(t *testing.T, data []byte) { + _ = (&ReservationDissolutionProposal{}).Unmarshal(data) + }) +} +func TestReservationAnchorProposal_UnmarshalRejectsMalformedInput(t *testing.T) { + t.Run("truncated json", func(t *testing.T) { + if err := (&ReservationAnchorProposal{}).Unmarshal([]byte(`{"depositFundingTxHash":`)); err == nil { + t.Fatal("expected error for truncated json") + } + }) + + t.Run("zero request nonce", func(t *testing.T) { + data, _ := json.Marshal(ReservationAnchorProposal{ + DepositFundingTxHash: bitcoin.Hash{0x01}, + RequestNonce: 0, + AnchorTxFee: big.NewInt(100), + }) + if err := (&ReservationAnchorProposal{}).Unmarshal(data); err == nil { + t.Fatal("expected error for zero request nonce") + } + }) + + t.Run("negative fee", func(t *testing.T) { + data, _ := json.Marshal(ReservationAnchorProposal{ + DepositFundingTxHash: bitcoin.Hash{0x01}, + RequestNonce: 1, + AnchorTxFee: big.NewInt(-100), + }) + if err := (&ReservationAnchorProposal{}).Unmarshal(data); err == nil { + t.Fatal("expected error for negative fee") + } + }) + t.Run("oversized fee", func(t *testing.T) { + data, _ := json.Marshal(ReservationAnchorProposal{ + DepositFundingTxHash: bitcoin.Hash{0x01}, + RequestNonce: 1, + AnchorTxFee: new(big.Int).Lsh(big.NewInt(1), 64), + }) + if err := (&ReservationAnchorProposal{}).Unmarshal(data); err == nil { + t.Fatal("expected error for oversized fee") + } + }) + + t.Run("wrong-length deposit funding transaction hash", func(t *testing.T) { + // Real wire format (see Marshal, which json.Marshals the [32]byte + // field directly): a JSON array of byte values, not a base64 + // string. Length 3 instead of 32 must be rejected explicitly, + // not silently zero-filled the way [32]byte unmarshal would. + data, err := json.Marshal(map[string]interface{}{ + "depositFundingTxHash": []int{0x01, 0x02, 0x03}, + "depositFundingOutputIndex": 0, + "requestNonce": 1, + "anchorTxFee": 100, + }) + if err != nil { + t.Fatal(err) + } + err = (&ReservationAnchorProposal{}).Unmarshal(data) + if err == nil || err.Error() != "invalid deposit funding transaction hash length: [3]" { + t.Fatalf("expected wrong-length error, got [%v]", err) + } + }) + + t.Run("full-length deposit funding transaction hash round-trips", func(t *testing.T) { + data, err := (&ReservationAnchorProposal{ + DepositFundingTxHash: bitcoin.Hash{0x01}, + RequestNonce: 1, + AnchorTxFee: big.NewInt(100), + }).Marshal() + if err != nil { + t.Fatal(err) + } + var proposal ReservationAnchorProposal + if err := proposal.Unmarshal(data); err != nil { + t.Fatalf("expected no error, got [%v]", err) + } + if proposal.DepositFundingTxHash != (bitcoin.Hash{0x01}) { + t.Fatalf("unexpected hash: [%v]", proposal.DepositFundingTxHash) + } + }) +} + +func TestReservedRedemptionProposal_UnmarshalRejectsMalformedInput(t *testing.T) { + t.Run("truncated json", func(t *testing.T) { + if err := (&ReservedRedemptionProposal{}).Unmarshal([]byte(`{"reservationKey":`)); err == nil { + t.Fatal("expected error for truncated json") + } + }) + + t.Run("zero request nonce", func(t *testing.T) { + data, _ := json.Marshal(ReservedRedemptionProposal{ + ReservationKey: big.NewInt(12345), + RequestNonce: 0, + RedemptionTxFee: big.NewInt(100), + }) + if err := (&ReservedRedemptionProposal{}).Unmarshal(data); err == nil { + t.Fatal("expected error for zero request nonce") + } + }) + + t.Run("negative fee", func(t *testing.T) { + data, _ := json.Marshal(ReservedRedemptionProposal{ + ReservationKey: big.NewInt(12345), + RequestNonce: 1, + RedemptionTxFee: big.NewInt(-100), + }) + if err := (&ReservedRedemptionProposal{}).Unmarshal(data); err == nil { + t.Fatal("expected error for negative fee") + } + }) + t.Run("negative reservation key", func(t *testing.T) { + data, _ := json.Marshal(ReservedRedemptionProposal{ + ReservationKey: big.NewInt(-1), + RequestNonce: 1, + RedemptionTxFee: big.NewInt(100), + }) + if err := (&ReservedRedemptionProposal{}).Unmarshal(data); err == nil { + t.Fatal("expected error for negative reservation key") + } + }) + + t.Run("oversized reservation key", func(t *testing.T) { + data, _ := json.Marshal(ReservedRedemptionProposal{ + ReservationKey: new(big.Int).Lsh(big.NewInt(1), 256), // > 256 bits + RequestNonce: 1, + RedemptionTxFee: big.NewInt(100), + }) + if err := (&ReservedRedemptionProposal{}).Unmarshal(data); err == nil { + t.Fatal("expected error for oversized reservation key") + } + }) + t.Run("oversized fee", func(t *testing.T) { + data, _ := json.Marshal(ReservedRedemptionProposal{ + ReservationKey: big.NewInt(12345), + RequestNonce: 1, + RedemptionTxFee: new(big.Int).Lsh(big.NewInt(1), 64), + }) + if err := (&ReservedRedemptionProposal{}).Unmarshal(data); err == nil { + t.Fatal("expected error for oversized fee") + } + }) +} + +func TestReservationReanchorProposal_UnmarshalRejectsMalformedInput(t *testing.T) { + t.Run("truncated json", func(t *testing.T) { + if err := (&ReservationReanchorProposal{}).Unmarshal([]byte(`{"reservationKey":`)); err == nil { + t.Fatal("expected error for truncated json") + } + }) + + t.Run("zero request nonce", func(t *testing.T) { + data, _ := json.Marshal(ReservationReanchorProposal{ + ReservationKey: big.NewInt(12345), + RequestNonce: 0, + ReanchorTxFee: big.NewInt(100), + }) + if err := (&ReservationReanchorProposal{}).Unmarshal(data); err == nil { + t.Fatal("expected error for zero request nonce") + } + }) + + t.Run("negative fee", func(t *testing.T) { + data, _ := json.Marshal(ReservationReanchorProposal{ + ReservationKey: big.NewInt(12345), + RequestNonce: 1, + ReanchorTxFee: big.NewInt(-100), + }) + if err := (&ReservationReanchorProposal{}).Unmarshal(data); err == nil { + t.Fatal("expected error for negative fee") + } + }) + t.Run("negative reservation key", func(t *testing.T) { + data, _ := json.Marshal(ReservationReanchorProposal{ + ReservationKey: big.NewInt(-1), + RequestNonce: 1, + ReanchorTxFee: big.NewInt(100), + }) + if err := (&ReservationReanchorProposal{}).Unmarshal(data); err == nil { + t.Fatal("expected error for negative reservation key") + } + }) + + t.Run("oversized reservation key", func(t *testing.T) { + data, _ := json.Marshal(ReservationReanchorProposal{ + ReservationKey: new(big.Int).Lsh(big.NewInt(1), 256), // > 256 bits + RequestNonce: 1, + ReanchorTxFee: big.NewInt(100), + }) + if err := (&ReservationReanchorProposal{}).Unmarshal(data); err == nil { + t.Fatal("expected error for oversized reservation key") + } + }) + t.Run("oversized fee", func(t *testing.T) { + data, _ := json.Marshal(ReservationReanchorProposal{ + ReservationKey: big.NewInt(12345), + RequestNonce: 1, + ReanchorTxFee: new(big.Int).Lsh(big.NewInt(1), 64), + }) + if err := (&ReservationReanchorProposal{}).Unmarshal(data); err == nil { + t.Fatal("expected error for oversized fee") + } + }) + + t.Run("wrong-length target wallet public key hash", func(t *testing.T) { + data, err := json.Marshal(map[string]interface{}{ + "reservationKey": 12345, + "requestNonce": 1, + "targetWalletPublicKeyHash": []int{0x01, 0x02, 0x03}, + "reanchorTxFee": 100, + }) + if err != nil { + t.Fatal(err) + } + err = (&ReservationReanchorProposal{}).Unmarshal(data) + if err == nil || err.Error() != "invalid target wallet public key hash length: [3]" { + t.Fatalf("expected wrong-length error, got [%v]", err) + } + }) + + t.Run("full-length target wallet public key hash round-trips", func(t *testing.T) { + data, err := (&ReservationReanchorProposal{ + ReservationKey: big.NewInt(12345), + RequestNonce: 1, + TargetWalletPublicKeyHash: [20]byte{0x01}, + ReanchorTxFee: big.NewInt(100), + }).Marshal() + if err != nil { + t.Fatal(err) + } + var proposal ReservationReanchorProposal + if err := proposal.Unmarshal(data); err != nil { + t.Fatalf("expected no error, got [%v]", err) + } + if proposal.TargetWalletPublicKeyHash != ([20]byte{0x01}) { + t.Fatalf("unexpected hash: [%v]", proposal.TargetWalletPublicKeyHash) + } + }) +} + +func TestReservationDissolutionProposal_UnmarshalRejectsMalformedInput(t *testing.T) { + t.Run("truncated json", func(t *testing.T) { + if err := (&ReservationDissolutionProposal{}).Unmarshal([]byte(`{"reservationKey":`)); err == nil { + t.Fatal("expected error for truncated json") + } + }) + + t.Run("zero request nonce", func(t *testing.T) { + data, _ := json.Marshal(ReservationDissolutionProposal{ + ReservationKey: big.NewInt(12345), + RequestNonce: 0, + DissolutionTxFee: big.NewInt(100), + }) + if err := (&ReservationDissolutionProposal{}).Unmarshal(data); err == nil { + t.Fatal("expected error for zero request nonce") + } + }) + + t.Run("negative fee", func(t *testing.T) { + data, _ := json.Marshal(ReservationDissolutionProposal{ + ReservationKey: big.NewInt(12345), + RequestNonce: 1, + DissolutionTxFee: big.NewInt(-100), + }) + if err := (&ReservationDissolutionProposal{}).Unmarshal(data); err == nil { + t.Fatal("expected error for negative fee") + } + }) + t.Run("negative reservation key", func(t *testing.T) { + data, _ := json.Marshal(ReservationDissolutionProposal{ + ReservationKey: big.NewInt(-1), + RequestNonce: 1, + DissolutionTxFee: big.NewInt(100), + }) + if err := (&ReservationDissolutionProposal{}).Unmarshal(data); err == nil { + t.Fatal("expected error for negative reservation key") + } + }) + + t.Run("oversized reservation key", func(t *testing.T) { + data, _ := json.Marshal(ReservationDissolutionProposal{ + ReservationKey: new(big.Int).Lsh(big.NewInt(1), 256), // > 256 bits + RequestNonce: 1, + DissolutionTxFee: big.NewInt(100), + }) + if err := (&ReservationDissolutionProposal{}).Unmarshal(data); err == nil { + t.Fatal("expected error for oversized reservation key") + } + }) + t.Run("oversized fee", func(t *testing.T) { + data, _ := json.Marshal(ReservationDissolutionProposal{ + ReservationKey: big.NewInt(12345), + RequestNonce: 1, + DissolutionTxFee: new(big.Int).Lsh(big.NewInt(1), 64), + }) + if err := (&ReservationDissolutionProposal{}).Unmarshal(data); err == nil { + t.Fatal("expected error for oversized fee") + } + }) +} diff --git a/pkg/tbtc/parameters.go b/pkg/tbtc/parameters.go index 18a1074744..525d312669 100644 --- a/pkg/tbtc/parameters.go +++ b/pkg/tbtc/parameters.go @@ -1,6 +1,10 @@ package tbtc -import "math/big" +import ( + "math/big" + + "github.com/keep-network/keep-core/pkg/chain" +) // MovingFundsParameters holds the current value of the on-chain parameters // relevant to the moving funds and moved funds sweep processes. It replaces a @@ -51,3 +55,33 @@ type RedemptionParameters struct { TimeoutSlashingAmount *big.Int TimeoutNotifierRewardMultiplier uint32 } + +// ReservationParameters holds the current value of the on-chain parameters +// relevant to UTXO reservations. +type ReservationParameters struct { + // Vault is the address of the reservation vault. Deposits revealed with + // this vault address are treated as UTXO reservations. + Vault chain.Address + // MinAmount is the minimal anchor output amount in satoshi accepted for + // a reservation. A value of 0 means the check is disabled. + MinAmount uint64 + // TxMaxFee is the maximum transaction fee in satoshi for a single + // reservation lifecycle transaction. + TxMaxFee uint64 + // TermSeconds is the custody term length in seconds. + TermSeconds uint32 + // DissolutionDelay is the delay snapshotted after term expiry before a + // reservation becomes dissolvable. + DissolutionDelay uint32 + // MaxTotalAmount is the maximum total amount of all active reservations + // in satoshi. A value of 0 means the check is disabled. + MaxTotalAmount uint64 + // MaxReservationsPerWallet is the maximum number of reservations a + // wallet may custody. A value of 0 means the check is disabled. + MaxReservationsPerWallet uint32 + // ActionTimeout is the timeout for reservation actions in seconds. + ActionTimeout uint32 + // RenewalWindowSeconds is the period before expiry during which a + // reservation can be renewed. + RenewalWindowSeconds uint32 +} diff --git a/pkg/tbtc/reservation.go b/pkg/tbtc/reservation.go new file mode 100644 index 0000000000..0f99c98dac --- /dev/null +++ b/pkg/tbtc/reservation.go @@ -0,0 +1,651 @@ +package tbtc + +import ( + "crypto/ecdsa" + "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. In other words, this is the + // worst-case time for a reservation anchor during which the wallet is busy + // and cannot take another action. The value of 600 blocks is roughly 2 + // hours, assuming 12 seconds per block. + reservationAnchorProposalValidityBlocks = 600 + // reservedRedemptionProposalValidityBlocks determines the reserved + // redemption proposal validity time expressed in blocks. In other words, + // this is the worst-case time for a reserved redemption during which the + // wallet is busy and cannot take another action. The value of 600 blocks + // is roughly 2 hours, assuming 12 seconds per block. + reservedRedemptionProposalValidityBlocks = 600 + // reservationReanchorProposalValidityBlocks determines the reservation + // re-anchor proposal validity time expressed in blocks. In other words, + // this is the worst-case time for a reservation re-anchor during which the + // wallet is busy and cannot take another action. The value of 600 blocks + // is roughly 2 hours, assuming 12 seconds per block. + reservationReanchorProposalValidityBlocks = 600 + // reservationDissolutionProposalValidityBlocks determines the reservation + // dissolution proposal validity time expressed in blocks. In other words, + // this is the worst-case time for a reservation dissolution during which + // the wallet is busy and cannot take another action. The value of 600 + // blocks is roughly 2 hours, assuming 12 seconds per block. + reservationDissolutionProposalValidityBlocks = 600 +) + +// 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. Dissolution is the sole +// terminal exception: it returns the anchor value to the wallet's pooled +// main UTXO, ending the reservation. +type Reservation struct { + // Owner is the reservation owner's address on the host chain. + Owner chain.Address + // 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 // No action in flight. + ReservationActionTypeAcceptance // The anchor/acceptance action. + ReservationActionTypeRedemption // A reserved redemption. + ReservationActionTypeReanchor // A wallet-migration re-anchor. + ReservationActionTypeDissolution // The terminal dissolution. +) + +// ReservationActionState represents the settlement state of a reservation +// action generation (models the anticipated two-phase authorize-then-prove +// redesign tracked in tbtc-v2#1088's own review findings, not the +// currently-reviewed single-phase contract; the enum values may change +// before Ethereum bindings are implemented). +type ReservationActionState uint8 + +const ( + ReservationActionStateUnknown ReservationActionState = iota // No action generation exists yet. + ReservationActionStatePending // Awaiting settlement. + ReservationActionStateSettled // Confirmed on-chain. + ReservationActionStateTimedOut // Expired without settlement. + ReservationActionStateVetoed // Rejected. + ReservationActionStateSuperseded // Replaced by a later generation. +) + +// ReservationAction represents one nonce-bound generation of a reservation +// action. All authorization data used to construct and settle the action is +// snapshotted when the generation is requested. +// +// The nonce-keyed lookup (see chain.go's GetReservationAction) and the +// terminal ReservationActionState values below model the anticipated +// two-phase authorize-then-prove redesign tracked in tbtc-v2#1088's own +// review findings, not the currently-reviewed single-phase contract; this +// interface may change before Ethereum bindings are implemented. +type ReservationAction struct { + // TargetWalletPublicKeyHash is the wallet an acceptance, re-anchor, or + // dissolution output must pay to. It is zero for redemptions. + TargetWalletPublicKeyHash [20]byte + // RequestedAt is the UNIX timestamp the action was requested at. + RequestedAt uint32 + // TimeoutAt is the UNIX timestamp at or after which the action times 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 - it + // tracks the current anchor value being acted on, not Reservation.MintedAmount's + // gross minted claim. + Amount uint64 + // RedeemerOutputScriptHash is the keccak256 hash of the length-prefixed + // output script authorized for a redemption. + 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 +} + +// 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 `json:"depositFundingTxHash"` + // DepositFundingOutputIndex is the funding output index of the reserved + // deposit to anchor. + DepositFundingOutputIndex uint32 `json:"depositFundingOutputIndex"` + // RequestNonce is the anchor authorization generation being executed. + RequestNonce uint64 `json:"requestNonce"` + // AnchorTxFee is the proposed BTC fee for the anchor transaction. + AnchorTxFee *big.Int `json:"anchorTxFee"` +} + +func (rap *ReservationAnchorProposal) ActionType() WalletActionType { + return ActionReservationAnchor +} + +func (rap *ReservationAnchorProposal) ValidityBlocks() uint64 { + return reservationAnchorProposalValidityBlocks +} + +// 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 `json:"reservationKey"` + // RequestNonce is the redemption request generation being executed. + RequestNonce uint64 `json:"requestNonce"` + // RedeemerOutputScript is the Bitcoin output script the redemption pays to. + RedeemerOutputScript bitcoin.Script `json:"redeemerOutputScript"` + // RedemptionTxFee is the proposed BTC fee for the reserved redemption + // transaction. + RedemptionTxFee *big.Int `json:"redemptionTxFee"` +} + +func (rrp *ReservedRedemptionProposal) ActionType() WalletActionType { + return ActionReservedRedemption +} + +func (rrp *ReservedRedemptionProposal) ValidityBlocks() uint64 { + return reservedRedemptionProposalValidityBlocks +} + +// 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 `json:"reservationKey"` + // RequestNonce is the re-anchor authorization generation being executed. + RequestNonce uint64 `json:"requestNonce"` + // TargetWalletPublicKeyHash is the 20-byte public key hash of the wallet + // receiving the anchor. + TargetWalletPublicKeyHash [20]byte `json:"targetWalletPublicKeyHash"` + // ReanchorTxFee is the proposed BTC fee for the re-anchor transaction. + ReanchorTxFee *big.Int `json:"reanchorTxFee"` +} + +func (rrp *ReservationReanchorProposal) ActionType() WalletActionType { + return ActionReservationReanchor +} + +func (rrp *ReservationReanchorProposal) ValidityBlocks() uint64 { + return reservationReanchorProposalValidityBlocks +} + +// 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 `json:"reservationKey"` + // RequestNonce is the dissolution authorization generation being executed. + RequestNonce uint64 `json:"requestNonce"` + // DissolutionTxFee is the proposed BTC fee for the dissolution + // transaction. + DissolutionTxFee *big.Int `json:"dissolutionTxFee"` +} + +func (rdp *ReservationDissolutionProposal) ActionType() WalletActionType { + return ActionReservationDissolution +} + +func (rdp *ReservationDissolutionProposal) ValidityBlocks() uint64 { + return reservationDissolutionProposalValidityBlocks +} + +func requireReservationAction( + action *ReservationAction, + expectedType ReservationActionType, + now uint32, + label string, +) error { + if action == nil { + return fmt.Errorf("reservation action is required") + } + if action.ActionType != expectedType { + return fmt.Errorf("reservation action is not %s", label) + } + if action.State != ReservationActionStatePending { + return fmt.Errorf("reservation action has already been settled") + } + if action.TimeoutAt == 0 { + return fmt.Errorf("reservation action timeout is required") + } + if now >= action.TimeoutAt { + return fmt.Errorf("reservation action has timed out") + } + return nil +} + +func requireValidActionFee(fee int64, maxFee uint64) error { + if fee <= 0 { + return fmt.Errorf("transaction fee must be positive") + } + if uint64(fee) > maxFee { + return fmt.Errorf("transaction fee exceeds the action fee limit") + } + return nil +} + +// assembleReservationAnchorTransaction constructs an unsigned reservation +// anchor transaction: a 1-input-1-output spend of the given reserved deposit +// into a fresh output controlled by the given wallet. The anchor mirrors the +// 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, + walletPublicKey *ecdsa.PublicKey, + action *ReservationAction, + reservationMinAmount uint64, + fee int64, + now uint32, +) (*bitcoin.TransactionBuilder, error) { + if walletPublicKey == nil { + return nil, fmt.Errorf("wallet public key is required") + } + walletPublicKeyHash := bitcoin.PublicKeyHash(walletPublicKey) + if err := requireReservationAction(action, ReservationActionTypeAcceptance, now, "an acceptance"); err != nil { + return nil, err + } + if action.TargetWalletPublicKeyHash == [20]byte{} { + return nil, fmt.Errorf("target wallet public key hash is required") + } + if action.TargetWalletPublicKeyHash != walletPublicKeyHash { + return nil, fmt.Errorf("acceptance action targets a different wallet") + } + if err := requireValidActionFee(fee, action.TxMaxFee); err != nil { + return nil, err + } + 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") + } + if anchorValue < int64(reservationMinAmount) { + return nil, fmt.Errorf("anchor value is below the reservation minimum amount") + } + + 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 1-input-1-output +// spend of the anchor UTXO to the redeemer output script. +func assembleReservedRedemptionTransaction( + bitcoinChain bitcoin.Chain, + bridgeChain interface { + ComputeReservationRedeemerOutputScriptHash(redeemerOutputScript bitcoin.Script) ([32]byte, error) + }, + anchorUtxo *bitcoin.UnspentTransactionOutput, + redeemerOutputScript bitcoin.Script, + action *ReservationAction, + fee int64, + now uint32, + expectedAnchorOutpoint *bitcoin.TransactionOutpoint, +) (*bitcoin.TransactionBuilder, error) { + if bridgeChain == nil { + return nil, fmt.Errorf("bridge chain is required") + } + if anchorUtxo == nil { + return nil, fmt.Errorf("anchor UTXO is required") + } + if expectedAnchorOutpoint == nil { + return nil, fmt.Errorf("expected anchor outpoint is required") + } + if anchorUtxo.Outpoint == nil { + return nil, fmt.Errorf("anchor UTXO outpoint is required") + } + if *anchorUtxo.Outpoint != *expectedAnchorOutpoint { + return nil, fmt.Errorf("anchor UTXO outpoint does not match the action snapshot") + } + if len(redeemerOutputScript) == 0 { + return nil, fmt.Errorf("redeemer output script is required") + } + if err := requireReservationAction(action, ReservationActionTypeRedemption, now, "a redemption"); err != nil { + return nil, err + } + 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 err := requireValidActionFee(fee, action.TxMaxFee); err != nil { + return nil, err + } + + redeemerOutputScriptHash, err := bridgeChain.ComputeReservationRedeemerOutputScriptHash(redeemerOutputScript) + if err != nil { + return nil, fmt.Errorf("cannot compute redeemer output script hash: [%v]", err) + } + + if redeemerOutputScriptHash != action.RedeemerOutputScriptHash { + return nil, fmt.Errorf("redeemer output script is not authorized") + } + + 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, + ) + } + + redemptionValue := anchorUtxo.Value - fee + if redemptionValue <= 0 { + return nil, fmt.Errorf( + "transaction fee exceeds the redemption amount", + ) + } + + 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, + action *ReservationAction, + reservationMinAmount uint64, + fee int64, + now uint32, + expectedAnchorOutpoint *bitcoin.TransactionOutpoint, +) (*bitcoin.TransactionBuilder, error) { + if anchorUtxo == nil { + return nil, fmt.Errorf("anchor UTXO is required") + } + if expectedAnchorOutpoint == nil { + return nil, fmt.Errorf("expected anchor outpoint is required") + } + if anchorUtxo.Outpoint == nil { + return nil, fmt.Errorf("anchor UTXO outpoint is required") + } + if *anchorUtxo.Outpoint != *expectedAnchorOutpoint { + return nil, fmt.Errorf("anchor UTXO outpoint does not match the action snapshot") + } + if err := requireReservationAction(action, ReservationActionTypeReanchor, now, "a re-anchor"); err != nil { + return nil, err + } + if action.TargetWalletPublicKeyHash != targetWalletPublicKeyHash { + return nil, fmt.Errorf("reanchor action targets a different wallet") + } + if action.TargetWalletPublicKeyHash == [20]byte{} { + return nil, fmt.Errorf("target wallet public key hash is required") + } + if err := requireValidActionFee(fee, action.TxMaxFee); err != nil { + return nil, err + } + + if anchorUtxo.Value <= 0 { + return nil, fmt.Errorf("anchor UTXO value must be positive") + } + if action.Amount != uint64(anchorUtxo.Value) { + return nil, fmt.Errorf("reanchor action amount does not match the anchor value") + } + + builder := bitcoin.NewTransactionBuilder(bitcoinChain) + + err := builder.AddPublicKeyHashInput(anchorUtxo) + 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") + } + if reanchorValue < int64(reservationMinAmount) { + return nil, fmt.Errorf("re-anchor value is below the reservation minimum amount") + } + + 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. +// +// The Bridge's dissolution proof accepts either input order (see +// Reservation.sol's dissolution-input verification, which matches by +// outpoint hash, not position); this assembler's anchor-first ordering +// is one of the two accepted orders. +func assembleReservationDissolutionTransaction( + bitcoinChain bitcoin.Chain, + bridgeChain interface { + ComputeMainUtxoHash(mainUtxo *bitcoin.UnspentTransactionOutput) [32]byte + }, + anchorUtxo *bitcoin.UnspentTransactionOutput, + walletMainUtxo *bitcoin.UnspentTransactionOutput, + walletPublicKey *ecdsa.PublicKey, + action *ReservationAction, + fee int64, + now uint32, + expectedAnchorOutpoint *bitcoin.TransactionOutpoint, +) (*bitcoin.TransactionBuilder, error) { + if walletPublicKey == nil { + return nil, fmt.Errorf("wallet public key is required") + } + walletPublicKeyHash := bitcoin.PublicKeyHash(walletPublicKey) + if bridgeChain == nil { + return nil, fmt.Errorf("bridge chain is required") + } + if anchorUtxo == nil { + return nil, fmt.Errorf("anchor UTXO is required") + } + if expectedAnchorOutpoint == nil { + return nil, fmt.Errorf("expected anchor outpoint is required") + } + if anchorUtxo.Outpoint == nil { + return nil, fmt.Errorf("anchor UTXO outpoint is required") + } + if *anchorUtxo.Outpoint != *expectedAnchorOutpoint { + return nil, fmt.Errorf("anchor UTXO outpoint does not match the action snapshot") + } + if err := requireReservationAction(action, ReservationActionTypeDissolution, now, "a dissolution"); err != nil { + return nil, err + } + if action.TargetWalletPublicKeyHash == [20]byte{} { + return nil, fmt.Errorf("target wallet public key hash is required") + } + if action.TargetWalletPublicKeyHash != walletPublicKeyHash { + return nil, fmt.Errorf("dissolution action targets a different wallet") + } + if anchorUtxo.Value <= 0 { + return nil, fmt.Errorf("anchor UTXO value must be positive") + } + if action.Amount != uint64(anchorUtxo.Value) { + return nil, fmt.Errorf( + "dissolution action amount does not match the anchor value", + ) + } + if err := requireValidActionFee(fee, action.TxMaxFee); err != nil { + return nil, err + } + + mainUtxoExpected := action.ExpectedMainUtxoHash != [32]byte{} + if mainUtxoExpected { + 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", + ) + } + } else if walletMainUtxo != nil { + return nil, fmt.Errorf("wallet main UTXO must not be provided when the dissolution action has no expected main UTXO snapshot") + } + + builder := bitcoin.NewTransactionBuilder(bitcoinChain) + + // The anchor outpoint is added first (one of the two input orders the + // Bridge's dissolution proof accepts; see the function doc comment). + err := builder.AddPublicKeyHashInput(anchorUtxo) + if err != nil { + return nil, fmt.Errorf( + "cannot add input pointing to anchor UTXO: [%v]", + err, + ) + } + + if mainUtxoExpected { + err = builder.AddPublicKeyHashInput(walletMainUtxo) + if err != nil { + return nil, fmt.Errorf( + "cannot add input pointing to wallet main UTXO: [%v]", + err, + ) + } + } + + dissolutionValue := builder.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..1ab561dd10 --- /dev/null +++ b/pkg/tbtc/reservation_test.go @@ -0,0 +1,1782 @@ +package tbtc + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "encoding/json" + "math/big" + "reflect" + "testing" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/chain" +) + +func 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, + RedeemerOutputScript: bitcoin.Script{0x00, 0x14, 0x02}, + RedemptionTxFee: big.NewInt(1600), + } + reanchorProposal := &ReservationReanchorProposal{ + ReservationKey: big.NewInt(54321), + RequestNonce: 3, + TargetWalletPublicKeyHash: [20]byte{0xaa, 0xbb}, + ReanchorTxFee: big.NewInt(1700), + } + dissolutionProposal := &ReservationDissolutionProposal{ + ReservationKey: big.NewInt(99999), + RequestNonce: 4, + 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) { + marshalJSON := func(t *testing.T, v interface{}) string { + t.Helper() + bytes, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return string(bytes) + } + + tests := map[string]struct { + actionType WalletActionType + payload func(t *testing.T) string + expectedError string + }{ + "anchor null payload": { + actionType: ActionReservationAnchor, + payload: func(t *testing.T) string { return "null" }, + expectedError: "cannot unmarshal proposal payload: [deposit funding transaction hash is required]", + }, + "anchor missing deposit funding tx hash": { + actionType: ActionReservationAnchor, + payload: func(t *testing.T) string { + return marshalJSON(t, &ReservationAnchorProposal{ + RequestNonce: 1, + AnchorTxFee: big.NewInt(1500), + }) + }, + expectedError: "cannot unmarshal proposal payload: [deposit funding transaction hash is required]", + }, + "anchor missing nonce": { + actionType: ActionReservationAnchor, + payload: func(t *testing.T) string { + return marshalJSON(t, &ReservationAnchorProposal{ + DepositFundingTxHash: bitcoin.Hash{0x01}, + AnchorTxFee: big.NewInt(1500), + }) + }, + expectedError: "cannot unmarshal proposal payload: [request nonce is required]", + }, + "anchor missing fee": { + actionType: ActionReservationAnchor, + payload: func(t *testing.T) string { + return marshalJSON(t, &ReservationAnchorProposal{ + DepositFundingTxHash: bitcoin.Hash{0x01}, + RequestNonce: 1, + }) + }, + expectedError: "cannot unmarshal proposal payload: [anchor transaction fee is required]", + }, + "reserved redemption null payload": { + actionType: ActionReservedRedemption, + payload: func(t *testing.T) string { return "null" }, + expectedError: "cannot unmarshal proposal payload: [reservation key is required]", + }, + "reserved redemption missing nonce": { + actionType: ActionReservedRedemption, + payload: func(t *testing.T) string { + return marshalJSON(t, &ReservedRedemptionProposal{ + ReservationKey: big.NewInt(12345), + RedeemerOutputScript: bitcoin.Script{0x00, 0x14, 0x02}, + RedemptionTxFee: big.NewInt(1600), + }) + }, + expectedError: "cannot unmarshal proposal payload: [request nonce is required]", + }, + "reserved redemption missing redeemer output script": { + actionType: ActionReservedRedemption, + payload: func(t *testing.T) string { + return marshalJSON(t, &ReservedRedemptionProposal{ + ReservationKey: big.NewInt(12345), + RequestNonce: 2, + RedemptionTxFee: big.NewInt(1600), + }) + }, + expectedError: "cannot unmarshal proposal payload: [redeemer output script is required]", + }, + "reserved redemption missing fee": { + actionType: ActionReservedRedemption, + payload: func(t *testing.T) string { + return marshalJSON(t, &ReservedRedemptionProposal{ + ReservationKey: big.NewInt(12345), + RequestNonce: 2, + RedeemerOutputScript: bitcoin.Script{0x00, 0x14, 0x02}, + }) + }, + expectedError: "cannot unmarshal proposal payload: [redemption transaction fee is required]", + }, + "re-anchor null payload": { + actionType: ActionReservationReanchor, + payload: func(t *testing.T) string { return "null" }, + expectedError: "cannot unmarshal proposal payload: [reservation key is required]", + }, + "re-anchor missing nonce": { + actionType: ActionReservationReanchor, + payload: func(t *testing.T) string { + return marshalJSON(t, &ReservationReanchorProposal{ + ReservationKey: big.NewInt(54321), + TargetWalletPublicKeyHash: [20]byte{0xaa, 0xbb}, + ReanchorTxFee: big.NewInt(1700), + }) + }, + expectedError: "cannot unmarshal proposal payload: [request nonce is required]", + }, + "re-anchor missing target wallet public key hash": { + actionType: ActionReservationReanchor, + payload: func(t *testing.T) string { + return marshalJSON(t, &ReservationReanchorProposal{ + ReservationKey: big.NewInt(54321), + RequestNonce: 3, + ReanchorTxFee: big.NewInt(1700), + }) + }, + expectedError: "cannot unmarshal proposal payload: [target wallet public key hash is required]", + }, + "re-anchor missing fee": { + actionType: ActionReservationReanchor, + payload: func(t *testing.T) string { + return marshalJSON(t, &ReservationReanchorProposal{ + ReservationKey: big.NewInt(54321), + RequestNonce: 3, + TargetWalletPublicKeyHash: [20]byte{0xaa, 0xbb}, + }) + }, + expectedError: "cannot unmarshal proposal payload: [re-anchor transaction fee is required]", + }, + "dissolution null payload": { + actionType: ActionReservationDissolution, + payload: func(t *testing.T) string { return "null" }, + expectedError: "cannot unmarshal proposal payload: [reservation key is required]", + }, + "dissolution missing nonce": { + actionType: ActionReservationDissolution, + payload: func(t *testing.T) string { + return marshalJSON(t, &ReservationDissolutionProposal{ + ReservationKey: big.NewInt(99999), + DissolutionTxFee: big.NewInt(1800), + }) + }, + expectedError: "cannot unmarshal proposal payload: [request nonce is required]", + }, + "dissolution missing fee": { + actionType: ActionReservationDissolution, + payload: func(t *testing.T) string { + return marshalJSON(t, &ReservationDissolutionProposal{ + ReservationKey: big.NewInt(99999), + RequestNonce: 4, + }) + }, + expectedError: "cannot unmarshal proposal payload: [dissolution transaction fee is required]", + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + _, err := unmarshalCoordinationProposal( + uint32(test.actionType), + []byte(test.payload(t)), + ) + if err == nil || err.Error() != test.expectedError { + t.Errorf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + test.expectedError, + err, + ) + } + }) + } +} + +func TestRequireReservationAction_Timeout(t *testing.T) { + tests := map[string]struct { + action *ReservationAction + now uint32 + expectedError string + }{ + "zero TimeoutAt is rejected as malformed": { + action: &ReservationAction{ + ActionType: ReservationActionTypeAcceptance, + State: ReservationActionStatePending, + TimeoutAt: 0, + }, + now: 50, + expectedError: "reservation action timeout is required", + }, + "now at or after TimeoutAt has timed out": { + action: &ReservationAction{ + ActionType: ReservationActionTypeAcceptance, + State: ReservationActionStatePending, + TimeoutAt: 100, + }, + now: 100, + expectedError: "reservation action has timed out", + }, + "now past TimeoutAt has timed out": { + action: &ReservationAction{ + ActionType: ReservationActionTypeAcceptance, + State: ReservationActionStatePending, + TimeoutAt: 100, + }, + now: 150, + expectedError: "reservation action has timed out", + }, + "now before TimeoutAt is not timed out": { + action: &ReservationAction{ + ActionType: ReservationActionTypeAcceptance, + State: ReservationActionStatePending, + TimeoutAt: 100, + }, + now: 99, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + err := requireReservationAction( + test.action, + ReservationActionTypeAcceptance, + test.now, + "an acceptance", + ) + if test.expectedError != "" { + if err == nil || err.Error() != test.expectedError { + t.Fatalf("expected error [%s], got [%v]", test.expectedError, err) + } + return + } + if err != nil { + t.Fatalf("expected no error, got [%v]", err) + } + }) + } +} + +func fundUtxo( + t *testing.T, + bitcoinChain bitcoin.Chain, + script bitcoin.Script, + values ...int64, +) []*bitcoin.UnspentTransactionOutput { + t.Helper() + + outputs := make([]*bitcoin.TransactionOutput, len(values)) + for i, value := range values { + outputs[i] = &bitcoin.TransactionOutput{ + Value: value, + PublicKeyScript: script, + } + } + + fundingTransaction := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x01}, + OutputIndex: 0, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: outputs, + } + if err := bitcoinChain.BroadcastTransaction(fundingTransaction); err != nil { + t.Fatal(err) + } + + utxos := make([]*bitcoin.UnspentTransactionOutput, len(values)) + for i, value := range values { + utxos[i] = &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTransaction.Hash(), + OutputIndex: uint32(i), + }, + Value: value, + } + } + return utxos +} + +func TestAssembleReservedRedemptionTransaction(t *testing.T) { + bitcoinChain := newLocalBitcoinChain() + bridgeChain := Connect() + privateKeyValue := big.NewInt(100) + wallet := generateWallet(privateKeyValue) + walletPublicKeyHash := bitcoin.PublicKeyHash(wallet.publicKey) + walletScript, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + + redeemerScript, err := bitcoin.PayToWitnessPublicKeyHash([20]byte{0x01}) + if err != nil { + t.Fatal(err) + } + + anchorUtxo := fundUtxo(t, bitcoinChain, walletScript, 100000)[0] + + redeemerOutputScriptHash, err := bridgeChain.ComputeReservationRedeemerOutputScriptHash( + redeemerScript, + ) + if err != nil { + t.Fatal(err) + } + + tests := map[string]struct { + action *ReservationAction + expectedOutputs []*bitcoin.TransactionOutput + fee int64 + expectedError string + }{ + "whole redemption": { + action: &ReservationAction{ + TxMaxFee: 2000, + ActionType: ReservationActionTypeRedemption, + State: ReservationActionStatePending, + TimeoutAt: 1000, + Amount: 100000, + RedeemerOutputScriptHash: redeemerOutputScriptHash, + }, + expectedOutputs: []*bitcoin.TransactionOutput{ + { + Value: 98500, + PublicKeyScript: redeemerScript, + }, + }, + fee: 1500, + }, + "fee exceeds redemption amount": { + action: &ReservationAction{ + TxMaxFee: 150000, + ActionType: ReservationActionTypeRedemption, + State: ReservationActionStatePending, + TimeoutAt: 1000, + Amount: 100000, + RedeemerOutputScriptHash: redeemerOutputScriptHash, + }, + fee: 100000, + expectedError: "transaction fee exceeds the redemption amount", + }, + "fee must be positive": { + action: &ReservationAction{ + TxMaxFee: 2000, + ActionType: ReservationActionTypeRedemption, + State: ReservationActionStatePending, + TimeoutAt: 1000, + Amount: 100000, + RedeemerOutputScriptHash: redeemerOutputScriptHash, + }, + fee: 0, + expectedError: "transaction fee must be positive", + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + builder, err := assembleReservedRedemptionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + redeemerScript, + test.action, + test.fee, + 0, + anchorUtxo.Outpoint, + ) + + if test.expectedError != "" { + if err == nil || err.Error() != test.expectedError { + t.Fatalf("expected error: [%s], got: [%v]", test.expectedError, err) + } + return + } + + if err != nil { + t.Fatal(err) + } + + 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, + ) + } + }) + } + t.Run("anchor outpoint mismatch", func(t *testing.T) { + _, err := assembleReservedRedemptionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + redeemerScript, + &ReservationAction{ + TxMaxFee: 2000, + ActionType: ReservationActionTypeRedemption, + State: ReservationActionStatePending, + Amount: 100000, + RedeemerOutputScriptHash: redeemerOutputScriptHash, + }, + 1500, + 50, + &bitcoin.TransactionOutpoint{TransactionHash: bitcoin.Hash{0x01}}, + ) + if err == nil || err.Error() != "anchor UTXO outpoint does not match the action snapshot" { + t.Fatalf("expected error [anchor UTXO outpoint does not match the action snapshot], got [%v]", err) + } + }) + + t.Run("nil anchor UTXO guard", func(t *testing.T) { + _, err := assembleReservedRedemptionTransaction( + bitcoinChain, + bridgeChain, + nil, + redeemerScript, + &ReservationAction{ + TxMaxFee: 2000, + ActionType: ReservationActionTypeRedemption, + State: ReservationActionStatePending, + Amount: 100000, + RedeemerOutputScriptHash: redeemerOutputScriptHash, + }, + 1500, + 50, + anchorUtxo.Outpoint, + ) + if err == nil || err.Error() != "anchor UTXO is required" { + t.Fatalf("expected error [anchor UTXO is required], got [%v]", err) + } + }) +} + +func TestAssembleReservationDissolutionTransaction(t *testing.T) { + 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) + } + + utxos := fundUtxo(t, bitcoinChain, walletScript, 100000, 200000) + anchorUtxo := utxos[0] + walletMainUtxo := utxos[1] + + baseAction := ReservationAction{ + TargetWalletPublicKeyHash: walletPublicKeyHash, + TxMaxFee: 2000, + ActionType: ReservationActionTypeDissolution, + State: ReservationActionStatePending, + TimeoutAt: 100, + Amount: 100000, + } + + tests := map[string]struct { + action *ReservationAction + expectedInputUtxos []*bitcoin.UnspentTransactionOutput + walletMainUtxo *bitcoin.UnspentTransactionOutput + expectedOutputValue int64 + expectedError string + }{ + "snapshotted main UTXO": { + action: func() *ReservationAction { + action := baseAction + action.ExpectedMainUtxoHash = bridgeChain.ComputeMainUtxoHash( + walletMainUtxo, + ) + return &action + }(), + expectedInputUtxos: []*bitcoin.UnspentTransactionOutput{ + anchorUtxo, + walletMainUtxo, + }, + walletMainUtxo: walletMainUtxo, + expectedOutputValue: 298500, + }, + "no-main-UTXO snapshot with newly current main UTXO": { + action: &baseAction, + walletMainUtxo: walletMainUtxo, + expectedError: "wallet main UTXO must not be provided when the dissolution action has no expected main UTXO snapshot", + }, + "mismatched action amount": { + action: func() *ReservationAction { + action := baseAction + action.Amount = 99999 + return &action + }(), + walletMainUtxo: walletMainUtxo, + expectedError: "dissolution action amount does not match the anchor value", + }, + "mismatched target wallet": { + action: func() *ReservationAction { + action := baseAction + action.TargetWalletPublicKeyHash = [20]byte{0x02} + return &action + }(), + walletMainUtxo: walletMainUtxo, + expectedError: "dissolution action targets a different wallet", + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + builder, err := assembleReservationDissolutionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + test.walletMainUtxo, + wallet.publicKey, + test.action, + 1500, + 0, + anchorUtxo.Outpoint, + ) + + if test.expectedError != "" { + if err == nil || err.Error() != test.expectedError { + t.Fatalf("expected error: [%s], got: [%v]", test.expectedError, err) + } + return + } + + if err != nil { + t.Fatal(err) + } + + 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, + ) + } + }) + } + t.Run("anchor outpoint mismatch", func(t *testing.T) { + _, err := assembleReservationDissolutionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + nil, + wallet.publicKey, + &baseAction, + 1500, + 50, + &bitcoin.TransactionOutpoint{TransactionHash: bitcoin.Hash{0x01}}, + ) + if err == nil || err.Error() != "anchor UTXO outpoint does not match the action snapshot" { + t.Fatalf("expected error [anchor UTXO outpoint does not match the action snapshot], got [%v]", err) + } + }) + t.Run("1-input dissolution", func(t *testing.T) { + builder, err := assembleReservationDissolutionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + nil, + wallet.publicKey, + &baseAction, + 1500, + 50, + anchorUtxo.Outpoint, + ) + if err != nil { + t.Fatal(err) + } + transaction := signReservationTransaction(t, builder, wallet.publicKey, privateKeyValue) + if len(transaction.Inputs) != 1 { + t.Fatalf("expected 1 input, got %v", len(transaction.Inputs)) + } + if transaction.Outputs[0].Value != 98500 { + t.Fatalf("expected output value 98500, got %v", transaction.Outputs[0].Value) + } + }) + + t.Run("nil anchor UTXO guard", func(t *testing.T) { + _, err := assembleReservationDissolutionTransaction( + bitcoinChain, + bridgeChain, + nil, + nil, + wallet.publicKey, + &baseAction, + 1500, + 50, + anchorUtxo.Outpoint, + ) + if err == nil || err.Error() != "anchor UTXO is required" { + t.Fatalf("expected error [anchor UTXO is required], got [%v]", err) + } + }) + + t.Run("wallet public key nil guard", func(t *testing.T) { + _, err := assembleReservationDissolutionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + nil, + nil, + &baseAction, + 1500, + 50, + anchorUtxo.Outpoint, + ) + if err == nil || err.Error() != "wallet public key is required" { + t.Fatalf("expected error [wallet public key is required], got [%v]", err) + } + }) +} + +func signReservationTransaction( + 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() + walletPrivateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + walletPublicKey := &walletPrivateKey.PublicKey + walletPublicKeyHash := bitcoin.PublicKeyHash(walletPublicKey) + redeemerScript := bitcoin.Script{0x00, 0x14, 0x02} + + walletScript, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + fundingTransaction := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x03}, + OutputIndex: 0, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 100000, + PublicKeyScript: walletScript, + }, + }, + } + if err := bitcoinChain.BroadcastTransaction(fundingTransaction); err != nil { + t.Fatal(err) + } + + anchorUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTransaction.Hash(), + OutputIndex: 0, + }, + Value: 100000, + } + redeemerOutputScriptHash, err := bridgeChain.ComputeReservationRedeemerOutputScriptHash( + redeemerScript, + ) + if err != nil { + t.Fatal(err) + } + redemptionAction := &ReservationAction{ + TxMaxFee: 2000, + ActionType: ReservationActionTypeRedemption, + State: ReservationActionStatePending, + TimeoutAt: 1000, + Amount: 100000, + RedeemerOutputScriptHash: redeemerOutputScriptHash, + } + dissolutionAction := &ReservationAction{ + TargetWalletPublicKeyHash: walletPublicKeyHash, + TxMaxFee: 2000, + ActionType: ReservationActionTypeDissolution, + State: ReservationActionStatePending, + TimeoutAt: 1000, + Amount: 100000, + } + + assertError := func(err error, expected string) { + if err == nil || err.Error() != expected { + t.Errorf("expected error [%v], got [%v]", expected, err) + } + } + + anchorAction := &ReservationAction{ + TargetWalletPublicKeyHash: walletPublicKeyHash, + ActionType: ReservationActionTypeAcceptance, + State: ReservationActionStatePending, + TimeoutAt: 100, + TxMaxFee: 2000, + } + _, err = assembleReservationAnchorTransaction( + bitcoinChain, + nil, + walletPublicKey, + anchorAction, + 0, + 1500, + 50, + ) + assertError(err, "deposit is required") + + mismatchedWalletAnchorAction := *anchorAction + mismatchedWalletAnchorAction.TargetWalletPublicKeyHash = [20]byte{0x02} + mismatchedWalletAnchorAction.TimeoutAt = 100 + _, err = assembleReservationAnchorTransaction( + bitcoinChain, + nil, + walletPublicKey, + &mismatchedWalletAnchorAction, + 0, + 1500, + 50, + ) + assertError(err, "acceptance action targets a different wallet") + + _, err = assembleReservationAnchorTransaction( + bitcoinChain, + nil, + walletPublicKey, + anchorAction, + 0, + 0, + 50, + ) + assertError(err, "transaction fee must be positive") + + _, err = assembleReservedRedemptionTransaction( + bitcoinChain, + bridgeChain, + nil, + redeemerScript, + redemptionAction, + 1500, + 50, + &bitcoin.TransactionOutpoint{}, + ) + assertError(err, "anchor UTXO is required") + + _, err = assembleReservedRedemptionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + bitcoin.Script{}, + redemptionAction, + 1500, + 50, + anchorUtxo.Outpoint, + ) + assertError(err, "redeemer output script is required") + + _, err = assembleReservedRedemptionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + redeemerScript, + nil, + 1500, + 50, + anchorUtxo.Outpoint, + ) + assertError(err, "reservation action is required") + + nonRedemptionAction := *redemptionAction + nonRedemptionAction.ActionType = ReservationActionTypeReanchor + _, err = assembleReservedRedemptionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + redeemerScript, + &nonRedemptionAction, + 1500, + 50, + anchorUtxo.Outpoint, + ) + assertError(err, "reservation action is not a redemption") + + nonPendingAction := *redemptionAction + nonPendingAction.State = ReservationActionStateTimedOut + _, err = assembleReservedRedemptionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + redeemerScript, + &nonPendingAction, + 1500, + 50, + anchorUtxo.Outpoint, + ) + assertError(err, "reservation action has already been settled") + + wrongScriptAction := *redemptionAction + wrongScriptAction.RedeemerOutputScriptHash = [32]byte{0x01} + _, err = assembleReservedRedemptionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + redeemerScript, + &wrongScriptAction, + 1500, + 50, + anchorUtxo.Outpoint, + ) + assertError(err, "redeemer output script is not authorized") + + zeroValueAnchorUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: anchorUtxo.Outpoint, + Value: 0, + } + _, err = assembleReservedRedemptionTransaction( + bitcoinChain, + bridgeChain, + zeroValueAnchorUtxo, + redeemerScript, + redemptionAction, + 1500, + 50, + zeroValueAnchorUtxo.Outpoint, + ) + assertError(err, "anchor UTXO value must be positive") + + zeroAmountAction := *redemptionAction + zeroAmountAction.Amount = 0 + _, err = assembleReservedRedemptionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + redeemerScript, + &zeroAmountAction, + 1500, + 50, + anchorUtxo.Outpoint, + ) + assertError(err, "redemption amount must be positive") + + wrongAmountAction := *redemptionAction + wrongAmountAction.Amount = 40000 + _, err = assembleReservedRedemptionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + redeemerScript, + &wrongAmountAction, + 1500, + 50, + anchorUtxo.Outpoint, + ) + assertError(err, "whole redemption amount must equal the anchor value") + + _, err = assembleReservedRedemptionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + redeemerScript, + redemptionAction, + 2500, + 50, + anchorUtxo.Outpoint, + ) + assertError(err, "transaction fee exceeds the action fee limit") + + _, err = assembleReservedRedemptionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + redeemerScript, + redemptionAction, + 0, + 50, + anchorUtxo.Outpoint, + ) + assertError(err, "transaction fee must be positive") + + _, err = assembleReservationReanchorTransaction( + bitcoinChain, + nil, + walletPublicKeyHash, + anchorAction, + 0, + 1500, + 50, + &bitcoin.TransactionOutpoint{}, + ) + assertError(err, "anchor UTXO is required") + + zeroFeeReanchorAction := &ReservationAction{ + TargetWalletPublicKeyHash: walletPublicKeyHash, + ActionType: ReservationActionTypeReanchor, + State: ReservationActionStatePending, + TimeoutAt: 1000, + TxMaxFee: 2000, + } + _, err = assembleReservationReanchorTransaction( + bitcoinChain, + anchorUtxo, + walletPublicKeyHash, + zeroFeeReanchorAction, + 0, + 0, + 0, + anchorUtxo.Outpoint, + ) + assertError(err, "transaction fee must be positive") + + _, err = assembleReservationDissolutionTransaction( + bitcoinChain, + bridgeChain, + nil, + nil, + walletPublicKey, + dissolutionAction, + 1500, + 50, + &bitcoin.TransactionOutpoint{}, + ) + assertError(err, "anchor UTXO is required") + + _, err = assembleReservationDissolutionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + nil, + walletPublicKey, + nil, + 1500, + 50, + anchorUtxo.Outpoint, + ) + assertError(err, "reservation action is required") + + invalidDissolutionAction := *dissolutionAction + invalidDissolutionAction.ActionType = ReservationActionTypeReanchor + _, err = assembleReservationDissolutionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + nil, + walletPublicKey, + &invalidDissolutionAction, + 1500, + 50, + anchorUtxo.Outpoint, + ) + assertError(err, "reservation action is not a dissolution") + + nonPendingDissolutionAction := *dissolutionAction + nonPendingDissolutionAction.State = ReservationActionStateTimedOut + _, err = assembleReservationDissolutionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + nil, + walletPublicKey, + &nonPendingDissolutionAction, + 1500, + 50, + anchorUtxo.Outpoint, + ) + assertError(err, "reservation action has already been settled") + + _, err = assembleReservationDissolutionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + nil, + walletPublicKey, + dissolutionAction, + 2500, + 0, + anchorUtxo.Outpoint, + ) + assertError(err, "transaction fee exceeds the action fee limit") + + _, err = assembleReservationDissolutionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + nil, + walletPublicKey, + dissolutionAction, + 0, + 0, + anchorUtxo.Outpoint, + ) + assertError(err, "transaction fee must be positive") + + highFeeLimitDissolutionAction := *dissolutionAction + highFeeLimitDissolutionAction.TxMaxFee = 150000 + _, err = assembleReservationDissolutionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + nil, + walletPublicKey, + &highFeeLimitDissolutionAction, + 150000, + 0, + anchorUtxo.Outpoint, + ) + assertError(err, "transaction fee exceeds the total inputs value") + + snapshottedMainUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x04}, + OutputIndex: 1, + }, + Value: 200000, + } + + _, err = assembleReservationDissolutionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + snapshottedMainUtxo, + walletPublicKey, + dissolutionAction, + 1500, + 0, + anchorUtxo.Outpoint, + ) + assertError( + err, + "wallet main UTXO must not be provided when the dissolution action has no expected main UTXO snapshot", + ) + + _, err = assembleReservationDissolutionTransaction( + bitcoinChain, + nil, + anchorUtxo, + nil, + walletPublicKey, + dissolutionAction, + 1500, + 0, + &bitcoin.TransactionOutpoint{}, + ) + assertError(err, "bridge chain is required") + + actionWithMainUtxo := *dissolutionAction + actionWithMainUtxo.ExpectedMainUtxoHash = bridgeChain.ComputeMainUtxoHash( + snapshottedMainUtxo, + ) + _, err = assembleReservationDissolutionTransaction( + bitcoinChain, + bridgeChain, + anchorUtxo, + nil, + walletPublicKey, + &actionWithMainUtxo, + 1500, + 0, + anchorUtxo.Outpoint, + ) + 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, + walletPublicKey, + &actionWithMainUtxo, + 1500, + 0, + anchorUtxo.Outpoint, + ) + assertError( + err, + "wallet main UTXO does not match the dissolution action snapshot", + ) +} +func TestAssembleReservationAnchorTransaction(t *testing.T) { + bitcoinChain := newLocalBitcoinChain() + walletPrivateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + walletPublicKey := &walletPrivateKey.PublicKey + walletPublicKeyHash := bitcoin.PublicKeyHash(walletPublicKey) + anchorAction := &ReservationAction{ + TargetWalletPublicKeyHash: walletPublicKeyHash, + ActionType: ReservationActionTypeAcceptance, + State: ReservationActionStatePending, + TimeoutAt: 1000, + TxMaxFee: 2000, + } + + deposit := &Deposit{ + Depositor: chain.Address("0x1111111111111111111111111111111111111111"), + WalletPublicKeyHash: walletPublicKeyHash, + } + depositScript, err := deposit.Script() + if err != nil { + t.Fatal(err) + } + depositScriptHash := sha256.Sum256(depositScript) + depositLockingScript, err := bitcoin.PayToWitnessScriptHash(depositScriptHash) + if err != nil { + t.Fatal(err) + } + + fundingTransaction := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x01}, + OutputIndex: 0, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 100000, + PublicKeyScript: depositLockingScript, + }, + }, + } + if err := bitcoinChain.BroadcastTransaction(fundingTransaction); err != nil { + t.Fatal(err) + } + + deposit.Utxo = &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTransaction.Hash(), + OutputIndex: 0, + }, + Value: 100000, + } + + builder, err := assembleReservationAnchorTransaction( + bitcoinChain, + deposit, + walletPublicKey, + anchorAction, + 0, + 1500, + 0, + ) + if err != nil { + t.Fatalf("expected no error, got: [%v]", err) + } + t.Run("wallet public key nil guard", func(t *testing.T) { + // Use a valid deposit for this test + _, err := assembleReservationAnchorTransaction( + bitcoinChain, + deposit, + nil, + anchorAction, + 0, + 1500, + 50, + ) + if err == nil || err.Error() != "wallet public key is required" { + t.Fatalf("expected error [wallet public key is required], got [%v]", err) + } + }) + + transaction := signReservationTransaction( + t, + builder, + walletPublicKey, + walletPrivateKey.D, + ) + + if len(transaction.Inputs) != 1 { + t.Fatalf("expected 1 input, got %v", len(transaction.Inputs)) + } + if !reflect.DeepEqual(transaction.Inputs[0].Outpoint, deposit.Utxo.Outpoint) { + t.Errorf( + "unexpected input outpoint\nexpected: [%+v]\nactual: [%+v]", + deposit.Utxo.Outpoint, + transaction.Inputs[0].Outpoint, + ) + } + + expectedAnchorValue := int64(100000 - 1500) + expectedScript, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + expectedOutputs := []*bitcoin.TransactionOutput{ + { + Value: expectedAnchorValue, + PublicKeyScript: expectedScript, + }, + } + if !reflect.DeepEqual(expectedOutputs, transaction.Outputs) { + t.Errorf( + "unexpected outputs\nexpected: [%+v]\nactual: [%+v]", + expectedOutputs, + transaction.Outputs, + ) + } + invalidTypeAction := *anchorAction + invalidTypeAction.ActionType = ReservationActionTypeRedemption + _, err = assembleReservationAnchorTransaction( + bitcoinChain, + deposit, + walletPublicKey, + &invalidTypeAction, + 0, + 1500, + 0, + ) + if err == nil || err.Error() != "reservation action is not an acceptance" { + t.Fatalf("expected error, got: [%v]", err) + } + + invalidStateAction := *anchorAction + invalidStateAction.State = ReservationActionStateTimedOut + _, err = assembleReservationAnchorTransaction( + bitcoinChain, + deposit, + walletPublicKey, + &invalidStateAction, + 0, + 1500, + 0, + ) + if err == nil || err.Error() != "reservation action has already been settled" { + t.Fatalf("expected error, got: [%v]", err) + } + _, err = assembleReservationAnchorTransaction( + bitcoinChain, + deposit, + walletPublicKey, + anchorAction, + 0, + 2500, + 0, + ) + if err == nil || err.Error() != "transaction fee exceeds the action fee limit" { + t.Fatalf("expected error, got: [%v]", err) + } + + _, err = assembleReservationAnchorTransaction( + bitcoinChain, + deposit, + walletPublicKey, + anchorAction, + 99500, + 1000, + 0, + ) + if err == nil || err.Error() != "anchor value is below the reservation minimum amount" { + t.Fatalf("expected error, got: [%v]", err) + } + + _, err = assembleReservationAnchorTransaction( + bitcoinChain, + deposit, + walletPublicKey, + nil, + 0, + 1500, + 0, + ) + if err == nil || err.Error() != "reservation action is required" { + t.Fatalf("expected error, got: [%v]", err) + } +} +func TestAssembleReservationReanchorTransaction(t *testing.T) { + bitcoinChain := newLocalBitcoinChain() + + privateKeyValue := big.NewInt(100) + wallet := generateWallet(privateKeyValue) + walletPublicKeyHash := bitcoin.PublicKeyHash(wallet.publicKey) + walletScript, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + + targetWalletPublicKeyHash := [20]byte{0x02} + anchorUtxo := fundUtxo(t, bitcoinChain, walletScript, 100000)[0] + + reanchorAction := &ReservationAction{ + ActionType: ReservationActionTypeReanchor, + State: ReservationActionStatePending, + TimeoutAt: 1000, + TxMaxFee: 2000, + TargetWalletPublicKeyHash: targetWalletPublicKeyHash, + Amount: uint64(anchorUtxo.Value), + } + + builder, err := assembleReservationReanchorTransaction( + bitcoinChain, + anchorUtxo, + targetWalletPublicKeyHash, + reanchorAction, + 0, + 1500, + 0, + anchorUtxo.Outpoint, + ) + if err != nil { + t.Fatalf("expected no error, got: [%v]", err) + } + transaction := signReservationTransaction( + t, + builder, + wallet.publicKey, + privateKeyValue, + ) + if transaction.Outputs[0].Value != 98500 { + t.Errorf("expected output value 98500, got: [%v]", transaction.Outputs[0].Value) + } + + invalidAction := *reanchorAction + invalidAction.ActionType = ReservationActionTypeAcceptance + _, err = assembleReservationReanchorTransaction( + bitcoinChain, + anchorUtxo, + targetWalletPublicKeyHash, + &invalidAction, + 0, + 1500, + 0, + anchorUtxo.Outpoint, + ) + if err == nil || err.Error() != "reservation action is not a re-anchor" { + t.Fatalf("expected error, got: [%v]", err) + } + + invalidStateAction := *reanchorAction + invalidStateAction.State = ReservationActionStateTimedOut + _, err = assembleReservationReanchorTransaction( + bitcoinChain, + anchorUtxo, + targetWalletPublicKeyHash, + &invalidStateAction, + 0, + 1500, + 0, + anchorUtxo.Outpoint, + ) + if err == nil || err.Error() != "reservation action has already been settled" { + t.Fatalf("expected error, got: [%v]", err) + } + + _, err = assembleReservationReanchorTransaction( + bitcoinChain, + anchorUtxo, + [20]byte{0x03}, + reanchorAction, + 0, + 1500, + 0, + anchorUtxo.Outpoint, + ) + if err == nil || err.Error() != "reanchor action targets a different wallet" { + t.Fatalf("expected error, got: [%v]", err) + } + + _, err = assembleReservationReanchorTransaction( + bitcoinChain, + anchorUtxo, + targetWalletPublicKeyHash, + reanchorAction, + 0, + 2500, + 0, + anchorUtxo.Outpoint, + ) + if err == nil || err.Error() != "transaction fee exceeds the action fee limit" { + t.Fatalf("expected error, got: [%v]", err) + } + + _, err = assembleReservationReanchorTransaction( + bitcoinChain, + anchorUtxo, + targetWalletPublicKeyHash, + reanchorAction, + 99500, + 1000, + 0, + anchorUtxo.Outpoint, + ) + if err == nil || err.Error() != "re-anchor value is below the reservation minimum amount" { + t.Fatalf("expected error, got: [%v]", err) + } + + _, err = assembleReservationReanchorTransaction( + bitcoinChain, + anchorUtxo, + targetWalletPublicKeyHash, + nil, + 0, + 1500, + 0, + anchorUtxo.Outpoint, + ) + if err == nil || err.Error() != "reservation action is required" { + t.Fatalf("expected error, got: [%v]", err) + } + + wrongOutpoint := &bitcoin.TransactionOutpoint{ + TransactionHash: anchorUtxo.Outpoint.TransactionHash, + OutputIndex: anchorUtxo.Outpoint.OutputIndex + 1, + } + _, err = assembleReservationReanchorTransaction( + bitcoinChain, + anchorUtxo, + targetWalletPublicKeyHash, + reanchorAction, + 0, + 1500, + 50, + wrongOutpoint, + ) + if err == nil || err.Error() != "anchor UTXO outpoint does not match the action snapshot" { + t.Fatalf("expected error, got: [%v]", err) + } + + t.Run("nil anchor UTXO guard", func(t *testing.T) { + _, err := assembleReservationReanchorTransaction( + bitcoinChain, + nil, + targetWalletPublicKeyHash, + reanchorAction, + 0, + 1500, + 50, + anchorUtxo.Outpoint, + ) + if err == nil || err.Error() != "anchor UTXO is required" { + t.Fatalf("expected error [anchor UTXO is required], got [%v]", err) + } + }) + + t.Run("target wallet public key hash zero guard", func(t *testing.T) { + _, err := assembleReservationReanchorTransaction( + bitcoinChain, + anchorUtxo, + [20]byte{}, + &ReservationAction{ + ActionType: ReservationActionTypeReanchor, + State: ReservationActionStatePending, + TimeoutAt: 1000, + TxMaxFee: 2000, + TargetWalletPublicKeyHash: [20]byte{}, + Amount: uint64(anchorUtxo.Value), + }, + 0, + 1500, + 50, + anchorUtxo.Outpoint, + ) + if err == nil || err.Error() != "target wallet public key hash is required" { + t.Fatalf("expected error [target wallet public key hash is required], got [%v]", err) + } + }) +} + +func TestAssembleReservationReanchorTransaction_AmountMismatch(t *testing.T) { + // Setup + bitcoinChain := newLocalBitcoinChain() + anchorUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x01}, + OutputIndex: 0, + }, + Value: 100000, + } + action := &ReservationAction{ + TargetWalletPublicKeyHash: [20]byte{0x01}, + ActionType: ReservationActionTypeReanchor, + State: ReservationActionStatePending, + TimeoutAt: 1000, + TxMaxFee: 2000, + Amount: 50000, // Mismatch + } + + // Execute + _, err := assembleReservationReanchorTransaction( + bitcoinChain, + anchorUtxo, + [20]byte{0x01}, + action, + 0, + 1000, + 0, + anchorUtxo.Outpoint, + ) + + // Assert + if err == nil || err.Error() != "reanchor action amount does not match the anchor value" { + t.Errorf("expected error [reanchor action amount does not match the anchor value], got [%v]", err) + } +} + +func TestAssembleReservationTransactions_BoundaryErrors(t *testing.T) { + bitcoinChain := newLocalBitcoinChain() + bridgeChain := Connect() + walletPrivateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + walletPublicKey := &walletPrivateKey.PublicKey + + walletPublicKeyHash := bitcoin.PublicKeyHash(walletPublicKey) + walletScript, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + anchorUtxo := fundUtxo(t, bitcoinChain, walletScript, 100000)[0] + + // 1. Anchor's deposit-value-exceeded + deposit := &Deposit{ + Depositor: chain.Address("0x1111111111111111111111111111111111111111"), + WalletPublicKeyHash: walletPublicKeyHash, + } + depositScript, err := deposit.Script() + if err != nil { + t.Fatal(err) + } + depositScriptHash := sha256.Sum256(depositScript) + depositLockingScript, err := bitcoin.PayToWitnessScriptHash(depositScriptHash) + if err != nil { + t.Fatal(err) + } + deposit.Utxo = fundUtxo(t, bitcoinChain, depositLockingScript, 1000)[0] + _, err = assembleReservationAnchorTransaction(bitcoinChain, deposit, walletPublicKey, &ReservationAction{TargetWalletPublicKeyHash: walletPublicKeyHash, ActionType: ReservationActionTypeAcceptance, State: ReservationActionStatePending, TimeoutAt: 1000, TxMaxFee: 2000}, 0, 2000, 0) + if err == nil || err.Error() != "transaction fee exceeds the deposit value" { + t.Errorf("expected error [transaction fee exceeds the deposit value], got [%v]", err) + } + + // 2. Redemption's bridge-chain-nil + _, err = assembleReservedRedemptionTransaction(bitcoinChain, nil, anchorUtxo, bitcoin.Script{0x00}, &ReservationAction{}, 100, 0, anchorUtxo.Outpoint) + if err == nil || err.Error() != "bridge chain is required" { + t.Errorf("expected error [bridge chain is required], got [%v]", err) + } + + // 3. Redemption's amount-exceeds-anchor + _, err = assembleReservedRedemptionTransaction(bitcoinChain, bridgeChain, anchorUtxo, bitcoin.Script{0x00}, &ReservationAction{ActionType: ReservationActionTypeRedemption, State: ReservationActionStatePending, TimeoutAt: 1000, Amount: 200000}, 100, 0, anchorUtxo.Outpoint) + if err == nil || err.Error() != "redemption amount exceeds the anchor value" { + t.Errorf("expected error [redemption amount exceeds the anchor value], got [%v]", err) + } + + // 4. Reanchor's fee-exceeds-anchor-value + _, err = assembleReservationReanchorTransaction(bitcoinChain, anchorUtxo, [20]byte{0x01}, &ReservationAction{TargetWalletPublicKeyHash: [20]byte{0x01}, ActionType: ReservationActionTypeReanchor, State: ReservationActionStatePending, TimeoutAt: 1000, TxMaxFee: 200000, Amount: 100000}, 0, 200000, 0, anchorUtxo.Outpoint) + if err == nil || err.Error() != "transaction fee exceeds the anchor value" { + t.Errorf("expected error [transaction fee exceeds the anchor value], got [%v]", err) + } + + // 5. Dissolution's zero-value-anchor + zeroValueAnchorUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x09}, + OutputIndex: 0, + }, + Value: 0, + } + _, err = assembleReservationDissolutionTransaction( + bitcoinChain, + bridgeChain, + zeroValueAnchorUtxo, + nil, + walletPublicKey, + &ReservationAction{ + TargetWalletPublicKeyHash: walletPublicKeyHash, + ActionType: ReservationActionTypeDissolution, + State: ReservationActionStatePending, + TimeoutAt: 1000, + TxMaxFee: 200, + }, + 100, + 0, + zeroValueAnchorUtxo.Outpoint, + ) + if err == nil || err.Error() != "anchor UTXO value must be positive" { + t.Errorf("expected error [anchor UTXO value must be positive], got [%v]", err) + } +} diff --git a/pkg/tbtc/wallet.go b/pkg/tbtc/wallet.go index ca346dec69..e61d84e006 100644 --- a/pkg/tbtc/wallet.go +++ b/pkg/tbtc/wallet.go @@ -23,6 +23,10 @@ import ( ) // WalletActionType represents actions types that can be performed by a wallet. +// +// Append-only: values are part of the wire-serialized wallet action +// protocol. Do not reorder or insert new values in the middle of the +// existing block; always append new action types at the end. type WalletActionType uint8 const ( @@ -32,6 +36,10 @@ const ( ActionRedemption ActionMovingFunds ActionMovedFundsSweep + ActionReservationAnchor + ActionReservedRedemption + ActionReservationReanchor + ActionReservationDissolution ) // ParseWalletActionType parses the given value into a WalletActionType. @@ -49,6 +57,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 +84,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 +113,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") } @@ -702,7 +734,7 @@ func EnsureWalletSyncedBetweenChains( ) } input := transaction.Inputs[0] - _, isDeposit, err := bridgeChain.GetDepositRequest( + depositRequest, isDeposit, err := bridgeChain.GetDepositRequest( input.Outpoint.TransactionHash, input.Outpoint.OutputIndex, ) @@ -720,6 +752,27 @@ func EnsureWalletSyncedBetweenChains( // If that's the case, the wallet has already created a deposit // sweep as their first Bitcoin transaction and the Bridge is // awaiting the SPV proof. + // + // A reservation anchor transaction spends a revealed deposit + // too, matching this exact 1-in-1-out shape, but it targets + // the reservation vault and deliberately never becomes the + // wallet's main UTXO. Treat that case as a legitimate + // terminal wallet output instead of an unproven sweep. + if depositRequest.Vault != nil { + // GetReservationParameters is not yet implemented on + // every chain backend (see errReservationsUnsupported); + // treat that, or an unset reservation vault, as "this + // deposit is not a reservation anchor" and fall through + // to the unproven-sweep error below, rather than failing + // wallet sync for ordinary (non-reservation) deposits. + reservationParameters, err := bridgeChain.GetReservationParameters() + if err == nil && + reservationParameters.Vault != "" && + *depositRequest.Vault == reservationParameters.Vault { + continue + } + } + return fmt.Errorf("wallet already produced their first " + "Bitcoin transaction (deposit sweep); Bridge is probably " + "awaiting the SPV proof", diff --git a/pkg/tbtc/wallet_test.go b/pkg/tbtc/wallet_test.go index 9ef4e41576..b7904e6264 100644 --- a/pkg/tbtc/wallet_test.go +++ b/pkg/tbtc/wallet_test.go @@ -11,6 +11,7 @@ import ( "fmt" "math/big" "reflect" + "sort" "strings" "sync" "testing" @@ -19,6 +20,7 @@ import ( "github.com/keep-network/keep-core/internal/testutils" "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/clientinfo" "github.com/keep-network/keep-core/pkg/protocol/group" "github.com/keep-network/keep-core/pkg/tecdsa" ) @@ -53,9 +55,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 +100,57 @@ 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 TestWalletActionType_MetricNameConsistency(t *testing.T) { + expected := clientinfo.GetAllWalletActionTypes() + + actual := make([]string, 0) + for i := uint8(0); ; i++ { + wat, err := ParseWalletActionType(i) + if err != nil { + break + } + + if wat == ActionNoop { + continue + } + + actual = append(actual, wat.MetricName()) + } + + sort.Strings(expected) + sort.Strings(actual) + + if !reflect.DeepEqual(expected, actual) { + t.Errorf("Metric names mismatch between WalletActionType and clientinfo.GetAllWalletActionTypes\nexpected: %v\nactual: %v", expected, actual) + } +} + func TestWalletDispatcher_Dispatch(t *testing.T) { walletDispatcher := newWalletDispatcher() @@ -901,6 +970,134 @@ func TestEnsureWalletSyncedBetweenChains_FreshWalletDepositSweepFirstTx(t *testi } } +func TestEnsureWalletSyncedBetweenChains_FreshWalletReservationAnchorFirstTx(t *testing.T) { + var walletPKH [20]byte + walletPKH[0] = 0xaa + + depositFundingTxHash := bitcoin.Hash{0xdd} + var depositFundingOutputIndex uint32 = 0 + + // A reservation anchor transaction. Its single input spends a deposit + // revealed with the reservation vault; its single output pays the + // wallet and deliberately never becomes the main UTXO. + anchorTx := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: depositFundingTxHash, + OutputIndex: depositFundingOutputIndex, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + {Value: 9000, PublicKeyScript: []byte{0x51}}, + }, + } + + anchorUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTx.Hash(), + OutputIndex: 0, + }, + Value: 9000, + } + + reservationVault := chain.Address("0x2222222222222222222222222222222222222222") + + localChain := Connect() + localChain.SetReservationParameters(ReservationParameters{ + Vault: reservationVault, + }) + localChain.setDepositRequest( + depositFundingTxHash, + depositFundingOutputIndex, + &DepositChainRequest{ + Vault: &reservationVault, + }, + ) + + btcChain := &walletSyncBtcChain{ + utxos: []*bitcoin.UnspentTransactionOutput{anchorUtxo}, + mempool: []*bitcoin.UnspentTransactionOutput{}, + txs: map[bitcoin.Hash]*bitcoin.Transaction{anchorTx.Hash(): anchorTx}, + } + + // EnsureWalletSyncedBetweenChains should NOT return an error for a + // reservation anchor: it spends a deposit revealed with the reservation + // vault, so it is a legitimate terminal wallet output rather than an + // unproven deposit sweep. + err := EnsureWalletSyncedBetweenChains(walletPKH, nil, localChain, btcChain) + + if err != nil { + t.Errorf("expected no error for reservation anchor, got [%v]", err) + } +} + +func TestEnsureWalletSyncedBetweenChains_FreshWalletDepositVaultMismatchFirstTx(t *testing.T) { + var walletPKH [20]byte + walletPKH[0] = 0xaa + + depositFundingTxHash := bitcoin.Hash{0xdd} + var depositFundingOutputIndex uint32 = 0 + + // Same 1-in-1-out shape as a reservation anchor, but the deposit's + // vault does not match the reservation vault (e.g. an ordinary + // TBTCVault deposit). This must still be treated as an unproven + // deposit sweep, not silently accepted as a reservation anchor. + sweepTx := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: depositFundingTxHash, + OutputIndex: depositFundingOutputIndex, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + {Value: 9000, PublicKeyScript: []byte{0x51}}, + }, + } + + sweepUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: sweepTx.Hash(), + OutputIndex: 0, + }, + Value: 9000, + } + + reservationVault := chain.Address("0x2222222222222222222222222222222222222222") + ordinaryVault := chain.Address("0x3333333333333333333333333333333333333333") + + localChain := Connect() + localChain.SetReservationParameters(ReservationParameters{ + Vault: reservationVault, + }) + localChain.setDepositRequest( + depositFundingTxHash, + depositFundingOutputIndex, + &DepositChainRequest{ + Vault: &ordinaryVault, + }, + ) + + btcChain := &walletSyncBtcChain{ + utxos: []*bitcoin.UnspentTransactionOutput{sweepUtxo}, + mempool: []*bitcoin.UnspentTransactionOutput{}, + txs: map[bitcoin.Hash]*bitcoin.Transaction{sweepTx.Hash(): sweepTx}, + } + + err := EnsureWalletSyncedBetweenChains(walletPKH, nil, localChain, btcChain) + + if err == nil { + t.Error("expected error for a non-reservation deposit sweep, got nil") + } +} + func TestEnsureWalletSyncedBetweenChains_MainUtxoInSync(t *testing.T) { var walletPKH [20]byte walletPKH[0] = 0xbb @@ -956,3 +1153,25 @@ func TestEnsureWalletSyncedBetweenChains_MainUtxoSpent(t *testing.T) { t.Error("expected error when main UTXO has been spent on Bitcoin, got nil") } } +func TestWalletActionType_String(t *testing.T) { + tests := map[WalletActionType]string{ + ActionNoop: "Noop", + ActionHeartbeat: "Heartbeat", + ActionDepositSweep: "DepositSweep", + ActionRedemption: "Redemption", + ActionMovingFunds: "MovingFunds", + ActionMovedFundsSweep: "MovedFundsSweep", + ActionReservationAnchor: "ReservationAnchor", + ActionReservedRedemption: "ReservedRedemption", + ActionReservationReanchor: "ReservationReanchor", + ActionReservationDissolution: "ReservationDissolution", + } + + for actionType, expected := range tests { + t.Run(expected, func(t *testing.T) { + if actionType.String() != expected { + t.Errorf("expected string [%s], got [%s]", expected, actionType.String()) + } + }) + } +} diff --git a/pkg/tbtcpg/chain_test.go b/pkg/tbtcpg/chain_test.go index cdff0f01e3..23a2bebbc6 100644 --- a/pkg/tbtcpg/chain_test.go +++ b/pkg/tbtcpg/chain_test.go @@ -1010,6 +1010,63 @@ func (lc *LocalChain) ComputeMainUtxoHash(mainUtxo *bitcoin.UnspentTransactionOu panic("unsupported") } +func (lc *LocalChain) ComputeReservationRedeemerOutputScriptHash( + redeemerOutputScript bitcoin.Script, +) ([32]byte, error) { + panic("unsupported") +} + +func (lc *LocalChain) GetReservation(reservationKey *big.Int) (*tbtc.Reservation, bool, error) { + panic("unsupported") +} + +func (lc *LocalChain) GetReservationAction( + reservationKey *big.Int, + requestNonce uint64, +) (*tbtc.ReservationAction, error) { + panic("unsupported") +} + +func (lc *LocalChain) GetReservationParameters() (tbtc.ReservationParameters, error) { + panic("unsupported") +} + +func (lc *LocalChain) GetReservationTotalAmount() (uint64, error) { + panic("unsupported") +} + +func (lc *LocalChain) ValidateReservationAnchorProposal( + walletPublicKeyHash [20]byte, + proposal *tbtc.ReservationAnchorProposal, + depositExtraInfo struct { + *tbtc.Deposit + FundingTx *bitcoin.Transaction + }, +) error { + panic("unsupported") +} + +func (lc *LocalChain) ValidateReservedRedemptionProposal( + walletPublicKeyHash [20]byte, + proposal *tbtc.ReservedRedemptionProposal, +) error { + panic("unsupported") +} + +func (lc *LocalChain) ValidateReservationReanchorProposal( + sourceWalletPublicKeyHash [20]byte, + proposal *tbtc.ReservationReanchorProposal, +) error { + panic("unsupported") +} + +func (lc *LocalChain) ValidateReservationDissolutionProposal( + walletPublicKeyHash [20]byte, + proposal *tbtc.ReservationDissolutionProposal, +) error { + panic("unsupported") +} + func (lc *LocalChain) ComputeMovingFundsCommitmentHash(targetWallets [][20]byte) [32]byte { packedWallets := []byte{} 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,