diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml index 4f0209b80a..d839630322 100644 --- a/.github/workflows/client.yml +++ b/.github/workflows/client.yml @@ -136,6 +136,20 @@ jobs: with: environment: ${{ github.event.inputs.environment }} + # TODO(https://github.com/threshold-network/tbtc-v2/pull/1112): remove once + # @keep-network/tbtc-v2 publishes the reservation-router surface (Bridge, + # WalletProposalValidator, RedemptionWatchtower, ReservationRouter, and the + # rest of the tbtc module's required_contracts) under the `development` npm + # tag. Until then, `make get_artifacts` can't fetch these from npm; the gen + # Makefile's vendored fallback artifacts (see + # pkg/chain/ethereum/tbtc/gen/Makefile's ReservationRouter.fallback-artifact.json + # rule and the Bridge/WalletProposalValidator reservation-methods-fallback.json + # patch rules) supply the missing methods for `environment=development` builds + # instead. This directory only exists so the Dockerfile's COPY step (guarded by + # `-n "$(ls -A ...)"`, a no-op when empty) always has a source to copy from; see + # ./ci-shims/tbtc-artifacts and the matching Dockerfile step. + - name: Prepare tbtc-v2 artifact shim directory + run: mkdir -p ci-shims/tbtc-artifacts - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 diff --git a/.gitignore b/.gitignore index dc42dcb3f6..3dddffa11e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,12 @@ # Executables /keep-client +# Temporary CI-only artifact injected before the Docker build; see Dockerfile +# and .github/workflows/client.yml (ReservationRouter tbtc-v2 PR #1112 shim). +/ci-shims/* +!/ci-shims/tbtc-artifacts/ +!/ci-shims/tbtc-artifacts/** + # IDEs .vscode/ .idea/ diff --git a/Dockerfile b/Dockerfile index dce9eba139..c2e5238e14 100644 --- a/Dockerfile +++ b/Dockerfile @@ -62,11 +62,31 @@ COPY ./pkg/protocol/inactivity/gen $APP_DIR/pkg/protocol/inactivity/gen RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.32.0 # Environment is to download published and tagged NPM packages versions. -ARG ENVIRONMENT +# Defaults to `development` to mirror the root Makefile's `ifndef environment` +# fallback (the "Build Docker Build Image" CI step never passes this build-arg). +ARG ENVIRONMENT=development COPY ./Makefile $APP_DIR/Makefile RUN make get_artifacts environment=$ENVIRONMENT +# TODO(https://github.com/threshold-network/tbtc-v2/pull/1112): remove once +# @keep-network/tbtc-v2 publishes Bridge/WalletProposalValidator/RedemptionWatchtower/ +# ReservationRouter (and the rest of the tbtc module's required_contracts) under the +# `development` npm tag. Until then, `get_artifacts` fetches a tbtc-v2 package whose +# Bridge/WalletProposalValidator/RedemptionWatchtower don't yet expose the reservation +# methods this PR binds against, and has no ReservationRouter artifact at all. The +# `client.yml` workflow locally compiles tbtc-v2 PR #1112 (pinned SHA) and drops its +# compiled ABI artifacts for the tbtc module's required_contracts at +# ./ci-shims/tbtc-artifacts/*.json when it runs; this only overrides the tbtc module's +# artifacts, and only for `environment=development` (PR CI) builds - sepolia/mainnet +# builds and the beacon/ecdsa/threshold modules are untouched. +COPY ./ci-shims/tbtc-artifacts /tmp/tbtc-artifacts +RUN if { [ -z "$ENVIRONMENT" ] || [ "$ENVIRONMENT" = "development" ]; } && ls /tmp/tbtc-artifacts/*.json >/dev/null 2>&1; then \ + echo "Using tbtc-v2 module artifacts built from tbtc-v2 PR #1112 (temporary shim)"; \ + cp /tmp/tbtc-artifacts/*.json \ + $APP_DIR/tmp/contracts/development/@keep-network/tbtc-v2/artifacts/; \ +fi + # Need this to resolve imports in generated Ethereum commands. COPY ./config $APP_DIR/config RUN make generate environment=$ENVIRONMENT diff --git a/ci-shims/tbtc-artifacts/.gitkeep b/ci-shims/tbtc-artifacts/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cmd/start.go b/cmd/start.go index 95e1ebc2bb..687292b642 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -8,6 +8,7 @@ import ( "github.com/keep-network/keep-core/pkg/tbtcpg" "github.com/keep-network/keep-common/pkg/persistence" + "github.com/keep-network/keep-core/build" "github.com/keep-network/keep-core/pkg/bitcoin/electrum" "github.com/keep-network/keep-core/pkg/operator" @@ -22,6 +23,7 @@ import ( "github.com/keep-network/keep-core/pkg/clientinfo" "github.com/keep-network/keep-core/pkg/firewall" "github.com/keep-network/keep-core/pkg/generator" + "github.com/keep-network/keep-core/pkg/maintainer/spv" "github.com/keep-network/keep-core/pkg/net" "github.com/keep-network/keep-core/pkg/net/libp2p" "github.com/keep-network/keep-core/pkg/net/retransmission" @@ -92,7 +94,11 @@ func start(cmd *cobra.Command) error { // Wire performance metrics into network provider if available var perfMetrics *clientinfo.PerformanceMetrics if clientInfoRegistry != nil { - perfMetrics = clientinfo.NewPerformanceMetrics(ctx, clientInfoRegistry) + perfMetrics = clientinfo.NewPerformanceMetrics( + ctx, + clientInfoRegistry, + clientConfig.Tbtc.Reservations.Enabled, + ) // Type assert to libp2p provider to set metrics recorder // The provider struct is not exported, so we use interface assertion if setter, ok := netProvider.(interface { @@ -160,9 +166,10 @@ func start(cmd *cobra.Command) error { proposalGenerator := tbtcpg.NewProposalGenerator( tbtcChain, btcChain, + clientConfig.Tbtc.Reservations.Enabled, ) - err = tbtc.Initialize( + resolver, err := tbtc.Initialize( ctx, tbtcChain, btcChain, @@ -177,7 +184,34 @@ func start(cmd *cobra.Command) error { clientConfig.Ethereum.Network, ) if err != nil { - return fmt.Errorf("error initializing TBTC: [%v]", err) + return fmt.Errorf("cannot initialize TBTC: [%v]", err) + } + + // Wire the reservation watchers (stranding, stale-deposit, + // action-timeout) directly against the same tbtcChain handle: + // cmd/start.go already imports both tbtc and spv, so there is no + // import-cycle reason to thread this through tbtc.Initialize via a + // callback type. Gated on the same flag that gates the reservation + // proposal generator tasks above. Failing to wire the watchers is + // fatal: the operator opted into reservations, so a missing + // watcher would silently strand anchors. + if clientConfig.Tbtc.Reservations.Enabled { + if !clientConfig.Maintainer.Spv.Reservations.Enabled { + logger.Warnf("Client reservation proposal generation is enabled; " + + "ensure the paired Maintainer.Spv.Reservations.Enabled flag is also " + + "enabled in the maintainer config for end-to-end operation") + } + if err := spv.WireReservationWatchers( + ctx, + tbtcChain, + tbtcChain, + resolver, + ); err != nil { + return fmt.Errorf( + "failed to wire reservation watchers: [%v]", + err, + ) + } } } diff --git a/config/config_test.go b/config/config_test.go index f8de558c4e..117c0dbe5f 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -233,6 +233,14 @@ func TestReadConfigFromFile(t *testing.T) { readValueFunc: func(c *Config) interface{} { return c.Maintainer.Spv.IdleBackoffTime }, expectedValue: 15 * time.Minute, }, + "Maintainer.Spv.Reservations.Enabled": { + readValueFunc: func(c *Config) interface{} { return c.Maintainer.Spv.Reservations.Enabled }, + expectedValue: true, + }, + "Tbtc.Reservations.Enabled": { + readValueFunc: func(c *Config) interface{} { return c.Tbtc.Reservations.Enabled }, + expectedValue: true, + }, } for _, filePath := range filePaths { diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index bac873545d..c14c628f95 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -1,6 +1,7 @@ // 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). +// tbtc_redemption.go, tbtc_reservation.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), @@ -61,6 +62,10 @@ type TbtcChain struct { sortitionPool *ecdsacontract.EcdsaSortitionPool walletProposalValidator *tbtccontract.WalletProposalValidator redemptionWatchtower *tbtccontract.RedemptionWatchtower + // reservationRouter is the abigen binding for ReservationRouter.sol's ABI + // constructed against the Bridge address (see tbtc_reservation.go for the + // address invariant explanation). + reservationRouter *tbtccontract.ReservationRouter // ecdsaDkgValidatorAddress optional; when zero, TBTC uses defaultGroupParameters(network). ecdsaDkgValidatorAddress common.Address @@ -263,6 +268,14 @@ func newTbtcChain( ) } + reservationRouter, err := reservationRouterBinding(bridgeAddress, baseChain) + if err != nil { + return nil, fmt.Errorf( + "failed to attach to ReservationRouter binding: [%v]", + err, + ) + } + return &TbtcChain{ baseChain: baseChain, bridge: bridge, @@ -271,6 +284,7 @@ func newTbtcChain( sortitionPool: sortitionPool, walletProposalValidator: walletProposalValidator, redemptionWatchtower: redemptionWatchtower, + reservationRouter: reservationRouter, ecdsaDkgValidatorAddress: ecdsaDkgValidatorAddress, sweptDepositsCache: cache.NewGenericTimeCache[*tbtc.DepositChainRequest](sweptDepositsCachePeriod), }, nil diff --git a/pkg/chain/ethereum/tbtc/gen/Bridge.reservation-methods-fallback.json b/pkg/chain/ethereum/tbtc/gen/Bridge.reservation-methods-fallback.json new file mode 100644 index 0000000000..ca7b69b5de --- /dev/null +++ b/pkg/chain/ethereum/tbtc/gen/Bridge.reservation-methods-fallback.json @@ -0,0 +1,21 @@ +[ + { + "inputs": [ + { + "internalType": "uint256", + "name": "depositKey", + "type": "uint256" + } + ], + "name": "isReservedDeposit", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + } +] \ No newline at end of file diff --git a/pkg/chain/ethereum/tbtc/gen/Makefile b/pkg/chain/ethereum/tbtc/gen/Makefile index 2229760f03..9a02409cca 100644 --- a/pkg/chain/ethereum/tbtc/gen/Makefile +++ b/pkg/chain/ethereum/tbtc/gen/Makefile @@ -1,7 +1,7 @@ npm_package_name=@keep-network/tbtc-v2 # Contracts for which the bindings should be generated. -required_contracts := Bridge MaintainerProxy LightRelay LightRelayMaintainerProxy WalletProposalValidator RedemptionWatchtower +required_contracts := Bridge MaintainerProxy LightRelay LightRelayMaintainerProxy WalletProposalValidator RedemptionWatchtower ReservationRouter # There is a bug in the currently used abigen version (v1.10.19) that makes it # re-declaring structs used by multiple contracts @@ -22,6 +22,7 @@ define after_abi_hook $(eval type := $(1)) $(if $(filter $(type),WalletProposalValidator),$(call fix_wallet_proposal_validator_collision)) $(if $(filter $(type),MaintainerProxy),$(call fix_maintainer_proxy_collision)) + $(if $(filter $(type),ReservationRouter),$(call fix_reservation_router_collision)) endef define fix_wallet_proposal_validator_collision @perl -pi -e s,BitcoinTxInfo,BitcoinTxInfo2,g ./abi/WalletProposalValidator.go @@ -32,12 +33,26 @@ define fix_maintainer_proxy_collision @perl -pi -e s,BitcoinTxProof,BitcoinTxProof2,g ./abi/MaintainerProxy.go @perl -pi -e s,BitcoinTxInfo,BitcoinTxInfo3,g ./abi/MaintainerProxy.go endef +# ReservationRouter introduces its own copies of the BitcoinTx.* structs +# (BitcoinTxInfo, BitcoinTxProof, BitcoinTxUTXO) used by submitReservationProof. +# These names already collide with Bridge's (BitcoinTxInfo/BitcoinTxProof/BitcoinTxUTXO), +# WalletProposalValidator's (BitcoinTxInfo2/BitcoinTxUTXO3), and MaintainerProxy's +# (BitcoinTxInfo3/BitcoinTxProof2/BitcoinTxUTXO2). Renumber ReservationRouter's +# copies to the next free suffix in each family. The router code only ever runs +# via Bridge.fallback delegatecall, so its storage pointer is unused; this is +# a purely textual rename to keep the abi package compiling. +define fix_reservation_router_collision + @perl -pi -e s,BitcoinTxInfo,BitcoinTxInfo4,g ./abi/ReservationRouter.go + @perl -pi -e s,BitcoinTxProof,BitcoinTxProof3,g ./abi/ReservationRouter.go + @perl -pi -e s,BitcoinTxUTXO,BitcoinTxUTXO4,g ./abi/ReservationRouter.go +endef # See explanation in https://github.com/keep-network/keep-common/issues/117. define after_contract_hook $(eval type := $(1)) $(if $(filter $(type),WalletProposalValidator),$(call fix_wallet_proposal_validator_contract_collision)) $(if $(filter $(type),MaintainerProxy),$(call fix_maintainer_proxy_contract_collision)) + $(if $(filter $(type),ReservationRouter),$(call fix_reservation_router_contract_collision)) endef define fix_wallet_proposal_validator_contract_collision @perl -pi -e s,BitcoinTxUTXO,BitcoinTxUTXO3,g ./contract/WalletProposalValidator.go @@ -51,5 +66,76 @@ define fix_maintainer_proxy_contract_collision @perl -pi -e s,BitcoinTxProof,BitcoinTxProof2,g ./cmd/MaintainerProxy.go @perl -pi -e s,BitcoinTxInfo,BitcoinTxInfo3,g ./cmd/MaintainerProxy.go endef +# keep-common's generator emits BitcoinTx* references inside the generated +# contract/cmd binding code paths too (e.g. method wrappers). Apply the same +# renames there so the contract and cmd packages compile. +define fix_reservation_router_contract_collision + @perl -pi -e s,BitcoinTxInfo,BitcoinTxInfo4,g ./contract/ReservationRouter.go + @perl -pi -e s,BitcoinTxProof,BitcoinTxProof3,g ./contract/ReservationRouter.go + @perl -pi -e s,BitcoinTxUTXO,BitcoinTxUTXO4,g ./contract/ReservationRouter.go + @perl -pi -e s,BitcoinTxInfo,BitcoinTxInfo4,g ./cmd/ReservationRouter.go + @perl -pi -e s,BitcoinTxProof,BitcoinTxProof3,g ./cmd/ReservationRouter.go + @perl -pi -e s,BitcoinTxUTXO,BitcoinTxUTXO4,g ./cmd/ReservationRouter.go +endef include ../../common/gen/Makefile + +# @keep-network/tbtc-v2@development on npm does not yet publish +# ReservationRouter.json (threshold-network/keep-core#4281), which makes +# `make generate` fail outright since nothing else can produce that +# prerequisite. Fall back to a vendored copy - its ABI is byte-for-byte +# the ABI already embedded in the committed bindings (re-derived from +# ReservationRouterMetaData.ABI, with the "struct"/"enum"/"contract" +# internalType prefix space abigen's metadata packer strips put back; +# verified by round-tripping through the same abigen + keep-common +# generator invocation and diffing byte-identical against +# abi/ReservationRouter.go, contract/ReservationRouter.go, and +# cmd/ReservationRouter.go) - only when the real artifact is missing, +# and only in the `development` environment, which already tolerates +# placeholder addresses (see the _address/% rule above). Non-development +# builds still hard-fail if the real artifact is ever missing there, +# since a real deployed address must never be substituted silently. +# Remove this rule once tbtc-v2 publishes the real artifact upstream. +${artifacts_dir}/ReservationRouter.json: +ifeq ($(environment), development) + @[ -f "$@" ] || { \ + echo "ReservationRouter - artifact missing from ${npm_package_name}@${environment}, using vendored fallback (see threshold-network/keep-core#4281)"; \ + cp ReservationRouter.fallback-artifact.json "$@"; \ + } +else + @[ -f "$@" ] || { echo "$@ does not exist!"; exit 1; } +endif + +# @keep-network/tbtc-v2@development on npm publishes Bridge.json and +# WalletProposalValidator.json, but both are stale relative to the +# reservation feature: they're missing isReservedDeposit (Bridge) and +# validateReservationAnchorProposal/validateReservationReanchorProposal +# (WalletProposalValidator), which the committed bindings already call +# (threshold-network/keep-core#4281). Unlike ReservationRouter.json, +# these files exist, so an only-if-missing artifact rule can't apply - +# patch the fetched artifact in place instead, merging in vendored +# fragments (extracted from the committed BridgeMetaData.ABI / +# WalletProposalValidatorMetaData.ABI, internalType prefix space +# restored the same way as ReservationRouter's; verified by +# round-tripping through the same abigen + keep-common generator +# invocation, producing a clean `go build ./...`) before anything reads +# the artifact. Only in `development`; only when the methods are +# actually missing, so a real future npm publish makes this a no-op +# without needing to be removed first. Non-development builds are +# untouched. Remove this whole block once tbtc-v2 publishes the real +# methods upstream. +.PHONY: patch-artifacts +check_artifacts: patch-artifacts +patch-artifacts: +ifeq ($(environment), development) + @jq -e '.abi[] | select(.name == "isReservedDeposit")' ${artifacts_dir}/Bridge.json >/dev/null 2>&1 || { \ + echo "Bridge - artifact missing reservation methods, patching in vendored fallback (see threshold-network/keep-core#4281)"; \ + jq --slurpfile extra Bridge.reservation-methods-fallback.json '.abi += $$extra[0]' ${artifacts_dir}/Bridge.json > ${artifacts_dir}/Bridge.json.patched && \ + mv ${artifacts_dir}/Bridge.json.patched ${artifacts_dir}/Bridge.json; \ + } + @jq -e '.abi[] | select(.name == "validateReservationAnchorProposal")' ${artifacts_dir}/WalletProposalValidator.json >/dev/null 2>&1 || { \ + echo "WalletProposalValidator - artifact missing reservation methods, patching in vendored fallback (see threshold-network/keep-core#4281)"; \ + jq --slurpfile extra WalletProposalValidator.reservation-methods-fallback.json '.abi += $$extra[0]' ${artifacts_dir}/WalletProposalValidator.json > ${artifacts_dir}/WalletProposalValidator.json.patched && \ + mv ${artifacts_dir}/WalletProposalValidator.json.patched ${artifacts_dir}/WalletProposalValidator.json; \ + } +endif diff --git a/pkg/chain/ethereum/tbtc/gen/ReservationRouter.fallback-artifact.json b/pkg/chain/ethereum/tbtc/gen/ReservationRouter.fallback-artifact.json new file mode 100644 index 0000000000..51f0d7ffd6 --- /dev/null +++ b/pkg/chain/ethereum/tbtc/gen/ReservationRouter.fallback-artifact.json @@ -0,0 +1,1138 @@ +{ + "address": "0x0000000000000000000000000000000000000000", + "abi": [ + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "oldGovernance", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "newGovernance", + "type": "address" + } + ], + "name": "GovernanceTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint8", + "name": "version", + "type": "uint8" + } + ], + "name": "Initialized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "reservationKey", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "requestNonce", + "type": "uint64" + }, + { + "indexed": true, + "internalType": "bytes20", + "name": "walletPubKeyHash", + "type": "bytes20" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "depositAmount", + "type": "uint64" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "txMaxFee", + "type": "uint64" + }, + { + "indexed": false, + "internalType": "uint32", + "name": "timeoutAt", + "type": "uint32" + } + ], + "name": "ReservationAcceptanceRequested", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "reservationKey", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "requestNonce", + "type": "uint64" + }, + { + "indexed": true, + "internalType": "bytes20", + "name": "walletPubKeyHash", + "type": "bytes20" + }, + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "anchorTxHash", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "anchorAmount", + "type": "uint64" + }, + { + "indexed": false, + "internalType": "uint32", + "name": "expiresAt", + "type": "uint32" + } + ], + "name": "ReservationAccepted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "reservationKey", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "requestNonce", + "type": "uint64" + } + ], + "name": "ReservationActionSuperseded", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "reservationKey", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "requestNonce", + "type": "uint64" + }, + { + "indexed": false, + "internalType": "enum Reservation.ActionType", + "name": "actionType", + "type": "uint8" + } + ], + "name": "ReservationActionTimedOut", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint64", + "name": "maxReservationsAmountPerWallet", + "type": "uint64" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "reservationMaxSingleAmount", + "type": "uint64" + }, + { + "indexed": false, + "internalType": "uint32", + "name": "maxActiveReservations", + "type": "uint32" + } + ], + "name": "ReservationCapsUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "reservationKey", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "requestNonce", + "type": "uint64" + }, + { + "indexed": false, + "internalType": "enum Reservation.ActionType", + "name": "actionType", + "type": "uint8" + } + ], + "name": "ReservationLateSettled", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint64", + "name": "reservationMinAmount", + "type": "uint64" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "reservationTxMaxFee", + "type": "uint64" + }, + { + "indexed": false, + "internalType": "uint32", + "name": "reservationTermSeconds", + "type": "uint32" + }, + { + "indexed": false, + "internalType": "uint32", + "name": "reservationDissolutionDelay", + "type": "uint32" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "reservationMaxTotalAmount", + "type": "uint64" + }, + { + "indexed": false, + "internalType": "uint32", + "name": "maxReservationsPerWallet", + "type": "uint32" + }, + { + "indexed": false, + "internalType": "uint32", + "name": "reservationActionTimeout", + "type": "uint32" + }, + { + "indexed": false, + "internalType": "uint32", + "name": "reservationRenewalWindowSeconds", + "type": "uint32" + } + ], + "name": "ReservationParametersUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "reservationKey", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "requestNonce", + "type": "uint64" + }, + { + "indexed": true, + "internalType": "bytes20", + "name": "sourceWalletPubKeyHash", + "type": "bytes20" + }, + { + "indexed": true, + "internalType": "bytes20", + "name": "targetWalletPubKeyHash", + "type": "bytes20" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "txMaxFee", + "type": "uint64" + } + ], + "name": "ReservationReanchorRequested", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "reservationKey", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "requestNonce", + "type": "uint64" + }, + { + "indexed": true, + "internalType": "bytes20", + "name": "newWalletPubKeyHash", + "type": "bytes20" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "newAnchorTxHash", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "newAnchorAmount", + "type": "uint64" + } + ], + "name": "ReservationReanchored", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "reservationKey", + "type": "uint256" + } + ], + "name": "ReservationRetryCreditMinted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "reservationRouter", + "type": "address" + } + ], + "name": "ReservationRouterSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "reservationKey", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "bytes20", + "name": "walletPubKeyHash", + "type": "bytes20" + }, + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "anchorAmount", + "type": "uint64" + } + ], + "name": "ReservationStranded", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "reservationVault", + "type": "address" + } + ], + "name": "ReservationVaultUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "depositKey", + "type": "uint256" + } + ], + "name": "ReservedDepositMarkedStale", + "type": "event" + }, + { + "inputs": [], + "name": "activeReservationsCount", + "outputs": [ + { + "internalType": "uint32", + "name": "count", + "type": "uint32" + }, + { + "internalType": "uint32", + "name": "maxActive", + "type": "uint32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "governance", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "reservationKey", + "type": "uint256" + }, + { + "internalType": "uint32[]", + "name": "walletMembersIDs", + "type": "uint32[]" + } + ], + "name": "notifyReservationActionTimeout", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "reservationKey", + "type": "uint256" + } + ], + "name": "notifyReservationStranded", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "depositKey", + "type": "uint256" + } + ], + "name": "notifyStaleReservedDeposit", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "pendingReservedDeposits", + "outputs": [ + { + "internalType": "uint64", + "name": "", + "type": "uint64" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "reservationKey", + "type": "uint256" + }, + { + "internalType": "bytes20", + "name": "walletPubKeyHash", + "type": "bytes20" + } + ], + "name": "requestReservationAcceptance", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "reservationKey", + "type": "uint256" + }, + { + "internalType": "bytes20", + "name": "targetWalletPubKeyHash", + "type": "bytes20" + } + ], + "name": "requestReservationReanchor", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "reservationKey", + "type": "uint256" + }, + { + "internalType": "uint64", + "name": "requestNonce", + "type": "uint64" + } + ], + "name": "reservationActions", + "outputs": [ + { + "components": [ + { + "internalType": "bytes20", + "name": "targetWalletPubKeyHash", + "type": "bytes20" + }, + { + "internalType": "uint32", + "name": "requestedAt", + "type": "uint32" + }, + { + "internalType": "uint32", + "name": "timeoutAt", + "type": "uint32" + }, + { + "internalType": "uint64", + "name": "txMaxFee", + "type": "uint64" + }, + { + "internalType": "enum Reservation.ActionType", + "name": "actionType", + "type": "uint8" + }, + { + "internalType": "enum Reservation.ActionState", + "name": "state", + "type": "uint8" + }, + { + "internalType": "bool", + "name": "feePaid", + "type": "bool" + }, + { + "internalType": "address", + "name": "redeemer", + "type": "address" + }, + { + "internalType": "uint64", + "name": "amount", + "type": "uint64" + }, + { + "internalType": "bytes32", + "name": "actionDataHash", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "sourceAnchorUtxoHash", + "type": "bytes32" + }, + { + "internalType": "bool", + "name": "usedRetryCredit", + "type": "bool" + }, + { + "internalType": "uint32", + "name": "watchtowerDefaultDelay", + "type": "uint32" + }, + { + "internalType": "uint32", + "name": "watchtowerLevelOneDelay", + "type": "uint32" + }, + { + "internalType": "uint32", + "name": "watchtowerLevelTwoDelay", + "type": "uint32" + }, + { + "internalType": "bool", + "name": "isPartial", + "type": "bool" + }, + { + "internalType": "uint64", + "name": "retryCreditSourceNonce", + "type": "uint64" + } + ], + "internalType": "struct Reservation.ReservationAction", + "name": "", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "anchorTxHash", + "type": "bytes32" + }, + { + "internalType": "uint32", + "name": "anchorTxOutputIndex", + "type": "uint32" + } + ], + "name": "reservationByAnchorUtxo", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "reservationCaps", + "outputs": [ + { + "internalType": "uint64", + "name": "maxReservationsAmountPerWallet", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "reservationMaxSingleAmount", + "type": "uint64" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "reservationParameters", + "outputs": [ + { + "internalType": "address", + "name": "reservationVault", + "type": "address" + }, + { + "internalType": "uint64", + "name": "reservationMinAmount", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "reservationTxMaxFee", + "type": "uint64" + }, + { + "internalType": "uint32", + "name": "reservationTermSeconds", + "type": "uint32" + }, + { + "internalType": "uint32", + "name": "reservationDissolutionDelay", + "type": "uint32" + }, + { + "internalType": "uint64", + "name": "reservationMaxTotalAmount", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "reservationTotalAmount", + "type": "uint64" + }, + { + "internalType": "uint32", + "name": "maxReservationsPerWallet", + "type": "uint32" + }, + { + "internalType": "uint32", + "name": "reservationActionTimeout", + "type": "uint32" + }, + { + "internalType": "uint32", + "name": "reservationRenewalWindowSeconds", + "type": "uint32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "reservationRouter", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "reservationKey", + "type": "uint256" + } + ], + "name": "reservations", + "outputs": [ + { + "components": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "uint64", + "name": "mintedAmount", + "type": "uint64" + }, + { + "internalType": "uint32", + "name": "acceptedAt", + "type": "uint32" + }, + { + "internalType": "bytes20", + "name": "walletPubKeyHash", + "type": "bytes20" + }, + { + "internalType": "uint64", + "name": "anchorAmount", + "type": "uint64" + }, + { + "internalType": "uint32", + "name": "expiresAt", + "type": "uint32" + }, + { + "internalType": "bytes32", + "name": "anchorTxHash", + "type": "bytes32" + }, + { + "internalType": "uint32", + "name": "anchorTxOutputIndex", + "type": "uint32" + }, + { + "internalType": "enum Reservation.ReservationState", + "name": "state", + "type": "uint8" + }, + { + "internalType": "uint64", + "name": "requestNonce", + "type": "uint64" + }, + { + "internalType": "bool", + "name": "retryCredit", + "type": "bool" + }, + { + "internalType": "uint32", + "name": "dissolutionEligibleAt", + "type": "uint32" + }, + { + "internalType": "uint64", + "name": "cumulativeReanchorFee", + "type": "uint64" + } + ], + "internalType": "struct Reservation.ReservationRequest", + "name": "", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "depositKey", + "type": "uint256" + } + ], + "name": "reservedDepositWallet", + "outputs": [ + { + "internalType": "bytes20", + "name": "", + "type": "bytes20" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "proofType", + "type": "uint8" + }, + { + "components": [ + { + "internalType": "bytes4", + "name": "version", + "type": "bytes4" + }, + { + "internalType": "bytes", + "name": "inputVector", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "outputVector", + "type": "bytes" + }, + { + "internalType": "bytes4", + "name": "locktime", + "type": "bytes4" + } + ], + "internalType": "struct BitcoinTx.Info", + "name": "txInfo", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "bytes", + "name": "merkleProof", + "type": "bytes" + }, + { + "internalType": "uint256", + "name": "txIndexInBlock", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "bitcoinHeaders", + "type": "bytes" + }, + { + "internalType": "bytes32", + "name": "coinbasePreimage", + "type": "bytes32" + }, + { + "internalType": "bytes", + "name": "coinbaseProof", + "type": "bytes" + } + ], + "internalType": "struct BitcoinTx.Proof", + "name": "proof", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "bytes32", + "name": "txHash", + "type": "bytes32" + }, + { + "internalType": "uint32", + "name": "txOutputIndex", + "type": "uint32" + }, + { + "internalType": "uint64", + "name": "txOutputValue", + "type": "uint64" + } + ], + "internalType": "struct BitcoinTx.UTXO", + "name": "mainUtxo", + "type": "tuple" + }, + { + "internalType": "uint256", + "name": "reservationKey", + "type": "uint256" + }, + { + "internalType": "uint64", + "name": "requestNonce", + "type": "uint64" + } + ], + "name": "submitReservationProof", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newGovernance", + "type": "address" + } + ], + "name": "transferGovernance", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint64", + "name": "maxReservationsAmountPerWallet", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "reservationMaxSingleAmount", + "type": "uint64" + }, + { + "internalType": "uint32", + "name": "maxActiveReservations", + "type": "uint32" + } + ], + "name": "updateReservationCaps", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "reservationVault", + "type": "address" + }, + { + "internalType": "uint64", + "name": "reservationMinAmount", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "reservationTxMaxFee", + "type": "uint64" + }, + { + "internalType": "uint32", + "name": "reservationTermSeconds", + "type": "uint32" + }, + { + "internalType": "uint32", + "name": "reservationDissolutionDelay", + "type": "uint32" + }, + { + "internalType": "uint64", + "name": "reservationMaxTotalAmount", + "type": "uint64" + }, + { + "internalType": "uint32", + "name": "maxReservationsPerWallet", + "type": "uint32" + }, + { + "internalType": "uint32", + "name": "reservationActionTimeout", + "type": "uint32" + }, + { + "internalType": "uint32", + "name": "reservationRenewalWindowSeconds", + "type": "uint32" + } + ], + "name": "updateReservationParameters", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes20", + "name": "walletPubKeyHash", + "type": "bytes20" + } + ], + "name": "walletReservations", + "outputs": [ + { + "internalType": "uint256[]", + "name": "", + "type": "uint256[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes20", + "name": "walletPubKeyHash", + "type": "bytes20" + } + ], + "name": "walletReservationsAmount", + "outputs": [ + { + "internalType": "uint64", + "name": "", + "type": "uint64" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes20", + "name": "walletPubKeyHash", + "type": "bytes20" + } + ], + "name": "walletReservationsCount", + "outputs": [ + { + "internalType": "uint32", + "name": "", + "type": "uint32" + } + ], + "stateMutability": "view", + "type": "function" + } + ] +} \ No newline at end of file diff --git a/pkg/chain/ethereum/tbtc/gen/WalletProposalValidator.reservation-methods-fallback.json b/pkg/chain/ethereum/tbtc/gen/WalletProposalValidator.reservation-methods-fallback.json new file mode 100644 index 0000000000..8692d77101 --- /dev/null +++ b/pkg/chain/ethereum/tbtc/gen/WalletProposalValidator.reservation-methods-fallback.json @@ -0,0 +1,145 @@ +[ + { + "inputs": [ + { + "components": [ + { + "internalType": "bytes20", + "name": "walletPubKeyHash", + "type": "bytes20" + }, + { + "components": [ + { + "internalType": "bytes32", + "name": "fundingTxHash", + "type": "bytes32" + }, + { + "internalType": "uint32", + "name": "fundingOutputIndex", + "type": "uint32" + } + ], + "internalType": "struct WalletProposalValidator.DepositKey", + "name": "depositKey", + "type": "tuple" + }, + { + "internalType": "uint256", + "name": "anchorTxFee", + "type": "uint256" + } + ], + "internalType": "struct WalletProposalValidator.ReservationAnchorProposal", + "name": "proposal", + "type": "tuple" + }, + { + "components": [ + { + "components": [ + { + "internalType": "bytes4", + "name": "version", + "type": "bytes4" + }, + { + "internalType": "bytes", + "name": "inputVector", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "outputVector", + "type": "bytes" + }, + { + "internalType": "bytes4", + "name": "locktime", + "type": "bytes4" + } + ], + "internalType": "struct BitcoinTx.Info", + "name": "fundingTx", + "type": "tuple" + }, + { + "internalType": "bytes8", + "name": "blindingFactor", + "type": "bytes8" + }, + { + "internalType": "bytes20", + "name": "walletPubKeyHash", + "type": "bytes20" + }, + { + "internalType": "bytes20", + "name": "refundPubKeyHash", + "type": "bytes20" + }, + { + "internalType": "bytes4", + "name": "refundLocktime", + "type": "bytes4" + } + ], + "internalType": "struct WalletProposalValidator.DepositExtraInfo", + "name": "depositExtraInfo", + "type": "tuple" + } + ], + "name": "validateReservationAnchorProposal", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "bytes20", + "name": "sourceWalletPubKeyHash", + "type": "bytes20" + }, + { + "internalType": "uint256", + "name": "reservationKey", + "type": "uint256" + }, + { + "internalType": "bytes20", + "name": "targetWalletPubKeyHash", + "type": "bytes20" + }, + { + "internalType": "uint256", + "name": "reanchorTxFee", + "type": "uint256" + } + ], + "internalType": "struct WalletProposalValidator.ReservationReanchorProposal", + "name": "proposal", + "type": "tuple" + } + ], + "name": "validateReservationReanchorProposal", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + } +] \ No newline at end of file diff --git a/pkg/chain/ethereum/tbtc/gen/_address/ReservationRouter b/pkg/chain/ethereum/tbtc/gen/_address/ReservationRouter new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pkg/chain/ethereum/tbtc/gen/abi/Bridge.go b/pkg/chain/ethereum/tbtc/gen/abi/Bridge.go index e76e6f779f..80df5a4d72 100644 --- a/pkg/chain/ethereum/tbtc/gen/abi/Bridge.go +++ b/pkg/chain/ethereum/tbtc/gen/abi/Bridge.go @@ -121,7 +121,7 @@ type WalletsWallet struct { // BridgeMetaData contains all meta data concerning the Bridge contract. var BridgeMetaData = &bind.MetaData{ - ABI: "[{\"inputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"depositDustThreshold\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"depositTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"depositTxMaxFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"depositRevealAheadPeriod\",\"type\":\"uint32\"}],\"name\":\"DepositParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"fundingTxHash\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"fundingOutputIndex\",\"type\":\"uint32\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"depositor\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"amount\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"bytes8\",\"name\":\"blindingFactor\",\"type\":\"bytes8\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes20\",\"name\":\"refundPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes4\",\"name\":\"refundLocktime\",\"type\":\"bytes4\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"name\":\"DepositRevealed\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"sweepTxHash\",\"type\":\"bytes32\"}],\"name\":\"DepositsSwept\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"sighash\",\"type\":\"bytes32\"}],\"name\":\"FraudChallengeDefeatTimedOut\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"sighash\",\"type\":\"bytes32\"}],\"name\":\"FraudChallengeDefeated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"sighash\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"uint8\",\"name\":\"v\",\"type\":\"uint8\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"r\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"s\",\"type\":\"bytes32\"}],\"name\":\"FraudChallengeSubmitted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"fraudChallengeDepositAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"fraudChallengeDefeatTimeout\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"fraudSlashingAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"fraudNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"FraudParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"oldGovernance\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"newGovernance\",\"type\":\"address\"}],\"name\":\"GovernanceTransferred\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint8\",\"name\":\"version\",\"type\":\"uint8\"}],\"name\":\"Initialized\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"movingFundsTxHash\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movingFundsTxOutputIndex\",\"type\":\"uint32\"}],\"name\":\"MovedFundsSweepTimedOut\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"sweepTxHash\",\"type\":\"bytes32\"}],\"name\":\"MovedFundsSwept\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"MovingFundsBelowDustReported\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes20[]\",\"name\":\"targetWallets\",\"type\":\"bytes20[]\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"submitter\",\"type\":\"address\"}],\"name\":\"MovingFundsCommitmentSubmitted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"movingFundsTxHash\",\"type\":\"bytes32\"}],\"name\":\"MovingFundsCompleted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"movingFundsTxMaxTotalFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"movingFundsDustThreshold\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutResetDelay\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movingFundsTimeout\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"movingFundsTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint16\",\"name\":\"movingFundsCommitmentGasOffset\",\"type\":\"uint16\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"movedFundsSweepTxMaxTotalFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeout\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"movedFundsSweepTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"MovingFundsParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"MovingFundsTimedOut\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"MovingFundsTimeoutReset\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"NewWalletRegistered\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[],\"name\":\"NewWalletRequested\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"redemptionDustThreshold\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"redemptionTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxTotalFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"redemptionTimeout\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"redemptionTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"redemptionTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"RedemptionParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"requestedAmount\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"treasuryFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"txMaxFee\",\"type\":\"uint64\"}],\"name\":\"RedemptionRequested\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"}],\"name\":\"RedemptionTimedOut\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"redemptionWatchtower\",\"type\":\"address\"}],\"name\":\"RedemptionWatchtowerSet\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"redemptionTxHash\",\"type\":\"bytes32\"}],\"name\":\"RedemptionsCompleted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"spvMaintainer\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"bool\",\"name\":\"isTrusted\",\"type\":\"bool\"}],\"name\":\"SpvMaintainerStatusUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"treasury\",\"type\":\"address\"}],\"name\":\"TreasuryUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"bool\",\"name\":\"isTrusted\",\"type\":\"bool\"}],\"name\":\"VaultStatusUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"WalletClosed\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"WalletClosing\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"WalletMovingFunds\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"walletCreationPeriod\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"walletCreationMinBtcBalance\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"walletCreationMaxBtcBalance\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"walletClosureMinBtcBalance\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"walletMaxAge\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"walletMaxBtcTransfer\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"walletClosingPeriod\",\"type\":\"uint32\"}],\"name\":\"WalletParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"WalletTerminated\",\"type\":\"event\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"publicKeyX\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"publicKeyY\",\"type\":\"bytes32\"}],\"name\":\"__ecdsaWalletCreatedCallback\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"publicKeyX\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"publicKeyY\",\"type\":\"bytes32\"}],\"name\":\"__ecdsaWalletHeartbeatFailedCallback\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"activeWalletPubKeyHash\",\"outputs\":[{\"internalType\":\"bytes20\",\"name\":\"\",\"type\":\"bytes20\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"contractReferences\",\"outputs\":[{\"internalType\":\"contractBank\",\"name\":\"bank\",\"type\":\"address\"},{\"internalType\":\"contractIRelay\",\"name\":\"relay\",\"type\":\"address\"},{\"internalType\":\"contractIWalletRegistry\",\"name\":\"ecdsaWalletRegistry\",\"type\":\"address\"},{\"internalType\":\"contractReimbursementPool\",\"name\":\"reimbursementPool\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes\",\"name\":\"walletPublicKey\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"preimage\",\"type\":\"bytes\"},{\"internalType\":\"bool\",\"name\":\"witness\",\"type\":\"bool\"}],\"name\":\"defeatFraudChallenge\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes\",\"name\":\"walletPublicKey\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"heartbeatMessage\",\"type\":\"bytes\"}],\"name\":\"defeatFraudChallengeWithHeartbeat\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"depositParameters\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"depositDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"depositTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"depositTxMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"depositRevealAheadPeriod\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"depositKey\",\"type\":\"uint256\"}],\"name\":\"deposits\",\"outputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"depositor\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"amount\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"revealedAt\",\"type\":\"uint32\"},{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"treasuryFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"sweptAt\",\"type\":\"uint32\"},{\"internalType\":\"bytes32\",\"name\":\"extraData\",\"type\":\"bytes32\"}],\"internalType\":\"structDeposit.DepositRequest\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"challengeKey\",\"type\":\"uint256\"}],\"name\":\"fraudChallenges\",\"outputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"challenger\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"depositAmount\",\"type\":\"uint256\"},{\"internalType\":\"uint32\",\"name\":\"reportedAt\",\"type\":\"uint32\"},{\"internalType\":\"bool\",\"name\":\"resolved\",\"type\":\"bool\"}],\"internalType\":\"structFraud.FraudChallenge\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"fraudParameters\",\"outputs\":[{\"internalType\":\"uint96\",\"name\":\"fraudChallengeDepositAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"fraudChallengeDefeatTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"fraudSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"fraudNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"getRedemptionWatchtower\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"governance\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_bank\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_relay\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_treasury\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_ecdsaWalletRegistry\",\"type\":\"address\"},{\"internalType\":\"addresspayable\",\"name\":\"_reimbursementPool\",\"type\":\"address\"},{\"internalType\":\"uint96\",\"name\":\"_txProofDifficultyFactor\",\"type\":\"uint96\"}],\"name\":\"initialize\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"name\":\"isVaultTrusted\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"liveWalletsCount\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"requestKey\",\"type\":\"uint256\"}],\"name\":\"movedFundsSweepRequests\",\"outputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"uint64\",\"name\":\"value\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"createdAt\",\"type\":\"uint32\"},{\"internalType\":\"enumMovingFunds.MovedFundsSweepRequestState\",\"name\":\"state\",\"type\":\"uint8\"}],\"internalType\":\"structMovingFunds.MovedFundsSweepRequest\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"movingFundsParameters\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"movingFundsTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"movingFundsDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutResetDelay\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"movingFundsTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"},{\"internalType\":\"uint16\",\"name\":\"movingFundsCommitmentGasOffset\",\"type\":\"uint16\"},{\"internalType\":\"uint64\",\"name\":\"movedFundsSweepTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"movedFundsSweepTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes\",\"name\":\"walletPublicKey\",\"type\":\"bytes\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"},{\"internalType\":\"bytes\",\"name\":\"preimageSha256\",\"type\":\"bytes\"}],\"name\":\"notifyFraudChallengeDefeatTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"movingFundsTxHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTxOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"}],\"name\":\"notifyMovedFundsSweepTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"}],\"name\":\"notifyMovingFundsBelowDust\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"}],\"name\":\"notifyMovingFundsTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"},{\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"}],\"name\":\"notifyRedemptionTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"}],\"name\":\"notifyRedemptionVeto\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"walletMainUtxo\",\"type\":\"tuple\"}],\"name\":\"notifyWalletCloseable\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"notifyWalletClosingPeriodElapsed\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"}],\"name\":\"pendingRedemptions\",\"outputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"requestedAmount\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"treasuryFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"txMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"requestedAt\",\"type\":\"uint32\"}],\"internalType\":\"structRedemption.RedemptionRequest\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"balanceOwner\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"redemptionData\",\"type\":\"bytes\"}],\"name\":\"receiveBalanceApproval\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"redemptionParameters\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"redemptionDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"redemptionTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"redemptionTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"redemptionTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"activeWalletMainUtxo\",\"type\":\"tuple\"}],\"name\":\"requestNewWallet\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"},{\"internalType\":\"uint64\",\"name\":\"amount\",\"type\":\"uint64\"}],\"name\":\"requestRedemption\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"resetMovingFundsTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"fundingTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"uint32\",\"name\":\"fundingOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"bytes8\",\"name\":\"blindingFactor\",\"type\":\"bytes8\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes20\",\"name\":\"refundPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes4\",\"name\":\"refundLocktime\",\"type\":\"bytes4\"},{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"internalType\":\"structDeposit.DepositRevealInfo\",\"name\":\"reveal\",\"type\":\"tuple\"}],\"name\":\"revealDeposit\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"fundingTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"uint32\",\"name\":\"fundingOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"bytes8\",\"name\":\"blindingFactor\",\"type\":\"bytes8\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes20\",\"name\":\"refundPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes4\",\"name\":\"refundLocktime\",\"type\":\"bytes4\"},{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"internalType\":\"structDeposit.DepositRevealInfo\",\"name\":\"reveal\",\"type\":\"tuple\"},{\"internalType\":\"bytes32\",\"name\":\"extraData\",\"type\":\"bytes32\"}],\"name\":\"revealDepositWithExtraData\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"redemptionWatchtower\",\"type\":\"address\"}],\"name\":\"setRedemptionWatchtower\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"spvMaintainer\",\"type\":\"address\"},{\"internalType\":\"bool\",\"name\":\"isTrusted\",\"type\":\"bool\"}],\"name\":\"setSpvMaintainerStatus\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"},{\"internalType\":\"bool\",\"name\":\"isTrusted\",\"type\":\"bool\"}],\"name\":\"setVaultStatus\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"utxoKey\",\"type\":\"uint256\"}],\"name\":\"spentMainUTXOs\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"sweepTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes\",\"name\":\"merkleProof\",\"type\":\"bytes\"},{\"internalType\":\"uint256\",\"name\":\"txIndexInBlock\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"bitcoinHeaders\",\"type\":\"bytes\"},{\"internalType\":\"bytes32\",\"name\":\"coinbasePreimage\",\"type\":\"bytes32\"},{\"internalType\":\"bytes\",\"name\":\"coinbaseProof\",\"type\":\"bytes\"}],\"internalType\":\"structBitcoinTx.Proof\",\"name\":\"sweepProof\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"name\":\"submitDepositSweepProof\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes\",\"name\":\"walletPublicKey\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"preimageSha256\",\"type\":\"bytes\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"r\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"s\",\"type\":\"bytes32\"},{\"internalType\":\"uint8\",\"name\":\"v\",\"type\":\"uint8\"}],\"internalType\":\"structBitcoinTx.RSVSignature\",\"name\":\"signature\",\"type\":\"tuple\"}],\"name\":\"submitFraudChallenge\",\"outputs\":[],\"stateMutability\":\"payable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"sweepTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes\",\"name\":\"merkleProof\",\"type\":\"bytes\"},{\"internalType\":\"uint256\",\"name\":\"txIndexInBlock\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"bitcoinHeaders\",\"type\":\"bytes\"},{\"internalType\":\"bytes32\",\"name\":\"coinbasePreimage\",\"type\":\"bytes32\"},{\"internalType\":\"bytes\",\"name\":\"coinbaseProof\",\"type\":\"bytes\"}],\"internalType\":\"structBitcoinTx.Proof\",\"name\":\"sweepProof\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"}],\"name\":\"submitMovedFundsSweepProof\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"walletMainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"},{\"internalType\":\"uint256\",\"name\":\"walletMemberIndex\",\"type\":\"uint256\"},{\"internalType\":\"bytes20[]\",\"name\":\"targetWallets\",\"type\":\"bytes20[]\"}],\"name\":\"submitMovingFundsCommitment\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"movingFundsTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes\",\"name\":\"merkleProof\",\"type\":\"bytes\"},{\"internalType\":\"uint256\",\"name\":\"txIndexInBlock\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"bitcoinHeaders\",\"type\":\"bytes\"},{\"internalType\":\"bytes32\",\"name\":\"coinbasePreimage\",\"type\":\"bytes32\"},{\"internalType\":\"bytes\",\"name\":\"coinbaseProof\",\"type\":\"bytes\"}],\"internalType\":\"structBitcoinTx.Proof\",\"name\":\"movingFundsProof\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"submitMovingFundsProof\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"redemptionTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes\",\"name\":\"merkleProof\",\"type\":\"bytes\"},{\"internalType\":\"uint256\",\"name\":\"txIndexInBlock\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"bitcoinHeaders\",\"type\":\"bytes\"},{\"internalType\":\"bytes32\",\"name\":\"coinbasePreimage\",\"type\":\"bytes32\"},{\"internalType\":\"bytes\",\"name\":\"coinbaseProof\",\"type\":\"bytes\"}],\"internalType\":\"structBitcoinTx.Proof\",\"name\":\"redemptionProof\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"submitRedemptionProof\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"}],\"name\":\"timedOutRedemptions\",\"outputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"requestedAmount\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"treasuryFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"txMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"requestedAt\",\"type\":\"uint32\"}],\"internalType\":\"structRedemption.RedemptionRequest\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"newGovernance\",\"type\":\"address\"}],\"name\":\"transferGovernance\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"treasury\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"txProofDifficultyFactor\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint64\",\"name\":\"depositDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"depositTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"depositTxMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"depositRevealAheadPeriod\",\"type\":\"uint32\"}],\"name\":\"updateDepositParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint96\",\"name\":\"fraudChallengeDepositAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"fraudChallengeDefeatTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"fraudSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"fraudNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"updateFraudParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint64\",\"name\":\"movingFundsTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"movingFundsDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutResetDelay\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"movingFundsTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"},{\"internalType\":\"uint16\",\"name\":\"movingFundsCommitmentGasOffset\",\"type\":\"uint16\"},{\"internalType\":\"uint64\",\"name\":\"movedFundsSweepTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"movedFundsSweepTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"updateMovingFundsParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint64\",\"name\":\"redemptionDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"redemptionTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"redemptionTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"redemptionTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"updateRedemptionParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"treasury\",\"type\":\"address\"}],\"name\":\"updateTreasury\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint32\",\"name\":\"walletCreationPeriod\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"walletCreationMinBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"walletCreationMaxBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"walletClosureMinBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"walletMaxAge\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"walletMaxBtcTransfer\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"walletClosingPeriod\",\"type\":\"uint32\"}],\"name\":\"updateWalletParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"walletParameters\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"walletCreationPeriod\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"walletCreationMinBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"walletCreationMaxBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"walletClosureMinBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"walletMaxAge\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"walletMaxBtcTransfer\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"walletClosingPeriod\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"wallets\",\"outputs\":[{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"mainUtxoHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint64\",\"name\":\"pendingRedemptionsValue\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"createdAt\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsRequestedAt\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"closingStartedAt\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"pendingMovedFundsSweepRequestsCount\",\"type\":\"uint32\"},{\"internalType\":\"enumWallets.WalletState\",\"name\":\"state\",\"type\":\"uint8\"},{\"internalType\":\"bytes32\",\"name\":\"movingFundsTargetWalletsCommitmentHash\",\"type\":\"bytes32\"}],\"internalType\":\"structWallets.Wallet\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"}]", + ABI: "[{\"inputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"depositDustThreshold\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"depositTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"depositTxMaxFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"depositRevealAheadPeriod\",\"type\":\"uint32\"}],\"name\":\"DepositParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"fundingTxHash\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"fundingOutputIndex\",\"type\":\"uint32\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"depositor\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"amount\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"bytes8\",\"name\":\"blindingFactor\",\"type\":\"bytes8\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes20\",\"name\":\"refundPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes4\",\"name\":\"refundLocktime\",\"type\":\"bytes4\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"name\":\"DepositRevealed\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"uint256\",\"name\":\"depositKey\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"newVault\",\"type\":\"address\"}],\"name\":\"DepositVaultFixed\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"sweepTxHash\",\"type\":\"bytes32\"}],\"name\":\"DepositsSwept\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"sighash\",\"type\":\"bytes32\"}],\"name\":\"FraudChallengeDefeatTimedOut\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"sighash\",\"type\":\"bytes32\"}],\"name\":\"FraudChallengeDefeated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"sighash\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"uint8\",\"name\":\"v\",\"type\":\"uint8\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"r\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"s\",\"type\":\"bytes32\"}],\"name\":\"FraudChallengeSubmitted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"fraudChallengeDepositAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"fraudChallengeDefeatTimeout\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"fraudSlashingAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"fraudNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"FraudParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"oldGovernance\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"newGovernance\",\"type\":\"address\"}],\"name\":\"GovernanceTransferred\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint8\",\"name\":\"version\",\"type\":\"uint8\"}],\"name\":\"Initialized\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"movingFundsTxHash\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movingFundsTxOutputIndex\",\"type\":\"uint32\"}],\"name\":\"MovedFundsSweepTimedOut\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"sweepTxHash\",\"type\":\"bytes32\"}],\"name\":\"MovedFundsSwept\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"MovingFundsBelowDustReported\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes20[]\",\"name\":\"targetWallets\",\"type\":\"bytes20[]\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"submitter\",\"type\":\"address\"}],\"name\":\"MovingFundsCommitmentSubmitted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"movingFundsTxHash\",\"type\":\"bytes32\"}],\"name\":\"MovingFundsCompleted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"movingFundsTxMaxTotalFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"movingFundsDustThreshold\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutResetDelay\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movingFundsTimeout\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"movingFundsTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint16\",\"name\":\"movingFundsCommitmentGasOffset\",\"type\":\"uint16\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"movedFundsSweepTxMaxTotalFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeout\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"movedFundsSweepTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"MovingFundsParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"MovingFundsTimedOut\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"MovingFundsTimeoutReset\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"NewWalletRegistered\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[],\"name\":\"NewWalletRequested\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"oldRebateStaking\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"newRebateStaking\",\"type\":\"address\"}],\"name\":\"RebateStakingRepaired\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"rebateStaking\",\"type\":\"address\"}],\"name\":\"RebateStakingSet\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"redemptionDustThreshold\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"redemptionTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxTotalFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"redemptionTimeout\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint96\",\"name\":\"redemptionTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"redemptionTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"RedemptionParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"requestedAmount\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"treasuryFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"txMaxFee\",\"type\":\"uint64\"}],\"name\":\"RedemptionRequested\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"}],\"name\":\"RedemptionTimedOut\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"redemptionWatchtower\",\"type\":\"address\"}],\"name\":\"RedemptionWatchtowerSet\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"redemptionTxHash\",\"type\":\"bytes32\"}],\"name\":\"RedemptionsCompleted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"spvMaintainer\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"bool\",\"name\":\"isTrusted\",\"type\":\"bool\"}],\"name\":\"SpvMaintainerStatusUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"treasury\",\"type\":\"address\"}],\"name\":\"TreasuryUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"bool\",\"name\":\"isTrusted\",\"type\":\"bool\"}],\"name\":\"VaultStatusUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"WalletClosed\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"WalletClosing\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"WalletMovingFunds\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"walletCreationPeriod\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"walletCreationMinBtcBalance\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"walletCreationMaxBtcBalance\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"walletClosureMinBtcBalance\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"walletMaxAge\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"walletMaxBtcTransfer\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"walletClosingPeriod\",\"type\":\"uint32\"}],\"name\":\"WalletParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"WalletTerminated\",\"type\":\"event\"},{\"stateMutability\":\"payable\",\"type\":\"fallback\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"publicKeyX\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"publicKeyY\",\"type\":\"bytes32\"}],\"name\":\"__ecdsaWalletCreatedCallback\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"publicKeyX\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"publicKeyY\",\"type\":\"bytes32\"}],\"name\":\"__ecdsaWalletHeartbeatFailedCallback\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"activeWalletPubKeyHash\",\"outputs\":[{\"internalType\":\"bytes20\",\"name\":\"\",\"type\":\"bytes20\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"contractReferences\",\"outputs\":[{\"internalType\":\"contractBank\",\"name\":\"bank\",\"type\":\"address\"},{\"internalType\":\"contractIRelay\",\"name\":\"relay\",\"type\":\"address\"},{\"internalType\":\"contractIWalletRegistry\",\"name\":\"ecdsaWalletRegistry\",\"type\":\"address\"},{\"internalType\":\"contractReimbursementPool\",\"name\":\"reimbursementPool\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes\",\"name\":\"walletPublicKey\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"preimage\",\"type\":\"bytes\"},{\"internalType\":\"bool\",\"name\":\"witness\",\"type\":\"bool\"}],\"name\":\"defeatFraudChallenge\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes\",\"name\":\"walletPublicKey\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"heartbeatMessage\",\"type\":\"bytes\"}],\"name\":\"defeatFraudChallengeWithHeartbeat\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"depositParameters\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"depositDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"depositTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"depositTxMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"depositRevealAheadPeriod\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"depositKey\",\"type\":\"uint256\"}],\"name\":\"deposits\",\"outputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"depositor\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"amount\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"revealedAt\",\"type\":\"uint32\"},{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"treasuryFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"sweptAt\",\"type\":\"uint32\"},{\"internalType\":\"bytes32\",\"name\":\"extraData\",\"type\":\"bytes32\"}],\"internalType\":\"structDeposit.DepositRequest\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"challengeKey\",\"type\":\"uint256\"}],\"name\":\"fraudChallenges\",\"outputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"challenger\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"depositAmount\",\"type\":\"uint256\"},{\"internalType\":\"uint32\",\"name\":\"reportedAt\",\"type\":\"uint32\"},{\"internalType\":\"bool\",\"name\":\"resolved\",\"type\":\"bool\"}],\"internalType\":\"structFraud.FraudChallenge\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"fraudParameters\",\"outputs\":[{\"internalType\":\"uint96\",\"name\":\"fraudChallengeDepositAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"fraudChallengeDefeatTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"fraudSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"fraudNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"getRebateStaking\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"getRedemptionWatchtower\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"getReservationRouter\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"governance\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_bank\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_relay\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_treasury\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_ecdsaWalletRegistry\",\"type\":\"address\"},{\"internalType\":\"addresspayable\",\"name\":\"_reimbursementPool\",\"type\":\"address\"},{\"internalType\":\"uint96\",\"name\":\"_txProofDifficultyFactor\",\"type\":\"uint96\"}],\"name\":\"initialize\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"initializeV2_FixVaultZeroDeposit\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"newRebateStaking\",\"type\":\"address\"}],\"name\":\"initializeV5_RepairRebateStaking\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"depositKey\",\"type\":\"uint256\"}],\"name\":\"isReservedDeposit\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"name\":\"isVaultTrusted\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"liveWalletsCount\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"requestKey\",\"type\":\"uint256\"}],\"name\":\"movedFundsSweepRequests\",\"outputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"uint64\",\"name\":\"value\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"createdAt\",\"type\":\"uint32\"},{\"internalType\":\"enumMovingFunds.MovedFundsSweepRequestState\",\"name\":\"state\",\"type\":\"uint8\"}],\"internalType\":\"structMovingFunds.MovedFundsSweepRequest\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"movingFundsParameters\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"movingFundsTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"movingFundsDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutResetDelay\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"movingFundsTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"},{\"internalType\":\"uint16\",\"name\":\"movingFundsCommitmentGasOffset\",\"type\":\"uint16\"},{\"internalType\":\"uint64\",\"name\":\"movedFundsSweepTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"movedFundsSweepTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes\",\"name\":\"walletPublicKey\",\"type\":\"bytes\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"},{\"internalType\":\"bytes\",\"name\":\"preimageSha256\",\"type\":\"bytes\"}],\"name\":\"notifyFraudChallengeDefeatTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"movingFundsTxHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTxOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"}],\"name\":\"notifyMovedFundsSweepTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"}],\"name\":\"notifyMovingFundsBelowDust\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"}],\"name\":\"notifyMovingFundsTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"},{\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"}],\"name\":\"notifyRedemptionTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"}],\"name\":\"notifyRedemptionVeto\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"walletMainUtxo\",\"type\":\"tuple\"}],\"name\":\"notifyWalletCloseable\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"notifyWalletClosingPeriodElapsed\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"}],\"name\":\"pendingRedemptions\",\"outputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"requestedAmount\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"treasuryFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"txMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"requestedAt\",\"type\":\"uint32\"}],\"internalType\":\"structRedemption.RedemptionRequest\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"balanceOwner\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"redemptionData\",\"type\":\"bytes\"}],\"name\":\"receiveBalanceApproval\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"redemptionParameters\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"redemptionDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"redemptionTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"redemptionTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"redemptionTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"activeWalletMainUtxo\",\"type\":\"tuple\"}],\"name\":\"requestNewWallet\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"},{\"internalType\":\"uint64\",\"name\":\"amount\",\"type\":\"uint64\"}],\"name\":\"requestRedemption\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"resetMovingFundsTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"fundingTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"uint32\",\"name\":\"fundingOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"bytes8\",\"name\":\"blindingFactor\",\"type\":\"bytes8\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes20\",\"name\":\"refundPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes4\",\"name\":\"refundLocktime\",\"type\":\"bytes4\"},{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"internalType\":\"structDeposit.DepositRevealInfo\",\"name\":\"reveal\",\"type\":\"tuple\"}],\"name\":\"revealDeposit\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"fundingTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"uint32\",\"name\":\"fundingOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"bytes8\",\"name\":\"blindingFactor\",\"type\":\"bytes8\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes20\",\"name\":\"refundPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes4\",\"name\":\"refundLocktime\",\"type\":\"bytes4\"},{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"internalType\":\"structDeposit.DepositRevealInfo\",\"name\":\"reveal\",\"type\":\"tuple\"},{\"internalType\":\"bytes32\",\"name\":\"extraData\",\"type\":\"bytes32\"}],\"name\":\"revealDepositWithExtraData\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"rebateStaking\",\"type\":\"address\"}],\"name\":\"setRebateStaking\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"redemptionWatchtower\",\"type\":\"address\"}],\"name\":\"setRedemptionWatchtower\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_reservationRouter\",\"type\":\"address\"}],\"name\":\"setReservationRouter\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"spvMaintainer\",\"type\":\"address\"},{\"internalType\":\"bool\",\"name\":\"isTrusted\",\"type\":\"bool\"}],\"name\":\"setSpvMaintainerStatus\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"},{\"internalType\":\"bool\",\"name\":\"isTrusted\",\"type\":\"bool\"}],\"name\":\"setVaultStatus\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"utxoKey\",\"type\":\"uint256\"}],\"name\":\"spentMainUTXOs\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"sweepTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes\",\"name\":\"merkleProof\",\"type\":\"bytes\"},{\"internalType\":\"uint256\",\"name\":\"txIndexInBlock\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"bitcoinHeaders\",\"type\":\"bytes\"},{\"internalType\":\"bytes32\",\"name\":\"coinbasePreimage\",\"type\":\"bytes32\"},{\"internalType\":\"bytes\",\"name\":\"coinbaseProof\",\"type\":\"bytes\"}],\"internalType\":\"structBitcoinTx.Proof\",\"name\":\"sweepProof\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"address\",\"name\":\"vault\",\"type\":\"address\"}],\"name\":\"submitDepositSweepProof\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes\",\"name\":\"walletPublicKey\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"preimageSha256\",\"type\":\"bytes\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"r\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"s\",\"type\":\"bytes32\"},{\"internalType\":\"uint8\",\"name\":\"v\",\"type\":\"uint8\"}],\"internalType\":\"structBitcoinTx.RSVSignature\",\"name\":\"signature\",\"type\":\"tuple\"}],\"name\":\"submitFraudChallenge\",\"outputs\":[],\"stateMutability\":\"payable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"sweepTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes\",\"name\":\"merkleProof\",\"type\":\"bytes\"},{\"internalType\":\"uint256\",\"name\":\"txIndexInBlock\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"bitcoinHeaders\",\"type\":\"bytes\"},{\"internalType\":\"bytes32\",\"name\":\"coinbasePreimage\",\"type\":\"bytes32\"},{\"internalType\":\"bytes\",\"name\":\"coinbaseProof\",\"type\":\"bytes\"}],\"internalType\":\"structBitcoinTx.Proof\",\"name\":\"sweepProof\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"}],\"name\":\"submitMovedFundsSweepProof\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"walletMainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"},{\"internalType\":\"uint256\",\"name\":\"walletMemberIndex\",\"type\":\"uint256\"},{\"internalType\":\"bytes20[]\",\"name\":\"targetWallets\",\"type\":\"bytes20[]\"}],\"name\":\"submitMovingFundsCommitment\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"movingFundsTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes\",\"name\":\"merkleProof\",\"type\":\"bytes\"},{\"internalType\":\"uint256\",\"name\":\"txIndexInBlock\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"bitcoinHeaders\",\"type\":\"bytes\"},{\"internalType\":\"bytes32\",\"name\":\"coinbasePreimage\",\"type\":\"bytes32\"},{\"internalType\":\"bytes\",\"name\":\"coinbaseProof\",\"type\":\"bytes\"}],\"internalType\":\"structBitcoinTx.Proof\",\"name\":\"movingFundsProof\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"submitMovingFundsProof\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"redemptionTx\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes\",\"name\":\"merkleProof\",\"type\":\"bytes\"},{\"internalType\":\"uint256\",\"name\":\"txIndexInBlock\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"bitcoinHeaders\",\"type\":\"bytes\"},{\"internalType\":\"bytes32\",\"name\":\"coinbasePreimage\",\"type\":\"bytes32\"},{\"internalType\":\"bytes\",\"name\":\"coinbaseProof\",\"type\":\"bytes\"}],\"internalType\":\"structBitcoinTx.Proof\",\"name\":\"redemptionProof\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"submitRedemptionProof\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"}],\"name\":\"timedOutRedemptions\",\"outputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"requestedAmount\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"treasuryFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"txMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"requestedAt\",\"type\":\"uint32\"}],\"internalType\":\"structRedemption.RedemptionRequest\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"newGovernance\",\"type\":\"address\"}],\"name\":\"transferGovernance\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"treasury\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"txProofDifficultyFactor\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint64\",\"name\":\"depositDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"depositTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"depositTxMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"depositRevealAheadPeriod\",\"type\":\"uint32\"}],\"name\":\"updateDepositParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint96\",\"name\":\"fraudChallengeDepositAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"fraudChallengeDefeatTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"fraudSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"fraudNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"updateFraudParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint64\",\"name\":\"movingFundsTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"movingFundsDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutResetDelay\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"movingFundsTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"},{\"internalType\":\"uint16\",\"name\":\"movingFundsCommitmentGasOffset\",\"type\":\"uint16\"},{\"internalType\":\"uint64\",\"name\":\"movedFundsSweepTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"movedFundsSweepTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"movedFundsSweepTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"updateMovingFundsParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint64\",\"name\":\"redemptionDustThreshold\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTreasuryFeeDivisor\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"redemptionTxMaxTotalFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"redemptionTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint96\",\"name\":\"redemptionTimeoutSlashingAmount\",\"type\":\"uint96\"},{\"internalType\":\"uint32\",\"name\":\"redemptionTimeoutNotifierRewardMultiplier\",\"type\":\"uint32\"}],\"name\":\"updateRedemptionParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"treasury\",\"type\":\"address\"}],\"name\":\"updateTreasury\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint32\",\"name\":\"walletCreationPeriod\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"walletCreationMinBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"walletCreationMaxBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"walletClosureMinBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"walletMaxAge\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"walletMaxBtcTransfer\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"walletClosingPeriod\",\"type\":\"uint32\"}],\"name\":\"updateWalletParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"walletParameters\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"walletCreationPeriod\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"walletCreationMinBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"walletCreationMaxBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"walletClosureMinBtcBalance\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"walletMaxAge\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"walletMaxBtcTransfer\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"walletClosingPeriod\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"wallets\",\"outputs\":[{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"ecdsaWalletID\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"mainUtxoHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint64\",\"name\":\"pendingRedemptionsValue\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"createdAt\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsRequestedAt\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"closingStartedAt\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"pendingMovedFundsSweepRequestsCount\",\"type\":\"uint32\"},{\"internalType\":\"enumWallets.WalletState\",\"name\":\"state\",\"type\":\"uint8\"},{\"internalType\":\"bytes32\",\"name\":\"movingFundsTargetWalletsCommitmentHash\",\"type\":\"bytes32\"}],\"internalType\":\"structWallets.Wallet\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"}]", } // BridgeABI is the input ABI used to generate the binding from. @@ -528,6 +528,37 @@ func (_Bridge *BridgeCallerSession) FraudParameters() (struct { return _Bridge.Contract.FraudParameters(&_Bridge.CallOpts) } +// GetRebateStaking is a free data retrieval call binding the contract method 0x3edf8238. +// +// Solidity: function getRebateStaking() view returns(address) +func (_Bridge *BridgeCaller) GetRebateStaking(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _Bridge.contract.Call(opts, &out, "getRebateStaking") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// GetRebateStaking is a free data retrieval call binding the contract method 0x3edf8238. +// +// Solidity: function getRebateStaking() view returns(address) +func (_Bridge *BridgeSession) GetRebateStaking() (common.Address, error) { + return _Bridge.Contract.GetRebateStaking(&_Bridge.CallOpts) +} + +// GetRebateStaking is a free data retrieval call binding the contract method 0x3edf8238. +// +// Solidity: function getRebateStaking() view returns(address) +func (_Bridge *BridgeCallerSession) GetRebateStaking() (common.Address, error) { + return _Bridge.Contract.GetRebateStaking(&_Bridge.CallOpts) +} + // GetRedemptionWatchtower is a free data retrieval call binding the contract method 0x5f3281ca. // // Solidity: function getRedemptionWatchtower() view returns(address) @@ -559,6 +590,37 @@ func (_Bridge *BridgeCallerSession) GetRedemptionWatchtower() (common.Address, e return _Bridge.Contract.GetRedemptionWatchtower(&_Bridge.CallOpts) } +// GetReservationRouter is a free data retrieval call binding the contract method 0x5157ec0a. +// +// Solidity: function getReservationRouter() view returns(address) +func (_Bridge *BridgeCaller) GetReservationRouter(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _Bridge.contract.Call(opts, &out, "getReservationRouter") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// GetReservationRouter is a free data retrieval call binding the contract method 0x5157ec0a. +// +// Solidity: function getReservationRouter() view returns(address) +func (_Bridge *BridgeSession) GetReservationRouter() (common.Address, error) { + return _Bridge.Contract.GetReservationRouter(&_Bridge.CallOpts) +} + +// GetReservationRouter is a free data retrieval call binding the contract method 0x5157ec0a. +// +// Solidity: function getReservationRouter() view returns(address) +func (_Bridge *BridgeCallerSession) GetReservationRouter() (common.Address, error) { + return _Bridge.Contract.GetReservationRouter(&_Bridge.CallOpts) +} + // Governance is a free data retrieval call binding the contract method 0x5aa6e675. // // Solidity: function governance() view returns(address) @@ -590,6 +652,37 @@ func (_Bridge *BridgeCallerSession) Governance() (common.Address, error) { return _Bridge.Contract.Governance(&_Bridge.CallOpts) } +// IsReservedDeposit is a free data retrieval call binding the contract method 0x93df529b. +// +// Solidity: function isReservedDeposit(uint256 depositKey) view returns(bool) +func (_Bridge *BridgeCaller) IsReservedDeposit(opts *bind.CallOpts, depositKey *big.Int) (bool, error) { + var out []interface{} + err := _Bridge.contract.Call(opts, &out, "isReservedDeposit", depositKey) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// IsReservedDeposit is a free data retrieval call binding the contract method 0x93df529b. +// +// Solidity: function isReservedDeposit(uint256 depositKey) view returns(bool) +func (_Bridge *BridgeSession) IsReservedDeposit(depositKey *big.Int) (bool, error) { + return _Bridge.Contract.IsReservedDeposit(&_Bridge.CallOpts, depositKey) +} + +// IsReservedDeposit is a free data retrieval call binding the contract method 0x93df529b. +// +// Solidity: function isReservedDeposit(uint256 depositKey) view returns(bool) +func (_Bridge *BridgeCallerSession) IsReservedDeposit(depositKey *big.Int) (bool, error) { + return _Bridge.Contract.IsReservedDeposit(&_Bridge.CallOpts, depositKey) +} + // IsVaultTrusted is a free data retrieval call binding the contract method 0xe53c0b55. // // Solidity: function isVaultTrusted(address vault) view returns(bool) @@ -1204,6 +1297,48 @@ func (_Bridge *BridgeTransactorSession) Initialize(_bank common.Address, _relay return _Bridge.Contract.Initialize(&_Bridge.TransactOpts, _bank, _relay, _treasury, _ecdsaWalletRegistry, _reimbursementPool, _txProofDifficultyFactor) } +// InitializeV2FixVaultZeroDeposit is a paid mutator transaction binding the contract method 0x456ffee0. +// +// Solidity: function initializeV2_FixVaultZeroDeposit() returns() +func (_Bridge *BridgeTransactor) InitializeV2FixVaultZeroDeposit(opts *bind.TransactOpts) (*types.Transaction, error) { + return _Bridge.contract.Transact(opts, "initializeV2_FixVaultZeroDeposit") +} + +// InitializeV2FixVaultZeroDeposit is a paid mutator transaction binding the contract method 0x456ffee0. +// +// Solidity: function initializeV2_FixVaultZeroDeposit() returns() +func (_Bridge *BridgeSession) InitializeV2FixVaultZeroDeposit() (*types.Transaction, error) { + return _Bridge.Contract.InitializeV2FixVaultZeroDeposit(&_Bridge.TransactOpts) +} + +// InitializeV2FixVaultZeroDeposit is a paid mutator transaction binding the contract method 0x456ffee0. +// +// Solidity: function initializeV2_FixVaultZeroDeposit() returns() +func (_Bridge *BridgeTransactorSession) InitializeV2FixVaultZeroDeposit() (*types.Transaction, error) { + return _Bridge.Contract.InitializeV2FixVaultZeroDeposit(&_Bridge.TransactOpts) +} + +// InitializeV5RepairRebateStaking is a paid mutator transaction binding the contract method 0x1ebf670d. +// +// Solidity: function initializeV5_RepairRebateStaking(address newRebateStaking) returns() +func (_Bridge *BridgeTransactor) InitializeV5RepairRebateStaking(opts *bind.TransactOpts, newRebateStaking common.Address) (*types.Transaction, error) { + return _Bridge.contract.Transact(opts, "initializeV5_RepairRebateStaking", newRebateStaking) +} + +// InitializeV5RepairRebateStaking is a paid mutator transaction binding the contract method 0x1ebf670d. +// +// Solidity: function initializeV5_RepairRebateStaking(address newRebateStaking) returns() +func (_Bridge *BridgeSession) InitializeV5RepairRebateStaking(newRebateStaking common.Address) (*types.Transaction, error) { + return _Bridge.Contract.InitializeV5RepairRebateStaking(&_Bridge.TransactOpts, newRebateStaking) +} + +// InitializeV5RepairRebateStaking is a paid mutator transaction binding the contract method 0x1ebf670d. +// +// Solidity: function initializeV5_RepairRebateStaking(address newRebateStaking) returns() +func (_Bridge *BridgeTransactorSession) InitializeV5RepairRebateStaking(newRebateStaking common.Address) (*types.Transaction, error) { + return _Bridge.Contract.InitializeV5RepairRebateStaking(&_Bridge.TransactOpts, newRebateStaking) +} + // NotifyFraudChallengeDefeatTimeout is a paid mutator transaction binding the contract method 0x79fc4eb3. // // Solidity: function notifyFraudChallengeDefeatTimeout(bytes walletPublicKey, uint32[] walletMembersIDs, bytes preimageSha256) returns() @@ -1498,6 +1633,27 @@ func (_Bridge *BridgeTransactorSession) RevealDepositWithExtraData(fundingTx Bit return _Bridge.Contract.RevealDepositWithExtraData(&_Bridge.TransactOpts, fundingTx, reveal, extraData) } +// SetRebateStaking is a paid mutator transaction binding the contract method 0xca73c462. +// +// Solidity: function setRebateStaking(address rebateStaking) returns() +func (_Bridge *BridgeTransactor) SetRebateStaking(opts *bind.TransactOpts, rebateStaking common.Address) (*types.Transaction, error) { + return _Bridge.contract.Transact(opts, "setRebateStaking", rebateStaking) +} + +// SetRebateStaking is a paid mutator transaction binding the contract method 0xca73c462. +// +// Solidity: function setRebateStaking(address rebateStaking) returns() +func (_Bridge *BridgeSession) SetRebateStaking(rebateStaking common.Address) (*types.Transaction, error) { + return _Bridge.Contract.SetRebateStaking(&_Bridge.TransactOpts, rebateStaking) +} + +// SetRebateStaking is a paid mutator transaction binding the contract method 0xca73c462. +// +// Solidity: function setRebateStaking(address rebateStaking) returns() +func (_Bridge *BridgeTransactorSession) SetRebateStaking(rebateStaking common.Address) (*types.Transaction, error) { + return _Bridge.Contract.SetRebateStaking(&_Bridge.TransactOpts, rebateStaking) +} + // SetRedemptionWatchtower is a paid mutator transaction binding the contract method 0xbe26ebad. // // Solidity: function setRedemptionWatchtower(address redemptionWatchtower) returns() @@ -1519,6 +1675,27 @@ func (_Bridge *BridgeTransactorSession) SetRedemptionWatchtower(redemptionWatcht return _Bridge.Contract.SetRedemptionWatchtower(&_Bridge.TransactOpts, redemptionWatchtower) } +// SetReservationRouter is a paid mutator transaction binding the contract method 0xd394a4d3. +// +// Solidity: function setReservationRouter(address _reservationRouter) returns() +func (_Bridge *BridgeTransactor) SetReservationRouter(opts *bind.TransactOpts, _reservationRouter common.Address) (*types.Transaction, error) { + return _Bridge.contract.Transact(opts, "setReservationRouter", _reservationRouter) +} + +// SetReservationRouter is a paid mutator transaction binding the contract method 0xd394a4d3. +// +// Solidity: function setReservationRouter(address _reservationRouter) returns() +func (_Bridge *BridgeSession) SetReservationRouter(_reservationRouter common.Address) (*types.Transaction, error) { + return _Bridge.Contract.SetReservationRouter(&_Bridge.TransactOpts, _reservationRouter) +} + +// SetReservationRouter is a paid mutator transaction binding the contract method 0xd394a4d3. +// +// Solidity: function setReservationRouter(address _reservationRouter) returns() +func (_Bridge *BridgeTransactorSession) SetReservationRouter(_reservationRouter common.Address) (*types.Transaction, error) { + return _Bridge.Contract.SetReservationRouter(&_Bridge.TransactOpts, _reservationRouter) +} + // SetSpvMaintainerStatus is a paid mutator transaction binding the contract method 0x5f2b2d0d. // // Solidity: function setSpvMaintainerStatus(address spvMaintainer, bool isTrusted) returns() @@ -1834,6 +2011,27 @@ func (_Bridge *BridgeTransactorSession) UpdateWalletParameters(walletCreationPer return _Bridge.Contract.UpdateWalletParameters(&_Bridge.TransactOpts, walletCreationPeriod, walletCreationMinBtcBalance, walletCreationMaxBtcBalance, walletClosureMinBtcBalance, walletMaxAge, walletMaxBtcTransfer, walletClosingPeriod) } +// Fallback is a paid mutator transaction binding the contract fallback function. +// +// Solidity: fallback() payable returns() +func (_Bridge *BridgeTransactor) Fallback(opts *bind.TransactOpts, calldata []byte) (*types.Transaction, error) { + return _Bridge.contract.RawTransact(opts, calldata) +} + +// Fallback is a paid mutator transaction binding the contract fallback function. +// +// Solidity: fallback() payable returns() +func (_Bridge *BridgeSession) Fallback(calldata []byte) (*types.Transaction, error) { + return _Bridge.Contract.Fallback(&_Bridge.TransactOpts, calldata) +} + +// Fallback is a paid mutator transaction binding the contract fallback function. +// +// Solidity: fallback() payable returns() +func (_Bridge *BridgeTransactorSession) Fallback(calldata []byte) (*types.Transaction, error) { + return _Bridge.Contract.Fallback(&_Bridge.TransactOpts, calldata) +} + // BridgeDepositParametersUpdatedIterator is returned from FilterDepositParametersUpdated and is used to iterate over the raw logs and unpacked data for DepositParametersUpdated events raised by the Bridge contract. type BridgeDepositParametersUpdatedIterator struct { Event *BridgeDepositParametersUpdated // Event containing the contract specifics and raw log @@ -2133,6 +2331,151 @@ func (_Bridge *BridgeFilterer) ParseDepositRevealed(log types.Log) (*BridgeDepos return event, nil } +// BridgeDepositVaultFixedIterator is returned from FilterDepositVaultFixed and is used to iterate over the raw logs and unpacked data for DepositVaultFixed events raised by the Bridge contract. +type BridgeDepositVaultFixedIterator struct { + Event *BridgeDepositVaultFixed // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *BridgeDepositVaultFixedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(BridgeDepositVaultFixed) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(BridgeDepositVaultFixed) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *BridgeDepositVaultFixedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *BridgeDepositVaultFixedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// BridgeDepositVaultFixed represents a DepositVaultFixed event raised by the Bridge contract. +type BridgeDepositVaultFixed struct { + DepositKey *big.Int + NewVault common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterDepositVaultFixed is a free log retrieval operation binding the contract event 0x6851c9da8832e374b52353e89727e1f35bd403bf45bc19c889e416393bd53973. +// +// Solidity: event DepositVaultFixed(uint256 indexed depositKey, address newVault) +func (_Bridge *BridgeFilterer) FilterDepositVaultFixed(opts *bind.FilterOpts, depositKey []*big.Int) (*BridgeDepositVaultFixedIterator, error) { + + var depositKeyRule []interface{} + for _, depositKeyItem := range depositKey { + depositKeyRule = append(depositKeyRule, depositKeyItem) + } + + logs, sub, err := _Bridge.contract.FilterLogs(opts, "DepositVaultFixed", depositKeyRule) + if err != nil { + return nil, err + } + return &BridgeDepositVaultFixedIterator{contract: _Bridge.contract, event: "DepositVaultFixed", logs: logs, sub: sub}, nil +} + +// WatchDepositVaultFixed is a free log subscription operation binding the contract event 0x6851c9da8832e374b52353e89727e1f35bd403bf45bc19c889e416393bd53973. +// +// Solidity: event DepositVaultFixed(uint256 indexed depositKey, address newVault) +func (_Bridge *BridgeFilterer) WatchDepositVaultFixed(opts *bind.WatchOpts, sink chan<- *BridgeDepositVaultFixed, depositKey []*big.Int) (event.Subscription, error) { + + var depositKeyRule []interface{} + for _, depositKeyItem := range depositKey { + depositKeyRule = append(depositKeyRule, depositKeyItem) + } + + logs, sub, err := _Bridge.contract.WatchLogs(opts, "DepositVaultFixed", depositKeyRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(BridgeDepositVaultFixed) + if err := _Bridge.contract.UnpackLog(event, "DepositVaultFixed", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseDepositVaultFixed is a log parse operation binding the contract event 0x6851c9da8832e374b52353e89727e1f35bd403bf45bc19c889e416393bd53973. +// +// Solidity: event DepositVaultFixed(uint256 indexed depositKey, address newVault) +func (_Bridge *BridgeFilterer) ParseDepositVaultFixed(log types.Log) (*BridgeDepositVaultFixed, error) { + event := new(BridgeDepositVaultFixed) + if err := _Bridge.contract.UnpackLog(event, "DepositVaultFixed", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + // BridgeDepositsSweptIterator is returned from FilterDepositsSwept and is used to iterate over the raw logs and unpacked data for DepositsSwept events raised by the Bridge contract. type BridgeDepositsSweptIterator struct { Event *BridgeDepositsSwept // Event containing the contract specifics and raw log @@ -4556,6 +4899,275 @@ func (_Bridge *BridgeFilterer) ParseNewWalletRequested(log types.Log) (*BridgeNe return event, nil } +// BridgeRebateStakingRepairedIterator is returned from FilterRebateStakingRepaired and is used to iterate over the raw logs and unpacked data for RebateStakingRepaired events raised by the Bridge contract. +type BridgeRebateStakingRepairedIterator struct { + Event *BridgeRebateStakingRepaired // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *BridgeRebateStakingRepairedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(BridgeRebateStakingRepaired) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(BridgeRebateStakingRepaired) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *BridgeRebateStakingRepairedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *BridgeRebateStakingRepairedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// BridgeRebateStakingRepaired represents a RebateStakingRepaired event raised by the Bridge contract. +type BridgeRebateStakingRepaired struct { + OldRebateStaking common.Address + NewRebateStaking common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterRebateStakingRepaired is a free log retrieval operation binding the contract event 0x3e9cf92a8b1e7b429a80cef0378c64004ed9e723a896f58a2bb02005bb34d8c5. +// +// Solidity: event RebateStakingRepaired(address oldRebateStaking, address newRebateStaking) +func (_Bridge *BridgeFilterer) FilterRebateStakingRepaired(opts *bind.FilterOpts) (*BridgeRebateStakingRepairedIterator, error) { + + logs, sub, err := _Bridge.contract.FilterLogs(opts, "RebateStakingRepaired") + if err != nil { + return nil, err + } + return &BridgeRebateStakingRepairedIterator{contract: _Bridge.contract, event: "RebateStakingRepaired", logs: logs, sub: sub}, nil +} + +// WatchRebateStakingRepaired is a free log subscription operation binding the contract event 0x3e9cf92a8b1e7b429a80cef0378c64004ed9e723a896f58a2bb02005bb34d8c5. +// +// Solidity: event RebateStakingRepaired(address oldRebateStaking, address newRebateStaking) +func (_Bridge *BridgeFilterer) WatchRebateStakingRepaired(opts *bind.WatchOpts, sink chan<- *BridgeRebateStakingRepaired) (event.Subscription, error) { + + logs, sub, err := _Bridge.contract.WatchLogs(opts, "RebateStakingRepaired") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(BridgeRebateStakingRepaired) + if err := _Bridge.contract.UnpackLog(event, "RebateStakingRepaired", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseRebateStakingRepaired is a log parse operation binding the contract event 0x3e9cf92a8b1e7b429a80cef0378c64004ed9e723a896f58a2bb02005bb34d8c5. +// +// Solidity: event RebateStakingRepaired(address oldRebateStaking, address newRebateStaking) +func (_Bridge *BridgeFilterer) ParseRebateStakingRepaired(log types.Log) (*BridgeRebateStakingRepaired, error) { + event := new(BridgeRebateStakingRepaired) + if err := _Bridge.contract.UnpackLog(event, "RebateStakingRepaired", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// BridgeRebateStakingSetIterator is returned from FilterRebateStakingSet and is used to iterate over the raw logs and unpacked data for RebateStakingSet events raised by the Bridge contract. +type BridgeRebateStakingSetIterator struct { + Event *BridgeRebateStakingSet // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *BridgeRebateStakingSetIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(BridgeRebateStakingSet) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(BridgeRebateStakingSet) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *BridgeRebateStakingSetIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *BridgeRebateStakingSetIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// BridgeRebateStakingSet represents a RebateStakingSet event raised by the Bridge contract. +type BridgeRebateStakingSet struct { + RebateStaking common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterRebateStakingSet is a free log retrieval operation binding the contract event 0xd1d9d4e9f516cb983e81d2a124ec97cb8d4ff00637f2a7f3229eadbed84e2df6. +// +// Solidity: event RebateStakingSet(address rebateStaking) +func (_Bridge *BridgeFilterer) FilterRebateStakingSet(opts *bind.FilterOpts) (*BridgeRebateStakingSetIterator, error) { + + logs, sub, err := _Bridge.contract.FilterLogs(opts, "RebateStakingSet") + if err != nil { + return nil, err + } + return &BridgeRebateStakingSetIterator{contract: _Bridge.contract, event: "RebateStakingSet", logs: logs, sub: sub}, nil +} + +// WatchRebateStakingSet is a free log subscription operation binding the contract event 0xd1d9d4e9f516cb983e81d2a124ec97cb8d4ff00637f2a7f3229eadbed84e2df6. +// +// Solidity: event RebateStakingSet(address rebateStaking) +func (_Bridge *BridgeFilterer) WatchRebateStakingSet(opts *bind.WatchOpts, sink chan<- *BridgeRebateStakingSet) (event.Subscription, error) { + + logs, sub, err := _Bridge.contract.WatchLogs(opts, "RebateStakingSet") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(BridgeRebateStakingSet) + if err := _Bridge.contract.UnpackLog(event, "RebateStakingSet", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseRebateStakingSet is a log parse operation binding the contract event 0xd1d9d4e9f516cb983e81d2a124ec97cb8d4ff00637f2a7f3229eadbed84e2df6. +// +// Solidity: event RebateStakingSet(address rebateStaking) +func (_Bridge *BridgeFilterer) ParseRebateStakingSet(log types.Log) (*BridgeRebateStakingSet, error) { + event := new(BridgeRebateStakingSet) + if err := _Bridge.contract.UnpackLog(event, "RebateStakingSet", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + // BridgeRedemptionParametersUpdatedIterator is returned from FilterRedemptionParametersUpdated and is used to iterate over the raw logs and unpacked data for RedemptionParametersUpdated events raised by the Bridge contract. type BridgeRedemptionParametersUpdatedIterator struct { Event *BridgeRedemptionParametersUpdated // Event containing the contract specifics and raw log diff --git a/pkg/chain/ethereum/tbtc/gen/abi/LightRelay.go b/pkg/chain/ethereum/tbtc/gen/abi/LightRelay.go index 971aae98b0..a0196e92c3 100644 --- a/pkg/chain/ethereum/tbtc/gen/abi/LightRelay.go +++ b/pkg/chain/ethereum/tbtc/gen/abi/LightRelay.go @@ -26,6 +26,7 @@ var ( _ = common.Big1 _ = types.BloomLookup _ = event.NewSubscription + _ = abi.ConvertType ) // LightRelayMetaData contains all meta data concerning the LightRelay contract. @@ -134,11 +135,11 @@ func NewLightRelayFilterer(address common.Address, filterer bind.ContractFiltere // bindLightRelay binds a generic wrapper to an already deployed contract. func bindLightRelay(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { - parsed, err := abi.JSON(strings.NewReader(LightRelayABI)) + parsed, err := LightRelayMetaData.GetAbi() if err != nil { return nil, err } - return bind.NewBoundContract(address, parsed, caller, transactor, filterer), nil + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil } // Call invokes the (constant) contract method with params as input values and diff --git a/pkg/chain/ethereum/tbtc/gen/abi/LightRelayMaintainerProxy.go b/pkg/chain/ethereum/tbtc/gen/abi/LightRelayMaintainerProxy.go index e90d8b1efa..910dedb510 100644 --- a/pkg/chain/ethereum/tbtc/gen/abi/LightRelayMaintainerProxy.go +++ b/pkg/chain/ethereum/tbtc/gen/abi/LightRelayMaintainerProxy.go @@ -26,6 +26,7 @@ var ( _ = common.Big1 _ = types.BloomLookup _ = event.NewSubscription + _ = abi.ConvertType ) // LightRelayMaintainerProxyMetaData contains all meta data concerning the LightRelayMaintainerProxy contract. @@ -134,11 +135,11 @@ func NewLightRelayMaintainerProxyFilterer(address common.Address, filterer bind. // bindLightRelayMaintainerProxy binds a generic wrapper to an already deployed contract. func bindLightRelayMaintainerProxy(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { - parsed, err := abi.JSON(strings.NewReader(LightRelayMaintainerProxyABI)) + parsed, err := LightRelayMaintainerProxyMetaData.GetAbi() if err != nil { return nil, err } - return bind.NewBoundContract(address, parsed, caller, transactor, filterer), nil + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil } // Call invokes the (constant) contract method with params as input values and diff --git a/pkg/chain/ethereum/tbtc/gen/abi/MaintainerProxy.go b/pkg/chain/ethereum/tbtc/gen/abi/MaintainerProxy.go index d76fa92d2c..ae37378e5d 100644 --- a/pkg/chain/ethereum/tbtc/gen/abi/MaintainerProxy.go +++ b/pkg/chain/ethereum/tbtc/gen/abi/MaintainerProxy.go @@ -26,6 +26,7 @@ var ( _ = common.Big1 _ = types.BloomLookup _ = event.NewSubscription + _ = abi.ConvertType ) // BitcoinTxInfo3 is an auto generated low-level Go binding around an user-defined struct. @@ -158,11 +159,11 @@ func NewMaintainerProxyFilterer(address common.Address, filterer bind.ContractFi // bindMaintainerProxy binds a generic wrapper to an already deployed contract. func bindMaintainerProxy(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { - parsed, err := abi.JSON(strings.NewReader(MaintainerProxyABI)) + parsed, err := MaintainerProxyMetaData.GetAbi() if err != nil { return nil, err } - return bind.NewBoundContract(address, parsed, caller, transactor, filterer), nil + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil } // Call invokes the (constant) contract method with params as input values and diff --git a/pkg/chain/ethereum/tbtc/gen/abi/RedemptionWatchtower.go b/pkg/chain/ethereum/tbtc/gen/abi/RedemptionWatchtower.go index 30c4b7ca59..8d48d59321 100644 --- a/pkg/chain/ethereum/tbtc/gen/abi/RedemptionWatchtower.go +++ b/pkg/chain/ethereum/tbtc/gen/abi/RedemptionWatchtower.go @@ -31,7 +31,7 @@ var ( // RedemptionWatchtowerMetaData contains all meta data concerning the RedemptionWatchtower contract. var RedemptionWatchtowerMetaData = &bind.MetaData{ - ABI: "[{\"inputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"}],\"name\":\"Banned\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"guardian\",\"type\":\"address\"}],\"name\":\"GuardianAdded\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"guardian\",\"type\":\"address\"}],\"name\":\"GuardianRemoved\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint8\",\"name\":\"version\",\"type\":\"uint8\"}],\"name\":\"Initialized\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"guardian\",\"type\":\"address\"}],\"name\":\"ObjectionRaised\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"previousOwner\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"newOwner\",\"type\":\"address\"}],\"name\":\"OwnershipTransferred\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"}],\"name\":\"Unbanned\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"}],\"name\":\"VetoFinalized\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"}],\"name\":\"VetoPeriodCheckOmitted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"amount\",\"type\":\"uint64\"}],\"name\":\"VetoedFundsWithdrawn\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"disabledAt\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"executor\",\"type\":\"address\"}],\"name\":\"WatchtowerDisabled\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"enabledAt\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"manager\",\"type\":\"address\"}],\"name\":\"WatchtowerEnabled\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"watchtowerLifetime\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"vetoPenaltyFeeDivisor\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"vetoFreezePeriod\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"defaultDelay\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"levelOneDelay\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"levelTwoDelay\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"waivedAmountLimit\",\"type\":\"uint64\"}],\"name\":\"WatchtowerParametersUpdated\",\"type\":\"event\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"guardian\",\"type\":\"address\"}],\"name\":\"addGuardian\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"bank\",\"outputs\":[{\"internalType\":\"contractBank\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"bridge\",\"outputs\":[{\"internalType\":\"contractBridge\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"defaultDelay\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"disableWatchtower\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_manager\",\"type\":\"address\"},{\"internalType\":\"address[]\",\"name\":\"_guardians\",\"type\":\"address[]\"}],\"name\":\"enableWatchtower\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"}],\"name\":\"getRedemptionDelay\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"contractBridge\",\"name\":\"_bridge\",\"type\":\"address\"}],\"name\":\"initialize\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"name\":\"isBanned\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"name\":\"isGuardian\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"},{\"internalType\":\"address\",\"name\":\"balanceOwner\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"}],\"name\":\"isSafeRedemption\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"levelOneDelay\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"levelTwoDelay\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"manager\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"name\":\"objections\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"owner\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"}],\"name\":\"raiseObjection\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"guardian\",\"type\":\"address\"}],\"name\":\"removeGuardian\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"renounceOwnership\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"newOwner\",\"type\":\"address\"}],\"name\":\"transferOwnership\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"}],\"name\":\"unban\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint32\",\"name\":\"_watchtowerLifetime\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"_vetoPenaltyFeeDivisor\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"_vetoFreezePeriod\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"_defaultDelay\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"_levelOneDelay\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"_levelTwoDelay\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"_waivedAmountLimit\",\"type\":\"uint64\"}],\"name\":\"updateWatchtowerParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"vetoFreezePeriod\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"vetoPenaltyFeeDivisor\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"\",\"type\":\"uint64\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"name\":\"vetoProposals\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"withdrawableAmount\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"finalizedAt\",\"type\":\"uint32\"},{\"internalType\":\"uint8\",\"name\":\"objectionsCount\",\"type\":\"uint8\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"waivedAmountLimit\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"\",\"type\":\"uint64\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"watchtowerDisabledAt\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"watchtowerEnabledAt\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"watchtowerLifetime\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"}],\"name\":\"withdrawVetoedFunds\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"}]", + ABI: "[{\"inputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"}],\"name\":\"Banned\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"guardian\",\"type\":\"address\"}],\"name\":\"GuardianAdded\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"guardian\",\"type\":\"address\"}],\"name\":\"GuardianRemoved\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint8\",\"name\":\"version\",\"type\":\"uint8\"}],\"name\":\"Initialized\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"guardian\",\"type\":\"address\"}],\"name\":\"ObjectionRaised\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"previousOwner\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"newOwner\",\"type\":\"address\"}],\"name\":\"OwnershipTransferred\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"}],\"name\":\"Unbanned\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"}],\"name\":\"VetoFinalized\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"}],\"name\":\"VetoPeriodCheckOmitted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"amount\",\"type\":\"uint64\"}],\"name\":\"VetoedFundsWithdrawn\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"disabledAt\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"executor\",\"type\":\"address\"}],\"name\":\"WatchtowerDisabled\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"enabledAt\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"manager\",\"type\":\"address\"}],\"name\":\"WatchtowerEnabled\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"watchtowerLifetime\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"vetoPenaltyFeeDivisor\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"vetoFreezePeriod\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"defaultDelay\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"levelOneDelay\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"levelTwoDelay\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"waivedAmountLimit\",\"type\":\"uint64\"}],\"name\":\"WatchtowerParametersUpdated\",\"type\":\"event\"},{\"inputs\":[],\"name\":\"REQUIRED_OBJECTIONS_COUNT\",\"outputs\":[{\"internalType\":\"uint8\",\"name\":\"\",\"type\":\"uint8\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"guardian\",\"type\":\"address\"}],\"name\":\"addGuardian\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"bank\",\"outputs\":[{\"internalType\":\"contractBank\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"bridge\",\"outputs\":[{\"internalType\":\"contractBridge\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"defaultDelay\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"disableWatchtower\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_manager\",\"type\":\"address\"},{\"internalType\":\"address[]\",\"name\":\"_guardians\",\"type\":\"address[]\"}],\"name\":\"enableWatchtower\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"}],\"name\":\"getRedemptionDelay\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"contractBridge\",\"name\":\"_bridge\",\"type\":\"address\"}],\"name\":\"initialize\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"name\":\"isBanned\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"name\":\"isGuardian\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"},{\"internalType\":\"address\",\"name\":\"balanceOwner\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"}],\"name\":\"isSafeRedemption\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"levelOneDelay\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"levelTwoDelay\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"manager\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"name\":\"objections\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"owner\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes\",\"name\":\"redeemerOutputScript\",\"type\":\"bytes\"}],\"name\":\"raiseObjection\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"guardian\",\"type\":\"address\"}],\"name\":\"removeGuardian\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"renounceOwnership\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"newOwner\",\"type\":\"address\"}],\"name\":\"transferOwnership\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"}],\"name\":\"unban\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint32\",\"name\":\"_watchtowerLifetime\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"_vetoPenaltyFeeDivisor\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"_vetoFreezePeriod\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"_defaultDelay\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"_levelOneDelay\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"_levelTwoDelay\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"_waivedAmountLimit\",\"type\":\"uint64\"}],\"name\":\"updateWatchtowerParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"vetoFreezePeriod\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"vetoPenaltyFeeDivisor\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"\",\"type\":\"uint64\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"name\":\"vetoProposals\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"withdrawableAmount\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"finalizedAt\",\"type\":\"uint32\"},{\"internalType\":\"uint8\",\"name\":\"objectionsCount\",\"type\":\"uint8\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"waivedAmountLimit\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"\",\"type\":\"uint64\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"watchtowerDisabledAt\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"watchtowerEnabledAt\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"watchtowerLifetime\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"redemptionKey\",\"type\":\"uint256\"}],\"name\":\"withdrawVetoedFunds\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"}]", } // RedemptionWatchtowerABI is the input ABI used to generate the binding from. @@ -180,6 +180,37 @@ func (_RedemptionWatchtower *RedemptionWatchtowerTransactorRaw) Transact(opts *b return _RedemptionWatchtower.Contract.contract.Transact(opts, method, params...) } +// REQUIREDOBJECTIONSCOUNT is a free data retrieval call binding the contract method 0x7a497647. +// +// Solidity: function REQUIRED_OBJECTIONS_COUNT() view returns(uint8) +func (_RedemptionWatchtower *RedemptionWatchtowerCaller) REQUIREDOBJECTIONSCOUNT(opts *bind.CallOpts) (uint8, error) { + var out []interface{} + err := _RedemptionWatchtower.contract.Call(opts, &out, "REQUIRED_OBJECTIONS_COUNT") + + if err != nil { + return *new(uint8), err + } + + out0 := *abi.ConvertType(out[0], new(uint8)).(*uint8) + + return out0, err + +} + +// REQUIREDOBJECTIONSCOUNT is a free data retrieval call binding the contract method 0x7a497647. +// +// Solidity: function REQUIRED_OBJECTIONS_COUNT() view returns(uint8) +func (_RedemptionWatchtower *RedemptionWatchtowerSession) REQUIREDOBJECTIONSCOUNT() (uint8, error) { + return _RedemptionWatchtower.Contract.REQUIREDOBJECTIONSCOUNT(&_RedemptionWatchtower.CallOpts) +} + +// REQUIREDOBJECTIONSCOUNT is a free data retrieval call binding the contract method 0x7a497647. +// +// Solidity: function REQUIRED_OBJECTIONS_COUNT() view returns(uint8) +func (_RedemptionWatchtower *RedemptionWatchtowerCallerSession) REQUIREDOBJECTIONSCOUNT() (uint8, error) { + return _RedemptionWatchtower.Contract.REQUIREDOBJECTIONSCOUNT(&_RedemptionWatchtower.CallOpts) +} + // Bank is a free data retrieval call binding the contract method 0x76cdb03b. // // Solidity: function bank() view returns(address) diff --git a/pkg/chain/ethereum/tbtc/gen/abi/ReservationRouter.go b/pkg/chain/ethereum/tbtc/gen/abi/ReservationRouter.go new file mode 100644 index 0000000000..8f4ac35f2e --- /dev/null +++ b/pkg/chain/ethereum/tbtc/gen/abi/ReservationRouter.go @@ -0,0 +1,3270 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package abi + +import ( + "errors" + "math/big" + "strings" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = errors.New + _ = big.NewInt + _ = strings.NewReader + _ = ethereum.NotFound + _ = bind.Bind + _ = common.Big1 + _ = types.BloomLookup + _ = event.NewSubscription + _ = abi.ConvertType +) + +// BitcoinTxInfo4 is an auto generated low-level Go binding around an user-defined struct. +type BitcoinTxInfo4 struct { + Version [4]byte + InputVector []byte + OutputVector []byte + Locktime [4]byte +} + +// BitcoinTxProof3 is an auto generated low-level Go binding around an user-defined struct. +type BitcoinTxProof3 struct { + MerkleProof []byte + TxIndexInBlock *big.Int + BitcoinHeaders []byte + CoinbasePreimage [32]byte + CoinbaseProof []byte +} + +// BitcoinTxUTXO4 is an auto generated low-level Go binding around an user-defined struct. +type BitcoinTxUTXO4 struct { + TxHash [32]byte + TxOutputIndex uint32 + TxOutputValue uint64 +} + +// ReservationReservationAction is an auto generated low-level Go binding around an user-defined struct. +type ReservationReservationAction struct { + TargetWalletPubKeyHash [20]byte + RequestedAt uint32 + TimeoutAt uint32 + TxMaxFee uint64 + ActionType uint8 + State uint8 + FeePaid bool + Redeemer common.Address + Amount uint64 + ActionDataHash [32]byte + SourceAnchorUtxoHash [32]byte + UsedRetryCredit bool + WatchtowerDefaultDelay uint32 + WatchtowerLevelOneDelay uint32 + WatchtowerLevelTwoDelay uint32 + IsPartial bool + RetryCreditSourceNonce uint64 +} + +// ReservationReservationRequest is an auto generated low-level Go binding around an user-defined struct. +type ReservationReservationRequest struct { + Owner common.Address + MintedAmount uint64 + AcceptedAt uint32 + WalletPubKeyHash [20]byte + AnchorAmount uint64 + ExpiresAt uint32 + AnchorTxHash [32]byte + AnchorTxOutputIndex uint32 + State uint8 + RequestNonce uint64 + RetryCredit bool + DissolutionEligibleAt uint32 + CumulativeReanchorFee uint64 +} + +// ReservationRouterMetaData contains all meta data concerning the ReservationRouter contract. +var ReservationRouterMetaData = &bind.MetaData{ + ABI: "[{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"oldGovernance\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"newGovernance\",\"type\":\"address\"}],\"name\":\"GovernanceTransferred\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint8\",\"name\":\"version\",\"type\":\"uint8\"}],\"name\":\"Initialized\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"uint256\",\"name\":\"reservationKey\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"requestNonce\",\"type\":\"uint64\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"depositAmount\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"txMaxFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"timeoutAt\",\"type\":\"uint32\"}],\"name\":\"ReservationAcceptanceRequested\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"uint256\",\"name\":\"reservationKey\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"requestNonce\",\"type\":\"uint64\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"owner\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"anchorTxHash\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"anchorAmount\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"expiresAt\",\"type\":\"uint32\"}],\"name\":\"ReservationAccepted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"uint256\",\"name\":\"reservationKey\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"requestNonce\",\"type\":\"uint64\"}],\"name\":\"ReservationActionSuperseded\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"uint256\",\"name\":\"reservationKey\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"requestNonce\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"enumReservation.ActionType\",\"name\":\"actionType\",\"type\":\"uint8\"}],\"name\":\"ReservationActionTimedOut\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"maxReservationsAmountPerWallet\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"reservationMaxSingleAmount\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"maxActiveReservations\",\"type\":\"uint32\"}],\"name\":\"ReservationCapsUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"uint256\",\"name\":\"reservationKey\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"requestNonce\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"enumReservation.ActionType\",\"name\":\"actionType\",\"type\":\"uint8\"}],\"name\":\"ReservationLateSettled\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"reservationMinAmount\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"reservationTxMaxFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"reservationTermSeconds\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"reservationDissolutionDelay\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"reservationMaxTotalAmount\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"maxReservationsPerWallet\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"reservationActionTimeout\",\"type\":\"uint32\"},{\"indexed\":false,\"internalType\":\"uint32\",\"name\":\"reservationRenewalWindowSeconds\",\"type\":\"uint32\"}],\"name\":\"ReservationParametersUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"uint256\",\"name\":\"reservationKey\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"requestNonce\",\"type\":\"uint64\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"sourceWalletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"targetWalletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"txMaxFee\",\"type\":\"uint64\"}],\"name\":\"ReservationReanchorRequested\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"uint256\",\"name\":\"reservationKey\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"requestNonce\",\"type\":\"uint64\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"newWalletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"newAnchorTxHash\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"newAnchorAmount\",\"type\":\"uint64\"}],\"name\":\"ReservationReanchored\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"uint256\",\"name\":\"reservationKey\",\"type\":\"uint256\"}],\"name\":\"ReservationRetryCreditMinted\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"reservationRouter\",\"type\":\"address\"}],\"name\":\"ReservationRouterSet\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"uint256\",\"name\":\"reservationKey\",\"type\":\"uint256\"},{\"indexed\":true,\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"owner\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"anchorAmount\",\"type\":\"uint64\"}],\"name\":\"ReservationStranded\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"reservationVault\",\"type\":\"address\"}],\"name\":\"ReservationVaultUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"uint256\",\"name\":\"depositKey\",\"type\":\"uint256\"}],\"name\":\"ReservedDepositMarkedStale\",\"type\":\"event\"},{\"inputs\":[],\"name\":\"activeReservationsCount\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"count\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"maxActive\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"governance\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"reservationKey\",\"type\":\"uint256\"},{\"internalType\":\"uint32[]\",\"name\":\"walletMembersIDs\",\"type\":\"uint32[]\"}],\"name\":\"notifyReservationActionTimeout\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"reservationKey\",\"type\":\"uint256\"}],\"name\":\"notifyReservationStranded\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"depositKey\",\"type\":\"uint256\"}],\"name\":\"notifyStaleReservedDeposit\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"pendingReservedDeposits\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"\",\"type\":\"uint64\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"reservationKey\",\"type\":\"uint256\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"requestReservationAcceptance\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"reservationKey\",\"type\":\"uint256\"},{\"internalType\":\"bytes20\",\"name\":\"targetWalletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"requestReservationReanchor\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"reservationKey\",\"type\":\"uint256\"},{\"internalType\":\"uint64\",\"name\":\"requestNonce\",\"type\":\"uint64\"}],\"name\":\"reservationActions\",\"outputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"targetWalletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"uint32\",\"name\":\"requestedAt\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"timeoutAt\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"enumReservation.ActionType\",\"name\":\"actionType\",\"type\":\"uint8\"},{\"internalType\":\"enumReservation.ActionState\",\"name\":\"state\",\"type\":\"uint8\"},{\"internalType\":\"bool\",\"name\":\"feePaid\",\"type\":\"bool\"},{\"internalType\":\"address\",\"name\":\"redeemer\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"amount\",\"type\":\"uint64\"},{\"internalType\":\"bytes32\",\"name\":\"actionDataHash\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"sourceAnchorUtxoHash\",\"type\":\"bytes32\"},{\"internalType\":\"bool\",\"name\":\"usedRetryCredit\",\"type\":\"bool\"},{\"internalType\":\"uint32\",\"name\":\"watchtowerDefaultDelay\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"watchtowerLevelOneDelay\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"watchtowerLevelTwoDelay\",\"type\":\"uint32\"},{\"internalType\":\"bool\",\"name\":\"isPartial\",\"type\":\"bool\"},{\"internalType\":\"uint64\",\"name\":\"retryCreditSourceNonce\",\"type\":\"uint64\"}],\"internalType\":\"structReservation.ReservationAction\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"anchorTxHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"anchorTxOutputIndex\",\"type\":\"uint32\"}],\"name\":\"reservationByAnchorUtxo\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"reservationCaps\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"maxReservationsAmountPerWallet\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"reservationMaxSingleAmount\",\"type\":\"uint64\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"reservationParameters\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"reservationVault\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"reservationMinAmount\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"reservationTxMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"reservationTermSeconds\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"reservationDissolutionDelay\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"reservationMaxTotalAmount\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"reservationTotalAmount\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"maxReservationsPerWallet\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"reservationActionTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"reservationRenewalWindowSeconds\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"reservationRouter\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"reservationKey\",\"type\":\"uint256\"}],\"name\":\"reservations\",\"outputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"owner\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"mintedAmount\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"acceptedAt\",\"type\":\"uint32\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"uint64\",\"name\":\"anchorAmount\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"expiresAt\",\"type\":\"uint32\"},{\"internalType\":\"bytes32\",\"name\":\"anchorTxHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"anchorTxOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"enumReservation.ReservationState\",\"name\":\"state\",\"type\":\"uint8\"},{\"internalType\":\"uint64\",\"name\":\"requestNonce\",\"type\":\"uint64\"},{\"internalType\":\"bool\",\"name\":\"retryCredit\",\"type\":\"bool\"},{\"internalType\":\"uint32\",\"name\":\"dissolutionEligibleAt\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"cumulativeReanchorFee\",\"type\":\"uint64\"}],\"internalType\":\"structReservation.ReservationRequest\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"depositKey\",\"type\":\"uint256\"}],\"name\":\"reservedDepositWallet\",\"outputs\":[{\"internalType\":\"bytes20\",\"name\":\"\",\"type\":\"bytes20\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint8\",\"name\":\"proofType\",\"type\":\"uint8\"},{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"txInfo\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes\",\"name\":\"merkleProof\",\"type\":\"bytes\"},{\"internalType\":\"uint256\",\"name\":\"txIndexInBlock\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"bitcoinHeaders\",\"type\":\"bytes\"},{\"internalType\":\"bytes32\",\"name\":\"coinbasePreimage\",\"type\":\"bytes32\"},{\"internalType\":\"bytes\",\"name\":\"coinbaseProof\",\"type\":\"bytes\"}],\"internalType\":\"structBitcoinTx.Proof\",\"name\":\"proof\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"mainUtxo\",\"type\":\"tuple\"},{\"internalType\":\"uint256\",\"name\":\"reservationKey\",\"type\":\"uint256\"},{\"internalType\":\"uint64\",\"name\":\"requestNonce\",\"type\":\"uint64\"}],\"name\":\"submitReservationProof\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"newGovernance\",\"type\":\"address\"}],\"name\":\"transferGovernance\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint64\",\"name\":\"maxReservationsAmountPerWallet\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"reservationMaxSingleAmount\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"maxActiveReservations\",\"type\":\"uint32\"}],\"name\":\"updateReservationCaps\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"reservationVault\",\"type\":\"address\"},{\"internalType\":\"uint64\",\"name\":\"reservationMinAmount\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"reservationTxMaxFee\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"reservationTermSeconds\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"reservationDissolutionDelay\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"reservationMaxTotalAmount\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"maxReservationsPerWallet\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"reservationActionTimeout\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"reservationRenewalWindowSeconds\",\"type\":\"uint32\"}],\"name\":\"updateReservationParameters\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"walletReservations\",\"outputs\":[{\"internalType\":\"uint256[]\",\"name\":\"\",\"type\":\"uint256[]\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"walletReservationsAmount\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"\",\"type\":\"uint64\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"}],\"name\":\"walletReservationsCount\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"}]", +} + +// ReservationRouterABI is the input ABI used to generate the binding from. +// Deprecated: Use ReservationRouterMetaData.ABI instead. +var ReservationRouterABI = ReservationRouterMetaData.ABI + +// ReservationRouter is an auto generated Go binding around an Ethereum contract. +type ReservationRouter struct { + ReservationRouterCaller // Read-only binding to the contract + ReservationRouterTransactor // Write-only binding to the contract + ReservationRouterFilterer // Log filterer for contract events +} + +// ReservationRouterCaller is an auto generated read-only Go binding around an Ethereum contract. +type ReservationRouterCaller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// ReservationRouterTransactor is an auto generated write-only Go binding around an Ethereum contract. +type ReservationRouterTransactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// ReservationRouterFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type ReservationRouterFilterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// ReservationRouterSession is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type ReservationRouterSession struct { + Contract *ReservationRouter // Generic contract binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// ReservationRouterCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type ReservationRouterCallerSession struct { + Contract *ReservationRouterCaller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// ReservationRouterTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type ReservationRouterTransactorSession struct { + Contract *ReservationRouterTransactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// ReservationRouterRaw is an auto generated low-level Go binding around an Ethereum contract. +type ReservationRouterRaw struct { + Contract *ReservationRouter // Generic contract binding to access the raw methods on +} + +// ReservationRouterCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type ReservationRouterCallerRaw struct { + Contract *ReservationRouterCaller // Generic read-only contract binding to access the raw methods on +} + +// ReservationRouterTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type ReservationRouterTransactorRaw struct { + Contract *ReservationRouterTransactor // Generic write-only contract binding to access the raw methods on +} + +// NewReservationRouter creates a new instance of ReservationRouter, bound to a specific deployed contract. +func NewReservationRouter(address common.Address, backend bind.ContractBackend) (*ReservationRouter, error) { + contract, err := bindReservationRouter(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &ReservationRouter{ReservationRouterCaller: ReservationRouterCaller{contract: contract}, ReservationRouterTransactor: ReservationRouterTransactor{contract: contract}, ReservationRouterFilterer: ReservationRouterFilterer{contract: contract}}, nil +} + +// NewReservationRouterCaller creates a new read-only instance of ReservationRouter, bound to a specific deployed contract. +func NewReservationRouterCaller(address common.Address, caller bind.ContractCaller) (*ReservationRouterCaller, error) { + contract, err := bindReservationRouter(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &ReservationRouterCaller{contract: contract}, nil +} + +// NewReservationRouterTransactor creates a new write-only instance of ReservationRouter, bound to a specific deployed contract. +func NewReservationRouterTransactor(address common.Address, transactor bind.ContractTransactor) (*ReservationRouterTransactor, error) { + contract, err := bindReservationRouter(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &ReservationRouterTransactor{contract: contract}, nil +} + +// NewReservationRouterFilterer creates a new log filterer instance of ReservationRouter, bound to a specific deployed contract. +func NewReservationRouterFilterer(address common.Address, filterer bind.ContractFilterer) (*ReservationRouterFilterer, error) { + contract, err := bindReservationRouter(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &ReservationRouterFilterer{contract: contract}, nil +} + +// bindReservationRouter binds a generic wrapper to an already deployed contract. +func bindReservationRouter(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := ReservationRouterMetaData.GetAbi() + if err != nil { + return nil, err + } + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_ReservationRouter *ReservationRouterRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _ReservationRouter.Contract.ReservationRouterCaller.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_ReservationRouter *ReservationRouterRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _ReservationRouter.Contract.ReservationRouterTransactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_ReservationRouter *ReservationRouterRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _ReservationRouter.Contract.ReservationRouterTransactor.contract.Transact(opts, method, params...) +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_ReservationRouter *ReservationRouterCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _ReservationRouter.Contract.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_ReservationRouter *ReservationRouterTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _ReservationRouter.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_ReservationRouter *ReservationRouterTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _ReservationRouter.Contract.contract.Transact(opts, method, params...) +} + +// ActiveReservationsCount is a free data retrieval call binding the contract method 0x93fe5eab. +// +// Solidity: function activeReservationsCount() view returns(uint32 count, uint32 maxActive) +func (_ReservationRouter *ReservationRouterCaller) ActiveReservationsCount(opts *bind.CallOpts) (struct { + Count uint32 + MaxActive uint32 +}, error) { + var out []interface{} + err := _ReservationRouter.contract.Call(opts, &out, "activeReservationsCount") + + outstruct := new(struct { + Count uint32 + MaxActive uint32 + }) + if err != nil { + return *outstruct, err + } + + outstruct.Count = *abi.ConvertType(out[0], new(uint32)).(*uint32) + outstruct.MaxActive = *abi.ConvertType(out[1], new(uint32)).(*uint32) + + return *outstruct, err + +} + +// ActiveReservationsCount is a free data retrieval call binding the contract method 0x93fe5eab. +// +// Solidity: function activeReservationsCount() view returns(uint32 count, uint32 maxActive) +func (_ReservationRouter *ReservationRouterSession) ActiveReservationsCount() (struct { + Count uint32 + MaxActive uint32 +}, error) { + return _ReservationRouter.Contract.ActiveReservationsCount(&_ReservationRouter.CallOpts) +} + +// ActiveReservationsCount is a free data retrieval call binding the contract method 0x93fe5eab. +// +// Solidity: function activeReservationsCount() view returns(uint32 count, uint32 maxActive) +func (_ReservationRouter *ReservationRouterCallerSession) ActiveReservationsCount() (struct { + Count uint32 + MaxActive uint32 +}, error) { + return _ReservationRouter.Contract.ActiveReservationsCount(&_ReservationRouter.CallOpts) +} + +// Governance is a free data retrieval call binding the contract method 0x5aa6e675. +// +// Solidity: function governance() view returns(address) +func (_ReservationRouter *ReservationRouterCaller) Governance(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _ReservationRouter.contract.Call(opts, &out, "governance") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// Governance is a free data retrieval call binding the contract method 0x5aa6e675. +// +// Solidity: function governance() view returns(address) +func (_ReservationRouter *ReservationRouterSession) Governance() (common.Address, error) { + return _ReservationRouter.Contract.Governance(&_ReservationRouter.CallOpts) +} + +// Governance is a free data retrieval call binding the contract method 0x5aa6e675. +// +// Solidity: function governance() view returns(address) +func (_ReservationRouter *ReservationRouterCallerSession) Governance() (common.Address, error) { + return _ReservationRouter.Contract.Governance(&_ReservationRouter.CallOpts) +} + +// PendingReservedDeposits is a free data retrieval call binding the contract method 0x34830fc8. +// +// Solidity: function pendingReservedDeposits() view returns(uint64) +func (_ReservationRouter *ReservationRouterCaller) PendingReservedDeposits(opts *bind.CallOpts) (uint64, error) { + var out []interface{} + err := _ReservationRouter.contract.Call(opts, &out, "pendingReservedDeposits") + + if err != nil { + return *new(uint64), err + } + + out0 := *abi.ConvertType(out[0], new(uint64)).(*uint64) + + return out0, err + +} + +// PendingReservedDeposits is a free data retrieval call binding the contract method 0x34830fc8. +// +// Solidity: function pendingReservedDeposits() view returns(uint64) +func (_ReservationRouter *ReservationRouterSession) PendingReservedDeposits() (uint64, error) { + return _ReservationRouter.Contract.PendingReservedDeposits(&_ReservationRouter.CallOpts) +} + +// PendingReservedDeposits is a free data retrieval call binding the contract method 0x34830fc8. +// +// Solidity: function pendingReservedDeposits() view returns(uint64) +func (_ReservationRouter *ReservationRouterCallerSession) PendingReservedDeposits() (uint64, error) { + return _ReservationRouter.Contract.PendingReservedDeposits(&_ReservationRouter.CallOpts) +} + +// ReservationActions is a free data retrieval call binding the contract method 0xcec8c6e9. +// +// Solidity: function reservationActions(uint256 reservationKey, uint64 requestNonce) view returns((bytes20,uint32,uint32,uint64,uint8,uint8,bool,address,uint64,bytes32,bytes32,bool,uint32,uint32,uint32,bool,uint64)) +func (_ReservationRouter *ReservationRouterCaller) ReservationActions(opts *bind.CallOpts, reservationKey *big.Int, requestNonce uint64) (ReservationReservationAction, error) { + var out []interface{} + err := _ReservationRouter.contract.Call(opts, &out, "reservationActions", reservationKey, requestNonce) + + if err != nil { + return *new(ReservationReservationAction), err + } + + out0 := *abi.ConvertType(out[0], new(ReservationReservationAction)).(*ReservationReservationAction) + + return out0, err + +} + +// ReservationActions is a free data retrieval call binding the contract method 0xcec8c6e9. +// +// Solidity: function reservationActions(uint256 reservationKey, uint64 requestNonce) view returns((bytes20,uint32,uint32,uint64,uint8,uint8,bool,address,uint64,bytes32,bytes32,bool,uint32,uint32,uint32,bool,uint64)) +func (_ReservationRouter *ReservationRouterSession) ReservationActions(reservationKey *big.Int, requestNonce uint64) (ReservationReservationAction, error) { + return _ReservationRouter.Contract.ReservationActions(&_ReservationRouter.CallOpts, reservationKey, requestNonce) +} + +// ReservationActions is a free data retrieval call binding the contract method 0xcec8c6e9. +// +// Solidity: function reservationActions(uint256 reservationKey, uint64 requestNonce) view returns((bytes20,uint32,uint32,uint64,uint8,uint8,bool,address,uint64,bytes32,bytes32,bool,uint32,uint32,uint32,bool,uint64)) +func (_ReservationRouter *ReservationRouterCallerSession) ReservationActions(reservationKey *big.Int, requestNonce uint64) (ReservationReservationAction, error) { + return _ReservationRouter.Contract.ReservationActions(&_ReservationRouter.CallOpts, reservationKey, requestNonce) +} + +// ReservationByAnchorUtxo is a free data retrieval call binding the contract method 0x79731f67. +// +// Solidity: function reservationByAnchorUtxo(bytes32 anchorTxHash, uint32 anchorTxOutputIndex) view returns(uint256) +func (_ReservationRouter *ReservationRouterCaller) ReservationByAnchorUtxo(opts *bind.CallOpts, anchorTxHash [32]byte, anchorTxOutputIndex uint32) (*big.Int, error) { + var out []interface{} + err := _ReservationRouter.contract.Call(opts, &out, "reservationByAnchorUtxo", anchorTxHash, anchorTxOutputIndex) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// ReservationByAnchorUtxo is a free data retrieval call binding the contract method 0x79731f67. +// +// Solidity: function reservationByAnchorUtxo(bytes32 anchorTxHash, uint32 anchorTxOutputIndex) view returns(uint256) +func (_ReservationRouter *ReservationRouterSession) ReservationByAnchorUtxo(anchorTxHash [32]byte, anchorTxOutputIndex uint32) (*big.Int, error) { + return _ReservationRouter.Contract.ReservationByAnchorUtxo(&_ReservationRouter.CallOpts, anchorTxHash, anchorTxOutputIndex) +} + +// ReservationByAnchorUtxo is a free data retrieval call binding the contract method 0x79731f67. +// +// Solidity: function reservationByAnchorUtxo(bytes32 anchorTxHash, uint32 anchorTxOutputIndex) view returns(uint256) +func (_ReservationRouter *ReservationRouterCallerSession) ReservationByAnchorUtxo(anchorTxHash [32]byte, anchorTxOutputIndex uint32) (*big.Int, error) { + return _ReservationRouter.Contract.ReservationByAnchorUtxo(&_ReservationRouter.CallOpts, anchorTxHash, anchorTxOutputIndex) +} + +// ReservationCaps is a free data retrieval call binding the contract method 0x63dfb29c. +// +// Solidity: function reservationCaps() view returns(uint64 maxReservationsAmountPerWallet, uint64 reservationMaxSingleAmount) +func (_ReservationRouter *ReservationRouterCaller) ReservationCaps(opts *bind.CallOpts) (struct { + MaxReservationsAmountPerWallet uint64 + ReservationMaxSingleAmount uint64 +}, error) { + var out []interface{} + err := _ReservationRouter.contract.Call(opts, &out, "reservationCaps") + + outstruct := new(struct { + MaxReservationsAmountPerWallet uint64 + ReservationMaxSingleAmount uint64 + }) + if err != nil { + return *outstruct, err + } + + outstruct.MaxReservationsAmountPerWallet = *abi.ConvertType(out[0], new(uint64)).(*uint64) + outstruct.ReservationMaxSingleAmount = *abi.ConvertType(out[1], new(uint64)).(*uint64) + + return *outstruct, err + +} + +// ReservationCaps is a free data retrieval call binding the contract method 0x63dfb29c. +// +// Solidity: function reservationCaps() view returns(uint64 maxReservationsAmountPerWallet, uint64 reservationMaxSingleAmount) +func (_ReservationRouter *ReservationRouterSession) ReservationCaps() (struct { + MaxReservationsAmountPerWallet uint64 + ReservationMaxSingleAmount uint64 +}, error) { + return _ReservationRouter.Contract.ReservationCaps(&_ReservationRouter.CallOpts) +} + +// ReservationCaps is a free data retrieval call binding the contract method 0x63dfb29c. +// +// Solidity: function reservationCaps() view returns(uint64 maxReservationsAmountPerWallet, uint64 reservationMaxSingleAmount) +func (_ReservationRouter *ReservationRouterCallerSession) ReservationCaps() (struct { + MaxReservationsAmountPerWallet uint64 + ReservationMaxSingleAmount uint64 +}, error) { + return _ReservationRouter.Contract.ReservationCaps(&_ReservationRouter.CallOpts) +} + +// ReservationParameters is a free data retrieval call binding the contract method 0xf75b4b1c. +// +// Solidity: function reservationParameters() view returns(address reservationVault, uint64 reservationMinAmount, uint64 reservationTxMaxFee, uint32 reservationTermSeconds, uint32 reservationDissolutionDelay, uint64 reservationMaxTotalAmount, uint64 reservationTotalAmount, uint32 maxReservationsPerWallet, uint32 reservationActionTimeout, uint32 reservationRenewalWindowSeconds) +func (_ReservationRouter *ReservationRouterCaller) ReservationParameters(opts *bind.CallOpts) (struct { + ReservationVault common.Address + ReservationMinAmount uint64 + ReservationTxMaxFee uint64 + ReservationTermSeconds uint32 + ReservationDissolutionDelay uint32 + ReservationMaxTotalAmount uint64 + ReservationTotalAmount uint64 + MaxReservationsPerWallet uint32 + ReservationActionTimeout uint32 + ReservationRenewalWindowSeconds uint32 +}, error) { + var out []interface{} + err := _ReservationRouter.contract.Call(opts, &out, "reservationParameters") + + outstruct := new(struct { + ReservationVault common.Address + ReservationMinAmount uint64 + ReservationTxMaxFee uint64 + ReservationTermSeconds uint32 + ReservationDissolutionDelay uint32 + ReservationMaxTotalAmount uint64 + ReservationTotalAmount uint64 + MaxReservationsPerWallet uint32 + ReservationActionTimeout uint32 + ReservationRenewalWindowSeconds uint32 + }) + if err != nil { + return *outstruct, err + } + + outstruct.ReservationVault = *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + outstruct.ReservationMinAmount = *abi.ConvertType(out[1], new(uint64)).(*uint64) + outstruct.ReservationTxMaxFee = *abi.ConvertType(out[2], new(uint64)).(*uint64) + outstruct.ReservationTermSeconds = *abi.ConvertType(out[3], new(uint32)).(*uint32) + outstruct.ReservationDissolutionDelay = *abi.ConvertType(out[4], new(uint32)).(*uint32) + outstruct.ReservationMaxTotalAmount = *abi.ConvertType(out[5], new(uint64)).(*uint64) + outstruct.ReservationTotalAmount = *abi.ConvertType(out[6], new(uint64)).(*uint64) + outstruct.MaxReservationsPerWallet = *abi.ConvertType(out[7], new(uint32)).(*uint32) + outstruct.ReservationActionTimeout = *abi.ConvertType(out[8], new(uint32)).(*uint32) + outstruct.ReservationRenewalWindowSeconds = *abi.ConvertType(out[9], new(uint32)).(*uint32) + + return *outstruct, err + +} + +// ReservationParameters is a free data retrieval call binding the contract method 0xf75b4b1c. +// +// Solidity: function reservationParameters() view returns(address reservationVault, uint64 reservationMinAmount, uint64 reservationTxMaxFee, uint32 reservationTermSeconds, uint32 reservationDissolutionDelay, uint64 reservationMaxTotalAmount, uint64 reservationTotalAmount, uint32 maxReservationsPerWallet, uint32 reservationActionTimeout, uint32 reservationRenewalWindowSeconds) +func (_ReservationRouter *ReservationRouterSession) ReservationParameters() (struct { + ReservationVault common.Address + ReservationMinAmount uint64 + ReservationTxMaxFee uint64 + ReservationTermSeconds uint32 + ReservationDissolutionDelay uint32 + ReservationMaxTotalAmount uint64 + ReservationTotalAmount uint64 + MaxReservationsPerWallet uint32 + ReservationActionTimeout uint32 + ReservationRenewalWindowSeconds uint32 +}, error) { + return _ReservationRouter.Contract.ReservationParameters(&_ReservationRouter.CallOpts) +} + +// ReservationParameters is a free data retrieval call binding the contract method 0xf75b4b1c. +// +// Solidity: function reservationParameters() view returns(address reservationVault, uint64 reservationMinAmount, uint64 reservationTxMaxFee, uint32 reservationTermSeconds, uint32 reservationDissolutionDelay, uint64 reservationMaxTotalAmount, uint64 reservationTotalAmount, uint32 maxReservationsPerWallet, uint32 reservationActionTimeout, uint32 reservationRenewalWindowSeconds) +func (_ReservationRouter *ReservationRouterCallerSession) ReservationParameters() (struct { + ReservationVault common.Address + ReservationMinAmount uint64 + ReservationTxMaxFee uint64 + ReservationTermSeconds uint32 + ReservationDissolutionDelay uint32 + ReservationMaxTotalAmount uint64 + ReservationTotalAmount uint64 + MaxReservationsPerWallet uint32 + ReservationActionTimeout uint32 + ReservationRenewalWindowSeconds uint32 +}, error) { + return _ReservationRouter.Contract.ReservationParameters(&_ReservationRouter.CallOpts) +} + +// ReservationRouter is a free data retrieval call binding the contract method 0x06ca90d2. +// +// Solidity: function reservationRouter() view returns(address) +func (_ReservationRouter *ReservationRouterCaller) ReservationRouter(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _ReservationRouter.contract.Call(opts, &out, "reservationRouter") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// ReservationRouter is a free data retrieval call binding the contract method 0x06ca90d2. +// +// Solidity: function reservationRouter() view returns(address) +func (_ReservationRouter *ReservationRouterSession) ReservationRouter() (common.Address, error) { + return _ReservationRouter.Contract.ReservationRouter(&_ReservationRouter.CallOpts) +} + +// ReservationRouter is a free data retrieval call binding the contract method 0x06ca90d2. +// +// Solidity: function reservationRouter() view returns(address) +func (_ReservationRouter *ReservationRouterCallerSession) ReservationRouter() (common.Address, error) { + return _ReservationRouter.Contract.ReservationRouter(&_ReservationRouter.CallOpts) +} + +// Reservations is a free data retrieval call binding the contract method 0x067cf832. +// +// Solidity: function reservations(uint256 reservationKey) view returns((address,uint64,uint32,bytes20,uint64,uint32,bytes32,uint32,uint8,uint64,bool,uint32,uint64)) +func (_ReservationRouter *ReservationRouterCaller) Reservations(opts *bind.CallOpts, reservationKey *big.Int) (ReservationReservationRequest, error) { + var out []interface{} + err := _ReservationRouter.contract.Call(opts, &out, "reservations", reservationKey) + + if err != nil { + return *new(ReservationReservationRequest), err + } + + out0 := *abi.ConvertType(out[0], new(ReservationReservationRequest)).(*ReservationReservationRequest) + + return out0, err + +} + +// Reservations is a free data retrieval call binding the contract method 0x067cf832. +// +// Solidity: function reservations(uint256 reservationKey) view returns((address,uint64,uint32,bytes20,uint64,uint32,bytes32,uint32,uint8,uint64,bool,uint32,uint64)) +func (_ReservationRouter *ReservationRouterSession) Reservations(reservationKey *big.Int) (ReservationReservationRequest, error) { + return _ReservationRouter.Contract.Reservations(&_ReservationRouter.CallOpts, reservationKey) +} + +// Reservations is a free data retrieval call binding the contract method 0x067cf832. +// +// Solidity: function reservations(uint256 reservationKey) view returns((address,uint64,uint32,bytes20,uint64,uint32,bytes32,uint32,uint8,uint64,bool,uint32,uint64)) +func (_ReservationRouter *ReservationRouterCallerSession) Reservations(reservationKey *big.Int) (ReservationReservationRequest, error) { + return _ReservationRouter.Contract.Reservations(&_ReservationRouter.CallOpts, reservationKey) +} + +// ReservedDepositWallet is a free data retrieval call binding the contract method 0x56803b55. +// +// Solidity: function reservedDepositWallet(uint256 depositKey) view returns(bytes20) +func (_ReservationRouter *ReservationRouterCaller) ReservedDepositWallet(opts *bind.CallOpts, depositKey *big.Int) ([20]byte, error) { + var out []interface{} + err := _ReservationRouter.contract.Call(opts, &out, "reservedDepositWallet", depositKey) + + if err != nil { + return *new([20]byte), err + } + + out0 := *abi.ConvertType(out[0], new([20]byte)).(*[20]byte) + + return out0, err + +} + +// ReservedDepositWallet is a free data retrieval call binding the contract method 0x56803b55. +// +// Solidity: function reservedDepositWallet(uint256 depositKey) view returns(bytes20) +func (_ReservationRouter *ReservationRouterSession) ReservedDepositWallet(depositKey *big.Int) ([20]byte, error) { + return _ReservationRouter.Contract.ReservedDepositWallet(&_ReservationRouter.CallOpts, depositKey) +} + +// ReservedDepositWallet is a free data retrieval call binding the contract method 0x56803b55. +// +// Solidity: function reservedDepositWallet(uint256 depositKey) view returns(bytes20) +func (_ReservationRouter *ReservationRouterCallerSession) ReservedDepositWallet(depositKey *big.Int) ([20]byte, error) { + return _ReservationRouter.Contract.ReservedDepositWallet(&_ReservationRouter.CallOpts, depositKey) +} + +// WalletReservations is a free data retrieval call binding the contract method 0x78699d2f. +// +// Solidity: function walletReservations(bytes20 walletPubKeyHash) view returns(uint256[]) +func (_ReservationRouter *ReservationRouterCaller) WalletReservations(opts *bind.CallOpts, walletPubKeyHash [20]byte) ([]*big.Int, error) { + var out []interface{} + err := _ReservationRouter.contract.Call(opts, &out, "walletReservations", walletPubKeyHash) + + if err != nil { + return *new([]*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new([]*big.Int)).(*[]*big.Int) + + return out0, err + +} + +// WalletReservations is a free data retrieval call binding the contract method 0x78699d2f. +// +// Solidity: function walletReservations(bytes20 walletPubKeyHash) view returns(uint256[]) +func (_ReservationRouter *ReservationRouterSession) WalletReservations(walletPubKeyHash [20]byte) ([]*big.Int, error) { + return _ReservationRouter.Contract.WalletReservations(&_ReservationRouter.CallOpts, walletPubKeyHash) +} + +// WalletReservations is a free data retrieval call binding the contract method 0x78699d2f. +// +// Solidity: function walletReservations(bytes20 walletPubKeyHash) view returns(uint256[]) +func (_ReservationRouter *ReservationRouterCallerSession) WalletReservations(walletPubKeyHash [20]byte) ([]*big.Int, error) { + return _ReservationRouter.Contract.WalletReservations(&_ReservationRouter.CallOpts, walletPubKeyHash) +} + +// WalletReservationsAmount is a free data retrieval call binding the contract method 0x63481e98. +// +// Solidity: function walletReservationsAmount(bytes20 walletPubKeyHash) view returns(uint64) +func (_ReservationRouter *ReservationRouterCaller) WalletReservationsAmount(opts *bind.CallOpts, walletPubKeyHash [20]byte) (uint64, error) { + var out []interface{} + err := _ReservationRouter.contract.Call(opts, &out, "walletReservationsAmount", walletPubKeyHash) + + if err != nil { + return *new(uint64), err + } + + out0 := *abi.ConvertType(out[0], new(uint64)).(*uint64) + + return out0, err + +} + +// WalletReservationsAmount is a free data retrieval call binding the contract method 0x63481e98. +// +// Solidity: function walletReservationsAmount(bytes20 walletPubKeyHash) view returns(uint64) +func (_ReservationRouter *ReservationRouterSession) WalletReservationsAmount(walletPubKeyHash [20]byte) (uint64, error) { + return _ReservationRouter.Contract.WalletReservationsAmount(&_ReservationRouter.CallOpts, walletPubKeyHash) +} + +// WalletReservationsAmount is a free data retrieval call binding the contract method 0x63481e98. +// +// Solidity: function walletReservationsAmount(bytes20 walletPubKeyHash) view returns(uint64) +func (_ReservationRouter *ReservationRouterCallerSession) WalletReservationsAmount(walletPubKeyHash [20]byte) (uint64, error) { + return _ReservationRouter.Contract.WalletReservationsAmount(&_ReservationRouter.CallOpts, walletPubKeyHash) +} + +// WalletReservationsCount is a free data retrieval call binding the contract method 0x1de0555a. +// +// Solidity: function walletReservationsCount(bytes20 walletPubKeyHash) view returns(uint32) +func (_ReservationRouter *ReservationRouterCaller) WalletReservationsCount(opts *bind.CallOpts, walletPubKeyHash [20]byte) (uint32, error) { + var out []interface{} + err := _ReservationRouter.contract.Call(opts, &out, "walletReservationsCount", walletPubKeyHash) + + if err != nil { + return *new(uint32), err + } + + out0 := *abi.ConvertType(out[0], new(uint32)).(*uint32) + + return out0, err + +} + +// WalletReservationsCount is a free data retrieval call binding the contract method 0x1de0555a. +// +// Solidity: function walletReservationsCount(bytes20 walletPubKeyHash) view returns(uint32) +func (_ReservationRouter *ReservationRouterSession) WalletReservationsCount(walletPubKeyHash [20]byte) (uint32, error) { + return _ReservationRouter.Contract.WalletReservationsCount(&_ReservationRouter.CallOpts, walletPubKeyHash) +} + +// WalletReservationsCount is a free data retrieval call binding the contract method 0x1de0555a. +// +// Solidity: function walletReservationsCount(bytes20 walletPubKeyHash) view returns(uint32) +func (_ReservationRouter *ReservationRouterCallerSession) WalletReservationsCount(walletPubKeyHash [20]byte) (uint32, error) { + return _ReservationRouter.Contract.WalletReservationsCount(&_ReservationRouter.CallOpts, walletPubKeyHash) +} + +// NotifyReservationActionTimeout is a paid mutator transaction binding the contract method 0x88aa5729. +// +// Solidity: function notifyReservationActionTimeout(uint256 reservationKey, uint32[] walletMembersIDs) returns() +func (_ReservationRouter *ReservationRouterTransactor) NotifyReservationActionTimeout(opts *bind.TransactOpts, reservationKey *big.Int, walletMembersIDs []uint32) (*types.Transaction, error) { + return _ReservationRouter.contract.Transact(opts, "notifyReservationActionTimeout", reservationKey, walletMembersIDs) +} + +// NotifyReservationActionTimeout is a paid mutator transaction binding the contract method 0x88aa5729. +// +// Solidity: function notifyReservationActionTimeout(uint256 reservationKey, uint32[] walletMembersIDs) returns() +func (_ReservationRouter *ReservationRouterSession) NotifyReservationActionTimeout(reservationKey *big.Int, walletMembersIDs []uint32) (*types.Transaction, error) { + return _ReservationRouter.Contract.NotifyReservationActionTimeout(&_ReservationRouter.TransactOpts, reservationKey, walletMembersIDs) +} + +// NotifyReservationActionTimeout is a paid mutator transaction binding the contract method 0x88aa5729. +// +// Solidity: function notifyReservationActionTimeout(uint256 reservationKey, uint32[] walletMembersIDs) returns() +func (_ReservationRouter *ReservationRouterTransactorSession) NotifyReservationActionTimeout(reservationKey *big.Int, walletMembersIDs []uint32) (*types.Transaction, error) { + return _ReservationRouter.Contract.NotifyReservationActionTimeout(&_ReservationRouter.TransactOpts, reservationKey, walletMembersIDs) +} + +// NotifyReservationStranded is a paid mutator transaction binding the contract method 0xf95ea36f. +// +// Solidity: function notifyReservationStranded(uint256 reservationKey) returns() +func (_ReservationRouter *ReservationRouterTransactor) NotifyReservationStranded(opts *bind.TransactOpts, reservationKey *big.Int) (*types.Transaction, error) { + return _ReservationRouter.contract.Transact(opts, "notifyReservationStranded", reservationKey) +} + +// NotifyReservationStranded is a paid mutator transaction binding the contract method 0xf95ea36f. +// +// Solidity: function notifyReservationStranded(uint256 reservationKey) returns() +func (_ReservationRouter *ReservationRouterSession) NotifyReservationStranded(reservationKey *big.Int) (*types.Transaction, error) { + return _ReservationRouter.Contract.NotifyReservationStranded(&_ReservationRouter.TransactOpts, reservationKey) +} + +// NotifyReservationStranded is a paid mutator transaction binding the contract method 0xf95ea36f. +// +// Solidity: function notifyReservationStranded(uint256 reservationKey) returns() +func (_ReservationRouter *ReservationRouterTransactorSession) NotifyReservationStranded(reservationKey *big.Int) (*types.Transaction, error) { + return _ReservationRouter.Contract.NotifyReservationStranded(&_ReservationRouter.TransactOpts, reservationKey) +} + +// NotifyStaleReservedDeposit is a paid mutator transaction binding the contract method 0x6ceb1b54. +// +// Solidity: function notifyStaleReservedDeposit(uint256 depositKey) returns() +func (_ReservationRouter *ReservationRouterTransactor) NotifyStaleReservedDeposit(opts *bind.TransactOpts, depositKey *big.Int) (*types.Transaction, error) { + return _ReservationRouter.contract.Transact(opts, "notifyStaleReservedDeposit", depositKey) +} + +// NotifyStaleReservedDeposit is a paid mutator transaction binding the contract method 0x6ceb1b54. +// +// Solidity: function notifyStaleReservedDeposit(uint256 depositKey) returns() +func (_ReservationRouter *ReservationRouterSession) NotifyStaleReservedDeposit(depositKey *big.Int) (*types.Transaction, error) { + return _ReservationRouter.Contract.NotifyStaleReservedDeposit(&_ReservationRouter.TransactOpts, depositKey) +} + +// NotifyStaleReservedDeposit is a paid mutator transaction binding the contract method 0x6ceb1b54. +// +// Solidity: function notifyStaleReservedDeposit(uint256 depositKey) returns() +func (_ReservationRouter *ReservationRouterTransactorSession) NotifyStaleReservedDeposit(depositKey *big.Int) (*types.Transaction, error) { + return _ReservationRouter.Contract.NotifyStaleReservedDeposit(&_ReservationRouter.TransactOpts, depositKey) +} + +// RequestReservationAcceptance is a paid mutator transaction binding the contract method 0xbc78a18e. +// +// Solidity: function requestReservationAcceptance(uint256 reservationKey, bytes20 walletPubKeyHash) returns() +func (_ReservationRouter *ReservationRouterTransactor) RequestReservationAcceptance(opts *bind.TransactOpts, reservationKey *big.Int, walletPubKeyHash [20]byte) (*types.Transaction, error) { + return _ReservationRouter.contract.Transact(opts, "requestReservationAcceptance", reservationKey, walletPubKeyHash) +} + +// RequestReservationAcceptance is a paid mutator transaction binding the contract method 0xbc78a18e. +// +// Solidity: function requestReservationAcceptance(uint256 reservationKey, bytes20 walletPubKeyHash) returns() +func (_ReservationRouter *ReservationRouterSession) RequestReservationAcceptance(reservationKey *big.Int, walletPubKeyHash [20]byte) (*types.Transaction, error) { + return _ReservationRouter.Contract.RequestReservationAcceptance(&_ReservationRouter.TransactOpts, reservationKey, walletPubKeyHash) +} + +// RequestReservationAcceptance is a paid mutator transaction binding the contract method 0xbc78a18e. +// +// Solidity: function requestReservationAcceptance(uint256 reservationKey, bytes20 walletPubKeyHash) returns() +func (_ReservationRouter *ReservationRouterTransactorSession) RequestReservationAcceptance(reservationKey *big.Int, walletPubKeyHash [20]byte) (*types.Transaction, error) { + return _ReservationRouter.Contract.RequestReservationAcceptance(&_ReservationRouter.TransactOpts, reservationKey, walletPubKeyHash) +} + +// RequestReservationReanchor is a paid mutator transaction binding the contract method 0xf934beb5. +// +// Solidity: function requestReservationReanchor(uint256 reservationKey, bytes20 targetWalletPubKeyHash) returns() +func (_ReservationRouter *ReservationRouterTransactor) RequestReservationReanchor(opts *bind.TransactOpts, reservationKey *big.Int, targetWalletPubKeyHash [20]byte) (*types.Transaction, error) { + return _ReservationRouter.contract.Transact(opts, "requestReservationReanchor", reservationKey, targetWalletPubKeyHash) +} + +// RequestReservationReanchor is a paid mutator transaction binding the contract method 0xf934beb5. +// +// Solidity: function requestReservationReanchor(uint256 reservationKey, bytes20 targetWalletPubKeyHash) returns() +func (_ReservationRouter *ReservationRouterSession) RequestReservationReanchor(reservationKey *big.Int, targetWalletPubKeyHash [20]byte) (*types.Transaction, error) { + return _ReservationRouter.Contract.RequestReservationReanchor(&_ReservationRouter.TransactOpts, reservationKey, targetWalletPubKeyHash) +} + +// RequestReservationReanchor is a paid mutator transaction binding the contract method 0xf934beb5. +// +// Solidity: function requestReservationReanchor(uint256 reservationKey, bytes20 targetWalletPubKeyHash) returns() +func (_ReservationRouter *ReservationRouterTransactorSession) RequestReservationReanchor(reservationKey *big.Int, targetWalletPubKeyHash [20]byte) (*types.Transaction, error) { + return _ReservationRouter.Contract.RequestReservationReanchor(&_ReservationRouter.TransactOpts, reservationKey, targetWalletPubKeyHash) +} + +// SubmitReservationProof is a paid mutator transaction binding the contract method 0x668a4980. +// +// Solidity: function submitReservationProof(uint8 proofType, (bytes4,bytes,bytes,bytes4) txInfo, (bytes,uint256,bytes,bytes32,bytes) proof, (bytes32,uint32,uint64) mainUtxo, uint256 reservationKey, uint64 requestNonce) returns() +func (_ReservationRouter *ReservationRouterTransactor) SubmitReservationProof(opts *bind.TransactOpts, proofType uint8, txInfo BitcoinTxInfo4, proof BitcoinTxProof3, mainUtxo BitcoinTxUTXO4, reservationKey *big.Int, requestNonce uint64) (*types.Transaction, error) { + return _ReservationRouter.contract.Transact(opts, "submitReservationProof", proofType, txInfo, proof, mainUtxo, reservationKey, requestNonce) +} + +// SubmitReservationProof is a paid mutator transaction binding the contract method 0x668a4980. +// +// Solidity: function submitReservationProof(uint8 proofType, (bytes4,bytes,bytes,bytes4) txInfo, (bytes,uint256,bytes,bytes32,bytes) proof, (bytes32,uint32,uint64) mainUtxo, uint256 reservationKey, uint64 requestNonce) returns() +func (_ReservationRouter *ReservationRouterSession) SubmitReservationProof(proofType uint8, txInfo BitcoinTxInfo4, proof BitcoinTxProof3, mainUtxo BitcoinTxUTXO4, reservationKey *big.Int, requestNonce uint64) (*types.Transaction, error) { + return _ReservationRouter.Contract.SubmitReservationProof(&_ReservationRouter.TransactOpts, proofType, txInfo, proof, mainUtxo, reservationKey, requestNonce) +} + +// SubmitReservationProof is a paid mutator transaction binding the contract method 0x668a4980. +// +// Solidity: function submitReservationProof(uint8 proofType, (bytes4,bytes,bytes,bytes4) txInfo, (bytes,uint256,bytes,bytes32,bytes) proof, (bytes32,uint32,uint64) mainUtxo, uint256 reservationKey, uint64 requestNonce) returns() +func (_ReservationRouter *ReservationRouterTransactorSession) SubmitReservationProof(proofType uint8, txInfo BitcoinTxInfo4, proof BitcoinTxProof3, mainUtxo BitcoinTxUTXO4, reservationKey *big.Int, requestNonce uint64) (*types.Transaction, error) { + return _ReservationRouter.Contract.SubmitReservationProof(&_ReservationRouter.TransactOpts, proofType, txInfo, proof, mainUtxo, reservationKey, requestNonce) +} + +// TransferGovernance is a paid mutator transaction binding the contract method 0xd38bfff4. +// +// Solidity: function transferGovernance(address newGovernance) returns() +func (_ReservationRouter *ReservationRouterTransactor) TransferGovernance(opts *bind.TransactOpts, newGovernance common.Address) (*types.Transaction, error) { + return _ReservationRouter.contract.Transact(opts, "transferGovernance", newGovernance) +} + +// TransferGovernance is a paid mutator transaction binding the contract method 0xd38bfff4. +// +// Solidity: function transferGovernance(address newGovernance) returns() +func (_ReservationRouter *ReservationRouterSession) TransferGovernance(newGovernance common.Address) (*types.Transaction, error) { + return _ReservationRouter.Contract.TransferGovernance(&_ReservationRouter.TransactOpts, newGovernance) +} + +// TransferGovernance is a paid mutator transaction binding the contract method 0xd38bfff4. +// +// Solidity: function transferGovernance(address newGovernance) returns() +func (_ReservationRouter *ReservationRouterTransactorSession) TransferGovernance(newGovernance common.Address) (*types.Transaction, error) { + return _ReservationRouter.Contract.TransferGovernance(&_ReservationRouter.TransactOpts, newGovernance) +} + +// UpdateReservationCaps is a paid mutator transaction binding the contract method 0x8308c2ca. +// +// Solidity: function updateReservationCaps(uint64 maxReservationsAmountPerWallet, uint64 reservationMaxSingleAmount, uint32 maxActiveReservations) returns() +func (_ReservationRouter *ReservationRouterTransactor) UpdateReservationCaps(opts *bind.TransactOpts, maxReservationsAmountPerWallet uint64, reservationMaxSingleAmount uint64, maxActiveReservations uint32) (*types.Transaction, error) { + return _ReservationRouter.contract.Transact(opts, "updateReservationCaps", maxReservationsAmountPerWallet, reservationMaxSingleAmount, maxActiveReservations) +} + +// UpdateReservationCaps is a paid mutator transaction binding the contract method 0x8308c2ca. +// +// Solidity: function updateReservationCaps(uint64 maxReservationsAmountPerWallet, uint64 reservationMaxSingleAmount, uint32 maxActiveReservations) returns() +func (_ReservationRouter *ReservationRouterSession) UpdateReservationCaps(maxReservationsAmountPerWallet uint64, reservationMaxSingleAmount uint64, maxActiveReservations uint32) (*types.Transaction, error) { + return _ReservationRouter.Contract.UpdateReservationCaps(&_ReservationRouter.TransactOpts, maxReservationsAmountPerWallet, reservationMaxSingleAmount, maxActiveReservations) +} + +// UpdateReservationCaps is a paid mutator transaction binding the contract method 0x8308c2ca. +// +// Solidity: function updateReservationCaps(uint64 maxReservationsAmountPerWallet, uint64 reservationMaxSingleAmount, uint32 maxActiveReservations) returns() +func (_ReservationRouter *ReservationRouterTransactorSession) UpdateReservationCaps(maxReservationsAmountPerWallet uint64, reservationMaxSingleAmount uint64, maxActiveReservations uint32) (*types.Transaction, error) { + return _ReservationRouter.Contract.UpdateReservationCaps(&_ReservationRouter.TransactOpts, maxReservationsAmountPerWallet, reservationMaxSingleAmount, maxActiveReservations) +} + +// UpdateReservationParameters is a paid mutator transaction binding the contract method 0x59f6408b. +// +// Solidity: function updateReservationParameters(address reservationVault, uint64 reservationMinAmount, uint64 reservationTxMaxFee, uint32 reservationTermSeconds, uint32 reservationDissolutionDelay, uint64 reservationMaxTotalAmount, uint32 maxReservationsPerWallet, uint32 reservationActionTimeout, uint32 reservationRenewalWindowSeconds) returns() +func (_ReservationRouter *ReservationRouterTransactor) UpdateReservationParameters(opts *bind.TransactOpts, reservationVault common.Address, reservationMinAmount uint64, reservationTxMaxFee uint64, reservationTermSeconds uint32, reservationDissolutionDelay uint32, reservationMaxTotalAmount uint64, maxReservationsPerWallet uint32, reservationActionTimeout uint32, reservationRenewalWindowSeconds uint32) (*types.Transaction, error) { + return _ReservationRouter.contract.Transact(opts, "updateReservationParameters", reservationVault, reservationMinAmount, reservationTxMaxFee, reservationTermSeconds, reservationDissolutionDelay, reservationMaxTotalAmount, maxReservationsPerWallet, reservationActionTimeout, reservationRenewalWindowSeconds) +} + +// UpdateReservationParameters is a paid mutator transaction binding the contract method 0x59f6408b. +// +// Solidity: function updateReservationParameters(address reservationVault, uint64 reservationMinAmount, uint64 reservationTxMaxFee, uint32 reservationTermSeconds, uint32 reservationDissolutionDelay, uint64 reservationMaxTotalAmount, uint32 maxReservationsPerWallet, uint32 reservationActionTimeout, uint32 reservationRenewalWindowSeconds) returns() +func (_ReservationRouter *ReservationRouterSession) UpdateReservationParameters(reservationVault common.Address, reservationMinAmount uint64, reservationTxMaxFee uint64, reservationTermSeconds uint32, reservationDissolutionDelay uint32, reservationMaxTotalAmount uint64, maxReservationsPerWallet uint32, reservationActionTimeout uint32, reservationRenewalWindowSeconds uint32) (*types.Transaction, error) { + return _ReservationRouter.Contract.UpdateReservationParameters(&_ReservationRouter.TransactOpts, reservationVault, reservationMinAmount, reservationTxMaxFee, reservationTermSeconds, reservationDissolutionDelay, reservationMaxTotalAmount, maxReservationsPerWallet, reservationActionTimeout, reservationRenewalWindowSeconds) +} + +// UpdateReservationParameters is a paid mutator transaction binding the contract method 0x59f6408b. +// +// Solidity: function updateReservationParameters(address reservationVault, uint64 reservationMinAmount, uint64 reservationTxMaxFee, uint32 reservationTermSeconds, uint32 reservationDissolutionDelay, uint64 reservationMaxTotalAmount, uint32 maxReservationsPerWallet, uint32 reservationActionTimeout, uint32 reservationRenewalWindowSeconds) returns() +func (_ReservationRouter *ReservationRouterTransactorSession) UpdateReservationParameters(reservationVault common.Address, reservationMinAmount uint64, reservationTxMaxFee uint64, reservationTermSeconds uint32, reservationDissolutionDelay uint32, reservationMaxTotalAmount uint64, maxReservationsPerWallet uint32, reservationActionTimeout uint32, reservationRenewalWindowSeconds uint32) (*types.Transaction, error) { + return _ReservationRouter.Contract.UpdateReservationParameters(&_ReservationRouter.TransactOpts, reservationVault, reservationMinAmount, reservationTxMaxFee, reservationTermSeconds, reservationDissolutionDelay, reservationMaxTotalAmount, maxReservationsPerWallet, reservationActionTimeout, reservationRenewalWindowSeconds) +} + +// ReservationRouterGovernanceTransferredIterator is returned from FilterGovernanceTransferred and is used to iterate over the raw logs and unpacked data for GovernanceTransferred events raised by the ReservationRouter contract. +type ReservationRouterGovernanceTransferredIterator struct { + Event *ReservationRouterGovernanceTransferred // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ReservationRouterGovernanceTransferredIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ReservationRouterGovernanceTransferred) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ReservationRouterGovernanceTransferred) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ReservationRouterGovernanceTransferredIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ReservationRouterGovernanceTransferredIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ReservationRouterGovernanceTransferred represents a GovernanceTransferred event raised by the ReservationRouter contract. +type ReservationRouterGovernanceTransferred struct { + OldGovernance common.Address + NewGovernance common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterGovernanceTransferred is a free log retrieval operation binding the contract event 0x5f56bee8cffbe9a78652a74a60705edede02af10b0bbb888ca44b79a0d42ce80. +// +// Solidity: event GovernanceTransferred(address oldGovernance, address newGovernance) +func (_ReservationRouter *ReservationRouterFilterer) FilterGovernanceTransferred(opts *bind.FilterOpts) (*ReservationRouterGovernanceTransferredIterator, error) { + + logs, sub, err := _ReservationRouter.contract.FilterLogs(opts, "GovernanceTransferred") + if err != nil { + return nil, err + } + return &ReservationRouterGovernanceTransferredIterator{contract: _ReservationRouter.contract, event: "GovernanceTransferred", logs: logs, sub: sub}, nil +} + +// WatchGovernanceTransferred is a free log subscription operation binding the contract event 0x5f56bee8cffbe9a78652a74a60705edede02af10b0bbb888ca44b79a0d42ce80. +// +// Solidity: event GovernanceTransferred(address oldGovernance, address newGovernance) +func (_ReservationRouter *ReservationRouterFilterer) WatchGovernanceTransferred(opts *bind.WatchOpts, sink chan<- *ReservationRouterGovernanceTransferred) (event.Subscription, error) { + + logs, sub, err := _ReservationRouter.contract.WatchLogs(opts, "GovernanceTransferred") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ReservationRouterGovernanceTransferred) + if err := _ReservationRouter.contract.UnpackLog(event, "GovernanceTransferred", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseGovernanceTransferred is a log parse operation binding the contract event 0x5f56bee8cffbe9a78652a74a60705edede02af10b0bbb888ca44b79a0d42ce80. +// +// Solidity: event GovernanceTransferred(address oldGovernance, address newGovernance) +func (_ReservationRouter *ReservationRouterFilterer) ParseGovernanceTransferred(log types.Log) (*ReservationRouterGovernanceTransferred, error) { + event := new(ReservationRouterGovernanceTransferred) + if err := _ReservationRouter.contract.UnpackLog(event, "GovernanceTransferred", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ReservationRouterInitializedIterator is returned from FilterInitialized and is used to iterate over the raw logs and unpacked data for Initialized events raised by the ReservationRouter contract. +type ReservationRouterInitializedIterator struct { + Event *ReservationRouterInitialized // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ReservationRouterInitializedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ReservationRouterInitialized) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ReservationRouterInitialized) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ReservationRouterInitializedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ReservationRouterInitializedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ReservationRouterInitialized represents a Initialized event raised by the ReservationRouter contract. +type ReservationRouterInitialized struct { + Version uint8 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterInitialized is a free log retrieval operation binding the contract event 0x7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb3847402498. +// +// Solidity: event Initialized(uint8 version) +func (_ReservationRouter *ReservationRouterFilterer) FilterInitialized(opts *bind.FilterOpts) (*ReservationRouterInitializedIterator, error) { + + logs, sub, err := _ReservationRouter.contract.FilterLogs(opts, "Initialized") + if err != nil { + return nil, err + } + return &ReservationRouterInitializedIterator{contract: _ReservationRouter.contract, event: "Initialized", logs: logs, sub: sub}, nil +} + +// WatchInitialized is a free log subscription operation binding the contract event 0x7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb3847402498. +// +// Solidity: event Initialized(uint8 version) +func (_ReservationRouter *ReservationRouterFilterer) WatchInitialized(opts *bind.WatchOpts, sink chan<- *ReservationRouterInitialized) (event.Subscription, error) { + + logs, sub, err := _ReservationRouter.contract.WatchLogs(opts, "Initialized") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ReservationRouterInitialized) + if err := _ReservationRouter.contract.UnpackLog(event, "Initialized", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseInitialized is a log parse operation binding the contract event 0x7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb3847402498. +// +// Solidity: event Initialized(uint8 version) +func (_ReservationRouter *ReservationRouterFilterer) ParseInitialized(log types.Log) (*ReservationRouterInitialized, error) { + event := new(ReservationRouterInitialized) + if err := _ReservationRouter.contract.UnpackLog(event, "Initialized", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ReservationRouterReservationAcceptanceRequestedIterator is returned from FilterReservationAcceptanceRequested and is used to iterate over the raw logs and unpacked data for ReservationAcceptanceRequested events raised by the ReservationRouter contract. +type ReservationRouterReservationAcceptanceRequestedIterator struct { + Event *ReservationRouterReservationAcceptanceRequested // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ReservationRouterReservationAcceptanceRequestedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationAcceptanceRequested) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationAcceptanceRequested) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ReservationRouterReservationAcceptanceRequestedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ReservationRouterReservationAcceptanceRequestedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ReservationRouterReservationAcceptanceRequested represents a ReservationAcceptanceRequested event raised by the ReservationRouter contract. +type ReservationRouterReservationAcceptanceRequested struct { + ReservationKey *big.Int + RequestNonce uint64 + WalletPubKeyHash [20]byte + DepositAmount uint64 + TxMaxFee uint64 + TimeoutAt uint32 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterReservationAcceptanceRequested is a free log retrieval operation binding the contract event 0x1444a0a2e553520e4766f36c68368e10105489e52dc701d7fc9859c651475059. +// +// Solidity: event ReservationAcceptanceRequested(uint256 indexed reservationKey, uint64 requestNonce, bytes20 indexed walletPubKeyHash, uint64 depositAmount, uint64 txMaxFee, uint32 timeoutAt) +func (_ReservationRouter *ReservationRouterFilterer) FilterReservationAcceptanceRequested(opts *bind.FilterOpts, reservationKey []*big.Int, walletPubKeyHash [][20]byte) (*ReservationRouterReservationAcceptanceRequestedIterator, error) { + + var reservationKeyRule []interface{} + for _, reservationKeyItem := range reservationKey { + reservationKeyRule = append(reservationKeyRule, reservationKeyItem) + } + + var walletPubKeyHashRule []interface{} + for _, walletPubKeyHashItem := range walletPubKeyHash { + walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) + } + + logs, sub, err := _ReservationRouter.contract.FilterLogs(opts, "ReservationAcceptanceRequested", reservationKeyRule, walletPubKeyHashRule) + if err != nil { + return nil, err + } + return &ReservationRouterReservationAcceptanceRequestedIterator{contract: _ReservationRouter.contract, event: "ReservationAcceptanceRequested", logs: logs, sub: sub}, nil +} + +// WatchReservationAcceptanceRequested is a free log subscription operation binding the contract event 0x1444a0a2e553520e4766f36c68368e10105489e52dc701d7fc9859c651475059. +// +// Solidity: event ReservationAcceptanceRequested(uint256 indexed reservationKey, uint64 requestNonce, bytes20 indexed walletPubKeyHash, uint64 depositAmount, uint64 txMaxFee, uint32 timeoutAt) +func (_ReservationRouter *ReservationRouterFilterer) WatchReservationAcceptanceRequested(opts *bind.WatchOpts, sink chan<- *ReservationRouterReservationAcceptanceRequested, reservationKey []*big.Int, walletPubKeyHash [][20]byte) (event.Subscription, error) { + + var reservationKeyRule []interface{} + for _, reservationKeyItem := range reservationKey { + reservationKeyRule = append(reservationKeyRule, reservationKeyItem) + } + + var walletPubKeyHashRule []interface{} + for _, walletPubKeyHashItem := range walletPubKeyHash { + walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) + } + + logs, sub, err := _ReservationRouter.contract.WatchLogs(opts, "ReservationAcceptanceRequested", reservationKeyRule, walletPubKeyHashRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ReservationRouterReservationAcceptanceRequested) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationAcceptanceRequested", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseReservationAcceptanceRequested is a log parse operation binding the contract event 0x1444a0a2e553520e4766f36c68368e10105489e52dc701d7fc9859c651475059. +// +// Solidity: event ReservationAcceptanceRequested(uint256 indexed reservationKey, uint64 requestNonce, bytes20 indexed walletPubKeyHash, uint64 depositAmount, uint64 txMaxFee, uint32 timeoutAt) +func (_ReservationRouter *ReservationRouterFilterer) ParseReservationAcceptanceRequested(log types.Log) (*ReservationRouterReservationAcceptanceRequested, error) { + event := new(ReservationRouterReservationAcceptanceRequested) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationAcceptanceRequested", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ReservationRouterReservationAcceptedIterator is returned from FilterReservationAccepted and is used to iterate over the raw logs and unpacked data for ReservationAccepted events raised by the ReservationRouter contract. +type ReservationRouterReservationAcceptedIterator struct { + Event *ReservationRouterReservationAccepted // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ReservationRouterReservationAcceptedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationAccepted) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationAccepted) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ReservationRouterReservationAcceptedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ReservationRouterReservationAcceptedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ReservationRouterReservationAccepted represents a ReservationAccepted event raised by the ReservationRouter contract. +type ReservationRouterReservationAccepted struct { + ReservationKey *big.Int + RequestNonce uint64 + WalletPubKeyHash [20]byte + Owner common.Address + AnchorTxHash [32]byte + AnchorAmount uint64 + ExpiresAt uint32 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterReservationAccepted is a free log retrieval operation binding the contract event 0xcdba3d32072456500fc4b138dd3c63bb0a72d568e71af8a51744bab51238b770. +// +// Solidity: event ReservationAccepted(uint256 indexed reservationKey, uint64 requestNonce, bytes20 indexed walletPubKeyHash, address indexed owner, bytes32 anchorTxHash, uint64 anchorAmount, uint32 expiresAt) +func (_ReservationRouter *ReservationRouterFilterer) FilterReservationAccepted(opts *bind.FilterOpts, reservationKey []*big.Int, walletPubKeyHash [][20]byte, owner []common.Address) (*ReservationRouterReservationAcceptedIterator, error) { + + var reservationKeyRule []interface{} + for _, reservationKeyItem := range reservationKey { + reservationKeyRule = append(reservationKeyRule, reservationKeyItem) + } + + var walletPubKeyHashRule []interface{} + for _, walletPubKeyHashItem := range walletPubKeyHash { + walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) + } + var ownerRule []interface{} + for _, ownerItem := range owner { + ownerRule = append(ownerRule, ownerItem) + } + + logs, sub, err := _ReservationRouter.contract.FilterLogs(opts, "ReservationAccepted", reservationKeyRule, walletPubKeyHashRule, ownerRule) + if err != nil { + return nil, err + } + return &ReservationRouterReservationAcceptedIterator{contract: _ReservationRouter.contract, event: "ReservationAccepted", logs: logs, sub: sub}, nil +} + +// WatchReservationAccepted is a free log subscription operation binding the contract event 0xcdba3d32072456500fc4b138dd3c63bb0a72d568e71af8a51744bab51238b770. +// +// Solidity: event ReservationAccepted(uint256 indexed reservationKey, uint64 requestNonce, bytes20 indexed walletPubKeyHash, address indexed owner, bytes32 anchorTxHash, uint64 anchorAmount, uint32 expiresAt) +func (_ReservationRouter *ReservationRouterFilterer) WatchReservationAccepted(opts *bind.WatchOpts, sink chan<- *ReservationRouterReservationAccepted, reservationKey []*big.Int, walletPubKeyHash [][20]byte, owner []common.Address) (event.Subscription, error) { + + var reservationKeyRule []interface{} + for _, reservationKeyItem := range reservationKey { + reservationKeyRule = append(reservationKeyRule, reservationKeyItem) + } + + var walletPubKeyHashRule []interface{} + for _, walletPubKeyHashItem := range walletPubKeyHash { + walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) + } + var ownerRule []interface{} + for _, ownerItem := range owner { + ownerRule = append(ownerRule, ownerItem) + } + + logs, sub, err := _ReservationRouter.contract.WatchLogs(opts, "ReservationAccepted", reservationKeyRule, walletPubKeyHashRule, ownerRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ReservationRouterReservationAccepted) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationAccepted", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseReservationAccepted is a log parse operation binding the contract event 0xcdba3d32072456500fc4b138dd3c63bb0a72d568e71af8a51744bab51238b770. +// +// Solidity: event ReservationAccepted(uint256 indexed reservationKey, uint64 requestNonce, bytes20 indexed walletPubKeyHash, address indexed owner, bytes32 anchorTxHash, uint64 anchorAmount, uint32 expiresAt) +func (_ReservationRouter *ReservationRouterFilterer) ParseReservationAccepted(log types.Log) (*ReservationRouterReservationAccepted, error) { + event := new(ReservationRouterReservationAccepted) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationAccepted", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ReservationRouterReservationActionSupersededIterator is returned from FilterReservationActionSuperseded and is used to iterate over the raw logs and unpacked data for ReservationActionSuperseded events raised by the ReservationRouter contract. +type ReservationRouterReservationActionSupersededIterator struct { + Event *ReservationRouterReservationActionSuperseded // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ReservationRouterReservationActionSupersededIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationActionSuperseded) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationActionSuperseded) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ReservationRouterReservationActionSupersededIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ReservationRouterReservationActionSupersededIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ReservationRouterReservationActionSuperseded represents a ReservationActionSuperseded event raised by the ReservationRouter contract. +type ReservationRouterReservationActionSuperseded struct { + ReservationKey *big.Int + RequestNonce uint64 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterReservationActionSuperseded is a free log retrieval operation binding the contract event 0x64979c37b08d25f639dac3b74caf99840af5995eba8a493b29dc8599312ea252. +// +// Solidity: event ReservationActionSuperseded(uint256 indexed reservationKey, uint64 requestNonce) +func (_ReservationRouter *ReservationRouterFilterer) FilterReservationActionSuperseded(opts *bind.FilterOpts, reservationKey []*big.Int) (*ReservationRouterReservationActionSupersededIterator, error) { + + var reservationKeyRule []interface{} + for _, reservationKeyItem := range reservationKey { + reservationKeyRule = append(reservationKeyRule, reservationKeyItem) + } + + logs, sub, err := _ReservationRouter.contract.FilterLogs(opts, "ReservationActionSuperseded", reservationKeyRule) + if err != nil { + return nil, err + } + return &ReservationRouterReservationActionSupersededIterator{contract: _ReservationRouter.contract, event: "ReservationActionSuperseded", logs: logs, sub: sub}, nil +} + +// WatchReservationActionSuperseded is a free log subscription operation binding the contract event 0x64979c37b08d25f639dac3b74caf99840af5995eba8a493b29dc8599312ea252. +// +// Solidity: event ReservationActionSuperseded(uint256 indexed reservationKey, uint64 requestNonce) +func (_ReservationRouter *ReservationRouterFilterer) WatchReservationActionSuperseded(opts *bind.WatchOpts, sink chan<- *ReservationRouterReservationActionSuperseded, reservationKey []*big.Int) (event.Subscription, error) { + + var reservationKeyRule []interface{} + for _, reservationKeyItem := range reservationKey { + reservationKeyRule = append(reservationKeyRule, reservationKeyItem) + } + + logs, sub, err := _ReservationRouter.contract.WatchLogs(opts, "ReservationActionSuperseded", reservationKeyRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ReservationRouterReservationActionSuperseded) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationActionSuperseded", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseReservationActionSuperseded is a log parse operation binding the contract event 0x64979c37b08d25f639dac3b74caf99840af5995eba8a493b29dc8599312ea252. +// +// Solidity: event ReservationActionSuperseded(uint256 indexed reservationKey, uint64 requestNonce) +func (_ReservationRouter *ReservationRouterFilterer) ParseReservationActionSuperseded(log types.Log) (*ReservationRouterReservationActionSuperseded, error) { + event := new(ReservationRouterReservationActionSuperseded) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationActionSuperseded", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ReservationRouterReservationActionTimedOutIterator is returned from FilterReservationActionTimedOut and is used to iterate over the raw logs and unpacked data for ReservationActionTimedOut events raised by the ReservationRouter contract. +type ReservationRouterReservationActionTimedOutIterator struct { + Event *ReservationRouterReservationActionTimedOut // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ReservationRouterReservationActionTimedOutIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationActionTimedOut) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationActionTimedOut) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ReservationRouterReservationActionTimedOutIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ReservationRouterReservationActionTimedOutIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ReservationRouterReservationActionTimedOut represents a ReservationActionTimedOut event raised by the ReservationRouter contract. +type ReservationRouterReservationActionTimedOut struct { + ReservationKey *big.Int + RequestNonce uint64 + ActionType uint8 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterReservationActionTimedOut is a free log retrieval operation binding the contract event 0xd3bb43b8c8b259f4da0efa2c7a34ce683c05d6e31864299fa4a867bb3ff218ba. +// +// Solidity: event ReservationActionTimedOut(uint256 indexed reservationKey, uint64 requestNonce, uint8 actionType) +func (_ReservationRouter *ReservationRouterFilterer) FilterReservationActionTimedOut(opts *bind.FilterOpts, reservationKey []*big.Int) (*ReservationRouterReservationActionTimedOutIterator, error) { + + var reservationKeyRule []interface{} + for _, reservationKeyItem := range reservationKey { + reservationKeyRule = append(reservationKeyRule, reservationKeyItem) + } + + logs, sub, err := _ReservationRouter.contract.FilterLogs(opts, "ReservationActionTimedOut", reservationKeyRule) + if err != nil { + return nil, err + } + return &ReservationRouterReservationActionTimedOutIterator{contract: _ReservationRouter.contract, event: "ReservationActionTimedOut", logs: logs, sub: sub}, nil +} + +// WatchReservationActionTimedOut is a free log subscription operation binding the contract event 0xd3bb43b8c8b259f4da0efa2c7a34ce683c05d6e31864299fa4a867bb3ff218ba. +// +// Solidity: event ReservationActionTimedOut(uint256 indexed reservationKey, uint64 requestNonce, uint8 actionType) +func (_ReservationRouter *ReservationRouterFilterer) WatchReservationActionTimedOut(opts *bind.WatchOpts, sink chan<- *ReservationRouterReservationActionTimedOut, reservationKey []*big.Int) (event.Subscription, error) { + + var reservationKeyRule []interface{} + for _, reservationKeyItem := range reservationKey { + reservationKeyRule = append(reservationKeyRule, reservationKeyItem) + } + + logs, sub, err := _ReservationRouter.contract.WatchLogs(opts, "ReservationActionTimedOut", reservationKeyRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ReservationRouterReservationActionTimedOut) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationActionTimedOut", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseReservationActionTimedOut is a log parse operation binding the contract event 0xd3bb43b8c8b259f4da0efa2c7a34ce683c05d6e31864299fa4a867bb3ff218ba. +// +// Solidity: event ReservationActionTimedOut(uint256 indexed reservationKey, uint64 requestNonce, uint8 actionType) +func (_ReservationRouter *ReservationRouterFilterer) ParseReservationActionTimedOut(log types.Log) (*ReservationRouterReservationActionTimedOut, error) { + event := new(ReservationRouterReservationActionTimedOut) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationActionTimedOut", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ReservationRouterReservationCapsUpdatedIterator is returned from FilterReservationCapsUpdated and is used to iterate over the raw logs and unpacked data for ReservationCapsUpdated events raised by the ReservationRouter contract. +type ReservationRouterReservationCapsUpdatedIterator struct { + Event *ReservationRouterReservationCapsUpdated // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ReservationRouterReservationCapsUpdatedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationCapsUpdated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationCapsUpdated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ReservationRouterReservationCapsUpdatedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ReservationRouterReservationCapsUpdatedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ReservationRouterReservationCapsUpdated represents a ReservationCapsUpdated event raised by the ReservationRouter contract. +type ReservationRouterReservationCapsUpdated struct { + MaxReservationsAmountPerWallet uint64 + ReservationMaxSingleAmount uint64 + MaxActiveReservations uint32 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterReservationCapsUpdated is a free log retrieval operation binding the contract event 0x846df5edb182147898ee0a522f03f78718544ce16b0f06e64fe2f593b1fb160d. +// +// Solidity: event ReservationCapsUpdated(uint64 maxReservationsAmountPerWallet, uint64 reservationMaxSingleAmount, uint32 maxActiveReservations) +func (_ReservationRouter *ReservationRouterFilterer) FilterReservationCapsUpdated(opts *bind.FilterOpts) (*ReservationRouterReservationCapsUpdatedIterator, error) { + + logs, sub, err := _ReservationRouter.contract.FilterLogs(opts, "ReservationCapsUpdated") + if err != nil { + return nil, err + } + return &ReservationRouterReservationCapsUpdatedIterator{contract: _ReservationRouter.contract, event: "ReservationCapsUpdated", logs: logs, sub: sub}, nil +} + +// WatchReservationCapsUpdated is a free log subscription operation binding the contract event 0x846df5edb182147898ee0a522f03f78718544ce16b0f06e64fe2f593b1fb160d. +// +// Solidity: event ReservationCapsUpdated(uint64 maxReservationsAmountPerWallet, uint64 reservationMaxSingleAmount, uint32 maxActiveReservations) +func (_ReservationRouter *ReservationRouterFilterer) WatchReservationCapsUpdated(opts *bind.WatchOpts, sink chan<- *ReservationRouterReservationCapsUpdated) (event.Subscription, error) { + + logs, sub, err := _ReservationRouter.contract.WatchLogs(opts, "ReservationCapsUpdated") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ReservationRouterReservationCapsUpdated) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationCapsUpdated", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseReservationCapsUpdated is a log parse operation binding the contract event 0x846df5edb182147898ee0a522f03f78718544ce16b0f06e64fe2f593b1fb160d. +// +// Solidity: event ReservationCapsUpdated(uint64 maxReservationsAmountPerWallet, uint64 reservationMaxSingleAmount, uint32 maxActiveReservations) +func (_ReservationRouter *ReservationRouterFilterer) ParseReservationCapsUpdated(log types.Log) (*ReservationRouterReservationCapsUpdated, error) { + event := new(ReservationRouterReservationCapsUpdated) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationCapsUpdated", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ReservationRouterReservationLateSettledIterator is returned from FilterReservationLateSettled and is used to iterate over the raw logs and unpacked data for ReservationLateSettled events raised by the ReservationRouter contract. +type ReservationRouterReservationLateSettledIterator struct { + Event *ReservationRouterReservationLateSettled // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ReservationRouterReservationLateSettledIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationLateSettled) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationLateSettled) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ReservationRouterReservationLateSettledIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ReservationRouterReservationLateSettledIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ReservationRouterReservationLateSettled represents a ReservationLateSettled event raised by the ReservationRouter contract. +type ReservationRouterReservationLateSettled struct { + ReservationKey *big.Int + RequestNonce uint64 + ActionType uint8 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterReservationLateSettled is a free log retrieval operation binding the contract event 0x152c5b7b78a634931e032d1ab3d0c033e2e5d00e0e75f20252767746a1fa4f6d. +// +// Solidity: event ReservationLateSettled(uint256 indexed reservationKey, uint64 requestNonce, uint8 actionType) +func (_ReservationRouter *ReservationRouterFilterer) FilterReservationLateSettled(opts *bind.FilterOpts, reservationKey []*big.Int) (*ReservationRouterReservationLateSettledIterator, error) { + + var reservationKeyRule []interface{} + for _, reservationKeyItem := range reservationKey { + reservationKeyRule = append(reservationKeyRule, reservationKeyItem) + } + + logs, sub, err := _ReservationRouter.contract.FilterLogs(opts, "ReservationLateSettled", reservationKeyRule) + if err != nil { + return nil, err + } + return &ReservationRouterReservationLateSettledIterator{contract: _ReservationRouter.contract, event: "ReservationLateSettled", logs: logs, sub: sub}, nil +} + +// WatchReservationLateSettled is a free log subscription operation binding the contract event 0x152c5b7b78a634931e032d1ab3d0c033e2e5d00e0e75f20252767746a1fa4f6d. +// +// Solidity: event ReservationLateSettled(uint256 indexed reservationKey, uint64 requestNonce, uint8 actionType) +func (_ReservationRouter *ReservationRouterFilterer) WatchReservationLateSettled(opts *bind.WatchOpts, sink chan<- *ReservationRouterReservationLateSettled, reservationKey []*big.Int) (event.Subscription, error) { + + var reservationKeyRule []interface{} + for _, reservationKeyItem := range reservationKey { + reservationKeyRule = append(reservationKeyRule, reservationKeyItem) + } + + logs, sub, err := _ReservationRouter.contract.WatchLogs(opts, "ReservationLateSettled", reservationKeyRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ReservationRouterReservationLateSettled) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationLateSettled", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseReservationLateSettled is a log parse operation binding the contract event 0x152c5b7b78a634931e032d1ab3d0c033e2e5d00e0e75f20252767746a1fa4f6d. +// +// Solidity: event ReservationLateSettled(uint256 indexed reservationKey, uint64 requestNonce, uint8 actionType) +func (_ReservationRouter *ReservationRouterFilterer) ParseReservationLateSettled(log types.Log) (*ReservationRouterReservationLateSettled, error) { + event := new(ReservationRouterReservationLateSettled) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationLateSettled", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ReservationRouterReservationParametersUpdatedIterator is returned from FilterReservationParametersUpdated and is used to iterate over the raw logs and unpacked data for ReservationParametersUpdated events raised by the ReservationRouter contract. +type ReservationRouterReservationParametersUpdatedIterator struct { + Event *ReservationRouterReservationParametersUpdated // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ReservationRouterReservationParametersUpdatedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationParametersUpdated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationParametersUpdated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ReservationRouterReservationParametersUpdatedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ReservationRouterReservationParametersUpdatedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ReservationRouterReservationParametersUpdated represents a ReservationParametersUpdated event raised by the ReservationRouter contract. +type ReservationRouterReservationParametersUpdated struct { + ReservationMinAmount uint64 + ReservationTxMaxFee uint64 + ReservationTermSeconds uint32 + ReservationDissolutionDelay uint32 + ReservationMaxTotalAmount uint64 + MaxReservationsPerWallet uint32 + ReservationActionTimeout uint32 + ReservationRenewalWindowSeconds uint32 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterReservationParametersUpdated is a free log retrieval operation binding the contract event 0x7e6c56281c83edd8db45ddcb09afd1cc2cc009bcc94213c657592cb15a5b2901. +// +// Solidity: event ReservationParametersUpdated(uint64 reservationMinAmount, uint64 reservationTxMaxFee, uint32 reservationTermSeconds, uint32 reservationDissolutionDelay, uint64 reservationMaxTotalAmount, uint32 maxReservationsPerWallet, uint32 reservationActionTimeout, uint32 reservationRenewalWindowSeconds) +func (_ReservationRouter *ReservationRouterFilterer) FilterReservationParametersUpdated(opts *bind.FilterOpts) (*ReservationRouterReservationParametersUpdatedIterator, error) { + + logs, sub, err := _ReservationRouter.contract.FilterLogs(opts, "ReservationParametersUpdated") + if err != nil { + return nil, err + } + return &ReservationRouterReservationParametersUpdatedIterator{contract: _ReservationRouter.contract, event: "ReservationParametersUpdated", logs: logs, sub: sub}, nil +} + +// WatchReservationParametersUpdated is a free log subscription operation binding the contract event 0x7e6c56281c83edd8db45ddcb09afd1cc2cc009bcc94213c657592cb15a5b2901. +// +// Solidity: event ReservationParametersUpdated(uint64 reservationMinAmount, uint64 reservationTxMaxFee, uint32 reservationTermSeconds, uint32 reservationDissolutionDelay, uint64 reservationMaxTotalAmount, uint32 maxReservationsPerWallet, uint32 reservationActionTimeout, uint32 reservationRenewalWindowSeconds) +func (_ReservationRouter *ReservationRouterFilterer) WatchReservationParametersUpdated(opts *bind.WatchOpts, sink chan<- *ReservationRouterReservationParametersUpdated) (event.Subscription, error) { + + logs, sub, err := _ReservationRouter.contract.WatchLogs(opts, "ReservationParametersUpdated") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ReservationRouterReservationParametersUpdated) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationParametersUpdated", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseReservationParametersUpdated is a log parse operation binding the contract event 0x7e6c56281c83edd8db45ddcb09afd1cc2cc009bcc94213c657592cb15a5b2901. +// +// Solidity: event ReservationParametersUpdated(uint64 reservationMinAmount, uint64 reservationTxMaxFee, uint32 reservationTermSeconds, uint32 reservationDissolutionDelay, uint64 reservationMaxTotalAmount, uint32 maxReservationsPerWallet, uint32 reservationActionTimeout, uint32 reservationRenewalWindowSeconds) +func (_ReservationRouter *ReservationRouterFilterer) ParseReservationParametersUpdated(log types.Log) (*ReservationRouterReservationParametersUpdated, error) { + event := new(ReservationRouterReservationParametersUpdated) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationParametersUpdated", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ReservationRouterReservationReanchorRequestedIterator is returned from FilterReservationReanchorRequested and is used to iterate over the raw logs and unpacked data for ReservationReanchorRequested events raised by the ReservationRouter contract. +type ReservationRouterReservationReanchorRequestedIterator struct { + Event *ReservationRouterReservationReanchorRequested // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ReservationRouterReservationReanchorRequestedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationReanchorRequested) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationReanchorRequested) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ReservationRouterReservationReanchorRequestedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ReservationRouterReservationReanchorRequestedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ReservationRouterReservationReanchorRequested represents a ReservationReanchorRequested event raised by the ReservationRouter contract. +type ReservationRouterReservationReanchorRequested struct { + ReservationKey *big.Int + RequestNonce uint64 + SourceWalletPubKeyHash [20]byte + TargetWalletPubKeyHash [20]byte + TxMaxFee uint64 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterReservationReanchorRequested is a free log retrieval operation binding the contract event 0x90323e7ede1e7009754d91387f23522528e6356b2667952ff305a500ffaa9c6d. +// +// Solidity: event ReservationReanchorRequested(uint256 indexed reservationKey, uint64 requestNonce, bytes20 indexed sourceWalletPubKeyHash, bytes20 indexed targetWalletPubKeyHash, uint64 txMaxFee) +func (_ReservationRouter *ReservationRouterFilterer) FilterReservationReanchorRequested(opts *bind.FilterOpts, reservationKey []*big.Int, sourceWalletPubKeyHash [][20]byte, targetWalletPubKeyHash [][20]byte) (*ReservationRouterReservationReanchorRequestedIterator, error) { + + var reservationKeyRule []interface{} + for _, reservationKeyItem := range reservationKey { + reservationKeyRule = append(reservationKeyRule, reservationKeyItem) + } + + var sourceWalletPubKeyHashRule []interface{} + for _, sourceWalletPubKeyHashItem := range sourceWalletPubKeyHash { + sourceWalletPubKeyHashRule = append(sourceWalletPubKeyHashRule, sourceWalletPubKeyHashItem) + } + var targetWalletPubKeyHashRule []interface{} + for _, targetWalletPubKeyHashItem := range targetWalletPubKeyHash { + targetWalletPubKeyHashRule = append(targetWalletPubKeyHashRule, targetWalletPubKeyHashItem) + } + + logs, sub, err := _ReservationRouter.contract.FilterLogs(opts, "ReservationReanchorRequested", reservationKeyRule, sourceWalletPubKeyHashRule, targetWalletPubKeyHashRule) + if err != nil { + return nil, err + } + return &ReservationRouterReservationReanchorRequestedIterator{contract: _ReservationRouter.contract, event: "ReservationReanchorRequested", logs: logs, sub: sub}, nil +} + +// WatchReservationReanchorRequested is a free log subscription operation binding the contract event 0x90323e7ede1e7009754d91387f23522528e6356b2667952ff305a500ffaa9c6d. +// +// Solidity: event ReservationReanchorRequested(uint256 indexed reservationKey, uint64 requestNonce, bytes20 indexed sourceWalletPubKeyHash, bytes20 indexed targetWalletPubKeyHash, uint64 txMaxFee) +func (_ReservationRouter *ReservationRouterFilterer) WatchReservationReanchorRequested(opts *bind.WatchOpts, sink chan<- *ReservationRouterReservationReanchorRequested, reservationKey []*big.Int, sourceWalletPubKeyHash [][20]byte, targetWalletPubKeyHash [][20]byte) (event.Subscription, error) { + + var reservationKeyRule []interface{} + for _, reservationKeyItem := range reservationKey { + reservationKeyRule = append(reservationKeyRule, reservationKeyItem) + } + + var sourceWalletPubKeyHashRule []interface{} + for _, sourceWalletPubKeyHashItem := range sourceWalletPubKeyHash { + sourceWalletPubKeyHashRule = append(sourceWalletPubKeyHashRule, sourceWalletPubKeyHashItem) + } + var targetWalletPubKeyHashRule []interface{} + for _, targetWalletPubKeyHashItem := range targetWalletPubKeyHash { + targetWalletPubKeyHashRule = append(targetWalletPubKeyHashRule, targetWalletPubKeyHashItem) + } + + logs, sub, err := _ReservationRouter.contract.WatchLogs(opts, "ReservationReanchorRequested", reservationKeyRule, sourceWalletPubKeyHashRule, targetWalletPubKeyHashRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ReservationRouterReservationReanchorRequested) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationReanchorRequested", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseReservationReanchorRequested is a log parse operation binding the contract event 0x90323e7ede1e7009754d91387f23522528e6356b2667952ff305a500ffaa9c6d. +// +// Solidity: event ReservationReanchorRequested(uint256 indexed reservationKey, uint64 requestNonce, bytes20 indexed sourceWalletPubKeyHash, bytes20 indexed targetWalletPubKeyHash, uint64 txMaxFee) +func (_ReservationRouter *ReservationRouterFilterer) ParseReservationReanchorRequested(log types.Log) (*ReservationRouterReservationReanchorRequested, error) { + event := new(ReservationRouterReservationReanchorRequested) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationReanchorRequested", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ReservationRouterReservationReanchoredIterator is returned from FilterReservationReanchored and is used to iterate over the raw logs and unpacked data for ReservationReanchored events raised by the ReservationRouter contract. +type ReservationRouterReservationReanchoredIterator struct { + Event *ReservationRouterReservationReanchored // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ReservationRouterReservationReanchoredIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationReanchored) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationReanchored) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ReservationRouterReservationReanchoredIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ReservationRouterReservationReanchoredIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ReservationRouterReservationReanchored represents a ReservationReanchored event raised by the ReservationRouter contract. +type ReservationRouterReservationReanchored struct { + ReservationKey *big.Int + RequestNonce uint64 + NewWalletPubKeyHash [20]byte + NewAnchorTxHash [32]byte + NewAnchorAmount uint64 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterReservationReanchored is a free log retrieval operation binding the contract event 0xe42922c665e9600f84def7070f9dfeeb5dd650fb3522fb6bb2326e676bd23319. +// +// Solidity: event ReservationReanchored(uint256 indexed reservationKey, uint64 requestNonce, bytes20 indexed newWalletPubKeyHash, bytes32 newAnchorTxHash, uint64 newAnchorAmount) +func (_ReservationRouter *ReservationRouterFilterer) FilterReservationReanchored(opts *bind.FilterOpts, reservationKey []*big.Int, newWalletPubKeyHash [][20]byte) (*ReservationRouterReservationReanchoredIterator, error) { + + var reservationKeyRule []interface{} + for _, reservationKeyItem := range reservationKey { + reservationKeyRule = append(reservationKeyRule, reservationKeyItem) + } + + var newWalletPubKeyHashRule []interface{} + for _, newWalletPubKeyHashItem := range newWalletPubKeyHash { + newWalletPubKeyHashRule = append(newWalletPubKeyHashRule, newWalletPubKeyHashItem) + } + + logs, sub, err := _ReservationRouter.contract.FilterLogs(opts, "ReservationReanchored", reservationKeyRule, newWalletPubKeyHashRule) + if err != nil { + return nil, err + } + return &ReservationRouterReservationReanchoredIterator{contract: _ReservationRouter.contract, event: "ReservationReanchored", logs: logs, sub: sub}, nil +} + +// WatchReservationReanchored is a free log subscription operation binding the contract event 0xe42922c665e9600f84def7070f9dfeeb5dd650fb3522fb6bb2326e676bd23319. +// +// Solidity: event ReservationReanchored(uint256 indexed reservationKey, uint64 requestNonce, bytes20 indexed newWalletPubKeyHash, bytes32 newAnchorTxHash, uint64 newAnchorAmount) +func (_ReservationRouter *ReservationRouterFilterer) WatchReservationReanchored(opts *bind.WatchOpts, sink chan<- *ReservationRouterReservationReanchored, reservationKey []*big.Int, newWalletPubKeyHash [][20]byte) (event.Subscription, error) { + + var reservationKeyRule []interface{} + for _, reservationKeyItem := range reservationKey { + reservationKeyRule = append(reservationKeyRule, reservationKeyItem) + } + + var newWalletPubKeyHashRule []interface{} + for _, newWalletPubKeyHashItem := range newWalletPubKeyHash { + newWalletPubKeyHashRule = append(newWalletPubKeyHashRule, newWalletPubKeyHashItem) + } + + logs, sub, err := _ReservationRouter.contract.WatchLogs(opts, "ReservationReanchored", reservationKeyRule, newWalletPubKeyHashRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ReservationRouterReservationReanchored) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationReanchored", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseReservationReanchored is a log parse operation binding the contract event 0xe42922c665e9600f84def7070f9dfeeb5dd650fb3522fb6bb2326e676bd23319. +// +// Solidity: event ReservationReanchored(uint256 indexed reservationKey, uint64 requestNonce, bytes20 indexed newWalletPubKeyHash, bytes32 newAnchorTxHash, uint64 newAnchorAmount) +func (_ReservationRouter *ReservationRouterFilterer) ParseReservationReanchored(log types.Log) (*ReservationRouterReservationReanchored, error) { + event := new(ReservationRouterReservationReanchored) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationReanchored", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ReservationRouterReservationRetryCreditMintedIterator is returned from FilterReservationRetryCreditMinted and is used to iterate over the raw logs and unpacked data for ReservationRetryCreditMinted events raised by the ReservationRouter contract. +type ReservationRouterReservationRetryCreditMintedIterator struct { + Event *ReservationRouterReservationRetryCreditMinted // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ReservationRouterReservationRetryCreditMintedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationRetryCreditMinted) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationRetryCreditMinted) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ReservationRouterReservationRetryCreditMintedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ReservationRouterReservationRetryCreditMintedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ReservationRouterReservationRetryCreditMinted represents a ReservationRetryCreditMinted event raised by the ReservationRouter contract. +type ReservationRouterReservationRetryCreditMinted struct { + ReservationKey *big.Int + Raw types.Log // Blockchain specific contextual infos +} + +// FilterReservationRetryCreditMinted is a free log retrieval operation binding the contract event 0x919795353bc408e11a0d5a133a9c6969367014ad975910a8b31d08b12597c4c2. +// +// Solidity: event ReservationRetryCreditMinted(uint256 indexed reservationKey) +func (_ReservationRouter *ReservationRouterFilterer) FilterReservationRetryCreditMinted(opts *bind.FilterOpts, reservationKey []*big.Int) (*ReservationRouterReservationRetryCreditMintedIterator, error) { + + var reservationKeyRule []interface{} + for _, reservationKeyItem := range reservationKey { + reservationKeyRule = append(reservationKeyRule, reservationKeyItem) + } + + logs, sub, err := _ReservationRouter.contract.FilterLogs(opts, "ReservationRetryCreditMinted", reservationKeyRule) + if err != nil { + return nil, err + } + return &ReservationRouterReservationRetryCreditMintedIterator{contract: _ReservationRouter.contract, event: "ReservationRetryCreditMinted", logs: logs, sub: sub}, nil +} + +// WatchReservationRetryCreditMinted is a free log subscription operation binding the contract event 0x919795353bc408e11a0d5a133a9c6969367014ad975910a8b31d08b12597c4c2. +// +// Solidity: event ReservationRetryCreditMinted(uint256 indexed reservationKey) +func (_ReservationRouter *ReservationRouterFilterer) WatchReservationRetryCreditMinted(opts *bind.WatchOpts, sink chan<- *ReservationRouterReservationRetryCreditMinted, reservationKey []*big.Int) (event.Subscription, error) { + + var reservationKeyRule []interface{} + for _, reservationKeyItem := range reservationKey { + reservationKeyRule = append(reservationKeyRule, reservationKeyItem) + } + + logs, sub, err := _ReservationRouter.contract.WatchLogs(opts, "ReservationRetryCreditMinted", reservationKeyRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ReservationRouterReservationRetryCreditMinted) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationRetryCreditMinted", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseReservationRetryCreditMinted is a log parse operation binding the contract event 0x919795353bc408e11a0d5a133a9c6969367014ad975910a8b31d08b12597c4c2. +// +// Solidity: event ReservationRetryCreditMinted(uint256 indexed reservationKey) +func (_ReservationRouter *ReservationRouterFilterer) ParseReservationRetryCreditMinted(log types.Log) (*ReservationRouterReservationRetryCreditMinted, error) { + event := new(ReservationRouterReservationRetryCreditMinted) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationRetryCreditMinted", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ReservationRouterReservationRouterSetIterator is returned from FilterReservationRouterSet and is used to iterate over the raw logs and unpacked data for ReservationRouterSet events raised by the ReservationRouter contract. +type ReservationRouterReservationRouterSetIterator struct { + Event *ReservationRouterReservationRouterSet // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ReservationRouterReservationRouterSetIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationRouterSet) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationRouterSet) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ReservationRouterReservationRouterSetIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ReservationRouterReservationRouterSetIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ReservationRouterReservationRouterSet represents a ReservationRouterSet event raised by the ReservationRouter contract. +type ReservationRouterReservationRouterSet struct { + ReservationRouter common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterReservationRouterSet is a free log retrieval operation binding the contract event 0xd9eacf62803dd1f1bb5342d8eb5951546c371915e06223f589c0c95486c7c769. +// +// Solidity: event ReservationRouterSet(address reservationRouter) +func (_ReservationRouter *ReservationRouterFilterer) FilterReservationRouterSet(opts *bind.FilterOpts) (*ReservationRouterReservationRouterSetIterator, error) { + + logs, sub, err := _ReservationRouter.contract.FilterLogs(opts, "ReservationRouterSet") + if err != nil { + return nil, err + } + return &ReservationRouterReservationRouterSetIterator{contract: _ReservationRouter.contract, event: "ReservationRouterSet", logs: logs, sub: sub}, nil +} + +// WatchReservationRouterSet is a free log subscription operation binding the contract event 0xd9eacf62803dd1f1bb5342d8eb5951546c371915e06223f589c0c95486c7c769. +// +// Solidity: event ReservationRouterSet(address reservationRouter) +func (_ReservationRouter *ReservationRouterFilterer) WatchReservationRouterSet(opts *bind.WatchOpts, sink chan<- *ReservationRouterReservationRouterSet) (event.Subscription, error) { + + logs, sub, err := _ReservationRouter.contract.WatchLogs(opts, "ReservationRouterSet") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ReservationRouterReservationRouterSet) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationRouterSet", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseReservationRouterSet is a log parse operation binding the contract event 0xd9eacf62803dd1f1bb5342d8eb5951546c371915e06223f589c0c95486c7c769. +// +// Solidity: event ReservationRouterSet(address reservationRouter) +func (_ReservationRouter *ReservationRouterFilterer) ParseReservationRouterSet(log types.Log) (*ReservationRouterReservationRouterSet, error) { + event := new(ReservationRouterReservationRouterSet) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationRouterSet", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ReservationRouterReservationStrandedIterator is returned from FilterReservationStranded and is used to iterate over the raw logs and unpacked data for ReservationStranded events raised by the ReservationRouter contract. +type ReservationRouterReservationStrandedIterator struct { + Event *ReservationRouterReservationStranded // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ReservationRouterReservationStrandedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationStranded) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationStranded) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ReservationRouterReservationStrandedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ReservationRouterReservationStrandedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ReservationRouterReservationStranded represents a ReservationStranded event raised by the ReservationRouter contract. +type ReservationRouterReservationStranded struct { + ReservationKey *big.Int + WalletPubKeyHash [20]byte + Owner common.Address + AnchorAmount uint64 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterReservationStranded is a free log retrieval operation binding the contract event 0x95a304fee209cd8169392534093ab2cb9ee3b8c7031cf873b2f0c40a03b44d4d. +// +// Solidity: event ReservationStranded(uint256 indexed reservationKey, bytes20 indexed walletPubKeyHash, address indexed owner, uint64 anchorAmount) +func (_ReservationRouter *ReservationRouterFilterer) FilterReservationStranded(opts *bind.FilterOpts, reservationKey []*big.Int, walletPubKeyHash [][20]byte, owner []common.Address) (*ReservationRouterReservationStrandedIterator, error) { + + var reservationKeyRule []interface{} + for _, reservationKeyItem := range reservationKey { + reservationKeyRule = append(reservationKeyRule, reservationKeyItem) + } + var walletPubKeyHashRule []interface{} + for _, walletPubKeyHashItem := range walletPubKeyHash { + walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) + } + var ownerRule []interface{} + for _, ownerItem := range owner { + ownerRule = append(ownerRule, ownerItem) + } + + logs, sub, err := _ReservationRouter.contract.FilterLogs(opts, "ReservationStranded", reservationKeyRule, walletPubKeyHashRule, ownerRule) + if err != nil { + return nil, err + } + return &ReservationRouterReservationStrandedIterator{contract: _ReservationRouter.contract, event: "ReservationStranded", logs: logs, sub: sub}, nil +} + +// WatchReservationStranded is a free log subscription operation binding the contract event 0x95a304fee209cd8169392534093ab2cb9ee3b8c7031cf873b2f0c40a03b44d4d. +// +// Solidity: event ReservationStranded(uint256 indexed reservationKey, bytes20 indexed walletPubKeyHash, address indexed owner, uint64 anchorAmount) +func (_ReservationRouter *ReservationRouterFilterer) WatchReservationStranded(opts *bind.WatchOpts, sink chan<- *ReservationRouterReservationStranded, reservationKey []*big.Int, walletPubKeyHash [][20]byte, owner []common.Address) (event.Subscription, error) { + + var reservationKeyRule []interface{} + for _, reservationKeyItem := range reservationKey { + reservationKeyRule = append(reservationKeyRule, reservationKeyItem) + } + var walletPubKeyHashRule []interface{} + for _, walletPubKeyHashItem := range walletPubKeyHash { + walletPubKeyHashRule = append(walletPubKeyHashRule, walletPubKeyHashItem) + } + var ownerRule []interface{} + for _, ownerItem := range owner { + ownerRule = append(ownerRule, ownerItem) + } + + logs, sub, err := _ReservationRouter.contract.WatchLogs(opts, "ReservationStranded", reservationKeyRule, walletPubKeyHashRule, ownerRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ReservationRouterReservationStranded) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationStranded", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseReservationStranded is a log parse operation binding the contract event 0x95a304fee209cd8169392534093ab2cb9ee3b8c7031cf873b2f0c40a03b44d4d. +// +// Solidity: event ReservationStranded(uint256 indexed reservationKey, bytes20 indexed walletPubKeyHash, address indexed owner, uint64 anchorAmount) +func (_ReservationRouter *ReservationRouterFilterer) ParseReservationStranded(log types.Log) (*ReservationRouterReservationStranded, error) { + event := new(ReservationRouterReservationStranded) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationStranded", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ReservationRouterReservationVaultUpdatedIterator is returned from FilterReservationVaultUpdated and is used to iterate over the raw logs and unpacked data for ReservationVaultUpdated events raised by the ReservationRouter contract. +type ReservationRouterReservationVaultUpdatedIterator struct { + Event *ReservationRouterReservationVaultUpdated // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ReservationRouterReservationVaultUpdatedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationVaultUpdated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservationVaultUpdated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ReservationRouterReservationVaultUpdatedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ReservationRouterReservationVaultUpdatedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ReservationRouterReservationVaultUpdated represents a ReservationVaultUpdated event raised by the ReservationRouter contract. +type ReservationRouterReservationVaultUpdated struct { + ReservationVault common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterReservationVaultUpdated is a free log retrieval operation binding the contract event 0x81b37784221191020714846b5fdcdfcbde796cfad2627d47dc81c2b7765b1910. +// +// Solidity: event ReservationVaultUpdated(address reservationVault) +func (_ReservationRouter *ReservationRouterFilterer) FilterReservationVaultUpdated(opts *bind.FilterOpts) (*ReservationRouterReservationVaultUpdatedIterator, error) { + + logs, sub, err := _ReservationRouter.contract.FilterLogs(opts, "ReservationVaultUpdated") + if err != nil { + return nil, err + } + return &ReservationRouterReservationVaultUpdatedIterator{contract: _ReservationRouter.contract, event: "ReservationVaultUpdated", logs: logs, sub: sub}, nil +} + +// WatchReservationVaultUpdated is a free log subscription operation binding the contract event 0x81b37784221191020714846b5fdcdfcbde796cfad2627d47dc81c2b7765b1910. +// +// Solidity: event ReservationVaultUpdated(address reservationVault) +func (_ReservationRouter *ReservationRouterFilterer) WatchReservationVaultUpdated(opts *bind.WatchOpts, sink chan<- *ReservationRouterReservationVaultUpdated) (event.Subscription, error) { + + logs, sub, err := _ReservationRouter.contract.WatchLogs(opts, "ReservationVaultUpdated") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ReservationRouterReservationVaultUpdated) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationVaultUpdated", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseReservationVaultUpdated is a log parse operation binding the contract event 0x81b37784221191020714846b5fdcdfcbde796cfad2627d47dc81c2b7765b1910. +// +// Solidity: event ReservationVaultUpdated(address reservationVault) +func (_ReservationRouter *ReservationRouterFilterer) ParseReservationVaultUpdated(log types.Log) (*ReservationRouterReservationVaultUpdated, error) { + event := new(ReservationRouterReservationVaultUpdated) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservationVaultUpdated", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ReservationRouterReservedDepositMarkedStaleIterator is returned from FilterReservedDepositMarkedStale and is used to iterate over the raw logs and unpacked data for ReservedDepositMarkedStale events raised by the ReservationRouter contract. +type ReservationRouterReservedDepositMarkedStaleIterator struct { + Event *ReservationRouterReservedDepositMarkedStale // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ReservationRouterReservedDepositMarkedStaleIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservedDepositMarkedStale) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ReservationRouterReservedDepositMarkedStale) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ReservationRouterReservedDepositMarkedStaleIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ReservationRouterReservedDepositMarkedStaleIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ReservationRouterReservedDepositMarkedStale represents a ReservedDepositMarkedStale event raised by the ReservationRouter contract. +type ReservationRouterReservedDepositMarkedStale struct { + DepositKey *big.Int + Raw types.Log // Blockchain specific contextual infos +} + +// FilterReservedDepositMarkedStale is a free log retrieval operation binding the contract event 0xf4a10f7395c3a5e8165714c665ddb7f2e320f8bc2a21d9f8722f48f8d1d71eab. +// +// Solidity: event ReservedDepositMarkedStale(uint256 indexed depositKey) +func (_ReservationRouter *ReservationRouterFilterer) FilterReservedDepositMarkedStale(opts *bind.FilterOpts, depositKey []*big.Int) (*ReservationRouterReservedDepositMarkedStaleIterator, error) { + + var depositKeyRule []interface{} + for _, depositKeyItem := range depositKey { + depositKeyRule = append(depositKeyRule, depositKeyItem) + } + + logs, sub, err := _ReservationRouter.contract.FilterLogs(opts, "ReservedDepositMarkedStale", depositKeyRule) + if err != nil { + return nil, err + } + return &ReservationRouterReservedDepositMarkedStaleIterator{contract: _ReservationRouter.contract, event: "ReservedDepositMarkedStale", logs: logs, sub: sub}, nil +} + +// WatchReservedDepositMarkedStale is a free log subscription operation binding the contract event 0xf4a10f7395c3a5e8165714c665ddb7f2e320f8bc2a21d9f8722f48f8d1d71eab. +// +// Solidity: event ReservedDepositMarkedStale(uint256 indexed depositKey) +func (_ReservationRouter *ReservationRouterFilterer) WatchReservedDepositMarkedStale(opts *bind.WatchOpts, sink chan<- *ReservationRouterReservedDepositMarkedStale, depositKey []*big.Int) (event.Subscription, error) { + + var depositKeyRule []interface{} + for _, depositKeyItem := range depositKey { + depositKeyRule = append(depositKeyRule, depositKeyItem) + } + + logs, sub, err := _ReservationRouter.contract.WatchLogs(opts, "ReservedDepositMarkedStale", depositKeyRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ReservationRouterReservedDepositMarkedStale) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservedDepositMarkedStale", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseReservedDepositMarkedStale is a log parse operation binding the contract event 0xf4a10f7395c3a5e8165714c665ddb7f2e320f8bc2a21d9f8722f48f8d1d71eab. +// +// Solidity: event ReservedDepositMarkedStale(uint256 indexed depositKey) +func (_ReservationRouter *ReservationRouterFilterer) ParseReservedDepositMarkedStale(log types.Log) (*ReservationRouterReservedDepositMarkedStale, error) { + event := new(ReservationRouterReservedDepositMarkedStale) + if err := _ReservationRouter.contract.UnpackLog(event, "ReservedDepositMarkedStale", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} diff --git a/pkg/chain/ethereum/tbtc/gen/abi/WalletProposalValidator.go b/pkg/chain/ethereum/tbtc/gen/abi/WalletProposalValidator.go index ed86d98785..c3197e49c0 100644 --- a/pkg/chain/ethereum/tbtc/gen/abi/WalletProposalValidator.go +++ b/pkg/chain/ethereum/tbtc/gen/abi/WalletProposalValidator.go @@ -95,9 +95,24 @@ type WalletProposalValidatorRedemptionProposal struct { RedemptionTxFee *big.Int } +// WalletProposalValidatorReservationAnchorProposal is an auto generated low-level Go binding around an user-defined struct. +type WalletProposalValidatorReservationAnchorProposal struct { + WalletPubKeyHash [20]byte + DepositKey WalletProposalValidatorDepositKey + AnchorTxFee *big.Int +} + +// WalletProposalValidatorReservationReanchorProposal is an auto generated low-level Go binding around an user-defined struct. +type WalletProposalValidatorReservationReanchorProposal struct { + SourceWalletPubKeyHash [20]byte + ReservationKey *big.Int + TargetWalletPubKeyHash [20]byte + ReanchorTxFee *big.Int +} + // WalletProposalValidatorMetaData contains all meta data concerning the WalletProposalValidator contract. var WalletProposalValidatorMetaData = &bind.MetaData{ - ABI: "[{\"inputs\":[{\"internalType\":\"contractBridge\",\"name\":\"_bridge\",\"type\":\"address\"}],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"inputs\":[],\"name\":\"DEPOSIT_MIN_AGE\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"DEPOSIT_REFUND_SAFETY_MARGIN\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"DEPOSIT_SWEEP_MAX_SIZE\",\"outputs\":[{\"internalType\":\"uint16\",\"name\":\"\",\"type\":\"uint16\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"REDEMPTION_MAX_SIZE\",\"outputs\":[{\"internalType\":\"uint16\",\"name\":\"\",\"type\":\"uint16\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"REDEMPTION_REQUEST_MIN_AGE\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"REDEMPTION_REQUEST_TIMEOUT_SAFETY_MARGIN\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"bridge\",\"outputs\":[{\"internalType\":\"contractBridge\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"fundingTxHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"fundingOutputIndex\",\"type\":\"uint32\"}],\"internalType\":\"structWalletProposalValidator.DepositKey[]\",\"name\":\"depositsKeys\",\"type\":\"tuple[]\"},{\"internalType\":\"uint256\",\"name\":\"sweepTxFee\",\"type\":\"uint256\"},{\"internalType\":\"uint256[]\",\"name\":\"depositsRevealBlocks\",\"type\":\"uint256[]\"}],\"internalType\":\"structWalletProposalValidator.DepositSweepProposal\",\"name\":\"proposal\",\"type\":\"tuple\"},{\"components\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"fundingTx\",\"type\":\"tuple\"},{\"internalType\":\"bytes8\",\"name\":\"blindingFactor\",\"type\":\"bytes8\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes20\",\"name\":\"refundPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes4\",\"name\":\"refundLocktime\",\"type\":\"bytes4\"}],\"internalType\":\"structWalletProposalValidator.DepositExtraInfo[]\",\"name\":\"depositsExtraInfo\",\"type\":\"tuple[]\"}],\"name\":\"validateDepositSweepProposal\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes\",\"name\":\"message\",\"type\":\"bytes\"}],\"internalType\":\"structWalletProposalValidator.HeartbeatProposal\",\"name\":\"proposal\",\"type\":\"tuple\"}],\"name\":\"validateHeartbeatProposal\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes32\",\"name\":\"movingFundsTxHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTxOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint256\",\"name\":\"movedFundsSweepTxFee\",\"type\":\"uint256\"}],\"internalType\":\"structWalletProposalValidator.MovedFundsSweepProposal\",\"name\":\"proposal\",\"type\":\"tuple\"}],\"name\":\"validateMovedFundsSweepProposal\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes20[]\",\"name\":\"targetWallets\",\"type\":\"bytes20[]\"},{\"internalType\":\"uint256\",\"name\":\"movingFundsTxFee\",\"type\":\"uint256\"}],\"internalType\":\"structWalletProposalValidator.MovingFundsProposal\",\"name\":\"proposal\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"walletMainUtxo\",\"type\":\"tuple\"}],\"name\":\"validateMovingFundsProposal\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes[]\",\"name\":\"redeemersOutputScripts\",\"type\":\"bytes[]\"},{\"internalType\":\"uint256\",\"name\":\"redemptionTxFee\",\"type\":\"uint256\"}],\"internalType\":\"structWalletProposalValidator.RedemptionProposal\",\"name\":\"proposal\",\"type\":\"tuple\"}],\"name\":\"validateRedemptionProposal\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"}]", + ABI: "[{\"inputs\":[{\"internalType\":\"contractBridge\",\"name\":\"_bridge\",\"type\":\"address\"}],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"inputs\":[],\"name\":\"DEPOSIT_MIN_AGE\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"DEPOSIT_REFUND_SAFETY_MARGIN\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"DEPOSIT_SWEEP_MAX_SIZE\",\"outputs\":[{\"internalType\":\"uint16\",\"name\":\"\",\"type\":\"uint16\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"REDEMPTION_MAX_SIZE\",\"outputs\":[{\"internalType\":\"uint16\",\"name\":\"\",\"type\":\"uint16\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"REDEMPTION_REQUEST_MIN_AGE\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"REDEMPTION_REQUEST_TIMEOUT_SAFETY_MARGIN\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"\",\"type\":\"uint32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"bridge\",\"outputs\":[{\"internalType\":\"contractBridge\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"fundingTxHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"fundingOutputIndex\",\"type\":\"uint32\"}],\"internalType\":\"structWalletProposalValidator.DepositKey[]\",\"name\":\"depositsKeys\",\"type\":\"tuple[]\"},{\"internalType\":\"uint256\",\"name\":\"sweepTxFee\",\"type\":\"uint256\"},{\"internalType\":\"uint256[]\",\"name\":\"depositsRevealBlocks\",\"type\":\"uint256[]\"}],\"internalType\":\"structWalletProposalValidator.DepositSweepProposal\",\"name\":\"proposal\",\"type\":\"tuple\"},{\"components\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"fundingTx\",\"type\":\"tuple\"},{\"internalType\":\"bytes8\",\"name\":\"blindingFactor\",\"type\":\"bytes8\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes20\",\"name\":\"refundPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes4\",\"name\":\"refundLocktime\",\"type\":\"bytes4\"}],\"internalType\":\"structWalletProposalValidator.DepositExtraInfo[]\",\"name\":\"depositsExtraInfo\",\"type\":\"tuple[]\"}],\"name\":\"validateDepositSweepProposal\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes\",\"name\":\"message\",\"type\":\"bytes\"}],\"internalType\":\"structWalletProposalValidator.HeartbeatProposal\",\"name\":\"proposal\",\"type\":\"tuple\"}],\"name\":\"validateHeartbeatProposal\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes32\",\"name\":\"movingFundsTxHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"movingFundsTxOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint256\",\"name\":\"movedFundsSweepTxFee\",\"type\":\"uint256\"}],\"internalType\":\"structWalletProposalValidator.MovedFundsSweepProposal\",\"name\":\"proposal\",\"type\":\"tuple\"}],\"name\":\"validateMovedFundsSweepProposal\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes20[]\",\"name\":\"targetWallets\",\"type\":\"bytes20[]\"},{\"internalType\":\"uint256\",\"name\":\"movingFundsTxFee\",\"type\":\"uint256\"}],\"internalType\":\"structWalletProposalValidator.MovingFundsProposal\",\"name\":\"proposal\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"txHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"txOutputIndex\",\"type\":\"uint32\"},{\"internalType\":\"uint64\",\"name\":\"txOutputValue\",\"type\":\"uint64\"}],\"internalType\":\"structBitcoinTx.UTXO\",\"name\":\"walletMainUtxo\",\"type\":\"tuple\"}],\"name\":\"validateMovingFundsProposal\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes[]\",\"name\":\"redeemersOutputScripts\",\"type\":\"bytes[]\"},{\"internalType\":\"uint256\",\"name\":\"redemptionTxFee\",\"type\":\"uint256\"}],\"internalType\":\"structWalletProposalValidator.RedemptionProposal\",\"name\":\"proposal\",\"type\":\"tuple\"}],\"name\":\"validateRedemptionProposal\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"fundingTxHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint32\",\"name\":\"fundingOutputIndex\",\"type\":\"uint32\"}],\"internalType\":\"structWalletProposalValidator.DepositKey\",\"name\":\"depositKey\",\"type\":\"tuple\"},{\"internalType\":\"uint256\",\"name\":\"anchorTxFee\",\"type\":\"uint256\"}],\"internalType\":\"structWalletProposalValidator.ReservationAnchorProposal\",\"name\":\"proposal\",\"type\":\"tuple\"},{\"components\":[{\"components\":[{\"internalType\":\"bytes4\",\"name\":\"version\",\"type\":\"bytes4\"},{\"internalType\":\"bytes\",\"name\":\"inputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"outputVector\",\"type\":\"bytes\"},{\"internalType\":\"bytes4\",\"name\":\"locktime\",\"type\":\"bytes4\"}],\"internalType\":\"structBitcoinTx.Info\",\"name\":\"fundingTx\",\"type\":\"tuple\"},{\"internalType\":\"bytes8\",\"name\":\"blindingFactor\",\"type\":\"bytes8\"},{\"internalType\":\"bytes20\",\"name\":\"walletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes20\",\"name\":\"refundPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"bytes4\",\"name\":\"refundLocktime\",\"type\":\"bytes4\"}],\"internalType\":\"structWalletProposalValidator.DepositExtraInfo\",\"name\":\"depositExtraInfo\",\"type\":\"tuple\"}],\"name\":\"validateReservationAnchorProposal\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"sourceWalletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"uint256\",\"name\":\"reservationKey\",\"type\":\"uint256\"},{\"internalType\":\"bytes20\",\"name\":\"targetWalletPubKeyHash\",\"type\":\"bytes20\"},{\"internalType\":\"uint256\",\"name\":\"reanchorTxFee\",\"type\":\"uint256\"}],\"internalType\":\"structWalletProposalValidator.ReservationReanchorProposal\",\"name\":\"proposal\",\"type\":\"tuple\"}],\"name\":\"validateReservationReanchorProposal\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"}]", } // WalletProposalValidatorABI is the input ABI used to generate the binding from. @@ -617,3 +632,65 @@ func (_WalletProposalValidator *WalletProposalValidatorSession) ValidateRedempti func (_WalletProposalValidator *WalletProposalValidatorCallerSession) ValidateRedemptionProposal(proposal WalletProposalValidatorRedemptionProposal) (bool, error) { return _WalletProposalValidator.Contract.ValidateRedemptionProposal(&_WalletProposalValidator.CallOpts, proposal) } + +// ValidateReservationAnchorProposal is a free data retrieval call binding the contract method 0xddded0a4. +// +// Solidity: function validateReservationAnchorProposal((bytes20,(bytes32,uint32),uint256) proposal, ((bytes4,bytes,bytes,bytes4),bytes8,bytes20,bytes20,bytes4) depositExtraInfo) view returns(bool) +func (_WalletProposalValidator *WalletProposalValidatorCaller) ValidateReservationAnchorProposal(opts *bind.CallOpts, proposal WalletProposalValidatorReservationAnchorProposal, depositExtraInfo WalletProposalValidatorDepositExtraInfo) (bool, error) { + var out []interface{} + err := _WalletProposalValidator.contract.Call(opts, &out, "validateReservationAnchorProposal", proposal, depositExtraInfo) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// ValidateReservationAnchorProposal is a free data retrieval call binding the contract method 0xddded0a4. +// +// Solidity: function validateReservationAnchorProposal((bytes20,(bytes32,uint32),uint256) proposal, ((bytes4,bytes,bytes,bytes4),bytes8,bytes20,bytes20,bytes4) depositExtraInfo) view returns(bool) +func (_WalletProposalValidator *WalletProposalValidatorSession) ValidateReservationAnchorProposal(proposal WalletProposalValidatorReservationAnchorProposal, depositExtraInfo WalletProposalValidatorDepositExtraInfo) (bool, error) { + return _WalletProposalValidator.Contract.ValidateReservationAnchorProposal(&_WalletProposalValidator.CallOpts, proposal, depositExtraInfo) +} + +// ValidateReservationAnchorProposal is a free data retrieval call binding the contract method 0xddded0a4. +// +// Solidity: function validateReservationAnchorProposal((bytes20,(bytes32,uint32),uint256) proposal, ((bytes4,bytes,bytes,bytes4),bytes8,bytes20,bytes20,bytes4) depositExtraInfo) view returns(bool) +func (_WalletProposalValidator *WalletProposalValidatorCallerSession) ValidateReservationAnchorProposal(proposal WalletProposalValidatorReservationAnchorProposal, depositExtraInfo WalletProposalValidatorDepositExtraInfo) (bool, error) { + return _WalletProposalValidator.Contract.ValidateReservationAnchorProposal(&_WalletProposalValidator.CallOpts, proposal, depositExtraInfo) +} + +// ValidateReservationReanchorProposal is a free data retrieval call binding the contract method 0x97dd538f. +// +// Solidity: function validateReservationReanchorProposal((bytes20,uint256,bytes20,uint256) proposal) view returns(bool) +func (_WalletProposalValidator *WalletProposalValidatorCaller) ValidateReservationReanchorProposal(opts *bind.CallOpts, proposal WalletProposalValidatorReservationReanchorProposal) (bool, error) { + var out []interface{} + err := _WalletProposalValidator.contract.Call(opts, &out, "validateReservationReanchorProposal", proposal) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// ValidateReservationReanchorProposal is a free data retrieval call binding the contract method 0x97dd538f. +// +// Solidity: function validateReservationReanchorProposal((bytes20,uint256,bytes20,uint256) proposal) view returns(bool) +func (_WalletProposalValidator *WalletProposalValidatorSession) ValidateReservationReanchorProposal(proposal WalletProposalValidatorReservationReanchorProposal) (bool, error) { + return _WalletProposalValidator.Contract.ValidateReservationReanchorProposal(&_WalletProposalValidator.CallOpts, proposal) +} + +// ValidateReservationReanchorProposal is a free data retrieval call binding the contract method 0x97dd538f. +// +// Solidity: function validateReservationReanchorProposal((bytes20,uint256,bytes20,uint256) proposal) view returns(bool) +func (_WalletProposalValidator *WalletProposalValidatorCallerSession) ValidateReservationReanchorProposal(proposal WalletProposalValidatorReservationReanchorProposal) (bool, error) { + return _WalletProposalValidator.Contract.ValidateReservationReanchorProposal(&_WalletProposalValidator.CallOpts, proposal) +} diff --git a/pkg/chain/ethereum/tbtc/gen/cmd/Bridge.go b/pkg/chain/ethereum/tbtc/gen/cmd/Bridge.go index f7a5944669..fe214dfed0 100644 --- a/pkg/chain/ethereum/tbtc/gen/cmd/Bridge.go +++ b/pkg/chain/ethereum/tbtc/gen/cmd/Bridge.go @@ -58,8 +58,11 @@ func init() { bDepositsCommand(), bFraudChallengesCommand(), bFraudParametersCommand(), + bGetRebateStakingCommand(), bGetRedemptionWatchtowerCommand(), + bGetReservationRouterCommand(), bGovernanceCommand(), + bIsReservedDepositCommand(), bIsVaultTrustedCommand(), bLiveWalletsCountCommand(), bMovedFundsSweepRequestsCommand(), @@ -77,6 +80,8 @@ func init() { bEcdsaWalletCreatedCallbackCommand(), bEcdsaWalletHeartbeatFailedCallbackCommand(), bInitializeCommand(), + bInitializeV2FixVaultZeroDepositCommand(), + bInitializeV5RepairRebateStakingCommand(), bNotifyMovingFundsBelowDustCommand(), bNotifyRedemptionVetoCommand(), bNotifyWalletCloseableCommand(), @@ -87,7 +92,9 @@ func init() { bResetMovingFundsTimeoutCommand(), bRevealDepositCommand(), bRevealDepositWithExtraDataCommand(), + bSetRebateStakingCommand(), bSetRedemptionWatchtowerCommand(), + bSetReservationRouterCommand(), bSetSpvMaintainerStatusCommand(), bSetVaultStatusCommand(), bSubmitDepositSweepProofCommand(), @@ -331,6 +338,40 @@ func bFraudParameters(c *cobra.Command, args []string) error { return nil } +func bGetRebateStakingCommand() *cobra.Command { + c := &cobra.Command{ + Use: "get-rebate-staking", + Short: "Calls the view method getRebateStaking on the Bridge contract.", + Args: cmd.ArgCountChecker(0), + RunE: bGetRebateStaking, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func bGetRebateStaking(c *cobra.Command, args []string) error { + contract, err := initializeBridge(c) + if err != nil { + return err + } + + result, err := contract.GetRebateStakingAtBlock( + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + func bGetRedemptionWatchtowerCommand() *cobra.Command { c := &cobra.Command{ Use: "get-redemption-watchtower", @@ -365,6 +406,40 @@ func bGetRedemptionWatchtower(c *cobra.Command, args []string) error { return nil } +func bGetReservationRouterCommand() *cobra.Command { + c := &cobra.Command{ + Use: "get-reservation-router", + Short: "Calls the view method getReservationRouter on the Bridge contract.", + Args: cmd.ArgCountChecker(0), + RunE: bGetReservationRouter, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func bGetReservationRouter(c *cobra.Command, args []string) error { + contract, err := initializeBridge(c) + if err != nil { + return err + } + + result, err := contract.GetReservationRouterAtBlock( + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + func bGovernanceCommand() *cobra.Command { c := &cobra.Command{ Use: "governance", @@ -399,6 +474,49 @@ func bGovernance(c *cobra.Command, args []string) error { return nil } +func bIsReservedDepositCommand() *cobra.Command { + c := &cobra.Command{ + Use: "is-reserved-deposit [arg_depositKey]", + Short: "Calls the view method isReservedDeposit on the Bridge contract.", + Args: cmd.ArgCountChecker(1), + RunE: bIsReservedDeposit, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func bIsReservedDeposit(c *cobra.Command, args []string) error { + contract, err := initializeBridge(c) + if err != nil { + return err + } + + arg_depositKey, err := hexutil.DecodeBig(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_depositKey, a uint256, from passed value %v", + args[0], + ) + } + + result, err := contract.IsReservedDepositAtBlock( + arg_depositKey, + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + func bIsVaultTrustedCommand() *cobra.Command { c := &cobra.Command{ Use: "is-vault-trusted [arg_vault]", @@ -1296,6 +1414,125 @@ func bInitialize(c *cobra.Command, args []string) error { return nil } +func bInitializeV2FixVaultZeroDepositCommand() *cobra.Command { + c := &cobra.Command{ + Use: "initialize-v2-fix-vault-zero-deposit", + Short: "Calls the nonpayable method initializeV2FixVaultZeroDeposit on the Bridge contract.", + Args: cmd.ArgCountChecker(0), + RunE: bInitializeV2FixVaultZeroDeposit, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + c.PreRunE = cmd.NonConstArgsChecker + cmd.InitNonConstFlags(c) + + return c +} + +func bInitializeV2FixVaultZeroDeposit(c *cobra.Command, args []string) error { + contract, err := initializeBridge(c) + if err != nil { + return err + } + + var ( + transaction *types.Transaction + ) + + if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { + // Do a regular submission. Take payable into account. + transaction, err = contract.InitializeV2FixVaultZeroDeposit() + if err != nil { + return err + } + + cmd.PrintOutput(transaction.Hash()) + } else { + // Do a call. + err = contract.CallInitializeV2FixVaultZeroDeposit( + cmd.BlockFlagValue.Int, + ) + if err != nil { + return err + } + + cmd.PrintOutput("success") + + cmd.PrintOutput( + "the transaction was not submitted to the chain; " + + "please add the `--submit` flag", + ) + } + + return nil +} + +func bInitializeV5RepairRebateStakingCommand() *cobra.Command { + c := &cobra.Command{ + Use: "initialize-v5-repair-rebate-staking [arg_newRebateStaking]", + Short: "Calls the nonpayable method initializeV5RepairRebateStaking on the Bridge contract.", + Args: cmd.ArgCountChecker(1), + RunE: bInitializeV5RepairRebateStaking, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + c.PreRunE = cmd.NonConstArgsChecker + cmd.InitNonConstFlags(c) + + return c +} + +func bInitializeV5RepairRebateStaking(c *cobra.Command, args []string) error { + contract, err := initializeBridge(c) + if err != nil { + return err + } + + arg_newRebateStaking, err := chainutil.AddressFromHex(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_newRebateStaking, a address, from passed value %v", + args[0], + ) + } + + var ( + transaction *types.Transaction + ) + + if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { + // Do a regular submission. Take payable into account. + transaction, err = contract.InitializeV5RepairRebateStaking( + arg_newRebateStaking, + ) + if err != nil { + return err + } + + cmd.PrintOutput(transaction.Hash()) + } else { + // Do a call. + err = contract.CallInitializeV5RepairRebateStaking( + arg_newRebateStaking, + cmd.BlockFlagValue.Int, + ) + if err != nil { + return err + } + + cmd.PrintOutput("success") + + cmd.PrintOutput( + "the transaction was not submitted to the chain; " + + "please add the `--submit` flag", + ) + } + + return nil +} + func bNotifyMovingFundsBelowDustCommand() *cobra.Command { c := &cobra.Command{ Use: "notify-moving-funds-below-dust [arg_walletPubKeyHash] [arg_mainUtxo_json]", @@ -2026,6 +2263,71 @@ func bRevealDepositWithExtraData(c *cobra.Command, args []string) error { return nil } +func bSetRebateStakingCommand() *cobra.Command { + c := &cobra.Command{ + Use: "set-rebate-staking [arg_rebateStaking]", + Short: "Calls the nonpayable method setRebateStaking on the Bridge contract.", + Args: cmd.ArgCountChecker(1), + RunE: bSetRebateStaking, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + c.PreRunE = cmd.NonConstArgsChecker + cmd.InitNonConstFlags(c) + + return c +} + +func bSetRebateStaking(c *cobra.Command, args []string) error { + contract, err := initializeBridge(c) + if err != nil { + return err + } + + arg_rebateStaking, err := chainutil.AddressFromHex(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_rebateStaking, a address, from passed value %v", + args[0], + ) + } + + var ( + transaction *types.Transaction + ) + + if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { + // Do a regular submission. Take payable into account. + transaction, err = contract.SetRebateStaking( + arg_rebateStaking, + ) + if err != nil { + return err + } + + cmd.PrintOutput(transaction.Hash()) + } else { + // Do a call. + err = contract.CallSetRebateStaking( + arg_rebateStaking, + cmd.BlockFlagValue.Int, + ) + if err != nil { + return err + } + + cmd.PrintOutput("success") + + cmd.PrintOutput( + "the transaction was not submitted to the chain; " + + "please add the `--submit` flag", + ) + } + + return nil +} + func bSetRedemptionWatchtowerCommand() *cobra.Command { c := &cobra.Command{ Use: "set-redemption-watchtower [arg_redemptionWatchtower]", @@ -2091,6 +2393,71 @@ func bSetRedemptionWatchtower(c *cobra.Command, args []string) error { return nil } +func bSetReservationRouterCommand() *cobra.Command { + c := &cobra.Command{ + Use: "set-reservation-router [arg__reservationRouter]", + Short: "Calls the nonpayable method setReservationRouter on the Bridge contract.", + Args: cmd.ArgCountChecker(1), + RunE: bSetReservationRouter, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + c.PreRunE = cmd.NonConstArgsChecker + cmd.InitNonConstFlags(c) + + return c +} + +func bSetReservationRouter(c *cobra.Command, args []string) error { + contract, err := initializeBridge(c) + if err != nil { + return err + } + + arg__reservationRouter, err := chainutil.AddressFromHex(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg__reservationRouter, a address, from passed value %v", + args[0], + ) + } + + var ( + transaction *types.Transaction + ) + + if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { + // Do a regular submission. Take payable into account. + transaction, err = contract.SetReservationRouter( + arg__reservationRouter, + ) + if err != nil { + return err + } + + cmd.PrintOutput(transaction.Hash()) + } else { + // Do a call. + err = contract.CallSetReservationRouter( + arg__reservationRouter, + cmd.BlockFlagValue.Int, + ) + if err != nil { + return err + } + + cmd.PrintOutput("success") + + cmd.PrintOutput( + "the transaction was not submitted to the chain; " + + "please add the `--submit` flag", + ) + } + + return nil +} + func bSetSpvMaintainerStatusCommand() *cobra.Command { c := &cobra.Command{ Use: "set-spv-maintainer-status [arg_spvMaintainer] [arg_isTrusted]", diff --git a/pkg/chain/ethereum/tbtc/gen/cmd/RedemptionWatchtower.go b/pkg/chain/ethereum/tbtc/gen/cmd/RedemptionWatchtower.go index ccbddec189..cb5578a1b1 100644 --- a/pkg/chain/ethereum/tbtc/gen/cmd/RedemptionWatchtower.go +++ b/pkg/chain/ethereum/tbtc/gen/cmd/RedemptionWatchtower.go @@ -61,6 +61,7 @@ func init() { rwManagerCommand(), rwObjectionsCommand(), rwOwnerCommand(), + rwREQUIREDOBJECTIONSCOUNTCommand(), rwVetoFreezePeriodCommand(), rwVetoPenaltyFeeDivisorCommand(), rwVetoProposalsCommand(), @@ -562,6 +563,40 @@ func rwOwner(c *cobra.Command, args []string) error { return nil } +func rwREQUIREDOBJECTIONSCOUNTCommand() *cobra.Command { + c := &cobra.Command{ + Use: "r-e-q-u-i-r-e-d-o-b-j-e-c-t-i-o-n-s-c-o-u-n-t", + Short: "Calls the view method rEQUIREDOBJECTIONSCOUNT on the RedemptionWatchtower contract.", + Args: cmd.ArgCountChecker(0), + RunE: rwREQUIREDOBJECTIONSCOUNT, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func rwREQUIREDOBJECTIONSCOUNT(c *cobra.Command, args []string) error { + contract, err := initializeRedemptionWatchtower(c) + if err != nil { + return err + } + + result, err := contract.REQUIREDOBJECTIONSCOUNTAtBlock( + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + func rwVetoFreezePeriodCommand() *cobra.Command { c := &cobra.Command{ Use: "veto-freeze-period", diff --git a/pkg/chain/ethereum/tbtc/gen/cmd/ReservationRouter.go b/pkg/chain/ethereum/tbtc/gen/cmd/ReservationRouter.go new file mode 100644 index 0000000000..8c23881c8e --- /dev/null +++ b/pkg/chain/ethereum/tbtc/gen/cmd/ReservationRouter.go @@ -0,0 +1,1331 @@ +// Code generated - DO NOT EDIT. +// This file is a generated command and any manual changes will be lost. + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "sync" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + + chainutil "github.com/keep-network/keep-common/pkg/chain/ethereum/ethutil" + "github.com/keep-network/keep-common/pkg/cmd" + "github.com/keep-network/keep-common/pkg/utils/decode" + "github.com/keep-network/keep-core/pkg/chain/ethereum/tbtc/gen/abi" + "github.com/keep-network/keep-core/pkg/chain/ethereum/tbtc/gen/contract" + + "github.com/spf13/cobra" +) + +var ReservationRouterCommand *cobra.Command + +var reservationRouterDescription = `The reservation-router command allows calling the ReservationRouter contract on an + Ethereum network. It has subcommands corresponding to each contract method, + which respectively each take parameters based on the contract method's + parameters. + + Subcommands will submit a non-mutating call to the network and output the + result. + + All subcommands can be called against a specific block by passing the + -b/--block flag. + + Subcommands for mutating methods may be submitted as a mutating transaction + by passing the -s/--submit flag. In this mode, this command will terminate + successfully once the transaction has been submitted, but will not wait for + the transaction to be included in a block. They return the transaction hash. + + Calls that require ether to be paid will get 0 ether by default, which can + be changed by passing the -v/--value flag.` + +func init() { + ReservationRouterCommand := &cobra.Command{ + Use: "reservation-router", + Short: `Provides access to the ReservationRouter contract.`, + Long: reservationRouterDescription, + } + + ReservationRouterCommand.AddCommand( + rrActiveReservationsCountCommand(), + rrGovernanceCommand(), + rrPendingReservedDepositsCommand(), + rrReservationActionsCommand(), + rrReservationByAnchorUtxoCommand(), + rrReservationCapsCommand(), + rrReservationParametersCommand(), + rrReservationRouterCommand(), + rrReservationsCommand(), + rrReservedDepositWalletCommand(), + rrWalletReservationsCommand(), + rrWalletReservationsAmountCommand(), + rrWalletReservationsCountCommand(), + rrNotifyReservationStrandedCommand(), + rrNotifyStaleReservedDepositCommand(), + rrRequestReservationAcceptanceCommand(), + rrRequestReservationReanchorCommand(), + rrSubmitReservationProofCommand(), + rrTransferGovernanceCommand(), + rrUpdateReservationCapsCommand(), + rrUpdateReservationParametersCommand(), + ) + + ModuleCommand.AddCommand(ReservationRouterCommand) +} + +/// ------------------- Const methods ------------------- + +func rrActiveReservationsCountCommand() *cobra.Command { + c := &cobra.Command{ + Use: "active-reservations-count", + Short: "Calls the view method activeReservationsCount on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(0), + RunE: rrActiveReservationsCount, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func rrActiveReservationsCount(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + result, err := contract.ActiveReservationsCountAtBlock( + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + +func rrGovernanceCommand() *cobra.Command { + c := &cobra.Command{ + Use: "governance", + Short: "Calls the view method governance on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(0), + RunE: rrGovernance, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func rrGovernance(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + result, err := contract.GovernanceAtBlock( + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + +func rrPendingReservedDepositsCommand() *cobra.Command { + c := &cobra.Command{ + Use: "pending-reserved-deposits", + Short: "Calls the view method pendingReservedDeposits on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(0), + RunE: rrPendingReservedDeposits, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func rrPendingReservedDeposits(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + result, err := contract.PendingReservedDepositsAtBlock( + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + +func rrReservationActionsCommand() *cobra.Command { + c := &cobra.Command{ + Use: "reservation-actions [arg_reservationKey] [arg_requestNonce]", + Short: "Calls the view method reservationActions on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(2), + RunE: rrReservationActions, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func rrReservationActions(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + arg_reservationKey, err := hexutil.DecodeBig(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_reservationKey, a uint256, from passed value %v", + args[0], + ) + } + arg_requestNonce, err := decode.ParseUint[uint64](args[1], 64) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_requestNonce, a uint64, from passed value %v", + args[1], + ) + } + + result, err := contract.ReservationActionsAtBlock( + arg_reservationKey, + arg_requestNonce, + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + +func rrReservationByAnchorUtxoCommand() *cobra.Command { + c := &cobra.Command{ + Use: "reservation-by-anchor-utxo [arg_anchorTxHash] [arg_anchorTxOutputIndex]", + Short: "Calls the view method reservationByAnchorUtxo on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(2), + RunE: rrReservationByAnchorUtxo, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func rrReservationByAnchorUtxo(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + arg_anchorTxHash, err := decode.ParseBytes32(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_anchorTxHash, a bytes32, from passed value %v", + args[0], + ) + } + arg_anchorTxOutputIndex, err := decode.ParseUint[uint32](args[1], 32) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_anchorTxOutputIndex, a uint32, from passed value %v", + args[1], + ) + } + + result, err := contract.ReservationByAnchorUtxoAtBlock( + arg_anchorTxHash, + arg_anchorTxOutputIndex, + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + +func rrReservationCapsCommand() *cobra.Command { + c := &cobra.Command{ + Use: "reservation-caps", + Short: "Calls the view method reservationCaps on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(0), + RunE: rrReservationCaps, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func rrReservationCaps(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + result, err := contract.ReservationCapsAtBlock( + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + +func rrReservationParametersCommand() *cobra.Command { + c := &cobra.Command{ + Use: "reservation-parameters", + Short: "Calls the view method reservationParameters on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(0), + RunE: rrReservationParameters, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func rrReservationParameters(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + result, err := contract.ReservationParametersAtBlock( + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + +func rrReservationRouterCommand() *cobra.Command { + c := &cobra.Command{ + Use: "reservation-router", + Short: "Calls the view method reservationRouter on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(0), + RunE: rrReservationRouter, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func rrReservationRouter(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + result, err := contract.ReservationRouterAtBlock( + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + +func rrReservationsCommand() *cobra.Command { + c := &cobra.Command{ + Use: "reservations [arg_reservationKey]", + Short: "Calls the view method reservations on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(1), + RunE: rrReservations, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func rrReservations(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + arg_reservationKey, err := hexutil.DecodeBig(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_reservationKey, a uint256, from passed value %v", + args[0], + ) + } + + result, err := contract.ReservationsAtBlock( + arg_reservationKey, + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + +func rrReservedDepositWalletCommand() *cobra.Command { + c := &cobra.Command{ + Use: "reserved-deposit-wallet [arg_depositKey]", + Short: "Calls the view method reservedDepositWallet on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(1), + RunE: rrReservedDepositWallet, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func rrReservedDepositWallet(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + arg_depositKey, err := hexutil.DecodeBig(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_depositKey, a uint256, from passed value %v", + args[0], + ) + } + + result, err := contract.ReservedDepositWalletAtBlock( + arg_depositKey, + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + +func rrWalletReservationsCommand() *cobra.Command { + c := &cobra.Command{ + Use: "wallet-reservations [arg_walletPubKeyHash]", + Short: "Calls the view method walletReservations on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(1), + RunE: rrWalletReservations, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func rrWalletReservations(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + arg_walletPubKeyHash, err := decode.ParseBytes20(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_walletPubKeyHash, a bytes20, from passed value %v", + args[0], + ) + } + + result, err := contract.WalletReservationsAtBlock( + arg_walletPubKeyHash, + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + +func rrWalletReservationsAmountCommand() *cobra.Command { + c := &cobra.Command{ + Use: "wallet-reservations-amount [arg_walletPubKeyHash]", + Short: "Calls the view method walletReservationsAmount on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(1), + RunE: rrWalletReservationsAmount, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func rrWalletReservationsAmount(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + arg_walletPubKeyHash, err := decode.ParseBytes20(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_walletPubKeyHash, a bytes20, from passed value %v", + args[0], + ) + } + + result, err := contract.WalletReservationsAmountAtBlock( + arg_walletPubKeyHash, + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + +func rrWalletReservationsCountCommand() *cobra.Command { + c := &cobra.Command{ + Use: "wallet-reservations-count [arg_walletPubKeyHash]", + Short: "Calls the view method walletReservationsCount on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(1), + RunE: rrWalletReservationsCount, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func rrWalletReservationsCount(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + arg_walletPubKeyHash, err := decode.ParseBytes20(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_walletPubKeyHash, a bytes20, from passed value %v", + args[0], + ) + } + + result, err := contract.WalletReservationsCountAtBlock( + arg_walletPubKeyHash, + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + +/// ------------------- Non-const methods ------------------- + +func rrNotifyReservationStrandedCommand() *cobra.Command { + c := &cobra.Command{ + Use: "notify-reservation-stranded [arg_reservationKey]", + Short: "Calls the nonpayable method notifyReservationStranded on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(1), + RunE: rrNotifyReservationStranded, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + c.PreRunE = cmd.NonConstArgsChecker + cmd.InitNonConstFlags(c) + + return c +} + +func rrNotifyReservationStranded(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + arg_reservationKey, err := hexutil.DecodeBig(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_reservationKey, a uint256, from passed value %v", + args[0], + ) + } + + var ( + transaction *types.Transaction + ) + + if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { + // Do a regular submission. Take payable into account. + transaction, err = contract.NotifyReservationStranded( + arg_reservationKey, + ) + if err != nil { + return err + } + + cmd.PrintOutput(transaction.Hash()) + } else { + // Do a call. + err = contract.CallNotifyReservationStranded( + arg_reservationKey, + cmd.BlockFlagValue.Int, + ) + if err != nil { + return err + } + + cmd.PrintOutput("success") + + cmd.PrintOutput( + "the transaction was not submitted to the chain; " + + "please add the `--submit` flag", + ) + } + + return nil +} + +func rrNotifyStaleReservedDepositCommand() *cobra.Command { + c := &cobra.Command{ + Use: "notify-stale-reserved-deposit [arg_depositKey]", + Short: "Calls the nonpayable method notifyStaleReservedDeposit on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(1), + RunE: rrNotifyStaleReservedDeposit, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + c.PreRunE = cmd.NonConstArgsChecker + cmd.InitNonConstFlags(c) + + return c +} + +func rrNotifyStaleReservedDeposit(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + arg_depositKey, err := hexutil.DecodeBig(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_depositKey, a uint256, from passed value %v", + args[0], + ) + } + + var ( + transaction *types.Transaction + ) + + if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { + // Do a regular submission. Take payable into account. + transaction, err = contract.NotifyStaleReservedDeposit( + arg_depositKey, + ) + if err != nil { + return err + } + + cmd.PrintOutput(transaction.Hash()) + } else { + // Do a call. + err = contract.CallNotifyStaleReservedDeposit( + arg_depositKey, + cmd.BlockFlagValue.Int, + ) + if err != nil { + return err + } + + cmd.PrintOutput("success") + + cmd.PrintOutput( + "the transaction was not submitted to the chain; " + + "please add the `--submit` flag", + ) + } + + return nil +} + +func rrRequestReservationAcceptanceCommand() *cobra.Command { + c := &cobra.Command{ + Use: "request-reservation-acceptance [arg_reservationKey] [arg_walletPubKeyHash]", + Short: "Calls the nonpayable method requestReservationAcceptance on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(2), + RunE: rrRequestReservationAcceptance, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + c.PreRunE = cmd.NonConstArgsChecker + cmd.InitNonConstFlags(c) + + return c +} + +func rrRequestReservationAcceptance(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + arg_reservationKey, err := hexutil.DecodeBig(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_reservationKey, a uint256, from passed value %v", + args[0], + ) + } + arg_walletPubKeyHash, err := decode.ParseBytes20(args[1]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_walletPubKeyHash, a bytes20, from passed value %v", + args[1], + ) + } + + var ( + transaction *types.Transaction + ) + + if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { + // Do a regular submission. Take payable into account. + transaction, err = contract.RequestReservationAcceptance( + arg_reservationKey, + arg_walletPubKeyHash, + ) + if err != nil { + return err + } + + cmd.PrintOutput(transaction.Hash()) + } else { + // Do a call. + err = contract.CallRequestReservationAcceptance( + arg_reservationKey, + arg_walletPubKeyHash, + cmd.BlockFlagValue.Int, + ) + if err != nil { + return err + } + + cmd.PrintOutput("success") + + cmd.PrintOutput( + "the transaction was not submitted to the chain; " + + "please add the `--submit` flag", + ) + } + + return nil +} + +func rrRequestReservationReanchorCommand() *cobra.Command { + c := &cobra.Command{ + Use: "request-reservation-reanchor [arg_reservationKey] [arg_targetWalletPubKeyHash]", + Short: "Calls the nonpayable method requestReservationReanchor on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(2), + RunE: rrRequestReservationReanchor, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + c.PreRunE = cmd.NonConstArgsChecker + cmd.InitNonConstFlags(c) + + return c +} + +func rrRequestReservationReanchor(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + arg_reservationKey, err := hexutil.DecodeBig(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_reservationKey, a uint256, from passed value %v", + args[0], + ) + } + arg_targetWalletPubKeyHash, err := decode.ParseBytes20(args[1]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_targetWalletPubKeyHash, a bytes20, from passed value %v", + args[1], + ) + } + + var ( + transaction *types.Transaction + ) + + if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { + // Do a regular submission. Take payable into account. + transaction, err = contract.RequestReservationReanchor( + arg_reservationKey, + arg_targetWalletPubKeyHash, + ) + if err != nil { + return err + } + + cmd.PrintOutput(transaction.Hash()) + } else { + // Do a call. + err = contract.CallRequestReservationReanchor( + arg_reservationKey, + arg_targetWalletPubKeyHash, + cmd.BlockFlagValue.Int, + ) + if err != nil { + return err + } + + cmd.PrintOutput("success") + + cmd.PrintOutput( + "the transaction was not submitted to the chain; " + + "please add the `--submit` flag", + ) + } + + return nil +} + +func rrSubmitReservationProofCommand() *cobra.Command { + c := &cobra.Command{ + Use: "submit-reservation-proof [arg_proofType] [arg_txInfo_json] [arg_proof_json] [arg_mainUtxo_json] [arg_reservationKey] [arg_requestNonce]", + Short: "Calls the nonpayable method submitReservationProof on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(6), + RunE: rrSubmitReservationProof, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + c.PreRunE = cmd.NonConstArgsChecker + cmd.InitNonConstFlags(c) + + return c +} + +func rrSubmitReservationProof(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + arg_proofType, err := decode.ParseUint[uint8](args[0], 8) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_proofType, a uint8, from passed value %v", + args[0], + ) + } + + arg_txInfo_json := abi.BitcoinTxInfo4{} + if err := json.Unmarshal([]byte(args[1]), &arg_txInfo_json); err != nil { + return fmt.Errorf("failed to unmarshal arg_txInfo_json to abi.BitcoinTxInfo4: %w", err) + } + + arg_proof_json := abi.BitcoinTxProof3{} + if err := json.Unmarshal([]byte(args[2]), &arg_proof_json); err != nil { + return fmt.Errorf("failed to unmarshal arg_proof_json to abi.BitcoinTxProof3: %w", err) + } + + arg_mainUtxo_json := abi.BitcoinTxUTXO4{} + if err := json.Unmarshal([]byte(args[3]), &arg_mainUtxo_json); err != nil { + return fmt.Errorf("failed to unmarshal arg_mainUtxo_json to abi.BitcoinTxUTXO4: %w", err) + } + arg_reservationKey, err := hexutil.DecodeBig(args[4]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_reservationKey, a uint256, from passed value %v", + args[4], + ) + } + arg_requestNonce, err := decode.ParseUint[uint64](args[5], 64) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_requestNonce, a uint64, from passed value %v", + args[5], + ) + } + + var ( + transaction *types.Transaction + ) + + if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { + // Do a regular submission. Take payable into account. + transaction, err = contract.SubmitReservationProof( + arg_proofType, + arg_txInfo_json, + arg_proof_json, + arg_mainUtxo_json, + arg_reservationKey, + arg_requestNonce, + ) + if err != nil { + return err + } + + cmd.PrintOutput(transaction.Hash()) + } else { + // Do a call. + err = contract.CallSubmitReservationProof( + arg_proofType, + arg_txInfo_json, + arg_proof_json, + arg_mainUtxo_json, + arg_reservationKey, + arg_requestNonce, + cmd.BlockFlagValue.Int, + ) + if err != nil { + return err + } + + cmd.PrintOutput("success") + + cmd.PrintOutput( + "the transaction was not submitted to the chain; " + + "please add the `--submit` flag", + ) + } + + return nil +} + +func rrTransferGovernanceCommand() *cobra.Command { + c := &cobra.Command{ + Use: "transfer-governance [arg_newGovernance]", + Short: "Calls the nonpayable method transferGovernance on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(1), + RunE: rrTransferGovernance, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + c.PreRunE = cmd.NonConstArgsChecker + cmd.InitNonConstFlags(c) + + return c +} + +func rrTransferGovernance(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + arg_newGovernance, err := chainutil.AddressFromHex(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_newGovernance, a address, from passed value %v", + args[0], + ) + } + + var ( + transaction *types.Transaction + ) + + if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { + // Do a regular submission. Take payable into account. + transaction, err = contract.TransferGovernance( + arg_newGovernance, + ) + if err != nil { + return err + } + + cmd.PrintOutput(transaction.Hash()) + } else { + // Do a call. + err = contract.CallTransferGovernance( + arg_newGovernance, + cmd.BlockFlagValue.Int, + ) + if err != nil { + return err + } + + cmd.PrintOutput("success") + + cmd.PrintOutput( + "the transaction was not submitted to the chain; " + + "please add the `--submit` flag", + ) + } + + return nil +} + +func rrUpdateReservationCapsCommand() *cobra.Command { + c := &cobra.Command{ + Use: "update-reservation-caps [arg_maxReservationsAmountPerWallet] [arg_reservationMaxSingleAmount] [arg_maxActiveReservations]", + Short: "Calls the nonpayable method updateReservationCaps on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(3), + RunE: rrUpdateReservationCaps, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + c.PreRunE = cmd.NonConstArgsChecker + cmd.InitNonConstFlags(c) + + return c +} + +func rrUpdateReservationCaps(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + arg_maxReservationsAmountPerWallet, err := decode.ParseUint[uint64](args[0], 64) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_maxReservationsAmountPerWallet, a uint64, from passed value %v", + args[0], + ) + } + arg_reservationMaxSingleAmount, err := decode.ParseUint[uint64](args[1], 64) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_reservationMaxSingleAmount, a uint64, from passed value %v", + args[1], + ) + } + arg_maxActiveReservations, err := decode.ParseUint[uint32](args[2], 32) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_maxActiveReservations, a uint32, from passed value %v", + args[2], + ) + } + + var ( + transaction *types.Transaction + ) + + if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { + // Do a regular submission. Take payable into account. + transaction, err = contract.UpdateReservationCaps( + arg_maxReservationsAmountPerWallet, + arg_reservationMaxSingleAmount, + arg_maxActiveReservations, + ) + if err != nil { + return err + } + + cmd.PrintOutput(transaction.Hash()) + } else { + // Do a call. + err = contract.CallUpdateReservationCaps( + arg_maxReservationsAmountPerWallet, + arg_reservationMaxSingleAmount, + arg_maxActiveReservations, + cmd.BlockFlagValue.Int, + ) + if err != nil { + return err + } + + cmd.PrintOutput("success") + + cmd.PrintOutput( + "the transaction was not submitted to the chain; " + + "please add the `--submit` flag", + ) + } + + return nil +} + +func rrUpdateReservationParametersCommand() *cobra.Command { + c := &cobra.Command{ + Use: "update-reservation-parameters [arg_reservationVault] [arg_reservationMinAmount] [arg_reservationTxMaxFee] [arg_reservationTermSeconds] [arg_reservationDissolutionDelay] [arg_reservationMaxTotalAmount] [arg_maxReservationsPerWallet] [arg_reservationActionTimeout] [arg_reservationRenewalWindowSeconds]", + Short: "Calls the nonpayable method updateReservationParameters on the ReservationRouter contract.", + Args: cmd.ArgCountChecker(9), + RunE: rrUpdateReservationParameters, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + c.PreRunE = cmd.NonConstArgsChecker + cmd.InitNonConstFlags(c) + + return c +} + +func rrUpdateReservationParameters(c *cobra.Command, args []string) error { + contract, err := initializeReservationRouter(c) + if err != nil { + return err + } + + arg_reservationVault, err := chainutil.AddressFromHex(args[0]) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_reservationVault, a address, from passed value %v", + args[0], + ) + } + arg_reservationMinAmount, err := decode.ParseUint[uint64](args[1], 64) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_reservationMinAmount, a uint64, from passed value %v", + args[1], + ) + } + arg_reservationTxMaxFee, err := decode.ParseUint[uint64](args[2], 64) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_reservationTxMaxFee, a uint64, from passed value %v", + args[2], + ) + } + arg_reservationTermSeconds, err := decode.ParseUint[uint32](args[3], 32) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_reservationTermSeconds, a uint32, from passed value %v", + args[3], + ) + } + arg_reservationDissolutionDelay, err := decode.ParseUint[uint32](args[4], 32) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_reservationDissolutionDelay, a uint32, from passed value %v", + args[4], + ) + } + arg_reservationMaxTotalAmount, err := decode.ParseUint[uint64](args[5], 64) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_reservationMaxTotalAmount, a uint64, from passed value %v", + args[5], + ) + } + arg_maxReservationsPerWallet, err := decode.ParseUint[uint32](args[6], 32) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_maxReservationsPerWallet, a uint32, from passed value %v", + args[6], + ) + } + arg_reservationActionTimeout, err := decode.ParseUint[uint32](args[7], 32) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_reservationActionTimeout, a uint32, from passed value %v", + args[7], + ) + } + arg_reservationRenewalWindowSeconds, err := decode.ParseUint[uint32](args[8], 32) + if err != nil { + return fmt.Errorf( + "couldn't parse parameter arg_reservationRenewalWindowSeconds, a uint32, from passed value %v", + args[8], + ) + } + + var ( + transaction *types.Transaction + ) + + if shouldSubmit, _ := c.Flags().GetBool(cmd.SubmitFlag); shouldSubmit { + // Do a regular submission. Take payable into account. + transaction, err = contract.UpdateReservationParameters( + arg_reservationVault, + arg_reservationMinAmount, + arg_reservationTxMaxFee, + arg_reservationTermSeconds, + arg_reservationDissolutionDelay, + arg_reservationMaxTotalAmount, + arg_maxReservationsPerWallet, + arg_reservationActionTimeout, + arg_reservationRenewalWindowSeconds, + ) + if err != nil { + return err + } + + cmd.PrintOutput(transaction.Hash()) + } else { + // Do a call. + err = contract.CallUpdateReservationParameters( + arg_reservationVault, + arg_reservationMinAmount, + arg_reservationTxMaxFee, + arg_reservationTermSeconds, + arg_reservationDissolutionDelay, + arg_reservationMaxTotalAmount, + arg_maxReservationsPerWallet, + arg_reservationActionTimeout, + arg_reservationRenewalWindowSeconds, + cmd.BlockFlagValue.Int, + ) + if err != nil { + return err + } + + cmd.PrintOutput("success") + + cmd.PrintOutput( + "the transaction was not submitted to the chain; " + + "please add the `--submit` flag", + ) + } + + return nil +} + +/// ------------------- Initialization ------------------- + +func initializeReservationRouter(c *cobra.Command) (*contract.ReservationRouter, error) { + cfg := *ModuleCommand.GetConfig() + + client, err := ethclient.Dial(cfg.URL) + if err != nil { + return nil, fmt.Errorf("error connecting to host chain node: [%v]", err) + } + + chainID, err := client.ChainID(context.Background()) + if err != nil { + return nil, fmt.Errorf( + "failed to resolve host chain id: [%v]", + err, + ) + } + + key, err := chainutil.DecryptKeyFile( + cfg.Account.KeyFile, + cfg.Account.KeyFilePassword, + ) + if err != nil { + return nil, fmt.Errorf( + "failed to read KeyFile: %s: [%v]", + cfg.Account.KeyFile, + err, + ) + } + + miningWaiter := chainutil.NewMiningWaiter(client, cfg) + + blockCounter, err := chainutil.NewBlockCounter(client) + if err != nil { + return nil, fmt.Errorf( + "failed to create block counter: [%v]", + err, + ) + } + + address, err := cfg.ContractAddress("ReservationRouter") + if err != nil { + return nil, fmt.Errorf( + "failed to get %s address: [%w]", + "ReservationRouter", + err, + ) + } + + return contract.NewReservationRouter( + address, + chainID, + key, + client, + chainutil.NewNonceManager(client, key.Address), + miningWaiter, + blockCounter, + &sync.Mutex{}, + ) +} diff --git a/pkg/chain/ethereum/tbtc/gen/cmd/WalletProposalValidator.go b/pkg/chain/ethereum/tbtc/gen/cmd/WalletProposalValidator.go index d86d97458f..92932d206d 100644 --- a/pkg/chain/ethereum/tbtc/gen/cmd/WalletProposalValidator.go +++ b/pkg/chain/ethereum/tbtc/gen/cmd/WalletProposalValidator.go @@ -59,6 +59,8 @@ func init() { wpvValidateMovedFundsSweepProposalCommand(), wpvValidateMovingFundsProposalCommand(), wpvValidateRedemptionProposalCommand(), + wpvValidateReservationAnchorProposalCommand(), + wpvValidateReservationReanchorProposalCommand(), ) ModuleCommand.AddCommand(WalletProposalValidatorCommand) @@ -470,6 +472,92 @@ func wpvValidateRedemptionProposal(c *cobra.Command, args []string) error { return nil } +func wpvValidateReservationAnchorProposalCommand() *cobra.Command { + c := &cobra.Command{ + Use: "validate-reservation-anchor-proposal [arg_proposal_json] [arg_depositExtraInfo_json]", + Short: "Calls the view method validateReservationAnchorProposal on the WalletProposalValidator contract.", + Args: cmd.ArgCountChecker(2), + RunE: wpvValidateReservationAnchorProposal, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func wpvValidateReservationAnchorProposal(c *cobra.Command, args []string) error { + contract, err := initializeWalletProposalValidator(c) + if err != nil { + return err + } + + arg_proposal_json := abi.WalletProposalValidatorReservationAnchorProposal{} + if err := json.Unmarshal([]byte(args[0]), &arg_proposal_json); err != nil { + return fmt.Errorf("failed to unmarshal arg_proposal_json to abi.WalletProposalValidatorReservationAnchorProposal: %w", err) + } + + arg_depositExtraInfo_json := abi.WalletProposalValidatorDepositExtraInfo{} + if err := json.Unmarshal([]byte(args[1]), &arg_depositExtraInfo_json); err != nil { + return fmt.Errorf("failed to unmarshal arg_depositExtraInfo_json to abi.WalletProposalValidatorDepositExtraInfo: %w", err) + } + + result, err := contract.ValidateReservationAnchorProposalAtBlock( + arg_proposal_json, + arg_depositExtraInfo_json, + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + +func wpvValidateReservationReanchorProposalCommand() *cobra.Command { + c := &cobra.Command{ + Use: "validate-reservation-reanchor-proposal [arg_proposal_json]", + Short: "Calls the view method validateReservationReanchorProposal on the WalletProposalValidator contract.", + Args: cmd.ArgCountChecker(1), + RunE: wpvValidateReservationReanchorProposal, + SilenceUsage: true, + DisableFlagsInUseLine: true, + } + + cmd.InitConstFlags(c) + + return c +} + +func wpvValidateReservationReanchorProposal(c *cobra.Command, args []string) error { + contract, err := initializeWalletProposalValidator(c) + if err != nil { + return err + } + + arg_proposal_json := abi.WalletProposalValidatorReservationReanchorProposal{} + if err := json.Unmarshal([]byte(args[0]), &arg_proposal_json); err != nil { + return fmt.Errorf("failed to unmarshal arg_proposal_json to abi.WalletProposalValidatorReservationReanchorProposal: %w", err) + } + + result, err := contract.ValidateReservationReanchorProposalAtBlock( + arg_proposal_json, + cmd.BlockFlagValue.Int, + ) + + if err != nil { + return err + } + + cmd.PrintOutput(result) + + return nil +} + /// ------------------- Non-const methods ------------------- /// ------------------- Initialization ------------------- diff --git a/pkg/chain/ethereum/tbtc/gen/contract/Bridge.go b/pkg/chain/ethereum/tbtc/gen/contract/Bridge.go index ae73c92607..30a9b3faa3 100644 --- a/pkg/chain/ethereum/tbtc/gen/contract/Bridge.go +++ b/pkg/chain/ethereum/tbtc/gen/contract/Bridge.go @@ -914,6 +914,268 @@ func (b *Bridge) InitializeGasEstimate( return result, err } +// Transaction submission. +func (b *Bridge) InitializeV2FixVaultZeroDeposit( + + transactionOptions ...chainutil.TransactionOptions, +) (*types.Transaction, error) { + bLogger.Debug( + "submitting transaction initializeV2FixVaultZeroDeposit", + ) + + b.transactionMutex.Lock() + defer b.transactionMutex.Unlock() + + // create a copy + transactorOptions := new(bind.TransactOpts) + *transactorOptions = *b.transactorOptions + + if len(transactionOptions) > 1 { + return nil, fmt.Errorf( + "could not process multiple transaction options sets", + ) + } else if len(transactionOptions) > 0 { + transactionOptions[0].Apply(transactorOptions) + } + + nonce, err := b.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } + + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) + + transaction, err := b.contract.InitializeV2FixVaultZeroDeposit( + transactorOptions, + ) + if err != nil { + return transaction, b.errorResolver.ResolveError( + err, + b.transactorOptions.From, + nil, + "initializeV2FixVaultZeroDeposit", + ) + } + + bLogger.Infof( + "submitted transaction initializeV2FixVaultZeroDeposit with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + go b.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + // If original transactor options has a non-zero gas limit, that + // means the client code set it on their own. In that case, we + // should rewrite the gas limit from the original transaction + // for each resubmission. If the gas limit is not set by the client + // code, let the the submitter re-estimate the gas limit on each + // resubmission. + if transactorOptions.GasLimit != 0 { + newTransactorOptions.GasLimit = transactorOptions.GasLimit + } + + transaction, err := b.contract.InitializeV2FixVaultZeroDeposit( + newTransactorOptions, + ) + if err != nil { + return nil, b.errorResolver.ResolveError( + err, + b.transactorOptions.From, + nil, + "initializeV2FixVaultZeroDeposit", + ) + } + + bLogger.Infof( + "submitted transaction initializeV2FixVaultZeroDeposit with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + return transaction, nil + }, + ) + + b.nonceManager.IncrementNonce() + + return transaction, err +} + +// Non-mutating call, not a transaction submission. +func (b *Bridge) CallInitializeV2FixVaultZeroDeposit( + blockNumber *big.Int, +) error { + var result interface{} = nil + + err := chainutil.CallAtBlock( + b.transactorOptions.From, + blockNumber, nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "initializeV2FixVaultZeroDeposit", + &result, + ) + + return err +} + +func (b *Bridge) InitializeV2FixVaultZeroDepositGasEstimate() (uint64, error) { + var result uint64 + + result, err := chainutil.EstimateGas( + b.callerOptions.From, + b.contractAddress, + "initializeV2FixVaultZeroDeposit", + b.contractABI, + b.transactor, + ) + + return result, err +} + +// Transaction submission. +func (b *Bridge) InitializeV5RepairRebateStaking( + arg_newRebateStaking common.Address, + + transactionOptions ...chainutil.TransactionOptions, +) (*types.Transaction, error) { + bLogger.Debug( + "submitting transaction initializeV5RepairRebateStaking", + " params: ", + fmt.Sprint( + arg_newRebateStaking, + ), + ) + + b.transactionMutex.Lock() + defer b.transactionMutex.Unlock() + + // create a copy + transactorOptions := new(bind.TransactOpts) + *transactorOptions = *b.transactorOptions + + if len(transactionOptions) > 1 { + return nil, fmt.Errorf( + "could not process multiple transaction options sets", + ) + } else if len(transactionOptions) > 0 { + transactionOptions[0].Apply(transactorOptions) + } + + nonce, err := b.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } + + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) + + transaction, err := b.contract.InitializeV5RepairRebateStaking( + transactorOptions, + arg_newRebateStaking, + ) + if err != nil { + return transaction, b.errorResolver.ResolveError( + err, + b.transactorOptions.From, + nil, + "initializeV5RepairRebateStaking", + arg_newRebateStaking, + ) + } + + bLogger.Infof( + "submitted transaction initializeV5RepairRebateStaking with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + go b.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + // If original transactor options has a non-zero gas limit, that + // means the client code set it on their own. In that case, we + // should rewrite the gas limit from the original transaction + // for each resubmission. If the gas limit is not set by the client + // code, let the the submitter re-estimate the gas limit on each + // resubmission. + if transactorOptions.GasLimit != 0 { + newTransactorOptions.GasLimit = transactorOptions.GasLimit + } + + transaction, err := b.contract.InitializeV5RepairRebateStaking( + newTransactorOptions, + arg_newRebateStaking, + ) + if err != nil { + return nil, b.errorResolver.ResolveError( + err, + b.transactorOptions.From, + nil, + "initializeV5RepairRebateStaking", + arg_newRebateStaking, + ) + } + + bLogger.Infof( + "submitted transaction initializeV5RepairRebateStaking with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + return transaction, nil + }, + ) + + b.nonceManager.IncrementNonce() + + return transaction, err +} + +// Non-mutating call, not a transaction submission. +func (b *Bridge) CallInitializeV5RepairRebateStaking( + arg_newRebateStaking common.Address, + blockNumber *big.Int, +) error { + var result interface{} = nil + + err := chainutil.CallAtBlock( + b.transactorOptions.From, + blockNumber, nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "initializeV5RepairRebateStaking", + &result, + arg_newRebateStaking, + ) + + return err +} + +func (b *Bridge) InitializeV5RepairRebateStakingGasEstimate( + arg_newRebateStaking common.Address, +) (uint64, error) { + var result uint64 + + result, err := chainutil.EstimateGas( + b.callerOptions.From, + b.contractAddress, + "initializeV5RepairRebateStaking", + b.contractABI, + b.transactor, + arg_newRebateStaking, + ) + + return result, err +} + // Transaction submission. func (b *Bridge) NotifyFraudChallengeDefeatTimeout( arg_walletPublicKey []byte, @@ -3027,16 +3289,16 @@ func (b *Bridge) RevealDepositWithExtraDataGasEstimate( } // Transaction submission. -func (b *Bridge) SetRedemptionWatchtower( - arg_redemptionWatchtower common.Address, +func (b *Bridge) SetRebateStaking( + arg_rebateStaking common.Address, transactionOptions ...chainutil.TransactionOptions, ) (*types.Transaction, error) { bLogger.Debug( - "submitting transaction setRedemptionWatchtower", + "submitting transaction setRebateStaking", " params: ", fmt.Sprint( - arg_redemptionWatchtower, + arg_rebateStaking, ), ) @@ -3062,22 +3324,22 @@ func (b *Bridge) SetRedemptionWatchtower( transactorOptions.Nonce = new(big.Int).SetUint64(nonce) - transaction, err := b.contract.SetRedemptionWatchtower( + transaction, err := b.contract.SetRebateStaking( transactorOptions, - arg_redemptionWatchtower, + arg_rebateStaking, ) if err != nil { return transaction, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "setRedemptionWatchtower", - arg_redemptionWatchtower, + "setRebateStaking", + arg_rebateStaking, ) } bLogger.Infof( - "submitted transaction setRedemptionWatchtower with id: [%s] and nonce [%v]", + "submitted transaction setRebateStaking with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -3096,22 +3358,22 @@ func (b *Bridge) SetRedemptionWatchtower( newTransactorOptions.GasLimit = transactorOptions.GasLimit } - transaction, err := b.contract.SetRedemptionWatchtower( + transaction, err := b.contract.SetRebateStaking( newTransactorOptions, - arg_redemptionWatchtower, + arg_rebateStaking, ) if err != nil { return nil, b.errorResolver.ResolveError( err, b.transactorOptions.From, nil, - "setRedemptionWatchtower", - arg_redemptionWatchtower, + "setRebateStaking", + arg_rebateStaking, ) } bLogger.Infof( - "submitted transaction setRedemptionWatchtower with id: [%s] and nonce [%v]", + "submitted transaction setRebateStaking with id: [%s] and nonce [%v]", transaction.Hash(), transaction.Nonce(), ) @@ -3126,8 +3388,8 @@ func (b *Bridge) SetRedemptionWatchtower( } // Non-mutating call, not a transaction submission. -func (b *Bridge) CallSetRedemptionWatchtower( - arg_redemptionWatchtower common.Address, +func (b *Bridge) CallSetRebateStaking( + arg_rebateStaking common.Address, blockNumber *big.Int, ) error { var result interface{} = nil @@ -3139,40 +3401,316 @@ func (b *Bridge) CallSetRedemptionWatchtower( b.caller, b.errorResolver, b.contractAddress, - "setRedemptionWatchtower", + "setRebateStaking", &result, - arg_redemptionWatchtower, + arg_rebateStaking, ) return err } -func (b *Bridge) SetRedemptionWatchtowerGasEstimate( - arg_redemptionWatchtower common.Address, +func (b *Bridge) SetRebateStakingGasEstimate( + arg_rebateStaking common.Address, ) (uint64, error) { var result uint64 result, err := chainutil.EstimateGas( b.callerOptions.From, b.contractAddress, - "setRedemptionWatchtower", + "setRebateStaking", b.contractABI, b.transactor, - arg_redemptionWatchtower, + arg_rebateStaking, ) return result, err } // Transaction submission. -func (b *Bridge) SetSpvMaintainerStatus( - arg_spvMaintainer common.Address, - arg_isTrusted bool, +func (b *Bridge) SetRedemptionWatchtower( + arg_redemptionWatchtower common.Address, transactionOptions ...chainutil.TransactionOptions, ) (*types.Transaction, error) { bLogger.Debug( - "submitting transaction setSpvMaintainerStatus", + "submitting transaction setRedemptionWatchtower", + " params: ", + fmt.Sprint( + arg_redemptionWatchtower, + ), + ) + + b.transactionMutex.Lock() + defer b.transactionMutex.Unlock() + + // create a copy + transactorOptions := new(bind.TransactOpts) + *transactorOptions = *b.transactorOptions + + if len(transactionOptions) > 1 { + return nil, fmt.Errorf( + "could not process multiple transaction options sets", + ) + } else if len(transactionOptions) > 0 { + transactionOptions[0].Apply(transactorOptions) + } + + nonce, err := b.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } + + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) + + transaction, err := b.contract.SetRedemptionWatchtower( + transactorOptions, + arg_redemptionWatchtower, + ) + if err != nil { + return transaction, b.errorResolver.ResolveError( + err, + b.transactorOptions.From, + nil, + "setRedemptionWatchtower", + arg_redemptionWatchtower, + ) + } + + bLogger.Infof( + "submitted transaction setRedemptionWatchtower with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + go b.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + // If original transactor options has a non-zero gas limit, that + // means the client code set it on their own. In that case, we + // should rewrite the gas limit from the original transaction + // for each resubmission. If the gas limit is not set by the client + // code, let the the submitter re-estimate the gas limit on each + // resubmission. + if transactorOptions.GasLimit != 0 { + newTransactorOptions.GasLimit = transactorOptions.GasLimit + } + + transaction, err := b.contract.SetRedemptionWatchtower( + newTransactorOptions, + arg_redemptionWatchtower, + ) + if err != nil { + return nil, b.errorResolver.ResolveError( + err, + b.transactorOptions.From, + nil, + "setRedemptionWatchtower", + arg_redemptionWatchtower, + ) + } + + bLogger.Infof( + "submitted transaction setRedemptionWatchtower with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + return transaction, nil + }, + ) + + b.nonceManager.IncrementNonce() + + return transaction, err +} + +// Non-mutating call, not a transaction submission. +func (b *Bridge) CallSetRedemptionWatchtower( + arg_redemptionWatchtower common.Address, + blockNumber *big.Int, +) error { + var result interface{} = nil + + err := chainutil.CallAtBlock( + b.transactorOptions.From, + blockNumber, nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "setRedemptionWatchtower", + &result, + arg_redemptionWatchtower, + ) + + return err +} + +func (b *Bridge) SetRedemptionWatchtowerGasEstimate( + arg_redemptionWatchtower common.Address, +) (uint64, error) { + var result uint64 + + result, err := chainutil.EstimateGas( + b.callerOptions.From, + b.contractAddress, + "setRedemptionWatchtower", + b.contractABI, + b.transactor, + arg_redemptionWatchtower, + ) + + return result, err +} + +// Transaction submission. +func (b *Bridge) SetReservationRouter( + arg__reservationRouter common.Address, + + transactionOptions ...chainutil.TransactionOptions, +) (*types.Transaction, error) { + bLogger.Debug( + "submitting transaction setReservationRouter", + " params: ", + fmt.Sprint( + arg__reservationRouter, + ), + ) + + b.transactionMutex.Lock() + defer b.transactionMutex.Unlock() + + // create a copy + transactorOptions := new(bind.TransactOpts) + *transactorOptions = *b.transactorOptions + + if len(transactionOptions) > 1 { + return nil, fmt.Errorf( + "could not process multiple transaction options sets", + ) + } else if len(transactionOptions) > 0 { + transactionOptions[0].Apply(transactorOptions) + } + + nonce, err := b.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } + + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) + + transaction, err := b.contract.SetReservationRouter( + transactorOptions, + arg__reservationRouter, + ) + if err != nil { + return transaction, b.errorResolver.ResolveError( + err, + b.transactorOptions.From, + nil, + "setReservationRouter", + arg__reservationRouter, + ) + } + + bLogger.Infof( + "submitted transaction setReservationRouter with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + go b.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + // If original transactor options has a non-zero gas limit, that + // means the client code set it on their own. In that case, we + // should rewrite the gas limit from the original transaction + // for each resubmission. If the gas limit is not set by the client + // code, let the the submitter re-estimate the gas limit on each + // resubmission. + if transactorOptions.GasLimit != 0 { + newTransactorOptions.GasLimit = transactorOptions.GasLimit + } + + transaction, err := b.contract.SetReservationRouter( + newTransactorOptions, + arg__reservationRouter, + ) + if err != nil { + return nil, b.errorResolver.ResolveError( + err, + b.transactorOptions.From, + nil, + "setReservationRouter", + arg__reservationRouter, + ) + } + + bLogger.Infof( + "submitted transaction setReservationRouter with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + return transaction, nil + }, + ) + + b.nonceManager.IncrementNonce() + + return transaction, err +} + +// Non-mutating call, not a transaction submission. +func (b *Bridge) CallSetReservationRouter( + arg__reservationRouter common.Address, + blockNumber *big.Int, +) error { + var result interface{} = nil + + err := chainutil.CallAtBlock( + b.transactorOptions.From, + blockNumber, nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "setReservationRouter", + &result, + arg__reservationRouter, + ) + + return err +} + +func (b *Bridge) SetReservationRouterGasEstimate( + arg__reservationRouter common.Address, +) (uint64, error) { + var result uint64 + + result, err := chainutil.EstimateGas( + b.callerOptions.From, + b.contractAddress, + "setReservationRouter", + b.contractABI, + b.transactor, + arg__reservationRouter, + ) + + return result, err +} + +// Transaction submission. +func (b *Bridge) SetSpvMaintainerStatus( + arg_spvMaintainer common.Address, + arg_isTrusted bool, + + transactionOptions ...chainutil.TransactionOptions, +) (*types.Transaction, error) { + bLogger.Debug( + "submitting transaction setSpvMaintainerStatus", " params: ", fmt.Sprint( arg_spvMaintainer, @@ -5961,6 +6499,43 @@ func (b *Bridge) FraudParametersAtBlock( return result, err } +func (b *Bridge) GetRebateStaking() (common.Address, error) { + result, err := b.contract.GetRebateStaking( + b.callerOptions, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "getRebateStaking", + ) + } + + return result, err +} + +func (b *Bridge) GetRebateStakingAtBlock( + blockNumber *big.Int, +) (common.Address, error) { + var result common.Address + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "getRebateStaking", + &result, + ) + + return result, err +} + func (b *Bridge) GetRedemptionWatchtower() (common.Address, error) { result, err := b.contract.GetRedemptionWatchtower( b.callerOptions, @@ -5998,6 +6573,43 @@ func (b *Bridge) GetRedemptionWatchtowerAtBlock( return result, err } +func (b *Bridge) GetReservationRouter() (common.Address, error) { + result, err := b.contract.GetReservationRouter( + b.callerOptions, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "getReservationRouter", + ) + } + + return result, err +} + +func (b *Bridge) GetReservationRouterAtBlock( + blockNumber *big.Int, +) (common.Address, error) { + var result common.Address + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "getReservationRouter", + &result, + ) + + return result, err +} + func (b *Bridge) Governance() (common.Address, error) { result, err := b.contract.Governance( b.callerOptions, @@ -6035,6 +6647,49 @@ func (b *Bridge) GovernanceAtBlock( return result, err } +func (b *Bridge) IsReservedDeposit( + arg_depositKey *big.Int, +) (bool, error) { + result, err := b.contract.IsReservedDeposit( + b.callerOptions, + arg_depositKey, + ) + + if err != nil { + return result, b.errorResolver.ResolveError( + err, + b.callerOptions.From, + nil, + "isReservedDeposit", + arg_depositKey, + ) + } + + return result, err +} + +func (b *Bridge) IsReservedDepositAtBlock( + arg_depositKey *big.Int, + blockNumber *big.Int, +) (bool, error) { + var result bool + + err := chainutil.CallAtBlock( + b.callerOptions.From, + blockNumber, + nil, + b.contractABI, + b.caller, + b.errorResolver, + b.contractAddress, + "isReservedDeposit", + &result, + arg_depositKey, + ) + + return result, err +} + func (b *Bridge) IsVaultTrusted( arg_vault common.Address, ) (bool, error) { @@ -6949,6 +7604,196 @@ func (b *Bridge) PastDepositRevealedEvents( return events, nil } +func (b *Bridge) DepositVaultFixedEvent( + opts *ethereum.SubscribeOpts, + depositKeyFilter []*big.Int, +) *BDepositVaultFixedSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &BDepositVaultFixedSubscription{ + b, + opts, + depositKeyFilter, + } +} + +type BDepositVaultFixedSubscription struct { + contract *Bridge + opts *ethereum.SubscribeOpts + depositKeyFilter []*big.Int +} + +type bridgeDepositVaultFixedFunc func( + DepositKey *big.Int, + NewVault common.Address, + blockNumber uint64, +) + +func (dvfs *BDepositVaultFixedSubscription) OnEvent( + handler bridgeDepositVaultFixedFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.BridgeDepositVaultFixed) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.DepositKey, + event.NewVault, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := dvfs.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (dvfs *BDepositVaultFixedSubscription) Pipe( + sink chan *abi.BridgeDepositVaultFixed, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(dvfs.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := dvfs.contract.blockCounter.CurrentBlock() + if err != nil { + bLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - dvfs.opts.PastBlocks + + bLogger.Infof( + "subscription monitoring fetching past DepositVaultFixed events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := dvfs.contract.PastDepositVaultFixedEvents( + fromBlock, + nil, + dvfs.depositKeyFilter, + ) + if err != nil { + bLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + bLogger.Infof( + "subscription monitoring fetched [%v] past DepositVaultFixed events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := dvfs.contract.watchDepositVaultFixed( + sink, + dvfs.depositKeyFilter, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (b *Bridge) watchDepositVaultFixed( + sink chan *abi.BridgeDepositVaultFixed, + depositKeyFilter []*big.Int, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return b.contract.WatchDepositVaultFixed( + &bind.WatchOpts{Context: ctx}, + sink, + depositKeyFilter, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + bLogger.Warnf( + "subscription to event DepositVaultFixed had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + bLogger.Errorf( + "subscription to event DepositVaultFixed failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (b *Bridge) PastDepositVaultFixedEvents( + startBlock uint64, + endBlock *uint64, + depositKeyFilter []*big.Int, +) ([]*abi.BridgeDepositVaultFixed, error) { + iterator, err := b.contract.FilterDepositVaultFixed( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + depositKeyFilter, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past DepositVaultFixed events: [%v]", + err, + ) + } + + events := make([]*abi.BridgeDepositVaultFixed, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + func (b *Bridge) DepositsSweptEvent( opts *ethereum.SubscribeOpts, ) *BDepositsSweptSubscription { @@ -10154,6 +10999,366 @@ func (b *Bridge) PastNewWalletRequestedEvents( return events, nil } +func (b *Bridge) RebateStakingRepairedEvent( + opts *ethereum.SubscribeOpts, +) *BRebateStakingRepairedSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &BRebateStakingRepairedSubscription{ + b, + opts, + } +} + +type BRebateStakingRepairedSubscription struct { + contract *Bridge + opts *ethereum.SubscribeOpts +} + +type bridgeRebateStakingRepairedFunc func( + OldRebateStaking common.Address, + NewRebateStaking common.Address, + blockNumber uint64, +) + +func (rsrs *BRebateStakingRepairedSubscription) OnEvent( + handler bridgeRebateStakingRepairedFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.BridgeRebateStakingRepaired) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.OldRebateStaking, + event.NewRebateStaking, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := rsrs.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rsrs *BRebateStakingRepairedSubscription) Pipe( + sink chan *abi.BridgeRebateStakingRepaired, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(rsrs.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := rsrs.contract.blockCounter.CurrentBlock() + if err != nil { + bLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - rsrs.opts.PastBlocks + + bLogger.Infof( + "subscription monitoring fetching past RebateStakingRepaired events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := rsrs.contract.PastRebateStakingRepairedEvents( + fromBlock, + nil, + ) + if err != nil { + bLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + bLogger.Infof( + "subscription monitoring fetched [%v] past RebateStakingRepaired events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := rsrs.contract.watchRebateStakingRepaired( + sink, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (b *Bridge) watchRebateStakingRepaired( + sink chan *abi.BridgeRebateStakingRepaired, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return b.contract.WatchRebateStakingRepaired( + &bind.WatchOpts{Context: ctx}, + sink, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + bLogger.Warnf( + "subscription to event RebateStakingRepaired had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + bLogger.Errorf( + "subscription to event RebateStakingRepaired failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (b *Bridge) PastRebateStakingRepairedEvents( + startBlock uint64, + endBlock *uint64, +) ([]*abi.BridgeRebateStakingRepaired, error) { + iterator, err := b.contract.FilterRebateStakingRepaired( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past RebateStakingRepaired events: [%v]", + err, + ) + } + + events := make([]*abi.BridgeRebateStakingRepaired, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (b *Bridge) RebateStakingSetEvent( + opts *ethereum.SubscribeOpts, +) *BRebateStakingSetSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &BRebateStakingSetSubscription{ + b, + opts, + } +} + +type BRebateStakingSetSubscription struct { + contract *Bridge + opts *ethereum.SubscribeOpts +} + +type bridgeRebateStakingSetFunc func( + RebateStaking common.Address, + blockNumber uint64, +) + +func (rsss *BRebateStakingSetSubscription) OnEvent( + handler bridgeRebateStakingSetFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.BridgeRebateStakingSet) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.RebateStaking, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := rsss.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rsss *BRebateStakingSetSubscription) Pipe( + sink chan *abi.BridgeRebateStakingSet, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(rsss.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := rsss.contract.blockCounter.CurrentBlock() + if err != nil { + bLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - rsss.opts.PastBlocks + + bLogger.Infof( + "subscription monitoring fetching past RebateStakingSet events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := rsss.contract.PastRebateStakingSetEvents( + fromBlock, + nil, + ) + if err != nil { + bLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + bLogger.Infof( + "subscription monitoring fetched [%v] past RebateStakingSet events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := rsss.contract.watchRebateStakingSet( + sink, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (b *Bridge) watchRebateStakingSet( + sink chan *abi.BridgeRebateStakingSet, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return b.contract.WatchRebateStakingSet( + &bind.WatchOpts{Context: ctx}, + sink, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + bLogger.Warnf( + "subscription to event RebateStakingSet had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + bLogger.Errorf( + "subscription to event RebateStakingSet failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (b *Bridge) PastRebateStakingSetEvents( + startBlock uint64, + endBlock *uint64, +) ([]*abi.BridgeRebateStakingSet, error) { + iterator, err := b.contract.FilterRebateStakingSet( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past RebateStakingSet events: [%v]", + err, + ) + } + + events := make([]*abi.BridgeRebateStakingSet, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + func (b *Bridge) RedemptionParametersUpdatedEvent( opts *ethereum.SubscribeOpts, ) *BRedemptionParametersUpdatedSubscription { diff --git a/pkg/chain/ethereum/tbtc/gen/contract/RedemptionWatchtower.go b/pkg/chain/ethereum/tbtc/gen/contract/RedemptionWatchtower.go index 9e49a30418..bd40cd601a 100644 --- a/pkg/chain/ethereum/tbtc/gen/contract/RedemptionWatchtower.go +++ b/pkg/chain/ethereum/tbtc/gen/contract/RedemptionWatchtower.go @@ -2165,6 +2165,43 @@ func (rw *RedemptionWatchtower) OwnerAtBlock( return result, err } +func (rw *RedemptionWatchtower) REQUIREDOBJECTIONSCOUNT() (uint8, error) { + result, err := rw.contract.REQUIREDOBJECTIONSCOUNT( + rw.callerOptions, + ) + + if err != nil { + return result, rw.errorResolver.ResolveError( + err, + rw.callerOptions.From, + nil, + "rEQUIREDOBJECTIONSCOUNT", + ) + } + + return result, err +} + +func (rw *RedemptionWatchtower) REQUIREDOBJECTIONSCOUNTAtBlock( + blockNumber *big.Int, +) (uint8, error) { + var result uint8 + + err := chainutil.CallAtBlock( + rw.callerOptions.From, + blockNumber, + nil, + rw.contractABI, + rw.caller, + rw.errorResolver, + rw.contractAddress, + "rEQUIREDOBJECTIONSCOUNT", + &result, + ) + + return result, err +} + func (rw *RedemptionWatchtower) VetoFreezePeriod() (uint32, error) { result, err := rw.contract.VetoFreezePeriod( rw.callerOptions, diff --git a/pkg/chain/ethereum/tbtc/gen/contract/ReservationRouter.go b/pkg/chain/ethereum/tbtc/gen/contract/ReservationRouter.go new file mode 100644 index 0000000000..97b6eba015 --- /dev/null +++ b/pkg/chain/ethereum/tbtc/gen/contract/ReservationRouter.go @@ -0,0 +1,5187 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package contract + +import ( + "context" + "fmt" + "math/big" + "strings" + "sync" + "time" + + hostchainabi "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "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/event" + + "github.com/ipfs/go-log" + + "github.com/keep-network/keep-common/pkg/chain/ethereum" + chainutil "github.com/keep-network/keep-common/pkg/chain/ethereum/ethutil" + "github.com/keep-network/keep-common/pkg/subscription" + "github.com/keep-network/keep-core/pkg/chain/ethereum/tbtc/gen/abi" +) + +// Create a package-level logger for this contract. The logger exists at +// package level so that the logger is registered at startup and can be +// included or excluded from logging at startup by name. +var rrLogger = log.Logger("keep-contract-ReservationRouter") + +type ReservationRouter struct { + contract *abi.ReservationRouter + contractAddress common.Address + contractABI *hostchainabi.ABI + caller bind.ContractCaller + transactor bind.ContractTransactor + callerOptions *bind.CallOpts + transactorOptions *bind.TransactOpts + errorResolver *chainutil.ErrorResolver + nonceManager *ethereum.NonceManager + miningWaiter *chainutil.MiningWaiter + blockCounter *ethereum.BlockCounter + + transactionMutex *sync.Mutex +} + +func NewReservationRouter( + contractAddress common.Address, + chainId *big.Int, + accountKey *keystore.Key, + backend bind.ContractBackend, + nonceManager *ethereum.NonceManager, + miningWaiter *chainutil.MiningWaiter, + blockCounter *ethereum.BlockCounter, + transactionMutex *sync.Mutex, +) (*ReservationRouter, error) { + callerOptions := &bind.CallOpts{ + From: accountKey.Address, + } + + transactorOptions, err := bind.NewKeyedTransactorWithChainID( + accountKey.PrivateKey, + chainId, + ) + if err != nil { + return nil, fmt.Errorf("failed to instantiate transactor: [%v]", err) + } + + contract, err := abi.NewReservationRouter( + contractAddress, + backend, + ) + if err != nil { + return nil, fmt.Errorf( + "failed to instantiate contract at address: %s [%v]", + contractAddress.String(), + err, + ) + } + + contractABI, err := hostchainabi.JSON(strings.NewReader(abi.ReservationRouterABI)) + if err != nil { + return nil, fmt.Errorf("failed to instantiate ABI: [%v]", err) + } + + return &ReservationRouter{ + contract: contract, + contractAddress: contractAddress, + contractABI: &contractABI, + caller: backend, + transactor: backend, + callerOptions: callerOptions, + transactorOptions: transactorOptions, + errorResolver: chainutil.NewErrorResolver(backend, &contractABI, &contractAddress), + nonceManager: nonceManager, + miningWaiter: miningWaiter, + blockCounter: blockCounter, + transactionMutex: transactionMutex, + }, nil +} + +// ----- Non-const Methods ------ + +// Transaction submission. +func (rr *ReservationRouter) NotifyReservationActionTimeout( + arg_reservationKey *big.Int, + arg_walletMembersIDs []uint32, + + transactionOptions ...chainutil.TransactionOptions, +) (*types.Transaction, error) { + rrLogger.Debug( + "submitting transaction notifyReservationActionTimeout", + " params: ", + fmt.Sprint( + arg_reservationKey, + arg_walletMembersIDs, + ), + ) + + rr.transactionMutex.Lock() + defer rr.transactionMutex.Unlock() + + // create a copy + transactorOptions := new(bind.TransactOpts) + *transactorOptions = *rr.transactorOptions + + if len(transactionOptions) > 1 { + return nil, fmt.Errorf( + "could not process multiple transaction options sets", + ) + } else if len(transactionOptions) > 0 { + transactionOptions[0].Apply(transactorOptions) + } + + nonce, err := rr.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } + + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) + + transaction, err := rr.contract.NotifyReservationActionTimeout( + transactorOptions, + arg_reservationKey, + arg_walletMembersIDs, + ) + if err != nil { + return transaction, rr.errorResolver.ResolveError( + err, + rr.transactorOptions.From, + nil, + "notifyReservationActionTimeout", + arg_reservationKey, + arg_walletMembersIDs, + ) + } + + rrLogger.Infof( + "submitted transaction notifyReservationActionTimeout with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + go rr.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + // If original transactor options has a non-zero gas limit, that + // means the client code set it on their own. In that case, we + // should rewrite the gas limit from the original transaction + // for each resubmission. If the gas limit is not set by the client + // code, let the the submitter re-estimate the gas limit on each + // resubmission. + if transactorOptions.GasLimit != 0 { + newTransactorOptions.GasLimit = transactorOptions.GasLimit + } + + transaction, err := rr.contract.NotifyReservationActionTimeout( + newTransactorOptions, + arg_reservationKey, + arg_walletMembersIDs, + ) + if err != nil { + return nil, rr.errorResolver.ResolveError( + err, + rr.transactorOptions.From, + nil, + "notifyReservationActionTimeout", + arg_reservationKey, + arg_walletMembersIDs, + ) + } + + rrLogger.Infof( + "submitted transaction notifyReservationActionTimeout with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + return transaction, nil + }, + ) + + rr.nonceManager.IncrementNonce() + + return transaction, err +} + +// Non-mutating call, not a transaction submission. +func (rr *ReservationRouter) CallNotifyReservationActionTimeout( + arg_reservationKey *big.Int, + arg_walletMembersIDs []uint32, + blockNumber *big.Int, +) error { + var result interface{} = nil + + err := chainutil.CallAtBlock( + rr.transactorOptions.From, + blockNumber, nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "notifyReservationActionTimeout", + &result, + arg_reservationKey, + arg_walletMembersIDs, + ) + + return err +} + +func (rr *ReservationRouter) NotifyReservationActionTimeoutGasEstimate( + arg_reservationKey *big.Int, + arg_walletMembersIDs []uint32, +) (uint64, error) { + var result uint64 + + result, err := chainutil.EstimateGas( + rr.callerOptions.From, + rr.contractAddress, + "notifyReservationActionTimeout", + rr.contractABI, + rr.transactor, + arg_reservationKey, + arg_walletMembersIDs, + ) + + return result, err +} + +// Transaction submission. +func (rr *ReservationRouter) NotifyReservationStranded( + arg_reservationKey *big.Int, + + transactionOptions ...chainutil.TransactionOptions, +) (*types.Transaction, error) { + rrLogger.Debug( + "submitting transaction notifyReservationStranded", + " params: ", + fmt.Sprint( + arg_reservationKey, + ), + ) + + rr.transactionMutex.Lock() + defer rr.transactionMutex.Unlock() + + // create a copy + transactorOptions := new(bind.TransactOpts) + *transactorOptions = *rr.transactorOptions + + if len(transactionOptions) > 1 { + return nil, fmt.Errorf( + "could not process multiple transaction options sets", + ) + } else if len(transactionOptions) > 0 { + transactionOptions[0].Apply(transactorOptions) + } + + nonce, err := rr.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } + + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) + + transaction, err := rr.contract.NotifyReservationStranded( + transactorOptions, + arg_reservationKey, + ) + if err != nil { + return transaction, rr.errorResolver.ResolveError( + err, + rr.transactorOptions.From, + nil, + "notifyReservationStranded", + arg_reservationKey, + ) + } + + rrLogger.Infof( + "submitted transaction notifyReservationStranded with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + go rr.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + // If original transactor options has a non-zero gas limit, that + // means the client code set it on their own. In that case, we + // should rewrite the gas limit from the original transaction + // for each resubmission. If the gas limit is not set by the client + // code, let the the submitter re-estimate the gas limit on each + // resubmission. + if transactorOptions.GasLimit != 0 { + newTransactorOptions.GasLimit = transactorOptions.GasLimit + } + + transaction, err := rr.contract.NotifyReservationStranded( + newTransactorOptions, + arg_reservationKey, + ) + if err != nil { + return nil, rr.errorResolver.ResolveError( + err, + rr.transactorOptions.From, + nil, + "notifyReservationStranded", + arg_reservationKey, + ) + } + + rrLogger.Infof( + "submitted transaction notifyReservationStranded with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + return transaction, nil + }, + ) + + rr.nonceManager.IncrementNonce() + + return transaction, err +} + +// Non-mutating call, not a transaction submission. +func (rr *ReservationRouter) CallNotifyReservationStranded( + arg_reservationKey *big.Int, + blockNumber *big.Int, +) error { + var result interface{} = nil + + err := chainutil.CallAtBlock( + rr.transactorOptions.From, + blockNumber, nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "notifyReservationStranded", + &result, + arg_reservationKey, + ) + + return err +} + +func (rr *ReservationRouter) NotifyReservationStrandedGasEstimate( + arg_reservationKey *big.Int, +) (uint64, error) { + var result uint64 + + result, err := chainutil.EstimateGas( + rr.callerOptions.From, + rr.contractAddress, + "notifyReservationStranded", + rr.contractABI, + rr.transactor, + arg_reservationKey, + ) + + return result, err +} + +// Transaction submission. +func (rr *ReservationRouter) NotifyStaleReservedDeposit( + arg_depositKey *big.Int, + + transactionOptions ...chainutil.TransactionOptions, +) (*types.Transaction, error) { + rrLogger.Debug( + "submitting transaction notifyStaleReservedDeposit", + " params: ", + fmt.Sprint( + arg_depositKey, + ), + ) + + rr.transactionMutex.Lock() + defer rr.transactionMutex.Unlock() + + // create a copy + transactorOptions := new(bind.TransactOpts) + *transactorOptions = *rr.transactorOptions + + if len(transactionOptions) > 1 { + return nil, fmt.Errorf( + "could not process multiple transaction options sets", + ) + } else if len(transactionOptions) > 0 { + transactionOptions[0].Apply(transactorOptions) + } + + nonce, err := rr.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } + + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) + + transaction, err := rr.contract.NotifyStaleReservedDeposit( + transactorOptions, + arg_depositKey, + ) + if err != nil { + return transaction, rr.errorResolver.ResolveError( + err, + rr.transactorOptions.From, + nil, + "notifyStaleReservedDeposit", + arg_depositKey, + ) + } + + rrLogger.Infof( + "submitted transaction notifyStaleReservedDeposit with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + go rr.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + // If original transactor options has a non-zero gas limit, that + // means the client code set it on their own. In that case, we + // should rewrite the gas limit from the original transaction + // for each resubmission. If the gas limit is not set by the client + // code, let the the submitter re-estimate the gas limit on each + // resubmission. + if transactorOptions.GasLimit != 0 { + newTransactorOptions.GasLimit = transactorOptions.GasLimit + } + + transaction, err := rr.contract.NotifyStaleReservedDeposit( + newTransactorOptions, + arg_depositKey, + ) + if err != nil { + return nil, rr.errorResolver.ResolveError( + err, + rr.transactorOptions.From, + nil, + "notifyStaleReservedDeposit", + arg_depositKey, + ) + } + + rrLogger.Infof( + "submitted transaction notifyStaleReservedDeposit with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + return transaction, nil + }, + ) + + rr.nonceManager.IncrementNonce() + + return transaction, err +} + +// Non-mutating call, not a transaction submission. +func (rr *ReservationRouter) CallNotifyStaleReservedDeposit( + arg_depositKey *big.Int, + blockNumber *big.Int, +) error { + var result interface{} = nil + + err := chainutil.CallAtBlock( + rr.transactorOptions.From, + blockNumber, nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "notifyStaleReservedDeposit", + &result, + arg_depositKey, + ) + + return err +} + +func (rr *ReservationRouter) NotifyStaleReservedDepositGasEstimate( + arg_depositKey *big.Int, +) (uint64, error) { + var result uint64 + + result, err := chainutil.EstimateGas( + rr.callerOptions.From, + rr.contractAddress, + "notifyStaleReservedDeposit", + rr.contractABI, + rr.transactor, + arg_depositKey, + ) + + return result, err +} + +// Transaction submission. +func (rr *ReservationRouter) RequestReservationAcceptance( + arg_reservationKey *big.Int, + arg_walletPubKeyHash [20]byte, + + transactionOptions ...chainutil.TransactionOptions, +) (*types.Transaction, error) { + rrLogger.Debug( + "submitting transaction requestReservationAcceptance", + " params: ", + fmt.Sprint( + arg_reservationKey, + arg_walletPubKeyHash, + ), + ) + + rr.transactionMutex.Lock() + defer rr.transactionMutex.Unlock() + + // create a copy + transactorOptions := new(bind.TransactOpts) + *transactorOptions = *rr.transactorOptions + + if len(transactionOptions) > 1 { + return nil, fmt.Errorf( + "could not process multiple transaction options sets", + ) + } else if len(transactionOptions) > 0 { + transactionOptions[0].Apply(transactorOptions) + } + + nonce, err := rr.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } + + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) + + transaction, err := rr.contract.RequestReservationAcceptance( + transactorOptions, + arg_reservationKey, + arg_walletPubKeyHash, + ) + if err != nil { + return transaction, rr.errorResolver.ResolveError( + err, + rr.transactorOptions.From, + nil, + "requestReservationAcceptance", + arg_reservationKey, + arg_walletPubKeyHash, + ) + } + + rrLogger.Infof( + "submitted transaction requestReservationAcceptance with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + go rr.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + // If original transactor options has a non-zero gas limit, that + // means the client code set it on their own. In that case, we + // should rewrite the gas limit from the original transaction + // for each resubmission. If the gas limit is not set by the client + // code, let the the submitter re-estimate the gas limit on each + // resubmission. + if transactorOptions.GasLimit != 0 { + newTransactorOptions.GasLimit = transactorOptions.GasLimit + } + + transaction, err := rr.contract.RequestReservationAcceptance( + newTransactorOptions, + arg_reservationKey, + arg_walletPubKeyHash, + ) + if err != nil { + return nil, rr.errorResolver.ResolveError( + err, + rr.transactorOptions.From, + nil, + "requestReservationAcceptance", + arg_reservationKey, + arg_walletPubKeyHash, + ) + } + + rrLogger.Infof( + "submitted transaction requestReservationAcceptance with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + return transaction, nil + }, + ) + + rr.nonceManager.IncrementNonce() + + return transaction, err +} + +// Non-mutating call, not a transaction submission. +func (rr *ReservationRouter) CallRequestReservationAcceptance( + arg_reservationKey *big.Int, + arg_walletPubKeyHash [20]byte, + blockNumber *big.Int, +) error { + var result interface{} = nil + + err := chainutil.CallAtBlock( + rr.transactorOptions.From, + blockNumber, nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "requestReservationAcceptance", + &result, + arg_reservationKey, + arg_walletPubKeyHash, + ) + + return err +} + +func (rr *ReservationRouter) RequestReservationAcceptanceGasEstimate( + arg_reservationKey *big.Int, + arg_walletPubKeyHash [20]byte, +) (uint64, error) { + var result uint64 + + result, err := chainutil.EstimateGas( + rr.callerOptions.From, + rr.contractAddress, + "requestReservationAcceptance", + rr.contractABI, + rr.transactor, + arg_reservationKey, + arg_walletPubKeyHash, + ) + + return result, err +} + +// Transaction submission. +func (rr *ReservationRouter) RequestReservationReanchor( + arg_reservationKey *big.Int, + arg_targetWalletPubKeyHash [20]byte, + + transactionOptions ...chainutil.TransactionOptions, +) (*types.Transaction, error) { + rrLogger.Debug( + "submitting transaction requestReservationReanchor", + " params: ", + fmt.Sprint( + arg_reservationKey, + arg_targetWalletPubKeyHash, + ), + ) + + rr.transactionMutex.Lock() + defer rr.transactionMutex.Unlock() + + // create a copy + transactorOptions := new(bind.TransactOpts) + *transactorOptions = *rr.transactorOptions + + if len(transactionOptions) > 1 { + return nil, fmt.Errorf( + "could not process multiple transaction options sets", + ) + } else if len(transactionOptions) > 0 { + transactionOptions[0].Apply(transactorOptions) + } + + nonce, err := rr.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } + + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) + + transaction, err := rr.contract.RequestReservationReanchor( + transactorOptions, + arg_reservationKey, + arg_targetWalletPubKeyHash, + ) + if err != nil { + return transaction, rr.errorResolver.ResolveError( + err, + rr.transactorOptions.From, + nil, + "requestReservationReanchor", + arg_reservationKey, + arg_targetWalletPubKeyHash, + ) + } + + rrLogger.Infof( + "submitted transaction requestReservationReanchor with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + go rr.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + // If original transactor options has a non-zero gas limit, that + // means the client code set it on their own. In that case, we + // should rewrite the gas limit from the original transaction + // for each resubmission. If the gas limit is not set by the client + // code, let the the submitter re-estimate the gas limit on each + // resubmission. + if transactorOptions.GasLimit != 0 { + newTransactorOptions.GasLimit = transactorOptions.GasLimit + } + + transaction, err := rr.contract.RequestReservationReanchor( + newTransactorOptions, + arg_reservationKey, + arg_targetWalletPubKeyHash, + ) + if err != nil { + return nil, rr.errorResolver.ResolveError( + err, + rr.transactorOptions.From, + nil, + "requestReservationReanchor", + arg_reservationKey, + arg_targetWalletPubKeyHash, + ) + } + + rrLogger.Infof( + "submitted transaction requestReservationReanchor with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + return transaction, nil + }, + ) + + rr.nonceManager.IncrementNonce() + + return transaction, err +} + +// Non-mutating call, not a transaction submission. +func (rr *ReservationRouter) CallRequestReservationReanchor( + arg_reservationKey *big.Int, + arg_targetWalletPubKeyHash [20]byte, + blockNumber *big.Int, +) error { + var result interface{} = nil + + err := chainutil.CallAtBlock( + rr.transactorOptions.From, + blockNumber, nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "requestReservationReanchor", + &result, + arg_reservationKey, + arg_targetWalletPubKeyHash, + ) + + return err +} + +func (rr *ReservationRouter) RequestReservationReanchorGasEstimate( + arg_reservationKey *big.Int, + arg_targetWalletPubKeyHash [20]byte, +) (uint64, error) { + var result uint64 + + result, err := chainutil.EstimateGas( + rr.callerOptions.From, + rr.contractAddress, + "requestReservationReanchor", + rr.contractABI, + rr.transactor, + arg_reservationKey, + arg_targetWalletPubKeyHash, + ) + + return result, err +} + +// Transaction submission. +func (rr *ReservationRouter) SubmitReservationProof( + arg_proofType uint8, + arg_txInfo abi.BitcoinTxInfo4, + arg_proof abi.BitcoinTxProof3, + arg_mainUtxo abi.BitcoinTxUTXO4, + arg_reservationKey *big.Int, + arg_requestNonce uint64, + + transactionOptions ...chainutil.TransactionOptions, +) (*types.Transaction, error) { + rrLogger.Debug( + "submitting transaction submitReservationProof", + " params: ", + fmt.Sprint( + arg_proofType, + arg_txInfo, + arg_proof, + arg_mainUtxo, + arg_reservationKey, + arg_requestNonce, + ), + ) + + rr.transactionMutex.Lock() + defer rr.transactionMutex.Unlock() + + // create a copy + transactorOptions := new(bind.TransactOpts) + *transactorOptions = *rr.transactorOptions + + if len(transactionOptions) > 1 { + return nil, fmt.Errorf( + "could not process multiple transaction options sets", + ) + } else if len(transactionOptions) > 0 { + transactionOptions[0].Apply(transactorOptions) + } + + nonce, err := rr.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } + + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) + + transaction, err := rr.contract.SubmitReservationProof( + transactorOptions, + arg_proofType, + arg_txInfo, + arg_proof, + arg_mainUtxo, + arg_reservationKey, + arg_requestNonce, + ) + if err != nil { + return transaction, rr.errorResolver.ResolveError( + err, + rr.transactorOptions.From, + nil, + "submitReservationProof", + arg_proofType, + arg_txInfo, + arg_proof, + arg_mainUtxo, + arg_reservationKey, + arg_requestNonce, + ) + } + + rrLogger.Infof( + "submitted transaction submitReservationProof with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + go rr.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + // If original transactor options has a non-zero gas limit, that + // means the client code set it on their own. In that case, we + // should rewrite the gas limit from the original transaction + // for each resubmission. If the gas limit is not set by the client + // code, let the the submitter re-estimate the gas limit on each + // resubmission. + if transactorOptions.GasLimit != 0 { + newTransactorOptions.GasLimit = transactorOptions.GasLimit + } + + transaction, err := rr.contract.SubmitReservationProof( + newTransactorOptions, + arg_proofType, + arg_txInfo, + arg_proof, + arg_mainUtxo, + arg_reservationKey, + arg_requestNonce, + ) + if err != nil { + return nil, rr.errorResolver.ResolveError( + err, + rr.transactorOptions.From, + nil, + "submitReservationProof", + arg_proofType, + arg_txInfo, + arg_proof, + arg_mainUtxo, + arg_reservationKey, + arg_requestNonce, + ) + } + + rrLogger.Infof( + "submitted transaction submitReservationProof with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + return transaction, nil + }, + ) + + rr.nonceManager.IncrementNonce() + + return transaction, err +} + +// Non-mutating call, not a transaction submission. +func (rr *ReservationRouter) CallSubmitReservationProof( + arg_proofType uint8, + arg_txInfo abi.BitcoinTxInfo4, + arg_proof abi.BitcoinTxProof3, + arg_mainUtxo abi.BitcoinTxUTXO4, + arg_reservationKey *big.Int, + arg_requestNonce uint64, + blockNumber *big.Int, +) error { + var result interface{} = nil + + err := chainutil.CallAtBlock( + rr.transactorOptions.From, + blockNumber, nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "submitReservationProof", + &result, + arg_proofType, + arg_txInfo, + arg_proof, + arg_mainUtxo, + arg_reservationKey, + arg_requestNonce, + ) + + return err +} + +func (rr *ReservationRouter) SubmitReservationProofGasEstimate( + arg_proofType uint8, + arg_txInfo abi.BitcoinTxInfo4, + arg_proof abi.BitcoinTxProof3, + arg_mainUtxo abi.BitcoinTxUTXO4, + arg_reservationKey *big.Int, + arg_requestNonce uint64, +) (uint64, error) { + var result uint64 + + result, err := chainutil.EstimateGas( + rr.callerOptions.From, + rr.contractAddress, + "submitReservationProof", + rr.contractABI, + rr.transactor, + arg_proofType, + arg_txInfo, + arg_proof, + arg_mainUtxo, + arg_reservationKey, + arg_requestNonce, + ) + + return result, err +} + +// Transaction submission. +func (rr *ReservationRouter) TransferGovernance( + arg_newGovernance common.Address, + + transactionOptions ...chainutil.TransactionOptions, +) (*types.Transaction, error) { + rrLogger.Debug( + "submitting transaction transferGovernance", + " params: ", + fmt.Sprint( + arg_newGovernance, + ), + ) + + rr.transactionMutex.Lock() + defer rr.transactionMutex.Unlock() + + // create a copy + transactorOptions := new(bind.TransactOpts) + *transactorOptions = *rr.transactorOptions + + if len(transactionOptions) > 1 { + return nil, fmt.Errorf( + "could not process multiple transaction options sets", + ) + } else if len(transactionOptions) > 0 { + transactionOptions[0].Apply(transactorOptions) + } + + nonce, err := rr.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } + + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) + + transaction, err := rr.contract.TransferGovernance( + transactorOptions, + arg_newGovernance, + ) + if err != nil { + return transaction, rr.errorResolver.ResolveError( + err, + rr.transactorOptions.From, + nil, + "transferGovernance", + arg_newGovernance, + ) + } + + rrLogger.Infof( + "submitted transaction transferGovernance with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + go rr.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + // If original transactor options has a non-zero gas limit, that + // means the client code set it on their own. In that case, we + // should rewrite the gas limit from the original transaction + // for each resubmission. If the gas limit is not set by the client + // code, let the the submitter re-estimate the gas limit on each + // resubmission. + if transactorOptions.GasLimit != 0 { + newTransactorOptions.GasLimit = transactorOptions.GasLimit + } + + transaction, err := rr.contract.TransferGovernance( + newTransactorOptions, + arg_newGovernance, + ) + if err != nil { + return nil, rr.errorResolver.ResolveError( + err, + rr.transactorOptions.From, + nil, + "transferGovernance", + arg_newGovernance, + ) + } + + rrLogger.Infof( + "submitted transaction transferGovernance with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + return transaction, nil + }, + ) + + rr.nonceManager.IncrementNonce() + + return transaction, err +} + +// Non-mutating call, not a transaction submission. +func (rr *ReservationRouter) CallTransferGovernance( + arg_newGovernance common.Address, + blockNumber *big.Int, +) error { + var result interface{} = nil + + err := chainutil.CallAtBlock( + rr.transactorOptions.From, + blockNumber, nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "transferGovernance", + &result, + arg_newGovernance, + ) + + return err +} + +func (rr *ReservationRouter) TransferGovernanceGasEstimate( + arg_newGovernance common.Address, +) (uint64, error) { + var result uint64 + + result, err := chainutil.EstimateGas( + rr.callerOptions.From, + rr.contractAddress, + "transferGovernance", + rr.contractABI, + rr.transactor, + arg_newGovernance, + ) + + return result, err +} + +// Transaction submission. +func (rr *ReservationRouter) UpdateReservationCaps( + arg_maxReservationsAmountPerWallet uint64, + arg_reservationMaxSingleAmount uint64, + arg_maxActiveReservations uint32, + + transactionOptions ...chainutil.TransactionOptions, +) (*types.Transaction, error) { + rrLogger.Debug( + "submitting transaction updateReservationCaps", + " params: ", + fmt.Sprint( + arg_maxReservationsAmountPerWallet, + arg_reservationMaxSingleAmount, + arg_maxActiveReservations, + ), + ) + + rr.transactionMutex.Lock() + defer rr.transactionMutex.Unlock() + + // create a copy + transactorOptions := new(bind.TransactOpts) + *transactorOptions = *rr.transactorOptions + + if len(transactionOptions) > 1 { + return nil, fmt.Errorf( + "could not process multiple transaction options sets", + ) + } else if len(transactionOptions) > 0 { + transactionOptions[0].Apply(transactorOptions) + } + + nonce, err := rr.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } + + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) + + transaction, err := rr.contract.UpdateReservationCaps( + transactorOptions, + arg_maxReservationsAmountPerWallet, + arg_reservationMaxSingleAmount, + arg_maxActiveReservations, + ) + if err != nil { + return transaction, rr.errorResolver.ResolveError( + err, + rr.transactorOptions.From, + nil, + "updateReservationCaps", + arg_maxReservationsAmountPerWallet, + arg_reservationMaxSingleAmount, + arg_maxActiveReservations, + ) + } + + rrLogger.Infof( + "submitted transaction updateReservationCaps with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + go rr.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + // If original transactor options has a non-zero gas limit, that + // means the client code set it on their own. In that case, we + // should rewrite the gas limit from the original transaction + // for each resubmission. If the gas limit is not set by the client + // code, let the the submitter re-estimate the gas limit on each + // resubmission. + if transactorOptions.GasLimit != 0 { + newTransactorOptions.GasLimit = transactorOptions.GasLimit + } + + transaction, err := rr.contract.UpdateReservationCaps( + newTransactorOptions, + arg_maxReservationsAmountPerWallet, + arg_reservationMaxSingleAmount, + arg_maxActiveReservations, + ) + if err != nil { + return nil, rr.errorResolver.ResolveError( + err, + rr.transactorOptions.From, + nil, + "updateReservationCaps", + arg_maxReservationsAmountPerWallet, + arg_reservationMaxSingleAmount, + arg_maxActiveReservations, + ) + } + + rrLogger.Infof( + "submitted transaction updateReservationCaps with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + return transaction, nil + }, + ) + + rr.nonceManager.IncrementNonce() + + return transaction, err +} + +// Non-mutating call, not a transaction submission. +func (rr *ReservationRouter) CallUpdateReservationCaps( + arg_maxReservationsAmountPerWallet uint64, + arg_reservationMaxSingleAmount uint64, + arg_maxActiveReservations uint32, + blockNumber *big.Int, +) error { + var result interface{} = nil + + err := chainutil.CallAtBlock( + rr.transactorOptions.From, + blockNumber, nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "updateReservationCaps", + &result, + arg_maxReservationsAmountPerWallet, + arg_reservationMaxSingleAmount, + arg_maxActiveReservations, + ) + + return err +} + +func (rr *ReservationRouter) UpdateReservationCapsGasEstimate( + arg_maxReservationsAmountPerWallet uint64, + arg_reservationMaxSingleAmount uint64, + arg_maxActiveReservations uint32, +) (uint64, error) { + var result uint64 + + result, err := chainutil.EstimateGas( + rr.callerOptions.From, + rr.contractAddress, + "updateReservationCaps", + rr.contractABI, + rr.transactor, + arg_maxReservationsAmountPerWallet, + arg_reservationMaxSingleAmount, + arg_maxActiveReservations, + ) + + return result, err +} + +// Transaction submission. +func (rr *ReservationRouter) UpdateReservationParameters( + arg_reservationVault common.Address, + arg_reservationMinAmount uint64, + arg_reservationTxMaxFee uint64, + arg_reservationTermSeconds uint32, + arg_reservationDissolutionDelay uint32, + arg_reservationMaxTotalAmount uint64, + arg_maxReservationsPerWallet uint32, + arg_reservationActionTimeout uint32, + arg_reservationRenewalWindowSeconds uint32, + + transactionOptions ...chainutil.TransactionOptions, +) (*types.Transaction, error) { + rrLogger.Debug( + "submitting transaction updateReservationParameters", + " params: ", + fmt.Sprint( + arg_reservationVault, + arg_reservationMinAmount, + arg_reservationTxMaxFee, + arg_reservationTermSeconds, + arg_reservationDissolutionDelay, + arg_reservationMaxTotalAmount, + arg_maxReservationsPerWallet, + arg_reservationActionTimeout, + arg_reservationRenewalWindowSeconds, + ), + ) + + rr.transactionMutex.Lock() + defer rr.transactionMutex.Unlock() + + // create a copy + transactorOptions := new(bind.TransactOpts) + *transactorOptions = *rr.transactorOptions + + if len(transactionOptions) > 1 { + return nil, fmt.Errorf( + "could not process multiple transaction options sets", + ) + } else if len(transactionOptions) > 0 { + transactionOptions[0].Apply(transactorOptions) + } + + nonce, err := rr.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } + + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) + + transaction, err := rr.contract.UpdateReservationParameters( + transactorOptions, + arg_reservationVault, + arg_reservationMinAmount, + arg_reservationTxMaxFee, + arg_reservationTermSeconds, + arg_reservationDissolutionDelay, + arg_reservationMaxTotalAmount, + arg_maxReservationsPerWallet, + arg_reservationActionTimeout, + arg_reservationRenewalWindowSeconds, + ) + if err != nil { + return transaction, rr.errorResolver.ResolveError( + err, + rr.transactorOptions.From, + nil, + "updateReservationParameters", + arg_reservationVault, + arg_reservationMinAmount, + arg_reservationTxMaxFee, + arg_reservationTermSeconds, + arg_reservationDissolutionDelay, + arg_reservationMaxTotalAmount, + arg_maxReservationsPerWallet, + arg_reservationActionTimeout, + arg_reservationRenewalWindowSeconds, + ) + } + + rrLogger.Infof( + "submitted transaction updateReservationParameters with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + go rr.miningWaiter.ForceMining( + transaction, + transactorOptions, + func(newTransactorOptions *bind.TransactOpts) (*types.Transaction, error) { + // If original transactor options has a non-zero gas limit, that + // means the client code set it on their own. In that case, we + // should rewrite the gas limit from the original transaction + // for each resubmission. If the gas limit is not set by the client + // code, let the the submitter re-estimate the gas limit on each + // resubmission. + if transactorOptions.GasLimit != 0 { + newTransactorOptions.GasLimit = transactorOptions.GasLimit + } + + transaction, err := rr.contract.UpdateReservationParameters( + newTransactorOptions, + arg_reservationVault, + arg_reservationMinAmount, + arg_reservationTxMaxFee, + arg_reservationTermSeconds, + arg_reservationDissolutionDelay, + arg_reservationMaxTotalAmount, + arg_maxReservationsPerWallet, + arg_reservationActionTimeout, + arg_reservationRenewalWindowSeconds, + ) + if err != nil { + return nil, rr.errorResolver.ResolveError( + err, + rr.transactorOptions.From, + nil, + "updateReservationParameters", + arg_reservationVault, + arg_reservationMinAmount, + arg_reservationTxMaxFee, + arg_reservationTermSeconds, + arg_reservationDissolutionDelay, + arg_reservationMaxTotalAmount, + arg_maxReservationsPerWallet, + arg_reservationActionTimeout, + arg_reservationRenewalWindowSeconds, + ) + } + + rrLogger.Infof( + "submitted transaction updateReservationParameters with id: [%s] and nonce [%v]", + transaction.Hash(), + transaction.Nonce(), + ) + + return transaction, nil + }, + ) + + rr.nonceManager.IncrementNonce() + + return transaction, err +} + +// Non-mutating call, not a transaction submission. +func (rr *ReservationRouter) CallUpdateReservationParameters( + arg_reservationVault common.Address, + arg_reservationMinAmount uint64, + arg_reservationTxMaxFee uint64, + arg_reservationTermSeconds uint32, + arg_reservationDissolutionDelay uint32, + arg_reservationMaxTotalAmount uint64, + arg_maxReservationsPerWallet uint32, + arg_reservationActionTimeout uint32, + arg_reservationRenewalWindowSeconds uint32, + blockNumber *big.Int, +) error { + var result interface{} = nil + + err := chainutil.CallAtBlock( + rr.transactorOptions.From, + blockNumber, nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "updateReservationParameters", + &result, + arg_reservationVault, + arg_reservationMinAmount, + arg_reservationTxMaxFee, + arg_reservationTermSeconds, + arg_reservationDissolutionDelay, + arg_reservationMaxTotalAmount, + arg_maxReservationsPerWallet, + arg_reservationActionTimeout, + arg_reservationRenewalWindowSeconds, + ) + + return err +} + +func (rr *ReservationRouter) UpdateReservationParametersGasEstimate( + arg_reservationVault common.Address, + arg_reservationMinAmount uint64, + arg_reservationTxMaxFee uint64, + arg_reservationTermSeconds uint32, + arg_reservationDissolutionDelay uint32, + arg_reservationMaxTotalAmount uint64, + arg_maxReservationsPerWallet uint32, + arg_reservationActionTimeout uint32, + arg_reservationRenewalWindowSeconds uint32, +) (uint64, error) { + var result uint64 + + result, err := chainutil.EstimateGas( + rr.callerOptions.From, + rr.contractAddress, + "updateReservationParameters", + rr.contractABI, + rr.transactor, + arg_reservationVault, + arg_reservationMinAmount, + arg_reservationTxMaxFee, + arg_reservationTermSeconds, + arg_reservationDissolutionDelay, + arg_reservationMaxTotalAmount, + arg_maxReservationsPerWallet, + arg_reservationActionTimeout, + arg_reservationRenewalWindowSeconds, + ) + + return result, err +} + +// ----- Const Methods ------ + +type activeReservationsCount struct { + Count uint32 + MaxActive uint32 +} + +func (rr *ReservationRouter) ActiveReservationsCount() (activeReservationsCount, error) { + result, err := rr.contract.ActiveReservationsCount( + rr.callerOptions, + ) + + if err != nil { + return result, rr.errorResolver.ResolveError( + err, + rr.callerOptions.From, + nil, + "activeReservationsCount", + ) + } + + return result, err +} + +func (rr *ReservationRouter) ActiveReservationsCountAtBlock( + blockNumber *big.Int, +) (activeReservationsCount, error) { + var result activeReservationsCount + + err := chainutil.CallAtBlock( + rr.callerOptions.From, + blockNumber, + nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "activeReservationsCount", + &result, + ) + + return result, err +} + +func (rr *ReservationRouter) Governance() (common.Address, error) { + result, err := rr.contract.Governance( + rr.callerOptions, + ) + + if err != nil { + return result, rr.errorResolver.ResolveError( + err, + rr.callerOptions.From, + nil, + "governance", + ) + } + + return result, err +} + +func (rr *ReservationRouter) GovernanceAtBlock( + blockNumber *big.Int, +) (common.Address, error) { + var result common.Address + + err := chainutil.CallAtBlock( + rr.callerOptions.From, + blockNumber, + nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "governance", + &result, + ) + + return result, err +} + +func (rr *ReservationRouter) PendingReservedDeposits() (uint64, error) { + result, err := rr.contract.PendingReservedDeposits( + rr.callerOptions, + ) + + if err != nil { + return result, rr.errorResolver.ResolveError( + err, + rr.callerOptions.From, + nil, + "pendingReservedDeposits", + ) + } + + return result, err +} + +func (rr *ReservationRouter) PendingReservedDepositsAtBlock( + blockNumber *big.Int, +) (uint64, error) { + var result uint64 + + err := chainutil.CallAtBlock( + rr.callerOptions.From, + blockNumber, + nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "pendingReservedDeposits", + &result, + ) + + return result, err +} + +func (rr *ReservationRouter) ReservationActions( + arg_reservationKey *big.Int, + arg_requestNonce uint64, +) (abi.ReservationReservationAction, error) { + result, err := rr.contract.ReservationActions( + rr.callerOptions, + arg_reservationKey, + arg_requestNonce, + ) + + if err != nil { + return result, rr.errorResolver.ResolveError( + err, + rr.callerOptions.From, + nil, + "reservationActions", + arg_reservationKey, + arg_requestNonce, + ) + } + + return result, err +} + +func (rr *ReservationRouter) ReservationActionsAtBlock( + arg_reservationKey *big.Int, + arg_requestNonce uint64, + blockNumber *big.Int, +) (abi.ReservationReservationAction, error) { + var result abi.ReservationReservationAction + + err := chainutil.CallAtBlock( + rr.callerOptions.From, + blockNumber, + nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "reservationActions", + &result, + arg_reservationKey, + arg_requestNonce, + ) + + return result, err +} + +func (rr *ReservationRouter) ReservationByAnchorUtxo( + arg_anchorTxHash [32]byte, + arg_anchorTxOutputIndex uint32, +) (*big.Int, error) { + result, err := rr.contract.ReservationByAnchorUtxo( + rr.callerOptions, + arg_anchorTxHash, + arg_anchorTxOutputIndex, + ) + + if err != nil { + return result, rr.errorResolver.ResolveError( + err, + rr.callerOptions.From, + nil, + "reservationByAnchorUtxo", + arg_anchorTxHash, + arg_anchorTxOutputIndex, + ) + } + + return result, err +} + +func (rr *ReservationRouter) ReservationByAnchorUtxoAtBlock( + arg_anchorTxHash [32]byte, + arg_anchorTxOutputIndex uint32, + blockNumber *big.Int, +) (*big.Int, error) { + var result *big.Int + + err := chainutil.CallAtBlock( + rr.callerOptions.From, + blockNumber, + nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "reservationByAnchorUtxo", + &result, + arg_anchorTxHash, + arg_anchorTxOutputIndex, + ) + + return result, err +} + +type reservationCaps struct { + MaxReservationsAmountPerWallet uint64 + ReservationMaxSingleAmount uint64 +} + +func (rr *ReservationRouter) ReservationCaps() (reservationCaps, error) { + result, err := rr.contract.ReservationCaps( + rr.callerOptions, + ) + + if err != nil { + return result, rr.errorResolver.ResolveError( + err, + rr.callerOptions.From, + nil, + "reservationCaps", + ) + } + + return result, err +} + +func (rr *ReservationRouter) ReservationCapsAtBlock( + blockNumber *big.Int, +) (reservationCaps, error) { + var result reservationCaps + + err := chainutil.CallAtBlock( + rr.callerOptions.From, + blockNumber, + nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "reservationCaps", + &result, + ) + + return result, err +} + +type reservationParameters struct { + ReservationVault common.Address + ReservationMinAmount uint64 + ReservationTxMaxFee uint64 + ReservationTermSeconds uint32 + ReservationDissolutionDelay uint32 + ReservationMaxTotalAmount uint64 + ReservationTotalAmount uint64 + MaxReservationsPerWallet uint32 + ReservationActionTimeout uint32 + ReservationRenewalWindowSeconds uint32 +} + +func (rr *ReservationRouter) ReservationParameters() (reservationParameters, error) { + result, err := rr.contract.ReservationParameters( + rr.callerOptions, + ) + + if err != nil { + return result, rr.errorResolver.ResolveError( + err, + rr.callerOptions.From, + nil, + "reservationParameters", + ) + } + + return result, err +} + +func (rr *ReservationRouter) ReservationParametersAtBlock( + blockNumber *big.Int, +) (reservationParameters, error) { + var result reservationParameters + + err := chainutil.CallAtBlock( + rr.callerOptions.From, + blockNumber, + nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "reservationParameters", + &result, + ) + + return result, err +} + +func (rr *ReservationRouter) ReservationRouter() (common.Address, error) { + result, err := rr.contract.ReservationRouter( + rr.callerOptions, + ) + + if err != nil { + return result, rr.errorResolver.ResolveError( + err, + rr.callerOptions.From, + nil, + "reservationRouter", + ) + } + + return result, err +} + +func (rr *ReservationRouter) ReservationRouterAtBlock( + blockNumber *big.Int, +) (common.Address, error) { + var result common.Address + + err := chainutil.CallAtBlock( + rr.callerOptions.From, + blockNumber, + nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "reservationRouter", + &result, + ) + + return result, err +} + +func (rr *ReservationRouter) Reservations( + arg_reservationKey *big.Int, +) (abi.ReservationReservationRequest, error) { + result, err := rr.contract.Reservations( + rr.callerOptions, + arg_reservationKey, + ) + + if err != nil { + return result, rr.errorResolver.ResolveError( + err, + rr.callerOptions.From, + nil, + "reservations", + arg_reservationKey, + ) + } + + return result, err +} + +func (rr *ReservationRouter) ReservationsAtBlock( + arg_reservationKey *big.Int, + blockNumber *big.Int, +) (abi.ReservationReservationRequest, error) { + var result abi.ReservationReservationRequest + + err := chainutil.CallAtBlock( + rr.callerOptions.From, + blockNumber, + nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "reservations", + &result, + arg_reservationKey, + ) + + return result, err +} + +func (rr *ReservationRouter) ReservedDepositWallet( + arg_depositKey *big.Int, +) ([20]byte, error) { + result, err := rr.contract.ReservedDepositWallet( + rr.callerOptions, + arg_depositKey, + ) + + if err != nil { + return result, rr.errorResolver.ResolveError( + err, + rr.callerOptions.From, + nil, + "reservedDepositWallet", + arg_depositKey, + ) + } + + return result, err +} + +func (rr *ReservationRouter) ReservedDepositWalletAtBlock( + arg_depositKey *big.Int, + blockNumber *big.Int, +) ([20]byte, error) { + var result [20]byte + + err := chainutil.CallAtBlock( + rr.callerOptions.From, + blockNumber, + nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "reservedDepositWallet", + &result, + arg_depositKey, + ) + + return result, err +} + +func (rr *ReservationRouter) WalletReservations( + arg_walletPubKeyHash [20]byte, +) ([]*big.Int, error) { + result, err := rr.contract.WalletReservations( + rr.callerOptions, + arg_walletPubKeyHash, + ) + + if err != nil { + return result, rr.errorResolver.ResolveError( + err, + rr.callerOptions.From, + nil, + "walletReservations", + arg_walletPubKeyHash, + ) + } + + return result, err +} + +func (rr *ReservationRouter) WalletReservationsAtBlock( + arg_walletPubKeyHash [20]byte, + blockNumber *big.Int, +) ([]*big.Int, error) { + var result []*big.Int + + err := chainutil.CallAtBlock( + rr.callerOptions.From, + blockNumber, + nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "walletReservations", + &result, + arg_walletPubKeyHash, + ) + + return result, err +} + +func (rr *ReservationRouter) WalletReservationsAmount( + arg_walletPubKeyHash [20]byte, +) (uint64, error) { + result, err := rr.contract.WalletReservationsAmount( + rr.callerOptions, + arg_walletPubKeyHash, + ) + + if err != nil { + return result, rr.errorResolver.ResolveError( + err, + rr.callerOptions.From, + nil, + "walletReservationsAmount", + arg_walletPubKeyHash, + ) + } + + return result, err +} + +func (rr *ReservationRouter) WalletReservationsAmountAtBlock( + arg_walletPubKeyHash [20]byte, + blockNumber *big.Int, +) (uint64, error) { + var result uint64 + + err := chainutil.CallAtBlock( + rr.callerOptions.From, + blockNumber, + nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "walletReservationsAmount", + &result, + arg_walletPubKeyHash, + ) + + return result, err +} + +func (rr *ReservationRouter) WalletReservationsCount( + arg_walletPubKeyHash [20]byte, +) (uint32, error) { + result, err := rr.contract.WalletReservationsCount( + rr.callerOptions, + arg_walletPubKeyHash, + ) + + if err != nil { + return result, rr.errorResolver.ResolveError( + err, + rr.callerOptions.From, + nil, + "walletReservationsCount", + arg_walletPubKeyHash, + ) + } + + return result, err +} + +func (rr *ReservationRouter) WalletReservationsCountAtBlock( + arg_walletPubKeyHash [20]byte, + blockNumber *big.Int, +) (uint32, error) { + var result uint32 + + err := chainutil.CallAtBlock( + rr.callerOptions.From, + blockNumber, + nil, + rr.contractABI, + rr.caller, + rr.errorResolver, + rr.contractAddress, + "walletReservationsCount", + &result, + arg_walletPubKeyHash, + ) + + return result, err +} + +// ------ Events ------- + +func (rr *ReservationRouter) GovernanceTransferredEvent( + opts *ethereum.SubscribeOpts, +) *RrGovernanceTransferredSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &RrGovernanceTransferredSubscription{ + rr, + opts, + } +} + +type RrGovernanceTransferredSubscription struct { + contract *ReservationRouter + opts *ethereum.SubscribeOpts +} + +type reservationRouterGovernanceTransferredFunc func( + OldGovernance common.Address, + NewGovernance common.Address, + blockNumber uint64, +) + +func (gts *RrGovernanceTransferredSubscription) OnEvent( + handler reservationRouterGovernanceTransferredFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.ReservationRouterGovernanceTransferred) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.OldGovernance, + event.NewGovernance, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := gts.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (gts *RrGovernanceTransferredSubscription) Pipe( + sink chan *abi.ReservationRouterGovernanceTransferred, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(gts.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := gts.contract.blockCounter.CurrentBlock() + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - gts.opts.PastBlocks + + rrLogger.Infof( + "subscription monitoring fetching past GovernanceTransferred events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := gts.contract.PastGovernanceTransferredEvents( + fromBlock, + nil, + ) + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + rrLogger.Infof( + "subscription monitoring fetched [%v] past GovernanceTransferred events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := gts.contract.watchGovernanceTransferred( + sink, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rr *ReservationRouter) watchGovernanceTransferred( + sink chan *abi.ReservationRouterGovernanceTransferred, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return rr.contract.WatchGovernanceTransferred( + &bind.WatchOpts{Context: ctx}, + sink, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + rrLogger.Warnf( + "subscription to event GovernanceTransferred had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + rrLogger.Errorf( + "subscription to event GovernanceTransferred failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (rr *ReservationRouter) PastGovernanceTransferredEvents( + startBlock uint64, + endBlock *uint64, +) ([]*abi.ReservationRouterGovernanceTransferred, error) { + iterator, err := rr.contract.FilterGovernanceTransferred( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past GovernanceTransferred events: [%v]", + err, + ) + } + + events := make([]*abi.ReservationRouterGovernanceTransferred, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (rr *ReservationRouter) InitializedEvent( + opts *ethereum.SubscribeOpts, +) *RrInitializedSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &RrInitializedSubscription{ + rr, + opts, + } +} + +type RrInitializedSubscription struct { + contract *ReservationRouter + opts *ethereum.SubscribeOpts +} + +type reservationRouterInitializedFunc func( + Version uint8, + blockNumber uint64, +) + +func (is *RrInitializedSubscription) OnEvent( + handler reservationRouterInitializedFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.ReservationRouterInitialized) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.Version, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := is.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (is *RrInitializedSubscription) Pipe( + sink chan *abi.ReservationRouterInitialized, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(is.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := is.contract.blockCounter.CurrentBlock() + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - is.opts.PastBlocks + + rrLogger.Infof( + "subscription monitoring fetching past Initialized events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := is.contract.PastInitializedEvents( + fromBlock, + nil, + ) + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + rrLogger.Infof( + "subscription monitoring fetched [%v] past Initialized events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := is.contract.watchInitialized( + sink, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rr *ReservationRouter) watchInitialized( + sink chan *abi.ReservationRouterInitialized, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return rr.contract.WatchInitialized( + &bind.WatchOpts{Context: ctx}, + sink, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + rrLogger.Warnf( + "subscription to event Initialized had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + rrLogger.Errorf( + "subscription to event Initialized failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (rr *ReservationRouter) PastInitializedEvents( + startBlock uint64, + endBlock *uint64, +) ([]*abi.ReservationRouterInitialized, error) { + iterator, err := rr.contract.FilterInitialized( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past Initialized events: [%v]", + err, + ) + } + + events := make([]*abi.ReservationRouterInitialized, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (rr *ReservationRouter) ReservationAcceptanceRequestedEvent( + opts *ethereum.SubscribeOpts, + reservationKeyFilter []*big.Int, + walletPubKeyHashFilter [][20]byte, +) *RrReservationAcceptanceRequestedSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &RrReservationAcceptanceRequestedSubscription{ + rr, + opts, + reservationKeyFilter, + walletPubKeyHashFilter, + } +} + +type RrReservationAcceptanceRequestedSubscription struct { + contract *ReservationRouter + opts *ethereum.SubscribeOpts + reservationKeyFilter []*big.Int + walletPubKeyHashFilter [][20]byte +} + +type reservationRouterReservationAcceptanceRequestedFunc func( + ReservationKey *big.Int, + RequestNonce uint64, + WalletPubKeyHash [20]byte, + DepositAmount uint64, + TxMaxFee uint64, + TimeoutAt uint32, + blockNumber uint64, +) + +func (rars *RrReservationAcceptanceRequestedSubscription) OnEvent( + handler reservationRouterReservationAcceptanceRequestedFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.ReservationRouterReservationAcceptanceRequested) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.ReservationKey, + event.RequestNonce, + event.WalletPubKeyHash, + event.DepositAmount, + event.TxMaxFee, + event.TimeoutAt, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := rars.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rars *RrReservationAcceptanceRequestedSubscription) Pipe( + sink chan *abi.ReservationRouterReservationAcceptanceRequested, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(rars.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := rars.contract.blockCounter.CurrentBlock() + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - rars.opts.PastBlocks + + rrLogger.Infof( + "subscription monitoring fetching past ReservationAcceptanceRequested events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := rars.contract.PastReservationAcceptanceRequestedEvents( + fromBlock, + nil, + rars.reservationKeyFilter, + rars.walletPubKeyHashFilter, + ) + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + rrLogger.Infof( + "subscription monitoring fetched [%v] past ReservationAcceptanceRequested events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := rars.contract.watchReservationAcceptanceRequested( + sink, + rars.reservationKeyFilter, + rars.walletPubKeyHashFilter, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rr *ReservationRouter) watchReservationAcceptanceRequested( + sink chan *abi.ReservationRouterReservationAcceptanceRequested, + reservationKeyFilter []*big.Int, + walletPubKeyHashFilter [][20]byte, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return rr.contract.WatchReservationAcceptanceRequested( + &bind.WatchOpts{Context: ctx}, + sink, + reservationKeyFilter, + walletPubKeyHashFilter, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + rrLogger.Warnf( + "subscription to event ReservationAcceptanceRequested had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + rrLogger.Errorf( + "subscription to event ReservationAcceptanceRequested failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (rr *ReservationRouter) PastReservationAcceptanceRequestedEvents( + startBlock uint64, + endBlock *uint64, + reservationKeyFilter []*big.Int, + walletPubKeyHashFilter [][20]byte, +) ([]*abi.ReservationRouterReservationAcceptanceRequested, error) { + iterator, err := rr.contract.FilterReservationAcceptanceRequested( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + reservationKeyFilter, + walletPubKeyHashFilter, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past ReservationAcceptanceRequested events: [%v]", + err, + ) + } + + events := make([]*abi.ReservationRouterReservationAcceptanceRequested, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (rr *ReservationRouter) ReservationAcceptedEvent( + opts *ethereum.SubscribeOpts, + reservationKeyFilter []*big.Int, + walletPubKeyHashFilter [][20]byte, + ownerFilter []common.Address, +) *RrReservationAcceptedSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &RrReservationAcceptedSubscription{ + rr, + opts, + reservationKeyFilter, + walletPubKeyHashFilter, + ownerFilter, + } +} + +type RrReservationAcceptedSubscription struct { + contract *ReservationRouter + opts *ethereum.SubscribeOpts + reservationKeyFilter []*big.Int + walletPubKeyHashFilter [][20]byte + ownerFilter []common.Address +} + +type reservationRouterReservationAcceptedFunc func( + ReservationKey *big.Int, + RequestNonce uint64, + WalletPubKeyHash [20]byte, + Owner common.Address, + AnchorTxHash [32]byte, + AnchorAmount uint64, + ExpiresAt uint32, + blockNumber uint64, +) + +func (ras *RrReservationAcceptedSubscription) OnEvent( + handler reservationRouterReservationAcceptedFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.ReservationRouterReservationAccepted) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.ReservationKey, + event.RequestNonce, + event.WalletPubKeyHash, + event.Owner, + event.AnchorTxHash, + event.AnchorAmount, + event.ExpiresAt, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := ras.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (ras *RrReservationAcceptedSubscription) Pipe( + sink chan *abi.ReservationRouterReservationAccepted, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(ras.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := ras.contract.blockCounter.CurrentBlock() + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - ras.opts.PastBlocks + + rrLogger.Infof( + "subscription monitoring fetching past ReservationAccepted events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := ras.contract.PastReservationAcceptedEvents( + fromBlock, + nil, + ras.reservationKeyFilter, + ras.walletPubKeyHashFilter, + ras.ownerFilter, + ) + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + rrLogger.Infof( + "subscription monitoring fetched [%v] past ReservationAccepted events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := ras.contract.watchReservationAccepted( + sink, + ras.reservationKeyFilter, + ras.walletPubKeyHashFilter, + ras.ownerFilter, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rr *ReservationRouter) watchReservationAccepted( + sink chan *abi.ReservationRouterReservationAccepted, + reservationKeyFilter []*big.Int, + walletPubKeyHashFilter [][20]byte, + ownerFilter []common.Address, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return rr.contract.WatchReservationAccepted( + &bind.WatchOpts{Context: ctx}, + sink, + reservationKeyFilter, + walletPubKeyHashFilter, + ownerFilter, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + rrLogger.Warnf( + "subscription to event ReservationAccepted had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + rrLogger.Errorf( + "subscription to event ReservationAccepted failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (rr *ReservationRouter) PastReservationAcceptedEvents( + startBlock uint64, + endBlock *uint64, + reservationKeyFilter []*big.Int, + walletPubKeyHashFilter [][20]byte, + ownerFilter []common.Address, +) ([]*abi.ReservationRouterReservationAccepted, error) { + iterator, err := rr.contract.FilterReservationAccepted( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + reservationKeyFilter, + walletPubKeyHashFilter, + ownerFilter, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past ReservationAccepted events: [%v]", + err, + ) + } + + events := make([]*abi.ReservationRouterReservationAccepted, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (rr *ReservationRouter) ReservationActionSupersededEvent( + opts *ethereum.SubscribeOpts, + reservationKeyFilter []*big.Int, +) *RrReservationActionSupersededSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &RrReservationActionSupersededSubscription{ + rr, + opts, + reservationKeyFilter, + } +} + +type RrReservationActionSupersededSubscription struct { + contract *ReservationRouter + opts *ethereum.SubscribeOpts + reservationKeyFilter []*big.Int +} + +type reservationRouterReservationActionSupersededFunc func( + ReservationKey *big.Int, + RequestNonce uint64, + blockNumber uint64, +) + +func (rass *RrReservationActionSupersededSubscription) OnEvent( + handler reservationRouterReservationActionSupersededFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.ReservationRouterReservationActionSuperseded) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.ReservationKey, + event.RequestNonce, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := rass.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rass *RrReservationActionSupersededSubscription) Pipe( + sink chan *abi.ReservationRouterReservationActionSuperseded, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(rass.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := rass.contract.blockCounter.CurrentBlock() + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - rass.opts.PastBlocks + + rrLogger.Infof( + "subscription monitoring fetching past ReservationActionSuperseded events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := rass.contract.PastReservationActionSupersededEvents( + fromBlock, + nil, + rass.reservationKeyFilter, + ) + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + rrLogger.Infof( + "subscription monitoring fetched [%v] past ReservationActionSuperseded events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := rass.contract.watchReservationActionSuperseded( + sink, + rass.reservationKeyFilter, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rr *ReservationRouter) watchReservationActionSuperseded( + sink chan *abi.ReservationRouterReservationActionSuperseded, + reservationKeyFilter []*big.Int, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return rr.contract.WatchReservationActionSuperseded( + &bind.WatchOpts{Context: ctx}, + sink, + reservationKeyFilter, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + rrLogger.Warnf( + "subscription to event ReservationActionSuperseded had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + rrLogger.Errorf( + "subscription to event ReservationActionSuperseded failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (rr *ReservationRouter) PastReservationActionSupersededEvents( + startBlock uint64, + endBlock *uint64, + reservationKeyFilter []*big.Int, +) ([]*abi.ReservationRouterReservationActionSuperseded, error) { + iterator, err := rr.contract.FilterReservationActionSuperseded( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + reservationKeyFilter, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past ReservationActionSuperseded events: [%v]", + err, + ) + } + + events := make([]*abi.ReservationRouterReservationActionSuperseded, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (rr *ReservationRouter) ReservationActionTimedOutEvent( + opts *ethereum.SubscribeOpts, + reservationKeyFilter []*big.Int, +) *RrReservationActionTimedOutSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &RrReservationActionTimedOutSubscription{ + rr, + opts, + reservationKeyFilter, + } +} + +type RrReservationActionTimedOutSubscription struct { + contract *ReservationRouter + opts *ethereum.SubscribeOpts + reservationKeyFilter []*big.Int +} + +type reservationRouterReservationActionTimedOutFunc func( + ReservationKey *big.Int, + RequestNonce uint64, + ActionType uint8, + blockNumber uint64, +) + +func (ratos *RrReservationActionTimedOutSubscription) OnEvent( + handler reservationRouterReservationActionTimedOutFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.ReservationRouterReservationActionTimedOut) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.ReservationKey, + event.RequestNonce, + event.ActionType, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := ratos.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (ratos *RrReservationActionTimedOutSubscription) Pipe( + sink chan *abi.ReservationRouterReservationActionTimedOut, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(ratos.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := ratos.contract.blockCounter.CurrentBlock() + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - ratos.opts.PastBlocks + + rrLogger.Infof( + "subscription monitoring fetching past ReservationActionTimedOut events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := ratos.contract.PastReservationActionTimedOutEvents( + fromBlock, + nil, + ratos.reservationKeyFilter, + ) + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + rrLogger.Infof( + "subscription monitoring fetched [%v] past ReservationActionTimedOut events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := ratos.contract.watchReservationActionTimedOut( + sink, + ratos.reservationKeyFilter, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rr *ReservationRouter) watchReservationActionTimedOut( + sink chan *abi.ReservationRouterReservationActionTimedOut, + reservationKeyFilter []*big.Int, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return rr.contract.WatchReservationActionTimedOut( + &bind.WatchOpts{Context: ctx}, + sink, + reservationKeyFilter, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + rrLogger.Warnf( + "subscription to event ReservationActionTimedOut had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + rrLogger.Errorf( + "subscription to event ReservationActionTimedOut failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (rr *ReservationRouter) PastReservationActionTimedOutEvents( + startBlock uint64, + endBlock *uint64, + reservationKeyFilter []*big.Int, +) ([]*abi.ReservationRouterReservationActionTimedOut, error) { + iterator, err := rr.contract.FilterReservationActionTimedOut( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + reservationKeyFilter, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past ReservationActionTimedOut events: [%v]", + err, + ) + } + + events := make([]*abi.ReservationRouterReservationActionTimedOut, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (rr *ReservationRouter) ReservationCapsUpdatedEvent( + opts *ethereum.SubscribeOpts, +) *RrReservationCapsUpdatedSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &RrReservationCapsUpdatedSubscription{ + rr, + opts, + } +} + +type RrReservationCapsUpdatedSubscription struct { + contract *ReservationRouter + opts *ethereum.SubscribeOpts +} + +type reservationRouterReservationCapsUpdatedFunc func( + MaxReservationsAmountPerWallet uint64, + ReservationMaxSingleAmount uint64, + MaxActiveReservations uint32, + blockNumber uint64, +) + +func (rcus *RrReservationCapsUpdatedSubscription) OnEvent( + handler reservationRouterReservationCapsUpdatedFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.ReservationRouterReservationCapsUpdated) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.MaxReservationsAmountPerWallet, + event.ReservationMaxSingleAmount, + event.MaxActiveReservations, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := rcus.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rcus *RrReservationCapsUpdatedSubscription) Pipe( + sink chan *abi.ReservationRouterReservationCapsUpdated, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(rcus.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := rcus.contract.blockCounter.CurrentBlock() + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - rcus.opts.PastBlocks + + rrLogger.Infof( + "subscription monitoring fetching past ReservationCapsUpdated events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := rcus.contract.PastReservationCapsUpdatedEvents( + fromBlock, + nil, + ) + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + rrLogger.Infof( + "subscription monitoring fetched [%v] past ReservationCapsUpdated events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := rcus.contract.watchReservationCapsUpdated( + sink, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rr *ReservationRouter) watchReservationCapsUpdated( + sink chan *abi.ReservationRouterReservationCapsUpdated, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return rr.contract.WatchReservationCapsUpdated( + &bind.WatchOpts{Context: ctx}, + sink, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + rrLogger.Warnf( + "subscription to event ReservationCapsUpdated had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + rrLogger.Errorf( + "subscription to event ReservationCapsUpdated failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (rr *ReservationRouter) PastReservationCapsUpdatedEvents( + startBlock uint64, + endBlock *uint64, +) ([]*abi.ReservationRouterReservationCapsUpdated, error) { + iterator, err := rr.contract.FilterReservationCapsUpdated( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past ReservationCapsUpdated events: [%v]", + err, + ) + } + + events := make([]*abi.ReservationRouterReservationCapsUpdated, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (rr *ReservationRouter) ReservationLateSettledEvent( + opts *ethereum.SubscribeOpts, + reservationKeyFilter []*big.Int, +) *RrReservationLateSettledSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &RrReservationLateSettledSubscription{ + rr, + opts, + reservationKeyFilter, + } +} + +type RrReservationLateSettledSubscription struct { + contract *ReservationRouter + opts *ethereum.SubscribeOpts + reservationKeyFilter []*big.Int +} + +type reservationRouterReservationLateSettledFunc func( + ReservationKey *big.Int, + RequestNonce uint64, + ActionType uint8, + blockNumber uint64, +) + +func (rlss *RrReservationLateSettledSubscription) OnEvent( + handler reservationRouterReservationLateSettledFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.ReservationRouterReservationLateSettled) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.ReservationKey, + event.RequestNonce, + event.ActionType, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := rlss.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rlss *RrReservationLateSettledSubscription) Pipe( + sink chan *abi.ReservationRouterReservationLateSettled, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(rlss.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := rlss.contract.blockCounter.CurrentBlock() + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - rlss.opts.PastBlocks + + rrLogger.Infof( + "subscription monitoring fetching past ReservationLateSettled events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := rlss.contract.PastReservationLateSettledEvents( + fromBlock, + nil, + rlss.reservationKeyFilter, + ) + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + rrLogger.Infof( + "subscription monitoring fetched [%v] past ReservationLateSettled events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := rlss.contract.watchReservationLateSettled( + sink, + rlss.reservationKeyFilter, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rr *ReservationRouter) watchReservationLateSettled( + sink chan *abi.ReservationRouterReservationLateSettled, + reservationKeyFilter []*big.Int, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return rr.contract.WatchReservationLateSettled( + &bind.WatchOpts{Context: ctx}, + sink, + reservationKeyFilter, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + rrLogger.Warnf( + "subscription to event ReservationLateSettled had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + rrLogger.Errorf( + "subscription to event ReservationLateSettled failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (rr *ReservationRouter) PastReservationLateSettledEvents( + startBlock uint64, + endBlock *uint64, + reservationKeyFilter []*big.Int, +) ([]*abi.ReservationRouterReservationLateSettled, error) { + iterator, err := rr.contract.FilterReservationLateSettled( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + reservationKeyFilter, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past ReservationLateSettled events: [%v]", + err, + ) + } + + events := make([]*abi.ReservationRouterReservationLateSettled, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (rr *ReservationRouter) ReservationParametersUpdatedEvent( + opts *ethereum.SubscribeOpts, +) *RrReservationParametersUpdatedSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &RrReservationParametersUpdatedSubscription{ + rr, + opts, + } +} + +type RrReservationParametersUpdatedSubscription struct { + contract *ReservationRouter + opts *ethereum.SubscribeOpts +} + +type reservationRouterReservationParametersUpdatedFunc func( + ReservationMinAmount uint64, + ReservationTxMaxFee uint64, + ReservationTermSeconds uint32, + ReservationDissolutionDelay uint32, + ReservationMaxTotalAmount uint64, + MaxReservationsPerWallet uint32, + ReservationActionTimeout uint32, + ReservationRenewalWindowSeconds uint32, + blockNumber uint64, +) + +func (rpus *RrReservationParametersUpdatedSubscription) OnEvent( + handler reservationRouterReservationParametersUpdatedFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.ReservationRouterReservationParametersUpdated) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.ReservationMinAmount, + event.ReservationTxMaxFee, + event.ReservationTermSeconds, + event.ReservationDissolutionDelay, + event.ReservationMaxTotalAmount, + event.MaxReservationsPerWallet, + event.ReservationActionTimeout, + event.ReservationRenewalWindowSeconds, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := rpus.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rpus *RrReservationParametersUpdatedSubscription) Pipe( + sink chan *abi.ReservationRouterReservationParametersUpdated, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(rpus.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := rpus.contract.blockCounter.CurrentBlock() + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - rpus.opts.PastBlocks + + rrLogger.Infof( + "subscription monitoring fetching past ReservationParametersUpdated events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := rpus.contract.PastReservationParametersUpdatedEvents( + fromBlock, + nil, + ) + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + rrLogger.Infof( + "subscription monitoring fetched [%v] past ReservationParametersUpdated events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := rpus.contract.watchReservationParametersUpdated( + sink, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rr *ReservationRouter) watchReservationParametersUpdated( + sink chan *abi.ReservationRouterReservationParametersUpdated, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return rr.contract.WatchReservationParametersUpdated( + &bind.WatchOpts{Context: ctx}, + sink, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + rrLogger.Warnf( + "subscription to event ReservationParametersUpdated had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + rrLogger.Errorf( + "subscription to event ReservationParametersUpdated failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (rr *ReservationRouter) PastReservationParametersUpdatedEvents( + startBlock uint64, + endBlock *uint64, +) ([]*abi.ReservationRouterReservationParametersUpdated, error) { + iterator, err := rr.contract.FilterReservationParametersUpdated( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past ReservationParametersUpdated events: [%v]", + err, + ) + } + + events := make([]*abi.ReservationRouterReservationParametersUpdated, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (rr *ReservationRouter) ReservationReanchorRequestedEvent( + opts *ethereum.SubscribeOpts, + reservationKeyFilter []*big.Int, + sourceWalletPubKeyHashFilter [][20]byte, + targetWalletPubKeyHashFilter [][20]byte, +) *RrReservationReanchorRequestedSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &RrReservationReanchorRequestedSubscription{ + rr, + opts, + reservationKeyFilter, + sourceWalletPubKeyHashFilter, + targetWalletPubKeyHashFilter, + } +} + +type RrReservationReanchorRequestedSubscription struct { + contract *ReservationRouter + opts *ethereum.SubscribeOpts + reservationKeyFilter []*big.Int + sourceWalletPubKeyHashFilter [][20]byte + targetWalletPubKeyHashFilter [][20]byte +} + +type reservationRouterReservationReanchorRequestedFunc func( + ReservationKey *big.Int, + RequestNonce uint64, + SourceWalletPubKeyHash [20]byte, + TargetWalletPubKeyHash [20]byte, + TxMaxFee uint64, + blockNumber uint64, +) + +func (rrrs *RrReservationReanchorRequestedSubscription) OnEvent( + handler reservationRouterReservationReanchorRequestedFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.ReservationRouterReservationReanchorRequested) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.ReservationKey, + event.RequestNonce, + event.SourceWalletPubKeyHash, + event.TargetWalletPubKeyHash, + event.TxMaxFee, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := rrrs.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rrrs *RrReservationReanchorRequestedSubscription) Pipe( + sink chan *abi.ReservationRouterReservationReanchorRequested, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(rrrs.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := rrrs.contract.blockCounter.CurrentBlock() + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - rrrs.opts.PastBlocks + + rrLogger.Infof( + "subscription monitoring fetching past ReservationReanchorRequested events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := rrrs.contract.PastReservationReanchorRequestedEvents( + fromBlock, + nil, + rrrs.reservationKeyFilter, + rrrs.sourceWalletPubKeyHashFilter, + rrrs.targetWalletPubKeyHashFilter, + ) + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + rrLogger.Infof( + "subscription monitoring fetched [%v] past ReservationReanchorRequested events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := rrrs.contract.watchReservationReanchorRequested( + sink, + rrrs.reservationKeyFilter, + rrrs.sourceWalletPubKeyHashFilter, + rrrs.targetWalletPubKeyHashFilter, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rr *ReservationRouter) watchReservationReanchorRequested( + sink chan *abi.ReservationRouterReservationReanchorRequested, + reservationKeyFilter []*big.Int, + sourceWalletPubKeyHashFilter [][20]byte, + targetWalletPubKeyHashFilter [][20]byte, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return rr.contract.WatchReservationReanchorRequested( + &bind.WatchOpts{Context: ctx}, + sink, + reservationKeyFilter, + sourceWalletPubKeyHashFilter, + targetWalletPubKeyHashFilter, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + rrLogger.Warnf( + "subscription to event ReservationReanchorRequested had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + rrLogger.Errorf( + "subscription to event ReservationReanchorRequested failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (rr *ReservationRouter) PastReservationReanchorRequestedEvents( + startBlock uint64, + endBlock *uint64, + reservationKeyFilter []*big.Int, + sourceWalletPubKeyHashFilter [][20]byte, + targetWalletPubKeyHashFilter [][20]byte, +) ([]*abi.ReservationRouterReservationReanchorRequested, error) { + iterator, err := rr.contract.FilterReservationReanchorRequested( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + reservationKeyFilter, + sourceWalletPubKeyHashFilter, + targetWalletPubKeyHashFilter, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past ReservationReanchorRequested events: [%v]", + err, + ) + } + + events := make([]*abi.ReservationRouterReservationReanchorRequested, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (rr *ReservationRouter) ReservationReanchoredEvent( + opts *ethereum.SubscribeOpts, + reservationKeyFilter []*big.Int, + newWalletPubKeyHashFilter [][20]byte, +) *RrReservationReanchoredSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &RrReservationReanchoredSubscription{ + rr, + opts, + reservationKeyFilter, + newWalletPubKeyHashFilter, + } +} + +type RrReservationReanchoredSubscription struct { + contract *ReservationRouter + opts *ethereum.SubscribeOpts + reservationKeyFilter []*big.Int + newWalletPubKeyHashFilter [][20]byte +} + +type reservationRouterReservationReanchoredFunc func( + ReservationKey *big.Int, + RequestNonce uint64, + NewWalletPubKeyHash [20]byte, + NewAnchorTxHash [32]byte, + NewAnchorAmount uint64, + blockNumber uint64, +) + +func (rrs *RrReservationReanchoredSubscription) OnEvent( + handler reservationRouterReservationReanchoredFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.ReservationRouterReservationReanchored) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.ReservationKey, + event.RequestNonce, + event.NewWalletPubKeyHash, + event.NewAnchorTxHash, + event.NewAnchorAmount, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := rrs.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rrs *RrReservationReanchoredSubscription) Pipe( + sink chan *abi.ReservationRouterReservationReanchored, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(rrs.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := rrs.contract.blockCounter.CurrentBlock() + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - rrs.opts.PastBlocks + + rrLogger.Infof( + "subscription monitoring fetching past ReservationReanchored events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := rrs.contract.PastReservationReanchoredEvents( + fromBlock, + nil, + rrs.reservationKeyFilter, + rrs.newWalletPubKeyHashFilter, + ) + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + rrLogger.Infof( + "subscription monitoring fetched [%v] past ReservationReanchored events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := rrs.contract.watchReservationReanchored( + sink, + rrs.reservationKeyFilter, + rrs.newWalletPubKeyHashFilter, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rr *ReservationRouter) watchReservationReanchored( + sink chan *abi.ReservationRouterReservationReanchored, + reservationKeyFilter []*big.Int, + newWalletPubKeyHashFilter [][20]byte, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return rr.contract.WatchReservationReanchored( + &bind.WatchOpts{Context: ctx}, + sink, + reservationKeyFilter, + newWalletPubKeyHashFilter, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + rrLogger.Warnf( + "subscription to event ReservationReanchored had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + rrLogger.Errorf( + "subscription to event ReservationReanchored failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (rr *ReservationRouter) PastReservationReanchoredEvents( + startBlock uint64, + endBlock *uint64, + reservationKeyFilter []*big.Int, + newWalletPubKeyHashFilter [][20]byte, +) ([]*abi.ReservationRouterReservationReanchored, error) { + iterator, err := rr.contract.FilterReservationReanchored( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + reservationKeyFilter, + newWalletPubKeyHashFilter, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past ReservationReanchored events: [%v]", + err, + ) + } + + events := make([]*abi.ReservationRouterReservationReanchored, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (rr *ReservationRouter) ReservationRetryCreditMintedEvent( + opts *ethereum.SubscribeOpts, + reservationKeyFilter []*big.Int, +) *RrReservationRetryCreditMintedSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &RrReservationRetryCreditMintedSubscription{ + rr, + opts, + reservationKeyFilter, + } +} + +type RrReservationRetryCreditMintedSubscription struct { + contract *ReservationRouter + opts *ethereum.SubscribeOpts + reservationKeyFilter []*big.Int +} + +type reservationRouterReservationRetryCreditMintedFunc func( + ReservationKey *big.Int, + blockNumber uint64, +) + +func (rrcms *RrReservationRetryCreditMintedSubscription) OnEvent( + handler reservationRouterReservationRetryCreditMintedFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.ReservationRouterReservationRetryCreditMinted) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.ReservationKey, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := rrcms.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rrcms *RrReservationRetryCreditMintedSubscription) Pipe( + sink chan *abi.ReservationRouterReservationRetryCreditMinted, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(rrcms.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := rrcms.contract.blockCounter.CurrentBlock() + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - rrcms.opts.PastBlocks + + rrLogger.Infof( + "subscription monitoring fetching past ReservationRetryCreditMinted events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := rrcms.contract.PastReservationRetryCreditMintedEvents( + fromBlock, + nil, + rrcms.reservationKeyFilter, + ) + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + rrLogger.Infof( + "subscription monitoring fetched [%v] past ReservationRetryCreditMinted events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := rrcms.contract.watchReservationRetryCreditMinted( + sink, + rrcms.reservationKeyFilter, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rr *ReservationRouter) watchReservationRetryCreditMinted( + sink chan *abi.ReservationRouterReservationRetryCreditMinted, + reservationKeyFilter []*big.Int, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return rr.contract.WatchReservationRetryCreditMinted( + &bind.WatchOpts{Context: ctx}, + sink, + reservationKeyFilter, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + rrLogger.Warnf( + "subscription to event ReservationRetryCreditMinted had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + rrLogger.Errorf( + "subscription to event ReservationRetryCreditMinted failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (rr *ReservationRouter) PastReservationRetryCreditMintedEvents( + startBlock uint64, + endBlock *uint64, + reservationKeyFilter []*big.Int, +) ([]*abi.ReservationRouterReservationRetryCreditMinted, error) { + iterator, err := rr.contract.FilterReservationRetryCreditMinted( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + reservationKeyFilter, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past ReservationRetryCreditMinted events: [%v]", + err, + ) + } + + events := make([]*abi.ReservationRouterReservationRetryCreditMinted, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (rr *ReservationRouter) ReservationRouterSetEvent( + opts *ethereum.SubscribeOpts, +) *RrReservationRouterSetSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &RrReservationRouterSetSubscription{ + rr, + opts, + } +} + +type RrReservationRouterSetSubscription struct { + contract *ReservationRouter + opts *ethereum.SubscribeOpts +} + +type reservationRouterReservationRouterSetFunc func( + ReservationRouter common.Address, + blockNumber uint64, +) + +func (rrss *RrReservationRouterSetSubscription) OnEvent( + handler reservationRouterReservationRouterSetFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.ReservationRouterReservationRouterSet) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.ReservationRouter, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := rrss.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rrss *RrReservationRouterSetSubscription) Pipe( + sink chan *abi.ReservationRouterReservationRouterSet, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(rrss.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := rrss.contract.blockCounter.CurrentBlock() + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - rrss.opts.PastBlocks + + rrLogger.Infof( + "subscription monitoring fetching past ReservationRouterSet events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := rrss.contract.PastReservationRouterSetEvents( + fromBlock, + nil, + ) + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + rrLogger.Infof( + "subscription monitoring fetched [%v] past ReservationRouterSet events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := rrss.contract.watchReservationRouterSet( + sink, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rr *ReservationRouter) watchReservationRouterSet( + sink chan *abi.ReservationRouterReservationRouterSet, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return rr.contract.WatchReservationRouterSet( + &bind.WatchOpts{Context: ctx}, + sink, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + rrLogger.Warnf( + "subscription to event ReservationRouterSet had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + rrLogger.Errorf( + "subscription to event ReservationRouterSet failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (rr *ReservationRouter) PastReservationRouterSetEvents( + startBlock uint64, + endBlock *uint64, +) ([]*abi.ReservationRouterReservationRouterSet, error) { + iterator, err := rr.contract.FilterReservationRouterSet( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past ReservationRouterSet events: [%v]", + err, + ) + } + + events := make([]*abi.ReservationRouterReservationRouterSet, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (rr *ReservationRouter) ReservationStrandedEvent( + opts *ethereum.SubscribeOpts, + reservationKeyFilter []*big.Int, + walletPubKeyHashFilter [][20]byte, + ownerFilter []common.Address, +) *RrReservationStrandedSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &RrReservationStrandedSubscription{ + rr, + opts, + reservationKeyFilter, + walletPubKeyHashFilter, + ownerFilter, + } +} + +type RrReservationStrandedSubscription struct { + contract *ReservationRouter + opts *ethereum.SubscribeOpts + reservationKeyFilter []*big.Int + walletPubKeyHashFilter [][20]byte + ownerFilter []common.Address +} + +type reservationRouterReservationStrandedFunc func( + ReservationKey *big.Int, + WalletPubKeyHash [20]byte, + Owner common.Address, + AnchorAmount uint64, + blockNumber uint64, +) + +func (rss *RrReservationStrandedSubscription) OnEvent( + handler reservationRouterReservationStrandedFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.ReservationRouterReservationStranded) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.ReservationKey, + event.WalletPubKeyHash, + event.Owner, + event.AnchorAmount, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := rss.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rss *RrReservationStrandedSubscription) Pipe( + sink chan *abi.ReservationRouterReservationStranded, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(rss.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := rss.contract.blockCounter.CurrentBlock() + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - rss.opts.PastBlocks + + rrLogger.Infof( + "subscription monitoring fetching past ReservationStranded events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := rss.contract.PastReservationStrandedEvents( + fromBlock, + nil, + rss.reservationKeyFilter, + rss.walletPubKeyHashFilter, + rss.ownerFilter, + ) + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + rrLogger.Infof( + "subscription monitoring fetched [%v] past ReservationStranded events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := rss.contract.watchReservationStranded( + sink, + rss.reservationKeyFilter, + rss.walletPubKeyHashFilter, + rss.ownerFilter, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rr *ReservationRouter) watchReservationStranded( + sink chan *abi.ReservationRouterReservationStranded, + reservationKeyFilter []*big.Int, + walletPubKeyHashFilter [][20]byte, + ownerFilter []common.Address, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return rr.contract.WatchReservationStranded( + &bind.WatchOpts{Context: ctx}, + sink, + reservationKeyFilter, + walletPubKeyHashFilter, + ownerFilter, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + rrLogger.Warnf( + "subscription to event ReservationStranded had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + rrLogger.Errorf( + "subscription to event ReservationStranded failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (rr *ReservationRouter) PastReservationStrandedEvents( + startBlock uint64, + endBlock *uint64, + reservationKeyFilter []*big.Int, + walletPubKeyHashFilter [][20]byte, + ownerFilter []common.Address, +) ([]*abi.ReservationRouterReservationStranded, error) { + iterator, err := rr.contract.FilterReservationStranded( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + reservationKeyFilter, + walletPubKeyHashFilter, + ownerFilter, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past ReservationStranded events: [%v]", + err, + ) + } + + events := make([]*abi.ReservationRouterReservationStranded, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (rr *ReservationRouter) ReservationVaultUpdatedEvent( + opts *ethereum.SubscribeOpts, +) *RrReservationVaultUpdatedSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &RrReservationVaultUpdatedSubscription{ + rr, + opts, + } +} + +type RrReservationVaultUpdatedSubscription struct { + contract *ReservationRouter + opts *ethereum.SubscribeOpts +} + +type reservationRouterReservationVaultUpdatedFunc func( + ReservationVault common.Address, + blockNumber uint64, +) + +func (rvus *RrReservationVaultUpdatedSubscription) OnEvent( + handler reservationRouterReservationVaultUpdatedFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.ReservationRouterReservationVaultUpdated) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.ReservationVault, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := rvus.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rvus *RrReservationVaultUpdatedSubscription) Pipe( + sink chan *abi.ReservationRouterReservationVaultUpdated, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(rvus.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := rvus.contract.blockCounter.CurrentBlock() + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - rvus.opts.PastBlocks + + rrLogger.Infof( + "subscription monitoring fetching past ReservationVaultUpdated events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := rvus.contract.PastReservationVaultUpdatedEvents( + fromBlock, + nil, + ) + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + rrLogger.Infof( + "subscription monitoring fetched [%v] past ReservationVaultUpdated events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := rvus.contract.watchReservationVaultUpdated( + sink, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rr *ReservationRouter) watchReservationVaultUpdated( + sink chan *abi.ReservationRouterReservationVaultUpdated, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return rr.contract.WatchReservationVaultUpdated( + &bind.WatchOpts{Context: ctx}, + sink, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + rrLogger.Warnf( + "subscription to event ReservationVaultUpdated had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + rrLogger.Errorf( + "subscription to event ReservationVaultUpdated failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (rr *ReservationRouter) PastReservationVaultUpdatedEvents( + startBlock uint64, + endBlock *uint64, +) ([]*abi.ReservationRouterReservationVaultUpdated, error) { + iterator, err := rr.contract.FilterReservationVaultUpdated( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past ReservationVaultUpdated events: [%v]", + err, + ) + } + + events := make([]*abi.ReservationRouterReservationVaultUpdated, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} + +func (rr *ReservationRouter) ReservedDepositMarkedStaleEvent( + opts *ethereum.SubscribeOpts, + depositKeyFilter []*big.Int, +) *RrReservedDepositMarkedStaleSubscription { + if opts == nil { + opts = new(ethereum.SubscribeOpts) + } + if opts.Tick == 0 { + opts.Tick = chainutil.DefaultSubscribeOptsTick + } + if opts.PastBlocks == 0 { + opts.PastBlocks = chainutil.DefaultSubscribeOptsPastBlocks + } + + return &RrReservedDepositMarkedStaleSubscription{ + rr, + opts, + depositKeyFilter, + } +} + +type RrReservedDepositMarkedStaleSubscription struct { + contract *ReservationRouter + opts *ethereum.SubscribeOpts + depositKeyFilter []*big.Int +} + +type reservationRouterReservedDepositMarkedStaleFunc func( + DepositKey *big.Int, + blockNumber uint64, +) + +func (rdmss *RrReservedDepositMarkedStaleSubscription) OnEvent( + handler reservationRouterReservedDepositMarkedStaleFunc, +) subscription.EventSubscription { + eventChan := make(chan *abi.ReservationRouterReservedDepositMarkedStale) + ctx, cancelCtx := context.WithCancel(context.Background()) + + go func() { + for { + select { + case <-ctx.Done(): + return + case event := <-eventChan: + handler( + event.DepositKey, + event.Raw.BlockNumber, + ) + } + } + }() + + sub := rdmss.Pipe(eventChan) + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rdmss *RrReservedDepositMarkedStaleSubscription) Pipe( + sink chan *abi.ReservationRouterReservedDepositMarkedStale, +) subscription.EventSubscription { + ctx, cancelCtx := context.WithCancel(context.Background()) + go func() { + ticker := time.NewTicker(rdmss.opts.Tick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + lastBlock, err := rdmss.contract.blockCounter.CurrentBlock() + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + } + fromBlock := lastBlock - rdmss.opts.PastBlocks + + rrLogger.Infof( + "subscription monitoring fetching past ReservedDepositMarkedStale events "+ + "starting from block [%v]", + fromBlock, + ) + events, err := rdmss.contract.PastReservedDepositMarkedStaleEvents( + fromBlock, + nil, + rdmss.depositKeyFilter, + ) + if err != nil { + rrLogger.Errorf( + "subscription failed to pull events: [%v]", + err, + ) + continue + } + rrLogger.Infof( + "subscription monitoring fetched [%v] past ReservedDepositMarkedStale events", + len(events), + ) + + for _, event := range events { + sink <- event + } + } + } + }() + + sub := rdmss.contract.watchReservedDepositMarkedStale( + sink, + rdmss.depositKeyFilter, + ) + + return subscription.NewEventSubscription(func() { + sub.Unsubscribe() + cancelCtx() + }) +} + +func (rr *ReservationRouter) watchReservedDepositMarkedStale( + sink chan *abi.ReservationRouterReservedDepositMarkedStale, + depositKeyFilter []*big.Int, +) event.Subscription { + subscribeFn := func(ctx context.Context) (event.Subscription, error) { + return rr.contract.WatchReservedDepositMarkedStale( + &bind.WatchOpts{Context: ctx}, + sink, + depositKeyFilter, + ) + } + + thresholdViolatedFn := func(elapsed time.Duration) { + rrLogger.Warnf( + "subscription to event ReservedDepositMarkedStale had to be "+ + "retried [%s] since the last attempt; please inspect "+ + "host chain connectivity", + elapsed, + ) + } + + subscriptionFailedFn := func(err error) { + rrLogger.Errorf( + "subscription to event ReservedDepositMarkedStale failed "+ + "with error: [%v]; resubscription attempt will be "+ + "performed", + err, + ) + } + + return chainutil.WithResubscription( + chainutil.SubscriptionBackoffMax, + subscribeFn, + chainutil.SubscriptionAlertThreshold, + thresholdViolatedFn, + subscriptionFailedFn, + ) +} + +func (rr *ReservationRouter) PastReservedDepositMarkedStaleEvents( + startBlock uint64, + endBlock *uint64, + depositKeyFilter []*big.Int, +) ([]*abi.ReservationRouterReservedDepositMarkedStale, error) { + iterator, err := rr.contract.FilterReservedDepositMarkedStale( + &bind.FilterOpts{ + Start: startBlock, + End: endBlock, + }, + depositKeyFilter, + ) + if err != nil { + return nil, fmt.Errorf( + "error retrieving past ReservedDepositMarkedStale events: [%v]", + err, + ) + } + + events := make([]*abi.ReservationRouterReservedDepositMarkedStale, 0) + + for iterator.Next() { + event := iterator.Event + events = append(events, event) + } + + return events, nil +} diff --git a/pkg/chain/ethereum/tbtc/gen/contract/WalletProposalValidator.go b/pkg/chain/ethereum/tbtc/gen/contract/WalletProposalValidator.go index b5d3591e01..c22b9b1c7c 100644 --- a/pkg/chain/ethereum/tbtc/gen/contract/WalletProposalValidator.go +++ b/pkg/chain/ethereum/tbtc/gen/contract/WalletProposalValidator.go @@ -585,4 +585,95 @@ func (wpv *WalletProposalValidator) ValidateRedemptionProposalAtBlock( return result, err } +func (wpv *WalletProposalValidator) ValidateReservationAnchorProposal( + arg_proposal abi.WalletProposalValidatorReservationAnchorProposal, + arg_depositExtraInfo abi.WalletProposalValidatorDepositExtraInfo, +) (bool, error) { + result, err := wpv.contract.ValidateReservationAnchorProposal( + wpv.callerOptions, + arg_proposal, + arg_depositExtraInfo, + ) + + if err != nil { + return result, wpv.errorResolver.ResolveError( + err, + wpv.callerOptions.From, + nil, + "validateReservationAnchorProposal", + arg_proposal, + arg_depositExtraInfo, + ) + } + + return result, err +} + +func (wpv *WalletProposalValidator) ValidateReservationAnchorProposalAtBlock( + arg_proposal abi.WalletProposalValidatorReservationAnchorProposal, + arg_depositExtraInfo abi.WalletProposalValidatorDepositExtraInfo, + blockNumber *big.Int, +) (bool, error) { + var result bool + + err := chainutil.CallAtBlock( + wpv.callerOptions.From, + blockNumber, + nil, + wpv.contractABI, + wpv.caller, + wpv.errorResolver, + wpv.contractAddress, + "validateReservationAnchorProposal", + &result, + arg_proposal, + arg_depositExtraInfo, + ) + + return result, err +} + +func (wpv *WalletProposalValidator) ValidateReservationReanchorProposal( + arg_proposal abi.WalletProposalValidatorReservationReanchorProposal, +) (bool, error) { + result, err := wpv.contract.ValidateReservationReanchorProposal( + wpv.callerOptions, + arg_proposal, + ) + + if err != nil { + return result, wpv.errorResolver.ResolveError( + err, + wpv.callerOptions.From, + nil, + "validateReservationReanchorProposal", + arg_proposal, + ) + } + + return result, err +} + +func (wpv *WalletProposalValidator) ValidateReservationReanchorProposalAtBlock( + arg_proposal abi.WalletProposalValidatorReservationReanchorProposal, + blockNumber *big.Int, +) (bool, error) { + var result bool + + err := chainutil.CallAtBlock( + wpv.callerOptions.From, + blockNumber, + nil, + wpv.contractABI, + wpv.caller, + wpv.errorResolver, + wpv.contractAddress, + "validateReservationReanchorProposal", + &result, + arg_proposal, + ) + + return result, err +} + // ------ Events ------- diff --git a/pkg/chain/ethereum/tbtc_reservation.go b/pkg/chain/ethereum/tbtc_reservation.go new file mode 100644 index 0000000000..9ccf72afb6 --- /dev/null +++ b/pkg/chain/ethereum/tbtc_reservation.go @@ -0,0 +1,995 @@ +// tbtc_reservation.go: TbtcChain reservation adapter. Implements the +// reservation-router surface - reservations/actions/parameters reads, +// request and notify transactions, and reservation event subscriptions - +// via the reservationRouter abigen binding constructed against the Bridge +// address (see reservationRouterBinding below). +package ethereum + +import ( + "fmt" + "math/big" + "sort" + + "github.com/ethereum/go-ethereum/common" + "github.com/keep-network/keep-common/pkg/chain/ethereum/ethutil" + "github.com/keep-network/keep-core/pkg/bitcoin" + "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/subscription" + "github.com/keep-network/keep-core/pkg/tbtc" +) + +// reservationRouterBinding constructs the ReservationRouter abigen binding +// pointed at the Bridge address. The router code only ever executes via +// Bridge.fallback's delegatecall, so the binding MUST be constructed against +// the Bridge address: the deployed router contract holds its own empty +// storage, so any call routed to its standalone address would either revert +// (writes) or return zeros (views); events emitted by router code carry the +// Bridge's address in their log because delegatecall preserves the caller's +// address context. The router's own deployment address is only needed for +// the one-time governance Bridge.setReservationRouter(routerAddress) call, +// which is out of scope here. +func reservationRouterBinding( + bridgeAddress common.Address, + baseChain *baseChain, +) (*tbtccontract.ReservationRouter, error) { + return tbtccontract.NewReservationRouter( + bridgeAddress, + baseChain.chainID, + baseChain.key, + baseChain.client, + baseChain.nonceManager, + baseChain.miningWaiter, + baseChain.blockCounter, + baseChain.transactionMutex, + ) +} + +// GetReservation returns the on-chain reservation record for the given +// reservation key via the reservationRouter binding (see reservationRouterBinding). +func (tc *TbtcChain) GetReservation( + reservationKey *big.Int, +) (*tbtc.Reservation, error) { + abiReservation, err := tc.reservationRouter.Reservations(reservationKey) + if err != nil { + return nil, fmt.Errorf( + "cannot get reservation [0x%x]: [%v]", + reservationKey, + err, + ) + } + + reservation, err := convertReservationFromAbiType(abiReservation) + if err != nil { + return nil, fmt.Errorf( + "cannot convert reservation [0x%x] from abi type: [%v]", + reservationKey, + err, + ) + } + + return reservation, nil +} + +// GetReservationAction returns the on-chain action record for the given +// reservation key and request nonce. +func (tc *TbtcChain) GetReservationAction( + reservationKey *big.Int, + requestNonce uint64, +) (*tbtc.ReservationAction, error) { + abiAction, err := tc.reservationRouter.ReservationActions( + reservationKey, + requestNonce, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot get reservation action [0x%x:%d]: [%v]", + reservationKey, + requestNonce, + err, + ) + } + + action, err := convertReservationActionFromAbiType(abiAction) + if err != nil { + return nil, fmt.Errorf( + "cannot convert reservation action [0x%x:%d] from abi type: [%v]", + reservationKey, + requestNonce, + err, + ) + } + + return action, nil +} + +// ReservationParameters returns the current on-chain Bridge reservation +// parameters (10-tuple) via the reservationRouter binding (see reservationRouterBinding). +func (tc *TbtcChain) ReservationParameters() ( + *tbtc.ReservationParameters, + error, +) { + abiParameters, err := tc.reservationRouter.ReservationParameters() + if err != nil { + return nil, fmt.Errorf( + "cannot get reservation parameters: [%v]", + err, + ) + } + + return convertReservationParametersFromAbiType(abiParameters), nil +} + +// TODO(test-coverage): ValidateReservationAnchorProposal has no direct unit +// test coverage. It requires go-ethereum simulated-backend infrastructure +// that does not exist anywhere in pkg/chain/ethereum today; blocked on that +// infra landing. See PR #4280 and its linked gap-analysis doc. +// ValidateReservationAnchorProposal asks the WalletProposalValidator +// whether the given anchor proposal is valid for the given wallet and +// reserved deposit. The validator is a separate contract reached at its +// own deployed address. +func (tc *TbtcChain) ValidateReservationAnchorProposal( + walletPublicKeyHash [20]byte, + proposal *tbtc.ReservationAnchorProposal, + depositExtraInfo struct { + *tbtc.Deposit + FundingTx *bitcoin.Transaction + }, +) error { + abiProposal, abiExtraInfo := buildReservationAnchorProposalAbi( + walletPublicKeyHash, + proposal, + depositExtraInfo, + ) + + valid, err := tc.walletProposalValidator.ValidateReservationAnchorProposal( + abiProposal, + abiExtraInfo, + ) + if err != nil { + return fmt.Errorf("validation failed: [%v]", err) + } + + // Should never happen because `validateReservationAnchorProposal` + // returns true or reverts (returns an error) but do the check just in + // case. + if !valid { + return fmt.Errorf("unexpected validation result") + } + + return nil +} + +// buildReservationAnchorProposalAbi constructs the ABI-struct arguments +// for WalletProposalValidator.ValidateReservationAnchorProposal from their +// application-level representations. Extracted as a pure function from +// ValidateReservationAnchorProposal so the field mapping can be unit +// tested directly, mirroring the reverse-direction converters below +// (convertReservationFromAbiType et al.). +func buildReservationAnchorProposalAbi( + walletPublicKeyHash [20]byte, + proposal *tbtc.ReservationAnchorProposal, + depositExtraInfo struct { + *tbtc.Deposit + FundingTx *bitcoin.Transaction + }, +) ( + tbtcabi.WalletProposalValidatorReservationAnchorProposal, + tbtcabi.WalletProposalValidatorDepositExtraInfo, +) { + // WalletProposalValidator's DepositExtraInfo.FundingTx is typed as + // BitcoinTxInfo2 because the BitcoinTxInfo struct is renamed via the + // collision hook in gen/Makefile (Bridge keeps the un-suffixed name; + // WalletProposalValidator gets the 2-suffix; MaintainerProxy the 3; + // ReservationRouter the 4). Mirroring the existing + // ValidateDepositSweepProposal pattern. + fundingTx := tbtcabi.BitcoinTxInfo2{ + Version: depositExtraInfo.FundingTx.SerializeVersion(), + InputVector: depositExtraInfo.FundingTx.SerializeInputs(), + OutputVector: depositExtraInfo.FundingTx.SerializeOutputs(), + Locktime: depositExtraInfo.FundingTx.SerializeLocktime(), + } + + depositKey := tbtcabi.WalletProposalValidatorDepositKey{ + FundingTxHash: proposal.DepositFundingTxHash, + FundingOutputIndex: proposal.DepositFundingOutputIndex, + } + + abiExtraInfo := tbtcabi.WalletProposalValidatorDepositExtraInfo{ + FundingTx: fundingTx, + BlindingFactor: depositExtraInfo.Deposit.BlindingFactor, + WalletPubKeyHash: depositExtraInfo.Deposit.WalletPublicKeyHash, + RefundPubKeyHash: depositExtraInfo.Deposit.RefundPublicKeyHash, + RefundLocktime: depositExtraInfo.Deposit.RefundLocktime, + } + + abiProposal := tbtcabi.WalletProposalValidatorReservationAnchorProposal{ + WalletPubKeyHash: walletPublicKeyHash, + DepositKey: depositKey, + AnchorTxFee: proposal.AnchorTxFee, + } + + return abiProposal, abiExtraInfo +} + +// ValidateReservationReanchorProposal asks the WalletProposalValidator +// whether the given re-anchor proposal is valid for the given source +// wallet. The validator is a separate contract reached at its own deployed +// address. +func (tc *TbtcChain) ValidateReservationReanchorProposal( + sourceWalletPublicKeyHash [20]byte, + proposal *tbtc.ReservationReanchorProposal, +) error { + abiProposal := buildReservationReanchorProposalAbi( + sourceWalletPublicKeyHash, + proposal, + ) + + valid, err := tc.walletProposalValidator.ValidateReservationReanchorProposal( + abiProposal, + ) + if err != nil { + return fmt.Errorf("validation failed: [%v]", err) + } + + // Should never happen because `validateReservationReanchorProposal` + // returns true or reverts (returns an error) but do the check just in + // case. + if !valid { + return fmt.Errorf("unexpected validation result") + } + + return nil +} + +// buildReservationReanchorProposalAbi constructs the ABI-struct argument +// for WalletProposalValidator.ValidateReservationReanchorProposal from its +// application-level representation. Extracted as a pure function from +// ValidateReservationReanchorProposal so the field mapping can be unit +// tested directly. +func buildReservationReanchorProposalAbi( + sourceWalletPublicKeyHash [20]byte, + proposal *tbtc.ReservationReanchorProposal, +) tbtcabi.WalletProposalValidatorReservationReanchorProposal { + return tbtcabi.WalletProposalValidatorReservationReanchorProposal{ + SourceWalletPubKeyHash: sourceWalletPublicKeyHash, + ReservationKey: proposal.ReservationKey, + TargetWalletPubKeyHash: proposal.TargetWalletPublicKeyHash, + ReanchorTxFee: proposal.ReanchorTxFee, + } +} + +// convertReservationFromAbiType converts the ReservationRouter-specific +// Reservation.ReservationRequest ABI struct to the TBTC application +// `tbtc.Reservation` representation. +// +// Field omissions (intentional, mirroring the Solidity-to-Go struct shrink): +// +// - `CumulativeReanchorFee`: written by every re-anchor hop but not +// exposed through the Go-side reservation; m1 has no fee-ceiling +// enforcement, so the field is dropped on the Go boundary. A later +// milestone that adds a fee ceiling should re-export this field on +// `tbtc.Reservation`. +// +// Anchor shape reassembly: the on-chain request splits the anchor UTXO into +// `anchorAmount`, `anchorTxHash`, and `anchorTxOutputIndex`; the Go-side +// representation folds those three back into a single +// `bitcoin.UnspentTransactionOutput` for consistency with the rest of the +// reservation API. +func convertReservationFromAbiType( + abiReservation tbtcabi.ReservationReservationRequest, +) (*tbtc.Reservation, error) { + state, err := parseReservationState(abiReservation.State) + if err != nil { + return nil, fmt.Errorf("cannot parse reservation state: [%v]", err) + } + + anchorUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: abiReservation.AnchorTxHash, + OutputIndex: abiReservation.AnchorTxOutputIndex, + }, + Value: int64(abiReservation.AnchorAmount), + } + + return &tbtc.Reservation{ + Owner: chain.Address(abiReservation.Owner.String()), + MintedAmount: abiReservation.MintedAmount, + AcceptedAt: abiReservation.AcceptedAt, + WalletPublicKeyHash: abiReservation.WalletPubKeyHash, + AnchorUtxo: anchorUtxo, + ExpiresAt: abiReservation.ExpiresAt, + State: state, + RequestNonce: abiReservation.RequestNonce, + RetryCredit: abiReservation.RetryCredit, + DissolutionEligibleAt: abiReservation.DissolutionEligibleAt, + }, nil +} + +// convertReservationActionFromAbiType converts the ReservationRouter- +// specific Reservation.ReservationAction ABI struct to the TBTC +// application `tbtc.ReservationAction` representation. +// +// Field omissions (intentional): +// +// - `SourceAnchorUtxoHash`, `UsedRetryCredit`, +// `Watchtower{Default,LevelOne,LevelTwo}Delay`, +// `RetryCreditSourceNonce`: written for governance / late-settlement +// reconciliation but not read by the operator client in m1. +// +// The on-chain `actionDataHash` field is polymorphic across action types: +// it carries the keccak256 of the redeemer output script for redemptions, +// the wallet main UTXO hash for dissolutions, and is zero otherwise. The +// Go-side struct splits that polymorphism into two named fields +// (`RedeemerOutputScriptHash` for redemptions, `ExpectedMainUtxoHash` +// for dissolutions); we route `actionDataHash` to the field that matches +// the action's type and zero the other. +func convertReservationActionFromAbiType( + abiAction tbtcabi.ReservationReservationAction, +) (*tbtc.ReservationAction, error) { + actionType, err := parseReservationActionType(abiAction.ActionType) + if err != nil { + return nil, fmt.Errorf( + "cannot parse reservation action type: [%v]", + err, + ) + } + + state, err := parseReservationActionState(abiAction.State) + if err != nil { + return nil, fmt.Errorf( + "cannot parse reservation action state: [%v]", + err, + ) + } + + var ( + redeemerOutputScriptHash [32]byte + expectedMainUtxoHash [32]byte + ) + switch actionType { + case tbtc.ReservationActionTypeRedemption: + redeemerOutputScriptHash = abiAction.ActionDataHash + case tbtc.ReservationActionTypeDissolution: + expectedMainUtxoHash = abiAction.ActionDataHash + } + + return &tbtc.ReservationAction{ + TargetWalletPublicKeyHash: abiAction.TargetWalletPubKeyHash, + RequestedAt: abiAction.RequestedAt, + TimeoutAt: abiAction.TimeoutAt, + TxMaxFee: abiAction.TxMaxFee, + ActionType: actionType, + State: state, + FeePaid: abiAction.FeePaid, + Redeemer: chain.Address(abiAction.Redeemer.String()), + Amount: abiAction.Amount, + RedeemerOutputScriptHash: redeemerOutputScriptHash, + ExpectedMainUtxoHash: expectedMainUtxoHash, + IsPartial: abiAction.IsPartial, + }, nil +} + +// convertReservationParametersFromAbiType converts the ReservationRouter +// 10-tuple to the `tbtc.ReservationParameters` representation. +func convertReservationParametersFromAbiType( + abiParameters struct { + ReservationVault common.Address + ReservationMinAmount uint64 + ReservationTxMaxFee uint64 + ReservationTermSeconds uint32 + ReservationDissolutionDelay uint32 + ReservationMaxTotalAmount uint64 + ReservationTotalAmount uint64 + MaxReservationsPerWallet uint32 + ReservationActionTimeout uint32 + ReservationRenewalWindowSeconds uint32 + }, +) *tbtc.ReservationParameters { + return &tbtc.ReservationParameters{ + ReservationVault: chain.Address(abiParameters.ReservationVault.String()), + ReservationMinAmount: abiParameters.ReservationMinAmount, + ReservationTxMaxFee: abiParameters.ReservationTxMaxFee, + ReservationTermSeconds: abiParameters.ReservationTermSeconds, + ReservationDissolutionDelay: abiParameters.ReservationDissolutionDelay, + ReservationMaxTotalAmount: abiParameters.ReservationMaxTotalAmount, + ReservationTotalAmount: abiParameters.ReservationTotalAmount, + MaxReservationsPerWallet: abiParameters.MaxReservationsPerWallet, + ReservationActionTimeout: abiParameters.ReservationActionTimeout, + ReservationRenewalWindowSeconds: abiParameters.ReservationRenewalWindowSeconds, + } +} + +// parseReservationState converts the on-chain ReservationState enum +// (uint8) to the tbtc.ReservationState value. Values match the Solidity +// declaration one-for-one (Unknown=0, Active=1, ActionPending=2, +// Closed=3, Stranded=4). +func parseReservationState(value uint8) (tbtc.ReservationState, error) { + switch value { + case 0: + return tbtc.ReservationStateUnknown, nil + case 1: + return tbtc.ReservationStateActive, nil + case 2: + return tbtc.ReservationStateActionPending, nil + case 3: + return tbtc.ReservationStateClosed, nil + case 4: + return tbtc.ReservationStateStranded, nil + default: + return 0, fmt.Errorf("unexpected reservation state value: [%d]", value) + } +} + +// parseReservationActionType converts the on-chain ActionType enum +// (uint8) to the tbtc.ReservationActionType value. Values match the +// Solidity declaration one-for-one (None=0, Acceptance=1, Redemption=2, +// Reanchor=3, Dissolution=4). +func parseReservationActionType(value uint8) (tbtc.ReservationActionType, error) { + switch value { + case 0: + return tbtc.ReservationActionTypeNone, nil + case 1: + return tbtc.ReservationActionTypeAcceptance, nil + case 2: + return tbtc.ReservationActionTypeRedemption, nil + case 3: + return tbtc.ReservationActionTypeReanchor, nil + case 4: + return tbtc.ReservationActionTypeDissolution, nil + default: + return 0, fmt.Errorf("unexpected reservation action type value: [%d]", value) + } +} + +// parseReservationActionState converts the on-chain ActionState enum +// (uint8) to the tbtc.ReservationActionState value. Values match the +// Solidity declaration one-for-one (Unknown=0, Pending=1, Settled=2, +// TimedOut=3, Vetoed=4, Superseded=5). +func parseReservationActionState(value uint8) (tbtc.ReservationActionState, error) { + switch value { + case 0: + return tbtc.ReservationActionStateUnknown, nil + case 1: + return tbtc.ReservationActionStatePending, nil + case 2: + return tbtc.ReservationActionStateSettled, nil + case 3: + return tbtc.ReservationActionStateTimedOut, nil + case 4: + return tbtc.ReservationActionStateVetoed, nil + case 5: + return tbtc.ReservationActionStateSuperseded, nil + default: + return 0, fmt.Errorf("unexpected reservation action state value: [%d]", value) + } +} + +// RequestReservationAcceptance calls the Bridge (via reservationRouter binding, +// see reservationRouterBinding) to start a new reservation acceptance action +// generation for the given reservation. +func (tc *TbtcChain) RequestReservationAcceptance( + reservationKey *big.Int, + walletPublicKeyHash [20]byte, +) error { + gasEstimate, err := tc.reservationRouter.RequestReservationAcceptanceGasEstimate( + reservationKey, + walletPublicKeyHash, + ) + if err != nil { + return err + } + + // Here we add a 20% margin to overcome the gas problems. + gasEstimateWithMargin := float64(gasEstimate) * float64(1.2) + + _, err = tc.reservationRouter.RequestReservationAcceptance( + reservationKey, + walletPublicKeyHash, + ethutil.TransactionOptions{ + GasLimit: uint64(gasEstimateWithMargin), + }, + ) + + return err +} + +// 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. +func (tc *TbtcChain) RequestReservationReanchor( + reservationKey *big.Int, + targetWalletPublicKeyHash [20]byte, +) error { + gasEstimate, err := tc.reservationRouter.RequestReservationReanchorGasEstimate( + reservationKey, + targetWalletPublicKeyHash, + ) + if err != nil { + return err + } + + // Here we add a 20% margin to overcome the gas problems. + gasEstimateWithMargin := float64(gasEstimate) * float64(1.2) + + _, err = tc.reservationRouter.RequestReservationReanchor( + reservationKey, + targetWalletPublicKeyHash, + ethutil.TransactionOptions{ + GasLimit: uint64(gasEstimateWithMargin), + }, + ) + + return err +} + +// SubmitReservationProof submits an SPV proof for the given reservation +// action generation to the Bridge. The proof path is onlySpvMaintainer on +// the router; the call goes through Bridge.fallback's delegatecall so the +// router code reads the Bridge's isSpvMaintainer mapping at the Bridge's +// address. +func (tc *TbtcChain) SubmitReservationProof( + proofType uint8, + txInfo *tbtc.BitcoinTxInfo, + proof *tbtc.BitcoinTxProof, + mainUtxo *tbtc.BitcoinTxUTXO, + reservationKey *big.Int, + requestNonce uint64, +) error { + abiTxInfo := tbtcabi.BitcoinTxInfo4{ + Version: txInfo.Version, + InputVector: txInfo.InputVector, + OutputVector: txInfo.OutputVector, + Locktime: txInfo.Locktime, + } + abiProof := tbtcabi.BitcoinTxProof3{ + MerkleProof: proof.MerkleProof, + TxIndexInBlock: proof.TxIndexInBlock, + BitcoinHeaders: proof.BitcoinHeaders, + CoinbasePreimage: proof.CoinbasePreimage, + CoinbaseProof: proof.CoinbaseProof, + } + abiUtxo := tbtcabi.BitcoinTxUTXO4{ + TxHash: mainUtxo.TxHash, + TxOutputIndex: mainUtxo.TxOutputIndex, + TxOutputValue: mainUtxo.TxOutputValue, + } + + gasEstimate, err := tc.reservationRouter.SubmitReservationProofGasEstimate( + proofType, + abiTxInfo, + abiProof, + abiUtxo, + reservationKey, + requestNonce, + ) + if err != nil { + return err + } + + // The original estimate for this contract call is too low; the + // reservation proof path dispatches into ReservationProofs.submit*Proof, + // which performs a non-trivial amount of storage I/O. Apply a 20% + // margin mirroring the existing SubmitRedemptionProofWithReimbursement + // pattern in this file. + gasEstimateWithMargin := float64(gasEstimate) * float64(1.2) + + _, err = tc.reservationRouter.SubmitReservationProof( + proofType, + abiTxInfo, + abiProof, + abiUtxo, + reservationKey, + requestNonce, + ethutil.TransactionOptions{ + GasLimit: uint64(gasEstimateWithMargin), + }, + ) + + return err +} + +// NotifyReservationActionTimeout notifies the Bridge that the timeout for +// the given reservation action generation has elapsed. +func (tc *TbtcChain) NotifyReservationActionTimeout( + reservationKey *big.Int, + walletMembersIDs []uint32, +) error { + gasEstimate, err := tc.reservationRouter.NotifyReservationActionTimeoutGasEstimate( + reservationKey, + walletMembersIDs, + ) + if err != nil { + return err + } + + // Here we add a 20% margin to overcome the gas problems. + gasEstimateWithMargin := float64(gasEstimate) * float64(1.2) + + _, err = tc.reservationRouter.NotifyReservationActionTimeout( + reservationKey, + walletMembersIDs, + ethutil.TransactionOptions{ + GasLimit: uint64(gasEstimateWithMargin), + }, + ) + + return err +} + +// NotifyStaleReservedDeposit notifies the Bridge that the given reserved +// deposit's wallet did not anchor it within the reservation-action timeout. +func (tc *TbtcChain) NotifyStaleReservedDeposit( + depositKey *big.Int, +) error { + gasEstimate, err := tc.reservationRouter.NotifyStaleReservedDepositGasEstimate( + depositKey, + ) + if err != nil { + return err + } + + // Here we add a 20% margin to overcome the gas problems. + gasEstimateWithMargin := float64(gasEstimate) * float64(1.2) + + _, err = tc.reservationRouter.NotifyStaleReservedDeposit( + depositKey, + ethutil.TransactionOptions{ + GasLimit: uint64(gasEstimateWithMargin), + }, + ) + + return err +} + +// NotifyReservationStranded notifies the Bridge that the wallet custodying +// the given reservation has been closed or terminated. +func (tc *TbtcChain) NotifyReservationStranded( + reservationKey *big.Int, +) error { + gasEstimate, err := tc.reservationRouter.NotifyReservationStrandedGasEstimate( + reservationKey, + ) + if err != nil { + return err + } + + // Here we add a 20% margin to overcome the gas problems. + gasEstimateWithMargin := float64(gasEstimate) * float64(1.2) + + _, err = tc.reservationRouter.NotifyReservationStranded( + reservationKey, + ethutil.TransactionOptions{ + GasLimit: uint64(gasEstimateWithMargin), + }, + ) + + return err +} + +// NotifyMovingFundsBelowDust notifies the Bridge that the given wallet's +// main UTXO has fallen below the moving funds dust threshold, ending the +// moving funds process and starting wallet closing immediately. This call +// is permissionless on-chain (MovingFunds.sol's notifyMovingFundsBelowDust +// carries no caller restriction), so it is submitted directly through the +// Bridge rather than routed through MaintainerProxy for reimbursement, +// mirroring the other reservation notify/request calls in this file. +func (tc *TbtcChain) NotifyMovingFundsBelowDust( + walletPublicKeyHash [20]byte, + mainUtxo *bitcoin.UnspentTransactionOutput, +) error { + var utxo tbtcabi.BitcoinTxUTXO + if mainUtxo != nil { + utxo = tbtcabi.BitcoinTxUTXO{ + TxHash: mainUtxo.Outpoint.TransactionHash, + TxOutputIndex: mainUtxo.Outpoint.OutputIndex, + TxOutputValue: uint64(mainUtxo.Value), + } + } + + gasEstimate, err := tc.bridge.NotifyMovingFundsBelowDustGasEstimate( + walletPublicKeyHash, + utxo, + ) + if err != nil { + return err + } + + // Here we add a 20% margin to overcome the gas problems, mirroring the + // other reservation notify calls in this file. + gasEstimateWithMargin := float64(gasEstimate) * float64(1.2) + + _, err = tc.bridge.NotifyMovingFundsBelowDust( + walletPublicKeyHash, + utxo, + ethutil.TransactionOptions{ + GasLimit: uint64(gasEstimateWithMargin), + }, + ) + + return err +} + +// ReservationCaps returns the cap parameters that gate reservation +// acceptance via the reservationRouter binding (see reservationRouterBinding). +func (tc *TbtcChain) ReservationCaps() ( + uint64, + uint64, + error, +) { + caps, err := tc.reservationRouter.ReservationCaps() + if err != nil { + return 0, 0, fmt.Errorf( + "cannot get reservation caps: [%v]", + err, + ) + } + + return caps.MaxReservationsAmountPerWallet, caps.ReservationMaxSingleAmount, nil +} + +// WalletReservationsAmount returns the aggregate satoshi amount currently +// anchored by the given wallet across all of its reservations. +func (tc *TbtcChain) WalletReservationsAmount( + walletPublicKeyHash [20]byte, +) (uint64, error) { + amount, err := tc.reservationRouter.WalletReservationsAmount(walletPublicKeyHash) + if err != nil { + return 0, fmt.Errorf( + "cannot get wallet reservations amount for [0x%x]: [%v]", + walletPublicKeyHash, + err, + ) + } + + return amount, nil +} + +// WalletReservationsCount returns the number of reservations currently +// custodied by the given wallet. +func (tc *TbtcChain) WalletReservationsCount( + walletPublicKeyHash [20]byte, +) (uint32, error) { + count, err := tc.reservationRouter.WalletReservationsCount(walletPublicKeyHash) + if err != nil { + return 0, fmt.Errorf( + "cannot get wallet reservations count for [0x%x]: [%v]", + walletPublicKeyHash, + err, + ) + } + + return count, nil +} + +// WalletReservations returns the reservation keys for all reservations +// currently custodied by the given wallet. +func (tc *TbtcChain) WalletReservations( + walletPublicKeyHash [20]byte, +) ([]*big.Int, error) { + keys, err := tc.reservationRouter.WalletReservations(walletPublicKeyHash) + if err != nil { + return nil, fmt.Errorf( + "cannot get wallet reservations for [0x%x]: [%v]", + walletPublicKeyHash, + err, + ) + } + + return keys, nil +} + +// ReservedDepositWallet returns the wallet public key hash to which the +// given reserved deposit was revealed. Returns the zero hash if the +// deposit is not a reserved deposit. +func (tc *TbtcChain) ReservedDepositWallet( + depositKey *big.Int, +) ([20]byte, error) { + walletPublicKeyHash, err := tc.reservationRouter.ReservedDepositWallet(depositKey) + if err != nil { + return [20]byte{}, fmt.Errorf( + "cannot get reserved deposit wallet for [0x%x]: [%v]", + depositKey, + err, + ) + } + + return walletPublicKeyHash, nil +} + +// ActiveReservationsCount returns the current count of active reservations +// across all wallets and the cap on that count. +func (tc *TbtcChain) ActiveReservationsCount() (uint32, uint32, error) { + activeReservationsCount, err := tc.reservationRouter.ActiveReservationsCount() + if err != nil { + return 0, 0, fmt.Errorf( + "cannot get active reservations count: [%v]", + err, + ) + } + + return activeReservationsCount.Count, activeReservationsCount.MaxActive, nil +} + +// IsReservedDeposit returns true if the given deposit was revealed with +// the reservation vault address and is therefore a reservation rather than +// a default deposit. +func (tc *TbtcChain) IsReservedDeposit( + depositKey *big.Int, +) (bool, error) { + isReserved, err := tc.bridge.IsReservedDeposit(depositKey) + if err != nil { + return false, fmt.Errorf( + "cannot check if deposit [0x%x] is reserved: [%v]", + depositKey, + err, + ) + } + + return isReserved, nil +} + +// OnReservationAcceptanceRequested registers a callback that is invoked +// when an on-chain ReservationAcceptanceRequested event is seen. The +// subscription filters against the Bridge's address (the binding is bound +// to the Bridge address; delegatecall preserves the caller's address +// context so events emitted by router code carry the Bridge's address). +func (tc *TbtcChain) OnReservationAcceptanceRequested( + handler func(event *tbtc.ReservationAcceptanceRequestedEvent), +) subscription.EventSubscription { + onEvent := func( + reservationKey *big.Int, + requestNonce uint64, + walletPublicKeyHash [20]byte, + depositAmount uint64, + txMaxFee uint64, + timeoutAt uint32, + blockNumber uint64, + ) { + handler(&tbtc.ReservationAcceptanceRequestedEvent{ + ReservationKey: reservationKey, + RequestNonce: requestNonce, + WalletPublicKeyHash: walletPublicKeyHash, + DepositAmount: depositAmount, + TxMaxFee: txMaxFee, + TimeoutAt: timeoutAt, + BlockNumber: blockNumber, + }) + } + + return tc.reservationRouter.ReservationAcceptanceRequestedEvent( + nil, + nil, + nil, + ).OnEvent(onEvent) +} + +// PastReservationAcceptanceRequestedEvents fetches past +// ReservationAcceptanceRequested events according to the provided filter +// or unfiltered if the filter is nil. +func (tc *TbtcChain) PastReservationAcceptanceRequestedEvents( + filter *tbtc.ReservationAcceptanceRequestedEventFilter, +) ([]*tbtc.ReservationAcceptanceRequestedEvent, error) { + var startBlock uint64 + var endBlock *uint64 + var reservationKey []*big.Int + var walletPublicKeyHash [][20]byte + + if filter != nil { + startBlock = filter.StartBlock + endBlock = filter.EndBlock + reservationKey = filter.ReservationKey + walletPublicKeyHash = filter.WalletPublicKeyHash + } + + events, err := tc.reservationRouter.PastReservationAcceptanceRequestedEvents( + startBlock, + endBlock, + reservationKey, + walletPublicKeyHash, + ) + if err != nil { + return nil, err + } + + convertedEvents := make([]*tbtc.ReservationAcceptanceRequestedEvent, 0) + for _, event := range events { + convertedEvents = append(convertedEvents, &tbtc.ReservationAcceptanceRequestedEvent{ + ReservationKey: event.ReservationKey, + RequestNonce: event.RequestNonce, + WalletPublicKeyHash: event.WalletPubKeyHash, + DepositAmount: event.DepositAmount, + TxMaxFee: event.TxMaxFee, + TimeoutAt: event.TimeoutAt, + BlockNumber: event.Raw.BlockNumber, + }) + } + + sort.SliceStable(convertedEvents, func(i, j int) bool { + return convertedEvents[i].BlockNumber < convertedEvents[j].BlockNumber + }) + + return convertedEvents, nil +} + +// OnReservationReanchorRequested registers a callback that is invoked +// when an on-chain ReservationReanchorRequested event is seen. +func (tc *TbtcChain) OnReservationReanchorRequested( + handler func(event *tbtc.ReservationReanchorRequestedEvent), +) subscription.EventSubscription { + onEvent := func( + reservationKey *big.Int, + requestNonce uint64, + sourceWalletPublicKeyHash [20]byte, + targetWalletPublicKeyHash [20]byte, + txMaxFee uint64, + blockNumber uint64, + ) { + handler(&tbtc.ReservationReanchorRequestedEvent{ + ReservationKey: reservationKey, + RequestNonce: requestNonce, + SourceWalletPublicKeyHash: sourceWalletPublicKeyHash, + TargetWalletPublicKeyHash: targetWalletPublicKeyHash, + TxMaxFee: txMaxFee, + BlockNumber: blockNumber, + }) + } + + return tc.reservationRouter.ReservationReanchorRequestedEvent( + nil, + nil, + nil, + nil, + ).OnEvent(onEvent) +} + +// PastReservationReanchorRequestedEvents fetches past +// ReservationReanchorRequested events according to the provided filter or +// unfiltered if the filter is nil. +func (tc *TbtcChain) PastReservationReanchorRequestedEvents( + filter *tbtc.ReservationReanchorRequestedEventFilter, +) ([]*tbtc.ReservationReanchorRequestedEvent, error) { + var startBlock uint64 + var endBlock *uint64 + var reservationKey []*big.Int + var sourceWalletPublicKeyHash [][20]byte + var targetWalletPublicKeyHash [][20]byte + + if filter != nil { + startBlock = filter.StartBlock + endBlock = filter.EndBlock + reservationKey = filter.ReservationKey + sourceWalletPublicKeyHash = filter.SourceWalletPublicKeyHash + targetWalletPublicKeyHash = filter.TargetWalletPublicKeyHash + } + + events, err := tc.reservationRouter.PastReservationReanchorRequestedEvents( + startBlock, + endBlock, + reservationKey, + sourceWalletPublicKeyHash, + targetWalletPublicKeyHash, + ) + if err != nil { + return nil, err + } + + convertedEvents := make([]*tbtc.ReservationReanchorRequestedEvent, 0) + for _, event := range events { + convertedEvents = append(convertedEvents, &tbtc.ReservationReanchorRequestedEvent{ + ReservationKey: event.ReservationKey, + RequestNonce: event.RequestNonce, + SourceWalletPublicKeyHash: event.SourceWalletPubKeyHash, + TargetWalletPublicKeyHash: event.TargetWalletPubKeyHash, + TxMaxFee: event.TxMaxFee, + BlockNumber: event.Raw.BlockNumber, + }) + } + + sort.SliceStable(convertedEvents, func(i, j int) bool { + return convertedEvents[i].BlockNumber < convertedEvents[j].BlockNumber + }) + + return convertedEvents, nil +} diff --git a/pkg/chain/ethereum/tbtc_reservation_test.go b/pkg/chain/ethereum/tbtc_reservation_test.go new file mode 100644 index 0000000000..4b18c74749 --- /dev/null +++ b/pkg/chain/ethereum/tbtc_reservation_test.go @@ -0,0 +1,467 @@ +// tbtc_reservation_test.go: unit tests for the reservation adapter +// (see tbtc_reservation.go) - ABI type round-trips for reservations, +// reservation actions, and reservation parameters, plus anchor/reanchor +// proposal encoding. +package ethereum + +import ( + "math/big" + "reflect" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/chain" + tbtcabi "github.com/keep-network/keep-core/pkg/chain/ethereum/tbtc/gen/abi" + "github.com/keep-network/keep-core/pkg/tbtc" +) + +func TestConvertReservationFromAbiType(t *testing.T) { + ownerAddress := common.HexToAddress( + "0x1234567890AbcdEF1234567890aBcdef12345678", + ) + anchorTxHash := [32]byte{0x01, 0x02, 0x03, 0x04} + + validAbiReservation := tbtcabi.ReservationReservationRequest{ + Owner: ownerAddress, + MintedAmount: 100000, + AcceptedAt: 1700000000, + WalletPubKeyHash: [20]byte{0xaa, 0xbb, 0xcc}, + AnchorAmount: 99000, + ExpiresAt: 1700100000, + AnchorTxHash: anchorTxHash, + AnchorTxOutputIndex: 1, + State: 1, // Active + RequestNonce: 7, + RetryCredit: true, + DissolutionEligibleAt: 1700200000, + // CumulativeReanchorFee is intentionally dropped on the Go + // boundary (see the Field omissions note on + // convertReservationFromAbiType). + CumulativeReanchorFee: 12345, + } + + t.Run("valid states", func(t *testing.T) { + var tests = map[string]struct { + abiState uint8 + expectedState tbtc.ReservationState + }{ + "unknown": {0, tbtc.ReservationStateUnknown}, + "active": {1, tbtc.ReservationStateActive}, + "pending": {2, tbtc.ReservationStateActionPending}, + "closed": {3, tbtc.ReservationStateClosed}, + "stranded": {4, tbtc.ReservationStateStranded}, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + abiReservation := validAbiReservation + abiReservation.State = test.abiState + + reservation, err := convertReservationFromAbiType(abiReservation) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if reservation.State != test.expectedState { + t.Errorf("expected state [%v], got [%v]", test.expectedState, reservation.State) + } + }) + } + }) + + t.Run("invalid state", func(t *testing.T) { + invalidAbiReservation := validAbiReservation + invalidAbiReservation.State = 255 + + reservation, err := convertReservationFromAbiType(invalidAbiReservation) + if reservation != nil { + t.Errorf("expected nil reservation, got [%+v]", reservation) + } + if err == nil { + t.Fatal("expected error, got nil") + } + }) + + // t.Run below documents the intentional CumulativeReanchorFee drop + // performed by convertReservationFromAbiType: the field is written + // on-chain by every re-anchor hop but is not exposed on + // tbtc.Reservation (see the Field omissions note on + // convertReservationFromAbiType). It also pins that every other + // field maps correctly - each field below is a distinct value so a + // future accidental restoration of CumulativeReanchorFee, or a + // swapped adjacent field, does not go unnoticed. + t.Run("drops cumulative reanchor fee and maps every other field", func(t *testing.T) { + abiReservation := tbtcabi.ReservationReservationRequest{ + Owner: common.HexToAddress("0x111111111111111111111111111111111111111B"), + MintedAmount: 111, + AcceptedAt: 222, + WalletPubKeyHash: [20]byte{0x01, 0x02, 0x03}, + AnchorAmount: 333, + ExpiresAt: 444, + AnchorTxHash: [32]byte{0x04, 0x05, 0x06}, + AnchorTxOutputIndex: 555, + State: 1, // ReservationStateActive + RequestNonce: 666, + RetryCredit: true, + DissolutionEligibleAt: 777, + CumulativeReanchorFee: 888, // must not appear anywhere in the output + } + + expected := &tbtc.Reservation{ + Owner: chain.Address("0x111111111111111111111111111111111111111B"), + MintedAmount: 111, + AcceptedAt: 222, + WalletPublicKeyHash: [20]byte{ + 0x01, 0x02, 0x03, + }, + AnchorUtxo: &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x04, 0x05, 0x06}, + OutputIndex: 555, + }, + Value: 333, + }, + ExpiresAt: 444, + State: tbtc.ReservationStateActive, + RequestNonce: 666, + RetryCredit: true, + DissolutionEligibleAt: 777, + } + + actual, err := convertReservationFromAbiType(abiReservation) + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(expected, actual) { + t.Errorf( + "unexpected reservation\nexpected: [%+v]\nactual: [%+v]", + expected, + actual, + ) + } + }) +} + +func TestConvertReservationActionFromAbiType(t *testing.T) { + targetWalletPKH := [20]byte{0x11, 0x22, 0x33} + redeemerAddress := common.HexToAddress( + "0xAbCdEf1234567890abcDef1234567890AbCdEf12", + ) + actionDataHash := [32]byte{0xde, 0xad, 0xbe, 0xef} + + baseAbiAction := tbtcabi.ReservationReservationAction{ + TargetWalletPubKeyHash: targetWalletPKH, + RequestedAt: 1700000000, + TimeoutAt: 1700003600, + TxMaxFee: 5000, + State: 1, // Pending + FeePaid: true, + Redeemer: redeemerAddress, + Amount: 50000, + ActionDataHash: actionDataHash, + IsPartial: true, + } + + // The action-type-to-hash-field routing (redemption -> redeemer output + // script hash, dissolution -> expected main UTXO hash, everything else + // -> neither) is the one non-trivial branch in this converter; exercise + // all three shapes. + var tests = map[string]struct { + abiActionType uint8 + expectedActionType tbtc.ReservationActionType + expectedRedeemerOutputScriptHash [32]byte + expectedExpectedMainUtxoHash [32]byte + }{ + "redemption routes hash to redeemer output script": { + abiActionType: 2, + expectedActionType: tbtc.ReservationActionTypeRedemption, + expectedRedeemerOutputScriptHash: actionDataHash, + expectedExpectedMainUtxoHash: [32]byte{}, + }, + "dissolution routes hash to expected main utxo": { + abiActionType: 4, + expectedActionType: tbtc.ReservationActionTypeDissolution, + expectedRedeemerOutputScriptHash: [32]byte{}, + expectedExpectedMainUtxoHash: actionDataHash, + }, + "acceptance leaves both hash fields zero": { + abiActionType: 1, + expectedActionType: tbtc.ReservationActionTypeAcceptance, + expectedRedeemerOutputScriptHash: [32]byte{}, + expectedExpectedMainUtxoHash: [32]byte{}, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + abiAction := baseAbiAction + abiAction.ActionType = test.abiActionType + + action, err := convertReservationActionFromAbiType(abiAction) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + expected := &tbtc.ReservationAction{ + TargetWalletPublicKeyHash: targetWalletPKH, + RequestedAt: 1700000000, + TimeoutAt: 1700003600, + TxMaxFee: 5000, + ActionType: test.expectedActionType, + State: tbtc.ReservationActionStatePending, + FeePaid: true, + Redeemer: chain.Address(redeemerAddress.String()), + Amount: 50000, + RedeemerOutputScriptHash: test.expectedRedeemerOutputScriptHash, + ExpectedMainUtxoHash: test.expectedExpectedMainUtxoHash, + IsPartial: true, + } + + if !reflect.DeepEqual(expected, action) { + t.Errorf( + "unexpected action\nexpected: [%+v]\nactual: [%+v]\n", + expected, + action, + ) + } + }) + } + + t.Run("invalid action type", func(t *testing.T) { + abiAction := baseAbiAction + abiAction.ActionType = 255 + + action, err := convertReservationActionFromAbiType(abiAction) + if action != nil { + t.Errorf("expected nil action, got [%+v]", action) + } + if err == nil { + t.Fatal("expected error, got nil") + } + }) + + t.Run("invalid action state", func(t *testing.T) { + abiAction := baseAbiAction + abiAction.ActionType = 1 + abiAction.State = 255 + + action, err := convertReservationActionFromAbiType(abiAction) + if action != nil { + t.Errorf("expected nil action, got [%+v]", action) + } + if err == nil { + t.Fatal("expected error, got nil") + } + }) + + t.Run("valid action states", func(t *testing.T) { + var tests = map[string]struct { + abiState uint8 + expectedState tbtc.ReservationActionState + }{ + "unknown": {0, tbtc.ReservationActionStateUnknown}, + "pending": {1, tbtc.ReservationActionStatePending}, + "settled": {2, tbtc.ReservationActionStateSettled}, + "timed out": {3, tbtc.ReservationActionStateTimedOut}, + "vetoed": {4, tbtc.ReservationActionStateVetoed}, + "superseded": {5, tbtc.ReservationActionStateSuperseded}, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + abiAction := baseAbiAction + abiAction.ActionType = 1 + abiAction.State = test.abiState + + action, err := convertReservationActionFromAbiType(abiAction) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + + if action.State != test.expectedState { + t.Errorf("expected state [%v], got [%v]", test.expectedState, action.State) + } + }) + } + }) +} + +// TestConvertReservationParametersFromAbiType verifies the full 10-tuple +// field mapping performed by convertReservationParametersFromAbiType. +// Field count/order had not previously been cross-checked against the +// live Solidity struct; every field below is set to a distinct non-zero +// value so a swapped or dropped field is caught, not masked by a shared +// zero-value default. +func TestConvertReservationParametersFromAbiType(t *testing.T) { + vaultAddress := common.HexToAddress( + "0x111111111111111111111111111111111111111A", + ) + + abiParameters := struct { + ReservationVault common.Address + ReservationMinAmount uint64 + ReservationTxMaxFee uint64 + ReservationTermSeconds uint32 + ReservationDissolutionDelay uint32 + ReservationMaxTotalAmount uint64 + ReservationTotalAmount uint64 + MaxReservationsPerWallet uint32 + ReservationActionTimeout uint32 + ReservationRenewalWindowSeconds uint32 + }{ + ReservationVault: vaultAddress, + ReservationMinAmount: 1000, + ReservationTxMaxFee: 5000, + ReservationTermSeconds: 1209600, + ReservationDissolutionDelay: 3600, + ReservationMaxTotalAmount: 10000000, + ReservationTotalAmount: 2500000, + MaxReservationsPerWallet: 5, + ReservationActionTimeout: 86400, + ReservationRenewalWindowSeconds: 604800, + } + + expected := &tbtc.ReservationParameters{ + ReservationVault: chain.Address("0x111111111111111111111111111111111111111A"), + ReservationMinAmount: 1000, + ReservationTxMaxFee: 5000, + ReservationTermSeconds: 1209600, + ReservationDissolutionDelay: 3600, + ReservationMaxTotalAmount: 10000000, + ReservationTotalAmount: 2500000, + MaxReservationsPerWallet: 5, + ReservationActionTimeout: 86400, + ReservationRenewalWindowSeconds: 604800, + } + + actual := convertReservationParametersFromAbiType(abiParameters) + + if !reflect.DeepEqual(expected, actual) { + t.Errorf( + "unexpected reservation parameters\nexpected: [%+v]\nactual: [%+v]", + expected, + actual, + ) + } +} + +func TestBuildReservationAnchorProposalAbi(t *testing.T) { + walletPublicKeyHash := [20]byte{ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, + 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, + } + fundingTxHash := bitcoin.Hash{0x21, 0x22, 0x23} + + proposal := &tbtc.ReservationAnchorProposal{ + DepositFundingTxHash: fundingTxHash, + DepositFundingOutputIndex: 7, + AnchorTxFee: big.NewInt(1500), + } + + fundingTx := &bitcoin.Transaction{ + Version: 2, + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x31}, + OutputIndex: 3, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 42000, + PublicKeyScript: []byte{0x00, 0x14}, + }}, + Locktime: 600000, + } + + deposit := &tbtc.Deposit{ + BlindingFactor: [8]byte{0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48}, + WalletPublicKeyHash: [20]byte{0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a, 0x5b, 0x5c, 0x5d, 0x5e, 0x5f, 0x60, 0x61, 0x62, 0x63, 0x64}, + RefundPublicKeyHash: [20]byte{0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x7b, 0x7c, 0x7d, 0x7e, 0x7f, 0x80, 0x81, 0x82, 0x83, 0x84}, + RefundLocktime: [4]byte{0x91, 0x92, 0x93, 0x94}, + } + + depositExtraInfo := struct { + *tbtc.Deposit + FundingTx *bitcoin.Transaction + }{Deposit: deposit, FundingTx: fundingTx} + + abiProposal, abiExtraInfo := buildReservationAnchorProposalAbi( + walletPublicKeyHash, + proposal, + depositExtraInfo, + ) + + expectedProposal := tbtcabi.WalletProposalValidatorReservationAnchorProposal{ + WalletPubKeyHash: walletPublicKeyHash, + DepositKey: tbtcabi.WalletProposalValidatorDepositKey{ + FundingTxHash: fundingTxHash, + FundingOutputIndex: 7, + }, + AnchorTxFee: big.NewInt(1500), + } + if !reflect.DeepEqual(expectedProposal, abiProposal) { + t.Errorf( + "unexpected abi proposal\nexpected: [%+v]\nactual: [%+v]\n", + expectedProposal, + abiProposal, + ) + } + + expectedExtraInfo := tbtcabi.WalletProposalValidatorDepositExtraInfo{ + FundingTx: tbtcabi.BitcoinTxInfo2{ + Version: fundingTx.SerializeVersion(), + InputVector: fundingTx.SerializeInputs(), + OutputVector: fundingTx.SerializeOutputs(), + Locktime: fundingTx.SerializeLocktime(), + }, + BlindingFactor: deposit.BlindingFactor, + WalletPubKeyHash: deposit.WalletPublicKeyHash, + RefundPubKeyHash: deposit.RefundPublicKeyHash, + RefundLocktime: deposit.RefundLocktime, + } + if !reflect.DeepEqual(expectedExtraInfo, abiExtraInfo) { + t.Errorf( + "unexpected abi extra info\nexpected: [%+v]\nactual: [%+v]\n", + expectedExtraInfo, + abiExtraInfo, + ) + } +} + +func TestBuildReservationReanchorProposalAbi(t *testing.T) { + sourceWalletPublicKeyHash := [20]byte{ + 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, + 0xab, 0xac, 0xad, 0xae, 0xaf, 0xb0, 0xb1, 0xb2, 0xb3, 0xb4, + } + targetWalletPublicKeyHash := [20]byte{ + 0xc1, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xca, + 0xcb, 0xcc, 0xcd, 0xce, 0xcf, 0xd0, 0xd1, 0xd2, 0xd3, 0xd4, + } + + proposal := &tbtc.ReservationReanchorProposal{ + ReservationKey: big.NewInt(54321), + TargetWalletPublicKeyHash: targetWalletPublicKeyHash, + ReanchorTxFee: big.NewInt(1700), + } + + abiProposal := buildReservationReanchorProposalAbi( + sourceWalletPublicKeyHash, + proposal, + ) + + expected := tbtcabi.WalletProposalValidatorReservationReanchorProposal{ + SourceWalletPubKeyHash: sourceWalletPublicKeyHash, + ReservationKey: big.NewInt(54321), + TargetWalletPubKeyHash: targetWalletPublicKeyHash, + ReanchorTxFee: big.NewInt(1700), + } + if !reflect.DeepEqual(expected, abiProposal) { + t.Errorf( + "unexpected abi proposal\nexpected: [%+v]\nactual: [%+v]\n", + expected, + abiProposal, + ) + } +} diff --git a/pkg/clientinfo/performance.go b/pkg/clientinfo/performance.go index 06e43cdab4..75f2235b53 100644 --- a/pkg/clientinfo/performance.go +++ b/pkg/clientinfo/performance.go @@ -37,6 +37,17 @@ type PerformanceMetrics struct { registry *Registry cancel context.CancelFunc + // reservationsEnabled mirrors tbtc.Config.Reservations.Enabled. Gates + // registration of the reservation-specific gauge metrics (active_ + // reservations_count, max_active_reservations, live_wallets_count, + // wallet_reservations_count) so a non-reservation deployment's metric + // surface does not change. The reservation wallet action counters + // (reservation_anchor, reservation_reanchor) are registered + // unconditionally regardless of this flag, because reservation action + // execution itself is not gated on it - see registerAllMetrics. + reservationsEnabled bool + + // Counters track cumulative counts of events countersMutex sync.RWMutex counters map[string]*counter @@ -72,14 +83,22 @@ const ( ) // NewPerformanceMetrics creates a new performance metrics instance. -func NewPerformanceMetrics(ctx context.Context, registry *Registry) *PerformanceMetrics { +// reservationsEnabled gates registration of the reservation-specific gauge +// metrics only (see registerAllMetrics); the reservation wallet action +// counters are registered unconditionally. +func NewPerformanceMetrics( + ctx context.Context, + registry *Registry, + reservationsEnabled bool, +) *PerformanceMetrics { ctx, cancel := context.WithCancel(ctx) pm := &PerformanceMetrics{ - registry: registry, - cancel: cancel, - counters: make(map[string]*counter), - histograms: make(map[string]*histogram), - gauges: make(map[string]*gauge), + registry: registry, + cancel: cancel, + reservationsEnabled: reservationsEnabled, + counters: make(map[string]*counter), + histograms: make(map[string]*histogram), + gauges: make(map[string]*gauge), } // Register all metrics upfront with 0 values so they appear in /metrics endpoint @@ -186,7 +205,17 @@ func (pm *PerformanceMetrics) registerAllMetrics() { // ----- wallet action metrics ----- // For each action type, register: total, success_total, failed_total, duration_seconds - for _, actionType := range GetAllWalletActionTypes() { + // Reservation action types are registered unconditionally: reservation + // action execution in node_proposals.go is not itself gated on + // Tbtc.Reservations.Enabled, so an operator running with the flag + // disabled can still execute anchor/re-anchor actions post-activation. + // Gating registration here would leave those wallet_action_reservation_* + // counters created (by IncrementCounter's slow path) but never + // exported, silently losing observability. + actionTypes := append(GetAllWalletActionTypes(), GetReservationWalletActionTypes()...) + + for _, actionType := range actionTypes { + actionCounters := []string{ WalletActionMetricName(actionType, "total"), WalletActionMetricName(actionType, "success_total"), @@ -314,6 +343,15 @@ func (pm *PerformanceMetrics) registerAllMetrics() { MetricRAMUtilizationPercent, MetricSwapUtilizationPercent, } + if pm.reservationsEnabled { + gauges = append( + gauges, + MetricReservationActiveReservationsCount, + MetricReservationMaxActiveReservations, + MetricReservationLiveWalletsCount, + MetricReservationWalletReservationsCount, + ) + } pm.gaugesMutex.Lock() for _, name := range gauges { @@ -624,6 +662,16 @@ const ( MetricCPULoadPercent = "cpu_load_percent" MetricRAMUtilizationPercent = "ram_utilization_percent" MetricSwapUtilizationPercent = "swap_utilization_percent" + + // Reservation Metrics (m1 reservations feature; only registered when + // reservationsEnabled - see NewPerformanceMetrics). These are leading + // indicators of the ยง4.1 saturation cliff: without them, an operator + // cannot see reservation capacity approaching its cap before + // acceptances silently stop. + MetricReservationActiveReservationsCount = "active_reservations_count" + MetricReservationMaxActiveReservations = "max_active_reservations" + MetricReservationLiveWalletsCount = "live_wallets_count" + MetricReservationWalletReservationsCount = "wallet_reservations_count" ) // Network join request failure reasons. These are the low-cardinality @@ -674,8 +722,8 @@ func GetAllNetworkJoinFailureReasons() []string { } } -// GetAllWalletActionTypes returns all wallet action types that should be tracked. -// ActionNoop is excluded as it's a no-op action. +// GetAllWalletActionTypes returns all non-reservation wallet action types that +// should be tracked. ActionNoop is excluded as it's a no-op action. func GetAllWalletActionTypes() []string { return []string{ "heartbeat", @@ -685,3 +733,12 @@ func GetAllWalletActionTypes() []string { "moved_funds_sweep", } } + +// GetReservationWalletActionTypes returns all reservation-specific wallet +// action types that should be tracked. +func GetReservationWalletActionTypes() []string { + return []string{ + "reservation_anchor", + "reservation_reanchor", + } +} diff --git a/pkg/clientinfo/performance_test.go b/pkg/clientinfo/performance_test.go index 2b040beaef..8cfb1235dd 100644 --- a/pkg/clientinfo/performance_test.go +++ b/pkg/clientinfo/performance_test.go @@ -19,7 +19,7 @@ func TestConcurrentCounterIncrement(t *testing.T) { defer cancel() registry := &Registry{keepclientinfo.NewRegistry(), ctx} - pm := NewPerformanceMetrics(ctx, registry) + pm := NewPerformanceMetrics(ctx, registry, true) const ( numGoroutines = 100 @@ -53,7 +53,7 @@ func TestConcurrentCounterDifferentMetrics(t *testing.T) { defer cancel() registry := &Registry{keepclientinfo.NewRegistry(), ctx} - pm := NewPerformanceMetrics(ctx, registry) + pm := NewPerformanceMetrics(ctx, registry, true) const ( numGoroutines = 50 @@ -117,7 +117,7 @@ func TestConcurrentDurationRecording(t *testing.T) { defer cancel() registry := &Registry{keepclientinfo.NewRegistry(), ctx} - pm := NewPerformanceMetrics(ctx, registry) + pm := NewPerformanceMetrics(ctx, registry, true) const ( numGoroutines = 50 @@ -171,7 +171,7 @@ func TestConcurrentGaugeSet(t *testing.T) { defer cancel() registry := &Registry{keepclientinfo.NewRegistry(), ctx} - pm := NewPerformanceMetrics(ctx, registry) + pm := NewPerformanceMetrics(ctx, registry, true) const ( numGoroutines = 100 @@ -207,7 +207,7 @@ func TestConcurrentDifferentOperations(t *testing.T) { defer cancel() registry := &Registry{keepclientinfo.NewRegistry(), ctx} - pm := NewPerformanceMetrics(ctx, registry) + pm := NewPerformanceMetrics(ctx, registry, true) const ( numGoroutines = 30 @@ -266,7 +266,7 @@ func TestHistogramBucketPlacement(t *testing.T) { defer cancel() registry := &Registry{keepclientinfo.NewRegistry(), ctx} - pm := NewPerformanceMetrics(ctx, registry) + pm := NewPerformanceMetrics(ctx, registry, true) metricName := "test_duration_seconds" @@ -326,7 +326,7 @@ func TestMetricsInitialization(t *testing.T) { defer cancel() registry := &Registry{keepclientinfo.NewRegistry(), ctx} - pm := NewPerformanceMetrics(ctx, registry) + pm := NewPerformanceMetrics(ctx, registry, true) // Test counters counters := []string{ @@ -364,7 +364,7 @@ func TestContextCancelation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) registry := &Registry{keepclientinfo.NewRegistry(), ctx} - pm := NewPerformanceMetrics(ctx, registry) + pm := NewPerformanceMetrics(ctx, registry, true) // Cancel context immediately cancel() @@ -432,7 +432,7 @@ func TestJoinFailureAndOnChainCountersRegistered(t *testing.T) { defer cancel() registry := &Registry{keepclientinfo.NewRegistry(), ctx} - pm := NewPerformanceMetrics(ctx, registry) + pm := NewPerformanceMetrics(ctx, registry, true) expectedCounters := []string{MetricFirewallOnChainChecksTotal} for _, reason := range GetAllNetworkJoinFailureReasons() { @@ -469,7 +469,7 @@ func TestDepositSweepProofSubmissionCountersRegistered(t *testing.T) { defer cancel() registry := &Registry{keepclientinfo.NewRegistry(), ctx} - pm := NewPerformanceMetrics(ctx, registry) + pm := NewPerformanceMetrics(ctx, registry, false) expectedCounters := []string{ MetricDepositSweepProofSubmissionsTotal, @@ -511,7 +511,7 @@ func TestSpvProofSkipCountersRegistered(t *testing.T) { defer cancel() registry := &Registry{keepclientinfo.NewRegistry(), ctx} - pm := NewPerformanceMetrics(ctx, registry) + pm := NewPerformanceMetrics(ctx, registry, false) expectedCounters := []string{ MetricSpvProofSkippedOutsideRelayRangeTotal, @@ -539,3 +539,196 @@ func TestSpvProofSkipCountersRegistered(t *testing.T) { } } } + +func TestWalletActionMetricsRegistered(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + registry := &Registry{keepclientinfo.NewRegistry(), ctx} + pm := NewPerformanceMetrics(ctx, registry, true) + + expectedActionTypes := []string{ + "heartbeat", + "deposit_sweep", + "redemption", + "moving_funds", + "moved_funds_sweep", + "reservation_anchor", + "reservation_reanchor", + } + + for _, actionType := range expectedActionTypes { + for _, metricType := range []string{ + "total", + "success_total", + "failed_total", + } { + metricName := WalletActionMetricName(actionType, metricType) + + pm.countersMutex.RLock() + _, exists := pm.counters[metricName] + pm.countersMutex.RUnlock() + if !exists { + t.Errorf("counter %s should be registered upfront", metricName) + } + } + + durationMetricName := WalletActionMetricName( + actionType, + "duration_seconds", + ) + pm.histogramsMutex.RLock() + _, exists := pm.histograms[durationMetricName] + pm.histogramsMutex.RUnlock() + if !exists { + t.Errorf( + "histogram %s should be registered upfront", + durationMetricName, + ) + } + } +} + +// TestWalletActionMetricsRegisteredRegardlessOfReservationsFlag verifies +// wallet_action_reservation_* counters and histograms are registered even +// when Tbtc.Reservations.Enabled is false. Reservation action execution +// (anchor/re-anchor co-signing) is not itself gated on that flag - only +// proposal generation, watcher wiring, and the reservation gauges are - so +// gating this registration would silently drop observability for the +// operators most likely to see unconditional execution. +func TestWalletActionMetricsRegisteredRegardlessOfReservationsFlag(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + registry := &Registry{keepclientinfo.NewRegistry(), ctx} + pm := NewPerformanceMetrics(ctx, registry, false) + + nonReservationActionTypes := []string{ + "heartbeat", + "deposit_sweep", + "redemption", + "moving_funds", + "moved_funds_sweep", + } + reservationActionTypes := []string{ + "reservation_anchor", + "reservation_reanchor", + } + + for _, actionType := range nonReservationActionTypes { + metricName := WalletActionMetricName(actionType, "total") + pm.countersMutex.RLock() + _, exists := pm.counters[metricName] + pm.countersMutex.RUnlock() + if !exists { + t.Errorf( + "counter %s should still be registered when reservations "+ + "are disabled", + metricName, + ) + } + } + + for _, actionType := range reservationActionTypes { + for _, metricType := range []string{"total", "success_total", "failed_total"} { + metricName := WalletActionMetricName(actionType, metricType) + pm.countersMutex.RLock() + _, exists := pm.counters[metricName] + pm.countersMutex.RUnlock() + if !exists { + t.Errorf( + "counter %s should be registered even when reservations "+ + "are disabled, since action execution is not gated "+ + "on the flag", + metricName, + ) + } + } + + durationMetricName := WalletActionMetricName(actionType, "duration_seconds") + pm.histogramsMutex.RLock() + _, exists := pm.histograms[durationMetricName] + pm.histogramsMutex.RUnlock() + if !exists { + t.Errorf( + "histogram %s should be registered even when reservations "+ + "are disabled, since action execution is not gated on "+ + "the flag", + durationMetricName, + ) + } + } +} + +// TestReservationGaugesRegistered verifies the four reservation saturation +// gauges (active_reservations_count, max_active_reservations, +// live_wallets_count, wallet_reservations_count) are registered upfront +// with a 0 value when reservations are enabled. +func TestReservationGaugesRegistered(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + registry := &Registry{keepclientinfo.NewRegistry(), ctx} + pm := NewPerformanceMetrics(ctx, registry, true) + + reservationGauges := []string{ + MetricReservationActiveReservationsCount, + MetricReservationMaxActiveReservations, + MetricReservationLiveWalletsCount, + MetricReservationWalletReservationsCount, + } + + for _, name := range reservationGauges { + pm.gaugesMutex.RLock() + g, exists := pm.gauges[name] + pm.gaugesMutex.RUnlock() + if !exists { + t.Errorf("gauge %s should be registered upfront", name) + continue + } + g.mutex.RLock() + value := g.value + g.mutex.RUnlock() + if value != 0 { + t.Errorf("gauge %s should start at 0, got %v", name, value) + } + } + + // SetGauge (as the reservation tasks do) must update the registered + // gauge, not silently no-op. + pm.SetGauge(MetricReservationActiveReservationsCount, 42) + if got := pm.GetGaugeValue(MetricReservationActiveReservationsCount); got != 42 { + t.Errorf("expected active_reservations_count = 42, got %v", got) + } +} + +// TestReservationGaugesNotRegisteredWhenReservationsDisabled verifies the +// four reservation saturation gauges are absent (not just zero) when the +// m1 reservations feature is disabled, mirroring the wallet-action-metrics +// gating in TestWalletActionMetricsNotRegisteredWhenReservationsDisabled. +func TestReservationGaugesNotRegisteredWhenReservationsDisabled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + registry := &Registry{keepclientinfo.NewRegistry(), ctx} + pm := NewPerformanceMetrics(ctx, registry, false) + + reservationGauges := []string{ + MetricReservationActiveReservationsCount, + MetricReservationMaxActiveReservations, + MetricReservationLiveWalletsCount, + MetricReservationWalletReservationsCount, + } + + for _, name := range reservationGauges { + pm.gaugesMutex.RLock() + _, exists := pm.gauges[name] + pm.gaugesMutex.RUnlock() + if exists { + t.Errorf( + "gauge %s should not be registered when reservations are disabled", + name, + ) + } + } +} diff --git a/pkg/maintainer/spv/bitcoin_chain_test.go b/pkg/maintainer/spv/bitcoin_chain_test.go index 35ce5acf6d..ceaa292334 100644 --- a/pkg/maintainer/spv/bitcoin_chain_test.go +++ b/pkg/maintainer/spv/bitcoin_chain_test.go @@ -47,6 +47,7 @@ type localBitcoinChain struct { transactions []*bitcoin.Transaction transactionConfirmations map[bitcoin.Hash]uint blockHeaders map[uint]*bitcoin.BlockHeader + coinbaseTxHash *bitcoin.Hash } func newLocalBitcoinChain() *localBitcoinChain { @@ -138,11 +139,20 @@ func (lbc *localBitcoinChain) GetBlockHeader(blockHeight uint) ( return nil, fmt.Errorf("block header does not exist") } +// GetTransactionMerkleProof returns a trivial, always-valid proof: an empty +// merkle-node list means the given transaction hash is treated as the +// block's merkle root directly, at position 0. Sufficient to let +// bitcoin.AssembleSpvProof complete against this fake chain without a real +// merkle-tree fixture. func (lbc *localBitcoinChain) GetTransactionMerkleProof( transactionHash bitcoin.Hash, blockHeight uint, ) (*bitcoin.TransactionMerkleProof, error) { - panic("unsupported") + return &bitcoin.TransactionMerkleProof{ + BlockHeight: blockHeight, + MerkleNodes: nil, + Position: 0, + }, nil } func (lbc *localBitcoinChain) GetTransactionsForPublicKeyHash( @@ -213,13 +223,33 @@ func (lbc *localBitcoinChain) EstimateSatPerVByteFee(blocks uint32) ( panic("unsupported") } +// GetCoinbaseTxHash returns the hash previously installed via +// setCoinbaseTxHash. Panics if never set, matching this fake chain's +// convention for exercising an unconfigured dependency. func (lbc *localBitcoinChain) GetCoinbaseTxHash(blockHeight uint) ( bitcoin.Hash, error, ) { + lbc.mutex.Lock() + defer lbc.mutex.Unlock() + + if lbc.coinbaseTxHash != nil { + return *lbc.coinbaseTxHash, nil + } panic("unsupported") } +// setCoinbaseTxHash installs the hash GetCoinbaseTxHash returns for every +// block height. The hash must belong to a transaction already known to +// GetTransaction (e.g. via BroadcastTransaction) since AssembleSpvProof +// looks the coinbase transaction up by this hash immediately after. +func (lbc *localBitcoinChain) setCoinbaseTxHash(hash bitcoin.Hash) { + lbc.mutex.Lock() + defer lbc.mutex.Unlock() + + lbc.coinbaseTxHash = &hash +} + func (lbc *localBitcoinChain) addBlockHeader( blockNumber uint, blockHeader *bitcoin.BlockHeader, diff --git a/pkg/maintainer/spv/chain.go b/pkg/maintainer/spv/chain.go index c9e060e9bf..e2023e2f84 100644 --- a/pkg/maintainer/spv/chain.go +++ b/pkg/maintainer/spv/chain.go @@ -4,6 +4,7 @@ import ( "math/big" "github.com/ethereum/go-ethereum/common" + "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/tbtc" @@ -85,6 +86,69 @@ type Chain interface { mainUTXO bitcoin.UnspentTransactionOutput, ) error + // SubmitReservationProof submits an SPV proof for the given reservation + // action generation. proofType selects between Acceptance, Redemption, + // Reanchor, and Dissolution proofs; m1 invokes only Acceptance (1) and + // Reanchor (3). The call is restricted to the SPV maintainer registered + // against the Bridge. + SubmitReservationProof( + proofType uint8, + txInfo *tbtc.BitcoinTxInfo, + proof *tbtc.BitcoinTxProof, + mainUtxo *tbtc.BitcoinTxUTXO, + reservationKey *big.Int, + requestNonce uint64, + ) error + + // NotifyReservationActionTimeout notifies the Bridge that the timeout + // for the given reservation action generation has elapsed without the + // SPV proof being submitted. The walletMembersIDs carry the operator + // IDs of the wallet that was authorized for the action. + NotifyReservationActionTimeout( + reservationKey *big.Int, + walletMembersIDs []uint32, + ) error + + // NotifyStaleReservedDeposit notifies the Bridge that the given reserved + // deposit's wallet did not anchor it within the reservation-action + // timeout and should be released back to the default sweeping path. + NotifyStaleReservedDeposit(depositKey *big.Int) error + + // NotifyReservationStranded notifies the Bridge that the wallet + // custodying the given reservation has been closed or terminated and + // the anchor is therefore stranded. + NotifyReservationStranded(reservationKey *big.Int) error + + // GetReservation gets the on-chain reservation record for the given + // reservation key. Returns an error if the reservation was not found. + GetReservation(reservationKey *big.Int) (*tbtc.Reservation, error) + + // GetReservationAction gets the on-chain action record for the given + // reservation key and request nonce. Returns an error if the action + // generation was not found. + GetReservationAction( + reservationKey *big.Int, + requestNonce uint64, + ) (*tbtc.ReservationAction, error) + + // ReservationParameters gets the current on-chain values of the Bridge + // reservation parameters. + ReservationParameters() (*tbtc.ReservationParameters, error) + + // WalletReservations returns the reservation keys for all reservations + // currently custodied by the given wallet. + WalletReservations(walletPublicKeyHash [20]byte) ([]*big.Int, error) + + // IsReservedDeposit returns true if the given deposit was revealed + // with the reservation vault address and is therefore a reservation + // rather than a default deposit. + IsReservedDeposit(depositKey *big.Int) (bool, error) + + // ReservedDepositWallet returns the wallet public key hash to which the + // given reserved deposit was revealed. Returns the zero hash if the + // deposit is not a reserved deposit. + ReservedDepositWallet(depositKey *big.Int) ([20]byte, error) + // PastDepositRevealedEvents fetches past deposit reveal events according // to the provided filter or unfiltered if the filter is nil. Returned // events are sorted by the block number in the ascending order, i.e. the @@ -109,4 +173,31 @@ type Chain interface { PastMovingFundsCommitmentSubmittedEvents( filter *tbtc.MovingFundsCommitmentSubmittedEventFilter, ) ([]*tbtc.MovingFundsCommitmentSubmittedEvent, error) + + // PastReservationAcceptanceRequestedEvents fetches past + // ReservationAcceptanceRequested events according to the provided filter + // or unfiltered if the filter is nil. Returned events are sorted by the + // block number in the ascending order. + PastReservationAcceptanceRequestedEvents( + filter *tbtc.ReservationAcceptanceRequestedEventFilter, + ) ([]*tbtc.ReservationAcceptanceRequestedEvent, error) + + // PastReservationReanchorRequestedEvents fetches past + // ReservationReanchorRequested events according to the provided filter + // or unfiltered if the filter is nil. Returned events are sorted by the + // block number in the ascending order. + PastReservationReanchorRequestedEvents( + filter *tbtc.ReservationReanchorRequestedEventFilter, + ) ([]*tbtc.ReservationReanchorRequestedEvent, error) + + // PastNewWalletRegisteredEvents fetches past NewWalletRegistered events + // according to the provided filter or unfiltered if the filter is nil. + // Returned events are sorted by the block number in the ascending order. + PastNewWalletRegisteredEvents( + filter *tbtc.NewWalletRegisteredEventFilter, + ) ([]*tbtc.NewWalletRegisteredEvent, error) + + // BuildDepositKey calculates the key used by the Bridge to store a + // deposit request, which is a unique identifier for a deposit on-chain. + BuildDepositKey(fundingTxHash bitcoin.Hash, fundingOutputIndex uint32) *big.Int } diff --git a/pkg/maintainer/spv/chain_test.go b/pkg/maintainer/spv/chain_test.go index 2a0bcc0a89..33a52039aa 100644 --- a/pkg/maintainer/spv/chain_test.go +++ b/pkg/maintainer/spv/chain_test.go @@ -9,8 +9,10 @@ import ( "fmt" "math/big" "sync" + "testing" "github.com/ethereum/go-ethereum/common" + "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/tbtc" @@ -43,6 +45,33 @@ type submittedMovedFundsSweepProof struct { mainUTXO bitcoin.UnspentTransactionOutput } +// submittedReservationStranded records a NotifyReservationStranded call for +// assertion in tests. +type submittedReservationStranded struct { + reservationKey *big.Int +} + +// submittedStaleReservedDeposit records a NotifyStaleReservedDeposit call for +// assertion in tests. +type submittedStaleReservedDeposit struct { + depositKey *big.Int +} + +// submittedReservationActionTimeout records a NotifyReservationActionTimeout +// call for assertion in tests. +type submittedReservationActionTimeout struct { + reservationKey *big.Int + walletMembersIDs []uint32 +} + +// reservedDepositRecord is the local-chain-side booking for a reserved +// deposit. WalletPublicKeyHash is the wallet currently assigned to the +// deposit; IsReserved drives the IsReservedDeposit return value. +type reservedDepositRecord struct { + walletPublicKeyHash [20]byte + isReserved bool +} + type localChain struct { mutex sync.Mutex @@ -59,10 +88,52 @@ type localChain struct { pastDepositRevealedEvents map[[32]byte][]*tbtc.DepositRevealedEvent pastMovingFundsCommitmentSubmittedEvents map[[32]byte][]*tbtc.MovingFundsCommitmentSubmittedEvent + // Reservation watcher state. Indexed by [16]byte / [24]byte map keys + // derived from the relevant big.Int so they fit the map type without + // per-test marshalling. + walletReservations map[[20]byte][]*big.Int + reservations map[string]*tbtc.Reservation + reservationActions map[string]*tbtc.ReservationAction + reservedDeposits map[string]*reservedDepositRecord + submittedStrandedKeys []*big.Int + submittedStaleDeposits []*big.Int + submittedActionTimeouts []*submittedReservationActionTimeout + reservationParameters *tbtc.ReservationParameters + + // 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. + getReservationActionErr error + walletReservationsErr error + isReservedDepositErr error + reservedDepositWalletErr error + notifyReservationActionTimeoutErr error + notifyStaleReservedDepositErr error + pastNewWalletRegisteredEventsErr error + notifyReservationStrandedErrByKey map[string]error + + // Wallet registration and pending-action-request event state for the + // watcher dispatch and reservation proof loop tests. + newWalletRegisteredEvents []*tbtc.NewWalletRegisteredEvent + reservationAcceptanceRequestedEvents []*tbtc.ReservationAcceptanceRequestedEvent + reservationReanchorRequestedEvents []*tbtc.ReservationReanchorRequestedEvent + txProofDifficultyFactor *big.Int currentEpoch uint64 currentEpochDifficulty *big.Int previousEpochDifficulty *big.Int + // submitReservationProofHook, when non-nil, overrides the default + // success stub and gives the test full control over + // SubmitReservationProof behavior (e.g. to assert arguments or return + // an error). + submitReservationProofHook func( + proofType uint8, + txInfo *tbtc.BitcoinTxInfo, + proof *tbtc.BitcoinTxProof, + mainUtxo *tbtc.BitcoinTxUTXO, + reservationKey *big.Int, + requestNonce uint64, + ) error } func newLocalChain() *localChain { @@ -74,9 +145,17 @@ func newLocalChain() *localChain { submittedRedemptionProofs: make([]*submittedRedemptionProof, 0), submittedDepositSweepProofs: make([]*submittedDepositSweepProof, 0), submittedMovingFundsProofs: make([]*submittedMovingFundsProof, 0), + submittedMovedFundsSweepProofs: make([]*submittedMovedFundsSweepProof, 0), pastRedemptionRequestedEvents: make(map[[32]byte][]*tbtc.RedemptionRequestedEvent), pastDepositRevealedEvents: make(map[[32]byte][]*tbtc.DepositRevealedEvent), pastMovingFundsCommitmentSubmittedEvents: make(map[[32]byte][]*tbtc.MovingFundsCommitmentSubmittedEvent), + walletReservations: make(map[[20]byte][]*big.Int), + reservations: make(map[string]*tbtc.Reservation), + reservationActions: make(map[string]*tbtc.ReservationAction), + reservedDeposits: make(map[string]*reservedDepositRecord), + submittedStrandedKeys: make([]*big.Int, 0), + submittedStaleDeposits: make([]*big.Int, 0), + submittedActionTimeouts: make([]*submittedReservationActionTimeout, 0), } } @@ -720,3 +799,473 @@ func (mbc *mockBlockCounter) SetCurrentBlock(block uint64) { func (mbc *mockBlockCounter) WatchBlocks(ctx context.Context) <-chan uint64 { panic("unsupported") } + +// SubmitReservationProof is a stub matching the reservation additions on +// the production Chain interface. +func (lc *localChain) SubmitReservationProof( + proofType uint8, + txInfo *tbtc.BitcoinTxInfo, + proof *tbtc.BitcoinTxProof, + mainUtxo *tbtc.BitcoinTxUTXO, + reservationKey *big.Int, + requestNonce uint64, +) error { + if lc.submitReservationProofHook != nil { + return lc.submitReservationProofHook( + proofType, + txInfo, + proof, + mainUtxo, + reservationKey, + requestNonce, + ) + } + panic("unsupported") +} + +// NotifyReservationActionTimeout records the notification for assertion in +// tests. The action-timeout watcher builder invokes this through the +// Chain interface to drive the notification path. +func (lc *localChain) NotifyReservationActionTimeout( + reservationKey *big.Int, + walletMembersIDs []uint32, +) error { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.submittedActionTimeouts = append( + lc.submittedActionTimeouts, + &submittedReservationActionTimeout{ + reservationKey: reservationKey, + walletMembersIDs: walletMembersIDs, + }, + ) + + return lc.notifyReservationActionTimeoutErr +} + +// getSubmittedReservationActionTimeouts returns the recorded action-timeout +// notifications in submission order. +func (lc *localChain) getSubmittedReservationActionTimeouts() []*submittedReservationActionTimeout { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + out := make([]*submittedReservationActionTimeout, len(lc.submittedActionTimeouts)) + copy(out, lc.submittedActionTimeouts) + return out +} + +// NotifyStaleReservedDeposit records the notification for assertion in +// tests. The stale-deposit watcher builder invokes this through the +// Chain interface to drive the notification path. +func (lc *localChain) NotifyStaleReservedDeposit(depositKey *big.Int) error { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.submittedStaleDeposits = append( + lc.submittedStaleDeposits, + depositKey, + ) + + return lc.notifyStaleReservedDepositErr +} + +// getSubmittedStaleReservedDeposits returns the recorded stale-deposit +// notifications in submission order. +func (lc *localChain) getSubmittedStaleReservedDeposits() []*big.Int { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + out := make([]*big.Int, len(lc.submittedStaleDeposits)) + copy(out, lc.submittedStaleDeposits) + return out +} + +// NotifyReservationStranded records the notification for assertion in +// tests. The stranding watcher builder invokes this through the Chain +// interface to drive the notification path. +func (lc *localChain) NotifyReservationStranded(reservationKey *big.Int) error { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + if err, ok := lc.notifyReservationStrandedErrByKey[reservationKey.String()]; ok { + return err + } + + lc.submittedStrandedKeys = append( + lc.submittedStrandedKeys, + reservationKey, + ) + + return nil +} + +// getSubmittedReservationStrandedKeys returns the recorded stranding +// notifications in submission order. +func (lc *localChain) getSubmittedReservationStrandedKeys() []*big.Int { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + out := make([]*big.Int, len(lc.submittedStrandedKeys)) + copy(out, lc.submittedStrandedKeys) + return out +} + +// GetReservation returns the reservation previously installed via +// setReservation. Returns an error if the reservation is not set, matching +// the production contract behavior. +func (lc *localChain) GetReservation( + reservationKey *big.Int, +) (*tbtc.Reservation, error) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + key := bigIntKey(reservationKey) + reservation, ok := lc.reservations[key] + if !ok { + return nil, fmt.Errorf("no reservation for given key") + } + return reservation, nil +} + +// setReservation installs a reservation for GetReservation to return. +func (lc *localChain) setReservation( + reservationKey *big.Int, + reservation *tbtc.Reservation, +) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.reservations[bigIntKey(reservationKey)] = reservation +} + +// GetReservationAction returns the reservation action previously installed +// via setReservationAction. Returns an error if the action is not set. +func (lc *localChain) GetReservationAction( + reservationKey *big.Int, + requestNonce uint64, +) (*tbtc.ReservationAction, error) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + if lc.getReservationActionErr != nil { + return nil, lc.getReservationActionErr + } + + key := buildReservationActionKey(reservationKey, requestNonce) + action, ok := lc.reservationActions[key] + if !ok { + return nil, fmt.Errorf("no action for given reservation/nonce") + } + return action, nil +} + +// setReservationAction installs a reservation action for GetReservationAction +// to return. +func (lc *localChain) setReservationAction( + reservationKey *big.Int, + requestNonce uint64, + action *tbtc.ReservationAction, +) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.reservationActions[buildReservationActionKey(reservationKey, requestNonce)] = action +} + +// buildReservationActionKey produces a map key encoding the reservation +// identifier and the nonce, using the big.Int's full base-16 text +// representation so distinct reservation keys can never collide. +func buildReservationActionKey( + reservationKey *big.Int, + requestNonce uint64, +) string { + return fmt.Sprintf("%s/%d", bigIntKey(reservationKey), requestNonce) +} + +// bigIntKey returns a map key string from a big.Int using its full base-16 +// text representation, so distinct values can never collide. Returns the +// empty string for nil. +func bigIntKey(v *big.Int) string { + if v == nil { + return "" + } + return v.Text(16) +} + +// ReservationParameters returns the reservation parameters previously +// installed via setReservationParameters, or a default set if none was set. +func (lc *localChain) ReservationParameters() ( + *tbtc.ReservationParameters, + error, +) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + if lc.reservationParameters != nil { + return lc.reservationParameters, nil + } + return &tbtc.ReservationParameters{ + ReservationActionTimeout: 3600, + }, nil +} + +// setReservationParameters installs reservation parameters for +// ReservationParameters to return. +func (lc *localChain) setReservationParameters( + params *tbtc.ReservationParameters, +) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.reservationParameters = params +} + +// WalletReservations returns the reservation keys previously installed via +// setWalletReservations. The slice is a copy so the caller can mutate it +// without affecting the local chain. +func (lc *localChain) WalletReservations( + walletPublicKeyHash [20]byte, +) ([]*big.Int, error) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + if lc.walletReservationsErr != nil { + return nil, lc.walletReservationsErr + } + + keys := lc.walletReservations[walletPublicKeyHash] + out := make([]*big.Int, len(keys)) + copy(out, keys) + return out, nil +} + +// setWalletReservations installs the list of reservation keys for a wallet. +func (lc *localChain) setWalletReservations( + walletPublicKeyHash [20]byte, + keys []*big.Int, +) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.walletReservations[walletPublicKeyHash] = append( + []*big.Int{}, + keys..., + ) +} + +// IsReservedDeposit returns whether the deposit was previously booked via +// setReservedDeposit. +func (lc *localChain) IsReservedDeposit( + depositKey *big.Int, +) (bool, error) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + if lc.isReservedDepositErr != nil { + return false, lc.isReservedDepositErr + } + + record, ok := lc.reservedDeposits[bigIntKey(depositKey)] + if !ok { + return false, nil + } + return record.isReserved, nil +} + +// ReservedDepositWallet returns the wallet previously assigned to the +// deposit via setReservedDeposit. +func (lc *localChain) ReservedDepositWallet( + depositKey *big.Int, +) ([20]byte, error) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + if lc.reservedDepositWalletErr != nil { + return [20]byte{}, lc.reservedDepositWalletErr + } + + record, ok := lc.reservedDeposits[bigIntKey(depositKey)] + if !ok { + return [20]byte{}, nil + } + return record.walletPublicKeyHash, nil +} + +// setReservedDeposit installs the reserved-deposit booking for +// IsReservedDeposit and ReservedDepositWallet to return. +func (lc *localChain) setReservedDeposit( + depositKey *big.Int, + walletPublicKeyHash [20]byte, + isReserved bool, +) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.reservedDeposits[bigIntKey(depositKey)] = &reservedDepositRecord{ + walletPublicKeyHash: walletPublicKeyHash, + isReserved: isReserved, + } +} + +func (lc *localChain) PastReservationAcceptanceRequestedEvents( + filter *tbtc.ReservationAcceptanceRequestedEventFilter, +) ([]*tbtc.ReservationAcceptanceRequestedEvent, error) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + var result []*tbtc.ReservationAcceptanceRequestedEvent + for _, event := range lc.reservationAcceptanceRequestedEvents { + if filter != nil && event.BlockNumber < filter.StartBlock { + continue + } + if filter != nil && len(filter.ReservationKey) > 0 { + matched := false + for _, key := range filter.ReservationKey { + if key.Cmp(event.ReservationKey) == 0 { + matched = true + break + } + } + if !matched { + continue + } + } + result = append(result, event) + } + + return result, nil +} + +func (lc *localChain) addReservationAcceptanceRequestedEvent( + event *tbtc.ReservationAcceptanceRequestedEvent, +) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.reservationAcceptanceRequestedEvents = append( + lc.reservationAcceptanceRequestedEvents, + event, + ) +} + +func (lc *localChain) PastReservationReanchorRequestedEvents( + filter *tbtc.ReservationReanchorRequestedEventFilter, +) ([]*tbtc.ReservationReanchorRequestedEvent, error) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + var result []*tbtc.ReservationReanchorRequestedEvent + for _, event := range lc.reservationReanchorRequestedEvents { + if filter != nil && event.BlockNumber < filter.StartBlock { + continue + } + if filter != nil && len(filter.ReservationKey) > 0 { + matched := false + for _, key := range filter.ReservationKey { + if key.Cmp(event.ReservationKey) == 0 { + matched = true + break + } + } + if !matched { + continue + } + } + result = append(result, event) + } + + return result, nil +} + +func (lc *localChain) addReservationReanchorRequestedEvent( + event *tbtc.ReservationReanchorRequestedEvent, +) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.reservationReanchorRequestedEvents = append( + lc.reservationReanchorRequestedEvents, + event, + ) +} + +func (lc *localChain) PastNewWalletRegisteredEvents( + filter *tbtc.NewWalletRegisteredEventFilter, +) ([]*tbtc.NewWalletRegisteredEvent, error) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + if lc.pastNewWalletRegisteredEventsErr != nil { + return nil, lc.pastNewWalletRegisteredEventsErr + } + + var result []*tbtc.NewWalletRegisteredEvent + for _, event := range lc.newWalletRegisteredEvents { + if filter != nil && event.BlockNumber < filter.StartBlock { + continue + } + if filter != nil && len(filter.EcdsaWalletID) > 0 { + matched := false + for _, id := range filter.EcdsaWalletID { + if id == event.EcdsaWalletID { + matched = true + break + } + } + if !matched { + continue + } + } + result = append(result, event) + } + + return result, nil +} + +func (lc *localChain) addNewWalletRegisteredEvent( + event *tbtc.NewWalletRegisteredEvent, +) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.newWalletRegisteredEvents = append(lc.newWalletRegisteredEvents, event) +} + +func (lc *localChain) setPastNewWalletRegisteredEventsErr(err error) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.pastNewWalletRegisteredEventsErr = err +} + +// BuildDepositKey is a test-double implementation independent of the +// production keccak256-based algorithm (pkg/chain/ethereum's unexported +// buildDepositKey): only self-consistency within this fake chain matters +// for unit tests, since nothing here cross-checks against a real contract. +func (lc *localChain) BuildDepositKey( + fundingTxHash bitcoin.Hash, + fundingOutputIndex uint32, +) *big.Int { + key := buildDepositRequestKey(fundingTxHash, fundingOutputIndex) + return new(big.Int).SetBytes(key[:]) +} +func TestIsReservedDeposit_PointerIdentity(t *testing.T) { + spvChain := newLocalChain() + + // Set reserved with one pointer + key1 := big.NewInt(123) + wallet := [20]byte{1, 2, 3} + spvChain.setReservedDeposit(key1, wallet, true) + + // Check reserved with another pointer with same value + key2 := big.NewInt(123) + isReserved, err := spvChain.IsReservedDeposit(key2) + + if err != nil { + t.Fatal(err) + } + if !isReserved { + t.Fatal("expected deposit to be reserved") + } +} diff --git a/pkg/maintainer/spv/config.go b/pkg/maintainer/spv/config.go index d9f3dfcf7f..149cfdfab5 100644 --- a/pkg/maintainer/spv/config.go +++ b/pkg/maintainer/spv/config.go @@ -2,6 +2,8 @@ package spv import ( "time" + + "github.com/keep-network/keep-core/pkg/tbtc" ) const ( @@ -72,6 +74,17 @@ type Config struct { // more transaction proofs to submit. IdleBackoffTime time.Duration + // Reservations controls SPV proof submission for reservation acceptance + // and re-anchor action generations. + // + // OPERATOR NOTE: This flag only controls SPV proof submission in the + // maintainer process. Proposal generation and watcher wiring in the client + // process are gated by the separate Tbtc.Reservations.Enabled flag. + // An operator MUST enable BOTH flags ([Tbtc.Reservations] in the client + // and [Maintainer.Spv.Reservations] in the maintainer) for the reservation + // feature to work end-to-end. + Reservations tbtc.ReservationsConfig + // MaxProofHeaders caps the forward walk over headers when assembling an // SPV proof. The proof window is anchored at a fixed start block, so a // run of leading minimum-difficulty (DIFF1) headers longer than this diff --git a/pkg/maintainer/spv/reservation_acceptance_proof.go b/pkg/maintainer/spv/reservation_acceptance_proof.go new file mode 100644 index 0000000000..5318995a79 --- /dev/null +++ b/pkg/maintainer/spv/reservation_acceptance_proof.go @@ -0,0 +1,75 @@ +package spv + +import ( + "math/big" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/tbtc" +) + +// ProofTypeReservationAcceptance is the value passed to +// SubmitReservationProof as proofType for a reservation acceptance SPV +// proof. The numeric value mirrors the on-chain ReservationProofType enum +// (1 = Acceptance). +const ProofTypeReservationAcceptance uint8 = 1 + +// SubmitReservationAcceptanceProof drives the SPV proof submission for a +// reservation acceptance action generation. The caller (the reservation +// proof loop) supplies the (reservationKey, requestNonce) pair of the +// on-chain action generation it is proving, plus the Bitcoin transaction +// hash of the anchor transaction already signed and broadcast by the wallet +// coordinator. The proof is fetched from btcChain, the anchor transaction +// is rebuilt locally to extract the deposit UTXO that was anchored, and the +// proof is submitted directly to the Bridge via the SPV maintainer's +// SubmitReservationProof entry point (not via MaintainerProxy: reservations +// are not reimbursed). +// +// requiredConfirmations must be > 0; the SPV maintainer relies on it to +// assemble the proof. +func SubmitReservationAcceptanceProof( + transactionHash bitcoin.Hash, + requiredConfirmations uint, + reservationKey *big.Int, + requestNonce uint64, + btcChain bitcoin.Chain, + spvChain Chain, +) error { + return submitReservationAcceptanceProof( + transactionHash, + requiredConfirmations, + reservationKey, + requestNonce, + btcChain, + spvChain, + bitcoin.AssembleSpvProof, + getMetricsRecorder(), + ) +} + +func submitReservationAcceptanceProof( + transactionHash bitcoin.Hash, + requiredConfirmations uint, + reservationKey *big.Int, + requestNonce uint64, + btcChain bitcoin.Chain, + spvChain Chain, + spvProofAssembler spvProofAssembler, + metricsRecorder interface { + IncrementCounter(name string, value float64) + }, +) error { + return submitReservationActionProof( + transactionHash, + requiredConfirmations, + reservationKey, + requestNonce, + btcChain, + spvChain, + spvProofAssembler, + metricsRecorder, + ProofTypeReservationAcceptance, + "reservation_acceptance_proof", + tbtc.ReservationActionTypeAcceptance, + "acceptance", + ) +} diff --git a/pkg/maintainer/spv/reservation_acceptance_proof_test.go b/pkg/maintainer/spv/reservation_acceptance_proof_test.go new file mode 100644 index 0000000000..fcb2be5ed5 --- /dev/null +++ b/pkg/maintainer/spv/reservation_acceptance_proof_test.go @@ -0,0 +1,239 @@ +package spv + +import ( + "bytes" + "fmt" + "math/big" + "testing" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/tbtc" +) + +// TestSubmitReservationAcceptanceProof verifies that +// submitReservationAcceptanceProof correctly parses a 1-input-1-output +// reservation acceptance (anchor) transaction, looks up the matching +// reservation action generation, and submits the SPV proof to the chain. +// It also covers the failure paths for missing action, mismatched action +// type, and wrong target wallet. +func TestSubmitReservationAcceptanceProof(t *testing.T) { + requiredConfirmations := uint(6) + + btcChain := newLocalBitcoinChain() + spvChain := newLocalChain() + + // Funding transaction that the anchor transaction spends (the reserved + // deposit's own UTXO). + fundingTx := &bitcoin.Transaction{ + Version: 1, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 600000, + PublicKeyScript: []byte{}, + }}, + } + if err := btcChain.BroadcastTransaction(fundingTx); err != nil { + t.Fatal(err) + } + fundingTxHash := fundingTx.Hash() + + // Anchor transaction: 1 input spending the deposit's funding UTXO, 1 + // output paying the accepting wallet. + walletPKH := [20]byte{0x92, 0xa6, 0xec, 0x88, 0x9a, 0x8f, 0xa3, 0x4f, 0x73, 0x1e, 0x63, 0x9e, 0xde, 0xde, 0x4c, 0x75, 0xe1, 0x84, 0x30, 0x7c} + walletScript, err := bitcoin.PayToWitnessPublicKeyHash(walletPKH) + if err != nil { + t.Fatal(err) + } + + anchorTx := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTxHash, + OutputIndex: 0, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 590000, + PublicKeyScript: walletScript, + }}, + } + if err := btcChain.BroadcastTransaction(anchorTx); err != nil { + t.Fatal(err) + } + + proof := &bitcoin.SpvProof{ + MerkleProof: []byte{0x01}, + TxIndexInBlock: 2, + BitcoinHeaders: []byte{0x03}, + } + + mockSpvProofAssembler := func( + hash bitcoin.Hash, + confirmations uint, + btcChain bitcoin.Chain, + ) (*bitcoin.Transaction, *bitcoin.SpvProof, error) { + if hash == anchorTx.Hash() && confirmations == requiredConfirmations { + return anchorTx, proof, nil + } + return nil, nil, fmt.Errorf("unexpected proof assembly request") + } + + reservationKey := big.NewInt(43) + requestNonce := uint64(1) + + spvChain.setReservationAction(reservationKey, requestNonce, &tbtc.ReservationAction{ + ActionType: tbtc.ReservationActionTypeAcceptance, + State: tbtc.ReservationActionStatePending, + TargetWalletPublicKeyHash: walletPKH, + }) + + spvChain.submitReservationProofHook = func( + proofType uint8, + txInfo *tbtc.BitcoinTxInfo, + txProof *tbtc.BitcoinTxProof, + mainUtxo *tbtc.BitcoinTxUTXO, + rk *big.Int, + rn uint64, + ) error { + if proofType != ProofTypeReservationAcceptance { + t.Errorf("unexpected proof type: got %d, want %d", proofType, ProofTypeReservationAcceptance) + } + if rk == nil || rk.Cmp(reservationKey) != 0 { + t.Errorf("unexpected reservation key: got %v, want %v", rk, reservationKey) + } + if rn != requestNonce { + t.Errorf("unexpected request nonce: got %d, want %d", rn, requestNonce) + } + if mainUtxo == nil { + t.Fatal("mainUtxo must not be nil") + } + if mainUtxo.TxOutputValue != 600000 { + t.Errorf("unexpected UTXO value: got %d, want %d", mainUtxo.TxOutputValue, 600000) + } + if txInfo == nil { + t.Fatal("txInfo must not be nil") + } + if !bytes.Equal(txProof.MerkleProof, proof.MerkleProof) { + t.Errorf("unexpected merkle proof") + } + return nil + } + + metricsRecorder := &mockMetricsRecorder{counts: make(map[string]float64)} + if err := submitReservationAcceptanceProof( + anchorTx.Hash(), + requiredConfirmations, + reservationKey, + requestNonce, + btcChain, + spvChain, + mockSpvProofAssembler, + metricsRecorder, + ); err != nil { + t.Fatal(err) + } + // Check metrics. + if count := metricsRecorder.counts["reservation_acceptance_proof_submissions_total"]; count != 1 { + t.Errorf("unexpected metrics count: got %f, want 1", count) + } + + // Negative path: nil reservationKey. + if err := submitReservationAcceptanceProof( + anchorTx.Hash(), + requiredConfirmations, + nil, + requestNonce, + btcChain, + spvChain, + mockSpvProofAssembler, + nil, + ); err == nil { + t.Fatal("expected error for nil reservation key") + } + + // Negative path: zero requestNonce. + if err := submitReservationAcceptanceProof( + anchorTx.Hash(), + requiredConfirmations, + reservationKey, + 0, + btcChain, + spvChain, + mockSpvProofAssembler, + nil, + ); err == nil { + t.Fatal("expected error for zero request nonce") + } + + // Negative path: action generation is not Pending. + spvChain.setReservationAction(reservationKey, requestNonce, &tbtc.ReservationAction{ + ActionType: tbtc.ReservationActionTypeAcceptance, + State: tbtc.ReservationActionStateSettled, + TargetWalletPublicKeyHash: walletPKH, + }) + if err := submitReservationAcceptanceProof( + anchorTx.Hash(), + requiredConfirmations, + reservationKey, + requestNonce, + btcChain, + spvChain, + mockSpvProofAssembler, + nil, + ); err == nil { + t.Fatal("expected error for settled action generation") + } + + // Negative path: action generation is the wrong type. + spvChain.setReservationAction(reservationKey, requestNonce, &tbtc.ReservationAction{ + ActionType: tbtc.ReservationActionTypeReanchor, + State: tbtc.ReservationActionStatePending, + TargetWalletPublicKeyHash: walletPKH, + }) + if err := submitReservationAcceptanceProof( + anchorTx.Hash(), + requiredConfirmations, + reservationKey, + requestNonce, + btcChain, + spvChain, + mockSpvProofAssembler, + nil, + ); err == nil { + t.Fatal("expected error for wrong action type") + } + + // Negative path: target wallet public key hash mismatch - the anchor + // output pays a different wallet than the action authorized. + spvChain.setReservationAction(reservationKey, requestNonce, &tbtc.ReservationAction{ + ActionType: tbtc.ReservationActionTypeAcceptance, + State: tbtc.ReservationActionStatePending, + TargetWalletPublicKeyHash: [20]byte{0xff}, + }) + if err := submitReservationAcceptanceProof( + anchorTx.Hash(), + requiredConfirmations, + reservationKey, + requestNonce, + btcChain, + spvChain, + mockSpvProofAssembler, + nil, + ); err == nil { + t.Fatal("expected error for target wallet public key hash mismatch") + } + + // Negative path: zero requiredConfirmations. + if err := submitReservationAcceptanceProof( + anchorTx.Hash(), + 0, + reservationKey, + requestNonce, + btcChain, + spvChain, + mockSpvProofAssembler, + nil, + ); err == nil { + t.Fatal("expected error for zero required confirmations") + } +} diff --git a/pkg/maintainer/spv/reservation_action_timeout_watch.go b/pkg/maintainer/spv/reservation_action_timeout_watch.go new file mode 100644 index 0000000000..e9255a11e2 --- /dev/null +++ b/pkg/maintainer/spv/reservation_action_timeout_watch.go @@ -0,0 +1,502 @@ +package spv + +import ( + "context" + "fmt" + "math/big" + "time" + + "github.com/keep-network/keep-core/pkg/tbtc" +) + +// reservationActionTimeoutLookBackBlocks bounds the pending-action-request event +// scan performed on the very first pass, before an incremental cursor +// exists. Mirrors reservationProofLookBackBlocks in reservation_proof_loop.go: +// 30 days at 12s/block. +const reservationActionTimeoutLookBackBlocks = uint64(216000) + +// maxActionTimeoutLoadRetries is the maximum number of consecutive +// GetReservationAction poll-pass failures a tracked pendingAction entry +// may accumulate before it is evicted from pendingActions. Mirrors the +// maxReservationActionLoadRetries convention in reservation_proof_loop.go, +// kept as a separate local constant rather than a shared one since the two +// loops track independent pending-action sets. +const maxActionTimeoutLoadRetries = 3 + +// actionTimeoutRenotifyInterval bounds how often a still-Pending action +// generation is re-offered to NotifyReservationActionTimeout once one +// attempt has already been made. Mirrors DefaultIdleBackOffTime's 10 +// minute convention in config.go; long enough to avoid resubmitting on +// every one-minute poll tick while a normal transaction confirms, short +// enough that a dropped or reverted notification is retried well within +// an action's timeout-to-slashing window rather than silently stalling +// for the process lifetime. +const actionTimeoutRenotifyInterval = 10 * time.Minute + +// ReservationActionTimeoutWatcher observes the reservation action set and +// notifies the Bridge when a pending action's on-chain deadline has elapsed +// without an SPV proof being submitted. +// +// The Bridge uses a per-action timeout window (snapshotted in the +// ReservationAction record at nonce creation time) to bound the lag between +// action creation and SPV proof submission. When the deadline passes without +// a proof, the SPV maintainer is no longer eligible to settle the action +// and the Bridge must be told to update the action to ReservationActionStateTimedOut. +// This triggers Bridge-side sweeping (e.g. fee slashing in m2+ and the +// fallback to owner late-settlement) and ensures the state machine can move +// forward. +// +// In m1 the operator-side penalty is a no-op; NotifyReservationActionTimeout +// is still called with the wallet member IDs as required by the Bridge +// function signature so that m2+ integrations only need to add the slashing +// logic without changing the call shape. +// +// The members resolver maps a wallet public key hash to the operator IDs +// composing that wallet's signing group. In production, node.ResolveWalletMembers +// resolves members for wallets the local operator co-signs and returns an error +// ("wallet not found") for other on-chain wallets. The watcher treats resolver +// errors for non-member wallets as an expected non-member condition and skips +// them silently at Debug log level, so the watcher only meaningfully monitors +// reservation action timeouts for wallets the local operator co-signs. +type ReservationActionTimeoutWatcher struct { + spvChain Chain + // nowFn returns the current UNIX timestamp the watcher treats as "now" + // for `now > timeoutAt` comparisons. Tests override it to drive the + // deadline forward; production wires it to time.Now in UTC. + nowFn func() uint32 + // interval is how often the background poll loop re-checks pending + // actions. Production always drives the watcher through Run, started + // as a background goroutine by WireReservationWatchers with the fixed + // one-minute DefaultReservationActionTimeoutPollInterval; there is no + // other production integration path. interval must be positive + // whenever Run is used; tests that call CheckReservationActionTimeouts + // directly, without starting Run, may leave it zero. + interval time.Duration + // membersResolver turns a wallet public key hash into the operator IDs + // the Bridge expects for the slashing argument. The resolver is + // injected to keep the watcher independent of the chain interface used + // to look up operator addresses (the SPV maintainer chain interface + // does not expose GetOperatorID today). + membersResolver tbtc.WalletMembersResolver + + acceptanceLastScannedBlock uint64 + reanchorLastScannedBlock uint64 + + // pendingActions tracks still-pending reservation actions discovered from + // acceptance and re-anchor request events across successive poll passes. + pendingActions map[string]*pendingAction +} + +type pendingAction struct { + reservationKey *big.Int + requestNonce uint64 + // loadFailures counts consecutive GetReservationAction failures for + // this entry across successive poll passes. Reset to 0 on a + // successful load; once it reaches maxActionTimeoutLoadRetries the + // entry is evicted so a permanently unreadable action does not + // accumulate forever in pendingActions. + loadFailures int + // notifiedAt is the UNIX timestamp of the last attempted (and locally + // reported successful) NotifyReservationActionTimeout call for this + // action generation, or 0 if none has been attempted yet. It is NOT + // treated as proof the notification landed: a submitted-but-dropped + // or reverted transaction still leaves the on-chain action Pending, + // so pollPendingActions re-attempts the notification once + // actionTimeoutRenotifyInterval has elapsed since notifiedAt rather + // than treating one local send as permanent evidence of success. + // Eviction still happens only once action.State actually leaves + // Pending, which is the real on-chain evidence the notification took + // effect. + notifiedAt uint32 +} + +// actionEventKey identifies one reservation action generation. +func actionEventKey(reservationKey *big.Int, requestNonce uint64) string { + return fmt.Sprintf("%s#%d", reservationKey.String(), requestNonce) +} + +// NewReservationActionTimeoutWatcher constructs a watcher bound to the +// given chain, members resolver, and poll interval. +// +// The members resolver is mandatory: the watcher will refuse to operate +// without it because emitting NotifyReservationActionTimeout with a nil +// or empty member slice would be ill-formed on the Bridge side. +// +// pollInterval must be positive whenever Run is used to drive the +// background loop. Production always uses Run, via +// WireReservationWatchers; pollInterval only needs to be non-zero for +// that path, not for tests that drive the watcher directly through +// CheckReservationActionTimeouts. +func NewReservationActionTimeoutWatcher( + spvChain Chain, + membersResolver tbtc.WalletMembersResolver, + pollInterval time.Duration, +) *ReservationActionTimeoutWatcher { + return &ReservationActionTimeoutWatcher{ + spvChain: spvChain, + nowFn: defaultActionTimeoutNowFn, + interval: pollInterval, + membersResolver: membersResolver, + pendingActions: make(map[string]*pendingAction), + } +} + +// defaultActionTimeoutNowFn returns time.Now() as a uint32 UNIX timestamp. +// Kept separate from the struct to allow tests to swap it deterministically. +func defaultActionTimeoutNowFn() uint32 { + return uint32(time.Now().Unix()) +} + +// nextScanRange calculates the start and current block numbers for the next +// event scan. On the first scan (lastScannedBlock == 0), the scan window is +// bounded by reservationActionTimeoutLookBackBlocks. On subsequent scans, it +// resumes from lastScannedBlock + 1. +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() + if err != nil { + return 0, 0, fmt.Errorf("failed to get current block: [%v]", err) + } + + if lastScannedBlock == 0 { + if currentBlock > reservationActionTimeoutLookBackBlocks { + startBlock = currentBlock - reservationActionTimeoutLookBackBlocks + } else { + startBlock = 0 + } + } else { + startBlock = lastScannedBlock + 1 + } + + return startBlock, currentBlock, nil +} + +// Run starts the background poll loop. It returns when ctx is done or when +// a fatal configuration error is detected. +// +// Each iteration discovers new reservation acceptance and re-anchor action +// request events incrementally, updates the tracked pending actions set, +// removes actions that are no longer pending, and calls +// CheckReservationActionTimeouts on any overdue pending action. +func (ratw *ReservationActionTimeoutWatcher) Run(ctx context.Context) error { + if ratw.membersResolver == nil { + return fmt.Errorf( + "action-timeout watcher requires a non-nil members resolver", + ) + } + if ratw.interval <= 0 { + return fmt.Errorf( + "action-timeout watcher requires a positive poll interval", + ) + } + + ticker := time.NewTicker(ratw.interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + } + + if err := ratw.pollPendingActions(); err != nil { + logger.Errorf("action-timeout watcher poll failed: [%v]", err) + } + } +} + +// pollPendingActions scans for newly requested reservation actions, updates the +// pendingActions map, evicts actions that are no longer pending, and checks +// overdue actions for timeout. +func (ratw *ReservationActionTimeoutWatcher) pollPendingActions() error { + // 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, + }, + ) + 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, + }, + ) + 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() + + // 3. Re-check each tracked action and remove entries that are no longer + // pending. Each tracked action costs one serial GetReservationAction RPC + // per poll tick; no multicall-style batching helper exists elsewhere in + // this codebase for this chain-read pattern (checked pkg/chain), so + // per-tick RPC count scales linearly with the number of tracked actions. + for key, item := range ratw.pendingActions { + action, err := ratw.spvChain.GetReservationAction( + item.reservationKey, + item.requestNonce, + ) + if err != nil { + item.loadFailures++ + logger.Errorf( + "failed to load reservation action [%v]/%d: [%v]", + item.reservationKey, + item.requestNonce, + err, + ) + if item.loadFailures >= maxActionTimeoutLoadRetries { + logger.Errorf( + "evicting reservation action [%v]/%d from tracking "+ + "after %d consecutive load failures", + item.reservationKey, + item.requestNonce, + item.loadFailures, + ) + delete(ratw.pendingActions, key) + } + continue + } + item.loadFailures = 0 + + if action.State != tbtc.ReservationActionStatePending { + delete(ratw.pendingActions, key) + continue + } + + if item.notifiedAt != 0 && + now-item.notifiedAt < uint32(actionTimeoutRenotifyInterval.Seconds()) { + // A timeout notification was attempted recently for this + // action generation while it remains Pending; give it time + // to land before resubmitting NotifyReservationActionTimeout + // on every poll tick. If the prior attempt's transaction was + // dropped or reverted, the action is still Pending once + // actionTimeoutRenotifyInterval elapses and this branch is + // skipped, so the next tick retries below. + continue + } + + if now > action.TimeoutAt { + if err := ratw.checkReservationActionTimeout( + item.reservationKey, + now, + action, + ); err != nil { + logger.Errorf( + "action-timeout watcher failed to check reservation [%v]: [%v]", + item.reservationKey, + err, + ) + } else { + item.notifiedAt = now + } + } + } + + return nil +} + +// CheckReservationActionTimeouts inspects the current action generation of a +// single reservation and notifies the Bridge if it is Pending and its +// TimeoutAt has elapsed. The caller controls the iteration; the watcher +// does not background-loop on its own (see Run for the poll-driven caller). +// +// Parameters: +// +// - reservationKey: the reservation identifier used by the Bridge's +// ReservationRouter. +// - now: a UNIX timestamp used to compare against TimeoutAt. Tests pass +// an explicit value; production passes time.Now().Unix() cast to uint32. +// +// The function resolves the custodying wallet, looks up the operator member +// IDs through the injected resolver, then inspects only the action +// generation at reservation.RequestNonce. By the Bridge invariant, only the +// most-recent action generation can be Pending - older nonces have already +// settled, timed out, or been superseded - so a single lookup suffices; no +// walk from nonce 0 is needed. A RequestNonce of 0 means no action +// generation has ever been requested against the reservation, so there is +// nothing to check. +func (ratw *ReservationActionTimeoutWatcher) CheckReservationActionTimeouts( + reservationKey *big.Int, + now uint32, +) error { + return ratw.checkReservationActionTimeout(reservationKey, now, nil) +} + +// checkReservationActionTimeout is the shared implementation behind +// CheckReservationActionTimeouts. When preloadedAction is non-nil, it is +// used in place of a second GetReservationAction RPC: pollPendingActions +// already loads the action for (reservationKey, requestNonce) once per +// poll pass to decide whether the entry is overdue, and by the Bridge +// invariant documented above that loaded action is the same one +// reservation.RequestNonce resolves to whenever its state is still +// Pending, so re-fetching it here would be a redundant RPC for the exact +// same value. +func (ratw *ReservationActionTimeoutWatcher) checkReservationActionTimeout( + reservationKey *big.Int, + now uint32, + preloadedAction *tbtc.ReservationAction, +) error { + if ratw.membersResolver == nil { + return fmt.Errorf( + "action-timeout watcher requires a non-nil members resolver", + ) + } + if reservationKey == nil { + return fmt.Errorf("reservation key must not be nil") + } + + reservation, err := ratw.spvChain.GetReservation(reservationKey) + if err != nil { + return fmt.Errorf( + "failed to load reservation [%v]: [%v]", + reservationKey, + err, + ) + } + + if reservation.RequestNonce == 0 { + // No action generation has ever been requested; nothing pending. + return nil + } + + walletPublicKeyHash := reservation.WalletPublicKeyHash + if walletPublicKeyHash == ([20]byte{}) { + logger.Debugf("reservation [%v] has no wallet; skipping", reservationKey) + return nil + } + + nonce := reservation.RequestNonce + + action := preloadedAction + if action == nil { + action, err = ratw.spvChain.GetReservationAction(reservationKey, nonce) + if err != nil { + return fmt.Errorf( + "failed to load action for reservation [%v] at nonce %d: [%v]", + reservationKey, + nonce, + err, + ) + } + } + + if action.State != tbtc.ReservationActionStatePending { + logger.Debugf( + "reservation [%v] action nonce %d state=%s; not pending, "+ + "nothing to time out", + reservationKey, + nonce, + action.State, + ) + return nil + } + + if now <= action.TimeoutAt { + logger.Debugf( + "reservation [%v] action nonce %d timeout at [%d] "+ + "not yet reached (now=%d); skipping", + reservationKey, + nonce, + action.TimeoutAt, + now, + ) + return nil + } + + memberIDs, err := ratw.membersResolver.ResolveWalletMembers(walletPublicKeyHash) + if err != nil { + logger.Debugf( + "operator is not a member of wallet [0x%x]; skipping action timeout check for reservation [%v]: [%v]", + walletPublicKeyHash, + reservationKey, + err, + ) + return nil + } + + if len(memberIDs) == 0 { + logger.Debugf( + "wallet [0x%x] members resolver returned an empty set; "+ + "skipping action timeout check for reservation [%v]", + walletPublicKeyHash, + reservationKey, + ) + return nil + } + + if err := ratw.spvChain.NotifyReservationActionTimeout( + reservationKey, + memberIDs, + ); err != nil { + return fmt.Errorf( + "failed to notify action timeout for "+ + "reservation [%v] nonce %d: [%v]", + reservationKey, + nonce, + err, + ) + } + + logger.Infof( + "notified action timeout for reservation [%v] nonce %d "+ + "(timeout=%d, members=%d)", + reservationKey, + nonce, + action.TimeoutAt, + len(memberIDs), + ) + + return nil +} diff --git a/pkg/maintainer/spv/reservation_action_timeout_watch_test.go b/pkg/maintainer/spv/reservation_action_timeout_watch_test.go new file mode 100644 index 0000000000..2ca43f7d56 --- /dev/null +++ b/pkg/maintainer/spv/reservation_action_timeout_watch_test.go @@ -0,0 +1,894 @@ +package spv + +import ( + "context" + "errors" + "math/big" + "sync" + "testing" + "time" + + "github.com/keep-network/keep-core/pkg/tbtc" + + "github.com/go-test/deep" +) + +// recordingActionTimeoutMembers is a test double for the +// tbtc.WalletMembersResolver interface. It returns the operator IDs configured +// at construction time and records the wallet PKHs it was asked to resolve. +type recordingActionTimeoutMembers struct { + walletIDs map[[20]byte][]uint32 + calls [][20]byte + errByPKH map[[20]byte]error +} + +func (r *recordingActionTimeoutMembers) ResolveWalletMembers( + walletPublicKeyHash [20]byte, +) ([]uint32, error) { + r.calls = append(r.calls, walletPublicKeyHash) + if err, ok := r.errByPKH[walletPublicKeyHash]; ok { + return nil, err + } + return r.walletIDs[walletPublicKeyHash], nil +} + +// seededReservation installs a reservation and (optionally) a list of +// action generations under spvChain for use in the action-timeout watcher +// tests. Helper reduces per-test noise. actions[0] is stored as generation +// nonce 1, actions[1] as nonce 2, etc., matching the 1-based action +// generation convention (a reservation has no generation 0; the first +// ever-requested action is nonce 1). +func seededReservation( + t *testing.T, + spvChain *localChain, + key *big.Int, + wallet [20]byte, + actions []*tbtc.ReservationAction, + requestNonce uint64, +) { + t.Helper() + spvChain.setReservation(key, &tbtc.Reservation{ + WalletPublicKeyHash: wallet, + RequestNonce: requestNonce, + }) + for i, action := range actions { + spvChain.setReservationAction(key, uint64(i)+1, action) + } +} + +func TestReservationActionTimeoutWatcher_NotifiesTimedOutPendingAction(t *testing.T) { + spvChain := newLocalChain() + + wallet := walletPKH() + key := reservationKey(0xC001) + members := []uint32{11, 22, 33} + + resolver := &recordingActionTimeoutMembers{ + walletIDs: map[[20]byte][]uint32{wallet: members}, + } + seededReservation( + t, + spvChain, + key, + wallet, + []*tbtc.ReservationAction{ + { + State: tbtc.ReservationActionStatePending, + TimeoutAt: 100, + }, + }, + 1, + ) + + watcher := NewReservationActionTimeoutWatcher(spvChain, resolver, 0) + if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + calls := spvChain.getSubmittedReservationActionTimeouts() + if len(calls) != 1 { + t.Fatalf("expected one timeout notification, got %d", len(calls)) + } + if diff := deep.Equal(key, calls[0].reservationKey); diff != nil { + t.Errorf("unexpected notified key: %v", diff) + } + if diff := deep.Equal(members, calls[0].walletMembersIDs); diff != nil { + t.Errorf("unexpected notified members: %v", diff) + } + // The resolver must be consulted exactly once per Check call, not per + // nonce, because the Bridge requires the member IDs to be consistent + // across all notifications emitted in response to a single reservation. + if len(resolver.calls) != 1 { + t.Errorf("expected resolver to be called once, got %d", len(resolver.calls)) + } +} + +func TestReservationActionTimeoutWatcher_DoesNotNotifyBeforeTimeout(t *testing.T) { + spvChain := newLocalChain() + + wallet := walletPKH() + key := reservationKey(0xC002) + + resolver := &recordingActionTimeoutMembers{ + walletIDs: map[[20]byte][]uint32{wallet: {1, 2}}, + } + seededReservation( + t, + spvChain, + key, + wallet, + []*tbtc.ReservationAction{ + { + State: tbtc.ReservationActionStatePending, + TimeoutAt: 10_000, + }, + }, + 1, + ) + + watcher := NewReservationActionTimeoutWatcher(spvChain, resolver, 0) + if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if calls := spvChain.getSubmittedReservationActionTimeouts(); len(calls) != 0 { + t.Fatalf( + "action not yet timed out; expected zero notifications, got %d", + len(calls), + ) + } +} + +func TestReservationActionTimeoutWatcher_IgnoresSettledOlderGeneration(t *testing.T) { + spvChain := newLocalChain() + + wallet := walletPKH() + key := reservationKey(0xC003) + + resolver := &recordingActionTimeoutMembers{ + walletIDs: map[[20]byte][]uint32{wallet: {1, 2}}, + } + // Generation 1 (an old re-anchor, say) is already Settled; generation 2 + // is the current pending generation and is past its deadline. The + // watcher must inspect only the current generation (RequestNonce = 2) + // and notify for it - this is the fix for the bug where an older + // walk-from-zero implementation stopped at the first non-pending + // generation and never reached the real timed-out one. + seededReservation( + t, + spvChain, + key, + wallet, + []*tbtc.ReservationAction{ + { + State: tbtc.ReservationActionStateSettled, + TimeoutAt: 100, + }, + { + State: tbtc.ReservationActionStatePending, + TimeoutAt: 100, + }, + }, + 2, + ) + + watcher := NewReservationActionTimeoutWatcher(spvChain, resolver, 0) + if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if calls := spvChain.getSubmittedReservationActionTimeouts(); len(calls) != 1 { + t.Fatalf( + "current generation is pending and past deadline; expected one "+ + "notification, got %d", + len(calls), + ) + } +} + +func TestReservationActionTimeoutWatcher_NotifiesCurrentGenerationOnly(t *testing.T) { + spvChain := newLocalChain() + + wallet := walletPKH() + key := reservationKey(0xC004) + + resolver := &recordingActionTimeoutMembers{ + walletIDs: map[[20]byte][]uint32{wallet: {7, 8, 9}}, + } + // Generation 1 is still pending and NOT past its deadline; generation 2 + // is the current pending generation and IS past its deadline. Only + // generation 2 (RequestNonce) is ever inspected, so exactly one + // notification fires regardless of generation 1's state. + seededReservation( + t, + spvChain, + key, + wallet, + []*tbtc.ReservationAction{ + { + State: tbtc.ReservationActionStatePending, + TimeoutAt: 10_000, + }, + { + State: tbtc.ReservationActionStatePending, + TimeoutAt: 100, + }, + }, + 2, + ) + + watcher := NewReservationActionTimeoutWatcher(spvChain, resolver, 0) + if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if calls := spvChain.getSubmittedReservationActionTimeouts(); len(calls) != 1 { + t.Fatalf( + "expected exactly one notification for the current generation, got %d", + len(calls), + ) + } +} + +func TestReservationActionTimeoutWatcher_SkipsReservationWithoutWallet(t *testing.T) { + spvChain := newLocalChain() + + key := reservationKey(0xC005) + // No wallet PKH assigned. + + resolver := &recordingActionTimeoutMembers{} + spvChain.setReservation(key, &tbtc.Reservation{ + WalletPublicKeyHash: [20]byte{}, + RequestNonce: 0, + }) + + watcher := NewReservationActionTimeoutWatcher(spvChain, resolver, 0) + if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if calls := spvChain.getSubmittedReservationActionTimeouts(); len(calls) != 0 { + t.Fatalf("zero-wallet reservation must skip, got %d notifications", len(calls)) + } + if len(resolver.calls) != 0 { + t.Fatalf("resolver must not be called for zero-wallet reservation, got %d calls", len(resolver.calls)) + } +} + +func TestReservationActionTimeoutWatcher_MembersResolverError(t *testing.T) { + spvChain := newLocalChain() + + wallet := walletPKH() + key := reservationKey(0xC006) + + // In production, node.ResolveWalletMembers errors ("wallet not found") for + // wallets the local operator is not a signing member of. The watcher must + // treat this as an expected non-membership condition and skip cleanly without + // returning an error or notifying. + resolver := &recordingActionTimeoutMembers{ + errByPKH: map[[20]byte]error{wallet: errors.New("wallet not found")}, + } + seededReservation( + t, + spvChain, + key, + wallet, + []*tbtc.ReservationAction{ + { + State: tbtc.ReservationActionStatePending, + TimeoutAt: 100, + }, + }, + 1, + ) + + watcher := NewReservationActionTimeoutWatcher(spvChain, resolver, 0) + if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { + t.Fatalf("expected nil error on resolver non-member error, got: %v", err) + } + if calls := spvChain.getSubmittedReservationActionTimeouts(); len(calls) != 0 { + t.Fatalf("no notifications should fire on resolver error, got %d", len(calls)) + } +} + +func TestReservationActionTimeoutWatcher_MembersResolverEmpty(t *testing.T) { + spvChain := newLocalChain() + + wallet := walletPKH() + key := reservationKey(0xC007) + + // An empty member set must also skip cleanly without emitting a notification. + resolver := &recordingActionTimeoutMembers{ + walletIDs: map[[20]byte][]uint32{wallet: {}}, + } + seededReservation( + t, + spvChain, + key, + wallet, + []*tbtc.ReservationAction{ + { + State: tbtc.ReservationActionStatePending, + TimeoutAt: 100, + }, + }, + 1, + ) + + watcher := NewReservationActionTimeoutWatcher(spvChain, resolver, 0) + if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { + t.Fatalf("expected nil error on empty member set, got: %v", err) + } + if calls := spvChain.getSubmittedReservationActionTimeouts(); len(calls) != 0 { + t.Fatalf("no notifications should fire on empty member set, got %d", len(calls)) + } +} + +func TestReservationActionTimeoutWatcher_NilResolverError(t *testing.T) { + spvChain := newLocalChain() + + watcher := NewReservationActionTimeoutWatcher(spvChain, nil, 0) + if err := watcher.CheckReservationActionTimeouts(reservationKey(0xC008), 5_000); err == nil { + t.Fatal("expected error for nil resolver, got nil") + } +} + +func TestReservationActionTimeoutWatcher_NilKeyError(t *testing.T) { + spvChain := newLocalChain() + resolver := &recordingActionTimeoutMembers{} + + watcher := NewReservationActionTimeoutWatcher(spvChain, resolver, 0) + if err := watcher.CheckReservationActionTimeouts(nil, 5_000); err == nil { + t.Fatal("expected error for nil reservation key, got nil") + } +} + +func TestReservationActionTimeoutWatcher_SkipsWalletZeroBranch(t *testing.T) { + // Isolates the wallet-zero skip branch from the RequestNonce == 0 skip + // branch: RequestNonce is nonzero (a real action generation exists) but + // WalletPublicKeyHash is zero, so the reservation exists yet has no + // wallet assigned. This must skip via the wallet-zero check, not be + // short-circuited by the (separate) RequestNonce == 0 check that an + // earlier version of this test file conflated by zeroing both fields + // together. + spvChain := newLocalChain() + resolver := &recordingActionTimeoutMembers{} + + key := reservationKey(0xC00B) + spvChain.setReservation(key, &tbtc.Reservation{ + WalletPublicKeyHash: [20]byte{}, + RequestNonce: 1, + }) + + watcher := NewReservationActionTimeoutWatcher(spvChain, resolver, 0) + if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if calls := spvChain.getSubmittedReservationActionTimeouts(); len(calls) != 0 { + t.Fatalf("zero-wallet reservation must skip, got %d notifications", len(calls)) + } + if len(resolver.calls) != 0 { + t.Fatalf("resolver must not be called for zero-wallet reservation, got %d calls", len(resolver.calls)) + } +} + +func TestReservationActionTimeoutWatcher_NotifierErrorPropagates(t *testing.T) { + spvChain := newLocalChain() + errFromNotifier := errors.New("downstream") + + wallet := walletPKH() + key := reservationKey(0xC00A) + + resolver := &recordingActionTimeoutMembers{ + walletIDs: map[[20]byte][]uint32{wallet: {1, 2, 3}}, + } + // The current generation is pending and past its deadline, but the + // Bridge notify call fails. With only one generation ever inspected per + // Check call, the failure must surface as an error from + // CheckReservationActionTimeouts (not be silently swallowed), so a + // poll-loop caller logs and retries on the next tick instead of + // wrongly treating it as settled. + spvChain.notifyReservationActionTimeoutErr = errFromNotifier + seededReservation( + t, + spvChain, + key, + wallet, + []*tbtc.ReservationAction{ + { + State: tbtc.ReservationActionStatePending, + TimeoutAt: 100, + }, + }, + 1, + ) + + watcher := NewReservationActionTimeoutWatcher(spvChain, resolver, 0) + err := watcher.CheckReservationActionTimeouts(key, 5_000) + if err == nil { + t.Fatal("expected the notifier error to propagate, got nil") + } + + if calls := spvChain.getSubmittedReservationActionTimeouts(); len(calls) != 1 { + t.Fatalf("expected exactly one notification attempt, got %d", len(calls)) + } +} + +func TestReservationActionTimeoutWatcher_NextScanRange(t *testing.T) { + spvChain := newLocalChain() + blockCounter := newMockBlockCounter() + spvChain.setBlockCounter(blockCounter) + + resolver := &recordingActionTimeoutMembers{} + watcher := NewReservationActionTimeoutWatcher(spvChain, resolver, time.Minute) + + // 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) - reservationActionTimeoutLookBackBlocks + 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() + blockCounter.SetCurrentBlock(1000) + spvChain.setBlockCounter(blockCounter) + + wallet1 := walletPKH() + members := []uint32{1, 2, 3} + resolver := &recordingActionTimeoutMembers{ + walletIDs: map[[20]byte][]uint32{wallet1: members}, + } + + pollInterval := 10 * time.Millisecond + ratw := NewReservationActionTimeoutWatcher( + spvChain, + resolver, + pollInterval, + ) + ratw.nowFn = func() uint32 { return 500 } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Tick 1: acceptance event for key1 (nonce 1). + key1 := reservationKey(0x1001) + spvChain.addReservationAcceptanceRequestedEvent(&tbtc.ReservationAcceptanceRequestedEvent{ + ReservationKey: key1, + RequestNonce: 1, + WalletPublicKeyHash: wallet1, + BlockNumber: 500, + }) + seededReservation( + t, + spvChain, + key1, + wallet1, + []*tbtc.ReservationAction{ + { + State: tbtc.ReservationActionStatePending, + TimeoutAt: 100, // Timed out (now=500 > 100) + }, + }, + 1, + ) + + errChan := make(chan error, 1) + go func() { + errChan <- ratw.Run(ctx) + }() + + // Wait for tick 1 to process key1. + time.Sleep(50 * time.Millisecond) + + // Verify key1 was notified. + calls := spvChain.getSubmittedReservationActionTimeouts() + if len(calls) != 1 { + t.Fatalf("expected 1 notification after tick 1, got %d", len(calls)) + } + if diff := deep.Equal(key1, calls[0].reservationKey); diff != nil { + t.Errorf("unexpected notified key: %v", diff) + } + + // Tick 2: key1 is now settled (no longer pending), and a reanchor event + // arrives for key2 (nonce 2) at block 1500. + spvChain.setReservationAction(key1, 1, &tbtc.ReservationAction{ + State: tbtc.ReservationActionStateSettled, + TimeoutAt: 100, + }) + + blockCounter.SetCurrentBlock(2000) + key2 := reservationKey(0x1002) + spvChain.addReservationReanchorRequestedEvent(&tbtc.ReservationReanchorRequestedEvent{ + ReservationKey: key2, + RequestNonce: 2, + SourceWalletPublicKeyHash: wallet1, + BlockNumber: 1500, + }) + seededReservation( + t, + spvChain, + key2, + wallet1, + []*tbtc.ReservationAction{ + { + State: tbtc.ReservationActionStateSettled, + TimeoutAt: 100, + }, + { + State: tbtc.ReservationActionStatePending, + TimeoutAt: 200, // Timed out (now=500 > 200) + }, + }, + 2, + ) + + // Wait for tick 2 to process key2 and evict key1. + time.Sleep(50 * time.Millisecond) + + cancel() + if err := <-errChan; err != nil { + t.Errorf("Run returned error: %v", err) + } + + // Total timeout notifications should now be 2 (key1 on tick 1, key2 on tick 2). + calls = spvChain.getSubmittedReservationActionTimeouts() + if len(calls) != 2 { + t.Fatalf("expected 2 notifications after tick 2, got %d", len(calls)) + } + if diff := deep.Equal(key2, calls[1].reservationKey); diff != nil { + t.Errorf("unexpected second notified key: %v", diff) + } + + // key1 should have been evicted from pendingActions because it became Settled. + key1EventKey := actionEventKey(key1, 1) + if _, ok := ratw.pendingActions[key1EventKey]; ok { + t.Errorf("key1 should have been evicted from pendingActions once Settled") + } +} + +// TestReservationActionTimeoutWatcher_RunLoop_DoesNotRenotifyWhilePending +// covers the dedup guarantee TestReservationActionTimeoutWatcher_RunLoop_IncrementalTracking +// does not: it never flips the tracked action's state away from Pending, +// so eviction-on-settlement cannot be what is suppressing repeat +// notifications. Two poll windows elapse (several 10ms ticks each) while +// the action stays Pending and past its deadline; the notifier must still +// show exactly one call, and the entry must still be present in +// pendingActions (not evicted) after both windows. Both assertions depend +// on Finding A's fix: the prior unconditional +// delete-after-successful-check would also happen to leave the call count +// at one, but only because it deletes the entry outright on tick 1 - it +// would fail the "still tracked" assertion below. +func TestReservationActionTimeoutWatcher_RunLoop_DoesNotRenotifyWhilePending(t *testing.T) { + spvChain := newLocalChain() + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(1000) + spvChain.setBlockCounter(blockCounter) + + wallet1 := walletPKH() + members := []uint32{1, 2, 3} + resolver := &recordingActionTimeoutMembers{ + walletIDs: map[[20]byte][]uint32{wallet1: members}, + } + + pollInterval := 10 * time.Millisecond + ratw := NewReservationActionTimeoutWatcher( + spvChain, + resolver, + pollInterval, + ) + ratw.nowFn = func() uint32 { return 500 } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + key1 := reservationKey(0x2001) + spvChain.addReservationAcceptanceRequestedEvent(&tbtc.ReservationAcceptanceRequestedEvent{ + ReservationKey: key1, + RequestNonce: 1, + WalletPublicKeyHash: wallet1, + BlockNumber: 500, + }) + seededReservation( + t, + spvChain, + key1, + wallet1, + []*tbtc.ReservationAction{ + { + State: tbtc.ReservationActionStatePending, + TimeoutAt: 100, // Timed out (now=500 > 100) + }, + }, + 1, + ) + + errChan := make(chan error, 1) + go func() { + errChan <- ratw.Run(ctx) + }() + + // Tick window 1: several poll ticks fire while key1 is Pending and + // overdue. + time.Sleep(50 * time.Millisecond) + + calls := spvChain.getSubmittedReservationActionTimeouts() + if len(calls) != 1 { + t.Fatalf("expected 1 notification after tick window 1, got %d", len(calls)) + } + if diff := deep.Equal(key1, calls[0].reservationKey); diff != nil { + t.Errorf("unexpected notified key: %v", diff) + } + + key1EventKey := actionEventKey(key1, 1) + item, ok := ratw.pendingActions[key1EventKey] + if !ok { + t.Fatalf("key1 should remain tracked in pendingActions while still Pending") + } + if item.notifiedAt == 0 { + t.Errorf("expected key1's pendingActions entry to record a notifiedAt timestamp") + } + + // Tick window 2: key1's action generation is left untouched - still + // Pending, still past TimeoutAt. Several more poll ticks fire. + time.Sleep(50 * time.Millisecond) + + cancel() + if err := <-errChan; err != nil { + t.Errorf("Run returned error: %v", err) + } + + calls = spvChain.getSubmittedReservationActionTimeouts() + if len(calls) != 1 { + t.Fatalf( + "expected notifier to still show exactly 1 call for key1 after "+ + "tick window 2 (not 2), got %d", + len(calls), + ) + } + + if _, ok := ratw.pendingActions[key1EventKey]; !ok { + t.Errorf( + "key1 should still be tracked in pendingActions after tick " + + "window 2: it never left Pending on-chain, so only " + + "state-driven eviction - not a delete-on-notify-success - " + + "may remove it", + ) + } +} + +// TestReservationActionTimeoutWatcher_RunLoop_RenotifiesAfterBackoffWindow +// proves the retry path the notifiedAt/actionTimeoutRenotifyInterval +// mechanism exists for: if the first NotifyReservationActionTimeout +// transaction is dropped or reverted, the action stays Pending on-chain +// forever, and the watcher must eventually try again rather than leaving +// item.notifiedAt as permanent (but false) evidence of success. This +// drives nowFn forward past actionTimeoutRenotifyInterval between two +// poll ticks and asserts a second notification call is submitted. +func TestReservationActionTimeoutWatcher_RunLoop_RenotifiesAfterBackoffWindow(t *testing.T) { + spvChain := newLocalChain() + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(1000) + spvChain.setBlockCounter(blockCounter) + + wallet1 := walletPKH() + members := []uint32{1, 2, 3} + resolver := &recordingActionTimeoutMembers{ + walletIDs: map[[20]byte][]uint32{wallet1: members}, + } + + pollInterval := 10 * time.Millisecond + ratw := NewReservationActionTimeoutWatcher( + spvChain, + resolver, + pollInterval, + ) + + var currentNow uint32 = 500 + var nowMutex sync.Mutex + ratw.nowFn = func() uint32 { + nowMutex.Lock() + defer nowMutex.Unlock() + return currentNow + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + key1 := reservationKey(0x2101) + spvChain.addReservationAcceptanceRequestedEvent(&tbtc.ReservationAcceptanceRequestedEvent{ + ReservationKey: key1, + RequestNonce: 1, + WalletPublicKeyHash: wallet1, + BlockNumber: 500, + }) + // TimeoutAt stays fixed and far in the past relative to every "now" + // value used below, so the action is overdue for the whole test. + seededReservation( + t, + spvChain, + key1, + wallet1, + []*tbtc.ReservationAction{ + { + State: tbtc.ReservationActionStatePending, + TimeoutAt: 100, + }, + }, + 1, + ) + + errChan := make(chan error, 1) + go func() { + errChan <- ratw.Run(ctx) + }() + + // First window: exactly one notification while notifiedAt is 0. + time.Sleep(50 * time.Millisecond) + if calls := spvChain.getSubmittedReservationActionTimeouts(); len(calls) != 1 { + t.Fatalf("expected 1 notification before the backoff window, got %d", len(calls)) + } + + // Advance "now" past actionTimeoutRenotifyInterval. The on-chain + // action is left untouched (still Pending, still overdue) - exactly + // the dropped/reverted-notification scenario this mechanism exists + // to recover from. + nowMutex.Lock() + currentNow = 500 + uint32(actionTimeoutRenotifyInterval.Seconds()) + 1 + nowMutex.Unlock() + + // Second window: the backoff has elapsed, so a retry notification + // must be submitted. + time.Sleep(50 * time.Millisecond) + + cancel() + if err := <-errChan; err != nil { + t.Errorf("Run returned error: %v", err) + } + + calls := spvChain.getSubmittedReservationActionTimeouts() + if len(calls) != 2 { + t.Fatalf( + "expected a retry notification after the backoff window "+ + "elapsed (2 total calls), got %d", + len(calls), + ) + } + for _, call := range calls { + if diff := deep.Equal(key1, call.reservationKey); diff != nil { + t.Errorf("unexpected notified key: %v", diff) + } + } +} + +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() + members := []uint32{1, 2, 3} + resolver := &recordingActionTimeoutMembers{ + walletIDs: map[[20]byte][]uint32{wallet1: members}, + } + + pollInterval := 10 * time.Millisecond + ratw := NewReservationActionTimeoutWatcher( + spvChain, + resolver, + pollInterval, + ) + ratw.nowFn = func() uint32 { return 500 } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // 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{ + { + State: tbtc.ReservationActionStatePending, + TimeoutAt: 100, + }, + }, + 1, + ) + + // Event 2 is within lookback: block 300,000. + recentKey := reservationKey(0x9002) + spvChain.addReservationAcceptanceRequestedEvent(&tbtc.ReservationAcceptanceRequestedEvent{ + ReservationKey: recentKey, + RequestNonce: 1, + WalletPublicKeyHash: wallet1, + BlockNumber: 300_000, + }) + seededReservation( + t, + spvChain, + recentKey, + wallet1, + []*tbtc.ReservationAction{ + { + State: tbtc.ReservationActionStatePending, + TimeoutAt: 100, + }, + }, + 1, + ) + + errChan := make(chan error, 1) + go func() { + errChan <- ratw.Run(ctx) + }() + + time.Sleep(50 * time.Millisecond) + cancel() + if err := <-errChan; err != nil { + t.Errorf("Run returned error: %v", err) + } + + // 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) + } + + // 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") + } +} diff --git a/pkg/maintainer/spv/reservation_proof_loop.go b/pkg/maintainer/spv/reservation_proof_loop.go new file mode 100644 index 0000000000..d54b74f204 --- /dev/null +++ b/pkg/maintainer/spv/reservation_proof_loop.go @@ -0,0 +1,779 @@ +package spv + +import ( + "bytes" + "context" + "errors" + "fmt" + "math/big" + "time" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/clientinfo" + "github.com/keep-network/keep-core/pkg/maintainer/btcdiff" + "github.com/keep-network/keep-core/pkg/tbtc" +) + +// errReservationActionNoLongerProvable is returned when a discovered +// transaction's action generation is no longer provable at submission time. +// This is an expected, benign skip rather than a submission failure. +var errReservationActionNoLongerProvable = errors.New( + "reservation action generation is no longer provable", +) + +// reservationProofLookBackBlocks bounds the pending-action-request event +// scan performed on the very first pass, before an incremental cursor +// exists. Mirrors ReservationAcceptanceLookBackBlocks / +// ReservationReanchorLookBackBlocks in pkg/tbtcpg: 30 days at 12s/block. +const reservationProofLookBackBlocks = uint64(216000) + +// verifyReservationActionStillProvable re-fetches the reservation action at +// (reservationKey, requestNonce) immediately before an SPV proof +// submission and confirms it is still the exact pending action generation +// the discovered transaction was found for. +// +// Its purpose is to distinguish an expected, benign "this action generation is +// no longer the exact pending one" outcome (Warn-logged, and skipped so it is +// treated as "never attempted" rather than counted as a failed submission +// attempt by metricsRecorder) from a genuine chain-read error (propagated to +// the caller) or a genuine logic error caught later inside +// submitReservationActionProof (which remains the authoritative +// pre-submission check โ€” it re-fetches the action itself right before +// SubmitReservationProof and is what actually prevents an incorrect or +// misdirected submission). +// +// This function does not, by itself, close any submission-correctness race โ€” +// it only produces cleaner logs and metrics for an expected outcome that +// submitReservationActionProof's own checks already handle safely either way. +func verifyReservationActionStillProvable( + spvChain Chain, + reservationKey *big.Int, + requestNonce uint64, + expectedActionType tbtc.ReservationActionType, + expectedTargetWalletPublicKeyHash [20]byte, +) (bool, error) { + action, err := spvChain.GetReservationAction(reservationKey, requestNonce) + if err != nil { + return false, fmt.Errorf( + "failed to re-verify reservation action [%v]/%d: [%v]", + reservationKey, + requestNonce, + err, + ) + } + + if action.ActionType != expectedActionType || + action.State != tbtc.ReservationActionStatePending { + logger.Warnf( + "skipping reservation proof submission for reservation "+ + "[%v]'s action generation [%d]: action generation is now "+ + "%s/%s, no longer the expected pending %s action", + reservationKey, + requestNonce, + action.ActionType.String(), + action.State.String(), + expectedActionType.String(), + ) + return false, nil + } + + if action.TargetWalletPublicKeyHash != expectedTargetWalletPublicKeyHash { + logger.Warnf( + "skipping reservation proof submission for reservation "+ + "[%v]'s action generation [%d]: target wallet changed "+ + "since discovery", + reservationKey, + requestNonce, + ) + return false, nil + } + + return true, nil +} + +// submitReservationAcceptanceActionProof re-verifies that event's action +// generation is still the exact pending one the discovered transaction was +// found for, then submits its SPV proof. Extracted out of +// proveReservationAcceptanceActions' submit callback so the wallet +// argument passed to verifyReservationActionStillProvable +// (event.WalletPublicKeyHash) can be exercised directly in a unit test, +// without going through Bitcoin transaction discovery. +func submitReservationAcceptanceActionProof( + spvChain Chain, + btcChain bitcoin.Chain, + event *tbtc.ReservationAcceptanceRequestedEvent, + transactionHash bitcoin.Hash, + requiredConfirmations uint, +) error { + stillProvable, err := verifyReservationActionStillProvable( + spvChain, + event.ReservationKey, + event.RequestNonce, + tbtc.ReservationActionTypeAcceptance, + event.WalletPublicKeyHash, + ) + if err != nil { + return err + } + if !stillProvable { + return errReservationActionNoLongerProvable + } + + return SubmitReservationAcceptanceProof( + transactionHash, + requiredConfirmations, + event.ReservationKey, + event.RequestNonce, + btcChain, + spvChain, + ) +} + +// submitReservationReanchorActionProof re-verifies that event's action +// generation is still the exact pending one the discovered transaction was +// found for, then submits its SPV proof. Extracted out of +// proveReservationReanchorActions' submit callback so the +// target-vs-source wallet-hash field selection passed to +// verifyReservationActionStillProvable (event.TargetWalletPublicKeyHash, +// not event.SourceWalletPublicKeyHash โ€” a re-anchor event carries both) +// can be exercised directly in a unit test, without going through Bitcoin +// transaction discovery: this package's local test double can only +// discover a transaction via the source wallet's outputs, which forces +// the two fields to coincide by construction in any end-to-end test and +// so cannot catch a swap between them. +func submitReservationReanchorActionProof( + spvChain Chain, + btcChain bitcoin.Chain, + event *tbtc.ReservationReanchorRequestedEvent, + transactionHash bitcoin.Hash, + requiredConfirmations uint, +) error { + stillProvable, err := verifyReservationActionStillProvable( + spvChain, + event.ReservationKey, + event.RequestNonce, + tbtc.ReservationActionTypeReanchor, + event.TargetWalletPublicKeyHash, + ) + if err != nil { + return err + } + if !stillProvable { + return errReservationActionNoLongerProvable + } + + return SubmitReservationReanchorProof( + transactionHash, + requiredConfirmations, + event.ReservationKey, + event.RequestNonce, + btcChain, + spvChain, + ) +} + +// reservationProofScanState persists the incremental event-scan cursor and +// the set of still-pending 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 +// reservationProofLookBackBlocks window - and refetching Bitcoin history +// for every wallet in it - every config.IdleBackoffTime. +type reservationProofScanState struct { + acceptanceLastScannedBlock uint64 + pendingAcceptanceEvents map[string]*tbtc.ReservationAcceptanceRequestedEvent + + reanchorLastScannedBlock uint64 + pendingReanchorEvents map[string]*tbtc.ReservationReanchorRequestedEvent +} + +func newReservationProofScanState() *reservationProofScanState { + return &reservationProofScanState{ + pendingAcceptanceEvents: make(map[string]*tbtc.ReservationAcceptanceRequestedEvent), + pendingReanchorEvents: make(map[string]*tbtc.ReservationReanchorRequestedEvent), + } +} + +// reservationEventKey identifies one reservation action generation, unique +// across both the acceptance and re-anchor pending-event maps. +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: the bounded +// reservationProofLookBackBlocks catch-up window on the very first pass +// (lastScannedBlock == 0), or just the delta since the previous pass's +// cursor on every pass thereafter, so a steady-state loop no longer +// re-fetches the full ~30-day window on every config.IdleBackoffTime tick. +func reservationProofNextScanRange( + spvChain Chain, + lastScannedBlock uint64, +) (startBlock uint64, currentBlock uint64, err error) { + blockCounter, err := spvChain.BlockCounter() + if err != nil { + return 0, 0, fmt.Errorf("failed to get block counter: [%v]", err) + } + + currentBlock, err = blockCounter.CurrentBlock() + if err != nil { + return 0, 0, fmt.Errorf("failed to get current block: [%v]", err) + } + + if lastScannedBlock == 0 { + if currentBlock > reservationProofLookBackBlocks { + return currentBlock - reservationProofLookBackBlocks, currentBlock, nil + } + return 0, currentBlock, nil + } + + return lastScannedBlock + 1, currentBlock, 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 +// control loop (see spv.go's Initialize), because SubmitReservationProof +// requires the (reservationKey, requestNonce) pair of the action generation +// being proven - context the generic +// unprovenTransactionsGetter/transactionProofSubmitter signatures (shared +// by deposit sweep, redemption, moving funds, and moved funds sweep) cannot +// carry. +// +// The loop shape mirrors spvMaintainer.startControlLoop/maintainSpv: an +// outer restart-backoff loop wraps an inner idle-backoff loop, so a +// transient error restarts after config.RestartBackoffTime and a clean pass +// with nothing to prove waits config.IdleBackoffTime before trying again. +func maintainReservationProofs( + ctx context.Context, + config Config, + spvChain Chain, + btcDiffChain btcdiff.Chain, + btcChain bitcoin.Chain, +) { + logger.Info("starting reservation proof maintainer") + + defer func() { + logger.Info("stopping reservation proof maintainer") + }() + + for { + err := runReservationProofLoop(ctx, config, spvChain, btcDiffChain, btcChain) + if err != nil { + logger.Errorf( + "error while maintaining reservation proofs: [%v]; "+ + "restarting reservation proof maintainer", + err, + ) + } + + select { + case <-time.After(config.RestartBackoffTime): + case <-ctx.Done(): + return + } + } +} + +// runReservationProofLoop repeatedly proves pending reservation acceptance +// and re-anchor action generations until ctx is done or an unrecoverable +// error occurs. Per-action errors (a single reservation's proof failing to +// assemble or submit) are logged and skipped rather than propagated, so one +// bad action generation does not block the rest; only a chain-wide failure +// (e.g. cannot read the current block) aborts the pass and triggers the +// outer restart backoff. +// +// A single reservationProofScanState is created once and threaded through +// every pass for the lifetime of the loop, carrying the incremental event +// cursor and pending-action set described on that type. +func runReservationProofLoop( + ctx context.Context, + config Config, + spvChain Chain, + btcDiffChain btcdiff.Chain, + btcChain bitcoin.Chain, +) error { + state := newReservationProofScanState() + + for { + if err := proveReservationAcceptanceActions( + state, + config, + spvChain, + btcDiffChain, + btcChain, + ); err != nil { + return fmt.Errorf( + "error while proving reservation acceptance actions: [%v]", + err, + ) + } + + if err := proveReservationReanchorActions( + state, + config, + spvChain, + btcDiffChain, + btcChain, + ); err != nil { + return fmt.Errorf( + "error while proving reservation re-anchor actions: [%v]", + err, + ) + } + + select { + case <-time.After(config.IdleBackoffTime): + case <-ctx.Done(): + return ctx.Err() + } + } +} + +// proveReservationAcceptanceActions finds pending ReservationAcceptance +// action generations, locates each one's already-broadcast anchor +// transaction on the Bitcoin chain (if any), and submits its SPV proof once +// it has accumulated enough confirmations. +func proveReservationAcceptanceActions( + state *reservationProofScanState, + config Config, + spvChain Chain, + btcDiffChain btcdiff.Chain, + btcChain bitcoin.Chain, +) error { + startBlock, currentBlock, err := reservationProofNextScanRange( + spvChain, + state.acceptanceLastScannedBlock, + ) + if err != nil { + return err + } + + newEvents, err := spvChain.PastReservationAcceptanceRequestedEvents( + &tbtc.ReservationAcceptanceRequestedEventFilter{ + StartBlock: startBlock, + EndBlock: ¤tBlock, + }, + ) + 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 + } + + // Re-check every tracked event's on-chain action state, evict settled/stale + // ones, and group still-pending events by wallet public key hash. + walletEvents := make(map[[20]byte][]*tbtc.ReservationAcceptanceRequestedEvent) + for key, event := range state.pendingAcceptanceEvents { + action, err := spvChain.GetReservationAction( + event.ReservationKey, + event.RequestNonce, + ) + if err != nil { + logger.Errorf( + "failed to load reservation acceptance action [%v]/%d: [%v]", + event.ReservationKey, + event.RequestNonce, + err, + ) + continue + } + + if action.State != tbtc.ReservationActionStatePending { + delete(state.pendingAcceptanceEvents, key) + continue + } + + walletEvents[event.WalletPublicKeyHash] = append( + walletEvents[event.WalletPublicKeyHash], + event, + ) + } + + for walletPublicKeyHash, events := range walletEvents { + walletTransactions, err := btcChain.GetTransactionsForPublicKeyHash( + walletPublicKeyHash, + config.TransactionLimit, + ) + if err != nil { + logger.Errorf("failed to get transactions for wallet: [%v]", err) + continue + } + + // Index wallet transactions by deposit key for O(1) matching. + 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, + ) + candidateTransactions[depositKey.String()] = transaction + } + } + + for _, event := range events { + transaction, ok := candidateTransactions[event.ReservationKey.String()] + if !ok { + continue + } + + if !isMatchingReservationAcceptanceTransaction(spvChain, event, transaction) { + continue + } + + if err := proveReservationTransaction( + transaction, + btcChain, + spvChain, + btcDiffChain, + config.MaxProofHeaders, + func(transactionHash bitcoin.Hash, requiredConfirmations uint) error { + return submitReservationAcceptanceActionProof( + spvChain, + btcChain, + event, + transactionHash, + requiredConfirmations, + ) + }, + ); err != nil { + logger.Errorf( + "failed to prove reservation acceptance transaction [%s] "+ + "for reservation [%v]: [%v]", + transaction.Hash().Hex(bitcoin.ReversedByteOrder), + event.ReservationKey, + err, + ) + continue + } + } + } + + state.acceptanceLastScannedBlock = currentBlock + + return nil +} + +func isMatchingReservationAcceptanceTransaction( + spvChain Chain, + event *tbtc.ReservationAcceptanceRequestedEvent, + transaction *bitcoin.Transaction, +) bool { + if len(transaction.Inputs) != 1 || len(transaction.Outputs) != 1 || transaction.Inputs[0].Outpoint == nil { + return false + } + + input := transaction.Inputs[0] + depositKey := spvChain.BuildDepositKey( + input.Outpoint.TransactionHash, + input.Outpoint.OutputIndex, + ) + + if depositKey.Cmp(event.ReservationKey) != 0 { + return false + } + + expectedScript, err := bitcoin.PayToWitnessPublicKeyHash( + event.WalletPublicKeyHash, + ) + if err != nil || !bytes.Equal(transaction.Outputs[0].PublicKeyScript, expectedScript) { + return false + } + + if depositRequest, found, err := spvChain.GetDepositRequest( + input.Outpoint.TransactionHash, + input.Outpoint.OutputIndex, + ); err != nil { + return false + } else if found { + fee := int64(depositRequest.Amount) - transaction.Outputs[0].Value + if fee <= 0 || (event.TxMaxFee > 0 && uint64(fee) > event.TxMaxFee) { + return false + } + } else { + fee := int64(event.DepositAmount) - transaction.Outputs[0].Value + if fee <= 0 || (event.TxMaxFee > 0 && uint64(fee) > event.TxMaxFee) { + return false + } + } + + return true +} + +// proveReservationReanchorActions finds pending 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. +func proveReservationReanchorActions( + state *reservationProofScanState, + config Config, + spvChain Chain, + btcDiffChain btcdiff.Chain, + btcChain bitcoin.Chain, +) error { + startBlock, currentBlock, err := reservationProofNextScanRange( + spvChain, + state.reanchorLastScannedBlock, + ) + if err != nil { + return err + } + + newEvents, err := spvChain.PastReservationReanchorRequestedEvents( + &tbtc.ReservationReanchorRequestedEventFilter{ + StartBlock: startBlock, + EndBlock: ¤tBlock, + }, + ) + if err != nil { + return fmt.Errorf( + "failed to get past reservation re-anchor requested events: [%v]", + err, + ) + } + + for _, event := range newEvents { + key := reservationEventKey(event.ReservationKey, event.RequestNonce) + state.pendingReanchorEvents[key] = event + } + + // Re-check every tracked event's on-chain action state, evict settled/stale + // ones, and group still-pending events by source wallet public key hash. + walletEvents := make(map[[20]byte][]*tbtc.ReservationReanchorRequestedEvent) + for key, event := range state.pendingReanchorEvents { + action, err := spvChain.GetReservationAction( + event.ReservationKey, + event.RequestNonce, + ) + if err != nil { + logger.Errorf( + "failed to load reservation re-anchor action [%v]/%d: [%v]", + event.ReservationKey, + event.RequestNonce, + err, + ) + continue + } + + if action.State != tbtc.ReservationActionStatePending { + delete(state.pendingReanchorEvents, key) + continue + } + + walletEvents[event.SourceWalletPublicKeyHash] = append( + walletEvents[event.SourceWalletPublicKeyHash], + event, + ) + } + + for walletPublicKeyHash, events := range walletEvents { + walletTransactions, err := btcChain.GetTransactionsForPublicKeyHash( + walletPublicKeyHash, + config.TransactionLimit, + ) + if err != nil { + logger.Errorf("failed to get transactions for wallet: [%v]", err) + continue + } + + // Index wallet transactions by spent outpoint for O(1) matching. + 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 { + candidateTransactions[*transaction.Inputs[0].Outpoint] = transaction + } + } + + for _, event := range events { + reservation, err := spvChain.GetReservation(event.ReservationKey) + 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 + } + + transaction, ok := candidateTransactions[*reservation.AnchorUtxo.Outpoint] + if !ok { + continue + } + + if !isMatchingReservationReanchorTransaction(event, reservation.AnchorUtxo, transaction) { + continue + } + + if err := proveReservationTransaction( + transaction, + btcChain, + spvChain, + btcDiffChain, + config.MaxProofHeaders, + func(transactionHash bitcoin.Hash, requiredConfirmations uint) error { + return submitReservationReanchorActionProof( + spvChain, + btcChain, + event, + transactionHash, + requiredConfirmations, + ) + }, + ); err != nil { + logger.Errorf( + "failed to prove reservation re-anchor transaction [%s] "+ + "for reservation [%v]: [%v]", + transaction.Hash().Hex(bitcoin.ReversedByteOrder), + event.ReservationKey, + err, + ) + continue + } + } + } + + state.reanchorLastScannedBlock = currentBlock + + return nil +} + +func isMatchingReservationReanchorTransaction( + event *tbtc.ReservationReanchorRequestedEvent, + anchorUtxo *bitcoin.UnspentTransactionOutput, + transaction *bitcoin.Transaction, +) bool { + if len(transaction.Inputs) != 1 || len(transaction.Outputs) != 1 || transaction.Inputs[0].Outpoint == nil { + return false + } + + input := transaction.Inputs[0] + if input.Outpoint.TransactionHash != anchorUtxo.Outpoint.TransactionHash || + input.Outpoint.OutputIndex != anchorUtxo.Outpoint.OutputIndex { + return false + } + + expectedScript, err := bitcoin.PayToWitnessPublicKeyHash( + event.TargetWalletPublicKeyHash, + ) + if err != nil || !bytes.Equal(transaction.Outputs[0].PublicKeyScript, expectedScript) { + return false + } + + fee := int64(anchorUtxo.Value) - transaction.Outputs[0].Value + if fee <= 0 || (event.TxMaxFee > 0 && uint64(fee) > event.TxMaxFee) { + return false + } + + return true +} + +// proveReservationTransaction assembles and submits the SPV proof for a +// single reservation acceptance or re-anchor transaction, once it has +// accumulated enough confirmations and its proof falls within the relay's +// difficulty range. +func proveReservationTransaction( + transaction *bitcoin.Transaction, + btcChain bitcoin.Chain, + spvChain Chain, + btcDiffChain btcdiff.Chain, + maxProofHeaders uint, + submit func(transactionHash bitcoin.Hash, requiredConfirmations uint) error, +) error { + // Normalize a zero maxProofHeaders: the 144 default is applied by flag + // registration (cmd/flags.go), so any Config built programmatically + // without going through flags would cap getProofInfo at 0 and skip every + // proof as proofSkipExceededMaxHeaders. + if maxProofHeaders == 0 { + maxProofHeaders = DefaultMaxProofHeaders + } + + transactionHashStr := transaction.Hash().Hex(bitcoin.ReversedByteOrder) + + accumulatedConfirmations, requiredConfirmations, skipReason, err := getProofInfo( + transaction.Hash(), + btcChain, + spvChain, + btcDiffChain, + maxProofHeaders, + ) + if err != nil { + return fmt.Errorf("failed to get proof info: [%v]", err) + } + + switch skipReason { + case proofSkipOutsideRelayRange: + logger.Warnf( + "skipped proving transaction [%s]; the range of the "+ + "required proof goes outside the previous and current "+ + "difficulty epochs as seen by the relay", + transactionHashStr, + ) + if recorder := getMetricsRecorder(); recorder != nil { + recorder.IncrementCounter( + clientinfo.MetricSpvProofSkippedOutsideRelayRangeTotal, + 1, + ) + } + return nil + case proofSkipExceededMaxHeaders: + logger.Errorf( + "skipped proving transaction [%s]; could not find a decisive "+ + "header or accumulate enough difficulty within [%d] "+ + "headers; the transaction may be permanently unprovable", + transactionHashStr, + maxProofHeaders, + ) + if recorder := getMetricsRecorder(); recorder != nil { + recorder.IncrementCounter( + clientinfo.MetricSpvProofSkippedExceededMaxHeadersTotal, + 1, + ) + } + return nil + } + + if accumulatedConfirmations < requiredConfirmations { + logger.Infof( + "skipped proving transaction [%s]; transaction has [%v/%v] "+ + "confirmations", + transactionHashStr, + accumulatedConfirmations, + requiredConfirmations, + ) + return nil + } + + if err := submit(transaction.Hash(), requiredConfirmations); err != nil { + if errors.Is(err, errReservationActionNoLongerProvable) { + return nil + } + + return err + } + + logger.Infof( + "successfully submitted proof for transaction [%s]", + transactionHashStr, + ) + + return nil +} diff --git a/pkg/maintainer/spv/reservation_proof_loop_test.go b/pkg/maintainer/spv/reservation_proof_loop_test.go new file mode 100644 index 0000000000..cb732556ee --- /dev/null +++ b/pkg/maintainer/spv/reservation_proof_loop_test.go @@ -0,0 +1,1512 @@ +package spv + +import ( + "fmt" + "math/big" + "testing" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/tbtc" +) + +// TestReservationProofNextScanRange covers the incremental scan-range +// arithmetic: the very first pass (lastScannedBlock == 0) is bounded to +// reservationProofLookBackBlocks behind the current block (or 0 if the +// chain is younger than that window); every later pass starts exactly one +// block after the previous pass's cursor, so a steady-state loop never +// rescans the full look-back window again. +func TestReservationProofNextScanRange(t *testing.T) { + tests := map[string]struct { + currentBlock uint64 + lastScannedBlock uint64 + expectedStart uint64 + }{ + "first pass, current block below the look-back window": { + currentBlock: 1000, + lastScannedBlock: 0, + expectedStart: 0, + }, + "first pass, current block at the look-back window boundary": { + currentBlock: reservationProofLookBackBlocks, + lastScannedBlock: 0, + expectedStart: 0, + }, + "first pass, current block beyond the look-back window": { + currentBlock: reservationProofLookBackBlocks + 500, + lastScannedBlock: 0, + expectedStart: 500, + }, + "later pass starts one block after the cursor, ignoring the look-back window": { + currentBlock: reservationProofLookBackBlocks * 3, + lastScannedBlock: reservationProofLookBackBlocks * 2, + expectedStart: reservationProofLookBackBlocks*2 + 1, + }, + } + + 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, err := reservationProofNextScanRange( + spvChain, + test.lastScannedBlock, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if 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 +// BuildDepositKey), whose sole output is P2WPKH to the custody wallet, +// and whose value is depositAmount - fee (with fee <= TxMaxFee), skip +// transactions with wrong shape, wrong script, or invalid value, and return nil +// when nothing matches. +func TestFindReservationAcceptanceTransaction(t *testing.T) { + spvChain := newLocalChain() + + fundingTxHash, err := bitcoin.NewHashFromString( + "585b6699f42291d1a9d0776b75f04c295ea203f83504349db11e94fdae7d1b2c", + bitcoin.InternalByteOrder, + ) + if err != nil { + t.Fatal(err) + } + reservationKey := spvChain.BuildDepositKey(fundingTxHash, 0) + + walletPublicKeyHash := [20]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20} + walletScript, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + + otherWalletPKH := [20]byte{99, 99, 99} + otherWalletScript, err := bitcoin.PayToWitnessPublicKeyHash(otherWalletPKH) + if err != nil { + t.Fatal(err) + } + + spvChain.setDepositRequest(fundingTxHash, 0, &tbtc.DepositChainRequest{ + Amount: 150000, + }) + + matchingTx := &bitcoin.Transaction{ + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTxHash, + OutputIndex: 0, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 100000, + PublicKeyScript: walletScript, + }}, + } + + // Wrong script: pays to a different wallet. + wrongScriptTx := &bitcoin.Transaction{ + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTxHash, + OutputIndex: 0, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 100000, + PublicKeyScript: otherWalletScript, + }}, + } + + // Wrong value: output value >= depositAmount (zero or negative fee). + wrongValueTx := &bitcoin.Transaction{ + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTxHash, + OutputIndex: 0, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 160000, + PublicKeyScript: walletScript, + }}, + } + + // Excess fee: fee 100000 > TxMaxFee 60000. + excessFeeTx := &bitcoin.Transaction{ + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTxHash, + OutputIndex: 0, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 50000, + PublicKeyScript: walletScript, + }}, + } + + // Wrong shape: two outputs, must be skipped even though it otherwise + // spends the right outpoint. + wrongShapeTx := &bitcoin.Transaction{ + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTxHash, + OutputIndex: 0, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{ + {Value: 50000, PublicKeyScript: walletScript}, + {Value: 50000, PublicKeyScript: walletScript}, + }, + } + + // Non-matching: correct shape, different outpoint. + otherTxHash, err := bitcoin.NewHashFromString( + "7cff663e3e08847a5579913f6a66bc6c01f5f48c6ae1783be77418ed188021e6", + bitcoin.InternalByteOrder, + ) + if err != nil { + t.Fatal(err) + } + nonMatchingTx := &bitcoin.Transaction{ + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: otherTxHash, + OutputIndex: 0, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 100000, + PublicKeyScript: walletScript, + }}, + } + + event := &tbtc.ReservationAcceptanceRequestedEvent{ + ReservationKey: reservationKey, + WalletPublicKeyHash: walletPublicKeyHash, + TxMaxFee: 60000, + } + findMatchingTx := func(candidates []*bitcoin.Transaction) *bitcoin.Transaction { + candidateTransactions := make(map[string]*bitcoin.Transaction) + for _, transaction := range candidates { + 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, + ) + candidateTransactions[depositKey.String()] = transaction + } + } + + if transaction, ok := candidateTransactions[event.ReservationKey.String()]; ok { + if isMatchingReservationAcceptanceTransaction(spvChain, event, transaction) { + return transaction + } + } + + return nil + } + + t.Run("finds the matching transaction among candidates", func(t *testing.T) { + found := findMatchingTx([]*bitcoin.Transaction{wrongShapeTx, nonMatchingTx, matchingTx}) + if found != matchingTx { + t.Errorf("expected to find the matching transaction, got %v", found) + } + }) + + t.Run("returns nil when nothing matches", func(t *testing.T) { + found := findMatchingTx([]*bitcoin.Transaction{wrongShapeTx, nonMatchingTx}) + if found != nil { + t.Errorf("expected nil, got %v", found) + } + }) + + t.Run("returns nil for an empty candidate list", func(t *testing.T) { + found := findMatchingTx(nil) + if found != nil { + t.Errorf("expected nil, got %v", found) + } + }) + + t.Run("skips transaction with wrong output script", func(t *testing.T) { + found := findMatchingTx([]*bitcoin.Transaction{wrongScriptTx}) + if found != nil { + t.Errorf("expected nil for wrong script transaction, got %v", found) + } + if isMatchingReservationAcceptanceTransaction(spvChain, event, wrongScriptTx) { + t.Errorf("expected isMatchingReservationAcceptanceTransaction to be false") + } + }) + + t.Run("skips transaction with wrong output value", func(t *testing.T) { + found := findMatchingTx([]*bitcoin.Transaction{wrongValueTx}) + if found != nil { + t.Errorf("expected nil for wrong value transaction, got %v", found) + } + if isMatchingReservationAcceptanceTransaction(spvChain, event, wrongValueTx) { + t.Errorf("expected isMatchingReservationAcceptanceTransaction to be false") + } + }) + + t.Run("skips transaction with excess fee", func(t *testing.T) { + found := findMatchingTx([]*bitcoin.Transaction{excessFeeTx}) + if found != nil { + t.Errorf("expected nil for excess fee transaction, got %v", found) + } + if isMatchingReservationAcceptanceTransaction(spvChain, event, excessFeeTx) { + t.Errorf("expected isMatchingReservationAcceptanceTransaction to be false") + } + }) +} + +// TestFindReservationReanchorTransaction verifies the re-anchor transaction +// matcher: it must find the 1-input-1-output transaction whose sole input +// spends the reservation's current anchor UTXO outpoint exactly, whose sole output +// is P2WPKH to the target wallet, and whose value is anchorUtxo.Value - fee +// (with fee <= TxMaxFee), skip wrong-shape, wrong-script, or invalid-value +// transactions, and return nil when nothing matches. +func TestFindReservationReanchorTransaction(t *testing.T) { + anchorTxHash, err := bitcoin.NewHashFromString( + "2222222222222222222222222222222222222222222222222222222222222222", + bitcoin.InternalByteOrder, + ) + if err != nil { + t.Fatal(err) + } + anchorUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHash, + OutputIndex: 1, + }, + Value: 600000, + } + + targetWalletPKH := [20]byte{21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40} + targetWalletScript, err := bitcoin.PayToWitnessPublicKeyHash(targetWalletPKH) + if err != nil { + t.Fatal(err) + } + + otherPKH := [20]byte{99, 99, 99} + otherScript, err := bitcoin.PayToWitnessPublicKeyHash(otherPKH) + if err != nil { + t.Fatal(err) + } + + matchingTx := &bitcoin.Transaction{ + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHash, + OutputIndex: 1, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 590000, + PublicKeyScript: targetWalletScript, + }}, + } + + wrongScriptTx := &bitcoin.Transaction{ + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHash, + OutputIndex: 1, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 590000, + PublicKeyScript: otherScript, + }}, + } + + wrongValueTx := &bitcoin.Transaction{ + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHash, + OutputIndex: 1, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 600000, + PublicKeyScript: targetWalletScript, + }}, + } + + excessFeeTx := &bitcoin.Transaction{ + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHash, + OutputIndex: 1, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 500000, + PublicKeyScript: targetWalletScript, + }}, + } + + // Same transaction hash, wrong output index: must not match. + wrongIndexTx := &bitcoin.Transaction{ + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHash, + OutputIndex: 0, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 590000, + PublicKeyScript: targetWalletScript, + }}, + } + + wrongShapeTx := &bitcoin.Transaction{ + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHash, + OutputIndex: 1, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{ + {Value: 300000, PublicKeyScript: targetWalletScript}, + {Value: 290000, PublicKeyScript: targetWalletScript}, + }, + } + + event := &tbtc.ReservationReanchorRequestedEvent{ + TargetWalletPublicKeyHash: targetWalletPKH, + TxMaxFee: 20000, + } + + findMatchingTx := func(candidates []*bitcoin.Transaction) *bitcoin.Transaction { + candidateTransactions := make(map[bitcoin.TransactionOutpoint]*bitcoin.Transaction) + for _, transaction := range candidates { + if len(transaction.Inputs) == 1 && len(transaction.Outputs) == 1 && transaction.Inputs[0].Outpoint != nil { + candidateTransactions[*transaction.Inputs[0].Outpoint] = transaction + } + } + + if transaction, ok := candidateTransactions[*anchorUtxo.Outpoint]; ok { + if isMatchingReservationReanchorTransaction(event, anchorUtxo, transaction) { + return transaction + } + } + + return nil + } + + t.Run("finds the matching transaction among candidates", func(t *testing.T) { + found := findMatchingTx([]*bitcoin.Transaction{wrongShapeTx, wrongIndexTx, matchingTx}) + if found != matchingTx { + t.Errorf("expected to find the matching transaction, got %v", found) + } + }) + + t.Run("returns nil when nothing matches", func(t *testing.T) { + found := findMatchingTx([]*bitcoin.Transaction{wrongShapeTx, wrongIndexTx}) + if found != nil { + t.Errorf("expected nil, got %v", found) + } + }) + + t.Run("skips transaction with wrong output script", func(t *testing.T) { + found := findMatchingTx([]*bitcoin.Transaction{wrongScriptTx}) + if found != nil { + t.Errorf("expected nil for wrong script transaction, got %v", found) + } + if isMatchingReservationReanchorTransaction(event, anchorUtxo, wrongScriptTx) { + t.Errorf("expected isMatchingReservationReanchorTransaction to be false") + } + }) + + t.Run("skips transaction with wrong output value", func(t *testing.T) { + found := findMatchingTx([]*bitcoin.Transaction{wrongValueTx}) + if found != nil { + t.Errorf("expected nil for wrong value transaction, got %v", found) + } + if isMatchingReservationReanchorTransaction(event, anchorUtxo, wrongValueTx) { + t.Errorf("expected isMatchingReservationReanchorTransaction to be false") + } + }) + + t.Run("skips transaction with excess fee", func(t *testing.T) { + found := findMatchingTx([]*bitcoin.Transaction{excessFeeTx}) + if found != nil { + t.Errorf("expected nil for excess fee transaction, got %v", found) + } + if isMatchingReservationReanchorTransaction(event, anchorUtxo, excessFeeTx) { + t.Errorf("expected isMatchingReservationReanchorTransaction to be false") + } + }) +} + +// TestProveReservationTransaction covers the submit-vs-skip decision: a +// transaction with enough confirmations and a proof within relay range must +// invoke submit exactly once; a transaction with too few confirmations must +// not invoke submit at all. +func TestProveReservationTransaction(t *testing.T) { + // Fixture mirrors TestGetProofInfo's "proof entirely within current + // epoch" case in spv_test.go: factor 6, 20 confirmations, headers + // spanning the proof window all at the current epoch's difficulty. + const proofStart = 790270 + diff := func(d int64) *big.Int { return big.NewInt(d) } + + transaction := &bitcoin.Transaction{} + transactionHash := transaction.Hash() + + newFixture := func(confirmations uint) (*localChain, *localBitcoinChain) { + spvChain := newLocalChain() + btcChain := newLocalBitcoinChain() + + if err := populateBlockHeaders( + btcChain, + proofStart, + proofStart+19, + func(uint) *big.Int { return diff(32) }, + ); err != nil { + t.Fatal(err) + } + btcChain.addTransactionConfirmations(transactionHash, confirmations) + + spvChain.setTxProofDifficultyFactor(big.NewInt(6)) + spvChain.setCurrentEpoch(392) + spvChain.setCurrentAndPrevEpochDifficulty(diff(32), diff(16)) + + return spvChain, btcChain + } + + t.Run("submits when confirmations and relay range are sufficient", func(t *testing.T) { + spvChain, btcChain := newFixture(20) + + submitted := false + err := proveReservationTransaction( + transaction, + btcChain, + spvChain, + spvChain, + // 0 exercises the zero-fallback that normalizes + // programmatically-built Config paths to the default bound; + // without it, getProofInfo would skip with + // proofSkipExceededMaxHeaders and this submission could never + // happen. + 0, + func(hash bitcoin.Hash, requiredConfirmations uint) error { + submitted = true + if hash != transaction.Hash() { + t.Errorf("unexpected submitted hash") + } + if requiredConfirmations != 6 { + t.Errorf( + "unexpected required confirmations: got %d, want 6", + requiredConfirmations, + ) + } + return nil + }, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !submitted { + t.Error("expected submit to be called") + } + }) + + t.Run("skips without submitting when confirmations are insufficient", func(t *testing.T) { + spvChain, btcChain := newFixture(2) + + submitted := false + err := proveReservationTransaction( + transaction, + btcChain, + spvChain, + spvChain, + DefaultMaxProofHeaders, + func(hash bitcoin.Hash, requiredConfirmations uint) error { + submitted = true + return nil + }, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if submitted { + t.Error("expected submit not to be called for insufficient confirmations") + } + }) + + t.Run("propagates submit errors", func(t *testing.T) { + spvChain, btcChain := newFixture(20) + + err := proveReservationTransaction( + transaction, + btcChain, + spvChain, + spvChain, + DefaultMaxProofHeaders, + func(hash bitcoin.Hash, requiredConfirmations uint) error { + return fmt.Errorf("submission failed") + }, + ) + if err == nil { + t.Fatal("expected submit error to propagate") + } + }) +} + +// TestProveReservationAcceptanceActions is an end-to-end test of the +// top-level orchestration function wired into production via +// runReservationProofLoop: it seeds a requested event, a matching pending +// action, and a matching wallet transaction, then asserts the submit hook +// fires with the correct (reservationKey, requestNonce) pair on the first pass. +// On a second pass with the on-chain action state mutated to Settled, it +// asserts no second submission occurs and the event is evicted from the pending map. +func TestProveReservationAcceptanceActions(t *testing.T) { + const proofStart = 790270 + diff := func(d int64) *big.Int { return big.NewInt(d) } + + spvChain := newLocalChain() + btcChain := newLocalBitcoinChain() + + if err := populateBlockHeaders( + btcChain, + proofStart, + proofStart+19, + func(uint) *big.Int { return diff(32) }, + ); err != nil { + t.Fatal(err) + } + spvChain.setTxProofDifficultyFactor(big.NewInt(6)) + spvChain.setCurrentEpoch(392) + spvChain.setCurrentAndPrevEpochDifficulty(diff(32), diff(16)) + + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(1000) + spvChain.setBlockCounter(blockCounter) + + fundingTx := &bitcoin.Transaction{ + Outputs: []*bitcoin.TransactionOutput{{Value: 150000}}, + } + if err := btcChain.BroadcastTransaction(fundingTx); err != nil { + t.Fatal(err) + } + fundingTxHash := fundingTx.Hash() + reservationKey := spvChain.BuildDepositKey(fundingTxHash, 0) + const requestNonce = 1 + + spvChain.setDepositRequest(fundingTxHash, 0, &tbtc.DepositChainRequest{ + Amount: 150000, + }) + + walletPublicKeyHash := [20]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20} + walletScript, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + + transaction := &bitcoin.Transaction{ + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTxHash, + OutputIndex: 0, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 100000, + PublicKeyScript: walletScript, + }}, + } + if err := btcChain.BroadcastTransaction(transaction); err != nil { + t.Fatal(err) + } + if err := btcChain.addTransactionConfirmations( + transaction.Hash(), + 20, + ); err != nil { + t.Fatal(err) + } + btcChain.setCoinbaseTxHash(transaction.Hash()) + + spvChain.addReservationAcceptanceRequestedEvent(&tbtc.ReservationAcceptanceRequestedEvent{ + ReservationKey: reservationKey, + RequestNonce: requestNonce, + WalletPublicKeyHash: walletPublicKeyHash, + BlockNumber: 500, + }) + spvChain.setReservationAction( + reservationKey, + requestNonce, + &tbtc.ReservationAction{ + State: tbtc.ReservationActionStatePending, + ActionType: tbtc.ReservationActionTypeAcceptance, + TargetWalletPublicKeyHash: walletPublicKeyHash, + }, + ) + + var submittedReservationKey *big.Int + var submittedRequestNonce uint64 + submissions := 0 + spvChain.submitReservationProofHook = func( + proofType uint8, + txInfo *tbtc.BitcoinTxInfo, + proof *tbtc.BitcoinTxProof, + mainUtxo *tbtc.BitcoinTxUTXO, + reservationKey *big.Int, + requestNonce uint64, + ) error { + submissions++ + submittedReservationKey = reservationKey + submittedRequestNonce = requestNonce + return nil + } + + config := Config{TransactionLimit: 100, MaxProofHeaders: DefaultMaxProofHeaders} + scanState := newReservationProofScanState() + + if err := proveReservationAcceptanceActions( + scanState, + config, + spvChain, + spvChain, + btcChain, + ); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if submissions != 1 { + t.Fatalf("expected exactly one proof submission, got %d", submissions) + } + if submittedReservationKey.Cmp(reservationKey) != 0 { + t.Errorf( + "unexpected submitted reservation key\nexpected: %v\nactual: %v", + reservationKey, + submittedReservationKey, + ) + } + if submittedRequestNonce != requestNonce { + t.Errorf( + "unexpected submitted request nonce\nexpected: %d\nactual: %d", + requestNonce, + submittedRequestNonce, + ) + } + + // Second pass: action transitions to Settled. Verify it is not resubmitted + // and is evicted from the pending map. + spvChain.setReservationAction( + reservationKey, + requestNonce, + &tbtc.ReservationAction{ + State: tbtc.ReservationActionStateSettled, + ActionType: tbtc.ReservationActionTypeAcceptance, + TargetWalletPublicKeyHash: walletPublicKeyHash, + }, + ) + + if err := proveReservationAcceptanceActions( + scanState, + config, + spvChain, + spvChain, + btcChain, + ); err != nil { + t.Fatalf("unexpected error on second pass: %v", err) + } + + if submissions != 1 { + t.Errorf("expected submissions to remain 1 on second pass, got %d", submissions) + } + key := reservationEventKey(reservationKey, requestNonce) + if _, exists := scanState.pendingAcceptanceEvents[key]; exists { + t.Errorf("expected settled event to be evicted from pendingAcceptanceEvents") + } + // Regression test: when the reservation action for a discovered transaction + // is no longer Pending at submission time, zero submissions occur. + t.Run("skip when action no longer pending", func(t *testing.T) { + const proofStart = 790270 + diff := func(d int64) *big.Int { return big.NewInt(d) } + + spvChain := newLocalChain() + btcChain := newLocalBitcoinChain() + + if err := populateBlockHeaders( + btcChain, + proofStart, + proofStart+19, + func(uint) *big.Int { return diff(32) }, + ); err != nil { + t.Fatal(err) + } + spvChain.setTxProofDifficultyFactor(big.NewInt(6)) + spvChain.setCurrentEpoch(392) + spvChain.setCurrentAndPrevEpochDifficulty(diff(32), diff(16)) + + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(1000) + spvChain.setBlockCounter(blockCounter) + + fundingTx := &bitcoin.Transaction{ + Outputs: []*bitcoin.TransactionOutput{{Value: 150000}}, + } + if err := btcChain.BroadcastTransaction(fundingTx); err != nil { + t.Fatal(err) + } + fundingTxHash := fundingTx.Hash() + reservationKey := spvChain.BuildDepositKey(fundingTxHash, 0) + const requestNonce = 1 + + walletPublicKeyHash := [20]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20} + walletScript, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + + transaction := &bitcoin.Transaction{ + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTxHash, + OutputIndex: 0, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 100000, + PublicKeyScript: walletScript, + }}, + } + if err := btcChain.BroadcastTransaction(transaction); err != nil { + t.Fatal(err) + } + if err := btcChain.addTransactionConfirmations( + transaction.Hash(), + 20, + ); err != nil { + t.Fatal(err) + } + btcChain.setCoinbaseTxHash(transaction.Hash()) + + // Set up a timed-out action (not pending) + spvChain.addReservationAcceptanceRequestedEvent(&tbtc.ReservationAcceptanceRequestedEvent{ + ReservationKey: reservationKey, + RequestNonce: requestNonce, + WalletPublicKeyHash: walletPublicKeyHash, + BlockNumber: 500, + }) + spvChain.setReservationAction( + reservationKey, + requestNonce, + &tbtc.ReservationAction{ + State: tbtc.ReservationActionStateTimedOut, // Not pending! + ActionType: tbtc.ReservationActionTypeAcceptance, + TargetWalletPublicKeyHash: walletPublicKeyHash, + }, + ) + + submissions := 0 + spvChain.submitReservationProofHook = func( + proofType uint8, + txInfo *tbtc.BitcoinTxInfo, + proof *tbtc.BitcoinTxProof, + mainUtxo *tbtc.BitcoinTxUTXO, + reservationKey *big.Int, + requestNonce uint64, + ) error { + submissions++ + return nil + } + + config := Config{TransactionLimit: 100, MaxProofHeaders: DefaultMaxProofHeaders} + + if err := proveReservationAcceptanceActions( + newReservationProofScanState(), + config, + spvChain, + spvChain, + btcChain, + ); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should have zero submissions because action is not pending + if submissions != 0 { + t.Fatalf("expected zero proofs submissions when action is not pending, got %d", submissions) + } + }) +} + +// TestProveReservationReanchorActions is an end-to-end test of the +// top-level orchestration function wired into production via +// runReservationProofLoop: it seeds a requested event, a matching +// reservation with an anchor UTXO, a matching pending action, and a +// matching wallet transaction, then asserts the submit hook fires with the +// correct (reservationKey, requestNonce) pair on the first pass. +// On a second pass with the on-chain action state mutated to Settled, it +// asserts no second submission occurs and the event is evicted from the pending map. +func TestProveReservationReanchorActions(t *testing.T) { + const proofStart = 790270 + diff := func(d int64) *big.Int { return big.NewInt(d) } + + spvChain := newLocalChain() + btcChain := newLocalBitcoinChain() + + if err := populateBlockHeaders( + btcChain, + proofStart, + proofStart+19, + func(uint) *big.Int { return diff(32) }, + ); err != nil { + t.Fatal(err) + } + spvChain.setTxProofDifficultyFactor(big.NewInt(6)) + spvChain.setCurrentEpoch(392) + spvChain.setCurrentAndPrevEpochDifficulty(diff(32), diff(16)) + + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(1000) + spvChain.setBlockCounter(blockCounter) + + reservationKey := big.NewInt(424242) + const requestNonce = 2 + + priorAnchorTx := &bitcoin.Transaction{ + Outputs: []*bitcoin.TransactionOutput{ + {Value: 10000}, + {Value: 600000}, + }, + } + if err := btcChain.BroadcastTransaction(priorAnchorTx); err != nil { + t.Fatal(err) + } + anchorTxHash := priorAnchorTx.Hash() + anchorUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHash, + OutputIndex: 1, + }, + Value: 600000, + } + + sourceWalletPublicKeyHash := [20]byte{21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40} + walletScript, err := bitcoin.PayToWitnessPublicKeyHash(sourceWalletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + + transaction := &bitcoin.Transaction{ + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHash, + OutputIndex: 1, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 590000, + PublicKeyScript: walletScript, + }}, + } + if err := btcChain.BroadcastTransaction(transaction); err != nil { + t.Fatal(err) + } + if err := btcChain.addTransactionConfirmations( + transaction.Hash(), + 20, + ); err != nil { + t.Fatal(err) + } + btcChain.setCoinbaseTxHash(transaction.Hash()) + + spvChain.addReservationReanchorRequestedEvent(&tbtc.ReservationReanchorRequestedEvent{ + ReservationKey: reservationKey, + RequestNonce: requestNonce, + SourceWalletPublicKeyHash: sourceWalletPublicKeyHash, + TargetWalletPublicKeyHash: sourceWalletPublicKeyHash, + BlockNumber: 500, + }) + spvChain.setReservationAction( + reservationKey, + requestNonce, + &tbtc.ReservationAction{ + State: tbtc.ReservationActionStatePending, + ActionType: tbtc.ReservationActionTypeReanchor, + TargetWalletPublicKeyHash: sourceWalletPublicKeyHash, + }, + ) + spvChain.setReservation(reservationKey, &tbtc.Reservation{ + AnchorUtxo: anchorUtxo, + }) + + var submittedReservationKey *big.Int + var submittedRequestNonce uint64 + submissions := 0 + spvChain.submitReservationProofHook = func( + proofType uint8, + txInfo *tbtc.BitcoinTxInfo, + proof *tbtc.BitcoinTxProof, + mainUtxo *tbtc.BitcoinTxUTXO, + reservationKey *big.Int, + requestNonce uint64, + ) error { + submissions++ + submittedReservationKey = reservationKey + submittedRequestNonce = requestNonce + return nil + } + + config := Config{TransactionLimit: 100, MaxProofHeaders: DefaultMaxProofHeaders} + scanState := newReservationProofScanState() + + if err := proveReservationReanchorActions( + scanState, + config, + spvChain, + spvChain, + btcChain, + ); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if submissions != 1 { + t.Fatalf("expected exactly one proof submission, got %d", submissions) + } + if submittedReservationKey.Cmp(reservationKey) != 0 { + t.Errorf( + "unexpected submitted reservation key\nexpected: %v\nactual: %v", + reservationKey, + submittedReservationKey, + ) + } + if submittedRequestNonce != requestNonce { + t.Errorf( + "unexpected submitted request nonce\nexpected: %d\nactual: %d", + requestNonce, + submittedRequestNonce, + ) + } + + // Regression test: when the reservation action for a discovered transaction + // is no longer Pending at submission time, zero submissions occur. + t.Run("skip when action no longer pending", func(t *testing.T) { + const proofStart = 790270 + diff := func(d int64) *big.Int { return big.NewInt(d) } + + spvChain := newLocalChain() + btcChain := newLocalBitcoinChain() + + if err := populateBlockHeaders( + btcChain, + proofStart, + proofStart+19, + func(uint) *big.Int { return diff(32) }, + ); err != nil { + t.Fatal(err) + } + spvChain.setTxProofDifficultyFactor(big.NewInt(6)) + spvChain.setCurrentEpoch(392) + spvChain.setCurrentAndPrevEpochDifficulty(diff(32), diff(16)) + + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(1000) + spvChain.setBlockCounter(blockCounter) + + reservationKey := big.NewInt(424242) + const requestNonce = 2 + + priorAnchorTx := &bitcoin.Transaction{ + Outputs: []*bitcoin.TransactionOutput{ + {Value: 10000}, + {Value: 600000}, + }, + } + if err := btcChain.BroadcastTransaction(priorAnchorTx); err != nil { + t.Fatal(err) + } + anchorTxHash := priorAnchorTx.Hash() + anchorUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHash, + OutputIndex: 1, + }, + Value: 600000, + } + + sourceWalletPublicKeyHash := [20]byte{21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40} + walletScript, err := bitcoin.PayToWitnessPublicKeyHash(sourceWalletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + + transaction := &bitcoin.Transaction{ + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHash, + OutputIndex: 1, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 590000, + PublicKeyScript: walletScript, + }}, + } + if err := btcChain.BroadcastTransaction(transaction); err != nil { + t.Fatal(err) + } + if err := btcChain.addTransactionConfirmations( + transaction.Hash(), + 20, + ); err != nil { + t.Fatal(err) + } + btcChain.setCoinbaseTxHash(transaction.Hash()) + + // Set up a timed-out action (not pending) + spvChain.addReservationReanchorRequestedEvent(&tbtc.ReservationReanchorRequestedEvent{ + ReservationKey: reservationKey, + RequestNonce: requestNonce, + SourceWalletPublicKeyHash: sourceWalletPublicKeyHash, + TargetWalletPublicKeyHash: sourceWalletPublicKeyHash, + BlockNumber: 500, + }) + spvChain.setReservationAction( + reservationKey, + requestNonce, + &tbtc.ReservationAction{ + State: tbtc.ReservationActionStateTimedOut, // Not pending! + ActionType: tbtc.ReservationActionTypeReanchor, + TargetWalletPublicKeyHash: sourceWalletPublicKeyHash, + }, + ) + spvChain.setReservation(reservationKey, &tbtc.Reservation{ + AnchorUtxo: anchorUtxo, + }) + + submissions := 0 + spvChain.submitReservationProofHook = func( + proofType uint8, + txInfo *tbtc.BitcoinTxInfo, + proof *tbtc.BitcoinTxProof, + mainUtxo *tbtc.BitcoinTxUTXO, + reservationKey *big.Int, + requestNonce uint64, + ) error { + submissions++ + return nil + } + + config := Config{TransactionLimit: 100, MaxProofHeaders: DefaultMaxProofHeaders} + + if err := proveReservationReanchorActions( + newReservationProofScanState(), + config, + spvChain, + spvChain, + btcChain, + ); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should have zero submissions because action is not pending + if submissions != 0 { + t.Fatalf("expected zero proofs submissions when action is not pending, got %d", submissions) + } + }) +} + +// TestSubmitReservationReanchorActionProof_UsesTargetWallet verifies that +// submitReservationReanchorActionProof re-checks the action generation +// against event.TargetWalletPublicKeyHash, not +// event.SourceWalletPublicKeyHash. TestProveReservationReanchorActions +// cannot catch a regression that swapped the two fields at the call site: +// this package's local Bitcoin-history test double can only discover a +// transaction via the source wallet's own outputs +// (localBitcoinChain.GetTransactionsForPublicKeyHash matches on output +// script), which forces source and target to coincide by construction in +// any test that goes through discovery. Calling +// submitReservationReanchorActionProof directly with a known transaction +// hash bypasses discovery, so source and target can differ here: the +// installed action authorizes only the target wallet, so passing Source +// instead of Target would make the guard wrongly skip the submission. +func TestSubmitReservationReanchorActionProof_UsesTargetWallet(t *testing.T) { + const proofStart = 790270 + diff := func(d int64) *big.Int { return big.NewInt(d) } + + spvChain := newLocalChain() + btcChain := newLocalBitcoinChain() + + if err := populateBlockHeaders( + btcChain, + proofStart, + proofStart+19, + func(uint) *big.Int { return diff(32) }, + ); err != nil { + t.Fatal(err) + } + spvChain.setTxProofDifficultyFactor(big.NewInt(6)) + spvChain.setCurrentEpoch(392) + spvChain.setCurrentAndPrevEpochDifficulty(diff(32), diff(16)) + + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(1000) + spvChain.setBlockCounter(blockCounter) + + reservationKey := big.NewInt(555555) + const requestNonce = 9 + + priorAnchorTx := &bitcoin.Transaction{ + Outputs: []*bitcoin.TransactionOutput{ + {Value: 10000}, + {Value: 600000}, + }, + } + if err := btcChain.BroadcastTransaction(priorAnchorTx); err != nil { + t.Fatal(err) + } + anchorTxHash := priorAnchorTx.Hash() + + sourceWalletPublicKeyHash := [20]byte{21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40} + targetWalletPublicKeyHash := [20]byte{100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119} + walletScript, err := bitcoin.PayToWitnessPublicKeyHash(targetWalletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + + transaction := &bitcoin.Transaction{ + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHash, + OutputIndex: 1, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 590000, + PublicKeyScript: walletScript, + }}, + } + if err := btcChain.BroadcastTransaction(transaction); err != nil { + t.Fatal(err) + } + if err := btcChain.addTransactionConfirmations( + transaction.Hash(), + 20, + ); err != nil { + t.Fatal(err) + } + btcChain.setCoinbaseTxHash(transaction.Hash()) + + // The on-chain action authorizes only the target wallet - genuinely + // distinct from the source wallet here, unlike the discovery-bound E2E + // test above. + spvChain.setReservationAction( + reservationKey, + requestNonce, + &tbtc.ReservationAction{ + State: tbtc.ReservationActionStatePending, + ActionType: tbtc.ReservationActionTypeReanchor, + TargetWalletPublicKeyHash: targetWalletPublicKeyHash, + }, + ) + + event := &tbtc.ReservationReanchorRequestedEvent{ + ReservationKey: reservationKey, + RequestNonce: requestNonce, + SourceWalletPublicKeyHash: sourceWalletPublicKeyHash, + TargetWalletPublicKeyHash: targetWalletPublicKeyHash, + } + + submissions := 0 + spvChain.submitReservationProofHook = func( + proofType uint8, + txInfo *tbtc.BitcoinTxInfo, + proof *tbtc.BitcoinTxProof, + mainUtxo *tbtc.BitcoinTxUTXO, + reservationKey *big.Int, + requestNonce uint64, + ) error { + submissions++ + return nil + } + + _, requiredConfirmations, _, err := getProofInfo( + transaction.Hash(), + btcChain, + spvChain, + spvChain, + DefaultMaxProofHeaders, + ) + if err != nil { + t.Fatalf("failed to get proof info: %v", err) + } + + if err := submitReservationReanchorActionProof( + spvChain, + btcChain, + event, + transaction.Hash(), + requiredConfirmations, + ); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if submissions != 1 { + t.Fatalf( + "expected exactly one proof submission using the target wallet, got %d", + submissions, + ) + } +} + +// TestVerifyReservationActionStillProvable tests the guard that confirms a reservation action +// is still the expected pending generation at submission time. +func TestVerifyReservationActionStillProvable(t *testing.T) { + tests := map[string]struct { + setupFunc func(*localChain, *big.Int, uint64) + reservationKey *big.Int + requestNonce uint64 + targetWalletPKH [20]byte + expectedActionType tbtc.ReservationActionType + expectedTargetWalletPublicKeyHash [20]byte + expectedStillProvable bool + expectedWantErr bool + description string + }{ + "happy path": { + setupFunc: func(lc *localChain, reservationKey *big.Int, requestNonce uint64) { + lc.setReservationAction(reservationKey, requestNonce, &tbtc.ReservationAction{ + ActionType: tbtc.ReservationActionTypeReanchor, + State: tbtc.ReservationActionStatePending, + TargetWalletPublicKeyHash: [20]byte{0x01, 0x02, 0x03}, + }) + }, + reservationKey: big.NewInt(1), + requestNonce: uint64(5), + targetWalletPKH: [20]byte{0x01, 0x02, 0x03}, + expectedActionType: tbtc.ReservationActionTypeReanchor, + expectedTargetWalletPublicKeyHash: [20]byte{0x01, 0x02, 0x03}, + expectedStillProvable: true, + expectedWantErr: false, + description: "action generation is still pending, still the expected type, and still targets the expected wallet", + }, + "stale action generation": { + setupFunc: func(lc *localChain, reservationKey *big.Int, requestNonce uint64) { + lc.setReservationAction(reservationKey, requestNonce, &tbtc.ReservationAction{ + ActionType: tbtc.ReservationActionTypeReanchor, + State: tbtc.ReservationActionStateTimedOut, + TargetWalletPublicKeyHash: [20]byte{0x01, 0x02, 0x03}, + }) + }, + reservationKey: big.NewInt(2), + requestNonce: uint64(7), + targetWalletPKH: [20]byte{0x01, 0x02, 0x03}, + expectedActionType: tbtc.ReservationActionTypeReanchor, + expectedTargetWalletPublicKeyHash: [20]byte{0x01, 0x02, 0x03}, + expectedStillProvable: false, + expectedWantErr: false, + description: "action generation is no longer pending (timed out)", + }, + "wrong action type": { + setupFunc: func(lc *localChain, reservationKey *big.Int, requestNonce uint64) { + lc.setReservationAction(reservationKey, requestNonce, &tbtc.ReservationAction{ + ActionType: tbtc.ReservationActionTypeDissolution, + State: tbtc.ReservationActionStatePending, + TargetWalletPublicKeyHash: [20]byte{0x01, 0x02, 0x03}, // must match expected to isolate ActionType check + }) + }, + reservationKey: big.NewInt(3), + requestNonce: uint64(8), + targetWalletPKH: [20]byte{0x01, 0x02, 0x03}, + expectedActionType: tbtc.ReservationActionTypeReanchor, + expectedTargetWalletPublicKeyHash: [20]byte{0x01, 0x02, 0x03}, + expectedStillProvable: false, + expectedWantErr: false, + description: "action generation is Pending but for a different action type than expected", + }, + "mismatched target wallet": { + setupFunc: func(lc *localChain, reservationKey *big.Int, requestNonce uint64) { + lc.setReservationAction(reservationKey, requestNonce, &tbtc.ReservationAction{ + ActionType: tbtc.ReservationActionTypeReanchor, + State: tbtc.ReservationActionStatePending, + TargetWalletPublicKeyHash: [20]byte{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x11, 0x22, 0x33, 0x44}, + }) + }, + reservationKey: big.NewInt(4), + requestNonce: uint64(3), + targetWalletPKH: [20]byte{0x92, 0xa6, 0xec, 0x88, 0x9a, 0x8f, 0xa3, 0x4f, 0x73, 0x1e}, + expectedActionType: tbtc.ReservationActionTypeReanchor, + expectedTargetWalletPublicKeyHash: [20]byte{0x92, 0xa6, 0xec, 0x88, 0x9a, 0x8f, 0xa3, 0x4f, 0x73, 0x1e}, + expectedStillProvable: false, + expectedWantErr: false, + description: "action generation targets a different wallet than expected", + }, + "genuine chain error": { + setupFunc: func(lc *localChain, reservationKey *big.Int, requestNonce uint64) { + lc.getReservationActionErr = fmt.Errorf("simulated chain read failure") + }, + reservationKey: big.NewInt(5), + requestNonce: uint64(1), + targetWalletPKH: [20]byte{}, // unused when error expected + expectedActionType: tbtc.ReservationActionTypeReanchor, + expectedTargetWalletPublicKeyHash: [20]byte{}, // unused when error expected + expectedStillProvable: false, + expectedWantErr: true, + description: "chain-level error re-fetching the action generation", + }, + "absent/zero-value action": { + setupFunc: func(lc *localChain, reservationKey *big.Int, requestNonce uint64) { + // Install zero value action: ActionType==None, State==Unknown + lc.setReservationAction(reservationKey, requestNonce, &tbtc.ReservationAction{}) + }, + reservationKey: big.NewInt(6), + requestNonce: uint64(2), + targetWalletPKH: [20]byte{0x01, 0x02, 0x03}, + expectedActionType: tbtc.ReservationActionTypeReanchor, // expecting Reanchor but got None + expectedTargetWalletPublicKeyHash: [20]byte{0x01, 0x02, 0x03}, + expectedStillProvable: false, + expectedWantErr: false, + description: "zero-value action models missing on-chain entry (treated as skip)", + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + spvChain := newLocalChain() + + if test.setupFunc != nil { + test.setupFunc(spvChain, test.reservationKey, test.requestNonce) + } + + stillProvable, err := verifyReservationActionStillProvable( + spvChain, + test.reservationKey, + test.requestNonce, + test.expectedActionType, + test.expectedTargetWalletPublicKeyHash, + ) + + if test.expectedWantErr { + if err == nil { + t.Fatal("expected an error but got nil") + } + if test.expectedStillProvable { + t.Fatal("expected error to report unprovable") + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if stillProvable != test.expectedStillProvable { + t.Fatalf("unexpected stillProvable value\nexpected: %v\nactual: %v", test.expectedStillProvable, stillProvable) + } + }) + } +} + +func TestProveReservationAcceptanceActions_LeavesPendingOnChainError(t *testing.T) { + spvChain := newLocalChain() + btcChain := newLocalBitcoinChain() + + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(1000) + spvChain.setBlockCounter(blockCounter) + + reservationKey := big.NewInt(12345) + const requestNonce = 1 + + spvChain.addReservationAcceptanceRequestedEvent(&tbtc.ReservationAcceptanceRequestedEvent{ + ReservationKey: reservationKey, + RequestNonce: requestNonce, + WalletPublicKeyHash: [20]byte{1}, + BlockNumber: 500, + }) + // Intentionally do NOT set the reservation action on spvChain, so GetReservationAction fails. + + scanState := newReservationProofScanState() + config := Config{TransactionLimit: 100, MaxProofHeaders: DefaultMaxProofHeaders} + key := reservationEventKey(reservationKey, requestNonce) + + // Multiple passes: event must remain pending unconditionally on read error without eviction. + for i := 1; i <= 5; i++ { + if err := proveReservationAcceptanceActions( + scanState, + config, + spvChain, + spvChain, + btcChain, + ); err != nil { + t.Fatalf("unexpected error on pass %d: %v", i, err) + } + + if _, exists := scanState.pendingAcceptanceEvents[key]; !exists { + t.Fatalf("expected event to remain pending on pass %d", i) + } + } + + if scanState.acceptanceLastScannedBlock != 1000 { + t.Fatalf("expected cursor to advance to current block 1000, got %d", scanState.acceptanceLastScannedBlock) + } +} + +func TestProveReservationReanchorActions_LeavesPendingOnChainError(t *testing.T) { + spvChain := newLocalChain() + btcChain := newLocalBitcoinChain() + + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(1000) + spvChain.setBlockCounter(blockCounter) + + reservationKey := big.NewInt(67890) + const requestNonce = 1 + + spvChain.addReservationReanchorRequestedEvent(&tbtc.ReservationReanchorRequestedEvent{ + ReservationKey: reservationKey, + RequestNonce: requestNonce, + SourceWalletPublicKeyHash: [20]byte{2}, + TargetWalletPublicKeyHash: [20]byte{2}, + BlockNumber: 500, + }) + // Intentionally do NOT set the reservation action on spvChain, so GetReservationAction fails. + + scanState := newReservationProofScanState() + config := Config{TransactionLimit: 100, MaxProofHeaders: DefaultMaxProofHeaders} + key := reservationEventKey(reservationKey, requestNonce) + + // Multiple passes: event must remain pending unconditionally on read error without eviction. + for i := 1; i <= 5; i++ { + if err := proveReservationReanchorActions( + scanState, + config, + spvChain, + spvChain, + btcChain, + ); err != nil { + t.Fatalf("unexpected error on pass %d: %v", i, err) + } + + if _, exists := scanState.pendingReanchorEvents[key]; !exists { + t.Fatalf("expected event to remain pending on pass %d", i) + } + } + + if scanState.reanchorLastScannedBlock != 1000 { + t.Fatalf("expected cursor to advance to current block 1000, got %d", scanState.reanchorLastScannedBlock) + } +} diff --git a/pkg/maintainer/spv/reservation_reanchor_proof.go b/pkg/maintainer/spv/reservation_reanchor_proof.go new file mode 100644 index 0000000000..a0dc79b6da --- /dev/null +++ b/pkg/maintainer/spv/reservation_reanchor_proof.go @@ -0,0 +1,335 @@ +package spv + +import ( + "fmt" + "math/big" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/tbtc" +) + +// ProofTypeReservationReanchor is the value passed to +// SubmitReservationProof as proofType for a reservation re-anchor SPV proof. +// The numeric value mirrors the on-chain ReservationProofType enum (3 = +// Reanchor). +const ProofTypeReservationReanchor uint8 = 3 + +// SubmitReservationReanchorProof drives the SPV proof submission for a +// reservation re-anchor action generation. The caller (the reservation +// proof loop) supplies the (reservationKey, requestNonce) +// pair of the on-chain action generation it is proving, plus the Bitcoin +// transaction hash of the re-anchor transaction already signed and +// broadcast by the wallet coordinator. The proof is fetched from btcChain, +// the re-anchor transaction is rebuilt locally to extract the anchor UTXO +// and target wallet, and the proof is submitted directly to the Bridge via +// the SPV maintainer's SubmitReservationProof entry point (not via +// MaintainerProxy: reservations are not reimbursed). +// +// requiredConfirmations must be > 0; the SPV maintainer relies on it to +// assemble the proof. +func SubmitReservationReanchorProof( + transactionHash bitcoin.Hash, + requiredConfirmations uint, + reservationKey *big.Int, + requestNonce uint64, + btcChain bitcoin.Chain, + spvChain Chain, +) error { + return submitReservationReanchorProof( + transactionHash, + requiredConfirmations, + reservationKey, + requestNonce, + btcChain, + spvChain, + bitcoin.AssembleSpvProof, + getMetricsRecorder(), + ) +} + +func submitReservationReanchorProof( + transactionHash bitcoin.Hash, + requiredConfirmations uint, + reservationKey *big.Int, + requestNonce uint64, + btcChain bitcoin.Chain, + spvChain Chain, + spvProofAssembler spvProofAssembler, + metricsRecorder interface { + IncrementCounter(name string, value float64) + }, +) error { + return submitReservationActionProof( + transactionHash, + requiredConfirmations, + reservationKey, + requestNonce, + btcChain, + spvChain, + spvProofAssembler, + metricsRecorder, + ProofTypeReservationReanchor, + "reservation_reanchor_proof", + tbtc.ReservationActionTypeReanchor, + "re-anchor", + ) +} + +// spentOutputAsUtxo fetches the single previous output spent by transaction's +// sole input and returns it as an UnspentTransactionOutput. Shared by +// parseReservationTransaction, which parses a 1-input-1-output reservation +// transaction and needs the spent outpoint's value to build the SPV proof's +// main UTXO. +func spentOutputAsUtxo( + btcChain bitcoin.Chain, + transaction *bitcoin.Transaction, +) (*bitcoin.UnspentTransactionOutput, error) { + if len(transaction.Inputs) != 1 { + return nil, fmt.Errorf( + "reservation transaction must have exactly one input", + ) + } + + spentOutpoint := transaction.Inputs[0].Outpoint + + previousTransaction, err := btcChain.GetTransaction( + spentOutpoint.TransactionHash, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot fetch previous transaction: [%v]", + err, + ) + } + + if int(spentOutpoint.OutputIndex) >= len(previousTransaction.Outputs) { + return nil, fmt.Errorf( + "spent output index [%v] out of bounds for previous "+ + "transaction with [%v] outputs", + spentOutpoint.OutputIndex, + len(previousTransaction.Outputs), + ) + } + + spentOutput := previousTransaction.Outputs[spentOutpoint.OutputIndex] + + return &bitcoin.UnspentTransactionOutput{ + Outpoint: spentOutpoint, + Value: spentOutput.Value, + }, nil +} + +// parseReservationTransaction parses the single input and single output +// of a reservation transaction and returns the UTXO that was spent and +// the target wallet's public key hash from the new output script. +func parseReservationTransaction( + btcChain bitcoin.Chain, + transaction *bitcoin.Transaction, + txType string, +) (*bitcoin.UnspentTransactionOutput, [20]byte, error) { + utxo, err := spentOutputAsUtxo(btcChain, transaction) + if err != nil { + return nil, [20]byte{}, err + } + + if len(transaction.Outputs) != 1 { + return nil, [20]byte{}, fmt.Errorf( + "reservation %v transaction must have exactly one output", + txType, + ) + } + + publicKeyHash, err := bitcoin.ExtractPublicKeyHash( + transaction.Outputs[0].PublicKeyScript, + ) + if err != nil { + return nil, [20]byte{}, fmt.Errorf( + "cannot extract %v public key hash: [%v]", + txType, + err, + ) + } + + return utxo, publicKeyHash, nil +} + +// buildReservationProofTxInfo serializes the relevant parts of the +// transaction into the BitcoinTxInfo structure expected by +// SubmitReservationProof. +func buildReservationProofTxInfo( + transaction *bitcoin.Transaction, +) *tbtc.BitcoinTxInfo { + return &tbtc.BitcoinTxInfo{ + Version: transaction.SerializeVersion(), + InputVector: transaction.SerializeInputs(), + OutputVector: transaction.SerializeOutputs(), + Locktime: transaction.SerializeLocktime(), + } +} + +// buildReservationProofTxProof converts a bitcoin.SpvProof into the +// BitcoinTxProof structure expected by SubmitReservationProof. +func buildReservationProofTxProof( + proof *bitcoin.SpvProof, +) *tbtc.BitcoinTxProof { + txIndexInBlock := big.NewInt(int64(proof.TxIndexInBlock)) + + return &tbtc.BitcoinTxProof{ + MerkleProof: proof.MerkleProof, + TxIndexInBlock: txIndexInBlock, + BitcoinHeaders: proof.BitcoinHeaders, + CoinbasePreimage: proof.CoinbasePreimage, + CoinbaseProof: proof.CoinbaseProof, + } +} + +// buildReservationProofMainUtxo packages the spent deposit or anchor UTXO +// into the BitcoinTxUTXO structure expected by SubmitReservationProof. +// +// IMPORTANT: The mainUtxo parameter is INERT IN MILESTONE 1. Per +// ReservationRouter.sol's devdoc on the tbtc-v2 reservations-upgrade branch: +// "Unused in milestone 1; Dissolution proofs are rejected by the underlying +// library. Reserved for milestone 2." The underlying ReservationProofs.sol +// library has zero references to mainUtxo. The current value passed is the +// spent deposit/anchor outpoint, which is harmless for m1 but a future +// milestone-2 activation MUST revisit what value is actually correct here. +// Do NOT change this value without updating the corresponding test assertion. +func buildReservationProofMainUtxo( + spentUtxo *bitcoin.UnspentTransactionOutput, +) *tbtc.BitcoinTxUTXO { + var ( + txHash [32]byte + txOutIndex uint32 + txOutValue uint64 + ) + + if spentUtxo.Outpoint != nil { + txHash = spentUtxo.Outpoint.TransactionHash + txOutIndex = spentUtxo.Outpoint.OutputIndex + } + if spentUtxo.Value < 0 { + txOutValue = 0 + } else { + txOutValue = uint64(spentUtxo.Value) + } + + return &tbtc.BitcoinTxUTXO{ + TxHash: txHash, + TxOutputIndex: txOutIndex, + TxOutputValue: txOutValue, + } +} + +func submitReservationActionProof( + transactionHash bitcoin.Hash, + requiredConfirmations uint, + reservationKey *big.Int, + requestNonce uint64, + btcChain bitcoin.Chain, + spvChain Chain, + spvProofAssembler spvProofAssembler, + metricsRecorder interface { + IncrementCounter(name string, value float64) + }, + proofType uint8, + metricsPrefix string, + expectedActionType tbtc.ReservationActionType, + txType string, +) error { + if metricsRecorder != nil { + metricsRecorder.IncrementCounter(metricsPrefix+"_submissions_total", 1) + } + + if requiredConfirmations == 0 { + if metricsRecorder != nil { + metricsRecorder.IncrementCounter(metricsPrefix+"_submissions_failed_total", 1) + } + return fmt.Errorf("provided required confirmations count must be greater than 0") + } + if reservationKey == nil { + if metricsRecorder != nil { + metricsRecorder.IncrementCounter(metricsPrefix+"_submissions_failed_total", 1) + } + return fmt.Errorf("reservation key is required") + } + if requestNonce == 0 { + if metricsRecorder != nil { + metricsRecorder.IncrementCounter(metricsPrefix+"_submissions_failed_total", 1) + } + return fmt.Errorf("request nonce must be > 0") + } + + transaction, proof, err := spvProofAssembler( + transactionHash, + requiredConfirmations, + btcChain, + ) + if err != nil { + if metricsRecorder != nil { + metricsRecorder.IncrementCounter(metricsPrefix+"_submissions_failed_total", 1) + } + return fmt.Errorf("failed to assemble transaction spv proof: [%v]", err) + } + + utxo, pkh, err := parseReservationTransaction(btcChain, transaction, txType) + if err != nil { + if metricsRecorder != nil { + metricsRecorder.IncrementCounter(metricsPrefix+"_submissions_failed_total", 1) + } + return fmt.Errorf("error while parsing reservation transaction inputs: [%v]", err) + } + + action, err := spvChain.GetReservationAction(reservationKey, requestNonce) + if err != nil { + if metricsRecorder != nil { + metricsRecorder.IncrementCounter(metricsPrefix+"_submissions_failed_total", 1) + } + return fmt.Errorf("cannot fetch reservation action generation: [%v]", err) + } + + // The action snapshot is the on-chain authorization for the destination, so verify the PKH match. + if pkh != action.TargetWalletPublicKeyHash { + if metricsRecorder != nil { + metricsRecorder.IncrementCounter(metricsPrefix+"_submissions_failed_total", 1) + } + return fmt.Errorf("target wallet public key hash mismatch") + } + + if action.ActionType != expectedActionType { + if metricsRecorder != nil { + metricsRecorder.IncrementCounter(metricsPrefix+"_submissions_failed_total", 1) + } + return fmt.Errorf("reservation action generation is not expected type") + } + + if action.State != tbtc.ReservationActionStatePending { + if metricsRecorder != nil { + metricsRecorder.IncrementCounter(metricsPrefix+"_submissions_failed_total", 1) + } + return fmt.Errorf("reservation action generation is not pending") + } + + txInfo := buildReservationProofTxInfo(transaction) + txProof := buildReservationProofTxProof(proof) + mainUtxo := buildReservationProofMainUtxo(utxo) + + if err := spvChain.SubmitReservationProof( + proofType, + txInfo, + txProof, + mainUtxo, + reservationKey, + requestNonce, + ); err != nil { + if metricsRecorder != nil { + metricsRecorder.IncrementCounter(metricsPrefix+"_submissions_failed_total", 1) + } + return fmt.Errorf("failed to submit reservation proof: [%v]", err) + } + + if metricsRecorder != nil { + metricsRecorder.IncrementCounter(metricsPrefix+"_submissions_succeeded_total", 1) + } + + return nil +} diff --git a/pkg/maintainer/spv/reservation_reanchor_proof_test.go b/pkg/maintainer/spv/reservation_reanchor_proof_test.go new file mode 100644 index 0000000000..549cf55c84 --- /dev/null +++ b/pkg/maintainer/spv/reservation_reanchor_proof_test.go @@ -0,0 +1,252 @@ +package spv + +import ( + "bytes" + "fmt" + "math/big" + "testing" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/tbtc" +) + +type mockMetricsRecorder struct { + counts map[string]float64 +} + +func (m *mockMetricsRecorder) IncrementCounter(name string, value float64) { + m.counts[name] += value +} + +// TestSubmitReservationReanchorProof verifies that submitReservationReanchorProof +// correctly parses a 1-input-1-output re-anchor transaction, looks up the +// matching reservation action generation, and submits the SPV proof to the +// chain. It also covers the failure paths for missing action and mismatched +// action type. +func TestSubmitReservationReanchorProof(t *testing.T) { + requiredConfirmations := uint(6) + + btcChain := newLocalBitcoinChain() + spvChain := newLocalChain() + + // Anchor transaction that the re-anchor spends. + anchorTx := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{}, + OutputIndex: 0, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 600000, + PublicKeyScript: []byte{}, + }}, + } + if err := btcChain.BroadcastTransaction(anchorTx); err != nil { + t.Fatal(err) + } + anchorTxHash := anchorTx.Hash() + + // Re-anchor transaction: 1 input spending anchorTx output 0, 1 output + // paying to the target wallet. + targetWalletPKH := [20]byte{0x92, 0xa6, 0xec, 0x88, 0x9a, 0x8f, 0xa3, 0x4f, 0x73, 0x1e, 0x63, 0x9e, 0xde, 0xde, 0x4c, 0x75, 0xe1, 0x84, 0x30, 0x7c} + targetScript, err := bitcoin.PayToWitnessPublicKeyHash(targetWalletPKH) + if err != nil { + t.Fatal(err) + } + + reanchorTx := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHash, + OutputIndex: 0, + }, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 590000, + PublicKeyScript: targetScript, + }}, + } + if err := btcChain.BroadcastTransaction(reanchorTx); err != nil { + t.Fatal(err) + } + + proof := &bitcoin.SpvProof{ + MerkleProof: []byte{0x01}, + TxIndexInBlock: 2, + BitcoinHeaders: []byte{0x03}, + } + + mockSpvProofAssembler := func( + hash bitcoin.Hash, + confirmations uint, + btcChain bitcoin.Chain, + ) (*bitcoin.Transaction, *bitcoin.SpvProof, error) { + if hash == reanchorTx.Hash() && confirmations == requiredConfirmations { + return reanchorTx, proof, nil + } + return nil, nil, fmt.Errorf("unexpected proof assembly request") + } + + reservationKey := big.NewInt(42) + requestNonce := uint64(7) + + spvChain.setReservation(reservationKey, &tbtc.Reservation{ + WalletPublicKeyHash: targetWalletPKH, + AnchorUtxo: &bitcoin.UnspentTransactionOutput{ + Outpoint: reanchorTx.Inputs[0].Outpoint, + Value: 600000, + }, + State: tbtc.ReservationStateActive, + RequestNonce: requestNonce, + }) + spvChain.setReservationAction(reservationKey, requestNonce, &tbtc.ReservationAction{ + ActionType: tbtc.ReservationActionTypeReanchor, + State: tbtc.ReservationActionStatePending, + TargetWalletPublicKeyHash: targetWalletPKH, + }) + + // Override SubmitReservationProof on the localChain to capture the call. + spvChain.submitReservationProofHook = func( + proofType uint8, + txInfo *tbtc.BitcoinTxInfo, + txProof *tbtc.BitcoinTxProof, + mainUtxo *tbtc.BitcoinTxUTXO, + rk *big.Int, + rn uint64, + ) error { + if proofType != ProofTypeReservationReanchor { + t.Errorf("unexpected proof type: got %d, want %d", proofType, ProofTypeReservationReanchor) + } + if rk == nil || rk.Cmp(reservationKey) != 0 { + t.Errorf("unexpected reservation key: got %v, want %v", rk, reservationKey) + } + if rn != requestNonce { + t.Errorf("unexpected request nonce: got %d, want %d", rn, requestNonce) + } + if mainUtxo == nil { + t.Fatal("mainUtxo must not be nil") + } + // The mainUtxo argument to SubmitReservationProof is currently + // populated from the spent anchor outpoint (see the milestone-1 + // comment on buildReservationProofMainUtxo). Assert the exact + // encoded value so a future change to that encoding does not + // silently drift without a test failure. + if mainUtxo.TxHash != anchorTxHash { + t.Errorf("unexpected UTXO tx hash: got %x, want %x", mainUtxo.TxHash, anchorTxHash) + } + if mainUtxo.TxOutputIndex != 0 { + t.Errorf("unexpected UTXO output index: got %d, want %d", mainUtxo.TxOutputIndex, 0) + } + if mainUtxo.TxOutputValue != 600000 { + t.Errorf("unexpected UTXO value: got %d, want %d", mainUtxo.TxOutputValue, 600000) + } + if txInfo == nil { + t.Fatal("txInfo must not be nil") + } + if !bytes.Equal(txProof.MerkleProof, proof.MerkleProof) { + t.Errorf("unexpected merkle proof") + } + return nil + } + + metricsRecorder := &mockMetricsRecorder{counts: make(map[string]float64)} + if err := submitReservationReanchorProof( + reanchorTx.Hash(), + requiredConfirmations, + reservationKey, + requestNonce, + btcChain, + spvChain, + mockSpvProofAssembler, + metricsRecorder, + ); err != nil { + t.Fatal(err) + } + // Check metrics. + if count := metricsRecorder.counts["reservation_reanchor_proof_submissions_total"]; count != 1 { + t.Errorf("unexpected metrics count: got %f, want 1", count) + } + + // Negative path: nil reservationKey. + if err := submitReservationReanchorProof( + reanchorTx.Hash(), + requiredConfirmations, + nil, + requestNonce, + btcChain, + spvChain, + mockSpvProofAssembler, + nil, + ); err == nil { + t.Fatal("expected error for nil reservation key") + } + + // Negative path: zero requestNonce. + if err := submitReservationReanchorProof( + reanchorTx.Hash(), + requiredConfirmations, + reservationKey, + 0, + btcChain, + spvChain, + mockSpvProofAssembler, + nil, + ); err == nil { + t.Fatal("expected error for zero request nonce") + } + + // Negative path: action generation is not Pending. + spvChain.setReservationAction(reservationKey, requestNonce, &tbtc.ReservationAction{ + ActionType: tbtc.ReservationActionTypeReanchor, + State: tbtc.ReservationActionStateSettled, + TargetWalletPublicKeyHash: targetWalletPKH, + }) + if err := submitReservationReanchorProof( + reanchorTx.Hash(), + requiredConfirmations, + reservationKey, + requestNonce, + btcChain, + spvChain, + mockSpvProofAssembler, + nil, + ); err == nil { + t.Fatal("expected error for settled action generation") + } + + // Negative path: action generation is the wrong type. + spvChain.setReservationAction(reservationKey, requestNonce, &tbtc.ReservationAction{ + ActionType: tbtc.ReservationActionTypeAcceptance, + State: tbtc.ReservationActionStatePending, + TargetWalletPublicKeyHash: targetWalletPKH, + }) + if err := submitReservationReanchorProof( + reanchorTx.Hash(), + requiredConfirmations, + reservationKey, + requestNonce, + btcChain, + spvChain, + mockSpvProofAssembler, + nil, + ); err == nil { + t.Fatal("expected error for wrong action type") + } + + // Negative path: zero requiredConfirmations. + if err := submitReservationReanchorProof( + reanchorTx.Hash(), + 0, + reservationKey, + requestNonce, + btcChain, + spvChain, + mockSpvProofAssembler, + nil, + ); err == nil { + t.Fatal("expected error for zero required confirmations") + } +} diff --git a/pkg/maintainer/spv/reservation_stale_deposit_watch.go b/pkg/maintainer/spv/reservation_stale_deposit_watch.go new file mode 100644 index 0000000000..21cfe8e7ba --- /dev/null +++ b/pkg/maintainer/spv/reservation_stale_deposit_watch.go @@ -0,0 +1,381 @@ +package spv + +import ( + "fmt" + "math/big" + + "github.com/keep-network/keep-core/pkg/tbtc" +) + +// staleDepositRevealScanLookBackBlocks bounds the reveal-timestamp fallback +// scan. 30 days at 12s/block, mirroring the convention used across this +// package. +const staleDepositRevealScanLookBackBlocks = uint64(216000) + +// StaleDepositResolution indicates the outcome of a stale deposit check +// to help callers (e.g. pollers) decide whether to keep or drop the deposit +// from tracking. +type StaleDepositResolution uint8 + +const ( + // StaleDepositResolutionUnknown is the zero value representing an unknown or errored resolution. + StaleDepositResolutionUnknown StaleDepositResolution = iota + // StaleDepositResolutionKeep indicates the deposit is still pending-stale and should be retained in the tracking set. + StaleDepositResolutionKeep + // StaleDepositResolutionDrop indicates the deposit is no longer a candidate for staleness (e.g. not reserved, settled action) and can be dropped from tracking. + StaleDepositResolutionDrop + // StaleDepositResolutionNotified indicates the deposit was confirmed stale and the notification was submitted. + StaleDepositResolutionNotified +) + +// ReservationStaleDepositWatcher observes deposit-revealed events and +// notifies the Bridge when a reserved deposit's acceptance window expired +// without the assigned wallet becoming live. +// +// A reserved deposit is a deposit that was revealed against a reservation +// vault address. The Bridge records the assigned wallet via +// `ReservedDepositWallet`. If that wallet fails to transition to StateLive +// within the reservation action timeout window, the deposit must be released +// back to the default deposit sweep path; otherwise it sits orphaned +// because the anchor can never be produced. The watcher is the backstop that +// flips the deposit's bookkeeping when the wallet never shows up. +type ReservationStaleDepositWatcher struct { + spvChain Chain + notified map[string]struct{} + memoizedTimeout map[string]staleDepositTimeoutMemo +} + +// staleDepositTimeoutMemo caches a reveal-derived staleness deadline +// alongside the ReservationActionTimeout governance parameter it was +// derived from. If governance later changes ReservationActionTimeout, the +// stored parameter no longer matches the live one and the memo is +// recomputed instead of silently reusing a stale deadline. +type staleDepositTimeoutMemo struct { + timeoutAt uint32 + reservationActionTimeout uint32 +} + +// NewReservationStaleDepositWatcher constructs a stale-deposit watcher +// bound to the given chain. +func NewReservationStaleDepositWatcher( + spvChain Chain, +) *ReservationStaleDepositWatcher { + return &ReservationStaleDepositWatcher{ + spvChain: spvChain, + notified: make(map[string]struct{}), + memoizedTimeout: make(map[string]staleDepositTimeoutMemo), + } +} + +// CheckStaleReservedDeposit is the synchronous core of the watcher. It is +// invoked by the integration's polling or deferred callback once the action +// timeout window may have elapsed. +// +// The function may submit a Bridge notification (NotifyStaleReservedDeposit) +// as a side effect, and it caches derived timeouts and notification state +// on the receiver across calls. There is no internal scheduling; the +// caller owns invocation lifecycle and synchronization. +// +// Conditions for notification: +// +// 1. `IsReservedDeposit(depositKey)` returns true. A non-reserved deposit +// is the default sweep path's responsibility; the watcher must not +// interfere with it. +// 2. The reservation's assigned wallet exists and is NOT in StateLive. +// A live wallet is expected to anchor the deposit itself; the action +// timeout window only applies when the wallet is missing or has not +// progressed to live. +// 3. The action timeout has elapsed. The watcher derives the timeout +// from the reservation action record at the current reservation +// RequestNonce. If the action has already been advanced +// (Settled/TimedOut/Superseded/Vetoed), the deposit is no longer in +// the pending-stale window and the watcher skips it without notifying. +// +// Parameters: +// - depositKey: the deposit identifier reported by the Bridge. +// - now: the UNIX timestamp against which the action timeout is +// compared. Tests pass an explicit value; production passes +// time.Now().Unix() cast to uint32. +func (rsdw *ReservationStaleDepositWatcher) CheckStaleReservedDeposit( + depositKey *big.Int, + now uint32, +) (StaleDepositResolution, error) { + if depositKey == nil { + return StaleDepositResolutionUnknown, fmt.Errorf("deposit key must not be nil") + } + if _, ok := rsdw.notified[depositKey.String()]; ok { + return StaleDepositResolutionNotified, nil + } + + isReserved, err := rsdw.spvChain.IsReservedDeposit(depositKey) + if err != nil { + return StaleDepositResolutionUnknown, fmt.Errorf( + "failed to determine if deposit [%v] is reserved: [%v]", + depositKey, + err, + ) + } + if !isReserved { + logger.Debugf( + "deposit [%v] is not a reserved deposit; skipping stale check", + depositKey, + ) + return StaleDepositResolutionDrop, nil + } + + walletPublicKeyHash, err := rsdw.spvChain.ReservedDepositWallet(depositKey) + if err != nil { + return StaleDepositResolutionUnknown, fmt.Errorf( + "failed to fetch wallet for reserved deposit [%v]: [%v]", + depositKey, + err, + ) + } + + // The Bridge only assigns a non-zero wallet to a reserved deposit. + // Defensive: if the wallet is zero the deposit bookkeeping is broken; + // rather than notify on partial information, we skip with a warning. + if walletPublicKeyHash == ([20]byte{}) { + logger.Warnf( + "reserved deposit [%v] has no wallet assigned; "+ + "skipping stale notification", + depositKey, + ) + return StaleDepositResolutionDrop, nil + } + + wallet, err := rsdw.spvChain.GetWallet(walletPublicKeyHash) + if err != nil { + return StaleDepositResolutionUnknown, fmt.Errorf( + "failed to fetch wallet [0x%x] for reserved deposit [%v]: [%v]", + walletPublicKeyHash, + depositKey, + err, + ) + } + + // Wallet live: anchor is expected on its own. The watcher does not + // interfere. + if wallet.State == tbtc.StateLive { + logger.Debugf( + "reserved deposit [%v] assigned to live wallet [0x%x]; "+ + "anchor expected; skipping stale notification", + depositKey, + walletPublicKeyHash, + ) + return StaleDepositResolutionKeep, nil + } + + // In m1 the reservation key and deposit key share the same identifier + // space exposed by the Bridge (ReservedDepositWallet and Reservation + // are both keyed by the same value); future revisions of the Bridge + // may introduce disjoint identifiers, in which case this direct use + // of depositKey as reservationKey must be replaced with a real lookup. + reservationKey := depositKey + + reservation, err := rsdw.spvChain.GetReservation(reservationKey) + if err != nil { + return StaleDepositResolutionUnknown, fmt.Errorf( + "failed to fetch reservation [%v]: [%v]", + reservationKey, + err, + ) + } + + var timeoutAt uint32 + if reservation.RequestNonce == 0 { + // No acceptance action generation has ever been requested on-chain + // for this reservation. Derive the staleness deadline from the + // deposit's own reveal timestamp instead of the (nonexistent) action's + // TimeoutAt: find the DepositRevealed event for this deposit key among + // the wallet's events, then load the deposit request's RevealedAt. + derivedTimeout, err := rsdw.deriveTimeoutFromReveal( + depositKey, + walletPublicKeyHash, + ) + if err != nil { + return StaleDepositResolutionUnknown, err + } + timeoutAt = derivedTimeout + } else { + action, err := rsdw.spvChain.GetReservationAction( + reservationKey, + reservation.RequestNonce, + ) + if err != nil { + return StaleDepositResolutionUnknown, fmt.Errorf( + "failed to fetch reservation action for [%v] nonce [%d]: [%v]", + reservationKey, + reservation.RequestNonce, + err, + ) + } + + if action.State == tbtc.ReservationActionStateUnknown { + // Confirmed no action generation exists yet on-chain for this nonce. + // Derive the staleness deadline from the deposit's own reveal timestamp. + derivedTimeout, err := rsdw.deriveTimeoutFromReveal( + depositKey, + walletPublicKeyHash, + ) + if err != nil { + return StaleDepositResolutionUnknown, err + } + timeoutAt = derivedTimeout + } else if action.State != tbtc.ReservationActionStatePending { + logger.Debugf( + "reservation [%v] acceptance action state=%s; "+ + "deposit [%v] is no longer pending-stale; skipping", + reservationKey, + action.State, + depositKey, + ) + return StaleDepositResolutionDrop, nil + } else { + timeoutAt = action.TimeoutAt + } + } + + if now <= timeoutAt { + logger.Debugf( + "reserved deposit [%v] action timeout at [%d] not yet reached "+ + "(now=%d); deferring stale notification", + depositKey, + timeoutAt, + now, + ) + return StaleDepositResolutionKeep, nil + } + + if err := rsdw.spvChain.NotifyStaleReservedDeposit(depositKey); err != nil { + return StaleDepositResolutionUnknown, fmt.Errorf( + "failed to notify stale reserved deposit [%v]: [%v]", + depositKey, + err, + ) + } + rsdw.notified[depositKey.String()] = struct{}{} + + logger.Infof( + "notified stale reserved deposit [%v] "+ + "(wallet [0x%x] state=%s, action timeout %d)", + depositKey, + walletPublicKeyHash, + wallet.State, + timeoutAt, + ) + + return StaleDepositResolutionNotified, nil +} + +// forgetDeposit clears any cached notification state and memoized +// staleness deadline 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.notified, key) + delete(rsdw.memoizedTimeout, key) +} + +func (rsdw *ReservationStaleDepositWatcher) deriveTimeoutFromReveal( + depositKey *big.Int, + walletPublicKeyHash [20]byte, +) (uint32, error) { + params, paramsErr := rsdw.spvChain.ReservationParameters() + if paramsErr != nil { + return 0, fmt.Errorf( + "failed to load reservation parameters for staleness "+ + "deadline derivation: [%v]", + paramsErr, + ) + } + + if memo, ok := rsdw.memoizedTimeout[depositKey.String()]; ok && + memo.reservationActionTimeout == params.ReservationActionTimeout { + return memo.timeoutAt, nil + } + + blockCounter, err := rsdw.spvChain.BlockCounter() + if err != nil { + return 0, fmt.Errorf( + "failed to get block counter for staleness deadline derivation: [%v]", + err, + ) + } + if blockCounter == nil { + return 0, fmt.Errorf( + "failed to get block counter for staleness deadline derivation: nil block counter", + ) + } + currentBlock, err := blockCounter.CurrentBlock() + if err != nil { + return 0, fmt.Errorf( + "failed to get current block for staleness deadline derivation: [%v]", + err, + ) + } + + startBlock := uint64(0) + if currentBlock > staleDepositRevealScanLookBackBlocks { + startBlock = currentBlock - staleDepositRevealScanLookBackBlocks + } + + 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 staleness "+ + "deadline derivation: [%v]", + 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]", + depositKey, + ) + } + + depositRequest, found, requestErr := rsdw.spvChain.GetDepositRequest( + matchingEvent.FundingTxHash, + matchingEvent.FundingOutputIndex, + ) + if requestErr != nil { + return 0, fmt.Errorf( + "failed to load deposit request for staleness deadline "+ + "derivation: [%v]", + requestErr, + ) + } + if !found { + return 0, fmt.Errorf( + "deposit request not found for deposit [%v]", + depositKey, + ) + } + + result := uint32(depositRequest.RevealedAt.Unix()) + params.ReservationActionTimeout + rsdw.memoizedTimeout[depositKey.String()] = staleDepositTimeoutMemo{ + timeoutAt: result, + reservationActionTimeout: params.ReservationActionTimeout, + } + return result, nil +} diff --git a/pkg/maintainer/spv/reservation_stale_deposit_watch_test.go b/pkg/maintainer/spv/reservation_stale_deposit_watch_test.go new file mode 100644 index 0000000000..8edea5b4e7 --- /dev/null +++ b/pkg/maintainer/spv/reservation_stale_deposit_watch_test.go @@ -0,0 +1,701 @@ +package spv + +import ( + "fmt" + "math/big" + "testing" + "time" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/tbtc" + + "github.com/go-test/deep" +) + +// reservationDepositKey returns a big.Int constructed from a uint64 to act +// as a reserved-deposit identifier in the stale-deposit watcher tests. +func reservationDepositKey(low uint64) *big.Int { + return new(big.Int).SetUint64(low) +} + +// reservationActionTimeout is the fixed action timeout used by the tests. +// It is large enough to keep the timeout ordering robust against any +// timestamp arithmetic in the watcher. +const reservationActionTimeout uint32 = 3600 + +func seedPastDepositRevealedEvent( + t *testing.T, + spvChain *localChain, + wallet [20]byte, + fundingTxHash bitcoin.Hash, + fundingOutputIndex uint32, + currentBlock uint64, +) { + t.Helper() + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(currentBlock) + spvChain.setBlockCounter(blockCounter) + + startBlock := uint64(0) + if currentBlock > staleDepositRevealScanLookBackBlocks { + startBlock = currentBlock - staleDepositRevealScanLookBackBlocks + } + endBlock := currentBlock + if err := spvChain.addPastDepositRevealedEvent( + &tbtc.DepositRevealedEventFilter{ + StartBlock: startBlock, + EndBlock: &endBlock, + WalletPublicKeyHash: [][20]byte{wallet}, + }, + &tbtc.DepositRevealedEvent{ + FundingTxHash: fundingTxHash, + FundingOutputIndex: fundingOutputIndex, + WalletPublicKeyHash: wallet, + }, + ); err != nil { + t.Fatal(err) + } +} + +func TestReservationStaleDepositWatcher_NonReservedDepositIsSkipped(t *testing.T) { + spvChain := newLocalChain() + + // Deposit is NOT booked as reserved. + spvChain.setReservedDeposit(reservationDepositKey(0xB001), walletPKH(), false) + + watcher := NewReservationStaleDepositWatcher(spvChain) + res, err := watcher.CheckStaleReservedDeposit(reservationDepositKey(0xB001), 5_000) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res != StaleDepositResolutionDrop { + t.Fatalf("expected resolution %v, got %v", StaleDepositResolutionDrop, res) + } + + if calls := spvChain.getSubmittedStaleReservedDeposits(); len(calls) != 0 { + t.Fatalf("non-reserved deposit must not notify, got %d calls", len(calls)) + } +} + +// TestReservationStaleDepositWatcher_LiveWalletIsKeptNotDropped verifies +// that a reserved deposit assigned to a Live wallet does not notify and +// resolves to Keep, not Drop: the wallet may still transition away from +// Live (e.g. MovingFunds/Closing/Terminated) before anchoring, and the +// poller's forward-only scan cursor means a deposit dropped here could +// never re-enter tracking to be caught later. +func TestReservationStaleDepositWatcher_LiveWalletIsKeptNotDropped(t *testing.T) { + spvChain := newLocalChain() + + key := reservationDepositKey(0xB002) + wallet := walletPKH() + spvChain.setReservedDeposit(key, wallet, true) + spvChain.setWallet(wallet, &tbtc.WalletChainData{ + State: tbtc.StateLive, + }) + + watcher := NewReservationStaleDepositWatcher(spvChain) + res, err := watcher.CheckStaleReservedDeposit(key, 10_000) + 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("live wallet must not trigger stale notification, got %d calls", len(calls)) + } +} + +func TestReservationStaleDepositWatcher_NotifiesAfterTimeout(t *testing.T) { + spvChain := newLocalChain() + + key := reservationDepositKey(0xB003) + wallet := walletPKH() + spvChain.setReservedDeposit(key, wallet, true) + spvChain.setWallet(wallet, &tbtc.WalletChainData{ + State: tbtc.StateUnknown, + }) + spvChain.setReservation(key, &tbtc.Reservation{ + RequestNonce: 1, + }) + + // Inject the acceptance (nonce 1) action with a deadline well below + // `now`. + spvChain.setReservationAction(key, 1, &tbtc.ReservationAction{ + State: tbtc.ReservationActionStatePending, + TimeoutAt: 100, + }) + spvChain.setReservationParameters(&tbtc.ReservationParameters{ + ReservationActionTimeout: reservationActionTimeout, + }) + + watcher := NewReservationStaleDepositWatcher(spvChain) + // now (5_000) > action.TimeoutAt (100). + res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res != StaleDepositResolutionNotified { + t.Fatalf("expected resolution %v, got %v", StaleDepositResolutionNotified, 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) + } +} + +func TestReservationStaleDepositWatcher_DoesNotNotifyBeforeTimeout(t *testing.T) { + spvChain := newLocalChain() + + key := reservationDepositKey(0xB004) + wallet := walletPKH() + spvChain.setReservedDeposit(key, wallet, true) + spvChain.setWallet(wallet, &tbtc.WalletChainData{ + State: tbtc.StateUnknown, + }) + spvChain.setReservation(key, &tbtc.Reservation{ + RequestNonce: 1, + }) + + // Action has a deadline of 10_000; we ask the watcher to evaluate at + // now=5_000, which is before the deadline. + spvChain.setReservationAction(key, 1, &tbtc.ReservationAction{ + State: tbtc.ReservationActionStatePending, + TimeoutAt: 10_000, + }) + spvChain.setReservationParameters(&tbtc.ReservationParameters{ + ReservationActionTimeout: reservationActionTimeout, + }) + + watcher := NewReservationStaleDepositWatcher(spvChain) + res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + 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("action not yet timed out; expected zero notifications, got %d", len(calls)) + } +} + +func TestReservationStaleDepositWatcher_SettledActionIsSkipped(t *testing.T) { + spvChain := newLocalChain() + + key := reservationDepositKey(0xB005) + wallet := walletPKH() + spvChain.setReservedDeposit(key, wallet, true) + spvChain.setWallet(wallet, &tbtc.WalletChainData{ + State: tbtc.StateUnknown, + }) + spvChain.setReservation(key, &tbtc.Reservation{ + RequestNonce: 1, + }) + + // Action is already settled (no longer pending). The watcher must skip + // the stale notification even though the wall clock has passed the + // deadline. + spvChain.setReservationAction(key, 1, &tbtc.ReservationAction{ + State: tbtc.ReservationActionStateSettled, + TimeoutAt: 100, + }) + + watcher := NewReservationStaleDepositWatcher(spvChain) + res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res != StaleDepositResolutionDrop { + t.Fatalf("expected resolution %v, got %v", StaleDepositResolutionDrop, res) + } + + if calls := spvChain.getSubmittedStaleReservedDeposits(); len(calls) != 0 { + t.Fatalf("settled action must skip stale notification, got %d calls", len(calls)) + } +} + +func TestReservationStaleDepositWatcher_ZeroWalletSkips(t *testing.T) { + spvChain := newLocalChain() + + key := reservationDepositKey(0xB006) + spvChain.setReservedDeposit(key, [20]byte{}, true) + + watcher := NewReservationStaleDepositWatcher(spvChain) + res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res != StaleDepositResolutionDrop { + t.Fatalf("expected resolution %v, got %v", StaleDepositResolutionDrop, res) + } + + if calls := spvChain.getSubmittedStaleReservedDeposits(); len(calls) != 0 { + t.Fatalf("zero-wallet deposit must skip, got %d calls", len(calls)) + } +} + +func TestReservationStaleDepositWatcher_AlreadyNotifiedReturnsNotified(t *testing.T) { + spvChain := newLocalChain() + + key := reservationDepositKey(0xB007) + wallet := walletPKH() + spvChain.setReservedDeposit(key, wallet, true) + spvChain.setWallet(wallet, &tbtc.WalletChainData{ + State: tbtc.StateUnknown, + }) + spvChain.setReservation(key, &tbtc.Reservation{ + RequestNonce: 1, + }) + spvChain.setReservationAction(key, 1, &tbtc.ReservationAction{ + State: tbtc.ReservationActionStatePending, + TimeoutAt: 100, + }) + spvChain.setReservationParameters(&tbtc.ReservationParameters{ + ReservationActionTimeout: reservationActionTimeout, + }) + + watcher := NewReservationStaleDepositWatcher(spvChain) + res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res != StaleDepositResolutionNotified { + t.Fatalf("expected resolution %v, got %v", StaleDepositResolutionNotified, res) + } + + // Second check for already notified deposit returns Notified without resubmitting. + res, err = watcher.CheckStaleReservedDeposit(key, 5_000) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res != StaleDepositResolutionNotified { + t.Fatalf("expected resolution %v, got %v", StaleDepositResolutionNotified, res) + } + + if calls := spvChain.getSubmittedStaleReservedDeposits(); len(calls) != 1 { + t.Fatalf("expected exactly one notification, got %d", len(calls)) + } +} + +func TestReservationStaleDepositWatcher_NilDepositKeyError(t *testing.T) { + spvChain := newLocalChain() + + watcher := NewReservationStaleDepositWatcher(spvChain) + res, err := watcher.CheckStaleReservedDeposit(nil, 5_000) + if err == nil { + t.Fatal("expected error for nil deposit key, got nil") + } + if res != StaleDepositResolutionUnknown { + t.Fatalf("expected resolution %v, got %v", StaleDepositResolutionUnknown, res) + } +} + +func TestReservationStaleDepositWatcher_IsReservedDepositChainError(t *testing.T) { + spvChain := newLocalChain() + + spvChain.isReservedDepositErr = fmt.Errorf("rpc unavailable") + + watcher := NewReservationStaleDepositWatcher(spvChain) + res, err := watcher.CheckStaleReservedDeposit(reservationDepositKey(0xB010), 5_000) + if err == nil { + t.Fatal("expected error when IsReservedDeposit fails, 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_ReservedDepositWalletChainError(t *testing.T) { + spvChain := newLocalChain() + + key := reservationDepositKey(0xB011) + spvChain.setReservedDeposit(key, walletPKH(), true) + spvChain.reservedDepositWalletErr = fmt.Errorf("rpc unavailable") + + watcher := NewReservationStaleDepositWatcher(spvChain) + res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + if err == nil { + t.Fatal("expected error when ReservedDepositWallet fails, 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_GetWalletChainError(t *testing.T) { + spvChain := newLocalChain() + + key := reservationDepositKey(0xB012) + spvChain.setReservedDeposit(key, walletPKH(), true) + // No spvChain.setWallet call: GetWallet errors naturally. + + watcher := NewReservationStaleDepositWatcher(spvChain) + res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + if err == nil { + t.Fatal("expected error when GetWallet fails, 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_GetReservationChainError(t *testing.T) { + spvChain := newLocalChain() + + key := reservationDepositKey(0xB016) + wallet := walletPKH() + spvChain.setReservedDeposit(key, wallet, true) + spvChain.setWallet(wallet, &tbtc.WalletChainData{ + State: tbtc.StateUnknown, + }) + // No spvChain.setReservation: GetReservation returns an error. + + watcher := NewReservationStaleDepositWatcher(spvChain) + res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + if err == nil { + t.Fatal("expected error when GetReservation fails, 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)) + } +} + +// TestReservationStaleDepositWatcher_GetReservationActionChainError_DoesNotNotifyEvenPastDeadline +// verifies that a transient RPC error on GetReservationAction is +// propagated as an error and MUST NOT fall through to the reveal-timestamp +// staleness path or trigger a premature stale deposit notification. +func TestReservationStaleDepositWatcher_GetReservationActionChainError_DoesNotNotifyEvenPastDeadline(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: 1, + }) + // Seed deposit revealed event and request with a deadline in the past: + // RevealedAt (1_000) + Timeout (3600) = 4_600. + seedPastDepositRevealedEvent(t, spvChain, wallet, fundingTxHash, fundingOutputIndex, 0) + spvChain.setDepositRequest(fundingTxHash, fundingOutputIndex, &tbtc.DepositChainRequest{ + RevealedAt: time.Unix(1_000, 0), + }) + spvChain.setReservationParameters(&tbtc.ReservationParameters{ + ReservationActionTimeout: reservationActionTimeout, + }) + + // GetReservationAction is NOT seeded, so it returns an error ("no action for given reservation/nonce"). + watcher := NewReservationStaleDepositWatcher(spvChain) + // now = 10_000 is well past the 4_600 reveal-derived deadline. A + // transient RPC error must be surfaced as an error and must not be + // conflated with an "action generation not yet created" Unknown state, + // which would fall through to the reveal fallback and submit a + // premature stale deposit notification. + res, err := watcher.CheckStaleReservedDeposit(key, 10_000) + if err == nil { + t.Fatal("expected error on transient GetReservationAction RPC failure, got nil") + } + if res != StaleDepositResolutionUnknown { + t.Fatalf("expected resolution %v, got %v", StaleDepositResolutionUnknown, res) + } + + if calls := spvChain.getSubmittedStaleReservedDeposits(); len(calls) != 0 { + t.Fatalf( + "transient RPC error must NOT trigger stale deposit notification, got %d calls", + len(calls), + ) + } +} + +// TestReservationStaleDepositWatcher_AdvancingNonceEvaluatesActiveGeneration +// verifies that the watcher reads reservation.RequestNonce via +// GetReservation rather than assuming a hardcoded nonce = 1. If nonce 1 +// timed out and a retry advanced the nonce to 2, the watcher must evaluate +// nonce 2's action generation, the current one. +func TestReservationStaleDepositWatcher_AdvancingNonceEvaluatesActiveGeneration(t *testing.T) { + spvChain := newLocalChain() + + key := reservationDepositKey(0xB020) + wallet := walletPKH() + spvChain.setReservedDeposit(key, wallet, true) + spvChain.setWallet(wallet, &tbtc.WalletChainData{ + State: tbtc.StateUnknown, + }) + // Reservation has advanced to nonce 2 (e.g. after retry). + spvChain.setReservation(key, &tbtc.Reservation{ + RequestNonce: 2, + }) + + // Nonce 1 is TimedOut (stale generation). + spvChain.setReservationAction(key, 1, &tbtc.ReservationAction{ + State: tbtc.ReservationActionStateTimedOut, + TimeoutAt: 100, + }) + // Nonce 2 is Pending with a timeout in the past relative to now (5_000). + spvChain.setReservationAction(key, 2, &tbtc.ReservationAction{ + State: tbtc.ReservationActionStatePending, + TimeoutAt: 200, + }) + spvChain.setReservationParameters(&tbtc.ReservationParameters{ + ReservationActionTimeout: reservationActionTimeout, + }) + + watcher := NewReservationStaleDepositWatcher(spvChain) + // now = 5_000 > nonce 2's TimeoutAt (200). + res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res != StaleDepositResolutionNotified { + t.Fatalf("expected resolution %v, got %v", StaleDepositResolutionNotified, res) + } + + calls := spvChain.getSubmittedStaleReservedDeposits() + if len(calls) != 1 { + t.Fatalf("expected one stale notification for nonce 2 timeout, got %d", len(calls)) + } + if diff := deep.Equal(key, calls[0]); diff != nil { + t.Errorf("unexpected notified key: %v", diff) + } +} + +// TestReservationStaleDepositWatcher_NoActionRequestedYetPropagatesWithoutMatchingEvent +// covers the "no acceptance action generation exists yet" branch +// (reservation.RequestNonce == 0 or action.State == ReservationActionStateUnknown) +// when no matching DepositRevealed event has been seeded either: the +// watcher cannot derive a staleness deadline from nothing, so it must +func TestReservationStaleDepositWatcher_NoActionRequestedYetPropagatesWithoutMatchingEvent(t *testing.T) { + spvChain := newLocalChain() + spvChain.setBlockCounter(newMockBlockCounter()) + + key := reservationDepositKey(0xB013) + wallet := walletPKH() + spvChain.setReservedDeposit(key, wallet, true) + spvChain.setWallet(wallet, &tbtc.WalletChainData{ + State: tbtc.StateUnknown, + }) + spvChain.setReservation(key, &tbtc.Reservation{ + RequestNonce: 0, + }) + + watcher := NewReservationStaleDepositWatcher(spvChain) + 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)) + } +} + +// TestReservationStaleDepositWatcher_NoActionRequestedYetNotifiesFromRevealTimestamp +// tests the reveal-timestamp derivation: a reserved deposit whose wallet never +// became Live, so its acceptance action generation was never requested +// on-chain (RequestNonce == 0). The watcher must derive the staleness deadline +// from the deposit's own reveal timestamp plus ReservationActionTimeout, +// and notify once that derived deadline has passed. +func TestReservationStaleDepositWatcher_NoActionRequestedYetNotifiesFromRevealTimestamp(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) + spvChain.setDepositRequest(fundingTxHash, fundingOutputIndex, &tbtc.DepositChainRequest{ + RevealedAt: time.Unix(1_000, 0), + }) + spvChain.setReservationParameters(&tbtc.ReservationParameters{ + ReservationActionTimeout: reservationActionTimeout, // 3600 + }) + + watcher := NewReservationStaleDepositWatcher(spvChain) + // Derived deadline = RevealedAt (1_000) + ReservationActionTimeout + // (3600) = 4_600. now = 10_000 > 4_600, so the deposit is stale. + res, err := watcher.CheckStaleReservedDeposit(key, 10_000) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res != StaleDepositResolutionNotified { + t.Fatalf("expected resolution %v, got %v", StaleDepositResolutionNotified, 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_NoActionRequestedYetDoesNotNotifyBeforeDerivedDeadline +// mirrors the notifying case above but asks at a `now` before the derived +// deadline, asserting the watcher correctly defers rather than notifying early. +func TestReservationStaleDepositWatcher_NoActionRequestedYetDoesNotNotifyBeforeDerivedDeadline(t *testing.T) { + spvChain := newLocalChain() + + wallet := walletPKH() + fundingTxHash, err := bitcoin.NewHashFromString( + "7cff663e3e08847a5579913f6a66bc6c01f5f48c6ae1783be77418ed188021e6", + bitcoin.InternalByteOrder, + ) + if err != nil { + t.Fatal(err) + } + fundingOutputIndex := uint32(1) + + 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) + spvChain.setDepositRequest(fundingTxHash, fundingOutputIndex, &tbtc.DepositChainRequest{ + RevealedAt: time.Unix(1_000, 0), + }) + spvChain.setReservationParameters(&tbtc.ReservationParameters{ + ReservationActionTimeout: reservationActionTimeout, // 3600 + }) + + watcher := NewReservationStaleDepositWatcher(spvChain) + // Derived deadline = 1_000 + 3600 = 4_600. now = 2_000 < 4_600. + res, err := watcher.CheckStaleReservedDeposit(key, 2_000) + 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( + "derived deadline not yet reached; expected zero notifications, got %d", + len(calls), + ) + } +} + +// TestReservationStaleDepositWatcher_NotifierError verifies that the +// stale-deposit watcher propagates a NotifyStaleReservedDeposit failure. +func TestReservationStaleDepositWatcher_NotifierError(t *testing.T) { + spvChain := newLocalChain() + + key := reservationDepositKey(0xB014) + wallet := walletPKH() + spvChain.setReservedDeposit(key, wallet, true) + spvChain.setWallet(wallet, &tbtc.WalletChainData{ + State: tbtc.StateUnknown, + }) + spvChain.setReservation(key, &tbtc.Reservation{ + RequestNonce: 1, + }) + spvChain.setReservationAction(key, 1, &tbtc.ReservationAction{ + State: tbtc.ReservationActionStatePending, + TimeoutAt: 100, + }) + spvChain.notifyStaleReservedDepositErr = fmt.Errorf("notifier unavailable") + + watcher := NewReservationStaleDepositWatcher(spvChain) + res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + if err == nil { + t.Fatal("expected error when the notifier fails, got nil") + } + if res != StaleDepositResolutionUnknown { + t.Fatalf("expected resolution %v, got %v", StaleDepositResolutionUnknown, res) + } +} + +// TestReservationStaleDepositWatcher_ExactTimeoutBoundaryDoesNotNotify +// covers the `now == action.TimeoutAt` boundary explicitly: the watcher's +// condition is `now <= action.TimeoutAt` (must NOT notify), so equality +// must defer exactly like "before the deadline" does. +func TestReservationStaleDepositWatcher_ExactTimeoutBoundaryDoesNotNotify(t *testing.T) { + spvChain := newLocalChain() + + key := reservationDepositKey(0xB015) + wallet := walletPKH() + spvChain.setReservedDeposit(key, wallet, true) + spvChain.setWallet(wallet, &tbtc.WalletChainData{ + State: tbtc.StateUnknown, + }) + spvChain.setReservation(key, &tbtc.Reservation{ + RequestNonce: 1, + }) + spvChain.setReservationAction(key, 1, &tbtc.ReservationAction{ + State: tbtc.ReservationActionStatePending, + TimeoutAt: 5_000, + }) + + watcher := NewReservationStaleDepositWatcher(spvChain) + res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + 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( + "now == action.TimeoutAt must not notify, got %d calls", + len(calls), + ) + } +} diff --git a/pkg/maintainer/spv/reservation_stranding_watch.go b/pkg/maintainer/spv/reservation_stranding_watch.go new file mode 100644 index 0000000000..58678b3d7b --- /dev/null +++ b/pkg/maintainer/spv/reservation_stranding_watch.go @@ -0,0 +1,101 @@ +package spv + +import ( + "fmt" + + "github.com/keep-network/keep-core/pkg/tbtc" +) + +// reservationStrandingWatcher observes wallet close/termination events and +// notifies the Bridge of any reservation whose anchor is now stranded. +// +// In tBTC v2 wallets, a live reservation anchor is held in a wallet-controlled +// output. When the wallet is closed or terminated the anchor is stranded: +// the keyset can no longer sign a redemption, reanchor, or dissolution +// transaction for that reservation. The Bridge must be informed so the +// reservation can transition to ReservationStateStranded and the anchor can +// be reconciled via the owner-facing late settlement path. +type reservationStrandingWatcher struct { + spvChain Chain +} + +// newReservationStrandingWatcher constructs a stranding watcher bound to the +// given chain. +// +// The watcher is intended to be wired to wallet-close events via a subscription +func newReservationStrandingWatcher(spvChain Chain) *reservationStrandingWatcher { + return &reservationStrandingWatcher{ + spvChain: spvChain, + } +} + +// checkReservationStrandingForWallet walks the reservations currently +// custodied by walletPublicKeyHash and forwards a stray notification to the +// Bridge for every reservation whose state is Active. +// +// This is the single-shot form used both by tests and by the integration +// wiring that subscribes to wallet close/termination events. It is +// intentionally synchronous and per-wallet: the caller decides which wallets +// to inspect, and the watcher does not run a background loop of its own. +// +// The function is idempotent at the chain level: notifying an already-stranded +// reservation is a no-op on the Bridge side. It is the caller's +// responsibility to dedupe notifications across watcher restarts; the watcher +// never silently drops or coalesces calls. +func (rsw *reservationStrandingWatcher) checkReservationStrandingForWallet( + walletPublicKeyHash [20]byte, +) error { + keys, err := rsw.spvChain.WalletReservations(walletPublicKeyHash) + if err != nil { + return fmt.Errorf( + "failed to fetch reservations for wallet [0x%x]: [%v]", + walletPublicKeyHash, + err, + ) + } + + for _, key := range keys { + if key == nil { + continue + } + + reservation, err := rsw.spvChain.GetReservation(key) + if err != nil { + logger.Errorf( + "failed to fetch reservation [%v]: [%v]; skipping", + key, + err, + ) + continue + } + + // A reservation with a pending action generation must be left for the + // action-timeout watcher. Marking it stranded would preempt a healthy + // settlement path and trigger gratuitous reconciliation cost for the + // owner. + if reservation.State != tbtc.ReservationStateActive { + logger.Debugf( + "reservation [%v] is not Active (state: %v); "+ + "deferring stray notification to action-timeout watcher", + key, + reservation.State, + ) + continue + } + + if err := rsw.spvChain.NotifyReservationStranded(key); err != nil { + logger.Errorf( + "failed to notify stranded reservation [%v]: [%v]", + key, + err, + ) + // Continue with the remaining reservations: a single failure + // must not starve the others. + continue + } + + logger.Infof("notified stranded reservation [%v]", key) + } + + return nil +} diff --git a/pkg/maintainer/spv/reservation_stranding_watch_test.go b/pkg/maintainer/spv/reservation_stranding_watch_test.go new file mode 100644 index 0000000000..46b142504d --- /dev/null +++ b/pkg/maintainer/spv/reservation_stranding_watch_test.go @@ -0,0 +1,296 @@ +package spv + +import ( + "fmt" + "math/big" + "testing" + + "github.com/keep-network/keep-core/pkg/tbtc" + + "github.com/go-test/deep" +) + +// reservationKey constructs an in-memory reservation key with the given low +// 16 bytes set. The watcher only consults `GetReservation` via the local +// chain's [16]byte map key, so compact test keys avoid accidental collisions +// across test cases. +func reservationKey(low uint64) *big.Int { + return new(big.Int).SetUint64(low) +} + +// walletPKH is a deterministic wallet public key hash used in the stranding +// tests. Tests that need a different wallet use walletPKHAt. +func walletPKH() [20]byte { + var out [20]byte + out[19] = 0x42 + return out +} + +// walletPKHAt returns a wallet PKH with the trailing byte set to byte b. It +// exists to make multi-wallet tests readable. +func walletPKHAt(b byte) [20]byte { + var out [20]byte + out[19] = b + return out +} + +func TestReservationStrandingWatcher_NoReservations(t *testing.T) { + spvChain := newLocalChain() + + watcher := newReservationStrandingWatcher(spvChain) + if watcher == nil { + t.Fatal("expected non-nil watcher") + } + + if err := watcher.checkReservationStrandingForWallet(walletPKH()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if calls := spvChain.getSubmittedReservationStrandedKeys(); len(calls) != 0 { + t.Fatalf( + "expected no notifications, got %d", + len(calls), + ) + } +} + +func TestReservationStrandingWatcher_NotifiesActiveReservation(t *testing.T) { + spvChain := newLocalChain() + + wallet := walletPKH() + key := reservationKey(0xAA01) + + spvChain.setWalletReservations(wallet, []*big.Int{key}) + spvChain.setReservation(key, &tbtc.Reservation{ + State: tbtc.ReservationStateActive, + }) + + watcher := newReservationStrandingWatcher(spvChain) + if err := watcher.checkReservationStrandingForWallet(wallet); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + calls := spvChain.getSubmittedReservationStrandedKeys() + if len(calls) != 1 { + t.Fatalf("expected one notification, got %d", len(calls)) + } + if diff := deep.Equal(key, calls[0]); diff != nil { + t.Errorf("unexpected notified key: %v", diff) + } +} + +func TestReservationStrandingWatcher_SkipsClosedReservation(t *testing.T) { + spvChain := newLocalChain() + + wallet := walletPKH() + key := reservationKey(0xAA02) + + spvChain.setWalletReservations(wallet, []*big.Int{key}) + spvChain.setReservation(key, &tbtc.Reservation{ + State: tbtc.ReservationStateClosed, + }) + + watcher := newReservationStrandingWatcher(spvChain) + if err := watcher.checkReservationStrandingForWallet(wallet); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if calls := spvChain.getSubmittedReservationStrandedKeys(); len(calls) != 0 { + t.Fatalf("expected no notifications, got %d", len(calls)) + } +} + +func TestReservationStrandingWatcher_SkipsPendingReservation(t *testing.T) { + spvChain := newLocalChain() + + wallet := walletPKH() + key := reservationKey(0xAA03) + + spvChain.setWalletReservations(wallet, []*big.Int{key}) + spvChain.setReservation(key, &tbtc.Reservation{ + State: tbtc.ReservationStateActionPending, + }) + + watcher := newReservationStrandingWatcher(spvChain) + if err := watcher.checkReservationStrandingForWallet(wallet); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if calls := spvChain.getSubmittedReservationStrandedKeys(); len(calls) != 0 { + t.Fatalf( + "expected pending reservation to defer to action-timeout, "+ + "got %d notifications", + len(calls), + ) + } +} + +func TestReservationStrandingWatcher_MultipleReservations(t *testing.T) { + spvChain := newLocalChain() + + wallet := walletPKH() + active := reservationKey(0xAA10) + closed := reservationKey(0xAA11) + pending := reservationKey(0xAA12) + stranded := reservationKey(0xAA13) + + spvChain.setWalletReservations( + wallet, + []*big.Int{active, closed, pending, stranded}, + ) + spvChain.setReservation(active, &tbtc.Reservation{ + State: tbtc.ReservationStateActive, + }) + spvChain.setReservation(closed, &tbtc.Reservation{ + State: tbtc.ReservationStateClosed, + }) + spvChain.setReservation(pending, &tbtc.Reservation{ + State: tbtc.ReservationStateActionPending, + }) + spvChain.setReservation(stranded, &tbtc.Reservation{ + State: tbtc.ReservationStateStranded, + }) + + watcher := newReservationStrandingWatcher(spvChain) + if err := watcher.checkReservationStrandingForWallet(wallet); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // The watcher must notify only for reservations in the Active state + // (finding #27's allow-list fix): closed, pending, and stranded + // reservations must all be skipped - a stranded reservation has + // already been notified once and re-notifying it is redundant, and a + // closed reservation was already resolved through in-kind redemption + // or another terminal path, not stranding. + calls := spvChain.getSubmittedReservationStrandedKeys() + if len(calls) != 1 { + t.Fatalf( + "expected exactly one notification (active only), "+ + "got %d: %v", + len(calls), + calls, + ) + } + if diff := deep.Equal(active, calls[0]); diff != nil { + t.Errorf("unexpected notified key: %v", diff) + } +} + +func TestReservationStrandingWatcher_UnknownReservationIsSkipped(t *testing.T) { + spvChain := newLocalChain() + + wallet := walletPKH() + staleKey := reservationKey(0xAA20) + freshKey := reservationKey(0xAA21) + + // walletReservations references staleKey but the chain has no record of + // it. freshKey is properly recorded. + spvChain.setWalletReservations( + wallet, + []*big.Int{staleKey, freshKey}, + ) + spvChain.setReservation(freshKey, &tbtc.Reservation{ + State: tbtc.ReservationStateActive, + }) + + watcher := newReservationStrandingWatcher(spvChain) + if err := watcher.checkReservationStrandingForWallet(wallet); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + calls := spvChain.getSubmittedReservationStrandedKeys() + if len(calls) != 1 { + t.Fatalf("expected one notification (freshKey), got %d", len(calls)) + } + if diff := deep.Equal(freshKey, calls[0]); diff != nil { + t.Errorf("unexpected notified key: %v", diff) + } +} + +func TestReservationStrandingWatcher_WalletChainError(t *testing.T) { + spvChain := newLocalChain() + + // No walletReservations entry: WalletReservations returns (nil, nil) for + // unknown wallets; the watcher iterates over a nil slice and exits + // cleanly, so we expect no error here. Reserve the case for wallets that + // have an entry which the chain then refuses to enumerate. + wallet := walletPKH() + spvChain.setWalletReservations(wallet, nil) + + watcher := newReservationStrandingWatcher(spvChain) + if err := watcher.checkReservationStrandingForWallet(wallet); err != nil { + t.Fatalf("unexpected error for empty wallet: %v", err) + } + + if calls := spvChain.getSubmittedReservationStrandedKeys(); len(calls) != 0 { + t.Fatalf( + "expected zero notifications on empty wallet list, got %d", + len(calls), + ) + } +} + +// TestReservationStrandingWatcher_NotifierErrorContinuesProcessing mirrors +// TestReservationActionTimeoutWatcher_NotifiesOncePerQualifyingNonce's +// resilience property for the sibling action-timeout watcher: a single +// NotifyReservationStranded failure must not starve the remaining +// reservations in the same wallet. +func TestReservationStrandingWatcher_NotifierErrorContinuesProcessing(t *testing.T) { + spvChain := newLocalChain() + + wallet := walletPKH() + failing := reservationKey(0xAA40) + succeeding := reservationKey(0xAA41) + + spvChain.setWalletReservations(wallet, []*big.Int{failing, succeeding}) + spvChain.setReservation(failing, &tbtc.Reservation{ + State: tbtc.ReservationStateActive, + }) + spvChain.setReservation(succeeding, &tbtc.Reservation{ + State: tbtc.ReservationStateActive, + }) + spvChain.notifyReservationStrandedErrByKey = map[string]error{ + failing.String(): fmt.Errorf("notifier unavailable"), + } + + watcher := newReservationStrandingWatcher(spvChain) + if err := watcher.checkReservationStrandingForWallet(wallet); err != nil { + t.Fatalf( + "a single notifier failure must not fail the whole check: %v", + err, + ) + } + + notified := spvChain.getSubmittedReservationStrandedKeys() + if len(notified) != 1 { + t.Fatalf( + "expected the remaining reservation to still be notified, got %d", + len(notified), + ) + } + if diff := deep.Equal(succeeding, notified[0]); diff != nil { + t.Errorf("unexpected notified key: %v", diff) + } +} + +// TestReservationStrandingWatcher_WalletReservationsChainError covers the +// case TestReservationStrandingWatcher_WalletChainError's comment +// explicitly calls out as unreserved: WalletReservations itself failing +// (as opposed to returning an empty list for an unknown wallet). +func TestReservationStrandingWatcher_WalletReservationsChainError(t *testing.T) { + spvChain := newLocalChain() + + spvChain.walletReservationsErr = fmt.Errorf("rpc unavailable") + + watcher := newReservationStrandingWatcher(spvChain) + if err := watcher.checkReservationStrandingForWallet(walletPKH()); err == nil { + t.Fatal("expected error when WalletReservations fails, got nil") + } + + if calls := spvChain.getSubmittedReservationStrandedKeys(); len(calls) != 0 { + t.Fatalf( + "expected no notifications on chain error, got %d", + len(calls), + ) + } +} diff --git a/pkg/maintainer/spv/reservation_wiring.go b/pkg/maintainer/spv/reservation_wiring.go new file mode 100644 index 0000000000..baa2a4be06 --- /dev/null +++ b/pkg/maintainer/spv/reservation_wiring.go @@ -0,0 +1,400 @@ +package spv + +import ( + "context" + "fmt" + "math/big" + "time" + + "github.com/keep-network/keep-core/pkg/subscription" + "github.com/keep-network/keep-core/pkg/tbtc" + + "github.com/ipfs/go-log/v2" +) + +var reservationWiringLogger = log.Logger("keep-maintainer-spv-reservations") + +// DefaultReservationStaleDepositPollInterval is the default poll interval used +// by the stale-deposit watcher fallback loop. The Bridge does not expose a +// live subscription for DepositRevealed in m1, so the wiring layer falls back +// to PastDepositRevealedEvents on a coarse interval and dispatches each new +// reveal to the watcher. The interval mirrors the action-timeout poll +// cadence so a single tick covers both reservation timers. +const DefaultReservationStaleDepositPollInterval = 1 * time.Minute + +// DefaultReservationActionTimeoutPollInterval is the default, fixed poll +// interval for the action-timeout watcher's Run loop - the background +// loop WireReservationWatchers starts and the only way the watcher is +// driven in production. It is intentionally conservative (1 minute) to +// limit the Bridge load from the per-tracked-action GetReservationAction +// reads Run issues on every tick. +const DefaultReservationActionTimeoutPollInterval = 1 * time.Minute + +// reservationStrandingStartupScanLookBackBlocks bounds the stranding +// watcher's startup catch-up scan of past wallet registrations. 30 days at +// 12s/block, mirroring the convention used across this package. Wallets +// registered further back than this bound are not covered by the startup +// scan; the live OnWalletClosed subscription plus the existing per-wallet +// close re-check are relied on to eventually catch them. +const reservationStrandingStartupScanLookBackBlocks = uint64(216000) + +// WalletClosedChain defines the chain interface required to subscribe to +// wallet close events. +type WalletClosedChain interface { + OnWalletClosed( + handler func(event *tbtc.WalletClosedEvent), + ) subscription.EventSubscription +} + +// WireReservationWatchers is the integration entry point that cmd/start.go +// calls directly when config.Reservations.Enabled is true. It constructs +// the three reservation watchers (stranding, stale-deposit, action-timeout), +// wires their Bridge-facing notifiers to the chain, and subscribes/starts +// each watcher against its source. +// +// `walletClosedChain` supplies the OnWalletClosed event subscription; +// `spvChain` supplies the reservation data reads, event queries, and +// Notify* writes. `ctx` controls the goroutine lifetimes started by the +// wiring function. +func WireReservationWatchers( + ctx context.Context, + walletClosedChain WalletClosedChain, + spvChain Chain, + walletMembersResolver tbtc.WalletMembersResolver, +) error { + if walletClosedChain == nil { + return fmt.Errorf("wallet closed chain must not be nil") + } + if spvChain == nil { + return fmt.Errorf("spv chain must not be nil") + } + if walletMembersResolver == nil { + return fmt.Errorf("wallet members resolver must not be nil") + } + + reservationWiringLogger.Infof( + "wiring reservation watchers; ensure Maintainer.Spv.Reservations.Enabled " + + "is also enabled in the SPV maintainer config for end-to-end operation", + ) + + strandingWatcher := newReservationStrandingWatcher(spvChain) + + // Startup catch-up scan: a wallet closed/terminated while this + // maintainer was down would otherwise never notify, since the live + // OnWalletClosed subscription only sees events from this point forward. + // We scan past wallet registrations bounded by + // reservationStrandingStartupScanLookBackBlocks and check the ones + // already Closed/Terminated now; older wallets are left to the live + // subscription plus the existing per-wallet close re-check. 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 > reservationStrandingStartupScanLookBackBlocks { + strandingStartupStartBlock = currentBlock - reservationStrandingStartupScanLookBackBlocks + } + + registeredEvents, err := spvChain.PastNewWalletRegisteredEvents( + &tbtc.NewWalletRegisteredEventFilter{StartBlock: strandingStartupStartBlock}, + ) + if err != nil { + reservationWiringLogger.Warnf( + "stranding startup scan failed to fetch wallet registration events: [%v]", + err, + ) + } else { + for _, event := range registeredEvents { + wallet, err := spvChain.GetWallet(event.WalletPublicKeyHash) + if err != nil { + reservationWiringLogger.Warnf( + "stranding startup scan failed to fetch wallet [0x%x]: [%v]", + event.WalletPublicKeyHash, + err, + ) + continue + } + if wallet.State != tbtc.StateClosed && + wallet.State != tbtc.StateTerminated { + continue + } + if err := strandingWatcher.checkReservationStrandingForWallet( + event.WalletPublicKeyHash, + ); err != nil { + reservationWiringLogger.Warnf( + "stranding startup scan failed to check wallet [0x%x]: [%v]", + event.WalletPublicKeyHash, + err, + ) + continue + } + } + } + + staleDepositWatcher := NewReservationStaleDepositWatcher(spvChain) + + actionTimeoutWatcher := NewReservationActionTimeoutWatcher( + spvChain, + walletMembersResolver, + DefaultReservationActionTimeoutPollInterval, + ) + + subscription := subscribeReservationWalletClosed(ctx, walletClosedChain, spvChain, strandingWatcher) + go func() { + <-ctx.Done() + subscription.Unsubscribe() + }() + startStaleDepositPoll(ctx, spvChain, staleDepositWatcher) + go func() { + if err := actionTimeoutWatcher.Run(ctx); err != nil { + reservationWiringLogger.Errorf( + "failed to run reservation action-timeout watcher: [%v]", + err, + ) + } + }() + + return nil +} + +// subscribeReservationWalletClosed registers the stranding watcher against +// the chain's wallet close / termination events. Each event dispatches a +// worker goroutine that resolves the closed wallet's ECDSA wallet ID (the +// only identifier WalletClosedEvent carries) to its public key hash and +// runs the watcher's stranding check for that wallet. +func subscribeReservationWalletClosed( + ctx context.Context, + walletClosedChain WalletClosedChain, + spvChain Chain, + watcher *reservationStrandingWatcher, +) subscription.EventSubscription { + return walletClosedChain.OnWalletClosed(func(event *tbtc.WalletClosedEvent) { + go func() { + select { + case <-ctx.Done(): + return + default: + } + walletPublicKeyHash, err := resolveWalletPublicKeyHash( + spvChain, + event.WalletID, + ) + if err != nil { + reservationWiringLogger.Errorf( + "failed to resolve public key hash for closed "+ + "wallet ID [0x%x]: [%v]", + event.WalletID, + err, + ) + return + } + + if err := watcher.checkReservationStrandingForWallet( + walletPublicKeyHash, + ); err != nil { + reservationWiringLogger.Errorf( + "failed to check reservation stranding for closed "+ + "wallet [0x%x] (ID [0x%x]): [%v]", + walletPublicKeyHash, + event.WalletID, + err, + ) + } + }() + }) +} + +// resolveWalletPublicKeyHash maps an ECDSA wallet ID to the wallet's public +// key hash via its NewWalletRegistered event. Every wallet is registered +// exactly once before it can be closed, and the filter is indexed on the +// wallet ID, so this is a targeted lookup rather than a history scan. +func resolveWalletPublicKeyHash( + spvChain Chain, + walletID [32]byte, +) ([20]byte, error) { + events, err := spvChain.PastNewWalletRegisteredEvents( + &tbtc.NewWalletRegisteredEventFilter{ + EcdsaWalletID: [][32]byte{walletID}, + }, + ) + if err != nil { + return [20]byte{}, fmt.Errorf( + "failed to fetch wallet registration event: [%w]", + err, + ) + } + if len(events) == 0 { + return [20]byte{}, fmt.Errorf( + "no wallet registration event found for wallet ID [0x%x]", + walletID, + ) + } + + // A wallet ID is registered at most once; take the latest match + // defensively in case of a duplicate log delivery. + return events[len(events)-1].WalletPublicKeyHash, nil +} + +// reservationStaleDepositLookBackBlocks bounds the first stale-deposit poll +// tick's DepositRevealed scan. 30 days at 12s/block, mirroring +// ReservationAcceptanceLookBackBlocks in pkg/tbtcpg. Subsequent ticks scan +// incrementally from the previous tick's block, so this bound only matters +// once, at startup. +const reservationStaleDepositLookBackBlocks = uint64(216000) + +// startStaleDepositPoll runs the stale-deposit watcher's live source as a +// polling loop over PastDepositRevealedEvents: the Bridge does not expose a +// live subscription for DepositRevealed in m1. Each tick fetches reveals +// since the previously scanned block, adds every reserved deposit among +// them to a tracked pending set, then re-runs CheckStaleReservedDeposit for +// every deposit already in the set. A deposit is dropped from the set once +// it is no longer reserved (released to the default sweep path, or swept) +// or its acceptance action has advanced past pending - both mean it can +// never go stale again, so re-checking it forever would be wasted RPCs. A +// deposit whose wallet has gone Live is kept in the set instead of +// dropped: the wallet may still transition away from Live (e.g. +// MovingFunds/Closing/Terminated) before anchoring, and the scan cursor +// only ever advances, so a dropped deposit could never re-enter tracking. +// +// The poller is intentionally tolerant of chain errors: a transient RPC +// failure logs and continues rather than aborting the wiring. +func startStaleDepositPoll( + ctx context.Context, + spvChain Chain, + watcher *ReservationStaleDepositWatcher, +) { + go func() { + ticker := time.NewTicker(DefaultReservationStaleDepositPollInterval) + defer ticker.Stop() + + var lastSeenBlock uint64 + pending := make(map[string]*big.Int) + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + + blockCounter, err := spvChain.BlockCounter() + if err != nil { + reservationWiringLogger.Errorf( + "stale-deposit poll failed to get block counter: [%v]", + err, + ) + continue + } + currentBlock, err := blockCounter.CurrentBlock() + if err != nil { + reservationWiringLogger.Errorf( + "stale-deposit poll failed to get current block: [%v]", + err, + ) + continue + } + + startBlock := lastSeenBlock + if startBlock == 0 && currentBlock > reservationStaleDepositLookBackBlocks { + startBlock = currentBlock - reservationStaleDepositLookBackBlocks + } + + events, err := spvChain.PastDepositRevealedEvents( + &tbtc.DepositRevealedEventFilter{ + StartBlock: startBlock + 1, + EndBlock: ¤tBlock, + }, + ) + if err != nil { + reservationWiringLogger.Errorf( + "stale-deposit poll failed to fetch deposit revealed "+ + "events: [%v]", + err, + ) + continue + } + + params, err := spvChain.ReservationParameters() + if err != nil { + reservationWiringLogger.Errorf( + "stale-deposit poll failed to fetch reservation "+ + "parameters: [%v]", + err, + ) + continue + } + + for _, event := range events { + if event.Vault == nil || *event.Vault != params.ReservationVault { + continue + } + + depositKey := spvChain.BuildDepositKey( + event.FundingTxHash, + event.FundingOutputIndex, + ) + + isReserved, err := spvChain.IsReservedDeposit(depositKey) + if err != nil { + reservationWiringLogger.Errorf( + "stale-deposit poll failed to check if deposit "+ + "[%v] is reserved: [%v]", + depositKey, + err, + ) + // Track it for retry instead of dropping it: this + // window's event won't be re-fetched once + // lastSeenBlock advances below, so silently skipping + // here would permanently orphan the deposit on one + // transient RPC flake. CheckStaleReservedDeposit + // performs its own independent IsReservedDeposit + // re-check on every tick (see + // reservation_stale_deposit_watch.go) and resolves to + // Drop if the deposit genuinely isn't reserved, so + // tracking it speculatively here is safe. + pending[depositKey.String()] = depositKey + continue + } + if !isReserved { + continue + } + + pending[depositKey.String()] = depositKey + } + + lastSeenBlock = currentBlock + now := uint32(time.Now().Unix()) + + for key, depositKey := range pending { + resolution, err := watcher.CheckStaleReservedDeposit( + depositKey, + now, + ) + if err != nil { + reservationWiringLogger.Errorf( + "stale-deposit poll failed to check deposit "+ + "[%v]: [%v]", + depositKey, + err, + ) + continue + } + + if resolution == StaleDepositResolutionDrop || + resolution == StaleDepositResolutionNotified { + delete(pending, key) + watcher.forgetDeposit(depositKey) + } + } + } + }() +} diff --git a/pkg/maintainer/spv/reservation_wiring_test.go b/pkg/maintainer/spv/reservation_wiring_test.go new file mode 100644 index 0000000000..30759fe587 --- /dev/null +++ b/pkg/maintainer/spv/reservation_wiring_test.go @@ -0,0 +1,374 @@ +package spv + +import ( + "context" + "fmt" + "math/big" + "testing" + + "github.com/keep-network/keep-core/pkg/subscription" + "github.com/keep-network/keep-core/pkg/tbtc" +) + +// TestResolveWalletPublicKeyHash covers the three branches of +// resolveWalletPublicKeyHash: a matching NewWalletRegistered event found, no +// matching event found, and a chain read error. +func TestResolveWalletPublicKeyHash(t *testing.T) { + walletID := [32]byte{0x01, 0x02, 0x03} + expectedPKH := [20]byte{0xAA, 0xBB, 0xCC} + + t.Run("found", func(t *testing.T) { + spvChain := newLocalChain() + spvChain.addNewWalletRegisteredEvent(&tbtc.NewWalletRegisteredEvent{ + EcdsaWalletID: walletID, + WalletPublicKeyHash: expectedPKH, + }) + + pkh, err := resolveWalletPublicKeyHash(spvChain, walletID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pkh != expectedPKH { + t.Errorf( + "unexpected public key hash\nexpected: %x\nactual: %x", + expectedPKH, + pkh, + ) + } + }) + + t.Run("not found", func(t *testing.T) { + spvChain := newLocalChain() + // No matching event registered for walletID; a different wallet's + // event exists to confirm the filter, not just an empty set, drives + // the not-found path. + spvChain.addNewWalletRegisteredEvent(&tbtc.NewWalletRegisteredEvent{ + EcdsaWalletID: [32]byte{0x99}, + WalletPublicKeyHash: expectedPKH, + }) + + _, err := resolveWalletPublicKeyHash(spvChain, walletID) + if err == nil { + t.Fatal("expected error for missing wallet registration event") + } + }) + + t.Run("chain error", func(t *testing.T) { + spvChain := newLocalChain() + spvChain.setPastNewWalletRegisteredEventsErr( + fmt.Errorf("rpc unavailable"), + ) + + _, err := resolveWalletPublicKeyHash(spvChain, walletID) + if err == nil { + t.Fatal("expected chain error to propagate") + } + }) + + t.Run("duplicate event delivery uses the latest match", func(t *testing.T) { + spvChain := newLocalChain() + staleePKH := [20]byte{0x11} + spvChain.addNewWalletRegisteredEvent(&tbtc.NewWalletRegisteredEvent{ + EcdsaWalletID: walletID, + WalletPublicKeyHash: staleePKH, + }) + spvChain.addNewWalletRegisteredEvent(&tbtc.NewWalletRegisteredEvent{ + EcdsaWalletID: walletID, + WalletPublicKeyHash: expectedPKH, + }) + + pkh, err := resolveWalletPublicKeyHash(spvChain, walletID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pkh != expectedPKH { + t.Errorf( + "expected the latest matching event to win\nexpected: %x\nactual: %x", + expectedPKH, + pkh, + ) + } + }) +} + +// TestCheckStaleReservedDeposit_Resolution covers the resolution outcomes of +// CheckStaleReservedDeposit used by the poller to decide pending-set retention: +// a deposit still reserved with unreached timeout, or reserved with a live +// wallet, must be kept (a live wallet can still transition away from Live +// before anchoring, so the poller must keep re-evaluating it); non-reserved +// deposits or deposits with settled actions must be dropped; and timed-out +// deposits must be notified and evicted. +func TestCheckStaleReservedDeposit_Resolution(t *testing.T) { + tests := map[string]struct { + isReserved bool + walletState tbtc.WalletState + actionState tbtc.ReservationActionState + timeoutAt uint32 + now uint32 + expectedResolution StaleDepositResolution + }{ + "not reserved": { + isReserved: false, + walletState: tbtc.StateMovingFunds, + actionState: tbtc.ReservationActionStatePending, + timeoutAt: 100, + now: 1000, + expectedResolution: StaleDepositResolutionDrop, + }, + "reserved, wallet live": { + isReserved: true, + walletState: tbtc.StateLive, + actionState: tbtc.ReservationActionStatePending, + timeoutAt: 100, + now: 1000, + expectedResolution: StaleDepositResolutionKeep, + }, + "reserved, action settled": { + isReserved: true, + walletState: tbtc.StateMovingFunds, + actionState: tbtc.ReservationActionStateSettled, + timeoutAt: 100, + now: 1000, + expectedResolution: StaleDepositResolutionDrop, + }, + "reserved, timeout not yet reached": { + isReserved: true, + walletState: tbtc.StateMovingFunds, + actionState: tbtc.ReservationActionStatePending, + timeoutAt: 5000, + now: 1000, + expectedResolution: StaleDepositResolutionKeep, + }, + "reserved, timeout passed and notified": { + isReserved: true, + walletState: tbtc.StateMovingFunds, + actionState: tbtc.ReservationActionStatePending, + timeoutAt: 100, + now: 1000, + expectedResolution: StaleDepositResolutionNotified, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + spvChain := newLocalChain() + depositKey := reservationDepositKey(0xCC01) + wallet := walletPKH() + spvChain.setReservedDeposit(depositKey, wallet, test.isReserved) + spvChain.setWallet(wallet, &tbtc.WalletChainData{ + State: test.walletState, + }) + spvChain.setReservation(depositKey, &tbtc.Reservation{ + RequestNonce: 1, + }) + spvChain.setReservationAction(depositKey, 1, &tbtc.ReservationAction{ + State: test.actionState, + TimeoutAt: test.timeoutAt, + }) + spvChain.setReservationParameters(&tbtc.ReservationParameters{ + ReservationActionTimeout: 3600, + }) + watcher := NewReservationStaleDepositWatcher(spvChain) + resolution, err := watcher.CheckStaleReservedDeposit(depositKey, test.now) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resolution != test.expectedResolution { + t.Errorf( + "unexpected resolution\nexpected: %v\nactual: %v", + test.expectedResolution, + resolution, + ) + } + }) + } +} + +type mockWalletMembersResolver struct { + resolveFn func(walletPublicKeyHash [20]byte) ([]uint32, error) +} + +func (m *mockWalletMembersResolver) ResolveWalletMembers(walletPublicKeyHash [20]byte) ([]uint32, error) { + return m.resolveFn(walletPublicKeyHash) +} + +type mockWalletClosedChain struct { + onWalletClosedHandler func(event *tbtc.WalletClosedEvent) +} + +func (m *mockWalletClosedChain) OnWalletClosed( + handler func(event *tbtc.WalletClosedEvent), +) subscription.EventSubscription { + m.onWalletClosedHandler = handler + return subscription.NewEventSubscription(func() {}) +} + +func TestWireReservationWatchers(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + walletClosedChain := &mockWalletClosedChain{} + spvChain := newLocalChain() + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(1000) + spvChain.setBlockCounter(blockCounter) + + resolver := &mockWalletMembersResolver{ + resolveFn: func(walletPublicKeyHash [20]byte) ([]uint32, error) { + return []uint32{1, 2, 3}, nil + }, + } + + if err := WireReservationWatchers(ctx, walletClosedChain, spvChain, resolver); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestWireReservationWatchers_NilParameters(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + walletClosedChain := &mockWalletClosedChain{} + spvChain := newLocalChain() + resolver := &mockWalletMembersResolver{ + resolveFn: func(walletPublicKeyHash [20]byte) ([]uint32, error) { + return []uint32{1}, nil + }, + } + + t.Run("nil wallet closed chain", func(t *testing.T) { + err := WireReservationWatchers(ctx, nil, spvChain, resolver) + if err == nil { + t.Fatal("expected error for nil wallet closed chain") + } + }) + + t.Run("nil spv chain", func(t *testing.T) { + err := WireReservationWatchers(ctx, walletClosedChain, nil, resolver) + if err == nil { + t.Fatal("expected error for nil spv chain") + } + }) + + t.Run("nil wallet members resolver", func(t *testing.T) { + err := WireReservationWatchers(ctx, walletClosedChain, spvChain, nil) + if err == nil { + t.Fatal("expected error for nil wallet members resolver") + } + }) +} + +// TestWireReservationWatchers_StartupCatchUpScan_TransientErrorsDoNotAbort +// verifies that the stranding watcher's startup catch-up scan tolerates a +// transient chain-read failure against one wallet (e.g. GetWallet +// returning an error): the scan continues to the remaining wallets rather +// than aborting client startup, correctly notifying Closed and Terminated +// wallets' stranded reservations while skipping Live ones. +func TestWireReservationWatchers_StartupCatchUpScan_TransientErrorsDoNotAbort(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + walletClosedChain := &mockWalletClosedChain{} + spvChain := newLocalChain() + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(5000) + spvChain.setBlockCounter(blockCounter) + + resolver := &mockWalletMembersResolver{ + resolveFn: func(walletPublicKeyHash [20]byte) ([]uint32, error) { + return []uint32{1, 2, 3}, nil + }, + } + + walletTransientError := walletPKHAt(0x01) + walletClosed := walletPKHAt(0x02) + walletLive := walletPKHAt(0x03) + walletTerminated := walletPKHAt(0x04) + + resKeyClosed := reservationKey(0xDD02) + resKeyLive := reservationKey(0xDD03) + resKeyTerminated := reservationKey(0xDD04) + + // Register all 4 wallets in NewWalletRegisteredEvents. + spvChain.addNewWalletRegisteredEvent(&tbtc.NewWalletRegisteredEvent{ + EcdsaWalletID: [32]byte{0x01}, + WalletPublicKeyHash: walletTransientError, + }) + spvChain.addNewWalletRegisteredEvent(&tbtc.NewWalletRegisteredEvent{ + EcdsaWalletID: [32]byte{0x02}, + WalletPublicKeyHash: walletClosed, + }) + spvChain.addNewWalletRegisteredEvent(&tbtc.NewWalletRegisteredEvent{ + EcdsaWalletID: [32]byte{0x03}, + WalletPublicKeyHash: walletLive, + }) + spvChain.addNewWalletRegisteredEvent(&tbtc.NewWalletRegisteredEvent{ + EcdsaWalletID: [32]byte{0x04}, + WalletPublicKeyHash: walletTerminated, + }) + + // walletTransientError is NOT added to spvChain.wallets, so GetWallet + // returns "no wallet for given PKH" simulating a transient RPC error. + + // walletClosed is Closed with an Active reservation. + spvChain.setWallet(walletClosed, &tbtc.WalletChainData{ + State: tbtc.StateClosed, + }) + spvChain.setWalletReservations(walletClosed, []*big.Int{resKeyClosed}) + spvChain.setReservation(resKeyClosed, &tbtc.Reservation{ + State: tbtc.ReservationStateActive, + }) + + // walletLive is Live with an Active reservation (must be skipped). + spvChain.setWallet(walletLive, &tbtc.WalletChainData{ + State: tbtc.StateLive, + }) + spvChain.setWalletReservations(walletLive, []*big.Int{resKeyLive}) + spvChain.setReservation(resKeyLive, &tbtc.Reservation{ + State: tbtc.ReservationStateActive, + }) + + // walletTerminated is Terminated with an Active reservation. + spvChain.setWallet(walletTerminated, &tbtc.WalletChainData{ + State: tbtc.StateTerminated, + }) + spvChain.setWalletReservations(walletTerminated, []*big.Int{resKeyTerminated}) + spvChain.setReservation(resKeyTerminated, &tbtc.Reservation{ + State: tbtc.ReservationStateActive, + }) + + // WireReservationWatchers must succeed without returning an error despite + // walletTransientError failing GetWallet. + err := WireReservationWatchers(ctx, walletClosedChain, spvChain, resolver) + if err != nil { + t.Fatalf("expected WireReservationWatchers to succeed despite transient wallet error: %v", err) + } + + // Verify that the stranded active reservations for walletClosed and + // walletTerminated were both notified, while walletLive was skipped. + notifiedKeys := spvChain.getSubmittedReservationStrandedKeys() + if len(notifiedKeys) != 2 { + t.Fatalf("expected 2 notified stranded keys, got %d: %v", len(notifiedKeys), notifiedKeys) + } + + foundClosed := false + foundTerminated := false + for _, k := range notifiedKeys { + if k.Cmp(resKeyClosed) == 0 { + foundClosed = true + } + if k.Cmp(resKeyTerminated) == 0 { + foundTerminated = true + } + if k.Cmp(resKeyLive) == 0 { + t.Errorf("live wallet reservation was unexpectedly notified as stranded") + } + } + + if !foundClosed { + t.Errorf("expected closed wallet reservation [%v] to be notified", resKeyClosed) + } + if !foundTerminated { + t.Errorf("expected terminated wallet reservation [%v] to be notified", resKeyTerminated) + } +} diff --git a/pkg/maintainer/spv/spv.go b/pkg/maintainer/spv/spv.go index 6275f5f83c..594c51a722 100644 --- a/pkg/maintainer/spv/spv.go +++ b/pkg/maintainer/spv/spv.go @@ -68,6 +68,23 @@ func Initialize( btcChain: btcChain, } + if config.Reservations.Enabled { + logger.Infof( + "SPV maintainer reservation proof submission is enabled; " + + "ensure the paired Tbtc.Reservations.Enabled flag is also " + + "enabled in the client config for end-to-end operation", + ) + // Reservation acceptance/re-anchor proofs run on a dedicated loop, + // not through the generic proofTypes map: SubmitReservationProof + // requires the (reservationKey, requestNonce) pair of the action + // generation being proven, which the generic + // unprovenTransactionsGetter/transactionProofSubmitter signatures + // (shared by deposit sweep, redemption, moving funds, and moved + // funds sweep, none of which need that pair) cannot carry. See + // reservation_proof_loop.go. + go maintainReservationProofs(ctx, config, spvChain, btcDiffChain, btcChain) + } + go spvMaintainer.startControlLoop(ctx) } diff --git a/pkg/net/local/broadcast_channel_manager.go b/pkg/net/local/broadcast_channel_manager.go index 2d9a90b5df..20b70addfe 100644 --- a/pkg/net/local/broadcast_channel_manager.go +++ b/pkg/net/local/broadcast_channel_manager.go @@ -16,6 +16,7 @@ const RetransmissionTick = 50 * time.Millisecond var broadcastChannelsMutex sync.Mutex var broadcastChannels map[string][]*localChannel +var broadcastChannelCancels map[string][]context.CancelFunc // getBroadcastChannel returns a BroadcastChannel designed to mediate between local // participants. It delivers all messages sent to the channel through its @@ -31,12 +32,19 @@ func getBroadcastChannel( if broadcastChannels == nil { broadcastChannels = make(map[string][]*localChannel) } + if broadcastChannelCancels == nil { + broadcastChannelCancels = make(map[string][]context.CancelFunc) + } _, exists := broadcastChannels[name] if !exists { broadcastChannels[name] = make([]*localChannel, 0) + broadcastChannelCancels[name] = make([]context.CancelFunc, 0) } + tickerCtx, cancelTicker := context.WithCancel(context.Background()) + broadcastChannelCancels[name] = append(broadcastChannelCancels[name], cancelTicker) + identifier := randomLocalIdentifier() channel := &localChannel{ name: name, @@ -47,7 +55,7 @@ func getBroadcastChannel( unmarshalersMutex: sync.Mutex{}, unmarshalersByType: make(map[string]func() net.TaggedUnmarshaler, 0), retransmissionTicker: retransmission.NewTimeTicker( - context.Background(), RetransmissionTick, + tickerCtx, RetransmissionTick, ), } broadcastChannels[name] = append(broadcastChannels[name], channel) @@ -66,3 +74,21 @@ func broadcastMessage(name string, message net.Message) error { return nil } + +// ReleaseBroadcastChannel cancels every outstanding retransmission ticker +// registered under name and removes name's entry from the registry, so a +// later invocation reusing name starts from an empty registry regardless of +// whether an earlier invocation's leader was still retransmitting. Callers +// that create broadcast channels in tests should call this from t.Cleanup, +// passing the same name they created the channel(s) under. +func ReleaseBroadcastChannel(name string) { + broadcastChannelsMutex.Lock() + defer broadcastChannelsMutex.Unlock() + + for _, cancel := range broadcastChannelCancels[name] { + cancel() + } + + delete(broadcastChannels, name) + delete(broadcastChannelCancels, name) +} diff --git a/pkg/net/local/broadcast_channel_manager_test.go b/pkg/net/local/broadcast_channel_manager_test.go new file mode 100644 index 0000000000..8427162865 --- /dev/null +++ b/pkg/net/local/broadcast_channel_manager_test.go @@ -0,0 +1,133 @@ +package local + +import ( + "context" + "testing" + "time" + + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/operator" +) + +// TestReleaseBroadcastChannel verifies ReleaseBroadcastChannel's actual +// effect, not just that it can be called: a released channel's +// retransmission ticker stops firing, and a name reused after release only +// delivers to the newly-registered channel, not any stale one left over +// from before the release. +// +// Delivery is observed via a raw messageHandler registered directly +// (bypassing Recv's retransmission.WithRetransmissionSupport dedup wrapper), +// because the standard retransmission strategy resends the same message +// with the same sequence number on every tick, and the dedup layer collapses +// those to a single callback invocation - counting through it would make +// "the ticker kept firing" indistinguishable from "the ticker fired once". +func TestReleaseBroadcastChannel(t *testing.T) { + // Use a name unique to this test (not a shared literal like + // "channel name", which broadcast_channel_test.go also uses) so a + // channel this test forgets to release can never cross-contaminate + // another test file's assertions in the same test binary. + name := t.Name() + t.Cleanup(func() { ReleaseBroadcastChannel(name) }) + + _, pubKey, err := operator.GenerateKeyPair(DefaultCurve) + if err != nil { + t.Fatal(err) + } + + createChannel := func(name string) *localChannel { + ch := getBroadcastChannel(name, pubKey) + lc := ch.(*localChannel) + lc.SetUnmarshaler(func() net.TaggedUnmarshaler { + return &mockNetMessage{} + }) + return lc + } + + registerRawHandler := func(lc *localChannel) <-chan net.Message { + handler := &messageHandler{ + ctx: context.Background(), + channel: make(chan net.Message, 64), + } + lc.messageHandlersMutex.Lock() + lc.messageHandlers = append(lc.messageHandlers, handler) + lc.messageHandlersMutex.Unlock() + return handler.channel + } + + // drain counts every message received on ch during window; used to + // count raw delivery attempts (one per ticker firing), not distinct + // messages. + drain := func(ch <-chan net.Message, window time.Duration) int { + deadline := time.After(window) + count := 0 + for { + select { + case <-ch: + count++ + case <-deadline: + return count + } + } + } + + // 1. Open a channel, let its retransmission ticker fire a few times, + // release it, then assert no further deliveries occur. + ch1 := createChannel(name) + ch1Deliveries := registerRawHandler(ch1) + + if err := ch1.Send(context.Background(), &mockNetMessage{}); err != nil { + t.Fatal(err) + } + + if got := drain(ch1Deliveries, RetransmissionTick*3); got <= 1 { + t.Fatalf( + "expected repeated ticker deliveries before release, got %d", + got, + ) + } + + ReleaseBroadcastChannel(name) + + // NewTimeTicker's piping goroutine selects between an already-elapsed + // timerTick.C and ctx.Done(); if both are ready when cancel() runs, Go's + // pseudo-random select can let exactly one straggler tick through before + // the goroutine observes cancellation and exits. That single straggler + // is a harmless, already-in-flight retransmission of a message already + // sent, not a sign the ticker "kept firing" - so absorb it in a short + // settle window before asserting the real invariant this test cares + // about: no further deliveries once release has taken effect. + if got := drain(ch1Deliveries, RetransmissionTick); got > 1 { + t.Errorf("expected at most one straggler tick after release, got %d", got) + } + + if got := drain(ch1Deliveries, RetransmissionTick*3); got != 0 { + t.Errorf("expected no deliveries after release, got %d", got) + } + + // 2. Open a new channel under the same, just-released name, send a + // message on it, and assert only the new channel's handler receives + // it - proving the old channel's registration was actually dropped + // by the release, not merely shadowed by a map pointer swap. + ch2 := createChannel(name) + ch2Deliveries := registerRawHandler(ch2) + + if err := ch2.Send(context.Background(), &mockNetMessage{}); err != nil { + t.Fatal(err) + } + + if got := drain(ch2Deliveries, RetransmissionTick*3); got <= 1 { + t.Errorf( + "expected repeated ticker deliveries from the new channel, got %d", + got, + ) + } + if got := drain(ch1Deliveries, RetransmissionTick*2); got != 0 { + t.Errorf( + "expected the released channel to receive nothing further, got %d", + got, + ) + } + + // 3. Releasing a name with zero registered channels is a safe no-op. + ReleaseBroadcastChannel("nonexistent") +} diff --git a/pkg/net/retransmission/ticker.go b/pkg/net/retransmission/ticker.go index a9e3e8e802..b794179e49 100644 --- a/pkg/net/retransmission/ticker.go +++ b/pkg/net/retransmission/ticker.go @@ -75,9 +75,10 @@ func (t *Ticker) start() { t.handlersMutex.Unlock() } - for ctx := range t.handlers { - delete(t.handlers, ctx) - } + t.handlersMutex.Lock() + defer t.handlersMutex.Unlock() + + clear(t.handlers) } func (t *Ticker) onTick(ctx context.Context, fn func()) { diff --git a/pkg/tbtc/chain.go b/pkg/tbtc/chain.go index a58599f273..09e6b75868 100644 --- a/pkg/tbtc/chain.go +++ b/pkg/tbtc/chain.go @@ -34,9 +34,9 @@ type GroupSelectionChain interface { } // GroupSelectionResult represents a group selection result, i.e. operators -// selected to perform the DKG protocol. The result consists of two slices -// of equal length holding the chain.OperatorID and chain.Address for each -// selected operator. +// selected to perform the group key generation protocol. The result consists of +// two slices of equal length holding the chain.OperatorID and chain.Address for +// each selected operator. type GroupSelectionResult struct { OperatorsIDs chain.OperatorIDs OperatorsAddresses chain.Addresses @@ -292,6 +292,13 @@ type BridgeChain interface { fundingOutputIndex uint32, ) (*DepositChainRequest, bool, error) + // BuildDepositKey calculates a deposit key for the given funding + // transaction hash and output index. Mirrors tbtcpg.Chain's identical + // method - the reservation anchor wallet action needs it to derive the + // m1 reservation key (reservationKey == depositKey) without depending + // on the tbtcpg package. + BuildDepositKey(fundingTxHash bitcoin.Hash, fundingOutputIndex uint32) *big.Int + // GetMovedFundsSweepRequest gets the on-chain moved funds sweep request for // the given moving funds transaction hash and output index. // The returned bool value indicates whether the request was found or not. @@ -427,6 +434,26 @@ type WalletProposalValidatorChain interface { }, ) error + // ValidateReservationAnchorProposal validates the given reservation + // anchor proposal against the chain. Returns an error if the proposal + // is not valid or nil otherwise. + ValidateReservationAnchorProposal( + walletPublicKeyHash [20]byte, + proposal *ReservationAnchorProposal, + depositExtraInfo struct { + *Deposit + FundingTx *bitcoin.Transaction + }, + ) error + + // ValidateReservationReanchorProposal validates the given reservation + // re-anchor proposal against the chain. Returns an error if the + // proposal is not valid or nil otherwise. + ValidateReservationReanchorProposal( + sourceWalletPublicKeyHash [20]byte, + proposal *ReservationReanchorProposal, + ) error + // ValidateRedemptionProposal validates the given redemption proposal // against the chain. Returns an error if the proposal is not valid or // nil otherwise. @@ -545,4 +572,336 @@ type Chain interface { InactivityClaimChain BridgeChain WalletProposalValidatorChain + ReservationChain +} + +// 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 +// ReservationRouter contract; the binding is constructed against the Bridge +// address, so every read, write, and log subscription on this interface +// routes through the Bridge's storage rather than the router's empty +// standalone storage. +type ReservationChain interface { + // RequestReservationAcceptance requests a reservation acceptance action + // generation for the given reservation. The reservation must be in a + // state that allows acceptance; the operator-side guard is enforced at + // the chain layer. + RequestReservationAcceptance( + reservationKey *big.Int, + walletPublicKeyHash [20]byte, + ) error + + // RequestReservationReanchor requests a reservation re-anchor action + // generation for the given reservation, targeting the given wallet. + RequestReservationReanchor( + reservationKey *big.Int, + targetWalletPublicKeyHash [20]byte, + ) error + + // SubmitReservationProof submits an SPV proof for the given reservation + // action generation. proofType selects between Acceptance, Redemption, + // Reanchor, and Dissolution proofs; m1 invokes only Acceptance (1) and + // Reanchor (3). The call is restricted to the SPV maintainer registered + // against the Bridge. + SubmitReservationProof( + proofType uint8, + txInfo *BitcoinTxInfo, + proof *BitcoinTxProof, + mainUtxo *BitcoinTxUTXO, + reservationKey *big.Int, + requestNonce uint64, + ) error + + // NotifyReservationActionTimeout notifies the Bridge that the timeout + // for the given reservation action generation has elapsed without the + // SPV proof being submitted. The walletMembersIDs carry the operator + // IDs of the wallet that was authorized for the action; they are used + // to slash the wallet in m2-era records (no-op in m1). + NotifyReservationActionTimeout( + reservationKey *big.Int, + walletMembersIDs []uint32, + ) error + + // NotifyStaleReservedDeposit notifies the Bridge that the given reserved + // deposit's wallet did not anchor it within the reservation-action + // timeout and should be released back to the default sweeping path. + NotifyStaleReservedDeposit(depositKey *big.Int) error + + // NotifyReservationStranded notifies the Bridge that the wallet + // custodying the given reservation has been closed or terminated and + // the anchor is therefore stranded. This is the m1 path that closes + // reservations whose wallet is no longer live. + NotifyReservationStranded(reservationKey *big.Int) error + + // GetReservation gets the on-chain reservation record for the given + // reservation key. Returns an error if the reservation was not found. + GetReservation(reservationKey *big.Int) (*Reservation, error) + + // GetReservationAction gets the on-chain action record for the given + // reservation key and request nonce. Returns an error if the action + // generation was not found. + GetReservationAction( + reservationKey *big.Int, + requestNonce uint64, + ) (*ReservationAction, error) + + // ReservationParameters gets the current on-chain values of the Bridge + // reservation parameters. + ReservationParameters() (*ReservationParameters, error) + + // ReservationCaps returns the cap parameters that gate reservation + // acceptance: the maximum aggregate satoshi amount a single wallet may + // custody across all of its reservations, and the maximum satoshi + // amount any single reservation may anchor. + ReservationCaps() (maxReservationsAmountPerWallet uint64, reservationMaxSingleAmount uint64, err error) + + // WalletReservationsAmount returns the aggregate satoshi amount + // currently anchored by the given wallet across all of its + // reservations. + WalletReservationsAmount(walletPublicKeyHash [20]byte) (uint64, error) + + // WalletReservationsCount returns the number of reservations currently + // custodied by the given wallet. + WalletReservationsCount(walletPublicKeyHash [20]byte) (uint32, error) + + // WalletReservations returns the reservation keys for all reservations + // currently custodied by the given wallet. + WalletReservations(walletPublicKeyHash [20]byte) ([]*big.Int, error) + + // ReservedDepositWallet returns the wallet public key hash to which + // the given reserved deposit was revealed. Returns the zero hash if the + // deposit is not a reserved deposit. + ReservedDepositWallet(depositKey *big.Int) ([20]byte, error) + + // ActiveReservationsCount returns the current count of active + // reservations across all wallets and the cap on that count. + ActiveReservationsCount() (count uint32, maxActive uint32, err error) + + // IsReservedDeposit returns true if the given deposit was revealed + // with the reservation vault address and is therefore a reservation + // rather than a default deposit. + IsReservedDeposit(depositKey *big.Int) (bool, error) + + // OnReservationAcceptanceRequested registers a callback that is invoked + // when an on-chain ReservationAcceptanceRequested event is seen. + OnReservationAcceptanceRequested( + handler func(event *ReservationAcceptanceRequestedEvent), + ) subscription.EventSubscription + + // PastReservationAcceptanceRequestedEvents fetches past + // ReservationAcceptanceRequested events according to the provided + // filter or unfiltered if the filter is nil. Returned events are sorted + // by the block number in the ascending order, i.e. the latest event is + // at the end of the slice. + PastReservationAcceptanceRequestedEvents( + filter *ReservationAcceptanceRequestedEventFilter, + ) ([]*ReservationAcceptanceRequestedEvent, error) + + // OnReservationReanchorRequested registers a callback that is invoked + // when an on-chain ReservationReanchorRequested event is seen. + OnReservationReanchorRequested( + handler func(event *ReservationReanchorRequestedEvent), + ) subscription.EventSubscription + + // PastReservationReanchorRequestedEvents fetches past + // ReservationReanchorRequested events according to the provided filter + // or unfiltered if the filter is nil. + PastReservationReanchorRequestedEvents( + filter *ReservationReanchorRequestedEventFilter, + ) ([]*ReservationReanchorRequestedEvent, error) +} + +// BitcoinTxInfo represents the on-chain BitcoinTx.Info struct used by +// reservation proof submissions. +type BitcoinTxInfo struct { + Version [4]byte + InputVector []byte + OutputVector []byte + Locktime [4]byte +} + +// BitcoinTxProof represents the on-chain BitcoinTx.Proof struct used by +// reservation proof submissions. +type BitcoinTxProof struct { + MerkleProof []byte + TxIndexInBlock *big.Int + BitcoinHeaders []byte + CoinbasePreimage [32]byte + CoinbaseProof []byte +} + +// BitcoinTxUTXO represents the on-chain BitcoinTx.UTXO struct used by +// reservation proof submissions. +type BitcoinTxUTXO struct { + TxHash [32]byte + TxOutputIndex uint32 + TxOutputValue uint64 +} + +// ReservationAcceptanceRequestedEvent represents a reservation acceptance +// requested event. +type ReservationAcceptanceRequestedEvent struct { + ReservationKey *big.Int + RequestNonce uint64 + WalletPublicKeyHash [20]byte + DepositAmount uint64 + TxMaxFee uint64 + TimeoutAt uint32 + BlockNumber uint64 +} + +// ReservationAcceptanceRequestedEventFilter is a component allowing to filter +// ReservationAcceptanceRequestedEvent. +type ReservationAcceptanceRequestedEventFilter struct { + StartBlock uint64 + EndBlock *uint64 + ReservationKey []*big.Int + WalletPublicKeyHash [][20]byte +} + +// ReservationAcceptedEvent represents a reservation accepted event. +type ReservationAcceptedEvent struct { + ReservationKey *big.Int + RequestNonce uint64 + WalletPublicKeyHash [20]byte + Owner chain.Address + AnchorTxHash [32]byte + AnchorAmount uint64 + ExpiresAt uint32 + BlockNumber uint64 +} + +// ReservationAcceptedEventFilter is a component allowing to filter +// ReservationAcceptedEvent. +type ReservationAcceptedEventFilter struct { + StartBlock uint64 + EndBlock *uint64 + ReservationKey []*big.Int + WalletPublicKeyHash [][20]byte + Owner []chain.Address +} + +// ReservationReanchorRequestedEvent represents a reservation re-anchor +// requested event. +type ReservationReanchorRequestedEvent struct { + ReservationKey *big.Int + RequestNonce uint64 + SourceWalletPublicKeyHash [20]byte + TargetWalletPublicKeyHash [20]byte + TxMaxFee uint64 + BlockNumber uint64 +} + +// ReservationReanchorRequestedEventFilter is a component allowing to filter +// ReservationReanchorRequestedEvent. +type ReservationReanchorRequestedEventFilter struct { + StartBlock uint64 + EndBlock *uint64 + ReservationKey []*big.Int + SourceWalletPublicKeyHash [][20]byte + TargetWalletPublicKeyHash [][20]byte +} + +// ReservationReanchoredEvent represents a reservation re-anchored event. +type ReservationReanchoredEvent struct { + ReservationKey *big.Int + RequestNonce uint64 + NewWalletPublicKeyHash [20]byte + NewAnchorTxHash [32]byte + NewAnchorAmount uint64 + BlockNumber uint64 +} + +// ReservationReanchoredEventFilter is a component allowing to filter +// ReservationReanchoredEvent. +type ReservationReanchoredEventFilter struct { + StartBlock uint64 + EndBlock *uint64 + ReservationKey []*big.Int + NewWalletPublicKeyHash [][20]byte +} + +// ReservationActionTimedOutEvent represents a reservation action timed out +// event. +type ReservationActionTimedOutEvent struct { + ReservationKey *big.Int + RequestNonce uint64 + ActionType ReservationActionType + BlockNumber uint64 +} + +// ReservationActionTimedOutEventFilter is a component allowing to filter +// ReservationActionTimedOutEvent. +type ReservationActionTimedOutEventFilter struct { + StartBlock uint64 + EndBlock *uint64 + ReservationKey []*big.Int +} + +// ReservationActionSupersededEvent represents a reservation action superseded +// event. +type ReservationActionSupersededEvent struct { + ReservationKey *big.Int + RequestNonce uint64 + BlockNumber uint64 +} + +// ReservationLateSettledEvent represents a reservation late-settled event. +type ReservationLateSettledEvent struct { + ReservationKey *big.Int + RequestNonce uint64 + ActionType ReservationActionType + BlockNumber uint64 +} + +// ReservationRetryCreditMintedEvent represents a reservation retry credit +// minted event. +type ReservationRetryCreditMintedEvent struct { + ReservationKey *big.Int + BlockNumber uint64 +} + +// ReservedDepositMarkedStaleEvent represents a reserved deposit marked stale +// event. +type ReservedDepositMarkedStaleEvent struct { + DepositKey *big.Int + BlockNumber uint64 +} + +// ReservationStrandedEvent represents a reservation stranded event. +type ReservationStrandedEvent struct { + ReservationKey *big.Int + WalletPublicKeyHash [20]byte + Owner chain.Address + AnchorAmount uint64 + BlockNumber uint64 +} + +// ReservationParametersUpdatedEvent represents a reservation parameters +// updated event. +type ReservationParametersUpdatedEvent struct { + ReservationMinAmount uint64 + ReservationTxMaxFee uint64 + ReservationTermSeconds uint32 + ReservationDissolutionDelay uint32 + ReservationMaxTotalAmount uint64 + MaxReservationsPerWallet uint32 + ReservationActionTimeout uint32 + ReservationRenewalWindowSeconds uint32 + BlockNumber uint64 +} + +// ReservationVaultUpdatedEvent represents a reservation vault updated event. +type ReservationVaultUpdatedEvent struct { + ReservationVault chain.Address + BlockNumber uint64 +} + +// ReservationCapsUpdatedEvent represents a reservation caps updated event. +type ReservationCapsUpdatedEvent struct { + MaxReservationsAmountPerWallet uint64 + ReservationMaxSingleAmount uint64 + MaxActiveReservations uint32 + BlockNumber uint64 } diff --git a/pkg/tbtc/chain_test.go b/pkg/tbtc/chain_test.go index 1dc799dc84..38b83f00bc 100644 --- a/pkg/tbtc/chain_test.go +++ b/pkg/tbtc/chain_test.go @@ -14,6 +14,8 @@ import ( "time" "github.com/ethereum/go-ethereum/crypto" + "golang.org/x/crypto/sha3" + "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/chain/local_v1" @@ -24,7 +26,6 @@ import ( "github.com/keep-network/keep-core/pkg/protocol/inactivity" "github.com/keep-network/keep-core/pkg/subscription" "github.com/keep-network/keep-core/pkg/tecdsa/dkg" - "golang.org/x/crypto/sha3" ) const ( @@ -102,6 +103,11 @@ type localChain struct { blockCounter chain.BlockCounter operatorPrivateKey *operator.PrivateKey + + reservation *Reservation + reservationAction *ReservationAction + validateReservationAnchorProposalErr error + validateReservationReanchorProposalErr error } func (lc *localChain) BlockCounter() (chain.BlockCounter, error) { @@ -802,6 +808,15 @@ func (lc *localChain) GetDepositRequest( return request, true, nil } +func (lc *localChain) BuildDepositKey( + fundingTxHash bitcoin.Hash, + fundingOutputIndex uint32, +) *big.Int { + depositKeyBytes := buildDepositRequestKey(fundingTxHash, fundingOutputIndex) + + return new(big.Int).SetBytes(depositKeyBytes[:]) +} + func (lc *localChain) setDepositRequest( fundingTxHash bitcoin.Hash, fundingOutputIndex uint32, @@ -1450,3 +1465,187 @@ func generateHandlerID() int { // Local chain implementation doesn't require secure randomness. return rand.Int() } + +// GetReservation returns the reservation previously installed via +// setReservation. Panics if never set, matching this fake chain's +// convention for exercising an unconfigured dependency. +func (lc *localChain) GetReservation( + reservationKey *big.Int, +) (*Reservation, error) { + if lc.reservation != nil { + return lc.reservation, nil + } + panic("unsupported") +} + +// setReservation installs the reservation GetReservation returns. +func (lc *localChain) setReservation(reservation *Reservation) { + lc.reservation = reservation +} + +// GetReservationAction returns the reservation action previously installed +// via setReservationAction. Panics if never set, matching this fake +// chain's convention for exercising an unconfigured dependency. +func (lc *localChain) GetReservationAction( + reservationKey *big.Int, + requestNonce uint64, +) (*ReservationAction, error) { + if lc.reservationAction != nil { + return lc.reservationAction, nil + } + panic("unsupported") +} + +// setReservationAction installs the action GetReservationAction returns. +func (lc *localChain) setReservationAction(action *ReservationAction) { + lc.reservationAction = action +} + +func (lc *localChain) ReservationParameters() (*ReservationParameters, error) { + panic("unsupported") +} + +// ValidateReservationAnchorProposal returns the error previously installed +// via setValidateReservationAnchorProposalErr, or nil (accept) by default. +func (lc *localChain) ValidateReservationAnchorProposal( + walletPublicKeyHash [20]byte, + proposal *ReservationAnchorProposal, + depositExtraInfo struct { + *Deposit + FundingTx *bitcoin.Transaction + }, +) error { + return lc.validateReservationAnchorProposalErr +} + +// setValidateReservationAnchorProposalErr installs the error +// ValidateReservationAnchorProposal returns. +func (lc *localChain) setValidateReservationAnchorProposalErr(err error) { + lc.validateReservationAnchorProposalErr = err +} + +// ValidateReservationReanchorProposal returns the error previously +// installed via setValidateReservationReanchorProposalErr, or nil (accept) +// by default. +func (lc *localChain) ValidateReservationReanchorProposal( + sourceWalletPublicKeyHash [20]byte, + proposal *ReservationReanchorProposal, +) error { + return lc.validateReservationReanchorProposalErr +} + +// setValidateReservationReanchorProposalErr installs the error +// ValidateReservationReanchorProposal returns. +func (lc *localChain) setValidateReservationReanchorProposalErr(err error) { + lc.validateReservationReanchorProposalErr = err +} + +func (lc *localChain) RequestReservationAcceptance( + reservationKey *big.Int, + walletPublicKeyHash [20]byte, +) error { + panic("unsupported") +} + +func (lc *localChain) RequestReservationReanchor( + reservationKey *big.Int, + targetWalletPublicKeyHash [20]byte, +) error { + panic("unsupported") +} + +func (lc *localChain) SubmitReservationProof( + proofType uint8, + txInfo *BitcoinTxInfo, + proof *BitcoinTxProof, + mainUtxo *BitcoinTxUTXO, + reservationKey *big.Int, + requestNonce uint64, +) error { + panic("unsupported") +} + +func (lc *localChain) NotifyReservationActionTimeout( + reservationKey *big.Int, + walletMembersIDs []uint32, +) error { + panic("unsupported") +} + +func (lc *localChain) NotifyStaleReservedDeposit( + depositKey *big.Int, +) error { + panic("unsupported") +} + +func (lc *localChain) NotifyReservationStranded( + reservationKey *big.Int, +) error { + panic("unsupported") +} + +func (lc *localChain) ReservationCaps() ( + uint64, + uint64, + error, +) { + return 0, 0, fmt.Errorf("unsupported") +} + +func (lc *localChain) WalletReservationsAmount( + walletPublicKeyHash [20]byte, +) (uint64, error) { + return 0, fmt.Errorf("unsupported") +} + +func (lc *localChain) WalletReservationsCount( + walletPublicKeyHash [20]byte, +) (uint32, error) { + return 0, fmt.Errorf("unsupported") +} + +func (lc *localChain) WalletReservations( + walletPublicKeyHash [20]byte, +) ([]*big.Int, error) { + return nil, fmt.Errorf("unsupported") +} + +func (lc *localChain) ReservedDepositWallet( + depositKey *big.Int, +) ([20]byte, error) { + return [20]byte{}, fmt.Errorf("unsupported") +} + +func (lc *localChain) ActiveReservationsCount() (uint32, uint32, error) { + return 0, 0, fmt.Errorf("unsupported") +} + +func (lc *localChain) IsReservedDeposit( + depositKey *big.Int, +) (bool, error) { + return false, fmt.Errorf("unsupported") +} + +func (lc *localChain) OnReservationAcceptanceRequested( + handler func(event *ReservationAcceptanceRequestedEvent), +) subscription.EventSubscription { + return subscription.NewEventSubscription(func() {}) +} + +func (lc *localChain) PastReservationAcceptanceRequestedEvents( + filter *ReservationAcceptanceRequestedEventFilter, +) ([]*ReservationAcceptanceRequestedEvent, error) { + return nil, fmt.Errorf("unsupported") +} + +func (lc *localChain) OnReservationReanchorRequested( + handler func(event *ReservationReanchorRequestedEvent), +) subscription.EventSubscription { + return subscription.NewEventSubscription(func() {}) +} + +func (lc *localChain) PastReservationReanchorRequestedEvents( + filter *ReservationReanchorRequestedEventFilter, +) ([]*ReservationReanchorRequestedEvent, error) { + return nil, fmt.Errorf("unsupported") +} diff --git a/pkg/tbtc/coordination.go b/pkg/tbtc/coordination.go index 43e0b2d79f..fc32428004 100644 --- a/pkg/tbtc/coordination.go +++ b/pkg/tbtc/coordination.go @@ -5,22 +5,26 @@ import ( "crypto/sha256" "encoding/binary" "fmt" + "math" "math/rand" "sort" "strings" "time" - "github.com/keep-network/keep-core/pkg/internal/pb" "go.uber.org/zap" "golang.org/x/exp/slices" + "github.com/keep-network/keep-common/pkg/chain/ethereum" + "github.com/keep-network/keep-core/pkg/internal/pb" + + "golang.org/x/sync/semaphore" + "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/clientinfo" "github.com/keep-network/keep-core/pkg/generator" "github.com/keep-network/keep-core/pkg/net" "github.com/keep-network/keep-core/pkg/protocol/group" - "golang.org/x/sync/semaphore" ) const ( @@ -66,8 +70,52 @@ const ( // upgrade to a binary containing this constant before the activation block // is reached. DepositSweepEveryWindowActivationBlock = uint64(24559289) + // reservationsActivationBlocks maps each Ethereum network to the block + // height at which reservation actions (anchor, re-anchor) become + // available in the coordination checklist. All operators must upgrade + // to a binary containing this table before a network's activation + // block is reached, mirroring DepositSweepEveryWindowActivationBlock's + // precondition above. Only ethereum.Developer and ethereum.Unknown + // (local/dev chains) activate the feature immediately at block 0; + // every other public network MUST have an explicit entry here, or + // reservationsActivationBlock never activates the feature for it + // (see that function) instead of silently defaulting to block 0. + // + // NOTE: The mainnet value is a placeholder that MUST be set to its + // real rollout height before release and must stay ahead of the + // mainnet chain tip. Sepolia deliberately has no entry below (falls + // through to math.MaxUint64, i.e. never activates) until a real + // Sepolia rollout height is chosen - an invented placeholder number + // here would be exactly the kind of silently-live landmine this + // table exists to prevent. ) +// reservationsActivationBlocks maps each Ethereum network to its +// reservations activation block. See the doc comment above. +var reservationsActivationBlocks = map[ethereum.Network]uint64{ + ethereum.Mainnet: 26500000, +} + +// reservationsActivationBlock returns the reservations activation block +// height for the given network. Only ethereum.Developer and +// ethereum.Unknown (local/dev chains) return 0, meaning reservation +// actions are active immediately. Every other network without an +// explicit entry in reservationsActivationBlocks returns +// math.MaxUint64, so an unrecognized public network never activates the +// feature instead of silently inheriting an immediate-activation +// default. +func reservationsActivationBlock(network ethereum.Network) uint64 { + if network == ethereum.Developer || network == ethereum.Unknown { + return 0 + } + + if block, ok := reservationsActivationBlocks[network]; ok { + return block + } + + return math.MaxUint64 +} + // errCoordinationExecutorBusy is an error returned when the coordination // executor cannot execute the requested coordination due to an ongoing one. var errCoordinationExecutorBusy = fmt.Errorf("coordination executor is busy") @@ -290,7 +338,8 @@ func (cm *coordinationMessage) Type() string { type coordinationExecutor struct { lock *semaphore.Weighted - chain Chain + chain Chain + ethereumNetwork ethereum.Network coordinatedWallet wallet membersIndexes []group.MemberIndex @@ -316,6 +365,7 @@ type coordinationExecutor struct { // given wallet. func newCoordinationExecutor( chain Chain, + ethereumNetwork ethereum.Network, coordinatedWallet wallet, membersIndexes []group.MemberIndex, operatorAddress chain.Address, @@ -328,6 +378,7 @@ func newCoordinationExecutor( return &coordinationExecutor{ lock: semaphore.NewWeighted(1), chain: chain, + ethereumNetwork: ethereumNetwork, coordinatedWallet: coordinatedWallet, membersIndexes: membersIndexes, operatorAddress: operatorAddress, @@ -586,8 +637,11 @@ func (ce *coordinationExecutor) getActionsChecklist( var actions []WalletActionType - // Redemption action is a priority action and should be checked on every - // coordination window. + // Redemption is a priority action and should be checked on every + // coordination window: unlike MovingFunds (and, pre-activation, the + // sweep actions) which remain frequency-gated below for throughput + // reasons, an unredeemed request risks user funds being stuck, not + // just throughput. actions = append(actions, ActionRedemption) // Other actions should be checked with a lower frequency. The default @@ -623,6 +677,30 @@ func (ce *coordinationExecutor) getActionsChecklist( } } + // Reservation actions (acceptance, re-anchor) are custody-critical like + // Redemption and are checked on every coordination window once the + // activation block is reached, not frequency-gated like the + // throughput-driven DepositSweep/MovedFundsSweep/MovingFunds actions + // above: a delayed reservation acceptance or re-anchor risks the + // on-chain ReservationActionTimeout backstop firing before the wallet + // subsystem gets a chance to act. The activation block is a per-network + // table (reservationsActivationBlock), not a single global constant, but + // it is still config-independent and globally observable from chain + // height alone -- which is what keeps leader and follower checklists in + // agreement without relying on local config. + // + // Note: because getActionsChecklist appends reservation actions after + // Redemption/DepositSweep/MovedFundsSweep/MovingFunds and + // ProposalGenerator.Generate returns on the first checklist action + // that yields a proposal, a wallet with steady redemption/sweep + // traffic can still delay reservation acceptance/re-anchor even + // though the checklist entry itself is unconditional. This is an + // accepted tradeoff bounded by ReservationActionTimeout, not a bug. + if coordinationBlock >= reservationsActivationBlock(ce.ethereumNetwork) { + actions = append(actions, ActionReservationAnchor) + actions = append(actions, ActionReservationReanchor) + } + // #nosec G404 (insecure random number source (rand)) // Drawing a decision about heartbeat does not require secure randomness. // Use first 8 bytes of the seed to initialize the RNG. diff --git a/pkg/tbtc/coordination_test.go b/pkg/tbtc/coordination_test.go index c597048fb0..4978d6ffb8 100644 --- a/pkg/tbtc/coordination_test.go +++ b/pkg/tbtc/coordination_test.go @@ -11,6 +11,9 @@ import ( "time" "github.com/go-test/deep" + "github.com/keep-network/keep-common/pkg/chain/ethereum" + "golang.org/x/exp/slices" + "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/chain/local_v1" @@ -20,7 +23,6 @@ import ( "github.com/keep-network/keep-core/pkg/operator" "github.com/keep-network/keep-core/pkg/protocol/group" "github.com/keep-network/keep-core/pkg/tecdsa" - "golang.org/x/exp/slices" "github.com/keep-network/keep-core/internal/testutils" ) @@ -172,116 +174,227 @@ func TestWatchCoordinationWindows(t *testing.T) { expectWindow(1800) } -func TestCoordinationExecutor_Coordinate(t *testing.T) { - // Uncompressed public key corresponding to the 20-byte public key hash: - // aa768412ceed10bd423c025542ca90071f9fb62d. - publicKeyHex, err := hex.DecodeString( - "0471e30bca60f6548d7b42582a478ea37ada63b402af7b3ddd57f0c95bb6843175" + - "aa0d2053a91a050a6797d85c38f2909cb7027f2344a01986aa2f9f8ca7a0c289", +// coordinationOperatorFixture bundles the per-operator state +// needed to run coordinationExecutor.coordinate as an independent +// in-process simulated node, its own local chain fake plus a broadcast +// channel shared with its peers the same way pkg/tbtc/node wires a real operator. +type coordinationOperatorFixture struct { + chain Chain + address chain.Address + channel net.BroadcastChannel + waitForBlockHeight func(ctx context.Context, blockHeight uint64) error +} + +// newCoordinationOperator builds one simulated operator shared by +// every coordinationExecutor.coordinate integration test in this file: a +// deterministic keypair (so leader election is reproducible across runs), a +// local chain fake wired to that keypair, and a broadcast channel joined to a +// local network shared by every operator in the same test so they exchange +// real coordinationMessage wire traffic. channelName need not be unique +// across test invocations: this registers a t.Cleanup that calls +// netlocal.ReleaseBroadcastChannel(channelName), which cancels that +// specific channel's retransmission ticker and clears the registry, so a +// later invocation reusing the same name starts from an empty registry regardless +// of whether an earlier invocation's leader was still retransmitting. +// channelName is passed as t.Name() purely so a leaked broadcast is easy to +// attribute to its source test. +func newCoordinationOperator( + t *testing.T, + privateKey int64, + coordinationBlock uint64, + channelName string, +) *coordinationOperatorFixture { + t.Helper() + + privateKeyBigInt := big.NewInt(privateKey) + x, y := local_v1.DefaultCurve.ScalarBaseMult(privateKeyBigInt.Bytes()) + + localChain := ConnectWithKey( + &operator.PrivateKey{ + PublicKey: operator.PublicKey{ + Curve: operator.Secp256k1, + X: x, + Y: y, + }, + D: privateKeyBigInt, + }, + 100*time.Millisecond, + ) + + localChain.setBlockHashByNumber( + coordinationBlock-32, + "1422996cbcbc38fc924a46f4df5f9064279d3ab43396e58386dac9b87440d64f", ) + + operatorAddress, err := localChain.operatorAddress() if err != nil { t.Fatal(err) } - // 20-byte public key hash corresponding to the public key above. - buffer, err := hex.DecodeString("aa768412ceed10bd423c025542ca90071f9fb62d") + _, operatorPublicKey, err := localChain.OperatorKeyPair() if err != nil { t.Fatal(err) } - var publicKeyHash [20]byte - copy(publicKeyHash[:], buffer) - - parseScript := func(script string) bitcoin.Script { - parsed, err := hex.DecodeString(script) - if err != nil { - t.Fatal(err) - } - return parsed + broadcastChannel, err := netlocal.ConnectWithKey(operatorPublicKey). + BroadcastChannelFor(channelName) + if err != nil { + t.Fatal(err) } + t.Cleanup(func() { netlocal.ReleaseBroadcastChannel(channelName) }) - coordinationBlock := uint64(900) - - type operatorFixture struct { - chain Chain - address chain.Address - channel net.BroadcastChannel - waitForBlockHeight func(ctx context.Context, blockHeight uint64) error - } - - generateOperator := func(privateKey int64) *operatorFixture { - // Generate operators with deterministic addresses that don't change - // between test runs. This is required to assert the leader selection. - privateKeyBigInt := big.NewInt(privateKey) - x, y := local_v1.DefaultCurve.ScalarBaseMult(privateKeyBigInt.Bytes()) - - localChain := ConnectWithKey( - &operator.PrivateKey{ - PublicKey: operator.PublicKey{ - Curve: operator.Secp256k1, - X: x, - Y: y, - }, - D: privateKeyBigInt, - }, - 100*time.Millisecond, - ) - - localChain.setBlockHashByNumber( - coordinationBlock-32, - "1422996cbcbc38fc924a46f4df5f9064279d3ab43396e58386dac9b87440d64f", - ) + broadcastChannel.SetUnmarshaler(func() net.TaggedUnmarshaler { + return &coordinationMessage{} + }) - operatorAddress, err := localChain.operatorAddress() + waitForBlockHeight := func(ctx context.Context, blockHeight uint64) error { + blockCounter, err := localChain.BlockCounter() if err != nil { - t.Fatal(err) + return err } - _, operatorPublicKey, err := localChain.OperatorKeyPair() + // The local chain fake's block counter always starts at 0 + // regardless of coordinationBlock (see local_v1.BlockCounter), + // but every caller here only ever asks to wait for a height + // derived as coordinationBlock + a fixed small offset (e.g. + // window.activePhaseEndBlock()). Waiting on the raw absolute + // height would take the fake's block-rate multiplied by + // coordinationBlock itself - days of wall-clock time for the + // mainnet-scale coordinationBlock values these tests use - + // leaking the leader's goroutine and this waiter registration + // for the life of the test binary, since coordinate() only + // cancels this context on failure, not on success. Translating + // to the counter's own relative frame makes the wait actually + // reachable in a few seconds instead. + wait, err := blockCounter.BlockHeightWaiter(blockHeight - coordinationBlock) if err != nil { - t.Fatal(err) + return err } - broadcastChannel, err := netlocal.ConnectWithKey(operatorPublicKey). - BroadcastChannelFor("test") - if err != nil { - t.Fatal(err) + select { + case <-wait: + case <-ctx.Done(): } - broadcastChannel.SetUnmarshaler(func() net.TaggedUnmarshaler { - return &coordinationMessage{} - }) + return nil + } - waitForBlockHeight := func(ctx context.Context, blockHeight uint64) error { - blockCounter, err := localChain.BlockCounter() - if err != nil { - return err - } + return &coordinationOperatorFixture{ + chain: localChain, + address: operatorAddress, + channel: broadcastChannel, + waitForBlockHeight: waitForBlockHeight, + } +} - wait, err := blockCounter.BlockHeightWaiter(blockHeight) - if err != nil { - return err - } +// coordinationReport captures one simulated operator's outcome +// from a single coordination round. +type coordinationReport struct { + operatorIndex int + result *coordinationResult + err error +} + +// runCoordinationRound runs coordinationExecutor.coordinate +// concurrently for every given operator against the same window - one +// goroutine per operator, sharing one proposalGenerator, membershipValidator, +// and protocolLatch across all three (the leader is the only goroutine that +// calls Generate; the latch is a shared, mutex-guarded execution counter, +// safe to share precisely because it does not serialize or order the +// goroutines -- the trailing protocolLatch.IsExecuting() == false assertion +// in each caller verifies all three balanced their Lock/Unlock) the same +// way a real node would have each operator drive its own executor in a +// separate process. Fails the test if not every operator reports within the +func runCoordinationRound( + t *testing.T, + operators []*coordinationOperatorFixture, + coordinatedWallet wallet, + proposalGenerator CoordinationProposalGenerator, + membershipValidator *group.MembershipValidator, + protocolLatch *generator.ProtocolLatch, + window *coordinationWindow, +) []*coordinationReport { + t.Helper() + + reportChan := make(chan *coordinationReport, len(operators)) + + for i, currentOperator := range operators { + go func(operatorIndex int, op *coordinationOperatorFixture) { + executor := newCoordinationExecutor( + op.chain, + ethereum.Unknown, + coordinatedWallet, + coordinatedWallet.membersByOperator(op.address), + op.address, + proposalGenerator, + op.channel, + membershipValidator, + protocolLatch, + op.waitForBlockHeight, + ) - select { - case <-wait: - case <-ctx.Done(): + result, err := executor.coordinate(window) + + reportChan <- &coordinationReport{ + operatorIndex: operatorIndex, + result: result, + err: err, } + }(i+1, currentOperator) + } - return nil + deadline := time.After(30 * time.Second) + reports := make([]*coordinationReport, 0, len(operators)) + for len(reports) < len(operators) { + select { + case report := <-reportChan: + reports = append(reports, report) + case <-deadline: + t.Fatalf( + "timed out waiting for coordination reports; got %d of %d", + len(reports), + len(operators), + ) } + } - return &operatorFixture{ - chain: localChain, - address: operatorAddress, - channel: broadcastChannel, - waitForBlockHeight: waitForBlockHeight, - } + return reports +} + +// newCoordinationWallet returns the 3-operator wallet fixture +// shared by every coordinationExecutor.coordinate integration test in this +// file: same wallet public key hash and operator-to-member-index layout, so +// leader election (operator2 wins) is identical across all of them - the +// seed depends only on the wallet public key hash and the safe-block hash +// newCoordinationOperator injects at coordinationBlock-32 (both +// identical across every caller here), not on the raw coordinationBlock +// value itself, so this holds regardless of which block a given caller +// passes. +func newCoordinationWallet( + t *testing.T, + operators []*coordinationOperatorFixture, +) (wallet, [20]byte) { + t.Helper() + + // Uncompressed public key corresponding to the 20-byte public key hash: + // aa768412ceed10bd423c025542ca90071f9fb62d. + publicKeyHex, err := hex.DecodeString( + "0471e30bca60f6548d7b42582a478ea37ada63b402af7b3ddd57f0c95bb6843175" + + "aa0d2053a91a050a6797d85c38f2909cb7027f2344a01986aa2f9f8ca7a0c289", + ) + if err != nil { + t.Fatal(err) } - operator1 := generateOperator(1) - operator2 := generateOperator(2) - operator3 := generateOperator(3) + // 20-byte public key hash corresponding to the public key above. + buffer, err := hex.DecodeString("aa768412ceed10bd423c025542ca90071f9fb62d") + if err != nil { + t.Fatal(err) + } + var publicKeyHash [20]byte + copy(publicKeyHash[:], buffer) + + operator1, operator2, operator3 := operators[0], operators[1], operators[2] coordinatedWallet := wallet{ publicKey: mustUnmarshalPublicKey(t, publicKeyHex), @@ -299,6 +412,32 @@ func TestCoordinationExecutor_Coordinate(t *testing.T) { }, } + return coordinatedWallet, publicKeyHash +} + +func TestCoordinationExecutor_Coordinate(t *testing.T) { + coordinationBlock := uint64(900) + + parseScript := func(script string) bitcoin.Script { + parsed, err := hex.DecodeString(script) + if err != nil { + t.Fatal(err) + } + + return parsed + } + + channelName := t.Name() + + operator1 := newCoordinationOperator(t, 1, coordinationBlock, channelName) + operator2 := newCoordinationOperator(t, 2, coordinationBlock, channelName) + operator3 := newCoordinationOperator(t, 3, coordinationBlock, channelName) + operators := []*coordinationOperatorFixture{ + operator1, operator2, operator3, + } + + coordinatedWallet, publicKeyHash := newCoordinationWallet(t, operators) + proposalGenerator := newMockCoordinationProposalGenerator( func( walletPublicKeyHash [20]byte, @@ -329,65 +468,17 @@ func TestCoordinationExecutor_Coordinate(t *testing.T) { protocolLatch := generator.NewProtocolLatch() - generateExecutor := func(operator *operatorFixture) *coordinationExecutor { - return newCoordinationExecutor( - operator.chain, - coordinatedWallet, - coordinatedWallet.membersByOperator(operator.address), - operator.address, - proposalGenerator, - operator.channel, - membershipValidator, - protocolLatch, - operator.waitForBlockHeight, - ) - } - window := newCoordinationWindow(coordinationBlock) - type report struct { - operatorIndex int - result *coordinationResult - err error - } - - reportChan := make(chan *report, 3) - - for i, currentOperator := range []*operatorFixture{ - operator1, - operator2, - operator3, - } { - go func(operatorIndex int, operator *operatorFixture) { - result, err := generateExecutor(operator).coordinate(window) - - reportChan <- &report{ - operatorIndex: operatorIndex, - result: result, - err: err, - } - }(i+1, currentOperator) - } - - reports := make([]*report, 0) -loop: - //lint:ignore S1000 for-select is used as the channel is not closed by senders. - for { - select { - case r := <-reportChan: - reports = append(reports, r) - - if len(reports) == 3 { - break loop - } - } - } - - slices.SortFunc(reports, func(i, j *report) int { - return i.operatorIndex - j.operatorIndex - }) - - testutils.AssertIntsEqual(t, "reports count", 3, len(reports)) + reports := runCoordinationRound( + t, + operators, + coordinatedWallet, + proposalGenerator, + membershipValidator, + protocolLatch, + window, + ) expectedResult := &coordinationResult{ wallet: coordinatedWallet, @@ -403,32 +494,22 @@ loop: faults: nil, } - expectedReports := []*report{ - { - operatorIndex: 1, - result: expectedResult, - err: nil, - }, - { - operatorIndex: 2, - result: expectedResult, - err: nil, - }, - { - operatorIndex: 3, - result: expectedResult, - err: nil, - }, - } - if !reflect.DeepEqual(expectedReports, reports) { - t.Errorf( - "unexpected reports:\n"+ - "expected: %v\n"+ - "actual: %v", - expectedReports, - reports, - ) - + for _, report := range reports { + if report.err != nil { + t.Fatalf( + "operator %d: unexpected error: %v", + report.operatorIndex, + report.err, + ) + } + if !reflect.DeepEqual(expectedResult, report.result) { + t.Errorf( + "operator %d: unexpected result:\nexpected: %+v\nactual: %+v", + report.operatorIndex, + expectedResult, + report.result, + ) + } } testutils.AssertBoolsEqual( @@ -439,6 +520,155 @@ loop: ) } +// TestCoordinationExecutor_Coordinate_ReservationProposals is the M1 +// multi-signer simulated integration test for Milestone 3: it scales +// TestCoordinationExecutor_Coordinate's 3-operator, real-broadcast-channel, +// real-leader-election harness to the two reservation proposal types, +// proving the leader/follower coordination round-trip (checklist generation +// -> leader election -> broadcast -> follower validation -> convergence) +// that no mocked unit test in pkg/tbtcpg can cover, since those call +// task.Run(request) directly and never go through +// coordinationExecutor.coordinate. The protobuf wire format for both +// proposal types and the checklist activation gate each already have their +// own dedicated coverage elsewhere in this file and in marshaling_test.go; +// this test's unduplicated value is proving the two compose correctly +// through a real coordinate() round-trip. +// +// This test requires ActionReservationAnchor/ActionReservationReanchor to +// actually appear in getActionsChecklist's output; without it, every +// operator's checklist search below falls through to NoopProposal. +func TestCoordinationExecutor_Coordinate_ReservationProposals(t *testing.T) { + // coordinationBlock is an arbitrary block number; every executor + // below is constructed with ethereum.Unknown (activation block 0, + // see runCoordinationRound), so the reservation actions checklist + // gate is satisfied at any height here and this value proves + // nothing about the gate itself (see + // TestCoordinationExecutor_GetActionsChecklist_Reservations for + // dedicated gate coverage). + coordinationBlock := uint64(900) + + tests := map[string]struct { + matchingAction WalletActionType + generatedProposal CoordinationProposal + expectedProposal CoordinationProposal + }{ + "anchor": { + matchingAction: ActionReservationAnchor, + generatedProposal: &ReservationAnchorProposal{ + DepositFundingTxHash: bitcoin.Hash{0x01, 0x02, 0x03}, + DepositFundingOutputIndex: 1, + RequestNonce: 7, + AnchorTxFee: big.NewInt(1500), + }, + expectedProposal: &ReservationAnchorProposal{ + DepositFundingTxHash: bitcoin.Hash{0x01, 0x02, 0x03}, + DepositFundingOutputIndex: 1, + RequestNonce: 7, + AnchorTxFee: big.NewInt(1500), + }, + }, + "reanchor": { + matchingAction: ActionReservationReanchor, + generatedProposal: &ReservationReanchorProposal{ + ReservationKey: big.NewInt(424242), + RequestNonce: 4, + TargetWalletPublicKeyHash: [20]byte{0xf8, 0x7e, 0xb7}, + ReanchorTxFee: big.NewInt(1200), + }, + expectedProposal: &ReservationReanchorProposal{ + ReservationKey: big.NewInt(424242), + RequestNonce: 4, + TargetWalletPublicKeyHash: [20]byte{0xf8, 0x7e, 0xb7}, + ReanchorTxFee: big.NewInt(1200), + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + channelName := t.Name() + + operator1 := newCoordinationOperator(t, 1, coordinationBlock, channelName) + operator2 := newCoordinationOperator(t, 2, coordinationBlock, channelName) + operator3 := newCoordinationOperator(t, 3, coordinationBlock, channelName) + operators := []*coordinationOperatorFixture{ + operator1, operator2, operator3, + } + + coordinatedWallet, publicKeyHash := newCoordinationWallet(t, operators) + + proposalGenerator := newMockCoordinationProposalGenerator( + func( + walletPublicKeyHash [20]byte, + actionsChecklist []WalletActionType, + _ uint, + ) (CoordinationProposal, error) { + for _, action := range actionsChecklist { + if walletPublicKeyHash == publicKeyHash && action == test.matchingAction { + return test.generatedProposal, nil + } + } + + return &NoopProposal{}, nil + }, + ) + + membershipValidator := group.NewMembershipValidator( + &testutils.MockLogger{}, + coordinatedWallet.signingGroupOperators, + Connect().Signing(), + ) + + protocolLatch := generator.NewProtocolLatch() + + window := newCoordinationWindow(coordinationBlock) + + reports := runCoordinationRound( + t, + operators, + coordinatedWallet, + proposalGenerator, + membershipValidator, + protocolLatch, + window, + ) + + expectedResult := &coordinationResult{ + wallet: coordinatedWallet, + window: window, + leader: operator2.address, + proposal: test.expectedProposal, + faults: nil, + } + + for _, report := range reports { + if report.err != nil { + t.Fatalf( + "operator %d: unexpected error: %v", + report.operatorIndex, + report.err, + ) + } + if !reflect.DeepEqual(expectedResult, report.result) { + t.Errorf( + "operator %d: unexpected result:\nexpected: %+v\nactual: %+v", + report.operatorIndex, + expectedResult, + report.result, + ) + } + } + + testutils.AssertBoolsEqual( + t, + "protocol latch state", + false, + protocolLatch.IsExecuting(), + ) + }) + } +} + func TestCoordinationExecutor_GetSeed(t *testing.T) { coordinationBlock := uint64(900) @@ -530,10 +760,13 @@ func TestCoordinationExecutor_GetLeader(t *testing.T) { func TestCoordinationExecutor_GetActionsChecklist(t *testing.T) { // All test cases below exercise the pre-activation code path because - // their coordination blocks are below - // DepositSweepEveryWindowActivationBlock. In this mode, all three - // actions (DepositSweep, MovedFundsSweep, MovingFunds) are gated to - // every 4th coordination window. + // their coordination blocks are below both + // DepositSweepEveryWindowActivationBlock and + // ReservationsActivationBlock. In this mode, DepositSweep, + // MovedFundsSweep, and MovingFunds are all gated to every 4th + // coordination window, and reservation actions never appear at all + // (see TestCoordinationExecutor_GetActionsChecklist_Reservations for + // the activation-block gate itself). tests := map[string]struct { coordinationBlock uint64 expectedChecklist []WalletActionType @@ -562,8 +795,8 @@ func TestCoordinationExecutor_GetActionsChecklist(t *testing.T) { coordinationBlock: 2700, expectedChecklist: []WalletActionType{ActionRedemption}, }, - // 4th-window (window 4): all actions present. Heartbeat randomly - // selected for this specific seed. + // 4th-window (window 4): sweep/moving-funds actions present. + // Heartbeat randomly selected for this specific seed. "block 3600": { coordinationBlock: 3600, expectedChecklist: []WalletActionType{ @@ -586,7 +819,8 @@ func TestCoordinationExecutor_GetActionsChecklist(t *testing.T) { coordinationBlock: 6300, expectedChecklist: []WalletActionType{ActionRedemption}, }, - // 4th-window (window 8): all actions present except heartbeat. + // 4th-window (window 8): sweep/moving-funds actions present, + // no heartbeat for this seed. "block 7200": { coordinationBlock: 7200, expectedChecklist: []WalletActionType{ @@ -608,7 +842,8 @@ func TestCoordinationExecutor_GetActionsChecklist(t *testing.T) { coordinationBlock: 9900, expectedChecklist: []WalletActionType{ActionRedemption}, }, - // 4th-window (window 12): all actions present except heartbeat. + // 4th-window (window 12): sweep/moving-funds actions present, + // no heartbeat for this seed. "block 10800": { coordinationBlock: 10800, expectedChecklist: []WalletActionType{ @@ -624,15 +859,14 @@ func TestCoordinationExecutor_GetActionsChecklist(t *testing.T) { }, "block 12600": { coordinationBlock: 12600, - expectedChecklist: []WalletActionType{ - ActionRedemption, - }, + expectedChecklist: []WalletActionType{ActionRedemption}, }, "block 13500": { coordinationBlock: 13500, expectedChecklist: []WalletActionType{ActionRedemption}, }, - // 4th-window (window 16): all actions present except heartbeat. + // 4th-window (window 16): sweep/moving-funds actions present, + // no heartbeat for this seed. "block 14400": { coordinationBlock: 14400, expectedChecklist: []WalletActionType{ @@ -649,7 +883,7 @@ func TestCoordinationExecutor_GetActionsChecklist(t *testing.T) { // loop since it does not vary per subtest. var _ uint64 = DepositSweepEveryWindowActivationBlock - executor := &coordinationExecutor{} + executor := &coordinationExecutor{ethereumNetwork: ethereum.Mainnet} for testName, test := range tests { t.Run( @@ -696,7 +930,7 @@ func TestCoordinationExecutor_GetActionsChecklist_PostActivation(t *testing.T) { is4thWindow bool }{ // Non-4th window (window 27289): DepositSweep and - // MovedFundsSweep present, MovingFunds absent. + // MovedFundsSweep present, MovingFunds absent (frequency-gated). "post-activation non-4th window 27289": { coordinationBlock: 24560100, expectedChecklist: []WalletActionType{ @@ -725,7 +959,8 @@ func TestCoordinationExecutor_GetActionsChecklist_PostActivation(t *testing.T) { is4thWindow: false, }, // 4th window (window 27292, divisible by 4): MovingFunds - // appears. Heartbeat is NOT triggered for this seed. + // appears (frequency-gated). Heartbeat is NOT triggered for + // this seed. "post-activation 4th window 27292 no heartbeat": { coordinationBlock: 24562800, expectedChecklist: []WalletActionType{ @@ -759,7 +994,8 @@ func TestCoordinationExecutor_GetActionsChecklist_PostActivation(t *testing.T) { is4thWindow: false, }, // 4th window (window 27320, divisible by 4): MovingFunds - // appears. Heartbeat is also triggered for this seed. + // appears (frequency-gated). Heartbeat is also triggered for + // this seed. "post-activation 4th window 27320 with heartbeat": { coordinationBlock: 24588000, expectedChecklist: []WalletActionType{ @@ -772,8 +1008,9 @@ func TestCoordinationExecutor_GetActionsChecklist_PostActivation(t *testing.T) { is4thWindow: true, }, // 4th window (window 27296, divisible by 4): MovingFunds - // appears. Heartbeat is NOT triggered, verifying that - // 4th-window behavior works independently of heartbeat. + // appears (frequency-gated). Heartbeat is NOT triggered, + // verifying that 4th-window behavior works independently of + // heartbeat. "post-activation 4th window 27296 no heartbeat": { coordinationBlock: 24566400, expectedChecklist: []WalletActionType{ @@ -790,7 +1027,7 @@ func TestCoordinationExecutor_GetActionsChecklist_PostActivation(t *testing.T) { // be typed as uint64. var _ uint64 = DepositSweepEveryWindowActivationBlock - executor := &coordinationExecutor{} + executor := &coordinationExecutor{ethereumNetwork: ethereum.Mainnet} for testName, test := range tests { t.Run( @@ -838,6 +1075,82 @@ func TestCoordinationExecutor_GetActionsChecklist_PostActivation(t *testing.T) { } } +// TestCoordinationExecutor_GetActionsChecklist_Reservations verifies the +// reservation actions checklist gate depends solely on the activation +// block, never on the frequency window or a local per-operator +// configuration flag - see coordinationExecutor.getActionsChecklist's +// comment for why: a follower gating checklist validation on its own +// local flag would wrongly fault an honest leader whenever the two +// operators' local configs diverge. +func TestCoordinationExecutor_GetActionsChecklist_Reservations(t *testing.T) { + tests := map[string]struct { + coordinationBlock uint64 + windowIndex uint64 + expectedReservationActions []WalletActionType + }{ + "below activation": { + coordinationBlock: reservationsActivationBlocks[ethereum.Mainnet] - 1, + windowIndex: 4, + expectedReservationActions: nil, + }, + "at activation, non-4th window": { + coordinationBlock: reservationsActivationBlocks[ethereum.Mainnet], + windowIndex: 5, + expectedReservationActions: []WalletActionType{ActionReservationAnchor, ActionReservationReanchor}, + }, + "at activation, 4th window": { + coordinationBlock: reservationsActivationBlocks[ethereum.Mainnet], + windowIndex: 4, + expectedReservationActions: []WalletActionType{ActionReservationAnchor, ActionReservationReanchor}, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + executor := &coordinationExecutor{ethereumNetwork: ethereum.Mainnet} + + // We don't care about the seed for this test, as it only affects + // the ActionHeartbeat which is not the focus here. + seed := [32]byte{} + + checklist := executor.getActionsChecklist( + test.windowIndex, + seed, + test.coordinationBlock, + ) + + // We only care about reservation actions. + var actualReservationActions []WalletActionType + for _, action := range checklist { + if action == ActionReservationAnchor || action == ActionReservationReanchor { + actualReservationActions = append(actualReservationActions, action) + } + } + + if diff := deep.Equal(actualReservationActions, test.expectedReservationActions); diff != nil { + t.Errorf("reservation actions mismatch: %v", diff) + } + }) + } +} + +func TestReservationsActivationBlock_SanityCheck(t *testing.T) { + // Reference Ethereum mainnet block height as of 2026-09-02 (~25,880,000). + // reservationsActivationBlocks[ethereum.Mainnet] must be set to a future + // block height ahead of chain tip before release. If this test fails, + // both the reference height and that value must be updated. + const referenceMainnetBlockHeight = uint64(25880000) + + mainnetActivationBlock := reservationsActivationBlocks[ethereum.Mainnet] + if mainnetActivationBlock <= referenceMainnetBlockHeight { + t.Errorf( + "mainnet reservationsActivationBlock [%d] must be ahead of the reference mainnet block height [%d]", + mainnetActivationBlock, + referenceMainnetBlockHeight, + ) + } +} + // assertPostActivationSafety verifies the safety invariants that must hold // for every non-nil post-activation checklist: // - ActionRedemption is at index 0. @@ -884,8 +1197,8 @@ func assertPostActivationSafety( // assertChecklistOrdering verifies that actions appear in canonical priority // order: Redemption < DepositSweep < MovedFundsSweep < MovingFunds < -// Heartbeat. Each consecutive pair of actions must have strictly increasing -// priority values. +// ReservationAnchor < ReservationReanchor < Heartbeat. Each consecutive pair +// of actions must have strictly increasing priority values. func assertChecklistOrdering( t *testing.T, checklist []WalletActionType, @@ -893,11 +1206,13 @@ func assertChecklistOrdering( t.Helper() actionPriority := map[WalletActionType]int{ - ActionRedemption: 0, - ActionDepositSweep: 1, - ActionMovedFundsSweep: 2, - ActionMovingFunds: 3, - ActionHeartbeat: 4, + ActionRedemption: 0, + ActionDepositSweep: 1, + ActionMovedFundsSweep: 2, + ActionMovingFunds: 3, + ActionReservationAnchor: 4, + ActionReservationReanchor: 5, + ActionHeartbeat: 6, } for i := 1; i < len(checklist); i++ { @@ -987,6 +1302,7 @@ func TestCoordinationExecutor_ExecuteLeaderRoutine(t *testing.T) { if err != nil { t.Fatal(err) } + t.Cleanup(func() { netlocal.ReleaseBroadcastChannel("test") }) broadcastChannel.SetUnmarshaler(func() net.TaggedUnmarshaler { return &coordinationMessage{} @@ -1196,6 +1512,7 @@ func TestCoordinationExecutor_ExecuteFollowerRoutine(t *testing.T) { if err != nil { t.Fatal(err) } + t.Cleanup(func() { netlocal.ReleaseBroadcastChannel("test") }) broadcastChannel.SetUnmarshaler(func() net.TaggedUnmarshaler { return &coordinationMessage{} @@ -1481,6 +1798,7 @@ func TestCoordinationExecutor_ExecuteFollowerRoutine_WithIdleLeader(t *testing.T if err != nil { t.Fatal(err) } + t.Cleanup(func() { netlocal.ReleaseBroadcastChannel("test-idle") }) executor := &coordinationExecutor{ // Set only relevant fields. diff --git a/pkg/tbtc/gen/pb/message.pb.go b/pkg/tbtc/gen/pb/message.pb.go index 7496ad009d..3d5037d7a4 100644 --- a/pkg/tbtc/gen/pb/message.pb.go +++ b/pkg/tbtc/gen/pb/message.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.28.0 -// protoc v3.19.4 +// protoc v3.21.12 // source: pkg/tbtc/gen/pb/message.proto package pb @@ -508,6 +508,148 @@ func (x *MovedFundsSweepProposal) GetSweepTxFee() []byte { return nil } +type ReservationAnchorProposal struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + DepositFundingTxHash []byte `protobuf:"bytes,1,opt,name=depositFundingTxHash,proto3" json:"depositFundingTxHash,omitempty"` + DepositFundingOutputIndex uint32 `protobuf:"varint,2,opt,name=depositFundingOutputIndex,proto3" json:"depositFundingOutputIndex,omitempty"` + RequestNonce uint64 `protobuf:"varint,3,opt,name=requestNonce,proto3" json:"requestNonce,omitempty"` + AnchorTxFee []byte `protobuf:"bytes,4,opt,name=anchorTxFee,proto3" json:"anchorTxFee,omitempty"` +} + +func (x *ReservationAnchorProposal) Reset() { + *x = ReservationAnchorProposal{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_tbtc_gen_pb_message_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ReservationAnchorProposal) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReservationAnchorProposal) ProtoMessage() {} + +func (x *ReservationAnchorProposal) ProtoReflect() protoreflect.Message { + mi := &file_pkg_tbtc_gen_pb_message_proto_msgTypes[8] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReservationAnchorProposal.ProtoReflect.Descriptor instead. +func (*ReservationAnchorProposal) Descriptor() ([]byte, []int) { + return file_pkg_tbtc_gen_pb_message_proto_rawDescGZIP(), []int{8} +} + +func (x *ReservationAnchorProposal) GetDepositFundingTxHash() []byte { + if x != nil { + return x.DepositFundingTxHash + } + return nil +} + +func (x *ReservationAnchorProposal) GetDepositFundingOutputIndex() uint32 { + if x != nil { + return x.DepositFundingOutputIndex + } + return 0 +} + +func (x *ReservationAnchorProposal) GetRequestNonce() uint64 { + if x != nil { + return x.RequestNonce + } + return 0 +} + +func (x *ReservationAnchorProposal) GetAnchorTxFee() []byte { + if x != nil { + return x.AnchorTxFee + } + return nil +} + +type ReservationReanchorProposal struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ReservationKey []byte `protobuf:"bytes,1,opt,name=reservationKey,proto3" json:"reservationKey,omitempty"` + RequestNonce uint64 `protobuf:"varint,2,opt,name=requestNonce,proto3" json:"requestNonce,omitempty"` + TargetWalletPublicKeyHash []byte `protobuf:"bytes,3,opt,name=targetWalletPublicKeyHash,proto3" json:"targetWalletPublicKeyHash,omitempty"` + ReanchorTxFee []byte `protobuf:"bytes,4,opt,name=reanchorTxFee,proto3" json:"reanchorTxFee,omitempty"` +} + +func (x *ReservationReanchorProposal) Reset() { + *x = ReservationReanchorProposal{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_tbtc_gen_pb_message_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ReservationReanchorProposal) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReservationReanchorProposal) ProtoMessage() {} + +func (x *ReservationReanchorProposal) ProtoReflect() protoreflect.Message { + mi := &file_pkg_tbtc_gen_pb_message_proto_msgTypes[9] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReservationReanchorProposal.ProtoReflect.Descriptor instead. +func (*ReservationReanchorProposal) Descriptor() ([]byte, []int) { + return file_pkg_tbtc_gen_pb_message_proto_rawDescGZIP(), []int{9} +} + +func (x *ReservationReanchorProposal) GetReservationKey() []byte { + if x != nil { + return x.ReservationKey + } + return nil +} + +func (x *ReservationReanchorProposal) GetRequestNonce() uint64 { + if x != nil { + return x.RequestNonce + } + return 0 +} + +func (x *ReservationReanchorProposal) GetTargetWalletPublicKeyHash() []byte { + if x != nil { + return x.TargetWalletPublicKeyHash + } + return nil +} + +func (x *ReservationReanchorProposal) GetReanchorTxFee() []byte { + if x != nil { + return x.ReanchorTxFee + } + return nil +} + type DepositSweepProposal_DepositKey struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -520,7 +662,7 @@ type DepositSweepProposal_DepositKey struct { func (x *DepositSweepProposal_DepositKey) Reset() { *x = DepositSweepProposal_DepositKey{} if protoimpl.UnsafeEnabled { - mi := &file_pkg_tbtc_gen_pb_message_proto_msgTypes[8] + mi := &file_pkg_tbtc_gen_pb_message_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -533,7 +675,7 @@ func (x *DepositSweepProposal_DepositKey) String() string { func (*DepositSweepProposal_DepositKey) ProtoMessage() {} func (x *DepositSweepProposal_DepositKey) ProtoReflect() protoreflect.Message { - mi := &file_pkg_tbtc_gen_pb_message_proto_msgTypes[8] + mi := &file_pkg_tbtc_gen_pb_message_proto_msgTypes[10] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -642,8 +784,34 @@ var file_pkg_tbtc_gen_pb_message_proto_rawDesc = []byte{ 0x46, 0x75, 0x6e, 0x64, 0x73, 0x54, 0x78, 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x1e, 0x0a, 0x0a, 0x73, 0x77, 0x65, 0x65, 0x70, 0x54, 0x78, 0x46, 0x65, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x73, 0x77, 0x65, 0x65, 0x70, 0x54, 0x78, 0x46, - 0x65, 0x65, 0x42, 0x06, 0x5a, 0x04, 0x2e, 0x2f, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x33, + 0x65, 0x65, 0x22, 0xd3, 0x01, 0x0a, 0x19, 0x52, 0x65, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x41, 0x6e, 0x63, 0x68, 0x6f, 0x72, 0x50, 0x72, 0x6f, 0x70, 0x6f, 0x73, 0x61, 0x6c, + 0x12, 0x32, 0x0a, 0x14, 0x64, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x46, 0x75, 0x6e, 0x64, 0x69, + 0x6e, 0x67, 0x54, 0x78, 0x48, 0x61, 0x73, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x14, + 0x64, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x46, 0x75, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x54, 0x78, + 0x48, 0x61, 0x73, 0x68, 0x12, 0x3c, 0x0a, 0x19, 0x64, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x46, + 0x75, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x49, 0x6e, 0x64, 0x65, + 0x78, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x19, 0x64, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, + 0x46, 0x75, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x49, 0x6e, 0x64, + 0x65, 0x78, 0x12, 0x22, 0x0a, 0x0c, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4e, 0x6f, 0x6e, + 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0c, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x4e, 0x6f, 0x6e, 0x63, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x61, 0x6e, 0x63, 0x68, 0x6f, 0x72, + 0x54, 0x78, 0x46, 0x65, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0b, 0x61, 0x6e, 0x63, + 0x68, 0x6f, 0x72, 0x54, 0x78, 0x46, 0x65, 0x65, 0x22, 0xcd, 0x01, 0x0a, 0x1b, 0x52, 0x65, 0x73, + 0x65, 0x72, 0x76, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x61, 0x6e, 0x63, 0x68, 0x6f, 0x72, + 0x50, 0x72, 0x6f, 0x70, 0x6f, 0x73, 0x61, 0x6c, 0x12, 0x26, 0x0a, 0x0e, 0x72, 0x65, 0x73, 0x65, + 0x72, 0x76, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, + 0x52, 0x0e, 0x72, 0x65, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4b, 0x65, 0x79, + 0x12, 0x22, 0x0a, 0x0c, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4e, 0x6f, 0x6e, 0x63, 0x65, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0c, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4e, + 0x6f, 0x6e, 0x63, 0x65, 0x12, 0x3c, 0x0a, 0x19, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x57, 0x61, + 0x6c, 0x6c, 0x65, 0x74, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x48, 0x61, 0x73, + 0x68, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x19, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x57, + 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x48, 0x61, + 0x73, 0x68, 0x12, 0x24, 0x0a, 0x0d, 0x72, 0x65, 0x61, 0x6e, 0x63, 0x68, 0x6f, 0x72, 0x54, 0x78, + 0x46, 0x65, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0d, 0x72, 0x65, 0x61, 0x6e, 0x63, + 0x68, 0x6f, 0x72, 0x54, 0x78, 0x46, 0x65, 0x65, 0x42, 0x06, 0x5a, 0x04, 0x2e, 0x2f, 0x70, 0x62, + 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -658,7 +826,7 @@ func file_pkg_tbtc_gen_pb_message_proto_rawDescGZIP() []byte { return file_pkg_tbtc_gen_pb_message_proto_rawDescData } -var file_pkg_tbtc_gen_pb_message_proto_msgTypes = make([]protoimpl.MessageInfo, 9) +var file_pkg_tbtc_gen_pb_message_proto_msgTypes = make([]protoimpl.MessageInfo, 11) var file_pkg_tbtc_gen_pb_message_proto_goTypes = []interface{}{ (*SigningDoneMessage)(nil), // 0: tbtc.SigningDoneMessage (*CoordinationProposal)(nil), // 1: tbtc.CoordinationProposal @@ -668,16 +836,18 @@ var file_pkg_tbtc_gen_pb_message_proto_goTypes = []interface{}{ (*RedemptionProposal)(nil), // 5: tbtc.RedemptionProposal (*MovingFundsProposal)(nil), // 6: tbtc.MovingFundsProposal (*MovedFundsSweepProposal)(nil), // 7: tbtc.MovedFundsSweepProposal - (*DepositSweepProposal_DepositKey)(nil), // 8: tbtc.DepositSweepProposal.DepositKey + (*ReservationAnchorProposal)(nil), // 8: tbtc.ReservationAnchorProposal + (*ReservationReanchorProposal)(nil), // 9: tbtc.ReservationReanchorProposal + (*DepositSweepProposal_DepositKey)(nil), // 10: tbtc.DepositSweepProposal.DepositKey } var file_pkg_tbtc_gen_pb_message_proto_depIdxs = []int32{ - 1, // 0: tbtc.CoordinationMessage.proposal:type_name -> tbtc.CoordinationProposal - 8, // 1: tbtc.DepositSweepProposal.depositsKeys:type_name -> tbtc.DepositSweepProposal.DepositKey - 2, // [2:2] is the sub-list for method output_type - 2, // [2:2] is the sub-list for method input_type - 2, // [2:2] is the sub-list for extension type_name - 2, // [2:2] is the sub-list for extension extendee - 0, // [0:2] is the sub-list for field type_name + 1, // 0: tbtc.CoordinationMessage.proposal:type_name -> tbtc.CoordinationProposal + 10, // 1: tbtc.DepositSweepProposal.depositsKeys:type_name -> tbtc.DepositSweepProposal.DepositKey + 2, // [2:2] is the sub-list for method output_type + 2, // [2:2] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name } func init() { file_pkg_tbtc_gen_pb_message_proto_init() } @@ -783,6 +953,30 @@ func file_pkg_tbtc_gen_pb_message_proto_init() { } } file_pkg_tbtc_gen_pb_message_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ReservationAnchorProposal); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_pkg_tbtc_gen_pb_message_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ReservationReanchorProposal); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_pkg_tbtc_gen_pb_message_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*DepositSweepProposal_DepositKey); i { case 0: return &v.state @@ -801,7 +995,7 @@ func file_pkg_tbtc_gen_pb_message_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_pkg_tbtc_gen_pb_message_proto_rawDesc, NumEnums: 0, - NumMessages: 9, + NumMessages: 11, NumExtensions: 0, NumServices: 0, }, diff --git a/pkg/tbtc/gen/pb/message.proto b/pkg/tbtc/gen/pb/message.proto index e26d31ab46..46833241fe 100644 --- a/pkg/tbtc/gen/pb/message.proto +++ b/pkg/tbtc/gen/pb/message.proto @@ -53,3 +53,17 @@ message MovedFundsSweepProposal { uint32 movingFundsTxOutputIndex = 2; bytes sweepTxFee = 3; } + +message ReservationAnchorProposal { + bytes depositFundingTxHash = 1; + uint32 depositFundingOutputIndex = 2; + uint64 requestNonce = 3; + bytes anchorTxFee = 4; +} + +message ReservationReanchorProposal { + bytes reservationKey = 1; + uint64 requestNonce = 2; + bytes targetWalletPublicKeyHash = 3; + bytes reanchorTxFee = 4; +} diff --git a/pkg/tbtc/inactivity_test.go b/pkg/tbtc/inactivity_test.go index ce8762a455..7eab1eebba 100644 --- a/pkg/tbtc/inactivity_test.go +++ b/pkg/tbtc/inactivity_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/keep-network/keep-common/pkg/chain/ethereum" "golang.org/x/crypto/sha3" "github.com/keep-network/keep-core/internal/testutils" @@ -171,6 +172,7 @@ func setupInactivityClaimExecutorScenario(t *testing.T) ( ) node, err := newNode( + ethereum.Unknown, groupParameters, localChain, newLocalBitcoinChain(), diff --git a/pkg/tbtc/marshaling.go b/pkg/tbtc/marshaling.go index 5483b43d0d..1087969335 100644 --- a/pkg/tbtc/marshaling.go +++ b/pkg/tbtc/marshaling.go @@ -229,12 +229,14 @@ func unmarshalCoordinationProposal(actionType uint32, payload []byte) ( } proposal, ok := map[WalletActionType]CoordinationProposal{ - ActionNoop: &NoopProposal{}, - ActionHeartbeat: &HeartbeatProposal{}, - ActionDepositSweep: &DepositSweepProposal{}, - ActionRedemption: &RedemptionProposal{}, - ActionMovingFunds: &MovingFundsProposal{}, - ActionMovedFundsSweep: &MovedFundsSweepProposal{}, + ActionNoop: &NoopProposal{}, + ActionHeartbeat: &HeartbeatProposal{}, + ActionDepositSweep: &DepositSweepProposal{}, + ActionRedemption: &RedemptionProposal{}, + ActionMovingFunds: &MovingFundsProposal{}, + ActionMovedFundsSweep: &MovedFundsSweepProposal{}, + ActionReservationAnchor: &ReservationAnchorProposal{}, + ActionReservationReanchor: &ReservationReanchorProposal{}, }[parsedActionType] if !ok { return nil, fmt.Errorf( @@ -484,3 +486,113 @@ func validateMemberIndex(protoIndex uint32) error { } return nil } + +// Marshal converts the ReservationAnchorProposal to a byte array. +func (rap *ReservationAnchorProposal) Marshal() ([]byte, error) { + if rap.AnchorTxFee == nil { + return nil, fmt.Errorf("anchor transaction fee is required") + } + + return proto.Marshal( + &pb.ReservationAnchorProposal{ + DepositFundingTxHash: rap.DepositFundingTxHash[:], + DepositFundingOutputIndex: rap.DepositFundingOutputIndex, + RequestNonce: rap.RequestNonce, + AnchorTxFee: rap.AnchorTxFee.Bytes(), + }) +} + +// Unmarshal converts a byte array back to the ReservationAnchorProposal. +func (rap *ReservationAnchorProposal) Unmarshal(data []byte) error { + pbMsg := pb.ReservationAnchorProposal{} + if err := proto.Unmarshal(data, &pbMsg); err != nil { + return fmt.Errorf("failed to unmarshal ReservationAnchorProposal: [%v]", err) + } + + if len(pbMsg.AnchorTxFee) == 0 { + return fmt.Errorf("anchor transaction fee is required") + } + if len(pbMsg.AnchorTxFee) > 8 { + return fmt.Errorf( + "invalid anchor transaction fee byte length: [%v]", + len(pbMsg.AnchorTxFee), + ) + } + if pbMsg.RequestNonce == 0 { + return fmt.Errorf("request nonce is required") + } + if len(pbMsg.DepositFundingTxHash) != 32 { + return fmt.Errorf( + "invalid deposit funding tx hash length: [%v]", + len(pbMsg.DepositFundingTxHash), + ) + } + if [32]byte(pbMsg.DepositFundingTxHash) == [32]byte{} { + return fmt.Errorf("deposit funding tx hash is required") + } + + copy(rap.DepositFundingTxHash[:], pbMsg.DepositFundingTxHash) + rap.DepositFundingOutputIndex = pbMsg.DepositFundingOutputIndex + rap.RequestNonce = pbMsg.RequestNonce + rap.AnchorTxFee = new(big.Int).SetBytes(pbMsg.AnchorTxFee) + + return nil +} + +// Marshal converts the ReservationReanchorProposal to a byte array. +func (rrp *ReservationReanchorProposal) Marshal() ([]byte, error) { + if rrp.ReservationKey == nil { + return nil, fmt.Errorf("reservation key is required") + } + if rrp.ReanchorTxFee == nil { + return nil, fmt.Errorf("re-anchor transaction fee is required") + } + + return proto.Marshal( + &pb.ReservationReanchorProposal{ + ReservationKey: rrp.ReservationKey.Bytes(), + RequestNonce: rrp.RequestNonce, + TargetWalletPublicKeyHash: append([]byte{}, rrp.TargetWalletPublicKeyHash[:]...), + ReanchorTxFee: rrp.ReanchorTxFee.Bytes(), + }) +} + +// Unmarshal converts a byte array back to the ReservationReanchorProposal. +func (rrp *ReservationReanchorProposal) Unmarshal(data []byte) error { + pbMsg := pb.ReservationReanchorProposal{} + if err := proto.Unmarshal(data, &pbMsg); err != nil { + return fmt.Errorf("failed to unmarshal ReservationReanchorProposal: [%v]", err) + } + + if len(pbMsg.ReservationKey) == 0 { + return fmt.Errorf("reservation key is required") + } + if pbMsg.RequestNonce == 0 { + return fmt.Errorf("request nonce is required") + } + if len(pbMsg.ReanchorTxFee) == 0 { + return fmt.Errorf("re-anchor transaction fee is required") + } + if len(pbMsg.ReanchorTxFee) > 8 { + return fmt.Errorf( + "invalid re-anchor transaction fee byte length: [%v]", + len(pbMsg.ReanchorTxFee), + ) + } + if len(pbMsg.TargetWalletPublicKeyHash) != 20 { + return fmt.Errorf( + "invalid target wallet public key hash length: [%v]", + len(pbMsg.TargetWalletPublicKeyHash), + ) + } + copy(rrp.TargetWalletPublicKeyHash[:], pbMsg.TargetWalletPublicKeyHash) + if rrp.TargetWalletPublicKeyHash == [20]byte{} { + return fmt.Errorf("target wallet public key hash is required") + } + + rrp.ReservationKey = new(big.Int).SetBytes(pbMsg.ReservationKey) + rrp.RequestNonce = pbMsg.RequestNonce + rrp.ReanchorTxFee = new(big.Int).SetBytes(pbMsg.ReanchorTxFee) + + return nil +} diff --git a/pkg/tbtc/marshaling_test.go b/pkg/tbtc/marshaling_test.go index 6fcbdcf831..fcfae5da64 100644 --- a/pkg/tbtc/marshaling_test.go +++ b/pkg/tbtc/marshaling_test.go @@ -228,6 +228,22 @@ func TestCoordinationMessage_MarshalingRoundtrip(t *testing.T) { SweepTxFee: big.NewInt(8000), }, }, + "with reservation anchor proposal": { + proposal: &ReservationAnchorProposal{ + DepositFundingTxHash: parseHash("709b55bd3da0f5a838125bd0ee20c5bfdd7caba173912d4281cae816b79a201b"), + DepositFundingOutputIndex: 2, + RequestNonce: 7, + AnchorTxFee: big.NewInt(1500), + }, + }, + "with reservation reanchor proposal": { + proposal: &ReservationReanchorProposal{ + ReservationKey: big.NewInt(424242), + RequestNonce: 4, + TargetWalletPublicKeyHash: toByte20("f87eb7ec3b15a3fdd7b57754d765694b3e0b4bf4"), + ReanchorTxFee: big.NewInt(1200), + }, + }, } walletPublicKeyHash := toByte20("aa768412ceed10bd423c025542ca90071f9fb62d") @@ -399,6 +415,64 @@ func TestFuzzCoordinationMessage_MarshalingRoundtrip_WithMovedFundsSweepProposal } } +func TestFuzzCoordinationMessage_MarshalingRoundtrip_WithReservationAnchorProposal(t *testing.T) { + for i := 0; i < 10; i++ { + var ( + senderID group.MemberIndex + coordinationBlock uint64 + walletPublicKeyHash [20]byte + proposal ReservationAnchorProposal + ) + + f := fuzz.New().NilChance(0.1). + NumElements(0, 512). + Funcs(pbutils.FuzzFuncs()...) + + f.Fuzz(&senderID) + f.Fuzz(&coordinationBlock) + f.Fuzz(&walletPublicKeyHash) + f.Fuzz(&proposal) + + coordinationMsg := &coordinationMessage{ + senderID: senderID, + coordinationBlock: coordinationBlock, + walletPublicKeyHash: walletPublicKeyHash, + proposal: &proposal, + } + + _ = pbutils.RoundTrip(coordinationMsg, &coordinationMessage{}) + } +} + +func TestFuzzCoordinationMessage_MarshalingRoundtrip_WithReservationReanchorProposal(t *testing.T) { + for i := 0; i < 10; i++ { + var ( + senderID group.MemberIndex + coordinationBlock uint64 + walletPublicKeyHash [20]byte + proposal ReservationReanchorProposal + ) + + f := fuzz.New().NilChance(0.1). + NumElements(0, 512). + Funcs(pbutils.FuzzFuncs()...) + + f.Fuzz(&senderID) + f.Fuzz(&coordinationBlock) + f.Fuzz(&walletPublicKeyHash) + f.Fuzz(&proposal) + + coordinationMsg := &coordinationMessage{ + senderID: senderID, + coordinationBlock: coordinationBlock, + walletPublicKeyHash: walletPublicKeyHash, + proposal: &proposal, + } + + _ = pbutils.RoundTrip(coordinationMsg, &coordinationMessage{}) + } +} + func TestFuzzCoordinationMessage_MarshalingRoundtrip_WithNoopProposal(t *testing.T) { for i := 0; i < 10; i++ { var ( @@ -428,6 +502,44 @@ func TestFuzzCoordinationMessage_MarshalingRoundtrip_WithNoopProposal(t *testing } } +func TestReservationAnchorProposal_Marshal_NilPanic(t *testing.T) { + proposal := &ReservationAnchorProposal{ + AnchorTxFee: nil, + } + + _, err := proposal.Marshal() + if err == nil { + t.Fatal("expected error when marshaling proposal with nil AnchorTxFee") + } + if err.Error() != "anchor transaction fee is required" { + t.Errorf("unexpected error: [%v]", err) + } +} + +func TestReservationAnchorProposal_Unmarshal_ZeroHash(t *testing.T) { + proposal := &ReservationAnchorProposal{ + DepositFundingTxHash: [32]byte{}, + } + // Manually construct the protobuf message to bypass nil check + pbMsg := &pb.ReservationAnchorProposal{ + DepositFundingTxHash: proposal.DepositFundingTxHash[:], + DepositFundingOutputIndex: 0, + RequestNonce: 1, + AnchorTxFee: big.NewInt(1000).Bytes(), + } + data, err := proto.Marshal(pbMsg) + if err != nil { + t.Fatal(err) + } + + err = proposal.Unmarshal(data) + if err == nil { + t.Fatal("expected error when unmarshaling proposal with zero hash") + } + if err.Error() != "deposit funding tx hash is required" { + t.Errorf("unexpected error: [%v]", err) + } +} func TestFuzzCoordinationMessage_Unmarshaler(t *testing.T) { pbutils.FuzzUnmarshaler(&coordinationMessage{}) } diff --git a/pkg/tbtc/node.go b/pkg/tbtc/node.go index fcf71ed5cb..5a96077995 100644 --- a/pkg/tbtc/node.go +++ b/pkg/tbtc/node.go @@ -10,7 +10,9 @@ import ( "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/clientinfo" + "github.com/keep-network/keep-common/pkg/chain/ethereum" "github.com/keep-network/keep-common/pkg/persistence" + "github.com/keep-network/keep-core/pkg/generator" "github.com/keep-network/keep-core/pkg/net" ) @@ -43,6 +45,7 @@ const ( // node represents the current state of an ECDSA node. type node struct { + ethereumNetwork ethereum.Network groupParameters *GroupParameters chain Chain @@ -120,6 +123,7 @@ type node struct { } func newNode( + ethereumNetwork ethereum.Network, groupParameters *GroupParameters, chain Chain, btcChain bitcoin.Chain, @@ -144,6 +148,7 @@ func newNode( node := &node{ groupParameters: groupParameters, chain: chain, + ethereumNetwork: ethereumNetwork, btcChain: btcChain, netProvider: netProvider, walletRegistry: walletRegistry, @@ -229,6 +234,18 @@ func (n *node) setPerformanceMetrics(metrics interface { pg.SetRedemptionMetricsRecorder(metrics) } + // Wire reservation metrics to proposal generator if it supports it, + // mirroring the redemption wiring above. A no-op on non-reservation + // deployments since SetReservationMetricsRecorder finds no reservation + // tasks in that case. + if pg, ok := n.proposalGenerator.(interface { + SetReservationMetricsRecorder(recorder interface { + SetGauge(name string, value float64) + }) + }); ok { + pg.SetReservationMetricsRecorder(metrics) + } + // Update metrics recorder for all cached coordination executors // This is important because executors may be created before metrics are set n.coordinationExecutorsMutex.Lock() @@ -310,3 +327,20 @@ func (n *node) validateDKG( ) { n.dkgExecutor.executeDkgValidation(seed, submissionBlock, result, resultHash) } + +func (n *node) ResolveWalletMembers(walletPublicKeyHash [20]byte) ([]uint32, error) { + wallet, found := n.walletRegistry.getWalletByPublicKeyHash(walletPublicKeyHash) + if !found { + return nil, fmt.Errorf("wallet not found") + } + + operatorIDs := make([]uint32, len(wallet.signingGroupOperators)) + for i, operatorAddress := range wallet.signingGroupOperators { + operatorID, err := n.chain.GetOperatorID(operatorAddress) + if err != nil { + return nil, err + } + operatorIDs[i] = uint32(operatorID) + } + return operatorIDs, nil +} diff --git a/pkg/tbtc/node_coordination.go b/pkg/tbtc/node_coordination.go index 7c61587b79..fa1bdb2f27 100644 --- a/pkg/tbtc/node_coordination.go +++ b/pkg/tbtc/node_coordination.go @@ -309,6 +309,24 @@ func processCoordinationResult(node *node, result *coordinationResult) { expiryBlock, ) } + case ActionReservationAnchor: + if proposal, ok := result.proposal.(*ReservationAnchorProposal); ok { + node.handleReservationAnchorProposal( + result.wallet, + proposal, + startBlock, + expiryBlock, + ) + } + case ActionReservationReanchor: + if proposal, ok := result.proposal.(*ReservationReanchorProposal); ok { + node.handleReservationReanchorProposal( + result.wallet, + proposal, + startBlock, + expiryBlock, + ) + } default: logger.Errorf("no handler for coordination result [%s]", result) } diff --git a/pkg/tbtc/node_executors.go b/pkg/tbtc/node_executors.go index 8a33854ef6..fee41512e4 100644 --- a/pkg/tbtc/node_executors.go +++ b/pkg/tbtc/node_executors.go @@ -201,6 +201,7 @@ func (n *node) getCoordinationExecutor( executor := newCoordinationExecutor( n.chain, + n.ethereumNetwork, wallet, membersIndexes, operatorAddress, diff --git a/pkg/tbtc/node_proposals.go b/pkg/tbtc/node_proposals.go index ede89206eb..3d11fec099 100644 --- a/pkg/tbtc/node_proposals.go +++ b/pkg/tbtc/node_proposals.go @@ -312,6 +312,136 @@ func (n *node) handleMovingFundsProposal( walletActionLogger.Infof("wallet action dispatched successfully") } +// handleReservationAnchorProposal handles an incoming reservation anchor proposal by +// orchestrating and dispatching an appropriate wallet action. +func (n *node) handleReservationAnchorProposal( + wallet wallet, + proposal *ReservationAnchorProposal, + startBlock uint64, + expiryBlock uint64, +) { + walletPublicKeyBytes, err := marshalPublicKey(wallet.publicKey) + if err != nil { + logger.Errorf("cannot marshal wallet public key: [%v]", err) + return + } + + signingExecutor, ok, err := n.getSigningExecutor(wallet.publicKey) + if err != nil { + logger.Errorf("cannot get signing executor: [%v]", err) + return + } + if !ok { + logger.Infof( + "node does not control signers of wallet PKH [0x%x]; "+ + "ignoring the received reservation anchor proposal", + walletPublicKeyBytes, + ) + return + } + + logger.Infof( + "starting orchestration of the reservation anchor action for wallet [0x%x]; "+ + "20-byte public key hash of that wallet is [0x%x]", + walletPublicKeyBytes, + bitcoin.PublicKeyHash(wallet.publicKey), + ) + + walletActionLogger := logger.With( + zap.String("wallet", fmt.Sprintf("0x%x", walletPublicKeyBytes)), + zap.String("action", ActionReservationAnchor.String()), + zap.Uint64("startBlock", startBlock), + zap.Uint64("expiryBlock", expiryBlock), + ) + walletActionLogger.Infof("dispatching wallet action") + + action := newReservationAnchorAction( + walletActionLogger, + n.chain, + n.btcChain, + wallet, + signingExecutor, + proposal, + startBlock, + expiryBlock, + n.waitForBlockHeight, + n.transactionMonitor, + ) + + err = n.walletDispatcher.dispatch(action) + if err != nil { + walletActionLogger.Errorf("cannot dispatch wallet action: [%v]", err) + return + } + + walletActionLogger.Infof("wallet action dispatched successfully") +} + +// handleReservationReanchorProposal handles an incoming reservation re-anchor proposal by +// orchestrating and dispatching an appropriate wallet action. +func (n *node) handleReservationReanchorProposal( + wallet wallet, + proposal *ReservationReanchorProposal, + startBlock uint64, + expiryBlock uint64, +) { + walletPublicKeyBytes, err := marshalPublicKey(wallet.publicKey) + if err != nil { + logger.Errorf("cannot marshal wallet public key: [%v]", err) + return + } + + signingExecutor, ok, err := n.getSigningExecutor(wallet.publicKey) + if err != nil { + logger.Errorf("cannot get signing executor: [%v]", err) + return + } + if !ok { + logger.Infof( + "node does not control signers of wallet PKH [0x%x]; "+ + "ignoring the received reservation re-anchor proposal", + walletPublicKeyBytes, + ) + return + } + + logger.Infof( + "starting orchestration of the reservation re-anchor action for wallet [0x%x]; "+ + "20-byte public key hash of that wallet is [0x%x]", + walletPublicKeyBytes, + bitcoin.PublicKeyHash(wallet.publicKey), + ) + + walletActionLogger := logger.With( + zap.String("wallet", fmt.Sprintf("0x%x", walletPublicKeyBytes)), + zap.String("action", ActionReservationReanchor.String()), + zap.Uint64("startBlock", startBlock), + zap.Uint64("expiryBlock", expiryBlock), + ) + walletActionLogger.Infof("dispatching wallet action") + + action := newReservationReanchorAction( + walletActionLogger, + n.chain, + n.btcChain, + wallet, + signingExecutor, + proposal, + startBlock, + expiryBlock, + n.waitForBlockHeight, + n.transactionMonitor, + ) + + err = n.walletDispatcher.dispatch(action) + if err != nil { + walletActionLogger.Errorf("cannot dispatch wallet action: [%v]", err) + return + } + + walletActionLogger.Infof("wallet action dispatched successfully") +} + // handleMovedFundsSweepProposal handles an incoming moved funds sweep proposal // by orchestrating and dispatching an appropriate wallet action. func (n *node) handleMovedFundsSweepProposal( diff --git a/pkg/tbtc/node_test.go b/pkg/tbtc/node_test.go index a756c69595..bc3e41b5c2 100644 --- a/pkg/tbtc/node_test.go +++ b/pkg/tbtc/node_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/keep-network/keep-common/pkg/chain/ethereum" "github.com/keep-network/keep-common/pkg/persistence" "github.com/keep-network/keep-core/internal/testutils" "github.com/keep-network/keep-core/pkg/bitcoin" @@ -52,6 +53,7 @@ func TestNode_GetSigningExecutor(t *testing.T) { keyStorePersistence := createMockKeyStorePersistence(t, signer) node, err := newNode( + ethereum.Unknown, groupParameters, localChain, newLocalBitcoinChain(), @@ -184,6 +186,7 @@ func TestNode_GetCoordinationExecutor(t *testing.T) { keyStorePersistence := createMockKeyStorePersistence(t, signer) node, err := newNode( + ethereum.Unknown, groupParameters, localChain, newLocalBitcoinChain(), @@ -321,6 +324,7 @@ func TestNode_RunCoordinationLayer(t *testing.T) { keyStorePersistence := createMockKeyStorePersistence(t, signer) n, err := newNode( + ethereum.Unknown, groupParameters, localChain, newLocalBitcoinChain(), @@ -1029,6 +1033,71 @@ func TestProcessCoordinationResult_MovedFundsSweepRoutesToHandler(t *testing.T) } } +// TestProcessCoordinationResult_ReservationAnchorRoutesToHandler verifies that +func TestProcessCoordinationResult_ReservationAnchorRoutesToHandler(t *testing.T) { + n, signer := setupNodeForHandlerTests(t) + walletKey := walletKeyFor(t, signer) + + // Mark the wallet busy so dispatch is rejected before execute() runs. + func() { + n.walletDispatcher.actionsMutex.Lock() + defer n.walletDispatcher.actionsMutex.Unlock() + n.walletDispatcher.actions[walletKey] = ActionNoop + }() + + result := &coordinationResult{ + wallet: signer.wallet, + proposal: &ReservationAnchorProposal{}, + window: &coordinationWindow{coordinationBlock: 1}, + } + + processCoordinationResult(n, result) + + // Busy sentinel must still be there: dispatch was attempted (routing worked) + // but returned errWalletBusy without touching the map entry. + _, ok := func() (WalletActionType, bool) { + n.walletDispatcher.actionsMutex.Lock() + defer n.walletDispatcher.actionsMutex.Unlock() + v, exists := n.walletDispatcher.actions[walletKey] + return v, exists + }() + if !ok { + t.Error("expected walletDispatcher to retain the busy sentinel after ReservationAnchor routing") + } +} + +func TestProcessCoordinationResult_ReservationReanchorRoutesToHandler(t *testing.T) { + n, signer := setupNodeForHandlerTests(t) + walletKey := walletKeyFor(t, signer) + + // Mark the wallet busy so dispatch is rejected before execute() runs. + func() { + n.walletDispatcher.actionsMutex.Lock() + defer n.walletDispatcher.actionsMutex.Unlock() + n.walletDispatcher.actions[walletKey] = ActionNoop + }() + + result := &coordinationResult{ + wallet: signer.wallet, + proposal: &ReservationReanchorProposal{}, + window: &coordinationWindow{coordinationBlock: 1}, + } + + processCoordinationResult(n, result) + + // Busy sentinel must still be there: dispatch was attempted (routing worked) + // but returned errWalletBusy without touching the map entry. + _, ok := func() (WalletActionType, bool) { + n.walletDispatcher.actionsMutex.Lock() + defer n.walletDispatcher.actionsMutex.Unlock() + v, exists := n.walletDispatcher.actions[walletKey] + return v, exists + }() + if !ok { + t.Error("expected walletDispatcher to retain the busy sentinel after ReservationReanchor routing") + } +} + // setupNodeForClosureTests creates a node backed by a fast-block localChain // (1 ms per block) so that WaitForBlockConfirmations (32 blocks) completes in // ~32 ms instead of seconds. @@ -1057,6 +1126,7 @@ func setupNodeForClosureTests(t *testing.T) (*node, *signer, *localChain) { }) n, err := newNode( + ethereum.Unknown, groupParameters, lc, newLocalBitcoinChain(), @@ -1236,6 +1306,7 @@ func setupNodeWithChain(t *testing.T) (*node, *signer, *localChain) { }) n, err := newNode( + ethereum.Unknown, groupParameters, lc, newLocalBitcoinChain(), diff --git a/pkg/tbtc/reservation.go b/pkg/tbtc/reservation.go new file mode 100644 index 0000000000..8cecef2958 --- /dev/null +++ b/pkg/tbtc/reservation.go @@ -0,0 +1,678 @@ +package tbtc + +import ( + "fmt" + "math/big" + "time" + + "go.uber.org/zap" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/chain" +) + +const ( + reservationLookBackBlocks = uint64(216000) + + // reservationAnchorProposalValidityBlocks determines the reservation + // anchor proposal validity time expressed in blocks. + reservationAnchorProposalValidityBlocks = 600 + + // reservationReanchorProposalValidityBlocks determines the reservation + // re-anchor proposal validity time expressed in blocks. + reservationReanchorProposalValidityBlocks = 600 +) + +// ReservationState represents the state of an on-chain UTXO reservation. +type ReservationState uint8 + +const ( + // ReservationStateUnknown means the reservation is unknown to the Bridge. + ReservationStateUnknown ReservationState = iota + // ReservationStateActive means the reservation's anchor outpoint is under + // wallet custody with no action in flight. + ReservationStateActive + // ReservationStateActionPending means a redemption, re-anchor, or + // dissolution action is pending. The action details are held in the + // nonce-keyed ReservationAction record. + ReservationStateActionPending + // ReservationStateClosed means the reservation was closed by an in-kind + // redemption, dissolution, or late settlement. + ReservationStateClosed + // ReservationStateStranded means the custodying wallet was terminated + // while the anchor was outstanding and the anchor is no longer tracked. + ReservationStateStranded +) + +// Reservation represents an on-chain UTXO reservation record. A reservation +// is a deposit that was anchored by the wallet - spent in a 1-input-1-output +// transaction into a fresh wallet-controlled output with no refund path - +// instead of being swept into the wallet main UTXO. The anchor outpoint is +// custodied without ever commingling with the pooled supply and is +// redeemable in-kind by the reservation owner. +type Reservation struct { + // Owner is the reservation owner's address on the host chain. + Owner chain.Address + // MintedAmount is the gross amount in satoshi credited to the owner at + // acceptance time. + MintedAmount uint64 + // AcceptedAt is the UNIX timestamp the reservation was accepted at. + AcceptedAt uint32 + // WalletPublicKeyHash is the 20-byte public key hash of the wallet + // custodying the current anchor outpoint. + WalletPublicKeyHash [20]byte + // AnchorUtxo is the reservation's current anchor outpoint, i.e. the + // wallet-controlled output holding the reserved coins. + AnchorUtxo *bitcoin.UnspentTransactionOutput + // ExpiresAt is the UNIX timestamp the custody term expires at. + ExpiresAt uint32 + // State is the current state of the reservation. + State ReservationState + // RequestNonce is the current monotonic reservation action generation. + RequestNonce uint64 + // RetryCredit indicates the owner has a single-use fee-free redemption + // retry entitlement after a fee-paid redemption timed out. + RetryCredit bool + // DissolutionEligibleAt is the UNIX timestamp at which the current term + // becomes eligible for dissolution. + DissolutionEligibleAt uint32 +} + +// ReservationActionType represents the type of a reservation action +// generation. +type ReservationActionType uint8 + +const ( + ReservationActionTypeNone ReservationActionType = iota + ReservationActionTypeAcceptance + ReservationActionTypeRedemption + ReservationActionTypeReanchor + ReservationActionTypeDissolution +) + +func (t ReservationActionType) String() string { + switch t { + case ReservationActionTypeNone: + return "None" + case ReservationActionTypeAcceptance: + return "Acceptance" + case ReservationActionTypeRedemption: + return "Redemption" + case ReservationActionTypeReanchor: + return "Reanchor" + case ReservationActionTypeDissolution: + return "Dissolution" + default: + return fmt.Sprintf("ReservationActionType(%d)", uint8(t)) + } +} + +// ReservationActionState represents the settlement state of a reservation +// action generation. +type ReservationActionState uint8 + +const ( + ReservationActionStateUnknown ReservationActionState = iota + ReservationActionStatePending + ReservationActionStateSettled + ReservationActionStateTimedOut + ReservationActionStateVetoed + ReservationActionStateSuperseded +) + +func (s ReservationActionState) String() string { + switch s { + case ReservationActionStateUnknown: + return "Unknown" + case ReservationActionStatePending: + return "Pending" + case ReservationActionStateSettled: + return "Settled" + case ReservationActionStateTimedOut: + return "TimedOut" + case ReservationActionStateVetoed: + return "Vetoed" + case ReservationActionStateSuperseded: + return "Superseded" + default: + return fmt.Sprintf("ReservationActionState(%d)", uint8(s)) + } +} + +// ReservationAction represents one nonce-bound generation of a reservation +// action. All authorization data used to construct and settle the action is +// snapshotted when the generation is requested. +type ReservationAction struct { + // TargetWalletPublicKeyHash is the wallet an acceptance, re-anchor, or + // dissolution output must pay to. It is zero for redemptions. + TargetWalletPublicKeyHash [20]byte + // RequestedAt is the UNIX timestamp the action was requested at. + RequestedAt uint32 + // TimeoutAt is the UNIX timestamp after which the action may time out. + TimeoutAt uint32 + // TxMaxFee is the snapshotted maximum Bitcoin transaction fee in satoshi. + TxMaxFee uint64 + // ActionType is the type of this action generation. + ActionType ReservationActionType + // State is the settlement state of this action generation. + State ReservationActionState + // FeePaid indicates the generation was created through a fee-paying vault + // entry point. + FeePaid bool + // Redeemer is the address that can reclaim escrow after a redemption + // timeout. It is empty for other action types. + Redeemer chain.Address + // Amount is the satoshi amount associated with the action generation. + Amount uint64 + // RedeemerOutputScriptHash is the keccak256 hash of the length-prefixed + // output script authorized for a redemption. + RedeemerOutputScriptHash [32]byte + // ExpectedMainUtxoHash identifies the wallet main UTXO snapshotted for a + // dissolution. It is zero for other action types and no-main-UTXO wallets. + ExpectedMainUtxoHash [32]byte + // IsPartial indicates a redemption spends only Amount and must re-anchor + // the remaining reservation value back to the custodying wallet. + IsPartial bool +} + +// ReservationParameters represents the on-chain values of the Bridge +// reservation parameters. +type ReservationParameters struct { + // ReservationVault is the address of the reservation vault. Deposits + // revealed with this vault address are treated as UTXO reservations. + ReservationVault chain.Address + // ReservationMinAmount is the minimal anchor output amount in satoshi + // accepted for a reservation. + ReservationMinAmount uint64 + // ReservationTxMaxFee is the maximum transaction fee in satoshi for a + // single reservation lifecycle transaction. + ReservationTxMaxFee uint64 + // ReservationTermSeconds is the custody term length in seconds. + ReservationTermSeconds uint32 + // ReservationDissolutionDelay is the delay snapshotted after term expiry + // before a reservation becomes dissolvable. + ReservationDissolutionDelay uint32 + // ReservationMaxTotalAmount is the maximum total amount of all active + // reservations in satoshi. + ReservationMaxTotalAmount uint64 + // ReservationTotalAmount is the current total amount of all active + // reservations in satoshi. + ReservationTotalAmount uint64 + // MaxReservationsPerWallet is the maximum number of reservations a wallet + // may custody. + MaxReservationsPerWallet uint32 + // ReservationActionTimeout is the timeout for reservation actions in + // seconds. + ReservationActionTimeout uint32 + // ReservationRenewalWindowSeconds is the period before expiry during which + // a reservation can be renewed. + ReservationRenewalWindowSeconds uint32 +} + +// ReservationAnchorProposal represents a reservation anchor proposal issued +// by a wallet's coordination leader. +type ReservationAnchorProposal struct { + // DepositFundingTxHash is the funding transaction hash of the reserved + // deposit to anchor. + DepositFundingTxHash bitcoin.Hash + // DepositFundingOutputIndex is the funding output index of the reserved + // deposit to anchor. + DepositFundingOutputIndex uint32 + // RequestNonce is the acceptance authorization generation being executed. + RequestNonce uint64 + // AnchorTxFee is the proposed BTC fee for the anchor transaction. + AnchorTxFee *big.Int +} + +// ActionType returns the specific type of the walletAction being subject +// of this proposal. +func (rap *ReservationAnchorProposal) ActionType() WalletActionType { + return ActionReservationAnchor +} + +// ValidityBlocks returns the number of blocks for which the proposal is valid. +func (rap *ReservationAnchorProposal) ValidityBlocks() uint64 { + return reservationAnchorProposalValidityBlocks +} + +// ReservationReanchorProposal represents a reservation re-anchor proposal +// issued by a wallet's coordination leader, moving a reservation's anchor +// outpoint to another wallet (e.g. during wallet migration). +type ReservationReanchorProposal struct { + // ReservationKey is the key of the reservation to re-anchor. + ReservationKey *big.Int + // RequestNonce is the re-anchor authorization generation being executed. + RequestNonce uint64 + // TargetWalletPublicKeyHash is the 20-byte public key hash of the wallet + // receiving the anchor. + TargetWalletPublicKeyHash [20]byte + // ReanchorTxFee is the proposed BTC fee for the re-anchor transaction. + ReanchorTxFee *big.Int +} + +// ActionType returns the specific type of the walletAction being subject +// of this proposal. +func (rrp *ReservationReanchorProposal) ActionType() WalletActionType { + return ActionReservationReanchor +} + +// ValidityBlocks returns the number of blocks for which the proposal is valid. +func (rrp *ReservationReanchorProposal) ValidityBlocks() uint64 { + return reservationReanchorProposalValidityBlocks +} + +// AssembleReservationAnchorTransaction constructs an unsigned reservation +// anchor transaction: a 1-input-1-output spend of the given reserved deposit +// into a fresh output controlled by the given wallet. The anchor mirrors the +// sweep's refund-disabling role without its consolidating role: the Bridge +// credits the reservation owner only against the SPV proof of this +// transaction. +func AssembleReservationAnchorTransaction( + bitcoinChain bitcoin.Chain, + deposit *Deposit, + walletPublicKeyHash [20]byte, + action *ReservationAction, + fee int64, +) (*bitcoin.TransactionBuilder, error) { + if deposit == nil { + return nil, fmt.Errorf("deposit is required") + } + if action == nil { + return nil, fmt.Errorf("reservation action is required") + } + if fee <= 0 { + return nil, fmt.Errorf("fee must be positive") + } + if uint64(fee) > action.TxMaxFee { + return nil, fmt.Errorf("fee exceeds the maximum allowed fee") + } + anchorValue := deposit.Utxo.Value - fee + if anchorValue <= 0 { + return nil, fmt.Errorf("transaction fee exceeds the deposit amount") + } + + builder := bitcoin.NewTransactionBuilder(bitcoinChain) + + depositScript, err := deposit.Script() + if err != nil { + return nil, fmt.Errorf("cannot get deposit script: [%v]", err) + } + + err = builder.AddScriptHashInput(deposit.Utxo, depositScript) + if err != nil { + return nil, fmt.Errorf("cannot add input pointing to deposit UTXO: [%v]", err) + } + + outputScript, err := bitcoin.PayToWitnessPublicKeyHash( + walletPublicKeyHash, + ) + if err != nil { + return nil, fmt.Errorf("cannot compute anchor script: [%v]", err) + } + + builder.AddOutput(&bitcoin.TransactionOutput{ + Value: anchorValue, + PublicKeyScript: outputScript, + }) + + return builder, nil +} + +// AssembleReservationReanchorTransaction constructs an unsigned reservation +// re-anchor transaction: a 1-input-1-output spend of the reservation's +// anchor outpoint into a fresh output controlled by the target wallet. Used +// during wallet migration so reservations never pin retiring wallets. +func AssembleReservationReanchorTransaction( + bitcoinChain bitcoin.Chain, + anchorUtxo *bitcoin.UnspentTransactionOutput, + targetWalletPublicKeyHash [20]byte, + action *ReservationAction, + fee int64, +) (*bitcoin.TransactionBuilder, error) { + if anchorUtxo == nil { + return nil, fmt.Errorf("anchor UTXO is required") + } + if action == nil { + return nil, fmt.Errorf("reservation action is required") + } + if fee <= 0 { + return nil, fmt.Errorf("fee must be positive") + } + if uint64(fee) > action.TxMaxFee { + return nil, fmt.Errorf("fee exceeds the maximum allowed fee") + } + + anchorValue := anchorUtxo.Value - fee + if anchorValue <= 0 { + return nil, fmt.Errorf("transaction fee exceeds the anchor value") + } + + builder := bitcoin.NewTransactionBuilder(bitcoinChain) + + err := builder.AddPublicKeyHashInput(anchorUtxo) + if err != nil { + return nil, fmt.Errorf( + "cannot add input pointing to anchor UTXO: [%v]", + err, + ) + } + + outputScript, err := bitcoin.PayToWitnessPublicKeyHash( + targetWalletPublicKeyHash, + ) + if err != nil { + return nil, fmt.Errorf("cannot compute anchor script: [%v]", err) + } + + builder.AddOutput(&bitcoin.TransactionOutput{ + Value: anchorValue, + PublicKeyScript: outputScript, + }) + + return builder, nil +} + +// reservationActionSigningTimeoutSafetyMarginBlocks is the duration, in +// blocks, that must remain before the proposal's expiry block for signing +// to be attempted. Mirrors redemptionSigningTimeoutSafetyMarginBlocks. +const reservationActionSigningTimeoutSafetyMarginBlocks = 300 + +// reservationActionBroadcastTimeout is the timeout applied while +// broadcasting a reservation anchor/re-anchor transaction. Mirrors +// redemptionBroadcastTimeout. +const reservationActionBroadcastTimeout = 15 * time.Minute + +// reservationActionBroadcastCheckDelay is the delay between broadcast +// attempts of a reservation anchor/re-anchor transaction. Mirrors +// redemptionBroadcastCheckDelay. +const reservationActionBroadcastCheckDelay = 1 * time.Minute + +// reservationAnchorAction is a walletAction implementation handling reservation +// anchor requests from the wallet coordinator. +type reservationAnchorAction struct { + logger *zap.SugaredLogger + chain Chain + btcChain bitcoin.Chain + custodyWallet wallet + transactionExecutor *walletTransactionExecutor + proposal *ReservationAnchorProposal + startBlock uint64 + expiryBlock uint64 +} + +func newReservationAnchorAction( + logger *zap.SugaredLogger, + chain Chain, + btcChain bitcoin.Chain, + custodyWallet wallet, + signingExecutor walletSigningExecutor, + proposal *ReservationAnchorProposal, + startBlock uint64, + expiryBlock uint64, + waitForBlockHeight waitForBlockFn, + transactionMonitor *transactionMonitor, +) *reservationAnchorAction { + transactionExecutor := newWalletTransactionExecutor( + btcChain, + custodyWallet, + signingExecutor, + waitForBlockHeight, + ) + transactionExecutor.setTransactionMonitor(transactionMonitor) + return &reservationAnchorAction{ + logger: logger, + chain: chain, + btcChain: btcChain, + custodyWallet: custodyWallet, + transactionExecutor: transactionExecutor, + proposal: proposal, + startBlock: startBlock, + expiryBlock: expiryBlock, + } +} + +func (raa *reservationAnchorAction) execute() error { + walletPublicKeyHash := bitcoin.PublicKeyHash(raa.custodyWallet.publicKey) + + fundingTx, err := raa.btcChain.GetTransaction(raa.proposal.DepositFundingTxHash) + if err != nil { + 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, + ) + if err != nil { + return fmt.Errorf("cannot fetch deposit request: [%v]", err) + } + if !found { + return fmt.Errorf("deposit request not found") + } + + deposit := matchingEvent.unpack(depositRequest.ExtraData) + + // m1 identity: the reservation key is the deposit key, mirroring the + // convention documented in pkg/maintainer/spv/reservation_stale_deposit_watch.go. + reservationKey := raa.chain.BuildDepositKey( + raa.proposal.DepositFundingTxHash, + raa.proposal.DepositFundingOutputIndex, + ) + + action, err := raa.chain.GetReservationAction(reservationKey, raa.proposal.RequestNonce) + if err != nil { + return fmt.Errorf("cannot get reservation action: [%v]", err) + } + if action.ActionType != ReservationActionTypeAcceptance || action.State != ReservationActionStatePending { + return fmt.Errorf("reservation action is not a pending acceptance") + } + if action.TargetWalletPublicKeyHash != walletPublicKeyHash { + return fmt.Errorf("reservation action targets a different wallet") + } + + err = raa.chain.ValidateReservationAnchorProposal( + walletPublicKeyHash, + raa.proposal, + struct { + *Deposit + FundingTx *bitcoin.Transaction + }{Deposit: deposit, FundingTx: fundingTx}, + ) + if err != nil { + return fmt.Errorf("cannot validate reservation anchor proposal: [%v]", err) + } + + unsignedTx, err := AssembleReservationAnchorTransaction( + raa.btcChain, + deposit, + walletPublicKeyHash, + action, + raa.proposal.AnchorTxFee.Int64(), + ) + if err != nil { + return fmt.Errorf("cannot assemble reservation anchor transaction: [%v]", err) + } + + // Prevent unsigned underflow in signing deadline calculation. + if raa.expiryBlock < reservationActionSigningTimeoutSafetyMarginBlocks { + return fmt.Errorf("invalid proposal expiry block") + } + + signedTx, err := raa.transactionExecutor.signTransaction( + raa.logger, + unsignedTx, + raa.startBlock, + raa.expiryBlock-reservationActionSigningTimeoutSafetyMarginBlocks, + ) + if err != nil { + return fmt.Errorf("cannot sign reservation anchor transaction: [%v]", err) + } + + err = raa.transactionExecutor.broadcastTransaction( + raa.logger, + signedTx, + reservationActionBroadcastTimeout, + reservationActionBroadcastCheckDelay, + ) + if err != nil { + return fmt.Errorf("cannot broadcast reservation anchor transaction: [%v]", err) + } + + return nil +} + +func (raa *reservationAnchorAction) wallet() wallet { + return raa.custodyWallet +} + +func (raa *reservationAnchorAction) actionType() WalletActionType { + return ActionReservationAnchor +} + +// reservationReanchorAction is a walletAction implementation handling +// reservation re-anchor requests from the wallet coordinator. +type reservationReanchorAction struct { + logger *zap.SugaredLogger + chain Chain + btcChain bitcoin.Chain + custodyWallet wallet + transactionExecutor *walletTransactionExecutor + proposal *ReservationReanchorProposal + startBlock uint64 + expiryBlock uint64 +} + +func newReservationReanchorAction( + logger *zap.SugaredLogger, + chain Chain, + btcChain bitcoin.Chain, + custodyWallet wallet, + signingExecutor walletSigningExecutor, + proposal *ReservationReanchorProposal, + startBlock uint64, + expiryBlock uint64, + waitForBlockHeight waitForBlockFn, + transactionMonitor *transactionMonitor, +) *reservationReanchorAction { + transactionExecutor := newWalletTransactionExecutor( + btcChain, + custodyWallet, + signingExecutor, + waitForBlockHeight, + ) + transactionExecutor.setTransactionMonitor(transactionMonitor) + return &reservationReanchorAction{ + logger: logger, + chain: chain, + btcChain: btcChain, + custodyWallet: custodyWallet, + transactionExecutor: transactionExecutor, + proposal: proposal, + startBlock: startBlock, + expiryBlock: expiryBlock, + } +} + +func (rra *reservationReanchorAction) execute() error { + walletPublicKeyHash := bitcoin.PublicKeyHash(rra.custodyWallet.publicKey) + + reservation, err := rra.chain.GetReservation(rra.proposal.ReservationKey) + if err != nil { + return fmt.Errorf("cannot get reservation: [%v]", err) + } + + action, err := rra.chain.GetReservationAction(rra.proposal.ReservationKey, rra.proposal.RequestNonce) + if err != nil { + return fmt.Errorf("cannot get reservation action: [%v]", err) + } + if action.ActionType != ReservationActionTypeReanchor || action.State != ReservationActionStatePending { + return fmt.Errorf("reservation action is not a pending reanchor") + } + if action.TargetWalletPublicKeyHash != rra.proposal.TargetWalletPublicKeyHash { + return fmt.Errorf("reservation action targets a different wallet") + } + + err = rra.chain.ValidateReservationReanchorProposal( + walletPublicKeyHash, + rra.proposal, + ) + if err != nil { + return fmt.Errorf("cannot validate reservation reanchor proposal: [%v]", err) + } + + unsignedTx, err := AssembleReservationReanchorTransaction( + rra.btcChain, + reservation.AnchorUtxo, + rra.proposal.TargetWalletPublicKeyHash, + action, + rra.proposal.ReanchorTxFee.Int64(), + ) + if err != nil { + return fmt.Errorf("cannot assemble reservation reanchor transaction: [%v]", err) + } + + // Prevent unsigned underflow in signing deadline calculation. + if rra.expiryBlock < reservationActionSigningTimeoutSafetyMarginBlocks { + return fmt.Errorf("invalid proposal expiry block") + } + + signedTx, err := rra.transactionExecutor.signTransaction( + rra.logger, + unsignedTx, + rra.startBlock, + rra.expiryBlock-reservationActionSigningTimeoutSafetyMarginBlocks, + ) + if err != nil { + return fmt.Errorf("cannot sign reservation reanchor transaction: [%v]", err) + } + + err = rra.transactionExecutor.broadcastTransaction( + rra.logger, + signedTx, + reservationActionBroadcastTimeout, + reservationActionBroadcastCheckDelay, + ) + if err != nil { + return fmt.Errorf("cannot broadcast reservation reanchor transaction: [%v]", err) + } + + return nil +} + +func (rra *reservationReanchorAction) wallet() wallet { + return rra.custodyWallet +} + +func (rra *reservationReanchorAction) actionType() WalletActionType { + return ActionReservationReanchor +} diff --git a/pkg/tbtc/reservation_test.go b/pkg/tbtc/reservation_test.go new file mode 100644 index 0000000000..2be0ac383c --- /dev/null +++ b/pkg/tbtc/reservation_test.go @@ -0,0 +1,1234 @@ +package tbtc + +import ( + "crypto/ecdsa" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "math/big" + "reflect" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec" + "go.uber.org/zap" + "google.golang.org/protobuf/proto" + + "github.com/keep-network/keep-core/internal/testutils" + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/tbtc/gen/pb" +) + +func TestReservationActionTypes(t *testing.T) { + for value, expected := range map[uint8]WalletActionType{ + 6: ActionReservationAnchor, + 8: ActionReservationReanchor, + } { + parsed, err := ParseWalletActionType(value) + if err != nil { + t.Fatal(err) + } + if parsed != expected { + t.Errorf( + "unexpected action type for [%v]: expected [%v] got [%v]", + value, + expected, + parsed, + ) + } + } +} + +func TestReservationStateValues(t *testing.T) { + tests := map[ReservationState]uint8{ + ReservationStateUnknown: 0, + ReservationStateActive: 1, + ReservationStateActionPending: 2, + ReservationStateClosed: 3, + ReservationStateStranded: 4, + } + + for state, expected := range tests { + if actual := uint8(state); actual != expected { + t.Errorf( + "unexpected reservation state value\nexpected: [%v]\nactual: [%v]", + expected, + actual, + ) + } + } +} + +func TestReservationActionTypeValues(t *testing.T) { + tests := map[ReservationActionType]uint8{ + ReservationActionTypeNone: 0, + ReservationActionTypeAcceptance: 1, + ReservationActionTypeRedemption: 2, + ReservationActionTypeReanchor: 3, + ReservationActionTypeDissolution: 4, + } + + for actionType, expected := range tests { + if actual := uint8(actionType); actual != expected { + t.Errorf( + "unexpected reservation action type value\nexpected: [%v]\nactual: [%v]", + expected, + actual, + ) + } + } +} + +func TestReservationActionStateValues(t *testing.T) { + tests := map[ReservationActionState]uint8{ + ReservationActionStateUnknown: 0, + ReservationActionStatePending: 1, + ReservationActionStateSettled: 2, + ReservationActionStateTimedOut: 3, + ReservationActionStateVetoed: 4, + ReservationActionStateSuperseded: 5, + } + + for state, expected := range tests { + if actual := uint8(state); actual != expected { + t.Errorf( + "unexpected reservation action state value\nexpected: [%v]\nactual: [%v]", + expected, + actual, + ) + } + } +} + +func TestReservationProposals_MarshalingRoundtrip(t *testing.T) { + anchorProposal := &ReservationAnchorProposal{ + DepositFundingTxHash: bitcoin.Hash{0x01, 0x02}, + DepositFundingOutputIndex: 3, + RequestNonce: 1, + AnchorTxFee: big.NewInt(1500), + } + + reanchorProposal := &ReservationReanchorProposal{ + ReservationKey: big.NewInt(54321), + RequestNonce: 3, + TargetWalletPublicKeyHash: [20]byte{0xaa, 0xbb}, + ReanchorTxFee: big.NewInt(1700), + } + + roundtrip := func( + proposal CoordinationProposal, + fresh CoordinationProposal, + ) { + marshaled, err := proposal.Marshal() + if err != nil { + t.Fatal(err) + } + if err := fresh.Unmarshal(marshaled); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(proposal, fresh) { + t.Errorf( + "unexpected unmarshaled proposal: expected [%+v] got [%+v]", + proposal, + fresh, + ) + } + } + + roundtrip(anchorProposal, &ReservationAnchorProposal{}) + roundtrip(reanchorProposal, &ReservationReanchorProposal{}) +} + +func TestReservationProposals_UnmarshalRejectsInvalidPayloads(t *testing.T) { + tests := map[string]struct { + actionType WalletActionType + payload []byte + expectedError string + }{ + // Proto3 scalar fields have no wire presence, so an entirely + // empty payload and one with every field explicitly zeroed are + // indistinguishable - a single "empty payload" case per type + // covers what the old JSON test split into "empty object" and + // "null payload" cases. + "anchor empty payload": { + actionType: ActionReservationAnchor, + payload: marshalPb(t, &pb.ReservationAnchorProposal{}), + expectedError: "cannot unmarshal proposal payload: [anchor transaction fee is required]", + }, + "anchor null payload": { + actionType: ActionReservationAnchor, + payload: nil, + expectedError: "cannot unmarshal proposal payload: [anchor transaction fee is required]", + }, + "anchor missing nonce": { + actionType: ActionReservationAnchor, + payload: marshalPb(t, &pb.ReservationAnchorProposal{ + AnchorTxFee: big.NewInt(1500).Bytes(), + }), + expectedError: "cannot unmarshal proposal payload: [request nonce is required]", + }, + "anchor invalid deposit funding tx hash length": { + actionType: ActionReservationAnchor, + payload: marshalPb(t, &pb.ReservationAnchorProposal{ + RequestNonce: 1, + AnchorTxFee: big.NewInt(1500).Bytes(), + }), + expectedError: "cannot unmarshal proposal payload: [invalid deposit funding tx hash length: [0]]", + }, + "re-anchor null payload": { + actionType: ActionReservationReanchor, + payload: nil, + expectedError: "cannot unmarshal proposal payload: [reservation key is required]", + }, + "re-anchor missing nonce": { + actionType: ActionReservationReanchor, + payload: marshalPb(t, &pb.ReservationReanchorProposal{ + ReservationKey: big.NewInt(54321).Bytes(), + ReanchorTxFee: big.NewInt(1700).Bytes(), + }), + expectedError: "cannot unmarshal proposal payload: [request nonce is required]", + }, + "re-anchor missing fee": { + actionType: ActionReservationReanchor, + payload: marshalPb(t, &pb.ReservationReanchorProposal{ + ReservationKey: big.NewInt(54321).Bytes(), + RequestNonce: 3, + }), + expectedError: "cannot unmarshal proposal payload: [re-anchor transaction fee is required]", + }, + "anchor fee exceeds 8 bytes": { + actionType: ActionReservationAnchor, + payload: marshalPb(t, &pb.ReservationAnchorProposal{ + AnchorTxFee: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, + RequestNonce: 1, + DepositFundingTxHash: make([]byte, 32), + DepositFundingOutputIndex: 0, + }), + expectedError: "cannot unmarshal proposal payload: [invalid anchor transaction fee byte length: [9]]", + }, + "re-anchor fee exceeds 8 bytes": { + actionType: ActionReservationReanchor, + payload: marshalPb(t, &pb.ReservationReanchorProposal{ + ReservationKey: big.NewInt(54321).Bytes(), + RequestNonce: 3, + ReanchorTxFee: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, + TargetWalletPublicKeyHash: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20}, + }), + expectedError: "cannot unmarshal proposal payload: [invalid re-anchor transaction fee byte length: [9]]", + }, + "anchor zero fee marshaled through Marshal is rejected as missing": { + actionType: ActionReservationAnchor, + payload: marshalThroughProposal(t, &ReservationAnchorProposal{ + DepositFundingTxHash: bitcoin.Hash{0x01, 0x02}, + DepositFundingOutputIndex: 3, + RequestNonce: 1, + AnchorTxFee: big.NewInt(0), + }), + expectedError: "cannot unmarshal proposal payload: [anchor transaction fee is required]", + }, + "re-anchor zero reservation key marshaled through Marshal is rejected as missing": { + actionType: ActionReservationReanchor, + payload: marshalThroughProposal(t, &ReservationReanchorProposal{ + ReservationKey: big.NewInt(0), + RequestNonce: 3, + TargetWalletPublicKeyHash: [20]byte{0xaa, 0xbb}, + ReanchorTxFee: big.NewInt(1700), + }), + expectedError: "cannot unmarshal proposal payload: [reservation key is required]", + }, + "re-anchor zero fee marshaled through Marshal is rejected as missing": { + actionType: ActionReservationReanchor, + payload: marshalThroughProposal(t, &ReservationReanchorProposal{ + ReservationKey: big.NewInt(54321), + RequestNonce: 3, + TargetWalletPublicKeyHash: [20]byte{0xaa, 0xbb}, + ReanchorTxFee: big.NewInt(0), + }), + expectedError: "cannot unmarshal proposal payload: [re-anchor transaction fee is required]", + }, + "re-anchor invalid target wallet hash length": { + actionType: ActionReservationReanchor, + payload: marshalPb(t, &pb.ReservationReanchorProposal{ + ReservationKey: big.NewInt(54321).Bytes(), + RequestNonce: 3, + ReanchorTxFee: big.NewInt(1700).Bytes(), + }), + expectedError: "cannot unmarshal proposal payload: [invalid target wallet public key hash length: [0]]", + }, + "re-anchor zero-value target wallet hash": { + actionType: ActionReservationReanchor, + payload: marshalPb(t, &pb.ReservationReanchorProposal{ + ReservationKey: big.NewInt(54321).Bytes(), + RequestNonce: 3, + TargetWalletPublicKeyHash: make([]byte, 20), + ReanchorTxFee: big.NewInt(1700).Bytes(), + }), + expectedError: "cannot unmarshal proposal payload: [target wallet public key hash is required]", + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + _, err := unmarshalCoordinationProposal( + uint32(test.actionType), + test.payload, + ) + if err == nil || err.Error() != test.expectedError { + t.Errorf( + "unexpected error\nexpected: [%v]\nactual: [%v]", + test.expectedError, + err, + ) + } + }) + } +} + +// marshalPb marshals a protobuf message for use as a test fixture payload. +func marshalPb(t *testing.T, msg proto.Message) []byte { + t.Helper() + data, err := proto.Marshal(msg) + if err != nil { + t.Fatal(err) + } + return data +} + +// marshalThroughProposal marshals a CoordinationProposal via its own Marshal +// method, for use as a test fixture payload. Unlike marshalPb, this exercises +// the proposal's real wire-encoding path (e.g. *big.Int.Bytes()) rather than +// hand-constructing the protobuf message directly. +func marshalThroughProposal(t *testing.T, proposal CoordinationProposal) []byte { + t.Helper() + data, err := proposal.Marshal() + if err != nil { + t.Fatal(err) + } + return data +} + +func signReservationTransaction( + t *testing.T, + builder *bitcoin.TransactionBuilder, + publicKey *ecdsa.PublicKey, + privateKeyValue *big.Int, +) *bitcoin.Transaction { + t.Helper() + + sigHashes, err := builder.ComputeSignatureHashes() + if err != nil { + t.Fatal(err) + } + + privateKey := &ecdsa.PrivateKey{ + PublicKey: *publicKey, + D: privateKeyValue, + } + signatures := make([]*bitcoin.SignatureContainer, len(sigHashes)) + for i, sigHash := range sigHashes { + r, s, err := ecdsa.Sign(rand.Reader, privateKey, sigHash.Bytes()) + if err != nil { + t.Fatal(err) + } + signatures[i] = &bitcoin.SignatureContainer{ + R: r, + S: s, + PublicKey: publicKey, + } + } + + transaction, err := builder.AddSignatures(signatures) + if err != nil { + t.Fatal(err) + } + + return transaction +} + +func TestAssembleReservationTransactions_InputValidation(t *testing.T) { + bitcoinChain := newLocalBitcoinChain() + walletPublicKeyHash := [20]byte{0x01} + + anchorUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x03}, + OutputIndex: 0, + }, + Value: 100000, + } + + assertError := func(err error, expected string) { + if err == nil || err.Error() != expected { + t.Errorf("expected error [%v], got [%v]", expected, err) + } + } + + var err error + _, err = AssembleReservationAnchorTransaction( + bitcoinChain, + nil, + walletPublicKeyHash, + nil, + 1500, + ) + assertError(err, "deposit is required") + + deposit := &Deposit{Utxo: anchorUtxo} + + _, err = AssembleReservationAnchorTransaction( + bitcoinChain, + deposit, + walletPublicKeyHash, + nil, + 1500, + ) + assertError(err, "reservation action is required") + + _, err = AssembleReservationAnchorTransaction( + bitcoinChain, + deposit, + walletPublicKeyHash, + &ReservationAction{TxMaxFee: 2000}, + 0, + ) + assertError(err, "fee must be positive") + + _, err = AssembleReservationAnchorTransaction( + bitcoinChain, + deposit, + walletPublicKeyHash, + &ReservationAction{TxMaxFee: 1000}, + 1500, + ) + assertError(err, "fee exceeds the maximum allowed fee") + + _, err = AssembleReservationReanchorTransaction( + bitcoinChain, + nil, + walletPublicKeyHash, + nil, + 1500, + ) + assertError(err, "anchor UTXO is required") + + _, err = AssembleReservationReanchorTransaction( + bitcoinChain, + anchorUtxo, + walletPublicKeyHash, + nil, + 1500, + ) + assertError(err, "reservation action is required") + + _, err = AssembleReservationReanchorTransaction( + bitcoinChain, + anchorUtxo, + walletPublicKeyHash, + &ReservationAction{TxMaxFee: 2000}, + 0, + ) + assertError(err, "fee must be positive") + + _, err = AssembleReservationReanchorTransaction( + bitcoinChain, + anchorUtxo, + walletPublicKeyHash, + &ReservationAction{TxMaxFee: 1000}, + 1500, + ) + assertError(err, "fee exceeds the maximum allowed fee") +} + +func TestAssembleReservationTransactions_FeeBoundaries(t *testing.T) { + bitcoinChain := newLocalBitcoinChain() + walletPublicKeyHash := [20]byte{0x01} + + // Anchor boundary + _, err := AssembleReservationAnchorTransaction( + bitcoinChain, + &Deposit{Utxo: &bitcoin.UnspentTransactionOutput{Value: 100000}}, + walletPublicKeyHash, + &ReservationAction{TxMaxFee: 200000}, + 100000, + ) + if err == nil || err.Error() != "transaction fee exceeds the deposit amount" { + t.Errorf("expected error [transaction fee exceeds the deposit amount], got [%v]", err) + } + + // Reanchor boundary + _, err = AssembleReservationReanchorTransaction( + bitcoinChain, + &bitcoin.UnspentTransactionOutput{Value: 100000}, + walletPublicKeyHash, + &ReservationAction{TxMaxFee: 200000}, + 100000, + ) + if err == nil || err.Error() != "transaction fee exceeds the anchor value" { + t.Errorf("expected error [transaction fee exceeds the anchor value], got [%v]", err) + } +} + +// TestAssembleReservationTransactions_HappyPathShape verifies the actual +// shape of a successfully assembled reservation anchor/re-anchor +// transaction: exactly one input, exactly one output, the output value +// equal to input value minus fee, and a P2WPKH locking script paying the +// target wallet. Existing tests only exercise error/boundary paths; none +// assert the happy-path output shape. +func TestAssembleReservationTransactions_HappyPathShape(t *testing.T) { + targetWalletPublicKeyHash := [20]byte{ + 0x8d, 0xb5, 0x0e, 0xb5, 0x20, 0x63, 0xea, 0x9d, 0x98, 0xb3, + 0xea, 0xc9, 0x14, 0x89, 0xa9, 0x0f, 0x73, 0x89, 0x86, 0xf6, + } + const fee = int64(1500) + const depositValue = int64(100000) + + expectedOutputScript, err := bitcoin.PayToWitnessPublicKeyHash( + targetWalletPublicKeyHash, + ) + if err != nil { + t.Fatal(err) + } + + btcecKey, err := btcec.NewPrivateKey(btcec.S256()) + if err != nil { + t.Fatal(err) + } + signingKey := (*ecdsa.PrivateKey)(btcecKey) + + t.Run("anchor transaction", func(t *testing.T) { + bitcoinChain := newLocalBitcoinChain() + + deposit := &Deposit{ + Depositor: "0x934b98637ca318a4d6e7ca6ffd1690b8e77df637", + WalletPublicKeyHash: [20]byte{0xaa}, + RefundPublicKeyHash: [20]byte{0xbb}, + RefundLocktime: [4]byte{0x60, 0xbc, 0xea, 0x61}, + } + depositScript, err := deposit.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: depositValue, + PublicKeyScript: fundingOutputScript, + }}, + } + if err := bitcoinChain.BroadcastTransaction(fundingTx); err != nil { + t.Fatal(err) + } + + deposit.Utxo = &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTx.Hash(), + OutputIndex: 0, + }, + Value: depositValue, + } + + builder, err := AssembleReservationAnchorTransaction( + bitcoinChain, + deposit, + targetWalletPublicKeyHash, + &ReservationAction{TxMaxFee: 2000}, + fee, + ) + if err != nil { + t.Fatal(err) + } + + signedTx := signReservationTransaction( + t, + builder, + &signingKey.PublicKey, + signingKey.D, + ) + + assertReservationTransactionShape( + t, + signedTx, + depositValue, + fee, + expectedOutputScript, + ) + }) + + t.Run("re-anchor transaction", func(t *testing.T) { + bitcoinChain := newLocalBitcoinChain() + + anchorOutputScript, err := bitcoin.PayToWitnessPublicKeyHash( + [20]byte{0xcc}, + ) + if err != nil { + t.Fatal(err) + } + + anchorFundingTx := &bitcoin.Transaction{ + Outputs: []*bitcoin.TransactionOutput{{ + Value: depositValue, + PublicKeyScript: anchorOutputScript, + }}, + } + if err := bitcoinChain.BroadcastTransaction(anchorFundingTx); err != nil { + t.Fatal(err) + } + + anchorUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorFundingTx.Hash(), + OutputIndex: 0, + }, + Value: depositValue, + } + + builder, err := AssembleReservationReanchorTransaction( + bitcoinChain, + anchorUtxo, + targetWalletPublicKeyHash, + &ReservationAction{TxMaxFee: 2000}, + fee, + ) + if err != nil { + t.Fatal(err) + } + + signedTx := signReservationTransaction( + t, + builder, + &signingKey.PublicKey, + signingKey.D, + ) + + assertReservationTransactionShape( + t, + signedTx, + depositValue, + fee, + expectedOutputScript, + ) + }) +} + +// assertReservationTransactionShape asserts the invariants a successfully +// assembled and signed reservation anchor/re-anchor transaction must +// satisfy: exactly 1 input, exactly 1 output, output value == inputValue - +// fee, and the output's locking script matches expectedOutputScript exactly. +func assertReservationTransactionShape( + t *testing.T, + transaction *bitcoin.Transaction, + inputValue int64, + fee int64, + expectedOutputScript bitcoin.Script, +) { + t.Helper() + + if len(transaction.Inputs) != 1 { + t.Fatalf("expected exactly 1 input, got %d", len(transaction.Inputs)) + } + if len(transaction.Outputs) != 1 { + t.Fatalf("expected exactly 1 output, got %d", len(transaction.Outputs)) + } + + expectedValue := inputValue - fee + if transaction.Outputs[0].Value != expectedValue { + t.Errorf( + "unexpected output value\nexpected: %d\nactual: %d", + expectedValue, + transaction.Outputs[0].Value, + ) + } + + if !reflect.DeepEqual( + []byte(transaction.Outputs[0].PublicKeyScript), + []byte(expectedOutputScript), + ) { + t.Errorf( + "unexpected output locking script\nexpected: %x\nactual: %x", + expectedOutputScript, + transaction.Outputs[0].PublicKeyScript, + ) + } +} + +// reservationTestWallet returns a wallet with a real ECDSA public key so +// bitcoin.PublicKeyHash (called at the top of both execute() methods) +// doesn't panic on a nil key. +func reservationTestWallet(t *testing.T) wallet { + t.Helper() + + publicKeyBytes, err := hex.DecodeString( + "0471e30bca60f6548d7b42582a478ea37ada63b402af7b3ddd57f0c95bb6843175" + + "aa0d2053a91a050a6797d85c38f2909cb7027f2344a01986aa2f9f8ca7a0c289", + ) + if err != nil { + t.Fatal(err) + } + + return wallet{publicKey: mustUnmarshalPublicKey(t, publicKeyBytes)} +} + +func TestReservationAnchorAction_Execute(t *testing.T) { + const fundingOutputIndex = 0 + + custodyWallet := reservationTestWallet(t) + walletPublicKeyHash := bitcoin.PublicKeyHash(custodyWallet.publicKey) + + newAction := func( + chain Chain, + btcChain bitcoin.Chain, + fundingTxHash bitcoin.Hash, + ) *reservationAnchorAction { + return newReservationAnchorAction( + zap.NewNop().Sugar(), + chain, + btcChain, + custodyWallet, + nil, // signing executor unreached by these negative-path cases + &ReservationAnchorProposal{ + DepositFundingTxHash: fundingTxHash, + DepositFundingOutputIndex: fundingOutputIndex, + RequestNonce: 1, + AnchorTxFee: big.NewInt(1500), + }, + 300000, + 300000+600, + nil, + nil, + ) + } + + t.Run("no matching DepositRevealed event", func(t *testing.T) { + chain := Connect() + btcChain := newLocalBitcoinChain() + + fundingTx := &bitcoin.Transaction{ + Outputs: []*bitcoin.TransactionOutput{{Value: 100000}}, + } + if err := btcChain.BroadcastTransaction(fundingTx); err != nil { + t.Fatal(err) + } + 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) + } + + err := newAction(chain, btcChain, fundingTxHash).execute() + if err == nil || err.Error() != "no matching DepositRevealed event for deposit" { + t.Errorf( + "unexpected error\nexpected: [no matching DepositRevealed event for deposit]\nactual: [%v]", + err, + ) + } + }) + + t.Run("deposit request not found", func(t *testing.T) { + chain := Connect() + btcChain := newLocalBitcoinChain() + + fundingTx := &bitcoin.Transaction{ + Outputs: []*bitcoin.TransactionOutput{{Value: 100000}}, + } + 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{{ + FundingTxHash: fundingTxHash, + FundingOutputIndex: fundingOutputIndex, + WalletPublicKeyHash: walletPublicKeyHash, + }}, + ); err != nil { + t.Fatal(err) + } + // Deliberately no setDepositRequest call: the Bridge has no + // request record for this funding outpoint. + + err := newAction(chain, btcChain, fundingTxHash).execute() + if err == nil || err.Error() != "deposit request not found" { + t.Errorf( + "unexpected error\nexpected: [deposit request not found]\nactual: [%v]", + err, + ) + } + }) + + t.Run("full happy path up to the signing boundary", func(t *testing.T) { + chain := Connect() + 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) + } + + 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{{ + 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, + }) + + 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{ + 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() + + 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.setDepositRequest(fundingTxHash, fundingOutputIndex, &DepositChainRequest{ + Amount: 100000, + RevealedAt: time.Now(), + }) + chain.setReservationAction(&ReservationAction{ + ActionType: ReservationActionTypeAcceptance, + State: ReservationActionStatePending, + TargetWalletPublicKeyHash: [20]byte{0xff}, // does not match the signing wallet + TxMaxFee: 2000, + }) + + action := newAction(chain, btcChain, fundingTxHash) + action.expiryBlock = 100 + + err = action.execute() + if err == nil || err.Error() != "reservation action targets a different wallet" { + t.Errorf( + "unexpected error\nexpected: [reservation action targets a different wallet]\nactual: [%v]", + err, + ) + } + }) +} + +func TestReservationReanchorAction_Execute(t *testing.T) { + custodyWallet := reservationTestWallet(t) + walletPublicKeyHash := bitcoin.PublicKeyHash(custodyWallet.publicKey) + + reservationKey := big.NewInt(777) + + newAction := func( + chain Chain, + btcChain bitcoin.Chain, + ) *reservationReanchorAction { + return newReservationReanchorAction( + zap.NewNop().Sugar(), + chain, + btcChain, + custodyWallet, + nil, // signing executor unreached by these negative-path cases + &ReservationReanchorProposal{ + ReservationKey: reservationKey, + RequestNonce: 1, + TargetWalletPublicKeyHash: walletPublicKeyHash, + ReanchorTxFee: big.NewInt(1500), + }, + 300000, + 100, // below reservationActionSigningTimeoutSafetyMarginBlocks + nil, + nil, + ) + } + + t.Run("full happy path up to the signing boundary", func(t *testing.T) { + chain := Connect() + btcChain := newLocalBitcoinChain() + + anchorOutputScript, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + priorAnchorTx := &bitcoin.Transaction{ + Outputs: []*bitcoin.TransactionOutput{ + {Value: 10000}, + {Value: 100000, PublicKeyScript: anchorOutputScript}, + }, + } + if err := btcChain.BroadcastTransaction(priorAnchorTx); err != nil { + t.Fatal(err) + } + + chain.setReservation(&Reservation{ + WalletPublicKeyHash: walletPublicKeyHash, + AnchorUtxo: &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: priorAnchorTx.Hash(), + OutputIndex: 1, + }, + Value: 100000, + }, + }) + chain.setReservationAction(&ReservationAction{ + ActionType: ReservationActionTypeReanchor, + State: ReservationActionStatePending, + TargetWalletPublicKeyHash: walletPublicKeyHash, + TxMaxFee: 2000, + }) + + // Every real upstream step (reservation load, action load, + // type/state check, target wallet match, on-chain validation, + // transaction assembly) must succeed before the expiry-block + // guard is reached and rejects the proposal - reaching this + // exact error is the test's proof that all of it worked. + err = newAction(chain, btcChain).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() + + chain.setReservation(&Reservation{ + WalletPublicKeyHash: walletPublicKeyHash, + AnchorUtxo: &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x01}, + OutputIndex: 0, + }, + Value: 100000, + }, + }) + chain.setReservationAction(&ReservationAction{ + ActionType: ReservationActionTypeReanchor, + State: ReservationActionStatePending, + TargetWalletPublicKeyHash: [20]byte{0xff}, // does not match the proposal's target + TxMaxFee: 2000, + }) + + err := newAction(chain, btcChain).execute() + if err == nil || err.Error() != "reservation action targets a different wallet" { + t.Errorf( + "unexpected error\nexpected: [reservation action targets a different wallet]\nactual: [%v]", + err, + ) + } + }) +} + +// TestAssembleReservationAnchorTransaction verifies the happy-path output +// shape of AssembleReservationAnchorTransaction: a 1-input-1-output +// transaction spending the reserved deposit's P2WSH UTXO into a single +// P2WPKH output controlled by the target wallet, valued at the deposit +// amount less the transaction fee. Prior to this test, existing coverage +// (TestAssembleReservationTransactions_InputValidation, +// TestAssembleReservationTransactions_FeeBoundaries) exercised only +// validation-error and fee-boundary-error paths; no test asserted the +// happy-path output shape. +func TestAssembleReservationAnchorTransaction(t *testing.T) { + bitcoinChain := newLocalBitcoinChain() + + privateKeyValue := big.NewInt(100) + testWallet := generateWallet(privateKeyValue) + walletPublicKeyHash := bitcoin.PublicKeyHash(testWallet.publicKey) + + targetPrivateKeyValue := big.NewInt(200) + targetWallet := generateWallet(targetPrivateKeyValue) + targetWalletPublicKeyHash := bitcoin.PublicKeyHash(targetWallet.publicKey) + targetWalletScript, err := bitcoin.PayToWitnessPublicKeyHash(targetWalletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + + deposit := &Deposit{ + Depositor: chain.Address("0x1111111111111111111111111111111111111111"), + BlindingFactor: [8]byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}, + WalletPublicKeyHash: walletPublicKeyHash, + RefundPublicKeyHash: [20]byte{0x02}, + RefundLocktime: [4]byte{0x03, 0x04, 0x05, 0x06}, + } + + depositScript, err := deposit.Script() + if err != nil { + t.Fatal(err) + } + + depositScriptHash := sha256.Sum256(depositScript) + depositLockingScript, err := bitcoin.PayToWitnessScriptHash(depositScriptHash) + if err != nil { + t.Fatal(err) + } + + fundingTransaction := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x09}, + OutputIndex: 0, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 100000, + PublicKeyScript: depositLockingScript, + }, + }, + } + if err := bitcoinChain.BroadcastTransaction(fundingTransaction); err != nil { + t.Fatal(err) + } + + deposit.Utxo = &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTransaction.Hash(), + OutputIndex: 0, + }, + Value: 100000, + } + + builder, err := AssembleReservationAnchorTransaction( + bitcoinChain, + deposit, + targetWalletPublicKeyHash, + &ReservationAction{TxMaxFee: 1500}, + 1500, + ) + if err != nil { + t.Fatal(err) + } + + transaction := signReservationTransaction( + t, + builder, + testWallet.publicKey, + privateKeyValue, + ) + + expectedOutputs := []*bitcoin.TransactionOutput{ + { + Value: 98500, + PublicKeyScript: targetWalletScript, + }, + } + + if !reflect.DeepEqual(expectedOutputs, transaction.Outputs) { + t.Errorf( + "unexpected outputs\nexpected: [%+v]\nactual: [%+v]", + expectedOutputs, + transaction.Outputs, + ) + } + + testutils.AssertIntsEqual(t, "inputs count", 1, len(transaction.Inputs)) +} + +// TestAssembleReservationReanchorTransaction verifies the happy-path output +// shape of AssembleReservationReanchorTransaction: a 1-input-1-output +// transaction spending the reservation's anchor UTXO into a single P2WPKH +// output controlled by the target wallet, valued at the anchor amount less +// the transaction fee. Prior to this test, existing coverage +// (TestAssembleReservationTransactions_InputValidation, +// TestAssembleReservationTransactions_FeeBoundaries) exercised only +// validation-error and fee-boundary-error paths; no test asserted the +// happy-path output shape. Note that pkg/tbtcpg does not yet exercise the +// reanchor assembly path via this function. +func TestAssembleReservationReanchorTransaction(t *testing.T) { + bitcoinChain := newLocalBitcoinChain() + + privateKeyValue := big.NewInt(100) + testWallet := generateWallet(privateKeyValue) + sourceWalletPublicKeyHash := bitcoin.PublicKeyHash(testWallet.publicKey) + sourceWalletScript, err := bitcoin.PayToWitnessPublicKeyHash(sourceWalletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + + targetPrivateKeyValue := big.NewInt(200) + targetWallet := generateWallet(targetPrivateKeyValue) + targetWalletPublicKeyHash := bitcoin.PublicKeyHash(targetWallet.publicKey) + targetWalletScript, err := bitcoin.PayToWitnessPublicKeyHash(targetWalletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + + fundingTransaction := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x0a}, + OutputIndex: 0, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 100000, + PublicKeyScript: sourceWalletScript, + }, + }, + } + if err := bitcoinChain.BroadcastTransaction(fundingTransaction); err != nil { + t.Fatal(err) + } + + anchorUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTransaction.Hash(), + OutputIndex: 0, + }, + Value: 100000, + } + + builder, err := AssembleReservationReanchorTransaction( + bitcoinChain, + anchorUtxo, + targetWalletPublicKeyHash, + &ReservationAction{TxMaxFee: 1500}, + 1500, + ) + if err != nil { + t.Fatal(err) + } + + transaction := signReservationTransaction( + t, + builder, + testWallet.publicKey, + privateKeyValue, + ) + + expectedOutputs := []*bitcoin.TransactionOutput{ + { + Value: 98500, + PublicKeyScript: targetWalletScript, + }, + } + + if !reflect.DeepEqual(expectedOutputs, transaction.Outputs) { + t.Errorf( + "unexpected outputs\nexpected: [%+v]\nactual: [%+v]", + expectedOutputs, + transaction.Outputs, + ) + } + + testutils.AssertIntsEqual(t, "inputs count", 1, len(transaction.Inputs)) +} diff --git a/pkg/tbtc/signing_test.go b/pkg/tbtc/signing_test.go index 3e7367fa43..f83fd61827 100644 --- a/pkg/tbtc/signing_test.go +++ b/pkg/tbtc/signing_test.go @@ -9,6 +9,8 @@ import ( "testing" "time" + "github.com/keep-network/keep-common/pkg/chain/ethereum" + "github.com/keep-network/keep-core/internal/testutils" "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/chain" @@ -261,6 +263,7 @@ func setupSigningExecutor(t *testing.T) *signingExecutor { keyStorePersistence := createMockKeyStorePersistence(t, signers...) node, err := newNode( + ethereum.Unknown, groupParameters, localChain, newLocalBitcoinChain(), diff --git a/pkg/tbtc/tbtc.go b/pkg/tbtc/tbtc.go index 715066fd7c..d4321de8e3 100644 --- a/pkg/tbtc/tbtc.go +++ b/pkg/tbtc/tbtc.go @@ -12,6 +12,7 @@ import ( "github.com/keep-network/keep-common/pkg/chain/ethereum" "github.com/keep-network/keep-common/pkg/persistence" + "github.com/keep-network/keep-core/pkg/clientinfo" "github.com/keep-network/keep-core/pkg/generator" "github.com/keep-network/keep-core/pkg/net" @@ -142,6 +143,19 @@ type Config struct { PreParamsGenerationConcurrency int // Concurrency level for key-generation for tECDSA. KeyGenerationConcurrency int + // Reservations gates the m1 reservation feature's proposal generation + // (acceptance, re-anchor), watcher wiring (stranding / stale-deposit / + // action-timeout), and reservation metrics registration. It does NOT + // gate reservation action execution: once a network's reservation + // activation block is reached (see reservationsActivationBlocks in + // coordination.go), every wallet signer validates, co-signs, and + // broadcasts reservation anchor/re-anchor Bitcoin transactions + // proposed by an upgraded leader regardless of this flag - follower/ + // executor dispatch gates only on wallet-signer membership, by + // design, so an honest follower can never be made to fault a leader + // over a local config difference. + Reservations ReservationsConfig + // WalletTxSatPerVByteFloor is the minimum fee rate (sat/vByte) applied // to wallet Bitcoin transactions. Zero means use // DefaultWalletTxSatPerVByteFloor. Maps to the @@ -155,6 +169,39 @@ type Config struct { WalletTxFeeBufferPercent int } +// ReservationsConfig holds the reservation-related tbtc.Config fields. It is +// a separate type so future reservation knobs (poll intervals, cap overrides) +// can be added without breaking the top-level Config layout. +// +// This flag controls BOTH reservation acceptance / re-anchor proposal +// GENERATION AND watcher wiring in the start process (the `start` +// command's coordination and watcher layers, config category Tbtc). The +// `maintainer` command runs as a separate process reading a +// disjoint config category (see config.MaintainerCategories) and has its +// own independent gate, spv.ReservationsConfig.Enabled, that controls SPV +// PROOF SUBMISSION for those same proposals. Neither command's config +// loading sees the other's category, so this flag cannot be derived from or +// validated against spv.ReservationsConfig.Enabled in code. An operator +// running both `start` and `maintainer` for the reservation feature to work +// end-to-end MUST enable both flags - normally the same [Tbtc.Reservations] +// / [Maintainer.Spv.Reservations] TOML sections in one shared config file. +type ReservationsConfig struct { + // Enabled toggles reservation acceptance / re-anchor proposal + // generation, reservation watcher wiring, and reservation metrics + // registration only. It does NOT gate reservation action execution: + // once a network's reservation activation block is reached, every + // wallet signer validates, co-signs, and broadcasts reservation + // proposals regardless of this flag - execution dispatch gates only + // on wallet-signer membership, by design. Defaults to false so + // existing deployments opt in explicitly. + Enabled bool +} + +// WalletMembersResolver defines the interface for resolving wallet members. +type WalletMembersResolver interface { + ResolveWalletMembers(walletPublicKeyHash [20]byte) ([]uint32, error) +} + // applyWalletTxFeePolicy applies the operator-tunable wallet-tx fee-floor // policy from Config to the package-level policy vars. Zero-valued Config // fields are skipped so a direct Config{} in tests retains the @@ -171,6 +218,15 @@ func applyWalletTxFeePolicy(config Config) { // Initialize kicks off the TBTC by initializing internal state, ensuring // preconditions like staking are met, and then kicking off the internal TBTC // implementation. Returns an error if this failed. +// +// Reservation watcher wiring (stranding / stale-deposit / action-timeout, +// see pkg/maintainer/spv.WireReservationWatchers) is not performed here: +// it lives in cmd/start.go, called directly against the same tbtc.Chain +// handle once Initialize returns successfully and gated on the same +// config.Reservations.Enabled flag. Threading it through Initialize via a +// callback type would only exist to dodge a tbtc -> spv import cycle that +// cmd/start.go (which already imports both packages) does not have. + func Initialize( ctx context.Context, chain Chain, @@ -184,7 +240,7 @@ func Initialize( clientInfo *clientinfo.Registry, perfMetrics *clientinfo.PerformanceMetrics, ethereumNetwork ethereum.Network, -) error { +) (WalletMembersResolver, error) { applyWalletTxFeePolicy(config) groupParameters := defaultGroupParameters(ethereumNetwork) @@ -194,7 +250,7 @@ func Initialize( }); ok { gp, err := ethChain.EcdsaWalletGroupParametersFromChain(ctx) if err != nil { - return fmt.Errorf( + return nil, fmt.Errorf( "cannot read TBTC group sizing from ECDSA validator: [%w]", err, ) @@ -213,6 +269,7 @@ func Initialize( } node, err := newNode( + ethereumNetwork, groupParameters, chain, btcChain, @@ -224,12 +281,12 @@ func Initialize( config, ) if err != nil { - return fmt.Errorf("cannot set up TBTC node: [%v]", err) + return nil, fmt.Errorf("cannot set up TBTC node: [%v]", err) } err = node.runCoordinationLayer(ctx) if err != nil { - return fmt.Errorf("cannot run coordination layer: [%w]", err) + return nil, fmt.Errorf("cannot run coordination layer: [%w]", err) } deduplicator := newDeduplicator() @@ -246,7 +303,11 @@ func Initialize( ) if perfMetrics == nil { - perfMetrics = clientinfo.NewPerformanceMetrics(ctx, clientInfo) + perfMetrics = clientinfo.NewPerformanceMetrics( + ctx, + clientInfo, + config.Reservations.Enabled, + ) } node.setPerformanceMetrics(perfMetrics) @@ -284,7 +345,7 @@ func Initialize( ), ) if err != nil { - return fmt.Errorf( + return nil, fmt.Errorf( "could not set up sortition pool monitoring: [%v]", err, ) @@ -446,7 +507,7 @@ func Initialize( }() }) - return nil + return node, nil } // enoughPreParamsInPoolPolicy is a policy that enforces the sufficient size diff --git a/pkg/tbtc/wallet.go b/pkg/tbtc/wallet.go index eb4bce52f5..af7269c9d2 100644 --- a/pkg/tbtc/wallet.go +++ b/pkg/tbtc/wallet.go @@ -13,13 +13,14 @@ import ( "golang.org/x/exp/slices" "github.com/ipfs/go-log/v2" + "go.uber.org/zap" + "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/clientinfo" "github.com/keep-network/keep-core/pkg/crypto/secp256k1" "github.com/keep-network/keep-core/pkg/protocol/group" "github.com/keep-network/keep-core/pkg/tecdsa" - "go.uber.org/zap" ) // WalletActionType represents actions types that can be performed by a wallet. @@ -32,6 +33,10 @@ const ( ActionRedemption ActionMovingFunds ActionMovedFundsSweep + ActionReservationAnchor + _ // reserved: formerly ActionReservedRedemption (wire value 7); client-side scaffolding removed, wire slot retained + ActionReservationReanchor + _ // reserved: formerly ActionReservationDissolution (wire value 9); client-side scaffolding removed, wire slot retained ) // ParseWalletActionType parses the given value into a WalletActionType. @@ -49,6 +54,14 @@ func ParseWalletActionType(value uint8) (WalletActionType, error) { return ActionMovingFunds, nil case ActionMovedFundsSweep: return ActionMovedFundsSweep, nil + case 6: + return ActionReservationAnchor, nil + case 8: + return ActionReservationReanchor, nil + // NOTE: Action types 7 and 9 are reserved wire slots (formerly ActionReservedRedemption + // and ActionReservationDissolution). Their client-side scaffolding was removed but the + // wire slots are retained for forward compatibility. Parsing is intentionally incomplete + // until M2 action types are implemented. See const declarations above for details. default: return 0, fmt.Errorf("unknown wallet action type [%v]", value) } @@ -68,6 +81,10 @@ func (wat WalletActionType) String() string { return "MovingFunds" case ActionMovedFundsSweep: return "MovedFundsSweep" + case ActionReservationAnchor: + return "ReservationAnchor" + case ActionReservationReanchor: + return "ReservationReanchor" default: panic("unknown wallet action type") } @@ -89,6 +106,10 @@ func (wat WalletActionType) MetricName() string { return "moving_funds" case ActionMovedFundsSweep: return "moved_funds_sweep" + case ActionReservationAnchor: + return "reservation_anchor" + case ActionReservationReanchor: + return "reservation_reanchor" default: panic("unknown wallet action type") } diff --git a/pkg/tbtc/wallet_test.go b/pkg/tbtc/wallet_test.go index 6dea580e52..eadc9d352a 100644 --- a/pkg/tbtc/wallet_test.go +++ b/pkg/tbtc/wallet_test.go @@ -53,9 +53,13 @@ func TestParseWalletActionType(t *testing.T) { value: 5, expectedAction: ActionMovedFundsSweep, }, + "reservation anchor": { + value: 6, + expectedAction: ActionReservationAnchor, + }, "unknown": { - value: 6, - expectedErr: fmt.Errorf("unknown wallet action type [6]"), + value: 10, + expectedErr: fmt.Errorf("unknown wallet action type [10]"), }, } @@ -82,6 +86,29 @@ func TestParseWalletActionType(t *testing.T) { } } +func TestWalletActionType_MetricName(t *testing.T) { + tests := map[WalletActionType]string{ + ActionNoop: "noop", + ActionHeartbeat: "heartbeat", + ActionDepositSweep: "deposit_sweep", + ActionRedemption: "redemption", + ActionMovingFunds: "moving_funds", + ActionMovedFundsSweep: "moved_funds_sweep", + ActionReservationAnchor: "reservation_anchor", + } + + for actionType, expected := range tests { + if actual := actionType.MetricName(); actual != expected { + t.Errorf( + "unexpected metric name for action type [%v]\nexpected: [%v]\nactual: [%v]", + actionType, + expected, + actual, + ) + } + } +} + func TestWalletDispatcher_Dispatch(t *testing.T) { walletDispatcher := newWalletDispatcher() diff --git a/pkg/tbtcpg/bitcoin_chain_test.go b/pkg/tbtcpg/bitcoin_chain_test.go index e5cd80cf67..7832bf7a0e 100644 --- a/pkg/tbtcpg/bitcoin_chain_test.go +++ b/pkg/tbtcpg/bitcoin_chain_test.go @@ -14,6 +14,7 @@ type LocalBitcoinChain struct { transactions map[bitcoin.Hash]*bitcoin.Transaction transactionsConfirmations map[bitcoin.Hash]uint satPerVByteFeeEstimation map[uint32]int64 + txHashesByPublicKeyHash map[[20]byte][]bitcoin.Hash } func NewLocalBitcoinChain() *LocalBitcoinChain { @@ -21,6 +22,7 @@ func NewLocalBitcoinChain() *LocalBitcoinChain { transactions: make(map[bitcoin.Hash]*bitcoin.Transaction), transactionsConfirmations: make(map[bitcoin.Hash]uint), satPerVByteFeeEstimation: make(map[uint32]int64), + txHashesByPublicKeyHash: make(map[[20]byte][]bitcoin.Hash), } } @@ -104,7 +106,23 @@ func (lbc *LocalBitcoinChain) GetTransactionsForPublicKeyHash( func (lbc *LocalBitcoinChain) GetTxHashesForPublicKeyHash( publicKeyHash [20]byte, ) ([]bitcoin.Hash, error) { - panic("unsupported") + lbc.mutex.Lock() + defer lbc.mutex.Unlock() + + return lbc.txHashesByPublicKeyHash[publicKeyHash], nil +} + +// SetTxHashesForPublicKeyHash wires the wallet's transaction history for +// GetTxHashesForPublicKeyHash, used by tbtc.DetermineWalletMainUtxo to +// locate the wallet's main UTXO among its past transactions. +func (lbc *LocalBitcoinChain) SetTxHashesForPublicKeyHash( + publicKeyHash [20]byte, + hashes []bitcoin.Hash, +) { + lbc.mutex.Lock() + defer lbc.mutex.Unlock() + + lbc.txHashesByPublicKeyHash[publicKeyHash] = hashes } func (lbc *LocalBitcoinChain) GetMempoolForPublicKeyHash( diff --git a/pkg/tbtcpg/chain.go b/pkg/tbtcpg/chain.go index a1bead42c5..3544cdd760 100644 --- a/pkg/tbtcpg/chain.go +++ b/pkg/tbtcpg/chain.go @@ -165,4 +165,111 @@ type Chain interface { // the deposit reveal before a deposit becomes eligible for // a processing. GetDepositMinAge() (uint32, error) + + // ValidateReservationAnchorProposal validates the given reservation + // anchor proposal against the chain. Returns an error if the proposal + // is not valid or nil otherwise. + ValidateReservationAnchorProposal( + walletPublicKeyHash [20]byte, + proposal *tbtc.ReservationAnchorProposal, + depositExtraInfo struct { + *tbtc.Deposit + FundingTx *bitcoin.Transaction + }, + ) error + + // ValidateReservationReanchorProposal validates the given reservation + // re-anchor proposal against the chain. Returns an error if the + // proposal is not valid or nil otherwise. + ValidateReservationReanchorProposal( + sourceWalletPublicKeyHash [20]byte, + proposal *tbtc.ReservationReanchorProposal, + ) error + + // RequestReservationAcceptance requests a reservation acceptance action + // generation for the given reservation. The reservation must be in a + // state that allows acceptance; the operator-side guard is enforced at + // the chain layer. + RequestReservationAcceptance( + reservationKey *big.Int, + walletPublicKeyHash [20]byte, + ) error + + // RequestReservationReanchor requests a reservation re-anchor action + // generation for the given reservation, targeting the given wallet. + RequestReservationReanchor( + reservationKey *big.Int, + targetWalletPublicKeyHash [20]byte, + ) error + + // NotifyMovingFundsBelowDust notifies the Bridge that the given wallet's + // main UTXO has fallen below the moving funds dust threshold, ending + // the moving funds process and starting wallet closing immediately. + // mainUtxo may be nil when the wallet has no main UTXO at all; the + // Bridge only uses it to verify the on-chain balance it already holds + // for the wallet, so it is ignored in that case. + NotifyMovingFundsBelowDust( + walletPublicKeyHash [20]byte, + mainUtxo *bitcoin.UnspentTransactionOutput, + ) error + + // GetReservation gets the on-chain reservation record for the given + // reservation key. Returns an error if the reservation was not found. + GetReservation(reservationKey *big.Int) (*tbtc.Reservation, error) + + // GetReservationAction gets the on-chain action record for the given + // reservation key and request nonce. Returns an error if the action + // generation was not found. + GetReservationAction( + reservationKey *big.Int, + requestNonce uint64, + ) (*tbtc.ReservationAction, error) + + // ReservationParameters gets the current on-chain values of the Bridge + // reservation parameters. + ReservationParameters() (*tbtc.ReservationParameters, error) + + // ReservationCaps returns the cap parameters that gate reservation + // acceptance: the maximum aggregate satoshi amount a single wallet may + // custody across all of its reservations, and the maximum satoshi + // amount any single reservation may anchor. + ReservationCaps() (maxReservationsAmountPerWallet uint64, reservationMaxSingleAmount uint64, err error) + + // WalletReservationsAmount returns the aggregate satoshi amount + // currently anchored by the given wallet across all of its + // reservations. + WalletReservationsAmount(walletPublicKeyHash [20]byte) (uint64, error) + + // WalletReservationsCount returns the number of reservations currently + // custodied by the given wallet. + WalletReservationsCount(walletPublicKeyHash [20]byte) (uint32, error) + + // WalletReservations returns the reservation keys for all reservations + // currently custodied by the given wallet. + WalletReservations(walletPublicKeyHash [20]byte) ([]*big.Int, error) + + // ActiveReservationsCount returns the current count of active + // reservations across all wallets and the cap on that count. + ActiveReservationsCount() (count uint32, maxActive uint32, err error) + + // IsReservedDeposit returns true if the given deposit was revealed + // with the reservation vault address and is therefore a reservation + // rather than a default deposit. + IsReservedDeposit(depositKey *big.Int) (bool, error) + + // PastReservationAcceptanceRequestedEvents fetches past + // ReservationAcceptanceRequested events according to the provided + // filter or unfiltered if the filter is nil. Returned events are sorted + // by the block number in the ascending order. + PastReservationAcceptanceRequestedEvents( + filter *tbtc.ReservationAcceptanceRequestedEventFilter, + ) ([]*tbtc.ReservationAcceptanceRequestedEvent, error) + + // PastReservationReanchorRequestedEvents fetches past + // ReservationReanchorRequested events according to the provided + // filter or unfiltered if the filter is nil. Returned events are + // sorted by the block number in the ascending order. + PastReservationReanchorRequestedEvents( + filter *tbtc.ReservationReanchorRequestedEventFilter, + ) ([]*tbtc.ReservationReanchorRequestedEvent, error) } diff --git a/pkg/tbtcpg/chain_test.go b/pkg/tbtcpg/chain_test.go index 48754b3972..c643e4ce99 100644 --- a/pkg/tbtcpg/chain_test.go +++ b/pkg/tbtcpg/chain_test.go @@ -29,6 +29,20 @@ type movingFundsCommitmentSubmission struct { TargetWallets [][20]byte } +// reservationReanchorRequestSubmission captures a submitted reservation +// re-anchor request that tests can inspect for assertion. +type reservationReanchorRequestSubmission struct { + ReservationKey *big.Int + TargetWalletPublicKeyHash [20]byte +} + +// belowDustNotification captures a submitted NotifyMovingFundsBelowDust +// call that tests can inspect for assertion. +type belowDustNotification struct { + WalletPublicKeyHash [20]byte + MainUtxo *bitcoin.UnspentTransactionOutput +} + type LocalChain struct { mutex sync.Mutex @@ -57,6 +71,17 @@ type LocalChain struct { operatorIDs map[chain.Address]uint32 redemptionDelays map[[32]byte]time.Duration depositMinAge uint32 + reservations map[string]*tbtc.Reservation + reservationActions map[string]*tbtc.ReservationAction + reservationParametersValue tbtc.ReservationParameters + reservationParametersSet bool + reservationProposalValidations map[[32]byte]bool + reservationReanchorRequestSubmissions []*reservationReanchorRequestSubmission + belowDustNotifications []*belowDustNotification + reservationWalletKeys map[[20]byte][]*big.Int + reservedDeposits map[string]bool + liveWalletsCountValue uint32 + liveWalletsCountSet bool depositSweepMaxSizeErr error } @@ -79,6 +104,14 @@ func NewLocalChain() *LocalChain { movedFundsSweepProposalValidations: make(map[[32]byte]bool), operatorIDs: make(map[chain.Address]uint32), redemptionDelays: make(map[[32]byte]time.Duration), + + reservations: make(map[string]*tbtc.Reservation), + reservationActions: make(map[string]*tbtc.ReservationAction), + reservationProposalValidations: make(map[[32]byte]bool), + reservationReanchorRequestSubmissions: make([]*reservationReanchorRequestSubmission, 0), + belowDustNotifications: make([]*belowDustNotification, 0), + reservationWalletKeys: make(map[[20]byte][]*big.Int), + reservedDeposits: make(map[string]bool), } } @@ -136,7 +169,7 @@ func buildPastDepositRevealedEventsKey( if filter.EndBlock != nil { endBlock := make([]byte, 8) - binary.BigEndian.PutUint64(startBlock, *filter.EndBlock) + binary.BigEndian.PutUint64(endBlock, *filter.EndBlock) buffer.Write(endBlock) } @@ -244,7 +277,7 @@ func buildPastNewWalletRegisteredEventsKey( if filter.EndBlock != nil { endBlock := make([]byte, 8) - binary.BigEndian.PutUint64(startBlock, *filter.EndBlock) + binary.BigEndian.PutUint64(endBlock, *filter.EndBlock) buffer.Write(endBlock) } @@ -313,7 +346,7 @@ func buildPastRedemptionRequestedEventsKey( if filter.EndBlock != nil { endBlock := make([]byte, 8) - binary.BigEndian.PutUint64(startBlock, *filter.EndBlock) + binary.BigEndian.PutUint64(endBlock, *filter.EndBlock) buffer.Write(endBlock) } @@ -348,7 +381,7 @@ func buildPastMovingFundsCommitmentSubmittedEventsKey( if filter.EndBlock != nil { endBlock := make([]byte, 8) - binary.BigEndian.PutUint64(startBlock, *filter.EndBlock) + binary.BigEndian.PutUint64(endBlock, *filter.EndBlock) buffer.Write(endBlock) } @@ -374,7 +407,7 @@ func buildPastMovingFundsCompletedEventsKey( if filter.EndBlock != nil { endBlock := make([]byte, 8) - binary.BigEndian.PutUint64(startBlock, *filter.EndBlock) + binary.BigEndian.PutUint64(endBlock, *filter.EndBlock) buffer.Write(endBlock) } @@ -1021,11 +1054,46 @@ func (lc *LocalChain) SetWalletParameters( } func (lc *LocalChain) GetLiveWalletsCount() (uint32, error) { - panic("unsupported") + lc.mutex.Lock() + defer lc.mutex.Unlock() + + if lc.liveWalletsCountSet { + return lc.liveWalletsCountValue, nil + } + + count := uint32(0) + for _, wallet := range lc.walletChainData { + if wallet != nil && wallet.State == tbtc.StateLive { + count++ + } + } + return count, nil +} + +// SetLiveWalletsCount stores an explicit live-wallets count for tests. +func (lc *LocalChain) SetLiveWalletsCount(count uint32) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.liveWalletsCountValue = count + lc.liveWalletsCountSet = true } func (lc *LocalChain) ComputeMainUtxoHash(mainUtxo *bitcoin.UnspentTransactionOutput) [32]byte { - panic("unsupported") + outputIndexBytes := make([]byte, 4) + binary.BigEndian.PutUint32(outputIndexBytes, mainUtxo.Outpoint.OutputIndex) + + valueBytes := make([]byte, 8) + binary.BigEndian.PutUint64(valueBytes, uint64(mainUtxo.Value)) + + return crypto.Keccak256Hash( + append( + append( + mainUtxo.Outpoint.TransactionHash[:], + outputIndexBytes..., + ), valueBytes..., + ), + ) } func (lc *LocalChain) ComputeMovingFundsCommitmentHash(targetWallets [][20]byte) [32]byte { @@ -1302,3 +1370,440 @@ func (mbc *MockBlockCounter) SetCurrentBlock(block uint64) { func (mbc *MockBlockCounter) WatchBlocks(ctx context.Context) <-chan uint64 { panic("unsupported") } + +// ValidateReservationAnchorProposal is a stub matching the reservation +// additions on the production Chain interface. Full behavioral +// validation belongs to the reservation acceptance proposal builder. +func (lc *LocalChain) ValidateReservationAnchorProposal( + walletPublicKeyHash [20]byte, + proposal *tbtc.ReservationAnchorProposal, + depositExtraInfo struct { + *tbtc.Deposit + FundingTx *bitcoin.Transaction + }, +) error { + panic("unsupported") +} + +// ValidateReservationReanchorProposal returns nil when no explicit +// validation result was registered, mirroring the production contract's +// happy path for tests that don't need to enforce specific validation +// outcomes. Tests that need to drive specific failure modes should +// populate this via SetReservationReanchorProposalValidationResult. +func (lc *LocalChain) ValidateReservationReanchorProposal( + sourceWalletPublicKeyHash [20]byte, + proposal *tbtc.ReservationReanchorProposal, +) error { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + if proposal == nil { + return fmt.Errorf("proposal is required") + } + + key, err := buildReservationReanchorProposalValidationKey( + sourceWalletPublicKeyHash, + proposal, + ) + if err != nil { + return err + } + + if result, ok := lc.reservationProposalValidations[key]; ok { + if !result { + return fmt.Errorf("validation failed") + } + } + + return nil +} + +// SetReservationReanchorProposalValidationResult stores the validation +// outcome for the given (sourceWalletPublicKeyHash, proposal) tuple. +func (lc *LocalChain) SetReservationReanchorProposalValidationResult( + sourceWalletPublicKeyHash [20]byte, + proposal *tbtc.ReservationReanchorProposal, + result bool, +) error { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + key, err := buildReservationReanchorProposalValidationKey( + sourceWalletPublicKeyHash, + proposal, + ) + if err != nil { + return err + } + + lc.reservationProposalValidations[key] = result + return nil +} + +func buildReservationReanchorProposalValidationKey( + sourceWalletPublicKeyHash [20]byte, + proposal *tbtc.ReservationReanchorProposal, +) ([32]byte, error) { + var buffer bytes.Buffer + + buffer.Write(sourceWalletPublicKeyHash[:]) + + if proposal != nil { + if proposal.ReservationKey != nil { + buffer.Write(proposal.ReservationKey.Bytes()) + } + for i := 0; i < 8; i++ { + buffer.Write([]byte{byte(proposal.RequestNonce >> (8 * i))}) + } + buffer.Write(proposal.TargetWalletPublicKeyHash[:]) + if proposal.ReanchorTxFee != nil { + buffer.Write(proposal.ReanchorTxFee.Bytes()) + } + } + + return sha256.Sum256(buffer.Bytes()), nil +} + +// RequestReservationAcceptance records a submitted reservation acceptance +// request for assertion in tests. +func (lc *LocalChain) RequestReservationAcceptance( + reservationKey *big.Int, + walletPublicKeyHash [20]byte, +) error { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + _ = walletPublicKeyHash + + // Mirror the on-chain Bridge's own nonce bump: GetReservation after + // this call must observe the incremented RequestNonce for the + // nonce-reconciliation check in proposeReservationAcceptance. + key := reservationKey.Text(16) + existing, ok := lc.reservations[key] + if ok && existing != nil { + updated := *existing + updated.RequestNonce++ + lc.reservations[key] = &updated + } else { + lc.reservations[key] = &tbtc.Reservation{RequestNonce: 1} + } + return nil +} + +// RequestReservationReanchor records a submitted reservation re-anchor +// request for assertion in tests. +func (lc *LocalChain) RequestReservationReanchor( + reservationKey *big.Int, + targetWalletPublicKeyHash [20]byte, +) error { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.reservationReanchorRequestSubmissions = append( + lc.reservationReanchorRequestSubmissions, + &reservationReanchorRequestSubmission{ + ReservationKey: new(big.Int).Set(reservationKey), + TargetWalletPublicKeyHash: targetWalletPublicKeyHash, + }, + ) + + // Mirror the on-chain Bridge's own nonce bump: GetReservation after + // this call must observe the incremented RequestNonce for the + // nonce-reconciliation check in ProposeReservationReanchor. + key := reservationKey.Text(16) + if existing, ok := lc.reservations[key]; ok && existing != nil { + updated := *existing + updated.RequestNonce++ + lc.reservations[key] = &updated + } + return nil +} + +// NotifyMovingFundsBelowDust records a submitted below-dust notification +// for assertion in tests. +func (lc *LocalChain) NotifyMovingFundsBelowDust( + walletPublicKeyHash [20]byte, + mainUtxo *bitcoin.UnspentTransactionOutput, +) error { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.belowDustNotifications = append( + lc.belowDustNotifications, + &belowDustNotification{ + WalletPublicKeyHash: walletPublicKeyHash, + MainUtxo: mainUtxo, + }, + ) + return nil +} + +// GetBelowDustNotifications returns the recorded NotifyMovingFundsBelowDust +// submissions for assertion. +func (lc *LocalChain) GetBelowDustNotifications() []*belowDustNotification { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + copy := make([]*belowDustNotification, len(lc.belowDustNotifications)) + for i, n := range lc.belowDustNotifications { + copy[i] = n + } + return copy +} + +// GetReservation returns the configured reservation record for the given +// reservation key, or an error if not found. +func (lc *LocalChain) GetReservation( + reservationKey *big.Int, +) (*tbtc.Reservation, error) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + if reservation, ok := lc.reservations[reservationKey.Text(16)]; ok { + return reservation, nil + } + return nil, fmt.Errorf("reservation not found") +} + +// SetReservation stores the given reservation record keyed by reservationKey. +func (lc *LocalChain) SetReservation( + reservationKey *big.Int, + reservation *tbtc.Reservation, +) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.reservations[reservationKey.Text(16)] = reservation +} + +// GetReservationAction returns the configured reservation action record. +func (lc *LocalChain) GetReservationAction( + reservationKey *big.Int, + requestNonce uint64, +) (*tbtc.ReservationAction, error) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + key := buildReservationActionKey(reservationKey, requestNonce) + if action, ok := lc.reservationActions[key]; ok { + return action, nil + } + // Fall back to comparing by value + for k, a := range lc.reservationActions { + expected := buildReservationActionKey(reservationKey, requestNonce) + if k == expected { + return a, nil + } + } + return nil, fmt.Errorf("reservation action not found") +} + +// SetReservationAction stores the given reservation action record. +func (lc *LocalChain) SetReservationAction( + reservationKey *big.Int, + requestNonce uint64, + action *tbtc.ReservationAction, +) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + key := buildReservationActionKey(reservationKey, requestNonce) + lc.reservationActions[key] = action +} + +func buildReservationActionKey( + reservationKey *big.Int, + requestNonce uint64, +) string { + if reservationKey == nil { + return fmt.Sprintf("nil/%d", requestNonce) + } + return fmt.Sprintf("%s/%d", reservationKey.String(), requestNonce) +} + +// ReservationParameters returns the configured reservation parameters. +func (lc *LocalChain) ReservationParameters() ( + *tbtc.ReservationParameters, + error, +) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + if !lc.reservationParametersSet { + return nil, fmt.Errorf("reservation parameters not set") + } + params := lc.reservationParametersValue + return ¶ms, nil +} + +// SetReservationParameters stores the given reservation parameters. +func (lc *LocalChain) SetReservationParameters(params tbtc.ReservationParameters) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.reservationParametersValue = params + lc.reservationParametersSet = true +} + +// ReservationCaps returns a static cap-pair useful for tests. +func (lc *LocalChain) ReservationCaps() ( + maxReservationsAmountPerWallet uint64, + reservationMaxSingleAmount uint64, + err error, +) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + return 100000000, 10000000, nil +} + +// WalletReservationsAmount returns the sum of anchor values for the +// wallet's reservations. +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 +} + +// WalletReservationsCount returns the count of reservations for the wallet. +func (lc *LocalChain) WalletReservationsCount( + walletPublicKeyHash [20]byte, +) (uint32, error) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + return uint32(len(lc.reservationWalletKeys[walletPublicKeyHash])), nil +} + +// WalletReservations returns the configured reservation keys for the wallet. +func (lc *LocalChain) WalletReservations( + walletPublicKeyHash [20]byte, +) ([]*big.Int, error) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + keys := lc.reservationWalletKeys[walletPublicKeyHash] + result := make([]*big.Int, len(keys)) + for i, k := range keys { + result[i] = new(big.Int).Set(k) + } + return result, nil +} + +// SetWalletReservations stores the reservation keys associated with the wallet. +func (lc *LocalChain) SetWalletReservations( + walletPublicKeyHash [20]byte, + reservationKeys []*big.Int, +) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + copy := make([]*big.Int, len(reservationKeys)) + for i, k := range reservationKeys { + copy[i] = new(big.Int).Set(k) + } + lc.reservationWalletKeys[walletPublicKeyHash] = copy +} + +// Reservations is a stub mirroring the Bridge view. Tests that need this +// data should populate it explicitly via custom extensions. + +// ActiveReservationsCount reports zero active reservations by default. +func (lc *LocalChain) ActiveReservationsCount() ( + count uint32, + maxActive uint32, + err error, +) { + return 0, 0, nil +} + +// IsReservedDeposit returns false unless the deposit key was previously +// marked reserved via SetReservedDeposit. +func (lc *LocalChain) IsReservedDeposit( + depositKey *big.Int, +) (bool, error) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + if depositKey == nil { + return false, nil + } + + return lc.reservedDeposits[depositKey.Text(16)], nil +} + +// SetReservedDeposit marks the given deposit key as reserved (or not) for +// IsReservedDeposit to return. +func (lc *LocalChain) SetReservedDeposit(depositKey *big.Int, reserved bool) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.reservedDeposits[depositKey.Text(16)] = reserved +} + +// PastReservationAcceptanceRequestedEvents returns no events by default. +func (lc *LocalChain) PastReservationAcceptanceRequestedEvents( + filter *tbtc.ReservationAcceptanceRequestedEventFilter, +) ([]*tbtc.ReservationAcceptanceRequestedEvent, error) { + return nil, nil +} + +// PastReservationReanchorRequestedEvents returns the recorded re-anchor +// request submissions that match the filter. +func (lc *LocalChain) PastReservationReanchorRequestedEvents( + filter *tbtc.ReservationReanchorRequestedEventFilter, +) ([]*tbtc.ReservationReanchorRequestedEvent, error) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + results := make([]*tbtc.ReservationReanchorRequestedEvent, 0) + for _, submission := range lc.reservationReanchorRequestSubmissions { + event := &tbtc.ReservationReanchorRequestedEvent{ + ReservationKey: new(big.Int).Set(submission.ReservationKey), + TargetWalletPublicKeyHash: submission.TargetWalletPublicKeyHash, + } + + if filter != nil { + if len(filter.TargetWalletPublicKeyHash) > 0 { + matched := false + for _, w := range filter.TargetWalletPublicKeyHash { + if w == submission.TargetWalletPublicKeyHash { + matched = true + break + } + } + if !matched { + continue + } + } + } + + results = append(results, event) + } + return results, nil +} + +// GetReservationReanchorRequestSubmissions returns the recorded +// reservation re-anchor request submissions for assertion. +func (lc *LocalChain) GetReservationReanchorRequestSubmissions() []*reservationReanchorRequestSubmission { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + copy := make([]*reservationReanchorRequestSubmission, len(lc.reservationReanchorRequestSubmissions)) + for i, s := range lc.reservationReanchorRequestSubmissions { + copy[i] = &reservationReanchorRequestSubmission{ + ReservationKey: new(big.Int).Set(s.ReservationKey), + TargetWalletPublicKeyHash: s.TargetWalletPublicKeyHash, + } + } + return copy +} diff --git a/pkg/tbtcpg/deposit_sweep.go b/pkg/tbtcpg/deposit_sweep.go index b8bf84749f..e207d43740 100644 --- a/pkg/tbtcpg/deposit_sweep.go +++ b/pkg/tbtcpg/deposit_sweep.go @@ -209,6 +209,20 @@ func findDeposits( depositKey := chain.BuildDepositKey(event.FundingTxHash, event.FundingOutputIndex) depositKeyStr := depositKey.Text(16) + isReserved, err := chain.IsReservedDeposit(depositKey) + if err != nil { + taskLogger.Errorf( + "failed to check if deposit [%s] is reserved: [%v]", + depositKeyStr, + err, + ) + continue + } + if isReserved { + taskLogger.Infof("skipping reserved deposit [%s]", depositKeyStr) + continue + } + taskLogger.Debugf("getting details of deposit [%s]", depositKeyStr) depositRequest, found, err := chain.GetDepositRequest( diff --git a/pkg/tbtcpg/deposit_sweep_test.go b/pkg/tbtcpg/deposit_sweep_test.go index dbfd9e24ea..544fe0e08f 100644 --- a/pkg/tbtcpg/deposit_sweep_test.go +++ b/pkg/tbtcpg/deposit_sweep_test.go @@ -1108,3 +1108,82 @@ func TestFindDepositsToSweep_VaultGrouping(t *testing.T) { } }) } + +// TestFindDepositsToSweep_ExcludesReservedDeposits verifies that findDeposits +// skips deposits IsReservedDeposit reports as reserved, so a wallet's +// reservation-vault deposits never starve its ordinary deposits of +// sweeping by winning the largest-group selection in FindDepositsToSweep. +func TestFindDepositsToSweep_ExcludesReservedDeposits(t *testing.T) { + currentBlock := uint64(300000) + filterStartBlock := currentBlock - tbtcpg.DepositSweepLookBackBlocks + walletPublicKeyHash := hexToByte20( + "7670343fc00ccc2d0cd65360e6ad400697ea0fed", + ) + + tbtcChain := tbtcpg.NewLocalChain() + btcChain := tbtcpg.NewLocalBitcoinChain() + + blockCounter := tbtcpg.NewMockBlockCounter() + blockCounter.SetCurrentBlock(currentBlock) + tbtcChain.SetBlockCounter(blockCounter) + tbtcChain.SetDepositMinAge(3600) + + // 1 ordinary (non-reserved) deposit. + ordinaryHash := setupVaultGroupingDeposit( + t, tbtcChain, btcChain, walletPublicKeyHash, filterStartBlock, + "6666666666666666666666666666666666666666666666666666666666666666", + 0, 290000, nil, + ) + + // 2 reservation-vault deposits: without exclusion this would be the + // larger group and would starve the ordinary deposit above. + reservedHash1 := setupVaultGroupingDeposit( + t, tbtcChain, btcChain, walletPublicKeyHash, filterStartBlock, + "7777777777777777777777777777777777777777777777777777777777777777", + 0, 290001, nil, + ) + reservedHash2 := setupVaultGroupingDeposit( + t, tbtcChain, btcChain, walletPublicKeyHash, filterStartBlock, + "8888888888888888888888888888888888888888888888888888888888888888", + 0, 290002, nil, + ) + + tbtcChain.SetReservedDeposit( + tbtcChain.BuildDepositKey(reservedHash1, 0), true, + ) + tbtcChain.SetReservedDeposit( + tbtcChain.BuildDepositKey(reservedHash2, 0), true, + ) + + task := tbtcpg.NewDepositSweepTask(tbtcChain, btcChain) + deposits, err := task.FindDepositsToSweep( + &testutils.MockLogger{}, + walletPublicKeyHash, + 10, + ) + if err != nil { + t.Fatal(err) + } + + if len(deposits) != 1 { + t.Fatalf( + "expected exactly 1 deposit (reserved ones excluded), got %d", + len(deposits), + ) + } + if deposits[0].FundingTxHash != ordinaryHash { + t.Errorf( + "expected the ordinary deposit %v, got %v", + ordinaryHash, + deposits[0].FundingTxHash, + ) + } + for _, d := range deposits { + if d.FundingTxHash == reservedHash1 || d.FundingTxHash == reservedHash2 { + t.Errorf( + "reserved deposit %v should have been excluded", + d.FundingTxHash, + ) + } + } +} diff --git a/pkg/tbtcpg/fee.go b/pkg/tbtcpg/fee.go index e6d33338a6..f165292a9b 100644 --- a/pkg/tbtcpg/fee.go +++ b/pkg/tbtcpg/fee.go @@ -5,6 +5,7 @@ import ( "fmt" "math" + "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/tbtc" ) @@ -196,3 +197,42 @@ func applyWalletTxFeeFloor( return totalFee, nil } + +// estimateReservationFixedSizeTxFee estimates the fee for a reservation +// transaction with a fixed virtual size. It mirrors the fee estimation +// logic in acceptance and re-anchor tasks, including fee flooring and +// max-fee clamping. exceedsMaxErrMsg is the caller-specific message used +// when the raw estimate already exceeds txMaxFee (acceptance and re-anchor +// use distinct action-named messages here, matching their pre-existing +// fixture expectations). +func estimateReservationFixedSizeTxFee( + btcChain bitcoin.Chain, + sizeEstimator *bitcoin.TransactionSizeEstimator, + txMaxFee uint64, + exceedsMaxErrMsg string, +) (int64, error) { + transactionSize, err := sizeEstimator.VirtualSize() + if err != nil { + return 0, fmt.Errorf( + "cannot estimate transaction virtual size: [%v]", + err, + ) + } + + feeEstimator := bitcoin.NewTransactionFeeEstimator(btcChain) + totalFee, err := feeEstimator.EstimateFee(transactionSize) + if err != nil { + return 0, fmt.Errorf("cannot estimate transaction fee: [%v]", err) + } + + if uint64(totalFee) > txMaxFee { + return 0, fmt.Errorf("%s", exceedsMaxErrMsg) + } + + totalFee, err = applyWalletTxFeeFloor(totalFee, transactionSize, txMaxFee) + if err != nil { + return 0, err + } + + return totalFee, nil +} diff --git a/pkg/tbtcpg/fee_test.go b/pkg/tbtcpg/fee_test.go index d6249dd23c..17db1aaa50 100644 --- a/pkg/tbtcpg/fee_test.go +++ b/pkg/tbtcpg/fee_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/tbtc" ) @@ -127,7 +128,120 @@ func TestApplyWalletTxFeeFloor(t *testing.T) { } } -// TestApplyWalletTxFeeFloor_BufferOverride verifies that the safety buffer +func TestEstimateReservationFixedSizeTxFee(t *testing.T) { + sizeEstimator := bitcoin.NewTransactionSizeEstimator(). + AddScriptHashInputs(1, DepositScriptByteSize, true). + AddPublicKeyHashOutputs(1, true) + + size, err := sizeEstimator.VirtualSize() + if err != nil { + t.Fatal(err) + } + + const ( + acceptanceErrMsg = "reservation acceptance estimated fee exceeds the maximum fee" + reanchorErrMsg = "reservation re-anchor estimated fee exceeds the maximum fee" + ) + + tests := map[string]struct { + estimateSatPerVByte int64 + txMaxFee uint64 + exceedsMaxErrMsg string + expectedFee int64 + expectErrorContains string + }{ + "low estimate is raised to the minimum floor": { + estimateSatPerVByte: 1, + txMaxFee: 100000, + exceedsMaxErrMsg: acceptanceErrMsg, + expectedFee: 5 * size, // max(5, ceil(1*1.25)=2)=5 sat/vByte * size + }, + "estimate above the floor is buffered by 25%": { + estimateSatPerVByte: 20, + txMaxFee: 100000, + exceedsMaxErrMsg: acceptanceErrMsg, + expectedFee: 25 * size, // ceil(20*1.25) = 25 sat/vByte * size + }, + "buffered estimate above the cap is bounded to the cap": { + estimateSatPerVByte: 20, + txMaxFee: uint64(22 * size), + exceedsMaxErrMsg: acceptanceErrMsg, + expectedFee: 22 * size, + }, + "raw estimate exactly equal to the cap is allowed and bounded": { + estimateSatPerVByte: 20, + txMaxFee: uint64(20 * size), + exceedsMaxErrMsg: acceptanceErrMsg, + expectedFee: 20 * size, + }, + "raw estimate 1 sat above the cap returns an error": { + estimateSatPerVByte: 20, + txMaxFee: uint64(20*size - 1), + exceedsMaxErrMsg: acceptanceErrMsg, + expectErrorContains: acceptanceErrMsg, + }, + "raw estimate above the cap returns acceptance error": { + estimateSatPerVByte: 30, + // The raw 30*size fee already exceeds the 10*size cap, so the raw-fee + // check must error before the minimum-floor logic runs. The returned + // error must match the exact exceedsMaxErrMsg passed by the caller. + txMaxFee: uint64(10 * size), + exceedsMaxErrMsg: acceptanceErrMsg, + expectErrorContains: acceptanceErrMsg, + }, + "raw estimate above the cap returns re-anchor error": { + estimateSatPerVByte: 30, + txMaxFee: uint64(10 * size), + exceedsMaxErrMsg: reanchorErrMsg, + expectErrorContains: reanchorErrMsg, + }, + "minimum floor above the cap returns an error": { + estimateSatPerVByte: 1, + // Cap sits below 5*size (the floor) but above the raw fee (1*size), + // so the minimum-fee check must error rather than lower the fee. + txMaxFee: uint64(3 * size), + exceedsMaxErrMsg: acceptanceErrMsg, + expectErrorContains: "minimum safe transaction fee", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + btcChain := NewLocalBitcoinChain() + btcChain.SetEstimateSatPerVByteFee(1, tc.estimateSatPerVByte) + + fee, err := estimateReservationFixedSizeTxFee( + btcChain, + sizeEstimator, + tc.txMaxFee, + tc.exceedsMaxErrMsg, + ) + + if tc.expectErrorContains != "" { + if err == nil { + t.Fatalf("expected an error, got fee [%d]", fee) + } + if !strings.Contains(err.Error(), tc.expectErrorContains) { + t.Fatalf( + "expected error containing [%s]; got [%v]", + tc.expectErrorContains, err, + ) + } + return + } + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + if fee != tc.expectedFee { + t.Errorf( + "unexpected fee\nexpected: [%d]\nactual: [%d]", + tc.expectedFee, fee, + ) + } + }) + } +} + // is driven by the canonical pkg/tbtc WalletTxFeeBufferPercent var, not // a hardcoded constant. A test that overrides the var MUST restore it // via t.Cleanup so other tests see the production defaults. diff --git a/pkg/tbtcpg/internal/test/marshaling.go b/pkg/tbtcpg/internal/test/marshaling.go index 57ee86bd88..faf6a69017 100644 --- a/pkg/tbtcpg/internal/test/marshaling.go +++ b/pkg/tbtcpg/internal/test/marshaling.go @@ -5,10 +5,11 @@ import ( "encoding/json" "errors" "fmt" - "github.com/keep-network/keep-core/pkg/tbtcpg" "math/big" "time" + "github.com/keep-network/keep-core/pkg/tbtcpg" + "github.com/keep-network/keep-core/internal/hexutils" "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/tbtc" @@ -374,3 +375,188 @@ func hexToSlice(hexString string) []byte { return bytes } + +// UnmarshalJSON implements a custom JSON unmarshaling logic to produce a +// proper ReservationReanchorTestScenario. +func (rrts *ReservationReanchorTestScenario) UnmarshalJSON(data []byte) error { + type reservationDataJSON struct { + ReservationKey string + WalletPublicKeyHash string + AnchorTxHash string + AnchorTxOutputIndex uint32 + AnchorValue int64 + State string + RequestNonce uint64 + HasPendingAction bool + PendingActionState string + } + type reservationReanchorTestScenarioJSON struct { + Title string + + SourceWalletPublicKeyHash string + SourceWalletState string + SourceWalletMainUtxoHash string + SourceWalletMainUtxoValue int64 + SourceWalletMainUtxoTxHash string + SourceWalletMainUtxoTxIndex uint32 + + TargetWalletPublicKeyHash string + + LiveWalletsCount uint32 + + MovingFundsDustThreshold uint64 + ReservationTxMaxFee uint64 + EstimateSatPerVByteFee int64 + ReanchorTxFee int64 + + Reservations []reservationDataJSON + + ExpectedProposal *reservationReanchorProposalJSON + ExpectedErr string + } + + var unmarshaled reservationReanchorTestScenarioJSON + + if err := json.Unmarshal(data, &unmarshaled); err != nil { + return err + } + + rrts.Title = unmarshaled.Title + + if len(unmarshaled.SourceWalletPublicKeyHash) > 0 { + copy(rrts.SourceWalletPublicKeyHash[:], hexToSlice(unmarshaled.SourceWalletPublicKeyHash)) + } + rrts.SourceWalletState = parseWalletState(unmarshaled.SourceWalletState) + if len(unmarshaled.SourceWalletMainUtxoHash) > 0 { + copy(rrts.SourceWalletMainUtxoHashBytes[:], hexToSlice(unmarshaled.SourceWalletMainUtxoHash)) + } else { + rrts.SourceWalletMainUtxoHashBytes = [32]byte{} + } + rrts.SourceWalletMainUtxoValue = unmarshaled.SourceWalletMainUtxoValue + rrts.SourceWalletMainUtxoTxHash = unmarshaled.SourceWalletMainUtxoTxHash + rrts.SourceWalletMainUtxoTxIndex = unmarshaled.SourceWalletMainUtxoTxIndex + + if len(unmarshaled.TargetWalletPublicKeyHash) > 0 { + copy(rrts.TargetWalletPublicKeyHash[:], hexToSlice(unmarshaled.TargetWalletPublicKeyHash)) + } + + rrts.LiveWalletsCount = unmarshaled.LiveWalletsCount + + rrts.MovingFundsDustThreshold = unmarshaled.MovingFundsDustThreshold + rrts.ReservationTxMaxFee = unmarshaled.ReservationTxMaxFee + rrts.EstimateSatPerVByteFee = unmarshaled.EstimateSatPerVByteFee + rrts.ReanchorTxFee = unmarshaled.ReanchorTxFee + + rrts.Reservations = make([]*ReservationReanchorData, 0, len(unmarshaled.Reservations)) + for _, r := range unmarshaled.Reservations { + d := &ReservationReanchorData{} + + if len(r.ReservationKey) > 0 { + keyBytes := hexToSlice(r.ReservationKey) + d.ReservationKey = new(big.Int).SetBytes(keyBytes) + } + if len(r.WalletPublicKeyHash) > 0 { + copy(d.WalletPublicKeyHash[:], hexToSlice(r.WalletPublicKeyHash)) + } + d.AnchorTxHash = r.AnchorTxHash + d.AnchorTxOutputIndex = r.AnchorTxOutputIndex + d.AnchorValue = r.AnchorValue + d.State = parseReservationState(r.State) + d.RequestNonce = r.RequestNonce + d.HasPendingAction = r.HasPendingAction + d.PendingActionState = parseReservationActionState(r.PendingActionState) + + rrts.Reservations = append(rrts.Reservations, d) + } + + if unmarshaled.ExpectedProposal != nil { + prop, err := unmarshaled.ExpectedProposal.convert() + if err != nil { + return fmt.Errorf( + "failed to convert expected reservation re-anchor proposal: [%w]", + err, + ) + } + rrts.ExpectedProposal = prop + } + + if len(unmarshaled.ExpectedErr) > 0 { + rrts.ExpectedErr = errors.New(unmarshaled.ExpectedErr) + } + + return nil +} + +type reservationReanchorProposalJSON struct { + ReservationKey string + RequestNonce uint64 + TargetWalletPublicKeyHash string + ReanchorTxFee int64 +} + +func (rj *reservationReanchorProposalJSON) convert() (*tbtc.ReservationReanchorProposal, error) { + if rj == nil { + return nil, nil + } + + result := &tbtc.ReservationReanchorProposal{ + RequestNonce: rj.RequestNonce, + ReanchorTxFee: big.NewInt(rj.ReanchorTxFee), + } + if len(rj.ReservationKey) > 0 { + result.ReservationKey = new(big.Int).SetBytes(hexToSlice(rj.ReservationKey)) + } + if len(rj.TargetWalletPublicKeyHash) > 0 { + copy(result.TargetWalletPublicKeyHash[:], hexToSlice(rj.TargetWalletPublicKeyHash)) + } + return result, nil +} + +func parseWalletState(s string) tbtc.WalletState { + switch s { + case "Live": + return tbtc.StateLive + case "MovingFunds": + return tbtc.StateMovingFunds + case "Closing": + return tbtc.StateClosing + case "Closed": + return tbtc.StateClosed + case "Terminated": + return tbtc.StateTerminated + default: + return tbtc.StateUnknown + } +} + +func parseReservationState(s string) tbtc.ReservationState { + switch s { + case "Active": + return tbtc.ReservationStateActive + case "ActionPending": + return tbtc.ReservationStateActionPending + case "Closed": + return tbtc.ReservationStateClosed + case "Stranded": + return tbtc.ReservationStateStranded + default: + return tbtc.ReservationStateUnknown + } +} + +func parseReservationActionState(s string) tbtc.ReservationActionState { + switch s { + case "Pending": + return tbtc.ReservationActionStatePending + case "Settled": + return tbtc.ReservationActionStateSettled + case "TimedOut": + return tbtc.ReservationActionStateTimedOut + case "Vetoed": + return tbtc.ReservationActionStateVetoed + case "Superseded": + return tbtc.ReservationActionStateSuperseded + default: + return tbtc.ReservationActionStateUnknown + } +} diff --git a/pkg/tbtcpg/internal/test/reservation_acceptance.go b/pkg/tbtcpg/internal/test/reservation_acceptance.go new file mode 100644 index 0000000000..9ce2d2f537 --- /dev/null +++ b/pkg/tbtcpg/internal/test/reservation_acceptance.go @@ -0,0 +1,370 @@ +package test + +import ( + "encoding/json" + "errors" + "fmt" + "math/big" + "time" + + "github.com/keep-network/keep-core/internal/hexutils" + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/tbtc" +) + +// reservationAcceptanceTestDataFilePrefix is the prefix shared by every +// reservation acceptance scenario file under testdata/. The loader walks +// the directory and matches files whose name starts with this prefix. +const reservationAcceptanceTestDataFilePrefix = "reservation_acceptance" + +// ReservedDepositScenario holds a single reserved deposit's data in a +// reservation acceptance test scenario, including unexported parsed copies +// populated by UnmarshalJSON for use by Materialize. +type ReservedDepositScenario struct { + FundingTxHash string + FundingOutputIndex uint32 + FundingTxConfirmations uint + FundingTxHex string + WalletPublicKeyHash string + Depositor string + BlindingFactor string + RefundPublicKeyHash string + RefundLocktime string + Amount uint64 + RevealBlock uint64 + Age int64 + SweptAt int64 + Vault string + + parsedFundingTxHash bitcoin.Hash + parsedFundingTx *bitcoin.Transaction +} + +// ReservationAcceptanceTestScenario represents one test scenario for the +// reservation acceptance proposal builder. It captures the on-chain state +// (chain parameters, reserved deposits, cap snapshot, wallet state) and the +// expected outcome (no proposal or a specific anchor proposal). +type ReservationAcceptanceTestScenario struct { + Title string + + ChainParameters struct { + AverageBlockTime time.Duration + CurrentBlock uint64 + DepositMinAge uint32 + } + + WalletPublicKeyHash [20]byte + + ReservationVault string + + WalletState string + + ReservationParameters struct { + ReservationMinAmount uint64 + ReservationTxMaxFee uint64 + ReservationMaxTotalAmount uint64 + ReservationTotalAmount uint64 + MaxReservationsPerWallet uint32 + } + + Caps struct { + MaxReservationsAmountPerWallet uint64 + ReservationMaxSingleAmount uint64 + } + + WalletCustody struct { + Count uint32 + Amount uint64 + } + + Global struct { + ActiveCount uint32 + MaxActive uint32 + } + + PendingReservedDeposits uint64 + + ReservedDeposits []*ReservedDepositScenario + + ExpectedAnchorProposal *tbtc.ReservationAnchorProposal + ExpectedErr error +} + +// reservationAnchorProposalScenario is the JSON-friendly representation of +// the expected anchor proposal. +type reservationAnchorProposalScenario struct { + DepositFundingTxHash string + DepositFundingOutputIndex uint32 + RequestNonce uint64 + AnchorTxFee int64 +} + +// convert builds a *tbtc.ReservationAnchorProposal from the scenario's +// JSON-friendly form. It returns nil when the scenario is nil. +func (ras *reservationAnchorProposalScenario) convert() *tbtc.ReservationAnchorProposal { + if ras == nil { + return nil + } + + fundingTxHash, err := bitcoin.NewHashFromString( + ras.DepositFundingTxHash, + bitcoin.ReversedByteOrder, + ) + if err != nil { + panic(fmt.Errorf( + "failed to parse anchor deposit funding tx hash: [%w]", + err, + )) + } + + return &tbtc.ReservationAnchorProposal{ + DepositFundingTxHash: fundingTxHash, + DepositFundingOutputIndex: ras.DepositFundingOutputIndex, + RequestNonce: ras.RequestNonce, + AnchorTxFee: big.NewInt(ras.AnchorTxFee), + } +} + +// LoadReservationAcceptanceTestScenario loads all scenarios related with +// reservation acceptance. The scenarios live in +// internal/test/testdata/reservation_acceptance_scenario_*.json. +func LoadReservationAcceptanceTestScenario() ( + []*ReservationAcceptanceTestScenario, + error, +) { + return loadTestScenarios[*ReservationAcceptanceTestScenario]( + reservationAcceptanceTestDataFilePrefix, + ) +} + +// UnmarshalJSON implements a custom JSON unmarshaling logic to produce a +// proper ReservationAcceptanceTestScenario. +func (rats *ReservationAcceptanceTestScenario) UnmarshalJSON( + data []byte, +) error { + type reservedDepositScenarioJSON struct { + FundingTxHash string + FundingOutputIndex uint32 + FundingTxConfirmations uint + FundingTxHex string + WalletPublicKeyHash string + Depositor string + BlindingFactor string + RefundPublicKeyHash string + RefundLocktime string + Amount uint64 + RevealBlock uint64 + Age int64 + SweptAt int64 + Vault string + } + + type scenario struct { + Title string + ChainParameters struct { + AverageBlockTime int64 + CurrentBlock uint64 + DepositMinAge uint32 + } + WalletPublicKeyHash string + ReservationVault string + Wallet struct { + State string + } + ReservationParameters struct { + ReservationMinAmount uint64 + ReservationTxMaxFee uint64 + ReservationMaxTotalAmount uint64 + ReservationTotalAmount uint64 + MaxReservationsPerWallet uint32 + } + Caps struct { + MaxReservationsAmountPerWallet uint64 + ReservationMaxSingleAmount uint64 + } + WalletCustody struct { + Count uint32 + Amount uint64 + } + Global struct { + ActiveCount uint32 + MaxActive uint32 + } + PendingReservedDeposits uint64 + ReservedDeposits []reservedDepositScenarioJSON + ExpectedAnchorProposal *reservationAnchorProposalScenario + ExpectedErr string + } + + bytesFromHex := func(str string) []byte { + value, err := hexutils.Decode(str) + if err != nil { + panic(err) + } + return value + } + + txFromHex := func(str string) *bitcoin.Transaction { + transaction := new(bitcoin.Transaction) + err := transaction.Deserialize(bytesFromHex(str)) + if err != nil { + panic(err) + } + return transaction + } + + var unmarshaled scenario + if err := json.Unmarshal(data, &unmarshaled); err != nil { + return err + } + + rats.Title = unmarshaled.Title + + rats.ChainParameters.AverageBlockTime = + time.Duration(unmarshaled.ChainParameters.AverageBlockTime) * time.Second + rats.ChainParameters.CurrentBlock = unmarshaled.ChainParameters.CurrentBlock + rats.ChainParameters.DepositMinAge = unmarshaled.ChainParameters.DepositMinAge + + if len(unmarshaled.WalletPublicKeyHash) > 0 { + walletBytes := hexToSlice(unmarshaled.WalletPublicKeyHash) + if len(walletBytes) != 20 { + return fmt.Errorf( + "wallet public key hash must be 20 bytes, got [%d]", + len(walletBytes), + ) + } + copy(rats.WalletPublicKeyHash[:], walletBytes) + } + + rats.ReservationVault = unmarshaled.ReservationVault + rats.WalletState = unmarshaled.Wallet.State + + rats.ReservationParameters.ReservationMinAmount = + unmarshaled.ReservationParameters.ReservationMinAmount + rats.ReservationParameters.ReservationTxMaxFee = + unmarshaled.ReservationParameters.ReservationTxMaxFee + rats.ReservationParameters.ReservationMaxTotalAmount = + unmarshaled.ReservationParameters.ReservationMaxTotalAmount + rats.ReservationParameters.ReservationTotalAmount = + unmarshaled.ReservationParameters.ReservationTotalAmount + rats.ReservationParameters.MaxReservationsPerWallet = + unmarshaled.ReservationParameters.MaxReservationsPerWallet + + rats.Caps.MaxReservationsAmountPerWallet = + unmarshaled.Caps.MaxReservationsAmountPerWallet + rats.Caps.ReservationMaxSingleAmount = + unmarshaled.Caps.ReservationMaxSingleAmount + + rats.WalletCustody.Count = unmarshaled.WalletCustody.Count + rats.WalletCustody.Amount = unmarshaled.WalletCustody.Amount + + rats.Global.ActiveCount = unmarshaled.Global.ActiveCount + rats.Global.MaxActive = unmarshaled.Global.MaxActive + + rats.PendingReservedDeposits = unmarshaled.PendingReservedDeposits + + now := time.Now() + + rats.ReservedDeposits = make([]*ReservedDepositScenario, 0) + for _, rd := range unmarshaled.ReservedDeposits { + fundingTxHash, err := bitcoin.NewHashFromString( + rd.FundingTxHash, + bitcoin.ReversedByteOrder, + ) + if err != nil { + return fmt.Errorf( + "failed to parse reserved deposit funding tx hash: [%w]", + err, + ) + } + + var fundingTx *bitcoin.Transaction + if len(rd.FundingTxHex) > 0 { + fundingTx = txFromHex(rd.FundingTxHex) + } + + rats.ReservedDeposits = append( + rats.ReservedDeposits, + &ReservedDepositScenario{ + FundingTxHash: rd.FundingTxHash, + FundingOutputIndex: rd.FundingOutputIndex, + FundingTxConfirmations: rd.FundingTxConfirmations, + FundingTxHex: rd.FundingTxHex, + WalletPublicKeyHash: rd.WalletPublicKeyHash, + Depositor: rd.Depositor, + BlindingFactor: rd.BlindingFactor, + RefundPublicKeyHash: rd.RefundPublicKeyHash, + RefundLocktime: rd.RefundLocktime, + Amount: rd.Amount, + RevealBlock: rd.RevealBlock, + Age: rd.Age, + SweptAt: rd.SweptAt, + Vault: rd.Vault, + parsedFundingTxHash: fundingTxHash, + parsedFundingTx: fundingTx, + }, + ) + } + + rats.ExpectedAnchorProposal = unmarshaled.ExpectedAnchorProposal.convert() + + if len(unmarshaled.ExpectedErr) > 0 { + 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 bitcoin.Hash + FundingOutputIndex uint32 + FundingTx *bitcoin.Transaction + WalletPublicKeyHash [20]byte + RevealBlock uint64 + RevealedAt time.Time + SweptAt time.Time + Amount uint64 + Vault *chain.Address +} + +// Materialize converts a scenario row into a fully-typed ReservedDeposit +// the test driver can wire into the local chain. +func (rds *ReservedDepositScenario) Materialize() (*ReservedDeposit, error) { + if rds == nil { + return nil, fmt.Errorf("nil scenario deposit") + } + + if rds.parsedFundingTxHash == (bitcoin.Hash{}) { + return nil, fmt.Errorf("scenario not yet unmarshaled") + } + + var walletHash [20]byte + if len(rds.WalletPublicKeyHash) > 0 { + copy(walletHash[:], hexToSlice(rds.WalletPublicKeyHash)) + } + + var vault *chain.Address + if len(rds.Vault) > 0 { + addr := chain.Address(rds.Vault) + vault = &addr + } + + age := time.Duration(rds.Age) * time.Second + revealedAt := time.Now().Add(-age) + + return &ReservedDeposit{ + FundingTxHash: rds.parsedFundingTxHash, + FundingOutputIndex: rds.FundingOutputIndex, + FundingTx: rds.parsedFundingTx, + WalletPublicKeyHash: walletHash, + RevealBlock: rds.RevealBlock, + RevealedAt: revealedAt, + SweptAt: time.Unix(rds.SweptAt, 0), + Amount: rds.Amount, + Vault: vault, + }, nil +} diff --git a/pkg/tbtcpg/internal/test/tbtcpgtest.go b/pkg/tbtcpg/internal/test/tbtcpgtest.go index b03cc40468..19d2ced166 100644 --- a/pkg/tbtcpg/internal/test/tbtcpgtest.go +++ b/pkg/tbtcpg/internal/test/tbtcpgtest.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "io/fs" + "math/big" "os" "path/filepath" "runtime" @@ -21,6 +22,7 @@ const ( findDepositsToSweepTestDataFilePrefix = "find_deposits" proposeDepositsSweepTestDataFilePrefix = "propose_sweep" findPendingRedemptionsTestDataFilePrefix = "find_pending_redemptions" + reservationReanchorTestDataFilePrefix = "reservation_reanchor" ) // Deposit holds the deposit data in the given test scenario. @@ -144,6 +146,58 @@ func LoadFindPendingRedemptionsTestScenario() ( ) } +// ReservationReanchorData holds the per-reservation data in a reservation +// re-anchor test scenario. +type ReservationReanchorData struct { + ReservationKey *big.Int + WalletPublicKeyHash [20]byte + AnchorTxHash string + AnchorTxOutputIndex uint32 + AnchorValue int64 + State tbtc.ReservationState + RequestNonce uint64 + HasPendingAction bool + PendingActionState tbtc.ReservationActionState +} + +// ReservationReanchorTestScenario represents a test scenario of preparing a +// reservation re-anchor proposal. +type ReservationReanchorTestScenario struct { + Title string + + SourceWalletPublicKeyHash [20]byte + SourceWalletState tbtc.WalletState + SourceWalletMainUtxoHashBytes [32]byte + SourceWalletMainUtxoValue int64 + SourceWalletMainUtxoTxHash string + SourceWalletMainUtxoTxIndex uint32 + + TargetWalletPublicKeyHash [20]byte + + LiveWalletsCount uint32 + + MovingFundsDustThreshold uint64 + ReservationTxMaxFee uint64 + EstimateSatPerVByteFee int64 + ReanchorTxFee int64 + + Reservations []*ReservationReanchorData + + ExpectedProposal *tbtc.ReservationReanchorProposal + ExpectedErr error +} + +// LoadReservationReanchorTestScenario loads all scenarios related to +// reservation re-anchor proposals. +func LoadReservationReanchorTestScenario() ( + []*ReservationReanchorTestScenario, + error, +) { + return loadTestScenarios[*ReservationReanchorTestScenario]( + reservationReanchorTestDataFilePrefix, + ) +} + func loadTestScenarios[T json.Unmarshaler](testDataFilePrefix string) ([]T, error) { filePaths, err := detectTestDataFiles(testDataFilePrefix) if err != nil { diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_0.json b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_0.json new file mode 100644 index 0000000000..40231251b1 --- /dev/null +++ b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_0.json @@ -0,0 +1,57 @@ +{ + "Title": "happy path - one reserved deposit eligible for acceptance", + "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": "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f00", + "FundingOutputIndex": 0, + "FundingTxConfirmations": 6, + "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", + "Depositor": "934b98637ca318a4d6e7ca6ffd1690b8e77df637", + "BlindingFactor": "f9f0c90d00039523", + "RefundPublicKeyHash": "e257eccafbc07c381642ce6e7e55120fb077fbed", + "RefundLocktime": "e0250162", + "Amount": 2000000, + "RevealBlock": 290000, + "Age": 7200, + "SweptAt": 0, + "Vault": "0xReservationVaultAddress1234567890abcdef12345678" + } + ], + "ExpectedAnchorProposal": { + "DepositFundingTxHash": "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f00", + "DepositFundingOutputIndex": 0, + "RequestNonce": 1, + "AnchorTxFee": 710 + }, + "ExpectedErr": "" +} diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_1.json b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_1.json new file mode 100644 index 0000000000..464de89f3a --- /dev/null +++ b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_1.json @@ -0,0 +1,52 @@ +{ + "Title": "cap rejection - wallet already at max active reservations count", + "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": 1, + "Amount": 2000000 + }, + "Global": { + "ActiveCount": 100, + "MaxActive": 100 + }, + "PendingReservedDeposits": 0, + "ReservedDeposits": [ + { + "FundingTxHash": "b1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f00", + "FundingOutputIndex": 0, + "FundingTxConfirmations": 6, + "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", + "Depositor": "934b98637ca318a4d6e7ca6ffd1690b8e77df637", + "BlindingFactor": "f9f0c90d00039524", + "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_2.json b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_2.json new file mode 100644 index 0000000000..0dc78d7711 --- /dev/null +++ b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_2.json @@ -0,0 +1,52 @@ +{ + "Title": "below-min rejection - deposit amount below ReservationMinAmount", + "ChainParameters": { + "AverageBlockTime": 12, + "CurrentBlock": 300000, + "DepositMinAge": 3600 + }, + "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", + "ReservationVault": "0xReservationVaultAddress1234567890abcdef12345678", + "Wallet": { + "State": "Live" + }, + "ReservationParameters": { + "ReservationMinAmount": 1000000, + "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": "c1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f00", + "FundingOutputIndex": 0, + "FundingTxConfirmations": 6, + "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", + "Depositor": "934b98637ca318a4d6e7ca6ffd1690b8e77df637", + "BlindingFactor": "f9f0c90d00039525", + "RefundPublicKeyHash": "e257eccafbc07c381642ce6e7e55120fb077fbed", + "RefundLocktime": "e0250162", + "Amount": 500000, + "RevealBlock": 290000, + "Age": 7200, + "SweptAt": 0, + "Vault": "0xReservationVaultAddress1234567890abcdef12345678" + } + ], + "ExpectedAnchorProposal": null, + "ExpectedErr": "" +} diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_3.json b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_3.json new file mode 100644 index 0000000000..6da58123e9 --- /dev/null +++ b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_3.json @@ -0,0 +1,52 @@ +{ + "Title": "stale-deposit rejection - wallet state is Closing, not Live", + "ChainParameters": { + "AverageBlockTime": 12, + "CurrentBlock": 300000, + "DepositMinAge": 3600 + }, + "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", + "ReservationVault": "0xReservationVaultAddress1234567890abcdef12345678", + "Wallet": { + "State": "Closing" + }, + "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": "d1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f00", + "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_4.json b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_4.json new file mode 100644 index 0000000000..53d9a51bc9 --- /dev/null +++ b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_4.json @@ -0,0 +1,52 @@ +{ + "Title": "cap rejection - wallet already at max reservations per wallet", + "ChainParameters": { + "AverageBlockTime": 12, + "CurrentBlock": 300000, + "DepositMinAge": 3600 + }, + "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", + "ReservationVault": "0xReservationVaultAddress1234567890abcdef12345678", + "Wallet": { + "State": "Live" + }, + "ReservationParameters": { + "ReservationMinAmount": 100000, + "ReservationTxMaxFee": 5000, + "ReservationMaxTotalAmount": 100000000, + "ReservationTotalAmount": 0, + "MaxReservationsPerWallet": 3 + }, + "Caps": { + "MaxReservationsAmountPerWallet": 50000000, + "ReservationMaxSingleAmount": 5000000 + }, + "WalletCustody": { + "Count": 3, + "Amount": 2000000 + }, + "Global": { + "ActiveCount": 0, + "MaxActive": 100 + }, + "PendingReservedDeposits": 0, + "ReservedDeposits": [ + { + "FundingTxHash": "e1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f00", + "FundingOutputIndex": 0, + "FundingTxConfirmations": 6, + "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", + "Depositor": "934b98637ca318a4d6e7ca6ffd1690b8e77df637", + "BlindingFactor": "f9f0c90d00039524", + "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_5.json b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_5.json new file mode 100644 index 0000000000..3f628af58b --- /dev/null +++ b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_5.json @@ -0,0 +1,52 @@ +{ + "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 new file mode 100644 index 0000000000..0692e7b9d2 --- /dev/null +++ b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_6.json @@ -0,0 +1,52 @@ +{ + "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 new file mode 100644 index 0000000000..be65953866 --- /dev/null +++ b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_7.json @@ -0,0 +1,52 @@ +{ + "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 new file mode 100644 index 0000000000..a9da9a8972 --- /dev/null +++ b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_8.json @@ -0,0 +1,72 @@ +{ + "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": "f9f0c90d00039528", + "RefundPublicKeyHash": "e257eccafbc07c381642ce6e7e55120fb077fbed", + "RefundLocktime": "e0250162", + "Amount": 50000, + "RevealBlock": 289000, + "Age": 7200, + "SweptAt": 0, + "Vault": "0xReservationVaultAddress1234567890abcdef12345678" + }, + { + "FundingTxHash": "c2c2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f02", + "FundingOutputIndex": 0, + "FundingTxConfirmations": 6, + "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", + "Depositor": "934b98637ca318a4d6e7ca6ffd1690b8e77df637", + "BlindingFactor": "f9f0c90d00039529", + "RefundPublicKeyHash": "e257eccafbc07c381642ce6e7e55120fb077fbed", + "RefundLocktime": "e0250162", + "Amount": 2000000, + "RevealBlock": 290000, + "Age": 7200, + "SweptAt": 0, + "Vault": "0xReservationVaultAddress1234567890abcdef12345678" + } + ], + "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 new file mode 100644 index 0000000000..46056b83ed --- /dev/null +++ b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_0.json @@ -0,0 +1,34 @@ +{ + "Title": "wallet-migration trigger: emits re-anchor proposal for first active reservation", + "SourceWalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", + "SourceWalletState": "MovingFunds", + "SourceWalletMainUtxoHash": "1111111111111111111111111111111111111111111111111111111111111111", + "SourceWalletMainUtxoValue": 500000, + "SourceWalletMainUtxoTxHash": "2222222222222222222222222222222222222222222222222222222222222222", + "SourceWalletMainUtxoTxIndex": 0, + "TargetWalletPublicKeyHash": "0x92a6ec889a8fa34f731e639edede4c75e184307c", + "LiveWalletsCount": 4, + "MovingFundsDustThreshold": 1000000, + "ReservationTxMaxFee": 100000, + "EstimateSatPerVByteFee": 1, + "ReanchorTxFee": 0, + "Reservations": [ + { + "ReservationKey": "0000000000000000000000000000000000000000000000000000000000aaaa01", + "WalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", + "AnchorTxHash": "3333333333333333333333333333333333333333333333333333333333333333", + "AnchorTxOutputIndex": 1, + "AnchorValue": 100000, + "State": "Active", + "RequestNonce": 0, + "HasPendingAction": false, + "PendingActionState": "Unknown" + } + ], + "ExpectedProposal": { + "ReservationKey": "0xaaaa01", + "RequestNonce": 1, + "TargetWalletPublicKeyHash": "0x92a6ec889a8fa34f731e639edede4c75e184307c", + "ReanchorTxFee": 550 + } +} diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_1.json b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_1.json new file mode 100644 index 0000000000..408618759a --- /dev/null +++ b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_1.json @@ -0,0 +1,29 @@ +{ + "Title": "M-27: Live wallet without main UTXO is not eligible for re-anchor (privileged-caller gate removed)", + "SourceWalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", + "SourceWalletState": "Live", + "SourceWalletMainUtxoHash": "0000000000000000000000000000000000000000000000000000000000000000", + "SourceWalletMainUtxoValue": 0, + "SourceWalletMainUtxoTxHash": "0000000000000000000000000000000000000000000000000000000000000000", + "SourceWalletMainUtxoTxIndex": 0, + "TargetWalletPublicKeyHash": "0x92a6ec889a8fa34f731e639edede4c75e184307c", + "LiveWalletsCount": 4, + "MovingFundsDustThreshold": 1000000, + "ReservationTxMaxFee": 100000, + "EstimateSatPerVByteFee": 1, + "ReanchorTxFee": 0, + "Reservations": [ + { + "ReservationKey": "0000000000000000000000000000000000000000000000000000000000bbbb02", + "WalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", + "AnchorTxHash": "6666666666666666666666666666666666666666666666666666666666666666", + "AnchorTxOutputIndex": 1, + "AnchorValue": 200000, + "State": "Active", + "RequestNonce": 5, + "HasPendingAction": false, + "PendingActionState": "Unknown" + } + ], + "ExpectedProposal": null +} diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_2.json b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_2.json new file mode 100644 index 0000000000..94d2fd837e --- /dev/null +++ b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_2.json @@ -0,0 +1,28 @@ +{ + "Title": "cap rejection: estimated re-anchor fee exceeds ReservationTxMaxFee", + "SourceWalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", + "SourceWalletState": "MovingFunds", + "SourceWalletMainUtxoHash": "7777777777777777777777777777777777777777777777777777777777777777", + "SourceWalletMainUtxoValue": 500000, + "SourceWalletMainUtxoTxHash": "8888888888888888888888888888888888888888888888888888888888888888", + "SourceWalletMainUtxoTxIndex": 0, + "TargetWalletPublicKeyHash": "0x92a6ec889a8fa34f731e639edede4c75e184307c", + "LiveWalletsCount": 4, + "MovingFundsDustThreshold": 1000000, + "ReservationTxMaxFee": 100, + "EstimateSatPerVByteFee": 25, + "ReanchorTxFee": 0, + "Reservations": [ + { + "ReservationKey": "0000000000000000000000000000000000000000000000000000000000cccc03", + "WalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", + "AnchorTxHash": "9999999999999999999999999999999999999999999999999999999999999999", + "AnchorTxOutputIndex": 1, + "AnchorValue": 500000, + "State": "Active", + "RequestNonce": 0, + "HasPendingAction": false, + "PendingActionState": "Unknown" + } + ] +} diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_3.json b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_3.json new file mode 100644 index 0000000000..f78f5252cb --- /dev/null +++ b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_3.json @@ -0,0 +1,28 @@ +{ + "Title": "action-already-pending: pending action suppresses re-anchor, no proposal emitted", + "SourceWalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", + "SourceWalletState": "MovingFunds", + "SourceWalletMainUtxoHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "SourceWalletMainUtxoValue": 500000, + "SourceWalletMainUtxoTxHash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "SourceWalletMainUtxoTxIndex": 0, + "TargetWalletPublicKeyHash": "0x92a6ec889a8fa34f731e639edede4c75e184307c", + "LiveWalletsCount": 4, + "MovingFundsDustThreshold": 1000000, + "ReservationTxMaxFee": 100000, + "EstimateSatPerVByteFee": 1, + "ReanchorTxFee": 0, + "Reservations": [ + { + "ReservationKey": "0000000000000000000000000000000000000000000000000000000000dddd04", + "WalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", + "AnchorTxHash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "AnchorTxOutputIndex": 1, + "AnchorValue": 200000, + "State": "Active", + "RequestNonce": 4, + "HasPendingAction": true, + "PendingActionState": "Pending" + } + ] +} diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_4.json b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_4.json new file mode 100644 index 0000000000..39d47f88d7 --- /dev/null +++ b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_4.json @@ -0,0 +1,28 @@ +{ + "Title": "cap rejection: minimum safe re-anchor fee exceeds ReservationTxMaxFee", + "SourceWalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", + "SourceWalletState": "MovingFunds", + "SourceWalletMainUtxoHash": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "SourceWalletMainUtxoValue": 500000, + "SourceWalletMainUtxoTxHash": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "SourceWalletMainUtxoTxIndex": 0, + "TargetWalletPublicKeyHash": "0x92a6ec889a8fa34f731e639edede4c75e184307c", + "LiveWalletsCount": 4, + "MovingFundsDustThreshold": 1000000, + "ReservationTxMaxFee": 500, + "EstimateSatPerVByteFee": 1, + "ReanchorTxFee": 0, + "Reservations": [ + { + "ReservationKey": "0000000000000000000000000000000000000000000000000000000000eeee05", + "WalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", + "AnchorTxHash": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "AnchorTxOutputIndex": 1, + "AnchorValue": 500000, + "State": "Active", + "RequestNonce": 0, + "HasPendingAction": false, + "PendingActionState": "Unknown" + } + ] +} diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_5.json b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_5.json new file mode 100644 index 0000000000..97f2d3aa1c --- /dev/null +++ b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_5.json @@ -0,0 +1,30 @@ +{ + "Title": "no-op: no live wallets available for re-anchor target", + "SourceWalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", + "SourceWalletState": "MovingFunds", + "SourceWalletMainUtxoHash": "1111111111111111111111111111111111111111111111111111111111111111", + "SourceWalletMainUtxoValue": 500000, + "SourceWalletMainUtxoTxHash": "2222222222222222222222222222222222222222222222222222222222222222", + "SourceWalletMainUtxoTxIndex": 0, + "TargetWalletPublicKeyHash": "", + "LiveWalletsCount": 0, + "MovingFundsDustThreshold": 1000000, + "ReservationTxMaxFee": 100000, + "EstimateSatPerVByteFee": 1, + "ReanchorTxFee": 0, + "Reservations": [ + { + "ReservationKey": "0000000000000000000000000000000000000000000000000000000000ffff06", + "WalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", + "AnchorTxHash": "3333333333333333333333333333333333333333333333333333333333333333", + "AnchorTxOutputIndex": 1, + "AnchorValue": 500000, + "State": "Active", + "RequestNonce": 0, + "HasPendingAction": false, + "PendingActionState": "Unknown" + } + ], + "ExpectedProposal": null, + "ExpectedErr": "" +} diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_6.json b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_6.json new file mode 100644 index 0000000000..2b02ea0d68 --- /dev/null +++ b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_6.json @@ -0,0 +1,18 @@ +{ + "Title": "no-op: wallet has no reservations to re-anchor", + "SourceWalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", + "SourceWalletState": "MovingFunds", + "SourceWalletMainUtxoHash": "4444444444444444444444444444444444444444444444444444444444444444", + "SourceWalletMainUtxoValue": 500000, + "SourceWalletMainUtxoTxHash": "5555555555555555555555555555555555555555555555555555555555555555", + "SourceWalletMainUtxoTxIndex": 0, + "TargetWalletPublicKeyHash": "", + "LiveWalletsCount": 4, + "MovingFundsDustThreshold": 1000000, + "ReservationTxMaxFee": 100000, + "EstimateSatPerVByteFee": 1, + "ReanchorTxFee": 0, + "Reservations": [], + "ExpectedProposal": null, + "ExpectedErr": "" +} diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_7.json b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_7.json new file mode 100644 index 0000000000..808358b1b1 --- /dev/null +++ b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_7.json @@ -0,0 +1,30 @@ +{ + "Title": "skip: reservation not in Active state is excluded from re-anchor", + "SourceWalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", + "SourceWalletState": "MovingFunds", + "SourceWalletMainUtxoHash": "6666666666666666666666666666666666666666666666666666666666666666", + "SourceWalletMainUtxoValue": 500000, + "SourceWalletMainUtxoTxHash": "7777777777777777777777777777777777777777777777777777777777777777", + "SourceWalletMainUtxoTxIndex": 0, + "TargetWalletPublicKeyHash": "", + "LiveWalletsCount": 4, + "MovingFundsDustThreshold": 1000000, + "ReservationTxMaxFee": 100000, + "EstimateSatPerVByteFee": 1, + "ReanchorTxFee": 0, + "Reservations": [ + { + "ReservationKey": "0000000000000000000000000000000000000000000000000000000000111107", + "WalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", + "AnchorTxHash": "8888888888888888888888888888888888888888888888888888888888888888", + "AnchorTxOutputIndex": 1, + "AnchorValue": 500000, + "State": "Stranded", + "RequestNonce": 0, + "HasPendingAction": false, + "PendingActionState": "Unknown" + } + ], + "ExpectedProposal": null, + "ExpectedErr": "" +} diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_8.json b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_8.json new file mode 100644 index 0000000000..efa8535f27 --- /dev/null +++ b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_8.json @@ -0,0 +1,34 @@ +{ + "Title": "findTargetWallet: source wallet is excluded from targets", + "SourceWalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", + "SourceWalletState": "MovingFunds", + "SourceWalletMainUtxoHash": "1111111111111111111111111111111111111111111111111111111111111111", + "SourceWalletMainUtxoValue": 500000, + "SourceWalletMainUtxoTxHash": "2222222222222222222222222222222222222222222222222222222222222222", + "SourceWalletMainUtxoTxIndex": 0, + "TargetWalletPublicKeyHash": "0x92a6ec889a8fa34f731e639edede4c75e184307c", + "LiveWalletsCount": 4, + "MovingFundsDustThreshold": 1000000, + "ReservationTxMaxFee": 100000, + "EstimateSatPerVByteFee": 1, + "ReanchorTxFee": 0, + "Reservations": [ + { + "ReservationKey": "0000000000000000000000000000000000000000000000000000000000aaaa01", + "WalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", + "AnchorTxHash": "3333333333333333333333333333333333333333333333333333333333333333", + "AnchorTxOutputIndex": 1, + "AnchorValue": 100000, + "State": "Active", + "RequestNonce": 0, + "HasPendingAction": false, + "PendingActionState": "Unknown" + } + ], + "ExpectedProposal": { + "ReservationKey": "0xaaaa01", + "RequestNonce": 1, + "TargetWalletPublicKeyHash": "0x92a6ec889a8fa34f731e639edede4c75e184307c", + "ReanchorTxFee": 550 + } +} diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_9.json b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_9.json new file mode 100644 index 0000000000..ac3f5f4020 --- /dev/null +++ b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_9.json @@ -0,0 +1,17 @@ +{ + "Title": "eligibility gate: Live wallet above dust threshold, no proposal", + "SourceWalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", + "SourceWalletState": "Live", + "SourceWalletMainUtxoHash": "1111111111111111111111111111111111111111111111111111111111111111", + "SourceWalletMainUtxoValue": 2000000, + "SourceWalletMainUtxoTxHash": "2222222222222222222222222222222222222222222222222222222222222222", + "SourceWalletMainUtxoTxIndex": 0, + "TargetWalletPublicKeyHash": "0x0000000000000000000000000000000000000000", + "LiveWalletsCount": 4, + "MovingFundsDustThreshold": 1000000, + "ReservationTxMaxFee": 100000, + "EstimateSatPerVByteFee": 1, + "ReanchorTxFee": 0, + "Reservations": [], + "ExpectedProposal": null +} diff --git a/pkg/tbtcpg/reservation_acceptance.go b/pkg/tbtcpg/reservation_acceptance.go new file mode 100644 index 0000000000..3ae531d283 --- /dev/null +++ b/pkg/tbtcpg/reservation_acceptance.go @@ -0,0 +1,929 @@ +package tbtcpg + +import ( + "context" + "errors" + "fmt" + "math/big" + "sort" + "strings" + "sync" + "time" + + "github.com/ipfs/go-log/v2" + "go.uber.org/zap" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/chain" + "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) + +// zeroAddressHex is the Ethereum zero address as returned by the chain +// adapter's address converter (chain.Address(common.Address{}.String()) +// never produces an empty string, even for the zero address) -- used to +// detect an unconfigured reservation vault instead of comparing against "". +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. +type ReservationAcceptanceTask struct { + chain Chain + btcChain bitcoin.Chain + + // metricsRecorder is optional and used for recording performance + // metrics: active_reservations_count, max_active_reservations, and + // wallet_reservations_count, sourced from the chain calls this task + // already makes in findReservationAcceptanceCandidate. These are + // leading indicators of the reservation capacity saturation cliff. + 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 +} + +// NewReservationAcceptanceTask constructs a ReservationAcceptanceTask. +func NewReservationAcceptanceTask( + chain Chain, + btcChain bitcoin.Chain, +) *ReservationAcceptanceTask { + return &ReservationAcceptanceTask{ + chain: chain, + btcChain: btcChain, + scanState: make(map[[20]byte]*reservationAcceptanceScanState), + } +} + +// setMetricsRecorder sets the metrics recorder for the reservation +// acceptance task. +func (rat *ReservationAcceptanceTask) setMetricsRecorder(recorder interface { + SetGauge(name string, value float64) +}) { + 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. +const maxReservationAcceptanceCandidatesPerRun = 50 + +// 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 +} + +// 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. +func (rat *ReservationAcceptanceTask) Run(request *tbtc.CoordinationProposalRequest) ( + tbtc.CoordinationProposal, + bool, + error, +) { + walletPublicKeyHash := request.WalletPublicKeyHash + + taskLogger := logger.With( + zap.String("task", rat.ActionType().String()), + zap.String("walletPKH", fmt.Sprintf("0x%x", walletPublicKeyHash)), + ) + + skipDepositKeys := make(map[string]bool) + + for { + candidate, err := rat.findReservationAcceptanceCandidate( + taskLogger, + walletPublicKeyHash, + skipDepositKeys, + ) + if err != nil { + return nil, false, fmt.Errorf( + "cannot find reservation acceptance candidate: [%w]", + err, + ) + } + if candidate == nil { + taskLogger.Info("no reservation acceptance candidate") + return nil, false, nil + } + + proposal, shouldExecute, 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]", + err, + ) + } + + if proposal == nil { + return nil, shouldExecute, nil + } + return proposal, shouldExecute, nil + } +} + +// ActionType returns the wallet action type this task proposes. +func (rat *ReservationAcceptanceTask) ActionType() tbtc.WalletActionType { + return tbtc.ActionReservationAnchor +} + +// reservationAcceptanceCandidate is the bundle a candidate reserved deposit +// for acceptance carries through the proposal builder. It captures the +// deposit's reveal context, the derived request nonce, plus the on-chain cap +// snapshot taken at scan time. +type reservationAcceptanceCandidate struct { + DepositKey *big.Int + Deposit *tbtc.Deposit + FundingTx *bitcoin.Transaction + ReservationParameters *tbtc.ReservationParameters + TxMaxFee uint64 + RequestNonce uint64 + 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( + taskLogger log.StandardLogger, + walletPublicKeyHash [20]byte, + skipDepositKeys map[string]bool, +) (*reservationAcceptanceCandidate, error) { + if walletPublicKeyHash == [20]byte{} { + return nil, fmt.Errorf("wallet public key hash is required") + } + + reservationParameters, err := rat.chain.ReservationParameters() + if err != nil { + return nil, fmt.Errorf( + "failed to get reservation parameters: [%w]", + err, + ) + } + reservationVault := reservationParameters.ReservationVault + if reservationVault == "" || reservationVault == chain.Address(zeroAddressHex) { + taskLogger.Info("reservation vault not configured") + return nil, nil + } + + wallet, err := rat.chain.GetWallet(walletPublicKeyHash) + if err != nil { + taskLogger.Errorf( + "failed to load wallet chain data: [%v]", + err, + ) + return nil, nil + } + if wallet.State != tbtc.StateLive { + taskLogger.Infof( + "wallet is not live (state=%v); cannot accept reservation", + wallet.State, + ) + return nil, nil + } + + blockCounter, err := rat.chain.BlockCounter() + if err != nil { + return nil, fmt.Errorf("failed to get block counter: [%w]", err) + } + currentBlock, err := blockCounter.CurrentBlock() + if err != nil { + return nil, fmt.Errorf( + "failed to get current block: [%w]", + err, + ) + } + + maxReservationsAmountPerWallet, reservationMaxSingleAmount, err := + rat.chain.ReservationCaps() + if err != nil { + return nil, fmt.Errorf( + "failed to get reservation caps: [%w]", + err, + ) + } + + walletReservationsCount, err := rat.chain.WalletReservationsCount( + 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), + ) + } + + walletReservationsAmount, err := rat.chain.WalletReservationsAmount( + walletPublicKeyHash, + ) + if err != nil { + return nil, fmt.Errorf( + "failed to get wallet reservations amount: [%w]", + err, + ) + } + + 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), + ) + } + + depositMinAgeSeconds, err := rat.chain.GetDepositMinAge() + if err != nil { + return nil, fmt.Errorf( + "failed to get deposit minimum age: [%w]", + err, + ) + } + depositMinAge := time.Duration(depositMinAgeSeconds) * time.Second + + if uint64(reservationParameters.ReservationActionTimeout) <= uint64(depositMinAgeSeconds) { + taskLogger.Errorf( + "misconfiguration: ReservationActionTimeout [%d] <= DEPOSIT_MIN_AGE [%d]; "+ + "every reserved deposit will be marked stale before it can become "+ + "acceptance-eligible", + reservationParameters.ReservationActionTimeout, + depositMinAgeSeconds, + ) + } + + depositRevealedEvents, err := rat.depositRevealedEventsSince( + walletPublicKeyHash, + currentBlock, + ) + if err != nil { + return nil, err + } + + // Take the oldest first. + sort.SliceStable(depositRevealedEvents, func(i, j int) bool { + return depositRevealedEvents[i].BlockNumber < depositRevealedEvents[j].BlockNumber + }) + + now := time.Now() + + candidatesExamined := 0 + for _, event := range depositRevealedEvents { + if !depositTargetsReservationVault(event.Vault, reservationVault) { + 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++ + + depositKey := rat.chain.BuildDepositKey( + event.FundingTxHash, + event.FundingOutputIndex, + ) + + if skipDepositKeys[depositKey.Text(16)] { + continue + } + + 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 + } + + if depositRequest.Amount < reservationParameters.ReservationMinAmount { + taskLogger.Infof( + "reserved deposit [%v] amount [%d] below minimum [%d]; skipping", + depositKey, + depositRequest.Amount, + reservationParameters.ReservationMinAmount, + ) + 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 + } + + if !checkReservationAcceptanceEligibility( + taskLogger, + depositRequest, + walletReservationsCount, + walletReservationsAmount, + activeReservationsCount, + maxActiveReservations, + maxReservationsAmountPerWallet, + reservationMaxSingleAmount, + reservationParameters, + ) { + taskLogger.Infof("not eligible: [%v]", depositKey) + continue + } + + fundingTx, err := rat.btcChain.GetTransaction(event.FundingTxHash) + if err != nil { + taskLogger.Errorf( + "failed to get funding tx for reserved deposit [%v]: [%v]", + depositKey, + err, + ) + continue + } + + confirmations, err := rat.btcChain.GetTransactionConfirmations( + context.Background(), + event.FundingTxHash, + ) + if err != nil { + taskLogger.Errorf( + "failed to get funding tx confirmations for [%v]: [%v]", + depositKey, + err, + ) + continue + } + if confirmations < tbtc.DepositSweepRequiredFundingTxConfirmations { + taskLogger.Debugf( + "reserved deposit [%v] funding tx confirmations [%d/%d] below required", + depositKey, + confirmations, + tbtc.DepositSweepRequiredFundingTxConfirmations, + ) + continue + } + + // Determine RequestNonce and re-request eligibility from the + // reservation's own on-chain state, which authoritatively reflects + // whether a prior generation is still pending -- not from acceptance- + // requested event history, which would still show a first generation + // that has since timed out and become eligible for retry again. + var requestNonce uint64 = 1 + 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. Treating it as + // "assume not yet created" would skip both the eligible-state + // gate and the hasPendingAction gate below. Skip this deposit for + // the current coordination window instead; the next window + // retries (mirrors the fail-safe policy in hasPendingAction). + taskLogger.Errorf( + "cannot get reservation [%v], skipping deposit for this window: [%v]", + depositKey, + err, + ) + continue + } + + // "Not yet created" is derived only from a successful read: a zero + // record reports State == Unknown with RequestNonce == 0, in which + // case the predicted requestNonce of 1 (set above) already applies + // and the gates below do not apply. + if reservation != nil && + !(reservation.State == tbtc.ReservationStateUnknown && reservation.RequestNonce == 0) { + if reservation.State == tbtc.ReservationStateActive || + reservation.State == tbtc.ReservationStateActionPending || + reservation.State == tbtc.ReservationStateClosed || + reservation.State == tbtc.ReservationStateStranded { + taskLogger.Infof( + "reservation [%v] in non-eligible state [%v], skipping", + depositKey, + reservation.State, + ) + continue + } + + if hasPendingAction(depositKey, reservation, rat.chain, taskLogger) { + taskLogger.Infof( + "reservation [%v] has pending action, skipping", + depositKey, + ) + continue + } + + requestNonce = reservation.RequestNonce + 1 + } + + // Estimate the anchor fee and check net-of-fee viability here, as + // part of candidate selection, rather than after a single candidate + // has already been chosen. A candidate that fails this check is + // skipped in favor of the next one; nothing marks it retried, so + // leaving this check in proposeReservationAcceptance (which is + // called for exactly one already-selected candidate) would cause + // the same doomed deposit to be re-selected and abort on every + // subsequent Run() until it aged out of the look-back window. + anchorFee, err := estimateReservationAcceptanceFee( + rat.btcChain, + reservationParameters.ReservationTxMaxFee, + ) + if err != nil { + taskLogger.Errorf( + "failed to estimate reservation acceptance transaction fee for [%v]: [%v]", + depositKey, + err, + ) + continue + } + + anchorValue := int64(depositRequest.Amount) - anchorFee + if anchorValue <= 0 { + taskLogger.Infof( + "reserved deposit [%v] value [%d] does not cover anchor fee [%d]; skipping", + depositKey, + depositRequest.Amount, + anchorFee, + ) + continue + } + if uint64(anchorValue) < reservationParameters.ReservationMinAmount { + taskLogger.Infof( + "reserved deposit [%v] net-of-fee value [%d] below minimum [%d]; skipping", + depositKey, + anchorValue, + reservationParameters.ReservationMinAmount, + ) + continue + } + + taskLogger.Infof( + "selected reserved deposit [%v] for acceptance", + depositKey, + ) + + return &reservationAcceptanceCandidate{ + DepositKey: depositKey, + Deposit: &tbtc.Deposit{ + Utxo: &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: event.FundingTxHash, + OutputIndex: event.FundingOutputIndex, + }, + Value: int64(depositRequest.Amount), + }, + Depositor: depositRequest.Depositor, + BlindingFactor: event.BlindingFactor, + WalletPublicKeyHash: event.WalletPublicKeyHash, + RefundPublicKeyHash: event.RefundPublicKeyHash, + RefundLocktime: event.RefundLocktime, + Vault: depositRequest.Vault, + ExtraData: depositRequest.ExtraData, + }, + FundingTx: fundingTx, + ReservationParameters: reservationParameters, + TxMaxFee: reservationParameters.ReservationTxMaxFee, + RequestNonce: requestNonce, + AnchorFee: anchorFee, + }, nil + } + + return nil, nil +} + +// hasPendingAction reports whether the on-chain reservation action +// generation at the reservation's current request nonce (if any) is in a +// pending state. This guards against duplicate acceptance requests within +// findReservationAcceptanceCandidate: the Bridge rejects a new request +// while the previous generation is still in flight. The caller supplies +// the reservation record it already fetched rather than this function +// re-reading it. +func hasPendingAction( + reservationKey *big.Int, + reservation *tbtc.Reservation, + chain Chain, + taskLogger log.StandardLogger, +) bool { + if reservation.RequestNonce == 0 { + return false + } + + action, err := chain.GetReservationAction( + reservationKey, + 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 + // caller send a duplicate acceptance request that the Bridge + // rejects while a real pending generation is in flight. Skip this + // reservation for the current coordination window instead; the + // next window retries. + taskLogger.Errorf( + "cannot get reservation action for [0x%x] nonce [%d]: [%v]", + reservationKey, + reservation.RequestNonce, + err, + ) + return true + } + + return action.State == tbtc.ReservationActionStatePending +} + +func checkReservationAcceptanceEligibility( + taskLogger log.StandardLogger, + depositRequest *tbtc.DepositChainRequest, + walletReservationsCount uint32, + walletReservationsAmount uint64, + activeReservationsCount uint32, + maxActiveReservations uint32, + maxReservationsAmountPerWallet uint64, + reservationMaxSingleAmount uint64, + reservationParameters *tbtc.ReservationParameters, +) bool { + if reservationParameters.MaxReservationsPerWallet > 0 && + walletReservationsCount >= reservationParameters.MaxReservationsPerWallet { + taskLogger.Infof( + "wallet reservations count [%d] already at max [%d]", + walletReservationsCount, + reservationParameters.MaxReservationsPerWallet, + ) + return false + } + + if maxActiveReservations == 0 { + taskLogger.Errorf( + "active reservations cap (maxActiveReservations) not configured " + + "(is 0); failing closed rather than treating as unlimited", + ) + return false + } + if activeReservationsCount >= maxActiveReservations { + taskLogger.Infof( + "active reservations count [%d] already at max [%d]", + activeReservationsCount, + maxActiveReservations, + ) + return false + } + + if reservationMaxSingleAmount > 0 && + depositRequest.Amount > reservationMaxSingleAmount { + taskLogger.Infof( + "deposit amount [%d] exceeds reservation single cap [%d]", + depositRequest.Amount, + reservationMaxSingleAmount, + ) + return false + } + + newWalletTotal := walletReservationsAmount + depositRequest.Amount + if maxReservationsAmountPerWallet > 0 && + newWalletTotal > maxReservationsAmountPerWallet { + taskLogger.Infof( + "accepting would push wallet past aggregate cap "+ + "[current=%d, deposit=%d, cap=%d]", + walletReservationsAmount, + depositRequest.Amount, + maxReservationsAmountPerWallet, + ) + return false + } + + if reservationParameters.ReservationMaxTotalAmount == 0 { + taskLogger.Errorf( + "global reservation total amount cap (ReservationMaxTotalAmount) " + + "not configured (is 0); failing closed rather than treating as unlimited", + ) + return false + } + newTotal := reservationParameters.ReservationTotalAmount + + depositRequest.Amount + if newTotal > reservationParameters.ReservationMaxTotalAmount { + taskLogger.Infof( + "global reservation total would exceed cap "+ + "[current=%d, deposit=%d, cap=%d]", + reservationParameters.ReservationTotalAmount, + depositRequest.Amount, + reservationParameters.ReservationMaxTotalAmount, + ) + return false + } + + return true +} + +// reservationAcceptancePreWriteError wraps a proposeReservationAcceptance +// failure that occurred before any chain-state-mutating call (assemble or +// validate). The caller (Run) treats this as "this deposit is doomed" and +// skips it in favor of the next candidate instead of aborting the whole +// coordination window -- a failure after a write (RequestReservationAcceptance +// or the post-request GetReservation check) still aborts the window as +// before, since a partial on-chain effect may already exist. +type reservationAcceptancePreWriteError struct { + err error +} + +func (e *reservationAcceptancePreWriteError) Error() string { + return e.err.Error() +} + +func (e *reservationAcceptancePreWriteError) Unwrap() error { + return e.err +} + +func (rat *ReservationAcceptanceTask) proposeReservationAcceptance( + taskLogger log.StandardLogger, + walletPublicKeyHash [20]byte, + candidate *reservationAcceptanceCandidate, +) (*tbtc.ReservationAnchorProposal, bool, error) { + if candidate == nil || candidate.Deposit == nil { + return nil, false, 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. + anchorFee := candidate.AnchorFee + + taskLogger.Infof("anchor transaction fee: [%d]", anchorFee) + + reservationKey := rat.chain.BuildDepositKey( + candidate.Deposit.Utxo.Outpoint.TransactionHash, + candidate.Deposit.Utxo.Outpoint.OutputIndex, + ) + + feeBoundAction := &tbtc.ReservationAction{ + TxMaxFee: candidate.TxMaxFee, + } + + if _, err := tbtc.AssembleReservationAnchorTransaction( + rat.btcChain, + candidate.Deposit, + walletPublicKeyHash, + feeBoundAction, + anchorFee, + ); err != nil { + return nil, false, &reservationAcceptancePreWriteError{ + err: fmt.Errorf( + "cannot assemble reservation anchor transaction: [%v]", + err, + ), + } + } + + proposal := &tbtc.ReservationAnchorProposal{ + DepositFundingTxHash: candidate.Deposit.Utxo.Outpoint.TransactionHash, + DepositFundingOutputIndex: candidate.Deposit.Utxo.Outpoint.OutputIndex, + RequestNonce: candidate.RequestNonce, + AnchorTxFee: big.NewInt(anchorFee), + } + + taskLogger.Infof("validating the reservation anchor proposal") + + if err := rat.chain.ValidateReservationAnchorProposal( + walletPublicKeyHash, + proposal, + struct { + *tbtc.Deposit + FundingTx *bitcoin.Transaction + }{ + Deposit: candidate.Deposit, + FundingTx: candidate.FundingTx, + }, + ); err != nil { + return nil, false, &reservationAcceptancePreWriteError{ + err: fmt.Errorf( + "failed to verify reservation anchor proposal: %v", + err, + ), + } + } + + // RequestReservationAcceptance is called as a side effect of proposal + // generation itself (before coordination has agreed to anything), which + // is a known, accepted deviation from the read-only-during-generation + // pattern (also present in MovingFundsTask's SubmitMovingFundsCommitment). + // The guards against re-requesting acceptance for existing or pending + // reservations (checked in findReservationAcceptanceCandidate) are the + // primary mitigation for spurious repeat writes. + if err := rat.chain.RequestReservationAcceptance( + reservationKey, + walletPublicKeyHash, + ); err != nil { + return nil, false, fmt.Errorf("cannot request reservation acceptance: [%v]", err) + } + + updatedReservation, err := rat.chain.GetReservation(reservationKey) + if err != nil { + return nil, false, fmt.Errorf("cannot re-read reservation: [%v]", err) + } + if updatedReservation.RequestNonce != candidate.RequestNonce { + return nil, false, fmt.Errorf( + "reservation request nonce mismatch after request: predicted [%d], on-chain [%d]", + candidate.RequestNonce, + updatedReservation.RequestNonce, + ) + } + proposal.RequestNonce = updatedReservation.RequestNonce + + return proposal, true, nil +} + +func estimateReservationAcceptanceFee( + btcChain bitcoin.Chain, + txMaxFee uint64, +) (int64, error) { + sizeEstimator := bitcoin.NewTransactionSizeEstimator(). + AddScriptHashInputs(1, DepositScriptByteSize, true). + AddPublicKeyHashOutputs(1, true) + + return estimateReservationFixedSizeTxFee( + btcChain, + sizeEstimator, + txMaxFee, + "reservation acceptance estimated fee exceeds the maximum fee", + ) +} + +func depositTargetsReservationVault( + depositVault *chain.Address, + reservationVault chain.Address, +) bool { + if depositVault == nil { + return false + } + return strings.EqualFold( + string(*depositVault), + string(reservationVault), + ) +} diff --git a/pkg/tbtcpg/reservation_acceptance_metrics_test.go b/pkg/tbtcpg/reservation_acceptance_metrics_test.go new file mode 100644 index 0000000000..b781067545 --- /dev/null +++ b/pkg/tbtcpg/reservation_acceptance_metrics_test.go @@ -0,0 +1,106 @@ +package tbtcpg + +import ( + "math/big" + "testing" + + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/tbtc" +) + +// fakeMetricsRecorder captures SetGauge calls for assertion, distinguishing +// "never called" from "called with a zero value" - the pre-registered gauge +// default in the real PerformanceMetrics would make a value-only assertion +// pass even if the wiring were silently dropped. +type fakeMetricsRecorder struct { + calls map[string]float64 +} + +func newFakeMetricsRecorder() *fakeMetricsRecorder { + return &fakeMetricsRecorder{calls: make(map[string]float64)} +} + +func (f *fakeMetricsRecorder) SetGauge(name string, value float64) { + f.calls[name] = value +} + +// TestReservationAcceptanceTask_RecordsSaturationGauges is a regression +// test for the M-clientinfo saturation-monitoring gap: findDeposits already +// fetches wallet_reservations_count, active_reservations_count, and +// max_active_reservations from the chain, but nothing exposed them as +// metrics, so an operator could not see reservation capacity approaching +// its cap before acceptances silently stopped. +func TestReservationAcceptanceTask_RecordsSaturationGauges(t *testing.T) { + lc := NewLocalChain() + btcChain := NewLocalBitcoinChain() + + walletPublicKeyHash := [20]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20} + + lc.SetReservationParameters(tbtc.ReservationParameters{ + ReservationVault: chain.Address( + "0xReservationVaultAddress1234567890abcdef12345678", + ), + 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) + + // Non-zero wallet reservations count so the assertion below can + // 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) + + if _, _, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + }); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got, ok := recorder.calls["wallet_reservations_count"]; !ok { + t.Error("expected wallet_reservations_count gauge to be recorded") + } else if got != 2 { + t.Errorf("expected wallet_reservations_count = 2, got %v", got) + } + + if _, ok := recorder.calls["active_reservations_count"]; !ok { + t.Error("expected active_reservations_count gauge to be recorded") + } + if _, ok := recorder.calls["max_active_reservations"]; !ok { + t.Error("expected max_active_reservations gauge to be recorded") + } +} diff --git a/pkg/tbtcpg/reservation_acceptance_test.go b/pkg/tbtcpg/reservation_acceptance_test.go new file mode 100644 index 0000000000..0a71e184d9 --- /dev/null +++ b/pkg/tbtcpg/reservation_acceptance_test.go @@ -0,0 +1,2436 @@ +package tbtcpg_test + +import ( + "crypto/ecdsa" + "crypto/rand" + "crypto/sha256" + "fmt" + "math/big" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec" + "github.com/ipfs/go-log/v2" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/tbtc" + "github.com/keep-network/keep-core/pkg/tbtcpg" + "github.com/keep-network/keep-core/pkg/tbtcpg/internal/test" +) + +// testAnchorFeeSat is the estimated reservation acceptance anchor fee in sats. +// It is computed as minWalletTxSatPerVByteFee (5 sat/vByte) multiplied by the +// estimated anchor transaction vsize (142 vBytes) because the test fixture's +// 1 sat/vByte fee rate oracle response is clamped to the 5 sat/vByte floor by +// applyWalletTxFeeFloor (see fee.go). +const testAnchorFeeSat = uint64(710) + +// testReservationVaultAddress is the reservation vault address used across +// this file's fixtures, so a deposit's Vault field targets the same vault +// configured in ReservationParameters.ReservationVault. +const testReservationVaultAddress = chain.Address( + "0xReservationVaultAddress1234567890abcdef12345678", +) + +// reservationAcceptanceLocalChain is a test-only mock of tbtcpg.Chain that +// embeds the production LocalChain and adds reservation-specific behavior. +// It exists as a separate type so this test file does not need to edit the +// shared chain_test.go fixture used by sibling builders. +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 + pastDepositRevealedEventsErr error +} + +func newReservationAcceptanceLocalChain() *reservationAcceptanceLocalChain { + lc := tbtcpg.NewLocalChain() + return &reservationAcceptanceLocalChain{ + LocalChain: lc, + } +} + +// 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. +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 + } + return nil, err + } + return events, nil +} + +func (ralc *reservationAcceptanceLocalChain) ReservationCaps() ( + uint64, + uint64, + error, +) { + return ralc.maxPerWalletAmount, ralc.maxSingleAmount, nil +} + +func (ralc *reservationAcceptanceLocalChain) WalletReservationsAmount( + walletPublicKeyHash [20]byte, +) (uint64, error) { + return ralc.walletReservationsAmount, nil +} + +func (ralc *reservationAcceptanceLocalChain) WalletReservationsCount( + walletPublicKeyHash [20]byte, +) (uint32, error) { + return ralc.walletReservationsCount, nil +} + +func (ralc *reservationAcceptanceLocalChain) ActiveReservationsCount() ( + uint32, + uint32, + error, +) { + return ralc.activeCount, ralc.maxActive, nil +} + +func (ralc *reservationAcceptanceLocalChain) PendingReservedDeposits() ( + uint64, + error, +) { + return ralc.pendingReserved, nil +} + +func (ralc *reservationAcceptanceLocalChain) GetWallet( + walletPublicKeyHash [20]byte, +) (*tbtc.WalletChainData, error) { + if ralc.getWalletErr != nil { + return nil, ralc.getWalletErr + } + 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". +// +// The embedded LocalChain.GetReservation errors for a reservation key that +// was never registered via SetReservation, but the real chain adapter +// (pkg/chain/ethereum/tbtc.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. +func (ralc *reservationAcceptanceLocalChain) GetReservation( + reservationKey *big.Int, +) (*tbtc.Reservation, error) { + if ralc.getReservationErr != nil { + err := ralc.getReservationErr + ralc.getReservationErr = nil + return nil, err + } + reservation, err := ralc.LocalChain.GetReservation(reservationKey) + if err != nil { + if err.Error() == "reservation not found" { + return &tbtc.Reservation{State: tbtc.ReservationStateUnknown}, nil + } + return nil, err + } + 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. +func (ralc *reservationAcceptanceLocalChain) ValidateReservationAnchorProposal( + walletPublicKeyHash [20]byte, + proposal *tbtc.ReservationAnchorProposal, + depositExtraInfo struct { + *tbtc.Deposit + FundingTx *bitcoin.Transaction + }, +) error { + if ralc.validateErr != nil { + return ralc.validateErr + } + if depositExtraInfo.Deposit == nil || + depositExtraInfo.Deposit.Utxo == nil || + depositExtraInfo.Deposit.Utxo.Outpoint == nil { + return fmt.Errorf( + "validate reservation anchor proposal: missing deposit UTXO outpoint", + ) + } + outpoint := depositExtraInfo.Deposit.Utxo.Outpoint + if outpoint.TransactionHash != proposal.DepositFundingTxHash { + return fmt.Errorf( + "validate reservation anchor proposal: funding tx hash mismatch: "+ + "proposal=[%x] candidate=[%x]", + proposal.DepositFundingTxHash, + outpoint.TransactionHash, + ) + } + if outpoint.OutputIndex != proposal.DepositFundingOutputIndex { + return fmt.Errorf( + "validate reservation anchor proposal: funding output index mismatch: "+ + "proposal=[%d] candidate=[%d]", + proposal.DepositFundingOutputIndex, + outpoint.OutputIndex, + ) + } + return nil +} + +func (ralc *reservationAcceptanceLocalChain) PastReservationAcceptanceRequestedEvents( + filter *tbtc.ReservationAcceptanceRequestedEventFilter, +) ([]*tbtc.ReservationAcceptanceRequestedEvent, error) { + if ralc.acceptanceEventsErr != nil { + return nil, ralc.acceptanceEventsErr + } + 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) + } + return results, nil +} + +func (ralc *reservationAcceptanceLocalChain) AddPastReservationAcceptanceRequestedEvent( + event *tbtc.ReservationAcceptanceRequestedEvent, +) { + ralc.acceptanceEvents = append(ralc.acceptanceEvents, event) +} + +// scenarioReservationAcceptanceChain wires a scenario's on-chain state +// into the test mock chain. +func scenarioReservationAcceptanceChain( + t *testing.T, + scenario *test.ReservationAcceptanceTestScenario, +) *reservationAcceptanceLocalChain { + t.Helper() + + ralc := newReservationAcceptanceLocalChain() + + var reservationVault chain.Address + if len(scenario.ReservationVault) > 0 { + reservationVault = chain.Address(scenario.ReservationVault) + } + + ralc.SetReservationParameters(tbtc.ReservationParameters{ + ReservationVault: reservationVault, + ReservationMinAmount: scenario.ReservationParameters.ReservationMinAmount, + ReservationTxMaxFee: scenario.ReservationParameters.ReservationTxMaxFee, + ReservationMaxTotalAmount: scenario.ReservationParameters.ReservationMaxTotalAmount, + ReservationTotalAmount: scenario.ReservationParameters.ReservationTotalAmount, + MaxReservationsPerWallet: scenario.ReservationParameters.MaxReservationsPerWallet, + }) + + ralc.maxPerWalletAmount = scenario.Caps.MaxReservationsAmountPerWallet + ralc.maxSingleAmount = scenario.Caps.ReservationMaxSingleAmount + ralc.walletReservationsAmount = scenario.WalletCustody.Amount + ralc.walletReservationsCount = scenario.WalletCustody.Count + ralc.activeCount = scenario.Global.ActiveCount + ralc.maxActive = scenario.Global.MaxActive + ralc.pendingReserved = scenario.PendingReservedDeposits + + ralc.SetDepositMinAge(scenario.ChainParameters.DepositMinAge) + + blockCounter := tbtcpg.NewMockBlockCounter() + blockCounter.SetCurrentBlock(scenario.ChainParameters.CurrentBlock) + ralc.SetBlockCounter(blockCounter) + + // Map a WalletState string back to the tbtc constant. + var walletState tbtc.WalletState + switch scenario.WalletState { + case "Live": + walletState = tbtc.StateLive + case "Closing": + walletState = tbtc.StateClosing + case "Closed": + walletState = tbtc.StateClosed + case "Terminated": + walletState = tbtc.StateTerminated + default: + walletState = tbtc.StateLive + } + + ralc.SetWallet( + scenario.WalletPublicKeyHash, + &tbtc.WalletChainData{State: walletState}, + ) + + return ralc +} + +// 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. +func registerReservedDeposits( + t *testing.T, + scenario *test.ReservationAcceptanceTestScenario, + ralc *reservationAcceptanceLocalChain, + btcChain *tbtcpg.LocalBitcoinChain, +) { + 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. + btcChain.SetEstimateSatPerVByteFee(1, 1) + + filterStartBlock := uint64(0) + if scenario.ChainParameters.CurrentBlock > tbtcpg.ReservationAcceptanceLookBackBlocks { + filterStartBlock = scenario.ChainParameters.CurrentBlock - + tbtcpg.ReservationAcceptanceLookBackBlocks + } + + for _, rd := range scenario.ReservedDeposits { + materialized, err := rd.Materialize() + if err != nil { + t.Fatalf( + "failed to materialize reserved deposit scenario row: [%v]", + err, + ) + } + + ralc.SetDepositRequest( + materialized.FundingTxHash, + materialized.FundingOutputIndex, + &tbtc.DepositChainRequest{ + Depositor: chain.Address(rd.Depositor), + Amount: rd.Amount, + RevealedAt: materialized.RevealedAt, + SweptAt: materialized.SweptAt, + Vault: materialized.Vault, + }, + ) + + 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.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, + }, + ) + 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 +} + +// newBoundaryTestChain builds a reservationAcceptanceLocalChain with the +// reservation-parameters/caps/wallet/block-counter setup shared by most of +// this file's Run()-based tests: a live wallet at walletPublicKeyHash, a +// ReservationParameters of {vault: testReservationVaultAddress, minAmount: +// 1000, txMaxFee: 5000, maxPerWallet: 5}, per-wallet/single caps of +// 5000000, an active-reservations cap of 100, and a deposit minimum age of +// one hour. overrides, when non-nil, runs after these defaults so a call +// site can customize only what it varies (e.g. re-set ReservationParameters +// with different values, raise a cap, or inject an error field). +func newBoundaryTestChain( + t *testing.T, + walletPublicKeyHash [20]byte, + currentBlock uint64, + overrides func(ralc *reservationAcceptanceLocalChain), +) *reservationAcceptanceLocalChain { + t.Helper() + + ralc := newReservationAcceptanceLocalChain() + + ralc.SetReservationParameters(tbtc.ReservationParameters{ + ReservationVault: testReservationVaultAddress, + ReservationMinAmount: 1000, + ReservationTxMaxFee: 5000, + MaxReservationsPerWallet: 5, + ReservationMaxTotalAmount: 100000000, + }) + ralc.maxPerWalletAmount = 5000000 + ralc.maxSingleAmount = 5000000 + ralc.maxActive = 100 + + ralc.SetDepositMinAge(3600) + ralc.SetWallet( + walletPublicKeyHash, + &tbtc.WalletChainData{State: tbtc.StateLive}, + ) + + blockCounter := tbtcpg.NewMockBlockCounter() + blockCounter.SetCurrentBlock(currentBlock) + ralc.SetBlockCounter(blockCounter) + + if overrides != nil { + overrides(ralc) + } + + return ralc +} + +// uint64Ptr and uint32Ptr let a TestReservationAcceptanceTask_BoundaryChecks +// table row distinguish an explicit cap value of 0 (production's +// "unlimited" semantic for these caps) from the field's unset zero value. +func uint64Ptr(v uint64) *uint64 { return &v } +func uint32Ptr(v uint32) *uint32 { return &v } + +// expectedAnchorsEqual compares two proposal objects field-by-field. +// deep.Equal cannot be used for this: by default it does not descend into +// unexported fields, and *big.Int's representation is entirely unexported, +// so it silently reports "no difference" for any two distinct AnchorTxFee +// values. AnchorTxFee therefore needs an explicit .Cmp(). +func expectedAnchorsEqual( + expected, actual *tbtc.ReservationAnchorProposal, +) bool { + if expected == nil && actual == nil { + return true + } + if expected == nil || actual == nil { + return false + } + + if expected.DepositFundingTxHash != actual.DepositFundingTxHash { + return false + } + if expected.DepositFundingOutputIndex != actual.DepositFundingOutputIndex { + return false + } + if expected.RequestNonce != actual.RequestNonce { + return false + } + if expected.AnchorTxFee == nil || actual.AnchorTxFee == nil { + return expected.AnchorTxFee == actual.AnchorTxFee + } + 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, + ) + } +} + +func TestReservationAcceptanceTask_ActionType(t *testing.T) { + task := tbtcpg.NewReservationAcceptanceTask( + newReservationAcceptanceLocalChain(), + tbtcpg.NewLocalBitcoinChain(), + ) + if task.ActionType() != tbtc.ActionReservationAnchor { + t.Errorf( + "unexpected action type\n"+ + "expected: %v\n"+ + "actual: %v", + tbtc.ActionReservationAnchor, + task.ActionType(), + ) + } +} + +func TestReservationAcceptanceTask_Run(t *testing.T) { + if err := log.SetLogLevel("*", "DEBUG"); err != nil { + t.Fatal(err) + } + + scenarios, err := test.LoadReservationAcceptanceTestScenario() + if err != nil { + t.Fatal(err) + } + + for _, scenario := range scenarios { + 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, + } + + proposal, shouldExecute, err := task.Run(request) + if err != nil { + if scenario.ExpectedErr == nil { + t.Fatalf("unexpected error: [%v]", err) + } + if scenario.ExpectedErr.Error() != err.Error() { + t.Fatalf( + "unexpected error message\n"+ + "expected: [%v]\n"+ + "actual: [%v]", + scenario.ExpectedErr, + err, + ) + } + return + } + if scenario.ExpectedErr != nil { + 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, + ) + } + return + } + + if !shouldExecute { + t.Errorf("expected shouldExecute=true, got false") + } + if proposal == nil { + t.Fatal("expected proposal, got nil") + } + + actualProposal, ok := proposal.(*tbtc.ReservationAnchorProposal) + if !ok { + t.Fatalf("expected *ReservationAnchorProposal, got %T", proposal) + } + + if !expectedAnchorsEqual(expectedProposal, actualProposal) { + t.Errorf( + "invalid anchor proposal\nexpected: %+v\nactual: %+v", + expectedProposal, + actualProposal, + ) + } + }) + } +} + +// TestReservationAcceptanceTask_AnchorTransactionAssembly verifies the +// wiring of AssembleReservationAnchorTransaction: it ensures that an assembled +// anchor transaction can be signed and produces a valid 1-input-1-output +// Bitcoin transaction paying the correct wallet P2WPKH output script with +// value equal to deposit amount minus the estimated anchor fee. +func TestReservationAcceptanceTask_AnchorTransactionAssembly(t *testing.T) { + btcChain := tbtcpg.NewLocalBitcoinChain() + btcChain.SetEstimateSatPerVByteFee(1, 1) + + privateKey, err := ecdsa.GenerateKey(btcec.S256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + walletPublicKeyHash := bitcoin.PublicKeyHash(&privateKey.PublicKey) + + depositAmount := uint64(2000000) + currentBlock := uint64(300000) + + ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, func(ralc *reservationAcceptanceLocalChain) { + ralc.maxPerWalletAmount = 50000000 + ralc.maxSingleAmount = 50000000 + }) + + deposit := &tbtc.Deposit{ + Depositor: chain.Address("934b98637ca318a4d6e7ca6ffd1690b8e77df637"), + BlindingFactor: [8]byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}, + WalletPublicKeyHash: walletPublicKeyHash, + RefundPublicKeyHash: [20]byte{0x02}, + RefundLocktime: [4]byte{0x03, 0x04, 0x05, 0x06}, + Vault: &[]chain.Address{testReservationVaultAddress}[0], + } + + depositScript, err := deposit.Script() + if err != nil { + t.Fatal(err) + } + + depositScriptHash := sha256.Sum256(depositScript) + depositLockingScript, err := bitcoin.PayToWitnessScriptHash(depositScriptHash) + if err != nil { + t.Fatal(err) + } + + fundingTx := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x09}, + OutputIndex: 0, + }, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: int64(depositAmount), + PublicKeyScript: depositLockingScript, + }, + }, + } + fundingTxHash := fundingTx.Hash() + btcChain.SetTransaction(fundingTxHash, fundingTx) + btcChain.SetTransactionConfirmations( + fundingTxHash, + tbtc.DepositSweepRequiredFundingTxConfirmations, + ) + + 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, + }, + ) + + filterStartBlock := currentBlock - tbtcpg.ReservationAcceptanceLookBackBlocks + if err := ralc.AddPastDepositRevealedEvent( + &tbtc.DepositRevealedEventFilter{ + StartBlock: filterStartBlock, + EndBlock: ¤tBlock, + WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + }, + &tbtc.DepositRevealedEvent{ + BlockNumber: 200000, + WalletPublicKeyHash: walletPublicKeyHash, + FundingTxHash: fundingTxHash, + FundingOutputIndex: 0, + Vault: deposit.Vault, + BlindingFactor: deposit.BlindingFactor, + RefundPublicKeyHash: deposit.RefundPublicKeyHash, + RefundLocktime: deposit.RefundLocktime, + }, + ); err != nil { + t.Fatal(err) + } + + task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + proposal, shouldExecute, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + }) + if err != nil { + t.Fatalf("unexpected error running task: [%v]", err) + } + if !shouldExecute { + t.Fatalf("expected shouldExecute=true, got false") + } + if proposal == nil { + t.Fatalf("expected non-nil proposal") + } + + anchorProposal, ok := proposal.(*tbtc.ReservationAnchorProposal) + if !ok { + t.Fatalf("expected *ReservationAnchorProposal, got %T", proposal) + } + + // Assert on the candidate-derived proposal's own fields, exercising the + // candidate-deposit mapping step (also checked by the fixture's + // ValidateReservationAnchorProposal override), rather than only + // reassembling from this test's own hand-built deposit object below. + if anchorProposal.DepositFundingTxHash != fundingTxHash { + t.Errorf( + "unexpected DepositFundingTxHash\nexpected: %x\nactual: %x", + fundingTxHash, + anchorProposal.DepositFundingTxHash, + ) + } + if anchorProposal.DepositFundingOutputIndex != 0 { + t.Errorf( + "unexpected DepositFundingOutputIndex\nexpected: 0\nactual: %d", + anchorProposal.DepositFundingOutputIndex, + ) + } + + // Re-assemble and sign to verify transaction builder output properties. + builder, err := tbtc.AssembleReservationAnchorTransaction( + btcChain, + deposit, + walletPublicKeyHash, + &tbtc.ReservationAction{TxMaxFee: 5000}, + anchorProposal.AnchorTxFee.Int64(), + ) + if err != nil { + t.Fatalf("failed to assemble reservation anchor transaction: [%v]", err) + } + + sigHashes, err := builder.ComputeSignatureHashes() + if err != nil { + t.Fatalf("failed to compute signature hashes: [%v]", err) + } + signatures := make([]*bitcoin.SignatureContainer, len(sigHashes)) + for i, sigHash := range sigHashes { + r, s, err := ecdsa.Sign(rand.Reader, privateKey, sigHash.Bytes()) + if err != nil { + t.Fatalf("failed to sign input: [%v]", err) + } + signatures[i] = &bitcoin.SignatureContainer{ + R: r, + S: s, + PublicKey: &privateKey.PublicKey, + } + } + + signedTx, err := builder.AddSignatures(signatures) + if err != nil { + t.Fatalf("failed to add signatures: [%v]", err) + } + + if len(signedTx.Inputs) != 1 { + t.Errorf("expected 1 input, got %d", len(signedTx.Inputs)) + } + if len(signedTx.Outputs) != 1 { + t.Errorf("expected 1 output, got %d", len(signedTx.Outputs)) + } + + expectedOutputScript, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + expectedOutputValue := int64(depositAmount) - anchorProposal.AnchorTxFee.Int64() + + if signedTx.Outputs[0].Value != expectedOutputValue { + t.Errorf( + "unexpected output value\nexpected: [%d]\nactual: [%d]", + expectedOutputValue, + signedTx.Outputs[0].Value, + ) + } + if string(signedTx.Outputs[0].PublicKeyScript) != string(expectedOutputScript) { + t.Errorf( + "unexpected output script\nexpected: [%x]\nactual: [%x]", + expectedOutputScript, + signedTx.Outputs[0].PublicKeyScript, + ) + } +} + +// TestReservationAcceptanceTask_NoCandidates verifies that the task is a +// no-op when the chain has no reserved deposits. +func TestReservationAcceptanceTask_NoCandidates(t *testing.T) { + btcChain := tbtcpg.NewLocalBitcoinChain() + + walletPublicKeyHash := hexToByte20( + "8db50eb52063ea9d98b3eac91489a90f738986f6", + ) + currentBlock := uint64(300000) + + 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) + } +} + +// TestReservationAcceptanceTask_VaultNotConfigured_ZeroAddress verifies +// that a zero-address ReservationVault (the actual value the production +// 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() + + walletPublicKeyHash := hexToByte20( + "8db50eb52063ea9d98b3eac91489a90f738986f6", + ) + + ralc.SetReservationParameters(tbtc.ReservationParameters{ + ReservationVault: chain.Address( + "0x0000000000000000000000000000000000000000", + ), + ReservationMaxTotalAmount: 100000000, + }) + ralc.maxPerWalletAmount = 1000000 + ralc.maxSingleAmount = 5000000 + ralc.maxActive = 100 + + ralc.SetDepositMinAge(3600) + ralc.SetWallet( + walletPublicKeyHash, + &tbtc.WalletChainData{State: tbtc.StateLive}, + ) + + currentBlock := uint64(300000) + blockCounter := tbtcpg.NewMockBlockCounter() + blockCounter.SetCurrentBlock(currentBlock) + ralc.SetBlockCounter(blockCounter) + + 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) + } +} + +// TestReservationAcceptanceTask_AmountCapBoundaries verifies the three +// amount-based eligibility caps (single-deposit, wallet-aggregate, +// global-total) at their exact boundary: a deposit that would land +// exactly at the cap is accepted, one satoshi over is rejected. +func TestReservationAcceptanceTask_AmountCapBoundaries(t *testing.T) { + const depositAmount = uint64(1_000_000) + + tests := map[string]struct { + singleCap uint64 + walletCap uint64 + walletExisting uint64 + globalCap uint64 + globalExisting uint64 + expectAcceptance bool + }{ + "single-deposit cap: exactly at cap is accepted": { + singleCap: depositAmount, + walletCap: depositAmount * 10, + globalCap: depositAmount * 10, + expectAcceptance: true, + }, + "single-deposit cap: one over cap is rejected": { + singleCap: depositAmount - 1, + walletCap: depositAmount * 10, + globalCap: depositAmount * 10, + expectAcceptance: false, + }, + "wallet-aggregate cap: exactly at cap is accepted": { + singleCap: depositAmount * 10, + walletCap: depositAmount, + walletExisting: 0, + globalCap: depositAmount * 10, + expectAcceptance: true, + }, + "wallet-aggregate cap: one over cap is rejected": { + singleCap: depositAmount * 10, + walletCap: depositAmount, + walletExisting: 1, + globalCap: depositAmount * 10, + expectAcceptance: false, + }, + "global-total cap: exactly at cap is accepted": { + singleCap: depositAmount * 10, + walletCap: depositAmount * 10, + globalCap: depositAmount, + globalExisting: 0, + expectAcceptance: true, + }, + "global-total cap: one over cap is rejected": { + singleCap: depositAmount * 10, + walletCap: depositAmount * 10, + globalCap: depositAmount, + globalExisting: 1, + expectAcceptance: false, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + ralc := newReservationAcceptanceLocalChain() + btcChain := tbtcpg.NewLocalBitcoinChain() + + walletPublicKeyHash := hexToByte20( + "8db50eb52063ea9d98b3eac91489a90f738986f6", + ) + + ralc.SetReservationParameters(tbtc.ReservationParameters{ + ReservationVault: chain.Address( + "0xReservationVaultAddress1234567890abcdef12345678", + ), + ReservationMinAmount: 1000, + ReservationTxMaxFee: 5000, + MaxReservationsPerWallet: 5, + ReservationMaxTotalAmount: test.globalCap, + ReservationTotalAmount: test.globalExisting, + }) + ralc.maxPerWalletAmount = test.walletCap + ralc.maxSingleAmount = test.singleCap + ralc.walletReservationsAmount = test.walletExisting + ralc.maxActive = 100 + + ralc.SetDepositMinAge(3600) + ralc.SetWallet( + walletPublicKeyHash, + &tbtc.WalletChainData{State: tbtc.StateLive}, + ) + + currentBlock := uint64(300000) + blockCounter := tbtcpg.NewMockBlockCounter() + blockCounter.SetCurrentBlock(currentBlock) + ralc.SetBlockCounter(blockCounter) + + fundingTxHash := fundingTxHashForTestName(testName) + 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: depositAmount, + RevealedAt: time.Now().Add(-2 * time.Hour), + SweptAt: time.Unix(0, 0), + Vault: &[]chain.Address{chain.Address( + "0xReservationVaultAddress1234567890abcdef12345678", + )}[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{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 test.expectAcceptance { + if !shouldExecute || proposal == nil { + t.Fatalf("expected proposal to be accepted at the exact cap boundary") + } + } else { + if shouldExecute || proposal != nil { + t.Fatalf("expected proposal to be rejected one unit over the cap") + } + } + }) + } +} + +// fundingTxHashForTestName derives a unique, deterministic funding tx hash +// per subtest name so parallel/sequential subtests never collide on the +// same fixture key. +func fundingTxHashForTestName(name string) bitcoin.Hash { + sum := 0 + for _, r := range name { + sum += int(r) + } + return hashFromString(fmt.Sprintf("%064x", sum+1)) +} + +// 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) + + btcChain := tbtcpg.NewLocalBitcoinChain() + + walletPublicKeyHash := hexToByte20( + "8db50eb52063ea9d98b3eac91489a90f738986f6", + ) + + // 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) + + ralc := newBoundaryTestChain( + t, + walletPublicKeyHash, + initialBlock, + func(ralc *reservationAcceptanceLocalChain) { + ralc.SetBlockCounter(blockCounter) + }, + ) + + // 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) + } + + task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + request := &tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + } + + // First run: only the old deposit exists, revealed at block 1 - before + // the look-back start block. No candidate is found on this run; the + // second run below is what actually proves the look-back start block + // is honored, by 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) + } + + // 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], + }, + ) + 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 does not target the reservation vault 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), + SweptAt: time.Unix(0, 0), + }, + ) + + filterStartBlock := uint64(0) + if currentBlock > tbtcpg.ReservationAcceptanceLookBackBlocks { + filterStartBlock = currentBlock - tbtcpg.ReservationAcceptanceLookBackBlocks + } + + // Set event vault away from the configured reservation vault so it + // actually exercises "not reserved". + 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{chain.Address( + "0xOtherVaultAddress1234567890abcdef12345678901234", + )}[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 passthrough inside findReservationAcceptanceCandidate: a +// reserved deposit candidate is discovered and matches the reservation +// vault, but the candidate wallet's chain data fails to load. +func TestReservationAcceptanceTask_GetWalletError(t *testing.T) { + btcChain := tbtcpg.NewLocalBitcoinChain() + + walletPublicKeyHash := hexToByte20( + "8db50eb52063ea9d98b3eac91489a90f738986f6", + ) + currentBlock := uint64(300000) + + // getWalletErr forces GetWallet to fail for the candidate wallet. + // Production logs and swallows the GetWallet error, so Run must + // return (nil, false, nil). + ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, func(ralc *reservationAcceptanceLocalChain) { + ralc.getWalletErr = fmt.Errorf("boom") + }) + + setupEligibleDeposit( + t, + ralc, + btcChain, + walletPublicKeyHash, + currentBlock, + 2000000, + ) + + task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + + proposal, shouldExecute, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if shouldExecute { + t.Errorf("expected shouldExecute=false, got true") + } + if proposal != nil { + t.Errorf("expected no proposal, got %v", 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". +func TestReservationAcceptanceTask_GetReservationError(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, + ) + + // 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) + } +} + +// TestReservationAcceptanceTask_Stateless_Maturity verifies the stateless +// observable contract across two consecutive Run calls on the same task instance: +// an immature candidate is skipped on the first run, but when time advances and +// the candidate matures, the second run on the same task instance proposes it +// without any cache-state interference. +func TestReservationAcceptanceTask_Stateless_Maturity(t *testing.T) { + 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], + }, + ) + + 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) + } + + 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], + }, + ) + + // 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") + } + + 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), + ) + } +} + +// TestReservationAcceptanceTask_ReservationParametersFetchedLive verifies +// that each Run() call reflects the chain's current live state rather than +// anything cached from a prior run on the same task instance: a +// governance-driven ReservationParameters change takes effect on the very +// next call, and an acceptance request recorded as a side effect of one +// Run() is visible to production's dedup guard on the next. +func TestReservationAcceptanceTask_ReservationParametersFetchedLive(t *testing.T) { + walletPublicKeyHash := hexToByte20( + "8db50eb52063ea9d98b3eac91489a90f738986f6", + ) + currentBlock := uint64(300000) + + t.Run("records acceptance request, skipping duplicate on subsequent run", func(t *testing.T) { + btcChain := tbtcpg.NewLocalBitcoinChain() + + ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, nil) + + fundingTxHash := setupEligibleDeposit( + t, + ralc, + btcChain, + walletPublicKeyHash, + currentBlock, + 2000000, + ) + + task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + request := &tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + } + + // First run: min amount (1000) is well below the deposit (2000000) - must accept. + _, shouldExecute, err := task.Run(request) + if err != nil { + t.Fatalf("unexpected error on first run: [%v]", err) + } + if !shouldExecute { + t.Fatalf("expected shouldExecute=true on first run, got false") + } + + // RequestReservationAcceptance bumped RequestNonce to 1 as a side + // effect of the first run, mirroring the on-chain Bridge. On real + // chain the Bridge also marks that generation's action record + // Pending; record that here too so the second run's dedup guard + // (hasPendingAction, which reads GetReservationAction) genuinely + // observes a pending generation instead of merely fail-closing on + // a not-found lookup. + depositKey := ralc.BuildDepositKey(fundingTxHash, 0) + ralc.SetReservationAction(depositKey, 1, &tbtc.ReservationAction{ + State: tbtc.ReservationActionStatePending, + }) + + // Second run on the same, now-requested deposit: the pending + // action generation recorded above must be found and the + // candidate skipped. + _, shouldExecute, err = task.Run(request) + if err != nil { + t.Fatalf("unexpected error on second run: [%v]", err) + } + if shouldExecute { + t.Fatalf("expected shouldExecute=false on second run due to pending acceptance action, got true") + } + }) + + t.Run("with parameter mutation rejects on subsequent run", func(t *testing.T) { + btcChain := tbtcpg.NewLocalBitcoinChain() + + ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, nil) + + setupEligibleDeposit( + t, + ralc, + btcChain, + walletPublicKeyHash, + currentBlock, + 2000000, + ) + + task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + request := &tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + } + + // First run: min amount (1000) is well below the deposit (2000000) - must accept. + _, shouldExecute, err := task.Run(request) + if err != nil { + t.Fatalf("unexpected error on first run: [%v]", err) + } + if !shouldExecute { + t.Fatalf("expected shouldExecute=true on first run, got false") + } + + // Mutate the chain fake's parameters in place - same task instance, + // same deposit, no new task created - then raise the min amount above + // the deposit's value. + ralc.SetReservationParameters(tbtc.ReservationParameters{ + ReservationVault: testReservationVaultAddress, + ReservationMinAmount: 3000000, + ReservationTxMaxFee: 5000, + MaxReservationsPerWallet: 5, + }) + + // Second run: if any part of the eligibility path retained the + // first run's ReservationMinAmount=1000 instead of reading the + // mutated live value, this would wrongly accept again. + _, shouldExecute, err = task.Run(request) + if err != nil { + t.Fatalf("unexpected error on second run: [%v]", err) + } + if shouldExecute { + t.Fatalf( + "expected shouldExecute=false on second run after raising " + + "ReservationMinAmount above the deposit's value", + ) + } + }) +} + +// TestReservationAcceptanceTask_BoundaryChecks exercises explicit +// at-limit/one-over-limit boundary crossings for the eligibility caps in +// checkReservationAcceptanceEligibility: +// - MaxReservationsPerWallet +// - ReservationMaxTotalAmount +// - ReservationMaxSingleAmount +// - MaxReservationsAmountPerWallet +// - ActiveReservationsCount +// ReservationMinAmount is not one of checkReservationAcceptanceEligibility's +// gates: the gross gate lives in findReservationAcceptanceCandidate, which +// requires depositAmount >= ReservationMinAmount; the same function +// additionally requires the net-of-fee value (deposit minus the estimated +// anchor fee) to clear it too. +func TestReservationAcceptanceTask_BoundaryChecks(t *testing.T) { + tests := map[string]struct { + depositAmount uint64 + maxReservationsPerWallet uint32 + walletReservationsCount uint32 + reservationMinAmount uint64 + reservationTotal uint64 + // maxSingleAmount, maxPerWalletAmount, maxActive, and + // reservationMaxTotal are pointers so a test row can explicitly + // request the cap-disabled value of 0 for the three caps where + // production treats 0 as "unlimited", or explicitly exercise the + // fail-closed misconfiguration path for the two caps + // (maxActiveReservations, ReservationMaxTotalAmount) where + // production treats 0 as "not configured" instead; nil means "use + // this test's default cap" (a large, effectively-unlimited value). + reservationMaxTotal *uint64 + maxSingleAmount *uint64 + maxPerWalletAmount *uint64 + walletReservationsAmount uint64 + maxActive *uint32 + activeCount uint32 + expectAccept bool + }{ + "MaxReservationsPerWallet: below limit accepts": { + depositAmount: 2000000, + maxReservationsPerWallet: 5, + walletReservationsCount: 4, + reservationMinAmount: 1000, + expectAccept: true, + }, + "MaxReservationsPerWallet: at limit rejects": { + depositAmount: 2000000, + maxReservationsPerWallet: 5, + walletReservationsCount: 5, + reservationMinAmount: 1000, + expectAccept: false, + }, + // checkReservationAcceptanceEligibility's gross-amount gate only + // requires depositAmount >= reservationMinAmount, but + // proposeReservationAcceptance additionally requires the + // *net-of-fee* anchor value (deposit - anchorFee) to also clear + // reservationMinAmount. Even though the test fixture sets a 1 sat/vByte + // oracle rate, applyWalletTxFeeFloor (see fee.go) clamps the rate to + // minWalletTxSatPerVByteFee (5 sat/vByte), resulting in a 710 sat fee + // (5 * 142 vsize = testAnchorFeeSat). Deposit amounts are offset by + // testAnchorFeeSat to test the exact net-of-fee boundary. + "ReservationMinAmount: exactly at minimum accepts": { + depositAmount: 100000 + testAnchorFeeSat, + maxReservationsPerWallet: 5, + reservationMinAmount: 100000, + expectAccept: true, + }, + "ReservationMinAmount: gross clears but net-of-fee value does not": { + depositAmount: 100050, + maxReservationsPerWallet: 5, + reservationMinAmount: 100000, + expectAccept: false, + }, + "ReservationMinAmount: one below minimum rejects": { + depositAmount: 99999, + maxReservationsPerWallet: 5, + reservationMinAmount: 100000, + expectAccept: false, + }, + "ReservationMaxTotalAmount: exactly at cap accepts": { + depositAmount: 2000000, + maxReservationsPerWallet: 5, + reservationMinAmount: 1000, + reservationTotal: 3000000, + reservationMaxTotal: uint64Ptr(5000000), + expectAccept: true, + }, + "ReservationMaxTotalAmount: one over cap rejects": { + depositAmount: 2000000, + maxReservationsPerWallet: 5, + reservationMinAmount: 1000, + reservationTotal: 3000001, + reservationMaxTotal: uint64Ptr(5000000), + expectAccept: false, + }, + // Unlike ReservationMaxSingleAmount and MaxReservationsAmountPerWallet + // below, ReservationMaxTotalAmount == 0 is NOT treated as + // "unlimited": checkReservationAcceptanceEligibility fails closed on + // a misconfigured (zero) global cap rather than silently allowing + // unbounded reservations. + "ReservationMaxTotalAmount: cap of 0 fails closed (misconfiguration)": { + depositAmount: 2000000, + maxReservationsPerWallet: 5, + reservationMinAmount: 1000, + reservationMaxTotal: uint64Ptr(0), + expectAccept: false, + }, + "ReservationMaxSingleAmount: exactly at cap accepts": { + depositAmount: 5000000, + maxSingleAmount: uint64Ptr(5000000), + maxReservationsPerWallet: 5, + reservationMinAmount: 1000, + expectAccept: true, + }, + "ReservationMaxSingleAmount: one over cap rejects": { + depositAmount: 5000001, + maxSingleAmount: uint64Ptr(5000000), + maxReservationsPerWallet: 5, + reservationMinAmount: 1000, + expectAccept: false, + }, + // A cap of 0 means "unlimited" in checkReservationAcceptanceEligibility + // (reservationMaxSingleAmount > 0 gates the check); maxPerWalletAmount + // is raised explicitly so it does not itself gate this deposit. + "ReservationMaxSingleAmount: cap of 0 means unlimited": { + depositAmount: 60000000, + maxSingleAmount: uint64Ptr(0), + maxPerWalletAmount: uint64Ptr(100000000), + maxReservationsPerWallet: 5, + reservationMinAmount: 1000, + expectAccept: true, + }, + "MaxReservationsAmountPerWallet: exactly at cap accepts": { + depositAmount: 2000000, + walletReservationsAmount: 3000000, + maxPerWalletAmount: uint64Ptr(5000000), + maxReservationsPerWallet: 5, + reservationMinAmount: 1000, + expectAccept: true, + }, + "MaxReservationsAmountPerWallet: one over cap rejects": { + depositAmount: 2000000, + walletReservationsAmount: 3000001, + maxPerWalletAmount: uint64Ptr(5000000), + maxReservationsPerWallet: 5, + reservationMinAmount: 1000, + expectAccept: false, + }, + // A cap of 0 means "unlimited" (maxReservationsAmountPerWallet > 0 + // gates the check); maxSingleAmount is raised explicitly so it does + // not itself gate this deposit. + "MaxReservationsAmountPerWallet: cap of 0 means unlimited": { + depositAmount: 2000000, + walletReservationsAmount: 60000000, + maxPerWalletAmount: uint64Ptr(0), + maxSingleAmount: uint64Ptr(100000000), + maxReservationsPerWallet: 5, + reservationMinAmount: 1000, + expectAccept: true, + }, + "ActiveReservationsCount: below limit accepts": { + depositAmount: 2000000, + maxActive: uint32Ptr(10), + activeCount: 9, + maxReservationsPerWallet: 5, + reservationMinAmount: 1000, + expectAccept: true, + }, + "ActiveReservationsCount: at limit rejects": { + depositAmount: 2000000, + maxActive: uint32Ptr(10), + activeCount: 10, + maxReservationsPerWallet: 5, + reservationMinAmount: 1000, + expectAccept: false, + }, + // Unlike ReservationMaxSingleAmount and MaxReservationsAmountPerWallet + // above, maxActiveReservations == 0 is NOT treated as "unlimited": + // checkReservationAcceptanceEligibility fails closed on a + // misconfigured (zero) active-reservations cap rather than silently + // allowing unbounded active reservations. + "ActiveReservationsCount: cap of 0 fails closed (misconfiguration)": { + depositAmount: 2000000, + maxActive: uint32Ptr(0), + activeCount: 1000, + maxReservationsPerWallet: 5, + reservationMinAmount: 1000, + expectAccept: false, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + ralc := newReservationAcceptanceLocalChain() + btcChain := tbtcpg.NewLocalBitcoinChain() + btcChain.SetEstimateSatPerVByteFee(1, 1) + + walletPublicKeyHash := hexToByte20( + "8db50eb52063ea9d98b3eac91489a90f738986f6", + ) + + reservationMaxTotal := uint64(100000000) + if test.reservationMaxTotal != nil { + reservationMaxTotal = *test.reservationMaxTotal + } + ralc.SetReservationParameters(tbtc.ReservationParameters{ + ReservationVault: testReservationVaultAddress, + ReservationMinAmount: test.reservationMinAmount, + ReservationTxMaxFee: 5000, + MaxReservationsPerWallet: test.maxReservationsPerWallet, + ReservationMaxTotalAmount: reservationMaxTotal, + ReservationTotalAmount: test.reservationTotal, + }) + ralc.maxPerWalletAmount = 50000000 + if test.maxPerWalletAmount != nil { + ralc.maxPerWalletAmount = *test.maxPerWalletAmount + } + ralc.maxSingleAmount = 50000000 + if test.maxSingleAmount != nil { + ralc.maxSingleAmount = *test.maxSingleAmount + } + ralc.maxActive = 100 + if test.maxActive != nil { + ralc.maxActive = *test.maxActive + } + ralc.activeCount = test.activeCount + ralc.walletReservationsAmount = test.walletReservationsAmount + ralc.walletReservationsCount = test.walletReservationsCount + + ralc.SetDepositMinAge(3600) + ralc.SetWallet( + walletPublicKeyHash, + &tbtc.WalletChainData{State: tbtc.StateLive}, + ) + + currentBlock := uint64(300000) + blockCounter := tbtcpg.NewMockBlockCounter() + blockCounter.SetCurrentBlock(currentBlock) + ralc.SetBlockCounter(blockCounter) + + setupEligibleDeposit( + t, + ralc, + btcChain, + walletPublicKeyHash, + currentBlock, + test.depositAmount, + ) + + task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + + request := &tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + } + + _, shouldExecute, err := task.Run(request) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + if shouldExecute != test.expectAccept { + t.Errorf( + "expected shouldExecute=%v, got %v", + test.expectAccept, + shouldExecute, + ) + } + }) + } +} + +// TestReservationAcceptanceTask_Stateless_NoReRequest verifies that once a +// reservation has an existing acceptance requested event, subsequent Run calls +// on the same task instance do not produce a duplicate acceptance proposal. +func TestReservationAcceptanceTask_Stateless_NoReRequest(t *testing.T) { + btcChain := tbtcpg.NewLocalBitcoinChain() + + walletPublicKeyHash := hexToByte20( + "8db50eb52063ea9d98b3eac91489a90f738986f6", + ) + + 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}, + }, + &tbtc.DepositRevealedEvent{ + BlockNumber: 290000, + WalletPublicKeyHash: walletPublicKeyHash, + FundingTxHash: fundingTxHash, + FundingOutputIndex: 0, + Vault: &[]chain.Address{testReservationVaultAddress}[0], + }, + ); err != nil { + t.Fatal(err) + } + + task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + request := &tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + } + + // First run: deposit is eligible and proposed. + proposal, shouldExecute, err := task.Run(request) + if err != nil { + t.Fatalf("first run error: [%v]", err) + } + if !shouldExecute || proposal == nil { + t.Fatalf("expected proposal on first run") + } + + // Simulate on-chain record: mark reservation as having an acceptance + // requested event. + depositKey := ralc.BuildDepositKey(fundingTxHash, 0) + ralc.AddPastReservationAcceptanceRequestedEvent(&tbtc.ReservationAcceptanceRequestedEvent{ + ReservationKey: depositKey, + RequestNonce: 1, + WalletPublicKeyHash: walletPublicKeyHash, + }) + + // Second run on the same task instance: must not produce a second proposal. + proposal, shouldExecute, err = task.Run(request) + if err != nil { + t.Fatalf("second run error: [%v]", err) + } + if shouldExecute || proposal != nil { + t.Fatalf("expected no proposal on second run due to existing acceptance event") + } +} + +// TestReservationAcceptanceTask_Stateless_NonEligibleReservationState verifies that +// a reservation whose on-chain state is Active, ActionPending, Closed, or Stranded +// is skipped from acceptance proposals. +func TestReservationAcceptanceTask_Stateless_NonEligibleReservationState(t *testing.T) { + nonEligibleStates := []tbtc.ReservationState{ + tbtc.ReservationStateActive, + tbtc.ReservationStateActionPending, + tbtc.ReservationStateClosed, + tbtc.ReservationStateStranded, + } + + 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{ + State: state, + RequestNonce: 1, + }) + + 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) + } + }) + } +} + +// TestReservationAcceptanceTask_Stateless_DynamicMinAmount verifies that the +// minimum-amount filter is retryable: a deposit below minimum on Run 1 is skipped, +// but when governance lowers the minimum amount, Run 2 on the same task instance +// proposes it. +func TestReservationAcceptanceTask_Stateless_DynamicMinAmount(t *testing.T) { + btcChain := tbtcpg.NewLocalBitcoinChain() + + walletPublicKeyHash := hexToByte20( + "8db50eb52063ea9d98b3eac91489a90f738986f6", + ) + currentBlock := uint64(300000) + + // Initial min amount is 5,000,000. + 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 initial 5,000,000 min). + 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) + } + + task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + request := &tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + } + + // First run: deposit amount is below min, skipped. + 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 deposit is below min amount") + } + + // Governance lowers min amount to 1,000,000. + ralc.SetReservationParameters(tbtc.ReservationParameters{ + ReservationVault: testReservationVaultAddress, + ReservationMinAmount: 1000000, + ReservationTxMaxFee: 5000, + MaxReservationsPerWallet: 5, + ReservationMaxTotalAmount: 100000000, + }) + + // Second run on the same task instance: deposit is now above min and proposed. + proposal, shouldExecute, err = task.Run(request) + if err != nil { + t.Fatalf("second run error: [%v]", err) + } + if !shouldExecute || proposal == nil { + t.Fatalf("expected proposal on second run after min amount lowered") + } +} + +// TestReservationAcceptanceTask_Stateless_RequestNonceIncremented verifies that +// when an existing reservation record has RequestNonce = N, the generated proposal +// uses RequestNonce = N + 1. +func TestReservationAcceptanceTask_Stateless_RequestNonceIncremented(t *testing.T) { + btcChain := tbtcpg.NewLocalBitcoinChain() + + walletPublicKeyHash := hexToByte20( + "8db50eb52063ea9d98b3eac91489a90f738986f6", + ) + + currentBlock := uint64(300000) + + ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, nil) + + fundingTxHash := hashFromString( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ) + 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}, + }, + &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) + // Set an existing reservation with StateUnknown (not active) and RequestNonce = 2. + ralc.SetReservation(depositKey, &tbtc.Reservation{ + State: tbtc.ReservationStateUnknown, + RequestNonce: 2, + }) + // Set previous action nonce 2 to TimedOut so hasPendingAction returns false. + ralc.SetReservationAction(depositKey, 2, &tbtc.ReservationAction{ + State: tbtc.ReservationActionStateTimedOut, + }) + + task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + request := &tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + } + + proposal, shouldExecute, err := task.Run(request) + if err != nil { + t.Fatalf("task error: [%v]", err) + } + if !shouldExecute || proposal == nil { + t.Fatalf("expected proposal") + } + + actualProposal, ok := proposal.(*tbtc.ReservationAnchorProposal) + if !ok { + t.Fatalf("expected *ReservationAnchorProposal, got %T", proposal) + } + if actualProposal.RequestNonce != 3 { + t.Errorf( + "unexpected RequestNonce\nexpected: 3\nactual: %d", + actualProposal.RequestNonce, + ) + } +} + +// TestReservationAcceptanceTask_PastDepositRevealedEventsError verifies that +// a genuine (non-sentinel) error from PastDepositRevealedEvents is +// propagated as a hard error, rather than being swallowed like the mock's +// "no events for given filter" sentinel. +func TestReservationAcceptanceTask_PastDepositRevealedEventsError(t *testing.T) { + btcChain := tbtcpg.NewLocalBitcoinChain() + + walletPublicKeyHash := hexToByte20( + "8db50eb52063ea9d98b3eac91489a90f738986f6", + ) + currentBlock := uint64(300000) + + ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, nil) + + // Otherwise-eligible deposit; the injected error must still short + // circuit before any candidate is ever evaluated. + setupEligibleDeposit( + t, + ralc, + btcChain, + walletPublicKeyHash, + currentBlock, + 2000000, + ) + + ralc.pastDepositRevealedEventsErr = fmt.Errorf("simulated rpc failure") + + task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + + _, shouldExecute, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + }) + if err == nil { + t.Fatalf("expected a non-nil error, got nil") + } + if shouldExecute { + t.Errorf("expected shouldExecute=false, got true") + } +} + +// TestReservationAcceptanceTask_ValidateProposalError verifies that a +// ValidateReservationAnchorProposal failure 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) { + btcChain := tbtcpg.NewLocalBitcoinChain() + + walletPublicKeyHash := hexToByte20( + "8db50eb52063ea9d98b3eac91489a90f738986f6", + ) + currentBlock := uint64(300000) + + ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, nil) + + setupEligibleDeposit( + t, + ralc, + btcChain, + walletPublicKeyHash, + currentBlock, + 2000000, + ) + + ralc.validateErr = fmt.Errorf("simulated validation failure") + + task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + + proposal, shouldExecute, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + }) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + if shouldExecute { + t.Errorf("expected shouldExecute=false, got true") + } + if proposal != nil { + t.Errorf("expected nil proposal, got %v", proposal) + } +} diff --git a/pkg/tbtcpg/reservation_reanchor.go b/pkg/tbtcpg/reservation_reanchor.go new file mode 100644 index 0000000000..cebcaddfb4 --- /dev/null +++ b/pkg/tbtcpg/reservation_reanchor.go @@ -0,0 +1,619 @@ +package tbtcpg + +import ( + "errors" + "fmt" + "math/big" + + "github.com/ipfs/go-log/v2" + "go.uber.org/zap" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/tbtc" +) + +// ReservationReanchorLookBackBlocks is the look-back period in blocks used +// when searching for submitted reservation-related events. It is equal to +// 30 days assuming 12 seconds per block. +const ReservationReanchorLookBackBlocks = uint64(216000) + +// 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. +type ReservationReanchorTask struct { + chain Chain + btcChain bitcoin.Chain + + // metricsRecorder is optional and used for recording performance + // metrics: live_wallets_count, sourced from the GetLiveWalletsCount + // chain call this task already makes. + metricsRecorder interface { + SetGauge(name string, value float64) + } +} + +// NewReservationReanchorTask returns a new ReservationReanchorTask bound to +// the given tbtc and Bitcoin chains. +func NewReservationReanchorTask( + chain Chain, + btcChain bitcoin.Chain, +) *ReservationReanchorTask { + return &ReservationReanchorTask{ + chain: chain, + btcChain: btcChain, + } +} + +// setMetricsRecorder sets the metrics recorder for the reservation +// re-anchor task. +func (rrt *ReservationReanchorTask) setMetricsRecorder(recorder interface { + SetGauge(name string, value float64) +}) { + rrt.metricsRecorder = recorder +} + +// ActionType returns the type of wallet action this task produces. +func (rrt *ReservationReanchorTask) ActionType() tbtc.WalletActionType { + return tbtc.ActionReservationReanchor +} + +// 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 +// (Reservation.sol:742-746, ReservationRouter.sol:269-277), which the +// client's ordinary operator key can never satisfy, so no below-dust +// re-anchor trigger is attempted for Live wallets. +// +// 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 +// (see notifyMovingFundsBelowDustIfEligible). +// +// Returns (nil, false, nil) when no reservation is eligible; callers should +// treat that as a benign no-op for the coordination window. +func (rrt *ReservationReanchorTask) Run( + request *tbtc.CoordinationProposalRequest, +) ( + tbtc.CoordinationProposal, + bool, + error, +) { + walletPublicKeyHash := request.WalletPublicKeyHash + + taskLogger := logger.With( + zap.String("task", rrt.ActionType().String()), + zap.String("walletPKH", fmt.Sprintf("0x%x", walletPublicKeyHash)), + ) + + walletChainData, err := rrt.chain.GetWallet(walletPublicKeyHash) + if err != nil { + return nil, false, fmt.Errorf( + "cannot get wallet chain data: [%w]", + err, + ) + } + + // 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/ + // 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 + // make the occupancy-monitor ratio permanently undefined. + liveWalletsCount, err := rrt.chain.GetLiveWalletsCount() + if err != nil { + return nil, false, fmt.Errorf( + "cannot get live wallets count: [%w]", + err, + ) + } + if rrt.metricsRecorder != nil { + rrt.metricsRecorder.SetGauge("live_wallets_count", float64(liveWalletsCount)) + } + + if walletChainData.State != tbtc.StateMovingFunds { + taskLogger.Info("wallet is not eligible for reservation re-anchor") + return nil, false, nil + } + + reservationKeys, err := rrt.chain.WalletReservations(walletPublicKeyHash) + if err != nil { + return nil, false, fmt.Errorf( + "cannot list wallet reservations: [%w]", + err, + ) + } + + if len(reservationKeys) == 0 { + taskLogger.Info("wallet has no reservations to re-anchor") + rrt.notifyMovingFundsBelowDustIfEligible(taskLogger, walletPublicKeyHash) + return nil, false, nil + } + + if liveWalletsCount == 0 { + taskLogger.Info("no live wallets available for re-anchor target") + return nil, false, nil + } + + targetWalletPublicKeyHash, err := rrt.findTargetWallet( + taskLogger, + walletPublicKeyHash, + ) + if err != nil { + taskLogger.Errorf( + "cannot pick re-anchor target wallet: [%v]", + err, + ) + return nil, false, nil + } + + for _, reservationKey := range reservationKeys { + reservation, err := rrt.chain.GetReservation(reservationKey) + if err != nil { + taskLogger.Errorf( + "cannot get reservation [0x%x]: [%v]", + reservationKey, + err, + ) + continue + } + + // Filter out reservations that are not in the Active state. + // Note: Checking Active state already covers pending actions, + // because a reservation with a pending action is in ActionPending state. + if reservation.State != tbtc.ReservationStateActive { + taskLogger.Infof( + "reservation [0x%x] not in Active state (state=%v), skipping", + reservationKey, + reservation.State, + ) + continue + } + + proposal, err := rrt.ProposeReservationReanchor( + taskLogger, + walletPublicKeyHash, + reservationKey, + reservation.RequestNonce+1, + targetWalletPublicKeyHash, + 0, + ) + if err != nil { + taskLogger.Errorf( + "cannot prepare reservation re-anchor proposal: [%v]", + err, + ) + var postWriteErr *errReservationReanchorPostWriteFailure + if errors.As(err, &postWriteErr) { + // RequestReservationReanchor already authorized a + // re-anchor action generation on-chain for this + // reservation before the post-write post-condition check + // (re-read + nonce verification) failed. Stop instead of + // continuing to the next reservation so at most one + // on-chain authorization is issued per Run pass. + break + } + continue + } + + return proposal, true, nil + } + + taskLogger.Info("no reservations eligible for re-anchor") + return nil, false, nil +} + +// errReservationReanchorPostWriteFailure marks a ProposeReservationReanchor +// failure that occurred after RequestReservationReanchor already +// authorized a re-anchor action generation on-chain. Run distinguishes +// this from a pre-write failure (validation, fee estimation, transaction +// assembly) via errors.As: on a pre-write failure no authorization was +// issued, so Run may safely try the next reservation, but on a post-write +// failure an authorization is already in flight and Run must stop instead +// of risking a second one in the same pass. +type errReservationReanchorPostWriteFailure struct { + err error +} + +func (e *errReservationReanchorPostWriteFailure) Error() string { + return e.err.Error() +} + +func (e *errReservationReanchorPostWriteFailure) Unwrap() error { + return e.err +} + +// ProposeReservationReanchor assembles a single reservation re-anchor proposal +// for the given reservation, targeting the given wallet. The supplied fee may +// be 0 to trigger on-chain-driven fee estimation; the caller is responsible +// for providing a RequestNonce that is exactly current_request_nonce + 1 on +// the reservation's view (the action generation being authorized). +func (rrt *ReservationReanchorTask) ProposeReservationReanchor( + taskLogger log.StandardLogger, + sourceWalletPublicKeyHash [20]byte, + reservationKey *big.Int, + requestNonce uint64, + targetWalletPublicKeyHash [20]byte, + fee int64, +) (*tbtc.ReservationReanchorProposal, error) { + if reservationKey == nil { + return nil, fmt.Errorf("reservation key is required") + } + if requestNonce == 0 { + return nil, fmt.Errorf("request nonce must be > 0") + } + if targetWalletPublicKeyHash == [20]byte{} { + return nil, fmt.Errorf("target wallet public key hash is required") + } + + taskLogger.Infof( + "preparing a reservation re-anchor proposal for reservation [0x%x]", + reservationKey, + ) + + reservation, err := rrt.chain.GetReservation(reservationKey) + if err != nil { + return nil, fmt.Errorf( + "cannot get reservation [0x%x]: [%w]", + reservationKey, + err, + ) + } + + // convertReservationFromAbiType (the Go-side chain adapter) always + // allocates a non-nil AnchorUtxo, populated with zero hash/value when + // no anchor exists on-chain, so a bare nil check can never fire against + // the production chain. Detect the unset case by value instead. + if reservation.AnchorUtxo == nil || + reservation.AnchorUtxo.Value == 0 || + reservation.AnchorUtxo.Outpoint == nil || + reservation.AnchorUtxo.Outpoint.TransactionHash == (bitcoin.Hash{}) { + return nil, fmt.Errorf( + "reservation [0x%x] has no anchor UTXO", + reservationKey, + ) + } + + // The Bridge caps each reservation lifecycle transaction with its own + // ReservationTxMaxFee, not the moving-funds TxMaxTotalFee, so we use + // the reservation parameters directly. Fetched unconditionally (not + // only when fee needs estimating) because the pre-check below also + // needs ReservationTxMaxFee to bound-check a caller-supplied fee. + params, err := rrt.chain.ReservationParameters() + if err != nil { + return nil, fmt.Errorf( + "cannot get reservation parameters: [%w]", + err, + ) + } + + if fee <= 0 { + taskLogger.Infof("estimating reservation re-anchor transaction fee") + + fee, err = estimateReservationReanchorFee( + rrt.btcChain, + params.ReservationTxMaxFee, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot estimate reservation re-anchor transaction fee: [%w]", + err, + ) + } + } + + taskLogger.Infof("reservation re-anchor transaction fee: [%d]", fee) + + feeBoundAction := &tbtc.ReservationAction{ + TxMaxFee: params.ReservationTxMaxFee, + } + + if _, err := tbtc.AssembleReservationReanchorTransaction( + rrt.btcChain, + reservation.AnchorUtxo, + targetWalletPublicKeyHash, + feeBoundAction, + fee, + ); err != nil { + return nil, fmt.Errorf( + "cannot assemble reservation re-anchor transaction: [%v]", + err, + ) + } + + proposal := &tbtc.ReservationReanchorProposal{ + ReservationKey: new(big.Int).Set(reservationKey), + RequestNonce: requestNonce, + TargetWalletPublicKeyHash: targetWalletPublicKeyHash, + ReanchorTxFee: big.NewInt(fee), + } + + if err := rrt.chain.ValidateReservationReanchorProposal( + sourceWalletPublicKeyHash, + proposal, + ); err != nil { + return nil, fmt.Errorf( + "failed to verify reservation re-anchor proposal: [%w]", + err, + ) + } + // The re-anchor request generation must be authorized on-chain. + // Note: Calling RequestReservationReanchor during proposal generation is an + // accepted deviation from the read-only-during-generation pattern, with + // precedent in MovingFundsTask's SubmitMovingFundsCommitment. + if err := rrt.chain.RequestReservationReanchor( + reservationKey, + targetWalletPublicKeyHash, + ); err != nil { + return nil, fmt.Errorf("cannot request reservation re-anchor: [%v]", err) + } + + updatedReservation, err := rrt.chain.GetReservation(reservationKey) + if err != nil { + return nil, &errReservationReanchorPostWriteFailure{ + err: fmt.Errorf("cannot re-read reservation: [%v]", err), + } + } + if updatedReservation.RequestNonce != requestNonce { + return nil, &errReservationReanchorPostWriteFailure{ + err: fmt.Errorf( + "reservation request nonce mismatch after request: predicted [%d], on-chain [%d]", + requestNonce, + updatedReservation.RequestNonce, + ), + } + } + proposal.RequestNonce = updatedReservation.RequestNonce + + return proposal, nil +} + +// findTargetWallet picks a live destination wallet from the on-chain wallet +// registry for the re-anchor transaction's output. The new wallet must be +// in StateLive and must not be the source wallet itself. +// +// Selection here is independent of the source wallet's own moving funds +// commitment (SubmitMovingFundsCommitment / +// PastMovingFundsCommitmentSubmittedEvents): this method 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 +// custody than the BTC the source wallet moves in the same window. This +// is a deliberate custody-scatter-not-theft tradeoff: every reservation +// stays fully accounted for on-chain regardless of which Live wallet +// holds its anchor, so scattering custody across an arbitrary Live wallet +// is a bookkeeping inconvenience, not a fund-safety issue. Selecting from +// the source wallet's actual commitment would require this task to parse +// 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 primary registration 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 this method 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) findTargetWallet( + taskLogger log.StandardLogger, + sourceWalletPublicKeyHash [20]byte, +) ([20]byte, error) { + blockCounter, err := rrt.chain.BlockCounter() + if err != nil { + return [20]byte{}, fmt.Errorf("failed to get block counter: [%v]", err) + } + + currentBlock, err := blockCounter.CurrentBlock() + if err != nil { + return [20]byte{}, fmt.Errorf("failed to get current block: [%v]", err) + } + + startBlock := uint64(0) + if currentBlock > ReservationReanchorLookBackBlocks { + startBlock = currentBlock - ReservationReanchorLookBackBlocks + } + + targetWalletPublicKeyHash, err := rrt.findLiveWalletFromRegistrationEvents( + taskLogger, + sourceWalletPublicKeyHash, + startBlock, + ) + if err == nil { + return targetWalletPublicKeyHash, nil + } + if startBlock == 0 { + // The bounded scan above already covered full chain history. + return [20]byte{}, err + } + + taskLogger.Infof( + "no live re-anchor target registered within the last [%d] blocks, "+ + "falling back to an unbounded registration event scan", + ReservationReanchorLookBackBlocks, + ) + + return rrt.findLiveWalletFromRegistrationEvents( + taskLogger, + sourceWalletPublicKeyHash, + 0, + ) +} + +// findLiveWalletFromRegistrationEvents scans new-wallet-registered events +// starting at startBlock and returns the most-recently-registered Live +// wallet other than sourceWalletPublicKeyHash. +func (rrt *ReservationReanchorTask) findLiveWalletFromRegistrationEvents( + taskLogger log.StandardLogger, + sourceWalletPublicKeyHash [20]byte, + startBlock uint64, +) ([20]byte, error) { + events, err := rrt.chain.PastNewWalletRegisteredEvents( + &tbtc.NewWalletRegisteredEventFilter{StartBlock: startBlock}, + ) + if err != nil { + return [20]byte{}, 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 { + continue + } + + 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 + } + + if wallet.State == tbtc.StateLive { + return walletPubKeyHash, nil + } + } + + return [20]byte{}, fmt.Errorf("no live wallet available for re-anchor target") +} + +// isBelowMovingFundsDustThreshold returns the wallet's resolved main UTXO +// (nil if it has none) and whether its value is below the moving funds +// dust threshold. The threshold is sourced from the on-chain +// MovingFundsParameters. A wallet without a main UTXO is considered to +// have fallen below the threshold. +func (rrt *ReservationReanchorTask) isBelowMovingFundsDustThreshold( + taskLogger log.StandardLogger, + walletPublicKeyHash [20]byte, +) (*bitcoin.UnspentTransactionOutput, bool, error) { + params, err := rrt.chain.GetMovingFundsParameters() + if err != nil { + return nil, false, fmt.Errorf( + "cannot get moving funds parameters: [%w]", + err, + ) + } + + walletChainData, err := rrt.chain.GetWallet(walletPublicKeyHash) + if err != nil { + return nil, false, fmt.Errorf( + "cannot get wallet chain data: [%w]", + err, + ) + } + + if walletChainData.MainUtxoHash == [32]byte{} { + // No main UTXO on-chain, the wallet has fully depleted its pool and + // must release any reservation anchors. + taskLogger.Info("wallet has no main UTXO; below dust threshold") + return nil, true, nil + } + + walletMainUtxo, err := tbtc.DetermineWalletMainUtxo( + walletPublicKeyHash, + rrt.chain, + rrt.btcChain, + ) + if err != nil { + return nil, false, fmt.Errorf( + "cannot determine wallet main UTXO: [%w]", + err, + ) + } + + if walletMainUtxo == nil { + taskLogger.Info("wallet has no resolvable main UTXO; below dust threshold") + return nil, true, nil + } + + below := walletMainUtxo.Value < int64(params.DustThreshold) + if below { + taskLogger.Infof( + "wallet main UTXO value [%d] below moving funds dust threshold [%d]", + walletMainUtxo.Value, + params.DustThreshold, + ) + } + return walletMainUtxo, below, nil +} + +// notifyMovingFundsBelowDustIfEligible checks whether the given (just +// drained) MovingFunds wallet's main UTXO has fallen below the moving +// funds dust threshold and, if so, notifies the Bridge so wallet closing +// can proceed. m1-b-implementation.md ยง5 documents this as the only +// remaining route to close a wallet that proved its funds moved while it +// still held reservation anchors: the Bridge's own automatic closing +// attempt runs once, while the reservation count is still non-zero, and is +// never retried. Errors are logged rather than propagated: a failed +// notification here must not block the coordination window, and the wallet +// remains in StateMovingFunds so the next call to Run retries. +func (rrt *ReservationReanchorTask) notifyMovingFundsBelowDustIfEligible( + taskLogger log.StandardLogger, + walletPublicKeyHash [20]byte, +) { + mainUtxo, below, err := rrt.isBelowMovingFundsDustThreshold( + taskLogger, + walletPublicKeyHash, + ) + if err != nil { + taskLogger.Errorf( + "cannot determine moving funds below-dust eligibility: [%v]", + err, + ) + return + } + if !below { + return + } + + if err := rrt.chain.NotifyMovingFundsBelowDust( + walletPublicKeyHash, + mainUtxo, + ); err != nil { + taskLogger.Errorf( + "cannot notify moving funds below dust: [%v]", + err, + ) + return + } + + taskLogger.Info( + "notified moving funds below dust; wallet has no remaining reservations", + ) +} + +// estimateReservationReanchorFee estimates the fee for a reservation +// re-anchor transaction. The transaction has one P2WPKH input (the +// reservation anchor) and one P2WPKH output (the new anchor under the +// target wallet), so its virtual size is fixed for any single re-anchor. +func estimateReservationReanchorFee( + btcChain bitcoin.Chain, + txMaxFee uint64, +) (int64, error) { + sizeEstimator := bitcoin.NewTransactionSizeEstimator(). + AddPublicKeyHashInputs(1, true). + AddPublicKeyHashOutputs(1, true) + + return estimateReservationFixedSizeTxFee( + btcChain, + sizeEstimator, + txMaxFee, + "reservation re-anchor estimated fee exceeds the maximum fee", + ) +} diff --git a/pkg/tbtcpg/reservation_reanchor_metrics_test.go b/pkg/tbtcpg/reservation_reanchor_metrics_test.go new file mode 100644 index 0000000000..72f543c3e7 --- /dev/null +++ b/pkg/tbtcpg/reservation_reanchor_metrics_test.go @@ -0,0 +1,60 @@ +package tbtcpg + +import ( + "math/big" + "testing" + + "github.com/keep-network/keep-core/pkg/tbtc" +) + +// TestReservationReanchorTask_RecordsLiveWalletsCountGauge is a regression +// test for the same saturation-monitoring gap in the re-anchor task: Run +// already fetches GetLiveWalletsCount but never exposed it as a metric. +func TestReservationReanchorTask_RecordsLiveWalletsCountGauge(t *testing.T) { + lc := NewLocalChain() + btcChain := NewLocalBitcoinChain() + + sourceWalletPublicKeyHash := [20]byte{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1} + targetWalletPublicKeyHash := [20]byte{2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2} + + blockCounter := NewMockBlockCounter() + blockCounter.SetCurrentBlock(1000) + lc.SetBlockCounter(blockCounter) + + if err := lc.AddPastNewWalletRegisteredEvent( + &tbtc.NewWalletRegisteredEventFilter{StartBlock: 0}, + &tbtc.NewWalletRegisteredEvent{WalletPublicKeyHash: targetWalletPublicKeyHash}, + ); err != nil { + t.Fatal(err) + } + + lc.SetWallet(sourceWalletPublicKeyHash, &tbtc.WalletChainData{State: tbtc.StateMovingFunds}) + lc.SetWallet(targetWalletPublicKeyHash, &tbtc.WalletChainData{State: tbtc.StateLive}) + // A reservation exists but has no anchor UTXO, so the wallet is left + // with nothing eligible to re-anchor. This test only cares that the + // live_wallets_count gauge fires before that failure, matching Run's + // actual call order. + reservationKey := big.NewInt(1) + lc.SetWalletReservations(sourceWalletPublicKeyHash, []*big.Int{reservationKey}) + lc.SetReservation(reservationKey, &tbtc.Reservation{ + WalletPublicKeyHash: sourceWalletPublicKeyHash, + State: tbtc.ReservationStateActive, + }) + lc.SetLiveWalletsCount(3) + + task := NewReservationReanchorTask(lc, btcChain) + recorder := newFakeMetricsRecorder() + task.setMetricsRecorder(recorder) + + if _, _, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: sourceWalletPublicKeyHash, + }); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got, ok := recorder.calls["live_wallets_count"]; !ok { + t.Error("expected live_wallets_count gauge to be recorded") + } else if got != 3 { + t.Errorf("expected live_wallets_count = 3, got %v", got) + } +} diff --git a/pkg/tbtcpg/reservation_reanchor_test.go b/pkg/tbtcpg/reservation_reanchor_test.go new file mode 100644 index 0000000000..56f6a20f9b --- /dev/null +++ b/pkg/tbtcpg/reservation_reanchor_test.go @@ -0,0 +1,678 @@ +package tbtcpg_test + +import ( + "math/big" + "testing" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/tbtc" + "github.com/keep-network/keep-core/pkg/tbtcpg" + pkgtest "github.com/keep-network/keep-core/pkg/tbtcpg/internal/test" +) + +func TestReservationReanchorTask_Run(t *testing.T) { + scenarios, err := pkgtest.LoadReservationReanchorTestScenario() + if err != nil { + t.Fatal(err) + } + + for _, scenario := range scenarios { + t.Run(scenario.Title, func(t *testing.T) { + tbtcChain := tbtcpg.NewLocalChain() + btcChain := tbtcpg.NewLocalBitcoinChain() + + // findTargetWallet now 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() + blockCounter.SetCurrentBlock(1000) + tbtcChain.SetBlockCounter(blockCounter) + + mainUtxoHash := scenario.SourceWalletMainUtxoHashBytes + if scenario.SourceWalletMainUtxoTxHash != "" && + scenario.SourceWalletMainUtxoTxHash != "0000000000000000000000000000000000000000000000000000000000000000" { + walletScript, err := bitcoin.PayToWitnessPublicKeyHash( + scenario.SourceWalletPublicKeyHash, + ) + if err != nil { + t.Fatal(err) + } + + mainUtxoTx := &bitcoin.Transaction{ + Version: 1, + Outputs: []*bitcoin.TransactionOutput{{ + Value: scenario.SourceWalletMainUtxoValue, + PublicKeyScript: walletScript, + }}, + } + // DetermineWalletMainUtxo builds the candidate outpoint from + // transaction.Hash() (the tx's own computed hash), not from + // whatever key it happens to be stored under - both must + // agree, so derive the storage key from the same call. + mainUtxoTxHash := mainUtxoTx.Hash() + btcChain.SetTransaction(mainUtxoTxHash, mainUtxoTx) + btcChain.SetTxHashesForPublicKeyHash( + scenario.SourceWalletPublicKeyHash, + []bitcoin.Hash{mainUtxoTxHash}, + ) + + mainUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: mainUtxoTxHash, + OutputIndex: scenario.SourceWalletMainUtxoTxIndex, + }, + Value: scenario.SourceWalletMainUtxoValue, + } + mainUtxoHash = tbtcChain.ComputeMainUtxoHash(mainUtxo) + } + + tbtcChain.SetWallet( + scenario.SourceWalletPublicKeyHash, + &tbtc.WalletChainData{ + State: scenario.SourceWalletState, + MainUtxoHash: mainUtxoHash, + }, + ) + + tbtcChain.SetMovingFundsParameters( + 1000000, + scenario.MovingFundsDustThreshold, + 0, + 0, + nil, + 0, + 0, + 0, + 0, + nil, + 0, + ) + + reservationKeys := make([]*big.Int, 0, len(scenario.Reservations)) + for _, r := range scenario.Reservations { + reservationKeys = append(reservationKeys, r.ReservationKey) + + anchorTxHash, err := bitcoin.NewHashFromString( + r.AnchorTxHash, + bitcoin.ReversedByteOrder, + ) + if err != nil { + t.Fatal(err) + } + + anchorWalletScript, err := bitcoin.PayToWitnessPublicKeyHash( + r.WalletPublicKeyHash, + ) + if err != nil { + t.Fatal(err) + } + anchorOutputs := make([]*bitcoin.TransactionOutput, r.AnchorTxOutputIndex+1) + for i := range anchorOutputs { + anchorOutputs[i] = &bitcoin.TransactionOutput{Value: 1} + } + anchorOutputs[r.AnchorTxOutputIndex] = &bitcoin.TransactionOutput{ + Value: r.AnchorValue, + PublicKeyScript: anchorWalletScript, + } + btcChain.SetTransaction(anchorTxHash, &bitcoin.Transaction{ + Version: 1, + Outputs: anchorOutputs, + }) + + reservationState := r.State + if r.HasPendingAction || r.PendingActionState == tbtc.ReservationActionStatePending { + // On-chain, a reservation with a pending action is in ActionPending state. + reservationState = tbtc.ReservationStateActionPending + } + + tbtcChain.SetReservation(r.ReservationKey, &tbtc.Reservation{ + WalletPublicKeyHash: r.WalletPublicKeyHash, + AnchorUtxo: &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHash, + OutputIndex: r.AnchorTxOutputIndex, + }, + Value: r.AnchorValue, + }, + State: reservationState, + RequestNonce: r.RequestNonce, + }) + + // Always install an action record when RequestNonce > 0 so + // hasPendingAction's real GetReservationAction lookup + // succeeds and evaluates State directly, matching what a + // real chain would have: RequestNonce only ever advances + // alongside a real action record. HasPendingAction=false + // scenarios use r.PendingActionState (a terminal state, not + // Pending) to model an already-settled prior generation. + if r.RequestNonce > 0 { + tbtcChain.SetReservationAction( + r.ReservationKey, + r.RequestNonce, + &tbtc.ReservationAction{ + ActionType: tbtc.ReservationActionTypeReanchor, + State: r.PendingActionState, + }, + ) + } + } + tbtcChain.SetWalletReservations( + scenario.SourceWalletPublicKeyHash, + reservationKeys, + ) + + tbtcChain.SetReservationParameters(tbtc.ReservationParameters{ + ReservationTxMaxFee: scenario.ReservationTxMaxFee, + }) + + btcChain.SetEstimateSatPerVByteFee(1, scenario.EstimateSatPerVByteFee) + + if scenario.TargetWalletPublicKeyHash != [20]byte{} { + err := tbtcChain.AddPastNewWalletRegisteredEvent( + &tbtc.NewWalletRegisteredEventFilter{StartBlock: 0}, + &tbtc.NewWalletRegisteredEvent{ + WalletPublicKeyHash: scenario.TargetWalletPublicKeyHash, + }, + ) + if err != nil { + t.Fatal(err) + } + + tbtcChain.SetWallet( + scenario.TargetWalletPublicKeyHash, + &tbtc.WalletChainData{ + State: tbtc.StateLive, + }, + ) + } + + tbtcChain.SetLiveWalletsCount(scenario.LiveWalletsCount) + + task := tbtcpg.NewReservationReanchorTask(tbtcChain, btcChain) + + if scenario.ExpectedProposal != nil { + err := tbtcChain.SetReservationReanchorProposalValidationResult( + scenario.SourceWalletPublicKeyHash, + scenario.ExpectedProposal, + true, + ) + if err != nil { + t.Fatal(err) + } + } + + proposal, _, err := task.Run( + &tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: scenario.SourceWalletPublicKeyHash, + }, + ) + + expectedErrStr := "" + if scenario.ExpectedErr != nil { + expectedErrStr = scenario.ExpectedErr.Error() + } + actualErrStr := "" + if err != nil { + actualErrStr = err.Error() + } + if expectedErrStr != actualErrStr { + t.Errorf( + "unexpected error\nexpected: %v\nactual: %v", + scenario.ExpectedErr, + err, + ) + } + + actualProposal, _ := proposal.(*tbtc.ReservationReanchorProposal) + + if !reanchorProposalsEqual(scenario.ExpectedProposal, actualProposal) { + t.Errorf( + "invalid reservation re-anchor proposal\n"+ + "expected: %+v\n"+ + "actual: %+v", + scenario.ExpectedProposal, + actualProposal, + ) + } + }) + } +} + +// reanchorProposalsEqual compares two proposals field-by-field. deep.Equal +// cannot be used here (or anywhere else the package compares a +// *tbtc.ReservationReanchorProposal/*tbtc.ReservationAnchorProposal): by +// default it does not descend into unexported fields, and *big.Int's +// representation is entirely unexported, so deep.Equal silently reports "no +// difference" for any two distinct *big.Int values. ReservationKey and +// ReanchorTxFee are both *big.Int, so they need an explicit .Cmp(). +func reanchorProposalsEqual( + expected, actual *tbtc.ReservationReanchorProposal, +) bool { + if expected == nil && actual == nil { + return true + } + if expected == nil || actual == nil { + return false + } + if (expected.ReservationKey == nil) != (actual.ReservationKey == nil) { + return false + } + if expected.ReservationKey != nil && + expected.ReservationKey.Cmp(actual.ReservationKey) != 0 { + return false + } + if expected.RequestNonce != actual.RequestNonce { + return false + } + if expected.TargetWalletPublicKeyHash != actual.TargetWalletPublicKeyHash { + return false + } + if (expected.ReanchorTxFee == nil) != (actual.ReanchorTxFee == nil) { + return false + } + if expected.ReanchorTxFee != nil && + expected.ReanchorTxFee.Cmp(actual.ReanchorTxFee) != 0 { + return false + } + return true +} + +func TestReservationReanchorTask_TargetWalletExclusion_SharedTask(t *testing.T) { + walletA := hexToByte20("1111111111111111111111111111111111111111") + walletB := hexToByte20("2222222222222222222222222222222222222222") + walletC := hexToByte20("3333333333333333333333333333333333333333") + + tbtcChain := tbtcpg.NewLocalChain() + btcChain := tbtcpg.NewLocalBitcoinChain() + + blockCounter := tbtcpg.NewMockBlockCounter() + blockCounter.SetCurrentBlock(1000) + tbtcChain.SetBlockCounter(blockCounter) + + // Register walletC at block 100, then walletB at block 200 (walletB is newest). + err := tbtcChain.AddPastNewWalletRegisteredEvent( + &tbtc.NewWalletRegisteredEventFilter{StartBlock: 0}, + &tbtc.NewWalletRegisteredEvent{WalletPublicKeyHash: walletC}, + ) + if err != nil { + t.Fatal(err) + } + err = tbtcChain.AddPastNewWalletRegisteredEvent( + &tbtc.NewWalletRegisteredEventFilter{StartBlock: 0}, + &tbtc.NewWalletRegisteredEvent{WalletPublicKeyHash: walletB}, + ) + if err != nil { + t.Fatal(err) + } + + tbtcChain.SetWallet(walletA, &tbtc.WalletChainData{State: tbtc.StateMovingFunds}) + tbtcChain.SetWallet(walletB, &tbtc.WalletChainData{State: tbtc.StateLive}) + tbtcChain.SetWallet(walletC, &tbtc.WalletChainData{State: tbtc.StateLive}) + tbtcChain.SetLiveWalletsCount(2) + + tbtcChain.SetMovingFundsParameters( + 1000000, + 1000000, + 0, + 0, + nil, + 0, + 0, + 0, + 0, + nil, + 0, + ) + tbtcChain.SetReservationParameters(tbtc.ReservationParameters{ + ReservationTxMaxFee: 100000, + }) + btcChain.SetEstimateSatPerVByteFee(1, 1) + + // Setup reservation for Wallet A. + resAKey := big.NewInt(1001) + anchorTxHashA, _ := bitcoin.NewHashFromString( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + bitcoin.ReversedByteOrder, + ) + walletAScript, err := bitcoin.PayToWitnessPublicKeyHash(walletA) + if err != nil { + t.Fatal(err) + } + btcChain.SetTransaction(anchorTxHashA, &bitcoin.Transaction{ + Version: 1, + Outputs: []*bitcoin.TransactionOutput{ + {Value: 1}, + {Value: 100000, PublicKeyScript: walletAScript}, + }, + }) + tbtcChain.SetReservation(resAKey, &tbtc.Reservation{ + WalletPublicKeyHash: walletA, + AnchorUtxo: &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHashA, + OutputIndex: 1, + }, + Value: 100000, + }, + State: tbtc.ReservationStateActive, + RequestNonce: 0, + }) + tbtcChain.SetWalletReservations(walletA, []*big.Int{resAKey}) + + // Setup reservation for Wallet B. + resBKey := big.NewInt(2001) + anchorTxHashB, _ := bitcoin.NewHashFromString( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + bitcoin.ReversedByteOrder, + ) + walletBScript, err := bitcoin.PayToWitnessPublicKeyHash(walletB) + if err != nil { + t.Fatal(err) + } + btcChain.SetTransaction(anchorTxHashB, &bitcoin.Transaction{ + Version: 1, + Outputs: []*bitcoin.TransactionOutput{ + {Value: 1}, + {Value: 200000, PublicKeyScript: walletBScript}, + }, + }) + tbtcChain.SetReservation(resBKey, &tbtc.Reservation{ + WalletPublicKeyHash: walletB, + AnchorUtxo: &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHashB, + OutputIndex: 1, + }, + Value: 200000, + }, + State: tbtc.ReservationStateActive, + RequestNonce: 0, + }) + tbtcChain.SetWalletReservations(walletB, []*big.Int{resBKey}) + + // Single task instance used for both runs. + task := tbtcpg.NewReservationReanchorTask(tbtcChain, btcChain) + + // First run for wallet A: should select wallet B as target. + propA, okA, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletA, + }) + if err != nil { + t.Fatalf("unexpected error for wallet A: %v", err) + } + if !okA || propA == nil { + t.Fatalf("expected proposal for wallet A, got ok=%v, prop=%v", okA, propA) + } + proposalA, ok := propA.(*tbtc.ReservationReanchorProposal) + if !ok { + t.Fatalf("unexpected proposal type: %T", propA) + } + if proposalA.TargetWalletPublicKeyHash != walletB { + t.Errorf( + "wallet A expected target walletB [%x], got [%x]", + walletB, + proposalA.TargetWalletPublicKeyHash, + ) + } + + // Second run with the SAME task instance for wallet B (now in StateMovingFunds): + // Must NOT select wallet B (itself), even though wallet B was cached in the previous run. + // Must select wallet C instead. + tbtcChain.SetWallet(walletB, &tbtc.WalletChainData{State: tbtc.StateMovingFunds}) + propB, okB, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletB, + }) + if err != nil { + t.Fatalf("unexpected error for wallet B: %v", err) + } + if !okB || propB == nil { + t.Fatalf("expected proposal for wallet B, got ok=%v, prop=%v", okB, propB) + } + proposalB, ok := propB.(*tbtc.ReservationReanchorProposal) + if !ok { + t.Fatalf("unexpected proposal type: %T", propB) + } + if proposalB.TargetWalletPublicKeyHash == walletB { + t.Errorf("wallet B selected itself as target wallet: [%x]", walletB) + } + if proposalB.TargetWalletPublicKeyHash != walletC { + t.Errorf( + "wallet B expected target walletC [%x], got [%x]", + walletC, + proposalB.TargetWalletPublicKeyHash, + ) + } + + // Third run: if wallet C is not live, wallet B must not produce any proposal + // (must not fall back to selecting itself). + tbtcChain.SetWallet(walletC, &tbtc.WalletChainData{State: tbtc.StateMovingFunds}) + propB2, okB2, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletB, + }) + if err != nil { + t.Fatalf("unexpected error on third run: %v", err) + } + if okB2 || propB2 != nil { + t.Errorf("expected no proposal when no other live wallet exists, got prop=%v", propB2) + } +} + +func TestReservationReanchorTask_Run_SkipNonActiveReservations(t *testing.T) { + walletA := hexToByte20("1111111111111111111111111111111111111111") + walletB := hexToByte20("2222222222222222222222222222222222222222") + + tbtcChain := tbtcpg.NewLocalChain() + btcChain := tbtcpg.NewLocalBitcoinChain() + + blockCounter := tbtcpg.NewMockBlockCounter() + blockCounter.SetCurrentBlock(1000) + tbtcChain.SetBlockCounter(blockCounter) + + err := tbtcChain.AddPastNewWalletRegisteredEvent( + &tbtc.NewWalletRegisteredEventFilter{StartBlock: 0}, + &tbtc.NewWalletRegisteredEvent{WalletPublicKeyHash: walletB}, + ) + if err != nil { + t.Fatal(err) + } + + tbtcChain.SetWallet(walletA, &tbtc.WalletChainData{State: tbtc.StateMovingFunds}) + tbtcChain.SetWallet(walletB, &tbtc.WalletChainData{State: tbtc.StateLive}) + tbtcChain.SetLiveWalletsCount(1) + + tbtcChain.SetMovingFundsParameters( + 1000000, + 1000000, + 0, + 0, + nil, + 0, + 0, + 0, + 0, + nil, + 0, + ) + tbtcChain.SetReservationParameters(tbtc.ReservationParameters{ + ReservationTxMaxFee: 100000, + }) + btcChain.SetEstimateSatPerVByteFee(1, 1) + + // Setup 2 reservations for Wallet A: + // res1 is in ReservationStateActionPending (should be skipped) + // res2 is in ReservationStateActive (should be proposed) + res1Key := big.NewInt(101) + anchorTxHash1, _ := bitcoin.NewHashFromString( + "1111111111111111111111111111111111111111111111111111111111111111", + bitcoin.ReversedByteOrder, + ) + walletAScript, err := bitcoin.PayToWitnessPublicKeyHash(walletA) + if err != nil { + t.Fatal(err) + } + btcChain.SetTransaction(anchorTxHash1, &bitcoin.Transaction{ + Version: 1, + Outputs: []*bitcoin.TransactionOutput{ + {Value: 1}, + {Value: 100000, PublicKeyScript: walletAScript}, + }, + }) + tbtcChain.SetReservation(res1Key, &tbtc.Reservation{ + WalletPublicKeyHash: walletA, + AnchorUtxo: &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHash1, + OutputIndex: 1, + }, + Value: 100000, + }, + State: tbtc.ReservationStateActionPending, + RequestNonce: 1, + }) + + res2Key := big.NewInt(102) + anchorTxHash2, _ := bitcoin.NewHashFromString( + "2222222222222222222222222222222222222222222222222222222222222222", + bitcoin.ReversedByteOrder, + ) + btcChain.SetTransaction(anchorTxHash2, &bitcoin.Transaction{ + Version: 1, + Outputs: []*bitcoin.TransactionOutput{ + {Value: 1}, + {Value: 200000, PublicKeyScript: walletAScript}, + }, + }) + tbtcChain.SetReservation(res2Key, &tbtc.Reservation{ + WalletPublicKeyHash: walletA, + AnchorUtxo: &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHash2, + OutputIndex: 1, + }, + Value: 200000, + }, + State: tbtc.ReservationStateActive, + RequestNonce: 0, + }) + + tbtcChain.SetWalletReservations(walletA, []*big.Int{res1Key, res2Key}) + + task := tbtcpg.NewReservationReanchorTask(tbtcChain, btcChain) + + prop, ok, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletA, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok || prop == nil { + t.Fatalf("expected proposal, got ok=%v, prop=%v", ok, prop) + } + proposal, ok := prop.(*tbtc.ReservationReanchorProposal) + if !ok { + t.Fatalf("unexpected proposal type: %T", prop) + } + if proposal.ReservationKey.Cmp(res2Key) != 0 { + t.Errorf("expected proposal for res2 [102], got [%v]", proposal.ReservationKey) + } + if proposal.TargetWalletPublicKeyHash != walletB { + t.Errorf("expected target walletB [%x], got [%x]", walletB, proposal.TargetWalletPublicKeyHash) + } +} + +// TestReservationReanchorTask_Run_NotifiesMovingFundsBelowDust is a +// regression test for the NotifyMovingFundsBelowDust wiring: once a +// MovingFunds wallet has no reservations left and its main UTXO is below +// the moving funds dust threshold, Run must call NotifyMovingFundsBelowDust +// exactly once with the wallet's resolved main UTXO. A wallet above the +// dust threshold must not trigger any notification. +func TestReservationReanchorTask_Run_NotifiesMovingFundsBelowDust(t *testing.T) { + walletPublicKeyHash := hexToByte20("ffb3f7538bfa98a511495dd96027cfbd57baf2fa") + + newFixture := func(mainUtxoValue int64) (*tbtcpg.LocalChain, *tbtcpg.LocalBitcoinChain) { + tbtcChain := tbtcpg.NewLocalChain() + btcChain := tbtcpg.NewLocalBitcoinChain() + + walletScript, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + mainUtxoTx := &bitcoin.Transaction{ + Version: 1, + Outputs: []*bitcoin.TransactionOutput{{ + Value: mainUtxoValue, + PublicKeyScript: walletScript, + }}, + } + mainUtxoTxHash := mainUtxoTx.Hash() + btcChain.SetTransaction(mainUtxoTxHash, mainUtxoTx) + btcChain.SetTxHashesForPublicKeyHash( + walletPublicKeyHash, + []bitcoin.Hash{mainUtxoTxHash}, + ) + + mainUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: mainUtxoTxHash, + OutputIndex: 0, + }, + Value: mainUtxoValue, + } + + tbtcChain.SetWallet(walletPublicKeyHash, &tbtc.WalletChainData{ + State: tbtc.StateMovingFunds, + MainUtxoHash: tbtcChain.ComputeMainUtxoHash(mainUtxo), + }) + tbtcChain.SetMovingFundsParameters( + 1000000, 1000000, 0, 0, nil, 0, 0, 0, 0, nil, 0, + ) + tbtcChain.SetWalletReservations(walletPublicKeyHash, nil) + + return tbtcChain, btcChain + } + + t.Run("below dust threshold: notifies exactly once", func(t *testing.T) { + tbtcChain, btcChain := newFixture(500000) + task := tbtcpg.NewReservationReanchorTask(tbtcChain, btcChain) + + prop, ok, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok || prop != nil { + t.Fatalf("expected no proposal, got ok=%v, prop=%v", ok, prop) + } + + notifications := tbtcChain.GetBelowDustNotifications() + if len(notifications) != 1 { + t.Fatalf("expected exactly 1 below-dust notification, got %d", len(notifications)) + } + if notifications[0].WalletPublicKeyHash != walletPublicKeyHash { + t.Errorf( + "unexpected notified wallet\nexpected: %x\nactual: %x", + walletPublicKeyHash, + notifications[0].WalletPublicKeyHash, + ) + } + if notifications[0].MainUtxo == nil || notifications[0].MainUtxo.Value != 500000 { + t.Errorf("unexpected notified main UTXO: %+v", notifications[0].MainUtxo) + } + }) + + t.Run("above dust threshold: no notification", func(t *testing.T) { + tbtcChain, btcChain := newFixture(2000000) + 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, got %d", len(notifications)) + } + }) +} diff --git a/pkg/tbtcpg/tbtcpg.go b/pkg/tbtcpg/tbtcpg.go index 38e2be8628..2eee5cf194 100644 --- a/pkg/tbtcpg/tbtcpg.go +++ b/pkg/tbtcpg/tbtcpg.go @@ -18,10 +18,11 @@ import ( "strings" "github.com/ipfs/go-log/v2" - "github.com/keep-network/keep-core/pkg/bitcoin" - "github.com/keep-network/keep-core/pkg/tbtc" "go.uber.org/zap" "golang.org/x/exp/slices" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/tbtc" ) var logger = log.Logger("keep-tbtcpg") @@ -57,10 +58,35 @@ func (pg *ProposalGenerator) SetRedemptionMetricsRecorder(recorder interface { } } -// NewProposalGenerator returns a new proposal generator. +// SetReservationMetricsRecorder sets the metrics recorder for the +// reservation acceptance and re-anchor tasks (registered only when +// reservationsEnabled - see NewProposalGenerator). A no-op when +// reservations are disabled since neither task is present in pg.tasks. +func (pg *ProposalGenerator) SetReservationMetricsRecorder(recorder interface { + SetGauge(name string, value float64) +}) { + for _, task := range pg.tasks { + switch t := task.(type) { + case *ReservationAcceptanceTask: + t.setMetricsRecorder(recorder) + case *ReservationReanchorTask: + t.setMetricsRecorder(recorder) + } + } +} + +// NewProposalGenerator returns a new proposal generator. When +// reservationsEnabled is true the proposal generator appends the reservation +// acceptance (anchor) and re-anchor tasks to the standard task list so that +// wallets with reservations can produce anchor and re-anchor proposals in +// addition to the default sweep / redemption / heartbeat / moving-funds / +// moved-funds-sweep proposals. When reservationsEnabled is false the +// reservation tasks are skipped entirely; the proposal generator is safe to +// construct in either mode and the existing task ordering is preserved. func NewProposalGenerator( chain Chain, btcChain bitcoin.Chain, + reservationsEnabled bool, ) *ProposalGenerator { tasks := []ProposalTask{ NewDepositSweepTask(chain, btcChain), @@ -70,6 +96,19 @@ func NewProposalGenerator( NewMovedFundsSweepTask(chain, btcChain), } + if reservationsEnabled { + // These tasks only run when the operator has opted into the + // reservation feature via config.Reservations.Enabled; the gate + // is applied at task registration so the coordination loop + // never even considers these actions on a non-reservation + // deployment. + tasks = append( + tasks, + NewReservationAcceptanceTask(chain, btcChain), + NewReservationReanchorTask(chain, btcChain), + ) + } + return &ProposalGenerator{ tasks: tasks, } diff --git a/pkg/tbtcpg/tbtcpg_test.go b/pkg/tbtcpg/tbtcpg_test.go index 75e13d6744..3cafe218f6 100644 --- a/pkg/tbtcpg/tbtcpg_test.go +++ b/pkg/tbtcpg/tbtcpg_test.go @@ -202,6 +202,69 @@ func TestProposalGenerator_Generate(t *testing.T) { } } +// TestNewProposalGenerator_ReservationsEnabled verifies the constructor's +// reservationsEnabled gate: when true, the reservation acceptance and +// re-anchor tasks must be wired into the generator's task list; when false, +// they must be entirely absent so the coordination loop never attempts +// them. Presence/absence is observed indirectly through Generate(), since +// pg.tasks is unexported: a checklist made up solely of reservation action +// types is either dispatched to a real task (which fails deterministically +// against the unconfigured chain double, proving the task was found) or +// falls through as unsupported to a nil-error no-op proposal (proving no +// task claims that action type). +func TestNewProposalGenerator_ReservationsEnabled(t *testing.T) { + walletPublicKeyHash := [20]byte{1, 2, 3} + + request := &tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + ActionsChecklist: []tbtc.WalletActionType{ + tbtc.ActionReservationAnchor, + tbtc.ActionReservationReanchor, + }, + } + + t.Run("enabled: reservation tasks are wired in", func(t *testing.T) { + generator := NewProposalGenerator( + NewLocalChain(), + NewLocalBitcoinChain(), + true, + ) + + for _, action := range []tbtc.WalletActionType{ + tbtc.ActionReservationAnchor, + tbtc.ActionReservationReanchor, + } { + _, err := generator.Generate(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + ActionsChecklist: []tbtc.WalletActionType{action}, + }) + if err == nil { + t.Errorf("expected error for action %v, got nil", action) + } + } + }) + + t.Run("disabled: reservation tasks are absent", func(t *testing.T) { + generator := NewProposalGenerator( + NewLocalChain(), + NewLocalBitcoinChain(), + false, + ) + + proposal, err := generator.Generate(request) + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + if !reflect.DeepEqual(&tbtc.NoopProposal{}, proposal) { + t.Fatalf( + "expected a no-op proposal since no task should claim "+ + "either reservation action type, got [%+v]", + proposal, + ) + } + }) +} + type mockProposalTaskResult uint8 const ( diff --git a/test/config.json b/test/config.json index 0792a2b49d..b3fc4be6b0 100644 --- a/test/config.json +++ b/test/config.json @@ -50,9 +50,18 @@ "HistoryDepth": 25000, "TransactionLimit": 80, "RestartBackoffTime": "2h", - "IdleBackoffTime": "15m" + "IdleBackoffTime": "15m", + "Reservations": { + "Enabled": true + } } }, + "Tbtc": { + "Reservations": { + "Enabled": true + } + }, + "Developer": { "RandomBeaconAddress": "0xcf64c2a367341170cb4e09cf8c0ed137d8473ceb", "WalletRegistryAddress": "0x143ba24e66fce8bca22f7d739f9a932c519b1c76", diff --git a/test/config.toml b/test/config.toml index 8e22dae494..7050f88b5e 100644 --- a/test/config.toml +++ b/test/config.toml @@ -47,6 +47,12 @@ TransactionLimit = 80 RestartBackoffTime = "2h" IdleBackoffTime = "15m" +[maintainer.Spv.Reservations] +Enabled = true + +[tbtc.reservations] +Enabled = true + [developer] RandomBeaconAddress = "0xcf64c2a367341170cb4e09cf8c0ed137d8473ceb" WalletRegistryAddress = "0x143ba24e66fce8bca22f7d739f9a932c519b1c76" diff --git a/test/config.yaml b/test/config.yaml index 8809033eeb..3f24e4c360 100644 --- a/test/config.yaml +++ b/test/config.yaml @@ -41,6 +41,12 @@ Maintainer: TransactionLimit: 80 RestartBackoffTime: "2h" IdleBackoffTime: "15m" + Reservations: + Enabled: true +Tbtc: + Reservations: + Enabled: true + Developer: RandomBeaconAddress: "0xcf64c2a367341170cb4e09cf8c0ed137d8473ceb" WalletRegistryAddress: "0x143ba24e66fce8bca22f7d739f9a932c519b1c76"