diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml index a426c83ad0..78bacb7b6c 100644 --- a/.github/workflows/client.yml +++ b/.github/workflows/client.yml @@ -165,27 +165,34 @@ jobs: # - the inKindFeeDebtSat and tbtcToken entries of the committed # ReservationVault binding against the compiled ReservationVault # artifact. + # It also checks the real validator that the in-memory EVM harness + # (pkg/chain/ethereum/tbtc_validator_harness_test.go) deploys: + # pkg/chain/ethereum/testdata/walletproposalvalidator/verify.sh + # compares the vendored WalletProposalValidator.json against the + # compiled artifact - the ABI exactly, and the creation and deployed + # bytecode with solc's trailing CBOR metadata removed (the metadata + # hashes the compiler input, which the unfrozen yarn install below + # can change without changing the code). An ABI-only diff cannot + # catch a validator whose logic changed under an identical ABI. # Cheap (checkout + yarn install + hardhat compile + a go-run ABI # extraction + jq diffs) and # runs whenever the Client workflow runs, without an additional # job-level path gate, mirroring btcec-vendor-byte-identity's # rationale for not gating by path. # - # tbtc-v2 ref: pinned to commit 9f8f5ef1 - the tip of threshold-network/tbtc-v2's - # `reservations-upgrade` integration branch (which merged - # threshold-network/tbtc-v2#1112, adding ReservationRouter.sol, into - # that branch, not `main`). A full commit hash is pinned rather than - # the branch name because a branch ref can move after this repo's - # generated bindings and vendored fragments are produced, and the - # diff above would then fail without any visible cause. `main` does - # not carry the reservation contracts yet (verified: no - # ReservationRouter.sol there as of this job's authoring), so - # pointing here at `main` would make every run of this job fail - # unconditionally. When the reservations work lands on tbtc-v2's - # `main`, switch `TBTC_V2_REF` back to `main`. + # tbtc-v2 ref: pinned to commit eec999aa, the merge of + # threshold-network/tbtc-v2#1161 into the `reservations-upgrade` + # integration branch, where the reservation contracts live (they are + # not on tbtc-v2's `main`). A full commit hash is pinned rather than + # a branch name because a branch can move after this repo's bindings, + # vendored fragments, and harness validator are produced, and the + # checks would then fail without any visible cause. The vendored + # harness validator is built from this same commit (see the + # provenance note in testdata/walletproposalvalidator/regenerate.sh), + # so bump both together. runs-on: ubuntu-latest env: - TBTC_V2_REF: 9f8f5ef1ca82f423571225066a291a6a0f963aac + TBTC_V2_REF: eec999aad43b3913df378840773348e17f68f1ba steps: - uses: actions/checkout@v4 @@ -254,6 +261,15 @@ jobs: cp "$BASE_VAULT/ReservationVault.sol/ReservationVault.json" "tmp/contracts/development/@keep-network/tbtc-v2/artifacts/ReservationVault.json" make -C pkg/chain/ethereum/tbtc/gen verify-vendored-fallback + - name: Verify the harness's vendored WalletProposalValidator matches the compiled validator + run: | + pkg/chain/ethereum/testdata/walletproposalvalidator/verify.sh \ + tbtc-v2-upstream/solidity/build/contracts/bridge/WalletProposalValidator.sol/WalletProposalValidator.json \ + || { + echo "::error::pkg/chain/ethereum/testdata/walletproposalvalidator/WalletProposalValidator.json does not match WalletProposalValidator compiled from tbtc-v2 ${TBTC_V2_REF} (see the output above). Regenerate it with regenerate.sh from that build, or align TBTC_V2_REF with its provenance note." + exit 1 + } + client-build-test-publish: needs: client-detect-changes if: | diff --git a/cmd/maintainer.go b/cmd/maintainer.go index 3729d4afef..3aad88b38c 100644 --- a/cmd/maintainer.go +++ b/cmd/maintainer.go @@ -79,11 +79,9 @@ func maintainers(cmd *cobra.Command, args []string) error { metricsRecorder := initializeMaintainerMetrics(ctx, blockCounter, tbtcChain, btcChain) - clientConfig.Maintainer.Spv.EthereumNetwork = clientConfig.Ethereum.Network - maintainer.Initialize( ctx, - clientConfig.Maintainer, + maintainerConfig(clientConfig), btcChain, btcDiffChain, tbtcChain, @@ -94,6 +92,17 @@ func maintainers(cmd *cobra.Command, args []string) error { return fmt.Errorf("unexpected context cancellation") } +// maintainerConfig returns the maintainers' config with the SPV +// maintainer's Ethereum network copied from the [ethereum] section. The +// SPV maintainer looks up the reservation activation block by that +// network; left unset, the network is Unknown and every reservation +// catch-up scan is skipped. +func maintainerConfig(cfg *config.Config) maintainer.Config { + maintainerConfig := cfg.Maintainer + maintainerConfig.Spv.EthereumNetwork = cfg.Ethereum.Network + return maintainerConfig +} + // initializeMaintainerMetrics sets up the client info registry and performance // metrics for the maintainer command. It returns a metrics recorder wired to // the SPV maintainer, or nil when the client info endpoint is not configured diff --git a/cmd/maintainer_test.go b/cmd/maintainer_test.go index 76863c2289..8ef976635a 100644 --- a/cmd/maintainer_test.go +++ b/cmd/maintainer_test.go @@ -5,8 +5,13 @@ import ( "net" "testing" + commonEthereum "github.com/keep-network/keep-common/pkg/chain/ethereum" + + "github.com/keep-network/keep-core/config" "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/clientinfo" + "github.com/keep-network/keep-core/pkg/maintainer" + "github.com/keep-network/keep-core/pkg/maintainer/spv" ) // stubBlockCounter is a minimal chain.BlockCounter implementation used to @@ -216,3 +221,30 @@ func TestInitializeMaintainerMetricsEnabledWhenPortSet(t *testing.T) { ) } } + +// TestMaintainerConfig verifies that the maintainer command hands the SPV +// maintainer the Ethereum network from the [ethereum] section: without it +// the network is Unknown, the reservation activation block lookup fails +// and every reservation catch-up scan is skipped. The rest of the +// maintainer config passes through unchanged. +func TestMaintainerConfig(t *testing.T) { + cfg := &config.Config{ + Ethereum: commonEthereum.Config{Network: commonEthereum.Sepolia}, + Maintainer: maintainer.Config{ + Spv: spv.Config{Enabled: true, ReservationProofsEnabled: true}, + }, + } + + got := maintainerConfig(cfg) + + if got.Spv.EthereumNetwork != commonEthereum.Sepolia { + t.Errorf( + "expected the SPV maintainer network %v, got %v", + commonEthereum.Sepolia, + got.Spv.EthereumNetwork, + ) + } + if !got.Spv.Enabled || !got.Spv.ReservationProofsEnabled { + t.Errorf("expected the rest of the SPV config to pass through, got %+v", got.Spv) + } +} diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index d485318fbc..47aa7f8915 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -9,21 +9,9 @@ // the per-concern files and reintroduce the old tbtc.go, silently dropping // whatever those later commits changed. Reconstructing the pre-split state // requires a manual merge, not a mechanical revert. -// tbtc.go: TbtcChain adapter construction and shared state. See tbtc_*.go for -// per-concern implementations (tbtc_deposit.go, tbtc_dkg.go, tbtc_moving_funds.go, -// tbtc_redemption.go, tbtc_wallet.go, tbtc_sortition.go, tbtc_inactivity.go). -// -// These files were split out of a single monolithic tbtc.go with no rename -// markers git can detect (each file is a fresh addition, not a tracked move), -// so a plain `git revert` of the split commit cannot be applied cleanly on -// top of any later commit that also touches this package: it would re-delete -// the per-concern files and reintroduce the old tbtc.go, silently dropping -// whatever those later commits changed. Reconstructing the pre-split state -// requires a manual merge, not a mechanical revert. package ethereum import ( - "context" "crypto/ecdsa" "encoding/binary" "errors" @@ -35,7 +23,6 @@ import ( "sync" "time" - hostchain "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/crypto" @@ -677,6 +664,7 @@ func convertReservationFromAbiType( RequestNonce: abiReservation.RequestNonce, RetryCredit: abiReservation.RetryCredit, DissolutionEligibleAt: abiReservation.DissolutionEligibleAt, + ReanchorCooldownUntil: abiReservation.ReanchorCooldownUntil, }, nil } @@ -689,6 +677,8 @@ func convertReservationFromAbiType( // - `UsedRetryCredit`, `Watchtower{Default,LevelOne,LevelTwo}Delay`, // `RetryCreditSourceNonce`: written for governance / late-settlement // reconciliation but not read by the operator client in m1. +// - `DissolutionDelay`: snapshotted by acceptance generations for the +// dissolution path, which the operator client does not drive in m1. // // The on-chain `actionDataHash` field is polymorphic across action types: // it carries the keccak256 of the redeemer output script for redemptions, @@ -843,81 +833,31 @@ func parseReservationActionState(value uint8) (tbtc.ReservationActionState, erro // RequestReservationReanchor asks the Bridge (via its ReservationRouter // delegatecall target) to start a new reservation re-anchor action generation -// for the given reservation, targeting the given wallet. The returned -// transaction hash lets the caller track the submission across coordination -// rounds via GetReservationReanchorRequestReceipt; the submission launches -// mining and gas bumping in the background and returns before the -// transaction is mined. +// for the given reservation, targeting the given wallet. func (tc *TbtcChain) RequestReservationReanchor( reservationKey *big.Int, targetWalletPublicKeyHash [20]byte, -) ([32]byte, error) { +) error { gasEstimate, err := tc.reservationRouter.RequestReservationReanchorGasEstimate( reservationKey, targetWalletPublicKeyHash, ) if err != nil { - return [32]byte{}, err + return err } // Here we add a 20% margin to overcome the gas problems. gasEstimateWithMargin := float64(gasEstimate) * float64(1.2) - tx, err := tc.reservationRouter.RequestReservationReanchor( + _, err = tc.reservationRouter.RequestReservationReanchor( reservationKey, targetWalletPublicKeyHash, ethutil.TransactionOptions{ GasLimit: uint64(gasEstimateWithMargin), }, ) - if err != nil { - return [32]byte{}, err - } - - return [32]byte(tx.Hash().Bytes()), nil -} - -// GetReservationReanchorRequestReceipt reports the mining status of a -// previously submitted RequestReservationReanchor transaction, as defined -// by the tbtc.ReservationReanchorRequestReceiptStatus values. The receipt -// lookup is bounded by a 30-second deadline, matching the baseChain header -// helpers. -// -// A receipt that does not exist yet - which go-ethereum reports as -// ethereum.NotFound for unknown or unmined hashes - maps to NotFound: the -// caller treats NotFound and Pending identically, bounded by the block the -// submission happened in. Every other lookup error (RPC outage, timeout, -// transport failure) is returned to the caller so an in-flight request is -// not mistaken for a dropped one. -func (tc *TbtcChain) GetReservationReanchorRequestReceipt( - txHash [32]byte, -) (tbtc.ReservationReanchorRequestReceiptStatus, error) { - ctx, cancelCtx := context.WithTimeout( - context.Background(), - 30*time.Second, - ) - defer cancelCtx() - receipt, err := tc.baseChain.client.TransactionReceipt( - ctx, - common.BytesToHash(txHash[:]), - ) - if err != nil { - if errors.Is(err, hostchain.NotFound) { - return tbtc.ReservationReanchorRequestReceiptNotFound, nil - } - return tbtc.ReservationReanchorRequestReceiptNotFound, fmt.Errorf( - "cannot fetch transaction receipt: %w", - err, - ) - } - if receipt == nil { - return tbtc.ReservationReanchorRequestReceiptNotFound, nil - } - if receipt.Status == 0 { - return tbtc.ReservationReanchorRequestReceiptReverted, nil - } - return tbtc.ReservationReanchorRequestReceiptMined, nil + return err } // SubmitReservationAcceptanceProof submits an SPV proof for the given @@ -1681,18 +1621,19 @@ func (tc *TbtcChain) ActiveReservationsCount() (uint32, uint32, error) { return activeReservationsCount.Count, activeReservationsCount.MaxActive, nil } -// reservationVaultBindings returns the cached, per-vault-address bindings -// (the ReservationVault itself and the TBTC token it holds its fee -// reserve in) constructed against a vault address read from the Bridge's -// on-chain reservation parameters. Successful constructions are cached per -// vault address. Failures are not cached: they are usually transient RPC -// errors (for example reading tbtcToken()), and caching them would disable -// the fee gauges until the process restarts. +// reservationVaultBindings holds the ReservationVault contract and the TBTC +// token contract the vault keeps its fee reserve in. type reservationVaultBindings struct { vault *tbtccontract.ReservationVault tbtc *tbtccontract.TBTC } +// reservationVaultBindings returns the cached, per-vault-address bindings +// (the ReservationVault itself and the TBTC token it holds its fee +// reserve in) for the given vault address. Successful constructions are +// cached per vault address. Failures are not cached: they are usually +// transient RPC errors (for example reading tbtcToken()), and caching them +// would disable the fee gauges until the process restarts. func (tc *TbtcChain) reservationVaultBindings(vaultAddress common.Address) (*reservationVaultBindings, error) { if bindings, ok := tc.vaultBindings.Load(vaultAddress); ok { return bindings.(*reservationVaultBindings), nil @@ -1743,21 +1684,16 @@ func (tc *TbtcChain) reservationVaultBindings(vaultAddress common.Address) (*res return bindings, nil } -// reservationVaultAddress resolves the on-chain reservation vault address +// decodeReservationVaultAddress decodes a reservation vault address read // from the Bridge's reservation parameters. It returns the zero address // when the vault is not configured (zero address in the parameters) and an -// error only when the configured address cannot be decoded as a valid -// 20-byte address, so callers can distinguish "no vault" (a skip, not -// an error) from a genuinely malformed configuration. -func (tc *TbtcChain) reservationVaultAddress() (common.Address, error) { - reservationParameters, err := tc.ReservationParameters() - if err != nil { - return common.Address{}, fmt.Errorf( - "cannot get reservation parameters: [%v]", - err, - ) - } - vaultAddressString := string(reservationParameters.ReservationVault) +// error only when the address cannot be decoded as a valid 20-byte +// address, so callers can distinguish "no vault" (a skip, not an error) +// from a genuinely malformed configuration. +func decodeReservationVaultAddress( + reservationVault chain.Address, +) (common.Address, error) { + vaultAddressString := string(reservationVault) vaultAddressBytes, err := hexutil.Decode(vaultAddressString) if err != nil || len(vaultAddressBytes) != common.AddressLength { if err == nil { @@ -1775,14 +1711,16 @@ func (tc *TbtcChain) reservationVaultAddress() (common.Address, error) { return common.BytesToAddress(vaultAddressBytes), nil } -// ReservationVaultFeeDebtSat returns the ReservationVault's outstanding -// in-kind fee debt in satoshi, read from the vault's inKindFeeDebtSat -// view. The vault address is resolved from the on-chain reservation -// parameters; when it is the zero address the vault is not configured -// and the method returns 0 with a nil error: the skip sentinel the -// metrics side consumes, not a chain error. -func (tc *TbtcChain) ReservationVaultFeeDebtSat() (uint64, error) { - vaultAddress, err := tc.reservationVaultAddress() +// ReservationVaultFeeDebtSat returns the given ReservationVault's +// outstanding in-kind fee debt in satoshi, read from the vault's +// inKindFeeDebtSat view. The caller passes the vault address it already +// read from the reservation parameters; when it is the zero address the +// vault is not configured and the method returns 0 with a nil error: the +// skip sentinel the metrics side consumes, not a chain error. +func (tc *TbtcChain) ReservationVaultFeeDebtSat( + reservationVault chain.Address, +) (uint64, error) { + vaultAddress, err := decodeReservationVaultAddress(reservationVault) if err != nil { return 0, err } @@ -1800,14 +1738,16 @@ func (tc *TbtcChain) ReservationVaultFeeDebtSat() (uint64, error) { return debtSat, nil } -// ReservationVaultFeeReserveTbtcBaseUnits returns the ReservationVault's -// TBTC fee-reserve balance in TBTC base units (whole TBTC x 1e18). The -// value is a token balance of a 1e18-scale token, which can exceed -// uint64 range, so it is returned as *big.Int. When the vault is not -// configured (zero address) the method returns zero with a nil error, -// mirroring ReservationVaultFeeDebtSat's skip sentinel. -func (tc *TbtcChain) ReservationVaultFeeReserveTbtcBaseUnits() (*big.Int, error) { - vaultAddress, err := tc.reservationVaultAddress() +// ReservationVaultFeeReserveTbtcBaseUnits returns the given +// ReservationVault's TBTC fee-reserve balance in TBTC base units (whole +// TBTC x 1e18). The value is a token balance of a 1e18-scale token, which +// can exceed uint64 range, so it is returned as *big.Int. When the vault +// is not configured (zero address) the method returns zero with a nil +// error, mirroring ReservationVaultFeeDebtSat's skip sentinel. +func (tc *TbtcChain) ReservationVaultFeeReserveTbtcBaseUnits( + reservationVault chain.Address, +) (*big.Int, error) { + vaultAddress, err := decodeReservationVaultAddress(reservationVault) if err != nil { return new(big.Int), err } diff --git a/pkg/chain/ethereum/tbtc/gen/Makefile b/pkg/chain/ethereum/tbtc/gen/Makefile index a82c34f022..1a4a2b4203 100644 --- a/pkg/chain/ethereum/tbtc/gen/Makefile +++ b/pkg/chain/ethereum/tbtc/gen/Makefile @@ -134,7 +134,8 @@ endif # (self-consistency against our own committed bindings only) cannot: # - the vendored fallback files vs the compiled artifacts, and # - the MetaData.ABI string in abi/.go vs the same -# compiled-artifact selectors. +# compiled-artifact selectors, so a fallback or binding update +# without the other fails here. # Every comparison is on whole-function entries (name, inputs with # components, outputs, stateMutability), sorted by function name and # canonicalized with jq -S, so each check naturally catches @@ -171,106 +172,52 @@ endif # npm/network access, whenever a vendored fallback file or the generated # bindings are touched or need re-verification. +# jq filters selecting the reservation entries keep-core depends on from +# each compiled artifact. +bridge_reservation_abi := [.abi[] | select(.name == "isReservedDeposit")] +wallet_proposal_validator_reservation_abi := [.abi[] | select(.name == "validateReservationAnchorProposal" or .name == "validateReservationReanchorProposal")] +reservation_vault_abi := [.abi[] | select(.name == "inKindFeeDebtSat" or .name == "tbtcToken")] + +# verify_abi runs one verify-vendored-fallback comparison: +# $(1) label used in the messages, +# $(2) command printing the local side (a vendored file, or the +# committed binding's embedded ABI via checkabi) as a JSON array, +# $(3) compiled artifact name in ${artifacts_dir}, without .json, +# $(4) jq filter selecting the matching entries from that artifact, +# $(5) remedy printed on drift. +# The local command writes to a temp file and is chained with && +# instead of being piped into jq, so when it fails (e.g. checkabi does +# not build) make stops with that command's own error rather than +# misreporting it as ABI drift. Arguments must not contain commas, +# since $(call) splits on them. +define verify_abi + @raw=$$(mktemp) && mine=$$(mktemp) && real=$$(mktemp) && \ + trap 'rm -f "$$raw" "$$mine" "$$real"' EXIT && \ + $(2) > "$$raw" && \ + jq -S 'sort_by(.name)' "$$raw" > "$$mine" && \ + jq -S '$(4) | sort_by(.name)' ${artifacts_dir}/$(3).json > "$$real" && \ + if diff -u "$$mine" "$$real"; then \ + echo "$(1) matches the real $(3) artifact"; \ + else \ + echo "$(1) differs from the real $(3) artifact - $(5)"; \ + exit 1; \ + fi +endef + .PHONY: verify-vendored-fallback verify-vendored-fallback: @[ -f "${artifacts_dir}/ReservationRouter.json" ] && [ -f "${artifacts_dir}/ReservationVault.json" ] && [ -f "${artifacts_dir}/Bridge.json" ] && [ -f "${artifacts_dir}/WalletProposalValidator.json" ] || { \ echo "verify-vendored-fallback requires real ${artifacts_dir}/ReservationRouter.json, ${artifacts_dir}/ReservationVault.json, ${artifacts_dir}/Bridge.json, and ${artifacts_dir}/WalletProposalValidator.json"; \ exit 1; \ } - @vendored=$$(mktemp) && real=$$(mktemp) && \ - jq -S '.abi | sort_by(.name)' ReservationRouter.fallback-artifact.json > "$$vendored" && \ - jq -S '.abi | sort_by(.name)' ${artifacts_dir}/ReservationRouter.json > "$$real" && \ - if diff -u "$$vendored" "$$real"; then \ - echo "ReservationRouter.fallback-artifact.json ABI matches the real artifact"; \ - rm -f "$$vendored" "$$real"; \ - else \ - echo "ReservationRouter.fallback-artifact.json ABI differs from the real artifact - regenerate the vendored fallback (see threshold-network/keep-core#4281)"; \ - rm -f "$$vendored" "$$real"; \ - exit 1; \ - fi - @vendored=$$(mktemp) && real=$$(mktemp) && \ - jq -S '[.[]] | sort_by(.name)' Bridge.reservation-methods-fallback.json > "$$vendored" && \ - jq -S '[.abi[] | select(.name == "isReservedDeposit")] | sort_by(.name)' ${artifacts_dir}/Bridge.json > "$$real" && \ - if diff -u "$$vendored" "$$real"; then \ - echo "Bridge.reservation-methods-fallback.json matches the real Bridge artifact"; \ - rm -f "$$vendored" "$$real"; \ - else \ - echo "Bridge.reservation-methods-fallback.json differs from the real Bridge artifact - regenerate the vendored fragment (see threshold-network/keep-core#4281)"; \ - rm -f "$$vendored" "$$real"; \ - exit 1; \ - fi - @vendored=$$(mktemp) && real=$$(mktemp) && \ - jq -S 'sort_by(.name)' WalletProposalValidator.reservation-methods-fallback.json > "$$vendored" && \ - jq -S '[.abi[] | select(.name == "validateReservationAnchorProposal" or .name == "validateReservationReanchorProposal")] | sort_by(.name)' ${artifacts_dir}/WalletProposalValidator.json > "$$real" && \ - if diff -u "$$vendored" "$$real"; then \ - echo "WalletProposalValidator.reservation-methods-fallback.json matches the real WalletProposalValidator artifact"; \ - rm -f "$$vendored" "$$real"; \ - else \ - echo "WalletProposalValidator.reservation-methods-fallback.json differs from the real WalletProposalValidator artifact - regenerate the vendored fragment (see threshold-network/keep-core#4281)"; \ - rm -f "$$vendored" "$$real"; \ - exit 1; \ - fi - # ReservationVault verification - @vendored=$$(mktemp) && real=$$(mktemp) && \ - jq -S '.abi | sort_by(.name)' ReservationVault.fallback-artifact.json > "$$vendored" && \ - jq -S '[.abi[] | select(.name == "inKindFeeDebtSat" or .name == "tbtcToken")] | sort_by(.name)' ${artifacts_dir}/ReservationVault.json > "$$real" && \ - if diff -u "$$vendored" "$$real"; then \ - echo "ReservationVault.fallback-artifact.json matches the real ReservationVault artifact"; \ - rm -f "$$vendored" "$$real"; \ - else \ - echo "ReservationVault.fallback-artifact.json differs from the real ReservationVault artifact - regenerate the vendored fallback (see threshold-network/keep-core#4281)"; \ - rm -f "$$vendored" "$$real"; \ - exit 1; \ - fi - # Go binding verification: the ABI embedded in the committed - # generated bindings (abi/.go's MetaData.ABI, extracted - # with go run ./checkabi) must agree with the same - # compiled-artifact selectors, so a fallback or binding update without - # the other fails here. - @binding=$$(mktemp) && real=$$(mktemp) && \ - go run ./checkabi ReservationRouter | jq -S '. | sort_by(.name)' > "$$binding" && \ - jq -S '.abi | sort_by(.name)' ${artifacts_dir}/ReservationRouter.json > "$$real" && \ - if diff -u "$$binding" "$$real"; then \ - echo "ReservationRouter binding ABI matches the real artifact"; \ - rm -f "$$binding" "$$real"; \ - else \ - echo "ReservationRouter binding ABI (abi/ReservationRouter.go) differs from the real artifact - regenerate the bindings"; \ - rm -f "$$binding" "$$real"; \ - exit 1; \ - fi - @binding=$$(mktemp) && real=$$(mktemp) && \ - go run ./checkabi Bridge isReservedDeposit | jq -S '. | sort_by(.name)' > "$$binding" && \ - jq -S '[.abi[] | select(.name == "isReservedDeposit")] | sort_by(.name)' ${artifacts_dir}/Bridge.json > "$$real" && \ - if diff -u "$$binding" "$$real"; then \ - echo "Bridge binding reservation methods match the real Bridge artifact"; \ - rm -f "$$binding" "$$real"; \ - else \ - echo "Bridge binding ABI (abi/Bridge.go) differs from the real Bridge artifact - regenerate the bindings"; \ - rm -f "$$binding" "$$real"; \ - exit 1; \ - fi - @binding=$$(mktemp) && real=$$(mktemp) && \ - go run ./checkabi WalletProposalValidator validateReservationAnchorProposal validateReservationReanchorProposal | jq -S '. | sort_by(.name)' > "$$binding" && \ - jq -S '[.abi[] | select(.name == "validateReservationAnchorProposal" or .name == "validateReservationReanchorProposal")] | sort_by(.name)' ${artifacts_dir}/WalletProposalValidator.json > "$$real" && \ - if diff -u "$$binding" "$$real"; then \ - echo "WalletProposalValidator binding reservation methods match the real artifact"; \ - rm -f "$$binding" "$$real"; \ - else \ - echo "WalletProposalValidator binding ABI (abi/WalletProposalValidator.go) differs from the real artifact - regenerate the bindings"; \ - rm -f "$$binding" "$$real"; \ - exit 1; \ - fi - @binding=$$(mktemp) && real=$$(mktemp) && \ - go run ./checkabi ReservationVault inKindFeeDebtSat tbtcToken | jq -S '. | sort_by(.name)' > "$$binding" && \ - jq -S '[.abi[] | select(.name == "inKindFeeDebtSat" or .name == "tbtcToken")] | sort_by(.name)' ${artifacts_dir}/ReservationVault.json > "$$real" && \ - if diff -u "$$binding" "$$real"; then \ - echo "ReservationVault binding reservation views match the real artifact"; \ - rm -f "$$binding" "$$real"; \ - else \ - echo "ReservationVault binding ABI (abi/ReservationVault.go) differs from the real artifact - regenerate the bindings"; \ - rm -f "$$binding" "$$real"; \ - exit 1; \ - fi + $(call verify_abi,ReservationRouter.fallback-artifact.json ABI,jq .abi ReservationRouter.fallback-artifact.json,ReservationRouter,.abi,regenerate the vendored fallback (see threshold-network/keep-core#4281)) + $(call verify_abi,Bridge.reservation-methods-fallback.json,cat Bridge.reservation-methods-fallback.json,Bridge,$(bridge_reservation_abi),regenerate the vendored fragment (see threshold-network/keep-core#4281)) + $(call verify_abi,WalletProposalValidator.reservation-methods-fallback.json,cat WalletProposalValidator.reservation-methods-fallback.json,WalletProposalValidator,$(wallet_proposal_validator_reservation_abi),regenerate the vendored fragment (see threshold-network/keep-core#4281)) + $(call verify_abi,ReservationVault.fallback-artifact.json,jq .abi ReservationVault.fallback-artifact.json,ReservationVault,$(reservation_vault_abi),regenerate the vendored fallback (see threshold-network/keep-core#4281)) + $(call verify_abi,ReservationRouter binding ABI (abi/ReservationRouter.go),go run ./checkabi ReservationRouter,ReservationRouter,.abi,regenerate the bindings) + $(call verify_abi,Bridge binding ABI (abi/Bridge.go),go run ./checkabi Bridge isReservedDeposit,Bridge,$(bridge_reservation_abi),regenerate the bindings) + $(call verify_abi,WalletProposalValidator binding ABI (abi/WalletProposalValidator.go),go run ./checkabi WalletProposalValidator validateReservationAnchorProposal validateReservationReanchorProposal,WalletProposalValidator,$(wallet_proposal_validator_reservation_abi),regenerate the bindings) + $(call verify_abi,ReservationVault binding ABI (abi/ReservationVault.go),go run ./checkabi ReservationVault inKindFeeDebtSat tbtcToken,ReservationVault,$(reservation_vault_abi),regenerate the bindings) # @keep-network/tbtc-v2@development on npm publishes Bridge.json and # WalletProposalValidator.json, but both are stale relative to the diff --git a/pkg/chain/ethereum/tbtc/gen/checkabi/main.go b/pkg/chain/ethereum/tbtc/gen/checkabi/main.go index f8c5fd1deb..ecc664eba3 100644 --- a/pkg/chain/ethereum/tbtc/gen/checkabi/main.go +++ b/pkg/chain/ethereum/tbtc/gen/checkabi/main.go @@ -42,7 +42,7 @@ func main() { if !ok { fmt.Fprintf( os.Stderr, - "checkabi: unknown contract %q (known: Bridge, ReservationRouter, ReservationVault, WalletProposalValidator)", + "checkabi: unknown contract %q (known: Bridge, ReservationRouter, ReservationVault, WalletProposalValidator)\n", name, ) os.Exit(2) @@ -130,10 +130,13 @@ func restoreParams(v any) { // internalTypeWithSpace re-inserts the space between the struct/enum/ // contract prefix and the qualified type name that abigen's packer -// strips. Non-prefixed internalType values pass through unchanged, so -// an entry that already has the space is a no-op. +// strips. Non-prefixed values and values that already have the space +// pass through unchanged. func internalTypeWithSpace(v string) string { for _, prefix := range []string{"struct", "enum", "contract"} { + if strings.HasPrefix(v, prefix+" ") { + return v + } if strings.HasPrefix(v, prefix) { return prefix + " " + strings.TrimPrefix(v, prefix) } diff --git a/pkg/chain/ethereum/tbtc_reservation_test.go b/pkg/chain/ethereum/tbtc_reservation_test.go index e29745b564..ffda1de417 100644 --- a/pkg/chain/ethereum/tbtc_reservation_test.go +++ b/pkg/chain/ethereum/tbtc_reservation_test.go @@ -4,9 +4,6 @@ package ethereum // Ethereum node, against a scripted fake client instead of a node or the // in-memory EVM harness: // -// - GetReservationReanchorRequestReceipt: the Mined/Reverted/NotFound -// mapping from TransactionReceipt results, error propagation for -// non-not-found RPC failures, and the bounded-lookup requirement. // - ReservationVaultFeeDebtSat / // ReservationVaultFeeReserveTbtcBaseUnits: that the gauges read the // vault's own on-chain values and that the fee reserve is the TBTC @@ -20,27 +17,25 @@ import ( "strings" "sync" "testing" - "time" hostchain "github.com/ethereum/go-ethereum" hostchainabi "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/accounts/keystore" "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" "github.com/keep-network/keep-common/pkg/chain/ethereum" "github.com/keep-network/keep-common/pkg/chain/ethereum/ethutil" + "github.com/keep-network/keep-core/pkg/chain" tbtcabi "github.com/keep-network/keep-core/pkg/chain/ethereum/tbtc/gen/abi" tbtccontract "github.com/keep-network/keep-core/pkg/chain/ethereum/tbtc/gen/contract" - "github.com/keep-network/keep-core/pkg/tbtc" ) // reservationFakeClient is a scripted ethutil.EthereumClient: view calls -// are answered from a per-(address, selector) table, TransactionReceipt is -// answered from canned fields, and every other method is promoted from the -// embedded nil interface and would panic if the code under test reached -// it - keeping the fake honest about the RPC surface actually exercised. +// are answered from a per-(address, selector) table, and every other +// method is promoted from the embedded nil interface and would panic if +// the code under test reached it - keeping the fake honest about the RPC +// surface actually exercised. type reservationFakeClient struct { ethutil.EthereumClient @@ -55,14 +50,6 @@ type reservationFakeClient struct { balances map[common.Address]*big.Int balanceOfTargets []common.Address balanceOfSel [4]byte - - // receiptResult / receiptErr are the canned TransactionReceipt - // answer; receiptCtx and receiptHash record the lookup's context and - // the hash it targeted. - receiptResult *types.Receipt - receiptErr error - receiptCtx context.Context - receiptHash common.Hash } func newReservationFakeClient(t *testing.T) *reservationFakeClient { @@ -149,17 +136,6 @@ func (c *reservationFakeClient) CallContract( return respond(), nil } -func (c *reservationFakeClient) TransactionReceipt( - ctx context.Context, - txHash common.Hash, -) (*types.Receipt, error) { - c.mu.Lock() - c.receiptCtx = ctx - c.receiptHash = txHash - c.mu.Unlock() - return c.receiptResult, c.receiptErr -} - // CodeAt answers the contract-code probe go-ethereum issues when a view // call returns zero bytes; the fake never does, so this is defensive. func (c *reservationFakeClient) CodeAt( @@ -259,121 +235,12 @@ func newReservationGaugeChain( } } -func TestGetReservationReanchorRequestReceipt(t *testing.T) { - var txHash [32]byte - for i := range txHash { - txHash[i] = byte(i + 1) - } - lookupHash := common.BytesToHash(txHash[:]) - - t.Run("successful receipt maps to Mined", func(t *testing.T) { - client := newReservationFakeClient(t) - client.receiptResult = &types.Receipt{Status: types.ReceiptStatusSuccessful} - chain := &TbtcChain{baseChain: &baseChain{client: client}} - - status, err := chain.GetReservationReanchorRequestReceipt(txHash) - if err != nil { - t.Fatalf("unexpected error: [%v]", err) - } - if status != tbtc.ReservationReanchorRequestReceiptMined { - t.Fatalf("expected Mined, got %v", status) - } - }) - - t.Run("failed receipt maps to Reverted", func(t *testing.T) { - client := newReservationFakeClient(t) - client.receiptResult = &types.Receipt{Status: types.ReceiptStatusFailed} - chain := &TbtcChain{baseChain: &baseChain{client: client}} - - status, err := chain.GetReservationReanchorRequestReceipt(txHash) - if err != nil { - t.Fatalf("unexpected error: [%v]", err) - } - if status != tbtc.ReservationReanchorRequestReceiptReverted { - t.Fatalf("expected Reverted, got %v", status) - } - }) - - t.Run("not-found receipt maps to NotFound without error", func(t *testing.T) { - client := newReservationFakeClient(t) - client.receiptErr = hostchain.NotFound - chain := &TbtcChain{baseChain: &baseChain{client: client}} - - status, err := chain.GetReservationReanchorRequestReceipt(txHash) - if err != nil { - t.Fatalf("expected no error for a not-found receipt, got [%v]", err) - } - if status != tbtc.ReservationReanchorRequestReceiptNotFound { - t.Fatalf("expected NotFound, got %v", status) - } - }) - - t.Run("nil receipt without error maps to NotFound", func(t *testing.T) { - client := newReservationFakeClient(t) - chain := &TbtcChain{baseChain: &baseChain{client: client}} - - status, err := chain.GetReservationReanchorRequestReceipt(txHash) - if err != nil { - t.Fatalf("expected no error, got [%v]", err) - } - if status != tbtc.ReservationReanchorRequestReceiptNotFound { - t.Fatalf("expected NotFound, got %v", status) - } - }) - - t.Run("transient RPC failure propagates", func(t *testing.T) { - rpcFailure := errors.New("provider RPC failed") - client := newReservationFakeClient(t) - client.receiptErr = rpcFailure - chain := &TbtcChain{baseChain: &baseChain{client: client}} - - _, err := chain.GetReservationReanchorRequestReceipt(txHash) - if err == nil { - t.Fatal("expected the transient RPC failure to propagate, got nil error") - } - if !errors.Is(err, rpcFailure) { - t.Fatalf("expected the wrapped error to match the RPC failure, got [%v]", err) - } - }) - - t.Run("lookup is bounded by a deadline and uses the given hash", func(t *testing.T) { - client := newReservationFakeClient(t) - client.receiptResult = &types.Receipt{Status: types.ReceiptStatusSuccessful} - chain := &TbtcChain{baseChain: &baseChain{client: client}} - - before := time.Now() - if _, err := chain.GetReservationReanchorRequestReceipt(txHash); err != nil { - t.Fatalf("unexpected error: [%v]", err) - } - if client.receiptCtx == nil { - t.Fatal("the receipt lookup context was never recorded") - } - deadline, ok := client.receiptCtx.Deadline() - if !ok { - t.Fatal("expected the receipt lookup to run under a bounded context") - } - if deadline.Before(before) || deadline.After(before.Add(31*time.Second)) { - t.Fatalf("deadline %v outside the [start, start+30s] window", deadline) - } - // The receipt lookup must have targeted the exact hash that was - // passed in. - if client.receiptHash != lookupHash { - t.Fatalf( - "receipt lookup targeted %s, want %s", - client.receiptHash.Hex(), - lookupHash.Hex(), - ) - } - }) -} - func TestReservationVaultFeeGaugesReadVaultValues(t *testing.T) { routerAddress := common.HexToAddress("0x00000000000000000000000000000000000000a1") vaultAddress := common.HexToAddress("0x00000000000000000000000000000000000000a2") tokenAddress := common.HexToAddress("0x00000000000000000000000000000000000000a3") otherAccount := common.HexToAddress("0x00000000000000000000000000000000000000a4") - routerABI := parseGenABI(t, tbtcabi.ReservationRouterABI) vaultABI := parseGenABI(t, tbtcabi.ReservationVaultABI) client := newReservationFakeClient(t) @@ -386,28 +253,9 @@ func TestReservationVaultFeeGaugesReadVaultValues(t *testing.T) { vaultTbtcBalance := new(big.Int).Lsh(big.NewInt(3), 18) // 3 TBTC otherTbtcBalance := new(big.Int).Lsh(big.NewInt(7), 18) // 7 TBTC - client.setView( - t, - routerAddress, - methodSelector(t, routerABI, "reservationParameters"), - func() []byte { - return packOutputs( - t, - routerABI, - "reservationParameters", - vaultAddress, - uint64(0), - uint64(0), - uint32(0), - uint32(0), - uint64(0), - uint64(0), - uint32(0), - uint32(0), - uint32(0), - ) - }, - ) + // The router's reservationParameters view is deliberately not + // scripted: the caller passes the vault address it already read, so + // the gauges must not re-read the reservation parameters. client.setView( t, vaultAddress, @@ -427,9 +275,10 @@ func TestReservationVaultFeeGaugesReadVaultValues(t *testing.T) { client.balances[vaultAddress] = vaultTbtcBalance client.balances[otherAccount] = otherTbtcBalance + vaultArg := chain.Address(vaultAddress.Hex()) chain := newReservationGaugeChain(t, client, routerAddress) - debt, err := chain.ReservationVaultFeeDebtSat() + debt, err := chain.ReservationVaultFeeDebtSat(vaultArg) if err != nil { t.Fatalf("unexpected error: [%v]", err) } @@ -437,7 +286,7 @@ func TestReservationVaultFeeGaugesReadVaultValues(t *testing.T) { t.Fatalf("expected the vault's in-kind fee debt %d, got %d", vaultDebtSat, debt) } - reserve, err := chain.ReservationVaultFeeReserveTbtcBaseUnits() + reserve, err := chain.ReservationVaultFeeReserveTbtcBaseUnits(vaultArg) if err != nil { t.Fatalf("unexpected error: [%v]", err) } @@ -463,37 +312,15 @@ func TestReservationVaultFeeGaugesReadVaultValues(t *testing.T) { func TestReservationVaultFeeGaugesSkipUnconfiguredVault(t *testing.T) { routerAddress := common.HexToAddress("0x00000000000000000000000000000000000000a1") - routerABI := parseGenABI(t, tbtcabi.ReservationRouterABI) client := newReservationFakeClient(t) - client.setView( - t, - routerAddress, - methodSelector(t, routerABI, "reservationParameters"), - func() []byte { - // Unconfigured vault: the reservation parameters carry the - // zero address, so the gauges must short-circuit before - // touching any vault or token view. - return packOutputs( - t, - routerABI, - "reservationParameters", - common.Address{}, - uint64(0), - uint64(0), - uint32(0), - uint32(0), - uint64(0), - uint64(0), - uint32(0), - uint32(0), - uint32(0), - ) - }, - ) + // Unconfigured vault: the reservation parameters carry the zero + // address, so the gauges must short-circuit before touching any vault + // or token view. + vaultArg := chain.Address(common.Address{}.Hex()) chain := newReservationGaugeChain(t, client, routerAddress) - debt, err := chain.ReservationVaultFeeDebtSat() + debt, err := chain.ReservationVaultFeeDebtSat(vaultArg) if err != nil { t.Fatalf("expected the unconfigured-vault skip, got error [%v]", err) } @@ -501,7 +328,7 @@ func TestReservationVaultFeeGaugesSkipUnconfiguredVault(t *testing.T) { t.Fatalf("expected the unconfigured-vault skip sentinel 0, got %d", debt) } - reserve, err := chain.ReservationVaultFeeReserveTbtcBaseUnits() + reserve, err := chain.ReservationVaultFeeReserveTbtcBaseUnits(vaultArg) if err != nil { t.Fatalf("expected the unconfigured-vault skip, got error [%v]", err) } diff --git a/pkg/chain/ethereum/tbtc_test.go b/pkg/chain/ethereum/tbtc_test.go index 7aa3953672..1fb25c780d 100644 --- a/pkg/chain/ethereum/tbtc_test.go +++ b/pkg/chain/ethereum/tbtc_test.go @@ -144,6 +144,7 @@ func TestConvertReservationFromAbiType(t *testing.T) { RetryCredit: true, DissolutionEligibleAt: 777, CumulativeReanchorFee: 888, // must not appear anywhere in the output + ReanchorCooldownUntil: 999, } expected := &tbtc.Reservation{ @@ -165,6 +166,7 @@ func TestConvertReservationFromAbiType(t *testing.T) { RequestNonce: 666, RetryCredit: true, DissolutionEligibleAt: 777, + ReanchorCooldownUntil: 999, } actual, err := convertReservationFromAbiType(abiReservation) @@ -201,6 +203,10 @@ func TestConvertReservationActionFromAbiType(t *testing.T) { ActionDataHash: actionDataHash, SourceAnchorUtxoHash: [32]byte{0x99, 0x88, 0x77, 0x66}, IsPartial: true, + // Distinct non-zero snapshots so a dropped or swapped mapping + // fails the comparison below instead of matching zero to zero. + TermSeconds: 1209600, + MinAmount: 25000, } // The action-type-to-hash-field routing (redemption -> redeemer output @@ -257,6 +263,8 @@ func TestConvertReservationActionFromAbiType(t *testing.T) { RedeemerOutputScriptHash: test.expectedRedeemerOutputScriptHash, ExpectedMainUtxoHash: test.expectedExpectedMainUtxoHash, IsPartial: true, + TermSeconds: 1209600, + MinAmount: 25000, } if !reflect.DeepEqual(expected, action) { diff --git a/pkg/chain/ethereum/tbtc_validator_harness_test.go b/pkg/chain/ethereum/tbtc_validator_harness_test.go index 64f9c2085f..d22f1c3702 100644 --- a/pkg/chain/ethereum/tbtc_validator_harness_test.go +++ b/pkg/chain/ethereum/tbtc_validator_harness_test.go @@ -68,7 +68,8 @@ var _ ethutil.EthereumClient = (*harnessBackend)(nil) // Enum values mirrored from tbtc-v2's Wallets.WalletState, Reservation.Action- // Type, and Reservation.ActionState (see StubBridge.sol's header comment). const ( - walletStateLive uint8 = 1 + walletStateLive uint8 = 1 + walletStateMovingFunds uint8 = 2 actionTypeAcceptance uint8 = 1 actionTypeReanchor uint8 = 3 @@ -76,6 +77,15 @@ const ( actionStatePending uint8 = 1 ) +// requestTimeoutSafetyMarginSeconds is the margin keep-core assumes the +// validator keeps before an action's timeoutAt: tbtcpg gates proposals on +// reservationRequestTimeoutSafetyMarginSeconds, which must equal tbtc-v2's +// WalletProposalValidatorConstants.REQUEST_TIMEOUT_SAFETY_MARGIN. The +// validator accepts only while block.timestamp < timeoutAt - margin, so +// the boundary tests below fail against the real bytecode if the on-chain +// margin ever moves away from this value. +const requestTimeoutSafetyMarginSeconds uint32 = 2 * 60 * 60 + // ----- Stub bridge struct mirrors ----- // // Field names must capitalize only the first letter of the corresponding @@ -208,11 +218,12 @@ const harnessBlockNumber uint64 = 1 // state persists) and exposes it through the keep-common // ethutil.EthereumClient interface. // -// An in-memory EVM over one shared StateDB is used instead of an -// ethclient/simulated node so that the real, unmodified validator -// bytecode runs to completion in the test binary without pulling in the -// simulated node's heavy dependencies, which the test binary cannot link -// on recent Go toolchains. +// An in-memory EVM over one shared StateDB is used instead of +// go-ethereum's ethclient/simulated backend because, at the pinned +// go-ethereum v1.13.15, that backend depends on github.com/fjl/memsize, +// whose //go:linkname reference to runtime.stopTheWorld the Go 1.23+ +// linker rejects ("invalid reference to runtime.stopTheWorld"): a test +// binary importing it does not link with this module's toolchain. // // The chain context is a fixed block number (harnessBlockNumber) and a // block timestamp captured at construction: the validator compares @@ -876,6 +887,120 @@ func TestValidateReservationAnchorProposal(t *testing.T) { ) mustContainError(t, err, "Anchor amount below the reservation minimum") }) + + // anchorSeed holds the inputs the cases below vary; the defaults set + // by validateAnchor describe a proposal the validator accepts. + type anchorSeed struct { + walletState uint8 + actionTarget [20]byte + timeoutAt uint32 + txMaxFee uint64 + anchorTxFee int64 + } + + // validateAnchor seeds a valid acceptance, lets adjust change one + // input, and runs the real validator. now is the harness block + // timestamp the validator compares against, not a fresh time.Now(), + // so boundary values are exact. + validateAnchor := func( + t *testing.T, + adjust func(now uint32, s *anchorSeed), + ) error { + h := newValidatorHarness(t) + now := uint32(h.backend.timestamp) + + s := anchorSeed{ + walletState: walletStateLive, + actionTarget: walletPubKeyHash, + timeoutAt: now + uint32((24 * time.Hour).Seconds()), + txMaxFee: 20_000, + anchorTxFee: 10_000, + } + adjust(now, &s) + + deposit, fundingTx, depositKey := buildFundingDeposit( + t, depositor, walletPubKeyHash, refundPubKeyHash, + now+uint32((60*24*time.Hour).Seconds()), 1_000_000, + ) + + h.setReservationParameters(stubParameters{ReservationVault: vault}) + h.setWallet(walletPubKeyHash, stubWallet{State: s.walletState}) + h.setDeposit(depositKey, stubDepositReq{ + Depositor: depositor, + Amount: 1_000_000, + RevealedAt: now - uint32((8 * time.Hour).Seconds()), + Vault: vault, + }) + h.setReservedDeposit(depositKey, true) + h.setReservationAction(depositKey, 1, stubAction{ + TargetWalletPubKeyHash: s.actionTarget, + RequestedAt: now - 3600, + TimeoutAt: s.timeoutAt, + TxMaxFee: s.txMaxFee, + ActionType: actionTypeAcceptance, + State: actionStatePending, + MinAmount: 100_000, + }) + h.commit() + + proposal := &tbtc.ReservationAnchorProposal{ + DepositFundingTxHash: fundingTx.Hash(), + DepositFundingOutputIndex: 0, + RequestNonce: 1, + AnchorTxFee: big.NewInt(s.anchorTxFee), + } + + return h.tc.ValidateReservationAnchorProposal( + walletPubKeyHash, proposal, depositExtraInfoOf(deposit, fundingTx), + ) + } + + t.Run("action timing out exactly at the safety margin is rejected", func(t *testing.T) { + // The validator's check is strict (block.timestamp < timeoutAt - + // margin), so a proposal exactly at the margin is already too + // late; keep-core must not treat it as signable. + err := validateAnchor(t, func(now uint32, s *anchorSeed) { + s.timeoutAt = now + requestTimeoutSafetyMarginSeconds + }) + mustContainError(t, err, "Acceptance action has timed out") + }) + + t.Run("action timing out one second past the safety margin is accepted", func(t *testing.T) { + err := validateAnchor(t, func(now uint32, s *anchorSeed) { + s.timeoutAt = now + requestTimeoutSafetyMarginSeconds + 1 + }) + if err != nil { + t.Fatalf("unexpected validation error: [%v]", err) + } + }) + + t.Run("wallet in MovingFunds state is accepted", func(t *testing.T) { + // Acceptance must keep working while a wallet moves funds, or + // reserved deposits on a draining wallet could never be anchored. + err := validateAnchor(t, func(now uint32, s *anchorSeed) { + s.walletState = walletStateMovingFunds + }) + if err != nil { + t.Fatalf("unexpected validation error: [%v]", err) + } + }) + + t.Run("fee above the action's snapshotted max fee", func(t *testing.T) { + err := validateAnchor(t, func(now uint32, s *anchorSeed) { + s.anchorTxFee = int64(s.txMaxFee) + 1 + }) + mustContainError(t, err, "Proposed transaction fee is too high") + }) + + t.Run("wallet does not match the authorized action", func(t *testing.T) { + // The proposal's wallet is Live and controls the deposit, but the + // action authorizes another wallet: only the action's target may + // anchor the reservation. + err := validateAnchor(t, func(now uint32, s *anchorSeed) { + s.actionTarget = [20]byte{0xee, 0x01} + }) + mustContainError(t, err, "Wallet does not match the authorized action") + }) } func TestValidateReservationReanchorProposal(t *testing.T) { @@ -972,4 +1097,97 @@ func TestValidateReservationReanchorProposal(t *testing.T) { ) mustContainError(t, err, "Target wallet does not match the authorized action") }) + + // reanchorSeed holds the inputs the cases below vary; the defaults + // set by validateReanchor describe a proposal the validator accepts. + type reanchorSeed struct { + timeoutAt uint32 + maxReservationsPerWallet uint32 + targetReservationsCount uint32 + } + + // validateReanchor seeds a valid re-anchor, lets adjust change one + // input, and runs the real validator. now is the harness block + // timestamp, so boundary values are exact. + validateReanchor := func( + t *testing.T, + adjust func(now uint32, s *reanchorSeed), + ) error { + h := newValidatorHarness(t) + now := uint32(h.backend.timestamp) + + s := reanchorSeed{ + timeoutAt: now + uint32((24 * time.Hour).Seconds()), + maxReservationsPerWallet: 10, + targetReservationsCount: 1, + } + adjust(now, &s) + + h.setReservation(reservationKey, stubResReq{ + WalletPubKeyHash: sourceWalletPubKeyHash, + }) + h.setReservationAction(reservationKey, 1, stubAction{ + TargetWalletPubKeyHash: targetWalletPubKeyHash, + RequestedAt: now - 3600, + TimeoutAt: s.timeoutAt, + TxMaxFee: 10_000, + ActionType: actionTypeReanchor, + State: actionStatePending, + }) + h.setWallet(targetWalletPubKeyHash, stubWallet{State: walletStateLive}) + h.setReservationParameters(stubParameters{ + MaxReservationsPerWallet: s.maxReservationsPerWallet, + }) + h.setWalletReservationsCount( + targetWalletPubKeyHash, s.targetReservationsCount, + ) + h.commit() + + return h.tc.ValidateReservationReanchorProposal( + sourceWalletPubKeyHash, + &tbtc.ReservationReanchorProposal{ + ReservationKey: reservationKey, + RequestNonce: 1, + TargetWalletPublicKeyHash: targetWalletPubKeyHash, + ReanchorTxFee: big.NewInt(5_000), + }, + ) + } + + t.Run("action timing out exactly at the safety margin is rejected", func(t *testing.T) { + // Strict check, as for acceptance: exactly at the margin is too + // late. + err := validateReanchor(t, func(now uint32, s *reanchorSeed) { + s.timeoutAt = now + requestTimeoutSafetyMarginSeconds + }) + mustContainError(t, err, "Re-anchor action has timed out") + }) + + t.Run("action timing out one second past the safety margin is accepted", func(t *testing.T) { + err := validateReanchor(t, func(now uint32, s *reanchorSeed) { + s.timeoutAt = now + requestTimeoutSafetyMarginSeconds + 1 + }) + if err != nil { + t.Fatalf("unexpected validation error: [%v]", err) + } + }) + + t.Run("target wallet count at the cap is accepted", func(t *testing.T) { + // requestReservationReanchor already counted this reservation + // against the target wallet, so at signing time a count equal to + // the cap is the normal state of a full wallet, not an overflow. + err := validateReanchor(t, func(now uint32, s *reanchorSeed) { + s.targetReservationsCount = s.maxReservationsPerWallet + }) + if err != nil { + t.Fatalf("unexpected validation error: [%v]", err) + } + }) + + t.Run("target wallet count above the cap is rejected", func(t *testing.T) { + err := validateReanchor(t, func(now uint32, s *reanchorSeed) { + s.targetReservationsCount = s.maxReservationsPerWallet + 1 + }) + mustContainError(t, err, "Wallet reservations cap exceeded") + }) } diff --git a/pkg/chain/ethereum/testdata/walletproposalvalidator/WalletProposalValidator.json b/pkg/chain/ethereum/testdata/walletproposalvalidator/WalletProposalValidator.json index 7753364808..a5d9b2b26f 100644 --- a/pkg/chain/ethereum/testdata/walletproposalvalidator/WalletProposalValidator.json +++ b/pkg/chain/ethereum/testdata/walletproposalvalidator/WalletProposalValidator.json @@ -530,6 +530,6 @@ "type": "function" } ], - "bytecode": "0x60a06040523480156200001157600080fd5b50604051620057ce380380620057ce833981016040819052620000349162000046565b6001600160a01b031660805262000078565b6000602082840312156200005957600080fd5b81516001600160a01b03811681146200007157600080fd5b9392505050565b60805161568b62000143600039600081816101b401528181610221015281816104110152818161059b0152818161077001528181610b9501528181610f5f0152818161110201528181611476015281816116c901528181611935015281816119af01528181611d7101528181611e9c01528181611f3101528181612032015281816120df015281816122e7015281816124b80152818161264c015281816128170152818161297501528181612bf601528181612d990152818161328001526137a8015261568b6000f3fe608060405234801561001057600080fd5b50600436106100ea5760003560e01c8063a57f734f1161008c578063e0276b2611610066578063e0276b2614610152578063e78cea92146101af578063f03a4bc5146101ee578063f9179ad21461020157600080fd5b8063a57f734f14610180578063d09520f714610193578063d6c7fa781461019c57600080fd5b80637405d7bf116100c85780637405d7bf14610135578063765f28f914610148578063996a756b146101525780639b337db21461016d57600080fd5b80630538ac1b146100ef5780630fde6c76146101125780636b562cec146100ef575b600080fd5b6100f8611c2081565b60405163ffffffff90911681526020015b60405180910390f35b61012561012036600461419c565b610214565b6040519015158152602001610109565b6101256101433660046141eb565b610b88565b6100f86201518081565b61015a601481565b60405161ffff9091168152602001610109565b61012561017b366004614285565b611469565b61012561018e3660046142e6565b6118f6565b6100f861025881565b6101256101aa366004614302565b6122da565b6101d67f000000000000000000000000000000000000000000000000000000000000000081565b6040516001600160a01b039091168152602001610109565b6101256101fc36600461431e565b6127a7565b61012561020f366004614359565b612812565b6000806001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d561025360208601866143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa158015610299573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906102bd9190614509565b905060018160e0015160058111156102d7576102d76145ac565b14806102f8575060028160e0015160058111156102f6576102f66145ac565b145b61035c5760405162461bcd60e51b815260206004820152602a60248201527f57616c6c6574206973206e6f7420696e204c697665206f72204d6f76696e6746604482015269756e647320737461746560b01b60648201526084015b60405180910390fd5b600061036b60208501856145c2565b915050806103bb5760405162461bcd60e51b815260206004820152601d60248201527f526564656d7074696f6e2062656c6f7720746865206d696e2073697a650000006044820152606401610353565b601481111561040c5760405162461bcd60e51b815260206004820152601f60248201527f526564656d7074696f6e206578636565647320746865206d61782073697a65006044820152606401610353565b6000807f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316636e70ce416040518163ffffffff1660e01b815260040160e060405180830381865afa15801561046d573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906104919190614628565b50509450945050505060008660400135116104fe5760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b8167ffffffffffffffff16866040013511156105685760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b60006105788460408901356146d6565b905060008461058b8360408b0135614700565b6105959190614713565b905060007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316635f3281ca6040518163ffffffff1660e01b8152600401602060405180830381865afa1580156105f7573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061061b919061473e565b905060008667ffffffffffffffff811115610638576106386143ee565b604051908082528060200260200182016040528015610661578160200160208202803683370190505b50905060005b87811015610b7757600061067e60208d018d6145c2565b8381811061068e5761068e614759565b90506020028101906106a0919061476f565b8080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509050600081805190602001208d600001602081019061070291906143d1565b6040516020016107269291909182526001600160601b031916602082015260340190565b60408051808303601f190181529082905280516020909101207f03d952f70000000000000000000000000000000000000000000000000000000082526004820181905291506000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906303d952f79060240160a060405180830381865afa1580156107bf573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906107e391906147b6565b9050806080015163ffffffff1660000361083f5760405162461bcd60e51b815260206004820181905260248201527f4e6f7420612070656e64696e6720726564656d7074696f6e20726571756573746044820152606401610353565b6102586001600160a01b038716156108f2576040517f0df5db70000000000000000000000000000000000000000000000000000000008152600481018490526000906001600160a01b03891690630df5db7090602401602060405180830381865afa1580156108b2573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906108d6919061484c565b90508163ffffffff168163ffffffff1611156108f0578091505b505b8082608001516109029190614869565b63ffffffff16421161097c5760405162461bcd60e51b815260206004820152602b60248201527f526564656d7074696f6e2072657175657374206d696e20616765206e6f74206160448201527f63686965766564207965740000000000000000000000000000000000000000006064820152608401610353565b60008a836080015161098e9190614869565b905061099c611c208261488d565b63ffffffff164210610a165760405162461bcd60e51b815260206004820152603960248201527f526564656d7074696f6e20726571756573742074696d656f757420736166657460448201527f79206d617267696e206973206e6f7420707265736572766564000000000000006064820152608401610353565b88610a2260018f614700565b8703610a3557610a328b826148aa565b90505b836060015167ffffffffffffffff16811115610ab95760405162461bcd60e51b815260206004820152603660248201527f50726f706f736564207472616e73616374696f6e207065722d7265717565737460448201527f2066656520736861726520697320746f6f2068696768000000000000000000006064820152608401610353565b60005b87811015610b3e5785898281518110610ad757610ad7614759565b602002602001015103610b2c5760405162461bcd60e51b815260206004820152601260248201527f4475706c696361746564207265717565737400000000000000000000000000006044820152606401610353565b80610b36816148bd565b915050610abc565b5084888881518110610b5257610b52614759565b6020026020010181815250505050505050508080610b6f906148bd565b915050610667565b5060019a9950505050505050505050565b6000806001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d5610bc760208801886143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa158015610c0d573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610c319190614509565b905060018160e001516005811115610c4b57610c4b6145ac565b1480610c6c575060028160e001516005811115610c6a57610c6a6145ac565b145b610ccb5760405162461bcd60e51b815260206004820152602a60248201527f57616c6c6574206973206e6f7420696e204c697665206f72204d6f76696e6746604482015269756e647320737461746560b01b6064820152608401610353565b6000610cda60208701876148d6565b905011610d295760405162461bcd60e51b815260206004820152601860248201527f53776565702062656c6f7720746865206d696e2073697a6500000000000000006044820152606401610353565b6014610d3860208701876148d6565b90501115610d885760405162461bcd60e51b815260206004820152601a60248201527f5377656570206578636565647320746865206d61782073697a650000000000006044820152606401610353565b82610d9660208701876148d6565b905014610e0b5760405162461bcd60e51b815260206004820152602e60248201527f45616368206465706f736974206b6579206d7573742068617665206d6174636860448201527f696e6720657874726120696e666f0000000000000000000000000000000000006064820152608401610353565b610e276040860135610e2060208801886148d6565b90506131f5565b600080610e3760208801886148d6565b905067ffffffffffffffff811115610e5157610e516143ee565b604051908082528060200260200182016040528015610e7a578160200160208202803683370190505b50905060005b610e8d60208901896148d6565b9050811015611459576000610ea560208a018a6148d6565b83818110610eb557610eb5614759565b905060400201803603810190610ecb9190614920565b90506000888884818110610ee157610ee1614759565b9050602002810190610ef39190614976565b610efc90614a53565b8251602080850151604051939450600093610f2e93920191825260e01b6001600160e01b031916602082015260240190565b60408051808303601f19018152908290528051602090910120630b02c43d60e41b82526004820181905291506000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063b02c43d09060240160e060405180830381865afa158015610fae573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610fd29190614b55565b9050806040015163ffffffff1660000361102e5760405162461bcd60e51b815260206004820152601460248201527f4465706f736974206e6f742072657665616c65640000000000000000000000006044820152606401610353565b611c2081604001516110409190614869565b63ffffffff1642116110945760405162461bcd60e51b815260206004820181905260248201527f4465706f736974206d696e20616765206e6f74206163686965766564207965746044820152606401610353565b60a081015163ffffffff16156110ec5760405162461bcd60e51b815260206004820152601560248201527f4465706f73697420616c726561647920737765707400000000000000000000006044820152606401610353565b6040516393df529b60e01b8152600481018390527f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906393df529b90602401602060405180830381865afa158015611151573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906111759190614c18565b156111e85760405162461bcd60e51b815260206004820152602360248201527f5265736572766564206465706f73697473206d757374206e6f7420626520737760448201527f65707400000000000000000000000000000000000000000000000000000000006064820152608401610353565b6111fc8482600001518360c001518661337c565b608083015160e881901c62ff00ff1663ff00ff0060d89290921c9190911617601081811c91901b17611231620151808261488d565b63ffffffff16421061129b5760405162461bcd60e51b815260206004820152602d60248201527f4465706f73697420726566756e6420736166657479206d617267696e2069732060448201526c1b9bdd081c1c995cd95c9d9959609a1b6064820152608401610353565b6112a860208e018e6143d1565b6bffffffffffffffffffffffff191684604001516bffffffffffffffffffffffff1916146113275760405162461bcd60e51b815260206004820152602660248201527f4465706f73697420636f6e74726f6c6c656420627920646966666572656e74206044820152651dd85b1b195d60d21b6064820152608401610353565b8560000361133757816060015197505b876001600160a01b031682606001516001600160a01b03161461139c5760405162461bcd60e51b815260206004820152601f60248201527f4465706f736974207461726765747320646966666572656e74207661756c74006044820152606401610353565b60005b8681101561142157838882815181106113ba576113ba614759565b60200260200101510361140f5760405162461bcd60e51b815260206004820152601260248201527f4475706c696361746564206465706f73697400000000000000000000000000006044820152606401610353565b80611419816148bd565b91505061139f565b508287878151811061143557611435614759565b60200260200101818152505050505050508080611451906148bd565b915050610e80565b50600193505050505b9392505050565b6000806001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d56114a860208701876143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa1580156114ee573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906115129190614509565b905060028160e00151600581111561152c5761152c6145ac565b1461159f5760405162461bcd60e51b815260206004820152602960248201527f536f757263652077616c6c6574206973206e6f7420696e204d6f76696e67467560448201527f6e647320737461746500000000000000000000000000000000000000000000006064820152608401610353565b6101008101516116175760405162461bcd60e51b815260206004820152602a60248201527f5461726765742077616c6c65747320636f6d6d69746d656e74206973206e6f7460448201527f207375626d6974746564000000000000000000000000000000000000000000006064820152608401610353565b61162460208501856145c2565b604051602001611635929190614c33565b60405160208183030381529060405280519060200120816101000151146116c45760405162461bcd60e51b815260206004820152603a60248201527f5461726765742077616c6c65747320646f206e6f74206d61746368207461726760448201527f65742077616c6c65747320636f6d6d69746d656e7420686173680000000000006064820152608401610353565b6000807f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663be05abe36040518163ffffffff1660e01b815260040161016060405180830381865afa158015611726573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061174a9190614c76565b50505050505050505091509150600061176784602001518761361a565b90508167ffffffffffffffff168167ffffffffffffffff1610156118195760405162461bcd60e51b815260206004820152604260248201527f536f757263652077616c6c6574204254432062616c616e63652069732062656c60448201527f6f7720746865206d6f76696e672066756e64732064757374207468726573686f60648201527f6c64000000000000000000000000000000000000000000000000000000000000608482015260a401610353565b600087604001351161187d5760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b8267ffffffffffffffff16876040013511156118e75760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b60019450505050505b92915050565b6040517f067cf8320000000000000000000000000000000000000000000000000000000081526020820135600482015260009081906001600160a01b037f0000000000000000000000000000000000000000000000000000000000000000169063067cf832906024016101c060405180830381865afa15801561197d573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906119a19190614d73565b905060006001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663cec8c6e960208601356119e96060880160408901614e7c565b6040516001600160e01b031960e085901b168152600481019290925267ffffffffffffffff16602482015260440161028060405180830381865afa158015611a35573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611a599190614e99565b9050600381608001516004811115611a7357611a736145ac565b14611ac05760405162461bcd60e51b815260206004820152601e60248201527f4e6f7420612070656e64696e672072652d616e63686f7220616374696f6e00006044820152606401610353565b60018160a001516005811115611ad857611ad86145ac565b14611b255760405162461bcd60e51b815260206004820152601f60248201527f52652d616e63686f7220616374696f6e206973206e6f742070656e64696e67006044820152606401610353565b611c208160400151611b37919061488d565b63ffffffff164210611b8b5760405162461bcd60e51b815260206004820152601e60248201527f52652d616e63686f7220616374696f6e206861732074696d6564206f757400006044820152606401610353565b611b9b60808501606086016143d1565b81516001600160601b0319908116911614611c1e5760405162461bcd60e51b815260206004820152603260248201527f5461726765742077616c6c657420646f6573206e6f74206d617463682074686560448201527f20617574686f72697a656420616374696f6e00000000000000000000000000006064820152608401610353565b611c2b60208501856143d1565b6bffffffffffffffffffffffff191682606001516bffffffffffffffffffffffff191614611cc15760405162461bcd60e51b815260206004820152602960248201527f5265736572766174696f6e20637573746f64696564206279206469666665726560448201527f6e742077616c6c657400000000000000000000000000000000000000000000006064820152608401610353565b611cce60208501856143d1565b6001600160601b031916611ce860808601606087016143d1565b6001600160601b03191603611d655760405162461bcd60e51b815260206004820152603060248201527f5461726765742077616c6c6574206d757374206469666665722066726f6d207460448201527f686520736f757263652077616c6c6574000000000000000000000000000000006064820152608401610353565b60016001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d5611da660808801606089016143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa158015611dec573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611e109190614509565b60e001516005811115611e2557611e256145ac565b14611e985760405162461bcd60e51b815260206004820152602360248201527f5461726765742077616c6c6574206d75737420626520696e204c69766520737460448201527f61746500000000000000000000000000000000000000000000000000000000006064820152608401610353565b60007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663f75b4b1c6040518163ffffffff1660e01b815260040161014060405180830381865afa158015611ef9573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611f1d9190614ffc565b50509750505050505050508063ffffffff167f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316631de0555a876060016020810190611f7191906143d1565b6040516001600160e01b031960e084901b1681526001600160601b03199091166004820152602401602060405180830381865afa158015611fb6573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611fda919061484c565b63ffffffff16111561202e5760405162461bcd60e51b815260206004820181905260248201527f57616c6c6574207265736572766174696f6e73206361702065786365656465646044820152606401610353565b60007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03166363dfb29c6040518163ffffffff1660e01b8152600401606060405180830381865afa15801561208e573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906120b291906150d2565b505090508067ffffffffffffffff166000148061218a575067ffffffffffffffff81166001600160a01b037f0000000000000000000000000000000000000000000000000000000000000000166363481e9861211460808a0160608b016143d1565b6040516001600160e01b031960e084901b1681526001600160601b03199091166004820152602401602060405180830381865afa158015612159573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061217d919061511f565b67ffffffffffffffff1611155b6121fc5760405162461bcd60e51b815260206004820152602360248201527f57616c6c657420726573657276656420616d6f756e742063617020657863656560448201527f64656400000000000000000000000000000000000000000000000000000000006064820152608401610353565b60008660800135116122605760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b826060015167ffffffffffffffff16866080013511156122ce5760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b50600195945050505050565b6000806001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d561231960208601866143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa15801561235f573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906123839190614509565b905060018160e00151600581111561239d5761239d6145ac565b14806123be575060028160e0015160058111156123bc576123bc6145ac565b145b6124305760405162461bcd60e51b815260206004820152603160248201527f536f757263652077616c6c6574206973206e6f7420696e204c697665206f722060448201527f4d6f76696e6746756e64732073746174650000000000000000000000000000006064820152608401610353565b60006020840135612447606086016040870161513c565b60405160200161246e92919091825260e01b6001600160e01b031916602082015260240190565b60408051808303601f190181529082905280516020909101207ff18cf1b10000000000000000000000000000000000000000000000000000000082526004820181905291506000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063f18cf1b190602401608060405180830381865afa158015612507573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061252b9190615159565b9050600181606001516003811115612545576125456145ac565b146125b85760405162461bcd60e51b815260206004820152602560248201527f53776565702072657175657374206973206e6f7420696e2050656e64696e672060448201527f73746174650000000000000000000000000000000000000000000000000000006064820152608401610353565b6125c560208601866143d1565b81516001600160601b03199081169116146126485760405162461bcd60e51b815260206004820152602b60248201527f5377656570207265717565737420646f6573206e6f742062656c6f6e6720746f60448201527f207468652077616c6c65740000000000000000000000000000000000000000006064820152608401610353565b60007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663be05abe36040518163ffffffff1660e01b815260040161016060405180830381865afa1580156126a9573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906126cd9190614c76565b505050975050505050505050600086606001351161273d5760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b8067ffffffffffffffff16866060013511156122ce5760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b60006127be6127b9602084018461476f565b6136f5565b61280a5760405162461bcd60e51b815260206004820152601d60248201527f4e6f7420612076616c696420686561727462656174206d6573736167650000006044820152606401610353565b506001919050565b6000807f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663f75b4b1c6040518163ffffffff1660e01b815260040161014060405180830381865afa158015612874573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906128989190614ffc565b50979850506001600160a01b03881696506128fc955050505050505760405162461bcd60e51b815260206004820152601960248201527f5265736572766174696f6e73206172652064697361626c6564000000000000006044820152606401610353565b61291161290c60208601866143d1565b61376d565b60006020850135612928606087016040880161513c565b60405160200161294f92919091825260e01b6001600160e01b031916602082015260240190565b60408051601f198184030181529190528051602090910120905060006001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663cec8c6e9836129ab60808a0160608b01614e7c565b6040516001600160e01b031960e085901b168152600481019290925267ffffffffffffffff16602482015260440161028060405180830381865afa1580156129f7573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612a1b9190614e99565b9050600181608001516004811115612a3557612a356145ac565b14612a825760405162461bcd60e51b815260206004820152601f60248201527f4e6f7420612070656e64696e6720616363657074616e636520616374696f6e006044820152606401610353565b60018160a001516005811115612a9a57612a9a6145ac565b14612ae75760405162461bcd60e51b815260206004820181905260248201527f416363657074616e636520616374696f6e206973206e6f742070656e64696e676044820152606401610353565b611c208160400151612af9919061488d565b63ffffffff164210612b4d5760405162461bcd60e51b815260206004820152601f60248201527f416363657074616e636520616374696f6e206861732074696d6564206f7574006044820152606401610353565b612b5a60208701876143d1565b81516001600160601b0319908116911614612bdd5760405162461bcd60e51b815260206004820152602b60248201527f57616c6c657420646f6573206e6f74206d617463682074686520617574686f7260448201527f697a656420616374696f6e0000000000000000000000000000000000000000006064820152608401610353565b604051630b02c43d60e41b8152600481018390526000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063b02c43d09060240160e060405180830381865afa158015612c45573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612c699190614b55565b9050806040015163ffffffff16600003612cc55760405162461bcd60e51b815260206004820152601460248201527f4465706f736974206e6f742072657665616c65640000000000000000000000006044820152606401610353565b611c208160400151612cd79190614869565b63ffffffff164211612d2b5760405162461bcd60e51b815260206004820181905260248201527f4465706f736974206d696e20616765206e6f74206163686965766564207965746044820152606401610353565b60a081015163ffffffff1615612d835760405162461bcd60e51b815260206004820152601560248201527f4465706f73697420616c726561647920737765707400000000000000000000006044820152606401610353565b6040516393df529b60e01b8152600481018490527f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906393df529b90602401602060405180830381865afa158015612de8573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612e0c9190614c18565b612e7d5760405162461bcd60e51b8152602060048201526024808201527f4465706f73697420776173206e6f742072657665616c6564206173207265736560448201527f72766564000000000000000000000000000000000000000000000000000000006064820152608401610353565b836001600160a01b031681606001516001600160a01b031614612f085760405162461bcd60e51b815260206004820152602b60248201527f4465706f736974206e6f7420726f7574656420746f207468652072657365727660448201527f6174696f6e207661756c740000000000000000000000000000000000000000006064820152608401610353565b6000876080013511612f6c5760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b816060015167ffffffffffffffff1687608001351115612fda5760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b866080013582610260015167ffffffffffffffff16612ff991906148aa565b816020015167ffffffffffffffff16101561307c5760405162461bcd60e51b815260206004820152602b60248201527f416e63686f7220616d6f756e742062656c6f772074686520726573657276617460448201527f696f6e206d696e696d756d0000000000000000000000000000000000000000006064820152608401610353565b6130a661309136899003890160208a01614920565b825160c08401516130a18a614a53565b61337c565b60006130e16130bb60a0890160808a016151e2565b60d881901c63ff00ff001662ff00ff60e89290921c9190911617601081811b91901c1790565b905063ffffffff81166130f762015180426148aa565b1061315a5760405162461bcd60e51b815260206004820152602d60248201527f4465706f73697420726566756e6420736166657479206d617267696e2069732060448201526c1b9bdd081c1c995cd95c9d9959609a1b6064820152608401610353565b61316760208901896143d1565b6001600160601b0319166131816060890160408a016143d1565b6001600160601b031916146131e75760405162461bcd60e51b815260206004820152602660248201527f4465706f73697420636f6e74726f6c6c656420627920646966666572656e74206044820152651dd85b1b195d60d21b6064820152608401610353565b506001979650505050505050565b600082116132555760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b600061326182846146d6565b90506000826132708386614700565b61327a9190614713565b905060007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663c42b64d06040518163ffffffff1660e01b8152600401608060405180830381865afa1580156132dc573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061330091906151fd565b5092505067ffffffffffffffff8216905061331b84846148aa565b11156133755760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b5050505050565b8051805160208083015160408085015160609095015190516000956133be956133aa9590949391920161528c565b6040516020818303038152906040526138b6565b855190915081146134375760405162461bcd60e51b815260206004820152602960248201527f457874726120696e666f2066756e64696e67207478206861736820646f65732060448201527f6e6f74206d6174636800000000000000000000000000000000000000000000006064820152608401610353565b60608361347a57602080840151604080860151606087015160808801519251613464958b959491016152c2565b60405160208183030381529060405290506134b7565b848484602001518560400151866060015187608001516040516020016134a5969594939291906154d6565b60405160208183030381529060405290505b60006134de876020015163ffffffff168560000151604001516138dd90919063ffffffff16565b905060006134eb82613abf565b905080516014148015613523575061350283613ad9565b6001600160601b031916613517826000613b00565b6001600160601b031916145b156135315750505050613614565b80516020148015613598575060028360405161354d91906155fe565b602060405180830381855afa15801561356a573d6000803e3d6000fd5b5050506040513d601f19601f8201168201806040525081019061358d919061560a565b61359682613b0f565b145b156135a65750505050613614565b60405162461bcd60e51b815260206004820152602f60248201527f457874726120696e666f2066756e64696e67206f75747075742073637269707460448201527f20646f6573206e6f74206d6174636800000000000000000000000000000000006064820152608401610353565b50505050565b600082156118f057828235613635604085016020860161513c565b6136456060860160408701614e7c565b6040516020016136829392919092835260e09190911b6001600160e01b031916602083015260c01b6001600160c01b0319166024820152602c0190565b60405160208183030381529060405280519060200120146136e55760405162461bcd60e51b815260206004820152601d60248201527f496e76616c69642077616c6c6574206d61696e205554584f20646174610000006044820152606401610353565b6114626060830160408401614e7c565b600060108214613707575060006118f0565b61374b600084848080601f0160208091040260200160405190810160405280939291908181526020018383808284376000920191909152509293925050613b009050565b6001600160c01b031990811614613764575060006118f0565b50600192915050565b6040517fe65e19d50000000000000000000000000000000000000000000000000000000081526001600160601b0319821660048201526000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063e65e19d59060240161012060405180830381865afa1580156137f8573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061381c9190614509565b60e0015190506001816005811115613836576138366145ac565b148061385357506002816005811115613851576138516145ac565b145b6138b25760405162461bcd60e51b815260206004820152602a60248201527f57616c6c6574206973206e6f7420696e204c697665206f72204d6f76696e6746604482015269756e647320737461746560b01b6064820152608401610353565b5050565b60006020600083516020850160025afa50602060006020600060025afa5050600051919050565b60606000806138eb85613b2a565b9092509050600182016139665760405162461bcd60e51b815260206004820152602260248201527f52656164206f76657272756e20647572696e6720566172496e7420706172736960448201527f6e670000000000000000000000000000000000000000000000000000000000006064820152608401610353565b8084106139b55760405162461bcd60e51b815260206004820152601160248201527f566f75742072656164206f76657272756e0000000000000000000000000000006044820152606401610353565b6000806139c38460016148aa565b905060005b86811015613a4b576139da8883613b41565b92506000198303613a2d5760405162461bcd60e51b815260206004820152601a60248201527f42616420566172496e7420696e207363726970745075626b65790000000000006044820152606401610353565b613a3783836148aa565b915080613a43816148bd565b9150506139c8565b50613a568782613b41565b91506000198203613aa95760405162461bcd60e51b815260206004820152601a60248201527f42616420566172496e7420696e207363726970745075626b65790000000000006044820152606401610353565b613ab4878284613bae565b979650505050505050565b60606118f0826008808551613ad49190614700565b613c7b565b60006020600083516020850160025afa50602060006020600060035afa5050600c51919050565b60006114628383016020015190565b60008151600003613b2257506000919050565b506020015190565b600080613b38836000613f80565b91509150915091565b6000613b4e8260096148aa565b83511015613b5f57506000196118f0565b600080613b7685613b718660086148aa565b613f80565b909250905060018201613b8f57600019925050506118f0565b80613b9b8360096148aa565b613ba591906148aa565b95945050505050565b606081600003613bcd5750604080516020810190915260008152611462565b6000613bd983856148aa565b90508381118015613beb575080855110155b613c375760405162461bcd60e51b815260206004820152601360248201527f536c696365206f7574206f6620626f756e6473000000000000000000000000006044820152606401610353565b604051915082604083010160405282825283850182038460208701018481015b80821015613c7057815183830152602082019150613c57565b505050509392505050565b60606000848481518110613c9157613c91614759565b016020015160f81c905082613ca7826001615623565b60ff1614613cc5575050604080516020810190915260008152611462565b84613cd18560016148aa565b81518110613ce157613ce1614759565b016020015160f81c600003613da05760028160ff161015613d12575050604080516020810190915260008152611462565b600085613d208660026148aa565b81518110613d3057613d30614759565b016020015160f81c9050613d4560028361563c565b60ff1681141580613d63575080602014158015613d63575080601414155b15613d81576040518060200160405280600081525092505050611462565b613d97613d8f8660036148aa565b879083613bae565b92505050611462565b6000613dac8686613b00565b90507fffffff000000000000000000000000000000000000000000000000000000000081167f1976a9000000000000000000000000000000000000000000000000000000000003613ec05785613e038660036148aa565b81518110613e1357613e13614759565b60209101015160f81c6014141580613e8b5750613e466002613e3586886148aa565b613e3f9190614700565b8790613b00565b7dffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff19167f88ac00000000000000000000000000000000000000000000000000000000000014155b15613ea9576040518060200160405280600081525092505050611462565b613d97613eb78660046148aa565b87906014613bae565b7fffffff000000000000000000000000000000000000000000000000000000000081167f17a914000000000000000000000000000000000000000000000000000000000003613f6757856001613f1686886148aa565b613f209190614700565b81518110613f3057613f30614759565b60209101015160f81c608714613f59576040518060200160405280600081525092505050611462565b613d97613eb78660036148aa565b5050506040805160208101909152600081529392505050565b6000806000613f8f85856140fe565b90508060ff16600003613fc4576000858581518110613fb057613fb0614759565b016020015190935060f81c91506140f79050565b83613fd0826001615623565b60ff16613fdd91906148aa565b85511015613ff457600019600092509250506140f7565b60008160ff166002036140385761402d6140196140128760016148aa565b8890613b00565b62ffff0060e882901c1660f89190911c1790565b61ffff1690506140ed565b8160ff16600403614061576140546130bb6140128760016148aa565b63ffffffff1690506140ed565b8160ff166008036140ed576140e061407d6140128760016148aa565b60c01c64ff000000ff600882811c91821665ff000000ff009390911b92831617601090811b67ffffffffffffffff1666ff00ff00ff00ff9290921667ff00ff00ff00ff009093169290921790911c65ffff0000ffff1617602081811c91901b1790565b67ffffffffffffffff1690505b60ff909116925090505b9250929050565b600082828151811061411257614112614759565b016020015160f81c60ff03614129575060086118f0565b82828151811061413b5761413b614759565b016020015160f81c60fe03614152575060046118f0565b82828151811061416457614164614759565b016020015160f81c60fd0361417b575060026118f0565b50600092915050565b60006060828403121561419657600080fd5b50919050565b6000602082840312156141ae57600080fd5b813567ffffffffffffffff8111156141c557600080fd5b6141d184828501614184565b949350505050565b60006080828403121561419657600080fd5b60008060006040848603121561420057600080fd5b833567ffffffffffffffff8082111561421857600080fd5b614224878388016141d9565b9450602086013591508082111561423a57600080fd5b818601915086601f83011261424e57600080fd5b81358181111561425d57600080fd5b8760208260051b850101111561427257600080fd5b6020830194508093505050509250925092565b6000806080838503121561429857600080fd5b823567ffffffffffffffff8111156142af57600080fd5b6142bb85828601614184565b9250506142cb8460208501614184565b90509250929050565b600060a0828403121561419657600080fd5b600060a082840312156142f857600080fd5b61146283836142d4565b60006080828403121561431457600080fd5b61146283836141d9565b60006020828403121561433057600080fd5b813567ffffffffffffffff81111561434757600080fd5b82016040818503121561146257600080fd5b60008060c0838503121561436c57600080fd5b61437684846142d4565b915060a083013567ffffffffffffffff81111561439257600080fd5b61439e858286016142d4565b9150509250929050565b6001600160601b0319811681146143be57600080fd5b50565b80356143cc816143a8565b919050565b6000602082840312156143e357600080fd5b8135611462816143a8565b634e487b7160e01b600052604160045260246000fd5b604051610120810167ffffffffffffffff81118282101715614428576144286143ee565b60405290565b60405160a0810167ffffffffffffffff81118282101715614428576144286143ee565b6040516080810167ffffffffffffffff81118282101715614428576144286143ee565b6040516101c0810167ffffffffffffffff81118282101715614428576144286143ee565b604051610280810167ffffffffffffffff81118282101715614428576144286143ee565b67ffffffffffffffff811681146143be57600080fd5b80516143cc816144bc565b63ffffffff811681146143be57600080fd5b80516143cc816144dd565b8051600681106143cc57600080fd5b6000610120828403121561451c57600080fd5b614524614404565b825181526020830151602082015261453e604084016144d2565b604082015261454f606084016144ef565b6060820152614560608084016144ef565b608082015261457160a084016144ef565b60a082015261458260c084016144ef565b60c082015261459360e084016144fa565b60e0820152610100928301519281019290925250919050565b634e487b7160e01b600052602160045260246000fd5b6000808335601e198436030181126145d957600080fd5b83018035915067ffffffffffffffff8211156145f457600080fd5b6020019150600581901b36038213156140f757600080fd5b80516bffffffffffffffffffffffff811681146143cc57600080fd5b600080600080600080600060e0888a03121561464357600080fd5b875161464e816144bc565b602089015190975061465f816144bc565b6040890151909650614670816144bc565b6060890151909550614681816144bc565b6080890151909450614692816144dd565b92506146a060a0890161460c565b915060c08801516146b0816144dd565b8091505092959891949750929550565b634e487b7160e01b600052601260045260246000fd5b6000826146e5576146e56146c0565b500690565b634e487b7160e01b600052601160045260246000fd5b818103818111156118f0576118f06146ea565b600082614722576147226146c0565b500490565b80516001600160a01b03811681146143cc57600080fd5b60006020828403121561475057600080fd5b61146282614727565b634e487b7160e01b600052603260045260246000fd5b6000808335601e1984360301811261478657600080fd5b83018035915067ffffffffffffffff8211156147a157600080fd5b6020019150368190038213156140f757600080fd5b600060a082840312156147c857600080fd5b60405160a0810181811067ffffffffffffffff821117156147eb576147eb6143ee565b6040526147f783614727565b81526020830151614807816144bc565b6020820152604083015161481a816144bc565b6040820152606083015161482d816144bc565b60608201526080830151614840816144dd565b60808201529392505050565b60006020828403121561485e57600080fd5b8151611462816144dd565b63ffffffff818116838216019080821115614886576148866146ea565b5092915050565b63ffffffff828116828216039080821115614886576148866146ea565b808201808211156118f0576118f06146ea565b6000600182016148cf576148cf6146ea565b5060010190565b6000808335601e198436030181126148ed57600080fd5b83018035915067ffffffffffffffff82111561490857600080fd5b6020019150600681901b36038213156140f757600080fd5b60006040828403121561493257600080fd5b6040516040810181811067ffffffffffffffff82111715614955576149556143ee565b60405282358152602083013561496a816144dd565b60208201529392505050565b60008235609e1983360301811261498c57600080fd5b9190910192915050565b80356001600160e01b0319811681146143cc57600080fd5b600082601f8301126149bf57600080fd5b813567ffffffffffffffff808211156149da576149da6143ee565b604051601f8301601f19908116603f01168101908282118183101715614a0257614a026143ee565b81604052838152866020858801011115614a1b57600080fd5b836020870160208301376000602085830101528094505050505092915050565b80356001600160c01b0319811681146143cc57600080fd5b600060a08236031215614a6557600080fd5b614a6d61442e565b823567ffffffffffffffff80821115614a8557600080fd5b818501915060808236031215614a9a57600080fd5b614aa2614451565b614aab83614996565b8152602083013582811115614abf57600080fd5b614acb368286016149ae565b602083015250604083013582811115614ae357600080fd5b614aef368286016149ae565b604083015250614b0160608401614996565b6060820152835250614b17905060208401614a3b565b6020820152614b28604084016143c1565b6040820152614b39606084016143c1565b6060820152614b4a60808401614996565b608082015292915050565b600060e08284031215614b6757600080fd5b60405160e0810181811067ffffffffffffffff82111715614b8a57614b8a6143ee565b604052614b9683614727565b81526020830151614ba6816144bc565b60208201526040830151614bb9816144dd565b6040820152614bca60608401614727565b60608201526080830151614bdd816144bc565b608082015260a0830151614bf0816144dd565b60a082015260c0928301519281019290925250919050565b805180151581146143cc57600080fd5b600060208284031215614c2a57600080fd5b61146282614c08565b60008184825b85811015614c6b578135614c4c816143a8565b6001600160601b03191683526020928301929190910190600101614c39565b509095945050505050565b60008060008060008060008060008060006101608c8e031215614c9857600080fd5b8b51614ca3816144bc565b60208d0151909b50614cb4816144bc565b60408d0151909a50614cc5816144dd565b60608d0151909950614cd6816144dd565b9750614ce460808d0161460c565b965060a08c0151614cf4816144dd565b60c08d015190965061ffff81168114614d0c57600080fd5b9450614d1a60e08d016144d2565b9350614d296101008d016144ef565b9250614d386101208d0161460c565b9150614d476101408d016144ef565b90509295989b509295989b9093969950565b80516143cc816143a8565b8051600581106143cc57600080fd5b60006101c08284031215614d8657600080fd5b614d8e614474565b614d9783614727565b8152614da5602084016144d2565b6020820152614db6604084016144ef565b6040820152614dc760608401614d59565b6060820152614dd8608084016144d2565b6080820152614de960a084016144ef565b60a082015260c083015160c0820152614e0460e084016144ef565b60e0820152610100614e17818501614d64565b90820152610120614e298482016144d2565b90820152610140614e3b848201614c08565b90820152610160614e4d8482016144ef565b90820152610180614e5f8482016144d2565b908201526101a0614e718482016144ef565b908201529392505050565b600060208284031215614e8e57600080fd5b8135611462816144bc565b60006102808284031215614eac57600080fd5b614eb4614498565b614ebd83614d59565b8152614ecb602084016144ef565b6020820152614edc604084016144ef565b6040820152614eed606084016144d2565b6060820152614efe60808401614d64565b6080820152614f0f60a084016144fa565b60a0820152614f2060c08401614c08565b60c0820152614f3160e08401614727565b60e082015261010083810151908201526101208084015190820152610140614f5a8185016144d2565b90820152610160614f6c848201614c08565b90820152610180614f7e8482016144ef565b908201526101a0614f908482016144ef565b908201526101c0614fa28482016144ef565b908201526101e0614fb48482016144d2565b90820152610200614fc6848201614c08565b90820152610220614fd88482016144ef565b90820152610240614fea8482016144ef565b90820152610260614e718482016144d2565b6000806000806000806000806000806101408b8d03121561501c57600080fd5b6150258b614727565b995060208b0151615035816144bc565b60408c0151909950615046816144bc565b60608c0151909850615057816144dd565b60808c0151909750615068816144dd565b60a08c0151909650615079816144bc565b60c08c015190955061508a816144bc565b60e08c015190945061509b816144dd565b6101008c01519093506150ad816144dd565b6101208c01519092506150bf816144dd565b809150509295989b9194979a5092959850565b6000806000606084860312156150e757600080fd5b83516150f2816144bc565b6020850151909350615103816144bc565b6040850151909250615114816144dd565b809150509250925092565b60006020828403121561513157600080fd5b8151611462816144bc565b60006020828403121561514e57600080fd5b8135611462816144dd565b60006080828403121561516b57600080fd5b6040516080810181811067ffffffffffffffff8211171561518e5761518e6143ee565b604052825161519c816143a8565b815260208301516151ac816144bc565b602082015260408301516151bf816144dd565b60408201526060830151600481106151d657600080fd5b60608201529392505050565b6000602082840312156151f457600080fd5b61146282614996565b6000806000806080858703121561521357600080fd5b845161521e816144bc565b602086015190945061522f816144bc565b6040860151909350615240816144bc565b6060860151909250615251816144dd565b939692955090935050565b6000815160005b8181101561527d5760208185018101518683015201615263565b50600093019283525090919050565b60006001600160e01b031980871683526152b26152ac600485018861525c565b8661525c565b9316835250506004019392505050565b600560fa1b8152606086901b6001600160601b0319166001820152607560f81b6015820152600160fb1b60168201526001600160c01b031985166017820152607560f81b601f820152600061537a61536d615344602085015b7f7600000000000000000000000000000000000000000000000000000000000000815260010190565b7fa900000000000000000000000000000000000000000000000000000000000000815260010190565b600560fa1b815260010190565b6001600160601b031986168152608760f81b601482015261542361536d61534461531b6153fa6153d1601587015b7f6300000000000000000000000000000000000000000000000000000000000000815260010190565b7fac00000000000000000000000000000000000000000000000000000000000000815260010190565b7f6700000000000000000000000000000000000000000000000000000000000000815260010190565b6001600160601b0319861681529050601160fb1b601482015261546d601582015b7f0400000000000000000000000000000000000000000000000000000000000000815260010190565b6001600160e01b031985168152905060b160f81b60048201526154ca6154a16153d1600584015b607560f81b815260010190565b7f6800000000000000000000000000000000000000000000000000000000000000815260010190565b98975050505050505050565b600560fa1b81526001600160601b0319606088901b166001820152607560f81b60158201527f20000000000000000000000000000000000000000000000000000000000000006016820152600060178201878152607560f81b6020820152600160fb1b6021820152602281016001600160c01b0319881681529050607560f81b600882015261556d61536d6153446009840161531b565b6001600160601b0319871681529050608760f81b60148201526155a161536d61534461531b6153fa6153d1601587016153a8565b6001600160601b0319861681529050601160fb1b60148201526155c660158201615444565b6001600160e01b031985168152905060b160f81b60048201526155f16154a16153d160058401615494565b9998505050505050505050565b6000611462828461525c565b60006020828403121561561c57600080fd5b5051919050565b60ff81811683821601908111156118f0576118f06146ea565b60ff82811682821603908111156118f0576118f06146ea56fea2646970667358221220a42086f4bb2ae452b1d286f3cc21e41b37b8545fb3793839aa4a3eb5087af85364736f6c63430008110033", - "deployedBytecode": "0x608060405234801561001057600080fd5b50600436106100ea5760003560e01c8063a57f734f1161008c578063e0276b2611610066578063e0276b2614610152578063e78cea92146101af578063f03a4bc5146101ee578063f9179ad21461020157600080fd5b8063a57f734f14610180578063d09520f714610193578063d6c7fa781461019c57600080fd5b80637405d7bf116100c85780637405d7bf14610135578063765f28f914610148578063996a756b146101525780639b337db21461016d57600080fd5b80630538ac1b146100ef5780630fde6c76146101125780636b562cec146100ef575b600080fd5b6100f8611c2081565b60405163ffffffff90911681526020015b60405180910390f35b61012561012036600461419c565b610214565b6040519015158152602001610109565b6101256101433660046141eb565b610b88565b6100f86201518081565b61015a601481565b60405161ffff9091168152602001610109565b61012561017b366004614285565b611469565b61012561018e3660046142e6565b6118f6565b6100f861025881565b6101256101aa366004614302565b6122da565b6101d67f000000000000000000000000000000000000000000000000000000000000000081565b6040516001600160a01b039091168152602001610109565b6101256101fc36600461431e565b6127a7565b61012561020f366004614359565b612812565b6000806001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d561025360208601866143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa158015610299573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906102bd9190614509565b905060018160e0015160058111156102d7576102d76145ac565b14806102f8575060028160e0015160058111156102f6576102f66145ac565b145b61035c5760405162461bcd60e51b815260206004820152602a60248201527f57616c6c6574206973206e6f7420696e204c697665206f72204d6f76696e6746604482015269756e647320737461746560b01b60648201526084015b60405180910390fd5b600061036b60208501856145c2565b915050806103bb5760405162461bcd60e51b815260206004820152601d60248201527f526564656d7074696f6e2062656c6f7720746865206d696e2073697a650000006044820152606401610353565b601481111561040c5760405162461bcd60e51b815260206004820152601f60248201527f526564656d7074696f6e206578636565647320746865206d61782073697a65006044820152606401610353565b6000807f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316636e70ce416040518163ffffffff1660e01b815260040160e060405180830381865afa15801561046d573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906104919190614628565b50509450945050505060008660400135116104fe5760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b8167ffffffffffffffff16866040013511156105685760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b60006105788460408901356146d6565b905060008461058b8360408b0135614700565b6105959190614713565b905060007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316635f3281ca6040518163ffffffff1660e01b8152600401602060405180830381865afa1580156105f7573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061061b919061473e565b905060008667ffffffffffffffff811115610638576106386143ee565b604051908082528060200260200182016040528015610661578160200160208202803683370190505b50905060005b87811015610b7757600061067e60208d018d6145c2565b8381811061068e5761068e614759565b90506020028101906106a0919061476f565b8080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509050600081805190602001208d600001602081019061070291906143d1565b6040516020016107269291909182526001600160601b031916602082015260340190565b60408051808303601f190181529082905280516020909101207f03d952f70000000000000000000000000000000000000000000000000000000082526004820181905291506000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906303d952f79060240160a060405180830381865afa1580156107bf573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906107e391906147b6565b9050806080015163ffffffff1660000361083f5760405162461bcd60e51b815260206004820181905260248201527f4e6f7420612070656e64696e6720726564656d7074696f6e20726571756573746044820152606401610353565b6102586001600160a01b038716156108f2576040517f0df5db70000000000000000000000000000000000000000000000000000000008152600481018490526000906001600160a01b03891690630df5db7090602401602060405180830381865afa1580156108b2573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906108d6919061484c565b90508163ffffffff168163ffffffff1611156108f0578091505b505b8082608001516109029190614869565b63ffffffff16421161097c5760405162461bcd60e51b815260206004820152602b60248201527f526564656d7074696f6e2072657175657374206d696e20616765206e6f74206160448201527f63686965766564207965740000000000000000000000000000000000000000006064820152608401610353565b60008a836080015161098e9190614869565b905061099c611c208261488d565b63ffffffff164210610a165760405162461bcd60e51b815260206004820152603960248201527f526564656d7074696f6e20726571756573742074696d656f757420736166657460448201527f79206d617267696e206973206e6f7420707265736572766564000000000000006064820152608401610353565b88610a2260018f614700565b8703610a3557610a328b826148aa565b90505b836060015167ffffffffffffffff16811115610ab95760405162461bcd60e51b815260206004820152603660248201527f50726f706f736564207472616e73616374696f6e207065722d7265717565737460448201527f2066656520736861726520697320746f6f2068696768000000000000000000006064820152608401610353565b60005b87811015610b3e5785898281518110610ad757610ad7614759565b602002602001015103610b2c5760405162461bcd60e51b815260206004820152601260248201527f4475706c696361746564207265717565737400000000000000000000000000006044820152606401610353565b80610b36816148bd565b915050610abc565b5084888881518110610b5257610b52614759565b6020026020010181815250505050505050508080610b6f906148bd565b915050610667565b5060019a9950505050505050505050565b6000806001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d5610bc760208801886143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa158015610c0d573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610c319190614509565b905060018160e001516005811115610c4b57610c4b6145ac565b1480610c6c575060028160e001516005811115610c6a57610c6a6145ac565b145b610ccb5760405162461bcd60e51b815260206004820152602a60248201527f57616c6c6574206973206e6f7420696e204c697665206f72204d6f76696e6746604482015269756e647320737461746560b01b6064820152608401610353565b6000610cda60208701876148d6565b905011610d295760405162461bcd60e51b815260206004820152601860248201527f53776565702062656c6f7720746865206d696e2073697a6500000000000000006044820152606401610353565b6014610d3860208701876148d6565b90501115610d885760405162461bcd60e51b815260206004820152601a60248201527f5377656570206578636565647320746865206d61782073697a650000000000006044820152606401610353565b82610d9660208701876148d6565b905014610e0b5760405162461bcd60e51b815260206004820152602e60248201527f45616368206465706f736974206b6579206d7573742068617665206d6174636860448201527f696e6720657874726120696e666f0000000000000000000000000000000000006064820152608401610353565b610e276040860135610e2060208801886148d6565b90506131f5565b600080610e3760208801886148d6565b905067ffffffffffffffff811115610e5157610e516143ee565b604051908082528060200260200182016040528015610e7a578160200160208202803683370190505b50905060005b610e8d60208901896148d6565b9050811015611459576000610ea560208a018a6148d6565b83818110610eb557610eb5614759565b905060400201803603810190610ecb9190614920565b90506000888884818110610ee157610ee1614759565b9050602002810190610ef39190614976565b610efc90614a53565b8251602080850151604051939450600093610f2e93920191825260e01b6001600160e01b031916602082015260240190565b60408051808303601f19018152908290528051602090910120630b02c43d60e41b82526004820181905291506000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063b02c43d09060240160e060405180830381865afa158015610fae573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610fd29190614b55565b9050806040015163ffffffff1660000361102e5760405162461bcd60e51b815260206004820152601460248201527f4465706f736974206e6f742072657665616c65640000000000000000000000006044820152606401610353565b611c2081604001516110409190614869565b63ffffffff1642116110945760405162461bcd60e51b815260206004820181905260248201527f4465706f736974206d696e20616765206e6f74206163686965766564207965746044820152606401610353565b60a081015163ffffffff16156110ec5760405162461bcd60e51b815260206004820152601560248201527f4465706f73697420616c726561647920737765707400000000000000000000006044820152606401610353565b6040516393df529b60e01b8152600481018390527f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906393df529b90602401602060405180830381865afa158015611151573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906111759190614c18565b156111e85760405162461bcd60e51b815260206004820152602360248201527f5265736572766564206465706f73697473206d757374206e6f7420626520737760448201527f65707400000000000000000000000000000000000000000000000000000000006064820152608401610353565b6111fc8482600001518360c001518661337c565b608083015160e881901c62ff00ff1663ff00ff0060d89290921c9190911617601081811c91901b17611231620151808261488d565b63ffffffff16421061129b5760405162461bcd60e51b815260206004820152602d60248201527f4465706f73697420726566756e6420736166657479206d617267696e2069732060448201526c1b9bdd081c1c995cd95c9d9959609a1b6064820152608401610353565b6112a860208e018e6143d1565b6bffffffffffffffffffffffff191684604001516bffffffffffffffffffffffff1916146113275760405162461bcd60e51b815260206004820152602660248201527f4465706f73697420636f6e74726f6c6c656420627920646966666572656e74206044820152651dd85b1b195d60d21b6064820152608401610353565b8560000361133757816060015197505b876001600160a01b031682606001516001600160a01b03161461139c5760405162461bcd60e51b815260206004820152601f60248201527f4465706f736974207461726765747320646966666572656e74207661756c74006044820152606401610353565b60005b8681101561142157838882815181106113ba576113ba614759565b60200260200101510361140f5760405162461bcd60e51b815260206004820152601260248201527f4475706c696361746564206465706f73697400000000000000000000000000006044820152606401610353565b80611419816148bd565b91505061139f565b508287878151811061143557611435614759565b60200260200101818152505050505050508080611451906148bd565b915050610e80565b50600193505050505b9392505050565b6000806001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d56114a860208701876143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa1580156114ee573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906115129190614509565b905060028160e00151600581111561152c5761152c6145ac565b1461159f5760405162461bcd60e51b815260206004820152602960248201527f536f757263652077616c6c6574206973206e6f7420696e204d6f76696e67467560448201527f6e647320737461746500000000000000000000000000000000000000000000006064820152608401610353565b6101008101516116175760405162461bcd60e51b815260206004820152602a60248201527f5461726765742077616c6c65747320636f6d6d69746d656e74206973206e6f7460448201527f207375626d6974746564000000000000000000000000000000000000000000006064820152608401610353565b61162460208501856145c2565b604051602001611635929190614c33565b60405160208183030381529060405280519060200120816101000151146116c45760405162461bcd60e51b815260206004820152603a60248201527f5461726765742077616c6c65747320646f206e6f74206d61746368207461726760448201527f65742077616c6c65747320636f6d6d69746d656e7420686173680000000000006064820152608401610353565b6000807f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663be05abe36040518163ffffffff1660e01b815260040161016060405180830381865afa158015611726573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061174a9190614c76565b50505050505050505091509150600061176784602001518761361a565b90508167ffffffffffffffff168167ffffffffffffffff1610156118195760405162461bcd60e51b815260206004820152604260248201527f536f757263652077616c6c6574204254432062616c616e63652069732062656c60448201527f6f7720746865206d6f76696e672066756e64732064757374207468726573686f60648201527f6c64000000000000000000000000000000000000000000000000000000000000608482015260a401610353565b600087604001351161187d5760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b8267ffffffffffffffff16876040013511156118e75760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b60019450505050505b92915050565b6040517f067cf8320000000000000000000000000000000000000000000000000000000081526020820135600482015260009081906001600160a01b037f0000000000000000000000000000000000000000000000000000000000000000169063067cf832906024016101c060405180830381865afa15801561197d573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906119a19190614d73565b905060006001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663cec8c6e960208601356119e96060880160408901614e7c565b6040516001600160e01b031960e085901b168152600481019290925267ffffffffffffffff16602482015260440161028060405180830381865afa158015611a35573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611a599190614e99565b9050600381608001516004811115611a7357611a736145ac565b14611ac05760405162461bcd60e51b815260206004820152601e60248201527f4e6f7420612070656e64696e672072652d616e63686f7220616374696f6e00006044820152606401610353565b60018160a001516005811115611ad857611ad86145ac565b14611b255760405162461bcd60e51b815260206004820152601f60248201527f52652d616e63686f7220616374696f6e206973206e6f742070656e64696e67006044820152606401610353565b611c208160400151611b37919061488d565b63ffffffff164210611b8b5760405162461bcd60e51b815260206004820152601e60248201527f52652d616e63686f7220616374696f6e206861732074696d6564206f757400006044820152606401610353565b611b9b60808501606086016143d1565b81516001600160601b0319908116911614611c1e5760405162461bcd60e51b815260206004820152603260248201527f5461726765742077616c6c657420646f6573206e6f74206d617463682074686560448201527f20617574686f72697a656420616374696f6e00000000000000000000000000006064820152608401610353565b611c2b60208501856143d1565b6bffffffffffffffffffffffff191682606001516bffffffffffffffffffffffff191614611cc15760405162461bcd60e51b815260206004820152602960248201527f5265736572766174696f6e20637573746f64696564206279206469666665726560448201527f6e742077616c6c657400000000000000000000000000000000000000000000006064820152608401610353565b611cce60208501856143d1565b6001600160601b031916611ce860808601606087016143d1565b6001600160601b03191603611d655760405162461bcd60e51b815260206004820152603060248201527f5461726765742077616c6c6574206d757374206469666665722066726f6d207460448201527f686520736f757263652077616c6c6574000000000000000000000000000000006064820152608401610353565b60016001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d5611da660808801606089016143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa158015611dec573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611e109190614509565b60e001516005811115611e2557611e256145ac565b14611e985760405162461bcd60e51b815260206004820152602360248201527f5461726765742077616c6c6574206d75737420626520696e204c69766520737460448201527f61746500000000000000000000000000000000000000000000000000000000006064820152608401610353565b60007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663f75b4b1c6040518163ffffffff1660e01b815260040161014060405180830381865afa158015611ef9573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611f1d9190614ffc565b50509750505050505050508063ffffffff167f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316631de0555a876060016020810190611f7191906143d1565b6040516001600160e01b031960e084901b1681526001600160601b03199091166004820152602401602060405180830381865afa158015611fb6573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611fda919061484c565b63ffffffff16111561202e5760405162461bcd60e51b815260206004820181905260248201527f57616c6c6574207265736572766174696f6e73206361702065786365656465646044820152606401610353565b60007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03166363dfb29c6040518163ffffffff1660e01b8152600401606060405180830381865afa15801561208e573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906120b291906150d2565b505090508067ffffffffffffffff166000148061218a575067ffffffffffffffff81166001600160a01b037f0000000000000000000000000000000000000000000000000000000000000000166363481e9861211460808a0160608b016143d1565b6040516001600160e01b031960e084901b1681526001600160601b03199091166004820152602401602060405180830381865afa158015612159573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061217d919061511f565b67ffffffffffffffff1611155b6121fc5760405162461bcd60e51b815260206004820152602360248201527f57616c6c657420726573657276656420616d6f756e742063617020657863656560448201527f64656400000000000000000000000000000000000000000000000000000000006064820152608401610353565b60008660800135116122605760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b826060015167ffffffffffffffff16866080013511156122ce5760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b50600195945050505050565b6000806001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d561231960208601866143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa15801561235f573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906123839190614509565b905060018160e00151600581111561239d5761239d6145ac565b14806123be575060028160e0015160058111156123bc576123bc6145ac565b145b6124305760405162461bcd60e51b815260206004820152603160248201527f536f757263652077616c6c6574206973206e6f7420696e204c697665206f722060448201527f4d6f76696e6746756e64732073746174650000000000000000000000000000006064820152608401610353565b60006020840135612447606086016040870161513c565b60405160200161246e92919091825260e01b6001600160e01b031916602082015260240190565b60408051808303601f190181529082905280516020909101207ff18cf1b10000000000000000000000000000000000000000000000000000000082526004820181905291506000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063f18cf1b190602401608060405180830381865afa158015612507573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061252b9190615159565b9050600181606001516003811115612545576125456145ac565b146125b85760405162461bcd60e51b815260206004820152602560248201527f53776565702072657175657374206973206e6f7420696e2050656e64696e672060448201527f73746174650000000000000000000000000000000000000000000000000000006064820152608401610353565b6125c560208601866143d1565b81516001600160601b03199081169116146126485760405162461bcd60e51b815260206004820152602b60248201527f5377656570207265717565737420646f6573206e6f742062656c6f6e6720746f60448201527f207468652077616c6c65740000000000000000000000000000000000000000006064820152608401610353565b60007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663be05abe36040518163ffffffff1660e01b815260040161016060405180830381865afa1580156126a9573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906126cd9190614c76565b505050975050505050505050600086606001351161273d5760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b8067ffffffffffffffff16866060013511156122ce5760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b60006127be6127b9602084018461476f565b6136f5565b61280a5760405162461bcd60e51b815260206004820152601d60248201527f4e6f7420612076616c696420686561727462656174206d6573736167650000006044820152606401610353565b506001919050565b6000807f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663f75b4b1c6040518163ffffffff1660e01b815260040161014060405180830381865afa158015612874573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906128989190614ffc565b50979850506001600160a01b03881696506128fc955050505050505760405162461bcd60e51b815260206004820152601960248201527f5265736572766174696f6e73206172652064697361626c6564000000000000006044820152606401610353565b61291161290c60208601866143d1565b61376d565b60006020850135612928606087016040880161513c565b60405160200161294f92919091825260e01b6001600160e01b031916602082015260240190565b60408051601f198184030181529190528051602090910120905060006001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663cec8c6e9836129ab60808a0160608b01614e7c565b6040516001600160e01b031960e085901b168152600481019290925267ffffffffffffffff16602482015260440161028060405180830381865afa1580156129f7573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612a1b9190614e99565b9050600181608001516004811115612a3557612a356145ac565b14612a825760405162461bcd60e51b815260206004820152601f60248201527f4e6f7420612070656e64696e6720616363657074616e636520616374696f6e006044820152606401610353565b60018160a001516005811115612a9a57612a9a6145ac565b14612ae75760405162461bcd60e51b815260206004820181905260248201527f416363657074616e636520616374696f6e206973206e6f742070656e64696e676044820152606401610353565b611c208160400151612af9919061488d565b63ffffffff164210612b4d5760405162461bcd60e51b815260206004820152601f60248201527f416363657074616e636520616374696f6e206861732074696d6564206f7574006044820152606401610353565b612b5a60208701876143d1565b81516001600160601b0319908116911614612bdd5760405162461bcd60e51b815260206004820152602b60248201527f57616c6c657420646f6573206e6f74206d617463682074686520617574686f7260448201527f697a656420616374696f6e0000000000000000000000000000000000000000006064820152608401610353565b604051630b02c43d60e41b8152600481018390526000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063b02c43d09060240160e060405180830381865afa158015612c45573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612c699190614b55565b9050806040015163ffffffff16600003612cc55760405162461bcd60e51b815260206004820152601460248201527f4465706f736974206e6f742072657665616c65640000000000000000000000006044820152606401610353565b611c208160400151612cd79190614869565b63ffffffff164211612d2b5760405162461bcd60e51b815260206004820181905260248201527f4465706f736974206d696e20616765206e6f74206163686965766564207965746044820152606401610353565b60a081015163ffffffff1615612d835760405162461bcd60e51b815260206004820152601560248201527f4465706f73697420616c726561647920737765707400000000000000000000006044820152606401610353565b6040516393df529b60e01b8152600481018490527f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906393df529b90602401602060405180830381865afa158015612de8573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612e0c9190614c18565b612e7d5760405162461bcd60e51b8152602060048201526024808201527f4465706f73697420776173206e6f742072657665616c6564206173207265736560448201527f72766564000000000000000000000000000000000000000000000000000000006064820152608401610353565b836001600160a01b031681606001516001600160a01b031614612f085760405162461bcd60e51b815260206004820152602b60248201527f4465706f736974206e6f7420726f7574656420746f207468652072657365727660448201527f6174696f6e207661756c740000000000000000000000000000000000000000006064820152608401610353565b6000876080013511612f6c5760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b816060015167ffffffffffffffff1687608001351115612fda5760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b866080013582610260015167ffffffffffffffff16612ff991906148aa565b816020015167ffffffffffffffff16101561307c5760405162461bcd60e51b815260206004820152602b60248201527f416e63686f7220616d6f756e742062656c6f772074686520726573657276617460448201527f696f6e206d696e696d756d0000000000000000000000000000000000000000006064820152608401610353565b6130a661309136899003890160208a01614920565b825160c08401516130a18a614a53565b61337c565b60006130e16130bb60a0890160808a016151e2565b60d881901c63ff00ff001662ff00ff60e89290921c9190911617601081811b91901c1790565b905063ffffffff81166130f762015180426148aa565b1061315a5760405162461bcd60e51b815260206004820152602d60248201527f4465706f73697420726566756e6420736166657479206d617267696e2069732060448201526c1b9bdd081c1c995cd95c9d9959609a1b6064820152608401610353565b61316760208901896143d1565b6001600160601b0319166131816060890160408a016143d1565b6001600160601b031916146131e75760405162461bcd60e51b815260206004820152602660248201527f4465706f73697420636f6e74726f6c6c656420627920646966666572656e74206044820152651dd85b1b195d60d21b6064820152608401610353565b506001979650505050505050565b600082116132555760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b600061326182846146d6565b90506000826132708386614700565b61327a9190614713565b905060007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663c42b64d06040518163ffffffff1660e01b8152600401608060405180830381865afa1580156132dc573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061330091906151fd565b5092505067ffffffffffffffff8216905061331b84846148aa565b11156133755760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b5050505050565b8051805160208083015160408085015160609095015190516000956133be956133aa9590949391920161528c565b6040516020818303038152906040526138b6565b855190915081146134375760405162461bcd60e51b815260206004820152602960248201527f457874726120696e666f2066756e64696e67207478206861736820646f65732060448201527f6e6f74206d6174636800000000000000000000000000000000000000000000006064820152608401610353565b60608361347a57602080840151604080860151606087015160808801519251613464958b959491016152c2565b60405160208183030381529060405290506134b7565b848484602001518560400151866060015187608001516040516020016134a5969594939291906154d6565b60405160208183030381529060405290505b60006134de876020015163ffffffff168560000151604001516138dd90919063ffffffff16565b905060006134eb82613abf565b905080516014148015613523575061350283613ad9565b6001600160601b031916613517826000613b00565b6001600160601b031916145b156135315750505050613614565b80516020148015613598575060028360405161354d91906155fe565b602060405180830381855afa15801561356a573d6000803e3d6000fd5b5050506040513d601f19601f8201168201806040525081019061358d919061560a565b61359682613b0f565b145b156135a65750505050613614565b60405162461bcd60e51b815260206004820152602f60248201527f457874726120696e666f2066756e64696e67206f75747075742073637269707460448201527f20646f6573206e6f74206d6174636800000000000000000000000000000000006064820152608401610353565b50505050565b600082156118f057828235613635604085016020860161513c565b6136456060860160408701614e7c565b6040516020016136829392919092835260e09190911b6001600160e01b031916602083015260c01b6001600160c01b0319166024820152602c0190565b60405160208183030381529060405280519060200120146136e55760405162461bcd60e51b815260206004820152601d60248201527f496e76616c69642077616c6c6574206d61696e205554584f20646174610000006044820152606401610353565b6114626060830160408401614e7c565b600060108214613707575060006118f0565b61374b600084848080601f0160208091040260200160405190810160405280939291908181526020018383808284376000920191909152509293925050613b009050565b6001600160c01b031990811614613764575060006118f0565b50600192915050565b6040517fe65e19d50000000000000000000000000000000000000000000000000000000081526001600160601b0319821660048201526000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063e65e19d59060240161012060405180830381865afa1580156137f8573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061381c9190614509565b60e0015190506001816005811115613836576138366145ac565b148061385357506002816005811115613851576138516145ac565b145b6138b25760405162461bcd60e51b815260206004820152602a60248201527f57616c6c6574206973206e6f7420696e204c697665206f72204d6f76696e6746604482015269756e647320737461746560b01b6064820152608401610353565b5050565b60006020600083516020850160025afa50602060006020600060025afa5050600051919050565b60606000806138eb85613b2a565b9092509050600182016139665760405162461bcd60e51b815260206004820152602260248201527f52656164206f76657272756e20647572696e6720566172496e7420706172736960448201527f6e670000000000000000000000000000000000000000000000000000000000006064820152608401610353565b8084106139b55760405162461bcd60e51b815260206004820152601160248201527f566f75742072656164206f76657272756e0000000000000000000000000000006044820152606401610353565b6000806139c38460016148aa565b905060005b86811015613a4b576139da8883613b41565b92506000198303613a2d5760405162461bcd60e51b815260206004820152601a60248201527f42616420566172496e7420696e207363726970745075626b65790000000000006044820152606401610353565b613a3783836148aa565b915080613a43816148bd565b9150506139c8565b50613a568782613b41565b91506000198203613aa95760405162461bcd60e51b815260206004820152601a60248201527f42616420566172496e7420696e207363726970745075626b65790000000000006044820152606401610353565b613ab4878284613bae565b979650505050505050565b60606118f0826008808551613ad49190614700565b613c7b565b60006020600083516020850160025afa50602060006020600060035afa5050600c51919050565b60006114628383016020015190565b60008151600003613b2257506000919050565b506020015190565b600080613b38836000613f80565b91509150915091565b6000613b4e8260096148aa565b83511015613b5f57506000196118f0565b600080613b7685613b718660086148aa565b613f80565b909250905060018201613b8f57600019925050506118f0565b80613b9b8360096148aa565b613ba591906148aa565b95945050505050565b606081600003613bcd5750604080516020810190915260008152611462565b6000613bd983856148aa565b90508381118015613beb575080855110155b613c375760405162461bcd60e51b815260206004820152601360248201527f536c696365206f7574206f6620626f756e6473000000000000000000000000006044820152606401610353565b604051915082604083010160405282825283850182038460208701018481015b80821015613c7057815183830152602082019150613c57565b505050509392505050565b60606000848481518110613c9157613c91614759565b016020015160f81c905082613ca7826001615623565b60ff1614613cc5575050604080516020810190915260008152611462565b84613cd18560016148aa565b81518110613ce157613ce1614759565b016020015160f81c600003613da05760028160ff161015613d12575050604080516020810190915260008152611462565b600085613d208660026148aa565b81518110613d3057613d30614759565b016020015160f81c9050613d4560028361563c565b60ff1681141580613d63575080602014158015613d63575080601414155b15613d81576040518060200160405280600081525092505050611462565b613d97613d8f8660036148aa565b879083613bae565b92505050611462565b6000613dac8686613b00565b90507fffffff000000000000000000000000000000000000000000000000000000000081167f1976a9000000000000000000000000000000000000000000000000000000000003613ec05785613e038660036148aa565b81518110613e1357613e13614759565b60209101015160f81c6014141580613e8b5750613e466002613e3586886148aa565b613e3f9190614700565b8790613b00565b7dffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff19167f88ac00000000000000000000000000000000000000000000000000000000000014155b15613ea9576040518060200160405280600081525092505050611462565b613d97613eb78660046148aa565b87906014613bae565b7fffffff000000000000000000000000000000000000000000000000000000000081167f17a914000000000000000000000000000000000000000000000000000000000003613f6757856001613f1686886148aa565b613f209190614700565b81518110613f3057613f30614759565b60209101015160f81c608714613f59576040518060200160405280600081525092505050611462565b613d97613eb78660036148aa565b5050506040805160208101909152600081529392505050565b6000806000613f8f85856140fe565b90508060ff16600003613fc4576000858581518110613fb057613fb0614759565b016020015190935060f81c91506140f79050565b83613fd0826001615623565b60ff16613fdd91906148aa565b85511015613ff457600019600092509250506140f7565b60008160ff166002036140385761402d6140196140128760016148aa565b8890613b00565b62ffff0060e882901c1660f89190911c1790565b61ffff1690506140ed565b8160ff16600403614061576140546130bb6140128760016148aa565b63ffffffff1690506140ed565b8160ff166008036140ed576140e061407d6140128760016148aa565b60c01c64ff000000ff600882811c91821665ff000000ff009390911b92831617601090811b67ffffffffffffffff1666ff00ff00ff00ff9290921667ff00ff00ff00ff009093169290921790911c65ffff0000ffff1617602081811c91901b1790565b67ffffffffffffffff1690505b60ff909116925090505b9250929050565b600082828151811061411257614112614759565b016020015160f81c60ff03614129575060086118f0565b82828151811061413b5761413b614759565b016020015160f81c60fe03614152575060046118f0565b82828151811061416457614164614759565b016020015160f81c60fd0361417b575060026118f0565b50600092915050565b60006060828403121561419657600080fd5b50919050565b6000602082840312156141ae57600080fd5b813567ffffffffffffffff8111156141c557600080fd5b6141d184828501614184565b949350505050565b60006080828403121561419657600080fd5b60008060006040848603121561420057600080fd5b833567ffffffffffffffff8082111561421857600080fd5b614224878388016141d9565b9450602086013591508082111561423a57600080fd5b818601915086601f83011261424e57600080fd5b81358181111561425d57600080fd5b8760208260051b850101111561427257600080fd5b6020830194508093505050509250925092565b6000806080838503121561429857600080fd5b823567ffffffffffffffff8111156142af57600080fd5b6142bb85828601614184565b9250506142cb8460208501614184565b90509250929050565b600060a0828403121561419657600080fd5b600060a082840312156142f857600080fd5b61146283836142d4565b60006080828403121561431457600080fd5b61146283836141d9565b60006020828403121561433057600080fd5b813567ffffffffffffffff81111561434757600080fd5b82016040818503121561146257600080fd5b60008060c0838503121561436c57600080fd5b61437684846142d4565b915060a083013567ffffffffffffffff81111561439257600080fd5b61439e858286016142d4565b9150509250929050565b6001600160601b0319811681146143be57600080fd5b50565b80356143cc816143a8565b919050565b6000602082840312156143e357600080fd5b8135611462816143a8565b634e487b7160e01b600052604160045260246000fd5b604051610120810167ffffffffffffffff81118282101715614428576144286143ee565b60405290565b60405160a0810167ffffffffffffffff81118282101715614428576144286143ee565b6040516080810167ffffffffffffffff81118282101715614428576144286143ee565b6040516101c0810167ffffffffffffffff81118282101715614428576144286143ee565b604051610280810167ffffffffffffffff81118282101715614428576144286143ee565b67ffffffffffffffff811681146143be57600080fd5b80516143cc816144bc565b63ffffffff811681146143be57600080fd5b80516143cc816144dd565b8051600681106143cc57600080fd5b6000610120828403121561451c57600080fd5b614524614404565b825181526020830151602082015261453e604084016144d2565b604082015261454f606084016144ef565b6060820152614560608084016144ef565b608082015261457160a084016144ef565b60a082015261458260c084016144ef565b60c082015261459360e084016144fa565b60e0820152610100928301519281019290925250919050565b634e487b7160e01b600052602160045260246000fd5b6000808335601e198436030181126145d957600080fd5b83018035915067ffffffffffffffff8211156145f457600080fd5b6020019150600581901b36038213156140f757600080fd5b80516bffffffffffffffffffffffff811681146143cc57600080fd5b600080600080600080600060e0888a03121561464357600080fd5b875161464e816144bc565b602089015190975061465f816144bc565b6040890151909650614670816144bc565b6060890151909550614681816144bc565b6080890151909450614692816144dd565b92506146a060a0890161460c565b915060c08801516146b0816144dd565b8091505092959891949750929550565b634e487b7160e01b600052601260045260246000fd5b6000826146e5576146e56146c0565b500690565b634e487b7160e01b600052601160045260246000fd5b818103818111156118f0576118f06146ea565b600082614722576147226146c0565b500490565b80516001600160a01b03811681146143cc57600080fd5b60006020828403121561475057600080fd5b61146282614727565b634e487b7160e01b600052603260045260246000fd5b6000808335601e1984360301811261478657600080fd5b83018035915067ffffffffffffffff8211156147a157600080fd5b6020019150368190038213156140f757600080fd5b600060a082840312156147c857600080fd5b60405160a0810181811067ffffffffffffffff821117156147eb576147eb6143ee565b6040526147f783614727565b81526020830151614807816144bc565b6020820152604083015161481a816144bc565b6040820152606083015161482d816144bc565b60608201526080830151614840816144dd565b60808201529392505050565b60006020828403121561485e57600080fd5b8151611462816144dd565b63ffffffff818116838216019080821115614886576148866146ea565b5092915050565b63ffffffff828116828216039080821115614886576148866146ea565b808201808211156118f0576118f06146ea565b6000600182016148cf576148cf6146ea565b5060010190565b6000808335601e198436030181126148ed57600080fd5b83018035915067ffffffffffffffff82111561490857600080fd5b6020019150600681901b36038213156140f757600080fd5b60006040828403121561493257600080fd5b6040516040810181811067ffffffffffffffff82111715614955576149556143ee565b60405282358152602083013561496a816144dd565b60208201529392505050565b60008235609e1983360301811261498c57600080fd5b9190910192915050565b80356001600160e01b0319811681146143cc57600080fd5b600082601f8301126149bf57600080fd5b813567ffffffffffffffff808211156149da576149da6143ee565b604051601f8301601f19908116603f01168101908282118183101715614a0257614a026143ee565b81604052838152866020858801011115614a1b57600080fd5b836020870160208301376000602085830101528094505050505092915050565b80356001600160c01b0319811681146143cc57600080fd5b600060a08236031215614a6557600080fd5b614a6d61442e565b823567ffffffffffffffff80821115614a8557600080fd5b818501915060808236031215614a9a57600080fd5b614aa2614451565b614aab83614996565b8152602083013582811115614abf57600080fd5b614acb368286016149ae565b602083015250604083013582811115614ae357600080fd5b614aef368286016149ae565b604083015250614b0160608401614996565b6060820152835250614b17905060208401614a3b565b6020820152614b28604084016143c1565b6040820152614b39606084016143c1565b6060820152614b4a60808401614996565b608082015292915050565b600060e08284031215614b6757600080fd5b60405160e0810181811067ffffffffffffffff82111715614b8a57614b8a6143ee565b604052614b9683614727565b81526020830151614ba6816144bc565b60208201526040830151614bb9816144dd565b6040820152614bca60608401614727565b60608201526080830151614bdd816144bc565b608082015260a0830151614bf0816144dd565b60a082015260c0928301519281019290925250919050565b805180151581146143cc57600080fd5b600060208284031215614c2a57600080fd5b61146282614c08565b60008184825b85811015614c6b578135614c4c816143a8565b6001600160601b03191683526020928301929190910190600101614c39565b509095945050505050565b60008060008060008060008060008060006101608c8e031215614c9857600080fd5b8b51614ca3816144bc565b60208d0151909b50614cb4816144bc565b60408d0151909a50614cc5816144dd565b60608d0151909950614cd6816144dd565b9750614ce460808d0161460c565b965060a08c0151614cf4816144dd565b60c08d015190965061ffff81168114614d0c57600080fd5b9450614d1a60e08d016144d2565b9350614d296101008d016144ef565b9250614d386101208d0161460c565b9150614d476101408d016144ef565b90509295989b509295989b9093969950565b80516143cc816143a8565b8051600581106143cc57600080fd5b60006101c08284031215614d8657600080fd5b614d8e614474565b614d9783614727565b8152614da5602084016144d2565b6020820152614db6604084016144ef565b6040820152614dc760608401614d59565b6060820152614dd8608084016144d2565b6080820152614de960a084016144ef565b60a082015260c083015160c0820152614e0460e084016144ef565b60e0820152610100614e17818501614d64565b90820152610120614e298482016144d2565b90820152610140614e3b848201614c08565b90820152610160614e4d8482016144ef565b90820152610180614e5f8482016144d2565b908201526101a0614e718482016144ef565b908201529392505050565b600060208284031215614e8e57600080fd5b8135611462816144bc565b60006102808284031215614eac57600080fd5b614eb4614498565b614ebd83614d59565b8152614ecb602084016144ef565b6020820152614edc604084016144ef565b6040820152614eed606084016144d2565b6060820152614efe60808401614d64565b6080820152614f0f60a084016144fa565b60a0820152614f2060c08401614c08565b60c0820152614f3160e08401614727565b60e082015261010083810151908201526101208084015190820152610140614f5a8185016144d2565b90820152610160614f6c848201614c08565b90820152610180614f7e8482016144ef565b908201526101a0614f908482016144ef565b908201526101c0614fa28482016144ef565b908201526101e0614fb48482016144d2565b90820152610200614fc6848201614c08565b90820152610220614fd88482016144ef565b90820152610240614fea8482016144ef565b90820152610260614e718482016144d2565b6000806000806000806000806000806101408b8d03121561501c57600080fd5b6150258b614727565b995060208b0151615035816144bc565b60408c0151909950615046816144bc565b60608c0151909850615057816144dd565b60808c0151909750615068816144dd565b60a08c0151909650615079816144bc565b60c08c015190955061508a816144bc565b60e08c015190945061509b816144dd565b6101008c01519093506150ad816144dd565b6101208c01519092506150bf816144dd565b809150509295989b9194979a5092959850565b6000806000606084860312156150e757600080fd5b83516150f2816144bc565b6020850151909350615103816144bc565b6040850151909250615114816144dd565b809150509250925092565b60006020828403121561513157600080fd5b8151611462816144bc565b60006020828403121561514e57600080fd5b8135611462816144dd565b60006080828403121561516b57600080fd5b6040516080810181811067ffffffffffffffff8211171561518e5761518e6143ee565b604052825161519c816143a8565b815260208301516151ac816144bc565b602082015260408301516151bf816144dd565b60408201526060830151600481106151d657600080fd5b60608201529392505050565b6000602082840312156151f457600080fd5b61146282614996565b6000806000806080858703121561521357600080fd5b845161521e816144bc565b602086015190945061522f816144bc565b6040860151909350615240816144bc565b6060860151909250615251816144dd565b939692955090935050565b6000815160005b8181101561527d5760208185018101518683015201615263565b50600093019283525090919050565b60006001600160e01b031980871683526152b26152ac600485018861525c565b8661525c565b9316835250506004019392505050565b600560fa1b8152606086901b6001600160601b0319166001820152607560f81b6015820152600160fb1b60168201526001600160c01b031985166017820152607560f81b601f820152600061537a61536d615344602085015b7f7600000000000000000000000000000000000000000000000000000000000000815260010190565b7fa900000000000000000000000000000000000000000000000000000000000000815260010190565b600560fa1b815260010190565b6001600160601b031986168152608760f81b601482015261542361536d61534461531b6153fa6153d1601587015b7f6300000000000000000000000000000000000000000000000000000000000000815260010190565b7fac00000000000000000000000000000000000000000000000000000000000000815260010190565b7f6700000000000000000000000000000000000000000000000000000000000000815260010190565b6001600160601b0319861681529050601160fb1b601482015261546d601582015b7f0400000000000000000000000000000000000000000000000000000000000000815260010190565b6001600160e01b031985168152905060b160f81b60048201526154ca6154a16153d1600584015b607560f81b815260010190565b7f6800000000000000000000000000000000000000000000000000000000000000815260010190565b98975050505050505050565b600560fa1b81526001600160601b0319606088901b166001820152607560f81b60158201527f20000000000000000000000000000000000000000000000000000000000000006016820152600060178201878152607560f81b6020820152600160fb1b6021820152602281016001600160c01b0319881681529050607560f81b600882015261556d61536d6153446009840161531b565b6001600160601b0319871681529050608760f81b60148201526155a161536d61534461531b6153fa6153d1601587016153a8565b6001600160601b0319861681529050601160fb1b60148201526155c660158201615444565b6001600160e01b031985168152905060b160f81b60048201526155f16154a16153d160058401615494565b9998505050505050505050565b6000611462828461525c565b60006020828403121561561c57600080fd5b5051919050565b60ff81811683821601908111156118f0576118f06146ea565b60ff82811682821603908111156118f0576118f06146ea56fea2646970667358221220a42086f4bb2ae452b1d286f3cc21e41b37b8545fb3793839aa4a3eb5087af85364736f6c63430008110033" + "bytecode": "0x60a06040523480156200001157600080fd5b50604051620057ce380380620057ce833981016040819052620000349162000046565b6001600160a01b031660805262000078565b6000602082840312156200005957600080fd5b81516001600160a01b03811681146200007157600080fd5b9392505050565b60805161568b62000143600039600081816101b401528181610221015281816104110152818161059b0152818161077001528181610b9501528181610f5f0152818161110201528181611476015281816116c901528181611935015281816119af01528181611d7101528181611e9c01528181611f3101528181612032015281816120df015281816122e7015281816124b80152818161264c015281816128170152818161297501528181612bf601528181612d990152818161328001526137a8015261568b6000f3fe608060405234801561001057600080fd5b50600436106100ea5760003560e01c8063a57f734f1161008c578063e0276b2611610066578063e0276b2614610152578063e78cea92146101af578063f03a4bc5146101ee578063f9179ad21461020157600080fd5b8063a57f734f14610180578063d09520f714610193578063d6c7fa781461019c57600080fd5b80637405d7bf116100c85780637405d7bf14610135578063765f28f914610148578063996a756b146101525780639b337db21461016d57600080fd5b80630538ac1b146100ef5780630fde6c76146101125780636b562cec146100ef575b600080fd5b6100f8611c2081565b60405163ffffffff90911681526020015b60405180910390f35b61012561012036600461419c565b610214565b6040519015158152602001610109565b6101256101433660046141eb565b610b88565b6100f86201518081565b61015a601481565b60405161ffff9091168152602001610109565b61012561017b366004614285565b611469565b61012561018e3660046142e6565b6118f6565b6100f861025881565b6101256101aa366004614302565b6122da565b6101d67f000000000000000000000000000000000000000000000000000000000000000081565b6040516001600160a01b039091168152602001610109565b6101256101fc36600461431e565b6127a7565b61012561020f366004614359565b612812565b6000806001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d561025360208601866143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa158015610299573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906102bd9190614509565b905060018160e0015160058111156102d7576102d76145ac565b14806102f8575060028160e0015160058111156102f6576102f66145ac565b145b61035c5760405162461bcd60e51b815260206004820152602a60248201527f57616c6c6574206973206e6f7420696e204c697665206f72204d6f76696e6746604482015269756e647320737461746560b01b60648201526084015b60405180910390fd5b600061036b60208501856145c2565b915050806103bb5760405162461bcd60e51b815260206004820152601d60248201527f526564656d7074696f6e2062656c6f7720746865206d696e2073697a650000006044820152606401610353565b601481111561040c5760405162461bcd60e51b815260206004820152601f60248201527f526564656d7074696f6e206578636565647320746865206d61782073697a65006044820152606401610353565b6000807f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316636e70ce416040518163ffffffff1660e01b815260040160e060405180830381865afa15801561046d573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906104919190614628565b50509450945050505060008660400135116104fe5760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b8167ffffffffffffffff16866040013511156105685760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b60006105788460408901356146d6565b905060008461058b8360408b0135614700565b6105959190614713565b905060007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316635f3281ca6040518163ffffffff1660e01b8152600401602060405180830381865afa1580156105f7573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061061b919061473e565b905060008667ffffffffffffffff811115610638576106386143ee565b604051908082528060200260200182016040528015610661578160200160208202803683370190505b50905060005b87811015610b7757600061067e60208d018d6145c2565b8381811061068e5761068e614759565b90506020028101906106a0919061476f565b8080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509050600081805190602001208d600001602081019061070291906143d1565b6040516020016107269291909182526001600160601b031916602082015260340190565b60408051808303601f190181529082905280516020909101207f03d952f70000000000000000000000000000000000000000000000000000000082526004820181905291506000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906303d952f79060240160a060405180830381865afa1580156107bf573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906107e391906147b6565b9050806080015163ffffffff1660000361083f5760405162461bcd60e51b815260206004820181905260248201527f4e6f7420612070656e64696e6720726564656d7074696f6e20726571756573746044820152606401610353565b6102586001600160a01b038716156108f2576040517f0df5db70000000000000000000000000000000000000000000000000000000008152600481018490526000906001600160a01b03891690630df5db7090602401602060405180830381865afa1580156108b2573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906108d6919061484c565b90508163ffffffff168163ffffffff1611156108f0578091505b505b8082608001516109029190614869565b63ffffffff16421161097c5760405162461bcd60e51b815260206004820152602b60248201527f526564656d7074696f6e2072657175657374206d696e20616765206e6f74206160448201527f63686965766564207965740000000000000000000000000000000000000000006064820152608401610353565b60008a836080015161098e9190614869565b905061099c611c208261488d565b63ffffffff164210610a165760405162461bcd60e51b815260206004820152603960248201527f526564656d7074696f6e20726571756573742074696d656f757420736166657460448201527f79206d617267696e206973206e6f7420707265736572766564000000000000006064820152608401610353565b88610a2260018f614700565b8703610a3557610a328b826148aa565b90505b836060015167ffffffffffffffff16811115610ab95760405162461bcd60e51b815260206004820152603660248201527f50726f706f736564207472616e73616374696f6e207065722d7265717565737460448201527f2066656520736861726520697320746f6f2068696768000000000000000000006064820152608401610353565b60005b87811015610b3e5785898281518110610ad757610ad7614759565b602002602001015103610b2c5760405162461bcd60e51b815260206004820152601260248201527f4475706c696361746564207265717565737400000000000000000000000000006044820152606401610353565b80610b36816148bd565b915050610abc565b5084888881518110610b5257610b52614759565b6020026020010181815250505050505050508080610b6f906148bd565b915050610667565b5060019a9950505050505050505050565b6000806001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d5610bc760208801886143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa158015610c0d573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610c319190614509565b905060018160e001516005811115610c4b57610c4b6145ac565b1480610c6c575060028160e001516005811115610c6a57610c6a6145ac565b145b610ccb5760405162461bcd60e51b815260206004820152602a60248201527f57616c6c6574206973206e6f7420696e204c697665206f72204d6f76696e6746604482015269756e647320737461746560b01b6064820152608401610353565b6000610cda60208701876148d6565b905011610d295760405162461bcd60e51b815260206004820152601860248201527f53776565702062656c6f7720746865206d696e2073697a6500000000000000006044820152606401610353565b6014610d3860208701876148d6565b90501115610d885760405162461bcd60e51b815260206004820152601a60248201527f5377656570206578636565647320746865206d61782073697a650000000000006044820152606401610353565b82610d9660208701876148d6565b905014610e0b5760405162461bcd60e51b815260206004820152602e60248201527f45616368206465706f736974206b6579206d7573742068617665206d6174636860448201527f696e6720657874726120696e666f0000000000000000000000000000000000006064820152608401610353565b610e276040860135610e2060208801886148d6565b90506131f5565b600080610e3760208801886148d6565b905067ffffffffffffffff811115610e5157610e516143ee565b604051908082528060200260200182016040528015610e7a578160200160208202803683370190505b50905060005b610e8d60208901896148d6565b9050811015611459576000610ea560208a018a6148d6565b83818110610eb557610eb5614759565b905060400201803603810190610ecb9190614920565b90506000888884818110610ee157610ee1614759565b9050602002810190610ef39190614976565b610efc90614a53565b8251602080850151604051939450600093610f2e93920191825260e01b6001600160e01b031916602082015260240190565b60408051808303601f19018152908290528051602090910120630b02c43d60e41b82526004820181905291506000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063b02c43d09060240160e060405180830381865afa158015610fae573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610fd29190614b55565b9050806040015163ffffffff1660000361102e5760405162461bcd60e51b815260206004820152601460248201527f4465706f736974206e6f742072657665616c65640000000000000000000000006044820152606401610353565b611c2081604001516110409190614869565b63ffffffff1642116110945760405162461bcd60e51b815260206004820181905260248201527f4465706f736974206d696e20616765206e6f74206163686965766564207965746044820152606401610353565b60a081015163ffffffff16156110ec5760405162461bcd60e51b815260206004820152601560248201527f4465706f73697420616c726561647920737765707400000000000000000000006044820152606401610353565b6040516393df529b60e01b8152600481018390527f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906393df529b90602401602060405180830381865afa158015611151573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906111759190614c18565b156111e85760405162461bcd60e51b815260206004820152602360248201527f5265736572766564206465706f73697473206d757374206e6f7420626520737760448201527f65707400000000000000000000000000000000000000000000000000000000006064820152608401610353565b6111fc8482600001518360c001518661337c565b608083015160e881901c62ff00ff1663ff00ff0060d89290921c9190911617601081811c91901b17611231620151808261488d565b63ffffffff16421061129b5760405162461bcd60e51b815260206004820152602d60248201527f4465706f73697420726566756e6420736166657479206d617267696e2069732060448201526c1b9bdd081c1c995cd95c9d9959609a1b6064820152608401610353565b6112a860208e018e6143d1565b6bffffffffffffffffffffffff191684604001516bffffffffffffffffffffffff1916146113275760405162461bcd60e51b815260206004820152602660248201527f4465706f73697420636f6e74726f6c6c656420627920646966666572656e74206044820152651dd85b1b195d60d21b6064820152608401610353565b8560000361133757816060015197505b876001600160a01b031682606001516001600160a01b03161461139c5760405162461bcd60e51b815260206004820152601f60248201527f4465706f736974207461726765747320646966666572656e74207661756c74006044820152606401610353565b60005b8681101561142157838882815181106113ba576113ba614759565b60200260200101510361140f5760405162461bcd60e51b815260206004820152601260248201527f4475706c696361746564206465706f73697400000000000000000000000000006044820152606401610353565b80611419816148bd565b91505061139f565b508287878151811061143557611435614759565b60200260200101818152505050505050508080611451906148bd565b915050610e80565b50600193505050505b9392505050565b6000806001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d56114a860208701876143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa1580156114ee573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906115129190614509565b905060028160e00151600581111561152c5761152c6145ac565b1461159f5760405162461bcd60e51b815260206004820152602960248201527f536f757263652077616c6c6574206973206e6f7420696e204d6f76696e67467560448201527f6e647320737461746500000000000000000000000000000000000000000000006064820152608401610353565b6101008101516116175760405162461bcd60e51b815260206004820152602a60248201527f5461726765742077616c6c65747320636f6d6d69746d656e74206973206e6f7460448201527f207375626d6974746564000000000000000000000000000000000000000000006064820152608401610353565b61162460208501856145c2565b604051602001611635929190614c33565b60405160208183030381529060405280519060200120816101000151146116c45760405162461bcd60e51b815260206004820152603a60248201527f5461726765742077616c6c65747320646f206e6f74206d61746368207461726760448201527f65742077616c6c65747320636f6d6d69746d656e7420686173680000000000006064820152608401610353565b6000807f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663be05abe36040518163ffffffff1660e01b815260040161016060405180830381865afa158015611726573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061174a9190614c76565b50505050505050505091509150600061176784602001518761361a565b90508167ffffffffffffffff168167ffffffffffffffff1610156118195760405162461bcd60e51b815260206004820152604260248201527f536f757263652077616c6c6574204254432062616c616e63652069732062656c60448201527f6f7720746865206d6f76696e672066756e64732064757374207468726573686f60648201527f6c64000000000000000000000000000000000000000000000000000000000000608482015260a401610353565b600087604001351161187d5760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b8267ffffffffffffffff16876040013511156118e75760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b60019450505050505b92915050565b6040517f067cf8320000000000000000000000000000000000000000000000000000000081526020820135600482015260009081906001600160a01b037f0000000000000000000000000000000000000000000000000000000000000000169063067cf832906024016101c060405180830381865afa15801561197d573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906119a19190614d73565b905060006001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663cec8c6e960208601356119e96060880160408901614e7c565b6040516001600160e01b031960e085901b168152600481019290925267ffffffffffffffff16602482015260440161028060405180830381865afa158015611a35573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611a599190614e99565b9050600381608001516004811115611a7357611a736145ac565b14611ac05760405162461bcd60e51b815260206004820152601e60248201527f4e6f7420612070656e64696e672072652d616e63686f7220616374696f6e00006044820152606401610353565b60018160a001516005811115611ad857611ad86145ac565b14611b255760405162461bcd60e51b815260206004820152601f60248201527f52652d616e63686f7220616374696f6e206973206e6f742070656e64696e67006044820152606401610353565b611c208160400151611b37919061488d565b63ffffffff164210611b8b5760405162461bcd60e51b815260206004820152601e60248201527f52652d616e63686f7220616374696f6e206861732074696d6564206f757400006044820152606401610353565b611b9b60808501606086016143d1565b81516001600160601b0319908116911614611c1e5760405162461bcd60e51b815260206004820152603260248201527f5461726765742077616c6c657420646f6573206e6f74206d617463682074686560448201527f20617574686f72697a656420616374696f6e00000000000000000000000000006064820152608401610353565b611c2b60208501856143d1565b6bffffffffffffffffffffffff191682606001516bffffffffffffffffffffffff191614611cc15760405162461bcd60e51b815260206004820152602960248201527f5265736572766174696f6e20637573746f64696564206279206469666665726560448201527f6e742077616c6c657400000000000000000000000000000000000000000000006064820152608401610353565b611cce60208501856143d1565b6001600160601b031916611ce860808601606087016143d1565b6001600160601b03191603611d655760405162461bcd60e51b815260206004820152603060248201527f5461726765742077616c6c6574206d757374206469666665722066726f6d207460448201527f686520736f757263652077616c6c6574000000000000000000000000000000006064820152608401610353565b60016001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d5611da660808801606089016143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa158015611dec573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611e109190614509565b60e001516005811115611e2557611e256145ac565b14611e985760405162461bcd60e51b815260206004820152602360248201527f5461726765742077616c6c6574206d75737420626520696e204c69766520737460448201527f61746500000000000000000000000000000000000000000000000000000000006064820152608401610353565b60007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663f75b4b1c6040518163ffffffff1660e01b815260040161014060405180830381865afa158015611ef9573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611f1d9190614ffc565b50509750505050505050508063ffffffff167f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316631de0555a876060016020810190611f7191906143d1565b6040516001600160e01b031960e084901b1681526001600160601b03199091166004820152602401602060405180830381865afa158015611fb6573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611fda919061484c565b63ffffffff16111561202e5760405162461bcd60e51b815260206004820181905260248201527f57616c6c6574207265736572766174696f6e73206361702065786365656465646044820152606401610353565b60007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03166363dfb29c6040518163ffffffff1660e01b8152600401606060405180830381865afa15801561208e573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906120b291906150d2565b505090508067ffffffffffffffff166000148061218a575067ffffffffffffffff81166001600160a01b037f0000000000000000000000000000000000000000000000000000000000000000166363481e9861211460808a0160608b016143d1565b6040516001600160e01b031960e084901b1681526001600160601b03199091166004820152602401602060405180830381865afa158015612159573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061217d919061511f565b67ffffffffffffffff1611155b6121fc5760405162461bcd60e51b815260206004820152602360248201527f57616c6c657420726573657276656420616d6f756e742063617020657863656560448201527f64656400000000000000000000000000000000000000000000000000000000006064820152608401610353565b60008660800135116122605760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b826060015167ffffffffffffffff16866080013511156122ce5760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b50600195945050505050565b6000806001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d561231960208601866143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa15801561235f573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906123839190614509565b905060018160e00151600581111561239d5761239d6145ac565b14806123be575060028160e0015160058111156123bc576123bc6145ac565b145b6124305760405162461bcd60e51b815260206004820152603160248201527f536f757263652077616c6c6574206973206e6f7420696e204c697665206f722060448201527f4d6f76696e6746756e64732073746174650000000000000000000000000000006064820152608401610353565b60006020840135612447606086016040870161513c565b60405160200161246e92919091825260e01b6001600160e01b031916602082015260240190565b60408051808303601f190181529082905280516020909101207ff18cf1b10000000000000000000000000000000000000000000000000000000082526004820181905291506000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063f18cf1b190602401608060405180830381865afa158015612507573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061252b9190615159565b9050600181606001516003811115612545576125456145ac565b146125b85760405162461bcd60e51b815260206004820152602560248201527f53776565702072657175657374206973206e6f7420696e2050656e64696e672060448201527f73746174650000000000000000000000000000000000000000000000000000006064820152608401610353565b6125c560208601866143d1565b81516001600160601b03199081169116146126485760405162461bcd60e51b815260206004820152602b60248201527f5377656570207265717565737420646f6573206e6f742062656c6f6e6720746f60448201527f207468652077616c6c65740000000000000000000000000000000000000000006064820152608401610353565b60007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663be05abe36040518163ffffffff1660e01b815260040161016060405180830381865afa1580156126a9573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906126cd9190614c76565b505050975050505050505050600086606001351161273d5760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b8067ffffffffffffffff16866060013511156122ce5760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b60006127be6127b9602084018461476f565b6136f5565b61280a5760405162461bcd60e51b815260206004820152601d60248201527f4e6f7420612076616c696420686561727462656174206d6573736167650000006044820152606401610353565b506001919050565b6000807f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663f75b4b1c6040518163ffffffff1660e01b815260040161014060405180830381865afa158015612874573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906128989190614ffc565b50979850506001600160a01b03881696506128fc955050505050505760405162461bcd60e51b815260206004820152601960248201527f5265736572766174696f6e73206172652064697361626c6564000000000000006044820152606401610353565b61291161290c60208601866143d1565b61376d565b60006020850135612928606087016040880161513c565b60405160200161294f92919091825260e01b6001600160e01b031916602082015260240190565b60408051601f198184030181529190528051602090910120905060006001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663cec8c6e9836129ab60808a0160608b01614e7c565b6040516001600160e01b031960e085901b168152600481019290925267ffffffffffffffff16602482015260440161028060405180830381865afa1580156129f7573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612a1b9190614e99565b9050600181608001516004811115612a3557612a356145ac565b14612a825760405162461bcd60e51b815260206004820152601f60248201527f4e6f7420612070656e64696e6720616363657074616e636520616374696f6e006044820152606401610353565b60018160a001516005811115612a9a57612a9a6145ac565b14612ae75760405162461bcd60e51b815260206004820181905260248201527f416363657074616e636520616374696f6e206973206e6f742070656e64696e676044820152606401610353565b611c208160400151612af9919061488d565b63ffffffff164210612b4d5760405162461bcd60e51b815260206004820152601f60248201527f416363657074616e636520616374696f6e206861732074696d6564206f7574006044820152606401610353565b612b5a60208701876143d1565b81516001600160601b0319908116911614612bdd5760405162461bcd60e51b815260206004820152602b60248201527f57616c6c657420646f6573206e6f74206d617463682074686520617574686f7260448201527f697a656420616374696f6e0000000000000000000000000000000000000000006064820152608401610353565b604051630b02c43d60e41b8152600481018390526000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063b02c43d09060240160e060405180830381865afa158015612c45573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612c699190614b55565b9050806040015163ffffffff16600003612cc55760405162461bcd60e51b815260206004820152601460248201527f4465706f736974206e6f742072657665616c65640000000000000000000000006044820152606401610353565b611c208160400151612cd79190614869565b63ffffffff164211612d2b5760405162461bcd60e51b815260206004820181905260248201527f4465706f736974206d696e20616765206e6f74206163686965766564207965746044820152606401610353565b60a081015163ffffffff1615612d835760405162461bcd60e51b815260206004820152601560248201527f4465706f73697420616c726561647920737765707400000000000000000000006044820152606401610353565b6040516393df529b60e01b8152600481018490527f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906393df529b90602401602060405180830381865afa158015612de8573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612e0c9190614c18565b612e7d5760405162461bcd60e51b8152602060048201526024808201527f4465706f73697420776173206e6f742072657665616c6564206173207265736560448201527f72766564000000000000000000000000000000000000000000000000000000006064820152608401610353565b836001600160a01b031681606001516001600160a01b031614612f085760405162461bcd60e51b815260206004820152602b60248201527f4465706f736974206e6f7420726f7574656420746f207468652072657365727660448201527f6174696f6e207661756c740000000000000000000000000000000000000000006064820152608401610353565b6000876080013511612f6c5760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b816060015167ffffffffffffffff1687608001351115612fda5760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b866080013582610260015167ffffffffffffffff16612ff991906148aa565b816020015167ffffffffffffffff16101561307c5760405162461bcd60e51b815260206004820152602b60248201527f416e63686f7220616d6f756e742062656c6f772074686520726573657276617460448201527f696f6e206d696e696d756d0000000000000000000000000000000000000000006064820152608401610353565b6130a661309136899003890160208a01614920565b825160c08401516130a18a614a53565b61337c565b60006130e16130bb60a0890160808a016151e2565b60d881901c63ff00ff001662ff00ff60e89290921c9190911617601081811b91901c1790565b905063ffffffff81166130f762015180426148aa565b1061315a5760405162461bcd60e51b815260206004820152602d60248201527f4465706f73697420726566756e6420736166657479206d617267696e2069732060448201526c1b9bdd081c1c995cd95c9d9959609a1b6064820152608401610353565b61316760208901896143d1565b6001600160601b0319166131816060890160408a016143d1565b6001600160601b031916146131e75760405162461bcd60e51b815260206004820152602660248201527f4465706f73697420636f6e74726f6c6c656420627920646966666572656e74206044820152651dd85b1b195d60d21b6064820152608401610353565b506001979650505050505050565b600082116132555760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b600061326182846146d6565b90506000826132708386614700565b61327a9190614713565b905060007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663c42b64d06040518163ffffffff1660e01b8152600401608060405180830381865afa1580156132dc573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061330091906151fd565b5092505067ffffffffffffffff8216905061331b84846148aa565b11156133755760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b5050505050565b8051805160208083015160408085015160609095015190516000956133be956133aa9590949391920161528c565b6040516020818303038152906040526138b6565b855190915081146134375760405162461bcd60e51b815260206004820152602960248201527f457874726120696e666f2066756e64696e67207478206861736820646f65732060448201527f6e6f74206d6174636800000000000000000000000000000000000000000000006064820152608401610353565b60608361347a57602080840151604080860151606087015160808801519251613464958b959491016152c2565b60405160208183030381529060405290506134b7565b848484602001518560400151866060015187608001516040516020016134a5969594939291906154d6565b60405160208183030381529060405290505b60006134de876020015163ffffffff168560000151604001516138dd90919063ffffffff16565b905060006134eb82613abf565b905080516014148015613523575061350283613ad9565b6001600160601b031916613517826000613b00565b6001600160601b031916145b156135315750505050613614565b80516020148015613598575060028360405161354d91906155fe565b602060405180830381855afa15801561356a573d6000803e3d6000fd5b5050506040513d601f19601f8201168201806040525081019061358d919061560a565b61359682613b0f565b145b156135a65750505050613614565b60405162461bcd60e51b815260206004820152602f60248201527f457874726120696e666f2066756e64696e67206f75747075742073637269707460448201527f20646f6573206e6f74206d6174636800000000000000000000000000000000006064820152608401610353565b50505050565b600082156118f057828235613635604085016020860161513c565b6136456060860160408701614e7c565b6040516020016136829392919092835260e09190911b6001600160e01b031916602083015260c01b6001600160c01b0319166024820152602c0190565b60405160208183030381529060405280519060200120146136e55760405162461bcd60e51b815260206004820152601d60248201527f496e76616c69642077616c6c6574206d61696e205554584f20646174610000006044820152606401610353565b6114626060830160408401614e7c565b600060108214613707575060006118f0565b61374b600084848080601f0160208091040260200160405190810160405280939291908181526020018383808284376000920191909152509293925050613b009050565b6001600160c01b031990811614613764575060006118f0565b50600192915050565b6040517fe65e19d50000000000000000000000000000000000000000000000000000000081526001600160601b0319821660048201526000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063e65e19d59060240161012060405180830381865afa1580156137f8573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061381c9190614509565b60e0015190506001816005811115613836576138366145ac565b148061385357506002816005811115613851576138516145ac565b145b6138b25760405162461bcd60e51b815260206004820152602a60248201527f57616c6c6574206973206e6f7420696e204c697665206f72204d6f76696e6746604482015269756e647320737461746560b01b6064820152608401610353565b5050565b60006020600083516020850160025afa50602060006020600060025afa5050600051919050565b60606000806138eb85613b2a565b9092509050600182016139665760405162461bcd60e51b815260206004820152602260248201527f52656164206f76657272756e20647572696e6720566172496e7420706172736960448201527f6e670000000000000000000000000000000000000000000000000000000000006064820152608401610353565b8084106139b55760405162461bcd60e51b815260206004820152601160248201527f566f75742072656164206f76657272756e0000000000000000000000000000006044820152606401610353565b6000806139c38460016148aa565b905060005b86811015613a4b576139da8883613b41565b92506000198303613a2d5760405162461bcd60e51b815260206004820152601a60248201527f42616420566172496e7420696e207363726970745075626b65790000000000006044820152606401610353565b613a3783836148aa565b915080613a43816148bd565b9150506139c8565b50613a568782613b41565b91506000198203613aa95760405162461bcd60e51b815260206004820152601a60248201527f42616420566172496e7420696e207363726970745075626b65790000000000006044820152606401610353565b613ab4878284613bae565b979650505050505050565b60606118f0826008808551613ad49190614700565b613c7b565b60006020600083516020850160025afa50602060006020600060035afa5050600c51919050565b60006114628383016020015190565b60008151600003613b2257506000919050565b506020015190565b600080613b38836000613f80565b91509150915091565b6000613b4e8260096148aa565b83511015613b5f57506000196118f0565b600080613b7685613b718660086148aa565b613f80565b909250905060018201613b8f57600019925050506118f0565b80613b9b8360096148aa565b613ba591906148aa565b95945050505050565b606081600003613bcd5750604080516020810190915260008152611462565b6000613bd983856148aa565b90508381118015613beb575080855110155b613c375760405162461bcd60e51b815260206004820152601360248201527f536c696365206f7574206f6620626f756e6473000000000000000000000000006044820152606401610353565b604051915082604083010160405282825283850182038460208701018481015b80821015613c7057815183830152602082019150613c57565b505050509392505050565b60606000848481518110613c9157613c91614759565b016020015160f81c905082613ca7826001615623565b60ff1614613cc5575050604080516020810190915260008152611462565b84613cd18560016148aa565b81518110613ce157613ce1614759565b016020015160f81c600003613da05760028160ff161015613d12575050604080516020810190915260008152611462565b600085613d208660026148aa565b81518110613d3057613d30614759565b016020015160f81c9050613d4560028361563c565b60ff1681141580613d63575080602014158015613d63575080601414155b15613d81576040518060200160405280600081525092505050611462565b613d97613d8f8660036148aa565b879083613bae565b92505050611462565b6000613dac8686613b00565b90507fffffff000000000000000000000000000000000000000000000000000000000081167f1976a9000000000000000000000000000000000000000000000000000000000003613ec05785613e038660036148aa565b81518110613e1357613e13614759565b60209101015160f81c6014141580613e8b5750613e466002613e3586886148aa565b613e3f9190614700565b8790613b00565b7dffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff19167f88ac00000000000000000000000000000000000000000000000000000000000014155b15613ea9576040518060200160405280600081525092505050611462565b613d97613eb78660046148aa565b87906014613bae565b7fffffff000000000000000000000000000000000000000000000000000000000081167f17a914000000000000000000000000000000000000000000000000000000000003613f6757856001613f1686886148aa565b613f209190614700565b81518110613f3057613f30614759565b60209101015160f81c608714613f59576040518060200160405280600081525092505050611462565b613d97613eb78660036148aa565b5050506040805160208101909152600081529392505050565b6000806000613f8f85856140fe565b90508060ff16600003613fc4576000858581518110613fb057613fb0614759565b016020015190935060f81c91506140f79050565b83613fd0826001615623565b60ff16613fdd91906148aa565b85511015613ff457600019600092509250506140f7565b60008160ff166002036140385761402d6140196140128760016148aa565b8890613b00565b62ffff0060e882901c1660f89190911c1790565b61ffff1690506140ed565b8160ff16600403614061576140546130bb6140128760016148aa565b63ffffffff1690506140ed565b8160ff166008036140ed576140e061407d6140128760016148aa565b60c01c64ff000000ff600882811c91821665ff000000ff009390911b92831617601090811b67ffffffffffffffff1666ff00ff00ff00ff9290921667ff00ff00ff00ff009093169290921790911c65ffff0000ffff1617602081811c91901b1790565b67ffffffffffffffff1690505b60ff909116925090505b9250929050565b600082828151811061411257614112614759565b016020015160f81c60ff03614129575060086118f0565b82828151811061413b5761413b614759565b016020015160f81c60fe03614152575060046118f0565b82828151811061416457614164614759565b016020015160f81c60fd0361417b575060026118f0565b50600092915050565b60006060828403121561419657600080fd5b50919050565b6000602082840312156141ae57600080fd5b813567ffffffffffffffff8111156141c557600080fd5b6141d184828501614184565b949350505050565b60006080828403121561419657600080fd5b60008060006040848603121561420057600080fd5b833567ffffffffffffffff8082111561421857600080fd5b614224878388016141d9565b9450602086013591508082111561423a57600080fd5b818601915086601f83011261424e57600080fd5b81358181111561425d57600080fd5b8760208260051b850101111561427257600080fd5b6020830194508093505050509250925092565b6000806080838503121561429857600080fd5b823567ffffffffffffffff8111156142af57600080fd5b6142bb85828601614184565b9250506142cb8460208501614184565b90509250929050565b600060a0828403121561419657600080fd5b600060a082840312156142f857600080fd5b61146283836142d4565b60006080828403121561431457600080fd5b61146283836141d9565b60006020828403121561433057600080fd5b813567ffffffffffffffff81111561434757600080fd5b82016040818503121561146257600080fd5b60008060c0838503121561436c57600080fd5b61437684846142d4565b915060a083013567ffffffffffffffff81111561439257600080fd5b61439e858286016142d4565b9150509250929050565b6001600160601b0319811681146143be57600080fd5b50565b80356143cc816143a8565b919050565b6000602082840312156143e357600080fd5b8135611462816143a8565b634e487b7160e01b600052604160045260246000fd5b604051610120810167ffffffffffffffff81118282101715614428576144286143ee565b60405290565b60405160a0810167ffffffffffffffff81118282101715614428576144286143ee565b6040516080810167ffffffffffffffff81118282101715614428576144286143ee565b6040516101c0810167ffffffffffffffff81118282101715614428576144286143ee565b604051610280810167ffffffffffffffff81118282101715614428576144286143ee565b67ffffffffffffffff811681146143be57600080fd5b80516143cc816144bc565b63ffffffff811681146143be57600080fd5b80516143cc816144dd565b8051600681106143cc57600080fd5b6000610120828403121561451c57600080fd5b614524614404565b825181526020830151602082015261453e604084016144d2565b604082015261454f606084016144ef565b6060820152614560608084016144ef565b608082015261457160a084016144ef565b60a082015261458260c084016144ef565b60c082015261459360e084016144fa565b60e0820152610100928301519281019290925250919050565b634e487b7160e01b600052602160045260246000fd5b6000808335601e198436030181126145d957600080fd5b83018035915067ffffffffffffffff8211156145f457600080fd5b6020019150600581901b36038213156140f757600080fd5b80516bffffffffffffffffffffffff811681146143cc57600080fd5b600080600080600080600060e0888a03121561464357600080fd5b875161464e816144bc565b602089015190975061465f816144bc565b6040890151909650614670816144bc565b6060890151909550614681816144bc565b6080890151909450614692816144dd565b92506146a060a0890161460c565b915060c08801516146b0816144dd565b8091505092959891949750929550565b634e487b7160e01b600052601260045260246000fd5b6000826146e5576146e56146c0565b500690565b634e487b7160e01b600052601160045260246000fd5b818103818111156118f0576118f06146ea565b600082614722576147226146c0565b500490565b80516001600160a01b03811681146143cc57600080fd5b60006020828403121561475057600080fd5b61146282614727565b634e487b7160e01b600052603260045260246000fd5b6000808335601e1984360301811261478657600080fd5b83018035915067ffffffffffffffff8211156147a157600080fd5b6020019150368190038213156140f757600080fd5b600060a082840312156147c857600080fd5b60405160a0810181811067ffffffffffffffff821117156147eb576147eb6143ee565b6040526147f783614727565b81526020830151614807816144bc565b6020820152604083015161481a816144bc565b6040820152606083015161482d816144bc565b60608201526080830151614840816144dd565b60808201529392505050565b60006020828403121561485e57600080fd5b8151611462816144dd565b63ffffffff818116838216019080821115614886576148866146ea565b5092915050565b63ffffffff828116828216039080821115614886576148866146ea565b808201808211156118f0576118f06146ea565b6000600182016148cf576148cf6146ea565b5060010190565b6000808335601e198436030181126148ed57600080fd5b83018035915067ffffffffffffffff82111561490857600080fd5b6020019150600681901b36038213156140f757600080fd5b60006040828403121561493257600080fd5b6040516040810181811067ffffffffffffffff82111715614955576149556143ee565b60405282358152602083013561496a816144dd565b60208201529392505050565b60008235609e1983360301811261498c57600080fd5b9190910192915050565b80356001600160e01b0319811681146143cc57600080fd5b600082601f8301126149bf57600080fd5b813567ffffffffffffffff808211156149da576149da6143ee565b604051601f8301601f19908116603f01168101908282118183101715614a0257614a026143ee565b81604052838152866020858801011115614a1b57600080fd5b836020870160208301376000602085830101528094505050505092915050565b80356001600160c01b0319811681146143cc57600080fd5b600060a08236031215614a6557600080fd5b614a6d61442e565b823567ffffffffffffffff80821115614a8557600080fd5b818501915060808236031215614a9a57600080fd5b614aa2614451565b614aab83614996565b8152602083013582811115614abf57600080fd5b614acb368286016149ae565b602083015250604083013582811115614ae357600080fd5b614aef368286016149ae565b604083015250614b0160608401614996565b6060820152835250614b17905060208401614a3b565b6020820152614b28604084016143c1565b6040820152614b39606084016143c1565b6060820152614b4a60808401614996565b608082015292915050565b600060e08284031215614b6757600080fd5b60405160e0810181811067ffffffffffffffff82111715614b8a57614b8a6143ee565b604052614b9683614727565b81526020830151614ba6816144bc565b60208201526040830151614bb9816144dd565b6040820152614bca60608401614727565b60608201526080830151614bdd816144bc565b608082015260a0830151614bf0816144dd565b60a082015260c0928301519281019290925250919050565b805180151581146143cc57600080fd5b600060208284031215614c2a57600080fd5b61146282614c08565b60008184825b85811015614c6b578135614c4c816143a8565b6001600160601b03191683526020928301929190910190600101614c39565b509095945050505050565b60008060008060008060008060008060006101608c8e031215614c9857600080fd5b8b51614ca3816144bc565b60208d0151909b50614cb4816144bc565b60408d0151909a50614cc5816144dd565b60608d0151909950614cd6816144dd565b9750614ce460808d0161460c565b965060a08c0151614cf4816144dd565b60c08d015190965061ffff81168114614d0c57600080fd5b9450614d1a60e08d016144d2565b9350614d296101008d016144ef565b9250614d386101208d0161460c565b9150614d476101408d016144ef565b90509295989b509295989b9093969950565b80516143cc816143a8565b8051600581106143cc57600080fd5b60006101c08284031215614d8657600080fd5b614d8e614474565b614d9783614727565b8152614da5602084016144d2565b6020820152614db6604084016144ef565b6040820152614dc760608401614d59565b6060820152614dd8608084016144d2565b6080820152614de960a084016144ef565b60a082015260c083015160c0820152614e0460e084016144ef565b60e0820152610100614e17818501614d64565b90820152610120614e298482016144d2565b90820152610140614e3b848201614c08565b90820152610160614e4d8482016144ef565b90820152610180614e5f8482016144d2565b908201526101a0614e718482016144ef565b908201529392505050565b600060208284031215614e8e57600080fd5b8135611462816144bc565b60006102808284031215614eac57600080fd5b614eb4614498565b614ebd83614d59565b8152614ecb602084016144ef565b6020820152614edc604084016144ef565b6040820152614eed606084016144d2565b6060820152614efe60808401614d64565b6080820152614f0f60a084016144fa565b60a0820152614f2060c08401614c08565b60c0820152614f3160e08401614727565b60e082015261010083810151908201526101208084015190820152610140614f5a8185016144d2565b90820152610160614f6c848201614c08565b90820152610180614f7e8482016144ef565b908201526101a0614f908482016144ef565b908201526101c0614fa28482016144ef565b908201526101e0614fb48482016144d2565b90820152610200614fc6848201614c08565b90820152610220614fd88482016144ef565b90820152610240614fea8482016144ef565b90820152610260614e718482016144d2565b6000806000806000806000806000806101408b8d03121561501c57600080fd5b6150258b614727565b995060208b0151615035816144bc565b60408c0151909950615046816144bc565b60608c0151909850615057816144dd565b60808c0151909750615068816144dd565b60a08c0151909650615079816144bc565b60c08c015190955061508a816144bc565b60e08c015190945061509b816144dd565b6101008c01519093506150ad816144dd565b6101208c01519092506150bf816144dd565b809150509295989b9194979a5092959850565b6000806000606084860312156150e757600080fd5b83516150f2816144bc565b6020850151909350615103816144bc565b6040850151909250615114816144dd565b809150509250925092565b60006020828403121561513157600080fd5b8151611462816144bc565b60006020828403121561514e57600080fd5b8135611462816144dd565b60006080828403121561516b57600080fd5b6040516080810181811067ffffffffffffffff8211171561518e5761518e6143ee565b604052825161519c816143a8565b815260208301516151ac816144bc565b602082015260408301516151bf816144dd565b60408201526060830151600481106151d657600080fd5b60608201529392505050565b6000602082840312156151f457600080fd5b61146282614996565b6000806000806080858703121561521357600080fd5b845161521e816144bc565b602086015190945061522f816144bc565b6040860151909350615240816144bc565b6060860151909250615251816144dd565b939692955090935050565b6000815160005b8181101561527d5760208185018101518683015201615263565b50600093019283525090919050565b60006001600160e01b031980871683526152b26152ac600485018861525c565b8661525c565b9316835250506004019392505050565b600560fa1b8152606086901b6001600160601b0319166001820152607560f81b6015820152600160fb1b60168201526001600160c01b031985166017820152607560f81b601f820152600061537a61536d615344602085015b7f7600000000000000000000000000000000000000000000000000000000000000815260010190565b7fa900000000000000000000000000000000000000000000000000000000000000815260010190565b600560fa1b815260010190565b6001600160601b031986168152608760f81b601482015261542361536d61534461531b6153fa6153d1601587015b7f6300000000000000000000000000000000000000000000000000000000000000815260010190565b7fac00000000000000000000000000000000000000000000000000000000000000815260010190565b7f6700000000000000000000000000000000000000000000000000000000000000815260010190565b6001600160601b0319861681529050601160fb1b601482015261546d601582015b7f0400000000000000000000000000000000000000000000000000000000000000815260010190565b6001600160e01b031985168152905060b160f81b60048201526154ca6154a16153d1600584015b607560f81b815260010190565b7f6800000000000000000000000000000000000000000000000000000000000000815260010190565b98975050505050505050565b600560fa1b81526001600160601b0319606088901b166001820152607560f81b60158201527f20000000000000000000000000000000000000000000000000000000000000006016820152600060178201878152607560f81b6020820152600160fb1b6021820152602281016001600160c01b0319881681529050607560f81b600882015261556d61536d6153446009840161531b565b6001600160601b0319871681529050608760f81b60148201526155a161536d61534461531b6153fa6153d1601587016153a8565b6001600160601b0319861681529050601160fb1b60148201526155c660158201615444565b6001600160e01b031985168152905060b160f81b60048201526155f16154a16153d160058401615494565b9998505050505050505050565b6000611462828461525c565b60006020828403121561561c57600080fd5b5051919050565b60ff81811683821601908111156118f0576118f06146ea565b60ff82811682821603908111156118f0576118f06146ea56fea2646970667358221220b6e5a79844d0f4fea2f7216bdbc256cab6427db737d76c0ea2bac56f2f1419be64736f6c63430008110033", + "deployedBytecode": "0x608060405234801561001057600080fd5b50600436106100ea5760003560e01c8063a57f734f1161008c578063e0276b2611610066578063e0276b2614610152578063e78cea92146101af578063f03a4bc5146101ee578063f9179ad21461020157600080fd5b8063a57f734f14610180578063d09520f714610193578063d6c7fa781461019c57600080fd5b80637405d7bf116100c85780637405d7bf14610135578063765f28f914610148578063996a756b146101525780639b337db21461016d57600080fd5b80630538ac1b146100ef5780630fde6c76146101125780636b562cec146100ef575b600080fd5b6100f8611c2081565b60405163ffffffff90911681526020015b60405180910390f35b61012561012036600461419c565b610214565b6040519015158152602001610109565b6101256101433660046141eb565b610b88565b6100f86201518081565b61015a601481565b60405161ffff9091168152602001610109565b61012561017b366004614285565b611469565b61012561018e3660046142e6565b6118f6565b6100f861025881565b6101256101aa366004614302565b6122da565b6101d67f000000000000000000000000000000000000000000000000000000000000000081565b6040516001600160a01b039091168152602001610109565b6101256101fc36600461431e565b6127a7565b61012561020f366004614359565b612812565b6000806001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d561025360208601866143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa158015610299573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906102bd9190614509565b905060018160e0015160058111156102d7576102d76145ac565b14806102f8575060028160e0015160058111156102f6576102f66145ac565b145b61035c5760405162461bcd60e51b815260206004820152602a60248201527f57616c6c6574206973206e6f7420696e204c697665206f72204d6f76696e6746604482015269756e647320737461746560b01b60648201526084015b60405180910390fd5b600061036b60208501856145c2565b915050806103bb5760405162461bcd60e51b815260206004820152601d60248201527f526564656d7074696f6e2062656c6f7720746865206d696e2073697a650000006044820152606401610353565b601481111561040c5760405162461bcd60e51b815260206004820152601f60248201527f526564656d7074696f6e206578636565647320746865206d61782073697a65006044820152606401610353565b6000807f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316636e70ce416040518163ffffffff1660e01b815260040160e060405180830381865afa15801561046d573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906104919190614628565b50509450945050505060008660400135116104fe5760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b8167ffffffffffffffff16866040013511156105685760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b60006105788460408901356146d6565b905060008461058b8360408b0135614700565b6105959190614713565b905060007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316635f3281ca6040518163ffffffff1660e01b8152600401602060405180830381865afa1580156105f7573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061061b919061473e565b905060008667ffffffffffffffff811115610638576106386143ee565b604051908082528060200260200182016040528015610661578160200160208202803683370190505b50905060005b87811015610b7757600061067e60208d018d6145c2565b8381811061068e5761068e614759565b90506020028101906106a0919061476f565b8080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509050600081805190602001208d600001602081019061070291906143d1565b6040516020016107269291909182526001600160601b031916602082015260340190565b60408051808303601f190181529082905280516020909101207f03d952f70000000000000000000000000000000000000000000000000000000082526004820181905291506000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906303d952f79060240160a060405180830381865afa1580156107bf573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906107e391906147b6565b9050806080015163ffffffff1660000361083f5760405162461bcd60e51b815260206004820181905260248201527f4e6f7420612070656e64696e6720726564656d7074696f6e20726571756573746044820152606401610353565b6102586001600160a01b038716156108f2576040517f0df5db70000000000000000000000000000000000000000000000000000000008152600481018490526000906001600160a01b03891690630df5db7090602401602060405180830381865afa1580156108b2573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906108d6919061484c565b90508163ffffffff168163ffffffff1611156108f0578091505b505b8082608001516109029190614869565b63ffffffff16421161097c5760405162461bcd60e51b815260206004820152602b60248201527f526564656d7074696f6e2072657175657374206d696e20616765206e6f74206160448201527f63686965766564207965740000000000000000000000000000000000000000006064820152608401610353565b60008a836080015161098e9190614869565b905061099c611c208261488d565b63ffffffff164210610a165760405162461bcd60e51b815260206004820152603960248201527f526564656d7074696f6e20726571756573742074696d656f757420736166657460448201527f79206d617267696e206973206e6f7420707265736572766564000000000000006064820152608401610353565b88610a2260018f614700565b8703610a3557610a328b826148aa565b90505b836060015167ffffffffffffffff16811115610ab95760405162461bcd60e51b815260206004820152603660248201527f50726f706f736564207472616e73616374696f6e207065722d7265717565737460448201527f2066656520736861726520697320746f6f2068696768000000000000000000006064820152608401610353565b60005b87811015610b3e5785898281518110610ad757610ad7614759565b602002602001015103610b2c5760405162461bcd60e51b815260206004820152601260248201527f4475706c696361746564207265717565737400000000000000000000000000006044820152606401610353565b80610b36816148bd565b915050610abc565b5084888881518110610b5257610b52614759565b6020026020010181815250505050505050508080610b6f906148bd565b915050610667565b5060019a9950505050505050505050565b6000806001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d5610bc760208801886143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa158015610c0d573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610c319190614509565b905060018160e001516005811115610c4b57610c4b6145ac565b1480610c6c575060028160e001516005811115610c6a57610c6a6145ac565b145b610ccb5760405162461bcd60e51b815260206004820152602a60248201527f57616c6c6574206973206e6f7420696e204c697665206f72204d6f76696e6746604482015269756e647320737461746560b01b6064820152608401610353565b6000610cda60208701876148d6565b905011610d295760405162461bcd60e51b815260206004820152601860248201527f53776565702062656c6f7720746865206d696e2073697a6500000000000000006044820152606401610353565b6014610d3860208701876148d6565b90501115610d885760405162461bcd60e51b815260206004820152601a60248201527f5377656570206578636565647320746865206d61782073697a650000000000006044820152606401610353565b82610d9660208701876148d6565b905014610e0b5760405162461bcd60e51b815260206004820152602e60248201527f45616368206465706f736974206b6579206d7573742068617665206d6174636860448201527f696e6720657874726120696e666f0000000000000000000000000000000000006064820152608401610353565b610e276040860135610e2060208801886148d6565b90506131f5565b600080610e3760208801886148d6565b905067ffffffffffffffff811115610e5157610e516143ee565b604051908082528060200260200182016040528015610e7a578160200160208202803683370190505b50905060005b610e8d60208901896148d6565b9050811015611459576000610ea560208a018a6148d6565b83818110610eb557610eb5614759565b905060400201803603810190610ecb9190614920565b90506000888884818110610ee157610ee1614759565b9050602002810190610ef39190614976565b610efc90614a53565b8251602080850151604051939450600093610f2e93920191825260e01b6001600160e01b031916602082015260240190565b60408051808303601f19018152908290528051602090910120630b02c43d60e41b82526004820181905291506000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063b02c43d09060240160e060405180830381865afa158015610fae573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610fd29190614b55565b9050806040015163ffffffff1660000361102e5760405162461bcd60e51b815260206004820152601460248201527f4465706f736974206e6f742072657665616c65640000000000000000000000006044820152606401610353565b611c2081604001516110409190614869565b63ffffffff1642116110945760405162461bcd60e51b815260206004820181905260248201527f4465706f736974206d696e20616765206e6f74206163686965766564207965746044820152606401610353565b60a081015163ffffffff16156110ec5760405162461bcd60e51b815260206004820152601560248201527f4465706f73697420616c726561647920737765707400000000000000000000006044820152606401610353565b6040516393df529b60e01b8152600481018390527f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906393df529b90602401602060405180830381865afa158015611151573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906111759190614c18565b156111e85760405162461bcd60e51b815260206004820152602360248201527f5265736572766564206465706f73697473206d757374206e6f7420626520737760448201527f65707400000000000000000000000000000000000000000000000000000000006064820152608401610353565b6111fc8482600001518360c001518661337c565b608083015160e881901c62ff00ff1663ff00ff0060d89290921c9190911617601081811c91901b17611231620151808261488d565b63ffffffff16421061129b5760405162461bcd60e51b815260206004820152602d60248201527f4465706f73697420726566756e6420736166657479206d617267696e2069732060448201526c1b9bdd081c1c995cd95c9d9959609a1b6064820152608401610353565b6112a860208e018e6143d1565b6bffffffffffffffffffffffff191684604001516bffffffffffffffffffffffff1916146113275760405162461bcd60e51b815260206004820152602660248201527f4465706f73697420636f6e74726f6c6c656420627920646966666572656e74206044820152651dd85b1b195d60d21b6064820152608401610353565b8560000361133757816060015197505b876001600160a01b031682606001516001600160a01b03161461139c5760405162461bcd60e51b815260206004820152601f60248201527f4465706f736974207461726765747320646966666572656e74207661756c74006044820152606401610353565b60005b8681101561142157838882815181106113ba576113ba614759565b60200260200101510361140f5760405162461bcd60e51b815260206004820152601260248201527f4475706c696361746564206465706f73697400000000000000000000000000006044820152606401610353565b80611419816148bd565b91505061139f565b508287878151811061143557611435614759565b60200260200101818152505050505050508080611451906148bd565b915050610e80565b50600193505050505b9392505050565b6000806001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d56114a860208701876143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa1580156114ee573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906115129190614509565b905060028160e00151600581111561152c5761152c6145ac565b1461159f5760405162461bcd60e51b815260206004820152602960248201527f536f757263652077616c6c6574206973206e6f7420696e204d6f76696e67467560448201527f6e647320737461746500000000000000000000000000000000000000000000006064820152608401610353565b6101008101516116175760405162461bcd60e51b815260206004820152602a60248201527f5461726765742077616c6c65747320636f6d6d69746d656e74206973206e6f7460448201527f207375626d6974746564000000000000000000000000000000000000000000006064820152608401610353565b61162460208501856145c2565b604051602001611635929190614c33565b60405160208183030381529060405280519060200120816101000151146116c45760405162461bcd60e51b815260206004820152603a60248201527f5461726765742077616c6c65747320646f206e6f74206d61746368207461726760448201527f65742077616c6c65747320636f6d6d69746d656e7420686173680000000000006064820152608401610353565b6000807f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663be05abe36040518163ffffffff1660e01b815260040161016060405180830381865afa158015611726573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061174a9190614c76565b50505050505050505091509150600061176784602001518761361a565b90508167ffffffffffffffff168167ffffffffffffffff1610156118195760405162461bcd60e51b815260206004820152604260248201527f536f757263652077616c6c6574204254432062616c616e63652069732062656c60448201527f6f7720746865206d6f76696e672066756e64732064757374207468726573686f60648201527f6c64000000000000000000000000000000000000000000000000000000000000608482015260a401610353565b600087604001351161187d5760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b8267ffffffffffffffff16876040013511156118e75760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b60019450505050505b92915050565b6040517f067cf8320000000000000000000000000000000000000000000000000000000081526020820135600482015260009081906001600160a01b037f0000000000000000000000000000000000000000000000000000000000000000169063067cf832906024016101c060405180830381865afa15801561197d573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906119a19190614d73565b905060006001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663cec8c6e960208601356119e96060880160408901614e7c565b6040516001600160e01b031960e085901b168152600481019290925267ffffffffffffffff16602482015260440161028060405180830381865afa158015611a35573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611a599190614e99565b9050600381608001516004811115611a7357611a736145ac565b14611ac05760405162461bcd60e51b815260206004820152601e60248201527f4e6f7420612070656e64696e672072652d616e63686f7220616374696f6e00006044820152606401610353565b60018160a001516005811115611ad857611ad86145ac565b14611b255760405162461bcd60e51b815260206004820152601f60248201527f52652d616e63686f7220616374696f6e206973206e6f742070656e64696e67006044820152606401610353565b611c208160400151611b37919061488d565b63ffffffff164210611b8b5760405162461bcd60e51b815260206004820152601e60248201527f52652d616e63686f7220616374696f6e206861732074696d6564206f757400006044820152606401610353565b611b9b60808501606086016143d1565b81516001600160601b0319908116911614611c1e5760405162461bcd60e51b815260206004820152603260248201527f5461726765742077616c6c657420646f6573206e6f74206d617463682074686560448201527f20617574686f72697a656420616374696f6e00000000000000000000000000006064820152608401610353565b611c2b60208501856143d1565b6bffffffffffffffffffffffff191682606001516bffffffffffffffffffffffff191614611cc15760405162461bcd60e51b815260206004820152602960248201527f5265736572766174696f6e20637573746f64696564206279206469666665726560448201527f6e742077616c6c657400000000000000000000000000000000000000000000006064820152608401610353565b611cce60208501856143d1565b6001600160601b031916611ce860808601606087016143d1565b6001600160601b03191603611d655760405162461bcd60e51b815260206004820152603060248201527f5461726765742077616c6c6574206d757374206469666665722066726f6d207460448201527f686520736f757263652077616c6c6574000000000000000000000000000000006064820152608401610353565b60016001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d5611da660808801606089016143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa158015611dec573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611e109190614509565b60e001516005811115611e2557611e256145ac565b14611e985760405162461bcd60e51b815260206004820152602360248201527f5461726765742077616c6c6574206d75737420626520696e204c69766520737460448201527f61746500000000000000000000000000000000000000000000000000000000006064820152608401610353565b60007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663f75b4b1c6040518163ffffffff1660e01b815260040161014060405180830381865afa158015611ef9573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611f1d9190614ffc565b50509750505050505050508063ffffffff167f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316631de0555a876060016020810190611f7191906143d1565b6040516001600160e01b031960e084901b1681526001600160601b03199091166004820152602401602060405180830381865afa158015611fb6573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611fda919061484c565b63ffffffff16111561202e5760405162461bcd60e51b815260206004820181905260248201527f57616c6c6574207265736572766174696f6e73206361702065786365656465646044820152606401610353565b60007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03166363dfb29c6040518163ffffffff1660e01b8152600401606060405180830381865afa15801561208e573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906120b291906150d2565b505090508067ffffffffffffffff166000148061218a575067ffffffffffffffff81166001600160a01b037f0000000000000000000000000000000000000000000000000000000000000000166363481e9861211460808a0160608b016143d1565b6040516001600160e01b031960e084901b1681526001600160601b03199091166004820152602401602060405180830381865afa158015612159573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061217d919061511f565b67ffffffffffffffff1611155b6121fc5760405162461bcd60e51b815260206004820152602360248201527f57616c6c657420726573657276656420616d6f756e742063617020657863656560448201527f64656400000000000000000000000000000000000000000000000000000000006064820152608401610353565b60008660800135116122605760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b826060015167ffffffffffffffff16866080013511156122ce5760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b50600195945050505050565b6000806001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d561231960208601866143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa15801561235f573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906123839190614509565b905060018160e00151600581111561239d5761239d6145ac565b14806123be575060028160e0015160058111156123bc576123bc6145ac565b145b6124305760405162461bcd60e51b815260206004820152603160248201527f536f757263652077616c6c6574206973206e6f7420696e204c697665206f722060448201527f4d6f76696e6746756e64732073746174650000000000000000000000000000006064820152608401610353565b60006020840135612447606086016040870161513c565b60405160200161246e92919091825260e01b6001600160e01b031916602082015260240190565b60408051808303601f190181529082905280516020909101207ff18cf1b10000000000000000000000000000000000000000000000000000000082526004820181905291506000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063f18cf1b190602401608060405180830381865afa158015612507573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061252b9190615159565b9050600181606001516003811115612545576125456145ac565b146125b85760405162461bcd60e51b815260206004820152602560248201527f53776565702072657175657374206973206e6f7420696e2050656e64696e672060448201527f73746174650000000000000000000000000000000000000000000000000000006064820152608401610353565b6125c560208601866143d1565b81516001600160601b03199081169116146126485760405162461bcd60e51b815260206004820152602b60248201527f5377656570207265717565737420646f6573206e6f742062656c6f6e6720746f60448201527f207468652077616c6c65740000000000000000000000000000000000000000006064820152608401610353565b60007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663be05abe36040518163ffffffff1660e01b815260040161016060405180830381865afa1580156126a9573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906126cd9190614c76565b505050975050505050505050600086606001351161273d5760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b8067ffffffffffffffff16866060013511156122ce5760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b60006127be6127b9602084018461476f565b6136f5565b61280a5760405162461bcd60e51b815260206004820152601d60248201527f4e6f7420612076616c696420686561727462656174206d6573736167650000006044820152606401610353565b506001919050565b6000807f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663f75b4b1c6040518163ffffffff1660e01b815260040161014060405180830381865afa158015612874573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906128989190614ffc565b50979850506001600160a01b03881696506128fc955050505050505760405162461bcd60e51b815260206004820152601960248201527f5265736572766174696f6e73206172652064697361626c6564000000000000006044820152606401610353565b61291161290c60208601866143d1565b61376d565b60006020850135612928606087016040880161513c565b60405160200161294f92919091825260e01b6001600160e01b031916602082015260240190565b60408051601f198184030181529190528051602090910120905060006001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663cec8c6e9836129ab60808a0160608b01614e7c565b6040516001600160e01b031960e085901b168152600481019290925267ffffffffffffffff16602482015260440161028060405180830381865afa1580156129f7573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612a1b9190614e99565b9050600181608001516004811115612a3557612a356145ac565b14612a825760405162461bcd60e51b815260206004820152601f60248201527f4e6f7420612070656e64696e6720616363657074616e636520616374696f6e006044820152606401610353565b60018160a001516005811115612a9a57612a9a6145ac565b14612ae75760405162461bcd60e51b815260206004820181905260248201527f416363657074616e636520616374696f6e206973206e6f742070656e64696e676044820152606401610353565b611c208160400151612af9919061488d565b63ffffffff164210612b4d5760405162461bcd60e51b815260206004820152601f60248201527f416363657074616e636520616374696f6e206861732074696d6564206f7574006044820152606401610353565b612b5a60208701876143d1565b81516001600160601b0319908116911614612bdd5760405162461bcd60e51b815260206004820152602b60248201527f57616c6c657420646f6573206e6f74206d617463682074686520617574686f7260448201527f697a656420616374696f6e0000000000000000000000000000000000000000006064820152608401610353565b604051630b02c43d60e41b8152600481018390526000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063b02c43d09060240160e060405180830381865afa158015612c45573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612c699190614b55565b9050806040015163ffffffff16600003612cc55760405162461bcd60e51b815260206004820152601460248201527f4465706f736974206e6f742072657665616c65640000000000000000000000006044820152606401610353565b611c208160400151612cd79190614869565b63ffffffff164211612d2b5760405162461bcd60e51b815260206004820181905260248201527f4465706f736974206d696e20616765206e6f74206163686965766564207965746044820152606401610353565b60a081015163ffffffff1615612d835760405162461bcd60e51b815260206004820152601560248201527f4465706f73697420616c726561647920737765707400000000000000000000006044820152606401610353565b6040516393df529b60e01b8152600481018490527f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906393df529b90602401602060405180830381865afa158015612de8573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612e0c9190614c18565b612e7d5760405162461bcd60e51b8152602060048201526024808201527f4465706f73697420776173206e6f742072657665616c6564206173207265736560448201527f72766564000000000000000000000000000000000000000000000000000000006064820152608401610353565b836001600160a01b031681606001516001600160a01b031614612f085760405162461bcd60e51b815260206004820152602b60248201527f4465706f736974206e6f7420726f7574656420746f207468652072657365727660448201527f6174696f6e207661756c740000000000000000000000000000000000000000006064820152608401610353565b6000876080013511612f6c5760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b816060015167ffffffffffffffff1687608001351115612fda5760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b866080013582610260015167ffffffffffffffff16612ff991906148aa565b816020015167ffffffffffffffff16101561307c5760405162461bcd60e51b815260206004820152602b60248201527f416e63686f7220616d6f756e742062656c6f772074686520726573657276617460448201527f696f6e206d696e696d756d0000000000000000000000000000000000000000006064820152608401610353565b6130a661309136899003890160208a01614920565b825160c08401516130a18a614a53565b61337c565b60006130e16130bb60a0890160808a016151e2565b60d881901c63ff00ff001662ff00ff60e89290921c9190911617601081811b91901c1790565b905063ffffffff81166130f762015180426148aa565b1061315a5760405162461bcd60e51b815260206004820152602d60248201527f4465706f73697420726566756e6420736166657479206d617267696e2069732060448201526c1b9bdd081c1c995cd95c9d9959609a1b6064820152608401610353565b61316760208901896143d1565b6001600160601b0319166131816060890160408a016143d1565b6001600160601b031916146131e75760405162461bcd60e51b815260206004820152602660248201527f4465706f73697420636f6e74726f6c6c656420627920646966666572656e74206044820152651dd85b1b195d60d21b6064820152608401610353565b506001979650505050505050565b600082116132555760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b600061326182846146d6565b90506000826132708386614700565b61327a9190614713565b905060007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663c42b64d06040518163ffffffff1660e01b8152600401608060405180830381865afa1580156132dc573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061330091906151fd565b5092505067ffffffffffffffff8216905061331b84846148aa565b11156133755760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b5050505050565b8051805160208083015160408085015160609095015190516000956133be956133aa9590949391920161528c565b6040516020818303038152906040526138b6565b855190915081146134375760405162461bcd60e51b815260206004820152602960248201527f457874726120696e666f2066756e64696e67207478206861736820646f65732060448201527f6e6f74206d6174636800000000000000000000000000000000000000000000006064820152608401610353565b60608361347a57602080840151604080860151606087015160808801519251613464958b959491016152c2565b60405160208183030381529060405290506134b7565b848484602001518560400151866060015187608001516040516020016134a5969594939291906154d6565b60405160208183030381529060405290505b60006134de876020015163ffffffff168560000151604001516138dd90919063ffffffff16565b905060006134eb82613abf565b905080516014148015613523575061350283613ad9565b6001600160601b031916613517826000613b00565b6001600160601b031916145b156135315750505050613614565b80516020148015613598575060028360405161354d91906155fe565b602060405180830381855afa15801561356a573d6000803e3d6000fd5b5050506040513d601f19601f8201168201806040525081019061358d919061560a565b61359682613b0f565b145b156135a65750505050613614565b60405162461bcd60e51b815260206004820152602f60248201527f457874726120696e666f2066756e64696e67206f75747075742073637269707460448201527f20646f6573206e6f74206d6174636800000000000000000000000000000000006064820152608401610353565b50505050565b600082156118f057828235613635604085016020860161513c565b6136456060860160408701614e7c565b6040516020016136829392919092835260e09190911b6001600160e01b031916602083015260c01b6001600160c01b0319166024820152602c0190565b60405160208183030381529060405280519060200120146136e55760405162461bcd60e51b815260206004820152601d60248201527f496e76616c69642077616c6c6574206d61696e205554584f20646174610000006044820152606401610353565b6114626060830160408401614e7c565b600060108214613707575060006118f0565b61374b600084848080601f0160208091040260200160405190810160405280939291908181526020018383808284376000920191909152509293925050613b009050565b6001600160c01b031990811614613764575060006118f0565b50600192915050565b6040517fe65e19d50000000000000000000000000000000000000000000000000000000081526001600160601b0319821660048201526000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063e65e19d59060240161012060405180830381865afa1580156137f8573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061381c9190614509565b60e0015190506001816005811115613836576138366145ac565b148061385357506002816005811115613851576138516145ac565b145b6138b25760405162461bcd60e51b815260206004820152602a60248201527f57616c6c6574206973206e6f7420696e204c697665206f72204d6f76696e6746604482015269756e647320737461746560b01b6064820152608401610353565b5050565b60006020600083516020850160025afa50602060006020600060025afa5050600051919050565b60606000806138eb85613b2a565b9092509050600182016139665760405162461bcd60e51b815260206004820152602260248201527f52656164206f76657272756e20647572696e6720566172496e7420706172736960448201527f6e670000000000000000000000000000000000000000000000000000000000006064820152608401610353565b8084106139b55760405162461bcd60e51b815260206004820152601160248201527f566f75742072656164206f76657272756e0000000000000000000000000000006044820152606401610353565b6000806139c38460016148aa565b905060005b86811015613a4b576139da8883613b41565b92506000198303613a2d5760405162461bcd60e51b815260206004820152601a60248201527f42616420566172496e7420696e207363726970745075626b65790000000000006044820152606401610353565b613a3783836148aa565b915080613a43816148bd565b9150506139c8565b50613a568782613b41565b91506000198203613aa95760405162461bcd60e51b815260206004820152601a60248201527f42616420566172496e7420696e207363726970745075626b65790000000000006044820152606401610353565b613ab4878284613bae565b979650505050505050565b60606118f0826008808551613ad49190614700565b613c7b565b60006020600083516020850160025afa50602060006020600060035afa5050600c51919050565b60006114628383016020015190565b60008151600003613b2257506000919050565b506020015190565b600080613b38836000613f80565b91509150915091565b6000613b4e8260096148aa565b83511015613b5f57506000196118f0565b600080613b7685613b718660086148aa565b613f80565b909250905060018201613b8f57600019925050506118f0565b80613b9b8360096148aa565b613ba591906148aa565b95945050505050565b606081600003613bcd5750604080516020810190915260008152611462565b6000613bd983856148aa565b90508381118015613beb575080855110155b613c375760405162461bcd60e51b815260206004820152601360248201527f536c696365206f7574206f6620626f756e6473000000000000000000000000006044820152606401610353565b604051915082604083010160405282825283850182038460208701018481015b80821015613c7057815183830152602082019150613c57565b505050509392505050565b60606000848481518110613c9157613c91614759565b016020015160f81c905082613ca7826001615623565b60ff1614613cc5575050604080516020810190915260008152611462565b84613cd18560016148aa565b81518110613ce157613ce1614759565b016020015160f81c600003613da05760028160ff161015613d12575050604080516020810190915260008152611462565b600085613d208660026148aa565b81518110613d3057613d30614759565b016020015160f81c9050613d4560028361563c565b60ff1681141580613d63575080602014158015613d63575080601414155b15613d81576040518060200160405280600081525092505050611462565b613d97613d8f8660036148aa565b879083613bae565b92505050611462565b6000613dac8686613b00565b90507fffffff000000000000000000000000000000000000000000000000000000000081167f1976a9000000000000000000000000000000000000000000000000000000000003613ec05785613e038660036148aa565b81518110613e1357613e13614759565b60209101015160f81c6014141580613e8b5750613e466002613e3586886148aa565b613e3f9190614700565b8790613b00565b7dffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff19167f88ac00000000000000000000000000000000000000000000000000000000000014155b15613ea9576040518060200160405280600081525092505050611462565b613d97613eb78660046148aa565b87906014613bae565b7fffffff000000000000000000000000000000000000000000000000000000000081167f17a914000000000000000000000000000000000000000000000000000000000003613f6757856001613f1686886148aa565b613f209190614700565b81518110613f3057613f30614759565b60209101015160f81c608714613f59576040518060200160405280600081525092505050611462565b613d97613eb78660036148aa565b5050506040805160208101909152600081529392505050565b6000806000613f8f85856140fe565b90508060ff16600003613fc4576000858581518110613fb057613fb0614759565b016020015190935060f81c91506140f79050565b83613fd0826001615623565b60ff16613fdd91906148aa565b85511015613ff457600019600092509250506140f7565b60008160ff166002036140385761402d6140196140128760016148aa565b8890613b00565b62ffff0060e882901c1660f89190911c1790565b61ffff1690506140ed565b8160ff16600403614061576140546130bb6140128760016148aa565b63ffffffff1690506140ed565b8160ff166008036140ed576140e061407d6140128760016148aa565b60c01c64ff000000ff600882811c91821665ff000000ff009390911b92831617601090811b67ffffffffffffffff1666ff00ff00ff00ff9290921667ff00ff00ff00ff009093169290921790911c65ffff0000ffff1617602081811c91901b1790565b67ffffffffffffffff1690505b60ff909116925090505b9250929050565b600082828151811061411257614112614759565b016020015160f81c60ff03614129575060086118f0565b82828151811061413b5761413b614759565b016020015160f81c60fe03614152575060046118f0565b82828151811061416457614164614759565b016020015160f81c60fd0361417b575060026118f0565b50600092915050565b60006060828403121561419657600080fd5b50919050565b6000602082840312156141ae57600080fd5b813567ffffffffffffffff8111156141c557600080fd5b6141d184828501614184565b949350505050565b60006080828403121561419657600080fd5b60008060006040848603121561420057600080fd5b833567ffffffffffffffff8082111561421857600080fd5b614224878388016141d9565b9450602086013591508082111561423a57600080fd5b818601915086601f83011261424e57600080fd5b81358181111561425d57600080fd5b8760208260051b850101111561427257600080fd5b6020830194508093505050509250925092565b6000806080838503121561429857600080fd5b823567ffffffffffffffff8111156142af57600080fd5b6142bb85828601614184565b9250506142cb8460208501614184565b90509250929050565b600060a0828403121561419657600080fd5b600060a082840312156142f857600080fd5b61146283836142d4565b60006080828403121561431457600080fd5b61146283836141d9565b60006020828403121561433057600080fd5b813567ffffffffffffffff81111561434757600080fd5b82016040818503121561146257600080fd5b60008060c0838503121561436c57600080fd5b61437684846142d4565b915060a083013567ffffffffffffffff81111561439257600080fd5b61439e858286016142d4565b9150509250929050565b6001600160601b0319811681146143be57600080fd5b50565b80356143cc816143a8565b919050565b6000602082840312156143e357600080fd5b8135611462816143a8565b634e487b7160e01b600052604160045260246000fd5b604051610120810167ffffffffffffffff81118282101715614428576144286143ee565b60405290565b60405160a0810167ffffffffffffffff81118282101715614428576144286143ee565b6040516080810167ffffffffffffffff81118282101715614428576144286143ee565b6040516101c0810167ffffffffffffffff81118282101715614428576144286143ee565b604051610280810167ffffffffffffffff81118282101715614428576144286143ee565b67ffffffffffffffff811681146143be57600080fd5b80516143cc816144bc565b63ffffffff811681146143be57600080fd5b80516143cc816144dd565b8051600681106143cc57600080fd5b6000610120828403121561451c57600080fd5b614524614404565b825181526020830151602082015261453e604084016144d2565b604082015261454f606084016144ef565b6060820152614560608084016144ef565b608082015261457160a084016144ef565b60a082015261458260c084016144ef565b60c082015261459360e084016144fa565b60e0820152610100928301519281019290925250919050565b634e487b7160e01b600052602160045260246000fd5b6000808335601e198436030181126145d957600080fd5b83018035915067ffffffffffffffff8211156145f457600080fd5b6020019150600581901b36038213156140f757600080fd5b80516bffffffffffffffffffffffff811681146143cc57600080fd5b600080600080600080600060e0888a03121561464357600080fd5b875161464e816144bc565b602089015190975061465f816144bc565b6040890151909650614670816144bc565b6060890151909550614681816144bc565b6080890151909450614692816144dd565b92506146a060a0890161460c565b915060c08801516146b0816144dd565b8091505092959891949750929550565b634e487b7160e01b600052601260045260246000fd5b6000826146e5576146e56146c0565b500690565b634e487b7160e01b600052601160045260246000fd5b818103818111156118f0576118f06146ea565b600082614722576147226146c0565b500490565b80516001600160a01b03811681146143cc57600080fd5b60006020828403121561475057600080fd5b61146282614727565b634e487b7160e01b600052603260045260246000fd5b6000808335601e1984360301811261478657600080fd5b83018035915067ffffffffffffffff8211156147a157600080fd5b6020019150368190038213156140f757600080fd5b600060a082840312156147c857600080fd5b60405160a0810181811067ffffffffffffffff821117156147eb576147eb6143ee565b6040526147f783614727565b81526020830151614807816144bc565b6020820152604083015161481a816144bc565b6040820152606083015161482d816144bc565b60608201526080830151614840816144dd565b60808201529392505050565b60006020828403121561485e57600080fd5b8151611462816144dd565b63ffffffff818116838216019080821115614886576148866146ea565b5092915050565b63ffffffff828116828216039080821115614886576148866146ea565b808201808211156118f0576118f06146ea565b6000600182016148cf576148cf6146ea565b5060010190565b6000808335601e198436030181126148ed57600080fd5b83018035915067ffffffffffffffff82111561490857600080fd5b6020019150600681901b36038213156140f757600080fd5b60006040828403121561493257600080fd5b6040516040810181811067ffffffffffffffff82111715614955576149556143ee565b60405282358152602083013561496a816144dd565b60208201529392505050565b60008235609e1983360301811261498c57600080fd5b9190910192915050565b80356001600160e01b0319811681146143cc57600080fd5b600082601f8301126149bf57600080fd5b813567ffffffffffffffff808211156149da576149da6143ee565b604051601f8301601f19908116603f01168101908282118183101715614a0257614a026143ee565b81604052838152866020858801011115614a1b57600080fd5b836020870160208301376000602085830101528094505050505092915050565b80356001600160c01b0319811681146143cc57600080fd5b600060a08236031215614a6557600080fd5b614a6d61442e565b823567ffffffffffffffff80821115614a8557600080fd5b818501915060808236031215614a9a57600080fd5b614aa2614451565b614aab83614996565b8152602083013582811115614abf57600080fd5b614acb368286016149ae565b602083015250604083013582811115614ae357600080fd5b614aef368286016149ae565b604083015250614b0160608401614996565b6060820152835250614b17905060208401614a3b565b6020820152614b28604084016143c1565b6040820152614b39606084016143c1565b6060820152614b4a60808401614996565b608082015292915050565b600060e08284031215614b6757600080fd5b60405160e0810181811067ffffffffffffffff82111715614b8a57614b8a6143ee565b604052614b9683614727565b81526020830151614ba6816144bc565b60208201526040830151614bb9816144dd565b6040820152614bca60608401614727565b60608201526080830151614bdd816144bc565b608082015260a0830151614bf0816144dd565b60a082015260c0928301519281019290925250919050565b805180151581146143cc57600080fd5b600060208284031215614c2a57600080fd5b61146282614c08565b60008184825b85811015614c6b578135614c4c816143a8565b6001600160601b03191683526020928301929190910190600101614c39565b509095945050505050565b60008060008060008060008060008060006101608c8e031215614c9857600080fd5b8b51614ca3816144bc565b60208d0151909b50614cb4816144bc565b60408d0151909a50614cc5816144dd565b60608d0151909950614cd6816144dd565b9750614ce460808d0161460c565b965060a08c0151614cf4816144dd565b60c08d015190965061ffff81168114614d0c57600080fd5b9450614d1a60e08d016144d2565b9350614d296101008d016144ef565b9250614d386101208d0161460c565b9150614d476101408d016144ef565b90509295989b509295989b9093969950565b80516143cc816143a8565b8051600581106143cc57600080fd5b60006101c08284031215614d8657600080fd5b614d8e614474565b614d9783614727565b8152614da5602084016144d2565b6020820152614db6604084016144ef565b6040820152614dc760608401614d59565b6060820152614dd8608084016144d2565b6080820152614de960a084016144ef565b60a082015260c083015160c0820152614e0460e084016144ef565b60e0820152610100614e17818501614d64565b90820152610120614e298482016144d2565b90820152610140614e3b848201614c08565b90820152610160614e4d8482016144ef565b90820152610180614e5f8482016144d2565b908201526101a0614e718482016144ef565b908201529392505050565b600060208284031215614e8e57600080fd5b8135611462816144bc565b60006102808284031215614eac57600080fd5b614eb4614498565b614ebd83614d59565b8152614ecb602084016144ef565b6020820152614edc604084016144ef565b6040820152614eed606084016144d2565b6060820152614efe60808401614d64565b6080820152614f0f60a084016144fa565b60a0820152614f2060c08401614c08565b60c0820152614f3160e08401614727565b60e082015261010083810151908201526101208084015190820152610140614f5a8185016144d2565b90820152610160614f6c848201614c08565b90820152610180614f7e8482016144ef565b908201526101a0614f908482016144ef565b908201526101c0614fa28482016144ef565b908201526101e0614fb48482016144d2565b90820152610200614fc6848201614c08565b90820152610220614fd88482016144ef565b90820152610240614fea8482016144ef565b90820152610260614e718482016144d2565b6000806000806000806000806000806101408b8d03121561501c57600080fd5b6150258b614727565b995060208b0151615035816144bc565b60408c0151909950615046816144bc565b60608c0151909850615057816144dd565b60808c0151909750615068816144dd565b60a08c0151909650615079816144bc565b60c08c015190955061508a816144bc565b60e08c015190945061509b816144dd565b6101008c01519093506150ad816144dd565b6101208c01519092506150bf816144dd565b809150509295989b9194979a5092959850565b6000806000606084860312156150e757600080fd5b83516150f2816144bc565b6020850151909350615103816144bc565b6040850151909250615114816144dd565b809150509250925092565b60006020828403121561513157600080fd5b8151611462816144bc565b60006020828403121561514e57600080fd5b8135611462816144dd565b60006080828403121561516b57600080fd5b6040516080810181811067ffffffffffffffff8211171561518e5761518e6143ee565b604052825161519c816143a8565b815260208301516151ac816144bc565b602082015260408301516151bf816144dd565b60408201526060830151600481106151d657600080fd5b60608201529392505050565b6000602082840312156151f457600080fd5b61146282614996565b6000806000806080858703121561521357600080fd5b845161521e816144bc565b602086015190945061522f816144bc565b6040860151909350615240816144bc565b6060860151909250615251816144dd565b939692955090935050565b6000815160005b8181101561527d5760208185018101518683015201615263565b50600093019283525090919050565b60006001600160e01b031980871683526152b26152ac600485018861525c565b8661525c565b9316835250506004019392505050565b600560fa1b8152606086901b6001600160601b0319166001820152607560f81b6015820152600160fb1b60168201526001600160c01b031985166017820152607560f81b601f820152600061537a61536d615344602085015b7f7600000000000000000000000000000000000000000000000000000000000000815260010190565b7fa900000000000000000000000000000000000000000000000000000000000000815260010190565b600560fa1b815260010190565b6001600160601b031986168152608760f81b601482015261542361536d61534461531b6153fa6153d1601587015b7f6300000000000000000000000000000000000000000000000000000000000000815260010190565b7fac00000000000000000000000000000000000000000000000000000000000000815260010190565b7f6700000000000000000000000000000000000000000000000000000000000000815260010190565b6001600160601b0319861681529050601160fb1b601482015261546d601582015b7f0400000000000000000000000000000000000000000000000000000000000000815260010190565b6001600160e01b031985168152905060b160f81b60048201526154ca6154a16153d1600584015b607560f81b815260010190565b7f6800000000000000000000000000000000000000000000000000000000000000815260010190565b98975050505050505050565b600560fa1b81526001600160601b0319606088901b166001820152607560f81b60158201527f20000000000000000000000000000000000000000000000000000000000000006016820152600060178201878152607560f81b6020820152600160fb1b6021820152602281016001600160c01b0319881681529050607560f81b600882015261556d61536d6153446009840161531b565b6001600160601b0319871681529050608760f81b60148201526155a161536d61534461531b6153fa6153d1601587016153a8565b6001600160601b0319861681529050601160fb1b60148201526155c660158201615444565b6001600160e01b031985168152905060b160f81b60048201526155f16154a16153d160058401615494565b9998505050505050505050565b6000611462828461525c565b60006020828403121561561c57600080fd5b5051919050565b60ff81811683821601908111156118f0576118f06146ea565b60ff82811682821603908111156118f0576118f06146ea56fea2646970667358221220b6e5a79844d0f4fea2f7216bdbc256cab6427db737d76c0ea2bac56f2f1419be64736f6c63430008110033" } \ No newline at end of file diff --git a/pkg/chain/ethereum/testdata/walletproposalvalidator/regenerate.sh b/pkg/chain/ethereum/testdata/walletproposalvalidator/regenerate.sh index ad9600e9a3..6ce9d4c204 100755 --- a/pkg/chain/ethereum/testdata/walletproposalvalidator/regenerate.sh +++ b/pkg/chain/ethereum/testdata/walletproposalvalidator/regenerate.sh @@ -14,13 +14,17 @@ # StateDB), never reimplementing it in Go. # # Provenance of the currently vendored WalletProposalValidator.json: -# commit: e635e2292fbb6f2c39d835fb0b3861c032934bd0 -# (threshold-network/tbtc-v2 branch fix/m1-cross-repo-review, -# on top of reservations-upgrade @ 9f8f5ef1). Includes the -# snapshotted-minAmount check in -# validateReservationAnchorProposal. +# commit: eec999aad43b3913df378840773348e17f68f1ba +# (threshold-network/tbtc-v2, merge of #1161 into +# reservations-upgrade). The same commit is pinned as +# TBTC_V2_REF in the reservation-router-vendored-fallback- +# verify job of .github/workflows/client.yml, which checks +# this file against its own compile of that commit with +# verify.sh. Built with `yarn install && yarn build` in +# tbtc-v2's solidity/ directory, as that job does. # Re-run this script against a newer tbtc-v2 build whenever the -# validator changes, and update this note. +# validator changes, bump TBTC_V2_REF to the same commit, and update +# this note. # # Usage: # TBTC_V2_BUILD_DIR=/path/to/tbtc-v2/solidity/build ./regenerate.sh @@ -28,15 +32,14 @@ # TBTC_V2_BUILD_DIR must point at a hardhat build/ directory (i.e. the # directory containing contracts/bridge/WalletProposalValidator.sol/ # WalletProposalValidator.json) produced by `yarn build` in the tbtc-v2 -# solidity package. Defaults to the sibling tbtc-v2-m1fix worktree used -# during development of this harness. +# solidity package. set -euo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SOLC="${SOLC:-solc}" -TBTC_V2_BUILD_DIR="${TBTC_V2_BUILD_DIR:-$HERE/../../../../../../tbtc-v2-m1fix/solidity/build}" +: "${TBTC_V2_BUILD_DIR:?set TBTC_V2_BUILD_DIR to a tbtc-v2 solidity/build directory (run yarn build there first)}" VALIDATOR_ARTIFACT="$TBTC_V2_BUILD_DIR/contracts/bridge/WalletProposalValidator.sol/WalletProposalValidator.json" if [[ ! -f "$VALIDATOR_ARTIFACT" ]]; then @@ -46,8 +49,10 @@ if [[ ! -f "$VALIDATOR_ARTIFACT" ]]; then fi echo "compiling StubBridge.sol with $($SOLC --version | tail -1)..." -"$SOLC" --optimize --optimize-runs 200 --via-ir --combined-json abi,bin \ - "$HERE/StubBridge.sol" >"$HERE/.stub-combined.json" +# Compile by relative path: solc embeds the source path in the bytecode +# metadata, so an absolute path would change StubBridge.json per machine. +(cd "$HERE" && "$SOLC" --optimize --optimize-runs 200 --via-ir \ + --combined-json abi,bin StubBridge.sol) >"$HERE/.stub-combined.json" python3 - "$HERE/.stub-combined.json" "$HERE/StubBridge.json" <<'PY' import json diff --git a/pkg/chain/ethereum/testdata/walletproposalvalidator/verify.sh b/pkg/chain/ethereum/testdata/walletproposalvalidator/verify.sh new file mode 100755 index 0000000000..1e4ae01302 --- /dev/null +++ b/pkg/chain/ethereum/testdata/walletproposalvalidator/verify.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# +# Checks that the vendored WalletProposalValidator.json, the real tbtc-v2 +# validator that pkg/chain/ethereum/tbtc_validator_harness_test.go +# deploys, matches a fresh compile of the tbtc-v2 commit it was vendored +# from (see the provenance note in regenerate.sh): +# +# - abi: exactly equal, +# - deployedBytecode: equal once the trailing CBOR metadata is removed, +# - bytecode: (the creation code the harness deploys) equal once +# that same metadata blob is removed. +# +# The metadata is excluded because it hashes the full compiler input, and +# the CI job's tbtc-v2 `yarn install` is not lockfile-frozen, so the hash +# can move while the executed code stays the same. +# +# Usage: +# ./verify.sh /path/to/compiled/WalletProposalValidator.json +# +# Run by the reservation-router-vendored-fallback-verify job in +# .github/workflows/client.yml against its compile of TBTC_V2_REF. + +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [[ $# -ne 1 || ! -f "$1" ]]; then + echo "usage: $0 /path/to/compiled/WalletProposalValidator.json" >&2 + exit 2 +fi + +python3 - "$HERE/WalletProposalValidator.json" "$1" <<'PY' +import json +import sys + +vendored_path, compiled_path = sys.argv[1], sys.argv[2] +vendored = json.load(open(vendored_path)) +compiled = json.load(open(compiled_path)) + +REMEDY = ( + "the vendored harness validator ({}) does not match the compiled " + "tbtc-v2 artifact ({}). Re-run regenerate.sh against a build of " + "TBTC_V2_REF and update its provenance note, or pin TBTC_V2_REF to " + "the commit the vendored file came from." +).format(vendored_path, compiled_path) + + +def fail(message): + print("error: " + message, file=sys.stderr) + print("error: " + REMEDY, file=sys.stderr) + sys.exit(1) + + +def code_of(artifact, field, label): + value = artifact.get(field) + if not isinstance(value, str) or not value.startswith("0x") or len(value) < 6: + fail("{} {} is missing or not 0x-prefixed hex".format(label, field)) + return bytes.fromhex(value[2:]) + + +def split_metadata(code, label): + # solc appends CBOR-encoded metadata to the runtime code, followed by + # its length as a big-endian uint16. The encoding is a CBOR map, so + # its first byte is 0xa0-0xb7; anything else means the length read + # is wrong and stripping would compare garbage. + length = int.from_bytes(code[-2:], "big") + if length + 2 > len(code) or not 0xA0 <= code[-(length + 2)] <= 0xB7: + fail("{} deployedBytecode has no recognizable trailing CBOR metadata".format(label)) + return code[: -(length + 2)], code[-(length + 2) :] + + +def first_difference(a, b): + for i, (x, y) in enumerate(zip(a, b)): + if x != y: + return i + return min(len(a), len(b)) + + +def compare_code(field, vendored_code, compiled_code): + if vendored_code != compiled_code: + fail( + "{} differs (metadata excluded): vendored {} bytes, compiled {} " + "bytes, first difference at byte {}".format( + field, + len(vendored_code), + len(compiled_code), + first_difference(vendored_code, compiled_code), + ) + ) + + +if vendored.get("abi") != compiled.get("abi"): + fail("abi differs") + +vendored_runtime, vendored_meta = split_metadata( + code_of(vendored, "deployedBytecode", "vendored"), "vendored" +) +compiled_runtime, compiled_meta = split_metadata( + code_of(compiled, "deployedBytecode", "compiled"), "compiled" +) +compare_code("deployedBytecode", vendored_runtime, compiled_runtime) + + +def strip_embedded(code, meta, label): + # The creation code carries the runtime code, metadata included. + if meta not in code: + fail("{} bytecode does not embed its deployedBytecode metadata".format(label)) + return code.replace(meta, b"") + + +compare_code( + "bytecode", + strip_embedded(code_of(vendored, "bytecode", "vendored"), vendored_meta, "vendored"), + strip_embedded(code_of(compiled, "bytecode", "compiled"), compiled_meta, "compiled"), +) + +print( + "WalletProposalValidator.json harness artifact matches the compiled " + "artifact (abi exact; bytecode and deployedBytecode without metadata)" +) +PY diff --git a/pkg/clientinfo/performance.go b/pkg/clientinfo/performance.go index 49f3f30030..09d86491ef 100644 --- a/pkg/clientinfo/performance.go +++ b/pkg/clientinfo/performance.go @@ -394,7 +394,7 @@ func (pm *PerformanceMetrics) registerGaugeMetrics() { MetricReservationLiveWalletsCount, MetricReservationWalletReservationsCount, MetricReservationVaultFeeDebtSat, - MetricReservationVaultFeeReserveTbtc, + MetricReservationVaultFeeReserveTbtcBaseUnits, ) } @@ -758,15 +758,15 @@ const ( // // - MetricReservationVaultFeeDebtSat is in satoshi, matching the // on-chain inKindFeeDebtSat view. - // - MetricReservationVaultFeeReserveTbtc is in TBTC base units + // - MetricReservationVaultFeeReserveTbtcBaseUnits is in TBTC base units // (1e18 per whole TBTC): a gauge value of N means N / 1e18 // whole TBTC held in reserve. The balance is a big.Int while // the gauge API takes a float64, so the base-unit figure is // published directly; float64 represents base units // approximately above 2^53 base units, so consumers must // treat the gauge as an indicator, not for exact accounting. - MetricReservationVaultFeeDebtSat = "reservation_vault_fee_debt_sat" - MetricReservationVaultFeeReserveTbtc = "reservation_vault_fee_reserve_tbtc" + MetricReservationVaultFeeDebtSat = "reservation_vault_fee_debt_sat" + MetricReservationVaultFeeReserveTbtcBaseUnits = "reservation_vault_fee_reserve_tbtc_base_units" ) // Network join request failure reasons. These are the low-cardinality diff --git a/pkg/clientinfo/performance_test.go b/pkg/clientinfo/performance_test.go index 31cca2fc81..cddf536a61 100644 --- a/pkg/clientinfo/performance_test.go +++ b/pkg/clientinfo/performance_test.go @@ -720,7 +720,7 @@ func TestWalletActionMetricsRegisteredRegardlessOfReservationsFlag(t *testing.T) // TestReservationGaugesRegistered verifies the six reservation gauges // (active_reservations_count, max_active_reservations, live_wallets_count, // wallet_reservations_count, reservation_vault_fee_debt_sat, -// reservation_vault_fee_reserve_tbtc) are registered upfront with a 0 +// reservation_vault_fee_reserve_tbtc_base_units) are registered upfront with a 0 // value when reservations are enabled. func TestReservationGaugesRegistered(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) @@ -735,7 +735,7 @@ func TestReservationGaugesRegistered(t *testing.T) { MetricReservationLiveWalletsCount, MetricReservationWalletReservationsCount, MetricReservationVaultFeeDebtSat, - MetricReservationVaultFeeReserveTbtc, + MetricReservationVaultFeeReserveTbtcBaseUnits, } for _, name := range reservationGauges { @@ -782,7 +782,7 @@ func TestReservationGaugesNotRegisteredWhenReservationsDisabled(t *testing.T) { MetricReservationLiveWalletsCount, MetricReservationWalletReservationsCount, MetricReservationVaultFeeDebtSat, - MetricReservationVaultFeeReserveTbtc, + MetricReservationVaultFeeReserveTbtcBaseUnits, } for _, name := range reservationGauges { diff --git a/pkg/maintainer/spv/chain_test.go b/pkg/maintainer/spv/chain_test.go index 2bcecf9aed..e35982d318 100644 --- a/pkg/maintainer/spv/chain_test.go +++ b/pkg/maintainer/spv/chain_test.go @@ -107,10 +107,12 @@ type localChain struct { // production zero-value default when no pre-termination event is found. walletTerminationCauses map[[20]byte]tbtc.WalletTerminationCause walletTerminationCauseCalls int + getReservationCalls int // Error-injection fields for the reservation watcher chain-error // passthrough tests: nil (the default) means the corresponding method // falls through to its normal, table-driven behavior. + getReservationErr error getReservationActionErr error walletReservationsErr error isReservedDepositErr error @@ -1062,21 +1064,37 @@ func (lc *localChain) setWalletTerminationCause( } // GetReservation returns the reservation previously installed via -// setReservation. Returns an error when no test record is installed. +// setReservation. An absent key yields a zero record +// (ReservationStateUnknown), like the Bridge's reservations mapping. func (lc *localChain) GetReservation( reservationKey *big.Int, ) (*tbtc.Reservation, error) { lc.mutex.Lock() defer lc.mutex.Unlock() + lc.getReservationCalls++ + + if lc.getReservationErr != nil { + return nil, lc.getReservationErr + } + key := bigIntKey(reservationKey) reservation, ok := lc.reservations[key] if !ok { - return nil, fmt.Errorf("no reservation for given key") + return &tbtc.Reservation{}, nil } return reservation, nil } +// getReservationCallCount returns how many times GetReservation has been +// invoked. +func (lc *localChain) getReservationCallCount() int { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + return lc.getReservationCalls +} + // setReservation installs a reservation for GetReservation to return. func (lc *localChain) setReservation( reservationKey *big.Int, @@ -1089,7 +1107,9 @@ func (lc *localChain) setReservation( } // GetReservationAction returns the reservation action previously installed -// via setReservationAction. Returns an error if the action is not set. +// via setReservationAction. An absent generation yields a zero record +// (ReservationActionStateUnknown), like the Bridge's reservationActions +// mapping. func (lc *localChain) GetReservationAction( reservationKey *big.Int, requestNonce uint64, @@ -1104,7 +1124,7 @@ func (lc *localChain) GetReservationAction( key := buildReservationActionKey(reservationKey, requestNonce) action, ok := lc.reservationActions[key] if !ok { - return nil, fmt.Errorf("no action for given reservation/nonce") + return &tbtc.ReservationAction{}, nil } return action, nil } @@ -1263,7 +1283,7 @@ func (lc *localChain) setReservedDeposit( // PastReservationAcceptanceRequestedEvents returns the events previously // installed via addReservationAcceptanceRequestedEvent, applying the -// filter's StartBlock and ReservationKey constraints. +// filter's StartBlock, EndBlock and ReservationKey constraints. func (lc *localChain) PastReservationAcceptanceRequestedEvents( filter *tbtc.ReservationAcceptanceRequestedEventFilter, ) ([]*tbtc.ReservationAcceptanceRequestedEvent, error) { @@ -1275,6 +1295,9 @@ func (lc *localChain) PastReservationAcceptanceRequestedEvents( if filter != nil && event.BlockNumber < filter.StartBlock { continue } + if filter != nil && filter.EndBlock != nil && event.BlockNumber > *filter.EndBlock { + continue + } if filter != nil && len(filter.ReservationKey) > 0 { matched := false for _, key := range filter.ReservationKey { @@ -1307,7 +1330,7 @@ func (lc *localChain) addReservationAcceptanceRequestedEvent( // PastReservationReanchorRequestedEvents returns the events previously // installed via addReservationReanchorRequestedEvent, applying the -// filter's StartBlock and ReservationKey constraints. +// filter's StartBlock, EndBlock and ReservationKey constraints. func (lc *localChain) PastReservationReanchorRequestedEvents( filter *tbtc.ReservationReanchorRequestedEventFilter, ) ([]*tbtc.ReservationReanchorRequestedEvent, error) { @@ -1319,6 +1342,9 @@ func (lc *localChain) PastReservationReanchorRequestedEvents( if filter != nil && event.BlockNumber < filter.StartBlock { continue } + if filter != nil && filter.EndBlock != nil && event.BlockNumber > *filter.EndBlock { + continue + } if filter != nil && len(filter.ReservationKey) > 0 { matched := false for _, key := range filter.ReservationKey { @@ -1364,6 +1390,9 @@ func (lc *localChain) PastNewWalletRegisteredEvents( if filter != nil && event.BlockNumber < filter.StartBlock { continue } + if filter != nil && filter.EndBlock != nil && event.BlockNumber > *filter.EndBlock { + continue + } if filter != nil && len(filter.EcdsaWalletID) > 0 { matched := false for _, id := range filter.EcdsaWalletID { diff --git a/pkg/maintainer/spv/config.go b/pkg/maintainer/spv/config.go index c4cd22fc70..79b49328b0 100644 --- a/pkg/maintainer/spv/config.go +++ b/pkg/maintainer/spv/config.go @@ -99,11 +99,12 @@ type Config struct { // command copies it from its own [ethereum] config section) rather // than read from a config file or CLI flag. It feeds the // reservation activation-block lookup (see - // tbtc.ReservationsActivationBlock), which bounds every - // reservation watcher's startup catch-up scan: a network without - // an activation entry is treated as reservations-inactive and its - // startup scans are skipped. The zero value (an unknown network) - // behaves exactly like a missing activation entry: reservation - // startup scans are skipped on it. + // tbtc.ReservationsActivationBlock): the reservation proof loop and + // the stale-deposit and action-timeout watchers start their first + // event scan at the activation block and walk to the tip in chunks. + // A network without an activation entry is treated as + // reservations-inactive: those first scans are skipped and later + // scans cover only new blocks. The zero value (an unknown network) + // behaves exactly like a missing activation entry. EthereumNetwork ethereum.Network } diff --git a/pkg/maintainer/spv/reservation_action_timeout_watch.go b/pkg/maintainer/spv/reservation_action_timeout_watch.go index ab0263d1f7..f252754308 100644 --- a/pkg/maintainer/spv/reservation_action_timeout_watch.go +++ b/pkg/maintainer/spv/reservation_action_timeout_watch.go @@ -3,7 +3,6 @@ package spv import ( "context" "fmt" - "math" "math/big" "sort" "time" @@ -139,14 +138,13 @@ type ReservationActionTimeoutWatcher struct { operatorAddress common.Address // activationBlock is the network's reservation activation block - // (tbtc.ReservationsActivationBlock): the first event scan starts - // from max(activationBlock, currentBlock-lookback), so the watcher - // picks up every pending action requested since the feature went - // live without scanning genesis on a long-running deployment. math.MaxUint64 - // is the sentinel for a network without an entry (reservations - // inactive) - the first scan still runs, bounded to the lookback - // window, since the watcher may have other reasons to exist beyond - // reservations. + // (tbtc.ReservationsActivationBlock). The first event scan starts + // here and walks to the tip in chunks (see reservationScanRange), so + // a restart finds every action requested since activation - an + // action that timed out but was never notified still holds + // capacity, however old it is. math.MaxUint64 marks a network + // without an entry: reservations are inactive there, the first scan + // is skipped and later scans cover only new blocks. activationBlock uint64 } @@ -170,9 +168,7 @@ type pendingAction struct { // actionEventKey identifies one reservation action generation. It // delegates to reservationEventKey (see reservation_proof_loop.go), the // canonical (reservationKey, requestNonce) key formatter shared by -// every pending-event map in this package; this watcher previously used -// its own "%s#%d" format, now consolidated onto reservationEventKey's -// pre-existing "%s:%d" format. +// every pending-event map in this package. func actionEventKey(reservationKey *big.Int, requestNonce uint64) string { return reservationEventKey(reservationKey, requestNonce) } @@ -217,52 +213,43 @@ func defaultActionTimeoutNowFn() uint32 { return uint32(time.Now().Unix()) } -// nextScanRange calculates the start and current block numbers for the -// next event scan. Item 1 (restart recovery) is scoped to the SPV proof -// loop and the stale-deposit watcher only; this watcher keeps its -// original bounded 30-day catch-up window on the first pass -// (reservationDefaultLookBackBlocks), narrowed to the network's -// reservation activation block only when that block is both known -// (tbtc.ReservationsActivationBlock did not return math.MaxUint64) and -// inside the lookback window already computed - so a network whose -// activation happened more recently than 30 days ago does not scan -// further back than activation, but a network with no activation entry -// (or one older than the lookback window) is unaffected and keeps the -// original bounded scan. Every later pass starts exactly one block past -// the previous cursor, unchanged. -func (ratw *ReservationActionTimeoutWatcher) nextScanRange( - lastScannedBlock uint64, -) (startBlock uint64, currentBlock uint64, err error) { - blockCounter, err := ratw.spvChain.BlockCounter() - if err != nil { - return 0, 0, fmt.Errorf("failed to get block counter: [%v]", err) - } - - currentBlock, err = blockCounter.CurrentBlock() +// scanActionRequests scans one kind of action-request event from *cursor +// to the confirmed tip (see reservationScanRange), tracks every requested +// generation that is not tracked yet and advances *cursor after each +// chunk. fetch returns the generations requested in one chunk. +func (ratw *ReservationActionTimeoutWatcher) scanActionRequests( + cursor *uint64, + fetch func(startBlock, endBlock uint64) ([]*pendingAction, error), +) error { + startBlock, endBlock, scan, err := nextReservationScanRange( + ratw.spvChain, + *cursor, + ratw.activationBlock, + ) if err != nil { - return 0, 0, fmt.Errorf("failed to get current block: [%v]", err) + return err } - if lastScannedBlock != 0 { - return lastScannedBlock + 1, currentBlock, nil + if !scan { + *cursor = endBlock + return nil } - // First pass: bounded catch-up window. The clamp to the network's - // reservation activation block applies only when the activation is - // known (not math.MaxUint64) and falls at or below the current tip; - // an activation block above the current tip - or an unknown - // activation - leaves the bounded lookback range unchanged, so the - // scan is ordinary and simply finds nothing while reservations have - // not yet activated on this network. - start := uint64(0) - if currentBlock > reservationDefaultLookBackBlocks { - start = currentBlock - reservationDefaultLookBackBlocks - } - if ratw.activationBlock > start && ratw.activationBlock != math.MaxUint64 && - ratw.activationBlock <= currentBlock { - start = ratw.activationBlock - } - return start, currentBlock, nil + return fetchPastEventsInChunks( + fetch, + startBlock, + endBlock, + func(actions []*pendingAction, chunkEnd uint64) { + for _, action := range actions { + key := actionEventKey(action.reservationKey, action.requestNonce) + // An already-tracked generation keeps its notifiedAt. + if _, tracked := ratw.pendingActions[key]; !tracked { + ratw.pendingActions[key] = action + } + } + *cursor = chunkEnd + }, + ) } // Run starts the background poll loop. It returns when ctx is done or when @@ -304,65 +291,55 @@ func (ratw *ReservationActionTimeoutWatcher) Run(ctx context.Context) error { func (ratw *ReservationActionTimeoutWatcher) pollPendingActions() error { ratw.drainStrandingRechecks() - // 1. Scan new ReservationAcceptanceRequestedEvents - acceptanceStartBlock, acceptanceCurrentBlock, err := ratw.nextScanRange( - ratw.acceptanceLastScannedBlock, - ) - if err != nil { - return fmt.Errorf("failed to get acceptance scan range: [%v]", err) - } - - acceptanceEvents, err := ratw.spvChain.PastReservationAcceptanceRequestedEvents( - &tbtc.ReservationAcceptanceRequestedEventFilter{ - StartBlock: acceptanceStartBlock, - EndBlock: &acceptanceCurrentBlock, + // 1. Scan new acceptance and re-anchor action requests. A scan error + // does not skip the timeout checks below: generations found before + // the error are already tracked, and the error is returned at the end + // of the tick. + acceptanceScanErr := ratw.scanActionRequests( + &ratw.acceptanceLastScannedBlock, + func(startBlock, endBlock uint64) ([]*pendingAction, error) { + events, err := ratw.spvChain.PastReservationAcceptanceRequestedEvents( + &tbtc.ReservationAcceptanceRequestedEventFilter{ + StartBlock: startBlock, + EndBlock: &endBlock, + }, + ) + if err != nil { + return nil, err + } + actions := make([]*pendingAction, len(events)) + for i, event := range events { + actions[i] = &pendingAction{ + reservationKey: event.ReservationKey, + requestNonce: event.RequestNonce, + } + } + return actions, nil }, ) - if err != nil { - return fmt.Errorf( - "failed to get past reservation acceptance requested events: [%v]", - err, - ) - } - - for _, event := range acceptanceEvents { - key := actionEventKey(event.ReservationKey, event.RequestNonce) - ratw.pendingActions[key] = &pendingAction{ - reservationKey: event.ReservationKey, - requestNonce: event.RequestNonce, - } - } - ratw.acceptanceLastScannedBlock = acceptanceCurrentBlock - - // 2. Scan new ReservationReanchorRequestedEvents - reanchorStartBlock, reanchorCurrentBlock, err := ratw.nextScanRange( - ratw.reanchorLastScannedBlock, - ) - if err != nil { - return fmt.Errorf("failed to get reanchor scan range: [%v]", err) - } - reanchorEvents, err := ratw.spvChain.PastReservationReanchorRequestedEvents( - &tbtc.ReservationReanchorRequestedEventFilter{ - StartBlock: reanchorStartBlock, - EndBlock: &reanchorCurrentBlock, + reanchorScanErr := ratw.scanActionRequests( + &ratw.reanchorLastScannedBlock, + func(startBlock, endBlock uint64) ([]*pendingAction, error) { + events, err := ratw.spvChain.PastReservationReanchorRequestedEvents( + &tbtc.ReservationReanchorRequestedEventFilter{ + StartBlock: startBlock, + EndBlock: &endBlock, + }, + ) + if err != nil { + return nil, err + } + actions := make([]*pendingAction, len(events)) + for i, event := range events { + actions[i] = &pendingAction{ + reservationKey: event.ReservationKey, + requestNonce: event.RequestNonce, + } + } + return actions, nil }, ) - if err != nil { - return fmt.Errorf( - "failed to get past reservation reanchor requested events: [%v]", - err, - ) - } - - for _, event := range reanchorEvents { - key := actionEventKey(event.ReservationKey, event.RequestNonce) - ratw.pendingActions[key] = &pendingAction{ - reservationKey: event.ReservationKey, - requestNonce: event.RequestNonce, - } - } - ratw.reanchorLastScannedBlock = reanchorCurrentBlock now := ratw.nowFn() @@ -466,6 +443,19 @@ func (ratw *ReservationActionTimeoutWatcher) pollPendingActions() error { } } + if acceptanceScanErr != nil { + return fmt.Errorf( + "failed to scan reservation acceptance requests: [%v]", + acceptanceScanErr, + ) + } + if reanchorScanErr != nil { + return fmt.Errorf( + "failed to scan reservation re-anchor requests: [%v]", + reanchorScanErr, + ) + } + return nil } diff --git a/pkg/maintainer/spv/reservation_action_timeout_watch_test.go b/pkg/maintainer/spv/reservation_action_timeout_watch_test.go index 1326966878..d4da79827e 100644 --- a/pkg/maintainer/spv/reservation_action_timeout_watch_test.go +++ b/pkg/maintainer/spv/reservation_action_timeout_watch_test.go @@ -61,7 +61,7 @@ func TestReservationActionTimeoutWatcher_NotifiesTimedOutPendingAction(t *testin 1, ) - watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), math.MaxUint64) + watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), 0) if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -110,7 +110,7 @@ func TestReservationActionTimeoutWatcher_NotifiesAcceptanceTimeoutViaDedicatedEn 1, ) - watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), math.MaxUint64) + watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), 0) if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -156,7 +156,7 @@ func TestReservationActionTimeoutWatcher_SkipsUnrecognizedActionType(t *testing. 1, ) - watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), math.MaxUint64) + watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), 0) if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -196,7 +196,7 @@ func TestReservationActionTimeoutWatcher_DoesNotNotifyBeforeTimeout(t *testing.T 1, ) - watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), math.MaxUint64) + watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), 0) if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -238,7 +238,7 @@ func TestReservationActionTimeoutWatcher_NotifiesAtExactDeadline(t *testing.T) { 1, ) - watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), math.MaxUint64) + watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), 0) if err := watcher.CheckReservationActionTimeouts(key, 100); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -273,7 +273,7 @@ func TestReservationActionTimeoutWatcher_NotifiesAtExactDeadline(t *testing.T) { 1, ) - watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), math.MaxUint64) + watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), 0) if err := watcher.CheckReservationActionTimeouts(key, 100); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -316,7 +316,7 @@ func TestReservationActionTimeoutWatcher_IgnoresSettledOlderGeneration(t *testin 2, ) - watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), math.MaxUint64) + watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), 0) if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -360,7 +360,7 @@ func TestReservationActionTimeoutWatcher_NotifiesCurrentGenerationOnly(t *testin 2, ) - watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), math.MaxUint64) + watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), 0) if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -384,7 +384,7 @@ func TestReservationActionTimeoutWatcher_SkipsReservationWithoutWallet(t *testin RequestNonce: 0, }) - watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), math.MaxUint64) + watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), 0) if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -422,7 +422,7 @@ func TestReservationActionTimeoutWatcher_ReanchorNotifiesUnconditionally(t *test 1, ) - watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), math.MaxUint64) + watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), 0) if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { t.Fatalf("expected nil error, got: %v", err) } @@ -439,7 +439,7 @@ func TestReservationActionTimeoutWatcher_ReanchorNotifiesUnconditionally(t *test func TestReservationActionTimeoutWatcher_NilKeyError(t *testing.T) { spvChain := newLocalChain() - watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), math.MaxUint64) + watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), 0) if err := watcher.CheckReservationActionTimeouts(nil, 5_000); err == nil { t.Fatal("expected error for nil reservation key, got nil") } @@ -456,7 +456,7 @@ func TestReservationActionTimeoutWatcher_SkipsWalletZeroBranch(t *testing.T) { RequestNonce: 1, }) - watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), math.MaxUint64) + watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), 0) if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -495,7 +495,7 @@ func TestReservationActionTimeoutWatcher_NotifierErrorPropagates(t *testing.T) { 1, ) - watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), math.MaxUint64) + watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), 0) err := watcher.CheckReservationActionTimeouts(key, 5_000) if err == nil { t.Fatal("expected the notifier error to propagate, got nil") @@ -546,7 +546,7 @@ func TestReservationActionTimeoutWatcher_StalePreloadNonceMismatchFallsBackToFre TimeoutAt: 100, } - watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), math.MaxUint64) + watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), 0) notified, err := watcher.checkReservationActionTimeout(key, 5_000, stalePreload, 1) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -576,7 +576,7 @@ func TestReservationActionTimeoutWatcher_PollPendingActions_SkipsNotifiedAtStamp wallet1 := walletPKH() - ratw := NewReservationActionTimeoutWatcher(spvChain, time.Minute, common.Address{}, nil, math.MaxUint64) + ratw := NewReservationActionTimeoutWatcher(spvChain, time.Minute, common.Address{}, nil, 0) ratw.nowFn = func() uint32 { return 500 } key1 := reservationKey(0x3001) @@ -638,7 +638,7 @@ func TestReservationActionTimeoutWatcher_PollPendingActions_RetainsEntryAcrossLo wallet1 := walletPKH() - ratw := NewReservationActionTimeoutWatcher(spvChain, time.Minute, common.Address{}, nil, math.MaxUint64) + ratw := NewReservationActionTimeoutWatcher(spvChain, time.Minute, common.Address{}, nil, 0) // now is comfortably beyond TimeoutAt (100) plus the maximum // possible reservationOperatorStaggerOffset (bounded by // actionTimeoutRenotifyInterval, 600s) so the FIRST-attempt stagger @@ -708,54 +708,6 @@ func TestReservationActionTimeoutWatcher_PollPendingActions_RetainsEntryAcrossLo } } -func TestReservationActionTimeoutWatcher_NextScanRange(t *testing.T) { - spvChain := newLocalChain() - blockCounter := newMockBlockCounter() - spvChain.setBlockCounter(blockCounter) - - watcher := NewReservationActionTimeoutWatcher(spvChain, time.Minute, common.Address{}, nil, math.MaxUint64) - - // Case 1: First scan (lastScannedBlock == 0) and currentBlock > lookback. - blockCounter.SetCurrentBlock(300_000) - startBlock, currentBlock, err := watcher.nextScanRange(0) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - expectedStart := uint64(300_000) - reservationDefaultLookBackBlocks - if startBlock != expectedStart { - t.Errorf("expected start block %d, got %d", expectedStart, startBlock) - } - if currentBlock != 300_000 { - t.Errorf("expected current block 300000, got %d", currentBlock) - } - - // Case 2: First scan (lastScannedBlock == 0) and currentBlock <= lookback. - blockCounter.SetCurrentBlock(100_000) - startBlock, currentBlock, err = watcher.nextScanRange(0) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if startBlock != 0 { - t.Errorf("expected start block 0, got %d", startBlock) - } - if currentBlock != 100_000 { - t.Errorf("expected current block 100000, got %d", currentBlock) - } - - // Case 3: Subsequent scan (lastScannedBlock > 0). - blockCounter.SetCurrentBlock(500_000) - startBlock, currentBlock, err = watcher.nextScanRange(450_000) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if startBlock != 450_001 { - t.Errorf("expected start block 450001, got %d", startBlock) - } - if currentBlock != 500_000 { - t.Errorf("expected current block 500000, got %d", currentBlock) - } -} - func TestReservationActionTimeoutWatcher_RunLoop_IncrementalTracking(t *testing.T) { spvChain := newLocalChain() blockCounter := newMockBlockCounter() @@ -765,7 +717,7 @@ func TestReservationActionTimeoutWatcher_RunLoop_IncrementalTracking(t *testing. wallet1 := walletPKH() pollInterval := 10 * time.Millisecond - ratw := NewReservationActionTimeoutWatcher(spvChain, pollInterval, common.Address{}, nil, math.MaxUint64) + ratw := NewReservationActionTimeoutWatcher(spvChain, pollInterval, common.Address{}, nil, 0) // now is comfortably beyond every seeded TimeoutAt plus the maximum // possible reservationOperatorStaggerOffset (bounded by // actionTimeoutRenotifyInterval, 600s) so the FIRST-attempt stagger @@ -888,7 +840,7 @@ func TestReservationActionTimeoutWatcher_RunLoop_DoesNotRenotifyWhilePending(t * wallet1 := walletPKH() pollInterval := 10 * time.Millisecond - ratw := NewReservationActionTimeoutWatcher(spvChain, pollInterval, common.Address{}, nil, math.MaxUint64) + ratw := NewReservationActionTimeoutWatcher(spvChain, pollInterval, common.Address{}, nil, 0) // now is comfortably beyond TimeoutAt (100) plus the maximum // possible reservationOperatorStaggerOffset (see the identical // comment in RunLoop_IncrementalTracking above). @@ -990,7 +942,7 @@ func TestReservationActionTimeoutWatcher_RunLoop_RenotifiesAfterBackoffWindow(t wallet1 := walletPKH() pollInterval := 10 * time.Millisecond - ratw := NewReservationActionTimeoutWatcher(spvChain, pollInterval, common.Address{}, nil, math.MaxUint64) + ratw := NewReservationActionTimeoutWatcher(spvChain, pollInterval, common.Address{}, nil, 0) // initialNow is comfortably beyond TimeoutAt (100) plus the maximum // possible reservationOperatorStaggerOffset (bounded by @@ -1074,97 +1026,106 @@ func TestReservationActionTimeoutWatcher_RunLoop_RenotifiesAfterBackoffWindow(t } } -func TestReservationActionTimeoutWatcher_RunLoop_BoundedFirstScan(t *testing.T) { - spvChain := newLocalChain() - blockCounter := newMockBlockCounter() - // Set current block high enough that lookback applies. - currentBlock := uint64(500_000) - blockCounter.SetCurrentBlock(currentBlock) - spvChain.setBlockCounter(blockCounter) - - wallet1 := walletPKH() - - pollInterval := 10 * time.Millisecond - ratw := NewReservationActionTimeoutWatcher(spvChain, pollInterval, common.Address{}, nil, math.MaxUint64) - // now is comfortably beyond every seeded TimeoutAt plus the maximum - // possible reservationOperatorStaggerOffset (see the identical - // comment in RunLoop_IncrementalTracking above). - ratw.nowFn = func() uint32 { return 900 } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() +// TestReservationActionTimeoutWatcher_FirstScanStartsAtActivation verifies +// that the first action-request scan starts at the reservation activation +// block rather than a fixed lookback window: an action requested long +// before the last 30 days that timed out without being notified still +// holds capacity, so the watcher must find and notify it. Requests before +// activation are not scanned, and on a network with no activation entry +// the first scan is skipped entirely. +func TestReservationActionTimeoutWatcher_FirstScanStartsAtActivation(t *testing.T) { + const ( + currentBlock = uint64(500_000) + activationBlock = uint64(50_000) + ) - // Event 1 is old: block 100,000 (before startBlock = 500,000 - 216,000 = 284,000). - oldKey := reservationKey(0x9001) - spvChain.addReservationAcceptanceRequestedEvent(&tbtc.ReservationAcceptanceRequestedEvent{ - ReservationKey: oldKey, - RequestNonce: 1, - WalletPublicKeyHash: wallet1, - BlockNumber: 100_000, - }) - seededReservation( - t, - spvChain, - oldKey, - wallet1, - []*tbtc.ReservationAction{ - { - ActionType: tbtc.ReservationActionTypeReanchor, - State: tbtc.ReservationActionStatePending, - TimeoutAt: 100, + seed := func(spvChain *localChain, key *big.Int, blockNumber uint64) { + spvChain.addReservationReanchorRequestedEvent(&tbtc.ReservationReanchorRequestedEvent{ + ReservationKey: key, + RequestNonce: 1, + SourceWalletPublicKeyHash: walletPKH(), + BlockNumber: blockNumber, + }) + seededReservation( + t, + spvChain, + key, + walletPKH(), + []*tbtc.ReservationAction{ + { + ActionType: tbtc.ReservationActionTypeReanchor, + State: tbtc.ReservationActionStatePending, + TimeoutAt: 100, + }, }, - }, - 1, - ) + 1, + ) + } - // Event 2 is within lookback: block 300,000. + preActivationKey := reservationKey(0x9000) + oldKey := reservationKey(0x9001) recentKey := reservationKey(0x9002) - spvChain.addReservationAcceptanceRequestedEvent(&tbtc.ReservationAcceptanceRequestedEvent{ - ReservationKey: recentKey, - RequestNonce: 1, - WalletPublicKeyHash: wallet1, - BlockNumber: 300_000, - }) - seededReservation( - t, - spvChain, - recentKey, - wallet1, - []*tbtc.ReservationAction{ - { - ActionType: tbtc.ReservationActionTypeReanchor, - State: tbtc.ReservationActionStatePending, - TimeoutAt: 100, - }, - }, - 1, - ) - errChan := make(chan error, 1) - go func() { - errChan <- ratw.Run(ctx) - }() + newWatcher := func(activation uint64) (*localChain, *ReservationActionTimeoutWatcher) { + spvChain := newLocalChain() + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(currentBlock) + spvChain.setBlockCounter(blockCounter) - time.Sleep(50 * time.Millisecond) - cancel() - if err := <-errChan; err != nil { - t.Errorf("Run returned error: %v", err) - } + // Older than activation, older than the 30-day window, recent. + seed(spvChain, preActivationKey, activationBlock-10_000) + seed(spvChain, oldKey, currentBlock-reservationDefaultLookBackBlocks-100_000) + seed(spvChain, recentKey, 300_000) - // Only recentKey should have been discovered and notified. - calls := spvChain.getSubmittedReservationActionTimeouts() - if len(calls) != 1 { - t.Fatalf("expected exactly 1 notification (recent event only), got %d", len(calls)) - } - if diff := deep.Equal(recentKey, calls[0].reservationKey); diff != nil { - t.Errorf("unexpected notified key: %v", diff) + ratw := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, nil, activation) + // now is comfortably beyond TimeoutAt plus the maximum + // reservationOperatorStaggerOffset. + ratw.nowFn = func() uint32 { return 900 } + return spvChain, ratw } - // oldKey should not be tracked in pendingActions. - oldEventKey := actionEventKey(oldKey, 1) - if _, ok := ratw.pendingActions[oldEventKey]; ok { - t.Errorf("oldKey should not have been discovered by bounded initial scan") - } + t.Run("known activation", func(t *testing.T) { + spvChain, ratw := newWatcher(activationBlock) + + if err := ratw.pollPendingActions(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + notified := make(map[string]bool) + for _, call := range spvChain.getSubmittedReservationActionTimeouts() { + notified[call.reservationKey.String()] = true + } + if !notified[oldKey.String()] { + t.Error("expected the action requested before the 30-day window to be notified") + } + if !notified[recentKey.String()] { + t.Error("expected the recent action to be notified") + } + if notified[preActivationKey.String()] { + t.Error("expected the pre-activation request not to be scanned") + } + if expected := currentBlock - reservationEventScanConfirmationBlocks; ratw.reanchorLastScannedBlock != expected { + t.Errorf("expected the cursor at %d, got %d", expected, ratw.reanchorLastScannedBlock) + } + }) + + t.Run("unknown network", func(t *testing.T) { + spvChain, ratw := newWatcher(math.MaxUint64) + + if err := ratw.pollPendingActions(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if calls := spvChain.getSubmittedReservationActionTimeouts(); len(calls) != 0 { + t.Errorf("expected no first scan on an unknown network, got %d notifications", len(calls)) + } + if len(ratw.pendingActions) != 0 { + t.Errorf("expected nothing tracked, got %d", len(ratw.pendingActions)) + } + if expected := currentBlock - reservationEventScanConfirmationBlocks; ratw.reanchorLastScannedBlock != expected { + t.Errorf("expected the cursor to move to %d, got %d", expected, ratw.reanchorLastScannedBlock) + } + }) } // TestReservationActionTimeoutWatcher_RecheckStrandingAfterActionTimeout_NotifiesWhenWalletClosed @@ -1201,7 +1162,7 @@ func TestReservationActionTimeoutWatcher_RecheckStrandingAfterActionTimeout_Noti TimeoutAt: 100, }) ratw := NewReservationActionTimeoutWatcher( - spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), math.MaxUint64, + spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), 0, ) if err := ratw.CheckReservationActionTimeouts(key, 5_000); err != nil { @@ -1254,7 +1215,7 @@ func TestReservationActionTimeoutWatcher_RecheckStrandingAfterActionTimeout_Skip }) ratw := NewReservationActionTimeoutWatcher( - spvChain, 0, common.Address{}, nil, math.MaxUint64, + spvChain, 0, common.Address{}, nil, 0, ) if err := ratw.CheckReservationActionTimeouts(key, 5_000); err != nil { @@ -1299,7 +1260,7 @@ func TestReservationActionTimeoutWatcher_RecheckStrandingAfterActionTimeout_NilW TimeoutAt: 100, }) - ratw := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), math.MaxUint64) + ratw := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), 0) if err := ratw.CheckReservationActionTimeouts(key, 5_000); err != nil { t.Fatalf("unexpected error: %v", err) @@ -1343,7 +1304,7 @@ func TestReservationActionTimeoutWatcher_PollPendingActions_BoundsRPCVolumePerTi wrapped := &actionCallCountingChain{Chain: spvChain} - ratw := NewReservationActionTimeoutWatcher(wrapped, 0, common.Address{}, nil, math.MaxUint64) + ratw := NewReservationActionTimeoutWatcher(wrapped, 0, common.Address{}, nil, 0) // now is comfortably beyond every seeded TimeoutAt plus the maximum // possible reservationOperatorStaggerOffset for every one of the // tracked keys (see the identical comment in @@ -1406,7 +1367,7 @@ func TestReservationActionTimeoutWatcher_PollPendingActions_BoundsRPCVolumePerTi // reservationActionTimeoutMaxChecksPerTick and, via its rotating cursor, // covers every tracked action within ceil(tracked/cap) ticks. func TestReservationActionTimeoutWatcher_NextActionCheckBatch_CapsAndRotates(t *testing.T) { - ratw := NewReservationActionTimeoutWatcher(newLocalChain(), 0, common.Address{}, nil, math.MaxUint64) + ratw := NewReservationActionTimeoutWatcher(newLocalChain(), 0, common.Address{}, nil, 0) total := reservationActionTimeoutMaxChecksPerTick + 10 for i := range total { @@ -1458,7 +1419,7 @@ func TestReservationActionTimeoutWatcher_NextActionCheckBatch_CapsAndRotates(t * // beginning - preserving the pre-fix single-pass behavior for the common // case where the cap never actually binds. func TestReservationActionTimeoutWatcher_NextActionCheckBatch_NoCapNeeded(t *testing.T) { - ratw := NewReservationActionTimeoutWatcher(newLocalChain(), 0, common.Address{}, nil, math.MaxUint64) + ratw := NewReservationActionTimeoutWatcher(newLocalChain(), 0, common.Address{}, nil, 0) for i := range 5 { key := reservationKey(uint64(0xF100 + i)) @@ -1499,7 +1460,7 @@ func TestReservationActionTimeoutWatcher_PollPendingActions_RenotifyBackoffSurvi wallet := walletPKH() key := reservationKey(0x3010) - ratw := NewReservationActionTimeoutWatcher(spvChain, time.Minute, common.Address{}, nil, math.MaxUint64) + ratw := NewReservationActionTimeoutWatcher(spvChain, time.Minute, common.Address{}, nil, 0) seededReservation( t, @@ -1557,7 +1518,7 @@ func TestReservationActionTimeoutWatcher_DrainStrandingRechecks_RequeuesOnGetWal // No spvChain.setWallet call for this wallet: GetWallet returns "no // wallet for given PKH", simulating a transient RPC failure. ratw := NewReservationActionTimeoutWatcher( - spvChain, 0, common.Address{}, nil, math.MaxUint64, + spvChain, 0, common.Address{}, nil, 0, ) ratw.strandingRecheckWallets[wallet] = struct{}{} @@ -1585,7 +1546,7 @@ func TestReservationActionTimeoutWatcher_DrainStrandingRechecks_RequeuesOnStrand strandingWatcher := newReservationStrandingWatcher(spvChain) strandingWatcher.retryDelay = time.Millisecond - ratw := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), math.MaxUint64) + ratw := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), 0) ratw.strandingRecheckWallets[wallet] = struct{}{} ratw.drainStrandingRechecks() @@ -1608,7 +1569,7 @@ func TestReservationActionTimeoutWatcher_DrainStrandingRechecks_SucceedsDoesNotR spvChain.setWallet(wallet, &tbtc.WalletChainData{State: tbtc.StateLive}) ratw := NewReservationActionTimeoutWatcher( - spvChain, 0, common.Address{}, nil, math.MaxUint64, + spvChain, 0, common.Address{}, nil, 0, ) ratw.strandingRecheckWallets[wallet] = struct{}{} diff --git a/pkg/maintainer/spv/reservation_event_scan.go b/pkg/maintainer/spv/reservation_event_scan.go index 32a1bd5ad7..4ae78adb6f 100644 --- a/pkg/maintainer/spv/reservation_event_scan.go +++ b/pkg/maintainer/spv/reservation_event_scan.go @@ -6,91 +6,130 @@ import ( ) // reservationEventScanChunkSize bounds the inclusive block range of a -// single Past*Events call in the reservation startup catch-up scans. The -// startup scan can stretch from the network's reservation activation -// block to the current tip, which on a long-running network is millions -// of blocks; a single getLogs across that span is not usable. 10,000 -// blocks keeps every individual call within the eth_getLogs range cap -// enforced by many common third-party RPC providers, so a provider with -// that cap still makes progress chunk by chunk instead of the whole -// walk stalling on the first rejected range (an aborted chunk discards -// the earlier chunks' progress). +// single Past*Events call in the reservation event scans. The first scan +// can stretch from the network's reservation activation block to the +// current tip, which on a long-running network is millions of blocks; a +// single getLogs across that span is not usable. 10,000 blocks keeps +// every individual call within the eth_getLogs range cap enforced by +// many common third-party RPC providers, so a provider with that cap +// still makes progress chunk by chunk. // -// Steady-state incremental scans (cursor + 1 to currentBlock) are far -// smaller than this chunk size and continue to issue a single call. +// Steady-state incremental scans (cursor + 1 to the confirmed tip) are +// far smaller than this chunk size and issue a single call. const reservationEventScanChunkSize uint64 = 10000 -// reservationStartupScanStartBlock returns the first block the -// reservation startup catch-up scan covers, together with a flag -// indicating whether a scan is warranted at all. -// -// activationBlock is the network's reservation activation block height -// (see tbtc.ReservationsActivationBlock). When it equals math.MaxUint64 -// the network has no reservations-activation entry and the feature is -// inactive for that network; the startup scan is skipped entirely - no -// reservation events can exist when reservations are inactive, and -// silently scanning an arbitrary window of unrelated history would just -// burn RPC quota. +// reservationEventScanConfirmationBlocks is how far behind the current +// chain tip every reservation event scan stops. A scan cursor never +// moves past a block that could still be replaced by a short reorg, so +// an event that only appears in a replacement block is still picked up +// by a later scan. Every reservation event starts a deadline measured in +// hours or days, so discovering it a dozen blocks late costs nothing. +const reservationEventScanConfirmationBlocks uint64 = 12 + +// reservationScanRange returns the inclusive block range +// [startBlock, endBlock] the next reservation event scan covers, given +// the scan's cursor (the last block already scanned, 0 before the first +// scan), the network's reservation activation block (see +// tbtc.ReservationsActivationBlock) and the current chain tip. // -// Otherwise the scan starts at activationBlock. If activation is still -// in the future, startBlock is greater than the current tip; the chunk -// helper treats that inverted range as a no-op. Keeping the future -// activation block intact also prevents callers from scanning -// pre-activation history. +// endBlock trails currentBlock by reservationEventScanConfirmationBlocks. +// The first scan starts at activationBlock, so a process started long +// after activation still finds every event emitted since then; later +// scans start one block past the cursor. When activation is still ahead +// of endBlock the range is inverted, fetchPastEventsInChunks treats it +// as a no-op and the cursor stays 0, so the next scan starts at +// activation again. // -// The boolean is false only for an inactive network, where the caller -// should not attempt a scan at all. -func reservationStartupScanStartBlock(activationBlock uint64) (uint64, bool) { +// scan is false only for the first scan on a network with no activation +// entry (math.MaxUint64): reservations are inactive there, so no +// catch-up scan runs and the caller should move its cursor straight to +// endBlock. Later scans on such a network cover only new blocks. +func reservationScanRange( + lastScannedBlock uint64, + activationBlock uint64, + currentBlock uint64, +) (startBlock uint64, endBlock uint64, scan bool) { + if currentBlock > reservationEventScanConfirmationBlocks { + endBlock = currentBlock - reservationEventScanConfirmationBlocks + } + + if lastScannedBlock != 0 { + return lastScannedBlock + 1, endBlock, true + } + if activationBlock == math.MaxUint64 { - return 0, false + return 0, endBlock, false } - return activationBlock, true + + return activationBlock, endBlock, true +} + +// nextReservationScanRange reads the current block from spvChain and +// returns reservationScanRange for the given cursor and activation block. +func nextReservationScanRange( + spvChain Chain, + lastScannedBlock uint64, + activationBlock uint64, +) (startBlock uint64, endBlock uint64, scan bool, err error) { + blockCounter, err := spvChain.BlockCounter() + if err != nil { + return 0, 0, false, fmt.Errorf("failed to get block counter: [%v]", err) + } + + currentBlock, err := blockCounter.CurrentBlock() + if err != nil { + return 0, 0, false, fmt.Errorf("failed to get current block: [%v]", err) + } + + startBlock, endBlock, scan = reservationScanRange( + lastScannedBlock, + activationBlock, + currentBlock, + ) + return startBlock, endBlock, scan, nil } // fetchPastEventsInChunks walks the inclusive block range // [startBlock, endBlock] in chunks of at most -// reservationEventScanChunkSize blocks, calling fetch for each chunk -// and concatenating the returned events in ascending block order. A -// fetch error stops the walk and is returned wrapped with the chunk -// range that triggered it. An inverted range (startBlock > endBlock) -// is a no-op and yields a nil slice without calling fetch, matching -// the conventions of the Past*Events methods on the spv chain -// implementations and giving callers a single conditional-free call -// pattern: compute the range, hand it to this helper, done. +// reservationEventScanChunkSize blocks, in ascending order. After each +// chunk is fetched it calls onChunk with that chunk's events and last +// block, so the caller can merge the events and advance its cursor +// chunk by chunk: a fetch error stops the walk, but the chunks before it +// are kept and the next scan resumes after the last one delivered. The +// error is returned wrapped with the failing chunk range. An inverted +// range (startBlock > endBlock) is a no-op that calls neither fetch nor +// onChunk. // -// The generic parameter T lets a single implementation serve all three -// reservation Past*Events fetches (acceptance requested, reanchor -// requested, deposit revealed) without per-type boilerplate. The -// caller builds a closure that constructs the event-type-specific -// filter, so the helper does not need to know the filter shape. +// The caller builds a fetch closure that constructs the +// event-type-specific filter, so one implementation serves every +// reservation Past*Events call. func fetchPastEventsInChunks[T any]( fetch func(startBlock, endBlock uint64) ([]T, error), startBlock, endBlock uint64, -) ([]T, error) { + onChunk func(events []T, chunkEnd uint64), +) error { if startBlock > endBlock { - return nil, nil + return nil } - var all []T for chunkStart := startBlock; ; { chunkEnd := chunkStart + reservationEventScanChunkSize - 1 - if chunkEnd > endBlock { + if chunkEnd > endBlock || chunkEnd < chunkStart { chunkEnd = endBlock } chunk, err := fetch(chunkStart, chunkEnd) if err != nil { - return nil, fmt.Errorf( + return fmt.Errorf( "fetching past events in chunk [%d..%d]: [%w]", chunkStart, chunkEnd, err, ) } - all = append(all, chunk...) + onChunk(chunk, chunkEnd) if chunkEnd == endBlock { - break + return nil } chunkStart = chunkEnd + 1 } - return all, nil } diff --git a/pkg/maintainer/spv/reservation_event_scan_test.go b/pkg/maintainer/spv/reservation_event_scan_test.go index 0f3e4e6e9a..a82945aac0 100644 --- a/pkg/maintainer/spv/reservation_event_scan_test.go +++ b/pkg/maintainer/spv/reservation_event_scan_test.go @@ -3,6 +3,7 @@ package spv import ( "errors" "fmt" + "math" "strings" "testing" ) @@ -115,6 +116,27 @@ func assertEachEventReturnedOnce( } } +// collectChunks runs fetchPastEventsInChunks against f and returns every +// event delivered to onChunk together with the chunkEnd of each delivery, +// in delivery order. +func collectChunks( + f *rangeRecordingFetcher, + startBlock, endBlock uint64, +) ([]chunkTestEvent, []uint64, error) { + var events []chunkTestEvent + var chunkEnds []uint64 + err := fetchPastEventsInChunks( + f.fetch, + startBlock, + endBlock, + func(chunk []chunkTestEvent, chunkEnd uint64) { + events = append(events, chunk...) + chunkEnds = append(chunkEnds, chunkEnd) + }, + ) + return events, chunkEnds, err +} + // TestFetchPastEventsInChunks_ChunkBoundaries drives a range spanning three // chunks and asserts that events sitting on the chunk boundaries - the last // block of chunk 1, the first block of chunk 2, and the later chunk 2/3 @@ -140,7 +162,7 @@ func TestFetchPastEventsInChunks_ChunkBoundaries(t *testing.T) { }, } - got, err := fetchPastEventsInChunks(f.fetch, 0, endBlock) + got, chunkEnds, err := collectChunks(f, 0, endBlock) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -167,6 +189,14 @@ func TestFetchPastEventsInChunks_ChunkBoundaries(t *testing.T) { expectedRanges[i].end, ) } + if chunkEnds[i] != expectedRanges[i].end { + t.Errorf( + "chunk %d delivered chunkEnd %d, expected %d", + i, + chunkEnds[i], + expectedRanges[i].end, + ) + } } } @@ -181,11 +211,7 @@ func TestFetchPastEventsInChunks_SingleChunk(t *testing.T) { }, } - got, err := fetchPastEventsInChunks( - f.fetch, - 0, - reservationEventScanChunkSize-1, - ) + got, _, err := collectChunks(f, 0, reservationEventScanChunkSize-1) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -201,18 +227,19 @@ func TestFetchPastEventsInChunks_SingleChunk(t *testing.T) { } // TestFetchPastEventsInChunks_InvertedRange verifies that an inverted range -// is a no-op: no fetch is issued and an empty result is returned. +// is a no-op: neither fetch nor onChunk is called, so a caller's cursor +// does not move. func TestFetchPastEventsInChunks_InvertedRange(t *testing.T) { f := &rangeRecordingFetcher{ events: []chunkTestEvent{{BlockNumber: 4}}, } - got, err := fetchPastEventsInChunks(f.fetch, 10, 5) + got, chunkEnds, err := collectChunks(f, 10, 5) if err != nil { t.Fatalf("unexpected error: %v", err) } - if got != nil { - t.Errorf("expected no events for an inverted range, got %v", got) + if got != nil || chunkEnds != nil { + t.Errorf("expected no deliveries for an inverted range, got %v / %v", got, chunkEnds) } if len(f.ranges) != 0 { t.Errorf("expected no chunk fetches for an inverted range, got %v", f.ranges) @@ -221,7 +248,9 @@ func TestFetchPastEventsInChunks_InvertedRange(t *testing.T) { // TestFetchPastEventsInChunks_ErrorInSecondChunk verifies that an error on // the second chunk is returned, wrapped with the range that failed, and -// that no partial result is returned. +// that the first chunk's events were already delivered, so a caller keeps +// that progress and resumes after the first chunk instead of restarting +// the whole walk. func TestFetchPastEventsInChunks_ErrorInSecondChunk(t *testing.T) { f := &rangeRecordingFetcher{ events: []chunkTestEvent{ @@ -232,16 +261,17 @@ func TestFetchPastEventsInChunks_ErrorInSecondChunk(t *testing.T) { failErr: errors.New("provider rejected the range"), } - got, err := fetchPastEventsInChunks( - f.fetch, - 0, - 2*reservationEventScanChunkSize-1, - ) + got, chunkEnds, err := collectChunks(f, 0, 2*reservationEventScanChunkSize-1) if err == nil { t.Fatal("expected an error from the second chunk, got nil") } - if got != nil { - t.Errorf("expected no partial result, got %v events", len(got)) + assertEachEventReturnedOnce(t, got, []chunkTestEvent{{BlockNumber: 100}}) + if len(chunkEnds) != 1 || chunkEnds[0] != reservationEventScanChunkSize-1 { + t.Errorf( + "expected only the first chunk [..%d] to be delivered, got %v", + reservationEventScanChunkSize-1, + chunkEnds, + ) } expectedRange := fmt.Sprintf( "[%d..%d]", @@ -265,3 +295,101 @@ func TestFetchPastEventsInChunks_ErrorInSecondChunk(t *testing.T) { 2*reservationEventScanChunkSize-1, ) } + +// TestReservationScanRange covers the scan-range policy shared by the proof +// loop, the stale-deposit watcher and the action-timeout watcher. The +// first scan starts at the activation block wherever it sits relative to +// the tip - an old activation is not cut off by any lookback window, +// because an action that timed out but was never notified still holds +// capacity - an unknown network skips the first scan, and every range +// stops reservationEventScanConfirmationBlocks behind the tip. +func TestReservationScanRange(t *testing.T) { + const lag = reservationEventScanConfirmationBlocks + + tests := map[string]struct { + lastScannedBlock uint64 + activationBlock uint64 + currentBlock uint64 + expectedStart uint64 + expectedEnd uint64 + expectedScan bool + }{ + "first scan, activation at genesis (Developer)": { + activationBlock: 0, + currentBlock: 1000, + expectedStart: 0, + expectedEnd: 1000 - lag, + expectedScan: true, + }, + "first scan, activation inside the last 30 days": { + activationBlock: 250_000, + currentBlock: 300_000, + expectedStart: 250_000, + expectedEnd: 300_000 - lag, + expectedScan: true, + }, + "first scan, activation older than 30 days": { + activationBlock: 1_000, + currentBlock: 1_000_000, + expectedStart: 1_000, + expectedEnd: 1_000_000 - lag, + expectedScan: true, + }, + "first scan, activation above the tip": { + activationBlock: 500_000, + currentBlock: 300_000, + expectedStart: 500_000, + expectedEnd: 300_000 - lag, + expectedScan: true, + }, + "first scan, unknown network": { + activationBlock: math.MaxUint64, + currentBlock: 300_000, + expectedStart: 0, + expectedEnd: 300_000 - lag, + expectedScan: false, + }, + "later scan starts one block past the cursor": { + lastScannedBlock: 450_000, + activationBlock: 0, + currentBlock: 500_000, + expectedStart: 450_001, + expectedEnd: 500_000 - lag, + expectedScan: true, + }, + "later scan on an unknown network covers new blocks": { + lastScannedBlock: 450_000, + activationBlock: math.MaxUint64, + currentBlock: 500_000, + expectedStart: 450_001, + expectedEnd: 500_000 - lag, + expectedScan: true, + }, + "tip within the confirmation depth": { + activationBlock: 0, + currentBlock: lag, + expectedStart: 0, + expectedEnd: 0, + expectedScan: true, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + start, end, scan := reservationScanRange( + test.lastScannedBlock, + test.activationBlock, + test.currentBlock, + ) + if scan != test.expectedScan { + t.Errorf("unexpected scan: expected %v, got %v", test.expectedScan, scan) + } + if start != test.expectedStart { + t.Errorf("unexpected start: expected %d, got %d", test.expectedStart, start) + } + if end != test.expectedEnd { + t.Errorf("unexpected end: expected %d, got %d", test.expectedEnd, end) + } + }) + } +} diff --git a/pkg/maintainer/spv/reservation_proof_loop.go b/pkg/maintainer/spv/reservation_proof_loop.go index 1d50f1038b..b40cc45026 100644 --- a/pkg/maintainer/spv/reservation_proof_loop.go +++ b/pkg/maintainer/spv/reservation_proof_loop.go @@ -5,6 +5,7 @@ import ( "encoding/binary" "fmt" "math/big" + "sort" "time" "github.com/ethereum/go-ethereum/crypto" @@ -19,9 +20,9 @@ import ( // the set of still-settleable action-request events across successive // passes of runReservationProofLoop, so proveReservationAcceptanceActions // and proveReservationReanchorActions scan only the event/Bitcoin history -// that has appeared since the previous pass instead of rescanning the full -// reservationDefaultLookBackBlocks window - and refetching Bitcoin history -// for every wallet in it - every config.IdleBackoffTime. +// that has appeared since the previous pass (see reservationScanRange) +// instead of rescanning from the activation block - and refetching +// Bitcoin history for every wallet - every config.IdleBackoffTime. type reservationProofScanState struct { acceptanceLastScannedBlock uint64 pendingAcceptanceEvents map[string]*tbtc.ReservationAcceptanceRequestedEvent @@ -29,6 +30,13 @@ type reservationProofScanState struct { reanchorLastScannedBlock uint64 pendingReanchorEvents map[string]*tbtc.ReservationReanchorRequestedEvent + // reanchorPass counts proveReservationReanchorActions passes; + // reanchorBackoff holds, per pendingReanchorEvents key, the backoff + // delay of a TimedOut generation whose last check found nothing to + // prove (see reanchorProofBackoff). + reanchorPass uint64 + reanchorBackoff map[string]*reanchorProofBackoff + // walletTransactionCache caches each wallet's confirmed Bitcoin // transaction hash set and bodies from the pass that fetched them, so // walletTransactionsForProof can skip the full GetTransactionsForPublicKeyHash @@ -61,6 +69,7 @@ func newReservationProofScanState() *reservationProofScanState { return &reservationProofScanState{ pendingAcceptanceEvents: make(map[string]*tbtc.ReservationAcceptanceRequestedEvent), pendingReanchorEvents: make(map[string]*tbtc.ReservationReanchorRequestedEvent), + reanchorBackoff: make(map[string]*reanchorProofBackoff), walletTransactionCache: make(map[[20]byte]*walletTransactionCacheEntry), nowFn: defaultReservationProofNowFn, } @@ -73,8 +82,9 @@ func defaultReservationProofNowFn() uint32 { return uint32(time.Now().Unix()) } -// reanchorSourceAnchorHash computes exactly the Bridge's -// anchorUtxoHash(reservation) from Reservation.sol: +// reanchorSourceAnchorHash computes the Bridge's +// anchorUtxoHash(reservation) from Reservation.sol for a reservation with +// an anchor outpoint: // keccak256(abi.encodePacked(anchorTxHash, uint32 anchorTxOutputIndex)) - // the 32-byte anchor transaction hash in its Bitcoin internal byte order // (the order the Bridge stores it, matching the @@ -82,7 +92,9 @@ func defaultReservationProofNowFn() uint32 { // big-endian uint32. It is the hash the Bridge's // requireCurrentSourceAnchor check in ReservationProofs.sol compares // against each tracked generation's on-chain source anchor snapshot. A -// reservation with no anchor outpoint yields the zero hash. +// reservation with no anchor outpoint yields the zero hash, where the +// Bridge would hash the zeroed fields instead; neither value can equal a +// generation's snapshot, which is always taken from a real anchor. func reanchorSourceAnchorHash(reservation *tbtc.Reservation) [32]byte { if reservation == nil || reservation.AnchorUtxo == nil || @@ -200,47 +212,6 @@ func reservationEventKey(reservationKey *big.Int, requestNonce uint64) string { return fmt.Sprintf("%s:%d", reservationKey.String(), requestNonce) } -// reservationProofNextScanRange returns the block range to scan for new -// pending-action-request events this pass. On the very first pass -// (lastScannedBlock == 0) the range starts at the network's reservation -// activation block (see reservationStartupScanStartBlock) so a process -// restart that happens long after reservations activated still picks up -// every action generation requested since activation, instead of being -// bounded by a fixed 30-day lookback. Steady-state passes (lastScannedBlock -// > 0) scan only the delta since the previous cursor, so no full-history -// refetch happens on every config.IdleBackoffTime tick. -// -// The first pass is a no-op when activationBlock == math.MaxUint64 - the -// network has no reservations-activation entry and the feature is -// inactive for it - in which case startBlock is returned as 0 with no -// currentBlock advance so the caller skips the scan and immediately -// stores currentBlock as the new cursor. -func reservationProofNextScanRange( - spvChain Chain, - lastScannedBlock uint64, - activationBlock uint64, -) (startBlock uint64, currentBlock uint64, skipScan bool, err error) { - blockCounter, err := spvChain.BlockCounter() - if err != nil { - return 0, 0, false, fmt.Errorf("failed to get block counter: [%v]", err) - } - - currentBlock, err = blockCounter.CurrentBlock() - if err != nil { - return 0, 0, false, fmt.Errorf("failed to get current block: [%v]", err) - } - - if lastScannedBlock == 0 { - start, active := reservationStartupScanStartBlock(activationBlock) - if !active { - return 0, currentBlock, true, nil - } - return start, currentBlock, false, nil - } - - return lastScannedBlock + 1, currentBlock, false, nil -} - // maintainReservationProofs runs the SPV proof submission loop for // reservation acceptance and re-anchor action generations. It is a // dedicated loop, separate from spvMaintainer's generic proofTypes-driven @@ -331,7 +302,10 @@ func runReservationProofLoop( // One cache per pass, shared by the acceptance and re-anchor proof // rounds below; see proofInfoCache in spv.go. cache := newProofInfoCache() - if err := proveReservationAcceptanceActions( + // The re-anchor round runs even when the acceptance round fails, + // so one failing event scan does not hold back the other kind of + // proof; the first error is returned once both rounds are done. + acceptanceErr := proveReservationAcceptanceActions( state, config, spvChain, @@ -339,14 +313,9 @@ func runReservationProofLoop( btcChain, cache, metricsRecorder, - ); err != nil { - return fmt.Errorf( - "error while proving reservation acceptance actions: [%v]", - err, - ) - } + ) - if err := proveReservationReanchorActions( + reanchorErr := proveReservationReanchorActions( state, config, spvChain, @@ -354,12 +323,7 @@ func runReservationProofLoop( btcChain, cache, metricsRecorder, - ); err != nil { - return fmt.Errorf( - "error while proving reservation re-anchor actions: [%v]", - err, - ) - } + ) // Evict cache entries for wallets with no remaining pending // acceptance or re-anchor actions now that both proof-generation @@ -367,6 +331,19 @@ func runReservationProofLoop( // see evictStaleWalletTransactionCacheEntries. evictStaleWalletTransactionCacheEntries(state) + if acceptanceErr != nil { + return fmt.Errorf( + "error while proving reservation acceptance actions: [%v]", + acceptanceErr, + ) + } + if reanchorErr != nil { + return fmt.Errorf( + "error while proving reservation re-anchor actions: [%v]", + reanchorErr, + ) + } + select { case <-time.After(config.IdleBackoffTime): case <-ctx.Done(): @@ -389,7 +366,7 @@ func proveReservationAcceptanceActions( cache *proofInfoCache, metricsRecorder MetricsRecorder, ) error { - startBlock, currentBlock, skipScan, err := reservationProofNextScanRange( + startBlock, endBlock, scan, err := nextReservationScanRange( spvChain, state.acceptanceLastScannedBlock, tbtc.ReservationsActivationBlock(config.EthereumNetwork), @@ -398,46 +375,49 @@ func proveReservationAcceptanceActions( return err } - if skipScan { - state.acceptanceLastScannedBlock = currentBlock - return nil - } - - newEvents, err := fetchPastEventsInChunks[*tbtc.ReservationAcceptanceRequestedEvent]( - func(chunkStart, chunkEnd uint64) ([]*tbtc.ReservationAcceptanceRequestedEvent, error) { - return spvChain.PastReservationAcceptanceRequestedEvents( - &tbtc.ReservationAcceptanceRequestedEventFilter{ - StartBlock: chunkStart, - EndBlock: &chunkEnd, - }, + // A scan error does not stop this pass: the chunks fetched before it + // are already tracked, and every tracked generation is still proved + // below. The error is returned once the pass is done. + var scanErr error + if scan { + if err := fetchPastEventsInChunks( + func(chunkStart, chunkEnd uint64) ([]*tbtc.ReservationAcceptanceRequestedEvent, error) { + return spvChain.PastReservationAcceptanceRequestedEvents( + &tbtc.ReservationAcceptanceRequestedEventFilter{ + StartBlock: chunkStart, + EndBlock: &chunkEnd, + }, + ) + }, + startBlock, + endBlock, + func(events []*tbtc.ReservationAcceptanceRequestedEvent, chunkEnd uint64) { + for _, event := range events { + key := reservationEventKey(event.ReservationKey, event.RequestNonce) + state.pendingAcceptanceEvents[key] = event + } + state.acceptanceLastScannedBlock = chunkEnd + }, + ); err != nil { + scanErr = fmt.Errorf( + "failed to get past reservation acceptance requested "+ + "events: [%v]", + err, ) - }, - startBlock, - currentBlock, - ) - if err != nil { - return fmt.Errorf( - "failed to get past reservation acceptance requested "+ - "events: [%v]", - err, - ) - } - - for _, event := range newEvents { - key := reservationEventKey(event.ReservationKey, event.RequestNonce) - state.pendingAcceptanceEvents[key] = event + } + } else { + state.acceptanceLastScannedBlock = endBlock } - // Re-check every tracked event's on-chain action state, evict the - // non-settleable ones, and group the still-settleable events by - // wallet public key hash. The Bridge settles a generation while its + // Re-check every tracked event's on-chain action state and evict the + // non-settleable ones. The Bridge settles a generation while its // action is Pending or TimedOut: a TimedOut acceptance generation // stays settleable only through timeoutAt + termSeconds (see // loadSettleableAction in ReservationProofs.sol), so the scans keep // TimedOut candidates until that window has closed instead of // deleting them the moment the state flips. now := state.nowFn() - walletEvents := make(map[[20]byte][]*tbtc.ReservationAcceptanceRequestedEvent) + var generations []reservationProofGeneration[*tbtc.ReservationAcceptanceRequestedEvent] for key, event := range state.pendingAcceptanceEvents { action, err := spvChain.GetReservationAction( event.ReservationKey, @@ -466,6 +446,28 @@ func proveReservationAcceptanceActions( delete(state.pendingAcceptanceEvents, key) continue } + + // Once any generation of this reservation has settled, the + // Bridge rejects every other one ("Reservation already + // exists" in submitReservationAcceptanceProof). Settling + // also marks the deposit swept, and a reserved deposit + // cannot be swept any other way, so the reservation state + // alone decides it. + reservation, err := spvChain.GetReservation(event.ReservationKey) + if err != nil { + logger.Errorf( + "failed to load reservation [%v] for timed-out "+ + "acceptance generation %d: [%v]", + event.ReservationKey, + event.RequestNonce, + err, + ) + continue + } + if reservation.State != tbtc.ReservationStateUnknown { + delete(state.pendingAcceptanceEvents, key) + continue + } case tbtc.ReservationActionStatePending: default: // Settled, Superseded, Vetoed and absent generations are not @@ -474,84 +476,105 @@ func proveReservationAcceptanceActions( continue } - walletEvents[event.WalletPublicKeyHash] = append( - walletEvents[event.WalletPublicKeyHash], - event, + generations = append( + generations, + reservationProofGeneration[*tbtc.ReservationAcceptanceRequestedEvent]{ + event: event, + reservationKey: event.ReservationKey, + requestNonce: event.RequestNonce, + pending: action.State == tbtc.ReservationActionStatePending, + }, ) } - - for walletPublicKeyHash, events := range walletEvents { - walletTransactions, err := walletTransactionsForProof( - state, - btcChain, - walletPublicKeyHash, - config.TransactionLimit, - ) - if err != nil { - logger.Errorf("failed to get transactions for wallet: [%v]", err) + sortReservationProofGenerations(generations) + + // Each wallet's candidate transactions are indexed once per pass, by + // deposit key. A wallet that broadcasts an RBF replacement chain for + // the same deposit key produces multiple candidates per key, so + // every candidate is kept rather than only the last one seen. A nil + // entry marks a wallet whose transactions could not be fetched this + // pass. + walletCandidates := make(map[[20]byte]map[string][]*bitcoin.Transaction) + submittedReservations := make(map[string]struct{}) + for _, generation := range generations { + event := generation.event + reservationKey := event.ReservationKey.String() + if _, submitted := submittedReservations[reservationKey]; submitted { continue } - // Index wallet transactions by deposit key for O(1) matching. A - // wallet that broadcasts an RBF replacement chain for the same - // deposit key produces multiple candidates per key, so every - // candidate is kept rather than only the last one seen. - candidateTransactions := make(map[string][]*bitcoin.Transaction) - for _, transaction := range walletTransactions { - if len(transaction.Inputs) == 1 && len(transaction.Outputs) == 1 && transaction.Inputs[0].Outpoint != nil { - input := transaction.Inputs[0] - depositKey := spvChain.BuildDepositKey( - input.Outpoint.TransactionHash, - input.Outpoint.OutputIndex, - ) - key := depositKey.String() - candidateTransactions[key] = append(candidateTransactions[key], transaction) + candidateTransactions, indexed := walletCandidates[event.WalletPublicKeyHash] + if !indexed { + walletTransactions, err := walletTransactionsForProof( + state, + btcChain, + event.WalletPublicKeyHash, + config.TransactionLimit, + ) + if err != nil { + logger.Errorf("failed to get transactions for wallet: [%v]", err) + } else { + candidateTransactions = make(map[string][]*bitcoin.Transaction) + for _, transaction := range walletTransactions { + if len(transaction.Inputs) == 1 && len(transaction.Outputs) == 1 && transaction.Inputs[0].Outpoint != nil { + input := transaction.Inputs[0] + depositKey := spvChain.BuildDepositKey( + input.Outpoint.TransactionHash, + input.Outpoint.OutputIndex, + ).String() + candidateTransactions[depositKey] = append( + candidateTransactions[depositKey], + transaction, + ) + } + } } + walletCandidates[event.WalletPublicKeyHash] = candidateTransactions } - for _, event := range events { - candidates, ok := candidateTransactions[event.ReservationKey.String()] - if !ok { - continue - } + candidates, ok := candidateTransactions[reservationKey] + if !ok { + continue + } - if err := proveReservationTransaction( - candidates, - func(transaction *bitcoin.Transaction) bool { - return isMatchingReservationAcceptanceTransaction(spvChain, event, transaction) - }, - btcChain, - spvChain, - btcDiffChain, - config.MaxProofHeaders, - cache, - metricsRecorder, - func(transactionHash bitcoin.Hash, requiredConfirmations uint) error { - return SubmitReservationAcceptanceProof( - transactionHash, - requiredConfirmations, - event.ReservationKey, - event.RequestNonce, - btcChain, - spvChain, - metricsRecorder, - ) - }, - ); err != nil { - logger.Errorf( - "failed to prove reservation acceptance transaction "+ - "for reservation [%v]: [%v]", + outcome, err := proveReservationTransaction( + candidates, + func(transaction *bitcoin.Transaction) bool { + return isMatchingReservationAcceptanceTransaction(spvChain, event, transaction) + }, + btcChain, + spvChain, + btcDiffChain, + config.MaxProofHeaders, + cache, + metricsRecorder, + func(transactionHash bitcoin.Hash, requiredConfirmations uint) error { + return SubmitReservationAcceptanceProof( + transactionHash, + requiredConfirmations, event.ReservationKey, - err, + event.RequestNonce, + btcChain, + spvChain, + metricsRecorder, ) - continue - } + }, + ) + if outcome == reservationProofSubmitted { + submittedReservations[reservationKey] = struct{}{} + } + if err != nil { + logger.Errorf( + "failed to prove reservation acceptance transaction "+ + "for reservation [%v]: [%v]", + event.ReservationKey, + err, + ) + continue } } - state.acceptanceLastScannedBlock = currentBlock - - return nil + return scanErr } func isMatchingReservationAcceptanceTransaction( @@ -604,70 +627,185 @@ func isMatchingReservationAcceptanceTransaction( return true } -// evictReanchorGenerationIfSourceAnchorMoved reports whether the TimedOut -// re-anchor generation identified by key must be evicted from state -// because the reservation's current anchor outpoint no longer matches -// the source anchor snapshotted on-chain when the generation was -// requested. +// reservationProofGeneration is one still-settleable action generation a +// proof loop pass may prove. reservation is the reservation record read +// while classifying a re-anchor generation, reused when proving it; it is +// nil for acceptance generations. +type reservationProofGeneration[E any] struct { + key string + event E + reservationKey *big.Int + requestNonce uint64 + pending bool + reservation *tbtc.Reservation +} + +// sortReservationProofGenerations orders generations for proof +// submission: grouped by reservation key, the Pending generation first, +// then TimedOut generations from the highest nonce down. Several +// generations of one reservation can match the same Bitcoin transaction, +// but only one proof can settle it; the proof loops submit at most one +// proof per reservation key per pass, walking generations in this order. +func sortReservationProofGenerations[E any]( + generations []reservationProofGeneration[E], +) { + sort.Slice(generations, func(i, j int) bool { + a, b := generations[i], generations[j] + if c := a.reservationKey.Cmp(b.reservationKey); c != 0 { + return c < 0 + } + if a.pending != b.pending { + return a.pending + } + return a.requestNonce > b.requestNonce + }) +} + +// settleableReanchorReservation loads the reservation of the tracked +// re-anchor generation identified by key and reports whether the +// generation is still worth proving this pass. A read error keeps the +// generation tracked but skips it for this pass. // -// The Bridge settles a re-anchor generation's late proof only while its -// snapshotted source anchor still matches the reservation's current -// anchor (requireCurrentSourceAnchor in ReservationProofs.sol rejects -// the submit otherwise, so once the anchor moves the generation can -// never settle again). The comparison is keyed on the action's -// on-chain sourceAnchorUtxoHash snapshot (written by -// anchorUtxoHash(reservation) at request time in Reservation.sol), not -// on any Go-side first-observation snapshot, so a process restart -// still evicts a generation whose source anchor was already replaced -// before the loop ever observed it. A generation whose on-chain source -// anchor still matches the current anchor outpoint is kept regardless -// of age - re-anchor late settlement is unbounded - so age alone never -// triggers an eviction. -func evictReanchorGenerationIfSourceAnchorMoved( +// A TimedOut generation that can never settle again is evicted from +// state. The Bridge settles a late re-anchor proof only while the +// reservation is Active, ActionPending or Stranded +// (prepareReservationForSettlement in ReservationProofs.sol) and while +// the generation's snapshotted source anchor still matches the +// reservation's current anchor (requireCurrentSourceAnchor). The +// comparison is keyed on the action's on-chain sourceAnchorUtxoHash +// snapshot, not on any Go-side first-observation snapshot, so a process +// restart still evicts a generation whose source anchor was already +// replaced before the loop ever observed it. Re-anchor late settlement +// is otherwise unbounded, so age alone never triggers an eviction. +func settleableReanchorReservation( state *reservationProofScanState, spvChain Chain, key string, event *tbtc.ReservationReanchorRequestedEvent, action *tbtc.ReservationAction, -) bool { +) (*tbtc.Reservation, bool) { reservation, err := spvChain.GetReservation(event.ReservationKey) if err != nil { - // The source-anchor comparison cannot be evaluated without a - // readable reservation record; keep tracking this generation - // and retry on the next pass. logger.Errorf( - "failed to load reservation for re-anchor eviction check [%v]: [%v]", + "failed to load reservation [%v]: [%v]", event.ReservationKey, err, ) + return nil, false + } + + if action.State != tbtc.ReservationActionStateTimedOut { + return reservation, true + } + + switch reservation.State { + case tbtc.ReservationStateActive, + tbtc.ReservationStateActionPending, + tbtc.ReservationStateStranded: + default: + forgetReanchorGeneration(state, key) + return nil, false + } + + if reanchorSourceAnchorHash(reservation) != action.SourceAnchorUtxoHash { + // The anchor outpoint moved past this generation's on-chain + // source anchor; the Bridge will reject its late settlement + // forever. + forgetReanchorGeneration(state, key) + return nil, false + } + + return reservation, true +} + +// reanchorProofBackoffMaxPasses caps the number of proof loop passes a +// TimedOut re-anchor generation is skipped for after a check that found +// nothing to prove. +const reanchorProofBackoffMaxPasses = 32 + +// reanchorProofBackoff delays re-checking a TimedOut re-anchor generation +// whose last check found no matching transaction, or only transactions +// whose proof cannot be built (outside the relay's difficulty range or +// longer than MaxProofHeaders). Such a generation can stay tracked +// indefinitely while its source anchor does not move, and re-checking it +// costs chain reads every pass. Some of those skip reasons clear with +// time (a transaction too fresh for the relay), so the delay is bounded. +type reanchorProofBackoff struct { + // retryPass is the first proof loop pass that checks the generation + // again. + retryPass uint64 + // skipPasses is the current delay in passes; it doubles after each + // fruitless check, up to reanchorProofBackoffMaxPasses. + skipPasses uint64 + // walletTxHashes is the source wallet's confirmed transaction hash + // list when the delay was set; a different list in the wallet + // transaction cache ends the delay early. + walletTxHashes []bitcoin.Hash +} + +// reanchorGenerationBackedOff reports whether the re-anchor generation +// identified by key is still inside its backoff delay this pass. The +// delay ends early once the source wallet's cached transaction list +// differs from the one recorded when the delay was set. +func reanchorGenerationBackedOff( + state *reservationProofScanState, + key string, + sourceWalletPublicKeyHash [20]byte, +) bool { + backoff, ok := state.reanchorBackoff[key] + if !ok { return false } - if reanchorSourceAnchorHash(reservation) == action.SourceAnchorUtxoHash { - // The reservation's current anchor outpoint is still the source - // anchor the generation was requested against; this TimedOut - // generation can still settle a late re-anchor proof on the - // Bridge. + if cached, ok := state.walletTransactionCache[sourceWalletPublicKeyHash]; ok && + !reservationTransactionHashesEqual(cached.txHashes, backoff.walletTxHashes) { + delete(state.reanchorBackoff, key) return false } - // The anchor outpoint moved past this generation's on-chain source - // anchor; the Bridge will reject its late settlement forever. + return state.reanchorPass < backoff.retryPass +} + +// backOffReanchorGeneration starts or extends the backoff delay of the +// re-anchor generation identified by key after a fruitless check. +func backOffReanchorGeneration( + state *reservationProofScanState, + key string, + sourceWalletPublicKeyHash [20]byte, +) { + skipPasses := uint64(1) + if previous, ok := state.reanchorBackoff[key]; ok { + skipPasses = min(previous.skipPasses*2, reanchorProofBackoffMaxPasses) + } + + var walletTxHashes []bitcoin.Hash + if cached, ok := state.walletTransactionCache[sourceWalletPublicKeyHash]; ok { + walletTxHashes = cached.txHashes + } + + state.reanchorBackoff[key] = &reanchorProofBackoff{ + retryPass: state.reanchorPass + skipPasses + 1, + skipPasses: skipPasses, + walletTxHashes: walletTxHashes, + } +} + +// forgetReanchorGeneration stops tracking the re-anchor generation +// identified by key. +func forgetReanchorGeneration(state *reservationProofScanState, key string) { delete(state.pendingReanchorEvents, key) - return true + delete(state.reanchorBackoff, key) } // proveReservationReanchorActions finds settleable ReservationReanchor // action generations, locates each one's already-broadcast re-anchor // transaction on the Bitcoin chain (if any), and submits its SPV proof // once it has accumulated enough confirmations. A TimedOut generation -// remains a candidate for late settlement without bound while its -// on-chain source anchor is still the reservation's current anchor -// outpoint; once a later settled generation has re-anchored the -// reservation past the source anchor the generation snapshotted at -// request time, the Bridge rejects this generation's late settlement -// and it is evicted instead (see -// evictReanchorGenerationIfSourceAnchorMoved). +// remains a candidate for late settlement without bound while the +// reservation is settleable and its on-chain source anchor is still the +// reservation's current anchor outpoint; otherwise it is evicted (see +// settleableReanchorReservation). A TimedOut generation with nothing to +// prove is re-checked on a bounded backoff (see reanchorProofBackoff). func proveReservationReanchorActions( state *reservationProofScanState, config Config, @@ -677,7 +815,7 @@ func proveReservationReanchorActions( cache *proofInfoCache, metricsRecorder MetricsRecorder, ) error { - startBlock, currentBlock, skipScan, err := reservationProofNextScanRange( + startBlock, endBlock, scan, err := nextReservationScanRange( spvChain, state.reanchorLastScannedBlock, tbtc.ReservationsActivationBlock(config.EthereumNetwork), @@ -686,48 +824,52 @@ func proveReservationReanchorActions( return err } - if skipScan { - state.reanchorLastScannedBlock = currentBlock - return nil - } - - newEvents, err := fetchPastEventsInChunks[*tbtc.ReservationReanchorRequestedEvent]( - func(chunkStart, chunkEnd uint64) ([]*tbtc.ReservationReanchorRequestedEvent, error) { - return spvChain.PastReservationReanchorRequestedEvents( - &tbtc.ReservationReanchorRequestedEventFilter{ - StartBlock: chunkStart, - EndBlock: &chunkEnd, - }, + // See proveReservationAcceptanceActions: a scan error is returned + // after the already-tracked generations have been proved. + var scanErr error + if scan { + if err := fetchPastEventsInChunks( + func(chunkStart, chunkEnd uint64) ([]*tbtc.ReservationReanchorRequestedEvent, error) { + return spvChain.PastReservationReanchorRequestedEvents( + &tbtc.ReservationReanchorRequestedEventFilter{ + StartBlock: chunkStart, + EndBlock: &chunkEnd, + }, + ) + }, + startBlock, + endBlock, + func(events []*tbtc.ReservationReanchorRequestedEvent, chunkEnd uint64) { + for _, event := range events { + key := reservationEventKey(event.ReservationKey, event.RequestNonce) + state.pendingReanchorEvents[key] = event + } + state.reanchorLastScannedBlock = chunkEnd + }, + ); err != nil { + scanErr = fmt.Errorf( + "failed to get past reservation re-anchor requested events: [%v]", + err, ) - }, - startBlock, - currentBlock, - ) - if err != nil { - return fmt.Errorf( - "failed to get past reservation re-anchor requested events: [%v]", - err, - ) + } + } else { + state.reanchorLastScannedBlock = endBlock } - for _, event := range newEvents { - key := reservationEventKey(event.ReservationKey, event.RequestNonce) - state.pendingReanchorEvents[key] = event - } + state.reanchorPass++ - // Re-check every tracked event's on-chain action state, evict the - // non-settleable ones, and group the still-settleable events by - // source wallet public key hash. The Bridge settles a re-anchor - // generation while its action is Pending or TimedOut, and re-anchor - // late settlement is unbounded (see loadSettleableAction in - // ReservationProofs.sol), so a TimedOut generation is kept as a - // candidate for as long as its on-chain source anchor is still the - // reservation's current anchor outpoint; once a later settled - // generation has re-anchored the reservation past the source - // anchor the generation snapshotted at request time, the Bridge - // rejects its late settlement and it is evicted. - walletEvents := make(map[[20]byte][]*tbtc.ReservationReanchorRequestedEvent) + // Re-check every tracked event's on-chain action state and evict the + // non-settleable ones. The Bridge settles a re-anchor generation + // while its action is Pending or TimedOut, and re-anchor late + // settlement is unbounded (see loadSettleableAction in + // ReservationProofs.sol), so a TimedOut generation is kept for as + // long as settleableReanchorReservation finds it can still settle. + var generations []reservationProofGeneration[*tbtc.ReservationReanchorRequestedEvent] for key, event := range state.pendingReanchorEvents { + if reanchorGenerationBackedOff(state, key, event.SourceWalletPublicKeyHash) { + continue + } + action, err := spvChain.GetReservationAction( event.ReservationKey, event.RequestNonce, @@ -743,81 +885,95 @@ func proveReservationReanchorActions( } switch action.State { - case tbtc.ReservationActionStatePending: - case tbtc.ReservationActionStateTimedOut: - if evictReanchorGenerationIfSourceAnchorMoved( - state, - spvChain, - key, - event, - action, - ) { - continue - } + case tbtc.ReservationActionStatePending, tbtc.ReservationActionStateTimedOut: default: // Settled, Superseded, Vetoed and absent generations are not // settleable on the Bridge; stop tracking them. - delete(state.pendingReanchorEvents, key) + forgetReanchorGeneration(state, key) continue } - walletEvents[event.SourceWalletPublicKeyHash] = append( - walletEvents[event.SourceWalletPublicKeyHash], - event, - ) - } - - for walletPublicKeyHash, events := range walletEvents { - walletTransactions, err := walletTransactionsForProof( + reservation, settleable := settleableReanchorReservation( state, - btcChain, - walletPublicKeyHash, - config.TransactionLimit, + spvChain, + key, + event, + action, ) - if err != nil { - logger.Errorf("failed to get transactions for wallet: [%v]", err) + if !settleable { continue } - // Index wallet transactions by spent outpoint for O(1) matching. A - // wallet that broadcasts an RBF replacement chain for the same - // anchor UTXO produces multiple candidates per outpoint, so every - // candidate is kept rather than only the last one seen. - candidateTransactions := make(map[bitcoin.TransactionOutpoint][]*bitcoin.Transaction) - for _, transaction := range walletTransactions { - if len(transaction.Inputs) == 1 && len(transaction.Outputs) == 1 && transaction.Inputs[0].Outpoint != nil { - outpoint := *transaction.Inputs[0].Outpoint - candidateTransactions[outpoint] = append(candidateTransactions[outpoint], transaction) - } + generations = append( + generations, + reservationProofGeneration[*tbtc.ReservationReanchorRequestedEvent]{ + key: key, + event: event, + reservationKey: event.ReservationKey, + requestNonce: event.RequestNonce, + pending: action.State == tbtc.ReservationActionStatePending, + reservation: reservation, + }, + ) + } + sortReservationProofGenerations(generations) + + // Each source wallet's candidate transactions are indexed once per + // pass, by spent outpoint. A wallet that broadcasts an RBF + // replacement chain for the same anchor UTXO produces multiple + // candidates per outpoint, so every candidate is kept rather than + // only the last one seen. A nil entry marks a wallet whose + // transactions could not be fetched this pass. + walletCandidates := make(map[[20]byte]map[bitcoin.TransactionOutpoint][]*bitcoin.Transaction) + submittedReservations := make(map[string]struct{}) + for _, generation := range generations { + event := generation.event + reservation := generation.reservation + reservationKey := event.ReservationKey.String() + if _, submitted := submittedReservations[reservationKey]; submitted { + continue } - for _, event := range events { - reservation, err := spvChain.GetReservation(event.ReservationKey) + candidateTransactions, indexed := walletCandidates[event.SourceWalletPublicKeyHash] + if !indexed { + walletTransactions, err := walletTransactionsForProof( + state, + btcChain, + event.SourceWalletPublicKeyHash, + config.TransactionLimit, + ) if err != nil { - logger.Errorf( - "failed to load reservation [%v]: [%v]", - event.ReservationKey, - err, - ) - continue - } - if reservation.AnchorUtxo == nil || - reservation.AnchorUtxo.Value == 0 || - reservation.AnchorUtxo.Outpoint == nil || - reservation.AnchorUtxo.Outpoint.TransactionHash == (bitcoin.Hash{}) { - logger.Errorf( - "reservation [%v] has no anchor UTXO to re-anchor from", - event.ReservationKey, - ) - continue + logger.Errorf("failed to get transactions for wallet: [%v]", err) + } else { + candidateTransactions = make(map[bitcoin.TransactionOutpoint][]*bitcoin.Transaction) + for _, transaction := range walletTransactions { + if len(transaction.Inputs) == 1 && len(transaction.Outputs) == 1 && transaction.Inputs[0].Outpoint != nil { + outpoint := *transaction.Inputs[0].Outpoint + candidateTransactions[outpoint] = append(candidateTransactions[outpoint], transaction) + } + } } + walletCandidates[event.SourceWalletPublicKeyHash] = candidateTransactions + } + if candidateTransactions == nil { + continue + } - candidates, ok := candidateTransactions[*reservation.AnchorUtxo.Outpoint] - if !ok { - continue - } + if reservation.AnchorUtxo == nil || + reservation.AnchorUtxo.Value == 0 || + reservation.AnchorUtxo.Outpoint == nil || + reservation.AnchorUtxo.Outpoint.TransactionHash == (bitcoin.Hash{}) { + logger.Errorf( + "reservation [%v] has no anchor UTXO to re-anchor from", + event.ReservationKey, + ) + continue + } - if err := proveReservationTransaction( + outcome := reservationProofNoMatch + var err error + if candidates, ok := candidateTransactions[*reservation.AnchorUtxo.Outpoint]; ok { + outcome, err = proveReservationTransaction( candidates, func(transaction *bitcoin.Transaction) bool { return isMatchingReservationReanchorTransaction(event, reservation.AnchorUtxo, transaction) @@ -839,21 +995,30 @@ func proveReservationReanchorActions( metricsRecorder, ) }, - ); err != nil { - logger.Errorf( - "failed to prove reservation re-anchor transaction "+ - "for reservation [%v]: [%v]", - event.ReservationKey, - err, - ) - continue - } + ) + } + if outcome == reservationProofSubmitted { + submittedReservations[reservationKey] = struct{}{} + } + if err != nil { + logger.Errorf( + "failed to prove reservation re-anchor transaction "+ + "for reservation [%v]: [%v]", + event.ReservationKey, + err, + ) + continue } - } - state.reanchorLastScannedBlock = currentBlock + if !generation.pending && + (outcome == reservationProofNoMatch || outcome == reservationProofUnprovable) { + backOffReanchorGeneration(state, generation.key, event.SourceWalletPublicKeyHash) + } else { + delete(state.reanchorBackoff, generation.key) + } + } - return nil + return scanErr } func isMatchingReservationReanchorTransaction( @@ -890,6 +1055,26 @@ func isMatchingReservationReanchorTransaction( return true } +// reservationProofOutcome reports what proveReservationTransaction did +// with one action generation's candidate transactions. +type reservationProofOutcome uint8 + +const ( + // reservationProofNoMatch means no candidate matched the generation + // (or proof info could not be read, reported with an error). + reservationProofNoMatch reservationProofOutcome = iota + // reservationProofAwaitingConfirmations means a matching candidate is + // still accumulating confirmations. + reservationProofAwaitingConfirmations + // reservationProofUnprovable means every matching candidate was + // skipped because its proof falls outside the relay's difficulty + // range or needs more than maxProofHeaders headers. + reservationProofUnprovable + // reservationProofSubmitted means a proof submission was attempted; + // the returned error reports whether it failed. + reservationProofSubmitted +) + // proveReservationTransaction assembles and submits the SPV proof for a // reservation acceptance or re-anchor transaction, once it has accumulated // enough confirmations and its proof falls within the relay's difficulty @@ -909,7 +1094,9 @@ func isMatchingReservationReanchorTransaction( // behavior. // // cache carries the pass-invariant chain reads shared with every other -// transaction proved in the same pass; see proofInfoCache. +// transaction proved in the same pass; see proofInfoCache. The returned +// outcome tells callers whether a submission was attempted and, if not, +// whether a later pass can expect anything to prove. func proveReservationTransaction( candidates []*bitcoin.Transaction, isMatch func(transaction *bitcoin.Transaction) bool, @@ -920,8 +1107,9 @@ func proveReservationTransaction( cache *proofInfoCache, metricsRecorder MetricsRecorder, submit func(transactionHash bitcoin.Hash, requiredConfirmations uint) error, -) error { +) (reservationProofOutcome, error) { var pending *bitcoin.Transaction + awaitingConfirmations := false var pendingConfirmations, pendingRequired uint var pendingSkipReason proofSkipReason @@ -939,12 +1127,12 @@ func proveReservationTransaction( cache, ) if err != nil { - return fmt.Errorf("failed to get proof info: [%v]", err) + return reservationProofNoMatch, fmt.Errorf("failed to get proof info: [%v]", err) } if skipReason == proofSkipNone && accumulatedConfirmations >= requiredConfirmations { if err := submit(transaction.Hash(), requiredConfirmations); err != nil { - return err + return reservationProofSubmitted, err } logger.Infof( @@ -952,9 +1140,12 @@ func proveReservationTransaction( transaction.Hash().Hex(bitcoin.ReversedByteOrder), ) - return nil + return reservationProofSubmitted, nil } + if skipReason == proofSkipNone { + awaitingConfirmations = true + } if pending == nil { pending = transaction pendingConfirmations = accumulatedConfirmations @@ -964,7 +1155,12 @@ func proveReservationTransaction( } if pending == nil { - return nil + return reservationProofNoMatch, nil + } + + outcome := reservationProofUnprovable + if awaitingConfirmations { + outcome = reservationProofAwaitingConfirmations } transactionHashStr := pending.Hash().Hex(bitcoin.ReversedByteOrder) @@ -983,7 +1179,7 @@ func proveReservationTransaction( 1, ) } - return nil + return outcome, nil case proofSkipExceededMaxHeaders: logger.Errorf( "skipped proving transaction [%s]; could not find a decisive "+ @@ -998,7 +1194,7 @@ func proveReservationTransaction( 1, ) } - return nil + return outcome, nil case proofSkipNone: logger.Infof( "skipped proving transaction [%s]; transaction has [%v/%v] "+ @@ -1007,9 +1203,9 @@ func proveReservationTransaction( pendingConfirmations, pendingRequired, ) - return nil + return outcome, nil default: - return fmt.Errorf( + return reservationProofNoMatch, fmt.Errorf( "unexpected proof skip reason [%d] for transaction [%s]", pendingSkipReason, transactionHashStr, diff --git a/pkg/maintainer/spv/reservation_proof_loop_test.go b/pkg/maintainer/spv/reservation_proof_loop_test.go index 9772f0db51..6711fe8e55 100644 --- a/pkg/maintainer/spv/reservation_proof_loop_test.go +++ b/pkg/maintainer/spv/reservation_proof_loop_test.go @@ -67,104 +67,6 @@ func (c *reservationProofBitcoinChain) GetTxHashesForPublicKeyHash( return hashes, nil } -// TestReservationProofNextScanRange covers the incremental scan-range -// arithmetic for the proof loop's activation-block-aware catch-up scan. -// The first pass (lastScannedBlock == 0) now starts at the network's -// reservation activation block (see tbtc.ReservationsActivationBlock) -// instead of a fixed 30-day lookback. An activation block of -// math.MaxUint64 (unknown / inactive network) makes the first scan a -// no-op: the cursor jumps to the chain tip and no events are fetched. -// Steady-state passes (lastScannedBlock > 0) start exactly one block -// after the previous cursor and are unchanged. -func TestReservationProofNextScanRange(t *testing.T) { - tests := map[string]struct { - activationBlock uint64 - currentBlock uint64 - lastScannedBlock uint64 - expectedStart uint64 - expectedSkipScan bool - }{ - // Developer network (activation at block 0) - first scan from 0. - "first pass, Developer network, current block below lookback": { - activationBlock: 0, - currentBlock: 1000, - lastScannedBlock: 0, - expectedStart: 0, - expectedSkipScan: false, - }, - "first pass, Developer network, current block beyond lookback": { - activationBlock: 0, - currentBlock: reservationDefaultLookBackBlocks + 500, - lastScannedBlock: 0, - expectedStart: 0, - expectedSkipScan: false, - }, - // Unknown network (activation = MaxUint64) - first scan skipped. - "first pass, unknown network": { - activationBlock: math.MaxUint64, - currentBlock: 300_000, - lastScannedBlock: 0, - expectedStart: 300_000 + 1, - expectedSkipScan: true, - }, - // Public network with activation ahead of current tip. - "first pass, activation in future": { - activationBlock: 500_000, - currentBlock: 300_000, - lastScannedBlock: 0, - expectedStart: 500_000, - expectedSkipScan: false, - }, - // Steady-state passes are unchanged. - "later pass starts one block after the cursor": { - activationBlock: 0, - currentBlock: reservationDefaultLookBackBlocks * 3, - lastScannedBlock: reservationDefaultLookBackBlocks * 2, - expectedStart: reservationDefaultLookBackBlocks*2 + 1, - expectedSkipScan: false, - }, - } - - for testName, test := range tests { - t.Run(testName, func(t *testing.T) { - spvChain := newLocalChain() - blockCounter := newMockBlockCounter() - blockCounter.SetCurrentBlock(test.currentBlock) - spvChain.setBlockCounter(blockCounter) - - startBlock, currentBlock, skipScan, err := reservationProofNextScanRange( - spvChain, - test.lastScannedBlock, - test.activationBlock, - ) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if skipScan != test.expectedSkipScan { - t.Errorf( - "unexpected skipScan\nexpected: %v\nactual: %v", - test.expectedSkipScan, - skipScan, - ) - } - if !test.expectedSkipScan && startBlock != test.expectedStart { - t.Errorf( - "unexpected start block\nexpected: %v\nactual: %v", - test.expectedStart, - startBlock, - ) - } - if currentBlock != test.currentBlock { - t.Errorf( - "unexpected current block\nexpected: %v\nactual: %v", - test.currentBlock, - currentBlock, - ) - } - }) - } -} - // TestFindReservationAcceptanceTransaction verifies the acceptance // transaction matcher: it must find the 1-input-1-output transaction whose // sole input spends the deposit UTXO identified by event.ReservationKey (via @@ -685,7 +587,7 @@ func TestProveReservationTransaction(t *testing.T) { spvChain, btcChain := newFixture(20) submitted := false - err := proveReservationTransaction( + outcome, err := proveReservationTransaction( []*bitcoin.Transaction{transaction}, alwaysMatchReservationTransaction, btcChain, @@ -719,13 +621,16 @@ func TestProveReservationTransaction(t *testing.T) { if !submitted { t.Error("expected submit to be called") } + if outcome != reservationProofSubmitted { + t.Errorf("expected outcome %v, got %v", reservationProofSubmitted, outcome) + } }) t.Run("skips without submitting when confirmations are insufficient", func(t *testing.T) { spvChain, btcChain := newFixture(2) submitted := false - err := proveReservationTransaction( + outcome, err := proveReservationTransaction( []*bitcoin.Transaction{transaction}, alwaysMatchReservationTransaction, btcChain, @@ -745,12 +650,15 @@ func TestProveReservationTransaction(t *testing.T) { if submitted { t.Error("expected submit not to be called for insufficient confirmations") } + if outcome != reservationProofAwaitingConfirmations { + t.Errorf("expected outcome %v, got %v", reservationProofAwaitingConfirmations, outcome) + } }) t.Run("propagates submit errors", func(t *testing.T) { spvChain, btcChain := newFixture(20) - err := proveReservationTransaction( + outcome, err := proveReservationTransaction( []*bitcoin.Transaction{transaction}, alwaysMatchReservationTransaction, btcChain, @@ -766,6 +674,9 @@ func TestProveReservationTransaction(t *testing.T) { if err == nil { t.Fatal("expected submit error to propagate") } + if outcome != reservationProofSubmitted { + t.Errorf("a failed submission is still an attempt: expected outcome %v, got %v", reservationProofSubmitted, outcome) + } }) t.Run("skips a non-matching candidate to reach a later unconfirmed matching one", func(t *testing.T) { @@ -787,7 +698,7 @@ func TestProveReservationTransaction(t *testing.T) { } submitted := false - err := proveReservationTransaction( + outcome, err := proveReservationTransaction( []*bitcoin.Transaction{nonMatching, matching}, isMatch, btcChain, @@ -812,6 +723,9 @@ func TestProveReservationTransaction(t *testing.T) { if submitted { t.Error("expected submit not to be called for insufficient confirmations") } + if outcome != reservationProofAwaitingConfirmations { + t.Errorf("expected outcome %v, got %v", reservationProofAwaitingConfirmations, outcome) + } }) } @@ -876,7 +790,7 @@ func TestProveReservationTransaction_RecordsMetrics(t *testing.T) { } submitted := false - if err := proveReservationTransaction( + if _, err := proveReservationTransaction( []*bitcoin.Transaction{transaction}, alwaysMatchReservationTransaction, btcChain, @@ -1260,7 +1174,7 @@ func TestProveReservationAcceptanceActions_LeavesPendingOnChainError(t *testing. WalletPublicKeyHash: [20]byte{1}, BlockNumber: 500, }) - // Intentionally do NOT set the reservation action on spvChain, so GetReservationAction fails. + spvChain.getReservationActionErr = fmt.Errorf("transient RPC failure") scanState := newReservationProofScanState() config := Config{ @@ -1290,8 +1204,8 @@ func TestProveReservationAcceptanceActions_LeavesPendingOnChainError(t *testing. } } - if scanState.acceptanceLastScannedBlock != 1000 { - t.Fatalf("expected cursor to advance to current block 1000, got %d", scanState.acceptanceLastScannedBlock) + if scanState.acceptanceLastScannedBlock != 1000-reservationEventScanConfirmationBlocks { + t.Fatalf("expected cursor to advance to confirmed tip %d, got %d", 1000-reservationEventScanConfirmationBlocks, scanState.acceptanceLastScannedBlock) } } @@ -1313,7 +1227,7 @@ func TestProveReservationReanchorActions_LeavesPendingOnChainError(t *testing.T) TargetWalletPublicKeyHash: [20]byte{2}, BlockNumber: 500, }) - // Intentionally do NOT set the reservation action on spvChain, so GetReservationAction fails. + spvChain.getReservationActionErr = fmt.Errorf("transient RPC failure") scanState := newReservationProofScanState() config := Config{ @@ -1343,8 +1257,8 @@ func TestProveReservationReanchorActions_LeavesPendingOnChainError(t *testing.T) } } - if scanState.reanchorLastScannedBlock != 1000 { - t.Fatalf("expected cursor to advance to current block 1000, got %d", scanState.reanchorLastScannedBlock) + if scanState.reanchorLastScannedBlock != 1000-reservationEventScanConfirmationBlocks { + t.Fatalf("expected cursor to advance to confirmed tip %d, got %d", 1000-reservationEventScanConfirmationBlocks, scanState.reanchorLastScannedBlock) } } @@ -1391,7 +1305,7 @@ func TestProveReservationTransaction_SelectsConfirmedRBFCandidate(t *testing.T) var submittedHash bitcoin.Hash submissions := 0 - err := proveReservationTransaction( + _, err := proveReservationTransaction( []*bitcoin.Transaction{unconfirmedReplaced, confirmedReplacement}, alwaysMatchReservationTransaction, btcChain, @@ -1855,6 +1769,7 @@ func newTimedOutReanchorFixture( }, ) spvChain.setReservation(reservationKey, &tbtc.Reservation{ + State: tbtc.ReservationStateActive, AnchorUtxo: anchorUtxo, }) @@ -2011,14 +1926,19 @@ func TestProveReservationReanchorActions_TimedOutUnbounded(t *testing.T) { // superseded, vetoed, and unknown) are evicted from the pending-event map // with no proof submission, in both the acceptance and re-anchor scans. func TestProveReservationActions_EvictNonSettleableStates(t *testing.T) { + // absent leaves the generation unseeded, so the fake returns the zero + // record the Bridge's reservationActions mapping returns for a key it + // has never written. states := []struct { - name string - state tbtc.ReservationActionState + name string + state tbtc.ReservationActionState + absent bool }{ - {"settled", tbtc.ReservationActionStateSettled}, - {"superseded", tbtc.ReservationActionStateSuperseded}, - {"vetoed", tbtc.ReservationActionStateVetoed}, - {"unknown", tbtc.ReservationActionStateUnknown}, + {"settled", tbtc.ReservationActionStateSettled, false}, + {"superseded", tbtc.ReservationActionStateSuperseded, false}, + {"vetoed", tbtc.ReservationActionStateVetoed, false}, + {"unknown", tbtc.ReservationActionStateUnknown, false}, + {"absent", tbtc.ReservationActionStateUnknown, true}, } for _, s := range states { @@ -2036,6 +1956,12 @@ func TestProveReservationActions_EvictNonSettleableStates(t *testing.T) { MinAmount: 1000, }, ) + if s.absent { + delete( + fixture.spvChain.reservationActions, + buildReservationActionKey(fixture.reservationKey, fixture.requestNonce), + ) + } if err := proveReservationAcceptanceActions( fixture.state, @@ -2071,6 +1997,12 @@ func TestProveReservationActions_EvictNonSettleableStates(t *testing.T) { MinAmount: 1000, }, ) + if s.absent { + delete( + fixture.spvChain.reservationActions, + buildReservationActionKey(fixture.reservationKey, fixture.requestNonce), + ) + } if err := proveReservationReanchorActions( fixture.state, @@ -2205,6 +2137,7 @@ func TestProveReservationReanchorActions_TimedOutSourceAnchor(t *testing.T) { fixture.spvChain.setReservation( fixture.reservationKey, &tbtc.Reservation{ + State: tbtc.ReservationStateActive, AnchorUtxo: &bitcoin.UnspentTransactionOutput{ Outpoint: &bitcoin.TransactionOutpoint{ TransactionHash: replacementAnchorTx.Hash(), @@ -2514,10 +2447,463 @@ func TestRunReservationProofLoop_ResumesScanAfterRestart(t *testing.T) { chain.reanchorScanCalls, ) } - if state.acceptanceLastScannedBlock != 1000 { - t.Errorf("expected the acceptance cursor to survive the restart at 1000, got %d", state.acceptanceLastScannedBlock) + if state.acceptanceLastScannedBlock != 1000-reservationEventScanConfirmationBlocks { + t.Errorf("expected the acceptance cursor to survive the restart at %d, got %d", 1000-reservationEventScanConfirmationBlocks, state.acceptanceLastScannedBlock) + } + if state.reanchorLastScannedBlock != 1000-reservationEventScanConfirmationBlocks { + t.Errorf("expected the re-anchor cursor to advance to %d on the restart, got %d", 1000-reservationEventScanConfirmationBlocks, state.reanchorLastScannedBlock) + } +} + +// reservationChunkFailingChain wraps localChain and fails the acceptance +// event fetch for any chunk starting at failChunkStart, so a test can +// drive a scan that breaks part-way through the activation-to-tip walk. +type reservationChunkFailingChain struct { + *localChain + + failChunkStart *uint64 +} + +func (c *reservationChunkFailingChain) PastReservationAcceptanceRequestedEvents( + filter *tbtc.ReservationAcceptanceRequestedEventFilter, +) ([]*tbtc.ReservationAcceptanceRequestedEvent, error) { + if c.failChunkStart != nil && filter.StartBlock == *c.failChunkStart { + return nil, errors.New("simulated provider failure") + } + return c.localChain.PastReservationAcceptanceRequestedEvents(filter) +} + +// TestProveReservationAcceptanceActions_ChunkedScan verifies the proof +// loop's event scan across chunk boundaries: every chunk of the +// activation-to-tip walk is scanned, a failing chunk keeps the progress of +// the chunks before it (the cursor stops at the last good chunk and the +// next pass resumes after it), and the cursor stays +// reservationEventScanConfirmationBlocks behind the tip, so an event that +// only shows up later at a height the first pass could have covered - as +// after a short reorg - is still found by the next pass. +func TestProveReservationAcceptanceActions_ChunkedScan(t *testing.T) { + const currentBlock = 2*reservationEventScanChunkSize + 500 + confirmedTip := uint64(currentBlock) - reservationEventScanConfirmationBlocks + + newFixture := func() (*localChain, *reservationChunkFailingChain, *reservationProofScanState, Config) { + inner := newLocalChain() + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(currentBlock) + inner.setBlockCounter(blockCounter) + return inner, + &reservationChunkFailingChain{localChain: inner}, + newReservationProofScanState(), + Config{ + TransactionLimit: 100, + MaxProofHeaders: DefaultMaxProofHeaders, + EthereumNetwork: ethereum.Developer, + } + } + + // addEvent tracks a Pending acceptance generation requested at + // blockNumber; its wallet has no transactions, so nothing is proved + // and the generation simply stays tracked. + addEvent := func(inner *localChain, key int64, blockNumber uint64) string { + reservationKey := big.NewInt(key) + inner.addReservationAcceptanceRequestedEvent(&tbtc.ReservationAcceptanceRequestedEvent{ + ReservationKey: reservationKey, + RequestNonce: 1, + WalletPublicKeyHash: [20]byte{byte(key)}, + BlockNumber: blockNumber, + }) + inner.setReservationAction(reservationKey, 1, &tbtc.ReservationAction{ + State: tbtc.ReservationActionStatePending, + ActionType: tbtc.ReservationActionTypeAcceptance, + }) + return reservationEventKey(reservationKey, 1) + } + + prove := func(chain *reservationChunkFailingChain, state *reservationProofScanState, config Config) error { + return proveReservationAcceptanceActions( + state, + config, + chain, + chain, + newReservationProofBitcoinChain(), + newProofInfoCache(), + nil, + ) + } + + t.Run("every chunk is scanned", func(t *testing.T) { + inner, chain, state, config := newFixture() + firstChunkKey := addEvent(inner, 1, 100) + secondChunkKey := addEvent(inner, 2, reservationEventScanChunkSize+100) + + if err := prove(chain, state, config); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + for _, key := range []string{firstChunkKey, secondChunkKey} { + if _, ok := state.pendingAcceptanceEvents[key]; !ok { + t.Errorf("expected event [%s] to be tracked", key) + } + } + if state.acceptanceLastScannedBlock != confirmedTip { + t.Errorf("expected the cursor at %d, got %d", confirmedTip, state.acceptanceLastScannedBlock) + } + }) + + t.Run("a failing chunk keeps earlier progress", func(t *testing.T) { + inner, chain, state, config := newFixture() + firstChunkKey := addEvent(inner, 1, 100) + secondChunkKey := addEvent(inner, 2, reservationEventScanChunkSize+100) + + failStart := reservationEventScanChunkSize + chain.failChunkStart = &failStart + if err := prove(chain, state, config); err == nil { + t.Fatal("expected the failing chunk to be reported") + } + if _, ok := state.pendingAcceptanceEvents[firstChunkKey]; !ok { + t.Error("expected the first chunk's event to be tracked despite the later failure") + } + if _, ok := state.pendingAcceptanceEvents[secondChunkKey]; ok { + t.Error("expected the failed chunk's event not to be tracked yet") + } + if state.acceptanceLastScannedBlock != reservationEventScanChunkSize-1 { + t.Errorf( + "expected the cursor at the end of the first chunk %d, got %d", + reservationEventScanChunkSize-1, + state.acceptanceLastScannedBlock, + ) + } + + chain.failChunkStart = nil + if err := prove(chain, state, config); err != nil { + t.Fatalf("unexpected error on the resumed pass: %v", err) + } + if _, ok := state.pendingAcceptanceEvents[secondChunkKey]; !ok { + t.Error("expected the resumed pass to find the second chunk's event") + } + if state.acceptanceLastScannedBlock != confirmedTip { + t.Errorf("expected the cursor at %d, got %d", confirmedTip, state.acceptanceLastScannedBlock) + } + }) + + t.Run("the cursor trails the tip", func(t *testing.T) { + inner, chain, state, config := newFixture() + + if err := prove(chain, state, config); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // An event at a height the first pass could have reached if it + // had scanned to the tip, first visible only now. + lateKey := addEvent(inner, 3, uint64(currentBlock)-reservationEventScanConfirmationBlocks/2) + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(currentBlock + reservationEventScanConfirmationBlocks) + inner.setBlockCounter(blockCounter) + + if err := prove(chain, state, config); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := state.pendingAcceptanceEvents[lateKey]; !ok { + t.Error("expected the event below the old tip to be found by the next pass") + } + }) +} + +// TestProveReservationAcceptanceActions_TimedOutEvictedOnceReservationExists +// verifies that a TimedOut acceptance generation still inside its late +// window is dropped once the reservation exists: the Bridge rejects every +// acceptance proof after one generation settled ("Reservation already +// exists"), so proving it again would only burn proof-assembly work. +func TestProveReservationAcceptanceActions_TimedOutEvictedOnceReservationExists(t *testing.T) { + fixture := newTimedOutAcceptanceFixture(t, encodingP2WPKH) + fixture.state.nowFn = func() uint32 { return 1050 } + fixture.spvChain.setReservation(fixture.reservationKey, &tbtc.Reservation{ + State: tbtc.ReservationStateActive, + }) + + if err := proveReservationAcceptanceActions( + fixture.state, + fixture.config, + fixture.spvChain, + fixture.spvChain, + fixture.btcChain, + newProofInfoCache(), + nil, + ); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if fixture.submissions != 0 { + t.Errorf("expected no submission, got %d", fixture.submissions) + } + key := reservationEventKey(fixture.reservationKey, fixture.requestNonce) + if _, tracked := fixture.state.pendingAcceptanceEvents[key]; tracked { + t.Error("expected the generation to be evicted once the reservation exists") + } +} + +// TestProveReservationActions_OneSubmissionPerReservation verifies that +// when a Pending generation and a TimedOut generation of the same +// reservation both match the same Bitcoin transaction, a pass submits one +// proof only - for the Pending generation - instead of broadcasting a +// second, reverting transaction. +func TestProveReservationActions_OneSubmissionPerReservation(t *testing.T) { + t.Run("acceptance", func(t *testing.T) { + fixture := newTimedOutAcceptanceFixture(t, encodingP2WPKH) + fixture.state.nowFn = func() uint32 { return 1050 } + + const pendingNonce = 2 + fixture.spvChain.addReservationAcceptanceRequestedEvent(&tbtc.ReservationAcceptanceRequestedEvent{ + ReservationKey: fixture.reservationKey, + RequestNonce: pendingNonce, + WalletPublicKeyHash: fixture.walletPKH, + DepositAmount: 150000, + BlockNumber: 600, + }) + fixture.spvChain.setReservationAction( + fixture.reservationKey, + pendingNonce, + &tbtc.ReservationAction{ + State: tbtc.ReservationActionStatePending, + ActionType: tbtc.ReservationActionTypeAcceptance, + TargetWalletPublicKeyHash: fixture.walletPKH, + TimeoutAt: 5000, + MinAmount: 1000, + }, + ) + + var submittedNonces []uint64 + fixture.spvChain.submitReservationAcceptanceProofHook = func( + _ *tbtc.BitcoinTxInfo, + _ *tbtc.BitcoinTxProof, + _ *big.Int, + requestNonce uint64, + ) error { + submittedNonces = append(submittedNonces, requestNonce) + return nil + } + + if err := proveReservationAcceptanceActions( + fixture.state, + fixture.config, + fixture.spvChain, + fixture.spvChain, + fixture.btcChain, + newProofInfoCache(), + nil, + ); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(submittedNonces) != 1 || submittedNonces[0] != pendingNonce { + t.Errorf("expected one submission for nonce %d, got %v", pendingNonce, submittedNonces) + } + }) + + t.Run("re-anchor", func(t *testing.T) { + fixture := newTimedOutReanchorFixture(t, encodingP2WPKH) + + pendingNonce := fixture.requestNonce + 1 + fixture.spvChain.addReservationReanchorRequestedEvent(&tbtc.ReservationReanchorRequestedEvent{ + ReservationKey: fixture.reservationKey, + RequestNonce: pendingNonce, + SourceWalletPublicKeyHash: fixture.sourceWalletPKH, + TargetWalletPublicKeyHash: fixture.sourceWalletPKH, + TxMaxFee: 20000, + BlockNumber: 600, + }) + fixture.spvChain.setReservationAction( + fixture.reservationKey, + pendingNonce, + &tbtc.ReservationAction{ + State: tbtc.ReservationActionStatePending, + ActionType: tbtc.ReservationActionTypeReanchor, + TargetWalletPublicKeyHash: fixture.sourceWalletPKH, + TimeoutAt: 5000, + }, + ) + + var submittedNonces []uint64 + fixture.spvChain.submitReservationReanchorProofHook = func( + _ *tbtc.BitcoinTxInfo, + _ *tbtc.BitcoinTxProof, + _ *big.Int, + requestNonce uint64, + ) error { + submittedNonces = append(submittedNonces, requestNonce) + return nil + } + + if err := proveReservationReanchorActions( + fixture.state, + fixture.config, + fixture.spvChain, + fixture.spvChain, + fixture.btcChain, + newProofInfoCache(), + nil, + ); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(submittedNonces) != 1 || submittedNonces[0] != pendingNonce { + t.Errorf("expected one submission for nonce %d, got %v", pendingNonce, submittedNonces) + } + }) +} + +// TestProveReservationReanchorActions_TimedOutReservationState verifies +// that a TimedOut re-anchor generation is dropped once its reservation can +// no longer settle a late proof (Closed or Unknown), while a Stranded +// reservation - which the Bridge still lets a late proof settle - keeps +// the generation and gets it proved. +func TestProveReservationReanchorActions_TimedOutReservationState(t *testing.T) { + tests := map[string]struct { + state tbtc.ReservationState + expectTracked bool + expectSubmitted int + }{ + "closed": {tbtc.ReservationStateClosed, false, 0}, + "unknown": {tbtc.ReservationStateUnknown, false, 0}, + "stranded": {tbtc.ReservationStateStranded, true, 1}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + fixture := newTimedOutReanchorFixture(t, encodingP2WPKH) + reservation, err := fixture.spvChain.GetReservation(fixture.reservationKey) + if err != nil { + t.Fatal(err) + } + updated := *reservation + updated.State = test.state + fixture.spvChain.setReservation(fixture.reservationKey, &updated) + + if err := proveReservationReanchorActions( + fixture.state, + fixture.config, + fixture.spvChain, + fixture.spvChain, + fixture.btcChain, + newProofInfoCache(), + nil, + ); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if fixture.submissions != test.expectSubmitted { + t.Errorf("expected %d submissions, got %d", test.expectSubmitted, fixture.submissions) + } + key := reservationEventKey(fixture.reservationKey, fixture.requestNonce) + if _, tracked := fixture.state.pendingReanchorEvents[key]; tracked != test.expectTracked { + t.Errorf("expected tracked=%v, got %v", test.expectTracked, tracked) + } + }) + } +} + +// TestProveReservationReanchorActions_ReadsReservationOncePerGeneration +// verifies that proving a re-anchor generation reads its reservation once +// per pass: the record read to decide whether the generation can settle +// is the one used to find its anchor. +func TestProveReservationReanchorActions_ReadsReservationOncePerGeneration(t *testing.T) { + fixture := newTimedOutReanchorFixture(t, encodingP2WPKH) + + if err := proveReservationReanchorActions( + fixture.state, + fixture.config, + fixture.spvChain, + fixture.spvChain, + fixture.btcChain, + newProofInfoCache(), + nil, + ); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if fixture.submissions != 1 { + t.Fatalf("expected the generation to be proved, got %d submissions", fixture.submissions) } - if state.reanchorLastScannedBlock != 1000 { - t.Errorf("expected the re-anchor cursor to advance to 1000 on the restart, got %d", state.reanchorLastScannedBlock) + if calls := fixture.spvChain.getReservationCallCount(); calls != 1 { + t.Errorf("expected one GetReservation read, got %d", calls) } } + +// TestProveReservationReanchorActions_TimedOutBackoff verifies that a +// TimedOut re-anchor generation with no matching transaction is not +// re-read every pass: after each fruitless check it is skipped for a +// doubling number of passes, a change in the source wallet's transaction +// list ends the delay early, and a Pending generation is never delayed. +func TestProveReservationReanchorActions_TimedOutBackoff(t *testing.T) { + newFixture := func(t *testing.T) *timedOutReanchorFixture { + fixture := newTimedOutReanchorFixture(t, encodingP2WPKH) + // The wallet's re-anchor transaction pays the source wallet, so + // pointing the event at another target wallet leaves it with no + // matching transaction. + for _, event := range fixture.spvChain.reservationReanchorRequestedEvents { + event.TargetWalletPublicKeyHash = [20]byte{0xEE} + } + return fixture + } + + prove := func(t *testing.T, fixture *timedOutReanchorFixture) { + if err := proveReservationReanchorActions( + fixture.state, + fixture.config, + fixture.spvChain, + fixture.spvChain, + fixture.btcChain, + newProofInfoCache(), + nil, + ); err != nil { + t.Fatalf("unexpected error: %v", err) + } + } + + t.Run("timed-out generation backs off", func(t *testing.T) { + fixture := newFixture(t) + + // Pass 1 checks; pass 2 is skipped; pass 3 checks and doubles + // the delay, so passes 4 and 5 are skipped and pass 6 checks. + expectedReads := []int{1, 1, 2, 2, 2, 3} + for pass, expected := range expectedReads { + prove(t, fixture) + if calls := fixture.spvChain.getReservationCallCount(); calls != expected { + t.Fatalf("pass %d: expected %d reservation reads, got %d", pass+1, expected, calls) + } + } + + key := reservationEventKey(fixture.reservationKey, fixture.requestNonce) + if _, tracked := fixture.state.pendingReanchorEvents[key]; !tracked { + t.Fatal("expected the backed-off generation to stay tracked") + } + + // A new transaction in the wallet's cached history ends the + // delay: the next pass checks the generation again. + cached := fixture.state.walletTransactionCache[fixture.sourceWalletPKH] + cached.txHashes = append(append([]bitcoin.Hash{}, cached.txHashes...), bitcoin.Hash{0x01}) + prove(t, fixture) + if calls := fixture.spvChain.getReservationCallCount(); calls != 4 { + t.Fatalf("expected the changed wallet history to end the delay, got %d reads", calls) + } + }) + + t.Run("pending generation is checked every pass", func(t *testing.T) { + fixture := newFixture(t) + fixture.spvChain.setReservationAction( + fixture.reservationKey, + fixture.requestNonce, + &tbtc.ReservationAction{ + State: tbtc.ReservationActionStatePending, + ActionType: tbtc.ReservationActionTypeReanchor, + TimeoutAt: 5000, + }, + ) + + for pass := 1; pass <= 3; pass++ { + prove(t, fixture) + if calls := fixture.spvChain.getReservationCallCount(); calls != pass { + t.Fatalf("pass %d: expected %d reservation reads, got %d", pass, pass, calls) + } + } + }) +} diff --git a/pkg/maintainer/spv/reservation_stale_deposit_watch.go b/pkg/maintainer/spv/reservation_stale_deposit_watch.go index 7de06a8fed..cfc59c82ab 100644 --- a/pkg/maintainer/spv/reservation_stale_deposit_watch.go +++ b/pkg/maintainer/spv/reservation_stale_deposit_watch.go @@ -3,15 +3,14 @@ package spv import ( "context" "encoding/binary" - "errors" "fmt" - "math" "math/big" "strings" "time" "github.com/ethereum/go-ethereum/common" + "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/tbtc" ) @@ -23,9 +22,7 @@ type StaleDepositResolution uint8 const ( // StaleDepositResolutionUnknown is the zero value, returned alongside // a non-nil error whenever the check could not be completed (a chain - // call failed, the deposit key was invalid, or the reveal-time refund - // deadline could not be snapshotted - for instance because the - // matching DepositRevealed event predates the catch-up window). The + // call failed or the deposit key was invalid). The // deposit is never added to or evicted from the caller's tracking // set on this resolution - the caller must retain it and retry on // the next tick. @@ -33,9 +30,9 @@ const ( // StaleDepositResolutionKeep indicates the deposit is still // pending-stale and must be retained in the caller's tracking set // for the next tick: its snapshotted refund deadline has not yet - // passed - for a pollTick-discovered deposit the deadline is - // memoized from the reveal event, so no per-deposit chain read is - // made before it does - its current action generation is still + // passed - the deadline comes from the reveal event, so no + // per-deposit chain read is made before it does - its current action + // generation is still // Pending (the contract would // revert the notification while an acceptance authorization is // pending), a previously submitted notification is inside its @@ -77,11 +74,9 @@ const ( // reverseUint32) at reveal. Until that deadline the deposit cannot // become stale - the contract rejects // NotifyStaleReservedDeposit with "Deposit refund deadline has not -// elapsed" - so for a deposit discovered by pollTick, whose deadline is -// memoized from the reveal event, the watcher makes no per-deposit -// chain read at all before the deadline (an in-memory gate only); a -// deposit checked directly without that memo first recovers its -// deadline from chain state (see getRefundDeadline). Once the deadline +// elapsed" - so the watcher, which keeps each tracked deposit's +// deadline from its reveal event, makes no per-deposit chain read at +// all before the deadline (an in-memory gate only). Once the deadline // has passed, the watcher notifies whenever the current action // generation is not Pending, regardless of the assigned wallet's // state: a Live wallet that never accepts its deposit is exactly the @@ -112,17 +107,6 @@ type ReservationStaleDepositWatcher struct { // notification. notifiedAt map[string]uint32 - // refundDeadlineMemo caches each tracked deposit's snapshotted - // on-chain refund deadline, derived from its DepositRevealed - // event's RefundLocktime via reverseUint32. pollTick fills it at - // discovery time, when the reveal event it schedules from is - // already in hand; CheckStaleReservedDeposit fills it lazily for - // deposits checked directly (the snapshot's one-time reads run - // only while this memo has no entry for the deposit), so in - // steady operation no per-deposit chain read happens before the - // deadline. - refundDeadlineMemo map[string]uint32 - // operatorAddress identifies this process for // reservationOperatorStaggerOffset (see reservation_wiring.go), // used to stagger a deposit's FIRST NotifyStaleReservedDeposit @@ -147,33 +131,33 @@ type ReservationStaleDepositWatcher struct { // through free functions - mirrors // ReservationActionTimeoutWatcher's self-contained Run loop. // - // The set is a single one: the old split between an actively- - // polled set and a slower-reconciled set of Live-wallet deposits - // is gone, because deadline-aware scheduling makes it redundant. - // Before a deposit's refund deadline passes it costs no chain - // reads at all (nothing to amortize away), and after the deadline + // Every tracked deposit is polled the same way, whatever its + // wallet state. Before a deposit's refund deadline passes it costs + // no chain reads at all, and after the deadline // every tracked deposit - including ones whose wallet is Live, // since the notification is wallet-state-independent - must be // re-checked every tick until its record clears on-chain. - pending map[string]*big.Int + pending map[string]*trackedStaleDeposit lastSeenBlock uint64 // activationBlock is the network's reservation activation block // (see tbtc.ReservationsActivationBlock): the first reveal scan - // starts here instead of a fixed lookback window, so a process - // restart after activation picks up every reveal that has appeared - // since the feature went live. math.MaxUint64 is the sentinel - // for a network without an entry (e.g. ethereum.Unknown): the - // watcher skips the startup scan entirely - reservations are - // inactive - and the cursor jumps to the chain head. + // starts here and walks to the tip in chunks (see + // reservationScanRange), so a process restart picks up every + // reveal since the feature went live. math.MaxUint64 is the + // sentinel for a network without an entry (e.g. ethereum.Unknown): + // reservations are inactive there, the first scan is skipped and + // the cursor moves to the confirmed tip. activationBlock uint64 } -// errStaleDepositCleared signals that the reserved deposit record was -// already released on-chain (its wallet field is zero) when a refund -// deadline snapshot was attempted: there is nothing left to schedule or -// notify for this deposit. -var errStaleDepositCleared = errors.New("reserved deposit record already cleared") +// trackedStaleDeposit is one deposit tracked by the stale-deposit poll +// loop: its key and the refund deadline the Bridge snapshotted at reveal, +// decoded from its DepositRevealed event (see reverseUint32). +type trackedStaleDeposit struct { + depositKey *big.Int + refundDeadline uint32 +} // NewReservationStaleDepositWatcher constructs a stale-deposit watcher // bound to the given chain. operatorAddress is required: it is used by @@ -189,13 +173,12 @@ func NewReservationStaleDepositWatcher( activationBlock uint64, ) *ReservationStaleDepositWatcher { return &ReservationStaleDepositWatcher{ - spvChain: spvChain, - operatorAddress: operatorAddress, - notifiedAt: make(map[string]uint32), - refundDeadlineMemo: make(map[string]uint32), - attempted: make(map[string]struct{}), - pending: make(map[string]*big.Int), - activationBlock: activationBlock, + spvChain: spvChain, + operatorAddress: operatorAddress, + notifiedAt: make(map[string]uint32), + attempted: make(map[string]struct{}), + pending: make(map[string]*trackedStaleDeposit), + activationBlock: activationBlock, } } @@ -215,9 +198,8 @@ func reverseUint32(locktime [4]byte) uint32 { // deposit. // // The function may submit a Bridge notification -// (NotifyStaleReservedDeposit) as a side effect, and it caches the -// snapshotted refund deadline and notification state on the receiver -// across calls. There is no internal scheduling; the caller owns +// (NotifyStaleReservedDeposit) as a side effect, and it caches +// notification state on the receiver across calls. There is no internal scheduling; the caller owns // invocation lifecycle and synchronization. // // Notification eligibility mirrors the contract's @@ -233,10 +215,8 @@ func reverseUint32(locktime [4]byte) uint32 { // 2. The snapshotted refund deadline (the reveal-time RefundLocktime, // byte-reversed; see reverseUint32) has STRICTLY passed: the // contract's requirement is block.timestamp > refundDeadline. At -// or before the deadline, a deposit discovered by pollTick -// performs no per-deposit chain reads at all (in-memory gate -// only); a deposit checked directly without that memo first -// recovers its deadline from chain state (see getRefundDeadline). +// or before the deadline the check performs no chain reads at all +// (in-memory gate only). // 3. The current action generation is not Pending: while an // acceptance authorization is pending, the contract reverts the // notification with "Acceptance authorization pending", so the @@ -258,12 +238,15 @@ func reverseUint32(locktime [4]byte) uint32 { // // Parameters: // - depositKey: the deposit identifier reported by the Bridge. +// - refundDeadline: the deposit's refund deadline snapshotted at +// reveal, decoded from its DepositRevealed event. // - now: the UNIX timestamp against which the refund deadline // and the notifiedAt renotify backoff are compared. Tests pass an // explicit value; production passes time.Now().Unix() cast to // uint32. func (rsdw *ReservationStaleDepositWatcher) CheckStaleReservedDeposit( depositKey *big.Int, + refundDeadline uint32, now uint32, ) (StaleDepositResolution, error) { if depositKey == nil { @@ -271,24 +254,7 @@ func (rsdw *ReservationStaleDepositWatcher) CheckStaleReservedDeposit( } depositKeyStr := depositKey.String() - - deadline, err := rsdw.getRefundDeadline(depositKey) - if errors.Is(err, errStaleDepositCleared) { - // The record was already released on-chain before its deadline - // could be snapshotted: there is nothing to release. - logger.Debugf( - "reserved deposit [%v] record already cleared; skipping "+ - "stale check", - depositKey, - ) - if _, outstanding := rsdw.notifiedAt[depositKeyStr]; outstanding { - return StaleDepositResolutionNotified, nil - } - return StaleDepositResolutionDrop, nil - } - if err != nil { - return StaleDepositResolutionUnknown, err - } + deadline := refundDeadline // Until the snapshotted refund deadline has STRICTLY passed the // contract rejects the notification and nothing about this deposit @@ -476,124 +442,14 @@ func (rsdw *ReservationStaleDepositWatcher) CheckStaleReservedDeposit( return StaleDepositResolutionKeep, nil } -// getRefundDeadline returns the deposit's snapshotted on-chain refund -// deadline, memoized on the receiver (see refundDeadlineMemo): -// pollTick fills it at discovery time, and this call back-fills it -// lazily for a deposit checked directly, paying the snapshot's one-time -// reads only while no entry exists yet. -func (rsdw *ReservationStaleDepositWatcher) getRefundDeadline( - depositKey *big.Int, -) (uint32, error) { - if deadline, ok := rsdw.refundDeadlineMemo[depositKey.String()]; ok { - return deadline, nil - } - - deadline, err := rsdw.snapshotRefundDeadline(depositKey) - if err != nil { - return 0, err - } - rsdw.refundDeadlineMemo[depositKey.String()] = deadline - return deadline, nil -} - -// snapshotRefundDeadline snapshots a deposit's on-chain refund deadline -// for the first time, from the RefundLocktime field of its own -// DepositRevealed event (decoded via reverseUint32 to the exact -// deadline the Bridge snapshotted at reveal - see tbtc-v2's -// Deposit.sol reveal-time snapshot of validateDepositRefundLocktime's -// result). The deposit's assigned wallet is read first, both to key the -// reveal-event scan and as an early exit: a zero wallet field means the -// record was already released, and there is nothing to schedule. -// -// The event scan is bounded by reservationDefaultLookBackBlocks, the -// same catch-up window every reservation watcher shares; a reveal that -// predates it (and was never seen by pollTick) cannot be snapshotted -// here and surfaces as an error the caller retries. -func (rsdw *ReservationStaleDepositWatcher) snapshotRefundDeadline( - depositKey *big.Int, -) (uint32, error) { - walletPublicKeyHash, err := rsdw.spvChain.ReservedDepositWallet(depositKey) - if err != nil { - return 0, fmt.Errorf( - "failed to fetch wallet for reserved deposit [%v]: [%w]", - depositKey, - err, - ) - } - if walletPublicKeyHash == ([20]byte{}) { - return 0, fmt.Errorf("%w for deposit [%v]", errStaleDepositCleared, depositKey) - } - - blockCounter, err := rsdw.spvChain.BlockCounter() - if err != nil { - return 0, fmt.Errorf( - "failed to get block counter for refund deadline snapshot: [%w]", - err, - ) - } - if blockCounter == nil { - return 0, fmt.Errorf( - "failed to get block counter for refund deadline snapshot: nil block counter", - ) - } - currentBlock, err := blockCounter.CurrentBlock() - if err != nil { - return 0, fmt.Errorf( - "failed to get current block for refund deadline snapshot: [%w]", - err, - ) - } - - startBlock := uint64(0) - if currentBlock > reservationDefaultLookBackBlocks { - startBlock = currentBlock - reservationDefaultLookBackBlocks - } - - events, eventsErr := rsdw.spvChain.PastDepositRevealedEvents( - &tbtc.DepositRevealedEventFilter{ - StartBlock: startBlock, - EndBlock: ¤tBlock, - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - }, - ) - if eventsErr != nil { - return 0, fmt.Errorf( - "failed to fetch deposit revealed events for refund deadline "+ - "snapshot: [%w]", - eventsErr, - ) - } - - var matchingEvent *tbtc.DepositRevealedEvent - for _, event := range events { - if rsdw.spvChain.BuildDepositKey( - event.FundingTxHash, - event.FundingOutputIndex, - ).Cmp(depositKey) == 0 { - matchingEvent = event - break - } - } - if matchingEvent == nil { - return 0, fmt.Errorf( - "no matching DepositRevealed event for deposit [%v] within the "+ - "catch-up window", - depositKey, - ) - } - - return reverseUint32(matchingEvent.RefundLocktime), nil -} - -// forgetDeposit clears any cached notification state and snapshotted -// refund deadline held for the given deposit key. The poller invokes +// forgetDeposit clears any cached notification state held for the given +// deposit key. The poller invokes // this once a deposit resolves to Drop or Notified, so a resolved // deposit's per-call cache entries do not linger in these maps for the // remaining life of the process. func (rsdw *ReservationStaleDepositWatcher) forgetDeposit(depositKey *big.Int) { key := depositKey.String() delete(rsdw.notifiedAt, key) - delete(rsdw.refundDeadlineMemo, key) delete(rsdw.attempted, key) } @@ -649,42 +505,42 @@ func (rsdw *ReservationStaleDepositWatcher) pollTick(now uint32) (int, bool) { return rsdw.trackedCount(), false } - // queryStartBlock is the inclusive lower bound of the next reveal - // scan. On the very first tick (lastSeenBlock == 0) it is the - // network's reservation activation block, so a process restart - // after activation picks up every reveal since the feature went - // live instead of the fixed 30-day lookback. Networks without an - // activation entry (math.MaxUint64) skip the catch-up scan - // entirely: reservations are inactive, so there are no reveals - // to find, and the cursor jumps to the head. Steady-state ticks - // resume at lastSeenBlock+1, unchanged from the original - // incremental scan. - var queryStartBlock uint64 - switch rsdw.lastSeenBlock { - case 0: - switch { - case rsdw.activationBlock == math.MaxUint64, - rsdw.activationBlock > currentBlock: - // Skip the catch-up window entirely (cursor on the - // next tick starts from this head). Returning early here - // (before the ReservationParameters fetch) keeps the - // tick fast on networks where reservations are not - // active; pending is empty so the Check loop below is a - // no-op anyway. - rsdw.lastSeenBlock = currentBlock - return len(rsdw.pending), true - default: - queryStartBlock = rsdw.activationBlock - } - default: - queryStartBlock = rsdw.lastSeenBlock + 1 - } - - // Chunk the reveal-event scan so a wide catch-up window - // (activation block to chain tip) does not become a single huge - // PastDepositRevealedEvents query. Steady-state deltas are small - // enough to fall into a single chunk. - events, err := fetchPastEventsInChunks[*tbtc.DepositRevealedEvent]( + // The reveal scan follows reservationScanRange: the first tick + // starts at the network's reservation activation block and later + // ticks resume one block past lastSeenBlock, always stopping a few + // blocks behind the tip. + startBlock, endBlock, scan := reservationScanRange( + rsdw.lastSeenBlock, + rsdw.activationBlock, + currentBlock, + ) + if !scan { + // Reservations are inactive on this network, so there are no + // reveals to catch up on. Returning before the + // ReservationParameters fetch keeps the tick cheap; pending is + // empty on the first tick, so there is nothing to check. + rsdw.lastSeenBlock = endBlock + return len(rsdw.pending), true + } + if startBlock > endBlock && len(rsdw.pending) == 0 { + // No new confirmed blocks (or activation is still ahead) and + // nothing tracked: nothing to do this tick. + return 0, true + } + + // A failed parameter read or reveal scan does not skip the checks + // below: deposits tracked so far, including those from chunks + // fetched before the error, are still checked. + scanOK := true + params, err := rsdw.spvChain.ReservationParameters() + if err != nil { + reservationWiringLogger.Errorf( + "stale-deposit poll failed to fetch reservation "+ + "parameters: [%v]", + err, + ) + scanOK = false + } else if err := fetchPastEventsInChunks( func(chunkStart, chunkEnd uint64) ([]*tbtc.DepositRevealedEvent, error) { return rsdw.spvChain.PastDepositRevealedEvents( &tbtc.DepositRevealedEventFilter{ @@ -693,58 +549,28 @@ func (rsdw *ReservationStaleDepositWatcher) pollTick(now uint32) (int, bool) { }, ) }, - queryStartBlock, - currentBlock, - ) - if err != nil { + startBlock, + endBlock, + func(events []*tbtc.DepositRevealedEvent, chunkEnd uint64) { + rsdw.trackRevealedDeposits(events, params.ReservationVault) + rsdw.lastSeenBlock = chunkEnd + }, + ); err != nil { reservationWiringLogger.Errorf( "stale-deposit poll failed to fetch deposit revealed "+ "events: [%v]", err, ) - return rsdw.trackedCount(), false - } - - params, err := rsdw.spvChain.ReservationParameters() - if err != nil { - reservationWiringLogger.Errorf( - "stale-deposit poll failed to fetch reservation "+ - "parameters: [%v]", - err, - ) - return rsdw.trackedCount(), false + scanOK = false } - for _, event := range events { - if event.Vault == nil || !strings.EqualFold(string(*event.Vault), string(params.ReservationVault)) { - continue - } - - depositKey := rsdw.spvChain.BuildDepositKey( - event.FundingTxHash, - event.FundingOutputIndex, - ) - - // Track every vault-matched reveal in memory only: the vault - // match (ReservationParameters().ReservationVault, fetched - // once per tick) is the discovery gate, and the refund - // deadline is memoized from the reveal event in hand, so a - // tracked deposit performs no per-deposit chain reads until - // its deadline passes - by which point CheckStaleReservedDeposit - // reads the record's wallet field first. Doing any read here - // would re-introduce the per-deposit RPC the deadline-aware - // scheduling was meant to eliminate. - - rsdw.pending[depositKey.String()] = depositKey - rsdw.refundDeadlineMemo[depositKey.String()] = reverseUint32( - event.RefundLocktime, + for key, tracked := range rsdw.pending { + depositKey := tracked.depositKey + resolution, err := rsdw.CheckStaleReservedDeposit( + depositKey, + tracked.refundDeadline, + now, ) - } - - rsdw.lastSeenBlock = currentBlock - - for key, depositKey := range rsdw.pending { - resolution, err := rsdw.CheckStaleReservedDeposit(depositKey, now) if err != nil { reservationWiringLogger.Errorf( "stale-deposit poll failed to check deposit "+ @@ -767,7 +593,33 @@ func (rsdw *ReservationStaleDepositWatcher) pollTick(now uint32) (int, bool) { } } - return len(rsdw.pending), true + return len(rsdw.pending), scanOK +} + +// trackRevealedDeposits adds every reveal routed to reservationVault to +// the tracked set, together with its refund deadline decoded from the +// reveal event. Tracking is in memory only: a tracked deposit performs no +// per-deposit chain reads until its deadline passes, by which point +// CheckStaleReservedDeposit reads the record's wallet field first. +func (rsdw *ReservationStaleDepositWatcher) trackRevealedDeposits( + events []*tbtc.DepositRevealedEvent, + reservationVault chain.Address, +) { + for _, event := range events { + if event.Vault == nil || !strings.EqualFold(string(*event.Vault), string(reservationVault)) { + continue + } + + depositKey := rsdw.spvChain.BuildDepositKey( + event.FundingTxHash, + event.FundingOutputIndex, + ) + + rsdw.pending[depositKey.String()] = &trackedStaleDeposit{ + depositKey: depositKey, + refundDeadline: reverseUint32(event.RefundLocktime), + } + } } // Run starts the background poll loop, mirroring diff --git a/pkg/maintainer/spv/reservation_stale_deposit_watch_test.go b/pkg/maintainer/spv/reservation_stale_deposit_watch_test.go index 171ef1218e..05b8805c88 100644 --- a/pkg/maintainer/spv/reservation_stale_deposit_watch_test.go +++ b/pkg/maintainer/spv/reservation_stale_deposit_watch_test.go @@ -37,58 +37,6 @@ func locktimeForDeadline(deadline uint32) [4]byte { return locktime } -// seedPastDepositRevealedEvent installs a DepositRevealed event for the -// given wallet/funding outpoint at refundLocktime, discoverable by -// snapshotRefundDeadline's wallet-keyed event scan. -func seedPastDepositRevealedEvent( - t *testing.T, - spvChain *localChain, - wallet [20]byte, - fundingTxHash bitcoin.Hash, - fundingOutputIndex uint32, - currentBlock uint64, - refundLocktime [4]byte, -) { - t.Helper() - blockCounter := newMockBlockCounter() - blockCounter.SetCurrentBlock(currentBlock) - spvChain.setBlockCounter(blockCounter) - - startBlock := uint64(0) - if currentBlock > reservationDefaultLookBackBlocks { - startBlock = currentBlock - reservationDefaultLookBackBlocks - } - endBlock := currentBlock - if err := spvChain.addPastDepositRevealedEvent( - &tbtc.DepositRevealedEventFilter{ - StartBlock: startBlock, - EndBlock: &endBlock, - WalletPublicKeyHash: [][20]byte{wallet}, - }, - &tbtc.DepositRevealedEvent{ - FundingTxHash: fundingTxHash, - FundingOutputIndex: fundingOutputIndex, - WalletPublicKeyHash: wallet, - RefundLocktime: refundLocktime, - }, - ); err != nil { - t.Fatal(err) - } -} - -// seedStaleDeadline pre-populates the watcher's refund-deadline memo -// directly, standing in for a pollTick discovery pass: production -// snapshots the deadline from the DepositRevealed event at discovery -// time, so most of these tests - which exercise CheckStaleReservedDeposit -// directly - do not need to also seed a matching reveal event. -func seedStaleDeadline( - watcher *ReservationStaleDepositWatcher, - depositKey *big.Int, - deadline uint32, -) { - watcher.refundDeadlineMemo[depositKey.String()] = deadline -} - // TestReverseUint32 pins the exact byte order the Bridge derives from a // reveal-time RefundLocktime. Deposit.sol documents the event field as // "the deposit refund locktime as 4-byte LE", and the on-chain deadline @@ -117,7 +65,7 @@ func TestReservationStaleDepositWatcher_NonReservedDepositIsSkipped(t *testing.T spvChain.setReservedDeposit(reservationDepositKey(0xB001), walletPKH(), false) watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) - res, err := watcher.CheckStaleReservedDeposit(reservationDepositKey(0xB001), 5_000) + res, err := watcher.CheckStaleReservedDeposit(reservationDepositKey(0xB001), 100, 5_000) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -151,9 +99,8 @@ func TestReservationStaleDepositWatcher_LiveWalletNotifiedAfterDeadline(t *testi }) watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) - seedStaleDeadline(watcher, key, 100) - res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + res, err := watcher.CheckStaleReservedDeposit(key, 100, 5_000) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -190,9 +137,8 @@ func TestReservationStaleDepositWatcher_TimedOutActionNotifiedAfterDeadline(t *t }) watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) - seedStaleDeadline(watcher, key, 100) - res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + res, err := watcher.CheckStaleReservedDeposit(key, 100, 5_000) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -231,9 +177,8 @@ func TestReservationStaleDepositWatcher_PendingActionNotNotifiedAfterDeadline(t }) watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) - seedStaleDeadline(watcher, key, 100) - res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + res, err := watcher.CheckStaleReservedDeposit(key, 100, 5_000) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -254,8 +199,8 @@ func TestReservationStaleDepositWatcher_PendingActionNotNotifiedAfterDeadline(t // (ReservedDepositWallet, GetReservation, GetReservationAction), // delegating every other method to the embedded Chain. It verifies the // "no chain reads before the deadline" invariant: with the deadline -// memoized, the pre-deadline check stays in-memory only, and only the -// post-deadline check reads on-chain state. +// taken from the reveal, the pre-deadline check stays in-memory only, +// and only the post-deadline check reads on-chain state. type staleReadCountingChain struct { Chain reservedDepositWalletCalls int @@ -312,9 +257,8 @@ func TestReservationStaleDepositWatcher_NoChainReadsBeforeDeadline(t *testing.T) // gate (see the identical comment in // TestReservationActionTimeoutWatcher_RunLoop_IncrementalTracking) // never masks the notification this test asserts. - seedStaleDeadline(watcher, key, 10_000) - res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + res, err := watcher.CheckStaleReservedDeposit(key, 10_000, 5_000) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -333,7 +277,7 @@ func TestReservationStaleDepositWatcher_NoChainReadsBeforeDeadline(t *testing.T) ) } - res, err = watcher.CheckStaleReservedDeposit(key, 10_601) + res, err = watcher.CheckStaleReservedDeposit(key, 10_000, 10_601) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -376,9 +320,8 @@ func TestReservationStaleDepositWatcher_CompletionDetectedByClearedWalletField(t }) watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) - seedStaleDeadline(watcher, key, 100) - res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + res, err := watcher.CheckStaleReservedDeposit(key, 100, 5_000) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -392,7 +335,7 @@ func TestReservationStaleDepositWatcher_CompletionDetectedByClearedWalletField(t // The notify transaction mines: the record's wallet field clears. spvChain.setReservedDeposit(key, [20]byte{}, true) - res, err = watcher.CheckStaleReservedDeposit(key, 5_060) + res, err = watcher.CheckStaleReservedDeposit(key, 100, 5_060) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -414,9 +357,8 @@ func TestReservationStaleDepositWatcher_CompletionDetectedByClearedWalletField(t spvChain.setReservedDeposit(key, [20]byte{}, true) watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) - seedStaleDeadline(watcher, key, 100) - res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + res, err := watcher.CheckStaleReservedDeposit(key, 100, 5_000) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -449,10 +391,9 @@ func TestReservationStaleDepositWatcher_NotifiedNotConfirmedIsRetried(t *testing }) watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) - seedStaleDeadline(watcher, key, 100) // First tick: submit the notification, awaiting confirmation. - res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + res, err := watcher.CheckStaleReservedDeposit(key, 100, 5_000) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -464,7 +405,7 @@ func TestReservationStaleDepositWatcher_NotifiedNotConfirmedIsRetried(t *testing // neither resubmit nor evict: the wallet field is still non-zero // (the notify transaction has not been observed to take effect), so // the deposit stays tracked rather than being lost. - res, err = watcher.CheckStaleReservedDeposit(key, 5_060) + res, err = watcher.CheckStaleReservedDeposit(key, 100, 5_060) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -480,7 +421,7 @@ func TestReservationStaleDepositWatcher_NotifiedNotConfirmedIsRetried(t *testing // actionTimeoutRenotifyInterval elapses, the watcher must retry // rather than treat the deposit as permanently resolved or lose it. renotifyAfter := 5_000 + uint32(actionTimeoutRenotifyInterval.Seconds()) + 1 - res, err = watcher.CheckStaleReservedDeposit(key, renotifyAfter) + res, err = watcher.CheckStaleReservedDeposit(key, 100, renotifyAfter) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -516,10 +457,9 @@ func TestReservationStaleDepositWatcher_RenotifyBackoffSurvivesNowBeforeNotified }) watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) - seedStaleDeadline(watcher, key, 100) // First tick: submit the notification, recording notifiedAt = 5_000. - res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + res, err := watcher.CheckStaleReservedDeposit(key, 100, 5_000) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -533,7 +473,7 @@ func TestReservationStaleDepositWatcher_RenotifyBackoffSurvivesNowBeforeNotified // than the renotify interval, so the code falls through and // resubmits immediately. The guard must keep this call in the // backoff window instead. - res, err = watcher.CheckStaleReservedDeposit(key, 4_000) + res, err = watcher.CheckStaleReservedDeposit(key, 100, 4_000) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -553,7 +493,7 @@ func TestReservationStaleDepositWatcher_NilDepositKeyError(t *testing.T) { spvChain := newLocalChain() watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) - res, err := watcher.CheckStaleReservedDeposit(nil, 5_000) + res, err := watcher.CheckStaleReservedDeposit(nil, 100, 5_000) if err == nil { t.Fatal("expected error for nil deposit key, got nil") } @@ -570,9 +510,9 @@ func TestReservationStaleDepositWatcher_ReservedDepositWalletChainError(t *testi spvChain.reservedDepositWalletErr = fmt.Errorf("rpc unavailable") watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) - // No memo seeded: the deadline snapshot's own ReservedDepositWallet - // read fails first. - res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + // Past the deadline, the first chain read - ReservedDepositWallet - + // fails. + res, err := watcher.CheckStaleReservedDeposit(key, 100, 5_000) if err == nil { t.Fatal("expected error when ReservedDepositWallet fails, got nil") } @@ -591,12 +531,11 @@ func TestReservationStaleDepositWatcher_GetReservationChainError(t *testing.T) { wallet := walletPKH() spvChain.setReservedDeposit(key, wallet, true) spvChain.setWallet(wallet, &tbtc.WalletChainData{State: tbtc.StateUnknown}) - // No spvChain.setReservation: GetReservation returns an error. + spvChain.getReservationErr = fmt.Errorf("transient RPC failure") watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) - seedStaleDeadline(watcher, key, 100) - res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + res, err := watcher.CheckStaleReservedDeposit(key, 100, 5_000) if err == nil { t.Fatal("expected error when GetReservation fails, got nil") } @@ -621,12 +560,11 @@ func TestReservationStaleDepositWatcher_GetReservationActionChainError_DoesNotNo spvChain.setReservedDeposit(key, wallet, true) spvChain.setWallet(wallet, &tbtc.WalletChainData{State: tbtc.StateUnknown}) spvChain.setReservation(key, &tbtc.Reservation{RequestNonce: 1}) - // GetReservationAction is NOT seeded, so it returns an error. + spvChain.getReservationActionErr = fmt.Errorf("transient RPC failure") watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) - seedStaleDeadline(watcher, key, 100) - res, err := watcher.CheckStaleReservedDeposit(key, 10_000) + res, err := watcher.CheckStaleReservedDeposit(key, 100, 10_000) if err == nil { t.Fatal("expected error on transient GetReservationAction RPC failure, got nil") } @@ -667,9 +605,8 @@ func TestReservationStaleDepositWatcher_AdvancingNonceEvaluatesCurrentGeneration }) watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) - seedStaleDeadline(watcher, key, 100) - res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + res, err := watcher.CheckStaleReservedDeposit(key, 100, 5_000) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -686,98 +623,6 @@ func TestReservationStaleDepositWatcher_AdvancingNonceEvaluatesCurrentGeneration } } -// TestReservationStaleDepositWatcher_RefundDeadlineFromRevealEvent covers -// the deadline snapshot's derivation path: a deposit checked without a -// pre-populated memo (not yet discovered by pollTick) has its deadline -// recovered from its own DepositRevealed event's RefundLocktime, and -// that decoded value must be honored precisely, both for "not yet -// reached" and "notify". -func TestReservationStaleDepositWatcher_RefundDeadlineFromRevealEvent(t *testing.T) { - spvChain := newLocalChain() - - wallet := walletPKH() - fundingTxHash, err := bitcoin.NewHashFromString( - "585b6699f42291d1a9d0776b75f04c295ea203f83504349db11e94fdae7d1b2c", - bitcoin.InternalByteOrder, - ) - if err != nil { - t.Fatal(err) - } - fundingOutputIndex := uint32(0) - - key := spvChain.BuildDepositKey(fundingTxHash, fundingOutputIndex) - spvChain.setReservedDeposit(key, wallet, true) - spvChain.setWallet(wallet, &tbtc.WalletChainData{State: tbtc.StateUnknown}) - spvChain.setReservation(key, &tbtc.Reservation{RequestNonce: 0}) - - seedPastDepositRevealedEvent( - t, spvChain, wallet, fundingTxHash, fundingOutputIndex, 0, - locktimeForDeadline(4_600), - ) - - watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) - - // now == the exact snapshotted deadline: the contract's gate is - // strictly-greater-than, so this must still defer. - res, err := watcher.CheckStaleReservedDeposit(key, 4_600) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if res != StaleDepositResolutionKeep { - t.Fatalf("expected resolution %v, got %v", StaleDepositResolutionKeep, res) - } - if calls := spvChain.getSubmittedStaleReservedDeposits(); len(calls) != 0 { - t.Fatalf("expected zero notifications at the exact deadline, got %d", len(calls)) - } - - // now strictly past the deadline, with no requested action - // generation (RequestNonce == 0): notification-eligible. now must - // also clear the first-attempt stagger window: deadline (4_600) - // plus the maximum possible reservationOperatorStaggerOffset - // (bounded by actionTimeoutRenotifyInterval, 600s), so 5_201. - res, err = watcher.CheckStaleReservedDeposit(key, 5_201) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if res != StaleDepositResolutionKeep { - t.Fatalf("expected resolution %v, got %v", StaleDepositResolutionKeep, res) - } - calls := spvChain.getSubmittedStaleReservedDeposits() - if len(calls) != 1 { - t.Fatalf("expected one stale notification, got %d", len(calls)) - } - if diff := deep.Equal(key, calls[0]); diff != nil { - t.Errorf("unexpected notified key: %v", diff) - } -} - -// TestReservationStaleDepositWatcher_NoMatchingRevealEventPropagatesError -// covers the derivation miss path: no DepositRevealed event can be found -// for the deposit within the catch-up window, so the deadline cannot be -// snapshotted and the check must propagate an error rather than guess. -func TestReservationStaleDepositWatcher_NoMatchingRevealEventPropagatesError(t *testing.T) { - spvChain := newLocalChain() - spvChain.setBlockCounter(newMockBlockCounter()) - - key := reservationDepositKey(0xB013) - wallet := walletPKH() - spvChain.setReservedDeposit(key, wallet, true) - // No seedPastDepositRevealedEvent call: the scan finds nothing. - - watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) - res, err := watcher.CheckStaleReservedDeposit(key, 5_000) - if err == nil { - t.Fatal("expected error when no matching deposit revealed event exists, got nil") - } - if res != StaleDepositResolutionUnknown { - t.Fatalf("expected resolution %v, got %v", StaleDepositResolutionUnknown, res) - } - - if calls := spvChain.getSubmittedStaleReservedDeposits(); len(calls) != 0 { - t.Fatalf("expected no notifications on chain error, got %d", len(calls)) - } -} - func TestReservationStaleDepositWatcher_NotifierError(t *testing.T) { spvChain := newLocalChain() @@ -793,9 +638,8 @@ func TestReservationStaleDepositWatcher_NotifierError(t *testing.T) { spvChain.notifyStaleReservedDepositErr = fmt.Errorf("notifier unavailable") watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) - seedStaleDeadline(watcher, key, 100) - res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + res, err := watcher.CheckStaleReservedDeposit(key, 100, 5_000) if err == nil { t.Fatal("expected error when the notifier fails, got nil") } @@ -806,7 +650,7 @@ func TestReservationStaleDepositWatcher_NotifierError(t *testing.T) { // TestReservationStaleDepositWatcher_ZeroWalletSkips verifies that a // deposit whose reserved-deposit record is already cleared (wallet field -// zero) when the deadline snapshot is attempted resolves Drop without +// zero) once its deadline has passed resolves Drop without // any notification. func TestReservationStaleDepositWatcher_ZeroWalletSkips(t *testing.T) { spvChain := newLocalChain() @@ -815,7 +659,7 @@ func TestReservationStaleDepositWatcher_ZeroWalletSkips(t *testing.T) { spvChain.setReservedDeposit(key, [20]byte{}, true) watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) - res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + res, err := watcher.CheckStaleReservedDeposit(key, 100, 5_000) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -849,7 +693,7 @@ func (c *staleDepositEventCountingChain) PastDepositRevealedEvents( // TestRunStaleDepositPollTick_TracksVaultMatchWithoutImmediateWalletRead // pins the discovery gate: a newly observed reveal whose vault matches // the reservation vault is tracked directly from the event, with its -// refund deadline memoized from the event's RefundLocktime, so the +// refund deadline taken from the event's RefundLocktime, so the // pre-deadline check performs no per-deposit chain read - and a // deposit whose record has since cleared on-chain is retired by the // post-deadline check's ReservedDepositWallet read alone, without @@ -873,7 +717,7 @@ func TestRunStaleDepositPollTick_TracksVaultMatchWithoutImmediateWalletRead( fundingTxHash := bitcoin.Hash{0x05} fundingOutputIndex := uint32(0) - endBlock := currentBlock + endBlock := currentBlock - reservationEventScanConfirmationBlocks if err := inner.addPastDepositRevealedEvent( &tbtc.DepositRevealedEventFilter{StartBlock: 0, EndBlock: &endBlock}, &tbtc.DepositRevealedEvent{ @@ -910,7 +754,7 @@ func TestRunStaleDepositPollTick_TracksVaultMatchWithoutImmediateWalletRead( watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) // First tick, now (1_000) is far before the deadline (50_000): the - // reveal is discovered and tracked, with its deadline memoized, + // reveal is discovered and tracked, with its deadline taken from the event, // and the check stays in-memory. trackedCount, ok := watcher.pollTick(1_000) if !ok { @@ -983,7 +827,7 @@ func TestRunStaleDepositPollTick_PendingReservedDepositNotifiedAfterDeadline( fundingTxHash := bitcoin.Hash{0x05} fundingOutputIndex := uint32(1) - endBlock := currentBlock + endBlock := currentBlock - reservationEventScanConfirmationBlocks if err := inner.addPastDepositRevealedEvent( &tbtc.DepositRevealedEventFilter{StartBlock: 0, EndBlock: &endBlock}, &tbtc.DepositRevealedEvent{ diff --git a/pkg/maintainer/spv/reservation_wiring.go b/pkg/maintainer/spv/reservation_wiring.go index 86faeb8ba1..1106272e1f 100644 --- a/pkg/maintainer/spv/reservation_wiring.go +++ b/pkg/maintainer/spv/reservation_wiring.go @@ -57,22 +57,18 @@ const DefaultReservationActionTimeoutPollInterval = 1 * time.Minute // without waiting on the real one-minute interval. var reservationActionTimeoutPollInterval = DefaultReservationActionTimeoutPollInterval -// reservationDefaultLookBackBlocks bounds every reservation watcher's -// startup/first-pass catch-up scan window: 30 days at 12s/block. It is -// the single source of truth for this bound, replacing what were -// previously 4+ independently-defined constants (this file's own -// reservationStrandingStartupScanLookBackBlocks and -// reservationStaleDepositLookBackBlocks, -// reservation_action_timeout_watch.go's -// reservationActionTimeoutLookBackBlocks, and -// reservation_proof_loop.go's reservationProofLookBackBlocks) all -// independently set to the identical literal value with near-duplicate -// doc comments. Every former duplicate, including the two thin aliases -// that used to remain solely for test-file references -// (reservationProofLookBackBlocks and -// reservation_stale_deposit_watch.go's -// staleDepositRevealScanLookBackBlocks), now references this constant -// directly. +// reservationWatcherPanicRecovered runs at the end of a watcher +// goroutine's recovered-panic path, right after +// recordReservationWatcherDeath. It does nothing in production; declared +// as a var solely so tests in this package can wait until that path has +// finished. +var reservationWatcherPanicRecovered = func() {} + +// reservationDefaultLookBackBlocks bounds the stranding startup scan of +// wallet registrations (see scanReservationStrandingStartupRegistrations): +// 30 days at 12s/block. The reservation event scans are not bounded by it; +// they start at the reservation activation block (see +// reservationScanRange). const reservationDefaultLookBackBlocks = uint64(216000) // reservationStrandingStartupScanRetryDelay bounds how long the startup @@ -218,14 +214,15 @@ func recordReservationWatcherDeath(recorder MetricsRecorder) { // interface. // // `ethNetwork` is the Ethereum network this wiring run targets; it -// drives the reservation activation-block lookup shared by the proof -// loop's startup catch-up scan and the stale-deposit watcher's startup -// reveal scan (see tbtc.ReservationsActivationBlock). Passing +// drives the reservation activation-block lookup (see +// tbtc.ReservationsActivationBlock) where the stale-deposit and +// action-timeout watchers start their first event scan. Passing // ethereum.Unknown (or any network without an entry in // reservationsActivationBlocks) returns math.MaxUint64 from the lookup, -// which the proof loop and stale-deposit watcher treat as "reservations -// never activate on this network" and skip their startup scans -// accordingly. +// which both watchers treat as "reservations inactive on this network": +// they skip their first catch-up scan and later scan only new blocks. +// The SPV proof loop does the same lookup from its own +// Config.EthereumNetwork. func WireReservationWatchers( ctx context.Context, walletClosedChain WalletClosedChain, @@ -266,46 +263,22 @@ func WireReservationWatchers( // OnWalletClosed subscription only sees events from this point forward. // We scan past wallet registrations bounded by // reservationDefaultLookBackBlocks and check the ones already - // Closed/Terminated now. This is an accepted operational + // Closed/Terminated now. The window is not tied to the reservation + // activation block: a wallet registered before activation can still + // custody reservations. This is an accepted operational // limitation, not a gap covered elsewhere: a wallet that closed or // was terminated more than this bound before the process started // has no path to stranding notification (see the warning logged // below when the bound actually truncates the scan window). // Transient per-wallet errors log warnings rather than failing // client startup. - strandingStartupStartBlock := uint64(0) - if blockCounter, bcErr := spvChain.BlockCounter(); bcErr != nil { - reservationWiringLogger.Warnf( - "stranding startup scan failed to get block counter; "+ - "scanning full history: [%v]", - bcErr, - ) - } else if currentBlock, cbErr := blockCounter.CurrentBlock(); cbErr != nil { - reservationWiringLogger.Warnf( - "stranding startup scan failed to get current block; "+ - "scanning full history: [%v]", - cbErr, - ) - } else if currentBlock > reservationDefaultLookBackBlocks { - strandingStartupStartBlock = currentBlock - reservationDefaultLookBackBlocks - reservationWiringLogger.Warnf( - "stranding startup scan is bounded to wallets registered at "+ - "block [%d] or later; a wallet registered and already "+ - "closed/terminated before that block will not be caught "+ - "by this scan, nor by the live OnWalletClosed "+ - "subscription, which cannot replay an already-emitted "+ - "close event - this is an accepted operational "+ - "limitation, not a gap covered elsewhere", - strandingStartupStartBlock, - ) - } - - registeredEvents, err := spvChain.PastNewWalletRegisteredEvents( - &tbtc.NewWalletRegisteredEventFilter{StartBlock: strandingStartupStartBlock}, - ) + registeredEvents, err := scanReservationStrandingStartupRegistrations(spvChain) if err != nil { reservationWiringLogger.Warnf( - "stranding startup scan failed to fetch wallet registration events: [%v]", + "stranding startup scan failed to fetch wallet registration "+ + "events; checking the [%d] registrations fetched before "+ + "the failure: [%v]", + len(registeredEvents), err, ) } @@ -321,64 +294,62 @@ func WireReservationWatchers( // event. var unresolvedWallets [][20]byte - if err == nil { - for _, event := range registeredEvents { - var wallet *tbtc.WalletChainData - var walletErr error - for attempt := range 3 { - wallet, walletErr = spvChain.GetWallet(event.WalletPublicKeyHash) - if walletErr == nil { - break - } - reservationWiringLogger.Warnf( - "stranding startup scan attempt %d/3 failed to fetch wallet [0x%x]: [%v]", - attempt+1, - event.WalletPublicKeyHash, - walletErr, - ) - } - if walletErr != nil { - reservationWiringLogger.Warnf( - "stranding startup scan giving up on wallet [0x%x] after "+ - "3 fetch attempts; queuing it for a second-chance "+ - "retry pass once wiring completes instead of "+ - "assuming it is Live: [%v]", - event.WalletPublicKeyHash, - walletErr, - ) - unresolvedWallets = append(unresolvedWallets, event.WalletPublicKeyHash) - continue - } - if wallet.State != tbtc.StateClosed && - wallet.State != tbtc.StateTerminated { - continue - } - var checkErr error - for attempt := range 3 { - checkErr = strandingWatcher.checkReservationStrandingForWallet( - event.WalletPublicKeyHash, - ) - if checkErr == nil { - break - } - reservationWiringLogger.Warnf( - "stranding startup scan attempt %d/3 failed to check "+ - "wallet [0x%x]: [%v]", - attempt+1, - event.WalletPublicKeyHash, - checkErr, - ) + for _, event := range registeredEvents { + var wallet *tbtc.WalletChainData + var walletErr error + for attempt := range 3 { + wallet, walletErr = spvChain.GetWallet(event.WalletPublicKeyHash) + if walletErr == nil { + break } - if checkErr != nil { - reservationWiringLogger.Warnf( - "stranding startup scan giving up on wallet [0x%x] "+ - "after 3 check attempts; its stranded reservations, "+ - "if any, will not be notified by this startup scan: [%v]", - event.WalletPublicKeyHash, - checkErr, - ) - continue + reservationWiringLogger.Warnf( + "stranding startup scan attempt %d/3 failed to fetch wallet [0x%x]: [%v]", + attempt+1, + event.WalletPublicKeyHash, + walletErr, + ) + } + if walletErr != nil { + reservationWiringLogger.Warnf( + "stranding startup scan giving up on wallet [0x%x] after "+ + "3 fetch attempts; queuing it for a second-chance "+ + "retry pass once wiring completes instead of "+ + "assuming it is Live: [%v]", + event.WalletPublicKeyHash, + walletErr, + ) + unresolvedWallets = append(unresolvedWallets, event.WalletPublicKeyHash) + continue + } + if wallet.State != tbtc.StateClosed && + wallet.State != tbtc.StateTerminated { + continue + } + var checkErr error + for attempt := range 3 { + checkErr = strandingWatcher.checkReservationStrandingForWallet( + event.WalletPublicKeyHash, + ) + if checkErr == nil { + break } + reservationWiringLogger.Warnf( + "stranding startup scan attempt %d/3 failed to check "+ + "wallet [0x%x]: [%v]", + attempt+1, + event.WalletPublicKeyHash, + checkErr, + ) + } + if checkErr != nil { + reservationWiringLogger.Warnf( + "stranding startup scan giving up on wallet [0x%x] "+ + "after 3 check attempts; its stranded reservations, "+ + "if any, will not be notified by this startup scan: [%v]", + event.WalletPublicKeyHash, + checkErr, + ) + continue } } @@ -386,10 +357,8 @@ func WireReservationWatchers( // and the action-timeout watcher (first action-request scan) share a // single lookup. math.MaxUint64 is the sentinel from // tbtc.ReservationsActivationBlock for networks without an entry - // (incl. ethereum.Unknown): the watchers treat it as "reservations - // never activate" and either skip the catch-up scan (stale-deposit - // watcher) or fall back to the bounded lookback window - // (action-timeout watcher). + // (incl. ethereum.Unknown): both watchers skip their first catch-up + // scan there (see reservationScanRange). activationBlock := tbtc.ReservationsActivationBlock(ethNetwork) staleDepositWatcher := NewReservationStaleDepositWatcher( @@ -467,6 +436,7 @@ func WireReservationWatchers( r, ) recordReservationWatcherDeath(metricsRecorder) + reservationWatcherPanicRecovered() } if !resultSent { resultCh <- result @@ -517,6 +487,7 @@ func WireReservationWatchers( r, ) recordReservationWatcherDeath(metricsRecorder) + reservationWatcherPanicRecovered() } if !resultSent { resultCh <- result @@ -630,6 +601,57 @@ func reservationSelfCheckMisconfigured( registration.count == 0 && staleDeposit.count == 0 && actionTimeout.count == 0 } +// scanReservationStrandingStartupRegistrations fetches the wallet +// registrations of the last reservationDefaultLookBackBlocks blocks for +// the stranding startup scan, in chunks (see fetchPastEventsInChunks). On +// a chunk error it returns the registrations fetched before the failing +// chunk together with the error. +func scanReservationStrandingStartupRegistrations( + spvChain Chain, +) ([]*tbtc.NewWalletRegisteredEvent, error) { + blockCounter, err := spvChain.BlockCounter() + if err != nil { + return nil, fmt.Errorf("failed to get block counter: [%v]", err) + } + currentBlock, err := blockCounter.CurrentBlock() + if err != nil { + return nil, fmt.Errorf("failed to get current block: [%v]", err) + } + + startBlock := uint64(0) + if currentBlock > reservationDefaultLookBackBlocks { + startBlock = currentBlock - reservationDefaultLookBackBlocks + reservationWiringLogger.Warnf( + "stranding startup scan is bounded to wallets registered at "+ + "block [%d] or later; a wallet registered and already "+ + "closed/terminated before that block will not be caught "+ + "by this scan, nor by the live OnWalletClosed "+ + "subscription, which cannot replay an already-emitted "+ + "close event - this is an accepted operational "+ + "limitation, not a gap covered elsewhere", + startBlock, + ) + } + + var registeredEvents []*tbtc.NewWalletRegisteredEvent + err = fetchPastEventsInChunks( + func(chunkStart, chunkEnd uint64) ([]*tbtc.NewWalletRegisteredEvent, error) { + return spvChain.PastNewWalletRegisteredEvents( + &tbtc.NewWalletRegisteredEventFilter{ + StartBlock: chunkStart, + EndBlock: &chunkEnd, + }, + ) + }, + startBlock, + currentBlock, + func(events []*tbtc.NewWalletRegisteredEvent, _ uint64) { + registeredEvents = append(registeredEvents, events...) + }, + ) + return registeredEvents, err +} + // retryReservationStrandingStartupScan gives the startup catch-up scan's // unresolved wallets (see WireReservationWatchers) one more chance, after // waiting out delay, once the rest of the wiring has already completed. diff --git a/pkg/maintainer/spv/reservation_wiring_test.go b/pkg/maintainer/spv/reservation_wiring_test.go index 7c46042051..7fc9af0684 100644 --- a/pkg/maintainer/spv/reservation_wiring_test.go +++ b/pkg/maintainer/spv/reservation_wiring_test.go @@ -187,9 +187,12 @@ func TestCheckStaleReservedDeposit_Resolution(t *testing.T) { State: test.actionState, }) watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) - seedStaleDeadline(watcher, depositKey, test.refundDeadline) - resolution, err := watcher.CheckStaleReservedDeposit(depositKey, test.now) + resolution, err := watcher.CheckStaleReservedDeposit( + depositKey, + test.refundDeadline, + test.now, + ) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -550,12 +553,11 @@ func TestWireReservationWatchers_DrivesRealNotifications_NotJustWiringSuccess(t }) fundingTxHash := bitcoin.Hash{0x02} fundingOutputIndex := uint32(0) - // currentBlock (1000) does not exceed staleDepositRevealScanLookBackBlocks, - // so pollTick's own startBlock computation stays 0 - mirror that - // here rather than subtracting the lookback bound directly (which - // would underflow uint64 for a currentBlock this small). + // The reveal sits below the confirmed tip (currentBlock 1000 minus + // reservationEventScanConfirmationBlocks), inside the first scan's + // activation-to-tip range on the Developer network. startBlock := uint64(0) - endBlock := uint64(1000) + endBlock := uint64(900) if err := spvChain.addPastDepositRevealedEvent( &tbtc.DepositRevealedEventFilter{StartBlock: startBlock + 1, EndBlock: &endBlock}, &tbtc.DepositRevealedEvent{ @@ -685,7 +687,7 @@ func TestWireReservationWatchers_DrainsStrandingRecheckThroughRealWiring(t *test State: tbtc.ReservationStateActive, }) - if err := WireReservationWatchers(ctx, walletClosedChain, spvChain, true, nil, ethereum.Unknown); err != nil { + if err := WireReservationWatchers(ctx, walletClosedChain, spvChain, true, nil, ethereum.Developer); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -895,7 +897,7 @@ func TestRunStaleDepositPollTick_SnapshotsRefundDeadlineFromRevealEvent(t *testi }) startBlock := currentBlock - reservationDefaultLookBackBlocks - endBlock := currentBlock + endBlock := currentBlock - reservationEventScanConfirmationBlocks fundingTxHash := bitcoin.Hash{0x01} fundingOutputIndex := uint32(0) @@ -930,11 +932,12 @@ func TestRunStaleDepositPollTick_SnapshotsRefundDeadlineFromRevealEvent(t *testi if trackedCount != 1 { t.Fatalf("expected 1 tracked deposit after the first tick, got %d", trackedCount) } - if _, ok := watcher.pending[depositKey.String()]; !ok { + tracked, ok := watcher.pending[depositKey.String()] + if !ok { t.Fatalf("expected the deposit to be tracked, got %v", watcher.pending) } - if deadline, ok := watcher.refundDeadlineMemo[depositKey.String()]; !ok || deadline != 5_000 { - t.Fatalf("expected the refund deadline snapshotted from the reveal event (5000), got %d (ok=%v)", deadline, ok) + if tracked.refundDeadline != 5_000 { + t.Fatalf("expected the refund deadline decoded from the reveal event (5000), got %d", tracked.refundDeadline) } if calls := spvChain.getSubmittedStaleReservedDeposits(); len(calls) != 0 { t.Fatalf("deposit is before its deadline; expected zero notifications, got %d", len(calls)) @@ -960,7 +963,7 @@ func TestRunStaleDepositPollTick_EvictsOnClearedWallet(t *testing.T) { }) startBlock := currentBlock - reservationDefaultLookBackBlocks - endBlock := currentBlock + endBlock := currentBlock - reservationEventScanConfirmationBlocks fundingTxHash := bitcoin.Hash{0x03} fundingOutputIndex := uint32(0) @@ -1014,7 +1017,7 @@ func TestRunStaleDepositPollTick_NotifiesAfterDeadline(t *testing.T) { }) startBlock := currentBlock - reservationDefaultLookBackBlocks - endBlock := currentBlock + endBlock := currentBlock - reservationEventScanConfirmationBlocks fundingTxHash := bitcoin.Hash{0x04} fundingOutputIndex := uint32(0) @@ -1056,19 +1059,45 @@ func TestRunStaleDepositPollTick_NotifiesAfterDeadline(t *testing.T) { } } +// awaitReservationWatcherPanicRecoveries installs +// reservationWatcherPanicRecovered for the duration of the test and +// returns a function that waits, bounded, until count watcher goroutines +// have finished their recovered-panic path. Waiting before the test ends +// also keeps those goroutines from reading the hook while the next test +// replaces it. +func awaitReservationWatcherPanicRecoveries(t *testing.T, count int) func() { + recovered := make(chan struct{}, count) + previous := reservationWatcherPanicRecovered + reservationWatcherPanicRecovered = func() { recovered <- struct{}{} } + t.Cleanup(func() { reservationWatcherPanicRecovered = previous }) + + return func() { + t.Helper() + deadline := time.After(5 * time.Second) + for range count { + select { + case <-recovered: + case <-deadline: + t.Fatal("a watcher goroutine never completed its recovered-panic path") + } + } + } +} + // TestWireReservationWatchers_WatcherDeathIncrementsMetric verifies that // when a watcher goroutine dies (here: a panic during its initial poll // pass, recovered by the goroutine's panic-recover), the watcher-death // counter is incremented on the supplied MetricsRecorder - not just a // log line - so a dead watcher is observable in metrics. Both watcher // goroutines are forced through the death path, and the test asserts -// exactly two increments (one per goroutine), read through the -// recorder's mutex-safe accessor so the polling race is real but -// data-race-free. +// exactly two increments (one per goroutine) once both goroutines have +// finished their recover path. func TestWireReservationWatchers_WatcherDeathIncrementsMetric(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + awaitRecoveries := awaitReservationWatcherPanicRecoveries(t, 2) + spvChain := newLocalChain() blockCounter := newMockBlockCounter() blockCounter.SetCurrentBlock(1000) @@ -1081,9 +1110,7 @@ func TestWireReservationWatchers_WatcherDeathIncrementsMetric(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - waitForReservationWiringCondition(t, 500*time.Millisecond, func() bool { - return recorder.Counter(clientinfo.MetricSpvReservationWatcherDeathsTotal) >= 2 - }) + awaitRecoveries() if got := recorder.Counter(clientinfo.MetricSpvReservationWatcherDeathsTotal); got != 2 { t.Fatalf("expected exactly two watcher-death increments (one per watcher goroutine), got %v", got) @@ -1093,30 +1120,20 @@ func TestWireReservationWatchers_WatcherDeathIncrementsMetric(t *testing.T) { // watcherDeathPanickingChain wraps a *localChain and panics on the first // chain read each watcher's initial poll pass performs, so the // goroutine's panic-recover (and thus the watcher-death counter) fires -// deterministically. Each intercepted read also signals deathSignal before -// it panics, so an end-to-end test can wait for both watcher goroutines to -// actually reach their forced panic - a real completion signal in place of -// a sleep. The channel is buffered enough for one send from each watcher. +// deterministically. type watcherDeathPanickingChain struct { *localChain - deathSignal chan struct{} } func (c *watcherDeathPanickingChain) PastDepositRevealedEvents( filter *tbtc.DepositRevealedEventFilter, ) ([]*tbtc.DepositRevealedEvent, error) { - if c.deathSignal != nil { - c.deathSignal <- struct{}{} - } panic("stale-deposit watcher boom") } func (c *watcherDeathPanickingChain) PastReservationAcceptanceRequestedEvents( filter *tbtc.ReservationAcceptanceRequestedEventFilter, ) ([]*tbtc.ReservationAcceptanceRequestedEvent, error) { - if c.deathSignal != nil { - c.deathSignal <- struct{}{} - } panic("action-timeout watcher boom") } @@ -1168,20 +1185,21 @@ func TestRecordReservationWatcherDeath_TypedNilPerformanceMetrics(t *testing.T) // process crash) is the proof the guard holds end-to-end, not just at the // unit level above. // -// The watcher goroutines' completion is signalled, not slept for: each -// intercepted chain read sends on deathSignal before it panics, so the -// test waits - with a bounded timeout - for both goroutines to actually -// reach their forced panic instead of assuming 100ms is enough. +// The test waits - with a bounded timeout - until both goroutines have +// finished their recover path (reservationWatcherPanicRecovered runs +// right after recordReservationWatcherDeath), so it cannot pass before +// the guarded call has actually run. func TestWireReservationWatchers_TypedNilPerformanceMetricsSurvivesWatcherDeath(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + awaitRecoveries := awaitReservationWatcherPanicRecoveries(t, 2) + spvChain := newLocalChain() blockCounter := newMockBlockCounter() blockCounter.SetCurrentBlock(1000) spvChain.setBlockCounter(blockCounter) - deathSignal := make(chan struct{}, 2) - panickingChain := &watcherDeathPanickingChain{localChain: spvChain, deathSignal: deathSignal} + panickingChain := &watcherDeathPanickingChain{localChain: spvChain} var typedNilMetrics *clientinfo.PerformanceMetrics @@ -1196,23 +1214,51 @@ func TestWireReservationWatchers_TypedNilPerformanceMetricsSurvivesWatcherDeath( t.Fatalf("unexpected error: %v", err) } - // One signal per watcher goroutine at its forced panic; waiting for - // both (bounded) is the assertion setup - a goroutine that never - // reaches its chain read means the death path was not actually - // exercised, and the test fails loudly rather than passing by - // timing. - deadline := time.After(5 * time.Second) - for range 2 { - select { - case <-deathSignal: - case <-deadline: - t.Fatal("a watcher goroutine never reached its forced panic") - } + // One signal per watcher goroutine once its recover path, including + // recordReservationWatcherDeath against the typed-nil recorder, has + // completed. A crash there would take the whole test process down, + // so receiving both signals is the assertion. + awaitRecoveries() +} + +// TestScanReservationStrandingStartupRegistrations verifies that the +// stranding startup scan walks the last reservationDefaultLookBackBlocks +// blocks in chunks and returns every registration inside that window +// exactly once, and none before it. +func TestScanReservationStrandingStartupRegistrations(t *testing.T) { + const windowStart = uint64(50_000) + spvChain := newLocalChain() + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(windowStart + reservationDefaultLookBackBlocks) + spvChain.setBlockCounter(blockCounter) + + blocks := []uint64{ + windowStart - 1, // before the window + windowStart + 100, // first chunk + windowStart + reservationEventScanChunkSize + 100, // second chunk + } + for i, blockNumber := range blocks { + spvChain.addNewWalletRegisteredEvent(&tbtc.NewWalletRegisteredEvent{ + WalletPublicKeyHash: walletPKHAt(byte(i + 1)), + BlockNumber: blockNumber, + }) + } + + events, err := scanReservationStrandingStartupRegistrations(spvChain) + if err != nil { + t.Fatalf("unexpected error: %v", err) } - // Both watcher goroutines have reached their forced panic and - // dispatched their recover handlers, which run - // recordReservationWatcherDeath against the typed-nil recorder; - // a crash would take the whole test process down, so surviving to - // here (with both signals observed) is the assertion. + if len(events) != 2 { + t.Fatalf("expected the 2 registrations inside the window, got %d", len(events)) + } + if events[0].BlockNumber != blocks[1] || events[1].BlockNumber != blocks[2] { + t.Errorf( + "expected registrations at blocks %d and %d, got %d and %d", + blocks[1], + blocks[2], + events[0].BlockNumber, + events[1].BlockNumber, + ) + } } diff --git a/pkg/tbtc/chain.go b/pkg/tbtc/chain.go index a70df7d0f7..f8e2921454 100644 --- a/pkg/tbtc/chain.go +++ b/pkg/tbtc/chain.go @@ -575,29 +575,6 @@ type Chain interface { ReservationChain } -// ReservationReanchorRequestReceiptStatus describes the outcome of a -// receipt lookup for a submitted RequestReservationReanchor transaction. -// The RequestReservationReanchor submission launches mining and gas -// bumping in the background and returns before the transaction is mined, -// so callers track the returned transaction hash across coordination -// rounds and resolve the request's fate from its receipt. -type ReservationReanchorRequestReceiptStatus int - -const ( - // ReservationReanchorRequestReceiptMined is the request transaction - // mined successfully: the new action generation exists on chain. - ReservationReanchorRequestReceiptMined ReservationReanchorRequestReceiptStatus = iota - // ReservationReanchorRequestReceiptReverted is the request transaction - // mined but reverted on chain: no action generation was written. - ReservationReanchorRequestReceiptReverted - // ReservationReanchorRequestReceiptPending is the request transaction - // is still in the mempool with no receipt yet. - ReservationReanchorRequestReceiptPending - // ReservationReanchorRequestReceiptNotFound is the request transaction - // was never observed on this node; it may have been dropped. - ReservationReanchorRequestReceiptNotFound -) - // ReservationChain defines the subset of the TBTC chain interface that pertains // specifically to UTXO reservation Bridge operations. The reservation state // machine is implemented behind Bridge.fallback's delegatecall to the @@ -608,23 +585,10 @@ const ( type ReservationChain interface { // RequestReservationReanchor requests a reservation re-anchor action // generation for the given reservation, targeting the given wallet. - // The returned bytes are the 32-byte hash of the submitted - // transaction; callers that submit the request track it across - // rounds via GetReservationReanchorRequestReceipt. A returned error - // means the submission itself failed before or at send time, so - // there is no transaction to track. RequestReservationReanchor( reservationKey *big.Int, targetWalletPublicKeyHash [20]byte, - ) ([32]byte, error) - - // GetReservationReanchorRequestReceipt reports the mining status of - // the RequestReservationReanchor transaction identified by txHash: - // mined successfully, mined but reverted, still pending in the - // mempool, or not observed. - GetReservationReanchorRequestReceipt( - txHash [32]byte, - ) (ReservationReanchorRequestReceiptStatus, error) + ) error // SubmitReservationAcceptanceProof submits an SPV proof for the given // reservation acceptance action generation. The call is restricted to @@ -718,23 +682,27 @@ type ReservationChain interface { // reservations across all wallets and the cap on that count. ActiveReservationsCount() (count uint32, maxActive uint32, err error) - // ReservationVaultFeeDebtSat returns the ReservationVault's + // ReservationVaultFeeDebtSat returns the given ReservationVault's // outstanding in-kind fee debt in satoshi, read from the vault's - // inKindFeeDebtSat view. If the reservation vault address is + // inKindFeeDebtSat view. The caller passes the vault address it + // already read from ReservationParameters. If the address is // zero (vault not configured), it returns zero with a nil // error: the skip sentinel the metric side consumes, not an // error. - ReservationVaultFeeDebtSat() (uint64, error) + ReservationVaultFeeDebtSat(reservationVault chain.Address) (uint64, error) - // ReservationVaultFeeReserveTbtcBaseUnits returns the + // ReservationVaultFeeReserveTbtcBaseUnits returns the given // ReservationVault's TBTC fee-reserve balance in TBTC base // units: TBTC is a 1e18 base-unit token, so the returned value // is whole TBTC x 1e18. The value can exceed uint64 range, so - // it is returned as *big.Int. If the reservation vault address - // is zero (vault not configured), it returns zero with a nil - // error: the skip sentinel the metric side consumes, not an - // error. - ReservationVaultFeeReserveTbtcBaseUnits() (*big.Int, error) + // it is returned as *big.Int. The caller passes the vault + // address it already read from ReservationParameters. If the + // address is zero (vault not configured), it returns zero with + // a nil error: the skip sentinel the metric side consumes, not + // an error. + ReservationVaultFeeReserveTbtcBaseUnits( + reservationVault chain.Address, + ) (*big.Int, error) // IsReservedDeposit returns true if the given deposit was revealed // with the reservation vault address and is therefore a reservation @@ -868,7 +836,6 @@ type ReservationReanchoredEvent struct { NewWalletPublicKeyHash [20]byte NewAnchorTxHash [32]byte NewAnchorAmount uint64 - MinerFee uint64 BlockNumber uint64 } diff --git a/pkg/tbtc/chain_test.go b/pkg/tbtc/chain_test.go index 7fe067b053..af25e4c27e 100644 --- a/pkg/tbtc/chain_test.go +++ b/pkg/tbtc/chain_test.go @@ -1569,13 +1569,7 @@ func (lc *localChain) setValidateReservationReanchorProposalErr(err error) { func (lc *localChain) RequestReservationReanchor( reservationKey *big.Int, targetWalletPublicKeyHash [20]byte, -) ([32]byte, error) { - panic("unsupported") -} - -func (lc *localChain) GetReservationReanchorRequestReceipt( - txHash [32]byte, -) (ReservationReanchorRequestReceiptStatus, error) { +) error { panic("unsupported") } @@ -1665,14 +1659,18 @@ func (lc *localChain) ActiveReservationsCount() (uint32, uint32, error) { // ReservationVaultFeeDebtSat is not exercised by this fake's tests; it // reports an unsupported error mirroring the sibling reservation view // methods on this fake. -func (lc *localChain) ReservationVaultFeeDebtSat() (uint64, error) { +func (lc *localChain) ReservationVaultFeeDebtSat( + reservationVault chain.Address, +) (uint64, error) { return 0, fmt.Errorf("unsupported") } // ReservationVaultFeeReserveTbtcBaseUnits is not exercised by this // fake's tests; it reports an unsupported error mirroring the sibling // reservation view methods on this fake. -func (lc *localChain) ReservationVaultFeeReserveTbtcBaseUnits() (*big.Int, error) { +func (lc *localChain) ReservationVaultFeeReserveTbtcBaseUnits( + reservationVault chain.Address, +) (*big.Int, error) { return nil, fmt.Errorf("unsupported") } diff --git a/pkg/tbtc/deposit_reveal_lookup.go b/pkg/tbtc/deposit_reveal_lookup.go new file mode 100644 index 0000000000..b0451a46d3 --- /dev/null +++ b/pkg/tbtc/deposit_reveal_lookup.go @@ -0,0 +1,177 @@ +package tbtc + +import ( + "fmt" + "time" +) + +// DepositRevealLookupChunkBlocks is the maximum block range a single +// PastDepositRevealedEvents call made by FindDepositRevealedEvent spans. +// Many Ethereum providers reject or truncate log queries over wider +// ranges. +const DepositRevealLookupChunkBlocks = uint64(10000) + +// DepositRevealLookupDefaultBlockTime is the block time +// FindDepositRevealedEventByRevealTime assumes when the caller does not +// know one: Ethereum's post-merge slot time. +const DepositRevealLookupDefaultBlockTime = 12 * time.Second + +// depositRevealLookupMinMarginBlocks is the smallest padding +// FindDepositRevealedEventByRevealTime applies on each side of the +// estimated reveal block. +const depositRevealLookupMinMarginBlocks = uint64(1000) + +// depositRevealLookupWidenFactor is how much wider than the first window +// the single retry window of FindDepositRevealedEventByRevealTime is. +const depositRevealLookupWidenFactor = uint64(4) + +// DepositRevealedEventSource is the chain surface the deposit reveal +// lookup helpers need. +type DepositRevealedEventSource interface { + PastDepositRevealedEvents( + filter *DepositRevealedEventFilter, + ) ([]*DepositRevealedEvent, error) +} + +// FindDepositRevealedEvent scans the DepositRevealed events of the given +// wallet in the block range [fromBlock, toBlock] and returns the first +// event for which match returns true, or nil if none does. The range is +// scanned newest chunk first, at most DepositRevealLookupChunkBlocks per +// call, and the scan stops at the first match. +func FindDepositRevealedEvent( + source DepositRevealedEventSource, + walletPublicKeyHash [20]byte, + fromBlock uint64, + toBlock uint64, + match func(event *DepositRevealedEvent) bool, +) (*DepositRevealedEvent, error) { + if fromBlock > toBlock { + return nil, nil + } + + chunkEnd := toBlock + for { + chunkStart := fromBlock + if chunkEnd-fromBlock >= DepositRevealLookupChunkBlocks { + chunkStart = chunkEnd - DepositRevealLookupChunkBlocks + 1 + } + + end := chunkEnd + events, err := source.PastDepositRevealedEvents( + &DepositRevealedEventFilter{ + StartBlock: chunkStart, + EndBlock: &end, + WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + }, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot get deposit revealed events in blocks [%d, %d]: [%w]", + chunkStart, + chunkEnd, + err, + ) + } + + for _, event := range events { + if match(event) { + return event, nil + } + } + + if chunkStart == fromBlock { + return nil, nil + } + chunkEnd = chunkStart - 1 + } +} + +// FindDepositRevealedEventByRevealTime returns the DepositRevealed event +// of the given wallet for which match returns true, locating it from the +// deposit's on-chain reveal timestamp instead of scanning the wallet's +// whole reveal history. The reveal block is estimated as +// currentBlock - (now - revealedAt) / averageBlockTime and padded on each +// side by 5% of the estimated distance (at least +// depositRevealLookupMinMarginBlocks). If the event is not in that window, +// the two outer strips of a window depositRevealLookupWidenFactor times +// wider are scanned once before giving up with a nil event. A +// non-positive averageBlockTime is replaced by +// DepositRevealLookupDefaultBlockTime. +func FindDepositRevealedEventByRevealTime( + source DepositRevealedEventSource, + walletPublicKeyHash [20]byte, + revealedAt time.Time, + now time.Time, + currentBlock uint64, + averageBlockTime time.Duration, + match func(event *DepositRevealedEvent) bool, +) (*DepositRevealedEvent, error) { + if averageBlockTime <= 0 { + averageBlockTime = DepositRevealLookupDefaultBlockTime + } + + elapsed := now.Sub(revealedAt) + if elapsed < 0 { + elapsed = 0 + } + blocksAgo := uint64(elapsed / averageBlockTime) + + estimatedBlock := uint64(0) + if currentBlock > blocksAgo { + estimatedBlock = currentBlock - blocksAgo + } + + margin := blocksAgo / 20 + if margin < depositRevealLookupMinMarginBlocks { + margin = depositRevealLookupMinMarginBlocks + } + + window := func(margin uint64) (uint64, uint64) { + from := uint64(0) + if estimatedBlock > margin { + from = estimatedBlock - margin + } + to := estimatedBlock + margin + if to > currentBlock { + to = currentBlock + } + return from, to + } + + from, to := window(margin) + event, err := FindDepositRevealedEvent( + source, + walletPublicKeyHash, + from, + to, + match, + ) + if err != nil || event != nil { + return event, err + } + + wideFrom, wideTo := window(margin * depositRevealLookupWidenFactor) + if to < wideTo { + event, err = FindDepositRevealedEvent( + source, + walletPublicKeyHash, + to+1, + wideTo, + match, + ) + if err != nil || event != nil { + return event, err + } + } + if wideFrom < from { + return FindDepositRevealedEvent( + source, + walletPublicKeyHash, + wideFrom, + from-1, + match, + ) + } + + return nil, nil +} diff --git a/pkg/tbtc/deposit_reveal_lookup_test.go b/pkg/tbtc/deposit_reveal_lookup_test.go new file mode 100644 index 0000000000..c0dc5b88e2 --- /dev/null +++ b/pkg/tbtc/deposit_reveal_lookup_test.go @@ -0,0 +1,275 @@ +package tbtc + +import ( + "fmt" + "testing" + "time" + + "github.com/keep-network/keep-core/pkg/bitcoin" +) + +// rangeDepositRevealedEvents answers PastDepositRevealedEvents the way an +// Ethereum node does: every registered event whose block lies in +// [StartBlock, EndBlock] and whose wallet matches the filter. It rejects +// unbounded queries and queries wider than DepositRevealLookupChunkBlocks, +// so a lookup that stops chunking fails loudly instead of silently +// passing against a lenient fake. It records every queried range. +type rangeDepositRevealedEvents struct { + events []*DepositRevealedEvent + queries [][2]uint64 +} + +func (r *rangeDepositRevealedEvents) PastDepositRevealedEvents( + filter *DepositRevealedEventFilter, +) ([]*DepositRevealedEvent, error) { + if filter == nil || filter.EndBlock == nil { + return nil, fmt.Errorf("unbounded deposit revealed events query") + } + if filter.StartBlock > *filter.EndBlock { + return nil, fmt.Errorf( + "inverted block range [%d, %d]", + filter.StartBlock, + *filter.EndBlock, + ) + } + if *filter.EndBlock-filter.StartBlock+1 > DepositRevealLookupChunkBlocks { + return nil, fmt.Errorf( + "block range [%d, %d] wider than one chunk", + filter.StartBlock, + *filter.EndBlock, + ) + } + r.queries = append(r.queries, [2]uint64{filter.StartBlock, *filter.EndBlock}) + + var result []*DepositRevealedEvent + for _, event := range r.events { + if event.BlockNumber < filter.StartBlock || + event.BlockNumber > *filter.EndBlock { + continue + } + walletMatch := len(filter.WalletPublicKeyHash) == 0 + for _, wallet := range filter.WalletPublicKeyHash { + if wallet == event.WalletPublicKeyHash { + walletMatch = true + } + } + if walletMatch { + result = append(result, event) + } + } + return result, nil +} + +// rangeRevealChain is a localChain whose DepositRevealed events are served +// by rangeDepositRevealedEvents. +type rangeRevealChain struct { + *localChain + reveals *rangeDepositRevealedEvents +} + +func newRangeRevealChain(events ...*DepositRevealedEvent) *rangeRevealChain { + return &rangeRevealChain{ + localChain: Connect(), + reveals: &rangeDepositRevealedEvents{events: events}, + } +} + +func (c *rangeRevealChain) PastDepositRevealedEvents( + filter *DepositRevealedEventFilter, +) ([]*DepositRevealedEvent, error) { + return c.reveals.PastDepositRevealedEvents(filter) +} + +// timedRangeRevealChain is a rangeRevealChain that reports a block time. +type timedRangeRevealChain struct { + *rangeRevealChain + blockTime time.Duration +} + +func (c *timedRangeRevealChain) AverageBlockTime() time.Duration { + return c.blockTime +} + +func matchFundingTxHash(hash bitcoin.Hash) func(*DepositRevealedEvent) bool { + return func(event *DepositRevealedEvent) bool { + return event.FundingTxHash == hash + } +} + +// TestFindDepositRevealedEvent_ChunkBoundaries pins the chunked scan: the +// range is covered newest chunk first without gaps or overlaps, an event +// on either edge of a chunk is found, and the scan stops at the first +// match instead of reading older chunks. +func TestFindDepositRevealedEvent_ChunkBoundaries(t *testing.T) { + wallet := [20]byte{0x01} + target := bitcoin.Hash{0xaa} + + tests := map[string]struct { + eventBlock uint64 + expectedQueries [][2]uint64 + }{ + "newest block of the newest chunk": { + eventBlock: 50000, + expectedQueries: [][2]uint64{{40001, 50000}}, + }, + "oldest block of the newest chunk": { + eventBlock: 40001, + expectedQueries: [][2]uint64{{40001, 50000}}, + }, + "newest block of the second chunk": { + eventBlock: 40000, + expectedQueries: [][2]uint64{ + {40001, 50000}, + {30001, 40000}, + }, + }, + "range start inside a partial last chunk": { + eventBlock: 25000, + expectedQueries: [][2]uint64{ + {40001, 50000}, + {30001, 40000}, + {25000, 30000}, + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + source := &rangeDepositRevealedEvents{ + events: []*DepositRevealedEvent{{ + BlockNumber: test.eventBlock, + WalletPublicKeyHash: wallet, + FundingTxHash: target, + }}, + } + + event, err := FindDepositRevealedEvent( + source, + wallet, + 25000, + 50000, + matchFundingTxHash(target), + ) + if err != nil { + t.Fatal(err) + } + if event == nil || event.BlockNumber != test.eventBlock { + t.Fatalf("expected the event at block %d, got %+v", test.eventBlock, event) + } + if fmt.Sprint(source.queries) != fmt.Sprint(test.expectedQueries) { + t.Fatalf( + "unexpected queried ranges\nexpected: %v\nactual: %v", + test.expectedQueries, + source.queries, + ) + } + }) + } + + t.Run("not found covers the whole range once", func(t *testing.T) { + source := &rangeDepositRevealedEvents{} + event, err := FindDepositRevealedEvent( + source, + wallet, + 25000, + 50000, + matchFundingTxHash(target), + ) + if err != nil || event != nil { + t.Fatalf("expected no event and no error, got %+v, %v", event, err) + } + expected := [][2]uint64{{40001, 50000}, {30001, 40000}, {25000, 30000}} + if fmt.Sprint(source.queries) != fmt.Sprint(expected) { + t.Fatalf("expected queries %v, got %v", expected, source.queries) + } + }) + + t.Run("range starting at block zero does not underflow", func(t *testing.T) { + source := &rangeDepositRevealedEvents{} + if _, err := FindDepositRevealedEvent( + source, + wallet, + 0, + 12000, + matchFundingTxHash(target), + ); err != nil { + t.Fatal(err) + } + expected := [][2]uint64{{2001, 12000}, {0, 2000}} + if fmt.Sprint(source.queries) != fmt.Sprint(expected) { + t.Fatalf("expected queries %v, got %v", expected, source.queries) + } + }) +} + +// TestFindDepositRevealedEventByRevealTime pins the reveal-time lookup a +// signer uses for an anchor proposal: a reveal far older than any fixed +// look-back window is found from its on-chain timestamp, block-time drift +// inside the margin is tolerated, and a reveal outside the first window +// but inside the widened one is found by the single retry. +func TestFindDepositRevealedEventByRevealTime(t *testing.T) { + wallet := [20]byte{0x01} + target := bitcoin.Hash{0xaa} + now := time.Now() + currentBlock := uint64(10_000_000) + // 300000 blocks (about 41 days at 12 s) before the current block. The + // margin for this distance is 5% of 300000 = 15000 blocks. + revealedAt := now.Add(-300000 * 12 * time.Second) + estimatedBlock := currentBlock - 300000 + + tests := map[string]struct { + eventBlock uint64 + expectHit bool + }{ + "exact estimate": {eventBlock: estimatedBlock, expectHit: true}, + "drift inside the first window": {eventBlock: estimatedBlock + 14000, expectHit: true}, + "later block found by widening": {eventBlock: estimatedBlock + 40000, expectHit: true}, + "earlier block found by widening": {eventBlock: estimatedBlock - 40000, expectHit: true}, + "outside the widened window": {eventBlock: estimatedBlock - 70000, expectHit: false}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + source := &rangeDepositRevealedEvents{ + events: []*DepositRevealedEvent{{ + BlockNumber: test.eventBlock, + WalletPublicKeyHash: wallet, + FundingTxHash: target, + }}, + } + + event, err := FindDepositRevealedEventByRevealTime( + source, + wallet, + revealedAt, + now, + currentBlock, + 0, + matchFundingTxHash(target), + ) + if err != nil { + t.Fatal(err) + } + if test.expectHit && (event == nil || event.BlockNumber != test.eventBlock) { + t.Fatalf("expected the event at block %d, got %+v", test.eventBlock, event) + } + if !test.expectHit && event != nil { + t.Fatalf("expected no event, got %+v", event) + } + // No block may be queried twice: the retry scans only the + // two outer strips of the widened window. + for i, a := range source.queries { + for _, b := range source.queries[i+1:] { + if a[0] <= b[1] && b[0] <= a[1] { + t.Fatalf( + "ranges %v and %v overlap: %v", + a, + b, + source.queries, + ) + } + } + } + }) + } +} diff --git a/pkg/tbtc/reservation.go b/pkg/tbtc/reservation.go index 1d1bcdc363..db161b8a98 100644 --- a/pkg/tbtc/reservation.go +++ b/pkg/tbtc/reservation.go @@ -12,12 +12,6 @@ import ( ) const ( - // reservationLookBackBlocks bounds how far back the reservation anchor - // action's DepositRevealed event lookup scans. 216000 blocks is ~30 - // days at 12s/block, the same convention used by - // MovingFundsCommitmentLookBackBlocks in moving_funds.go. - reservationLookBackBlocks = uint64(216000) - // reservationAnchorProposalValidityBlocks determines the reservation // anchor proposal validity time expressed in blocks. reservationAnchorProposalValidityBlocks = 600 @@ -80,6 +74,10 @@ type Reservation struct { // DissolutionEligibleAt is the UNIX timestamp at which the current term // becomes eligible for dissolution. DissolutionEligibleAt uint32 + // ReanchorCooldownUntil is the UNIX timestamp before which a + // permissionless re-anchor request reverts. A re-anchor action timeout + // sets it to the timeout time plus the timed-out action's duration. + ReanchorCooldownUntil uint32 } // ReservationActionType represents the type of a reservation action @@ -226,11 +224,13 @@ type ReservationAction struct { // TermSeconds is the custody term snapshotted when the generation was // requested. It also bounds the late-acceptance settlement window // (timeoutAt + termSeconds), so proof submitters must read it from the - // action, not from the live governance parameter. + // action, not from the live governance parameter. Set only for + // acceptance generations; zero for other action types. TermSeconds uint32 // MinAmount is the minimum reservation amount snapshotted when the // generation was requested. Settlement and signer validation both - // enforce this snapshot, not the live governance parameter. + // enforce this snapshot, not the live governance parameter. Set only + // for acceptance generations; zero for other action types. MinAmount uint64 } @@ -502,36 +502,6 @@ func (raa *reservationAnchorAction) execute() error { return fmt.Errorf("cannot fetch funding transaction: [%v]", err) } - // The proposal carries only the deposit's funding outpoint, not the - // block it was revealed at, so the DepositRevealed event lookup cannot - // be block-range narrowed the way the deposit sweep validation path - // narrows it via DepositsRevealBlocks. Narrow by wallet PKH instead and - // match the exact funding outpoint among the returned events. - eventsStartBlock := uint64(0) - if raa.startBlock > reservationLookBackBlocks { - eventsStartBlock = raa.startBlock - reservationLookBackBlocks - } - - events, err := raa.chain.PastDepositRevealedEvents(&DepositRevealedEventFilter{ - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - StartBlock: eventsStartBlock, - }) - if err != nil { - return fmt.Errorf("cannot fetch deposit revealed events: [%v]", err) - } - - var matchingEvent *DepositRevealedEvent - for _, event := range events { - if event.FundingTxHash == raa.proposal.DepositFundingTxHash && - event.FundingOutputIndex == raa.proposal.DepositFundingOutputIndex { - matchingEvent = event - break - } - } - if matchingEvent == nil { - return fmt.Errorf("no matching DepositRevealed event for deposit") - } - depositRequest, found, err := raa.chain.GetDepositRequest( raa.proposal.DepositFundingTxHash, raa.proposal.DepositFundingOutputIndex, @@ -543,6 +513,37 @@ func (raa *reservationAnchorAction) execute() error { return fmt.Errorf("deposit request not found") } + // The proposal carries only the deposit's funding outpoint, not the + // block it was revealed at. A depositor may request acceptance long + // after the reveal, so the DepositRevealed event is located from the + // deposit's on-chain reveal timestamp rather than from a fixed + // look-back window, and matched on the exact funding outpoint. The + // chain's configured block time is used when it exposes one. + averageBlockTime := DepositRevealLookupDefaultBlockTime + if timedChain, ok := raa.chain.(interface { + AverageBlockTime() time.Duration + }); ok { + averageBlockTime = timedChain.AverageBlockTime() + } + matchingEvent, err := FindDepositRevealedEventByRevealTime( + raa.chain, + walletPublicKeyHash, + depositRequest.RevealedAt, + time.Now(), + raa.startBlock, + averageBlockTime, + func(event *DepositRevealedEvent) bool { + return event.FundingTxHash == raa.proposal.DepositFundingTxHash && + event.FundingOutputIndex == raa.proposal.DepositFundingOutputIndex + }, + ) + if err != nil { + return fmt.Errorf("cannot fetch deposit revealed events: [%v]", err) + } + if matchingEvent == nil { + return fmt.Errorf("no matching DepositRevealed event for deposit") + } + deposit := matchingEvent.unpack(depositRequest.ExtraData) // m1 identity: the reservation key is the deposit key, mirroring the diff --git a/pkg/tbtc/reservation_test.go b/pkg/tbtc/reservation_test.go index 473903cadf..3fb8574838 100644 --- a/pkg/tbtc/reservation_test.go +++ b/pkg/tbtc/reservation_test.go @@ -748,7 +748,6 @@ func TestReservationAnchorAction_Execute(t *testing.T) { } t.Run("no matching DepositRevealed event", func(t *testing.T) { - chain := Connect() btcChain := newLocalBitcoinChain() fundingTx := &bitcoin.Transaction{ @@ -759,22 +758,20 @@ func TestReservationAnchorAction_Execute(t *testing.T) { } fundingTxHash := fundingTx.Hash() - // A DepositRevealed event exists for this wallet, but for a - // different funding outpoint - the matching loop must walk past - // it and still report no match, not silently accept it. - if err := chain.setPastDepositRevealedEvents( - &DepositRevealedEventFilter{ - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - StartBlock: 300000 - reservationLookBackBlocks, - }, - []*DepositRevealedEvent{{ - FundingTxHash: bitcoin.Hash{0x99}, - FundingOutputIndex: 0, - WalletPublicKeyHash: walletPublicKeyHash, - }}, - ); err != nil { - t.Fatal(err) - } + // A DepositRevealed event exists for this wallet in the searched + // window, but for a different funding outpoint - the matching + // must walk past it and still report no match, not silently + // accept it. + chain := newRangeRevealChain(&DepositRevealedEvent{ + BlockNumber: 299990, + FundingTxHash: bitcoin.Hash{0x99}, + FundingOutputIndex: 0, + WalletPublicKeyHash: walletPublicKeyHash, + }) + chain.setDepositRequest(fundingTxHash, fundingOutputIndex, &DepositChainRequest{ + Amount: 100000, + RevealedAt: time.Now(), + }) err := newAction(chain, btcChain, fundingTxHash).execute() if err == nil || err.Error() != "no matching DepositRevealed event for deposit" { @@ -786,7 +783,6 @@ func TestReservationAnchorAction_Execute(t *testing.T) { }) t.Run("deposit request not found", func(t *testing.T) { - chain := Connect() btcChain := newLocalBitcoinChain() fundingTx := &bitcoin.Transaction{ @@ -797,19 +793,12 @@ func TestReservationAnchorAction_Execute(t *testing.T) { } fundingTxHash := fundingTx.Hash() - if err := chain.setPastDepositRevealedEvents( - &DepositRevealedEventFilter{ - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - StartBlock: 300000 - reservationLookBackBlocks, - }, - []*DepositRevealedEvent{{ - FundingTxHash: fundingTxHash, - FundingOutputIndex: fundingOutputIndex, - WalletPublicKeyHash: walletPublicKeyHash, - }}, - ); err != nil { - t.Fatal(err) - } + chain := newRangeRevealChain(&DepositRevealedEvent{ + BlockNumber: 299990, + FundingTxHash: fundingTxHash, + FundingOutputIndex: fundingOutputIndex, + WalletPublicKeyHash: walletPublicKeyHash, + }) // Deliberately no setDepositRequest call: the Bridge has no // request record for this funding outpoint. @@ -822,81 +811,107 @@ func TestReservationAnchorAction_Execute(t *testing.T) { } }) - t.Run("full happy path up to the signing boundary", func(t *testing.T) { - chain := Connect() - btcChain := newLocalBitcoinChain() + // The happy path runs for a recent reveal, for a reveal 250000 blocks + // (about 35 days) back, and on a chain with 1-second blocks: a + // depositor may request acceptance long after revealing, and the + // signer locates the reveal from its timestamp and the chain's block + // time. The 250000-block reveal's event sits 3000 blocks off the block + // estimated from its timestamp, inside the lookup margin, as + // block-time drift would place it. + happyPathCases := map[string]struct { + revealAge time.Duration + revealBlock uint64 + blockTime time.Duration + }{ + "recent reveal": { + revealAge: 0, + revealBlock: 299990, + }, + "reveal 250000 blocks back": { + revealAge: 250000 * 12 * time.Second, + revealBlock: 300000 - 250000 + 3000, + }, + "reveal 7200 one-second blocks back": { + revealAge: 2 * time.Hour, + revealBlock: 300000 - 7200, + blockTime: time.Second, + }, + } + for name, happyPathCase := range happyPathCases { + t.Run("full happy path up to the signing boundary: "+name, func(t *testing.T) { + btcChain := newLocalBitcoinChain() - depositForScript := &Deposit{ - Depositor: "0x0000000000000000000000000000000000000001", - WalletPublicKeyHash: walletPublicKeyHash, - } - depositScript, err := depositForScript.Script() - if err != nil { - t.Fatal(err) - } - scriptHash := sha256.Sum256(depositScript) - fundingOutputScript, err := bitcoin.PayToWitnessScriptHash(scriptHash) - if err != nil { - t.Fatal(err) - } + depositForScript := &Deposit{ + Depositor: "0x0000000000000000000000000000000000000001", + WalletPublicKeyHash: walletPublicKeyHash, + } + depositScript, err := depositForScript.Script() + if err != nil { + t.Fatal(err) + } + scriptHash := sha256.Sum256(depositScript) + fundingOutputScript, err := bitcoin.PayToWitnessScriptHash(scriptHash) + if err != nil { + t.Fatal(err) + } - fundingTx := &bitcoin.Transaction{ - Outputs: []*bitcoin.TransactionOutput{{ - Value: 100000, - PublicKeyScript: fundingOutputScript, - }}, - } - if err := btcChain.BroadcastTransaction(fundingTx); err != nil { - t.Fatal(err) - } - fundingTxHash := fundingTx.Hash() + fundingTx := &bitcoin.Transaction{ + Outputs: []*bitcoin.TransactionOutput{{ + Value: 100000, + PublicKeyScript: fundingOutputScript, + }}, + } + if err := btcChain.BroadcastTransaction(fundingTx); err != nil { + t.Fatal(err) + } + fundingTxHash := fundingTx.Hash() - if err := chain.setPastDepositRevealedEvents( - &DepositRevealedEventFilter{ - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - StartBlock: 300000 - reservationLookBackBlocks, - }, - []*DepositRevealedEvent{{ + chain := newRangeRevealChain(&DepositRevealedEvent{ + BlockNumber: happyPathCase.revealBlock, FundingTxHash: fundingTxHash, FundingOutputIndex: fundingOutputIndex, WalletPublicKeyHash: walletPublicKeyHash, Amount: 100000, Depositor: "0x0000000000000000000000000000000000000001", - }}, - ); err != nil { - t.Fatal(err) - } - chain.setDepositRequest(fundingTxHash, fundingOutputIndex, &DepositChainRequest{ - Amount: 100000, - RevealedAt: time.Now(), - }) - chain.setReservationAction(&ReservationAction{ - ActionType: ReservationActionTypeAcceptance, - State: ReservationActionStatePending, - TargetWalletPublicKeyHash: walletPublicKeyHash, - TxMaxFee: 2000, + }) + chain.setDepositRequest(fundingTxHash, fundingOutputIndex, &DepositChainRequest{ + Amount: 100000, + RevealedAt: time.Now().Add(-happyPathCase.revealAge), + }) + chain.setReservationAction(&ReservationAction{ + ActionType: ReservationActionTypeAcceptance, + State: ReservationActionStatePending, + TargetWalletPublicKeyHash: walletPublicKeyHash, + TxMaxFee: 2000, + }) + + var actionChain Chain = chain + if happyPathCase.blockTime != 0 { + actionChain = &timedRangeRevealChain{ + rangeRevealChain: chain, + blockTime: happyPathCase.blockTime, + } + } + action := newAction(actionChain, btcChain, fundingTxHash) + // Below reservationActionSigningTimeoutSafetyMarginBlocks (300): + // every real upstream step (event match, deposit request fetch, + // reservation key derivation, action load, target wallet match, + // on-chain validation, transaction assembly) must succeed before + // this guard is reached and rejects the proposal - reaching this + // exact error is the test's proof that all of it worked. + action.expiryBlock = 100 + + err = action.execute() + if err == nil || err.Error() != "invalid proposal expiry block" { + t.Errorf( + "unexpected error\nexpected: [invalid proposal expiry block]\nactual: [%v]", + err, + ) + } }) - - action := newAction(chain, btcChain, fundingTxHash) - // Below reservationActionSigningTimeoutSafetyMarginBlocks (300): - // every real upstream step (event match, deposit request fetch, - // reservation key derivation, action load, target wallet match, - // on-chain validation, transaction assembly) must succeed before - // this guard is reached and rejects the proposal - reaching this - // exact error is the test's proof that all of it worked. - action.expiryBlock = 100 - - err = action.execute() - if err == nil || err.Error() != "invalid proposal expiry block" { - t.Errorf( - "unexpected error\nexpected: [invalid proposal expiry block]\nactual: [%v]", - err, - ) - } - }) + } t.Run("target wallet mismatch is rejected before signing", func(t *testing.T) { - chain := Connect() btcChain := newLocalBitcoinChain() depositForScript := &Deposit{ @@ -924,21 +939,14 @@ func TestReservationAnchorAction_Execute(t *testing.T) { } fundingTxHash := fundingTx.Hash() - if err := chain.setPastDepositRevealedEvents( - &DepositRevealedEventFilter{ - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - StartBlock: 300000 - reservationLookBackBlocks, - }, - []*DepositRevealedEvent{{ - FundingTxHash: fundingTxHash, - FundingOutputIndex: fundingOutputIndex, - WalletPublicKeyHash: walletPublicKeyHash, - Amount: 100000, - Depositor: "0x0000000000000000000000000000000000000001", - }}, - ); err != nil { - t.Fatal(err) - } + chain := newRangeRevealChain(&DepositRevealedEvent{ + BlockNumber: 299990, + FundingTxHash: fundingTxHash, + FundingOutputIndex: fundingOutputIndex, + WalletPublicKeyHash: walletPublicKeyHash, + Amount: 100000, + Depositor: "0x0000000000000000000000000000000000000001", + }) chain.setDepositRequest(fundingTxHash, fundingOutputIndex, &DepositChainRequest{ Amount: 100000, RevealedAt: time.Now(), diff --git a/pkg/tbtcpg/chain.go b/pkg/tbtcpg/chain.go index 55002a8b04..da76376e73 100644 --- a/pkg/tbtcpg/chain.go +++ b/pkg/tbtcpg/chain.go @@ -187,22 +187,11 @@ type Chain interface { ) error // RequestReservationReanchor requests a reservation re-anchor action - // generation for the given reservation, targeting the given wallet. The - // returned bytes are the 32-byte hash of the submitted transaction; - // callers that submit the request track it across rounds via - // GetReservationReanchorRequestReceipt. + // generation for the given reservation, targeting the given wallet. RequestReservationReanchor( reservationKey *big.Int, targetWalletPublicKeyHash [20]byte, - ) ([32]byte, error) - - // GetReservationReanchorRequestReceipt reports the mining status of the - // RequestReservationReanchor transaction identified by txHash: mined - // successfully, mined but reverted, still pending in the mempool, or - // not observed. - GetReservationReanchorRequestReceipt( - txHash [32]byte, - ) (tbtc.ReservationReanchorRequestReceiptStatus, error) + ) error // NotifyMovingFundsBelowDust notifies the Bridge that the given wallet's // main UTXO has fallen below the moving funds dust threshold, ending @@ -251,23 +240,27 @@ type Chain interface { // reservations across all wallets and the cap on that count. ActiveReservationsCount() (count uint32, maxActive uint32, err error) - // ReservationVaultFeeDebtSat returns the ReservationVault's + // ReservationVaultFeeDebtSat returns the given ReservationVault's // outstanding in-kind fee debt in satoshi, read from the vault's - // inKindFeeDebtSat view. If the reservation vault address is + // inKindFeeDebtSat view. The caller passes the vault address it + // already read from ReservationParameters. If the address is // zero (vault not configured), it returns zero with a nil // error: the skip sentinel the metric side consumes, not an // error. - ReservationVaultFeeDebtSat() (uint64, error) + ReservationVaultFeeDebtSat(reservationVault chain.Address) (uint64, error) - // ReservationVaultFeeReserveTbtcBaseUnits returns the + // ReservationVaultFeeReserveTbtcBaseUnits returns the given // ReservationVault's TBTC fee-reserve balance in TBTC base // units: TBTC is a 1e18 base-unit token, so the returned value // is whole TBTC x 1e18. The value can exceed uint64 range, so - // it is returned as *big.Int. If the reservation vault address - // is zero (vault not configured), it returns zero with a nil - // error: the skip sentinel the metric side consumes, not an - // error. - ReservationVaultFeeReserveTbtcBaseUnits() (*big.Int, error) + // it is returned as *big.Int. The caller passes the vault + // address it already read from ReservationParameters. If the + // address is zero (vault not configured), it returns zero with + // a nil error: the skip sentinel the metric side consumes, not + // an error. + ReservationVaultFeeReserveTbtcBaseUnits( + reservationVault chain.Address, + ) (*big.Int, error) // IsReservedDeposit returns true if the given deposit was revealed // with the reservation vault address and is therefore a reservation diff --git a/pkg/tbtcpg/chain_test.go b/pkg/tbtcpg/chain_test.go index ee09562e3b..b9e2fa676b 100644 --- a/pkg/tbtcpg/chain_test.go +++ b/pkg/tbtcpg/chain_test.go @@ -21,6 +21,13 @@ import ( "github.com/keep-network/keep-core/pkg/tbtc" ) +// reservationDepositRefundSafetyMarginSeconds mirrors +// WalletProposalValidatorConstants.DEPOSIT_REFUND_SAFETY_MARGIN +// (24 hours): the on-chain acceptance validator refuses to sign an +// anchor whose refund becomes available less than a day from now, so a +// wallet signing later cannot race the depositor's refund. +const reservationDepositRefundSafetyMarginSeconds = 24 * 60 * 60 + type movingFundsCommitmentSubmission struct { WalletPublicKeyHash [20]byte WalletMainUtxo *bitcoin.UnspentTransactionOutput @@ -34,7 +41,13 @@ type movingFundsCommitmentSubmission struct { type reservationReanchorRequestSubmission struct { ReservationKey *big.Int TargetWalletPublicKeyHash [20]byte - TxHash [32]byte +} + +// reservationCapacity is a wallet's reservation count and amount, as in +// Reservation.sol's walletReservationInfo. +type reservationCapacity struct { + count uint32 + amount uint64 } // belowDustNotification captures a submitted NotifyMovingFundsBelowDust @@ -86,13 +99,17 @@ type LocalChain struct { reservationParametersSet bool reservationProposalValidations map[[32]byte]bool reservationReanchorRequestSubmissions []*reservationReanchorRequestSubmission - reservationReanchorRequestReceipts map[[32]byte]tbtc.ReservationReanchorRequestReceiptStatus - // revertNextReanchorRequest makes the next RequestReservationReanchor - // submission record a Reverted receipt and write no generation. - revertNextReanchorRequest bool + // reservationReanchorRequestAttempts records every + // RequestReservationReanchor call, including reverted ones. + reservationReanchorRequestAttempts []*reservationReanchorRequestSubmission + // reanchorReservedCapacity is the count and amount capacity pending + // re-anchor requests reserved on their target wallets. Together with + // the wallet's custodied reservations it forms the wallet's + // walletReservationInfo ledger entry (see walletReservationInfoLocked). + reanchorReservedCapacity map[[20]byte]reservationCapacity // pendingNextReanchorRequest makes the next RequestReservationReanchor - // submission record a Pending receipt and write no generation, - // modeling a submission still unconfirmed in the mempool. + // submission succeed without writing a generation, modeling a + // submission still unconfirmed in the mempool (or dropped from it). pendingNextReanchorRequest bool // reservationReanchorValidationCalls counts calls to // ValidateReservationReanchorProposal, so tests can observe whether @@ -137,7 +154,7 @@ func NewLocalChain() *LocalChain { reservationActions: make(map[string]*tbtc.ReservationAction), reservationProposalValidations: make(map[[32]byte]bool), reservationReanchorRequestSubmissions: make([]*reservationReanchorRequestSubmission, 0), - reservationReanchorRequestReceipts: make(map[[32]byte]tbtc.ReservationReanchorRequestReceiptStatus), + reanchorReservedCapacity: make(map[[20]byte]reservationCapacity), belowDustNotifications: make([]*belowDustNotification, 0), reservationWalletKeys: make(map[[20]byte][]*big.Int), reservedDeposits: make(map[string]bool), @@ -1406,8 +1423,10 @@ func (mbc *MockBlockCounter) WatchBlocks(ctx context.Context) <-chan uint64 { // would pass a lenient one: the wallet state, the pending acceptance action // record and its timeout margin, the deposit's reveal/age/sweep/reserved // state and vault routing, the anchor fee bounds against the generation's -// snapshotted max fee, the snapshotted minimum, the refund safety margin, -// and the deposit's controlling wallet. +// snapshotted max fee, the snapshotted minimum, the deposit extra info +// (funding transaction hash and deposit locking script, as +// validateDepositExtraInfo checks them), the refund safety margin, and +// the deposit's controlling wallet. func (lc *LocalChain) ValidateReservationAnchorProposal( walletPublicKeyHash [20]byte, proposal *tbtc.ReservationAnchorProposal, @@ -1469,7 +1488,9 @@ func (lc *LocalChain) ValidateReservationAnchorProposal( ) { return fmt.Errorf("deposit min age not achieved yet") } - if !depositRequest.SweptAt.IsZero() { + // The chain adapter reports an unswept deposit as the UNIX epoch + // (sweptAt == 0 on-chain); a zero time.Time is accepted as well. + if !depositRequest.SweptAt.IsZero() && depositRequest.SweptAt.Unix() != 0 { return fmt.Errorf("deposit already swept") } @@ -1511,6 +1532,48 @@ func (lc *LocalChain) ValidateReservationAnchorProposal( if deposit == nil { return fmt.Errorf("deposit extra info is required") } + + // Mirror validateDepositExtraInfo: the extra info's funding + // transaction must hash to the proposal's funding transaction hash, + // and its output at the proposal's index must lock funds with the + // deposit script rebuilt from the on-chain depositor and extra data + // plus the extra info's reveal fields, as P2SH or P2WSH. + fundingTx := depositExtraInfo.FundingTx + if fundingTx == nil || + fundingTx.Hash() != proposal.DepositFundingTxHash { + return fmt.Errorf("extra info funding tx hash does not match") + } + if int(proposal.DepositFundingOutputIndex) >= len(fundingTx.Outputs) { + return fmt.Errorf("extra info funding output script does not match") + } + depositScript, err := (&tbtc.Deposit{ + Depositor: depositRequest.Depositor, + ExtraData: depositRequest.ExtraData, + BlindingFactor: deposit.BlindingFactor, + WalletPublicKeyHash: deposit.WalletPublicKeyHash, + RefundPublicKeyHash: deposit.RefundPublicKeyHash, + RefundLocktime: deposit.RefundLocktime, + }).Script() + if err != nil { + return fmt.Errorf("cannot build deposit script: [%v]", err) + } + p2wsh, err := bitcoin.PayToWitnessScriptHash( + bitcoin.WitnessScriptHash(depositScript), + ) + if err != nil { + return err + } + p2sh, err := bitcoin.PayToScriptHash(bitcoin.ScriptHash(depositScript)) + if err != nil { + return err + } + fundingOutputScript := + fundingTx.Outputs[proposal.DepositFundingOutputIndex].PublicKeyScript + if !bytes.Equal(fundingOutputScript, p2wsh) && + !bytes.Equal(fundingOutputScript, p2sh) { + return fmt.Errorf("extra info funding output script does not match") + } + // The refund locktime is stored little-endian on-chain; reverse it // back to a UNIX timestamp and preserve the 24-hour refund safety // margin the on-chain validator enforces. @@ -1697,212 +1760,293 @@ func buildReservationReanchorProposalValidationKey( return sha256.Sum256(buffer.Bytes()), nil } -// RequestReservationReanchor records a submitted reservation re-anchor -// request for assertion in tests, and mirrors the on-chain effects of -// Reservation.sol's requestReservationReanchor: it requires the -// reservation to be Active (fails closed otherwise, matching "Reservation -// is not active"), then bumps its RequestNonce, flips its state to -// ActionPending, and writes the new generation as a Pending Reanchor -// action authorizing targetWalletPublicKeyHash. This makes the new -// request-then-wait-then-read flow in ProposeReservationReanchor -// observable in tests: reading the action before this call succeeds -// returns "not found", exactly like an on-chain read of an -// as-yet-unwritten generation would. +// RequestReservationReanchor mirrors Reservation.sol's +// requestReservationReanchor for a permissionless caller. It reverts, in +// Solidity's order and with Solidity's reasons, unless the reservation is +// Active and out of its re-anchor cooldown, the source wallet is +// MovingFunds or Closing, the target differs from the source and is Live, +// the action timeout leaves a signing window before the validator's safety +// margin, the anchor stays above ReservationTxMaxFee + ReservationMinAmount, +// and the target has count headroom (a zero MaxReservationsPerWallet blocks +// every request) and amount headroom (a zero amount cap disables that +// check). A request that passes is recorded as a submission; it then +// reserves the target's count and amount capacity, bumps the nonce, moves +// the reservation to ActionPending and writes exactly the action fields +// Solidity writes. Every call, reverted or not, is recorded as an attempt. // -// The fake models the submission as immediately mined, matching the -// production adapter's ForceMining fast path: it returns a deterministic -// transaction hash (derived from the submission contents plus the -// submission count) and registers the receipt as Mined, so the default -// GetReservationReanchorRequestReceipt answer for a recorded submission is -// Mined. Tests that need a different outcome (a reverted or still-pending -// receipt) override it via SetReservationReanchorRequestReceipt before -// driving the task. SetRevertNextReservationReanchorRequest makes the -// next submission succeed with a hash but record the receipt as Reverted -// instead of writing the on-chain generation, matching a request that -// reverts on-chain after submission. +// SetNextReservationReanchorRequestPending makes the next request that +// passes the checks succeed without writing anything, matching a +// transaction that has been sent but not mined. func (lc *LocalChain) RequestReservationReanchor( reservationKey *big.Int, targetWalletPublicKeyHash [20]byte, -) ([32]byte, error) { +) error { lc.mutex.Lock() defer lc.mutex.Unlock() + lc.reservationReanchorRequestAttempts = append( + lc.reservationReanchorRequestAttempts, + &reservationReanchorRequestSubmission{ + ReservationKey: new(big.Int).Set(reservationKey), + TargetWalletPublicKeyHash: targetWalletPublicKeyHash, + }, + ) + + if !lc.reservationParametersSet { + return fmt.Errorf("reservation parameters not set") + } + params := lc.reservationParametersValue + key := reservationKey.Text(16) existing, ok := lc.reservations[key] if !ok || existing == nil || existing.State != tbtc.ReservationStateActive { - return [32]byte{}, fmt.Errorf("reservation is not active") + return fmt.Errorf("Reservation is not active") } - submissionCount := len(lc.reservationReanchorRequestSubmissions) - txHash := lc.nextReservationReanchorRequestHash( - reservationKey, + now := uint32(time.Now().Unix()) + if now < existing.ReanchorCooldownUntil { + return fmt.Errorf("Reanchor cooldown in effect") + } + + // walletChainData is read directly: GetWallet takes lc.mutex, which + // this method already holds. + sourceState := tbtc.StateUnknown + if source, ok := lc.walletChainData[existing.WalletPublicKeyHash]; ok { + sourceState = source.State + } + if sourceState == tbtc.StateLive { + return fmt.Errorf("Only governance can rotate a Live wallet's anchor") + } + if sourceState != tbtc.StateMovingFunds && sourceState != tbtc.StateClosing { + return fmt.Errorf("Source wallet must be in MovingFunds or Closing state") + } + + if targetWalletPublicKeyHash == existing.WalletPublicKeyHash { + return fmt.Errorf("Target wallet must differ from the source wallet") + } + if target, ok := lc.walletChainData[targetWalletPublicKeyHash]; !ok || + target.State != tbtc.StateLive { + return fmt.Errorf("Target wallet must be in Live state") + } + + timeoutAt := now + params.ReservationActionTimeout + if !(uint64(timeoutAt) > reservationRequestTimeoutSafetyMarginSeconds && + uint64(now)+1 < uint64(timeoutAt)-reservationRequestTimeoutSafetyMarginSeconds) { + return fmt.Errorf("Reanchor authorization has no signing window") + } + + anchorAmount := uint64(0) + if existing.AnchorUtxo != nil { + anchorAmount = uint64(existing.AnchorUtxo.Value) + } + if anchorAmount <= params.ReservationTxMaxFee+params.ReservationMinAmount { + return fmt.Errorf( + "Reanchor would fall below the minimum reservation amount", + ) + } + + targetCount, targetAmount := lc.walletReservationInfoLocked( targetWalletPublicKeyHash, - submissionCount, ) + if targetCount+1 > params.MaxReservationsPerWallet { + return fmt.Errorf("Wallet reservations cap exceeded") + } + if maxAmount := lc.reservationCapsLocked()[0]; maxAmount != 0 && + targetAmount+anchorAmount > maxAmount { + return fmt.Errorf("Wallet reserved amount cap exceeded") + } lc.reservationReanchorRequestSubmissions = append( lc.reservationReanchorRequestSubmissions, &reservationReanchorRequestSubmission{ ReservationKey: new(big.Int).Set(reservationKey), TargetWalletPublicKeyHash: targetWalletPublicKeyHash, - TxHash: txHash, }, ) - if lc.revertNextReanchorRequest { - lc.revertNextReanchorRequest = false - lc.reservationReanchorRequestReceipts[txHash] = - tbtc.ReservationReanchorRequestReceiptReverted - return txHash, nil - } - if lc.pendingNextReanchorRequest { lc.pendingNextReanchorRequest = false - lc.reservationReanchorRequestReceipts[txHash] = - tbtc.ReservationReanchorRequestReceiptPending - return txHash, nil - } - - // Mirror the request-time count-capacity check Reservation.sol's - // requestReservationReanchor performs before reserving target - // capacity. Computing against lc.reservationWalletKeys directly - // rather than via WalletReservationsCount keeps everything under - // the same mutex and avoids a re-entrant lock deadlock. The check - // is only enforced when the live parameters have been set with a - // nonzero MaxReservationsPerWallet (which the on-chain setter - // itself requires > 0, so a zero value here only appears in tests - // that have not yet configured parameters). - if lc.reservationParametersSet && - lc.reservationParametersValue.MaxReservationsPerWallet > 0 { - currentTargetCount := uint32( - len(lc.reservationWalletKeys[targetWalletPublicKeyHash]), - ) - if currentTargetCount+1 > - lc.reservationParametersValue.MaxReservationsPerWallet { - return [32]byte{}, fmt.Errorf("wallet reservations cap exceeded") - } + return nil } - // Mirror the request-time amount-capacity check Reservation.sol's - // requestReservationReanchor performs on the target: the target's - // already-reserved amount plus this anchor's value must stay at or - // below the configured per-wallet amount cap (a zero cap disables the - // check, matching Solidity's `maxReservationsAmountPerWallet == 0` - // convention). The reserved amount is computed against - // lc.reservationWalletKeys directly, like the count check above, so - // everything stays under the same mutex and sees the same state the - // headroom pre-check's WalletReservationsAmount read observed. - { - maxAmount := lc.reservationCapsLocked()[0] - if maxAmount > 0 { - targetReservedAmount := uint64(0) - for _, reservationKey := range lc.reservationWalletKeys[targetWalletPublicKeyHash] { - if reservation, ok := lc.reservations[reservationKey.Text(16)]; ok && reservation != nil && reservation.AnchorUtxo != nil { - targetReservedAmount += uint64(reservation.AnchorUtxo.Value) - } - } - anchorValue := uint64(0) - if existing.AnchorUtxo != nil { - anchorValue = uint64(existing.AnchorUtxo.Value) - } - if targetReservedAmount+anchorValue > maxAmount { - return [32]byte{}, fmt.Errorf("wallet reserved amount cap exceeded") - } - } - } + reserved := lc.reanchorReservedCapacity[targetWalletPublicKeyHash] + reserved.count++ + reserved.amount += anchorAmount + lc.reanchorReservedCapacity[targetWalletPublicKeyHash] = reserved updated := *existing updated.RequestNonce++ updated.State = tbtc.ReservationStateActionPending lc.reservations[key] = &updated - txMaxFee := lc.reservationParametersValue.ReservationTxMaxFee actionKey := buildReservationActionKey(reservationKey, updated.RequestNonce) lc.reservationActions[actionKey] = &tbtc.ReservationAction{ ActionType: tbtc.ReservationActionTypeReanchor, State: tbtc.ReservationActionStatePending, + RequestedAt: now, + TimeoutAt: timeoutAt, + TxMaxFee: params.ReservationTxMaxFee, TargetWalletPublicKeyHash: targetWalletPublicKeyHash, - TxMaxFee: txMaxFee, - MinAmount: lc.reservationParametersValue.ReservationMinAmount, - // A fresh generation's timeout sits far enough ahead that the - // validator's REQUEST_TIMEOUT_SAFETY_MARGIN gate (now plus - // two hours) cannot reject it; tests that exercise the gate - // seed their own actions with nearer TimeoutAt values. - TimeoutAt: uint32(time.Now().Add(24 * time.Hour).Unix()), - TermSeconds: lc.reservationParametersValue.ReservationTermSeconds, + Amount: anchorAmount, + SourceAnchorUtxoHash: reservationAnchorUtxoHash(existing), } - lc.reservationReanchorRequestReceipts[txHash] = - tbtc.ReservationReanchorRequestReceiptMined - - return txHash, nil + return nil } -// nextReservationReanchorRequestHash derives the fake's deterministic -// transaction hash for a re-anchor submission: the keccak-256 of the -// reservation key, the target wallet hash, and the submission count. -// Callers hold lc.mutex. -func (lc *LocalChain) nextReservationReanchorRequestHash( - reservationKey *big.Int, - targetWalletPublicKeyHash [20]byte, - submissionCount int, -) [32]byte { - var buffer bytes.Buffer - buffer.Write(reservationKey.Bytes()) - buffer.Write(targetWalletPublicKeyHash[:]) - for i := 0; i < 4; i++ { - buffer.Write([]byte{byte(uint32(submissionCount) >> (8 * i))}) +// TimeOutReservationReanchor mirrors Reservation.sol's +// notifyReservationActionTimeout for the reservation's current Pending +// Reanchor generation, as if called now: the action becomes TimedOut, the +// target's reserved capacity is released, the reservation returns to +// Active, and its re-anchor cooldown runs for the action's own duration. +// The timeoutAt check is skipped so tests need not wait for it. +func (lc *LocalChain) TimeOutReservationReanchor(reservationKey *big.Int) error { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + reservation, action, err := lc.pendingReanchorLocked(reservationKey) + if err != nil { + return err } - var hash [32]byte - copy(hash[:], crypto.Keccak256(buffer.Bytes())) - return hash + + timedOut := *action + timedOut.State = tbtc.ReservationActionStateTimedOut + lc.reservationActions[buildReservationActionKey( + reservationKey, + reservation.RequestNonce, + )] = &timedOut + + lc.releaseReanchorReservedCapacityLocked(action) + + updated := *reservation + updated.State = tbtc.ReservationStateActive + updated.ReanchorCooldownUntil = uint32(time.Now().Unix()) + + (action.TimeoutAt - action.RequestedAt) + lc.reservations[reservationKey.Text(16)] = &updated + + return nil } -// GetReservationReanchorRequestReceipt returns the recorded receipt status -// of a RequestReservationReanchor submission: a hash recorded via the -// submission itself (Mined, or Reverted when a revert was forced) or via -// SetReservationReanchorRequestPending reports its recorded status, and a -// hash never seen by the fake reports NotFound. -func (lc *LocalChain) GetReservationReanchorRequestReceipt( - txHash [32]byte, -) (tbtc.ReservationReanchorRequestReceiptStatus, error) { +// SettleReservationReanchor mirrors the accounting of a proven re-anchor +// (ReservationProofs.sol's settleReanchorAccounting) for the reservation's +// current Pending Reanchor generation: the action becomes Settled, custody +// and the reservation key move from the source wallet to the target, the +// anchor becomes newAnchorUtxo, and the reservation returns to Active. The +// capacity reserved on the target at request time becomes the target's +// custodied reservation, so the target keeps count + 1 and its amount +// drops by the miner fee, while the source's count and amount are +// released. +func (lc *LocalChain) SettleReservationReanchor( + reservationKey *big.Int, + newAnchorUtxo *bitcoin.UnspentTransactionOutput, +) error { lc.mutex.Lock() defer lc.mutex.Unlock() - if status, ok := lc.reservationReanchorRequestReceipts[txHash]; ok { - return status, nil + reservation, action, err := lc.pendingReanchorLocked(reservationKey) + if err != nil { + return err } - return tbtc.ReservationReanchorRequestReceiptNotFound, nil + + settled := *action + settled.State = tbtc.ReservationActionStateSettled + lc.reservationActions[buildReservationActionKey( + reservationKey, + reservation.RequestNonce, + )] = &settled + + lc.releaseReanchorReservedCapacityLocked(action) + + source := reservation.WalletPublicKeyHash + remaining := make([]*big.Int, 0, len(lc.reservationWalletKeys[source])) + for _, k := range lc.reservationWalletKeys[source] { + if k.Cmp(reservationKey) != 0 { + remaining = append(remaining, k) + } + } + lc.reservationWalletKeys[source] = remaining + lc.reservationWalletKeys[action.TargetWalletPublicKeyHash] = append( + lc.reservationWalletKeys[action.TargetWalletPublicKeyHash], + new(big.Int).Set(reservationKey), + ) + + updated := *reservation + updated.State = tbtc.ReservationStateActive + updated.WalletPublicKeyHash = action.TargetWalletPublicKeyHash + updated.AnchorUtxo = newAnchorUtxo + lc.reservations[reservationKey.Text(16)] = &updated + + return nil } -// SetReservationReanchorRequestReceipt overrides the receipt status the -// fake reports for the given submission hash (e.g. to model a request -// that is still pending in the mempool). -func (lc *LocalChain) SetReservationReanchorRequestReceipt( - txHash [32]byte, - status tbtc.ReservationReanchorRequestReceiptStatus, -) { - lc.mutex.Lock() - defer lc.mutex.Unlock() +// pendingReanchorLocked returns the reservation and its current action, +// failing unless the reservation is ActionPending and the action is a +// Pending Reanchor. Callers hold lc.mutex. +func (lc *LocalChain) pendingReanchorLocked( + reservationKey *big.Int, +) (*tbtc.Reservation, *tbtc.ReservationAction, error) { + reservation, ok := lc.reservations[reservationKey.Text(16)] + if !ok || reservation.State != tbtc.ReservationStateActionPending { + return nil, nil, fmt.Errorf("Reservation is not in ActionPending state") + } + action, ok := lc.reservationActions[buildReservationActionKey( + reservationKey, + reservation.RequestNonce, + )] + if !ok || + action.ActionType != tbtc.ReservationActionTypeReanchor || + action.State != tbtc.ReservationActionStatePending { + return nil, nil, fmt.Errorf("Action is not a pending re-anchor") + } + return reservation, action, nil +} - lc.reservationReanchorRequestReceipts[txHash] = status +// releaseReanchorReservedCapacityLocked releases the target capacity a +// re-anchor request reserved. Callers hold lc.mutex. +func (lc *LocalChain) releaseReanchorReservedCapacityLocked( + action *tbtc.ReservationAction, +) { + reserved := lc.reanchorReservedCapacity[action.TargetWalletPublicKeyHash] + reserved.count-- + reserved.amount -= action.Amount + lc.reanchorReservedCapacity[action.TargetWalletPublicKeyHash] = reserved +} + +// reservationAnchorUtxoHash mirrors Reservation.sol's anchorUtxoHash: +// keccak256(anchorTxHash || uint32 anchorTxOutputIndex). +func reservationAnchorUtxoHash(reservation *tbtc.Reservation) [32]byte { + if reservation.AnchorUtxo == nil || reservation.AnchorUtxo.Outpoint == nil { + return [32]byte{} + } + outpoint := reservation.AnchorUtxo.Outpoint + packed := make([]byte, 36) + copy(packed, outpoint.TransactionHash[:]) + binary.BigEndian.PutUint32(packed[32:], outpoint.OutputIndex) + var hash [32]byte + copy(hash[:], crypto.Keccak256(packed)) + return hash } -// SetRevertNextReservationReanchorRequest makes the next -// RequestReservationReanchor submission succeed (returning its hash) -// while recording its receipt as Reverted and writing no on-chain -// generation. -func (lc *LocalChain) SetRevertNextReservationReanchorRequest() { +// GetReservationReanchorRequestAttempts returns every +// RequestReservationReanchor call, including ones the fake reverted. +func (lc *LocalChain) GetReservationReanchorRequestAttempts() []*reservationReanchorRequestSubmission { lc.mutex.Lock() defer lc.mutex.Unlock() - lc.revertNextReanchorRequest = true + attempts := make([]*reservationReanchorRequestSubmission, len(lc.reservationReanchorRequestAttempts)) + for i, a := range lc.reservationReanchorRequestAttempts { + attempts[i] = &reservationReanchorRequestSubmission{ + ReservationKey: new(big.Int).Set(a.ReservationKey), + TargetWalletPublicKeyHash: a.TargetWalletPublicKeyHash, + } + } + return attempts } // SetNextReservationReanchorRequestPending makes the next -// RequestReservationReanchor submission succeed (returning its hash) -// while recording its receipt as Pending and writing no on-chain -// generation, matching a request still unconfirmed in the mempool. +// RequestReservationReanchor submission that passes the checks succeed +// while writing nothing, matching a request still unconfirmed in the +// mempool. func (lc *LocalChain) SetNextReservationReanchorRequestPending() { lc.mutex.Lock() defer lc.mutex.Unlock() @@ -2081,31 +2225,49 @@ func (lc *LocalChain) SetReservationCaps( lc.reservationCapsSet = true } -// WalletReservationsAmount returns the sum of anchor values for the -// wallet's reservations. +// WalletReservationsAmount returns the wallet's reserved amount: the +// anchor values of its custodied reservations plus the amount pending +// re-anchor requests reserved on it. func (lc *LocalChain) WalletReservationsAmount( walletPublicKeyHash [20]byte, ) (uint64, error) { lc.mutex.Lock() defer lc.mutex.Unlock() - var total uint64 - for _, reservationKey := range lc.reservationWalletKeys[walletPublicKeyHash] { - if r, ok := lc.reservations[reservationKey.Text(16)]; ok && r != nil && r.AnchorUtxo != nil { - total += uint64(r.AnchorUtxo.Value) - } - } - return total, nil + _, amount := lc.walletReservationInfoLocked(walletPublicKeyHash) + return amount, nil } -// WalletReservationsCount returns the count of reservations for the wallet. +// WalletReservationsCount returns the wallet's reservation count: its +// custodied reservations plus the pending re-anchor requests targeting it. func (lc *LocalChain) WalletReservationsCount( walletPublicKeyHash [20]byte, ) (uint32, error) { lc.mutex.Lock() defer lc.mutex.Unlock() - return uint32(len(lc.reservationWalletKeys[walletPublicKeyHash])), nil + count, _ := lc.walletReservationInfoLocked(walletPublicKeyHash) + return count, nil +} + +// walletReservationInfoLocked returns the wallet's walletReservationInfo +// ledger entry. Reservation.sol counts a reservation against the wallet +// custodying it and, while a re-anchor request is pending, also against +// the request's target; the fake derives the first part from the wallet's +// reservation keys and tracks the second in reanchorReservedCapacity. +// Callers hold lc.mutex. +func (lc *LocalChain) walletReservationInfoLocked( + walletPublicKeyHash [20]byte, +) (uint32, uint64) { + reserved := lc.reanchorReservedCapacity[walletPublicKeyHash] + count := uint32(len(lc.reservationWalletKeys[walletPublicKeyHash])) + reserved.count + amount := reserved.amount + for _, reservationKey := range lc.reservationWalletKeys[walletPublicKeyHash] { + if r, ok := lc.reservations[reservationKey.Text(16)]; ok && r != nil && r.AnchorUtxo != nil { + amount += uint64(r.AnchorUtxo.Value) + } + } + return count, amount } // WalletReservations returns the configured reservation keys for the wallet. @@ -2202,7 +2364,9 @@ func (lc *LocalChain) SetReservationVaultFeeReserveBalance( // ReservationVaultFeeDebtSat returns the value configured via // SetReservationVaultFeeDebtSat; zero by default. -func (lc *LocalChain) ReservationVaultFeeDebtSat() (uint64, error) { +func (lc *LocalChain) ReservationVaultFeeDebtSat( + reservationVault chain.Address, +) (uint64, error) { lc.mutex.Lock() defer lc.mutex.Unlock() @@ -2211,7 +2375,9 @@ func (lc *LocalChain) ReservationVaultFeeDebtSat() (uint64, error) { // ReservationVaultFeeReserveTbtcBaseUnits returns the value configured // via SetReservationVaultFeeReserveBalance; zero by default. -func (lc *LocalChain) ReservationVaultFeeReserveTbtcBaseUnits() (*big.Int, error) { +func (lc *LocalChain) ReservationVaultFeeReserveTbtcBaseUnits( + reservationVault chain.Address, +) (*big.Int, error) { lc.mutex.Lock() defer lc.mutex.Unlock() @@ -2274,7 +2440,6 @@ func (lc *LocalChain) GetReservationReanchorRequestSubmissions() []*reservationR copy[i] = &reservationReanchorRequestSubmission{ ReservationKey: new(big.Int).Set(s.ReservationKey), TargetWalletPublicKeyHash: s.TargetWalletPublicKeyHash, - TxHash: s.TxHash, } } return copy diff --git a/pkg/tbtcpg/internal/test/reservation_acceptance.go b/pkg/tbtcpg/internal/test/reservation_acceptance.go index 2882b8e81e..26c12cb1e2 100644 --- a/pkg/tbtcpg/internal/test/reservation_acceptance.go +++ b/pkg/tbtcpg/internal/test/reservation_acceptance.go @@ -39,15 +39,14 @@ type ReservedDepositScenario struct { // PendingAcceptanceAction, when non-nil, instructs the test driver to // seed the deposit's current generation as a Pending Acceptance action - // record targeting WalletPublicKeyHash. The acceptance task only - // proposes against deposits whose current generation is exactly that - // record (mirroring WalletProposalValidator.sol's - // validateReservationAnchorProposal precondition), so scenarios that - // expect a proposal must seed one here with the live parameters' - // snapshots as they stood at the depositor's request time. Scenarios - // that expect rejection at any earlier cap gate (wallet state, per-wallet - // count, per-wallet amount, single-amount, global total, per-deposit - // minimum) leave this nil and reject before any action lookup happens. + // record targeting TargetWalletPublicKeyHash, together with the + // ReservationAcceptanceRequested event the depositor's request emits. + // The acceptance task only discovers deposits through that event and + // only proposes against deposits whose current generation is exactly + // that record (mirroring WalletProposalValidator.sol's + // validateReservationAnchorProposal precondition), so every scenario + // that should reach a signer-time rule must seed one here, with the + // parameter snapshots as they stood at the depositor's request time. PendingAcceptanceAction *PendingAcceptanceActionScenario parsedFundingTxHash bitcoin.Hash @@ -191,6 +190,7 @@ func (rats *ReservationAcceptanceTestScenario) UnmarshalJSON( MinAmount uint64 TermSeconds uint32 TargetWalletPublicKeyHash string + TimeoutAt uint32 } type reservedDepositScenarioJSON struct { @@ -316,8 +316,6 @@ func (rats *ReservationAcceptanceTestScenario) UnmarshalJSON( rats.PendingReservedDeposits = unmarshaled.PendingReservedDeposits - now := time.Now() - rats.ReservedDeposits = make([]*ReservedDepositScenario, 0) for _, rd := range unmarshaled.ReservedDeposits { fundingTxHash, err := bitcoin.NewHashFromString( @@ -344,6 +342,7 @@ func (rats *ReservationAcceptanceTestScenario) UnmarshalJSON( MinAmount: rd.PendingAcceptanceAction.MinAmount, TermSeconds: rd.PendingAcceptanceAction.TermSeconds, TargetWalletPublicKeyHash: rd.PendingAcceptanceAction.TargetWalletPublicKeyHash, + TimeoutAt: rd.PendingAcceptanceAction.TimeoutAt, } } @@ -377,17 +376,25 @@ func (rats *ReservationAcceptanceTestScenario) UnmarshalJSON( rats.ExpectedErr = errors.New(unmarshaled.ExpectedErr) } - _ = now return nil } // ReservedDeposit is the materialized form of a reserved deposit scenario, // populated by the test driver once the chain state is set up. type ReservedDeposit struct { + // FundingTxHash is the hash of FundingTx, the deposit's real funding + // transaction. LabelFundingTxHash is the scenario's FundingTxHash, + // which only labels the deposit when the scenario gives no + // FundingTxHex; ExpectedAnchorProposal refers to deposits by label. FundingTxHash bitcoin.Hash + LabelFundingTxHash bitcoin.Hash FundingOutputIndex uint32 FundingTx *bitcoin.Transaction WalletPublicKeyHash [20]byte + Depositor chain.Address + BlindingFactor [8]byte + RefundPublicKeyHash [20]byte + RefundLocktime [4]byte RevealBlock uint64 RevealedAt time.Time SweptAt time.Time @@ -396,7 +403,11 @@ type ReservedDeposit struct { } // Materialize converts a scenario row into a fully-typed ReservedDeposit -// the test driver can wire into the local chain. +// the test driver can wire into the local chain. When the scenario gives +// no FundingTxHex, a funding transaction whose output at +// FundingOutputIndex locks Amount with the deposit's P2WSH script is +// built from the row's reveal fields, so the deposit passes the strict +// validator's extra-info checks. func (rds *ReservedDepositScenario) Materialize() (*ReservedDeposit, error) { if rds == nil { return nil, fmt.Errorf("nil scenario deposit") @@ -418,6 +429,58 @@ func (rds *ReservedDepositScenario) Materialize() (*ReservedDeposit, error) { vault = &addr } + var blindingFactor [8]byte + copy(blindingFactor[:], hexToSlice(rds.BlindingFactor)) + var refundPublicKeyHash [20]byte + copy(refundPublicKeyHash[:], hexToSlice(rds.RefundPublicKeyHash)) + var refundLocktime [4]byte + copy(refundLocktime[:], hexToSlice(rds.RefundLocktime)) + + fundingTx := rds.parsedFundingTx + if fundingTx == nil { + depositScript, err := (&tbtc.Deposit{ + Depositor: chain.Address(rds.Depositor), + BlindingFactor: blindingFactor, + WalletPublicKeyHash: walletHash, + RefundPublicKeyHash: refundPublicKeyHash, + RefundLocktime: refundLocktime, + }).Script() + if err != nil { + return nil, fmt.Errorf("cannot build deposit script: [%w]", err) + } + depositLockingScript, err := bitcoin.PayToWitnessScriptHash( + bitcoin.WitnessScriptHash(depositScript), + ) + if err != nil { + return nil, err + } + + outputs := make([]*bitcoin.TransactionOutput, rds.FundingOutputIndex+1) + for i := range outputs { + outputs[i] = &bitcoin.TransactionOutput{ + PublicKeyScript: append([]byte{0x00, 0x14}, make([]byte, 20)...), + } + } + outputs[rds.FundingOutputIndex] = &bitcoin.TransactionOutput{ + Value: int64(rds.Amount), + PublicKeyScript: depositLockingScript, + } + + fundingTx = &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + // The label makes every scenario deposit's funding + // transaction, and so its hash, distinct. + TransactionHash: rds.parsedFundingTxHash, + OutputIndex: 0, + }, + Sequence: 0xffffffff, + }}, + Outputs: outputs, + } + } + age := time.Duration(rds.Age) * time.Second revealedAt := time.Now().Add(-age) @@ -445,14 +508,20 @@ func (rds *ReservedDepositScenario) Materialize() (*ReservedDeposit, error) { MinAmount: rds.PendingAcceptanceAction.MinAmount, TermSeconds: rds.PendingAcceptanceAction.TermSeconds, TimeoutAt: timeoutAt, + Amount: rds.Amount, } } return &ReservedDeposit{ - FundingTxHash: rds.parsedFundingTxHash, + FundingTxHash: fundingTx.Hash(), + LabelFundingTxHash: rds.parsedFundingTxHash, FundingOutputIndex: rds.FundingOutputIndex, - FundingTx: rds.parsedFundingTx, + FundingTx: fundingTx, WalletPublicKeyHash: walletHash, + Depositor: chain.Address(rds.Depositor), + BlindingFactor: blindingFactor, + RefundPublicKeyHash: refundPublicKeyHash, + RefundLocktime: refundLocktime, RevealBlock: rds.RevealBlock, RevealedAt: revealedAt, SweptAt: time.Unix(rds.SweptAt, 0), diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_0.json b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_0.json index f977855386..c180bd5887 100644 --- a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_0.json +++ b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_0.json @@ -3,7 +3,7 @@ "ChainParameters": { "AverageBlockTime": 12, "CurrentBlock": 300000, - "DepositMinAge": 3600 + "DepositMinAge": 7200 }, "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", "ReservationVault": "0xReservationVaultAddress1234567890abcdef12345678", @@ -39,17 +39,17 @@ "Depositor": "934b98637ca318a4d6e7ca6ffd1690b8e77df637", "BlindingFactor": "f9f0c90d00039523", "RefundPublicKeyHash": "e257eccafbc07c381642ce6e7e55120fb077fbed", - "RefundLocktime": "e0250162", + "RefundLocktime": "80d8db70", "Amount": 2000000, "RevealBlock": 290000, - "Age": 7200, + "Age": 10800, "SweptAt": 0, "Vault": "0xReservationVaultAddress1234567890abcdef12345678", "PendingAcceptanceAction": { "RequestNonce": 1, "TxMaxFee": 5000, "MinAmount": 100000, - "TermSeconds": 86400, + "TermSeconds": 7776000, "TargetWalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6" } } diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_1.json b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_1.json index 464de89f3a..eb68d5bf17 100644 --- a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_1.json +++ b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_1.json @@ -1,9 +1,9 @@ { - "Title": "cap rejection - wallet already at max active reservations count", + "Title": "boundary - deposit amount exactly the snapshotted minimum plus max fee is accepted", "ChainParameters": { "AverageBlockTime": 12, "CurrentBlock": 300000, - "DepositMinAge": 3600 + "DepositMinAge": 7200 }, "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", "ReservationVault": "0xReservationVaultAddress1234567890abcdef12345678", @@ -11,7 +11,7 @@ "State": "Live" }, "ReservationParameters": { - "ReservationMinAmount": 100000, + "ReservationMinAmount": 1000000, "ReservationTxMaxFee": 5000, "ReservationMaxTotalAmount": 100000000, "ReservationTotalAmount": 0, @@ -22,31 +22,43 @@ "ReservationMaxSingleAmount": 5000000 }, "WalletCustody": { - "Count": 1, - "Amount": 2000000 + "Count": 0, + "Amount": 0 }, "Global": { - "ActiveCount": 100, + "ActiveCount": 0, "MaxActive": 100 }, "PendingReservedDeposits": 0, "ReservedDeposits": [ { - "FundingTxHash": "b1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f00", + "FundingTxHash": "c1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f02", "FundingOutputIndex": 0, "FundingTxConfirmations": 6, "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", "Depositor": "934b98637ca318a4d6e7ca6ffd1690b8e77df637", - "BlindingFactor": "f9f0c90d00039524", + "BlindingFactor": "f9f0c90d00039523", "RefundPublicKeyHash": "e257eccafbc07c381642ce6e7e55120fb077fbed", - "RefundLocktime": "e0250162", - "Amount": 2000000, + "RefundLocktime": "80d8db70", + "Amount": 1005000, "RevealBlock": 290000, - "Age": 7200, + "Age": 10800, "SweptAt": 0, - "Vault": "0xReservationVaultAddress1234567890abcdef12345678" + "Vault": "0xReservationVaultAddress1234567890abcdef12345678", + "PendingAcceptanceAction": { + "RequestNonce": 1, + "TxMaxFee": 5000, + "MinAmount": 1000000, + "TermSeconds": 7776000, + "TargetWalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6" + } } ], - "ExpectedAnchorProposal": null, + "ExpectedAnchorProposal": { + "DepositFundingTxHash": "c1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f02", + "DepositFundingOutputIndex": 0, + "RequestNonce": 1, + "AnchorTxFee": 710 + }, "ExpectedErr": "" } diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_2.json b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_2.json index 416d7714f0..5b21bfa785 100644 --- a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_2.json +++ b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_2.json @@ -1,17 +1,17 @@ { - "Title": "below-min rejection - deposit amount below ReservationMinAmount", + "Title": "wallet-state rejection - a Closing wallet does not consume its pending acceptance", "ChainParameters": { "AverageBlockTime": 12, "CurrentBlock": 300000, - "DepositMinAge": 3600 + "DepositMinAge": 7200 }, "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", "ReservationVault": "0xReservationVaultAddress1234567890abcdef12345678", "Wallet": { - "State": "Live" + "State": "Closing" }, "ReservationParameters": { - "ReservationMinAmount": 1000000, + "ReservationMinAmount": 100000, "ReservationTxMaxFee": 5000, "ReservationMaxTotalAmount": 100000000, "ReservationTotalAmount": 0, @@ -32,24 +32,24 @@ "PendingReservedDeposits": 0, "ReservedDeposits": [ { - "FundingTxHash": "c1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f02", + "FundingTxHash": "d1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f03", "FundingOutputIndex": 0, "FundingTxConfirmations": 6, "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", "Depositor": "934b98637ca318a4d6e7ca6ffd1690b8e77df637", "BlindingFactor": "f9f0c90d00039523", "RefundPublicKeyHash": "e257eccafbc07c381642ce6e7e55120fb077fbed", - "RefundLocktime": "e0250162", - "Amount": 500000, + "RefundLocktime": "80d8db70", + "Amount": 2000000, "RevealBlock": 290000, - "Age": 7200, + "Age": 10800, "SweptAt": 0, "Vault": "0xReservationVaultAddress1234567890abcdef12345678", "PendingAcceptanceAction": { "RequestNonce": 1, "TxMaxFee": 5000, - "MinAmount": 1000000, - "TermSeconds": 86400, + "MinAmount": 100000, + "TermSeconds": 7776000, "TargetWalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6" } } diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_3.json b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_3.json index 6da58123e9..ca2ebe4dec 100644 --- a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_3.json +++ b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_3.json @@ -1,14 +1,14 @@ { - "Title": "stale-deposit rejection - wallet state is Closing, not Live", + "Title": "scan continues past an earlier candidate with too few funding confirmations to a later eligible one", "ChainParameters": { "AverageBlockTime": 12, "CurrentBlock": 300000, - "DepositMinAge": 3600 + "DepositMinAge": 7200 }, "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", "ReservationVault": "0xReservationVaultAddress1234567890abcdef12345678", "Wallet": { - "State": "Closing" + "State": "Live" }, "ReservationParameters": { "ReservationMinAmount": 100000, @@ -32,21 +32,55 @@ "PendingReservedDeposits": 0, "ReservedDeposits": [ { - "FundingTxHash": "d1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f00", + "FundingTxHash": "c1c2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f01", "FundingOutputIndex": 0, - "FundingTxConfirmations": 6, + "FundingTxConfirmations": 1, "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", "Depositor": "934b98637ca318a4d6e7ca6ffd1690b8e77df637", - "BlindingFactor": "f9f0c90d00039526", + "BlindingFactor": "f9f0c90d00039523", "RefundPublicKeyHash": "e257eccafbc07c381642ce6e7e55120fb077fbed", - "RefundLocktime": "e0250162", + "RefundLocktime": "80d8db70", "Amount": 2000000, "RevealBlock": 290000, - "Age": 7200, + "Age": 10800, + "SweptAt": 0, + "Vault": "0xReservationVaultAddress1234567890abcdef12345678", + "PendingAcceptanceAction": { + "RequestNonce": 1, + "TxMaxFee": 5000, + "MinAmount": 100000, + "TermSeconds": 7776000, + "TargetWalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6" + } + }, + { + "FundingTxHash": "c2c2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f02", + "FundingOutputIndex": 0, + "FundingTxConfirmations": 6, + "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", + "Depositor": "934b98637ca318a4d6e7ca6ffd1690b8e77df637", + "BlindingFactor": "a9f0c90d00039524", + "RefundPublicKeyHash": "e257eccafbc07c381642ce6e7e55120fb077fbed", + "RefundLocktime": "80d8db70", + "Amount": 2000000, + "RevealBlock": 290001, + "Age": 10800, "SweptAt": 0, - "Vault": "0xReservationVaultAddress1234567890abcdef12345678" + "Vault": "0xReservationVaultAddress1234567890abcdef12345678", + "PendingAcceptanceAction": { + "RequestNonce": 1, + "TxMaxFee": 5000, + "MinAmount": 100000, + "TermSeconds": 7776000, + "TargetWalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6" + } } ], - "ExpectedAnchorProposal": null, + "ExpectedAnchorProposal": { + "DepositFundingTxHash": "c2c2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f02", + "DepositFundingOutputIndex": 0, + "RequestNonce": 1, + "AnchorTxFee": 710 + }, "ExpectedErr": "" } diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_4.json b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_4.json index 53d9a51bc9..29db881ac6 100644 --- a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_4.json +++ b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_4.json @@ -1,29 +1,29 @@ { - "Title": "cap rejection - wallet already at max reservations per wallet", + "Title": "moving funds wallet - a MovingFunds wallet consumes its pending acceptance at the generation's nonce", "ChainParameters": { "AverageBlockTime": 12, "CurrentBlock": 300000, - "DepositMinAge": 3600 + "DepositMinAge": 7200 }, "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", "ReservationVault": "0xReservationVaultAddress1234567890abcdef12345678", "Wallet": { - "State": "Live" + "State": "MovingFunds" }, "ReservationParameters": { "ReservationMinAmount": 100000, "ReservationTxMaxFee": 5000, "ReservationMaxTotalAmount": 100000000, "ReservationTotalAmount": 0, - "MaxReservationsPerWallet": 3 + "MaxReservationsPerWallet": 5 }, "Caps": { "MaxReservationsAmountPerWallet": 50000000, "ReservationMaxSingleAmount": 5000000 }, "WalletCustody": { - "Count": 3, - "Amount": 2000000 + "Count": 0, + "Amount": 0 }, "Global": { "ActiveCount": 0, @@ -32,21 +32,33 @@ "PendingReservedDeposits": 0, "ReservedDeposits": [ { - "FundingTxHash": "e1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f00", + "FundingTxHash": "e1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f04", "FundingOutputIndex": 0, "FundingTxConfirmations": 6, "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", "Depositor": "934b98637ca318a4d6e7ca6ffd1690b8e77df637", - "BlindingFactor": "f9f0c90d00039524", + "BlindingFactor": "f9f0c90d00039523", "RefundPublicKeyHash": "e257eccafbc07c381642ce6e7e55120fb077fbed", - "RefundLocktime": "e0250162", + "RefundLocktime": "80d8db70", "Amount": 2000000, "RevealBlock": 290000, - "Age": 7200, + "Age": 10800, "SweptAt": 0, - "Vault": "0xReservationVaultAddress1234567890abcdef12345678" + "Vault": "0xReservationVaultAddress1234567890abcdef12345678", + "PendingAcceptanceAction": { + "RequestNonce": 2, + "TxMaxFee": 5000, + "MinAmount": 100000, + "TermSeconds": 7776000, + "TargetWalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6" + } } ], - "ExpectedAnchorProposal": null, + "ExpectedAnchorProposal": { + "DepositFundingTxHash": "e1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f04", + "DepositFundingOutputIndex": 0, + "RequestNonce": 2, + "AnchorTxFee": 710 + }, "ExpectedErr": "" } diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_5.json b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_5.json deleted file mode 100644 index 3f628af58b..0000000000 --- a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_5.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "Title": "cap rejection - deposit amount exceeds ReservationMaxSingleAmount", - "ChainParameters": { - "AverageBlockTime": 12, - "CurrentBlock": 300000, - "DepositMinAge": 3600 - }, - "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", - "ReservationVault": "0xReservationVaultAddress1234567890abcdef12345678", - "Wallet": { - "State": "Live" - }, - "ReservationParameters": { - "ReservationMinAmount": 100000, - "ReservationTxMaxFee": 5000, - "ReservationMaxTotalAmount": 100000000, - "ReservationTotalAmount": 0, - "MaxReservationsPerWallet": 5 - }, - "Caps": { - "MaxReservationsAmountPerWallet": 50000000, - "ReservationMaxSingleAmount": 1000000 - }, - "WalletCustody": { - "Count": 1, - "Amount": 0 - }, - "Global": { - "ActiveCount": 0, - "MaxActive": 100 - }, - "PendingReservedDeposits": 0, - "ReservedDeposits": [ - { - "FundingTxHash": "f1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f00", - "FundingOutputIndex": 0, - "FundingTxConfirmations": 6, - "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", - "Depositor": "934b98637ca318a4d6e7ca6ffd1690b8e77df637", - "BlindingFactor": "f9f0c90d00039525", - "RefundPublicKeyHash": "e257eccafbc07c381642ce6e7e55120fb077fbed", - "RefundLocktime": "e0250162", - "Amount": 2000000, - "RevealBlock": 290000, - "Age": 7200, - "SweptAt": 0, - "Vault": "0xReservationVaultAddress1234567890abcdef12345678" - } - ], - "ExpectedAnchorProposal": null, - "ExpectedErr": "" -} diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_6.json b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_6.json deleted file mode 100644 index 0692e7b9d2..0000000000 --- a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_6.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "Title": "cap rejection - accepting would exceed wallet aggregate amount cap", - "ChainParameters": { - "AverageBlockTime": 12, - "CurrentBlock": 300000, - "DepositMinAge": 3600 - }, - "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", - "ReservationVault": "0xReservationVaultAddress1234567890abcdef12345678", - "Wallet": { - "State": "Live" - }, - "ReservationParameters": { - "ReservationMinAmount": 100000, - "ReservationTxMaxFee": 5000, - "ReservationMaxTotalAmount": 100000000, - "ReservationTotalAmount": 0, - "MaxReservationsPerWallet": 5 - }, - "Caps": { - "MaxReservationsAmountPerWallet": 3000000, - "ReservationMaxSingleAmount": 5000000 - }, - "WalletCustody": { - "Count": 1, - "Amount": 2000000 - }, - "Global": { - "ActiveCount": 0, - "MaxActive": 100 - }, - "PendingReservedDeposits": 0, - "ReservedDeposits": [ - { - "FundingTxHash": "11b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f00", - "FundingOutputIndex": 0, - "FundingTxConfirmations": 6, - "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", - "Depositor": "934b98637ca318a4d6e7ca6ffd1690b8e77df637", - "BlindingFactor": "f9f0c90d00039526", - "RefundPublicKeyHash": "e257eccafbc07c381642ce6e7e55120fb077fbed", - "RefundLocktime": "e0250162", - "Amount": 2000000, - "RevealBlock": 290000, - "Age": 7200, - "SweptAt": 0, - "Vault": "0xReservationVaultAddress1234567890abcdef12345678" - } - ], - "ExpectedAnchorProposal": null, - "ExpectedErr": "" -} diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_7.json b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_7.json deleted file mode 100644 index be65953866..0000000000 --- a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_7.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "Title": "cap rejection - accepting would exceed global reservation total cap", - "ChainParameters": { - "AverageBlockTime": 12, - "CurrentBlock": 300000, - "DepositMinAge": 3600 - }, - "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", - "ReservationVault": "0xReservationVaultAddress1234567890abcdef12345678", - "Wallet": { - "State": "Live" - }, - "ReservationParameters": { - "ReservationMinAmount": 100000, - "ReservationTxMaxFee": 5000, - "ReservationMaxTotalAmount": 3000000, - "ReservationTotalAmount": 2000000, - "MaxReservationsPerWallet": 5 - }, - "Caps": { - "MaxReservationsAmountPerWallet": 50000000, - "ReservationMaxSingleAmount": 5000000 - }, - "WalletCustody": { - "Count": 1, - "Amount": 2000000 - }, - "Global": { - "ActiveCount": 0, - "MaxActive": 100 - }, - "PendingReservedDeposits": 0, - "ReservedDeposits": [ - { - "FundingTxHash": "21b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f00", - "FundingOutputIndex": 0, - "FundingTxConfirmations": 6, - "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", - "Depositor": "934b98637ca318a4d6e7ca6ffd1690b8e77df637", - "BlindingFactor": "f9f0c90d00039527", - "RefundPublicKeyHash": "e257eccafbc07c381642ce6e7e55120fb077fbed", - "RefundLocktime": "e0250162", - "Amount": 2000000, - "RevealBlock": 290000, - "Age": 7200, - "SweptAt": 0, - "Vault": "0xReservationVaultAddress1234567890abcdef12345678" - } - ], - "ExpectedAnchorProposal": null, - "ExpectedErr": "" -} diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_8.json b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_8.json deleted file mode 100644 index 768e723797..0000000000 --- a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_8.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "Title": "scan continues past an earlier ineligible candidate to a later eligible one", - "ChainParameters": { - "AverageBlockTime": 12, - "CurrentBlock": 300000, - "DepositMinAge": 3600 - }, - "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", - "ReservationVault": "0xReservationVaultAddress1234567890abcdef12345678", - "Wallet": { - "State": "Live" - }, - "ReservationParameters": { - "ReservationMinAmount": 100000, - "ReservationTxMaxFee": 5000, - "ReservationMaxTotalAmount": 100000000, - "ReservationTotalAmount": 0, - "MaxReservationsPerWallet": 5 - }, - "Caps": { - "MaxReservationsAmountPerWallet": 50000000, - "ReservationMaxSingleAmount": 5000000 - }, - "WalletCustody": { - "Count": 0, - "Amount": 0 - }, - "Global": { - "ActiveCount": 0, - "MaxActive": 100 - }, - "PendingReservedDeposits": 0, - "ReservedDeposits": [ - { - "FundingTxHash": "c1c2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f01", - "FundingOutputIndex": 0, - "FundingTxConfirmations": 6, - "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", - "Depositor": "934b98637ca318a4d6e7ca6ffd1690b8e77df637", - "BlindingFactor": "f9f0c90d00039523", - "RefundPublicKeyHash": "e257eccafbc07c381642ce6e7e55120fb077fbed", - "RefundLocktime": "e0250162", - "Amount": 50000, - "RevealBlock": 290000, - "Age": 7200, - "SweptAt": 0, - "Vault": "0xReservationVaultAddress1234567890abcdef12345678", - "PendingAcceptanceAction": { - "RequestNonce": 1, - "TxMaxFee": 5000, - "MinAmount": 100000, - "TermSeconds": 86400, - "TargetWalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6" - } - }, - { - "FundingTxHash": "c2c2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f02", - "FundingOutputIndex": 0, - "FundingTxConfirmations": 6, - "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", - "Depositor": "934b98637ca318a4d6e7ca6ffd1690b8e77df637", - "BlindingFactor": "f9f0c90d00039523", - "RefundPublicKeyHash": "e257eccafbc07c381642ce6e7e55120fb077fbed", - "RefundLocktime": "e0250162", - "Amount": 2000000, - "RevealBlock": 290001, - "Age": 7200, - "SweptAt": 0, - "Vault": "0xReservationVaultAddress1234567890abcdef12345678", - "PendingAcceptanceAction": { - "RequestNonce": 1, - "TxMaxFee": 5000, - "MinAmount": 100000, - "TermSeconds": 86400, - "TargetWalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6" - } - } - ], - "ExpectedAnchorProposal": { - "DepositFundingTxHash": "c2c2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f02", - "DepositFundingOutputIndex": 0, - "RequestNonce": 1, - "AnchorTxFee": 710 - }, - "ExpectedErr": "" -} diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_0.json b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_0.json index f4c379d8a4..f0a7b1f09d 100644 --- a/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_0.json +++ b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_0.json @@ -19,7 +19,7 @@ "WalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", "AnchorTxHash": "3333333333333333333333333333333333333333333333333333333333333333", "AnchorTxOutputIndex": 1, - "AnchorValue": 100000, + "AnchorValue": 200000, "State": "Active", "RequestNonce": 0, "HasPendingAction": false, diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_8.json b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_8.json index 0a64c2a85d..6c26bad389 100644 --- a/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_8.json +++ b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_8.json @@ -19,7 +19,7 @@ "WalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", "AnchorTxHash": "3333333333333333333333333333333333333333333333333333333333333333", "AnchorTxOutputIndex": 1, - "AnchorValue": 100000, + "AnchorValue": 200000, "State": "Active", "RequestNonce": 0, "HasPendingAction": false, diff --git a/pkg/tbtcpg/reservation_acceptance.go b/pkg/tbtcpg/reservation_acceptance.go index 0cac75c8f9..008b12a15c 100644 --- a/pkg/tbtcpg/reservation_acceptance.go +++ b/pkg/tbtcpg/reservation_acceptance.go @@ -2,7 +2,6 @@ package tbtcpg import ( "context" - "errors" "fmt" "math/big" "sort" @@ -18,10 +17,25 @@ import ( "github.com/keep-network/keep-core/pkg/tbtc" ) -// ReservationAcceptanceLookBackBlocks is the look-back period in blocks used -// when searching for reservation candidate deposits. It mirrors the deposit -// sweep look-back window: 30 days at 12 seconds per block. -const ReservationAcceptanceLookBackBlocks = uint64(216000) +// reservationAcceptanceRequestLookBackMarginBlocks is added to the +// on-chain reservation action timeout (converted to blocks) to form the +// look-back window of the ReservationAcceptanceRequested scan. Solidity's +// requestReservationAcceptance sets timeoutAt = request time + the action +// timeout, so a generation that can still be signed was requested within +// that timeout; the margin (about one day at 12 seconds per block) absorbs +// block-time drift and a moderate governance decrease of the timeout. +const reservationAcceptanceRequestLookBackMarginBlocks = uint64(7200) + +// reservationAcceptanceRevealLookBackMargin is added to a pending +// acceptance generation's snapshotted term to bound how far before the +// request the deposit's DepositRevealed event is searched for. +// Deposit.sol caps a reserved deposit's refund deadline at reveal time + +// term + 24 hours, and requestReservationAcceptance requires timeoutAt + +// 24 hours <= refund deadline, so the reveal happened at most one term +// before the request. The cap uses the term in force at reveal time, +// which governance may have lowered before the request; the margin +// absorbs such a decrease of up to about a week, plus block-time drift. +const reservationAcceptanceRevealLookBackMargin = 7 * 24 * time.Hour // zeroAddressHex is the Ethereum zero address as returned by the chain // adapter's address converter (chain.Address(common.Address{}.String()) @@ -30,11 +44,11 @@ const ReservationAcceptanceLookBackBlocks = uint64(216000) const zeroAddressHex = "0x0000000000000000000000000000000000000000" // ReservationAcceptanceTask is a task that may produce a reservation -// acceptance (anchor) proposal. It scans the chain for reserved deposits -// revealed to the operator's wallet, validates the wallet's eligibility -// against the active reservation caps, and emits a proposal whose resulting -// transaction is a 1-input-1-output anchor that disables the deposit's -// refund path. +// acceptance (anchor) proposal. It discovers the Pending Acceptance +// generations depositors requested for the operator's wallet from +// ReservationAcceptanceRequested events, and emits a proposal whose +// resulting transaction is a 1-input-1-output anchor that disables the +// deposit's refund path. type ReservationAcceptanceTask struct { chain Chain btcChain bitcoin.Chain @@ -42,22 +56,27 @@ type ReservationAcceptanceTask struct { // metricsRecorder is optional and used for recording performance // metrics: active_reservations_count, max_active_reservations, // wallet_reservations_count, reservation_vault_fee_debt_sat, and - // reservation_vault_fee_reserve_tbtc, sourced from the chain calls - // this task already makes in findReservationAcceptanceCandidate. - // These are leading indicators of reservation capacity saturation. + // reservation_vault_fee_reserve_tbtc_base_units, published once per + // Run. The occupancy gauges reuse chain reads the task makes anyway; + // the two vault fee gauges add two reads of the vault per Run. They + // are leading indicators of reservation capacity saturation and of + // reservation fee pressure. metricsRecorder interface { SetGauge(name string, value float64) } - // scanStateMutex guards scanState. Run() may be invoked for different - // wallets concurrently, and every call shares this one task instance - // (see NewProposalGenerator), so the per-wallet scan-state map needs - // its own lock rather than relying on a single caller goroutine. - scanStateMutex sync.Mutex - // scanState holds, per wallet, the incremental deposit-reveal scan - // cursor and its cached candidate events (see - // reservationAcceptanceScanState). - scanState map[[20]byte]*reservationAcceptanceScanState + // revealCacheMutex guards revealCache. Run() may be invoked for + // different wallets concurrently, and every call shares this one task + // instance (see NewProposalGenerator). + revealCacheMutex sync.Mutex + // revealCache holds, per wallet, the DepositRevealed events found for + // the previous Run's pending acceptance candidates, keyed by + // depositKey.Text(16). Reveal events never change, and a depositor may + // request acceptance long after the reveal, so a candidate re-examined + // in a later window does not repeat its reveal search. Each Run + // replaces its wallet's entry with the reveals of its own candidates, + // so the cache never outgrows the current candidate set. + revealCache map[[20]byte]map[string]*tbtc.DepositRevealedEvent // fundingTxLookupTimeout bounds a single candidate's // GetTransactionConfirmations call in @@ -78,7 +97,7 @@ func NewReservationAcceptanceTask( return &ReservationAcceptanceTask{ chain: chain, btcChain: btcChain, - scanState: make(map[[20]byte]*reservationAcceptanceScanState), + revealCache: make(map[[20]byte]map[string]*tbtc.DepositRevealedEvent), fundingTxLookupTimeout: reservationAcceptanceFundingTxLookupTimeout, } } @@ -91,11 +110,13 @@ func (rat *ReservationAcceptanceTask) setMetricsRecorder(recorder interface { rat.metricsRecorder = recorder } -// maxReservationAcceptanceCandidatesPerRun bounds the number of reserved -// deposits examined by findReservationAcceptanceCandidate in a single -// Run() call. Reveals are gas-only (no SPV proof required to appear), so -// reveal volume is not bounded by anything else; this cap keeps per-window -// work bounded even if a wallet's reveal volume spikes. +// maxReservationAcceptanceCandidatesPerRun bounds the number of pending +// acceptance generations examined past the on-chain pending-acceptance +// check in a single Run() call. Only generations a depositor requested +// for this wallet count toward it; each such request reserved wallet +// capacity on-chain, so the per-wallet reservation caps also bound how +// many can exist. The cap keeps per-window reveal lookups, Ethereum reads +// and Bitcoin lookups bounded. const maxReservationAcceptanceCandidatesPerRun = 50 // reservationAcceptanceFundingTxLookupWorkers bounds the number of reserved @@ -125,13 +146,17 @@ const reservationAcceptanceFundingTxLookupWorkers = 8 // directly. const reservationAcceptanceFundingTxLookupTimeout = 30 * time.Second -// reservationAcceptanceFundingTxCandidate is a phase-one-eligible reserved -// deposit awaiting the concurrent funding-transaction lookup performed by +// reservationAcceptanceFundingTxCandidate is a reserved deposit whose +// current generation is a Pending Acceptance targeting the operator's +// wallet and whose reveal and deposit request passed every Ethereum-side +// check, awaiting the concurrent funding-transaction lookup performed by // fetchReservationAcceptanceFundingTxs. type reservationAcceptanceFundingTxCandidate struct { event *tbtc.DepositRevealedEvent depositKey *big.Int depositRequest *tbtc.DepositChainRequest + requestNonce uint64 + action *tbtc.ReservationAction } // reservationAcceptanceFundingTxLookup is the outcome of one candidate's @@ -147,100 +172,22 @@ type reservationAcceptanceFundingTxLookup struct { confirmationsErr error } -// reservationAcceptanceScanState is the per-wallet incremental deposit- -// reveal scan cursor and its in-memory candidate cache, mirroring the -// cursor/cache split used by pkg/maintainer/spv/reservation_proof_loop.go's -// reservationProofScanState: only the block-range delta since the previous -// Run() call is fetched from the chain, while the cached event set is -// still fully re-evaluated against live eligibility state on every call, -// since an already-cached event's temporal maturity, applicable caps, and -// reservation state can all change between calls. -type reservationAcceptanceScanState struct { - // mutex guards the fields below across the entire read-fetch-merge- - // prune sequence in depositRevealedEventsSince, not just the map - // lookup in the caller: two concurrent Run() calls for the same - // wallet must serialize on this wallet's cursor rather than racing - // on lastScannedBlock/events. - mutex sync.Mutex - lastScannedBlock uint64 - events []*tbtc.DepositRevealedEvent -} - -// depositRevealedEventsSince returns every DepositRevealedEvent within the -// ReservationAcceptanceLookBackBlocks window for walletPublicKeyHash, using -// this task's per-wallet incremental cursor (see reservationAcceptanceScanState): -// the first call for a wallet performs the full look-back scan; every call -// after fetches only the block-range delta since the previous call and -// merges it into the cached set. Events that have aged out of the -// look-back window are pruned from the cache on every call. -func (rat *ReservationAcceptanceTask) depositRevealedEventsSince( - walletPublicKeyHash [20]byte, - currentBlock uint64, -) ([]*tbtc.DepositRevealedEvent, error) { - rat.scanStateMutex.Lock() - state, ok := rat.scanState[walletPublicKeyHash] - if !ok { - state = &reservationAcceptanceScanState{} - rat.scanState[walletPublicKeyHash] = state - } - rat.scanStateMutex.Unlock() - - state.mutex.Lock() - defer state.mutex.Unlock() - - windowStartBlock := uint64(0) - if currentBlock > ReservationAcceptanceLookBackBlocks { - windowStartBlock = currentBlock - ReservationAcceptanceLookBackBlocks - } - - fetchStartBlock := windowStartBlock - if state.lastScannedBlock != 0 { - fetchStartBlock = state.lastScannedBlock + 1 - } - - if fetchStartBlock <= currentBlock { - newEvents, err := rat.chain.PastDepositRevealedEvents( - &tbtc.DepositRevealedEventFilter{ - StartBlock: fetchStartBlock, - EndBlock: ¤tBlock, - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - }, - ) - if err != nil { - return nil, fmt.Errorf( - "failed to get past deposit revealed events: [%w]", - err, - ) - } - - state.events = append(state.events, newEvents...) - state.lastScannedBlock = currentBlock - } - - // Prune events that have aged out of the look-back window and build a - // fresh slice, so the caller's in-place sort does not reorder the - // cached backing array shared across Run() calls for this wallet. - prunedEvents := make([]*tbtc.DepositRevealedEvent, 0, len(state.events)) - for _, event := range state.events { - if event.BlockNumber >= windowStartBlock { - prunedEvents = append(prunedEvents, event) - } - } - state.events = prunedEvents - - events := make([]*tbtc.DepositRevealedEvent, len(prunedEvents)) - copy(events, prunedEvents) - return events, nil +// reservationAcceptanceFeeEstimate is a cached anchor fee estimate for one +// snapshotted fee cap. +type reservationAcceptanceFeeEstimate struct { + fee int64 + err error } -// Run inspects the chain for an acceptance candidate reserved deposit and, -// if one passes the eligibility gate, returns the resulting anchor proposal. -// The task is a no-op (proposal == nil, shouldExecute == false) when no -// candidate exists. A candidate whose proposal generation fails before any -// chain-state-mutating call (assemble/validate) is skipped in favor of the -// next candidate rather than aborting the window outright -- see -// reservationAcceptancePreWriteError. A failure after a write still aborts -// the window, since a partial on-chain effect may already exist. +// Run inspects the chain for a reserved deposit whose depositor requested +// acceptance by the operator's wallet and, if one passes every signer-time +// rule, returns the resulting anchor proposal. The per-window setup +// (parameters, wallet, gauges, candidate discovery) runs once; candidates +// are then tried in order. A candidate that fails a later check, the fee +// estimate, or proposal generation is logged and skipped in favor of the +// next one: the task performs no chain-state-mutating call, so a failed +// candidate leaves nothing behind. The task is a no-op (proposal == nil, +// shouldExecute == false) when no candidate succeeds. func (rat *ReservationAcceptanceTask) Run(request *tbtc.CoordinationProposalRequest) ( tbtc.CoordinationProposal, bool, @@ -253,54 +200,64 @@ func (rat *ReservationAcceptanceTask) Run(request *tbtc.CoordinationProposalRequ zap.String("walletPKH", fmt.Sprintf("0x%x", walletPublicKeyHash)), ) - skipDepositKeys := make(map[string]bool) - - for { - candidate, err := rat.findReservationAcceptanceCandidate( + pendingCandidates, reservationParameters, err := + rat.findReservationAcceptanceCandidates( taskLogger, walletPublicKeyHash, - skipDepositKeys, ) - if err != nil { - return nil, false, fmt.Errorf( - "cannot find reservation acceptance candidate: [%w]", - err, - ) - } + if err != nil { + return nil, false, fmt.Errorf( + "cannot find reservation acceptance candidate: [%w]", + err, + ) + } + + // The two Electrum calls (GetTransaction and + // GetTransactionConfirmations) are looked up through a bounded, + // lazily-scheduled pipeline (see fetchReservationAcceptanceFundingTxs), + // so an unhealthy Bitcoin backend cannot turn the candidate set into a + // fully serial multi-hour retry chain, and the common case -- the + // first candidate succeeds -- does not pay for fetching every other + // candidate's funding transaction. fundingTxLookups.next(i) hands back + // results in candidate order; deferring stop() tells the pipeline to + // abandon any lookup it has not yet dispatched once Run returns. + fundingTxLookups := rat.fetchReservationAcceptanceFundingTxs(pendingCandidates) + defer fundingTxLookups.stop() + + feeEstimates := make(map[uint64]reservationAcceptanceFeeEstimate) + + for i, pendingCandidate := range pendingCandidates { + candidate := rat.completeReservationAcceptanceCandidate( + taskLogger, + pendingCandidate, + fundingTxLookups.next(i), + feeEstimates, + reservationParameters, + ) if candidate == nil { - taskLogger.Info("no reservation acceptance candidate") - return nil, false, nil + continue } - proposal, shouldExecute, err := rat.proposeReservationAcceptance( + proposal, err := rat.proposeReservationAcceptance( taskLogger, walletPublicKeyHash, candidate, ) if err != nil { - var preWriteErr *reservationAcceptancePreWriteError - if errors.As(err, &preWriteErr) { - taskLogger.Warnf( - "reservation acceptance candidate [%v] failed before "+ - "any chain-state-mutating call, trying next "+ - "candidate: [%v]", - candidate.DepositKey, - err, - ) - skipDepositKeys[candidate.DepositKey.Text(16)] = true - continue - } - return nil, false, fmt.Errorf( - "cannot prepare reservation acceptance proposal: [%w]", + taskLogger.Warnf( + "reservation acceptance candidate [%v] failed, trying "+ + "next candidate: [%v]", + candidate.DepositKey, err, ) + continue } - if proposal == nil { - return nil, shouldExecute, nil - } - return proposal, shouldExecute, nil + return proposal, true, nil } + + taskLogger.Info("no reservation acceptance candidate") + return nil, false, nil } // ActionType returns the wallet action type this task proposes. @@ -311,8 +268,8 @@ func (rat *ReservationAcceptanceTask) ActionType() tbtc.WalletActionType { // reservationAcceptanceCandidate is the bundle a candidate reserved deposit // for acceptance carries through the proposal builder. It captures the // deposit's reveal context, the pending acceptance generation's real request -// nonce and snapshotted fields, plus the on-chain cap snapshot taken at scan -// time. +// nonce and snapshotted fields, plus the reservation parameters read at +// scan time. // // On-chain, only the deposit's depositor may call requestReservationAcceptance, // and that call is what writes the Pending Acceptance action record. This @@ -331,24 +288,34 @@ type reservationAcceptanceCandidate struct { AnchorFee int64 } -// findReservationAcceptanceCandidate returns the first reserved deposit -// that the operator's wallet may accept, or nil when none qualifies. -// skipDepositKeys (keyed by depositKey.Text(16)) excludes deposits the -// caller already tried and rejected earlier in the same Run() call, so a -// deposit whose proposal generation fails pre-write does not block every -// other candidate on the wallet. -func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( +// findReservationAcceptanceCandidates performs the per-window setup (it +// reads the reservation parameters and wallet, publishes the gauges) and +// returns, in the order they should be tried, the reserved deposits whose +// current generation is a Pending Acceptance targeting the wallet and +// whose reveal and deposit request pass every Ethereum-side check. +// +// Candidates are discovered from the wallet's ReservationAcceptanceRequested +// events rather than from deposit reveals: only a depositor's request +// creates an anchorable generation, and it may come long after the +// reveal. The on-chain pending-acceptance check runs before a candidate +// counts toward maxReservationAcceptanceCandidatesPerRun and before any +// reveal search or Bitcoin lookup, so reveals that were never requested +// cannot use up the budget. +func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidates( taskLogger log.StandardLogger, walletPublicKeyHash [20]byte, - skipDepositKeys map[string]bool, -) (*reservationAcceptanceCandidate, error) { +) ( + []*reservationAcceptanceFundingTxCandidate, + *tbtc.ReservationParameters, + error, +) { if walletPublicKeyHash == [20]byte{} { - return nil, fmt.Errorf("wallet public key hash is required") + return nil, nil, fmt.Errorf("wallet public key hash is required") } reservationParameters, err := rat.chain.ReservationParameters() if err != nil { - return nil, fmt.Errorf( + return nil, nil, fmt.Errorf( "failed to get reservation parameters: [%w]", err, ) @@ -366,108 +333,28 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( 0, ) rat.metricsRecorder.SetGauge( - "reservation_vault_fee_reserve_tbtc", + "reservation_vault_fee_reserve_tbtc_base_units", 0, ) } taskLogger.Info("reservation vault not configured") - return nil, nil + return nil, reservationParameters, nil } wallet, err := rat.chain.GetWallet(walletPublicKeyHash) if err != nil { - return nil, fmt.Errorf( + return nil, nil, fmt.Errorf( "failed to load wallet chain data: [%w]", err, ) } - // wallet_reservations_count / active_reservations_count / - // max_active_reservations are published unconditionally on every - // findReservationAcceptanceCandidate pass, mirroring the sibling - // live_wallets_count gauge that ReservationReanchorTask publishes every - // coordination window: they must not depend on the StateLive guard - // below, which is false for a wallet mid-rotation (moving funds or - // closing) -- exactly when gauge staleness matters most. Gating the - // publish on that guard would leave these gauges stuck at their - // registered-zero value for as long as the wallet is not Live. - walletReservationsCount, err := rat.chain.WalletReservationsCount( + if err := rat.publishReservationGauges( + taskLogger, walletPublicKeyHash, - ) - if err != nil { - return nil, fmt.Errorf( - "failed to get wallet reservations count: [%w]", - err, - ) - } - if rat.metricsRecorder != nil { - rat.metricsRecorder.SetGauge( - "wallet_reservations_count", - float64(walletReservationsCount), - ) - } - - activeReservationsCount, maxActiveReservations, err := - rat.chain.ActiveReservationsCount() - if err != nil { - return nil, fmt.Errorf( - "failed to get active reservations count: [%w]", - err, - ) - } - if rat.metricsRecorder != nil { - rat.metricsRecorder.SetGauge( - "active_reservations_count", - float64(activeReservationsCount), - ) - rat.metricsRecorder.SetGauge( - "max_active_reservations", - float64(maxActiveReservations), - ) - } - - // reservation_vault_fee_debt_sat / - // reservation_vault_fee_reserve_tbtc publish the ReservationVault's - // outstanding in-kind fee debt (in satoshi) and fee-reserve balance - // (in TBTC base units, 1e18 per whole TBTC; a gauge value of N - // means N / 1e18 whole TBTC), unconditionally on every pass, - // next to the occupancy gauges above: a vault whose fee debt or - // reserve is growing is the leading indicator of reservation fee - // pressure. A read error is logged and the gauge keeps its - // previously recorded value: the gauges are observability-only, - // so an RPC failure here must never abort proposal generation. - // When the reservation vault is not configured (zero address), - // findReservationAcceptanceCandidate has already returned above, - // publishing the fee gauges as zero, so this block is unreachable - // for that pass. - if rat.metricsRecorder != nil { - feeDebtSat, err := rat.chain.ReservationVaultFeeDebtSat() - if err != nil { - taskLogger.Warnf( - "failed to get reservation vault fee debt: [%v]", - err, - ) - } else { - rat.metricsRecorder.SetGauge( - "reservation_vault_fee_debt_sat", - float64(feeDebtSat), - ) - } - - feeReserve, err := - rat.chain.ReservationVaultFeeReserveTbtcBaseUnits() - if err != nil { - taskLogger.Warnf( - "failed to get reservation vault fee reserve: [%v]", - err, - ) - } else if feeReserve != nil { - reserveFloat, _ := feeReserve.Float64() - rat.metricsRecorder.SetGauge( - "reservation_vault_fee_reserve_tbtc", - reserveFloat, - ) - } + reservationVault, + ); err != nil { + return nil, nil, err } // Mirror WalletProposalValidator.sol's @@ -483,16 +370,16 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( "cannot accept reservation", wallet.State, ) - return nil, nil + return nil, reservationParameters, nil } blockCounter, err := rat.chain.BlockCounter() if err != nil { - return nil, fmt.Errorf("failed to get block counter: [%w]", err) + return nil, nil, fmt.Errorf("failed to get block counter: [%w]", err) } currentBlock, err := blockCounter.CurrentBlock() if err != nil { - return nil, fmt.Errorf( + return nil, nil, fmt.Errorf( "failed to get current block: [%w]", err, ) @@ -504,15 +391,14 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( // when the Pending Acceptance generation this task consumes was // created. Re-applying the caps at consumption time would // double-count that generation against them, so only the signer-time - // rules the validator enforces are mirrored here (see - // findReservationAcceptanceCandidate and - // proposeReservationAcceptance). Request-time cap checks remain in - // place where a fresh request is made: the re-anchor task's target - // headroom pre-check and its request flow. + // rules the validator enforces are mirrored here and in + // completeReservationAcceptanceCandidate. Request-time cap checks + // remain in place where a fresh request is made: the re-anchor task's + // target headroom pre-check and its request flow. depositMinAgeSeconds, err := rat.chain.GetDepositMinAge() if err != nil { - return nil, fmt.Errorf( + return nil, nil, fmt.Errorf( "failed to get deposit minimum age: [%w]", err, ) @@ -529,193 +415,57 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( ) } - depositRevealedEvents, err := rat.depositRevealedEventsSince( + averageBlockTime := rat.chain.AverageBlockTime() + if averageBlockTime <= 0 { + averageBlockTime = tbtc.DepositRevealLookupDefaultBlockTime + } + + requestEvents, err := rat.reservationAcceptanceRequestedEvents( walletPublicKeyHash, currentBlock, + reservationParameters.ReservationActionTimeout, + averageBlockTime, ) if err != nil { - return nil, err + return nil, nil, err } - // Take the oldest first. - sort.SliceStable(depositRevealedEvents, func(i, j int) bool { - return depositRevealedEvents[i].BlockNumber < depositRevealedEvents[j].BlockNumber - }) - - // The anchor fee is identical for every candidate examined below (same - // fixed-size 1-input-1-output anchor transaction, same fee-rate oracle - // and cap). It is estimated at most once per Run() call -- on the - // first candidate that survives every earlier per-candidate gate -- - // and the result is cached in anchorFee/anchorFeeComputed for reuse by - // every subsequent candidate, instead of being unconditionally - // recomputed for each of the up to - // maxReservationAcceptanceCandidatesPerRun candidates examined by the - // loop. It is intentionally not computed further up in this function: - // a wallet with no candidate that reaches this gate (e.g. no reserved - // deposits at all) must remain a clean no-op, without paying for a fee - // estimate it will never use. - var anchorFee int64 - anchorFeeComputed := false - var lastFeeCap uint64 now := time.Now() + rat.revealCacheMutex.Lock() + previousReveals := rat.revealCache[walletPublicKeyHash] + rat.revealCacheMutex.Unlock() + currentReveals := make(map[string]*tbtc.DepositRevealedEvent) + defer func() { + rat.revealCacheMutex.Lock() + rat.revealCache[walletPublicKeyHash] = currentReveals + rat.revealCacheMutex.Unlock() + }() + var pendingCandidates []*reservationAcceptanceFundingTxCandidate candidatesExamined := 0 - for _, event := range depositRevealedEvents { - if !depositTargetsReservationVault(event.Vault, reservationVault) { - continue - } - - depositKey := rat.chain.BuildDepositKey( - event.FundingTxHash, - event.FundingOutputIndex, - ) - - if skipDepositKeys[depositKey.Text(16)] { - continue - } - - if candidatesExamined >= maxReservationAcceptanceCandidatesPerRun { - taskLogger.Warnf( - "reached max reservation acceptance candidates per run "+ - "[%d]; remaining reserved deposits will be examined "+ - "on a subsequent run", - maxReservationAcceptanceCandidatesPerRun, - ) - break - } - candidatesExamined++ - - depositRequest, foundRequest, err := rat.chain.GetDepositRequest( - event.FundingTxHash, - event.FundingOutputIndex, - ) - if err != nil { - taskLogger.Errorf( - "failed to get deposit request for [%v]: [%v]", - depositKey, - err, - ) - continue - } - if !foundRequest { - taskLogger.Warnf( - "no deposit request for reserved deposit [%v]", - depositKey, - ) - continue - } - - matureAt := depositRequest.RevealedAt.Add(depositMinAge) - if !now.After(matureAt) { - taskLogger.Infof( - "reserved deposit [%v] is not old enough: now=%v, matureAt=%v", - depositKey, - now, matureAt, - ) - continue - } - - if depositRequest.SweptAt.Unix() != 0 { - taskLogger.Debugf( - "reserved deposit [%v] is already swept", - depositKey, - ) - continue - } + for _, requestEvent := range requestEvents { + depositKey := requestEvent.ReservationKey - // Phase one filters on liveness-only conditions; the - // pending-acceptance precondition, the generation's timeout - // safety margin, and its snapshotted minimum plus anchor fee - // are all enforced per candidate in the second pass below, - // where the generation's own record is read. - - pendingCandidates = append( - pendingCandidates, - &reservationAcceptanceFundingTxCandidate{ - event: event, - depositKey: depositKey, - depositRequest: depositRequest, - }, - ) - } - - // The two Electrum calls below (GetTransaction and - // GetTransactionConfirmations) are looked up through a bounded, - // lazily-scheduled pipeline (see fetchReservationAcceptanceFundingTxs) - // instead of eagerly for every phase-one-eligible candidate collected - // above, so an unhealthy Bitcoin backend cannot turn this bounded - // candidate set into a fully serial multi-hour retry chain, AND a - // caller that only needs the first fully eligible candidate -- the - // common case, since candidates are examined oldest-first -- does not - // pay for fetching every other pending candidate's funding - // transaction. fundingTxLookups.next(i) still hands back each - // candidate's result in the original oldest-first order, so the loop - // below still returns the first fully eligible one deterministically, - // exactly as the previous strictly serial implementation did; - // deferring stop() here ensures every exit from this loop -- a - // candidate found, or every candidate exhausted -- tells the pipeline - // to abandon any lookup it has not yet dispatched. - fundingTxLookups := rat.fetchReservationAcceptanceFundingTxs(pendingCandidates) - defer fundingTxLookups.stop() - - for i, pendingCandidate := range pendingCandidates { - event := pendingCandidate.event - depositKey := pendingCandidate.depositKey - depositRequest := pendingCandidate.depositRequest - lookup := fundingTxLookups.next(i) - - if lookup.fundingTxErr != nil { - taskLogger.Errorf( - "failed to get funding tx for reserved deposit [%v]: [%v]", - depositKey, - lookup.fundingTxErr, - ) - continue - } - fundingTx := lookup.fundingTx - - if lookup.confirmationsErr != nil { - taskLogger.Errorf( - "failed to get funding tx confirmations for [%v]: [%v]", - depositKey, - lookup.confirmationsErr, - ) - continue - } - if lookup.confirmations < tbtc.DepositSweepRequiredFundingTxConfirmations { - taskLogger.Debugf( - "reserved deposit [%v] funding tx confirmations [%d/%d] below required", - depositKey, - lookup.confirmations, - tbtc.DepositSweepRequiredFundingTxConfirmations, - ) + // The event's own timeout lets a generation whose signing window + // has closed be dropped without any RPC. + if uint64(now.Unix())+ + uint64(reservationRequestTimeoutSafetyMarginSeconds) >= + uint64(requestEvent.TimeoutAt) { continue } // Determine the acceptance authorization generation from the - // reservation's own on-chain state, which authoritatively reflects - // whether the depositor has requested this deposit's acceptance -- - // not from acceptance-requested event history, which would still - // show a first generation that has since timed out and become - // eligible for a retry. - // - // The deposit's current generation is the only one this task may - // anchor: on-chain, only the deposit's own depositor may call - // requestReservationAcceptance, and that call is what writes the - // Pending Acceptance action record this task validates against. - // Deposits whose current generation is not a Pending Acceptance - // record (yet unknown, a timed-out or settled prior generation, or - // a reservation already anchored) are skipped, because the owner - // has not requested this deposit's acceptance. + // reservation's own on-chain state, which authoritatively + // reflects whether the requested generation is still the + // current one and still pending. reservation, err := rat.chain.GetReservation(depositKey) if err != nil { // Fail safe: the production chain adapter never errors for // "not found" (it returns a zero record with State == // Unknown), so a non-nil error here can only be an RPC/decode - // failure, not a signal that the reservation is not yet - // created. Skip this deposit for the current coordination + // failure. Skip this deposit for the current coordination // window instead; the next window retries. taskLogger.Errorf( "cannot get reservation [%v], skipping deposit for this window: [%v]", @@ -726,20 +476,17 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( } // The pending-generation precondition: a successful read of a - // generation whose record is not yet a Pending Acceptance action - // (RequestNonce == 0 with the zero record, or any non-acceptance - // / non-pending record) means the depositor has not requested this - // deposit's acceptance and it is not anchorable. + // generation whose record is not a Pending Acceptance action + // (a timed-out or settled generation, or any non-acceptance + // record) is not anchorable. action, err := rat.chain.GetReservationAction( depositKey, reservation.RequestNonce, ) if err != nil { // Fail safe: a lookup error is indistinguishable from "still - // pending" here, and treating it as not-pending would let the - // task build a proposal for a generation that may already be - // in flight. Skip this deposit for the current coordination - // window instead; the next window retries. + // pending" here. Skip this deposit for the current + // coordination window instead; the next window retries. taskLogger.Errorf( "cannot get reservation action for [0x%x] nonce [%d]: [%v]", depositKey, @@ -753,8 +500,7 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( action.State != tbtc.ReservationActionStatePending { taskLogger.Infof( "reservation [%v] generation [%d] is not a pending "+ - "acceptance action (type=%v, state=%v); the depositor "+ - "has not requested acceptance, skipping", + "acceptance action (type=%v, state=%v); skipping", depositKey, reservation.RequestNonce, action.ActionType, @@ -794,107 +540,455 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( continue } - // The action record holds the generation's real nonce and its - // snapshotted fee bound; the proposal and its net-of-fee check - // below are built from them, not from live parameter values. - requestNonce := reservation.RequestNonce - txMaxFee := action.TxMaxFee - - // 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. - // The estimate is cached per fee cap: the cap is snapshotted per - // action generation, so a different generation's bound invalidates - // the cached estimate. - if !anchorFeeComputed || txMaxFee != lastFeeCap { - var feeErr error - anchorFee, feeErr = estimateReservationAcceptanceFee( - rat.btcChain, - txMaxFee, + if candidatesExamined >= maxReservationAcceptanceCandidatesPerRun { + taskLogger.Warnf( + "reached max reservation acceptance candidates per run "+ + "[%d]; remaining pending acceptances will be examined "+ + "on a subsequent run", + maxReservationAcceptanceCandidatesPerRun, ) - if feeErr != nil { - return nil, fmt.Errorf( - "failed to estimate reservation acceptance transaction fee: [%w]", - feeErr, - ) - } - anchorFeeComputed = true - lastFeeCap = txMaxFee + break } + candidatesExamined++ - // Mirror WalletProposalValidator.validateReservationAnchorProposal's - // minimum check against this generation's own snapshotted values: - // the deposit amount must satisfy - // amount >= action.MinAmount + anchorFee before the on-chain - // validation can pass. The addition-based formulation avoids the - // underflow the contract guards against when the estimated fee - // exceeds a small deposit's amount. The live ReservationMinAmount - // is deliberately not used: the snapshot is what the validator - // enforces for this generation. - feeSats := uint64(anchorFee) - minPlusFee := action.MinAmount + feeSats - if minPlusFee < action.MinAmount || depositRequest.Amount < minPlusFee { + revealEvent, err := rat.findReservationAcceptanceReveal( + walletPublicKeyHash, + depositKey, + requestEvent.BlockNumber, + action.TermSeconds, + averageBlockTime, + previousReveals, + ) + if err != nil { + taskLogger.Errorf( + "cannot find deposit reveal for reservation [%v], skipping "+ + "deposit for this window: [%v]", + depositKey, + err, + ) + continue + } + if revealEvent == nil { + taskLogger.Warnf( + "no deposit reveal found for reservation [%v] in the "+ + "blocks before its acceptance request; skipping", + depositKey, + ) + continue + } + currentReveals[depositKey.Text(16)] = revealEvent + + if !depositTargetsReservationVault(revealEvent.Vault, reservationVault) { + taskLogger.Warnf( + "reserved deposit [%v] was not revealed to the reservation "+ + "vault; skipping", + depositKey, + ) + continue + } + + depositRequest, foundRequest, err := rat.chain.GetDepositRequest( + revealEvent.FundingTxHash, + revealEvent.FundingOutputIndex, + ) + if err != nil { + taskLogger.Errorf( + "failed to get deposit request for [%v]: [%v]", + depositKey, + err, + ) + continue + } + if !foundRequest { + taskLogger.Warnf( + "no deposit request for reserved deposit [%v]", + depositKey, + ) + continue + } + + matureAt := depositRequest.RevealedAt.Add(depositMinAge) + if !now.After(matureAt) { taskLogger.Infof( - "reserved deposit [%v] amount [%d] below the generation's "+ - "snapshotted minimum [%d] plus anchor fee [%d]; skipping", + "reserved deposit [%v] is not old enough: now=%v, matureAt=%v", depositKey, - depositRequest.Amount, - action.MinAmount, - anchorFee, + now, matureAt, + ) + continue + } + + if depositRequest.SweptAt.Unix() != 0 { + taskLogger.Debugf( + "reserved deposit [%v] is already swept", + depositKey, + ) + continue + } + + pendingCandidates = append( + pendingCandidates, + &reservationAcceptanceFundingTxCandidate{ + event: revealEvent, + depositKey: depositKey, + depositRequest: depositRequest, + requestNonce: reservation.RequestNonce, + action: action, + }, + ) + } + + return pendingCandidates, reservationParameters, nil +} + +// publishReservationGauges publishes the occupancy and vault fee gauges. +// wallet_reservations_count / active_reservations_count / +// max_active_reservations are published on every Run regardless of the +// wallet state, mirroring the sibling live_wallets_count gauge that +// ReservationReanchorTask publishes every coordination window: a wallet +// mid-rotation (moving funds or closing) is exactly when gauge staleness +// matters most. +// +// reservation_vault_fee_debt_sat / reservation_vault_fee_reserve_tbtc_base_units +// publish the ReservationVault's outstanding in-kind fee debt (in +// satoshi) and fee-reserve balance (in TBTC base units, 1e18 per whole +// TBTC; a gauge value of N means N / 1e18 whole TBTC): a vault whose fee +// debt or reserve is growing is the leading indicator of reservation fee +// pressure. The vault address the caller already read is passed to the +// chain, so each gauge costs a single vault read. +// +// A failed occupancy read is returned as an error. The vault fee gauges +// are observability-only: a read error is logged, the gauge keeps its +// previously recorded value, and proposal generation goes on. +func (rat *ReservationAcceptanceTask) publishReservationGauges( + taskLogger log.StandardLogger, + walletPublicKeyHash [20]byte, + reservationVault chain.Address, +) error { + walletReservationsCount, err := rat.chain.WalletReservationsCount( + walletPublicKeyHash, + ) + if err != nil { + return fmt.Errorf( + "failed to get wallet reservations count: [%w]", + err, + ) + } + + activeReservationsCount, maxActiveReservations, err := + rat.chain.ActiveReservationsCount() + if err != nil { + return fmt.Errorf( + "failed to get active reservations count: [%w]", + err, + ) + } + + if rat.metricsRecorder == nil { + return nil + } + + rat.metricsRecorder.SetGauge( + "wallet_reservations_count", + float64(walletReservationsCount), + ) + rat.metricsRecorder.SetGauge( + "active_reservations_count", + float64(activeReservationsCount), + ) + rat.metricsRecorder.SetGauge( + "max_active_reservations", + float64(maxActiveReservations), + ) + + feeDebtSat, err := rat.chain.ReservationVaultFeeDebtSat(reservationVault) + if err != nil { + taskLogger.Warnf( + "failed to get reservation vault fee debt: [%v]", + err, + ) + } else { + rat.metricsRecorder.SetGauge( + "reservation_vault_fee_debt_sat", + float64(feeDebtSat), + ) + } + + feeReserve, err := + rat.chain.ReservationVaultFeeReserveTbtcBaseUnits(reservationVault) + if err != nil { + taskLogger.Warnf( + "failed to get reservation vault fee reserve: [%v]", + err, + ) + } else if feeReserve != nil { + reserveFloat, _ := feeReserve.Float64() + rat.metricsRecorder.SetGauge( + "reservation_vault_fee_reserve_tbtc_base_units", + reserveFloat, + ) + } + + return nil +} + +// reservationAcceptanceRequestedEvents returns the wallet's +// ReservationAcceptanceRequested events that may still describe a +// signable generation, one per reservation (the highest request nonce), +// ordered by timeout, the generation closest to timing out first. The +// look-back window is the on-chain action timeout converted to blocks plus +// reservationAcceptanceRequestLookBackMarginBlocks. +func (rat *ReservationAcceptanceTask) reservationAcceptanceRequestedEvents( + walletPublicKeyHash [20]byte, + currentBlock uint64, + actionTimeoutSeconds uint32, + averageBlockTime time.Duration, +) ([]*tbtc.ReservationAcceptanceRequestedEvent, error) { + lookBackBlocks := uint64( + time.Duration(actionTimeoutSeconds)*time.Second/averageBlockTime, + ) + reservationAcceptanceRequestLookBackMarginBlocks + + startBlock := uint64(0) + if currentBlock > lookBackBlocks { + startBlock = currentBlock - lookBackBlocks + } + + // The window is wider than many providers accept for one log query, so + // it is scanned in chunks of the reveal lookup's size. + var events []*tbtc.ReservationAcceptanceRequestedEvent + for chunkStart := startBlock; chunkStart <= currentBlock; { + chunkEnd := currentBlock + if currentBlock-chunkStart >= tbtc.DepositRevealLookupChunkBlocks { + chunkEnd = chunkStart + tbtc.DepositRevealLookupChunkBlocks - 1 + } + + chunkEvents, err := rat.chain.PastReservationAcceptanceRequestedEvents( + &tbtc.ReservationAcceptanceRequestedEventFilter{ + StartBlock: chunkStart, + EndBlock: &chunkEnd, + WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + }, + ) + if err != nil { + return nil, fmt.Errorf( + "failed to get past reservation acceptance requested "+ + "events in blocks [%d, %d]: [%w]", + chunkStart, + chunkEnd, + err, ) + } + events = append(events, chunkEvents...) + + chunkStart = chunkEnd + 1 + } + + latest := make(map[string]*tbtc.ReservationAcceptanceRequestedEvent) + for _, event := range events { + if event.ReservationKey == nil || + event.WalletPublicKeyHash != walletPublicKeyHash { continue } + key := event.ReservationKey.Text(16) + if existing, ok := latest[key]; !ok || + event.RequestNonce > existing.RequestNonce { + latest[key] = event + } + } + + result := make([]*tbtc.ReservationAcceptanceRequestedEvent, 0, len(latest)) + for _, event := range latest { + result = append(result, event) + } + sort.Slice(result, func(i, j int) bool { + if result[i].TimeoutAt != result[j].TimeoutAt { + return result[i].TimeoutAt < result[j].TimeoutAt + } + if result[i].BlockNumber != result[j].BlockNumber { + return result[i].BlockNumber < result[j].BlockNumber + } + return result[i].ReservationKey.Cmp(result[j].ReservationKey) < 0 + }) + + return result, nil +} + +// findReservationAcceptanceReveal returns the DepositRevealed event of the +// reserved deposit identified by depositKey, or nil when it is not found. +// A reveal found on the previous Run is reused. Otherwise the wallet's +// reveals are scanned backward from the acceptance request's block down +// to one snapshotted term plus reservationAcceptanceRevealLookBackMargin +// before it (see that constant for why the reveal cannot be older). +func (rat *ReservationAcceptanceTask) findReservationAcceptanceReveal( + walletPublicKeyHash [20]byte, + depositKey *big.Int, + requestBlock uint64, + termSeconds uint32, + averageBlockTime time.Duration, + previousReveals map[string]*tbtc.DepositRevealedEvent, +) (*tbtc.DepositRevealedEvent, error) { + if cached, ok := previousReveals[depositKey.Text(16)]; ok { + return cached, nil + } + + lookBackBlocks := uint64( + (time.Duration(termSeconds)*time.Second + + reservationAcceptanceRevealLookBackMargin) / averageBlockTime, + ) + fromBlock := uint64(0) + if requestBlock > lookBackBlocks { + fromBlock = requestBlock - lookBackBlocks + } + + return tbtc.FindDepositRevealedEvent( + rat.chain, + walletPublicKeyHash, + fromBlock, + requestBlock, + func(event *tbtc.DepositRevealedEvent) bool { + return rat.chain.BuildDepositKey( + event.FundingTxHash, + event.FundingOutputIndex, + ).Cmp(depositKey) == 0 + }, + ) +} + +// completeReservationAcceptanceCandidate applies the Bitcoin-side and fee +// checks to a candidate from findReservationAcceptanceCandidates and +// returns the complete candidate, or nil (after logging why) when it does +// not qualify. feeEstimates caches the anchor fee estimate per snapshotted +// fee cap for the duration of one Run. +func (rat *ReservationAcceptanceTask) completeReservationAcceptanceCandidate( + taskLogger log.StandardLogger, + pendingCandidate *reservationAcceptanceFundingTxCandidate, + lookup reservationAcceptanceFundingTxLookup, + feeEstimates map[uint64]reservationAcceptanceFeeEstimate, + reservationParameters *tbtc.ReservationParameters, +) *reservationAcceptanceCandidate { + event := pendingCandidate.event + depositKey := pendingCandidate.depositKey + depositRequest := pendingCandidate.depositRequest + action := pendingCandidate.action + + if lookup.fundingTxErr != nil { + taskLogger.Errorf( + "failed to get funding tx for reserved deposit [%v]: [%v]", + depositKey, + lookup.fundingTxErr, + ) + return nil + } + if lookup.confirmationsErr != nil { + taskLogger.Errorf( + "failed to get funding tx confirmations for [%v]: [%v]", + depositKey, + lookup.confirmationsErr, + ) + return nil + } + if lookup.confirmations < tbtc.DepositSweepRequiredFundingTxConfirmations { + taskLogger.Debugf( + "reserved deposit [%v] funding tx confirmations [%d/%d] below required", + depositKey, + lookup.confirmations, + tbtc.DepositSweepRequiredFundingTxConfirmations, + ) + return nil + } + + // The anchor transaction has a fixed size, so its fee only depends on + // the fee-rate oracle and the generation's snapshotted fee cap. The + // estimate (or its error) is cached per cap, so candidates sharing a + // cap do not repeat it. A fee error -- including an estimate above + // this generation's cap -- skips the candidate: another generation + // with a higher cap may still be viable. The estimator does not + // separate oracle failures from cap overruns, so both are skipped. + txMaxFee := action.TxMaxFee + feeEstimate, ok := feeEstimates[txMaxFee] + if !ok { + feeEstimate.fee, feeEstimate.err = estimateReservationAcceptanceFee( + rat.btcChain, + txMaxFee, + ) + feeEstimates[txMaxFee] = feeEstimate + } + if feeEstimate.err != nil { + taskLogger.Warnf( + "cannot estimate reservation acceptance transaction fee for "+ + "reserved deposit [%v] (max fee [%d]); skipping: [%v]", + depositKey, + txMaxFee, + feeEstimate.err, + ) + return nil + } + anchorFee := feeEstimate.fee + + // Mirror WalletProposalValidator.validateReservationAnchorProposal's + // minimum check against this generation's own snapshotted values: + // the deposit amount must satisfy + // amount >= action.MinAmount + anchorFee before the on-chain + // validation can pass. The addition-based formulation avoids the + // underflow the contract guards against when the estimated fee + // exceeds a small deposit's amount. The live ReservationMinAmount + // is deliberately not used: the snapshot is what the validator + // enforces for this generation. + feeSats := uint64(anchorFee) + minPlusFee := action.MinAmount + feeSats + if minPlusFee < action.MinAmount || depositRequest.Amount < minPlusFee { taskLogger.Infof( - "selected reserved deposit [%v] for acceptance", + "reserved deposit [%v] amount [%d] below the generation's "+ + "snapshotted minimum [%d] plus anchor fee [%d]; skipping", depositKey, + depositRequest.Amount, + action.MinAmount, + anchorFee, ) + return nil + } - return &reservationAcceptanceCandidate{ - DepositKey: depositKey, - Deposit: &tbtc.Deposit{ - Utxo: &bitcoin.UnspentTransactionOutput{ - Outpoint: &bitcoin.TransactionOutpoint{ - TransactionHash: event.FundingTxHash, - OutputIndex: event.FundingOutputIndex, - }, - Value: int64(depositRequest.Amount), + taskLogger.Infof( + "selected reserved deposit [%v] for acceptance", + depositKey, + ) + + return &reservationAcceptanceCandidate{ + DepositKey: depositKey, + Deposit: &tbtc.Deposit{ + Utxo: &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: event.FundingTxHash, + OutputIndex: event.FundingOutputIndex, }, - Depositor: depositRequest.Depositor, - BlindingFactor: event.BlindingFactor, - WalletPublicKeyHash: event.WalletPublicKeyHash, - RefundPublicKeyHash: event.RefundPublicKeyHash, - RefundLocktime: event.RefundLocktime, - Vault: depositRequest.Vault, - ExtraData: depositRequest.ExtraData, + Value: int64(depositRequest.Amount), }, - FundingTx: fundingTx, - ReservationParameters: reservationParameters, - TxMaxFee: txMaxFee, - RequestNonce: requestNonce, - AnchorFee: anchorFee, - }, nil + Depositor: depositRequest.Depositor, + BlindingFactor: event.BlindingFactor, + WalletPublicKeyHash: event.WalletPublicKeyHash, + RefundPublicKeyHash: event.RefundPublicKeyHash, + RefundLocktime: event.RefundLocktime, + Vault: depositRequest.Vault, + ExtraData: depositRequest.ExtraData, + }, + FundingTx: lookup.fundingTx, + ReservationParameters: reservationParameters, + TxMaxFee: txMaxFee, + RequestNonce: pendingCandidate.requestNonce, + AnchorFee: anchorFee, } - - return nil, nil } // reservationAcceptanceFundingTxPipeline is a lazily-scheduled, bounded, -// oldest-first funding-transaction lookup pipeline returned by +// in-order funding-transaction lookup pipeline returned by // fetchReservationAcceptanceFundingTxs. A naive worker pool launches every // candidate's GetTransaction/GetTransactionConfirmations call // unconditionally, so a caller that only needs the first fully eligible -// candidate -- the common case, since candidates are examined oldest-first -// -- still pays for fetching every other pending candidate's funding -// transaction. This pipeline instead hands results back one index at a -// time via next(), and stop() tells it to abandon every lookup it has not -// yet dispatched, so a caller that stops asking for more results after an -// early match never causes those later fetches to happen at all. +// candidate still pays for fetching every other pending candidate's +// funding transaction. This pipeline instead hands results back one index +// at a time via next(), and stop() tells it to abandon every lookup it has +// not yet dispatched, so a caller that stops asking for more results after +// an early match never causes those later fetches to happen at all. type reservationAcceptanceFundingTxPipeline struct { // results holds one buffered (capacity 1) channel per candidate index. // Buffering lets a fetch that completes after stop() has already been @@ -908,11 +1002,11 @@ type reservationAcceptanceFundingTxPipeline struct { // next blocks until candidate index i's funding-transaction lookup // completes and returns its result. Callers MUST consume indexes in -// increasing order (0, 1, 2, ...) -- the same oldest-first order -// candidates was given to fetchReservationAcceptanceFundingTxs in. Because -// each candidate's result is delivered on its own per-index channel, a -// later candidate's fetch racing ahead and completing first can never be -// mistaken for an earlier candidate's result. +// increasing order (0, 1, 2, ...) -- the same order candidates was given +// to fetchReservationAcceptanceFundingTxs in. Because each candidate's +// result is delivered on its own per-index channel, a later candidate's +// fetch racing ahead and completing first can never be mistaken for an +// earlier candidate's result. func (p *reservationAcceptanceFundingTxPipeline) next( i int, ) reservationAcceptanceFundingTxLookup { @@ -938,22 +1032,17 @@ func (p *reservationAcceptanceFundingTxPipeline) stop() { // fetchReservationAcceptanceFundingTxs starts looking up the funding // transaction and its confirmation count for every candidate in -// candidates, in oldest-first order, and returns a +// candidates, in order, and returns a // reservationAcceptanceFundingTxPipeline the caller pulls results from one // index at a time via next(). At most // reservationAcceptanceFundingTxLookupWorkers lookups ever run -// concurrently -- a strict cap, exactly as the previous eager -// implementation enforced with its shared worker goroutines. This is a -// bound on concurrency, not on the total number of Bitcoin RPCs issued -// over the pipeline's lifetime: unlike that implementation, which -// launched every candidate's fetch before the caller could inspect any -// result, fetches here are dispatched lazily and the dispatcher only -// stops handing out new candidates once the caller calls the returned -// pipeline's stop() (see findReservationAcceptanceCandidate, which does -// so via defer once it either finds a fully eligible candidate or -// exhausts every candidate). Because the dispatcher waits on stop(), not -// on the caller actually consuming each result via next(), a caller whose -// own per-candidate work (e.g. Ethereum round-trips) is slower than the +// concurrently. This is a bound on concurrency, not on the total number +// of Bitcoin RPCs issued over the pipeline's lifetime: fetches are +// dispatched lazily and the dispatcher only stops handing out new +// candidates once the caller calls the returned pipeline's stop() (Run +// does so via defer). Because the dispatcher waits on stop(), not on the +// caller actually consuming each result via next(), a caller whose own +// per-candidate work (e.g. proposal validation) is slower than the // Bitcoin lookups can still see up to every one of // maxReservationAcceptanceCandidatesPerRun (50) pending candidates // dispatched before stop() is observed; the early-stop savings this @@ -981,11 +1070,9 @@ func (rat *ReservationAcceptanceTask) fetchReservationAcceptanceFundingTxs( } dispatchSlots := make(chan struct{}, workers) - // The dispatcher below walks candidates in their given (oldest-first) - // order, acquiring a dispatchSlots slot before starting each one's - // fetch, so at most `workers` fetches ever run concurrently -- a - // strict bound, the same one the previous eager implementation - // enforced with its shared worker goroutines. It gives stop() + // The dispatcher below walks candidates in their given order, + // acquiring a dispatchSlots slot before starting each one's fetch, so + // at most `workers` fetches ever run concurrently. It gives stop() // priority over dispatching another candidate: it checks // pipeline.stopCh non-blockingly before attempting to acquire a slot, // and returns immediately without acquiring or launching if stop has @@ -1046,52 +1133,30 @@ func (rat *ReservationAcceptanceTask) fetchReservationAcceptanceFundingTxs( return pipeline } -// reservationAcceptancePreWriteError wraps a proposeReservationAcceptance -// failure that occurred before any chain-state-mutating call. The pending -// acceptance generation this proposal consumes was created on-chain by -// the deposit's own depositor, not by this task, so candidate selection -// and proposal building are read-only: an assemble or validation failure -// can be retried on the next window and can never leave a partial -// on-chain effect. The caller (Run) skips the doomed deposit in favor of -// the next candidate instead of aborting the whole coordination window. -type reservationAcceptancePreWriteError struct { - err error -} - -func (e *reservationAcceptancePreWriteError) Error() string { - return e.err.Error() -} - -func (e *reservationAcceptancePreWriteError) Unwrap() error { - return e.err -} - +// proposeReservationAcceptance assembles and validates the anchor proposal +// for an already selected candidate. The pending acceptance generation the +// proposal consumes was created on-chain by the deposit's own depositor +// (the only caller permitted to request acceptance), so this builder +// performs no chain-state-mutating call and any error leaves nothing +// behind; Run skips the candidate on error. func (rat *ReservationAcceptanceTask) proposeReservationAcceptance( taskLogger log.StandardLogger, walletPublicKeyHash [20]byte, candidate *reservationAcceptanceCandidate, -) (*tbtc.ReservationAnchorProposal, bool, error) { +) (*tbtc.ReservationAnchorProposal, error) { if candidate == nil || candidate.Deposit == nil { - return nil, false, fmt.Errorf("candidate is required") + return nil, fmt.Errorf("candidate is required") } taskLogger.Infof("preparing a reservation acceptance proposal") - // 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. + // The anchor fee and its net-of-fee viability were already computed + // and validated during candidate selection, so a doomed candidate is + // skipped there in favor of the next one. anchorFee := candidate.AnchorFee taskLogger.Infof("anchor transaction fee: [%d]", anchorFee) - // The pending acceptance generation this proposal consumes was already - // created on-chain by the deposit's own depositor (the only caller - // permitted to request acceptance); candidate selection read it, so - // this builder performs no chain-state-mutating call of its own and - // its validation is a clean pre-write check. feeBoundAction := &tbtc.ReservationAction{ TxMaxFee: candidate.TxMaxFee, } @@ -1103,12 +1168,10 @@ func (rat *ReservationAcceptanceTask) proposeReservationAcceptance( feeBoundAction, anchorFee, ); err != nil { - return nil, false, &reservationAcceptancePreWriteError{ - err: fmt.Errorf( - "cannot assemble reservation anchor transaction: [%v]", - err, - ), - } + return nil, fmt.Errorf( + "cannot assemble reservation anchor transaction: [%v]", + err, + ) } proposal := &tbtc.ReservationAnchorProposal{ @@ -1131,15 +1194,13 @@ func (rat *ReservationAcceptanceTask) proposeReservationAcceptance( FundingTx: candidate.FundingTx, }, ); err != nil { - return nil, false, &reservationAcceptancePreWriteError{ - err: fmt.Errorf( - "failed to verify reservation anchor proposal: %v", - err, - ), - } + return nil, fmt.Errorf( + "failed to verify reservation anchor proposal: %v", + err, + ) } - return proposal, true, nil + return proposal, nil } func estimateReservationAcceptanceFee( diff --git a/pkg/tbtcpg/reservation_acceptance_export_test.go b/pkg/tbtcpg/reservation_acceptance_export_test.go new file mode 100644 index 0000000000..b464d91d80 --- /dev/null +++ b/pkg/tbtcpg/reservation_acceptance_export_test.go @@ -0,0 +1,11 @@ +package tbtcpg + +// SetMetricsRecorderForTest exposes setMetricsRecorder to the external +// tbtcpg_test package, whose acceptance fixtures build full candidates. +func (rat *ReservationAcceptanceTask) SetMetricsRecorderForTest( + recorder interface { + SetGauge(name string, value float64) + }, +) { + rat.setMetricsRecorder(recorder) +} diff --git a/pkg/tbtcpg/reservation_acceptance_metrics_test.go b/pkg/tbtcpg/reservation_acceptance_metrics_test.go index c9f2079bf9..b9aa58584f 100644 --- a/pkg/tbtcpg/reservation_acceptance_metrics_test.go +++ b/pkg/tbtcpg/reservation_acceptance_metrics_test.go @@ -61,31 +61,6 @@ func TestReservationAcceptanceTask_RecordsSaturationGauges(t *testing.T) { // distinguish a real wired value from a coincidental zero default. lc.SetWalletReservations(walletPublicKeyHash, []*big.Int{big.NewInt(1), big.NewInt(2)}) - // Run scans PastDepositRevealedEvents with a filter bounded by - // ReservationAcceptanceLookBackBlocks; register an empty match so the - // call succeeds and Run proceeds to (correctly) report no candidate, - // rather than erroring on an unregistered filter. - currentBlock := uint64(300000) - filterStartBlock := currentBlock - ReservationAcceptanceLookBackBlocks - if err := lc.AddPastDepositRevealedEvent( - &tbtc.DepositRevealedEventFilter{ - StartBlock: filterStartBlock, - EndBlock: ¤tBlock, - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - }, - &tbtc.DepositRevealedEvent{ - // Targets a different vault so it is filtered out immediately - // without needing a matching deposit request/funding tx. - BlockNumber: filterStartBlock, - WalletPublicKeyHash: walletPublicKeyHash, - Vault: &[]chain.Address{chain.Address( - "0xOtherVaultAddress1234567890abcdef123456789012", - )}[0], - }, - ); err != nil { - t.Fatal(err) - } - task := NewReservationAcceptanceTask(lc, btcChain) recorder := newFakeMetricsRecorder() task.setMetricsRecorder(recorder) @@ -118,9 +93,9 @@ func TestReservationAcceptanceTask_RecordsSaturationGauges(t *testing.T) { "expected reservation_vault_fee_debt_sat gauge to be recorded", ) } - if _, ok := recorder.calls["reservation_vault_fee_reserve_tbtc"]; !ok { + if _, ok := recorder.calls["reservation_vault_fee_reserve_tbtc_base_units"]; !ok { t.Error( - "expected reservation_vault_fee_reserve_tbtc gauge to be recorded", + "expected reservation_vault_fee_reserve_tbtc_base_units gauge to be recorded", ) } } @@ -137,11 +112,15 @@ type feeObservabilityChain struct { feeReserveErr error } -func (c *feeObservabilityChain) ReservationVaultFeeDebtSat() (uint64, error) { +func (c *feeObservabilityChain) ReservationVaultFeeDebtSat( + reservationVault chain.Address, +) (uint64, error) { return c.feeDebtSat, c.feeDebtErr } -func (c *feeObservabilityChain) ReservationVaultFeeReserveTbtcBaseUnits() (*big.Int, error) { +func (c *feeObservabilityChain) ReservationVaultFeeReserveTbtcBaseUnits( + reservationVault chain.Address, +) (*big.Int, error) { return c.feeReserve, c.feeReserveErr } @@ -187,11 +166,11 @@ func TestReservationAcceptanceTask_RecordsVaultFeeGauges(t *testing.T) { // task's big.Int -> float64 conversion), so the assertion tracks // the exact value the recorder would have received. wantReserve, _ := new(big.Int).Lsh(big.NewInt(2), 18).Float64() - if got, ok := recorder.calls["reservation_vault_fee_reserve_tbtc"]; !ok { - t.Error("expected reservation_vault_fee_reserve_tbtc gauge to be recorded") + if got, ok := recorder.calls["reservation_vault_fee_reserve_tbtc_base_units"]; !ok { + t.Error("expected reservation_vault_fee_reserve_tbtc_base_units gauge to be recorded") } else if got != wantReserve { t.Errorf( - "expected reservation_vault_fee_reserve_tbtc = %v, got %v", + "expected reservation_vault_fee_reserve_tbtc_base_units = %v, got %v", wantReserve, got, ) @@ -234,9 +213,9 @@ func TestReservationAcceptanceTask_VaultFeeReadErrorKeepsGauges(t *testing.T) { "reservation_vault_fee_debt_sat must not be published on read error", ) } - if _, ok := recorder.calls["reservation_vault_fee_reserve_tbtc"]; ok { + if _, ok := recorder.calls["reservation_vault_fee_reserve_tbtc_base_units"]; ok { t.Error( - "reservation_vault_fee_reserve_tbtc must not be published on read error", + "reservation_vault_fee_reserve_tbtc_base_units must not be published on read error", ) } @@ -249,7 +228,7 @@ func TestReservationAcceptanceTask_VaultFeeReadErrorKeepsGauges(t *testing.T) { // TestReservationAcceptanceTask_ZeroVaultAddressZeroesFeeGauges asserts // an unconfigured (zero-address) reservation vault short-circuits -// findReservationAcceptanceCandidate before any fee-gauge read chain +// findReservationAcceptanceCandidates before any fee-gauge read chain // call (the wrapper's nonzero fee values below are tripwires: // publishing them would mean the fee methods ran on the unconfigured // path), while the two fee gauges are still published as zero: a @@ -295,10 +274,10 @@ func TestReservationAcceptanceTask_ZeroVaultAddressZeroesFeeGauges(t *testing.T) } else if got != 0 { t.Errorf("expected reservation_vault_fee_debt_sat = 0, got %v", got) } - if got, ok := recorder.calls["reservation_vault_fee_reserve_tbtc"]; !ok { - t.Error("expected reservation_vault_fee_reserve_tbtc gauge to be recorded") + if got, ok := recorder.calls["reservation_vault_fee_reserve_tbtc_base_units"]; !ok { + t.Error("expected reservation_vault_fee_reserve_tbtc_base_units gauge to be recorded") } else if got != 0 { - t.Errorf("expected reservation_vault_fee_reserve_tbtc = 0, got %v", got) + t.Errorf("expected reservation_vault_fee_reserve_tbtc_base_units = 0, got %v", got) } } @@ -339,7 +318,7 @@ func TestReservationAcceptanceTask_UnconfiguredVaultZeroesFeeGauges(t *testing.T if got := recorder.calls["reservation_vault_fee_debt_sat"]; got != 12345 { t.Fatalf("pass 1: expected fee-debt gauge 12345, got %v", got) } - if got := recorder.calls["reservation_vault_fee_reserve_tbtc"]; got == 0 { + if got := recorder.calls["reservation_vault_fee_reserve_tbtc_base_units"]; got == 0 { t.Fatalf("pass 1: expected a nonzero fee-reserve gauge, got 0") } @@ -364,7 +343,7 @@ func TestReservationAcceptanceTask_UnconfiguredVaultZeroesFeeGauges(t *testing.T got, ) } - if got := recorder.calls["reservation_vault_fee_reserve_tbtc"]; got != 0 { + if got := recorder.calls["reservation_vault_fee_reserve_tbtc_base_units"]; got != 0 { t.Errorf( "pass 2: expected the fee-reserve gauge zeroed for an "+ "unconfigured vault, got %v", @@ -374,9 +353,9 @@ func TestReservationAcceptanceTask_UnconfiguredVaultZeroesFeeGauges(t *testing.T } // runFeeGaugeFixture configures a LocalChain with a live wallet, a -// configured (non-zero) reservation vault, a block counter, and one -// DepositRevealed event targeting a different vault so the scan -// completes with no candidate. Shared by the fee-gauge tests. +// configured (non-zero) reservation vault and a block counter. The +// embedded LocalChain reports no acceptance requests, so Run completes +// with no candidate. Shared by the fee-gauge tests. func runFeeGaugeFixture(t *testing.T, lc *LocalChain) { t.Helper() @@ -397,30 +376,11 @@ func runFeeGaugeFixture(t *testing.T, lc *LocalChain) { blockCounter := NewMockBlockCounter() blockCounter.SetCurrentBlock(300000) lc.SetBlockCounter(blockCounter) - - currentBlock := uint64(300000) - filterStartBlock := currentBlock - ReservationAcceptanceLookBackBlocks - if err := lc.AddPastDepositRevealedEvent( - &tbtc.DepositRevealedEventFilter{ - StartBlock: filterStartBlock, - EndBlock: ¤tBlock, - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - }, - &tbtc.DepositRevealedEvent{ - BlockNumber: filterStartBlock, - WalletPublicKeyHash: walletPublicKeyHash, - Vault: &[]chain.Address{chain.Address( - "0xOtherVaultAddress1234567890abcdef123456789012", - )}[0], - }, - ); err != nil { - t.Fatal(err) - } } // runZeroVaultFixture configures a LocalChain with a live wallet and a // zero-address reservation vault, so -// findReservationAcceptanceCandidate returns at the "reservation +// findReservationAcceptanceCandidates returns at the "reservation // vault not configured" gate before any chain read (including the // fee reads) or any gauge publication. func runZeroVaultFixture(t *testing.T, lc *LocalChain) { @@ -442,7 +402,7 @@ func runZeroVaultFixture(t *testing.T, lc *LocalChain) { // through Run(), so these tests can hold each candidate's Bitcoin RPCs // under precise, deterministic control (artificial concurrency tracking, // artificial per-candidate latency, and a context that is never -// resolved) without depending on findReservationAcceptanceCandidate's +// resolved) without depending on findReservationAcceptanceCandidates' // unrelated eligibility gates. // concurrencyTrackingBTCChain wraps LocalBitcoinChain and records, across @@ -633,9 +593,9 @@ func (c *latencyBTCChain) GetTransaction( // TestFetchReservationAcceptanceFundingTxs_OldestFirstOrderingPreserved is // a regression test asserting that next(i) always returns candidate i's -// own lookup result, even when a later (in oldest-first order) +// own lookup result, even when a later (in candidate order) // candidate's fetch races ahead and completes first -- the property -// findReservationAcceptanceCandidate's oldest-first selection loop +// Run's in-order candidate loop // depends on to return the correct candidate regardless of which // underlying network call happens to finish first. func TestFetchReservationAcceptanceFundingTxs_OldestFirstOrderingPreserved(t *testing.T) { @@ -721,7 +681,7 @@ func (c *countingBTCChain) callCount() int { // TestFetchReservationAcceptanceFundingTxs_StopsAfterEarlyMatch is a // regression test for the eager-fetch cost the PR-4324 review flagged: a -// caller that only needs the oldest-first candidate's result and then +// caller that only needs the first candidate's result and then // calls stop() must not cause every remaining candidate's Bitcoin RPCs to // be dispatched, even with a full maxReservationAcceptanceCandidatesPerRun // backlog pending. Before the fix, this scenario issued a GetTransaction @@ -787,157 +747,3 @@ func TestFetchReservationAcceptanceFundingTxs_StopsAfterEarlyMatch(t *testing.T) ) } } - -// activeReservationsCapChain wraps LocalChain, overriding -// ActiveReservationsCount to report a usable (count, cap) pair. The -// embedded LocalChain's own ActiveReservationsCount always reports (0, 0), -// and checkReservationAcceptanceEligibility fails closed whenever -// maxActiveReservations is 0 (treating it as "not configured" rather than -// unlimited), so no candidate can ever pass eligibility against the bare -// fixture -- this override is required for any test that needs -// findReservationAcceptanceCandidate to actually find a candidate. -type activeReservationsCapChain struct { - *LocalChain -} - -func (c *activeReservationsCapChain) ActiveReservationsCount() (uint32, uint32, error) { - return 0, 1000, nil -} - -// TestFindReservationAcceptanceCandidate_SkippedCandidatesDoNotConsumeCap -// is a regression test for the cap-vs-skip ordering bug the PR-4324 review -// flagged: candidatesExamined used to be incremented before the -// skipDepositKeys check, so a deposit already rejected earlier in the same -// Run() call -- and therefore present in skipDepositKeys on Run's retry -- -// still consumed a slot of the maxReservationAcceptanceCandidatesPerRun -// budget even though it was never actually re-examined this pass. This -// registers exactly maxReservationAcceptanceCandidatesPerRun already- -// skipped candidates ahead of one genuinely eligible candidate, in -// oldest-first order: under the pre-fix ordering the cap would be -// exhausted by the skipped candidates alone, and the eligible one -- which -// sorts after all of them -- would never be reached. Under the fix, -// skipped candidates cost nothing against the cap and the eligible one is -// found. -func TestFindReservationAcceptanceCandidate_SkippedCandidatesDoNotConsumeCap(t *testing.T) { - lc := &activeReservationsCapChain{LocalChain: NewLocalChain()} - btcChain := NewLocalBitcoinChain() - btcChain.SetEstimateSatPerVByteFee(1, 1) - - walletPublicKeyHash := [20]byte{9, 8, 7, 6, 5, 4, 3, 2, 1} - vault := chain.Address("0xReservationVaultAddress1234567890abcdef12345678") - - lc.SetReservationParameters(tbtc.ReservationParameters{ - ReservationVault: vault, - ReservationMinAmount: 1000, - ReservationTxMaxFee: 5000, - MaxReservationsPerWallet: 5, - ReservationMaxTotalAmount: 100000000, - }) - lc.SetWallet(walletPublicKeyHash, &tbtc.WalletChainData{State: tbtc.StateLive}) - lc.SetDepositMinAge(3600) - - blockCounter := NewMockBlockCounter() - blockCounter.SetCurrentBlock(300000) - lc.SetBlockCounter(blockCounter) - - currentBlock := uint64(300000) - filterStartBlock := currentBlock - ReservationAcceptanceLookBackBlocks - filter := &tbtc.DepositRevealedEventFilter{ - StartBlock: filterStartBlock, - EndBlock: ¤tBlock, - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - } - - // maxReservationAcceptanceCandidatesPerRun candidates, all pre-marked - // as already skipped and given the earliest block numbers, so - // oldest-first order walks every one of them before the eligible - // candidate registered below. - skipDepositKeys := make(map[string]bool) - for i := range maxReservationAcceptanceCandidatesPerRun { - fundingTxHash := bitcoin.Hash{byte(i)} - if err := lc.AddPastDepositRevealedEvent(filter, &tbtc.DepositRevealedEvent{ - BlockNumber: filterStartBlock + uint64(i), - WalletPublicKeyHash: walletPublicKeyHash, - Vault: &vault, - FundingTxHash: fundingTxHash, - FundingOutputIndex: 0, - }); err != nil { - t.Fatal(err) - } - depositKey := lc.BuildDepositKey(fundingTxHash, 0) - skipDepositKeys[depositKey.Text(16)] = true - } - - // One genuinely eligible candidate, given the latest block number so - // it is walked last, after every skipped candidate above. - eligibleFundingTxHash := bitcoin.Hash{0xEE} - eligibleAmount := uint64(2000000) - if err := lc.AddPastDepositRevealedEvent(filter, &tbtc.DepositRevealedEvent{ - BlockNumber: filterStartBlock + uint64(maxReservationAcceptanceCandidatesPerRun), - WalletPublicKeyHash: walletPublicKeyHash, - Vault: &vault, - FundingTxHash: eligibleFundingTxHash, - FundingOutputIndex: 0, - }); err != nil { - t.Fatal(err) - } - lc.SetDepositRequest(eligibleFundingTxHash, 0, &tbtc.DepositChainRequest{ - Amount: eligibleAmount, - RevealedAt: time.Now().Add(-2 * time.Hour), - SweptAt: time.Unix(0, 0), - Vault: &vault, - }) - btcChain.SetTransaction(eligibleFundingTxHash, &bitcoin.Transaction{}) - btcChain.SetTransactionConfirmations( - eligibleFundingTxHash, - tbtc.DepositSweepRequiredFundingTxConfirmations, - ) - eligibleDepositKey := lc.BuildDepositKey(eligibleFundingTxHash, 0) - lc.SetReservation(eligibleDepositKey, &tbtc.Reservation{ - State: tbtc.ReservationStateUnknown, - RequestNonce: 0, - }) - // Seed a Pending Acceptance action at nonce 0 targeting the wallet - // so the candidate clears findReservationAcceptanceCandidate's - // action-record gate -- the precondition the production validator - // enforces -- and the cap-vs-skip ordering being tested is the only - // thing that can keep this candidate from being returned. - lc.SetReservationAction(eligibleDepositKey, 0, &tbtc.ReservationAction{ - ActionType: tbtc.ReservationActionTypeAcceptance, - State: tbtc.ReservationActionStatePending, - TargetWalletPublicKeyHash: walletPublicKeyHash, - TxMaxFee: 5000, - MinAmount: 1000, - TermSeconds: 86400, - // Far-future TimeoutAt so the validator's timeout safety-margin - // gate (REQUEST_TIMEOUT_SAFETY_MARGIN, 2 hours) does not skip - // this candidate; the cap-vs-skip ordering is the only thing - // under test here. - TimeoutAt: uint32(time.Now().Add(24 * time.Hour).Unix()), - }) - - task := NewReservationAcceptanceTask(lc, btcChain) - - candidate, err := task.findReservationAcceptanceCandidate( - logger, - walletPublicKeyHash, - skipDepositKeys, - ) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if candidate == nil { - t.Fatal( - "expected the eligible candidate past the skipped window to " + - "be found; got nil, which means the skipped candidates " + - "consumed the per-run cap before it was ever examined", - ) - } - if candidate.DepositKey.Cmp(eligibleDepositKey) != 0 { - t.Errorf( - "expected the found candidate's deposit key to be [%v], got [%v]", - eligibleDepositKey, - candidate.DepositKey, - ) - } -} diff --git a/pkg/tbtcpg/reservation_acceptance_test.go b/pkg/tbtcpg/reservation_acceptance_test.go index 60240f3081..060cb19fe2 100644 --- a/pkg/tbtcpg/reservation_acceptance_test.go +++ b/pkg/tbtcpg/reservation_acceptance_test.go @@ -3,7 +3,7 @@ package tbtcpg_test import ( "crypto/ecdsa" "crypto/rand" - "crypto/sha256" + "encoding/binary" "fmt" "math/big" "testing" @@ -33,56 +33,146 @@ const testReservationVaultAddress = chain.Address( "0xReservationVaultAddress1234567890abcdef12345678", ) +// testDepositor is the depositor of every deposit built by this file's +// fixtures. +const testDepositor = chain.Address("934b98637ca318a4d6e7ca6ffd1690b8e77df637") + +// testReservationTermSeconds is the snapshotted custody term of the +// fixtures' pending acceptance generations: MIN_RESERVATION_TERM (90 +// days), the shortest term Reservation.sol accepts. +const testReservationTermSeconds = uint32(90 * 24 * 60 * 60) + +// testDepositMinAgeSeconds is WalletProposalValidator.sol's DEPOSIT_MIN_AGE +// (2 hours). +const testDepositMinAgeSeconds = uint32(7200) + // 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 -// shared chain_test.go fixture used by sibling builders. +// Its PastDepositRevealedEvents and PastReservationAcceptanceRequestedEvents +// answer range queries the way an Ethereum node does, from events +// registered on this type. type reservationAcceptanceLocalChain struct { *tbtcpg.LocalChain - 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 + 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 + acceptanceEventFilters []tbtc.ReservationAcceptanceRequestedEventFilter + + revealEvents []*tbtc.DepositRevealedEvent pastDepositRevealedEventsErr error + + getReservationCalls int + validateCalls int + feeDebtVaults []chain.Address + feeReserveVaults []chain.Address } func newReservationAcceptanceLocalChain() *reservationAcceptanceLocalChain { - lc := tbtcpg.NewLocalChain() return &reservationAcceptanceLocalChain{ - LocalChain: lc, + LocalChain: tbtcpg.NewLocalChain(), } } -// PastDepositRevealedEvents overrides the embedded LocalChain -// 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. +// PastDepositRevealedEvents returns every registered reveal whose block is +// in [StartBlock, EndBlock] and whose wallet matches the filter. It +// rejects unbounded queries and queries wider than +// tbtc.DepositRevealLookupChunkBlocks, the range many providers cap log +// queries at, so a lookup that stops chunking fails loudly. 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 { - if err.Error() == "no events for given filter" { - return []*tbtc.DepositRevealedEvent{}, nil + if filter == nil || filter.EndBlock == nil { + return nil, fmt.Errorf("unbounded deposit revealed events query") + } + if *filter.EndBlock < filter.StartBlock || + *filter.EndBlock-filter.StartBlock+1 > tbtc.DepositRevealLookupChunkBlocks { + return nil, fmt.Errorf( + "invalid deposit revealed events block range [%d, %d]", + filter.StartBlock, + *filter.EndBlock, + ) + } + + var result []*tbtc.DepositRevealedEvent + for _, event := range ralc.revealEvents { + if event.BlockNumber < filter.StartBlock || + event.BlockNumber > *filter.EndBlock { + continue } - return nil, err + if !walletMatches(filter.WalletPublicKeyHash, event.WalletPublicKeyHash) { + continue + } + result = append(result, event) + } + return result, nil +} + +// PastReservationAcceptanceRequestedEvents returns every registered request +// matching the filter's block range, wallets and reservation keys, and +// records the filter. +func (ralc *reservationAcceptanceLocalChain) PastReservationAcceptanceRequestedEvents( + filter *tbtc.ReservationAcceptanceRequestedEventFilter, +) ([]*tbtc.ReservationAcceptanceRequestedEvent, error) { + if ralc.acceptanceEventsErr != nil { + return nil, ralc.acceptanceEventsErr + } + if filter == nil { + return nil, fmt.Errorf("unfiltered acceptance requested events query") + } + ralc.acceptanceEventFilters = append(ralc.acceptanceEventFilters, *filter) + + var results []*tbtc.ReservationAcceptanceRequestedEvent + for _, event := range ralc.acceptanceEvents { + if event.BlockNumber < filter.StartBlock { + continue + } + if filter.EndBlock != nil && event.BlockNumber > *filter.EndBlock { + continue + } + if !walletMatches(filter.WalletPublicKeyHash, event.WalletPublicKeyHash) { + continue + } + if len(filter.ReservationKey) > 0 { + match := false + for _, k := range filter.ReservationKey { + if k != nil && k.Cmp(event.ReservationKey) == 0 { + match = true + break + } + } + if !match { + continue + } + } + results = append(results, event) + } + return results, nil +} + +func walletMatches(filter [][20]byte, wallet [20]byte) bool { + if len(filter) == 0 { + return true } - return events, nil + for _, w := range filter { + if w == wallet { + return true + } + } + return false } func (ralc *reservationAcceptanceLocalChain) ReservationCaps() ( @@ -129,27 +219,21 @@ 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 production's -// fail-safe candidate-selection skip path (see production's documented -// deviation above the call site): the failing candidate is skipped for -// the window rather than treated as "not yet created". +// GetReservation delegates to the embedded LocalChain and counts calls, +// 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. // // The embedded LocalChain.GetReservation errors for a reservation key that // was never registered via SetReservation, but the real chain adapter -// (pkg/chain/ethereum/tbtc_reservation.go's GetReservation) reads a Solidity mapping, -// which never errors for an absent key -- it returns the zero-value -// struct (State == ReservationStateUnknown, RequestNonce == 0). This -// override normalizes the embedded mock's "not found" error into that -// same zero-value record so every other test in this file (none of which -// pre-register a reservation for a brand-new candidate deposit) continues -// to exercise the "not yet created" path production actually takes. +// reads a Solidity mapping, which never errors for an absent key -- it +// returns the zero-value struct (State == ReservationStateUnknown, +// RequestNonce == 0). This override normalizes the embedded mock's "not +// found" error into that same zero-value record. func (ralc *reservationAcceptanceLocalChain) GetReservation( reservationKey *big.Int, ) (*tbtc.Reservation, error) { + ralc.getReservationCalls++ if ralc.getReservationErr != nil { err := ralc.getReservationErr ralc.getReservationErr = nil @@ -165,12 +249,12 @@ func (ralc *reservationAcceptanceLocalChain) GetReservation( return reservation, nil } -// 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. +// ValidateReservationAnchorProposal counts calls and returns validateErr +// when set. Otherwise it checks the proposal's funding outpoint against +// the candidate deposit's own outpoint (the candidate-deposit mapping +// step) and then applies the embedded LocalChain's strict validator, +// which mirrors every precondition of +// WalletProposalValidator.validateReservationAnchorProposal. func (ralc *reservationAcceptanceLocalChain) ValidateReservationAnchorProposal( walletPublicKeyHash [20]byte, proposal *tbtc.ReservationAnchorProposal, @@ -179,6 +263,7 @@ func (ralc *reservationAcceptanceLocalChain) ValidateReservationAnchorProposal( FundingTx *bitcoin.Transaction }, ) error { + ralc.validateCalls++ if ralc.validateErr != nil { return ralc.validateErr } @@ -190,53 +275,256 @@ func (ralc *reservationAcceptanceLocalChain) ValidateReservationAnchorProposal( ) } 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 { + if outpoint.TransactionHash != proposal.DepositFundingTxHash || + outpoint.OutputIndex != proposal.DepositFundingOutputIndex { return fmt.Errorf( - "validate reservation anchor proposal: funding output index mismatch: "+ - "proposal=[%d] candidate=[%d]", - proposal.DepositFundingOutputIndex, - outpoint.OutputIndex, + "validate reservation anchor proposal: funding outpoint mismatch", ) } - return nil + return ralc.LocalChain.ValidateReservationAnchorProposal( + walletPublicKeyHash, + proposal, + depositExtraInfo, + ) } -func (ralc *reservationAcceptanceLocalChain) PastReservationAcceptanceRequestedEvents( - filter *tbtc.ReservationAcceptanceRequestedEventFilter, -) ([]*tbtc.ReservationAcceptanceRequestedEvent, error) { - if ralc.acceptanceEventsErr != nil { - return nil, ralc.acceptanceEventsErr + +// ReservationVaultFeeDebtSat records the vault it was asked about and +// delegates to the embedded LocalChain. +func (ralc *reservationAcceptanceLocalChain) ReservationVaultFeeDebtSat( + reservationVault chain.Address, +) (uint64, error) { + ralc.feeDebtVaults = append(ralc.feeDebtVaults, reservationVault) + return ralc.LocalChain.ReservationVaultFeeDebtSat(reservationVault) +} + +// ReservationVaultFeeReserveTbtcBaseUnits records the vault it was asked +// about and delegates to the embedded LocalChain. +func (ralc *reservationAcceptanceLocalChain) ReservationVaultFeeReserveTbtcBaseUnits( + reservationVault chain.Address, +) (*big.Int, error) { + ralc.feeReserveVaults = append(ralc.feeReserveVaults, reservationVault) + return ralc.LocalChain.ReservationVaultFeeReserveTbtcBaseUnits(reservationVault) +} + +// futureRefundLocktime returns a refund locktime 180 days ahead, +// little-endian as the deposit script and the on-chain validator hold it, +// so the validator's 24-hour refund safety margin holds. +func futureRefundLocktime() [4]byte { + var locktime [4]byte + binary.LittleEndian.PutUint32( + locktime[:], + uint32(time.Now().Add(180*24*time.Hour).Unix()), + ) + return locktime +} + +// buildReservedDeposit builds a deposit revealed to walletPublicKeyHash +// through the test reservation vault, together with a funding transaction +// whose output 0 locks amount with the deposit's P2WSH script. seed makes +// the deposit, and so its funding transaction hash, unique. +func buildReservedDeposit( + t *testing.T, + walletPublicKeyHash [20]byte, + amount uint64, + seed byte, + vault chain.Address, +) (*tbtc.Deposit, *bitcoin.Transaction) { + t.Helper() + + deposit := &tbtc.Deposit{ + Depositor: testDepositor, + BlindingFactor: [8]byte{seed, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}, + WalletPublicKeyHash: walletPublicKeyHash, + RefundPublicKeyHash: [20]byte{0x02, seed}, + RefundLocktime: futureRefundLocktime(), + Vault: &vault, } - var results []*tbtc.ReservationAcceptanceRequestedEvent - for _, event := range ralc.acceptanceEvents { - if filter != nil && len(filter.ReservationKey) > 0 { - match := false - for _, k := range filter.ReservationKey { - if k != nil && event.ReservationKey != nil && k.Cmp(event.ReservationKey) == 0 { - match = true - break - } - } - if !match { - continue - } - } - results = append(results, event) + + depositScript, err := deposit.Script() + if err != nil { + t.Fatal(err) } - return results, nil + depositLockingScript, err := bitcoin.PayToWitnessScriptHash( + bitcoin.WitnessScriptHash(depositScript), + ) + if err != nil { + t.Fatal(err) + } + + fundingTx := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x09, seed}, + OutputIndex: 0, + }, + Sequence: 0xffffffff, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: int64(amount), + PublicKeyScript: depositLockingScript, + }}, + } + + deposit.Utxo = &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTx.Hash(), + OutputIndex: 0, + }, + Value: int64(amount), + } + + return deposit, fundingTx } -func (ralc *reservationAcceptanceLocalChain) AddPastReservationAcceptanceRequestedEvent( - event *tbtc.ReservationAcceptanceRequestedEvent, -) { - ralc.acceptanceEvents = append(ralc.acceptanceEvents, event) +// pendingDepositOptions describes a reserved deposit whose depositor +// requested acceptance, as addPendingDeposit seeds it. +type pendingDepositOptions struct { + amount uint64 + seed byte + revealBlock uint64 + requestBlock uint64 + revealedAt time.Time + confirmations uint + requestNonce uint64 + txMaxFee uint64 + minAmount uint64 + termSeconds uint32 + timeoutAt uint32 + actionState tbtc.ReservationActionState + // withoutAction seeds the request event but no reservation or + // action record, as when the generation's record is gone. + withoutAction bool + // revealVault, when set, overrides the vault in the reveal event. + revealVault *chain.Address +} + +// defaultPendingDepositOptions returns options for a mature, confirmed +// 2,000,000 sat deposit revealed 1000 blocks and requested 10 blocks +// before currentBlock, whose nonce-1 Pending Acceptance generation +// snapshots a 5000 sat max fee, a 1000 sat minimum, a 90-day term and a +// timeout 24 hours ahead. +func defaultPendingDepositOptions(currentBlock uint64) pendingDepositOptions { + return pendingDepositOptions{ + amount: 2000000, + seed: 0x01, + revealBlock: currentBlock - 1000, + requestBlock: currentBlock - 10, + revealedAt: time.Now().Add(-3 * time.Hour), + confirmations: tbtc.DepositSweepRequiredFundingTxConfirmations, + requestNonce: 1, + txMaxFee: 5000, + minAmount: 1000, + termSeconds: testReservationTermSeconds, + timeoutAt: uint32(time.Now().Add(24 * time.Hour).Unix()), + actionState: tbtc.ReservationActionStatePending, + } +} + +// pendingDeposit is a deposit seeded by addPendingDeposit. +type pendingDeposit struct { + fundingTxHash bitcoin.Hash + depositKey *big.Int + deposit *tbtc.Deposit +} + +// addPendingDeposit seeds everything the chain and the Bitcoin chain hold +// for a reserved deposit whose depositor requested acceptance by +// walletPublicKeyHash: the funding transaction and its confirmations, the +// deposit request, the reserved flag, the DepositRevealed event, the +// reservation and its acceptance action record, and the +// ReservationAcceptanceRequested event. +func addPendingDeposit( + t *testing.T, + ralc *reservationAcceptanceLocalChain, + btcChain *tbtcpg.LocalBitcoinChain, + walletPublicKeyHash [20]byte, + opts pendingDepositOptions, +) *pendingDeposit { + t.Helper() + + vault := testReservationVaultAddress + if params, err := ralc.ReservationParameters(); err == nil && + params.ReservationVault != "" { + vault = params.ReservationVault + } + + deposit, fundingTx := buildReservedDeposit( + t, + walletPublicKeyHash, + opts.amount, + opts.seed, + vault, + ) + fundingTxHash := fundingTx.Hash() + depositKey := ralc.BuildDepositKey(fundingTxHash, 0) + + btcChain.SetEstimateSatPerVByteFee(1, 1) + btcChain.SetTransaction(fundingTxHash, fundingTx) + btcChain.SetTransactionConfirmations(fundingTxHash, opts.confirmations) + + ralc.SetDepositRequest(fundingTxHash, 0, &tbtc.DepositChainRequest{ + Depositor: testDepositor, + Amount: opts.amount, + RevealedAt: opts.revealedAt, + SweptAt: time.Unix(0, 0), + Vault: &vault, + }) + ralc.SetReservedDeposit(depositKey, true) + + revealVault := &vault + if opts.revealVault != nil { + revealVault = opts.revealVault + } + ralc.revealEvents = append(ralc.revealEvents, &tbtc.DepositRevealedEvent{ + BlockNumber: opts.revealBlock, + WalletPublicKeyHash: walletPublicKeyHash, + FundingTxHash: fundingTxHash, + FundingOutputIndex: 0, + Depositor: testDepositor, + Amount: opts.amount, + BlindingFactor: deposit.BlindingFactor, + RefundPublicKeyHash: deposit.RefundPublicKeyHash, + RefundLocktime: deposit.RefundLocktime, + Vault: revealVault, + }) + + if !opts.withoutAction { + ralc.SetReservation(depositKey, &tbtc.Reservation{ + WalletPublicKeyHash: walletPublicKeyHash, + State: tbtc.ReservationStateUnknown, + RequestNonce: opts.requestNonce, + }) + ralc.SetReservationAction(depositKey, opts.requestNonce, &tbtc.ReservationAction{ + ActionType: tbtc.ReservationActionTypeAcceptance, + State: opts.actionState, + TargetWalletPublicKeyHash: walletPublicKeyHash, + TxMaxFee: opts.txMaxFee, + MinAmount: opts.minAmount, + TermSeconds: opts.termSeconds, + TimeoutAt: opts.timeoutAt, + Amount: opts.amount, + }) + } + + ralc.acceptanceEvents = append( + ralc.acceptanceEvents, + &tbtc.ReservationAcceptanceRequestedEvent{ + ReservationKey: depositKey, + RequestNonce: opts.requestNonce, + WalletPublicKeyHash: walletPublicKeyHash, + DepositAmount: opts.amount, + TxMaxFee: opts.txMaxFee, + TimeoutAt: opts.timeoutAt, + BlockNumber: opts.requestBlock, + }, + ) + + return &pendingDeposit{ + fundingTxHash: fundingTxHash, + depositKey: depositKey, + deposit: deposit, + } } // scenarioReservationAcceptanceChain wires a scenario's on-chain state @@ -261,6 +549,8 @@ func scenarioReservationAcceptanceChain( ReservationMaxTotalAmount: scenario.ReservationParameters.ReservationMaxTotalAmount, ReservationTotalAmount: scenario.ReservationParameters.ReservationTotalAmount, MaxReservationsPerWallet: scenario.ReservationParameters.MaxReservationsPerWallet, + ReservationTermSeconds: testReservationTermSeconds, + ReservationActionTimeout: 24 * 60 * 60, }) ralc.maxPerWalletAmount = scenario.Caps.MaxReservationsAmountPerWallet @@ -272,16 +562,21 @@ func scenarioReservationAcceptanceChain( ralc.pendingReserved = scenario.PendingReservedDeposits ralc.SetDepositMinAge(scenario.ChainParameters.DepositMinAge) + ralc.SetAverageBlockTime(scenario.ChainParameters.AverageBlockTime) blockCounter := tbtcpg.NewMockBlockCounter() blockCounter.SetCurrentBlock(scenario.ChainParameters.CurrentBlock) ralc.SetBlockCounter(blockCounter) - // Map a WalletState string back to the tbtc constant. + // Map a WalletState string back to the tbtc constant. An unknown + // string fails the test rather than silently running as another + // state. var walletState tbtc.WalletState switch scenario.WalletState { case "Live": walletState = tbtc.StateLive + case "MovingFunds": + walletState = tbtc.StateMovingFunds case "Closing": walletState = tbtc.StateClosing case "Closed": @@ -289,7 +584,7 @@ func scenarioReservationAcceptanceChain( case "Terminated": walletState = tbtc.StateTerminated default: - walletState = tbtc.StateLive + t.Fatalf("unknown scenario wallet state [%s]", scenario.WalletState) } ralc.SetWallet( @@ -301,27 +596,25 @@ func scenarioReservationAcceptanceChain( } // registerReservedDeposits wires the scenario's reserved deposits into the -// mock chain as deposit requests and past DepositRevealedEvents. Bitcoin -// transaction registrations live on the btcChain mock. +// mock chains: the funding transaction, the deposit request, the reserved +// flag, the DepositRevealed event and, for rows with a +// PendingAcceptanceAction, the reservation, the action record and the +// ReservationAcceptanceRequested event (requested 10 blocks after the +// reveal). It returns the map from each row's label hash to its real +// funding transaction hash. func registerReservedDeposits( t *testing.T, scenario *test.ReservationAcceptanceTestScenario, ralc *reservationAcceptanceLocalChain, btcChain *tbtcpg.LocalBitcoinChain, -) { +) map[bitcoin.Hash]bitcoin.Hash { t.Helper() - // Configure the fee oracle rate. proposeReservationAcceptance now - // estimates the anchor fee dynamically (see estimateReservationAcceptanceFee); - // 1 sat/vByte hits the applyWalletTxFeeFloor minimum, matching the - // convention used by the sibling reservation re-anchor test fixtures. + // proposeReservationAcceptance estimates the anchor fee dynamically; + // 1 sat/vByte hits the applyWalletTxFeeFloor minimum. btcChain.SetEstimateSatPerVByteFee(1, 1) - filterStartBlock := uint64(0) - if scenario.ChainParameters.CurrentBlock > tbtcpg.ReservationAcceptanceLookBackBlocks { - filterStartBlock = scenario.ChainParameters.CurrentBlock - - tbtcpg.ReservationAcceptanceLookBackBlocks - } + hashesByLabel := make(map[bitcoin.Hash]bitcoin.Hash) for _, rd := range scenario.ReservedDeposits { materialized, err := rd.Materialize() @@ -331,222 +624,90 @@ func registerReservedDeposits( err, ) } + hashesByLabel[materialized.LabelFundingTxHash] = materialized.FundingTxHash + + depositKey := ralc.BuildDepositKey( + materialized.FundingTxHash, + materialized.FundingOutputIndex, + ) ralc.SetDepositRequest( materialized.FundingTxHash, materialized.FundingOutputIndex, &tbtc.DepositChainRequest{ - Depositor: chain.Address(rd.Depositor), - Amount: rd.Amount, + Depositor: materialized.Depositor, + Amount: materialized.Amount, RevealedAt: materialized.RevealedAt, SweptAt: materialized.SweptAt, Vault: materialized.Vault, }, ) + ralc.SetReservedDeposit(depositKey, true) - // When the scenario seeds a Pending Acceptance action, also - // install the reservation record and the action record so - // findReservationAcceptanceCandidate's read of - // GetReservationAction(depositKey, reservation.RequestNonce) - // observes a Pending Acceptance action record targeting this - // wallet -- the precondition the production validator enforces. - // A reservation's State in production stays Unknown until - // settlement, so the seeded record follows that convention. - if rd.PendingAcceptanceAction != nil { - if pendingAction := rd.PendingAcceptanceActionValue(); pendingAction != nil { - depositKey := ralc.BuildDepositKey( - materialized.FundingTxHash, - materialized.FundingOutputIndex, - ) - ralc.SetReservation(depositKey, &tbtc.Reservation{ - WalletPublicKeyHash: materialized.WalletPublicKeyHash, - State: tbtc.ReservationStateUnknown, - RequestNonce: rd.PendingAcceptanceAction.RequestNonce, - }) - ralc.SetReservationAction( - depositKey, - rd.PendingAcceptanceAction.RequestNonce, - pendingAction, - ) - } - } - - if materialized.FundingTx != nil { - btcChain.SetTransaction( - materialized.FundingTxHash, - materialized.FundingTx, - ) - } else { - dummyTx := &bitcoin.Transaction{ - Outputs: []*bitcoin.TransactionOutput{{ - Value: 0, - PublicKeyScript: append([]byte{0x00, 0x20}, make([]byte, 32)...), - }}, - } - btcChain.SetTransaction( - materialized.FundingTxHash, - dummyTx, - ) - } + btcChain.SetTransaction( + materialized.FundingTxHash, + materialized.FundingTx, + ) btcChain.SetTransactionConfirmations( materialized.FundingTxHash, rd.FundingTxConfirmations, ) - currentBlock := scenario.ChainParameters.CurrentBlock - err = ralc.AddPastDepositRevealedEvent( - &tbtc.DepositRevealedEventFilter{ - StartBlock: filterStartBlock, - EndBlock: ¤tBlock, - WalletPublicKeyHash: [][20]byte{materialized.WalletPublicKeyHash}, - }, - &tbtc.DepositRevealedEvent{ - BlockNumber: materialized.RevealBlock, - WalletPublicKeyHash: materialized.WalletPublicKeyHash, - FundingTxHash: materialized.FundingTxHash, - FundingOutputIndex: materialized.FundingOutputIndex, - Vault: materialized.Vault, + ralc.revealEvents = append(ralc.revealEvents, &tbtc.DepositRevealedEvent{ + BlockNumber: materialized.RevealBlock, + WalletPublicKeyHash: materialized.WalletPublicKeyHash, + FundingTxHash: materialized.FundingTxHash, + FundingOutputIndex: materialized.FundingOutputIndex, + Depositor: materialized.Depositor, + Amount: materialized.Amount, + BlindingFactor: materialized.BlindingFactor, + RefundPublicKeyHash: materialized.RefundPublicKeyHash, + RefundLocktime: materialized.RefundLocktime, + Vault: materialized.Vault, + }) + + pendingAction := rd.PendingAcceptanceActionValue() + if pendingAction == nil { + continue + } + // A reservation's State in production stays Unknown until + // settlement, so the seeded record follows that convention. + ralc.SetReservation(depositKey, &tbtc.Reservation{ + WalletPublicKeyHash: materialized.WalletPublicKeyHash, + State: tbtc.ReservationStateUnknown, + RequestNonce: rd.PendingAcceptanceAction.RequestNonce, + }) + ralc.SetReservationAction( + depositKey, + rd.PendingAcceptanceAction.RequestNonce, + pendingAction, + ) + ralc.acceptanceEvents = append( + ralc.acceptanceEvents, + &tbtc.ReservationAcceptanceRequestedEvent{ + ReservationKey: depositKey, + RequestNonce: rd.PendingAcceptanceAction.RequestNonce, + WalletPublicKeyHash: pendingAction.TargetWalletPublicKeyHash, + DepositAmount: materialized.Amount, + TxMaxFee: pendingAction.TxMaxFee, + TimeoutAt: pendingAction.TimeoutAt, + BlockNumber: materialized.RevealBlock + 10, }, ) - if err != nil { - t.Fatalf( - "failed to register past deposit revealed event: [%v]", - err, - ) - } - } -} - -// 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 -} - -// seedPendingAcceptanceAction wires a Pending Acceptance action record -// for the deposit at fundingTxHash/index into the local chain, matching -// the snapshot a depositor's requestReservationAcceptance call would have -// written on-chain at the time of the request. Tests then exercise -// findReservationAcceptanceCandidate's read of the action record at -// reservation.RequestNonce -- the precondition the production validator -// enforces -- instead of taking the operator-request path that was -// removed. -func seedPendingAcceptanceAction( - t *testing.T, - ralc *reservationAcceptanceLocalChain, - fundingTxHash bitcoin.Hash, - fundingOutputIndex uint32, - walletPublicKeyHash [20]byte, - requestNonce uint64, - txMaxFee uint64, - minAmount uint64, - termSeconds uint32, -) { - t.Helper() - - depositKey := ralc.BuildDepositKey(fundingTxHash, fundingOutputIndex) - ralc.SetReservation(depositKey, &tbtc.Reservation{ - WalletPublicKeyHash: walletPublicKeyHash, - State: tbtc.ReservationStateUnknown, - RequestNonce: requestNonce, - }) - ralc.SetReservationAction(depositKey, requestNonce, &tbtc.ReservationAction{ - ActionType: tbtc.ReservationActionTypeAcceptance, - State: tbtc.ReservationActionStatePending, - TargetWalletPublicKeyHash: walletPublicKeyHash, - TxMaxFee: txMaxFee, - MinAmount: minAmount, - TermSeconds: termSeconds, - // Far-future TimeoutAt so the validator's safety-margin gate - // (REQUEST_TIMEOUT_SAFETY_MARGIN, 2 hours) does not reject the - // action. Tests that intentionally exercise the gate set - // TimeoutAt via SetReservationAction directly. - TimeoutAt: uint32(time.Now().Add(24 * time.Hour).Unix()), - }) + return hashesByLabel } // 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). +// 1000, txMaxFee: 5000, maxPerWallet: 5, term: 90 days, action timeout: 24 +// hours}, per-wallet/single caps of 5000000, an active-reservations cap of +// 100, a deposit minimum age of DEPOSIT_MIN_AGE (2 hours) and a 12-second +// block time. overrides, when non-nil, runs after these defaults so a +// call site can customize only what it varies. func newBoundaryTestChain( t *testing.T, walletPublicKeyHash [20]byte, @@ -556,18 +717,13 @@ func newBoundaryTestChain( t.Helper() ralc := newReservationAcceptanceLocalChain() - ralc.SetReservationParameters(tbtc.ReservationParameters{ - ReservationVault: testReservationVaultAddress, - ReservationMinAmount: 1000, - ReservationTxMaxFee: 5000, - MaxReservationsPerWallet: 5, - ReservationMaxTotalAmount: 100000000, - }) + ralc.SetReservationParameters(defaultTestReservationParameters()) ralc.maxPerWalletAmount = 5000000 ralc.maxSingleAmount = 5000000 ralc.maxActive = 100 - ralc.SetDepositMinAge(3600) + ralc.SetDepositMinAge(testDepositMinAgeSeconds) + ralc.SetAverageBlockTime(12 * time.Second) ralc.SetWallet( walletPublicKeyHash, &tbtc.WalletChainData{State: tbtc.StateLive}, @@ -584,15 +740,120 @@ func newBoundaryTestChain( 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, +// defaultTestReservationParameters returns the reservation parameters +// newBoundaryTestChain installs. +func defaultTestReservationParameters() tbtc.ReservationParameters { + return tbtc.ReservationParameters{ + ReservationVault: testReservationVaultAddress, + ReservationMinAmount: 1000, + ReservationTxMaxFee: 5000, + MaxReservationsPerWallet: 5, + ReservationMaxTotalAmount: 100000000, + ReservationTermSeconds: testReservationTermSeconds, + ReservationActionTimeout: 24 * 60 * 60, + } +} + +// runTask runs a fresh acceptance task once for walletPublicKeyHash. +func runTask( + t *testing.T, + ralc *reservationAcceptanceLocalChain, + btcChain *tbtcpg.LocalBitcoinChain, + walletPublicKeyHash [20]byte, +) (*tbtc.ReservationAnchorProposal, bool, error) { + t.Helper() + + task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + return runExistingTask(t, task, walletPublicKeyHash) +} + +// runExistingTask runs the given acceptance task once for +// walletPublicKeyHash. +func runExistingTask( + t *testing.T, + task *tbtcpg.ReservationAcceptanceTask, + walletPublicKeyHash [20]byte, +) (*tbtc.ReservationAnchorProposal, bool, error) { + t.Helper() + + proposal, shouldExecute, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + }) + if proposal == nil { + return nil, shouldExecute, err + } + anchorProposal, ok := proposal.(*tbtc.ReservationAnchorProposal) + if !ok { + t.Fatalf("expected *ReservationAnchorProposal, got %T", proposal) + } + return anchorProposal, shouldExecute, err +} + +// expectProposalFor fails the test unless the run produced a proposal for +// the given deposit at the given nonce. +func expectProposalFor( + t *testing.T, + proposal *tbtc.ReservationAnchorProposal, + shouldExecute bool, + err error, + deposit *pendingDeposit, + requestNonce uint64, +) { + t.Helper() + + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + if !shouldExecute || proposal == nil { + t.Fatalf("expected a proposal, got shouldExecute=%v", shouldExecute) + } + if proposal.DepositFundingTxHash != deposit.fundingTxHash { + t.Fatalf( + "unexpected deposit funding tx hash\nexpected: %s\nactual: %s", + deposit.fundingTxHash.Hex(bitcoin.ReversedByteOrder), + proposal.DepositFundingTxHash.Hex(bitcoin.ReversedByteOrder), + ) + } + if proposal.RequestNonce != requestNonce { + t.Fatalf( + "expected the proposal to carry the pending generation's nonce "+ + "[%d], got [%d]", + requestNonce, + proposal.RequestNonce, + ) + } +} + +// expectNoProposal fails the test unless the run was a clean no-op. +func expectNoProposal( + t *testing.T, + proposal *tbtc.ReservationAnchorProposal, + shouldExecute bool, + err error, +) { + t.Helper() + + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + if shouldExecute || proposal != nil { + t.Fatalf( + "expected no proposal, got shouldExecute=%v proposal=%+v", + shouldExecute, + proposal, + ) + } +} + +// 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, // so it silently reports "no difference" for any two distinct AnchorTxFee // values. AnchorTxFee therefore needs an explicit .Cmp(). func expectedAnchorsEqual( @@ -620,19 +881,9 @@ func expectedAnchorsEqual( return expected.AnchorTxFee.Cmp(actual.AnchorTxFee) == 0 } -func TestReservationAcceptanceLookBackBlocks(t *testing.T) { - expectedValue := uint64(216000) - - if tbtcpg.ReservationAcceptanceLookBackBlocks != expectedValue { - t.Errorf( - "unexpected ReservationAcceptanceLookBackBlocks\n"+ - "expected: %d\n"+ - "actual: %d", - expectedValue, - tbtcpg.ReservationAcceptanceLookBackBlocks, - ) - } -} +var testWalletPublicKeyHash = hexToByte20( + "8db50eb52063ea9d98b3eac91489a90f738986f6", +) func TestReservationAcceptanceTask_ActionType(t *testing.T) { task := tbtcpg.NewReservationAcceptanceTask( @@ -664,15 +915,14 @@ func TestReservationAcceptanceTask_Run(t *testing.T) { t.Run(scenario.Title, func(t *testing.T) { ralc := scenarioReservationAcceptanceChain(t, scenario) btcChain := tbtcpg.NewLocalBitcoinChain() - registerReservedDeposits(t, scenario, ralc, btcChain) - - task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) - - request := &tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: scenario.WalletPublicKeyHash, - } + hashesByLabel := registerReservedDeposits(t, scenario, ralc, btcChain) - proposal, shouldExecute, err := task.Run(request) + proposal, shouldExecute, err := runTask( + t, + ralc, + btcChain, + scenario.WalletPublicKeyHash, + ) if err != nil { if scenario.ExpectedErr == nil { t.Fatalf("unexpected error: [%v]", err) @@ -692,20 +942,8 @@ func TestReservationAcceptanceTask_Run(t *testing.T) { t.Fatalf("expected error [%v], got nil", scenario.ExpectedErr) } - expectedProposal := scenario.ExpectedAnchorProposal - - if expectedProposal == nil { - if shouldExecute { - t.Errorf( - "unexpected proposal returned when none expected", - ) - } - if proposal != nil { - t.Errorf( - "expected nil proposal, got [%+v]", - proposal, - ) - } + if scenario.ExpectedAnchorProposal == nil { + expectNoProposal(t, proposal, shouldExecute, nil) return } @@ -716,16 +954,18 @@ func TestReservationAcceptanceTask_Run(t *testing.T) { t.Fatal("expected proposal, got nil") } - actualProposal, ok := proposal.(*tbtc.ReservationAnchorProposal) + expectedProposal := *scenario.ExpectedAnchorProposal + realHash, ok := hashesByLabel[expectedProposal.DepositFundingTxHash] if !ok { - t.Fatalf("expected *ReservationAnchorProposal, got %T", proposal) + t.Fatalf("expected proposal refers to an unknown deposit label") } + expectedProposal.DepositFundingTxHash = realHash - if !expectedAnchorsEqual(expectedProposal, actualProposal) { + if !expectedAnchorsEqual(&expectedProposal, proposal) { t.Errorf( "invalid anchor proposal\nexpected: %+v\nactual: %+v", expectedProposal, - actualProposal, + proposal, ) } }) @@ -739,7 +979,6 @@ func TestReservationAcceptanceTask_Run(t *testing.T) { // value equal to deposit amount minus the estimated anchor fee. func TestReservationAcceptanceTask_AnchorTransactionAssembly(t *testing.T) { btcChain := tbtcpg.NewLocalBitcoinChain() - btcChain.SetEstimateSatPerVByteFee(1, 1) privateKey, err := ecdsa.GenerateKey(btcec.S256(), rand.Reader) if err != nil { @@ -750,145 +989,17 @@ func TestReservationAcceptanceTask_AnchorTransactionAssembly(t *testing.T) { 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, - ) - - deposit.Utxo = &bitcoin.UnspentTransactionOutput{ - Outpoint: &bitcoin.TransactionOutpoint{ - TransactionHash: fundingTxHash, - OutputIndex: 0, - }, - Value: int64(depositAmount), - } - - 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, - }, - ) - - // Seed a Pending Acceptance action at nonce 1 with the boundary - // chain's snapshot parameters so the candidate passes - // findReservationAcceptanceCandidate's action-record gate. The - // wallet here is the deposit's own WalletPublicKeyHash (the wallet - // that revealed the deposit), which is also the chain's active - // wallet set above. - seedPendingAcceptanceAction( + ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, nil) + deposit := addPendingDeposit( t, ralc, - fundingTxHash, - 0, + btcChain, walletPublicKeyHash, - 1, - 5000, - 1000, - 86400, + defaultPendingDepositOptions(currentBlock), ) - 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, - ) - } + anchorProposal, shouldExecute, err := runTask(t, ralc, btcChain, walletPublicKeyHash) + expectProposalFor(t, anchorProposal, shouldExecute, err, deposit, 1) if anchorProposal.DepositFundingOutputIndex != 0 { t.Errorf( "unexpected DepositFundingOutputIndex\nexpected: 0\nactual: %d", @@ -899,7 +1010,7 @@ func TestReservationAcceptanceTask_AnchorTransactionAssembly(t *testing.T) { // Re-assemble and sign to verify transaction builder output properties. builder, err := tbtc.AssembleReservationAnchorTransaction( btcChain, - deposit, + deposit.deposit, walletPublicKeyHash, &tbtc.ReservationAction{TxMaxFee: 5000}, anchorProposal.AnchorTxFee.Int64(), @@ -960,38 +1071,13 @@ func TestReservationAcceptanceTask_AnchorTransactionAssembly(t *testing.T) { } // TestReservationAcceptanceTask_NoCandidates verifies that the task is a -// no-op when the chain has no reserved deposits. +// no-op when no depositor requested acceptance by the wallet. func TestReservationAcceptanceTask_NoCandidates(t *testing.T) { btcChain := tbtcpg.NewLocalBitcoinChain() + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, 300000, nil) - walletPublicKeyHash := hexToByte20( - "8db50eb52063ea9d98b3eac91489a90f738986f6", - ) - currentBlock := uint64(300000) - - ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, func(ralc *reservationAcceptanceLocalChain) { - ralc.SetReservationParameters(tbtc.ReservationParameters{ - ReservationVault: testReservationVaultAddress, - }) - ralc.maxPerWalletAmount = 1000000 - }) - - task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) - - request := &tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: walletPublicKeyHash, - } - - proposal, shouldExecute, err := task.Run(request) - if err != nil { - t.Fatalf("unexpected error: [%v]", err) - } - if shouldExecute { - t.Errorf("expected shouldExecute=false, got true") - } - if proposal != nil { - t.Errorf("expected nil proposal, got [%+v]", proposal) - } + proposal, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) + expectNoProposal(t, proposal, shouldExecute, err) } // TestReservationAcceptanceTask_VaultNotConfigured_ZeroAddress verifies @@ -999,406 +1085,186 @@ func TestReservationAcceptanceTask_NoCandidates(t *testing.T) { // chain.Address converter emits for an unset vault, never an empty // string) is correctly treated as "not configured". func TestReservationAcceptanceTask_VaultNotConfigured_ZeroAddress(t *testing.T) { - ralc := newReservationAcceptanceLocalChain() btcChain := tbtcpg.NewLocalBitcoinChain() + currentBlock := uint64(300000) - walletPublicKeyHash := hexToByte20( - "8db50eb52063ea9d98b3eac91489a90f738986f6", + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) + // A request exists, but the vault is unset: the task must not look. + addPendingDeposit( + t, + ralc, + btcChain, + testWalletPublicKeyHash, + defaultPendingDepositOptions(currentBlock), ) - - ralc.SetReservationParameters(tbtc.ReservationParameters{ - ReservationVault: chain.Address( - "0x0000000000000000000000000000000000000000", - ), - }) - ralc.maxPerWalletAmount = 1000000 - ralc.maxSingleAmount = 5000000 - ralc.maxActive = 100 - - ralc.SetDepositMinAge(3600) - ralc.SetWallet( - walletPublicKeyHash, - &tbtc.WalletChainData{State: tbtc.StateLive}, + params := defaultTestReservationParameters() + params.ReservationVault = chain.Address( + "0x0000000000000000000000000000000000000000", ) + ralc.SetReservationParameters(params) - currentBlock := uint64(300000) - blockCounter := tbtcpg.NewMockBlockCounter() - blockCounter.SetCurrentBlock(currentBlock) - ralc.SetBlockCounter(blockCounter) - - task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) - - request := &tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: walletPublicKeyHash, - } - - proposal, shouldExecute, err := task.Run(request) - if err != nil { - t.Fatalf("unexpected error: [%v]", err) - } - if shouldExecute { - t.Errorf("expected shouldExecute=false, got true") - } - if proposal != nil { - t.Errorf("expected nil proposal, got [%+v]", proposal) + proposal, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) + expectNoProposal(t, proposal, shouldExecute, err) + if len(ralc.acceptanceEventFilters) != 0 { + t.Errorf("expected no acceptance request scan for an unset vault") } } -// TestReservationAcceptanceTask_IgnoresRequestTimeCaps is a regression -// test for the cross-repo review change: the acceptance task consumes the -// depositor's Pending Acceptance generation rather than issuing its own -// request, so it must not re-apply the request-time capacity caps -// (wallet count, active count) that Solidity's requestReservationAcceptance -// already reserved when that generation was created. Re-applying them -// here would double-count the very generation being consumed against -// them and block every otherwise eligible pending acceptance. -// -// This test pins that: with the wallet's own reservation count already at -// the per-wallet cap and the active-reservations count already at the -// active cap (the request-time capacity reserved by this very pending -// generation), the acceptance task still proposes the anchor. A regression -// that re-added the request-time cap gate at consumption time would make -// this test fail. +// TestReservationAcceptanceTask_IgnoresRequestTimeCaps pins that the +// acceptance task, which consumes the depositor's Pending Acceptance +// generation rather than issuing its own request, does not re-apply the +// request-time capacity caps (wallet count, active count) that Solidity's +// requestReservationAcceptance already reserved when that generation was +// created. Re-applying them would double-count the very generation being +// consumed and block every otherwise eligible pending acceptance. func TestReservationAcceptanceTask_IgnoresRequestTimeCaps(t *testing.T) { btcChain := tbtcpg.NewLocalBitcoinChain() - - walletPublicKeyHash := hexToByte20( - "8db50eb52063ea9d98b3eac91489a90f738986f6", - ) currentBlock := uint64(300000) - ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, nil) - - // The pending acceptance generation this candidate consumes was - // already reserved against the capacity caps at request time, so - // the wallet's own count and the global active count are at their - // caps even before this anchor is signed: the wallet holds the cap - // number of reservations (including this one) and the active - // reservations count equals the max. A fresh request would be - // rejected on-chain; consuming the reserved generation must not - // be. - fundingTxHash := setupEligibleDeposit( + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) + deposit := addPendingDeposit( t, ralc, btcChain, - walletPublicKeyHash, - currentBlock, - 2000000, - ) - seedPendingAcceptanceAction( - t, - ralc, - fundingTxHash, - 0, - walletPublicKeyHash, - 1, - 5000, - 1000, - 86400, + testWalletPublicKeyHash, + defaultPendingDepositOptions(currentBlock), ) // The wallet's own reservation count is at the cap (this pending // generation counts toward it) and the active count is at the // active cap: exactly the state Solidity's request-time // reserveAcceptanceCapacity leaves behind. - ralc.SetWalletReservations(walletPublicKeyHash, []*big.Int{ + ralc.SetWalletReservations(testWalletPublicKeyHash, []*big.Int{ new(big.Int).SetInt64(7), }) ralc.maxActive = 100 ralc.activeCount = 100 ralc.walletReservationsCount = 5 - task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) - request := &tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: walletPublicKeyHash, - } + proposal, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) + expectProposalFor(t, proposal, shouldExecute, err, deposit, 1) +} - proposal, shouldExecute, err := task.Run(request) - if err != nil { - t.Fatalf("unexpected error: [%v]", err) +// TestReservationAcceptanceTask_RequestLookBackWindow pins how far back +// acceptance requests are scanned. Solidity sets a generation's timeoutAt +// to request time + the action timeout, so a generation that can still be +// signed was requested within that timeout: the scan covers the on-chain +// action timeout in blocks plus a one-day margin, filtered by wallet and +// split into 10000-block chunks, and a request inside that window is +// proposed. +func TestReservationAcceptanceTask_RequestLookBackWindow(t *testing.T) { + btcChain := tbtcpg.NewLocalBitcoinChain() + currentBlock := uint64(300000) + + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) + // 24-hour action timeout at 12 s per block is 7200 blocks; the + // margin adds another 7200. + expectedStartBlock := currentBlock - 14400 + + opts := defaultPendingDepositOptions(currentBlock) + opts.requestBlock = expectedStartBlock + opts.revealBlock = expectedStartBlock - 100 + deposit := addPendingDeposit(t, ralc, btcChain, testWalletPublicKeyHash, opts) + + proposal, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) + expectProposalFor(t, proposal, shouldExecute, err, deposit, 1) + + // The 14400-block window is scanned in 10000-block chunks. + expectedRanges := [][2]uint64{ + {expectedStartBlock, expectedStartBlock + 9999}, + {expectedStartBlock + 10000, currentBlock}, } - if !shouldExecute || proposal == nil { + if len(ralc.acceptanceEventFilters) != len(expectedRanges) { t.Fatalf( - "expected the pending acceptance to be consumed even though " + - "the request-time capacity caps (wallet count, active " + - "count) are already at their limits; the caps were " + - "reserved when the generation was requested", + "expected %d acceptance request scan chunks, got %d", + len(expectedRanges), + len(ralc.acceptanceEventFilters), ) } - if proposal.(*tbtc.ReservationAnchorProposal).RequestNonce != 1 { - t.Fatalf( - "expected the proposal to carry the pending generation's "+ - "real nonce (1), got [%d]", - proposal.(*tbtc.ReservationAnchorProposal).RequestNonce, - ) + for i, expected := range expectedRanges { + filter := ralc.acceptanceEventFilters[i] + if filter.StartBlock != expected[0] || + filter.EndBlock == nil || *filter.EndBlock != expected[1] { + t.Fatalf( + "unexpected scan chunk %d: start=%d end=%v, expected [%d, %d]", + i, + filter.StartBlock, + filter.EndBlock, + expected[0], + expected[1], + ) + } + } + filter := ralc.acceptanceEventFilters[0] + if len(filter.WalletPublicKeyHash) != 1 || + filter.WalletPublicKeyHash[0] != testWalletPublicKeyHash { + t.Fatalf("expected the scan to be filtered by the wallet") } } -// TestReservationAcceptanceTask_BoundedLookback verifies that the bounded -// look-back window is applied when the current block exceeds it. -func TestReservationAcceptanceTask_BoundedLookback(t *testing.T) { - initialBlock := uint64(400000) - +// TestReservationAcceptanceTask_RevealLongBeforeRequest is a regression +// test for acceptance requests made long after the reveal. A depositor may +// request acceptance up to one term after revealing, so a deposit revealed +// 300000 blocks (about 41 days) before its request must be found and +// proposed. The reveal sits exactly on the oldest block of a 10000-block +// lookup chunk to catch off-by-one errors at chunk edges. +func TestReservationAcceptanceTask_RevealLongBeforeRequest(t *testing.T) { btcChain := tbtcpg.NewLocalBitcoinChain() + currentBlock := uint64(1000000) - walletPublicKeyHash := hexToByte20( - "8db50eb52063ea9d98b3eac91489a90f738986f6", - ) + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) - // A block counter this test can advance between runs is created up - // front (rather than letting newBoundaryTestChain own an opaque one), - // so the second run below can simulate a later coordination window - // the way production actually progresses, exercising the task's - // per-wallet incremental scan cursor (see depositRevealedEventsSince) - // instead of re-querying the exact same already-scanned range twice. - blockCounter := tbtcpg.NewMockBlockCounter() - blockCounter.SetCurrentBlock(initialBlock) + opts := defaultPendingDepositOptions(currentBlock) + opts.requestBlock = currentBlock - 10 + // Chunks run backward from the request block: the 30th covers + // [requestBlock - 300000 + 1, requestBlock - 290000]. + opts.revealBlock = opts.requestBlock - 300000 + 1 + opts.revealedAt = time.Now().Add(-300000 * 12 * time.Second) + deposit := addPendingDeposit(t, ralc, btcChain, testWalletPublicKeyHash, opts) - ralc := newBoundaryTestChain( - t, - walletPublicKeyHash, - initialBlock, - func(ralc *reservationAcceptanceLocalChain) { - ralc.SetBlockCounter(blockCounter) - }, - ) + proposal, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) + expectProposalFor(t, proposal, shouldExecute, err, deposit, 1) +} - // Register an event below the look-back start block (block 1), under - // the unbounded filter a buggy filterStartBlock=0 computation would - // query with. - oldFundingTxHash := hashFromString( - "1111111111111111111111111111111111111111111111111111111111111111", - ) - if err := ralc.AddPastDepositRevealedEvent( - &tbtc.DepositRevealedEventFilter{ - StartBlock: 0, - EndBlock: &initialBlock, - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - }, - &tbtc.DepositRevealedEvent{ - BlockNumber: 1, - WalletPublicKeyHash: walletPublicKeyHash, - FundingTxHash: oldFundingTxHash, - FundingOutputIndex: 0, - }, - ); err != nil { - t.Fatal(err) - } +// TestReservationAcceptanceTask_DepositNotRoutedToReservationVault +// confirms that a candidate whose reveal routed the deposit to another +// vault is skipped. +func TestReservationAcceptanceTask_DepositNotRoutedToReservationVault(t *testing.T) { + btcChain := tbtcpg.NewLocalBitcoinChain() + currentBlock := uint64(300000) - task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) - request := &tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: walletPublicKeyHash, - } + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) + otherVault := chain.Address("0xOtherVaultAddress1234567890abcdef123456789012") + opts := defaultPendingDepositOptions(currentBlock) + opts.revealVault = &otherVault + addPendingDeposit(t, ralc, btcChain, testWalletPublicKeyHash, opts) - // 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 advancing the block counter and registering an - // eligible deposit within the resulting incremental scan delta. - 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) + proposal, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) + expectNoProposal(t, proposal, shouldExecute, err) + if ralc.validateCalls != 0 { + t.Errorf("expected no validation, got %d calls", ralc.validateCalls) } - - // Advance the block counter (as a later coordination window would) - // and register an eligible deposit within the resulting incremental - // delta range [initialBlock+1, nextBlock]. - nextBlock := initialBlock + 10 - blockCounter.SetCurrentBlock(nextBlock) - - fundingTxHash := hashFromString( - "2222222222222222222222222222222222222222222222222222222222222222", - ) - dummyTx := &bitcoin.Transaction{ - Outputs: []*bitcoin.TransactionOutput{{ - Value: 2000000, - 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{testReservationVaultAddress}[0], - }, - ) - // Seed a Pending Acceptance action matching the boundary chain's - // parameters so this freshly-revealed deposit is eligible to be - // accepted by the second run. - seedPendingAcceptanceAction( - t, - ralc, - fundingTxHash, - 0, - walletPublicKeyHash, - 1, - 5000, - 1000, - 86400, - ) - if err := ralc.AddPastDepositRevealedEvent( - &tbtc.DepositRevealedEventFilter{ - StartBlock: initialBlock + 1, - EndBlock: &nextBlock, - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - }, - &tbtc.DepositRevealedEvent{ - BlockNumber: nextBlock, - WalletPublicKeyHash: walletPublicKeyHash, - FundingTxHash: fundingTxHash, - FundingOutputIndex: 0, - Vault: &[]chain.Address{testReservationVaultAddress}[0], - }, - ); err != nil { - t.Fatal(err) - } - - // Second run: the newly revealed deposit must be found and accepted. - proposal, shouldExecute, err = task.Run(request) - if err != nil { - t.Fatalf("unexpected error: [%v]", err) - } - if !shouldExecute { - t.Fatalf("expected shouldExecute=true, got false") - } - if proposal == nil { - t.Fatalf("expected proposal, got nil") - } - 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\n"+ - "expected: %s\n"+ - "actual: %s", - fundingTxHash.Hex(bitcoin.ReversedByteOrder), - actualProposal.DepositFundingTxHash.Hex( - bitcoin.ReversedByteOrder, - ), - ) - } -} - -// TestReservationAcceptanceTask_DepositNotReserved confirms that a deposit -// that fails IsReservedDeposit is filtered out. -func TestReservationAcceptanceTask_DepositNotReserved(t *testing.T) { - btcChain := tbtcpg.NewLocalBitcoinChain() - - walletPublicKeyHash := hexToByte20( - "8db50eb52063ea9d98b3eac91489a90f738986f6", - ) - currentBlock := uint64(300000) - - ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, func(ralc *reservationAcceptanceLocalChain) { - ralc.SetReservationParameters(tbtc.ReservationParameters{ - ReservationVault: testReservationVaultAddress, - }) - }) - - fundingTxHash := hashFromString( - "3333333333333333333333333333333333333333333333333333333333333333", - ) - 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), - }, - ) - if err := ralc.AddPastDepositRevealedEvent( - &tbtc.DepositRevealedEventFilter{ - StartBlock: 0, - EndBlock: ¤tBlock, - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - }, - &tbtc.DepositRevealedEvent{ - BlockNumber: 290000, - 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, - } - - proposal, shouldExecute, err := task.Run(request) - if err != nil { - t.Fatalf("unexpected error: [%v]", err) - } - if shouldExecute { - t.Errorf("expected shouldExecute=false, got true") - } - if proposal != nil { - t.Errorf("expected no proposal for non-reserved deposit, got %v", proposal) - } -} +} // TestReservationAcceptanceTask_GetWalletError exercises the GetWallet -// error propagation inside findReservationAcceptanceCandidate: a -// reserved deposit candidate is discovered and matches the reservation -// vault, but the candidate wallet's chain data fails to load. Production -// now propagates this RPC failure instead of masking it as "no eligible -// candidate", so the coordinator can retry rather than silently treating -// a transient chain-read failure as a benign no-op. +// error propagation: the candidate wallet's chain data fails to load and +// the RPC failure is propagated instead of being masked as "no eligible +// candidate", so the coordinator can retry. func TestReservationAcceptanceTask_GetWalletError(t *testing.T) { btcChain := tbtcpg.NewLocalBitcoinChain() - - walletPublicKeyHash := hexToByte20( - "8db50eb52063ea9d98b3eac91489a90f738986f6", - ) currentBlock := uint64(300000) - // getWalletErr forces GetWallet to fail for the candidate wallet. - ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, func(ralc *reservationAcceptanceLocalChain) { + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, func(ralc *reservationAcceptanceLocalChain) { ralc.getWalletErr = fmt.Errorf("boom") }) - - setupEligibleDeposit( + addPendingDeposit( t, ralc, btcChain, - walletPublicKeyHash, - currentBlock, - 2000000, + testWalletPublicKeyHash, + defaultPendingDepositOptions(currentBlock), ) - task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) - - proposal, shouldExecute, err := task.Run(&tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: walletPublicKeyHash, - }) + proposal, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) if err == nil { t.Fatal("expected error, got nil") } @@ -1411,354 +1277,110 @@ func TestReservationAcceptanceTask_GetWalletError(t *testing.T) { err, ) } - if shouldExecute { - t.Errorf("expected shouldExecute=false, got true") - } - if proposal != nil { - t.Errorf("expected no proposal, got %v", proposal) + if shouldExecute || proposal != nil { + t.Errorf("expected no proposal") } } // TestReservationAcceptanceTask_GetReservationError verifies the fail-safe -// policy documented above the production call site: since the production -// chain adapter never errors for "not found" (it returns a zero record -// with State == Unknown), a GetReservation error can only be an RPC/decode -// failure, and the task must skip the affected deposit for this window -// rather than fail open and treat it as "not yet created". +// policy: since the production chain adapter never errors for "not found" +// (it returns a zero record with State == Unknown), a GetReservation error +// can only be an RPC/decode failure, and the task must skip the affected +// deposit for this window rather than fail open. func TestReservationAcceptanceTask_GetReservationError(t *testing.T) { btcChain := tbtcpg.NewLocalBitcoinChain() - - walletPublicKeyHash := hexToByte20( - "8db50eb52063ea9d98b3eac91489a90f738986f6", - ) currentBlock := uint64(300000) - ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, nil) - - setupEligibleDeposit( + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) + addPendingDeposit( t, ralc, btcChain, - walletPublicKeyHash, - currentBlock, - 2000000, + testWalletPublicKeyHash, + defaultPendingDepositOptions(currentBlock), ) - - // 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=false, got true") - } - if proposal != nil { - t.Fatalf("expected nil proposal, got [%+v]", proposal) - } + proposal, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) + expectNoProposal(t, proposal, shouldExecute, err) } -// 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. +// TestReservationAcceptanceTask_Stateless_Maturity verifies that an +// immature candidate is skipped on one run, and proposed by a later run of +// the same task instance once it matures, without cache interference. func TestReservationAcceptanceTask_Stateless_Maturity(t *testing.T) { btcChain := tbtcpg.NewLocalBitcoinChain() - - walletPublicKeyHash := hexToByte20( - "8db50eb52063ea9d98b3eac91489a90f738986f6", - ) - currentBlock := uint64(300000) - ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, nil) - - fundingTxHash := hashFromString( - "5555555555555555555555555555555555555555555555555555555555555555", - ) - 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, - ) - - // Candidate revealed only 10 minutes ago (depositMinAge is 1 hour). - ralc.SetDepositRequest( - fundingTxHash, - 0, - &tbtc.DepositChainRequest{ - Depositor: chain.Address("934b98637ca318a4d6e7ca6ffd1690b8e77df637"), - Amount: 2000000, - RevealedAt: time.Now().Add(-10 * time.Minute), - SweptAt: time.Unix(0, 0), - Vault: &[]chain.Address{testReservationVaultAddress}[0], - }, - ) - - // Seed the Pending Acceptance action so once the deposit matures, - // the candidate passes findReservationAcceptanceCandidate's - // action-record gate. - seedPendingAcceptanceAction( - t, - ralc, - fundingTxHash, - 0, - walletPublicKeyHash, - 1, - 5000, - 1000, - 86400, - ) - - filterStartBlock := uint64(0) - if currentBlock > tbtcpg.ReservationAcceptanceLookBackBlocks { - filterStartBlock = currentBlock - tbtcpg.ReservationAcceptanceLookBackBlocks - } + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) - if err := ralc.AddPastDepositRevealedEvent( - &tbtc.DepositRevealedEventFilter{ - StartBlock: filterStartBlock, - EndBlock: ¤tBlock, - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - }, - &tbtc.DepositRevealedEvent{ - BlockNumber: 290000, - WalletPublicKeyHash: walletPublicKeyHash, - FundingTxHash: fundingTxHash, - FundingOutputIndex: 0, - Vault: &[]chain.Address{testReservationVaultAddress}[0], - }, - ); err != nil { - t.Fatal(err) - } + // Revealed only 10 minutes ago (DEPOSIT_MIN_AGE is 2 hours). + opts := defaultPendingDepositOptions(currentBlock) + opts.revealedAt = time.Now().Add(-10 * time.Minute) + deposit := addPendingDeposit(t, ralc, btcChain, testWalletPublicKeyHash, opts) task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) - request := &tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: walletPublicKeyHash, - } - // First run: deposit is immature, should not be proposed. - proposal, shouldExecute, err := task.Run(request) - if err != nil { - t.Fatalf("first run error: [%v]", err) - } - if shouldExecute || proposal != nil { - t.Fatalf("expected no proposal on first run for immature deposit") - } - - // Advance deposit age (simulating passage of time to 2 hours ago). - 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{testReservationVaultAddress}[0], - }, - ) + proposal, shouldExecute, err := runExistingTask(t, task, testWalletPublicKeyHash) + expectNoProposal(t, proposal, shouldExecute, err) - // Second run on the same task instance: deposit is now mature and proposed. - proposal, shouldExecute, err = task.Run(request) - if err != nil { - t.Fatalf("second run error: [%v]", err) - } - if !shouldExecute || proposal == nil { - t.Fatalf("expected proposal on second run after deposit matured") - } + // Time passes: the deposit was revealed 3 hours ago. + ralc.SetDepositRequest(deposit.fundingTxHash, 0, &tbtc.DepositChainRequest{ + Depositor: testDepositor, + Amount: opts.amount, + RevealedAt: time.Now().Add(-3 * time.Hour), + SweptAt: time.Unix(0, 0), + Vault: &[]chain.Address{testReservationVaultAddress}[0], + }) - 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), - ) - } + proposal, shouldExecute, err = runExistingTask(t, task, testWalletPublicKeyHash) + expectProposalFor(t, proposal, shouldExecute, err, deposit, 1) } // 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. It also -// pins two behaviors of the snapshot-driven minimum-amount gate and the -// depositor-action-driven proposal consumption that supersede the -// pre-fix operator-side request path: -// - the live ReservationParameters is consulted on every Run -- a -// governance mutation takes effect on the very next call; -// - the minimum-amount gate uses the generation's snapshotted minimum -// rather than the live ReservationMinAmount, so a live raise that -// crosses the deposit does not retroactively invalidate a pending -// generation whose own snapshot still clears it. +// that each Run() call reflects the chain's current live state, and that +// the minimum-amount gate uses the generation's snapshotted minimum rather +// than the live ReservationMinAmount: a live raise that crosses the +// deposit does not retroactively invalidate a pending generation whose own +// snapshot still clears it. func TestReservationAcceptanceTask_ReservationParametersFetchedLive(t *testing.T) { - walletPublicKeyHash := hexToByte20( - "8db50eb52063ea9d98b3eac91489a90f738986f6", - ) + btcChain := tbtcpg.NewLocalBitcoinChain() currentBlock := uint64(300000) - t.Run("live parameters are re-fetched each 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, - } - - // Seed a Pending Acceptance action at nonce 1 with snapshot - // MinAmount=1000 (matching the live min at the moment of the - // depositor's request), so the candidate is eligible on both - // runs. - seedPendingAcceptanceAction( - t, - ralc, - setupEligibleDeposit( - t, - ralc, - btcChain, - walletPublicKeyHash, - currentBlock, - 2000000, - ), - 0, - walletPublicKeyHash, - 1, - 5000, - 1000, - 86400, - ) - - // First run: snapshot min (1000) plus fee (710) 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") - } - - // Raise the live ReservationMinAmount above the deposit's value. - // The snapshotted MinAmount on the seeded action (1000) is - // unchanged, so the deposit must still pass the gate on the - // second run. - ralc.SetReservationParameters(tbtc.ReservationParameters{ - ReservationVault: testReservationVaultAddress, - ReservationMinAmount: 3000000, - ReservationTxMaxFee: 5000, - MaxReservationsPerWallet: 5, - ReservationMaxTotalAmount: 100000000, - }) - - proposal, shouldExecute, err := task.Run(request) - if err != nil { - t.Fatalf("unexpected error on second run: [%v]", err) - } - if !shouldExecute { - t.Fatalf( - "expected shouldExecute=true on second run after raising " + - "the live ReservationMinAmount above the deposit's " + - "value -- the snapshot min (1000) still clears the " + - "gate, so a false here means the live value was used", - ) - } - if proposal == nil { - t.Fatalf("expected a non-nil proposal on second run") - } - }) - - t.Run("operator never requests acceptance; the depositor's pending action is consumed", func(t *testing.T) { - btcChain := tbtcpg.NewLocalBitcoinChain() - - ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, nil) + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) + deposit := addPendingDeposit( + t, + ralc, + btcChain, + testWalletPublicKeyHash, + defaultPendingDepositOptions(currentBlock), + ) - fundingTxHash := setupEligibleDeposit( - t, - ralc, - btcChain, - walletPublicKeyHash, - currentBlock, - 2000000, - ) + task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) - // Seed a Pending Acceptance action the depositor would have - // requested via requestReservationAcceptance. Production now - // consumes that record rather than issuing a fresh one. - seedPendingAcceptanceAction( - t, - ralc, - fundingTxHash, - 0, - walletPublicKeyHash, - 1, - 5000, - 1000, - 86400, - ) + proposal, shouldExecute, err := runExistingTask(t, task, testWalletPublicKeyHash) + expectProposalFor(t, proposal, shouldExecute, err, deposit, 1) - task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) - request := &tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: walletPublicKeyHash, - } + // Raise the live ReservationMinAmount above the deposit's value. The + // snapshotted MinAmount on the seeded action (1000) is unchanged, so + // the deposit must still pass the gate on the second run. + params := defaultTestReservationParameters() + params.ReservationMinAmount = 3000000 + ralc.SetReservationParameters(params) - proposal, shouldExecute, err := task.Run(request) - if err != nil { - t.Fatalf("unexpected error on first run: [%v]", err) - } - if !shouldExecute || proposal == nil { - t.Fatalf("expected shouldExecute=true on first run") - } - anchorProposal, ok := proposal.(*tbtc.ReservationAnchorProposal) - if !ok { - t.Fatalf("expected *ReservationAnchorProposal, got %T", proposal) - } - if anchorProposal.RequestNonce != 1 { - t.Fatalf( - "expected proposal to carry the generation's actual "+ - "nonce (1), got [%d] -- a value of N+1 means the "+ - "task invented the next nonce instead of consuming "+ - "the depositor's action", - anchorProposal.RequestNonce, - ) - } - }) + proposal, shouldExecute, err = runExistingTask(t, task, testWalletPublicKeyHash) + expectProposalFor(t, proposal, shouldExecute, err, deposit, 1) } // TestReservationAcceptanceTask_BoundaryChecks exercises explicit -// at-limit/one-over-limit boundary crossings for the snapshotted -// ReservationMinAmount the acceptance candidate enforces against its -// own action record (the gross gate is depositAmount >= ReservationMinAmount, -// and the net-of-fee gate is depositAmount >= MinAmount + fee). The -// request-time capacity caps (active count, per-wallet count and -// amount, single amount, global total) are no longer re-checked here: -// Solidity's requestReservationAcceptance already reserved them when -// the Pending Acceptance generation was created. +// at-limit/one-over-limit boundary crossings for the snapshotted minimum +// the acceptance candidate enforces against its own action record +// (depositAmount >= MinAmount + anchor fee). A generation below that bound +// cannot be created on-chain (requestReservationAcceptance requires +// amount >= MinAmount + txMaxFee and the anchor fee never exceeds +// txMaxFee), so the rejecting rows pin a defensive check. The +// request-time capacity caps are not re-checked here: Solidity's +// requestReservationAcceptance already reserved them. func TestReservationAcceptanceTask_BoundaryChecks(t *testing.T) { tests := map[string]struct { depositAmount uint64 @@ -1786,18 +1408,10 @@ func TestReservationAcceptanceTask_BoundaryChecks(t *testing.T) { reservationMaxTotal: 100000000, expectAccept: true, }, - // 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 + // The fixture's 1 sat/vByte oracle rate is clamped by + // applyWalletTxFeeFloor (see fee.go) to 5 sat/vByte, a 710 sat + // fee (testAnchorFeeSat). Deposit amounts are offset by // testAnchorFeeSat to test the exact net-of-fee boundary. - // The snapshot min on the seeded action mirrors the live - // ReservationMinAmount so the gate is governed by the row's - // value, not by the previous row's. "ReservationMinAmount: exactly at minimum accepts": { depositAmount: 100000 + testAnchorFeeSat, maxReservationsPerWallet: 5, @@ -1831,83 +1445,38 @@ func TestReservationAcceptanceTask_BoundaryChecks(t *testing.T) { for testName, test := range tests { t.Run(testName, func(t *testing.T) { - ralc := newReservationAcceptanceLocalChain() btcChain := tbtcpg.NewLocalBitcoinChain() - btcChain.SetEstimateSatPerVByteFee(1, 1) - - walletPublicKeyHash := hexToByte20( - "8db50eb52063ea9d98b3eac91489a90f738986f6", - ) - - ralc.SetReservationParameters(tbtc.ReservationParameters{ - ReservationVault: testReservationVaultAddress, - ReservationMinAmount: test.reservationMinAmount, - ReservationTxMaxFee: 5000, - MaxReservationsPerWallet: test.maxReservationsPerWallet, - ReservationMaxTotalAmount: test.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) - - fundingTxHash := setupEligibleDeposit( - t, - ralc, - btcChain, - walletPublicKeyHash, - currentBlock, - test.depositAmount, - ) - - // Seed a Pending Acceptance action with the row's snapshot - // min so this test exercises the per-row cap configuration, - // not the previous row's leftover state. Reject rows trip a - // cap gate before the action lookup, so the snapshot value - // only matters for the accept rows; seeding it consistently - // keeps the driver uniform. - seedPendingAcceptanceAction( - t, - ralc, - fundingTxHash, - 0, - walletPublicKeyHash, - 1, - 5000, - test.reservationMinAmount, - 86400, - ) - task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, func(ralc *reservationAcceptanceLocalChain) { + params := defaultTestReservationParameters() + params.ReservationMinAmount = test.reservationMinAmount + params.MaxReservationsPerWallet = test.maxReservationsPerWallet + params.ReservationMaxTotalAmount = test.reservationMaxTotal + params.ReservationTotalAmount = test.reservationTotal + ralc.SetReservationParameters(params) + ralc.maxPerWalletAmount = 50000000 + if test.maxPerWalletAmount != nil { + ralc.maxPerWalletAmount = *test.maxPerWalletAmount + } + ralc.maxSingleAmount = 50000000 + if test.maxSingleAmount != nil { + ralc.maxSingleAmount = *test.maxSingleAmount + } + if test.maxActive != nil { + ralc.maxActive = *test.maxActive + } + ralc.activeCount = test.activeCount + ralc.walletReservationsAmount = test.walletReservationsAmount + ralc.walletReservationsCount = test.walletReservationsCount + }) - request := &tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: walletPublicKeyHash, - } + opts := defaultPendingDepositOptions(currentBlock) + opts.amount = test.depositAmount + opts.minAmount = test.reservationMinAmount + addPendingDeposit(t, ralc, btcChain, testWalletPublicKeyHash, opts) - _, shouldExecute, err := task.Run(request) + _, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) if err != nil { t.Fatalf("unexpected error: [%v]", err) } @@ -1923,146 +1492,55 @@ func TestReservationAcceptanceTask_BoundaryChecks(t *testing.T) { } // TestReservationAcceptanceTask_Stateless_NoReRequest pins the two -// surviving "no double-acceptance" cases after the operator-side -// RequestReservationAcceptance path was removed. The acceptance task -// consumes, rather than creates, the depositor's request record, so a -// double-proposal can no longer be caused by repeated operator requests: -// it can only happen if the task builds a proposal for a generation it -// is not allowed to anchor. The two cases below cover that: -// - a Pending Acceptance action that targets another wallet must not -// be consumed by this operator; -// - a Settled (or otherwise non-Pending) action at the current nonce -// must not be re-anchored. +// "no double-acceptance" cases: the acceptance task consumes, rather than +// creates, the depositor's request record, so a double proposal can only +// happen if it builds a proposal for a generation it may not anchor: +// - a Pending Acceptance action that targets another wallet; +// - a Settled (or otherwise non-Pending) action at the current nonce. func TestReservationAcceptanceTask_Stateless_NoReRequest(t *testing.T) { - btcChain := tbtcpg.NewLocalBitcoinChain() - - walletPublicKeyHash := hexToByte20( - "8db50eb52063ea9d98b3eac91489a90f738986f6", - ) otherWalletPublicKeyHash := hexToByte20( "7c1c4dbaaf8d75b08f9ea8e3f9a2c3a98e1f0d77", ) - currentBlock := uint64(300000) - ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, nil) - - fundingTxHash := hashFromString( - "6666666666666666666666666666666666666666666666666666666666666666", - ) - 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{testReservationVaultAddress}[0], - }, - ) - - filterStartBlock := uint64(0) - if currentBlock > tbtcpg.ReservationAcceptanceLookBackBlocks { - filterStartBlock = currentBlock - tbtcpg.ReservationAcceptanceLookBackBlocks - } - - if err := ralc.AddPastDepositRevealedEvent( - &tbtc.DepositRevealedEventFilter{ - StartBlock: filterStartBlock, - EndBlock: ¤tBlock, - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + tests := map[string]func(action *tbtc.ReservationAction){ + "pending acceptance targeting another wallet is not consumed": func(action *tbtc.ReservationAction) { + action.TargetWalletPublicKeyHash = otherWalletPublicKeyHash }, - &tbtc.DepositRevealedEvent{ - BlockNumber: 290000, - WalletPublicKeyHash: walletPublicKeyHash, - FundingTxHash: fundingTxHash, - FundingOutputIndex: 0, - Vault: &[]chain.Address{testReservationVaultAddress}[0], + "settled generation is not re-anchored": func(action *tbtc.ReservationAction) { + action.State = tbtc.ReservationActionStateSettled }, - ); err != nil { - t.Fatal(err) - } - - task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) - request := &tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: walletPublicKeyHash, } - depositKey := ralc.BuildDepositKey(fundingTxHash, 0) - - t.Run("pending acceptance targeting another wallet is not consumed", func(t *testing.T) { - ralc.SetReservation(depositKey, &tbtc.Reservation{ - WalletPublicKeyHash: walletPublicKeyHash, - State: tbtc.ReservationStateUnknown, - RequestNonce: 1, - }) - ralc.SetReservationAction(depositKey, 1, &tbtc.ReservationAction{ - ActionType: tbtc.ReservationActionTypeAcceptance, - State: tbtc.ReservationActionStatePending, - TargetWalletPublicKeyHash: otherWalletPublicKeyHash, - TxMaxFee: 5000, - MinAmount: 1000, - TermSeconds: 86400, - }) - - proposal, shouldExecute, err := task.Run(request) - if err != nil { - t.Fatalf("unexpected error: [%v]", err) - } - if shouldExecute || proposal != nil { - t.Fatalf( - "expected no proposal for a pending acceptance generation " + - "authorizing a different wallet", + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + btcChain := tbtcpg.NewLocalBitcoinChain() + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) + deposit := addPendingDeposit( + t, + ralc, + btcChain, + testWalletPublicKeyHash, + defaultPendingDepositOptions(currentBlock), ) - } - }) + action, err := ralc.GetReservationAction(deposit.depositKey, 1) + if err != nil { + t.Fatal(err) + } + mutated := *action + mutate(&mutated) + ralc.SetReservationAction(deposit.depositKey, 1, &mutated) - t.Run("settled generation is not re-anchored", func(t *testing.T) { - ralc.SetReservation(depositKey, &tbtc.Reservation{ - WalletPublicKeyHash: walletPublicKeyHash, - State: tbtc.ReservationStateUnknown, - RequestNonce: 1, - }) - ralc.SetReservationAction(depositKey, 1, &tbtc.ReservationAction{ - ActionType: tbtc.ReservationActionTypeAcceptance, - State: tbtc.ReservationActionStateSettled, - TargetWalletPublicKeyHash: walletPublicKeyHash, - TxMaxFee: 5000, - MinAmount: 1000, - TermSeconds: 86400, + proposal, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) + expectNoProposal(t, proposal, shouldExecute, err) }) - - proposal, shouldExecute, err := task.Run(request) - if err != nil { - t.Fatalf("unexpected error: [%v]", err) - } - if shouldExecute || proposal != nil { - t.Fatalf( - "expected no proposal for a Settled generation -- the " + - "task must not re-anchor a generation whose action " + - "is no longer Pending", - ) - } - }) + } } -// TestReservationAcceptanceTask_Stateless_NonEligibleReservationState verifies that -// a reservation whose on-chain state is Active, ActionPending, Closed, or Stranded -// is skipped from acceptance proposals. +// TestReservationAcceptanceTask_Stateless_NonEligibleReservationState +// verifies that a reservation whose on-chain state is Active, +// ActionPending, Closed, or Stranded, with no pending acceptance action +// at its current nonce, is skipped from acceptance proposals. func TestReservationAcceptanceTask_Stateless_NonEligibleReservationState(t *testing.T) { nonEligibleStates := []tbtc.ReservationState{ tbtc.ReservationStateActive, @@ -2074,81 +1552,19 @@ func TestReservationAcceptanceTask_Stateless_NonEligibleReservationState(t *test for _, state := range nonEligibleStates { t.Run(fmt.Sprintf("state_%v", state), func(t *testing.T) { btcChain := tbtcpg.NewLocalBitcoinChain() - - walletPublicKeyHash := hexToByte20( - "8db50eb52063ea9d98b3eac91489a90f738986f6", - ) currentBlock := uint64(300000) - ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, nil) - - fundingTxHash := hashFromString( - "8888888888888888888888888888888888888888888888888888888888888888", - ) - 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{ - Amount: 2000000, - RevealedAt: time.Now().Add(-2 * time.Hour), - SweptAt: time.Unix(0, 0), - Vault: &[]chain.Address{testReservationVaultAddress}[0], - }, - ) - - filterStartBlock := uint64(0) - if currentBlock > tbtcpg.ReservationAcceptanceLookBackBlocks { - filterStartBlock = currentBlock - tbtcpg.ReservationAcceptanceLookBackBlocks - } - - if err := ralc.AddPastDepositRevealedEvent( - &tbtc.DepositRevealedEventFilter{ - StartBlock: filterStartBlock, - EndBlock: ¤tBlock, - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - }, - &tbtc.DepositRevealedEvent{ - BlockNumber: 290000, - WalletPublicKeyHash: walletPublicKeyHash, - FundingTxHash: fundingTxHash, - FundingOutputIndex: 0, - Vault: &[]chain.Address{testReservationVaultAddress}[0], - }, - ); err != nil { - t.Fatal(err) - } - - depositKey := ralc.BuildDepositKey(fundingTxHash, 0) - ralc.SetReservation(depositKey, &tbtc.Reservation{ + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) + opts := defaultPendingDepositOptions(currentBlock) + opts.withoutAction = true + deposit := addPendingDeposit(t, ralc, btcChain, testWalletPublicKeyHash, opts) + ralc.SetReservation(deposit.depositKey, &tbtc.Reservation{ State: state, - RequestNonce: 1, + RequestNonce: 2, }) - task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) - request := &tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: walletPublicKeyHash, - } - - proposal, shouldExecute, err := task.Run(request) - if err != nil { - t.Fatalf("unexpected task error: [%v]", err) - } - if shouldExecute || proposal != nil { - t.Fatalf("expected candidate with state %v to be skipped", state) - } + proposal, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) + expectNoProposal(t, proposal, shouldExecute, err) }) } } @@ -2157,228 +1573,75 @@ func TestReservationAcceptanceTask_Stateless_NonEligibleReservationState(t *test // each generation of a reservation carries its own snapshotted minimum // amount: a deposit whose first generation's snapshot is above the // deposit is skipped for that generation, but a fresh generation written -// after governance lowered the live minimum (so the new snapshot clears -// the deposit) becomes eligible and is consumed at its own real nonce. +// after governance lowered the live minimum becomes eligible and is +// consumed at its own real nonce. func TestReservationAcceptanceTask_Stateless_DynamicMinAmount(t *testing.T) { btcChain := tbtcpg.NewLocalBitcoinChain() - - walletPublicKeyHash := hexToByte20( - "8db50eb52063ea9d98b3eac91489a90f738986f6", - ) currentBlock := uint64(300000) - 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", - ) - 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, - ) - - // Deposit amount is 2,000,000 (below the first generation's - // snapshotted minimum of 5,000,000). - 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{testReservationVaultAddress}[0], - }, - ) - - filterStartBlock := uint64(0) - if currentBlock > tbtcpg.ReservationAcceptanceLookBackBlocks { - filterStartBlock = currentBlock - tbtcpg.ReservationAcceptanceLookBackBlocks - } - - if err := ralc.AddPastDepositRevealedEvent( - &tbtc.DepositRevealedEventFilter{ - StartBlock: filterStartBlock, - EndBlock: ¤tBlock, - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - }, - &tbtc.DepositRevealedEvent{ - BlockNumber: 290000, - WalletPublicKeyHash: walletPublicKeyHash, - FundingTxHash: fundingTxHash, - FundingOutputIndex: 0, - Vault: &[]chain.Address{testReservationVaultAddress}[0], - }, - ); err != nil { - t.Fatal(err) - } - - depositKey := ralc.BuildDepositKey(fundingTxHash, 0) + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) // First generation: snapshot min (5,000,000) is above the deposit // (2,000,000); the candidate must be skipped. - ralc.SetReservation(depositKey, &tbtc.Reservation{ - WalletPublicKeyHash: walletPublicKeyHash, - State: tbtc.ReservationStateUnknown, - RequestNonce: 1, - }) - ralc.SetReservationAction(depositKey, 1, &tbtc.ReservationAction{ - ActionType: tbtc.ReservationActionTypeAcceptance, - State: tbtc.ReservationActionStatePending, - TargetWalletPublicKeyHash: walletPublicKeyHash, - TxMaxFee: 5000, - MinAmount: 5000000, - TermSeconds: 86400, - // Far-future TimeoutAt so the timeout safety-margin gate is - // not the reason generation 1 is skipped: the subject of this - // test is the snapshotted minimum, and generation 1's snapshot - // (5,000,000) is what must reject the 2,000,000 deposit. - TimeoutAt: uint32(time.Now().Add(24 * time.Hour).Unix()), - }) + opts := defaultPendingDepositOptions(currentBlock) + opts.minAmount = 5000000 + deposit := addPendingDeposit(t, ralc, btcChain, testWalletPublicKeyHash, opts) task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) - request := &tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: walletPublicKeyHash, - } - - proposal, shouldExecute, err := task.Run(request) - if err != nil { - t.Fatalf("first run error: [%v]", err) - } - if shouldExecute || proposal != nil { - t.Fatalf( - "expected no proposal when the first generation's snapshotted " + - "minimum (5,000,000) is above the deposit (2,000,000)", - ) - } - // Governance lowers the live minimum. The depositor issues a fresh - // request -- production bumps the reservation's RequestNonce and - // writes a new action record carrying the new snapshot -- and the - // candidate becomes eligible. - ralc.SetReservationParameters(tbtc.ReservationParameters{ - ReservationVault: testReservationVaultAddress, - ReservationMinAmount: 1000000, - ReservationTxMaxFee: 5000, - MaxReservationsPerWallet: 5, - ReservationMaxTotalAmount: 100000000, - }) + proposal, shouldExecute, err := runExistingTask(t, task, testWalletPublicKeyHash) + expectNoProposal(t, proposal, shouldExecute, err) - ralc.SetReservation(depositKey, &tbtc.Reservation{ - WalletPublicKeyHash: walletPublicKeyHash, + // The depositor issues a fresh request: the reservation's nonce is + // bumped, a new action record carries the new snapshot, and a new + // ReservationAcceptanceRequested event is emitted. + timeoutAt := uint32(time.Now().Add(24 * time.Hour).Unix()) + ralc.SetReservation(deposit.depositKey, &tbtc.Reservation{ + WalletPublicKeyHash: testWalletPublicKeyHash, State: tbtc.ReservationStateUnknown, RequestNonce: 2, }) - ralc.SetReservationAction(depositKey, 2, &tbtc.ReservationAction{ + ralc.SetReservationAction(deposit.depositKey, 2, &tbtc.ReservationAction{ ActionType: tbtc.ReservationActionTypeAcceptance, State: tbtc.ReservationActionStatePending, - TargetWalletPublicKeyHash: walletPublicKeyHash, + TargetWalletPublicKeyHash: testWalletPublicKeyHash, TxMaxFee: 5000, MinAmount: 1000000, - TermSeconds: 86400, - // Far-future TimeoutAt so the timeout safety-margin gate does - // not reject the second generation. - TimeoutAt: uint32(time.Now().Add(24 * time.Hour).Unix()), + TermSeconds: testReservationTermSeconds, + TimeoutAt: timeoutAt, + }) + ralc.acceptanceEvents = append(ralc.acceptanceEvents, &tbtc.ReservationAcceptanceRequestedEvent{ + ReservationKey: deposit.depositKey, + RequestNonce: 2, + WalletPublicKeyHash: testWalletPublicKeyHash, + DepositAmount: 2000000, + TxMaxFee: 5000, + TimeoutAt: timeoutAt, + BlockNumber: currentBlock - 5, }) - proposal, shouldExecute, err = task.Run(request) - if err != nil { - t.Fatalf("second run error: [%v]", err) - } - if !shouldExecute || proposal == nil { - t.Fatalf( - "expected a proposal on the second run after governance " + - "lowered the live minimum and the depositor issued a new " + - "generation", - ) - } - anchorProposal, ok := proposal.(*tbtc.ReservationAnchorProposal) - if !ok { - t.Fatalf("expected *ReservationAnchorProposal, got %T", proposal) - } - if anchorProposal.RequestNonce != 2 { - t.Fatalf( - "expected the proposal to carry the second generation's actual "+ - "nonce (2), got [%d]", - anchorProposal.RequestNonce, - ) - } + proposal, shouldExecute, err = runExistingTask(t, task, testWalletPublicKeyHash) + expectProposalFor(t, proposal, shouldExecute, err, deposit, 2) } -// TestReservationAcceptanceTask_Stateless_RequestNonceIncremented verified -// that an existing reservation record at RequestNonce N produced a -// proposal at RequestNonce N + 1 -- the operator-side -// RequestReservationAcceptance path that was removed in the cross-repo -// review. The acceptance task now consumes, rather than creates, the -// depositor's action record: a successful proposal always carries the -// reservation's current RequestNonce, never an invented N + 1. That -// behavior is pinned by: -// -// - TestReservationAcceptanceTask_Run/happy_path (the JSON scenario 0), -// whose ExpectedAnchorProposal.RequestNonce equals the seeded -// action's nonce (1); -// - TestReservationAcceptanceTask_ReservationParametersFetchedLive, -// which asserts the proposal's RequestNonce equals the generation's -// real nonce (1) when the operator-side path is in scope; -// - TestReservationAcceptanceTask_Stateless_DynamicMinAmount, which -// pins RequestNonce = 2 once the depositor issues a second -// generation. -// -// The strict-fake corollary -- a proposal at nonce + 1 is rejected by -// LocalChain.ValidateReservationAnchorProposal, the precondition the -// production validator mirrors -- is exercised by -// TestLocalChain_ValidateReservationAnchorProposal_StrictNonceGate. - -// 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) { +// TestReservationAcceptanceTask_AcceptanceRequestedEventsError verifies +// that a failure to fetch the wallet's acceptance requests aborts the Run +// with an error, rather than being reported as "no candidate". +func TestReservationAcceptanceTask_AcceptanceRequestedEventsError(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( + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) + addPendingDeposit( t, ralc, btcChain, - walletPublicKeyHash, - currentBlock, - 2000000, + testWalletPublicKeyHash, + defaultPendingDepositOptions(currentBlock), ) + ralc.acceptanceEventsErr = fmt.Errorf("simulated rpc failure") - ralc.pastDepositRevealedEventsErr = fmt.Errorf("simulated rpc failure") - - task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) - - _, shouldExecute, err := task.Run(&tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: walletPublicKeyHash, - }) + _, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) if err == nil { t.Fatalf("expected a non-nil error, got nil") } @@ -2387,89 +1650,75 @@ func TestReservationAcceptanceTask_PastDepositRevealedEventsError(t *testing.T) } } -// TestReservationAcceptanceTask_ValidateProposalError verifies that a -// ValidateReservationAnchorProposal failure is treated as a pre-write -// failure (see reservationAcceptancePreWriteError): the doomed candidate -// is skipped rather than aborting the whole coordination window, so with -// no other candidate available Run reports a clean no-op instead of an -// error. -func TestReservationAcceptanceTask_ValidateProposalError(t *testing.T) { +// TestReservationAcceptanceTask_RevealLookupErrorSkipsCandidate verifies +// that a failed DepositRevealed lookup for one candidate skips that +// candidate for the window instead of aborting the Run. +func TestReservationAcceptanceTask_RevealLookupErrorSkipsCandidate(t *testing.T) { btcChain := tbtcpg.NewLocalBitcoinChain() - - walletPublicKeyHash := hexToByte20( - "8db50eb52063ea9d98b3eac91489a90f738986f6", - ) currentBlock := uint64(300000) - ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, nil) - - fundingTxHash := setupEligibleDeposit( + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) + addPendingDeposit( t, ralc, btcChain, - walletPublicKeyHash, - currentBlock, - 2000000, + testWalletPublicKeyHash, + defaultPendingDepositOptions(currentBlock), ) + ralc.pastDepositRevealedEventsErr = fmt.Errorf("simulated rpc failure") + + proposal, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) + expectNoProposal(t, proposal, shouldExecute, err) +} - // Seed a Pending Acceptance action so the candidate actually - // reaches proposeReservationAcceptance and the validate call fires. - seedPendingAcceptanceAction( +// TestReservationAcceptanceTask_ValidateProposalError verifies that a +// ValidateReservationAnchorProposal failure skips the candidate rather +// than aborting the coordination window: with no other candidate, Run +// reports a clean no-op instead of an error. +func TestReservationAcceptanceTask_ValidateProposalError(t *testing.T) { + btcChain := tbtcpg.NewLocalBitcoinChain() + currentBlock := uint64(300000) + + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) + addPendingDeposit( t, ralc, - fundingTxHash, - 0, - walletPublicKeyHash, - 1, - 5000, - 1000, - 86400, + btcChain, + testWalletPublicKeyHash, + defaultPendingDepositOptions(currentBlock), ) - 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("unexpected error: [%v]", err) - } - if shouldExecute { - t.Errorf("expected shouldExecute=false, got true") - } - if proposal != nil { - t.Errorf("expected nil proposal, got %v", proposal) + proposal, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) + expectNoProposal(t, proposal, shouldExecute, err) + if ralc.validateCalls != 1 { + t.Errorf("expected the validator to be called once, got %d", ralc.validateCalls) } } -// TestLocalChain_ValidateReservationAnchorProposal_StrictNonceGate is a -// regression test for the action-record precondition -// WalletProposalValidator.sol's validateReservationAnchorProposal enforces: -// the action at the proposal's RequestNonce must already be a Pending -// Acceptance action targeting this wallet. Production sets the proposal's -// RequestNonce from the action record it just read, so the validator -// always agrees -- but a regression that let production invent a nonce -// (the pre-fix operator-side request path) would silently pass against a -// lenient fake. This test exercises the underlying fake validator -// directly: a proposal at the seeded nonce passes, while nonce + 1 is -// rejected with the precondition message. -func TestLocalChain_ValidateReservationAnchorProposal_StrictNonceGate(t *testing.T) { - lc := tbtcpg.NewLocalChain() - - walletPublicKeyHash := [20]byte{0x8d, 0xb5, 0x0e, 0xb5, 0x20, 0x63, 0xea, 0x9d, 0x98, 0xb3, 0xea, 0xc9, 0x14, 0x89, 0xa9, 0x0f, 0x73, 0x89, 0x86, 0xf6} +// strictValidatorFixture seeds a bare LocalChain with everything its +// strict anchor validator checks for a reserved deposit revealed to +// walletPublicKeyHash, with a Pending Acceptance action at nonce 2, and +// returns a valid proposal and its deposit extra info. +func strictValidatorFixture( + t *testing.T, + lc *tbtcpg.LocalChain, + walletPublicKeyHash [20]byte, +) ( + *tbtc.ReservationAnchorProposal, + struct { + *tbtc.Deposit + FundingTx *bitcoin.Transaction + }, + *big.Int, +) { + t.Helper() - fundingTxHash := bitcoin.Hash{0xab} - fundingOutputIndex := uint32(0) - // The validator keys action records by the deposit key derived from - // the proposal's funding outpoint, not by some pre-chosen big.Int; - // build the key through the same helper so the seed and the - // validator agree. - depositKey := lc.BuildDepositKey(fundingTxHash, fundingOutputIndex) + vault := testReservationVaultAddress + deposit, fundingTx := buildReservedDeposit(t, walletPublicKeyHash, 2000000, 0x33, vault) + fundingTxHash := fundingTx.Hash() + depositKey := lc.BuildDepositKey(fundingTxHash, 0) - // Seed a Pending Acceptance action at nonce 2 -- the canonical - // generation the production task would consume. lc.SetReservation(depositKey, &tbtc.Reservation{ WalletPublicKeyHash: walletPublicKeyHash, State: tbtc.ReservationStateUnknown, @@ -2481,73 +1730,65 @@ func TestLocalChain_ValidateReservationAnchorProposal_StrictNonceGate(t *testing TargetWalletPublicKeyHash: walletPublicKeyHash, TxMaxFee: 5000, MinAmount: 1000, - TermSeconds: 86400, - // Far-future TimeoutAt so the fake validator's timeout - // safety-margin gate does not reject the OK proposal; the - // strict-nonce precondition is the only gate under test. - TimeoutAt: uint32(time.Now().Add(24 * time.Hour).Unix()), + TermSeconds: testReservationTermSeconds, + TimeoutAt: uint32(time.Now().Add(24 * time.Hour).Unix()), }) - // Seed the remaining state the strict fake mirrors from the - // on-chain validator: a Live wallet, a reserved deposit revealed - // through the reservation vault, and the configured parameters. - vault := testReservationVaultAddress lc.SetWallet(walletPublicKeyHash, &tbtc.WalletChainData{State: tbtc.StateLive}) lc.SetReservationParameters(tbtc.ReservationParameters{ ReservationVault: vault, }) - lc.SetDepositRequest( - fundingTxHash, - fundingOutputIndex, - &tbtc.DepositChainRequest{ - Amount: 2000000, - RevealedAt: time.Now().Add(-48 * time.Hour), - Vault: &vault, - }, - ) + lc.SetDepositRequest(fundingTxHash, 0, &tbtc.DepositChainRequest{ + Depositor: testDepositor, + Amount: 2000000, + RevealedAt: time.Now().Add(-48 * time.Hour), + SweptAt: time.Unix(0, 0), + Vault: &vault, + }) lc.SetReservedDeposit(depositKey, true) - lc.SetDepositMinAge(3600) - - depositExtraInfo := struct { - *tbtc.Deposit - FundingTx *bitcoin.Transaction - }{ - Deposit: &tbtc.Deposit{ - WalletPublicKeyHash: walletPublicKeyHash, - // Far-future refund locktime, little-endian as stored on - // chain, so the 24-hour refund safety margin holds. - RefundLocktime: [4]byte{0x00, 0x79, 0xf7, 0x77}, - }, - } + lc.SetDepositMinAge(testDepositMinAgeSeconds) - proposalOK := &tbtc.ReservationAnchorProposal{ + proposal := &tbtc.ReservationAnchorProposal{ DepositFundingTxHash: fundingTxHash, - DepositFundingOutputIndex: fundingOutputIndex, + DepositFundingOutputIndex: 0, RequestNonce: 2, AnchorTxFee: big.NewInt(710), } + extraInfo := struct { + *tbtc.Deposit + FundingTx *bitcoin.Transaction + }{Deposit: deposit, FundingTx: fundingTx} + + return proposal, extraInfo, depositKey +} + +// TestLocalChain_ValidateReservationAnchorProposal_StrictNonceGate is a +// regression test for the action-record precondition +// WalletProposalValidator.sol's validateReservationAnchorProposal enforces: +// the action at the proposal's RequestNonce must already be a Pending +// Acceptance action targeting this wallet. A proposal at the seeded nonce +// passes, while nonce + 1 is rejected. +func TestLocalChain_ValidateReservationAnchorProposal_StrictNonceGate(t *testing.T) { + lc := tbtcpg.NewLocalChain() + proposal, extraInfo, _ := strictValidatorFixture(t, lc, testWalletPublicKeyHash) + if err := lc.ValidateReservationAnchorProposal( - walletPublicKeyHash, - proposalOK, - depositExtraInfo, + testWalletPublicKeyHash, + proposal, + extraInfo, ); err != nil { t.Fatalf( "strict fake rejected a proposal at the seeded action's nonce "+ - "(2): %v -- the validator must agree with the production "+ - "task's action-record lookup", + "(2): %v", err, ) } - proposalOff := &tbtc.ReservationAnchorProposal{ - DepositFundingTxHash: fundingTxHash, - DepositFundingOutputIndex: fundingOutputIndex, - RequestNonce: 3, - AnchorTxFee: big.NewInt(710), - } + proposalOff := *proposal + proposalOff.RequestNonce = 3 if err := lc.ValidateReservationAnchorProposal( - walletPublicKeyHash, - proposalOff, - depositExtraInfo, + testWalletPublicKeyHash, + &proposalOff, + extraInfo, ); err == nil { t.Fatalf( "strict fake accepted a proposal at nonce + 1 -- the " + @@ -2557,167 +1798,139 @@ func TestLocalChain_ValidateReservationAnchorProposal_StrictNonceGate(t *testing } } +// TestLocalChain_ValidateReservationAnchorProposal_DepositExtraInfo pins +// the strict fake's mirror of validateDepositExtraInfo: a funding +// transaction that does not hash to the proposal's funding transaction +// hash, or whose output does not carry the deposit script rebuilt from +// the extra info's reveal fields, is rejected. +func TestLocalChain_ValidateReservationAnchorProposal_DepositExtraInfo(t *testing.T) { + t.Run("another funding transaction", func(t *testing.T) { + lc := tbtcpg.NewLocalChain() + proposal, extraInfo, _ := strictValidatorFixture(t, lc, testWalletPublicKeyHash) + _, otherFundingTx := buildReservedDeposit( + t, + testWalletPublicKeyHash, + 2000000, + 0x44, + testReservationVaultAddress, + ) + extraInfo.FundingTx = otherFundingTx + + err := lc.ValidateReservationAnchorProposal( + testWalletPublicKeyHash, + proposal, + extraInfo, + ) + if err == nil || err.Error() != "extra info funding tx hash does not match" { + t.Fatalf("expected a funding tx hash mismatch, got [%v]", err) + } + }) + + t.Run("reveal fields that do not rebuild the locking script", func(t *testing.T) { + lc := tbtcpg.NewLocalChain() + proposal, extraInfo, _ := strictValidatorFixture(t, lc, testWalletPublicKeyHash) + wrongDeposit := *extraInfo.Deposit + wrongDeposit.BlindingFactor = [8]byte{0xff} + extraInfo.Deposit = &wrongDeposit + + err := lc.ValidateReservationAnchorProposal( + testWalletPublicKeyHash, + proposal, + extraInfo, + ) + if err == nil || err.Error() != "extra info funding output script does not match" { + t.Fatalf("expected a funding output script mismatch, got [%v]", err) + } + }) +} + // TestReservationAcceptanceTask_DepositWithoutPendingActionIsSkipped is a -// regression test for the action-record gate -// findReservationAcceptanceCandidate enforces: a deposit with no current -// Pending Acceptance action -- whether because the depositor never -// requested acceptance or because a prior generation has already settled -// -- is never proposed. A regression that fell back to the operator-side -// request path could accidentally trigger a request here, which would -// show up as a non-zero submission count. +// regression test for the action-record gate: a requested deposit whose +// current generation has no Pending Acceptance action -- because it timed +// out, settled, or its record is gone -- is never proposed. func TestReservationAcceptanceTask_DepositWithoutPendingActionIsSkipped(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, - ) - - // Deliberately no seedPendingAcceptanceAction call: the deposit's - // current generation is the zero record (no reservation, no action). - // The acceptance task must skip it -- it consumes the depositor's - // pending action, never requests one on the operator's behalf -- and - // return nil. - task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) + opts := defaultPendingDepositOptions(currentBlock) + opts.withoutAction = true + addPendingDeposit(t, ralc, btcChain, testWalletPublicKeyHash, opts) - proposal, shouldExecute, err := task.Run(&tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: walletPublicKeyHash, - }) - if err != nil { - t.Fatalf("unexpected error: [%v]", err) - } - if shouldExecute { - t.Errorf("expected shouldExecute=false for a deposit without a pending action, got true") - } - if proposal != nil { - t.Errorf("expected nil proposal, got %v", proposal) - } + proposal, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) + expectNoProposal(t, proposal, shouldExecute, err) } // TestReservationAcceptanceTask_SkipsCandidateInsideTimeoutSafetyMargin is a -// regression test for the timeout safety margin gate added in the cross-repo -// review: findReservationAcceptanceCandidate skips any candidate whose +// regression test for the timeout safety margin gate: a candidate whose // pending acceptance generation has TimeoutAt at or before -// now + REQUEST_TIMEOUT_SAFETY_MARGIN (2 hours). A candidate just outside -// the margin (TimeoutAt = now + 7202) must be found and proposed. The -// validator fake mirrors the same gate so the direct unit test covers -// both the task and the fake. +// now + REQUEST_TIMEOUT_SAFETY_MARGIN (2 hours) is skipped before any +// proposal is validated. The request event's own timeout drops such a +// generation without any chain read; the action record, which is +// authoritative, is checked again. A candidate comfortably outside the +// margin is proposed. func TestReservationAcceptanceTask_SkipsCandidateInsideTimeoutSafetyMargin(t *testing.T) { - t.Run("inside margin: skipped", func(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, - ) - - // TimeoutAt = now + 7198 seconds: the margin gate - // (now + 7200 >= TimeoutAt) fires and the candidate is skipped. - depositKey := ralc.BuildDepositKey(fundingTxHash, 0) - ralc.SetReservation(depositKey, &tbtc.Reservation{ - WalletPublicKeyHash: walletPublicKeyHash, - State: tbtc.ReservationStateUnknown, - RequestNonce: 0, - }) - ralc.SetReservationAction(depositKey, 0, &tbtc.ReservationAction{ - ActionType: tbtc.ReservationActionTypeAcceptance, - State: tbtc.ReservationActionStatePending, - TargetWalletPublicKeyHash: walletPublicKeyHash, - TxMaxFee: 5000, - MinAmount: 1000, - TermSeconds: 86400, - TimeoutAt: uint32(time.Now().Add(7198 * time.Second).Unix()), - }) - - task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + currentBlock := uint64(300000) - proposal, shouldExecute, err := task.Run(&tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: walletPublicKeyHash, - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if shouldExecute || proposal != nil { - t.Fatalf( - "expected no proposal for candidate inside the timeout "+ - "safety margin; got shouldExecute=%v", - shouldExecute, + t.Run("inside margin: skipped without chain reads", func(t *testing.T) { + btcChain := tbtcpg.NewLocalBitcoinChain() + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) + opts := defaultPendingDepositOptions(currentBlock) + opts.timeoutAt = uint32(time.Now().Add(7198 * time.Second).Unix()) + addPendingDeposit(t, ralc, btcChain, testWalletPublicKeyHash, opts) + + proposal, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) + expectNoProposal(t, proposal, shouldExecute, err) + if ralc.getReservationCalls != 0 { + t.Errorf( + "expected no reservation read for a request inside the "+ + "margin, got %d", + ralc.getReservationCalls, ) } + if ralc.validateCalls != 0 { + t.Errorf("expected no validation, got %d calls", ralc.validateCalls) + } }) - t.Run("just outside margin: proposed", func(t *testing.T) { + t.Run("action record inside margin: skipped", func(t *testing.T) { btcChain := tbtcpg.NewLocalBitcoinChain() - walletPublicKeyHash := hexToByte20( - "8db50eb52063ea9d98b3eac91489a90f738986f6", - ) - currentBlock := uint64(300000) - - ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, nil) - - fundingTxHash := setupEligibleDeposit( + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) + deposit := addPendingDeposit( t, ralc, btcChain, - walletPublicKeyHash, - currentBlock, - 2000000, + testWalletPublicKeyHash, + defaultPendingDepositOptions(currentBlock), ) - - // TimeoutAt = now + 7202 seconds: the margin gate - // (now + 7200 >= TimeoutAt) does not fire and the candidate - // is found and proposed. - depositKey := ralc.BuildDepositKey(fundingTxHash, 0) - ralc.SetReservation(depositKey, &tbtc.Reservation{ - WalletPublicKeyHash: walletPublicKeyHash, - State: tbtc.ReservationStateUnknown, - RequestNonce: 0, - }) - ralc.SetReservationAction(depositKey, 0, &tbtc.ReservationAction{ - ActionType: tbtc.ReservationActionTypeAcceptance, - State: tbtc.ReservationActionStatePending, - TargetWalletPublicKeyHash: walletPublicKeyHash, - TxMaxFee: 5000, - MinAmount: 1000, - TermSeconds: 86400, - TimeoutAt: uint32(time.Now().Add(7202 * time.Second).Unix()), - }) - - task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) - - proposal, shouldExecute, err := task.Run(&tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: walletPublicKeyHash, - }) + action, err := ralc.GetReservationAction(deposit.depositKey, 1) if err != nil { - t.Fatalf("unexpected error: %v", err) + t.Fatal(err) } - if !shouldExecute || proposal == nil { - t.Fatalf( - "expected a proposal for candidate just outside the timeout "+ - "safety margin; got shouldExecute=%v", - shouldExecute, - ) + insideMargin := *action + insideMargin.TimeoutAt = uint32(time.Now().Add(7198 * time.Second).Unix()) + ralc.SetReservationAction(deposit.depositKey, 1, &insideMargin) + + proposal, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) + expectNoProposal(t, proposal, shouldExecute, err) + if ralc.validateCalls != 0 { + t.Errorf("expected no validation, got %d calls", ralc.validateCalls) + } + }) + + t.Run("outside margin: proposed", func(t *testing.T) { + btcChain := tbtcpg.NewLocalBitcoinChain() + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) + opts := defaultPendingDepositOptions(currentBlock) + // 100 seconds of slack past the 7200-second margin, so a slow + // host cannot push the candidate inside it. + opts.timeoutAt = uint32(time.Now().Add(7300 * time.Second).Unix()) + deposit := addPendingDeposit(t, ralc, btcChain, testWalletPublicKeyHash, opts) + + proposal, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) + expectProposalFor(t, proposal, shouldExecute, err, deposit, 1) + if ralc.validateCalls != 1 { + t.Errorf("expected one validation, got %d calls", ralc.validateCalls) } }) } @@ -2725,98 +1938,32 @@ func TestReservationAcceptanceTask_SkipsCandidateInsideTimeoutSafetyMargin(t *te // TestLocalChain_ValidateReservationAnchorProposal_RejectsInsideTimeoutMargin // is a regression test for the timeout safety margin gate in the LocalChain // validator fake: a proposal whose action TimeoutAt is at or before -// now + REQUEST_TIMEOUT_SAFETY_MARGIN is rejected with "timed out". -// Just outside the margin (TimeoutAt = now + 7202) passes. +// now + REQUEST_TIMEOUT_SAFETY_MARGIN is rejected. func TestLocalChain_ValidateReservationAnchorProposal_RejectsInsideTimeoutMargin(t *testing.T) { lc := tbtcpg.NewLocalChain() - - walletPublicKeyHash := [20]byte{0x8d, 0xb5, 0x0e, 0xb5, 0x20, 0x63, 0xea, 0x9d, 0x98, 0xb3, 0xea, 0xc9, 0x14, 0x89, 0xa9, 0x0f, 0x73, 0x89, 0x86, 0xf6} - - fundingTxHash := bitcoin.Hash{0xab} - fundingOutputIndex := uint32(0) - depositKey := lc.BuildDepositKey(fundingTxHash, fundingOutputIndex) - - // Seed the action at nonce 1 with a far-future TimeoutAt; the nonce - // gate is not under test here (covered by StrictNonceGate). - lc.SetReservation(depositKey, &tbtc.Reservation{ - WalletPublicKeyHash: walletPublicKeyHash, - State: tbtc.ReservationStateUnknown, - RequestNonce: 1, - }) - lc.SetReservationAction(depositKey, 1, &tbtc.ReservationAction{ - ActionType: tbtc.ReservationActionTypeAcceptance, - State: tbtc.ReservationActionStatePending, - TargetWalletPublicKeyHash: walletPublicKeyHash, - TxMaxFee: 5000, - MinAmount: 1000, - TermSeconds: 86400, - TimeoutAt: uint32(time.Now().Add(24 * time.Hour).Unix()), - }) - // Seed the state the strict fake mirrors from the on-chain - // validator so the far-future call below can pass every gate - // except the timeout margin: a Live wallet, a reserved deposit - // revealed through the reservation vault, and the configured - // parameters. - vault := testReservationVaultAddress - lc.SetWallet(walletPublicKeyHash, &tbtc.WalletChainData{State: tbtc.StateLive}) - lc.SetReservationParameters(tbtc.ReservationParameters{ - ReservationVault: vault, - }) - lc.SetDepositRequest( - fundingTxHash, - fundingOutputIndex, - &tbtc.DepositChainRequest{ - Amount: 2000000, - RevealedAt: time.Now().Add(-48 * time.Hour), - Vault: &vault, - }, - ) - lc.SetReservedDeposit(depositKey, true) - lc.SetDepositMinAge(3600) - - depositExtraInfo := struct { - *tbtc.Deposit - FundingTx *bitcoin.Transaction - }{ - Deposit: &tbtc.Deposit{ - WalletPublicKeyHash: walletPublicKeyHash, - // Far-future refund locktime, little-endian as stored on - // chain, so the 24-hour refund safety margin holds. - RefundLocktime: [4]byte{0x00, 0x79, 0xf7, 0x77}, - }, - } - - proposal := &tbtc.ReservationAnchorProposal{ - DepositFundingTxHash: fundingTxHash, - DepositFundingOutputIndex: fundingOutputIndex, - RequestNonce: 1, - AnchorTxFee: big.NewInt(710), - } + proposal, extraInfo, depositKey := strictValidatorFixture(t, lc, testWalletPublicKeyHash) // Far-future TimeoutAt: validation passes. if err := lc.ValidateReservationAnchorProposal( - walletPublicKeyHash, + testWalletPublicKeyHash, proposal, - depositExtraInfo, + extraInfo, ); err != nil { t.Fatalf("far-future TimeoutAt should pass: %v", err) } - // Override TimeoutAt to be just inside the 2-hour margin. - lc.SetReservationAction(depositKey, 1, &tbtc.ReservationAction{ - ActionType: tbtc.ReservationActionTypeAcceptance, - State: tbtc.ReservationActionStatePending, - TargetWalletPublicKeyHash: walletPublicKeyHash, - TxMaxFee: 5000, - MinAmount: 1000, - TermSeconds: 86400, - TimeoutAt: uint32(time.Now().Add(7198 * time.Second).Unix()), - }) + action, err := lc.GetReservationAction(depositKey, 2) + if err != nil { + t.Fatal(err) + } + insideMargin := *action + insideMargin.TimeoutAt = uint32(time.Now().Add(7198 * time.Second).Unix()) + lc.SetReservationAction(depositKey, 2, &insideMargin) if err := lc.ValidateReservationAnchorProposal( - walletPublicKeyHash, + testWalletPublicKeyHash, proposal, - depositExtraInfo, + extraInfo, ); err == nil { t.Fatal( "expected timeout margin rejection for TimeoutAt = now + 7198, got nil", @@ -2897,3 +2044,135 @@ func TestLocalChain_ValidateReservationReanchorProposal_RejectsInsideTimeoutMarg ) } } + +// TestReservationAcceptanceTask_BudgetCountsOnlyPendingAcceptances is a +// regression test for budget starvation: the per-run candidate budget +// (50) must only be spent on generations the on-chain check confirms are +// a Pending Acceptance targeting this wallet. Here 51 requested deposits +// whose generations have already settled sort ahead of one real pending +// acceptance; if they consumed the budget, the real request would never +// be reached and would time out. +func TestReservationAcceptanceTask_BudgetCountsOnlyPendingAcceptances(t *testing.T) { + btcChain := tbtcpg.NewLocalBitcoinChain() + currentBlock := uint64(300000) + + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) + + // Candidates are tried in timeout order, so the settled generations + // get the earlier timeouts. They were accepted before their timeout, + // so their request events are still inside the scan window. + for i := 0; i < 51; i++ { + opts := defaultPendingDepositOptions(currentBlock) + opts.seed = byte(0x40 + i) + opts.timeoutAt = uint32(time.Now().Add(20 * time.Hour).Unix()) + opts.actionState = tbtc.ReservationActionStateSettled + addPendingDeposit(t, ralc, btcChain, testWalletPublicKeyHash, opts) + } + realOpts := defaultPendingDepositOptions(currentBlock) + realOpts.seed = 0x01 + realOpts.timeoutAt = uint32(time.Now().Add(23 * time.Hour).Unix()) + real := addPendingDeposit(t, ralc, btcChain, testWalletPublicKeyHash, realOpts) + + proposal, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) + expectProposalFor(t, proposal, shouldExecute, err, real, 1) +} + +// TestReservationAcceptanceTask_FeeErrorSkipsCandidate is a regression test +// for per-generation fee caps: a generation whose snapshotted max fee is +// below the current anchor fee estimate is skipped, and the next +// generation, with a higher cap, is proposed instead of the whole window +// aborting. +func TestReservationAcceptanceTask_FeeErrorSkipsCandidate(t *testing.T) { + btcChain := tbtcpg.NewLocalBitcoinChain() + currentBlock := uint64(300000) + + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) + + lowCap := defaultPendingDepositOptions(currentBlock) + lowCap.seed = 0x10 + lowCap.txMaxFee = 500 // below the 710 sat estimate + lowCap.timeoutAt = uint32(time.Now().Add(20 * time.Hour).Unix()) + addPendingDeposit(t, ralc, btcChain, testWalletPublicKeyHash, lowCap) + + highCap := defaultPendingDepositOptions(currentBlock) + highCap.seed = 0x11 + highCap.timeoutAt = uint32(time.Now().Add(23 * time.Hour).Unix()) + viable := addPendingDeposit(t, ralc, btcChain, testWalletPublicKeyHash, highCap) + + proposal, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) + expectProposalFor(t, proposal, shouldExecute, err, viable, 1) +} + +// gaugeRecorder counts SetGauge calls per gauge. +type gaugeRecorder struct { + calls map[string]int +} + +func (g *gaugeRecorder) SetGauge(name string, value float64) { + g.calls[name]++ +} + +// TestReservationAcceptanceTask_GaugesPublishedOncePerRun pins the cost of +// the vault fee gauges: they are read once per Run, with the vault address +// the task already read from the reservation parameters, even when an +// earlier candidate fails validation and the task moves on to the next. +func TestReservationAcceptanceTask_GaugesPublishedOncePerRun(t *testing.T) { + btcChain := tbtcpg.NewLocalBitcoinChain() + currentBlock := uint64(300000) + + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) + + // The first candidate carries a funding transaction whose output does + // not hold its deposit script, so the strict validator rejects it. + broken := defaultPendingDepositOptions(currentBlock) + broken.seed = 0x20 + broken.timeoutAt = uint32(time.Now().Add(20 * time.Hour).Unix()) + brokenDeposit := addPendingDeposit(t, ralc, btcChain, testWalletPublicKeyHash, broken) + for _, event := range ralc.revealEvents { + if event.FundingTxHash == brokenDeposit.fundingTxHash { + event.BlindingFactor = [8]byte{0xff} + } + } + + good := defaultPendingDepositOptions(currentBlock) + good.seed = 0x21 + good.timeoutAt = uint32(time.Now().Add(23 * time.Hour).Unix()) + goodDeposit := addPendingDeposit(t, ralc, btcChain, testWalletPublicKeyHash, good) + + task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + recorder := &gaugeRecorder{calls: make(map[string]int)} + task.SetMetricsRecorderForTest(recorder) + + proposal, shouldExecute, err := runExistingTask(t, task, testWalletPublicKeyHash) + expectProposalFor(t, proposal, shouldExecute, err, goodDeposit, 1) + + if ralc.validateCalls != 2 { + t.Fatalf("expected both candidates to be validated, got %d", ralc.validateCalls) + } + if len(ralc.feeDebtVaults) != 1 || len(ralc.feeReserveVaults) != 1 { + t.Fatalf( + "expected one read per vault fee gauge, got debt=%d reserve=%d", + len(ralc.feeDebtVaults), + len(ralc.feeReserveVaults), + ) + } + if ralc.feeDebtVaults[0] != testReservationVaultAddress || + ralc.feeReserveVaults[0] != testReservationVaultAddress { + t.Fatalf( + "expected the fee reads to use the parameters' vault, got %v / %v", + ralc.feeDebtVaults, + ralc.feeReserveVaults, + ) + } + for _, gauge := range []string{ + "wallet_reservations_count", + "active_reservations_count", + "max_active_reservations", + "reservation_vault_fee_debt_sat", + "reservation_vault_fee_reserve_tbtc_base_units", + } { + if recorder.calls[gauge] != 1 { + t.Errorf("expected gauge %s published once, got %d", gauge, recorder.calls[gauge]) + } + } +} diff --git a/pkg/tbtcpg/reservation_reanchor.go b/pkg/tbtcpg/reservation_reanchor.go index 27b903ad05..5cda987479 100644 --- a/pkg/tbtcpg/reservation_reanchor.go +++ b/pkg/tbtcpg/reservation_reanchor.go @@ -24,9 +24,7 @@ const ReservationReanchorLookBackBlocks = uint64(216000) // waitForReservationReanchorRequestMined waits for a submitted // RequestReservationReanchor transaction to be mined, mirroring // MovingFundsTask.SubmitMovingFundsCommitment's bounded wait-then-re-read -// pattern. It doubles as the same-round fast path for a fresh request -// (below) and the "not observed for this many blocks means dropped" -// bound for the in-flight receipt check in Run. +// pattern. const reservationReanchorRequestWaitBlocks = uint64(6) // reservationRequestTimeoutSafetyMarginSeconds mirrors @@ -39,39 +37,13 @@ const reservationReanchorRequestWaitBlocks = uint64(6) // validator reject them. const reservationRequestTimeoutSafetyMarginSeconds = 2 * 60 * 60 -// reservationDepositRefundSafetyMarginSeconds mirrors -// WalletProposalValidatorConstants.DEPOSIT_REFUND_SAFETY_MARGIN -// (24 hours): the on-chain acceptance validator refuses to sign an -// anchor whose refund becomes available less than a day from now, so a -// wallet signing later cannot race the depositor's refund. -const reservationDepositRefundSafetyMarginSeconds = 24 * 60 * 60 - -// reservationReanchorInFlightRequest tracks a RequestReservationReanchor -// submission that has not yet been resolved by its receipt, keyed by the -// reservation key in the task's inFlightReanchorRequests map. -type reservationReanchorInFlightRequest struct { - // txHash is the RequestReservationReanchor transaction hash the - // receipt check looks up. - txHash [32]byte - // submittedAtBlock is the current block observed when the submission - // was recorded. - submittedAtBlock uint64 - // sourceWalletPublicKeyHash is the wallet that hosted the reservation - // when the submission was recorded. It identifies the entry to - // forgetInFlightReanchorRequests if that wallet's reservation list no - // longer contains the key: a custody move that happened before the - // next Run round means nothing left to resolve there and the chain - // state alone drives any resume. - sourceWalletPublicKeyHash [20]byte -} - // ReservationReanchorTask is a task that may produce a reservation re-anchor // proposal. The wallet enters this task when the source wallet has begun a -// move to a new wallet (state StateMovingFunds) or when the source wallet's -// main UTXO has dropped below the moving funds dust threshold (below-dust -// re-anchor). For every reservation currently custodied by the wallet, the -// task picks a destination wallet and assembles a 1-input-1-output re-anchor -// transaction moving the anchor outpoint into that destination wallet. +// move to a new wallet (state StateMovingFunds) or is closing +// (state StateClosing). For every reservation currently custodied by the +// wallet, the task picks a destination wallet and assembles a +// 1-input-1-output re-anchor transaction moving the anchor outpoint into +// that destination wallet. type ReservationReanchorTask struct { chain Chain btcChain bitcoin.Chain @@ -89,29 +61,14 @@ type ReservationReanchorTask struct { // TestReservationReanchorTask_TargetWalletExclusion_SharedTask). targetWalletCacheMutex sync.Mutex // cachedTargetWalletPublicKeyHash is the most recently selected - // re-anchor target wallet. findTargetWallet reuses it across Run - // calls -- validating headroom and liveness only for this one wallet - // -- instead of repeating its O(W) GetWallet-per-registration scan on - // every re-anchor window; a fresh full scan only runs when the cache - // is empty, the cached wallet fails validation, or the cached wallet - // is excluded. Meaningful only when hasCachedTargetWallet is true. + // re-anchor target wallet. reanchorTargetSearch tries it first in + // every Run -- validating headroom and liveness only for this one + // wallet -- instead of repeating its O(W) GetWallet-per-registration + // scan on every re-anchor window; a registration scan only runs when + // the cache is empty or the cached wallet is unusable. Meaningful only + // when hasCachedTargetWallet is true. cachedTargetWalletPublicKeyHash [20]byte hasCachedTargetWallet bool - - // inFlightReanchorRequestsMutex guards inFlightReanchorRequests. - // This task instance is shared across concurrent Run calls for - // different source wallets, as targetWalletCacheMutex is, so the - // per-reservation tracking state needs its own mutex. - inFlightReanchorRequestsMutex sync.Mutex - // inFlightReanchorRequests tracks RequestReservationReanchor - // submissions that have not yet been resolved by their receipt, - // keyed by reservation key. The receipt check at the top of Run - // settles each entry: mined resumes from chain state, reverted or - // not-observed-past-the-bound allows a fresh request, still - // pending skips this round. State loss on restart is tolerable: a - // mined request shows up as ActionPending (resume path) and a - // dropped request leaves Active (a new request is safe). - inFlightReanchorRequests map[string]reservationReanchorInFlightRequest } // NewReservationReanchorTask returns a new ReservationReanchorTask bound to @@ -121,9 +78,8 @@ func NewReservationReanchorTask( btcChain bitcoin.Chain, ) *ReservationReanchorTask { return &ReservationReanchorTask{ - chain: chain, - btcChain: btcChain, - inFlightReanchorRequests: make(map[string]reservationReanchorInFlightRequest), + chain: chain, + btcChain: btcChain, } } @@ -143,13 +99,15 @@ func (rrt *ReservationReanchorTask) ActionType() tbtc.WalletActionType { // Run evaluates whether the given wallet needs to re-anchor any of its // reservations and returns a single ReservationReanchorProposal for the // first reservation found to be re-anchorable. A wallet is a candidate for -// re-anchor only once it has entered the StateMovingFunds state (the -// wallet is migrating and reservations must be released to a live -// wallet); tbtc-v2's Reservation.requestReservationReanchor requires a -// privileged (governance) caller for StateLive sources (enforced on-chain -// in the tbtc-v2 contracts repo, outside keep-core), which the -// client's ordinary operator key can never satisfy, so no below-dust -// re-anchor trigger is attempted for Live wallets. +// re-anchor once it has entered the StateMovingFunds or StateClosing state +// (the wallet is winding down and reservations must be released to a live +// wallet), matching the source states tbtc-v2's permissionless +// Reservation.requestReservationReanchor accepts. A wallet can reach +// Closing while it still holds reservations, and it cannot finish closing +// until they are gone, so a re-anchor missed during MovingFunds must still +// be possible from Closing. Live sources require a privileged +// (governance) caller on-chain, which the client's ordinary operator key +// can never satisfy, so Live wallets are skipped. // // Each reservation is either Active (needs a freshly requested generation) // or already ActionPending with its current generation a Pending Reanchor @@ -158,6 +116,9 @@ func (rrt *ReservationReanchorTask) ActionType() tbtc.WalletActionType { // resumed at its real nonce and authorized target rather than skipped, so // an in-flight authorization is not abandoned and re-requested. // +// A pass sends at most one RequestReservationReanchor: once a request has +// been sent, any later failure ends the pass without a proposal. +// // Once a MovingFunds wallet's reservations are fully drained, Run also // checks whether its main UTXO has fallen below the moving funds dust // threshold and, if so, notifies the Bridge so wallet closing can proceed @@ -190,7 +151,7 @@ func (rrt *ReservationReanchorTask) Run( // live_wallets_count is published unconditionally on every Run pass, // mirroring the sibling active_reservations_count/max_active_reservations // gauges that ReservationAcceptanceTask publishes every coordination - // window: it must not depend on this task's StateMovingFunds/ + // window: it must not depend on this task's wallet-state/ // non-empty-reservations guards below, which are false for most // wallets in steady state. Gating the publish on those guards would // leave the gauge stuck at its registered-zero value indefinitely and @@ -206,7 +167,8 @@ func (rrt *ReservationReanchorTask) Run( rrt.metricsRecorder.SetGauge("live_wallets_count", float64(liveWalletsCount)) } - if walletChainData.State != tbtc.StateMovingFunds { + if walletChainData.State != tbtc.StateMovingFunds && + walletChainData.State != tbtc.StateClosing { taskLogger.Info("wallet is not eligible for reservation re-anchor") return nil, false, nil } @@ -219,13 +181,6 @@ func (rrt *ReservationReanchorTask) Run( ) } - // A reservation that left the wallet since the entry was recorded - // has nothing left to resolve under it: drop the in-flight entries - // that no longer appear in the wallet's reservation list before any - // path below (including the empty-list early return) can settle - // them. - rrt.forgetInFlightReanchorRequestsNotIn(walletPublicKeyHash, reservationKeys) - if len(reservationKeys) == 0 { taskLogger.Info("wallet has no reservations to re-anchor") // This duty stays embedded in Run() rather than becoming its own @@ -233,8 +188,12 @@ func (rrt *ReservationReanchorTask) Run( // scheduling wired up wherever coordination tasks are registered // (outside this package), which is a larger cross-package change // than justified here, whereas embedding it costs only piggybacking - // on this task's own already-scheduled invocation cadence. - rrt.notifyMovingFundsBelowDustIfEligible(taskLogger, walletPublicKeyHash) + // on this task's own already-scheduled invocation cadence. A + // Closing wallet is already past the moving funds process, so the + // notification only applies to MovingFunds wallets. + if walletChainData.State == tbtc.StateMovingFunds { + rrt.notifyMovingFundsBelowDustIfEligible(taskLogger, walletPublicKeyHash) + } return nil, false, nil } @@ -263,12 +222,14 @@ func (rrt *ReservationReanchorTask) Run( ) } - // triedTargets accumulates target wallets a cap-related revert has - // already ruled out during this Run pass. Capacity only grows during - // a pass (nothing here releases it), so a wallet excluded for one - // reservation is correctly excluded for every later reservation in - // the same pass too. - triedTargets := make(map[[20]byte]bool) + // One target search serves every reservation of this pass, so + // registrations and wallet states are read at most once per pass. + targets := rrt.newReanchorTargetSearch( + taskLogger, + walletPublicKeyHash, + params.MaxReservationsPerWallet, + maxReservationsAmountPerWallet, + ) reservationLoop: for _, reservationKey := range reservationKeys { @@ -281,59 +242,6 @@ reservationLoop: ) } - // In-flight request tracking: if a RequestReservationReanchor - // submitted in an earlier round for this reservation is still - // unresolved, check its receipt before doing anything else. A - // mined request means the chain now holds the new generation - // (resume from chain state below); a reverted or dropped one - // means the chain still shows the old state and a fresh request - // is allowed; a still-pending one means this reservation is - // skipped this round. The submission's block number bounds the - // pending window: once the submission is not observed within - // reservationReanchorRequestWaitBlocks it is treated as dropped - // and a new request is allowed. - if inFlight, ok := rrt.getReanchorRequestInFlight(reservationKey); ok { - resolved, reReadReservation, err := - rrt.resolveReanchorRequestInFlight( - taskLogger, - reservationKey, - inFlight, - ) - if err != nil { - taskLogger.Errorf( - "cannot resolve in-flight re-anchor request for "+ - "[0x%x]: [%v]", - reservationKey, - err, - ) - continue reservationLoop - } - if !resolved { - taskLogger.Infof( - "re-anchor request for [0x%x] still in flight; "+ - "skipping this reservation this round", - reservationKey, - ) - continue reservationLoop - } - if reReadReservation { - // The request just resolved as mined: re-read the - // reservation record so the switch below dispatches on - // the state the mined generation actually advanced it - // to (ActionPending with the new nonce). - reservation, err = rrt.chain.GetReservation(reservationKey) - if err != nil { - taskLogger.Errorf( - "cannot re-read reservation [0x%x] after an "+ - "in-flight request resolved as mined: [%v]", - reservationKey, - err, - ) - continue reservationLoop - } - } - } - switch reservation.State { case tbtc.ReservationStateActionPending: // Resume path: this generation was already authorized (by @@ -375,8 +283,8 @@ reservationLoop: // block.timestamp < action.TimeoutAt - // REQUEST_TIMEOUT_SAFETY_MARGIN, so a generation at or past // that boundary is skipped rather than proposed and rejected; - // a fresh request may be issued instead once the reservation - // is back in the Active state. + // a fresh request may be issued instead once the generation + // times out and the resulting re-anchor cooldown ends. if uint64(time.Now().Unix())+ uint64(reservationRequestTimeoutSafetyMarginSeconds) >= uint64(action.TimeoutAt) { @@ -428,15 +336,35 @@ reservationLoop: } anchorValue := uint64(reservation.AnchorUtxo.Value) - for { - target, err := rrt.findTargetWallet( - taskLogger, - walletPublicKeyHash, + // Mirror Reservation.sol's request-time gates for a + // permissionless caller, which would otherwise revert the + // request after a target search: no request before + // reanchorCooldownUntil, and the anchor must stay above + // txMaxFee + minAmount. + if uint64(time.Now().Unix()) < uint64(reservation.ReanchorCooldownUntil) { + taskLogger.Infof( + "reservation [0x%x] is in its re-anchor cooldown until "+ + "[%d], skipping", + reservationKey, + reservation.ReanchorCooldownUntil, + ) + continue reservationLoop + } + if anchorValue <= params.ReservationTxMaxFee+params.ReservationMinAmount { + taskLogger.Infof( + "reservation [0x%x] anchor [%d] is not above the "+ + "re-anchor floor (tx max fee [%d] + min amount [%d]), "+ + "skipping", + reservationKey, anchorValue, - params.MaxReservationsPerWallet, - maxReservationsAmountPerWallet, - triedTargets, + params.ReservationTxMaxFee, + params.ReservationMinAmount, ) + continue reservationLoop + } + + for { + target, err := targets.find(anchorValue) if err != nil { if errors.Is(err, errNoLiveTargetWallet) { taskLogger.Infof( @@ -468,24 +396,27 @@ reservationLoop: err, ) + var postSubmitErr *errReservationReanchorPostSubmitFailure + if errors.As(err, &postSubmitErr) { + // The request was sent. End the pass so it sends at + // most one request; a later round picks the request + // up from chain state (resume path once mined, a new + // request if the reservation is still Active). + return nil, false, nil + } + if isReservationCapRevertError(err) { // The target's capacity changed since our headroom // pre-check (a concurrent re-anchor or acceptance - // consumed it); evict it and try the next candidate + // consumed it); exclude it and try the next candidate // for this same reservation instead of giving up. - triedTargets[target] = true - rrt.evictCachedTargetWallet(target) + targets.exclude(target) continue } - // Every other failure on the request path is safe to - // retry on the next round without re-requesting: a - // request that went on-chain is tracked in-flight by - // its transaction hash (resolved by the receipt check - // at the top of this pass), and a pre-request local - // failure (fee estimation, assembly, validation) - // changed no chain state. Skip this reservation this - // pass. + // Nothing was sent (fee estimation, assembly, or another + // request revert), so no chain state changed: skip this + // reservation this pass. continue reservationLoop } @@ -502,18 +433,6 @@ reservationLoop: return nil, false, nil } -// errReservationReanchorRequestNotMined signals that a just-submitted -// RequestReservationReanchor was not observed as mined within the -// same-round fast-path wait. The submission itself succeeded: an -// in-flight tracking entry (with the submitted transaction hash and -// block) was recorded before the wait, so subsequent Run rounds -// settle it via its receipt instead of issuing a duplicate request. -// Run treats this as "skip this reservation this round" rather than -// as a window failure. -var errReservationReanchorRequestNotMined = errors.New( - "reservation re-anchor request not yet mined", -) - // isReservationCapRevertError reports whether err is a reservation // wallet-capacity revert from either RequestReservationReanchor or // ValidateReservationReanchorProposal, mirroring the two capacity @@ -529,24 +448,48 @@ func isReservationCapRevertError(err error) bool { return strings.Contains(err.Error(), "cap exceeded") } +// errReservationReanchorPostSubmitFailure marks a ProposeReservationReanchor +// failure that happened after RequestReservationReanchor was sent. Run +// checks for it with errors.As and ends the pass instead of moving on to +// another target or reservation, so a pass sends at most one re-anchor +// request. A failure before the request was sent leaves no chain state +// behind and is not wrapped. +type errReservationReanchorPostSubmitFailure struct { + err error +} + +func (e *errReservationReanchorPostSubmitFailure) Error() string { + return e.err.Error() +} + +func (e *errReservationReanchorPostSubmitFailure) Unwrap() error { + return e.err +} + // ProposeReservationReanchor assembles a single reservation re-anchor // proposal for the given reservation, targeting the given wallet. // // If the reservation's current generation is not already a Pending // Reanchor action authorizing this exact target, this call first requests // one on-chain (RequestReservationReanchor) and waits for it to be mined -// before re-reading the reservation and action for the real request nonce -// and snapshotted fee bound; only then does it build and validate the -// proposal. This ordering is required by the on-chain validator, which -// requires the action at proposal.RequestNonce to already be a Pending -// Reanchor -- Reservation.sol's requestReservationReanchor is what writes -// that record, so validating first would read the zero action and always -// revert. +// before re-reading the reservation and action for the real request nonce; +// only then does it build and validate the proposal. This ordering is +// required by the on-chain validator, which requires the action at +// proposal.RequestNonce to already be a Pending Reanchor -- +// Reservation.sol's requestReservationReanchor is what writes that record, +// so validating first would read the zero action and always revert. +// +// Before sending the request, the fee is estimated and the transaction is +// assembled against the live ReservationTxMaxFee, the value Solidity +// snapshots into the action the request creates. A fee that cannot fit +// therefore fails before anything is sent instead of leaving an unusable +// request on-chain. Any failure after the request was sent is returned as +// an *errReservationReanchorPostSubmitFailure. // // A caller resuming an already-Pending generation (Run does this for // reservations in ActionPending state) passes that generation's own -// authorized target and reaches this method with no chain write of its -// own; a failure here is therefore a pre-write failure, safe to retry. +// authorized target; the fee is then checked against the action's own +// snapshotted TxMaxFee and no chain write happens. // // The supplied fee may be 0 to trigger on-chain-driven fee estimation. func (rrt *ReservationReanchorTask) ProposeReservationReanchor( @@ -602,74 +545,120 @@ func (rrt *ReservationReanchorTask) ProposeReservationReanchor( action.ActionType == tbtc.ReservationActionTypeReanchor && action.State == tbtc.ReservationActionStatePending && action.TargetWalletPublicKeyHash == targetWalletPublicKeyHash - requestNonce := reservation.RequestNonce - if !resuming { - if reservation.State != tbtc.ReservationStateActive { - return nil, fmt.Errorf( - "reservation [0x%x] is not active and has no matching "+ - "pending re-anchor action to resume", - reservationKey, - ) - } - - taskLogger.Infof( - "requesting reservation re-anchor for [0x%x] to wallet [0x%x]", - reservationKey, - targetWalletPublicKeyHash, - ) - - txHash, err := rrt.chain.RequestReservationReanchor( - reservationKey, + if resuming { + // The fee bound is the action's own snapshotted txMaxFee: + // WalletProposalValidator.validateReservationReanchorProposal + // checks the proposed fee against action.txMaxFee, not the live + // parameter. + fee, err = rrt.prepareReservationReanchorTransaction( + taskLogger, + reservation.AnchorUtxo, targetWalletPublicKeyHash, + action.TxMaxFee, + fee, ) if err != nil { - return nil, fmt.Errorf("cannot request reservation re-anchor: [%w]", err) + return nil, err } - // Record the in-flight submission for cross-round receipt - // resolution (see Run's in-flight receipt check): a later round - // sees this entry and resolves the request via its receipt - // (mined -> resume, reverted/dropped -> allow a new request, - // pending -> skip this round). - rrt.recordReanchorRequestInFlight( + return rrt.validatedReservationReanchorProposal( taskLogger, sourceWalletPublicKeyHash, reservationKey, - txHash, + reservation.RequestNonce, + targetWalletPublicKeyHash, + fee, ) + } - // Same-round fast path: wait for the request to mine so this - // round builds the proposal directly rather than waiting for - // the next round's receipt resolution. A timeout is no longer a - // window-aborting post-write failure: the in-flight entry above - // carries the submission into the receipt check, which resolves - // it safely in subsequent rounds. - reservation, action, err = rrt.waitForReservationReanchorRequestMined( - taskLogger, + if reservation.State != tbtc.ReservationStateActive { + return nil, fmt.Errorf( + "reservation [0x%x] is not active and has no matching "+ + "pending re-anchor action to resume", reservationKey, - targetWalletPublicKeyHash, - requestNonce, ) - if err != nil { - return nil, errReservationReanchorRequestNotMined + } + + params, err := rrt.chain.ReservationParameters() + if err != nil { + return nil, fmt.Errorf( + "cannot get reservation parameters: [%w]", + err, + ) + } + + fee, err = rrt.prepareReservationReanchorTransaction( + taskLogger, + reservation.AnchorUtxo, + targetWalletPublicKeyHash, + params.ReservationTxMaxFee, + fee, + ) + if err != nil { + return nil, err + } + + taskLogger.Infof( + "requesting reservation re-anchor for [0x%x] to wallet [0x%x]", + reservationKey, + targetWalletPublicKeyHash, + ) + + if err := rrt.chain.RequestReservationReanchor( + reservationKey, + targetWalletPublicKeyHash, + ); err != nil { + return nil, fmt.Errorf("cannot request reservation re-anchor: [%w]", err) + } + + minedReservation, err := rrt.waitForReservationReanchorRequestMined( + taskLogger, + reservationKey, + targetWalletPublicKeyHash, + reservation.RequestNonce, + ) + if err != nil { + return nil, &errReservationReanchorPostSubmitFailure{ + err: fmt.Errorf( + "reservation re-anchor request not confirmed: [%w]", + err, + ), } - requestNonce = reservation.RequestNonce } - // The fee bound is the action's own snapshotted txMaxFee, not a live - // parameter value: WalletProposalValidator.validateReservationReanchorProposal - // checks the proposed fee against action.txMaxFee specifically (mirroring - // how it checks a redemption's fee against the request's own stored - // txMaxFee rather than a live Bridge parameter). - feeBoundAction := &tbtc.ReservationAction{TxMaxFee: action.TxMaxFee} + proposal, err := rrt.validatedReservationReanchorProposal( + taskLogger, + sourceWalletPublicKeyHash, + reservationKey, + minedReservation.RequestNonce, + targetWalletPublicKeyHash, + fee, + ) + if err != nil { + return nil, &errReservationReanchorPostSubmitFailure{err: err} + } + + return proposal, nil +} +// prepareReservationReanchorTransaction estimates the re-anchor fee when +// fee is not positive, and checks that the re-anchor transaction can be +// assembled with that fee under txMaxFee. It returns the fee to propose. +func (rrt *ReservationReanchorTask) prepareReservationReanchorTransaction( + taskLogger log.StandardLogger, + anchorUtxo *bitcoin.UnspentTransactionOutput, + targetWalletPublicKeyHash [20]byte, + txMaxFee uint64, + fee int64, +) (int64, error) { if fee <= 0 { taskLogger.Infof("estimating reservation re-anchor transaction fee") - fee, err = estimateReservationReanchorFee(rrt.btcChain, action.TxMaxFee) + var err error + fee, err = estimateReservationReanchorFee(rrt.btcChain, txMaxFee) if err != nil { - return nil, fmt.Errorf( + return 0, fmt.Errorf( "cannot estimate reservation re-anchor transaction fee: [%w]", err, ) @@ -680,17 +669,30 @@ func (rrt *ReservationReanchorTask) ProposeReservationReanchor( if _, err := tbtc.AssembleReservationReanchorTransaction( rrt.btcChain, - reservation.AnchorUtxo, + anchorUtxo, targetWalletPublicKeyHash, - feeBoundAction, + &tbtc.ReservationAction{TxMaxFee: txMaxFee}, fee, ); err != nil { - return nil, fmt.Errorf( + return 0, fmt.Errorf( "cannot assemble reservation re-anchor transaction: [%v]", err, ) } + return fee, nil +} + +// validatedReservationReanchorProposal builds the proposal for the given +// generation and validates it on-chain. +func (rrt *ReservationReanchorTask) validatedReservationReanchorProposal( + taskLogger log.StandardLogger, + sourceWalletPublicKeyHash [20]byte, + reservationKey *big.Int, + requestNonce uint64, + targetWalletPublicKeyHash [20]byte, + fee int64, +) (*tbtc.ReservationReanchorProposal, error) { proposal := &tbtc.ReservationReanchorProposal{ ReservationKey: new(big.Int).Set(reservationKey), RequestNonce: requestNonce, @@ -718,26 +720,28 @@ func (rrt *ReservationReanchorTask) ProposeReservationReanchor( // RequestReservationReanchor transaction to be mined: the reservation's // RequestNonce must have advanced past preRequestNonce, and the action at // the new nonce must be the Pending Reanchor generation authorizing -// targetWalletPublicKeyHash. +// targetWalletPublicKeyHash. If the nonce advanced to any other action, +// another request was mined first and this one can only revert, so the +// wait ends immediately with an error. func (rrt *ReservationReanchorTask) waitForReservationReanchorRequestMined( taskLogger log.StandardLogger, reservationKey *big.Int, targetWalletPublicKeyHash [20]byte, preRequestNonce uint64, -) (*tbtc.Reservation, *tbtc.ReservationAction, error) { +) (*tbtc.Reservation, error) { blockCounter, err := rrt.chain.BlockCounter() if err != nil { - return nil, nil, fmt.Errorf("error getting block counter: [%w]", err) + return nil, fmt.Errorf("error getting block counter: [%w]", err) } currentBlock, err := blockCounter.CurrentBlock() if err != nil { - return nil, nil, fmt.Errorf("error getting current block: [%w]", err) + return nil, fmt.Errorf("error getting current block: [%w]", err) } for blockHeight := currentBlock + 1; blockHeight <= currentBlock+reservationReanchorRequestWaitBlocks; blockHeight++ { if err := blockCounter.WaitForBlockHeight(blockHeight); err != nil { - return nil, nil, fmt.Errorf("error while waiting for block height: [%w]", err) + return nil, fmt.Errorf("error while waiting for block height: [%w]", err) } reservation, err := rrt.chain.GetReservation(reservationKey) @@ -765,18 +769,29 @@ func (rrt *ReservationReanchorTask) waitForReservationReanchorRequestMined( continue } - if action.ActionType == tbtc.ReservationActionTypeReanchor && - action.State == tbtc.ReservationActionStatePending && - action.TargetWalletPublicKeyHash == targetWalletPublicKeyHash { - taskLogger.Infof( - "reservation re-anchor request for [0x%x] confirmed at "+ - "block [%d], nonce [%d]", + if action.ActionType != tbtc.ReservationActionTypeReanchor || + action.State != tbtc.ReservationActionStatePending || + action.TargetWalletPublicKeyHash != targetWalletPublicKeyHash { + return nil, fmt.Errorf( + "reservation [0x%x] advanced to nonce [%d] with a "+ + "different action (type=%v, state=%v, target=0x%x); "+ + "another request was mined first", reservationKey, - blockHeight, reservation.RequestNonce, + action.ActionType, + action.State, + action.TargetWalletPublicKeyHash, ) - return reservation, action, nil } + + taskLogger.Infof( + "reservation re-anchor request for [0x%x] confirmed at "+ + "block [%d], nonce [%d]", + reservationKey, + blockHeight, + reservation.RequestNonce, + ) + return reservation, nil } taskLogger.Infof( @@ -787,7 +802,7 @@ func (rrt *ReservationReanchorTask) waitForReservationReanchorRequestMined( ) } - return nil, nil, fmt.Errorf( + return nil, fmt.Errorf( "reservation re-anchor request for [0x%x] not confirmed within "+ "[%d] blocks", reservationKey, @@ -795,28 +810,34 @@ func (rrt *ReservationReanchorTask) waitForReservationReanchorRequestMined( ) } -// errNoLiveTargetWallet signals that no eligible re-anchor destination -// wallet was found -- either no wallet is Live at all, or every candidate -// lacks count/amount headroom -- a legitimate "nothing to do yet" outcome, -// not a chain-read failure. Run treats it as a benign no-op for the -// current reservation; any other error returned by findTargetWallet is a -// genuine RPC/chain-read failure and is propagated so the coordinator -// retries. -var errNoLiveTargetWallet = errors.New("no live wallet available for re-anchor target") - -// findTargetWallet picks a live destination wallet, with count and amount -// headroom for anchorValue, from the on-chain wallet registry for the -// re-anchor transaction's output. The new wallet must be in StateLive, -// must not be the source wallet itself, must not be in excluded (wallets -// a cap-related revert already ruled out earlier in this Run pass), and -// must have room under maxReservationsPerWallet / -// maxReservationsAmountPerWallet for one more reservation of anchorValue -// satoshi -- mirroring the capacity Reservation.sol's +// reanchorTargetSearch picks live destination wallets for the re-anchor +// requests of one Run pass. A target must be in StateLive, must not be the +// source wallet itself, and must have room under maxReservationsPerWallet / +// maxReservationsAmountPerWallet for one more reservation of the anchor +// value -- mirroring the capacity Reservation.sol's // requestReservationReanchor reserves on the target at request time. // +// The search keeps its state for the whole pass and is local to one Run +// call (the task instance is shared across concurrent Runs for different +// source wallets). Registration events and wallet states are read at most +// once per pass, lazily on the first reservation that needs a target, and +// capacity reads are not repeated for wallets or anchor values already +// ruled out. Nothing in a pass releases target capacity, and a pass ends +// after its first request, so what was ruled out stays ruled out. +// +// The previously selected target wallet is cached across passes and is +// validated first; the registration scan only runs when the cached wallet +// is missing or unusable. The scan is bounded to +// ReservationReanchorLookBackBlocks (mirroring the other look-back scans in +// this package) and falls back to an unbounded scan only when no recently +// registered wallet has headroom: GetLiveWalletsCount (checked by Run) can +// confirm live wallets exist even when none of them registered within the +// look-back window. The fallback skips wallets the bounded scan already +// read. +// // Selection here is independent of the source wallet's own moving funds // commitment (SubmitMovingFundsCommitment / -// PastMovingFundsCommitmentSubmittedEvents): this method does not attempt +// PastMovingFundsCommitmentSubmittedEvents): the search does not attempt // to route the reservation's anchor UTXO to one of the specific wallets // the source wallet has committed to for its Bitcoin funds move. A // reservation re-anchored here may therefore end up under different @@ -829,459 +850,307 @@ var errNoLiveTargetWallet = errors.New("no live wallet available for re-anchor t // and disambiguate among possibly several committed target wallets at // proposal time; that is not worth building unless the chain interface // already exposed the mapping trivially, which it does not today. -// -// The previously selected target wallet is cached and, when present and -// not excluded, is the only wallet validated (GetWallet + StateLive + -// not-the-source + headroom) before reuse -- avoiding the O(W) -// GetWallet-per-registration scan below on every re-anchor window. A -// fresh scan runs only when the cache is empty, the cached wallet fails -// validation (it since went non-Live, lost its headroom, or the caller's -// own source wallet now matches it), or the cached wallet is excluded. -func (rrt *ReservationReanchorTask) findTargetWallet( +type reanchorTargetSearch struct { + rrt *ReservationReanchorTask + logger log.StandardLogger + sourceWalletPublicKeyHash [20]byte + maxReservationsPerWallet uint32 + maxReservationsAmountPerWallet uint64 + + // checked holds every wallet whose state was already read this pass. + checked map[[20]byte]bool + // excluded holds wallets ruled out for every anchor value this pass: + // count cap reached, or rejected by a request-time capacity revert. + excluded map[[20]byte]bool + // candidates are the Live wallets found so far, in selection order: + // the cached wallet first, then newest registration first. + candidates [][20]byte + + cachedChecked bool + recentScanned bool + fullScanned bool + + // noHeadroomFromAnchor is the smallest anchor value no candidate had + // headroom for; zero means no such value is known yet. The amount + // check only gets harder as the anchor grows, so every anchor at or + // above it is ruled out too. + noHeadroomFromAnchor uint64 +} + +// newReanchorTargetSearch returns a target search for one Run pass. It +// reads nothing until find is first called. +func (rrt *ReservationReanchorTask) newReanchorTargetSearch( taskLogger log.StandardLogger, sourceWalletPublicKeyHash [20]byte, - anchorValue uint64, maxReservationsPerWallet uint32, maxReservationsAmountPerWallet uint64, - excluded map[[20]byte]bool, -) ([20]byte, error) { - if cached, ok := rrt.cachedTargetWallet(sourceWalletPublicKeyHash); ok && !excluded[cached] { - walletChainData, err := rrt.chain.GetWallet(cached) - if err == nil && walletChainData.State == tbtc.StateLive { - hasHeadroom, err := rrt.walletHasReanchorHeadroom( - cached, anchorValue, maxReservationsPerWallet, maxReservationsAmountPerWallet, - ) - if err != nil { - return [20]byte{}, err - } - if hasHeadroom { - return cached, nil - } - taskLogger.Infof( - "cached re-anchor target wallet [0x%x] has no headroom; "+ - "scanning for a new one", - cached, - ) - } else { - taskLogger.Infof( - "cached re-anchor target wallet [0x%x] is no longer valid; "+ - "scanning for a new one", - cached, - ) - } - rrt.evictCachedTargetWallet(cached) - } - - targetWalletPublicKeyHash, err := rrt.scanForTargetWallet( - taskLogger, - sourceWalletPublicKeyHash, - anchorValue, - maxReservationsPerWallet, - maxReservationsAmountPerWallet, - excluded, - ) - if err != nil { - return [20]byte{}, err +) *reanchorTargetSearch { + return &reanchorTargetSearch{ + rrt: rrt, + logger: taskLogger, + sourceWalletPublicKeyHash: sourceWalletPublicKeyHash, + maxReservationsPerWallet: maxReservationsPerWallet, + maxReservationsAmountPerWallet: maxReservationsAmountPerWallet, + checked: make(map[[20]byte]bool), + excluded: make(map[[20]byte]bool), } - - rrt.setCachedTargetWallet(targetWalletPublicKeyHash) - return targetWalletPublicKeyHash, nil } -// walletHasReanchorHeadroom mirrors the capacity check -// Reservation.sol's requestReservationReanchor performs on the target -// wallet at request time: the wallet's count of currently-custodied -// reservations plus one must not exceed maxReservationsPerWallet, and -// (only when the amount cap is configured -- zero disables it, matching -// Solidity's `maxReservationsAmountPerWallet == 0` disable convention) -// its current reserved amount plus anchorValue must not exceed -// maxReservationsAmountPerWallet. -func (rrt *ReservationReanchorTask) walletHasReanchorHeadroom( - walletPublicKeyHash [20]byte, - anchorValue uint64, - maxReservationsPerWallet uint32, - maxReservationsAmountPerWallet uint64, -) (bool, error) { - count, err := rrt.chain.WalletReservationsCount(walletPublicKeyHash) - if err != nil { - return false, fmt.Errorf("cannot get wallet reservations count: [%w]", err) - } - if count+1 > maxReservationsPerWallet { - return false, nil +// errNoLiveTargetWallet signals that no eligible re-anchor destination +// wallet was found -- either no wallet is Live at all, or every candidate +// lacks count/amount headroom -- a legitimate "nothing to do yet" outcome, +// not a chain-read failure. Run treats it as a benign no-op for the +// current reservation; any other error returned by the target search is a +// genuine RPC/chain-read failure and is propagated so the coordinator +// retries. +var errNoLiveTargetWallet = errors.New("no live wallet available for re-anchor target") + +// find returns a target wallet with headroom for anchorValue, loading more +// candidates only when the ones already found have none. +func (s *reanchorTargetSearch) find(anchorValue uint64) ([20]byte, error) { + if s.noHeadroomFromAnchor != 0 && anchorValue >= s.noHeadroomFromAnchor { + return [20]byte{}, errNoLiveTargetWallet } - if maxReservationsAmountPerWallet > 0 { - amount, err := rrt.chain.WalletReservationsAmount(walletPublicKeyHash) + // Candidates already checked for this anchor value are not checked + // again after more candidates are loaded. + from := 0 + for { + target, found, err := s.pick(anchorValue, from) if err != nil { - return false, fmt.Errorf("cannot get wallet reservations amount: [%w]", err) + return [20]byte{}, err } - if amount+anchorValue > maxReservationsAmountPerWallet { - return false, nil + if found { + s.rrt.setCachedTargetWallet(target) + return target, nil } - } - - return true, nil -} - -// cachedTargetWallet returns the cached re-anchor target wallet, if any, -// still usable for sourceWalletPublicKeyHash. A cached wallet that now -// equals the requesting source wallet itself (e.g. a former target has -// since entered MovingFunds and become a source in its own right) is -// reported as absent so the caller falls back to a fresh scan. -func (rrt *ReservationReanchorTask) cachedTargetWallet( - sourceWalletPublicKeyHash [20]byte, -) ([20]byte, bool) { - rrt.targetWalletCacheMutex.Lock() - defer rrt.targetWalletCacheMutex.Unlock() - if !rrt.hasCachedTargetWallet { - return [20]byte{}, false - } - if rrt.cachedTargetWalletPublicKeyHash == sourceWalletPublicKeyHash { - return [20]byte{}, false + from = len(s.candidates) + loaded, err := s.loadMoreCandidates() + if err != nil { + return [20]byte{}, err + } + if !loaded { + s.noHeadroomFromAnchor = anchorValue + return [20]byte{}, errNoLiveTargetWallet + } } - return rrt.cachedTargetWalletPublicKeyHash, true } -// setCachedTargetWallet records the most recently selected re-anchor -// target wallet for reuse by future findTargetWallet calls. -func (rrt *ReservationReanchorTask) setCachedTargetWallet( - targetWalletPublicKeyHash [20]byte, -) { - rrt.targetWalletCacheMutex.Lock() - defer rrt.targetWalletCacheMutex.Unlock() - - rrt.cachedTargetWalletPublicKeyHash = targetWalletPublicKeyHash - rrt.hasCachedTargetWallet = true +// exclude rules target out for the rest of the pass, after a request-time +// capacity revert showed its capacity changed since the headroom check. +func (s *reanchorTargetSearch) exclude(target [20]byte) { + s.excluded[target] = true + s.rrt.evictCachedTargetWallet(target) } -// evictCachedTargetWallet clears the cached re-anchor target wallet if it -// currently equals target, so the next findTargetWallet call performs a -// fresh scan instead of reusing a wallet just proven to have no headroom -// or to have reverted on capacity. -func (rrt *ReservationReanchorTask) evictCachedTargetWallet(target [20]byte) { - rrt.targetWalletCacheMutex.Lock() - defer rrt.targetWalletCacheMutex.Unlock() - - if rrt.hasCachedTargetWallet && rrt.cachedTargetWalletPublicKeyHash == target { - rrt.hasCachedTargetWallet = false - } -} +// pick returns the first candidate, starting at index from, with headroom +// for anchorValue. A candidate whose count cap is reached is excluded for +// the rest of the pass. +func (s *reanchorTargetSearch) pick( + anchorValue uint64, + from int, +) ([20]byte, bool, error) { + for _, candidate := range s.candidates[from:] { + if s.excluded[candidate] { + continue + } -// recordReanchorRequestInFlight records the just-submitted -// RequestReservationReanchor transaction hash for the reservation under -// sourceWalletPublicKeyHash, the wallet that hosted the reservation when -// the submission was recorded. The current block (or 0 if the block -// counter is unavailable) bounds the receipt check's "not observed for -// this many blocks means dropped" logic. -func (rrt *ReservationReanchorTask) recordReanchorRequestInFlight( - taskLogger log.StandardLogger, - sourceWalletPublicKeyHash [20]byte, - reservationKey *big.Int, - txHash [32]byte, -) { - var submittedBlock uint64 - if blockCounter, err := rrt.chain.BlockCounter(); err == nil { - if currentBlock, err := blockCounter.CurrentBlock(); err == nil { - submittedBlock = currentBlock - } else { - taskLogger.Warnf( - "cannot read current block when recording in-flight re-anchor "+ - "request for [0x%x]: [%v]; the receipt check will treat "+ - "any pending status as past the window", - reservationKey, + count, err := s.rrt.chain.WalletReservationsCount(candidate) + if err != nil { + return [20]byte{}, false, fmt.Errorf( + "cannot get wallet reservations count: [%w]", err, ) } - } else { - taskLogger.Warnf( - "cannot get block counter when recording in-flight re-anchor "+ - "request for [0x%x]: [%v]; the receipt check will treat any "+ - "pending status as past the window", - reservationKey, - err, - ) - } - - rrt.inFlightReanchorRequestsMutex.Lock() - defer rrt.inFlightReanchorRequestsMutex.Unlock() + if count+1 > s.maxReservationsPerWallet { + s.logger.Infof( + "candidate re-anchor target wallet [0x%x] has no count "+ + "headroom, skipping", + candidate, + ) + s.exclude(candidate) + continue + } - rrt.inFlightReanchorRequests[reservationKey.Text(16)] = reservationReanchorInFlightRequest{ - txHash: txHash, - submittedAtBlock: submittedBlock, - sourceWalletPublicKeyHash: sourceWalletPublicKeyHash, - } -} + // Zero disables the amount cap, matching Solidity's + // `maxReservationsAmountPerWallet == 0` convention. + if s.maxReservationsAmountPerWallet > 0 { + amount, err := s.rrt.chain.WalletReservationsAmount(candidate) + if err != nil { + return [20]byte{}, false, fmt.Errorf( + "cannot get wallet reservations amount: [%w]", + err, + ) + } + if amount+anchorValue > s.maxReservationsAmountPerWallet { + s.logger.Infof( + "candidate re-anchor target wallet [0x%x] has no amount "+ + "headroom for [%d] satoshi, skipping", + candidate, + anchorValue, + ) + s.rrt.evictCachedTargetWallet(candidate) + continue + } + } -// forgetInFlightReanchorRequestsNotIn drops the in-flight entries for -// sourceWalletPublicKeyHash whose reservation key is not in currentKeys, -// called at the top of Run, just after the wallet's reservation list is -// read: a reservation that has since left the wallet (moved to another -// custody) has nothing left to resume under this wallet, so the chain -// state alone drives any follow-up and the tracking entry would otherwise -// leak for the task's lifetime. Entries hosted by any other wallet are -// left untouched. -func (rrt *ReservationReanchorTask) forgetInFlightReanchorRequestsNotIn( - sourceWalletPublicKeyHash [20]byte, - currentKeys []*big.Int, -) { - present := make(map[string]bool, len(currentKeys)) - for _, key := range currentKeys { - present[key.Text(16)] = true + return candidate, true, nil } - rrt.inFlightReanchorRequestsMutex.Lock() - defer rrt.inFlightReanchorRequestsMutex.Unlock() + return [20]byte{}, false, nil +} - for key, entry := range rrt.inFlightReanchorRequests { - if entry.sourceWalletPublicKeyHash == sourceWalletPublicKeyHash && - !present[key] { - delete(rrt.inFlightReanchorRequests, key) +// loadMoreCandidates adds the next batch of Live wallets to the +// candidates: the cached wallet, then the wallets registered within the +// look-back window, then every other registered wallet. It reports false +// once there is nothing left to load. +func (s *reanchorTargetSearch) loadMoreCandidates() (bool, error) { + if !s.cachedChecked { + s.cachedChecked = true + if cached, ok := s.rrt.cachedTargetWallet(s.sourceWalletPublicKeyHash); ok { + if !s.addCandidateIfLive(cached) { + s.logger.Infof( + "cached re-anchor target wallet [0x%x] is no longer "+ + "valid; scanning for a new one", + cached, + ) + s.rrt.evictCachedTargetWallet(cached) + } + return true, nil } } -} - -// getReanchorRequestInFlight returns the in-flight record for the -// reservation key, if any. -func (rrt *ReservationReanchorTask) getReanchorRequestInFlight( - reservationKey *big.Int, -) (reservationReanchorInFlightRequest, bool) { - rrt.inFlightReanchorRequestsMutex.Lock() - defer rrt.inFlightReanchorRequestsMutex.Unlock() - - entry, ok := rrt.inFlightReanchorRequests[reservationKey.Text(16)] - return entry, ok -} - -// forgetInFlightReanchorRequest drops the in-flight record for the -// reservation key, called once the receipt outcome has been resolved. -func (rrt *ReservationReanchorTask) forgetInFlightReanchorRequest( - reservationKey *big.Int, -) { - rrt.inFlightReanchorRequestsMutex.Lock() - defer rrt.inFlightReanchorRequestsMutex.Unlock() - - delete(rrt.inFlightReanchorRequests, reservationKey.Text(16)) -} -// resolveReanchorRequestInFlight looks up the receipt for an in-flight -// RequestReservationReanchor submission and settles it. -// -// - Mined: the chain now holds the new generation; the entry is -// forgotten. Callers should re-read the reservation and take the -// ActionPending resume path. -// - Reverted: no generation was written; the entry is forgotten and a -// fresh request is allowed this round. -// - Pending / NotFound: the submission is still unobserved. Within -// reservationReanchorRequestWaitBlocks of submission the caller -// should skip this reservation for this round (the receipt may -// surface on a later one); past that bound the submission is -// treated as dropped and a new request is allowed. -// -// The returned values are: -// -// resolved -- the in-flight entry has been settled and the -// caller may continue past it; -// re-mustClear -- the chain state may have advanced (only true for -// the Mined case), so the caller should re-read the -// reservation record before dispatching. -func (rrt *ReservationReanchorTask) resolveReanchorRequestInFlight( - taskLogger log.StandardLogger, - reservationKey *big.Int, - inFlight reservationReanchorInFlightRequest, -) (resolved bool, reMustClear bool, err error) { - status, err := rrt.chain.GetReservationReanchorRequestReceipt(inFlight.txHash) - if err != nil { - return false, false, fmt.Errorf( - "receipt lookup: [%w]", - err, - ) - } + if !s.recentScanned { + s.recentScanned = true - switch status { - case tbtc.ReservationReanchorRequestReceiptMined: - taskLogger.Infof( - "in-flight re-anchor request [%x] for reservation [0x%x] mined; "+ - "resuming from chain state", - inFlight.txHash, - reservationKey, - ) - rrt.forgetInFlightReanchorRequest(reservationKey) - return true, true, nil - case tbtc.ReservationReanchorRequestReceiptReverted: - taskLogger.Infof( - "in-flight re-anchor request [%x] for reservation [0x%x] reverted; "+ - "allowing a new request", - inFlight.txHash, - reservationKey, - ) - rrt.forgetInFlightReanchorRequest(reservationKey) - return true, false, nil - case tbtc.ReservationReanchorRequestReceiptPending, - tbtc.ReservationReanchorRequestReceiptNotFound: - blockCounter, err := rrt.chain.BlockCounter() + blockCounter, err := s.rrt.chain.BlockCounter() if err != nil { - return false, false, fmt.Errorf( - "block counter: [%w]", - err, - ) + return false, fmt.Errorf("failed to get block counter: [%v]", err) } currentBlock, err := blockCounter.CurrentBlock() if err != nil { - return false, false, fmt.Errorf( - "current block: [%w]", - err, - ) - } - if currentBlock > inFlight.submittedAtBlock && - currentBlock-inFlight.submittedAtBlock > - reservationReanchorRequestWaitBlocks { - taskLogger.Infof( - "in-flight re-anchor request [%x] for reservation [0x%x] "+ - "unobserved for [%d] blocks; treating as dropped and "+ - "allowing a new request", - inFlight.txHash, - reservationKey, - currentBlock-inFlight.submittedAtBlock, - ) - rrt.forgetInFlightReanchorRequest(reservationKey) - return true, false, nil + return false, fmt.Errorf("failed to get current block: [%v]", err) } - return false, false, nil - } - return true, false, nil -} + startBlock := uint64(0) + if currentBlock > ReservationReanchorLookBackBlocks { + startBlock = currentBlock - ReservationReanchorLookBackBlocks + } + if startBlock == 0 { + // The bounded scan already covers full chain history. + s.fullScanned = true + } -// scanForTargetWallet performs the registration-event scan findTargetWallet -// falls back to when no cached target wallet is usable. The primary scan -// is bounded to ReservationReanchorLookBackBlocks (mirroring the other -// look-back scans in this package): an unbounded eth_getLogs scan on -// every re-anchor attempt is too expensive to run every window. -// GetLiveWalletsCount (checked by the caller before findTargetWallet -// runs) can confirm live wallets exist even when none of them registered -// within the look-back window, so a bounded scan that finds no candidate -// falls back to an unbounded one instead of leaving Run stuck returning -// no proposal indefinitely. -func (rrt *ReservationReanchorTask) scanForTargetWallet( - taskLogger log.StandardLogger, - sourceWalletPublicKeyHash [20]byte, - anchorValue uint64, - maxReservationsPerWallet uint32, - maxReservationsAmountPerWallet uint64, - excluded map[[20]byte]bool, -) ([20]byte, error) { - blockCounter, err := rrt.chain.BlockCounter() - if err != nil { - return [20]byte{}, fmt.Errorf("failed to get block counter: [%v]", err) + return true, s.addRegisteredCandidates(startBlock) } - currentBlock, err := blockCounter.CurrentBlock() - if err != nil { - return [20]byte{}, fmt.Errorf("failed to get current block: [%v]", err) - } + if !s.fullScanned { + s.fullScanned = true - startBlock := uint64(0) - if currentBlock > ReservationReanchorLookBackBlocks { - startBlock = currentBlock - ReservationReanchorLookBackBlocks - } + s.logger.Infof( + "no live re-anchor target with headroom registered within the "+ + "last [%d] blocks, falling back to an unbounded registration "+ + "event scan", + ReservationReanchorLookBackBlocks, + ) - targetWalletPublicKeyHash, err := rrt.findLiveWalletFromRegistrationEvents( - taskLogger, - sourceWalletPublicKeyHash, - startBlock, - anchorValue, - maxReservationsPerWallet, - maxReservationsAmountPerWallet, - excluded, - ) - if err == nil { - return targetWalletPublicKeyHash, nil - } - if startBlock == 0 { - // The bounded scan above already covered full chain history. - return [20]byte{}, err + return true, s.addRegisteredCandidates(0) } - taskLogger.Infof( - "no live re-anchor target with headroom registered within the "+ - "last [%d] blocks, falling back to an unbounded registration "+ - "event scan", - ReservationReanchorLookBackBlocks, - ) - - return rrt.findLiveWalletFromRegistrationEvents( - taskLogger, - sourceWalletPublicKeyHash, - 0, - anchorValue, - maxReservationsPerWallet, - maxReservationsAmountPerWallet, - excluded, - ) + return false, nil } -// findLiveWalletFromRegistrationEvents scans new-wallet-registered events -// starting at startBlock and returns the most-recently-registered Live -// wallet, other than sourceWalletPublicKeyHash or any wallet in excluded, -// that has count/amount headroom for anchorValue. -func (rrt *ReservationReanchorTask) findLiveWalletFromRegistrationEvents( - taskLogger log.StandardLogger, - sourceWalletPublicKeyHash [20]byte, - startBlock uint64, - anchorValue uint64, - maxReservationsPerWallet uint32, - maxReservationsAmountPerWallet uint64, - excluded map[[20]byte]bool, -) ([20]byte, error) { - events, err := rrt.chain.PastNewWalletRegisteredEvents( +// addRegisteredCandidates scans new-wallet-registered events from +// startBlock and adds, newest first, the Live wallets not read yet. +func (s *reanchorTargetSearch) addRegisteredCandidates(startBlock uint64) error { + events, err := s.rrt.chain.PastNewWalletRegisteredEvents( &tbtc.NewWalletRegisteredEventFilter{StartBlock: startBlock}, ) if err != nil { - return [20]byte{}, fmt.Errorf( + return fmt.Errorf( "failed to get past new wallet registered events: [%v]", err, ) } for i := len(events) - 1; i >= 0; i-- { - walletPubKeyHash := events[i].WalletPublicKeyHash - if walletPubKeyHash == sourceWalletPublicKeyHash || excluded[walletPubKeyHash] { - continue - } + s.addCandidateIfLive(events[i].WalletPublicKeyHash) + } - wallet, err := rrt.chain.GetWallet(walletPubKeyHash) - if err != nil { - taskLogger.Errorf( - "failed to get wallet data for wallet with PKH [0x%x]: [%v]", - walletPubKeyHash, - err, - ) - continue - } + return nil +} - if wallet.State != tbtc.StateLive { - continue - } +// addCandidateIfLive reads the wallet's state, unless it was already read +// this pass, and adds it to the candidates if it is Live and not the +// source wallet. It reports whether the wallet was added. +func (s *reanchorTargetSearch) addCandidateIfLive(walletPublicKeyHash [20]byte) bool { + if walletPublicKeyHash == s.sourceWalletPublicKeyHash || + s.checked[walletPublicKeyHash] { + return false + } + s.checked[walletPublicKeyHash] = true - hasHeadroom, err := rrt.walletHasReanchorHeadroom( - walletPubKeyHash, anchorValue, maxReservationsPerWallet, maxReservationsAmountPerWallet, + wallet, err := s.rrt.chain.GetWallet(walletPublicKeyHash) + if err != nil { + s.logger.Errorf( + "failed to get wallet data for wallet with PKH [0x%x]: [%v]", + walletPublicKeyHash, + err, ) - if err != nil { - return [20]byte{}, err - } - if !hasHeadroom { - taskLogger.Infof( - "candidate re-anchor target wallet [0x%x] has no headroom, skipping", - walletPubKeyHash, - ) - continue - } + return false + } + if wallet.State != tbtc.StateLive { + return false + } - return walletPubKeyHash, nil + s.candidates = append(s.candidates, walletPublicKeyHash) + return true +} + +// cachedTargetWallet returns the cached re-anchor target wallet, if any, +// still usable for sourceWalletPublicKeyHash. A cached wallet that now +// equals the requesting source wallet itself (e.g. a former target has +// since entered MovingFunds and become a source in its own right) is +// reported as absent so the caller falls back to a fresh scan. +func (rrt *ReservationReanchorTask) cachedTargetWallet( + sourceWalletPublicKeyHash [20]byte, +) ([20]byte, bool) { + rrt.targetWalletCacheMutex.Lock() + defer rrt.targetWalletCacheMutex.Unlock() + + if !rrt.hasCachedTargetWallet { + return [20]byte{}, false } + if rrt.cachedTargetWalletPublicKeyHash == sourceWalletPublicKeyHash { + return [20]byte{}, false + } + return rrt.cachedTargetWalletPublicKeyHash, true +} + +// setCachedTargetWallet records the most recently selected re-anchor +// target wallet for reuse by future Run passes. +func (rrt *ReservationReanchorTask) setCachedTargetWallet( + targetWalletPublicKeyHash [20]byte, +) { + rrt.targetWalletCacheMutex.Lock() + defer rrt.targetWalletCacheMutex.Unlock() - return [20]byte{}, errNoLiveTargetWallet + rrt.cachedTargetWalletPublicKeyHash = targetWalletPublicKeyHash + rrt.hasCachedTargetWallet = true +} + +// evictCachedTargetWallet clears the cached re-anchor target wallet if it +// currently equals target, so the next Run pass performs a fresh scan +// instead of reusing a wallet just proven to have no headroom or to have +// reverted on capacity. +func (rrt *ReservationReanchorTask) evictCachedTargetWallet(target [20]byte) { + rrt.targetWalletCacheMutex.Lock() + defer rrt.targetWalletCacheMutex.Unlock() + + if rrt.hasCachedTargetWallet && rrt.cachedTargetWalletPublicKeyHash == target { + rrt.hasCachedTargetWallet = false + } } // isBelowMovingFundsDustThreshold returns the wallet's resolved main UTXO diff --git a/pkg/tbtcpg/reservation_reanchor_inflight_test.go b/pkg/tbtcpg/reservation_reanchor_inflight_test.go index 0023ce425e..bccfc8f789 100644 --- a/pkg/tbtcpg/reservation_reanchor_inflight_test.go +++ b/pkg/tbtcpg/reservation_reanchor_inflight_test.go @@ -12,8 +12,8 @@ import ( // newInFlightReanchorFixture wires a fresh LocalChain / LocalBitcoinChain // pair with a single Active reservation, its anchor UTXO, and a // registered Live target wallet, ready to drive -// ReservationReanchorTask.Run across the receipt-resolution scenarios in -// this file. The block counter starts at block 1000. +// ReservationReanchorTask.Run across the multi-round scenarios in this +// file. The block counter starts at block 1000. func newInFlightReanchorFixture(t *testing.T) ( tbtcChain *LocalChain, btcChain *LocalBitcoinChain, @@ -57,6 +57,7 @@ func newInFlightReanchorFixture(t *testing.T) ( ReservationTxMaxFee: 100000, MaxReservationsPerWallet: 5, ReservationMinAmount: 1000, + ReservationActionTimeout: 86400, }) btcChain.SetEstimateSatPerVByteFee(1, 1) @@ -125,25 +126,25 @@ func seedResumableReanchorGeneration( State: tbtc.ReservationActionStatePending, TargetWalletPublicKeyHash: targetWalletPublicKeyHash, TxMaxFee: 100000, - MinAmount: 1000, - TermSeconds: 86400, + Amount: 200000, TimeoutAt: uint32(timeoutAt.Unix()), }) } -// TestReservationReanchorTask_InFlight_PendingWithinBound_SkipsWithoutNewRequest -// pins the "still pending, within the drop bound" branch of -// resolveReanchorRequestInFlight: a submission that has not yet -// confirmed must suppress a second RequestReservationReanchor call for -// the same reservation, instead of re-requesting every round. -func TestReservationReanchorTask_InFlight_PendingWithinBound_SkipsWithoutNewRequest(t *testing.T) { - tbtcChain, _, blockCounter, task, sourceWalletPublicKeyHash, _, _ := +// TestReservationReanchorTask_UnminedRequest_NextRoundRequestsAgain pins +// what Run does with a request it submitted but did not see mined within +// its wait: the pass ends without a proposal, and because Run keeps no +// memory of the submission, the next round dispatches on chain state +// alone. A reservation still Active on-chain (the request reverted, was +// dropped, or is still unmined) is eligible for a fresh request, which +// is safe because Reservation.sol only accepts a request against an +// Active reservation. +func TestReservationReanchorTask_UnminedRequest_NextRoundRequestsAgain(t *testing.T) { + tbtcChain, _, blockCounter, task, sourceWalletPublicKeyHash, targetWalletPublicKeyHash, _ := newInFlightReanchorFixture(t) - // Round 1: the request is submitted but stays unconfirmed (Pending - // receipt, no generation written), so the same-round fast path - // cannot find the mined generation and this reservation is skipped - // for the round -- but the submission is now tracked in-flight. + // Round 1: the request is submitted but writes no generation, so the + // mined-wait cannot find it and the pass ends without a proposal. tbtcChain.SetNextReservationReanchorRequestPending() proposal, ok, err := task.Run(&tbtc.CoordinationProposalRequest{ @@ -162,65 +163,9 @@ func TestReservationReanchorTask_InFlight_PendingWithinBound_SkipsWithoutNewRequ t.Fatalf("round 1: expected exactly 1 submission, got %d", got) } - // Round 2: the block has advanced by less than the drop bound (6) - // and the receipt is still Pending, so the in-flight entry must - // resolve to "still unconfirmed" and this round must not submit a - // second request. - blockCounter.SetCurrentBlock(1003) - - proposal, ok, err = task.Run(&tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: sourceWalletPublicKeyHash, - }) - if err != nil { - t.Fatalf("round 2: unexpected error: %v", err) - } - if ok || proposal != nil { - t.Fatalf( - "round 2: expected no proposal while the pending request "+ - "remains within its window, got ok=%v proposal=%v", ok, proposal, - ) - } - if got := len(tbtcChain.GetReservationReanchorRequestSubmissions()); got != 1 { - t.Fatalf( - "round 2: expected the in-flight pending request to "+ - "suppress a second submission, got %d submissions", got, - ) - } -} - -// TestReservationReanchorTask_InFlight_Reverted_AllowsNewRequest pins the -// Reverted branch of resolveReanchorRequestInFlight: once a submission's -// receipt resolves as reverted, the reservation (left Active on-chain, -// since the revert wrote no generation) must be eligible for a fresh -// request on the very next round. -func TestReservationReanchorTask_InFlight_Reverted_AllowsNewRequest(t *testing.T) { - tbtcChain, _, blockCounter, task, sourceWalletPublicKeyHash, targetWalletPublicKeyHash, _ := - newInFlightReanchorFixture(t) - - // Round 1: the request reverts on-chain; no generation is written, - // so the fast path cannot find it and the reservation is skipped - // this round, tracked in-flight by its reverted receipt. - tbtcChain.SetRevertNextReservationReanchorRequest() - - proposal, ok, err := task.Run(&tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: sourceWalletPublicKeyHash, - }) - if err != nil { - t.Fatalf("round 1: unexpected error: %v", err) - } - if ok || proposal != nil { - t.Fatalf( - "round 1: expected no proposal from a reverted request, "+ - "got ok=%v proposal=%v", ok, proposal, - ) - } - if got := len(tbtcChain.GetReservationReanchorRequestSubmissions()); got != 1 { - t.Fatalf("round 1: expected exactly 1 submission, got %d", got) - } - - // Round 2: the receipt resolves as Reverted, so a fresh request - // must be allowed and mined via the same-round fast path. - blockCounter.SetCurrentBlock(1001) + // Round 2: the reservation is still Active on-chain, so a fresh + // request is issued and mined within the wait. + blockCounter.SetCurrentBlock(1900) proposal, ok, err = task.Run(&tbtc.CoordinationProposalRequest{ WalletPublicKeyHash: sourceWalletPublicKeyHash, @@ -230,8 +175,8 @@ func TestReservationReanchorTask_InFlight_Reverted_AllowsNewRequest(t *testing.T } if !ok || proposal == nil { t.Fatalf( - "round 2: expected a proposal from a freshly allowed "+ - "request, got ok=%v proposal=%v", ok, proposal, + "round 2: expected a proposal from a fresh request, got "+ + "ok=%v proposal=%v", ok, proposal, ) } reanchorProposal, ok := proposal.(*tbtc.ReservationReanchorProposal) @@ -251,79 +196,17 @@ func TestReservationReanchorTask_InFlight_Reverted_AllowsNewRequest(t *testing.T ) } if got := len(tbtcChain.GetReservationReanchorRequestSubmissions()); got != 2 { - t.Fatalf( - "round 2: expected the reverted request to allow a second "+ - "submission, got %d submissions", got, - ) - } -} - -// TestReservationReanchorTask_InFlight_DroppedPastBound_AllowsNewRequest -// pins the "unobserved past the drop bound" branch of -// resolveReanchorRequestInFlight: a submission whose receipt is no -// longer found at all, once the block count has advanced more than -// reservationReanchorRequestWaitBlocks (6) past the submission block, -// must be treated as dropped and a fresh request must be allowed. -func TestReservationReanchorTask_InFlight_DroppedPastBound_AllowsNewRequest(t *testing.T) { - tbtcChain, _, blockCounter, task, sourceWalletPublicKeyHash, _, _ := - newInFlightReanchorFixture(t) - - tbtcChain.SetNextReservationReanchorRequestPending() - - proposal, ok, err := task.Run(&tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: sourceWalletPublicKeyHash, - }) - if err != nil { - t.Fatalf("round 1: unexpected error: %v", err) - } - if ok || proposal != nil { - t.Fatalf( - "round 1: expected no proposal while the request is "+ - "unconfirmed, got ok=%v proposal=%v", ok, proposal, - ) - } - submissions := tbtcChain.GetReservationReanchorRequestSubmissions() - if len(submissions) != 1 { - t.Fatalf("round 1: expected exactly 1 submission, got %d", len(submissions)) - } - - // The submission is no longer observable at all (e.g. it fell out - // of the mempool) and the block has advanced past the drop bound - // (6): the in-flight entry must be treated as dropped and a fresh - // request must be allowed. - tbtcChain.SetReservationReanchorRequestReceipt( - submissions[0].TxHash, - tbtc.ReservationReanchorRequestReceiptNotFound, - ) - blockCounter.SetCurrentBlock(1007) - - proposal, ok, err = task.Run(&tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: sourceWalletPublicKeyHash, - }) - if err != nil { - t.Fatalf("round 2: unexpected error: %v", err) - } - if !ok || proposal == nil { - t.Fatalf( - "round 2: expected a proposal from a freshly allowed "+ - "request once the submission is treated as dropped, "+ - "got ok=%v proposal=%v", ok, proposal, - ) - } - if got := len(tbtcChain.GetReservationReanchorRequestSubmissions()); got != 2 { - t.Fatalf( - "round 2: expected the dropped request to allow a second "+ - "submission, got %d submissions", got, - ) + t.Fatalf("round 2: expected a second submission, got %d", got) } } -// TestReservationReanchorTask_InFlight_Mined_ResumesWithoutNewRequest -// pins the Mined branch of resolveReanchorRequestInFlight: once a -// submission's receipt resolves as mined, the next round must resume -// from the chain-written Pending Reanchor generation (validated with its -// real request nonce) instead of issuing a new request. -func TestReservationReanchorTask_InFlight_Mined_ResumesWithoutNewRequest(t *testing.T) { +// TestReservationReanchorTask_UnminedRequest_MinedBeforeNextRound_Resumes +// pins the other half of the chain-state dispatch: a request that was not +// seen mined within round 1's wait but is mined before round 2 leaves the +// reservation ActionPending with a Pending Reanchor generation, so round 2 +// must resume that generation at its real nonce instead of requesting +// again. +func TestReservationReanchorTask_UnminedRequest_MinedBeforeNextRound_Resumes(t *testing.T) { tbtcChain, _, blockCounter, task, sourceWalletPublicKeyHash, targetWalletPublicKeyHash, reservationKey := newInFlightReanchorFixture(t) @@ -341,23 +224,18 @@ func TestReservationReanchorTask_InFlight_Mined_ResumesWithoutNewRequest(t *test "unconfirmed, got ok=%v proposal=%v", ok, proposal, ) } - submissions := tbtcChain.GetReservationReanchorRequestSubmissions() - if len(submissions) != 1 { - t.Fatalf("round 1: expected exactly 1 submission, got %d", len(submissions)) + if got := len(tbtcChain.GetReservationReanchorRequestSubmissions()); got != 1 { + t.Fatalf("round 1: expected exactly 1 submission, got %d", got) } - // Simulate the submission being mined between rounds: the chain now - // holds the new generation (ActionPending, nonce 1, a Pending - // Reanchor authorizing the target) and the receipt reports Mined. + // The submission is mined between rounds: the chain now holds the + // new generation (ActionPending, nonce 1, a Pending Reanchor + // authorizing the target). seedResumableReanchorGeneration( t, tbtcChain, targetWalletPublicKeyHash, reservationKey, 1, time.Now().Add(24*time.Hour), ) - tbtcChain.SetReservationReanchorRequestReceipt( - submissions[0].TxHash, - tbtc.ReservationReanchorRequestReceiptMined, - ) - blockCounter.SetCurrentBlock(1001) + blockCounter.SetCurrentBlock(1900) proposal, ok, err = task.Run(&tbtc.CoordinationProposalRequest{ WalletPublicKeyHash: sourceWalletPublicKeyHash, @@ -394,110 +272,44 @@ func TestReservationReanchorTask_InFlight_Mined_ResumesWithoutNewRequest(t *test "new submission, got %d submissions", got, ) } - if got := len(task.inFlightReanchorRequests); got != 0 { - t.Fatalf( - "round 2: expected the mined in-flight entry to be "+ - "forgotten once resolved, got %d entries", got, - ) - } } -// TestReservationReanchorTask_InFlight_ReservationLeftWallet_ForgottenOnNextRun -// pins the in-flight reconciliation: a submission still unconfirmed when -// its reservation's custody has since moved away from the source wallet -// (the wallet's reservation list no longer names it) must be forgotten on -// the next Run rather than tracked for the task's lifetime, while a -// concurrently unconfirmed submission for a different wallet must survive -// the same pass untouched. -func TestReservationReanchorTask_InFlight_ReservationLeftWallet_ForgottenOnNextRun(t *testing.T) { - tbtcChain, btcChain, _, task, sourceWalletPublicKeyHash, _, reservationKey := +// TestReservationReanchorTask_ReservationLeftWallet_NotRequestedAgain +// pins that a reservation whose custody moved away from the source wallet +// between rounds is not requested again: round 1 requests and proposes the +// re-anchor, its proof settles before round 2, and round 2 reads the +// source's now-empty reservation list from chain state and issues nothing. +func TestReservationReanchorTask_ReservationLeftWallet_NotRequestedAgain(t *testing.T) { + tbtcChain, _, blockCounter, task, sourceWalletPublicKeyHash, targetWalletPublicKeyHash, reservationKey := newInFlightReanchorFixture(t) - // A second MovingFunds wallet with its own active reservation, so - // its own in-flight entry can be checked for survival. - secondSourceWalletPublicKeyHash := [20]byte{ - 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, - } - secondReservationKey := big.NewInt(7002) - tbtcChain.SetWallet( - secondSourceWalletPublicKeyHash, - &tbtc.WalletChainData{State: tbtc.StateMovingFunds}, - ) - secondAnchorTxHash, err := bitcoin.NewHashFromString( - "8888888888888888888888888888888888888888888888888888888888888888"[:64], - bitcoin.ReversedByteOrder, - ) - if err != nil { - t.Fatal(err) - } - secondSourceScript, err := bitcoin.PayToWitnessPublicKeyHash( - secondSourceWalletPublicKeyHash, - ) + proposal, ok, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: sourceWalletPublicKeyHash, + }) if err != nil { - t.Fatal(err) + t.Fatalf("round 1: unexpected error: %v", err) } - btcChain.SetTransaction(secondAnchorTxHash, &bitcoin.Transaction{ - Version: 1, - Outputs: []*bitcoin.TransactionOutput{ - {Value: 1}, - {Value: 200000, PublicKeyScript: secondSourceScript}, - }, - }) - tbtcChain.SetReservation(secondReservationKey, &tbtc.Reservation{ - WalletPublicKeyHash: secondSourceWalletPublicKeyHash, - AnchorUtxo: &bitcoin.UnspentTransactionOutput{ - Outpoint: &bitcoin.TransactionOutpoint{ - TransactionHash: secondAnchorTxHash, - OutputIndex: 1, - }, - Value: 200000, - }, - State: tbtc.ReservationStateActive, - RequestNonce: 0, - }) - tbtcChain.SetWalletReservations( - secondSourceWalletPublicKeyHash, - []*big.Int{secondReservationKey}, - ) - - // Round 1 for both wallets: each submits a request that stays - // unconfirmed (Pending receipt, no generation written), so both are - // tracked in-flight by their transaction hashes. - tbtcChain.SetNextReservationReanchorRequestPending() - if _, _, err := task.Run(&tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: sourceWalletPublicKeyHash, - }); err != nil { - t.Fatalf("round 1 (first wallet): unexpected error: %v", err) + if !ok || proposal == nil { + t.Fatalf("round 1: expected a proposal, got ok=%v proposal=%v", ok, proposal) } - if _, ok := task.getReanchorRequestInFlight(reservationKey); !ok { - t.Fatalf( - "round 1: expected the first wallet's unconfirmed request to be " + - "tracked in-flight", - ) + if got := len(tbtcChain.GetReservationReanchorRequestSubmissions()); got != 1 { + t.Fatalf("round 1: expected exactly 1 submission, got %d", got) } - tbtcChain.SetNextReservationReanchorRequestPending() - if _, _, err := task.Run(&tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: secondSourceWalletPublicKeyHash, - }); err != nil { - t.Fatalf("round 1 (second wallet): unexpected error: %v", err) - } - if _, ok := task.getReanchorRequestInFlight(secondReservationKey); !ok { - t.Fatalf( - "round 1: expected the second wallet's unconfirmed request to be " + - "tracked in-flight", - ) + // The re-anchor proof settles between rounds: custody moves to the + // target and the source's reservation list no longer names the key. + if err := tbtcChain.SettleReservationReanchor( + reservationKey, + &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{TransactionHash: bitcoin.Hash{0x99}}, + Value: 199450, + }, + ); err != nil { + t.Fatal(err) } + blockCounter.SetCurrentBlock(1900) - // The first wallet's reservation moves custody elsewhere between - // rounds: it no longer appears in the wallet's reservation list. - tbtcChain.SetWalletReservations(sourceWalletPublicKeyHash, nil) - - // Round 2 for the first wallet: the wallet's list is now empty, so - // the departure must be reconciled -- the first wallet's in-flight - // entry is forgotten, while the second wallet's entry (a different - // source wallet, untouched by this pass) survives. - proposal, ok, err := task.Run(&tbtc.CoordinationProposalRequest{ + proposal, ok, err = task.Run(&tbtc.CoordinationProposalRequest{ WalletPublicKeyHash: sourceWalletPublicKeyHash, }) if err != nil { @@ -511,25 +323,16 @@ func TestReservationReanchorTask_InFlight_ReservationLeftWallet_ForgottenOnNextR proposal, ) } - if _, found := task.getReanchorRequestInFlight(reservationKey); found { - t.Error( - "round 2: expected the departed reservation's in-flight " + - "entry to be forgotten, but it was still tracked", - ) - } - if _, found := task.getReanchorRequestInFlight(secondReservationKey); !found { - t.Error( - "round 2: expected another wallet's in-flight entry to " + - "survive the reconciliation pass", - ) - } - if got := len(tbtcChain.GetReservationReanchorRequestSubmissions()); got != 2 { + if got := len(tbtcChain.GetReservationReanchorRequestAttempts()); got != 1 { t.Fatalf( - "round 2: expected no new re-anchor request submissions, "+ - "got %d", + "round 2: expected no new re-anchor request, got %d attempts "+ + "in total", got, ) } + if count, err := tbtcChain.WalletReservationsCount(targetWalletPublicKeyHash); err != nil || count != 1 { + t.Fatalf("expected the target to custody the reservation, got count %d (err %v)", count, err) + } } // TestReservationReanchorTask_ResumeMarginGate pins the resume path's @@ -620,3 +423,445 @@ func TestReservationReanchorTask_ResumeMarginGate(t *testing.T) { } }) } + +// TestReservationReanchorTask_ClosingSource pins that a Closing source +// wallet is re-anchored like a MovingFunds one. Reservation.sol accepts a +// permissionless request from a Closing source, a wallet can reach Closing +// while it still holds reservations, and it cannot finish closing until +// they are gone, so both a fresh request and the resume of an +// already-authorized generation must still happen from Closing. +func TestReservationReanchorTask_ClosingSource(t *testing.T) { + t.Run("active reservation is requested and proposed", func(t *testing.T) { + tbtcChain, _, _, task, sourceWalletPublicKeyHash, targetWalletPublicKeyHash, _ := + newInFlightReanchorFixture(t) + tbtcChain.SetWallet(sourceWalletPublicKeyHash, &tbtc.WalletChainData{State: tbtc.StateClosing}) + + proposal, ok, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: sourceWalletPublicKeyHash, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok || proposal == nil { + t.Fatalf("expected a proposal for a Closing source, got ok=%v proposal=%v", ok, proposal) + } + reanchorProposal := proposal.(*tbtc.ReservationReanchorProposal) + if reanchorProposal.RequestNonce != 1 || + reanchorProposal.TargetWalletPublicKeyHash != targetWalletPublicKeyHash { + t.Fatalf("unexpected proposal: %+v", reanchorProposal) + } + if got := len(tbtcChain.GetReservationReanchorRequestSubmissions()); got != 1 { + t.Fatalf("expected exactly 1 submission, got %d", got) + } + }) + + t.Run("pending generation is resumed", func(t *testing.T) { + tbtcChain, _, _, task, sourceWalletPublicKeyHash, targetWalletPublicKeyHash, reservationKey := + newInFlightReanchorFixture(t) + tbtcChain.SetWallet(sourceWalletPublicKeyHash, &tbtc.WalletChainData{State: tbtc.StateClosing}) + seedResumableReanchorGeneration( + t, tbtcChain, targetWalletPublicKeyHash, reservationKey, 3, + time.Now().Add(24*time.Hour), + ) + + proposal, ok, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: sourceWalletPublicKeyHash, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok || proposal == nil { + t.Fatalf("expected the pending generation to be resumed, got ok=%v proposal=%v", ok, proposal) + } + if nonce := proposal.(*tbtc.ReservationReanchorProposal).RequestNonce; nonce != 3 { + t.Fatalf("expected the resumed proposal at nonce 3, got [%d]", nonce) + } + if got := len(tbtcChain.GetReservationReanchorRequestAttempts()); got != 0 { + t.Fatalf("expected no request on the resume path, got %d", got) + } + }) +} + +// addActiveReanchorReservation adds another Active reservation, with its +// anchor transaction, to the source wallet of a newInFlightReanchorFixture. +func addActiveReanchorReservation( + t *testing.T, + tbtcChain *LocalChain, + btcChain *LocalBitcoinChain, + sourceWalletPublicKeyHash [20]byte, + reservationKey *big.Int, + anchorTxHash bitcoin.Hash, + anchorValue int64, +) { + t.Helper() + + sourceWalletScript, err := bitcoin.PayToWitnessPublicKeyHash(sourceWalletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + btcChain.SetTransaction(anchorTxHash, &bitcoin.Transaction{ + Version: 1, + Outputs: []*bitcoin.TransactionOutput{ + {Value: anchorValue, PublicKeyScript: sourceWalletScript}, + }, + }) + tbtcChain.SetReservation(reservationKey, &tbtc.Reservation{ + WalletPublicKeyHash: sourceWalletPublicKeyHash, + AnchorUtxo: &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHash, + OutputIndex: 0, + }, + Value: anchorValue, + }, + State: tbtc.ReservationStateActive, + }) + keys, err := tbtcChain.WalletReservations(sourceWalletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + tbtcChain.SetWalletReservations( + sourceWalletPublicKeyHash, + append(keys, reservationKey), + ) +} + +// TestReservationReanchorTask_FeeAboveCap_SendsNoRequest pins that the fee +// is estimated against the live ReservationTxMaxFee before the request is +// sent. During a Bitcoin fee spike the estimate exceeds the cap, and the +// pass must end without an on-chain request: a request sent first would +// only hold the target's capacity until the action times out and then +// start a cooldown. +func TestReservationReanchorTask_FeeAboveCap_SendsNoRequest(t *testing.T) { + tbtcChain, btcChain, _, task, sourceWalletPublicKeyHash, _, _ := + newInFlightReanchorFixture(t) + // About 110 vbytes at 1000 sat/vbyte is well above the 100000 cap. + btcChain.SetEstimateSatPerVByteFee(1, 1000) + + proposal, ok, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: sourceWalletPublicKeyHash, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok || proposal != nil { + t.Fatalf("expected no proposal, got ok=%v proposal=%v", ok, proposal) + } + if got := len(tbtcChain.GetReservationReanchorRequestAttempts()); got != 0 { + t.Fatalf("expected no re-anchor request when the fee exceeds the cap, got %d", got) + } +} + +// TestReservationReanchorTask_OneRequestPerPass pins that a pass sends at +// most one RequestReservationReanchor. Once a request has been sent, any +// later failure (not mined within the wait, or proposal validation +// failing) must end the pass instead of moving on to the next +// reservation, which would send a second request in the same pass. +func TestReservationReanchorTask_OneRequestPerPass(t *testing.T) { + t.Run("request not mined within the wait", func(t *testing.T) { + tbtcChain, btcChain, _, task, sourceWalletPublicKeyHash, _, _ := + newInFlightReanchorFixture(t) + addActiveReanchorReservation( + t, tbtcChain, btcChain, sourceWalletPublicKeyHash, + big.NewInt(7002), bitcoin.Hash{0x72}, 200000, + ) + tbtcChain.SetNextReservationReanchorRequestPending() + + proposal, ok, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: sourceWalletPublicKeyHash, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok || proposal != nil { + t.Fatalf("expected no proposal, got ok=%v proposal=%v", ok, proposal) + } + if got := len(tbtcChain.GetReservationReanchorRequestAttempts()); got != 1 { + t.Fatalf("expected exactly 1 re-anchor request in the pass, got %d", got) + } + }) + + t.Run("validation fails after the request was mined", func(t *testing.T) { + tbtcChain, btcChain, _, task, sourceWalletPublicKeyHash, targetWalletPublicKeyHash, reservationKey := + newInFlightReanchorFixture(t) + addActiveReanchorReservation( + t, tbtcChain, btcChain, sourceWalletPublicKeyHash, + big.NewInt(7002), bitcoin.Hash{0x72}, 200000, + ) + if err := tbtcChain.SetReservationReanchorProposalValidationResult( + sourceWalletPublicKeyHash, + &tbtc.ReservationReanchorProposal{ + ReservationKey: reservationKey, + RequestNonce: 1, + TargetWalletPublicKeyHash: targetWalletPublicKeyHash, + ReanchorTxFee: big.NewInt(550), + }, + false, + ); err != nil { + t.Fatal(err) + } + + proposal, ok, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: sourceWalletPublicKeyHash, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok || proposal != nil { + t.Fatalf("expected no proposal, got ok=%v proposal=%v", ok, proposal) + } + if got := tbtcChain.GetReservationReanchorValidationCallCount(); got != 1 { + t.Fatalf("expected the forced validation failure to be reached once, got %d", got) + } + if got := len(tbtcChain.GetReservationReanchorRequestAttempts()); got != 1 { + t.Fatalf("expected exactly 1 re-anchor request in the pass, got %d", got) + } + }) +} + +// frontRunReanchorChain models another requester whose re-anchor request +// for the same reservation, to a different target, is mined before ours: +// our request call succeeds, but the chain holds the other generation. +type frontRunReanchorChain struct { + *LocalChain + + frontRunTarget [20]byte +} + +func (c *frontRunReanchorChain) RequestReservationReanchor( + reservationKey *big.Int, + targetWalletPublicKeyHash [20]byte, +) error { + return c.LocalChain.RequestReservationReanchor(reservationKey, c.frontRunTarget) +} + +// countingBlockCounter counts WaitForBlockHeight calls. +type countingBlockCounter struct { + *MockBlockCounter + + waits int +} + +func (c *countingBlockCounter) WaitForBlockHeight(blockNumber uint64) error { + c.waits++ + return c.MockBlockCounter.WaitForBlockHeight(blockNumber) +} + +// TestReservationReanchorTask_FrontRunRequest_StopsWaiting pins that the +// mined-wait stops as soon as the reservation's nonce advances to an +// action that is not ours: our request can then only revert, so waiting +// out the remaining blocks would only delay the coordination window. +func TestReservationReanchorTask_FrontRunRequest_StopsWaiting(t *testing.T) { + tbtcChain, btcChain, _, _, sourceWalletPublicKeyHash, _, _ := + newInFlightReanchorFixture(t) + + frontRunTarget := [20]byte{9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9} + tbtcChain.SetWallet(frontRunTarget, &tbtc.WalletChainData{State: tbtc.StateLive}) + + blockCounter := &countingBlockCounter{MockBlockCounter: NewMockBlockCounter()} + blockCounter.SetCurrentBlock(1000) + tbtcChain.SetBlockCounter(blockCounter) + + chain := &frontRunReanchorChain{LocalChain: tbtcChain, frontRunTarget: frontRunTarget} + task := NewReservationReanchorTask(chain, btcChain) + + proposal, ok, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: sourceWalletPublicKeyHash, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok || proposal != nil { + t.Fatalf("expected no proposal for a front-run request, got ok=%v proposal=%v", ok, proposal) + } + if blockCounter.waits != 1 { + t.Fatalf( + "expected the wait to stop at the first block showing the "+ + "other generation, waited for %d blocks", + blockCounter.waits, + ) + } + if got := len(tbtcChain.GetReservationReanchorRequestAttempts()); got != 1 { + t.Fatalf("expected exactly 1 re-anchor request in the pass, got %d", got) + } +} + +// TestReservationReanchorTask_RequestTimeGates pins that Run applies +// Reservation.sol's request-time gates before sending a request: a +// reservation in its re-anchor cooldown, or whose anchor is not above +// ReservationTxMaxFee + ReservationMinAmount, is skipped without a request +// that would revert. +func TestReservationReanchorTask_RequestTimeGates(t *testing.T) { + t.Run("cooldown after a timed-out request", func(t *testing.T) { + tbtcChain, _, _, task, sourceWalletPublicKeyHash, _, reservationKey := + newInFlightReanchorFixture(t) + + if _, ok, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: sourceWalletPublicKeyHash, + }); err != nil || !ok { + t.Fatalf("round 1: expected a proposal, got ok=%v err=%v", ok, err) + } + // The generation is never proven and times out, which starts the + // reservation's re-anchor cooldown. + if err := tbtcChain.TimeOutReservationReanchor(reservationKey); err != nil { + t.Fatal(err) + } + + proposal, ok, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: sourceWalletPublicKeyHash, + }) + if err != nil { + t.Fatalf("round 2: unexpected error: %v", err) + } + if ok || proposal != nil { + t.Fatalf("round 2: expected no proposal during the cooldown, got ok=%v proposal=%v", ok, proposal) + } + if got := len(tbtcChain.GetReservationReanchorRequestAttempts()); got != 1 { + t.Fatalf("round 2: expected no request during the cooldown, got %d attempts in total", got) + } + }) + + // The fixture's floor is ReservationTxMaxFee (100000) + + // ReservationMinAmount (1000). + floorTests := map[string]struct { + anchorValue int64 + expectedProposal bool + }{ + "anchor at the floor is skipped": {101000, false}, + "anchor above the floor is proposed": {101001, true}, + } + for name, test := range floorTests { + t.Run(name, func(t *testing.T) { + tbtcChain, btcChain, _, task, sourceWalletPublicKeyHash, _, _ := + newInFlightReanchorFixture(t) + tbtcChain.SetWalletReservations(sourceWalletPublicKeyHash, nil) + addActiveReanchorReservation( + t, tbtcChain, btcChain, sourceWalletPublicKeyHash, + big.NewInt(7003), bitcoin.Hash{0x73}, test.anchorValue, + ) + + _, ok, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: sourceWalletPublicKeyHash, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok != test.expectedProposal { + t.Fatalf("expected proposal=%v, got %v", test.expectedProposal, ok) + } + expectedAttempts := 0 + if test.expectedProposal { + expectedAttempts = 1 + } + if got := len(tbtcChain.GetReservationReanchorRequestAttempts()); got != expectedAttempts { + t.Fatalf("expected %d request attempts, got %d", expectedAttempts, got) + } + }) + } +} + +// TestReservationReanchorTask_AmountHeadroomPrecheck pins the amount half +// of the target headroom pre-check on its own: a Live target with count +// room but whose reserved amount plus the anchor exceeds +// maxReservationsAmountPerWallet must be skipped without a request (the +// request would revert on-chain), a target landing exactly on the cap must +// be accepted (Solidity reverts only above it), and a zero cap must not +// limit the amount at all. +func TestReservationReanchorTask_AmountHeadroomPrecheck(t *testing.T) { + tests := map[string]struct { + amountCap uint64 + filledReserved int64 + expectFilled bool + }{ + "target over the amount cap is skipped": { + amountCap: 300000, + filledReserved: 150000, + expectFilled: false, + }, + "target landing exactly on the amount cap is accepted": { + amountCap: 300000, + filledReserved: 100000, + expectFilled: true, + }, + "zero amount cap is unlimited": { + amountCap: 0, + filledReserved: 1000000000000, + expectFilled: true, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + tbtcChain, _, _, task, sourceWalletPublicKeyHash, alternateTarget, _ := + newInFlightReanchorFixture(t) + + // The partly filled target registered last, so the search + // examines it before the fixture's target. + filledTarget := [20]byte{8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8} + if err := tbtcChain.AddPastNewWalletRegisteredEvent( + &tbtc.NewWalletRegisteredEventFilter{StartBlock: 0}, + &tbtc.NewWalletRegisteredEvent{WalletPublicKeyHash: filledTarget}, + ); err != nil { + t.Fatal(err) + } + tbtcChain.SetWallet(filledTarget, &tbtc.WalletChainData{State: tbtc.StateLive}) + tbtcChain.SetLiveWalletsCount(2) + filledKey := big.NewInt(8801) + tbtcChain.SetReservation(filledKey, &tbtc.Reservation{ + WalletPublicKeyHash: filledTarget, + AnchorUtxo: &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{TransactionHash: bitcoin.Hash{0x88}}, + Value: test.filledReserved, + }, + State: tbtc.ReservationStateActive, + }) + tbtcChain.SetWalletReservations(filledTarget, []*big.Int{filledKey}) + tbtcChain.SetReservationCaps(test.amountCap, 0) + + proposal, ok, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: sourceWalletPublicKeyHash, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok || proposal == nil { + t.Fatalf("expected a proposal, got ok=%v proposal=%v", ok, proposal) + } + + expectedTarget := alternateTarget + if test.expectFilled { + expectedTarget = filledTarget + } + if got := proposal.(*tbtc.ReservationReanchorProposal).TargetWalletPublicKeyHash; got != expectedTarget { + t.Fatalf("expected target [%x], got [%x]", expectedTarget, got) + } + attempts := tbtcChain.GetReservationReanchorRequestAttempts() + if len(attempts) != 1 { + t.Fatalf("expected exactly 1 re-anchor request, got %d", len(attempts)) + } + if attempts[0].TargetWalletPublicKeyHash != expectedTarget { + t.Fatalf( + "expected the only request to target [%x], got [%x]", + expectedTarget, + attempts[0].TargetWalletPublicKeyHash, + ) + } + }) + } +} + +// TestReservationRequestTimeoutSafetyMarginMatchesValidator pins the Go +// copy of WalletProposalValidatorConstants.REQUEST_TIMEOUT_SAFETY_MARGIN +// (tbtc-v2 solidity/contracts/bridge/WalletProposalValidatorConstants.sol, +// 2 hours). The EVM harness test checks the on-chain value; this checks +// the constant the re-anchor and acceptance tasks use to skip generations +// the validator would no longer sign. +func TestReservationRequestTimeoutSafetyMarginMatchesValidator(t *testing.T) { + if reservationRequestTimeoutSafetyMarginSeconds != 7200 { + t.Fatalf( + "expected the request timeout safety margin to be 7200 "+ + "seconds, got %d", + reservationRequestTimeoutSafetyMarginSeconds, + ) + } +} diff --git a/pkg/tbtcpg/reservation_reanchor_metrics_test.go b/pkg/tbtcpg/reservation_reanchor_metrics_test.go index 2524e592ab..282b5c52de 100644 --- a/pkg/tbtcpg/reservation_reanchor_metrics_test.go +++ b/pkg/tbtcpg/reservation_reanchor_metrics_test.go @@ -4,12 +4,13 @@ import ( "math/big" "testing" + "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/tbtc" ) // scanningLocalChain wraps LocalChain with a PastNewWalletRegisteredEvents // call counter so a cache-vs-scan test can observe how many registration -// scans findTargetWallet actually triggered. +// scans the re-anchor target search actually triggered. type scanningLocalChain struct { *LocalChain @@ -82,17 +83,15 @@ func TestReservationReanchorTask_RecordsLiveWalletsCountGauge(t *testing.T) { } } -// TestReservationReanchorTask_FindTargetWallet_CachesAcrossRuns is a -// regression test for the target-wallet cache added to findTargetWallet: -// once a re-anchor target wallet has been selected for a source wallet, -// a subsequent call to findTargetWallet for the same source wallet must +// TestReservationReanchorTask_TargetSearch_CachesAcrossRuns is a +// regression test for the target-wallet cache: once a re-anchor target +// wallet has been selected for a source wallet, the next Run pass must // reuse the cached target -- validating only that one wallet via // GetWallet -- instead of repeating the O(W) GetWallet-per-registration -// scan. A cached target whose headroom has since dropped must be -// evicted so the next call performs a fresh scan, and that fresh scan -// must pick the most recently registered Live wallet that still has -// room. -func TestReservationReanchorTask_FindTargetWallet_CachesAcrossRuns(t *testing.T) { +// scan. A cached target whose count headroom has since dropped must be +// evicted so that pass performs a fresh scan, and that fresh scan must +// pick the most recently registered Live wallet that still has room. +func TestReservationReanchorTask_TargetSearch_CachesAcrossRuns(t *testing.T) { walletA := [20]byte{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1} walletB := [20]byte{2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2} walletC := [20]byte{3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3} @@ -120,56 +119,37 @@ func TestReservationReanchorTask_FindTargetWallet_CachesAcrossRuns(t *testing.T) lc.SetWallet(walletB, &tbtc.WalletChainData{State: tbtc.StateLive}) lc.SetWallet(walletC, &tbtc.WalletChainData{State: tbtc.StateLive}) - lc.SetReservationParameters(tbtc.ReservationParameters{ - ReservationTxMaxFee: 100000, - MaxReservationsPerWallet: 5, - }) - task := NewReservationReanchorTask(lc, NewLocalBitcoinChain()) - params, err := task.chain.ReservationParameters() - if err != nil { - t.Fatalf("ReservationParameters: %v", err) - } - - // First call: no cache, must scan the registration events. - got, err := task.findTargetWallet( - logger, - walletA, - 100000, - params.MaxReservationsPerWallet, - 100000000, - map[[20]byte]bool{}, - ) + // Each pass gets its own search, as each Run does. + find := func() ([20]byte, error) { + return task.newReanchorTargetSearch(logger, walletA, 5, 100000000).find(100000) + } + + // First pass: no cache, must scan the registration events. + got, err := find() if err != nil { - t.Fatalf("first findTargetWallet: unexpected error: %v", err) + t.Fatalf("first pass: unexpected error: %v", err) } if got != walletC { - t.Fatalf("first call: expected target wallet C [%x], got [%x]", walletC, got) + t.Fatalf("first pass: expected target wallet C [%x], got [%x]", walletC, got) } if lc.calls != 1 { t.Fatalf( - "first call should have triggered exactly 1 registration-event "+ + "first pass should have triggered exactly 1 registration-event "+ "scan, got %d", lc.calls, ) } - // Second call: cache hit. No new registration-event scan. - got, err = task.findTargetWallet( - logger, - walletA, - 100000, - params.MaxReservationsPerWallet, - 100000000, - map[[20]byte]bool{}, - ) + // Second pass: cache hit. No new registration-event scan. + got, err = find() if err != nil { - t.Fatalf("second findTargetWallet (cache hit): unexpected error: %v", err) + t.Fatalf("second pass (cache hit): unexpected error: %v", err) } if got != walletC { t.Fatalf( - "second call: expected the cached target wallet C [%x], got [%x]", + "second pass: expected the cached target wallet C [%x], got [%x]", walletC, got, ) @@ -177,34 +157,27 @@ func TestReservationReanchorTask_FindTargetWallet_CachesAcrossRuns(t *testing.T) if lc.calls != 1 { t.Fatalf( "cache hit must not trigger another registration-event scan; "+ - "observed %d total scans after 2 calls (want 1)", + "observed %d total scans after 2 passes (want 1)", lc.calls, ) } - // Third call: cached target C now has no headroom (count = cap), - // which forces eviction and a fresh scan. The fresh scan walks B - // and C newest-first; C is excluded by the caller-supplied - // triedTargets map, B has headroom -- so B wins. + // Third pass: cached target C now has no count headroom (count = + // cap), which forces eviction and a fresh scan. The fresh scan walks + // C and B newest-first; C was already read this pass and is + // excluded, B has headroom -- so B wins. walletKeys := make([]*big.Int, 5) for i := range walletKeys { walletKeys[i] = big.NewInt(int64(1000 + i)) } lc.SetWalletReservations(walletC, walletKeys) - got, err = task.findTargetWallet( - logger, - walletA, - 100000, - params.MaxReservationsPerWallet, - 100000000, - map[[20]byte]bool{walletC: true}, - ) + got, err = find() if err != nil { - t.Fatalf("third findTargetWallet (evict + rescan): unexpected error: %v", err) + t.Fatalf("third pass (evict + rescan): unexpected error: %v", err) } if got != walletB { t.Fatalf( - "third call: expected the fresh-scan target wallet B [%x] "+ + "third pass: expected the fresh-scan target wallet B [%x] "+ "after the cached C lost headroom, got [%x]", walletB, got, @@ -213,8 +186,172 @@ func TestReservationReanchorTask_FindTargetWallet_CachesAcrossRuns(t *testing.T) if lc.calls != 2 { t.Fatalf( "eviction must trigger a fresh scan; observed %d total "+ - "scans after 3 calls (want 2)", + "scans after 3 passes (want 2)", lc.calls, ) } } + +// targetSearchCountingChain counts the chain reads the re-anchor target +// search makes. +type targetSearchCountingChain struct { + *LocalChain + + registrationScans int + walletReads map[[20]byte]int + countReads map[[20]byte]int +} + +func (c *targetSearchCountingChain) PastNewWalletRegisteredEvents( + filter *tbtc.NewWalletRegisteredEventFilter, +) ([]*tbtc.NewWalletRegisteredEvent, error) { + c.registrationScans++ + return c.LocalChain.PastNewWalletRegisteredEvents(filter) +} + +func (c *targetSearchCountingChain) GetWallet( + walletPublicKeyHash [20]byte, +) (*tbtc.WalletChainData, error) { + c.walletReads[walletPublicKeyHash]++ + return c.LocalChain.GetWallet(walletPublicKeyHash) +} + +func (c *targetSearchCountingChain) WalletReservationsCount( + walletPublicKeyHash [20]byte, +) (uint32, error) { + c.countReads[walletPublicKeyHash]++ + return c.LocalChain.WalletReservationsCount(walletPublicKeyHash) +} + +// TestReservationReanchorTask_TargetSearch_ReadsOncePerRun pins that when +// no Live wallet has headroom, a Run pass with several reservations +// searches for a target once: one bounded and one unbounded registration +// scan, one state read per wallet, one count read for a count-capped +// wallet, and no capacity reads at all for a reservation whose anchor is +// at least one already found to fit nowhere. Saturated caps are exactly when this +// search repeats, so the pass must not repeat it per reservation. +func TestReservationReanchorTask_TargetSearch_ReadsOncePerRun(t *testing.T) { + source := [20]byte{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1} + countFull := [20]byte{2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2} + amountFull := [20]byte{3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3} + oldCountFull := [20]byte{4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4} + + lc := NewLocalChain() + chain := &targetSearchCountingChain{ + LocalChain: lc, + walletReads: make(map[[20]byte]int), + countReads: make(map[[20]byte]int), + } + + // A current block past the look-back window makes the bounded scan + // start above zero, so the unbounded fallback also runs. + blockCounter := NewMockBlockCounter() + blockCounter.SetCurrentBlock(300000) + lc.SetBlockCounter(blockCounter) + recent := &tbtc.NewWalletRegisteredEventFilter{ + StartBlock: 300000 - ReservationReanchorLookBackBlocks, + } + all := &tbtc.NewWalletRegisteredEventFilter{StartBlock: 0} + for _, registration := range []struct { + filter *tbtc.NewWalletRegisteredEventFilter + wallet [20]byte + }{ + {recent, countFull}, + {recent, amountFull}, + {all, oldCountFull}, + {all, countFull}, + {all, amountFull}, + } { + if err := lc.AddPastNewWalletRegisteredEvent( + registration.filter, + &tbtc.NewWalletRegisteredEvent{WalletPublicKeyHash: registration.wallet}, + ); err != nil { + t.Fatal(err) + } + } + + lc.SetWallet(source, &tbtc.WalletChainData{State: tbtc.StateMovingFunds}) + for _, wallet := range [][20]byte{countFull, amountFull, oldCountFull} { + lc.SetWallet(wallet, &tbtc.WalletChainData{State: tbtc.StateLive}) + } + lc.SetLiveWalletsCount(3) + lc.SetReservationParameters(tbtc.ReservationParameters{ + ReservationTxMaxFee: 10000, + MaxReservationsPerWallet: 2, + ReservationActionTimeout: 86400, + }) + lc.SetReservationCaps(300000, 0) + + reservation := func(key int64, wallet [20]byte, value int64) *big.Int { + reservationKey := big.NewInt(key) + lc.SetReservation(reservationKey, &tbtc.Reservation{ + WalletPublicKeyHash: wallet, + AnchorUtxo: &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{byte(key)}, + }, + Value: value, + }, + State: tbtc.ReservationStateActive, + }) + return reservationKey + } + // countFull and oldCountFull are at the count cap; amountFull has + // count room but 250000 reserved, so no anchor of 200000 or more fits. + lc.SetWalletReservations(countFull, []*big.Int{ + reservation(11, countFull, 1), reservation(12, countFull, 1), + }) + lc.SetWalletReservations(oldCountFull, []*big.Int{ + reservation(13, oldCountFull, 1), reservation(14, oldCountFull, 1), + }) + lc.SetWalletReservations(amountFull, []*big.Int{ + reservation(15, amountFull, 250000), + }) + // The 300000 anchor is ruled out by the 200000 one without any read; + // the smaller 100000 anchor re-checks only amountFull, since the + // count-capped wallets are ruled out for every anchor. + lc.SetWalletReservations(source, []*big.Int{ + reservation(21, source, 200000), + reservation(22, source, 300000), + reservation(23, source, 100000), + }) + + task := NewReservationReanchorTask(chain, NewLocalBitcoinChain()) + proposal, ok, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: source, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok || proposal != nil { + t.Fatalf("expected no proposal, got ok=%v proposal=%v", ok, proposal) + } + + if chain.registrationScans != 2 { + t.Errorf( + "expected one bounded and one unbounded registration scan, got %d", + chain.registrationScans, + ) + } + expectedCountReads := map[[20]byte]int{ + countFull: 1, + oldCountFull: 1, + amountFull: 2, + } + for wallet, expected := range expectedCountReads { + if got := chain.walletReads[wallet]; got != 1 { + t.Errorf("expected wallet [%x] state to be read once, got %d", wallet, got) + } + if got := chain.countReads[wallet]; got != expected { + t.Errorf( + "expected wallet [%x] count to be read %d times, got %d", + wallet, + expected, + got, + ) + } + } + if got := len(lc.GetReservationReanchorRequestAttempts()); got != 0 { + t.Errorf("expected no re-anchor request, got %d", got) + } +} diff --git a/pkg/tbtcpg/reservation_reanchor_test.go b/pkg/tbtcpg/reservation_reanchor_test.go index 35c9ea508e..d5b174c951 100644 --- a/pkg/tbtcpg/reservation_reanchor_test.go +++ b/pkg/tbtcpg/reservation_reanchor_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/ethereum/go-ethereum/crypto" "github.com/ipfs/go-log/v2" "github.com/keep-network/keep-core/pkg/bitcoin" @@ -28,7 +29,7 @@ func TestReservationReanchorTask_Run(t *testing.T) { tbtcChain := tbtcpg.NewLocalChain() btcChain := tbtcpg.NewLocalBitcoinChain() - // findTargetWallet now bounds its wallet-registration scan to + // The target search bounds its wallet-registration scan to // ReservationReanchorLookBackBlocks; a small current block keeps // the computed StartBlock at 0, matching the filter used below. blockCounter := tbtcpg.NewMockBlockCounter() @@ -178,8 +179,7 @@ func TestReservationReanchorTask_Run(t *testing.T) { if r.HasPendingAction { action.TargetWalletPublicKeyHash = scenario.TargetWalletPublicKeyHash action.TxMaxFee = scenario.ReservationTxMaxFee - action.MinAmount = 1000 - action.TermSeconds = 86400 + action.Amount = uint64(r.AnchorValue) } tbtcChain.SetReservationAction( r.ReservationKey, @@ -195,7 +195,7 @@ func TestReservationReanchorTask_Run(t *testing.T) { // Fall back to a non-zero MaxReservationsPerWallet when the // scenario omits it: the headroom pre-check used by - // findTargetWallet rejects every candidate when the cap is + // the target search rejects every candidate when the cap is // zero (count + 1 > 0 is always true), so a zero default // would silently turn every scenario into "no live target". maxPerWallet := scenario.MaxReservationsPerWallet @@ -205,15 +205,16 @@ func TestReservationReanchorTask_Run(t *testing.T) { tbtcChain.SetReservationParameters(tbtc.ReservationParameters{ ReservationTxMaxFee: scenario.ReservationTxMaxFee, MaxReservationsPerWallet: maxPerWallet, + ReservationActionTimeout: 86400, }) btcChain.SetEstimateSatPerVByteFee(1, scenario.EstimateSatPerVByteFee) // Unconditionally register the source wallet itself in the - // same past-registration-events bucket findTargetWallet + // same past-registration-events bucket the target search // queries (filter{StartBlock: 0}), even for scenarios with no - // target wallet. findLiveWalletFromRegistrationEvents always - // skips a registration matching the source wallet, so this is + // target wallet. The target search always skips a + // registration matching the source wallet, so this is // inert for target selection; its only purpose is to give the // mock chain a populated entry so PastNewWalletRegisteredEvents // returns an (empty-after-filtering) slice instead of its @@ -386,8 +387,9 @@ func TestReservationReanchorTask_TargetWalletExclusion_SharedTask(t *testing.T) 0, ) tbtcChain.SetReservationParameters(tbtc.ReservationParameters{ - ReservationTxMaxFee: 100000, + ReservationTxMaxFee: 10000, MaxReservationsPerWallet: 5, + ReservationActionTimeout: 86400, }) btcChain.SetEstimateSatPerVByteFee(1, 1) @@ -557,8 +559,9 @@ func TestReservationReanchorTask_Run_SkipNonActiveReservations(t *testing.T) { 0, ) tbtcChain.SetReservationParameters(tbtc.ReservationParameters{ - ReservationTxMaxFee: 100000, + ReservationTxMaxFee: 10000, MaxReservationsPerWallet: 5, + ReservationActionTimeout: 86400, }) btcChain.SetEstimateSatPerVByteFee(1, 1) @@ -666,7 +669,7 @@ type reservationReanchorLocalChain struct { // pastNewWalletRegisteredEventsCalls counts calls to // PastNewWalletRegisteredEvents, letting tests assert on how many - // times findTargetWallet's registration-event scan actually ran + // times the target search's registration-event scan actually ran // (e.g. that a cached target wallet suppressed a repeat scan). pastNewWalletRegisteredEventsCalls int @@ -708,16 +711,16 @@ func newReservationReanchorLocalChain() *reservationReanchorLocalChain { func (rrlc *reservationReanchorLocalChain) RequestReservationReanchor( reservationKey *big.Int, targetWalletPublicKeyHash [20]byte, -) ([32]byte, error) { +) error { if !rrlc.capRevertConsumed && rrlc.forceCapRevertFor == targetWalletPublicKeyHash { rrlc.capRevertConsumed = true - return [32]byte{}, errors.New("wallet reservations cap exceeded") + return errors.New("wallet reservations cap exceeded") } if !rrlc.amountCapRevertConsumed && rrlc.forceAmountCapRevertFor == targetWalletPublicKeyHash { rrlc.amountCapRevertConsumed = true - return [32]byte{}, errors.New("wallet reserved amount cap exceeded") + return errors.New("wallet reserved amount cap exceeded") } return rrlc.LocalChain.RequestReservationReanchor( reservationKey, @@ -873,6 +876,31 @@ func TestReservationReanchorTask_Run_NotifiesMovingFundsBelowDust(t *testing.T) } }) + t.Run("closing wallet: no notification even below dust", func(t *testing.T) { + tbtcChain, btcChain := newFixture(500000, nil, true) + wallet, err := tbtcChain.GetWallet(walletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + closing := *wallet + closing.State = tbtc.StateClosing + tbtcChain.SetWallet(walletPublicKeyHash, &closing) + task := tbtcpg.NewReservationReanchorTask(tbtcChain, btcChain) + + if _, _, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + }); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if notifications := tbtcChain.GetBelowDustNotifications(); len(notifications) != 0 { + t.Fatalf( + "expected no below-dust notifications for a Closing wallet, got %d", + len(notifications), + ) + } + }) + t.Run("wallet never reservation-touched: no notification even below dust", func(t *testing.T) { tbtcChain, btcChain := newFixture(500000, nil, false) task := tbtcpg.NewReservationReanchorTask(tbtcChain, btcChain) @@ -919,14 +947,9 @@ func TestReservationReanchorTask_Run_NotifiesMovingFundsBelowDust(t *testing.T) }) } -// TestReservationReanchorTask_FindTargetWallet_CachesAcrossRuns was moved -// to reservation_reanchor_metrics_test.go, which is in package tbtcpg and -// can therefore reach the unexported findTargetWallet method directly. -// See that file for the test body. - // TestReservationReanchorTask_CapRevertLeadsToNextCandidate is a -// regression test for the cap-revert branch Run adds on top of -// findTargetWallet: a request-time capacity revert (modeled here as a +// regression test for the cap-revert branch Run adds on top of the +// target search: a request-time capacity revert (modeled here as a // concurrent consumer eating the cached target's headroom between the // headroom pre-check and the request submission) must not abort the // coordination window. Run must evict the reverted target, mark it as @@ -979,8 +1002,9 @@ func TestReservationReanchorTask_CapRevertLeadsToNextCandidate(t *testing.T) { 0, ) tbtcChain.SetReservationParameters(tbtc.ReservationParameters{ - ReservationTxMaxFee: 100000, + ReservationTxMaxFee: 10000, MaxReservationsPerWallet: 5, + ReservationActionTimeout: 86400, }) btcChain.SetEstimateSatPerVByteFee(1, 1) @@ -1079,6 +1103,13 @@ func TestReservationReanchorTask_AmountCapFillLeadsToNextCandidate(t *testing.T) t.Run("fake chain enforces the target amount cap", func(t *testing.T) { lc := tbtcpg.NewLocalChain() + lc.SetReservationParameters(tbtc.ReservationParameters{ + ReservationTxMaxFee: 10000, + MaxReservationsPerWallet: 5, + ReservationActionTimeout: 86400, + }) + lc.SetWallet(sourceWalletPublicKeyHash, &tbtc.WalletChainData{State: tbtc.StateMovingFunds}) + lc.SetWallet(filledTargetWalletPublicKeyHash, &tbtc.WalletChainData{State: tbtc.StateLive}) // Configure a per-wallet reserved-amount cap the filled target // (250000 reserved + 100000 anchor = 350000) breaches. lc.SetReservationCaps(300000, 1000000) @@ -1118,11 +1149,11 @@ func TestReservationReanchorTask_AmountCapFillLeadsToNextCandidate(t *testing.T) }) lc.SetWalletReservations(filledTargetWalletPublicKeyHash, []*big.Int{fillKey}) - if _, err := lc.RequestReservationReanchor( + if err := lc.RequestReservationReanchor( resKey, filledTargetWalletPublicKeyHash, ); err == nil || - !strings.Contains(err.Error(), "wallet reserved amount cap exceeded") { + !strings.Contains(err.Error(), "Wallet reserved amount cap exceeded") { t.Fatalf( "expected the fake to reject the re-anchor request with "+ "the amount-cap revert when the target's reserved "+ @@ -1142,7 +1173,7 @@ func TestReservationReanchorTask_AmountCapFillLeadsToNextCandidate(t *testing.T) tbtcChain.SetBlockCounter(blockCounter) // Register the alternate target (oldest), then the filled - // target (newest): findTargetWallet scans registration events + // target (newest): the target search scans registration events // newest-first, so the filled target is the first candidate // examined -- it passes the headroom pre-check and its request // reverts on the amount cap, forcing eviction to the alternate. @@ -1178,8 +1209,9 @@ func TestReservationReanchorTask_AmountCapFillLeadsToNextCandidate(t *testing.T) 0, ) tbtcChain.SetReservationParameters(tbtc.ReservationParameters{ - ReservationTxMaxFee: 100000, + ReservationTxMaxFee: 10000, MaxReservationsPerWallet: 5, + ReservationActionTimeout: 86400, }) btcChain.SetEstimateSatPerVByteFee(1, 1) @@ -1291,6 +1323,7 @@ func TestReservationReanchorTask_ResumesPendingReanchorWithoutNewRequest(t *test ReservationTxMaxFee: 100000, MaxReservationsPerWallet: 5, ReservationMinAmount: 1000, + ReservationActionTimeout: 86400, }) btcChain.SetEstimateSatPerVByteFee(1, 1) @@ -1331,8 +1364,7 @@ func TestReservationReanchorTask_ResumesPendingReanchorWithoutNewRequest(t *test State: tbtc.ReservationActionStatePending, TargetWalletPublicKeyHash: targetWalletPublicKeyHash, TxMaxFee: 100000, - MinAmount: 1000, - TermSeconds: 86400, + Amount: 200000, // Far-future TimeoutAt so the safety-margin gate does not // skip the resumed generation. TimeoutAt: uint32(time.Now().Add(24 * time.Hour).Unix()), @@ -1423,8 +1455,9 @@ func TestProposeReservationReanchor_ValidatesOnlyAfterRequestMined(t *testing.T) 0, ) tbtcChain.SetReservationParameters(tbtc.ReservationParameters{ - ReservationTxMaxFee: 100000, + ReservationTxMaxFee: 10000, MaxReservationsPerWallet: 5, + ReservationActionTimeout: 86400, }) btcChain.SetEstimateSatPerVByteFee(1, 1) @@ -1524,3 +1557,297 @@ func TestProposeReservationReanchor_ValidatesOnlyAfterRequestMined(t *testing.T) ) } } + +// TestLocalChain_RequestReservationReanchor_MirrorsSolidity pins the +// LocalChain re-anchor request fake to Reservation.sol's +// requestReservationReanchor, notifyReservationActionTimeout and re-anchor +// settlement accounting. The task tests above rely on the fake rejecting +// exactly what the contract rejects and reserving exactly the capacity the +// contract reserves; a looser fake would let a task regression pass. +func TestLocalChain_RequestReservationReanchor_MirrorsSolidity(t *testing.T) { + sourceWallet := hexToByte20("1111111111111111111111111111111111111111") + targetWallet := hexToByte20("2222222222222222222222222222222222222222") + otherWallet := hexToByte20("3333333333333333333333333333333333333333") + reservationKey := big.NewInt(4242) + anchorTxHash := bitcoin.Hash{0x42} + + newFixture := func() *tbtcpg.LocalChain { + lc := tbtcpg.NewLocalChain() + lc.SetReservationParameters(tbtc.ReservationParameters{ + ReservationTxMaxFee: 10000, + ReservationMinAmount: 1000, + MaxReservationsPerWallet: 2, + ReservationActionTimeout: 86400, + }) + lc.SetWallet(sourceWallet, &tbtc.WalletChainData{State: tbtc.StateMovingFunds}) + lc.SetWallet(targetWallet, &tbtc.WalletChainData{State: tbtc.StateLive}) + lc.SetReservation(reservationKey, &tbtc.Reservation{ + WalletPublicKeyHash: sourceWallet, + AnchorUtxo: &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHash, + OutputIndex: 3, + }, + Value: 100000, + }, + State: tbtc.ReservationStateActive, + }) + lc.SetWalletReservations(sourceWallet, []*big.Int{reservationKey}) + return lc + } + + walletInfo := func(t *testing.T, lc *tbtcpg.LocalChain, wallet [20]byte) (uint32, uint64) { + t.Helper() + count, err := lc.WalletReservationsCount(wallet) + if err != nil { + t.Fatal(err) + } + amount, err := lc.WalletReservationsAmount(wallet) + if err != nil { + t.Fatal(err) + } + return count, amount + } + + t.Run("request reserves target capacity and writes the action", func(t *testing.T) { + lc := newFixture() + before := uint32(time.Now().Unix()) + + if err := lc.RequestReservationReanchor(reservationKey, targetWallet); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if count, amount := walletInfo(t, lc, targetWallet); count != 1 || amount != 100000 { + t.Fatalf("expected target capacity (1, 100000), got (%d, %d)", count, amount) + } + if count, amount := walletInfo(t, lc, sourceWallet); count != 1 || amount != 100000 { + t.Fatalf("expected source capacity unchanged (1, 100000), got (%d, %d)", count, amount) + } + + reservation, err := lc.GetReservation(reservationKey) + if err != nil { + t.Fatal(err) + } + if reservation.State != tbtc.ReservationStateActionPending || reservation.RequestNonce != 1 { + t.Fatalf( + "expected ActionPending at nonce 1, got %v at nonce %d", + reservation.State, + reservation.RequestNonce, + ) + } + + action, err := lc.GetReservationAction(reservationKey, 1) + if err != nil { + t.Fatal(err) + } + packed := make([]byte, 36) + copy(packed, anchorTxHash[:]) + packed[35] = 3 + var expectedSourceAnchorUtxoHash [32]byte + copy(expectedSourceAnchorUtxoHash[:], crypto.Keccak256(packed)) + expected := tbtc.ReservationAction{ + ActionType: tbtc.ReservationActionTypeReanchor, + State: tbtc.ReservationActionStatePending, + RequestedAt: action.RequestedAt, + TimeoutAt: action.RequestedAt + 86400, + TxMaxFee: 10000, + TargetWalletPublicKeyHash: targetWallet, + Amount: 100000, + SourceAnchorUtxoHash: expectedSourceAnchorUtxoHash, + } + if *action != expected { + t.Fatalf("unexpected action\nexpected: %+v\nactual: %+v", expected, *action) + } + if action.RequestedAt < before || action.RequestedAt > uint32(time.Now().Unix()) { + t.Fatalf("RequestedAt [%d] is not the request time", action.RequestedAt) + } + }) + + t.Run("closing source is accepted", func(t *testing.T) { + lc := newFixture() + lc.SetWallet(sourceWallet, &tbtc.WalletChainData{State: tbtc.StateClosing}) + if err := lc.RequestReservationReanchor(reservationKey, targetWallet); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + reverts := map[string]struct { + mutate func(lc *tbtcpg.LocalChain) + target [20]byte + expectedReason string + }{ + "reservation not active": { + mutate: func(lc *tbtcpg.LocalChain) { + r, _ := lc.GetReservation(reservationKey) + updated := *r + updated.State = tbtc.ReservationStateActionPending + lc.SetReservation(reservationKey, &updated) + }, + target: targetWallet, + expectedReason: "Reservation is not active", + }, + "cooldown in effect": { + mutate: func(lc *tbtcpg.LocalChain) { + r, _ := lc.GetReservation(reservationKey) + updated := *r + updated.ReanchorCooldownUntil = uint32(time.Now().Add(time.Hour).Unix()) + lc.SetReservation(reservationKey, &updated) + }, + target: targetWallet, + expectedReason: "Reanchor cooldown in effect", + }, + "live source": { + mutate: func(lc *tbtcpg.LocalChain) { + lc.SetWallet(sourceWallet, &tbtc.WalletChainData{State: tbtc.StateLive}) + }, + target: targetWallet, + expectedReason: "Only governance can rotate a Live wallet's anchor", + }, + "terminated source": { + mutate: func(lc *tbtcpg.LocalChain) { + lc.SetWallet(sourceWallet, &tbtc.WalletChainData{State: tbtc.StateTerminated}) + }, + target: targetWallet, + expectedReason: "Source wallet must be in MovingFunds or Closing state", + }, + "target equals source": { + mutate: func(lc *tbtcpg.LocalChain) {}, + target: sourceWallet, + expectedReason: "Target wallet must differ from the source wallet", + }, + "target not live": { + mutate: func(lc *tbtcpg.LocalChain) {}, + target: otherWallet, + expectedReason: "Target wallet must be in Live state", + }, + "no signing window": { + mutate: func(lc *tbtcpg.LocalChain) { + lc.SetReservationParameters(tbtc.ReservationParameters{ + ReservationTxMaxFee: 10000, + ReservationMinAmount: 1000, + MaxReservationsPerWallet: 2, + ReservationActionTimeout: 7201, + }) + }, + target: targetWallet, + expectedReason: "Reanchor authorization has no signing window", + }, + "anchor at the floor": { + mutate: func(lc *tbtcpg.LocalChain) { + r, _ := lc.GetReservation(reservationKey) + updated := *r + anchor := *r.AnchorUtxo + anchor.Value = 11000 + updated.AnchorUtxo = &anchor + lc.SetReservation(reservationKey, &updated) + }, + target: targetWallet, + expectedReason: "Reanchor would fall below the minimum reservation amount", + }, + "zero count cap blocks every request": { + mutate: func(lc *tbtcpg.LocalChain) { + lc.SetReservationParameters(tbtc.ReservationParameters{ + ReservationTxMaxFee: 10000, + ReservationMinAmount: 1000, + MaxReservationsPerWallet: 0, + ReservationActionTimeout: 86400, + }) + }, + target: targetWallet, + expectedReason: "Wallet reservations cap exceeded", + }, + "target count at the cap": { + mutate: func(lc *tbtcpg.LocalChain) { + lc.SetWalletReservations(targetWallet, []*big.Int{big.NewInt(1), big.NewInt(2)}) + }, + target: targetWallet, + expectedReason: "Wallet reservations cap exceeded", + }, + "target amount over the cap": { + mutate: func(lc *tbtcpg.LocalChain) { + lc.SetReservationCaps(99999, 0) + }, + target: targetWallet, + expectedReason: "Wallet reserved amount cap exceeded", + }, + } + for name, test := range reverts { + t.Run("reverts: "+name, func(t *testing.T) { + lc := newFixture() + test.mutate(lc) + before, _ := lc.GetReservation(reservationKey) + + err := lc.RequestReservationReanchor(reservationKey, test.target) + if err == nil || !strings.Contains(err.Error(), test.expectedReason) { + t.Fatalf("expected revert [%s], got [%v]", test.expectedReason, err) + } + if got := len(lc.GetReservationReanchorRequestSubmissions()); got != 0 { + t.Fatalf("expected no submission for a reverted request, got %d", got) + } + if got := len(lc.GetReservationReanchorRequestAttempts()); got != 1 { + t.Fatalf("expected the reverted request to be recorded as an attempt, got %d", got) + } + after, _ := lc.GetReservation(reservationKey) + if after.RequestNonce != before.RequestNonce || after.State != before.State { + t.Fatalf("a reverted request changed the reservation: %+v", after) + } + }) + } + + t.Run("timeout releases target capacity and starts the cooldown", func(t *testing.T) { + lc := newFixture() + if err := lc.RequestReservationReanchor(reservationKey, targetWallet); err != nil { + t.Fatal(err) + } + if err := lc.TimeOutReservationReanchor(reservationKey); err != nil { + t.Fatal(err) + } + + if count, amount := walletInfo(t, lc, targetWallet); count != 0 || amount != 0 { + t.Fatalf("expected target capacity released, got (%d, %d)", count, amount) + } + reservation, _ := lc.GetReservation(reservationKey) + if reservation.State != tbtc.ReservationStateActive { + t.Fatalf("expected the reservation back in Active, got %v", reservation.State) + } + if reservation.ReanchorCooldownUntil < uint32(time.Now().Add(86399*time.Second).Unix()) { + t.Fatalf( + "expected a cooldown of the action's 86400s duration, got until [%d]", + reservation.ReanchorCooldownUntil, + ) + } + action, _ := lc.GetReservationAction(reservationKey, 1) + if action.State != tbtc.ReservationActionStateTimedOut { + t.Fatalf("expected the action TimedOut, got %v", action.State) + } + err := lc.RequestReservationReanchor(reservationKey, targetWallet) + if err == nil || !strings.Contains(err.Error(), "Reanchor cooldown in effect") { + t.Fatalf("expected the cooldown to block a new request, got [%v]", err) + } + }) + + t.Run("settlement moves custody and keeps the miner fee out of the target amount", func(t *testing.T) { + lc := newFixture() + if err := lc.RequestReservationReanchor(reservationKey, targetWallet); err != nil { + t.Fatal(err) + } + if err := lc.SettleReservationReanchor(reservationKey, &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{TransactionHash: bitcoin.Hash{0x43}}, + Value: 99000, + }); err != nil { + t.Fatal(err) + } + + if count, amount := walletInfo(t, lc, sourceWallet); count != 0 || amount != 0 { + t.Fatalf("expected source capacity released, got (%d, %d)", count, amount) + } + if count, amount := walletInfo(t, lc, targetWallet); count != 1 || amount != 99000 { + t.Fatalf("expected target capacity (1, 99000), got (%d, %d)", count, amount) + } + reservation, _ := lc.GetReservation(reservationKey) + if reservation.WalletPublicKeyHash != targetWallet || + reservation.State != tbtc.ReservationStateActive { + t.Fatalf("unexpected reservation after settlement: %+v", reservation) + } + }) +}