diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index d252b92a67..38f8c444c9 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -2525,6 +2525,10 @@ func (tc *TbtcChain) ReservationParameters() ( return convertReservationParametersFromAbiType(abiParameters), nil } +// TODO(test-coverage): ValidateReservationAnchorProposal has no direct unit +// test coverage. It requires go-ethereum simulated-backend infrastructure +// that does not exist anywhere in pkg/chain/ethereum today; blocked on that +// infra landing. See PR #4280 and its linked gap-analysis doc. // ValidateReservationAnchorProposal asks the WalletProposalValidator // whether the given anchor proposal is valid for the given wallet and // reserved deposit. The validator is a separate contract reached at its diff --git a/pkg/chain/ethereum/tbtc_test.go b/pkg/chain/ethereum/tbtc_test.go index 1614297fda..e383fe53ea 100644 --- a/pkg/chain/ethereum/tbtc_test.go +++ b/pkg/chain/ethereum/tbtc_test.go @@ -556,8 +556,8 @@ func TestConvertReservationFromAbiType(t *testing.T) { RetryCredit: true, DissolutionEligibleAt: 1700200000, // CumulativeReanchorFee is intentionally dropped on the Go - // boundary (see the function doc comment); set it to a nonzero - // value to prove it never leaks into tbtc.Reservation. + // boundary (see the Field omissions note on + // convertReservationFromAbiType). CumulativeReanchorFee: 12345, } @@ -602,6 +602,66 @@ func TestConvertReservationFromAbiType(t *testing.T) { t.Fatal("expected error, got nil") } }) + + // t.Run below documents the intentional CumulativeReanchorFee drop + // performed by convertReservationFromAbiType: the field is written + // on-chain by every re-anchor hop but is not exposed on + // tbtc.Reservation (see the Field omissions note on + // convertReservationFromAbiType). It also pins that every other + // field maps correctly - each field below is a distinct value so a + // future accidental restoration of CumulativeReanchorFee, or a + // swapped adjacent field, does not go unnoticed. + t.Run("drops cumulative reanchor fee and maps every other field", func(t *testing.T) { + abiReservation := tbtcabi.ReservationReservationRequest{ + Owner: common.HexToAddress("0x111111111111111111111111111111111111111B"), + MintedAmount: 111, + AcceptedAt: 222, + WalletPubKeyHash: [20]byte{0x01, 0x02, 0x03}, + AnchorAmount: 333, + ExpiresAt: 444, + AnchorTxHash: [32]byte{0x04, 0x05, 0x06}, + AnchorTxOutputIndex: 555, + State: 1, // ReservationStateActive + RequestNonce: 666, + RetryCredit: true, + DissolutionEligibleAt: 777, + CumulativeReanchorFee: 888, // must not appear anywhere in the output + } + + expected := &tbtc.Reservation{ + Owner: chain.Address("0x111111111111111111111111111111111111111B"), + MintedAmount: 111, + AcceptedAt: 222, + WalletPublicKeyHash: [20]byte{ + 0x01, 0x02, 0x03, + }, + AnchorUtxo: &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x04, 0x05, 0x06}, + OutputIndex: 555, + }, + Value: 333, + }, + ExpiresAt: 444, + State: tbtc.ReservationStateActive, + RequestNonce: 666, + RetryCredit: true, + DissolutionEligibleAt: 777, + } + + actual, err := convertReservationFromAbiType(abiReservation) + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(expected, actual) { + t.Errorf( + "unexpected reservation\nexpected: [%+v]\nactual: [%+v]", + expected, + actual, + ) + } + }) } func TestConvertReservationActionFromAbiType(t *testing.T) { @@ -748,9 +808,15 @@ func TestConvertReservationActionFromAbiType(t *testing.T) { }) } +// TestConvertReservationParametersFromAbiType verifies the full 10-tuple +// field mapping performed by convertReservationParametersFromAbiType. +// Field count/order had not previously been cross-checked against the +// live Solidity struct; every field below is set to a distinct non-zero +// value so a swapped or dropped field is caught, not masked by a shared +// zero-value default. func TestConvertReservationParametersFromAbiType(t *testing.T) { vaultAddress := common.HexToAddress( - "0x9876543210FeDcBa9876543210fEdCbA98765432", + "0x111111111111111111111111111111111111111A", ) abiParameters := struct { @@ -777,10 +843,8 @@ func TestConvertReservationParametersFromAbiType(t *testing.T) { ReservationRenewalWindowSeconds: 604800, } - parameters := convertReservationParametersFromAbiType(abiParameters) - expected := &tbtc.ReservationParameters{ - ReservationVault: chain.Address(vaultAddress.String()), + ReservationVault: chain.Address("0x111111111111111111111111111111111111111A"), ReservationMinAmount: 1000, ReservationTxMaxFee: 5000, ReservationTermSeconds: 1209600, @@ -792,11 +856,13 @@ func TestConvertReservationParametersFromAbiType(t *testing.T) { ReservationRenewalWindowSeconds: 604800, } - if !reflect.DeepEqual(expected, parameters) { + actual := convertReservationParametersFromAbiType(abiParameters) + + if !reflect.DeepEqual(expected, actual) { t.Errorf( - "unexpected parameters\nexpected: [%+v]\nactual: [%+v]\n", + "unexpected reservation parameters\nexpected: [%+v]\nactual: [%+v]", expected, - parameters, + actual, ) } } diff --git a/pkg/tbtc/reservation_test.go b/pkg/tbtc/reservation_test.go index 0153d81aad..2be0ac383c 100644 --- a/pkg/tbtc/reservation_test.go +++ b/pkg/tbtc/reservation_test.go @@ -14,7 +14,9 @@ import ( "go.uber.org/zap" "google.golang.org/protobuf/proto" + "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/tbtc/gen/pb" ) @@ -174,7 +176,7 @@ func TestReservationProposals_UnmarshalRejectsInvalidPayloads(t *testing.T) { }), expectedError: "cannot unmarshal proposal payload: [invalid deposit funding tx hash length: [0]]", }, - "re-anchor empty payload": { + "re-anchor null payload": { actionType: ActionReservationReanchor, payload: nil, expectedError: "cannot unmarshal proposal payload: [reservation key is required]", @@ -1026,3 +1028,207 @@ func TestReservationReanchorAction_Execute(t *testing.T) { } }) } + +// TestAssembleReservationAnchorTransaction verifies the happy-path output +// shape of AssembleReservationAnchorTransaction: a 1-input-1-output +// transaction spending the reserved deposit's P2WSH UTXO into a single +// P2WPKH output controlled by the target wallet, valued at the deposit +// amount less the transaction fee. Prior to this test, existing coverage +// (TestAssembleReservationTransactions_InputValidation, +// TestAssembleReservationTransactions_FeeBoundaries) exercised only +// validation-error and fee-boundary-error paths; no test asserted the +// happy-path output shape. +func TestAssembleReservationAnchorTransaction(t *testing.T) { + bitcoinChain := newLocalBitcoinChain() + + privateKeyValue := big.NewInt(100) + testWallet := generateWallet(privateKeyValue) + walletPublicKeyHash := bitcoin.PublicKeyHash(testWallet.publicKey) + + targetPrivateKeyValue := big.NewInt(200) + targetWallet := generateWallet(targetPrivateKeyValue) + targetWalletPublicKeyHash := bitcoin.PublicKeyHash(targetWallet.publicKey) + targetWalletScript, err := bitcoin.PayToWitnessPublicKeyHash(targetWalletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + + deposit := &Deposit{ + Depositor: chain.Address("0x1111111111111111111111111111111111111111"), + BlindingFactor: [8]byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}, + WalletPublicKeyHash: walletPublicKeyHash, + RefundPublicKeyHash: [20]byte{0x02}, + RefundLocktime: [4]byte{0x03, 0x04, 0x05, 0x06}, + } + + 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{0x09}, + 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, + targetWalletPublicKeyHash, + &ReservationAction{TxMaxFee: 1500}, + 1500, + ) + if err != nil { + t.Fatal(err) + } + + transaction := signReservationTransaction( + t, + builder, + testWallet.publicKey, + privateKeyValue, + ) + + expectedOutputs := []*bitcoin.TransactionOutput{ + { + Value: 98500, + PublicKeyScript: targetWalletScript, + }, + } + + if !reflect.DeepEqual(expectedOutputs, transaction.Outputs) { + t.Errorf( + "unexpected outputs\nexpected: [%+v]\nactual: [%+v]", + expectedOutputs, + transaction.Outputs, + ) + } + + testutils.AssertIntsEqual(t, "inputs count", 1, len(transaction.Inputs)) +} + +// TestAssembleReservationReanchorTransaction verifies the happy-path output +// shape of AssembleReservationReanchorTransaction: a 1-input-1-output +// transaction spending the reservation's anchor UTXO into a single P2WPKH +// output controlled by the target wallet, valued at the anchor amount less +// the transaction fee. Prior to this test, existing coverage +// (TestAssembleReservationTransactions_InputValidation, +// TestAssembleReservationTransactions_FeeBoundaries) exercised only +// validation-error and fee-boundary-error paths; no test asserted the +// happy-path output shape. Note that pkg/tbtcpg does not yet exercise the +// reanchor assembly path via this function. +func TestAssembleReservationReanchorTransaction(t *testing.T) { + bitcoinChain := newLocalBitcoinChain() + + privateKeyValue := big.NewInt(100) + testWallet := generateWallet(privateKeyValue) + sourceWalletPublicKeyHash := bitcoin.PublicKeyHash(testWallet.publicKey) + sourceWalletScript, err := bitcoin.PayToWitnessPublicKeyHash(sourceWalletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + + targetPrivateKeyValue := big.NewInt(200) + targetWallet := generateWallet(targetPrivateKeyValue) + targetWalletPublicKeyHash := bitcoin.PublicKeyHash(targetWallet.publicKey) + targetWalletScript, err := bitcoin.PayToWitnessPublicKeyHash(targetWalletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + + fundingTransaction := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x0a}, + OutputIndex: 0, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 100000, + PublicKeyScript: sourceWalletScript, + }, + }, + } + if err := bitcoinChain.BroadcastTransaction(fundingTransaction); err != nil { + t.Fatal(err) + } + + anchorUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTransaction.Hash(), + OutputIndex: 0, + }, + Value: 100000, + } + + builder, err := AssembleReservationReanchorTransaction( + bitcoinChain, + anchorUtxo, + targetWalletPublicKeyHash, + &ReservationAction{TxMaxFee: 1500}, + 1500, + ) + if err != nil { + t.Fatal(err) + } + + transaction := signReservationTransaction( + t, + builder, + testWallet.publicKey, + privateKeyValue, + ) + + expectedOutputs := []*bitcoin.TransactionOutput{ + { + Value: 98500, + PublicKeyScript: targetWalletScript, + }, + } + + if !reflect.DeepEqual(expectedOutputs, transaction.Outputs) { + t.Errorf( + "unexpected outputs\nexpected: [%+v]\nactual: [%+v]", + expectedOutputs, + transaction.Outputs, + ) + } + + testutils.AssertIntsEqual(t, "inputs count", 1, len(transaction.Inputs)) +} diff --git a/pkg/tbtcpg/chain_test.go b/pkg/tbtcpg/chain_test.go index 8ad1e985b9..575fe06562 100644 --- a/pkg/tbtcpg/chain_test.go +++ b/pkg/tbtcpg/chain_test.go @@ -169,7 +169,7 @@ func buildPastDepositRevealedEventsKey( if filter.EndBlock != nil { endBlock := make([]byte, 8) - binary.BigEndian.PutUint64(startBlock, *filter.EndBlock) + binary.BigEndian.PutUint64(endBlock, *filter.EndBlock) buffer.Write(endBlock) } @@ -277,7 +277,7 @@ func buildPastNewWalletRegisteredEventsKey( if filter.EndBlock != nil { endBlock := make([]byte, 8) - binary.BigEndian.PutUint64(startBlock, *filter.EndBlock) + binary.BigEndian.PutUint64(endBlock, *filter.EndBlock) buffer.Write(endBlock) } @@ -346,7 +346,7 @@ func buildPastRedemptionRequestedEventsKey( if filter.EndBlock != nil { endBlock := make([]byte, 8) - binary.BigEndian.PutUint64(startBlock, *filter.EndBlock) + binary.BigEndian.PutUint64(endBlock, *filter.EndBlock) buffer.Write(endBlock) } @@ -381,7 +381,7 @@ func buildPastMovingFundsCommitmentSubmittedEventsKey( if filter.EndBlock != nil { endBlock := make([]byte, 8) - binary.BigEndian.PutUint64(startBlock, *filter.EndBlock) + binary.BigEndian.PutUint64(endBlock, *filter.EndBlock) buffer.Write(endBlock) } @@ -407,7 +407,7 @@ func buildPastMovingFundsCompletedEventsKey( if filter.EndBlock != nil { endBlock := make([]byte, 8) - binary.BigEndian.PutUint64(startBlock, *filter.EndBlock) + binary.BigEndian.PutUint64(endBlock, *filter.EndBlock) buffer.Write(endBlock) } diff --git a/pkg/tbtcpg/reservation_acceptance.go b/pkg/tbtcpg/reservation_acceptance.go index caa7907e40..0b4ae55992 100644 --- a/pkg/tbtcpg/reservation_acceptance.go +++ b/pkg/tbtcpg/reservation_acceptance.go @@ -130,6 +130,7 @@ type reservationAcceptanceCandidate struct { ReservationParameters *tbtc.ReservationParameters TxMaxFee uint64 RequestNonce uint64 + AnchorFee int64 } // findReservationAcceptanceCandidate returns the first reserved deposit @@ -423,6 +424,47 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( requestNonce = reservation.RequestNonce + 1 } + // Estimate the anchor fee and check net-of-fee viability here, as + // part of candidate selection, rather than after a single candidate + // has already been chosen. A candidate that fails this check is + // skipped in favor of the next one; nothing marks it retried, so + // leaving this check in proposeReservationAcceptance (which is + // called for exactly one already-selected candidate) would cause + // the same doomed deposit to be re-selected and abort on every + // subsequent Run() until it aged out of the look-back window. + anchorFee, err := estimateReservationAcceptanceFee( + rat.btcChain, + reservationParameters.ReservationTxMaxFee, + ) + if err != nil { + taskLogger.Errorf( + "failed to estimate reservation acceptance transaction fee for [%v]: [%v]", + depositKey, + err, + ) + continue + } + + anchorValue := int64(depositRequest.Amount) - anchorFee + if anchorValue <= 0 { + taskLogger.Infof( + "reserved deposit [%v] value [%d] does not cover anchor fee [%d]; skipping", + depositKey, + depositRequest.Amount, + anchorFee, + ) + continue + } + if uint64(anchorValue) < reservationParameters.ReservationMinAmount { + taskLogger.Infof( + "reserved deposit [%v] net-of-fee value [%d] below minimum [%d]; skipping", + depositKey, + anchorValue, + reservationParameters.ReservationMinAmount, + ) + continue + } + taskLogger.Infof( "selected reserved deposit [%v] for acceptance", depositKey, @@ -449,6 +491,7 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( ReservationParameters: reservationParameters, TxMaxFee: reservationParameters.ReservationTxMaxFee, RequestNonce: requestNonce, + AnchorFee: anchorFee, }, nil } @@ -589,30 +632,13 @@ func (rat *ReservationAcceptanceTask) proposeReservationAcceptance( taskLogger.Infof("preparing a reservation acceptance proposal") - anchorFee, err := estimateReservationAcceptanceFee( - rat.btcChain, - candidate.TxMaxFee, - ) - if err != nil { - return nil, false, fmt.Errorf( - "cannot estimate reservation acceptance transaction fee: [%v]", - err, - ) - } - - anchorValue := candidate.Deposit.Utxo.Value - anchorFee - if anchorValue <= 0 { - return nil, false, fmt.Errorf( - "deposit value [%d] does not cover anchor fee [%d]", - candidate.Deposit.Utxo.Value, - anchorFee, - ) - } - - if candidate.ReservationParameters != nil && - uint64(anchorValue) < candidate.ReservationParameters.ReservationMinAmount { - return nil, false, nil - } + // The anchor fee and its net-of-fee viability were already computed and + // validated during candidate selection in findReservationAcceptanceCandidate; + // re-checking here (after exactly one candidate has already been chosen) + // would abort this Run() outright on failure instead of trying the next + // candidate, causing the same doomed deposit to be re-selected on every + // subsequent Run() until it aged out of the look-back window. + anchorFee := candidate.AnchorFee taskLogger.Infof("anchor transaction fee: [%d]", anchorFee) diff --git a/pkg/tbtcpg/reservation_acceptance_test.go b/pkg/tbtcpg/reservation_acceptance_test.go index 40ea2bd138..fa7ac1c9ff 100644 --- a/pkg/tbtcpg/reservation_acceptance_test.go +++ b/pkg/tbtcpg/reservation_acceptance_test.go @@ -1,11 +1,15 @@ package tbtcpg_test import ( + "crypto/ecdsa" + "crypto/rand" + "crypto/sha256" "fmt" "math/big" "testing" "time" + "github.com/btcsuite/btcd/btcec" "github.com/ipfs/go-log/v2" "github.com/keep-network/keep-core/pkg/bitcoin" @@ -15,6 +19,20 @@ import ( "github.com/keep-network/keep-core/pkg/tbtcpg/internal/test" ) +// testAnchorFeeSat is the estimated reservation acceptance anchor fee in sats. +// It is computed as minWalletTxSatPerVByteFee (5 sat/vByte) multiplied by the +// estimated anchor transaction vsize (142 vBytes) because the test fixture's +// 1 sat/vByte fee rate oracle response is clamped to the 5 sat/vByte floor by +// applyWalletTxFeeFloor (see fee.go). +const testAnchorFeeSat = uint64(710) + +// testReservationVaultAddress is the reservation vault address used across +// this file's fixtures, so a deposit's Vault field targets the same vault +// configured in ReservationParameters.ReservationVault. +const testReservationVaultAddress = chain.Address( + "0xReservationVaultAddress1234567890abcdef12345678", +) + // reservationAcceptanceLocalChain is a test-only mock of tbtcpg.Chain that // embeds the production LocalChain and adds reservation-specific behavior. // It exists as a separate type so this test file does not need to edit the @@ -22,52 +40,51 @@ import ( type reservationAcceptanceLocalChain struct { *tbtcpg.LocalChain - reservationParameters *tbtc.ReservationParameters - maxPerWalletAmount uint64 - maxSingleAmount uint64 - walletReservationsAmount uint64 - walletReservationsCount uint32 - activeCount uint32 - maxActive uint32 - pendingReserved uint64 - reservedDeposits map[string]bool - validateErr error - getWalletErr error - acceptanceEvents []*tbtc.ReservationAcceptanceRequestedEvent - acceptanceEventsErr error + maxPerWalletAmount uint64 + maxSingleAmount uint64 + walletReservationsAmount uint64 + walletReservationsCount uint32 + activeCount uint32 + maxActive uint32 + pendingReserved uint64 + validateErr error + getWalletErr error + getReservationErr error + acceptanceEvents []*tbtc.ReservationAcceptanceRequestedEvent + acceptanceEventsErr error + pastDepositRevealedEventsErr error } func newReservationAcceptanceLocalChain() *reservationAcceptanceLocalChain { lc := tbtcpg.NewLocalChain() return &reservationAcceptanceLocalChain{ - LocalChain: lc, - reservedDeposits: make(map[string]bool), + LocalChain: lc, } } // PastDepositRevealedEvents overrides the embedded LocalChain -// implementation to return an empty slice (rather than an error) when no -// events are registered for the filter. A real chain returns an empty -// event list when no deposits match; the in-memory mock's panic-stub -// "no events for given filter" error is a fixture bug that this override -// papers over without touching shared test infrastructure. +// implementation to narrow its "no events for given filter" sentinel +// error (the mock's signal for "nothing registered for this filter yet") +// into an empty slice, matching a real chain's behavior of returning an +// empty event list rather than an error when no deposits match. Any +// other error - including one injected via pastDepositRevealedEventsErr - +// is propagated unchanged. func (ralc *reservationAcceptanceLocalChain) PastDepositRevealedEvents( filter *tbtc.DepositRevealedEventFilter, ) ([]*tbtc.DepositRevealedEvent, error) { + if ralc.pastDepositRevealedEventsErr != nil { + return nil, ralc.pastDepositRevealedEventsErr + } events, err := ralc.LocalChain.PastDepositRevealedEvents(filter) if err != nil { - return []*tbtc.DepositRevealedEvent{}, nil + if err.Error() == "no events for given filter" { + return []*tbtc.DepositRevealedEvent{}, nil + } + return nil, err } return events, nil } -func (ralc *reservationAcceptanceLocalChain) ReservationParameters() ( - *tbtc.ReservationParameters, - error, -) { - return ralc.reservationParameters, nil -} - func (ralc *reservationAcceptanceLocalChain) ReservationCaps() ( uint64, uint64, @@ -103,15 +120,6 @@ func (ralc *reservationAcceptanceLocalChain) PendingReservedDeposits() ( return ralc.pendingReserved, nil } -func (ralc *reservationAcceptanceLocalChain) IsReservedDeposit( - depositKey *big.Int, -) (bool, error) { - if depositKey == nil { - return false, nil - } - return ralc.reservedDeposits[depositKey.Text(16)], nil -} - func (ralc *reservationAcceptanceLocalChain) GetWallet( walletPublicKeyHash [20]byte, ) (*tbtc.WalletChainData, error) { @@ -121,6 +129,32 @@ func (ralc *reservationAcceptanceLocalChain) GetWallet( return ralc.LocalChain.GetWallet(walletPublicKeyHash) } +// GetReservation delegates to the embedded LocalChain, except that a +// non-nil getReservationErr is consumed exactly once: it fires on the +// very next call and then clears itself, simulating a transient RPC +// failure rather than a permanent one. This lets +// TestReservationAcceptanceTask_GetReservationError exercise the +// candidate-selection fail-open path (see production's documented +// deviation above the call site) while still letting +// proposeReservationAcceptance's later post-request GetReservation +// verification call succeed once the reservation actually exists. +func (ralc *reservationAcceptanceLocalChain) GetReservation( + reservationKey *big.Int, +) (*tbtc.Reservation, error) { + if ralc.getReservationErr != nil { + err := ralc.getReservationErr + ralc.getReservationErr = nil + return nil, err + } + return ralc.LocalChain.GetReservation(reservationKey) +} + +// ValidateReservationAnchorProposal overrides the embedded LocalChain +// implementation. When validateErr is set it returns that error +// unconditionally (see TestReservationAcceptanceTask_ValidateProposalError). +// Otherwise it genuinely exercises the candidate-deposit mapping step by +// checking the proposal's funding outpoint against the candidate deposit's +// own funding outpoint, rather than unconditionally succeeding. func (ralc *reservationAcceptanceLocalChain) ValidateReservationAnchorProposal( walletPublicKeyHash [20]byte, proposal *tbtc.ReservationAnchorProposal, @@ -129,7 +163,34 @@ func (ralc *reservationAcceptanceLocalChain) ValidateReservationAnchorProposal( FundingTx *bitcoin.Transaction }, ) error { - return ralc.validateErr + if ralc.validateErr != nil { + return ralc.validateErr + } + if depositExtraInfo.Deposit == nil || + depositExtraInfo.Deposit.Utxo == nil || + depositExtraInfo.Deposit.Utxo.Outpoint == nil { + return fmt.Errorf( + "validate reservation anchor proposal: missing deposit UTXO outpoint", + ) + } + outpoint := depositExtraInfo.Deposit.Utxo.Outpoint + if outpoint.TransactionHash != proposal.DepositFundingTxHash { + return fmt.Errorf( + "validate reservation anchor proposal: funding tx hash mismatch: "+ + "proposal=[%x] candidate=[%x]", + proposal.DepositFundingTxHash, + outpoint.TransactionHash, + ) + } + if outpoint.OutputIndex != proposal.DepositFundingOutputIndex { + return fmt.Errorf( + "validate reservation anchor proposal: funding output index mismatch: "+ + "proposal=[%d] candidate=[%d]", + proposal.DepositFundingOutputIndex, + outpoint.OutputIndex, + ) + } + return nil } func (ralc *reservationAcceptanceLocalChain) PastReservationAcceptanceRequestedEvents( @@ -178,14 +239,14 @@ func scenarioReservationAcceptanceChain( reservationVault = chain.Address(scenario.ReservationVault) } - ralc.reservationParameters = &tbtc.ReservationParameters{ + ralc.SetReservationParameters(tbtc.ReservationParameters{ ReservationVault: reservationVault, ReservationMinAmount: scenario.ReservationParameters.ReservationMinAmount, ReservationTxMaxFee: scenario.ReservationParameters.ReservationTxMaxFee, ReservationMaxTotalAmount: scenario.ReservationParameters.ReservationMaxTotalAmount, ReservationTotalAmount: scenario.ReservationParameters.ReservationTotalAmount, MaxReservationsPerWallet: scenario.ReservationParameters.MaxReservationsPerWallet, - } + }) ralc.maxPerWalletAmount = scenario.Caps.MaxReservationsAmountPerWallet ralc.maxSingleAmount = scenario.Caps.ReservationMaxSingleAmount @@ -225,9 +286,8 @@ func scenarioReservationAcceptanceChain( } // registerReservedDeposits wires the scenario's reserved deposits into the -// mock chain as deposit requests and past DepositRevealedEvents. It also -// marks them as reserved via IsReservedDeposit. Bitcoin transaction -// registrations live on the btcChain mock. +// mock chain as deposit requests and past DepositRevealedEvents. Bitcoin +// transaction registrations live on the btcChain mock. func registerReservedDeposits( t *testing.T, scenario *test.ReservationAcceptanceTestScenario, @@ -312,15 +372,141 @@ func registerReservedDeposits( err, ) } + } +} - depositKey := ralc.BuildDepositKey( - materialized.FundingTxHash, - materialized.FundingOutputIndex, - ) - ralc.reservedDeposits[depositKey.Text(16)] = true +// setupEligibleDeposit registers an eligible deposit funding transaction, +// deposit request, and matching DepositRevealedEvent on the mock chains. +// It returns the funding transaction hash. +func setupEligibleDeposit( + t *testing.T, + ralc *reservationAcceptanceLocalChain, + btcChain *tbtcpg.LocalBitcoinChain, + walletPublicKeyHash [20]byte, + currentBlock uint64, + depositAmount uint64, +) bitcoin.Hash { + t.Helper() + + fundingTxHash := hashFromString( + "2222222222222222222222222222222222222222222222222222222222222222", + ) + + dummyTx := &bitcoin.Transaction{ + Outputs: []*bitcoin.TransactionOutput{{ + Value: int64(depositAmount), + PublicKeyScript: append([]byte{0x00, 0x20}, make([]byte, 32)...), + }}, + } + btcChain.SetTransaction(fundingTxHash, dummyTx) + btcChain.SetEstimateSatPerVByteFee(1, 1) + btcChain.SetTransactionConfirmations( + fundingTxHash, + tbtc.DepositSweepRequiredFundingTxConfirmations, + ) + + vaultAddress := testReservationVaultAddress + if params, err := ralc.ReservationParameters(); err == nil && + params.ReservationVault != "" { + vaultAddress = params.ReservationVault + } + + ralc.SetDepositRequest( + fundingTxHash, + 0, + &tbtc.DepositChainRequest{ + Depositor: chain.Address("934b98637ca318a4d6e7ca6ffd1690b8e77df637"), + Amount: depositAmount, + RevealedAt: time.Now().Add(-2 * time.Hour), + SweptAt: time.Unix(0, 0), + Vault: &vaultAddress, + }, + ) + + filterStartBlock := uint64(0) + if currentBlock > tbtcpg.ReservationAcceptanceLookBackBlocks { + filterStartBlock = currentBlock - tbtcpg.ReservationAcceptanceLookBackBlocks + } + + revealBlock := filterStartBlock + if revealBlock == 0 { + revealBlock = 1 + } + + err := ralc.AddPastDepositRevealedEvent( + &tbtc.DepositRevealedEventFilter{ + StartBlock: filterStartBlock, + EndBlock: ¤tBlock, + WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + }, + &tbtc.DepositRevealedEvent{ + BlockNumber: revealBlock, + WalletPublicKeyHash: walletPublicKeyHash, + FundingTxHash: fundingTxHash, + FundingOutputIndex: 0, + Vault: &vaultAddress, + }, + ) + if err != nil { + t.Fatalf("failed to add past deposit revealed event: [%v]", err) + } + + return fundingTxHash +} + +// newBoundaryTestChain builds a reservationAcceptanceLocalChain with the +// reservation-parameters/caps/wallet/block-counter setup shared by most of +// this file's Run()-based tests: a live wallet at walletPublicKeyHash, a +// ReservationParameters of {vault: testReservationVaultAddress, minAmount: +// 1000, txMaxFee: 5000, maxPerWallet: 5}, per-wallet/single caps of +// 5000000, an active-reservations cap of 100, and a deposit minimum age of +// one hour. overrides, when non-nil, runs after these defaults so a call +// site can customize only what it varies (e.g. re-set ReservationParameters +// with different values, raise a cap, or inject an error field). +func newBoundaryTestChain( + t *testing.T, + walletPublicKeyHash [20]byte, + currentBlock uint64, + overrides func(ralc *reservationAcceptanceLocalChain), +) *reservationAcceptanceLocalChain { + t.Helper() + + ralc := newReservationAcceptanceLocalChain() + + ralc.SetReservationParameters(tbtc.ReservationParameters{ + ReservationVault: testReservationVaultAddress, + ReservationMinAmount: 1000, + ReservationTxMaxFee: 5000, + MaxReservationsPerWallet: 5, + ReservationMaxTotalAmount: 100000000, + }) + ralc.maxPerWalletAmount = 5000000 + ralc.maxSingleAmount = 5000000 + ralc.maxActive = 100 + + ralc.SetDepositMinAge(3600) + ralc.SetWallet( + walletPublicKeyHash, + &tbtc.WalletChainData{State: tbtc.StateLive}, + ) + + blockCounter := tbtcpg.NewMockBlockCounter() + blockCounter.SetCurrentBlock(currentBlock) + ralc.SetBlockCounter(blockCounter) + + if overrides != nil { + overrides(ralc) } + + return ralc } +// uint64Ptr and uint32Ptr let a TestReservationAcceptanceTask_BoundaryChecks +// table row distinguish an explicit cap value of 0 (production's +// "unlimited" semantic for these caps) from the field's unset zero value. +func uint64Ptr(v uint64) *uint64 { return &v } +func uint32Ptr(v uint32) *uint32 { return &v } + // expectedAnchorsEqual compares two proposal objects field-by-field. // deep.Equal cannot be used for this: by default it does not descend into // unexported fields, and *big.Int's representation is entirely unexported, @@ -463,35 +649,231 @@ func TestReservationAcceptanceTask_Run(t *testing.T) { } } -// TestReservationAcceptanceTask_NoCandidates verifies that the task is a -// no-op when the chain has no reserved deposits. -func TestReservationAcceptanceTask_NoCandidates(t *testing.T) { - ralc := newReservationAcceptanceLocalChain() +// TestReservationAcceptanceTask_AnchorTransactionAssembly verifies the +// wiring of AssembleReservationAnchorTransaction: it ensures that an assembled +// anchor transaction can be signed and produces a valid 1-input-1-output +// Bitcoin transaction paying the correct wallet P2WPKH output script with +// value equal to deposit amount minus the estimated anchor fee. +func TestReservationAcceptanceTask_AnchorTransactionAssembly(t *testing.T) { btcChain := tbtcpg.NewLocalBitcoinChain() + btcChain.SetEstimateSatPerVByteFee(1, 1) - walletPublicKeyHash := hexToByte20( - "8db50eb52063ea9d98b3eac91489a90f738986f6", + privateKey, err := ecdsa.GenerateKey(btcec.S256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + walletPublicKeyHash := bitcoin.PublicKeyHash(&privateKey.PublicKey) + + depositAmount := uint64(2000000) + currentBlock := uint64(300000) + + ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, func(ralc *reservationAcceptanceLocalChain) { + ralc.maxPerWalletAmount = 50000000 + ralc.maxSingleAmount = 50000000 + }) + + deposit := &tbtc.Deposit{ + Depositor: chain.Address("934b98637ca318a4d6e7ca6ffd1690b8e77df637"), + BlindingFactor: [8]byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}, + WalletPublicKeyHash: walletPublicKeyHash, + RefundPublicKeyHash: [20]byte{0x02}, + RefundLocktime: [4]byte{0x03, 0x04, 0x05, 0x06}, + Vault: &[]chain.Address{testReservationVaultAddress}[0], + } + + 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) + } + + fundingTx := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x09}, + OutputIndex: 0, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: int64(depositAmount), + PublicKeyScript: depositLockingScript, + }, + }, + } + fundingTxHash := fundingTx.Hash() + btcChain.SetTransaction(fundingTxHash, fundingTx) + btcChain.SetTransactionConfirmations( + fundingTxHash, + tbtc.DepositSweepRequiredFundingTxConfirmations, ) - ralc.reservationParameters = &tbtc.ReservationParameters{ - ReservationVault: chain.Address( - "0xReservationVaultAddress1234567890abcdef12345678", - ), + deposit.Utxo = &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTxHash, + OutputIndex: 0, + }, + Value: int64(depositAmount), } - ralc.maxPerWalletAmount = 1000000 - ralc.maxSingleAmount = 5000000 - ralc.maxActive = 100 - ralc.SetDepositMinAge(3600) - ralc.SetWallet( + ralc.SetDepositRequest( + fundingTxHash, + 0, + &tbtc.DepositChainRequest{ + Depositor: deposit.Depositor, + Amount: depositAmount, + RevealedAt: time.Now().Add(-2 * time.Hour), + SweptAt: time.Unix(0, 0), + Vault: deposit.Vault, + }, + ) + + filterStartBlock := currentBlock - tbtcpg.ReservationAcceptanceLookBackBlocks + if err := ralc.AddPastDepositRevealedEvent( + &tbtc.DepositRevealedEventFilter{ + StartBlock: filterStartBlock, + EndBlock: ¤tBlock, + WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + }, + &tbtc.DepositRevealedEvent{ + BlockNumber: 200000, + WalletPublicKeyHash: walletPublicKeyHash, + FundingTxHash: fundingTxHash, + FundingOutputIndex: 0, + Vault: deposit.Vault, + BlindingFactor: deposit.BlindingFactor, + RefundPublicKeyHash: deposit.RefundPublicKeyHash, + RefundLocktime: deposit.RefundLocktime, + }, + ); err != nil { + t.Fatal(err) + } + + task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + proposal, shouldExecute, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + }) + if err != nil { + t.Fatalf("unexpected error running task: [%v]", err) + } + if !shouldExecute { + t.Fatalf("expected shouldExecute=true, got false") + } + if proposal == nil { + t.Fatalf("expected non-nil proposal") + } + + anchorProposal, ok := proposal.(*tbtc.ReservationAnchorProposal) + if !ok { + t.Fatalf("expected *ReservationAnchorProposal, got %T", proposal) + } + + // Assert on the candidate-derived proposal's own fields, exercising the + // candidate-deposit mapping step (also checked by the fixture's + // ValidateReservationAnchorProposal override), rather than only + // reassembling from this test's own hand-built deposit object below. + if anchorProposal.DepositFundingTxHash != fundingTxHash { + t.Errorf( + "unexpected DepositFundingTxHash\nexpected: %x\nactual: %x", + fundingTxHash, + anchorProposal.DepositFundingTxHash, + ) + } + if anchorProposal.DepositFundingOutputIndex != 0 { + t.Errorf( + "unexpected DepositFundingOutputIndex\nexpected: 0\nactual: %d", + anchorProposal.DepositFundingOutputIndex, + ) + } + + // Re-assemble and sign to verify transaction builder output properties. + builder, err := tbtc.AssembleReservationAnchorTransaction( + btcChain, + deposit, walletPublicKeyHash, - &tbtc.WalletChainData{State: tbtc.StateLive}, + &tbtc.ReservationAction{TxMaxFee: 5000}, + anchorProposal.AnchorTxFee.Int64(), ) + if err != nil { + t.Fatalf("failed to assemble reservation anchor transaction: [%v]", err) + } + + sigHashes, err := builder.ComputeSignatureHashes() + if err != nil { + t.Fatalf("failed to compute signature hashes: [%v]", err) + } + 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.Fatalf("failed to sign input: [%v]", err) + } + signatures[i] = &bitcoin.SignatureContainer{ + R: r, + S: s, + PublicKey: &privateKey.PublicKey, + } + } + + signedTx, err := builder.AddSignatures(signatures) + if err != nil { + t.Fatalf("failed to add signatures: [%v]", err) + } + + if len(signedTx.Inputs) != 1 { + t.Errorf("expected 1 input, got %d", len(signedTx.Inputs)) + } + if len(signedTx.Outputs) != 1 { + t.Errorf("expected 1 output, got %d", len(signedTx.Outputs)) + } + + expectedOutputScript, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + expectedOutputValue := int64(depositAmount) - anchorProposal.AnchorTxFee.Int64() + + if signedTx.Outputs[0].Value != expectedOutputValue { + t.Errorf( + "unexpected output value\nexpected: [%d]\nactual: [%d]", + expectedOutputValue, + signedTx.Outputs[0].Value, + ) + } + if string(signedTx.Outputs[0].PublicKeyScript) != string(expectedOutputScript) { + t.Errorf( + "unexpected output script\nexpected: [%x]\nactual: [%x]", + expectedOutputScript, + signedTx.Outputs[0].PublicKeyScript, + ) + } +} + +// TestReservationAcceptanceTask_NoCandidates verifies that the task is a +// no-op when the chain has no reserved deposits. +func TestReservationAcceptanceTask_NoCandidates(t *testing.T) { + btcChain := tbtcpg.NewLocalBitcoinChain() + walletPublicKeyHash := hexToByte20( + "8db50eb52063ea9d98b3eac91489a90f738986f6", + ) currentBlock := uint64(300000) - blockCounter := tbtcpg.NewMockBlockCounter() - blockCounter.SetCurrentBlock(currentBlock) - ralc.SetBlockCounter(blockCounter) + + ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, func(ralc *reservationAcceptanceLocalChain) { + ralc.SetReservationParameters(tbtc.ReservationParameters{ + ReservationVault: testReservationVaultAddress, + }) + ralc.maxPerWalletAmount = 1000000 + }) task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) @@ -523,11 +905,12 @@ func TestReservationAcceptanceTask_VaultNotConfigured_ZeroAddress(t *testing.T) "8db50eb52063ea9d98b3eac91489a90f738986f6", ) - ralc.reservationParameters = &tbtc.ReservationParameters{ + ralc.SetReservationParameters(tbtc.ReservationParameters{ ReservationVault: chain.Address( "0x0000000000000000000000000000000000000000", ), - } + ReservationMaxTotalAmount: 100000000, + }) ralc.maxPerWalletAmount = 1000000 ralc.maxSingleAmount = 5000000 ralc.maxActive = 100 @@ -627,7 +1010,7 @@ func TestReservationAcceptanceTask_AmountCapBoundaries(t *testing.T) { "8db50eb52063ea9d98b3eac91489a90f738986f6", ) - ralc.reservationParameters = &tbtc.ReservationParameters{ + ralc.SetReservationParameters(tbtc.ReservationParameters{ ReservationVault: chain.Address( "0xReservationVaultAddress1234567890abcdef12345678", ), @@ -636,7 +1019,7 @@ func TestReservationAcceptanceTask_AmountCapBoundaries(t *testing.T) { MaxReservationsPerWallet: 5, ReservationMaxTotalAmount: test.globalCap, ReservationTotalAmount: test.globalExisting, - } + }) ralc.maxPerWalletAmount = test.walletCap ralc.maxSingleAmount = test.singleCap ralc.walletReservationsAmount = test.walletExisting @@ -741,40 +1124,16 @@ func fundingTxHashForTestName(name string) bitcoin.Hash { // look-back window is applied when the current block exceeds it. func TestReservationAcceptanceTask_BoundedLookback(t *testing.T) { currentBlock := uint64(400000) - expectedStartBlock := currentBlock - - tbtcpg.ReservationAcceptanceLookBackBlocks - ralc := newReservationAcceptanceLocalChain() btcChain := tbtcpg.NewLocalBitcoinChain() walletPublicKeyHash := hexToByte20( "8db50eb52063ea9d98b3eac91489a90f738986f6", ) - ralc.reservationParameters = &tbtc.ReservationParameters{ - ReservationVault: chain.Address( - "0xReservationVaultAddress1234567890abcdef12345678", - ), - ReservationMinAmount: 1000, - ReservationTxMaxFee: 5000, - MaxReservationsPerWallet: 5, - ReservationMaxTotalAmount: 100000000, - } - ralc.maxPerWalletAmount = 5000000 - ralc.maxSingleAmount = 5000000 - ralc.maxActive = 100 - - ralc.SetDepositMinAge(3600) - ralc.SetWallet( - walletPublicKeyHash, - &tbtc.WalletChainData{State: tbtc.StateLive}, - ) - - blockCounter := tbtcpg.NewMockBlockCounter() - blockCounter.SetCurrentBlock(currentBlock) - ralc.SetBlockCounter(blockCounter) + ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, nil) - // Event below the look-back start block must NOT be returned. + // Register an event below the look-back start block (block 1). oldFundingTxHash := hashFromString( "1111111111111111111111111111111111111111111111111111111111111111", ) @@ -794,65 +1153,39 @@ func TestReservationAcceptanceTask_BoundedLookback(t *testing.T) { t.Fatal(err) } - // Event at the look-back start block must be returned. Mark it as - // reserved and provide a deposit request. - fundingTxHash := hashFromString( - "2222222222222222222222222222222222222222222222222222222222222222", - ) - dummyTx := &bitcoin.Transaction{ - Outputs: []*bitcoin.TransactionOutput{{ - Value: 0, - PublicKeyScript: append([]byte{0x00, 0x20}, make([]byte, 32)...), - }}, - } - btcChain.SetTransaction(fundingTxHash, dummyTx) - btcChain.SetEstimateSatPerVByteFee(1, 1) - btcChain.SetTransactionConfirmations( - fundingTxHash, - tbtc.DepositSweepRequiredFundingTxConfirmations, - ) - ralc.SetDepositRequest( - fundingTxHash, - 0, - &tbtc.DepositChainRequest{ - Depositor: chain.Address("934b98637ca318a4d6e7ca6ffd1690b8e77df637"), - Amount: 2000000, - RevealedAt: time.Now().Add(-2 * time.Hour), - SweptAt: time.Unix(0, 0), - Vault: &[]chain.Address{chain.Address( - "0xReservationVaultAddress1234567890abcdef12345678", - )}[0], - }, - ) - depositKey := ralc.BuildDepositKey(fundingTxHash, 0) - ralc.reservedDeposits[depositKey.Text(16)] = true - - if err := ralc.AddPastDepositRevealedEvent( - &tbtc.DepositRevealedEventFilter{ - StartBlock: expectedStartBlock, - EndBlock: ¤tBlock, - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - }, - &tbtc.DepositRevealedEvent{ - BlockNumber: expectedStartBlock, - WalletPublicKeyHash: walletPublicKeyHash, - FundingTxHash: fundingTxHash, - FundingOutputIndex: 0, - Vault: &[]chain.Address{chain.Address( - "0xReservationVaultAddress1234567890abcdef12345678", - )}[0], - }, - ); err != nil { - t.Fatal(err) - } - task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) - request := &tbtc.CoordinationProposalRequest{ WalletPublicKeyHash: walletPublicKeyHash, } + // First run: only the old deposit exists, revealed at block 1 - before + // the look-back start block. No candidate is found on this run; the + // second run below is what actually proves the look-back start block + // is honored, by registering an eligible deposit exactly at that block + // and confirming it is then found and accepted. proposal, shouldExecute, err := task.Run(request) + if err != nil { + t.Fatalf("unexpected error on old deposit run: [%v]", err) + } + if shouldExecute { + t.Errorf("expected shouldExecute=false for deposit below lookback window, got true") + } + if proposal != nil { + t.Errorf("expected nil proposal for deposit below lookback window, got [%+v]", proposal) + } + + // Register an eligible deposit at the look-back start block. + fundingTxHash := setupEligibleDeposit( + t, + ralc, + btcChain, + walletPublicKeyHash, + currentBlock, + 2000000, + ) + + // Second run: the deposit at the look-back start block must be found and accepted. + proposal, shouldExecute, err = task.Run(request) if err != nil { t.Fatalf("unexpected error: [%v]", err) } @@ -882,32 +1215,18 @@ func TestReservationAcceptanceTask_BoundedLookback(t *testing.T) { // TestReservationAcceptanceTask_DepositNotReserved confirms that a deposit // that does not target the reservation vault is filtered out. func TestReservationAcceptanceTask_DepositNotReserved(t *testing.T) { - ralc := newReservationAcceptanceLocalChain() btcChain := tbtcpg.NewLocalBitcoinChain() walletPublicKeyHash := hexToByte20( "8db50eb52063ea9d98b3eac91489a90f738986f6", ) - - ralc.reservationParameters = &tbtc.ReservationParameters{ - ReservationVault: chain.Address( - "0xReservationVaultAddress1234567890abcdef12345678", - ), - } - ralc.maxPerWalletAmount = 5000000 - ralc.maxSingleAmount = 5000000 - ralc.maxActive = 100 - - ralc.SetDepositMinAge(3600) - ralc.SetWallet( - walletPublicKeyHash, - &tbtc.WalletChainData{State: tbtc.StateLive}, - ) - currentBlock := uint64(300000) - blockCounter := tbtcpg.NewMockBlockCounter() - blockCounter.SetCurrentBlock(currentBlock) - ralc.SetBlockCounter(blockCounter) + + ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, func(ralc *reservationAcceptanceLocalChain) { + ralc.SetReservationParameters(tbtc.ReservationParameters{ + ReservationVault: testReservationVaultAddress, + }) + }) fundingTxHash := hashFromString( "3333333333333333333333333333333333333333333333333333333333333333", @@ -976,81 +1295,28 @@ func TestReservationAcceptanceTask_DepositNotReserved(t *testing.T) { // reserved deposit candidate is discovered and matches the reservation // vault, but the candidate wallet's chain data fails to load. func TestReservationAcceptanceTask_GetWalletError(t *testing.T) { - ralc := newReservationAcceptanceLocalChain() btcChain := tbtcpg.NewLocalBitcoinChain() walletPublicKeyHash := hexToByte20( "8db50eb52063ea9d98b3eac91489a90f738986f6", ) - - ralc.reservationParameters = &tbtc.ReservationParameters{ - ReservationVault: chain.Address( - "0xReservationVaultAddress1234567890abcdef12345678", - ), - ReservationMinAmount: 1000, - ReservationTxMaxFee: 5000, - MaxReservationsPerWallet: 5, - } - ralc.maxPerWalletAmount = 5000000 - ralc.maxSingleAmount = 5000000 - ralc.maxActive = 100 - - ralc.SetDepositMinAge(3600) - // No SetWallet call: GetWallet fails for the candidate wallet, and - // getWalletErr forces the exact error to assert against. - ralc.getWalletErr = fmt.Errorf("boom") - currentBlock := uint64(300000) - blockCounter := tbtcpg.NewMockBlockCounter() - blockCounter.SetCurrentBlock(currentBlock) - ralc.SetBlockCounter(blockCounter) - fundingTxHash := hashFromString( - "4444444444444444444444444444444444444444444444444444444444444444", - ) - btcChain.SetTransaction(fundingTxHash, &bitcoin.Transaction{}) - btcChain.SetTransactionConfirmations( - fundingTxHash, - tbtc.DepositSweepRequiredFundingTxConfirmations, - ) - ralc.SetDepositRequest( - fundingTxHash, - 0, - &tbtc.DepositChainRequest{ - Amount: 2000000, - RevealedAt: time.Now().Add(-2 * time.Hour), - SweptAt: time.Unix(0, 0), - Vault: &[]chain.Address{chain.Address( - "0xReservationVaultAddress1234567890abcdef12345678", - )}[0], - }, - ) - depositKey := ralc.BuildDepositKey(fundingTxHash, 0) - ralc.reservedDeposits[depositKey.Text(16)] = true - - filterStartBlock := uint64(0) - if currentBlock > tbtcpg.ReservationAcceptanceLookBackBlocks { - filterStartBlock = currentBlock - tbtcpg.ReservationAcceptanceLookBackBlocks - } + // getWalletErr forces GetWallet to fail for the candidate wallet. + // Production logs and swallows the GetWallet error, so Run must + // return (nil, false, nil). + ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, func(ralc *reservationAcceptanceLocalChain) { + ralc.getWalletErr = fmt.Errorf("boom") + }) - if err := ralc.AddPastDepositRevealedEvent( - &tbtc.DepositRevealedEventFilter{ - StartBlock: filterStartBlock, - EndBlock: ¤tBlock, - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - }, - &tbtc.DepositRevealedEvent{ - BlockNumber: 1, - WalletPublicKeyHash: walletPublicKeyHash, - FundingTxHash: fundingTxHash, - FundingOutputIndex: 0, - Vault: &[]chain.Address{chain.Address( - "0xReservationVaultAddress1234567890abcdef12345678", - )}[0], - }, - ); err != nil { - t.Fatal(err) - } + setupEligibleDeposit( + t, + ralc, + btcChain, + walletPublicKeyHash, + currentBlock, + 2000000, + ) task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) @@ -1068,42 +1334,83 @@ func TestReservationAcceptanceTask_GetWalletError(t *testing.T) { } } +// TestReservationAcceptanceTask_GetReservationError verifies that when +// GetReservation fails, the task logs the error and falls through to +// RequestReservationAcceptance, continuing with proposal emission. +func TestReservationAcceptanceTask_GetReservationError(t *testing.T) { + btcChain := tbtcpg.NewLocalBitcoinChain() + + walletPublicKeyHash := hexToByte20( + "8db50eb52063ea9d98b3eac91489a90f738986f6", + ) + currentBlock := uint64(300000) + + ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, nil) + + fundingTxHash := setupEligibleDeposit( + t, + ralc, + btcChain, + walletPublicKeyHash, + currentBlock, + 2000000, + ) + + // Force GetReservation to return an error. + ralc.getReservationErr = fmt.Errorf("simulated get reservation error") + + task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + + proposal, shouldExecute, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + }) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + if !shouldExecute { + t.Errorf("expected shouldExecute=true, got false") + } + if proposal == nil { + t.Fatalf("expected non-nil proposal") + } + + actualProposal, ok := proposal.(*tbtc.ReservationAnchorProposal) + if !ok { + t.Fatalf("expected *ReservationAnchorProposal, got %T", proposal) + } + if actualProposal.DepositFundingTxHash != fundingTxHash { + t.Errorf( + "unexpected deposit funding tx hash\nexpected: %s\nactual: %s", + fundingTxHash.Hex(bitcoin.ReversedByteOrder), + actualProposal.DepositFundingTxHash.Hex(bitcoin.ReversedByteOrder), + ) + } + // GetReservation's error is intentionally fail-open (see production + // comment above the call site): a brand-new candidate's first + // acceptance request nonce defaults to 1. + if actualProposal.RequestNonce != 1 { + t.Errorf( + "unexpected RequestNonce\nexpected: 1\nactual: %d", + actualProposal.RequestNonce, + ) + } +} + // TestReservationAcceptanceTask_Stateless_Maturity verifies the stateless // observable contract across two consecutive Run calls on the same task instance: // an immature candidate is skipped on the first run, but when time advances and // the candidate matures, the second run on the same task instance proposes it // without any cache-state interference. func TestReservationAcceptanceTask_Stateless_Maturity(t *testing.T) { - ralc := newReservationAcceptanceLocalChain() btcChain := tbtcpg.NewLocalBitcoinChain() walletPublicKeyHash := hexToByte20( "8db50eb52063ea9d98b3eac91489a90f738986f6", ) - ralc.reservationParameters = &tbtc.ReservationParameters{ - ReservationVault: chain.Address( - "0xReservationVaultAddress1234567890abcdef12345678", - ), - ReservationMinAmount: 1000, - ReservationTxMaxFee: 5000, - MaxReservationsPerWallet: 5, - ReservationMaxTotalAmount: 100000000, - } - ralc.maxPerWalletAmount = 5000000 - ralc.maxSingleAmount = 5000000 - ralc.maxActive = 100 - - ralc.SetDepositMinAge(3600) - ralc.SetWallet( - walletPublicKeyHash, - &tbtc.WalletChainData{State: tbtc.StateLive}, - ) - currentBlock := uint64(300000) - blockCounter := tbtcpg.NewMockBlockCounter() - blockCounter.SetCurrentBlock(currentBlock) - ralc.SetBlockCounter(blockCounter) + + ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, nil) fundingTxHash := hashFromString( "5555555555555555555555555555555555555555555555555555555555555555", @@ -1130,9 +1437,7 @@ func TestReservationAcceptanceTask_Stateless_Maturity(t *testing.T) { Amount: 2000000, RevealedAt: time.Now().Add(-10 * time.Minute), SweptAt: time.Unix(0, 0), - Vault: &[]chain.Address{chain.Address( - "0xReservationVaultAddress1234567890abcdef12345678", - )}[0], + Vault: &[]chain.Address{testReservationVaultAddress}[0], }, ) @@ -1152,9 +1457,7 @@ func TestReservationAcceptanceTask_Stateless_Maturity(t *testing.T) { WalletPublicKeyHash: walletPublicKeyHash, FundingTxHash: fundingTxHash, FundingOutputIndex: 0, - Vault: &[]chain.Address{chain.Address( - "0xReservationVaultAddress1234567890abcdef12345678", - )}[0], + Vault: &[]chain.Address{testReservationVaultAddress}[0], }, ); err != nil { t.Fatal(err) @@ -1183,9 +1486,7 @@ func TestReservationAcceptanceTask_Stateless_Maturity(t *testing.T) { Amount: 2000000, RevealedAt: time.Now().Add(-2 * time.Hour), SweptAt: time.Unix(0, 0), - Vault: &[]chain.Address{chain.Address( - "0xReservationVaultAddress1234567890abcdef12345678", - )}[0], + Vault: &[]chain.Address{testReservationVaultAddress}[0], }, ) @@ -1204,47 +1505,412 @@ func TestReservationAcceptanceTask_Stateless_Maturity(t *testing.T) { } if actualProposal.DepositFundingTxHash != fundingTxHash { t.Errorf( - "unexpected deposit funding tx hash\nexpected: %s\nactual: %s", + "unexpected deposit funding tx hash\nexpected: %s\nactual: %s", fundingTxHash.Hex(bitcoin.ReversedByteOrder), actualProposal.DepositFundingTxHash.Hex(bitcoin.ReversedByteOrder), ) } } +// TestReservationAcceptanceTask_ReservationParametersFetchedLive verifies +// that each Run() call reflects the chain's current live state rather than +// anything cached from a prior run on the same task instance: a +// governance-driven ReservationParameters change takes effect on the very +// next call, and an acceptance request recorded as a side effect of one +// Run() is visible to production's dedup guard on the next. +func TestReservationAcceptanceTask_ReservationParametersFetchedLive(t *testing.T) { + walletPublicKeyHash := hexToByte20( + "8db50eb52063ea9d98b3eac91489a90f738986f6", + ) + currentBlock := uint64(300000) + + t.Run("records acceptance request, skipping duplicate on subsequent run", func(t *testing.T) { + btcChain := tbtcpg.NewLocalBitcoinChain() + + ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, nil) + + fundingTxHash := setupEligibleDeposit( + t, + ralc, + btcChain, + walletPublicKeyHash, + currentBlock, + 2000000, + ) + + task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + request := &tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + } + + // First run: min amount (1000) is well below the deposit (2000000) - must accept. + _, shouldExecute, err := task.Run(request) + if err != nil { + t.Fatalf("unexpected error on first run: [%v]", err) + } + if !shouldExecute { + t.Fatalf("expected shouldExecute=true on first run, got false") + } + + // RequestReservationAcceptance bumped RequestNonce to 1 as a side + // effect of the first run, mirroring the on-chain Bridge. On real + // chain the Bridge also marks that generation's action record + // Pending; record that here too so the second run's dedup guard + // (hasPendingAction, which reads GetReservationAction) genuinely + // observes a pending generation instead of merely fail-closing on + // a not-found lookup. + depositKey := ralc.BuildDepositKey(fundingTxHash, 0) + ralc.SetReservationAction(depositKey, 1, &tbtc.ReservationAction{ + State: tbtc.ReservationActionStatePending, + }) + + // Second run on the same, now-requested deposit: the pending + // action generation recorded above must be found and the + // candidate skipped. + _, shouldExecute, err = task.Run(request) + if err != nil { + t.Fatalf("unexpected error on second run: [%v]", err) + } + if shouldExecute { + t.Fatalf("expected shouldExecute=false on second run due to pending acceptance action, got true") + } + }) + + t.Run("with parameter mutation rejects on subsequent run", func(t *testing.T) { + btcChain := tbtcpg.NewLocalBitcoinChain() + + ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, nil) + + setupEligibleDeposit( + t, + ralc, + btcChain, + walletPublicKeyHash, + currentBlock, + 2000000, + ) + + task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + request := &tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + } + + // First run: min amount (1000) is well below the deposit (2000000) - must accept. + _, shouldExecute, err := task.Run(request) + if err != nil { + t.Fatalf("unexpected error on first run: [%v]", err) + } + if !shouldExecute { + t.Fatalf("expected shouldExecute=true on first run, got false") + } + + // Mutate the chain fake's parameters in place - same task instance, + // same deposit, no new task created - then raise the min amount above + // the deposit's value. + ralc.SetReservationParameters(tbtc.ReservationParameters{ + ReservationVault: testReservationVaultAddress, + ReservationMinAmount: 3000000, + ReservationTxMaxFee: 5000, + MaxReservationsPerWallet: 5, + }) + + // Second run: if any part of the eligibility path retained the + // first run's ReservationMinAmount=1000 instead of reading the + // mutated live value, this would wrongly accept again. + _, shouldExecute, err = task.Run(request) + if err != nil { + t.Fatalf("unexpected error on second run: [%v]", err) + } + if shouldExecute { + t.Fatalf( + "expected shouldExecute=false on second run after raising " + + "ReservationMinAmount above the deposit's value", + ) + } + }) +} + +// TestReservationAcceptanceTask_BoundaryChecks exercises explicit +// at-limit/one-over-limit boundary crossings for the eligibility caps in +// checkReservationAcceptanceEligibility: +// - MaxReservationsPerWallet +// - ReservationMaxTotalAmount +// - ReservationMaxSingleAmount +// - MaxReservationsAmountPerWallet +// - ActiveReservationsCount +// ReservationMinAmount is not one of checkReservationAcceptanceEligibility's +// gates: the gross gate lives in findReservationAcceptanceCandidate, which +// requires depositAmount >= ReservationMinAmount; the same function +// additionally requires the net-of-fee value (deposit minus the estimated +// anchor fee) to clear it too. +func TestReservationAcceptanceTask_BoundaryChecks(t *testing.T) { + tests := map[string]struct { + depositAmount uint64 + maxReservationsPerWallet uint32 + walletReservationsCount uint32 + reservationMinAmount uint64 + reservationTotal uint64 + // maxSingleAmount, maxPerWalletAmount, maxActive, and + // reservationMaxTotal are pointers so a test row can explicitly + // request the cap-disabled value of 0 for the three caps where + // production treats 0 as "unlimited", or explicitly exercise the + // fail-closed misconfiguration path for the two caps + // (maxActiveReservations, ReservationMaxTotalAmount) where + // production treats 0 as "not configured" instead; nil means "use + // this test's default cap" (a large, effectively-unlimited value). + reservationMaxTotal *uint64 + maxSingleAmount *uint64 + maxPerWalletAmount *uint64 + walletReservationsAmount uint64 + maxActive *uint32 + activeCount uint32 + expectAccept bool + }{ + "MaxReservationsPerWallet: below limit accepts": { + depositAmount: 2000000, + maxReservationsPerWallet: 5, + walletReservationsCount: 4, + reservationMinAmount: 1000, + expectAccept: true, + }, + "MaxReservationsPerWallet: at limit rejects": { + depositAmount: 2000000, + maxReservationsPerWallet: 5, + walletReservationsCount: 5, + reservationMinAmount: 1000, + expectAccept: false, + }, + // checkReservationAcceptanceEligibility's gross-amount gate only + // requires depositAmount >= reservationMinAmount, but + // proposeReservationAcceptance additionally requires the + // *net-of-fee* anchor value (deposit - anchorFee) to also clear + // reservationMinAmount. Even though the test fixture sets a 1 sat/vByte + // oracle rate, applyWalletTxFeeFloor (see fee.go) clamps the rate to + // minWalletTxSatPerVByteFee (5 sat/vByte), resulting in a 710 sat fee + // (5 * 142 vsize = testAnchorFeeSat). Deposit amounts are offset by + // testAnchorFeeSat to test the exact net-of-fee boundary. + "ReservationMinAmount: exactly at minimum accepts": { + depositAmount: 100000 + testAnchorFeeSat, + maxReservationsPerWallet: 5, + reservationMinAmount: 100000, + expectAccept: true, + }, + "ReservationMinAmount: gross clears but net-of-fee value does not": { + depositAmount: 100050, + maxReservationsPerWallet: 5, + reservationMinAmount: 100000, + expectAccept: false, + }, + "ReservationMinAmount: one below minimum rejects": { + depositAmount: 99999, + maxReservationsPerWallet: 5, + reservationMinAmount: 100000, + expectAccept: false, + }, + "ReservationMaxTotalAmount: exactly at cap accepts": { + depositAmount: 2000000, + maxReservationsPerWallet: 5, + reservationMinAmount: 1000, + reservationTotal: 3000000, + reservationMaxTotal: uint64Ptr(5000000), + expectAccept: true, + }, + "ReservationMaxTotalAmount: one over cap rejects": { + depositAmount: 2000000, + maxReservationsPerWallet: 5, + reservationMinAmount: 1000, + reservationTotal: 3000001, + reservationMaxTotal: uint64Ptr(5000000), + expectAccept: false, + }, + // Unlike ReservationMaxSingleAmount and MaxReservationsAmountPerWallet + // below, ReservationMaxTotalAmount == 0 is NOT treated as + // "unlimited": checkReservationAcceptanceEligibility fails closed on + // a misconfigured (zero) global cap rather than silently allowing + // unbounded reservations. + "ReservationMaxTotalAmount: cap of 0 fails closed (misconfiguration)": { + depositAmount: 2000000, + maxReservationsPerWallet: 5, + reservationMinAmount: 1000, + reservationMaxTotal: uint64Ptr(0), + expectAccept: false, + }, + "ReservationMaxSingleAmount: exactly at cap accepts": { + depositAmount: 5000000, + maxSingleAmount: uint64Ptr(5000000), + maxReservationsPerWallet: 5, + reservationMinAmount: 1000, + expectAccept: true, + }, + "ReservationMaxSingleAmount: one over cap rejects": { + depositAmount: 5000001, + maxSingleAmount: uint64Ptr(5000000), + maxReservationsPerWallet: 5, + reservationMinAmount: 1000, + expectAccept: false, + }, + // A cap of 0 means "unlimited" in checkReservationAcceptanceEligibility + // (reservationMaxSingleAmount > 0 gates the check); maxPerWalletAmount + // is raised explicitly so it does not itself gate this deposit. + "ReservationMaxSingleAmount: cap of 0 means unlimited": { + depositAmount: 60000000, + maxSingleAmount: uint64Ptr(0), + maxPerWalletAmount: uint64Ptr(100000000), + maxReservationsPerWallet: 5, + reservationMinAmount: 1000, + expectAccept: true, + }, + "MaxReservationsAmountPerWallet: exactly at cap accepts": { + depositAmount: 2000000, + walletReservationsAmount: 3000000, + maxPerWalletAmount: uint64Ptr(5000000), + maxReservationsPerWallet: 5, + reservationMinAmount: 1000, + expectAccept: true, + }, + "MaxReservationsAmountPerWallet: one over cap rejects": { + depositAmount: 2000000, + walletReservationsAmount: 3000001, + maxPerWalletAmount: uint64Ptr(5000000), + maxReservationsPerWallet: 5, + reservationMinAmount: 1000, + expectAccept: false, + }, + // A cap of 0 means "unlimited" (maxReservationsAmountPerWallet > 0 + // gates the check); maxSingleAmount is raised explicitly so it does + // not itself gate this deposit. + "MaxReservationsAmountPerWallet: cap of 0 means unlimited": { + depositAmount: 2000000, + walletReservationsAmount: 60000000, + maxPerWalletAmount: uint64Ptr(0), + maxSingleAmount: uint64Ptr(100000000), + maxReservationsPerWallet: 5, + reservationMinAmount: 1000, + expectAccept: true, + }, + "ActiveReservationsCount: below limit accepts": { + depositAmount: 2000000, + maxActive: uint32Ptr(10), + activeCount: 9, + maxReservationsPerWallet: 5, + reservationMinAmount: 1000, + expectAccept: true, + }, + "ActiveReservationsCount: at limit rejects": { + depositAmount: 2000000, + maxActive: uint32Ptr(10), + activeCount: 10, + maxReservationsPerWallet: 5, + reservationMinAmount: 1000, + expectAccept: false, + }, + // Unlike ReservationMaxSingleAmount and MaxReservationsAmountPerWallet + // above, maxActiveReservations == 0 is NOT treated as "unlimited": + // checkReservationAcceptanceEligibility fails closed on a + // misconfigured (zero) active-reservations cap rather than silently + // allowing unbounded active reservations. + "ActiveReservationsCount: cap of 0 fails closed (misconfiguration)": { + depositAmount: 2000000, + maxActive: uint32Ptr(0), + activeCount: 1000, + maxReservationsPerWallet: 5, + reservationMinAmount: 1000, + expectAccept: false, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + ralc := newReservationAcceptanceLocalChain() + btcChain := tbtcpg.NewLocalBitcoinChain() + btcChain.SetEstimateSatPerVByteFee(1, 1) + + walletPublicKeyHash := hexToByte20( + "8db50eb52063ea9d98b3eac91489a90f738986f6", + ) + + reservationMaxTotal := uint64(100000000) + if test.reservationMaxTotal != nil { + reservationMaxTotal = *test.reservationMaxTotal + } + ralc.SetReservationParameters(tbtc.ReservationParameters{ + ReservationVault: testReservationVaultAddress, + ReservationMinAmount: test.reservationMinAmount, + ReservationTxMaxFee: 5000, + MaxReservationsPerWallet: test.maxReservationsPerWallet, + ReservationMaxTotalAmount: reservationMaxTotal, + ReservationTotalAmount: test.reservationTotal, + }) + ralc.maxPerWalletAmount = 50000000 + if test.maxPerWalletAmount != nil { + ralc.maxPerWalletAmount = *test.maxPerWalletAmount + } + ralc.maxSingleAmount = 50000000 + if test.maxSingleAmount != nil { + ralc.maxSingleAmount = *test.maxSingleAmount + } + ralc.maxActive = 100 + if test.maxActive != nil { + ralc.maxActive = *test.maxActive + } + ralc.activeCount = test.activeCount + ralc.walletReservationsAmount = test.walletReservationsAmount + ralc.walletReservationsCount = test.walletReservationsCount + + ralc.SetDepositMinAge(3600) + ralc.SetWallet( + walletPublicKeyHash, + &tbtc.WalletChainData{State: tbtc.StateLive}, + ) + + currentBlock := uint64(300000) + blockCounter := tbtcpg.NewMockBlockCounter() + blockCounter.SetCurrentBlock(currentBlock) + ralc.SetBlockCounter(blockCounter) + + setupEligibleDeposit( + t, + ralc, + btcChain, + walletPublicKeyHash, + currentBlock, + test.depositAmount, + ) + + task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + + request := &tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + } + + _, shouldExecute, err := task.Run(request) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + if shouldExecute != test.expectAccept { + t.Errorf( + "expected shouldExecute=%v, got %v", + test.expectAccept, + shouldExecute, + ) + } + }) + } +} + // TestReservationAcceptanceTask_Stateless_NoReRequest verifies that once a // reservation has an existing acceptance requested event, subsequent Run calls // on the same task instance do not produce a duplicate acceptance proposal. func TestReservationAcceptanceTask_Stateless_NoReRequest(t *testing.T) { - ralc := newReservationAcceptanceLocalChain() btcChain := tbtcpg.NewLocalBitcoinChain() walletPublicKeyHash := hexToByte20( "8db50eb52063ea9d98b3eac91489a90f738986f6", ) - ralc.reservationParameters = &tbtc.ReservationParameters{ - ReservationVault: chain.Address( - "0xReservationVaultAddress1234567890abcdef12345678", - ), - ReservationMinAmount: 1000, - ReservationTxMaxFee: 5000, - MaxReservationsPerWallet: 5, - ReservationMaxTotalAmount: 100000000, - } - ralc.maxPerWalletAmount = 5000000 - ralc.maxSingleAmount = 5000000 - ralc.maxActive = 100 - - ralc.SetDepositMinAge(3600) - ralc.SetWallet( - walletPublicKeyHash, - &tbtc.WalletChainData{State: tbtc.StateLive}, - ) - currentBlock := uint64(300000) - blockCounter := tbtcpg.NewMockBlockCounter() - blockCounter.SetCurrentBlock(currentBlock) - ralc.SetBlockCounter(blockCounter) + + ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, nil) fundingTxHash := hashFromString( "6666666666666666666666666666666666666666666666666666666666666666", @@ -1270,9 +1936,7 @@ func TestReservationAcceptanceTask_Stateless_NoReRequest(t *testing.T) { Amount: 2000000, RevealedAt: time.Now().Add(-2 * time.Hour), SweptAt: time.Unix(0, 0), - Vault: &[]chain.Address{chain.Address( - "0xReservationVaultAddress1234567890abcdef12345678", - )}[0], + Vault: &[]chain.Address{testReservationVaultAddress}[0], }, ) @@ -1292,9 +1956,7 @@ func TestReservationAcceptanceTask_Stateless_NoReRequest(t *testing.T) { WalletPublicKeyHash: walletPublicKeyHash, FundingTxHash: fundingTxHash, FundingOutputIndex: 0, - Vault: &[]chain.Address{chain.Address( - "0xReservationVaultAddress1234567890abcdef12345678", - )}[0], + Vault: &[]chain.Address{testReservationVaultAddress}[0], }, ); err != nil { t.Fatal(err) @@ -1346,35 +2008,14 @@ func TestReservationAcceptanceTask_Stateless_NonEligibleReservationState(t *test for _, state := range nonEligibleStates { t.Run(fmt.Sprintf("state_%v", state), func(t *testing.T) { - ralc := newReservationAcceptanceLocalChain() btcChain := tbtcpg.NewLocalBitcoinChain() walletPublicKeyHash := hexToByte20( "8db50eb52063ea9d98b3eac91489a90f738986f6", ) - - ralc.reservationParameters = &tbtc.ReservationParameters{ - ReservationVault: chain.Address( - "0xReservationVaultAddress1234567890abcdef12345678", - ), - ReservationMinAmount: 1000, - ReservationTxMaxFee: 5000, - MaxReservationsPerWallet: 5, - } - ralc.maxPerWalletAmount = 5000000 - ralc.maxSingleAmount = 5000000 - ralc.maxActive = 100 - - ralc.SetDepositMinAge(3600) - ralc.SetWallet( - walletPublicKeyHash, - &tbtc.WalletChainData{State: tbtc.StateLive}, - ) - currentBlock := uint64(300000) - blockCounter := tbtcpg.NewMockBlockCounter() - blockCounter.SetCurrentBlock(currentBlock) - ralc.SetBlockCounter(blockCounter) + + ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, nil) fundingTxHash := hashFromString( "8888888888888888888888888888888888888888888888888888888888888888", @@ -1399,9 +2040,7 @@ func TestReservationAcceptanceTask_Stateless_NonEligibleReservationState(t *test Amount: 2000000, RevealedAt: time.Now().Add(-2 * time.Hour), SweptAt: time.Unix(0, 0), - Vault: &[]chain.Address{chain.Address( - "0xReservationVaultAddress1234567890abcdef12345678", - )}[0], + Vault: &[]chain.Address{testReservationVaultAddress}[0], }, ) @@ -1421,9 +2060,7 @@ func TestReservationAcceptanceTask_Stateless_NonEligibleReservationState(t *test WalletPublicKeyHash: walletPublicKeyHash, FundingTxHash: fundingTxHash, FundingOutputIndex: 0, - Vault: &[]chain.Address{chain.Address( - "0xReservationVaultAddress1234567890abcdef12345678", - )}[0], + Vault: &[]chain.Address{testReservationVaultAddress}[0], }, ); err != nil { t.Fatal(err) @@ -1456,37 +2093,25 @@ func TestReservationAcceptanceTask_Stateless_NonEligibleReservationState(t *test // but when governance lowers the minimum amount, Run 2 on the same task instance // proposes it. func TestReservationAcceptanceTask_Stateless_DynamicMinAmount(t *testing.T) { - ralc := newReservationAcceptanceLocalChain() btcChain := tbtcpg.NewLocalBitcoinChain() walletPublicKeyHash := hexToByte20( "8db50eb52063ea9d98b3eac91489a90f738986f6", ) + currentBlock := uint64(300000) // Initial min amount is 5,000,000. - ralc.reservationParameters = &tbtc.ReservationParameters{ - ReservationVault: chain.Address( - "0xReservationVaultAddress1234567890abcdef12345678", - ), - ReservationMinAmount: 5000000, - ReservationTxMaxFee: 5000, - MaxReservationsPerWallet: 5, - ReservationMaxTotalAmount: 100000000, - } - ralc.maxPerWalletAmount = 50000000 - ralc.maxSingleAmount = 50000000 - ralc.maxActive = 100 - - ralc.SetDepositMinAge(3600) - ralc.SetWallet( - walletPublicKeyHash, - &tbtc.WalletChainData{State: tbtc.StateLive}, - ) - - currentBlock := uint64(300000) - blockCounter := tbtcpg.NewMockBlockCounter() - blockCounter.SetCurrentBlock(currentBlock) - ralc.SetBlockCounter(blockCounter) + ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, func(ralc *reservationAcceptanceLocalChain) { + ralc.SetReservationParameters(tbtc.ReservationParameters{ + ReservationVault: testReservationVaultAddress, + ReservationMinAmount: 5000000, + ReservationTxMaxFee: 5000, + MaxReservationsPerWallet: 5, + ReservationMaxTotalAmount: 100000000, + }) + ralc.maxPerWalletAmount = 50000000 + ralc.maxSingleAmount = 50000000 + }) fundingTxHash := hashFromString( "9999999999999999999999999999999999999999999999999999999999999999", @@ -1513,9 +2138,7 @@ func TestReservationAcceptanceTask_Stateless_DynamicMinAmount(t *testing.T) { Amount: 2000000, RevealedAt: time.Now().Add(-2 * time.Hour), SweptAt: time.Unix(0, 0), - Vault: &[]chain.Address{chain.Address( - "0xReservationVaultAddress1234567890abcdef12345678", - )}[0], + Vault: &[]chain.Address{testReservationVaultAddress}[0], }, ) @@ -1535,9 +2158,7 @@ func TestReservationAcceptanceTask_Stateless_DynamicMinAmount(t *testing.T) { WalletPublicKeyHash: walletPublicKeyHash, FundingTxHash: fundingTxHash, FundingOutputIndex: 0, - Vault: &[]chain.Address{chain.Address( - "0xReservationVaultAddress1234567890abcdef12345678", - )}[0], + Vault: &[]chain.Address{testReservationVaultAddress}[0], }, ); err != nil { t.Fatal(err) @@ -1558,15 +2179,13 @@ func TestReservationAcceptanceTask_Stateless_DynamicMinAmount(t *testing.T) { } // Governance lowers min amount to 1,000,000. - ralc.reservationParameters = &tbtc.ReservationParameters{ - ReservationVault: chain.Address( - "0xReservationVaultAddress1234567890abcdef12345678", - ), + ralc.SetReservationParameters(tbtc.ReservationParameters{ + ReservationVault: testReservationVaultAddress, ReservationMinAmount: 1000000, ReservationTxMaxFee: 5000, MaxReservationsPerWallet: 5, ReservationMaxTotalAmount: 100000000, - } + }) // Second run on the same task instance: deposit is now above min and proposed. proposal, shouldExecute, err = task.Run(request) @@ -1582,36 +2201,15 @@ func TestReservationAcceptanceTask_Stateless_DynamicMinAmount(t *testing.T) { // when an existing reservation record has RequestNonce = N, the generated proposal // uses RequestNonce = N + 1. func TestReservationAcceptanceTask_Stateless_RequestNonceIncremented(t *testing.T) { - ralc := newReservationAcceptanceLocalChain() btcChain := tbtcpg.NewLocalBitcoinChain() walletPublicKeyHash := hexToByte20( "8db50eb52063ea9d98b3eac91489a90f738986f6", ) - ralc.reservationParameters = &tbtc.ReservationParameters{ - ReservationVault: chain.Address( - "0xReservationVaultAddress1234567890abcdef12345678", - ), - ReservationMinAmount: 1000, - ReservationTxMaxFee: 5000, - MaxReservationsPerWallet: 5, - ReservationMaxTotalAmount: 100000000, - } - ralc.maxPerWalletAmount = 5000000 - ralc.maxSingleAmount = 5000000 - ralc.maxActive = 100 - - ralc.SetDepositMinAge(3600) - ralc.SetWallet( - walletPublicKeyHash, - &tbtc.WalletChainData{State: tbtc.StateLive}, - ) - currentBlock := uint64(300000) - blockCounter := tbtcpg.NewMockBlockCounter() - blockCounter.SetCurrentBlock(currentBlock) - ralc.SetBlockCounter(blockCounter) + + ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, nil) fundingTxHash := hashFromString( "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", @@ -1637,9 +2235,7 @@ func TestReservationAcceptanceTask_Stateless_RequestNonceIncremented(t *testing. Amount: 2000000, RevealedAt: time.Now().Add(-2 * time.Hour), SweptAt: time.Unix(0, 0), - Vault: &[]chain.Address{chain.Address( - "0xReservationVaultAddress1234567890abcdef12345678", - )}[0], + Vault: &[]chain.Address{testReservationVaultAddress}[0], }, ) @@ -1659,9 +2255,7 @@ func TestReservationAcceptanceTask_Stateless_RequestNonceIncremented(t *testing. WalletPublicKeyHash: walletPublicKeyHash, FundingTxHash: fundingTxHash, FundingOutputIndex: 0, - Vault: &[]chain.Address{chain.Address( - "0xReservationVaultAddress1234567890abcdef12345678", - )}[0], + Vault: &[]chain.Address{testReservationVaultAddress}[0], }, ); err != nil { t.Fatal(err) @@ -1702,3 +2296,83 @@ func TestReservationAcceptanceTask_Stateless_RequestNonceIncremented(t *testing. ) } } + +// TestReservationAcceptanceTask_PastDepositRevealedEventsError verifies that +// a genuine (non-sentinel) error from PastDepositRevealedEvents is +// propagated as a hard error, rather than being swallowed like the mock's +// "no events for given filter" sentinel. +func TestReservationAcceptanceTask_PastDepositRevealedEventsError(t *testing.T) { + btcChain := tbtcpg.NewLocalBitcoinChain() + + walletPublicKeyHash := hexToByte20( + "8db50eb52063ea9d98b3eac91489a90f738986f6", + ) + currentBlock := uint64(300000) + + ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, nil) + + // Otherwise-eligible deposit; the injected error must still short + // circuit before any candidate is ever evaluated. + setupEligibleDeposit( + t, + ralc, + btcChain, + walletPublicKeyHash, + currentBlock, + 2000000, + ) + + ralc.pastDepositRevealedEventsErr = fmt.Errorf("simulated rpc failure") + + task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + + _, shouldExecute, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + }) + if err == nil { + t.Fatalf("expected a non-nil error, got nil") + } + if shouldExecute { + t.Errorf("expected shouldExecute=false, got true") + } +} + +// TestReservationAcceptanceTask_ValidateProposalError verifies that a +// ValidateReservationAnchorProposal failure aborts proposal generation with +// a wrapped error, rather than being silently ignored. +func TestReservationAcceptanceTask_ValidateProposalError(t *testing.T) { + btcChain := tbtcpg.NewLocalBitcoinChain() + + walletPublicKeyHash := hexToByte20( + "8db50eb52063ea9d98b3eac91489a90f738986f6", + ) + currentBlock := uint64(300000) + + ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, nil) + + setupEligibleDeposit( + t, + ralc, + btcChain, + walletPublicKeyHash, + currentBlock, + 2000000, + ) + + ralc.validateErr = fmt.Errorf("simulated validation failure") + + task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + + proposal, shouldExecute, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + }) + if err == nil { + t.Fatalf("expected a non-nil error, got nil") + } + if shouldExecute { + t.Errorf("expected shouldExecute=false, got true") + } + if proposal != nil { + t.Errorf("expected nil proposal, got %v", proposal) + } +}