From 009cf3d39384a1ea7a135efb9ca61706a9c54f71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 30 Sep 2026 08:13:21 +0000 Subject: [PATCH 01/31] fix(clientinfo): name vault fee reserve gauge in TBTC base units --- pkg/clientinfo/performance.go | 8 +++---- pkg/clientinfo/performance_test.go | 6 ++--- pkg/tbtcpg/reservation_acceptance.go | 8 +++---- .../reservation_acceptance_metrics_test.go | 24 +++++++++---------- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/pkg/clientinfo/performance.go b/pkg/clientinfo/performance.go index 49f3f30030..09d86491ef 100644 --- a/pkg/clientinfo/performance.go +++ b/pkg/clientinfo/performance.go @@ -394,7 +394,7 @@ func (pm *PerformanceMetrics) registerGaugeMetrics() { MetricReservationLiveWalletsCount, MetricReservationWalletReservationsCount, MetricReservationVaultFeeDebtSat, - MetricReservationVaultFeeReserveTbtc, + MetricReservationVaultFeeReserveTbtcBaseUnits, ) } @@ -758,15 +758,15 @@ const ( // // - MetricReservationVaultFeeDebtSat is in satoshi, matching the // on-chain inKindFeeDebtSat view. - // - MetricReservationVaultFeeReserveTbtc is in TBTC base units + // - MetricReservationVaultFeeReserveTbtcBaseUnits is in TBTC base units // (1e18 per whole TBTC): a gauge value of N means N / 1e18 // whole TBTC held in reserve. The balance is a big.Int while // the gauge API takes a float64, so the base-unit figure is // published directly; float64 represents base units // approximately above 2^53 base units, so consumers must // treat the gauge as an indicator, not for exact accounting. - MetricReservationVaultFeeDebtSat = "reservation_vault_fee_debt_sat" - MetricReservationVaultFeeReserveTbtc = "reservation_vault_fee_reserve_tbtc" + MetricReservationVaultFeeDebtSat = "reservation_vault_fee_debt_sat" + MetricReservationVaultFeeReserveTbtcBaseUnits = "reservation_vault_fee_reserve_tbtc_base_units" ) // Network join request failure reasons. These are the low-cardinality diff --git a/pkg/clientinfo/performance_test.go b/pkg/clientinfo/performance_test.go index 31cca2fc81..cddf536a61 100644 --- a/pkg/clientinfo/performance_test.go +++ b/pkg/clientinfo/performance_test.go @@ -720,7 +720,7 @@ func TestWalletActionMetricsRegisteredRegardlessOfReservationsFlag(t *testing.T) // TestReservationGaugesRegistered verifies the six reservation gauges // (active_reservations_count, max_active_reservations, live_wallets_count, // wallet_reservations_count, reservation_vault_fee_debt_sat, -// reservation_vault_fee_reserve_tbtc) are registered upfront with a 0 +// reservation_vault_fee_reserve_tbtc_base_units) are registered upfront with a 0 // value when reservations are enabled. func TestReservationGaugesRegistered(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) @@ -735,7 +735,7 @@ func TestReservationGaugesRegistered(t *testing.T) { MetricReservationLiveWalletsCount, MetricReservationWalletReservationsCount, MetricReservationVaultFeeDebtSat, - MetricReservationVaultFeeReserveTbtc, + MetricReservationVaultFeeReserveTbtcBaseUnits, } for _, name := range reservationGauges { @@ -782,7 +782,7 @@ func TestReservationGaugesNotRegisteredWhenReservationsDisabled(t *testing.T) { MetricReservationLiveWalletsCount, MetricReservationWalletReservationsCount, MetricReservationVaultFeeDebtSat, - MetricReservationVaultFeeReserveTbtc, + MetricReservationVaultFeeReserveTbtcBaseUnits, } for _, name := range reservationGauges { diff --git a/pkg/tbtcpg/reservation_acceptance.go b/pkg/tbtcpg/reservation_acceptance.go index 0cac75c8f9..81d20808a1 100644 --- a/pkg/tbtcpg/reservation_acceptance.go +++ b/pkg/tbtcpg/reservation_acceptance.go @@ -42,7 +42,7 @@ type ReservationAcceptanceTask struct { // metricsRecorder is optional and used for recording performance // metrics: active_reservations_count, max_active_reservations, // wallet_reservations_count, reservation_vault_fee_debt_sat, and - // reservation_vault_fee_reserve_tbtc, sourced from the chain calls + // reservation_vault_fee_reserve_tbtc_base_units, sourced from the chain calls // this task already makes in findReservationAcceptanceCandidate. // These are leading indicators of reservation capacity saturation. metricsRecorder interface { @@ -366,7 +366,7 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( 0, ) rat.metricsRecorder.SetGauge( - "reservation_vault_fee_reserve_tbtc", + "reservation_vault_fee_reserve_tbtc_base_units", 0, ) } @@ -427,7 +427,7 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( } // reservation_vault_fee_debt_sat / - // reservation_vault_fee_reserve_tbtc publish the ReservationVault's + // reservation_vault_fee_reserve_tbtc_base_units publish the ReservationVault's // outstanding in-kind fee debt (in satoshi) and fee-reserve balance // (in TBTC base units, 1e18 per whole TBTC; a gauge value of N // means N / 1e18 whole TBTC), unconditionally on every pass, @@ -464,7 +464,7 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( } else if feeReserve != nil { reserveFloat, _ := feeReserve.Float64() rat.metricsRecorder.SetGauge( - "reservation_vault_fee_reserve_tbtc", + "reservation_vault_fee_reserve_tbtc_base_units", reserveFloat, ) } diff --git a/pkg/tbtcpg/reservation_acceptance_metrics_test.go b/pkg/tbtcpg/reservation_acceptance_metrics_test.go index c9f2079bf9..341c399c3d 100644 --- a/pkg/tbtcpg/reservation_acceptance_metrics_test.go +++ b/pkg/tbtcpg/reservation_acceptance_metrics_test.go @@ -118,9 +118,9 @@ func TestReservationAcceptanceTask_RecordsSaturationGauges(t *testing.T) { "expected reservation_vault_fee_debt_sat gauge to be recorded", ) } - if _, ok := recorder.calls["reservation_vault_fee_reserve_tbtc"]; !ok { + if _, ok := recorder.calls["reservation_vault_fee_reserve_tbtc_base_units"]; !ok { t.Error( - "expected reservation_vault_fee_reserve_tbtc gauge to be recorded", + "expected reservation_vault_fee_reserve_tbtc_base_units gauge to be recorded", ) } } @@ -187,11 +187,11 @@ func TestReservationAcceptanceTask_RecordsVaultFeeGauges(t *testing.T) { // task's big.Int -> float64 conversion), so the assertion tracks // the exact value the recorder would have received. wantReserve, _ := new(big.Int).Lsh(big.NewInt(2), 18).Float64() - if got, ok := recorder.calls["reservation_vault_fee_reserve_tbtc"]; !ok { - t.Error("expected reservation_vault_fee_reserve_tbtc gauge to be recorded") + if got, ok := recorder.calls["reservation_vault_fee_reserve_tbtc_base_units"]; !ok { + t.Error("expected reservation_vault_fee_reserve_tbtc_base_units gauge to be recorded") } else if got != wantReserve { t.Errorf( - "expected reservation_vault_fee_reserve_tbtc = %v, got %v", + "expected reservation_vault_fee_reserve_tbtc_base_units = %v, got %v", wantReserve, got, ) @@ -234,9 +234,9 @@ func TestReservationAcceptanceTask_VaultFeeReadErrorKeepsGauges(t *testing.T) { "reservation_vault_fee_debt_sat must not be published on read error", ) } - if _, ok := recorder.calls["reservation_vault_fee_reserve_tbtc"]; ok { + if _, ok := recorder.calls["reservation_vault_fee_reserve_tbtc_base_units"]; ok { t.Error( - "reservation_vault_fee_reserve_tbtc must not be published on read error", + "reservation_vault_fee_reserve_tbtc_base_units must not be published on read error", ) } @@ -295,10 +295,10 @@ func TestReservationAcceptanceTask_ZeroVaultAddressZeroesFeeGauges(t *testing.T) } else if got != 0 { t.Errorf("expected reservation_vault_fee_debt_sat = 0, got %v", got) } - if got, ok := recorder.calls["reservation_vault_fee_reserve_tbtc"]; !ok { - t.Error("expected reservation_vault_fee_reserve_tbtc gauge to be recorded") + if got, ok := recorder.calls["reservation_vault_fee_reserve_tbtc_base_units"]; !ok { + t.Error("expected reservation_vault_fee_reserve_tbtc_base_units gauge to be recorded") } else if got != 0 { - t.Errorf("expected reservation_vault_fee_reserve_tbtc = 0, got %v", got) + t.Errorf("expected reservation_vault_fee_reserve_tbtc_base_units = 0, got %v", got) } } @@ -339,7 +339,7 @@ func TestReservationAcceptanceTask_UnconfiguredVaultZeroesFeeGauges(t *testing.T if got := recorder.calls["reservation_vault_fee_debt_sat"]; got != 12345 { t.Fatalf("pass 1: expected fee-debt gauge 12345, got %v", got) } - if got := recorder.calls["reservation_vault_fee_reserve_tbtc"]; got == 0 { + if got := recorder.calls["reservation_vault_fee_reserve_tbtc_base_units"]; got == 0 { t.Fatalf("pass 1: expected a nonzero fee-reserve gauge, got 0") } @@ -364,7 +364,7 @@ func TestReservationAcceptanceTask_UnconfiguredVaultZeroesFeeGauges(t *testing.T got, ) } - if got := recorder.calls["reservation_vault_fee_reserve_tbtc"]; got != 0 { + if got := recorder.calls["reservation_vault_fee_reserve_tbtc_base_units"]; got != 0 { t.Errorf( "pass 2: expected the fee-reserve gauge zeroed for an "+ "unconfigured vault, got %v", From 2fde8d3becd86969be06f76a5fc0feb8e10d0930 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 30 Sep 2026 08:15:14 +0000 Subject: [PATCH 02/31] test(spv): return zero records for absent reservation keys and honour EndBlock in event fakes --- pkg/maintainer/spv/chain_test.go | 41 ++++++++++++++++--- .../spv/reservation_proof_loop_test.go | 33 +++++++++++---- .../reservation_stale_deposit_watch_test.go | 4 +- 3 files changed, 62 insertions(+), 16 deletions(-) diff --git a/pkg/maintainer/spv/chain_test.go b/pkg/maintainer/spv/chain_test.go index 2bcecf9aed..e35982d318 100644 --- a/pkg/maintainer/spv/chain_test.go +++ b/pkg/maintainer/spv/chain_test.go @@ -107,10 +107,12 @@ type localChain struct { // production zero-value default when no pre-termination event is found. walletTerminationCauses map[[20]byte]tbtc.WalletTerminationCause walletTerminationCauseCalls int + getReservationCalls int // Error-injection fields for the reservation watcher chain-error // passthrough tests: nil (the default) means the corresponding method // falls through to its normal, table-driven behavior. + getReservationErr error getReservationActionErr error walletReservationsErr error isReservedDepositErr error @@ -1062,21 +1064,37 @@ func (lc *localChain) setWalletTerminationCause( } // GetReservation returns the reservation previously installed via -// setReservation. Returns an error when no test record is installed. +// setReservation. An absent key yields a zero record +// (ReservationStateUnknown), like the Bridge's reservations mapping. func (lc *localChain) GetReservation( reservationKey *big.Int, ) (*tbtc.Reservation, error) { lc.mutex.Lock() defer lc.mutex.Unlock() + lc.getReservationCalls++ + + if lc.getReservationErr != nil { + return nil, lc.getReservationErr + } + key := bigIntKey(reservationKey) reservation, ok := lc.reservations[key] if !ok { - return nil, fmt.Errorf("no reservation for given key") + return &tbtc.Reservation{}, nil } return reservation, nil } +// getReservationCallCount returns how many times GetReservation has been +// invoked. +func (lc *localChain) getReservationCallCount() int { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + return lc.getReservationCalls +} + // setReservation installs a reservation for GetReservation to return. func (lc *localChain) setReservation( reservationKey *big.Int, @@ -1089,7 +1107,9 @@ func (lc *localChain) setReservation( } // GetReservationAction returns the reservation action previously installed -// via setReservationAction. Returns an error if the action is not set. +// via setReservationAction. An absent generation yields a zero record +// (ReservationActionStateUnknown), like the Bridge's reservationActions +// mapping. func (lc *localChain) GetReservationAction( reservationKey *big.Int, requestNonce uint64, @@ -1104,7 +1124,7 @@ func (lc *localChain) GetReservationAction( key := buildReservationActionKey(reservationKey, requestNonce) action, ok := lc.reservationActions[key] if !ok { - return nil, fmt.Errorf("no action for given reservation/nonce") + return &tbtc.ReservationAction{}, nil } return action, nil } @@ -1263,7 +1283,7 @@ func (lc *localChain) setReservedDeposit( // PastReservationAcceptanceRequestedEvents returns the events previously // installed via addReservationAcceptanceRequestedEvent, applying the -// filter's StartBlock and ReservationKey constraints. +// filter's StartBlock, EndBlock and ReservationKey constraints. func (lc *localChain) PastReservationAcceptanceRequestedEvents( filter *tbtc.ReservationAcceptanceRequestedEventFilter, ) ([]*tbtc.ReservationAcceptanceRequestedEvent, error) { @@ -1275,6 +1295,9 @@ func (lc *localChain) PastReservationAcceptanceRequestedEvents( if filter != nil && event.BlockNumber < filter.StartBlock { continue } + if filter != nil && filter.EndBlock != nil && event.BlockNumber > *filter.EndBlock { + continue + } if filter != nil && len(filter.ReservationKey) > 0 { matched := false for _, key := range filter.ReservationKey { @@ -1307,7 +1330,7 @@ func (lc *localChain) addReservationAcceptanceRequestedEvent( // PastReservationReanchorRequestedEvents returns the events previously // installed via addReservationReanchorRequestedEvent, applying the -// filter's StartBlock and ReservationKey constraints. +// filter's StartBlock, EndBlock and ReservationKey constraints. func (lc *localChain) PastReservationReanchorRequestedEvents( filter *tbtc.ReservationReanchorRequestedEventFilter, ) ([]*tbtc.ReservationReanchorRequestedEvent, error) { @@ -1319,6 +1342,9 @@ func (lc *localChain) PastReservationReanchorRequestedEvents( if filter != nil && event.BlockNumber < filter.StartBlock { continue } + if filter != nil && filter.EndBlock != nil && event.BlockNumber > *filter.EndBlock { + continue + } if filter != nil && len(filter.ReservationKey) > 0 { matched := false for _, key := range filter.ReservationKey { @@ -1364,6 +1390,9 @@ func (lc *localChain) PastNewWalletRegisteredEvents( if filter != nil && event.BlockNumber < filter.StartBlock { continue } + if filter != nil && filter.EndBlock != nil && event.BlockNumber > *filter.EndBlock { + continue + } if filter != nil && len(filter.EcdsaWalletID) > 0 { matched := false for _, id := range filter.EcdsaWalletID { diff --git a/pkg/maintainer/spv/reservation_proof_loop_test.go b/pkg/maintainer/spv/reservation_proof_loop_test.go index 9772f0db51..068bf0be5e 100644 --- a/pkg/maintainer/spv/reservation_proof_loop_test.go +++ b/pkg/maintainer/spv/reservation_proof_loop_test.go @@ -1260,7 +1260,7 @@ func TestProveReservationAcceptanceActions_LeavesPendingOnChainError(t *testing. WalletPublicKeyHash: [20]byte{1}, BlockNumber: 500, }) - // Intentionally do NOT set the reservation action on spvChain, so GetReservationAction fails. + spvChain.getReservationActionErr = fmt.Errorf("transient RPC failure") scanState := newReservationProofScanState() config := Config{ @@ -1313,7 +1313,7 @@ func TestProveReservationReanchorActions_LeavesPendingOnChainError(t *testing.T) TargetWalletPublicKeyHash: [20]byte{2}, BlockNumber: 500, }) - // Intentionally do NOT set the reservation action on spvChain, so GetReservationAction fails. + spvChain.getReservationActionErr = fmt.Errorf("transient RPC failure") scanState := newReservationProofScanState() config := Config{ @@ -2011,14 +2011,19 @@ func TestProveReservationReanchorActions_TimedOutUnbounded(t *testing.T) { // superseded, vetoed, and unknown) are evicted from the pending-event map // with no proof submission, in both the acceptance and re-anchor scans. func TestProveReservationActions_EvictNonSettleableStates(t *testing.T) { + // absent leaves the generation unseeded, so the fake returns the zero + // record the Bridge's reservationActions mapping returns for a key it + // has never written. states := []struct { - name string - state tbtc.ReservationActionState + name string + state tbtc.ReservationActionState + absent bool }{ - {"settled", tbtc.ReservationActionStateSettled}, - {"superseded", tbtc.ReservationActionStateSuperseded}, - {"vetoed", tbtc.ReservationActionStateVetoed}, - {"unknown", tbtc.ReservationActionStateUnknown}, + {"settled", tbtc.ReservationActionStateSettled, false}, + {"superseded", tbtc.ReservationActionStateSuperseded, false}, + {"vetoed", tbtc.ReservationActionStateVetoed, false}, + {"unknown", tbtc.ReservationActionStateUnknown, false}, + {"absent", tbtc.ReservationActionStateUnknown, true}, } for _, s := range states { @@ -2036,6 +2041,12 @@ func TestProveReservationActions_EvictNonSettleableStates(t *testing.T) { MinAmount: 1000, }, ) + if s.absent { + delete( + fixture.spvChain.reservationActions, + buildReservationActionKey(fixture.reservationKey, fixture.requestNonce), + ) + } if err := proveReservationAcceptanceActions( fixture.state, @@ -2071,6 +2082,12 @@ func TestProveReservationActions_EvictNonSettleableStates(t *testing.T) { MinAmount: 1000, }, ) + if s.absent { + delete( + fixture.spvChain.reservationActions, + buildReservationActionKey(fixture.reservationKey, fixture.requestNonce), + ) + } if err := proveReservationReanchorActions( fixture.state, diff --git a/pkg/maintainer/spv/reservation_stale_deposit_watch_test.go b/pkg/maintainer/spv/reservation_stale_deposit_watch_test.go index 171ef1218e..62e8f2f717 100644 --- a/pkg/maintainer/spv/reservation_stale_deposit_watch_test.go +++ b/pkg/maintainer/spv/reservation_stale_deposit_watch_test.go @@ -591,7 +591,7 @@ func TestReservationStaleDepositWatcher_GetReservationChainError(t *testing.T) { wallet := walletPKH() spvChain.setReservedDeposit(key, wallet, true) spvChain.setWallet(wallet, &tbtc.WalletChainData{State: tbtc.StateUnknown}) - // No spvChain.setReservation: GetReservation returns an error. + spvChain.getReservationErr = fmt.Errorf("transient RPC failure") watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) seedStaleDeadline(watcher, key, 100) @@ -621,7 +621,7 @@ func TestReservationStaleDepositWatcher_GetReservationActionChainError_DoesNotNo spvChain.setReservedDeposit(key, wallet, true) spvChain.setWallet(wallet, &tbtc.WalletChainData{State: tbtc.StateUnknown}) spvChain.setReservation(key, &tbtc.Reservation{RequestNonce: 1}) - // GetReservationAction is NOT seeded, so it returns an error. + spvChain.getReservationActionErr = fmt.Errorf("transient RPC failure") watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) seedStaleDeadline(watcher, key, 100) From aa136959b9a31177bb4eaf4c3824d3537ca79af0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 30 Sep 2026 08:15:53 +0000 Subject: [PATCH 03/31] build(tbtc): collapse verify-vendored-fallback into one ABI-diff macro The eight copy-pasted mktemp/jq/diff blocks become calls to a single verify_abi macro. The local side is written to a temp file and chained with && instead of being piped into jq, so a checkabi build failure now stops make with checkabi's own error instead of being reported as ABI drift. Explanatory comments that sat inside the recipe (and were echoed by make) move above the target. --- pkg/chain/ethereum/tbtc/gen/Makefile | 137 ++++++++------------------- 1 file changed, 42 insertions(+), 95 deletions(-) diff --git a/pkg/chain/ethereum/tbtc/gen/Makefile b/pkg/chain/ethereum/tbtc/gen/Makefile index a82c34f022..1a4a2b4203 100644 --- a/pkg/chain/ethereum/tbtc/gen/Makefile +++ b/pkg/chain/ethereum/tbtc/gen/Makefile @@ -134,7 +134,8 @@ endif # (self-consistency against our own committed bindings only) cannot: # - the vendored fallback files vs the compiled artifacts, and # - the MetaData.ABI string in abi/.go vs the same -# compiled-artifact selectors. +# compiled-artifact selectors, so a fallback or binding update +# without the other fails here. # Every comparison is on whole-function entries (name, inputs with # components, outputs, stateMutability), sorted by function name and # canonicalized with jq -S, so each check naturally catches @@ -171,106 +172,52 @@ endif # npm/network access, whenever a vendored fallback file or the generated # bindings are touched or need re-verification. +# jq filters selecting the reservation entries keep-core depends on from +# each compiled artifact. +bridge_reservation_abi := [.abi[] | select(.name == "isReservedDeposit")] +wallet_proposal_validator_reservation_abi := [.abi[] | select(.name == "validateReservationAnchorProposal" or .name == "validateReservationReanchorProposal")] +reservation_vault_abi := [.abi[] | select(.name == "inKindFeeDebtSat" or .name == "tbtcToken")] + +# verify_abi runs one verify-vendored-fallback comparison: +# $(1) label used in the messages, +# $(2) command printing the local side (a vendored file, or the +# committed binding's embedded ABI via checkabi) as a JSON array, +# $(3) compiled artifact name in ${artifacts_dir}, without .json, +# $(4) jq filter selecting the matching entries from that artifact, +# $(5) remedy printed on drift. +# The local command writes to a temp file and is chained with && +# instead of being piped into jq, so when it fails (e.g. checkabi does +# not build) make stops with that command's own error rather than +# misreporting it as ABI drift. Arguments must not contain commas, +# since $(call) splits on them. +define verify_abi + @raw=$$(mktemp) && mine=$$(mktemp) && real=$$(mktemp) && \ + trap 'rm -f "$$raw" "$$mine" "$$real"' EXIT && \ + $(2) > "$$raw" && \ + jq -S 'sort_by(.name)' "$$raw" > "$$mine" && \ + jq -S '$(4) | sort_by(.name)' ${artifacts_dir}/$(3).json > "$$real" && \ + if diff -u "$$mine" "$$real"; then \ + echo "$(1) matches the real $(3) artifact"; \ + else \ + echo "$(1) differs from the real $(3) artifact - $(5)"; \ + exit 1; \ + fi +endef + .PHONY: verify-vendored-fallback verify-vendored-fallback: @[ -f "${artifacts_dir}/ReservationRouter.json" ] && [ -f "${artifacts_dir}/ReservationVault.json" ] && [ -f "${artifacts_dir}/Bridge.json" ] && [ -f "${artifacts_dir}/WalletProposalValidator.json" ] || { \ echo "verify-vendored-fallback requires real ${artifacts_dir}/ReservationRouter.json, ${artifacts_dir}/ReservationVault.json, ${artifacts_dir}/Bridge.json, and ${artifacts_dir}/WalletProposalValidator.json"; \ exit 1; \ } - @vendored=$$(mktemp) && real=$$(mktemp) && \ - jq -S '.abi | sort_by(.name)' ReservationRouter.fallback-artifact.json > "$$vendored" && \ - jq -S '.abi | sort_by(.name)' ${artifacts_dir}/ReservationRouter.json > "$$real" && \ - if diff -u "$$vendored" "$$real"; then \ - echo "ReservationRouter.fallback-artifact.json ABI matches the real artifact"; \ - rm -f "$$vendored" "$$real"; \ - else \ - echo "ReservationRouter.fallback-artifact.json ABI differs from the real artifact - regenerate the vendored fallback (see threshold-network/keep-core#4281)"; \ - rm -f "$$vendored" "$$real"; \ - exit 1; \ - fi - @vendored=$$(mktemp) && real=$$(mktemp) && \ - jq -S '[.[]] | sort_by(.name)' Bridge.reservation-methods-fallback.json > "$$vendored" && \ - jq -S '[.abi[] | select(.name == "isReservedDeposit")] | sort_by(.name)' ${artifacts_dir}/Bridge.json > "$$real" && \ - if diff -u "$$vendored" "$$real"; then \ - echo "Bridge.reservation-methods-fallback.json matches the real Bridge artifact"; \ - rm -f "$$vendored" "$$real"; \ - else \ - echo "Bridge.reservation-methods-fallback.json differs from the real Bridge artifact - regenerate the vendored fragment (see threshold-network/keep-core#4281)"; \ - rm -f "$$vendored" "$$real"; \ - exit 1; \ - fi - @vendored=$$(mktemp) && real=$$(mktemp) && \ - jq -S 'sort_by(.name)' WalletProposalValidator.reservation-methods-fallback.json > "$$vendored" && \ - jq -S '[.abi[] | select(.name == "validateReservationAnchorProposal" or .name == "validateReservationReanchorProposal")] | sort_by(.name)' ${artifacts_dir}/WalletProposalValidator.json > "$$real" && \ - if diff -u "$$vendored" "$$real"; then \ - echo "WalletProposalValidator.reservation-methods-fallback.json matches the real WalletProposalValidator artifact"; \ - rm -f "$$vendored" "$$real"; \ - else \ - echo "WalletProposalValidator.reservation-methods-fallback.json differs from the real WalletProposalValidator artifact - regenerate the vendored fragment (see threshold-network/keep-core#4281)"; \ - rm -f "$$vendored" "$$real"; \ - exit 1; \ - fi - # ReservationVault verification - @vendored=$$(mktemp) && real=$$(mktemp) && \ - jq -S '.abi | sort_by(.name)' ReservationVault.fallback-artifact.json > "$$vendored" && \ - jq -S '[.abi[] | select(.name == "inKindFeeDebtSat" or .name == "tbtcToken")] | sort_by(.name)' ${artifacts_dir}/ReservationVault.json > "$$real" && \ - if diff -u "$$vendored" "$$real"; then \ - echo "ReservationVault.fallback-artifact.json matches the real ReservationVault artifact"; \ - rm -f "$$vendored" "$$real"; \ - else \ - echo "ReservationVault.fallback-artifact.json differs from the real ReservationVault artifact - regenerate the vendored fallback (see threshold-network/keep-core#4281)"; \ - rm -f "$$vendored" "$$real"; \ - exit 1; \ - fi - # Go binding verification: the ABI embedded in the committed - # generated bindings (abi/.go's MetaData.ABI, extracted - # with go run ./checkabi) must agree with the same - # compiled-artifact selectors, so a fallback or binding update without - # the other fails here. - @binding=$$(mktemp) && real=$$(mktemp) && \ - go run ./checkabi ReservationRouter | jq -S '. | sort_by(.name)' > "$$binding" && \ - jq -S '.abi | sort_by(.name)' ${artifacts_dir}/ReservationRouter.json > "$$real" && \ - if diff -u "$$binding" "$$real"; then \ - echo "ReservationRouter binding ABI matches the real artifact"; \ - rm -f "$$binding" "$$real"; \ - else \ - echo "ReservationRouter binding ABI (abi/ReservationRouter.go) differs from the real artifact - regenerate the bindings"; \ - rm -f "$$binding" "$$real"; \ - exit 1; \ - fi - @binding=$$(mktemp) && real=$$(mktemp) && \ - go run ./checkabi Bridge isReservedDeposit | jq -S '. | sort_by(.name)' > "$$binding" && \ - jq -S '[.abi[] | select(.name == "isReservedDeposit")] | sort_by(.name)' ${artifacts_dir}/Bridge.json > "$$real" && \ - if diff -u "$$binding" "$$real"; then \ - echo "Bridge binding reservation methods match the real Bridge artifact"; \ - rm -f "$$binding" "$$real"; \ - else \ - echo "Bridge binding ABI (abi/Bridge.go) differs from the real Bridge artifact - regenerate the bindings"; \ - rm -f "$$binding" "$$real"; \ - exit 1; \ - fi - @binding=$$(mktemp) && real=$$(mktemp) && \ - go run ./checkabi WalletProposalValidator validateReservationAnchorProposal validateReservationReanchorProposal | jq -S '. | sort_by(.name)' > "$$binding" && \ - jq -S '[.abi[] | select(.name == "validateReservationAnchorProposal" or .name == "validateReservationReanchorProposal")] | sort_by(.name)' ${artifacts_dir}/WalletProposalValidator.json > "$$real" && \ - if diff -u "$$binding" "$$real"; then \ - echo "WalletProposalValidator binding reservation methods match the real artifact"; \ - rm -f "$$binding" "$$real"; \ - else \ - echo "WalletProposalValidator binding ABI (abi/WalletProposalValidator.go) differs from the real artifact - regenerate the bindings"; \ - rm -f "$$binding" "$$real"; \ - exit 1; \ - fi - @binding=$$(mktemp) && real=$$(mktemp) && \ - go run ./checkabi ReservationVault inKindFeeDebtSat tbtcToken | jq -S '. | sort_by(.name)' > "$$binding" && \ - jq -S '[.abi[] | select(.name == "inKindFeeDebtSat" or .name == "tbtcToken")] | sort_by(.name)' ${artifacts_dir}/ReservationVault.json > "$$real" && \ - if diff -u "$$binding" "$$real"; then \ - echo "ReservationVault binding reservation views match the real artifact"; \ - rm -f "$$binding" "$$real"; \ - else \ - echo "ReservationVault binding ABI (abi/ReservationVault.go) differs from the real artifact - regenerate the bindings"; \ - rm -f "$$binding" "$$real"; \ - exit 1; \ - fi + $(call verify_abi,ReservationRouter.fallback-artifact.json ABI,jq .abi ReservationRouter.fallback-artifact.json,ReservationRouter,.abi,regenerate the vendored fallback (see threshold-network/keep-core#4281)) + $(call verify_abi,Bridge.reservation-methods-fallback.json,cat Bridge.reservation-methods-fallback.json,Bridge,$(bridge_reservation_abi),regenerate the vendored fragment (see threshold-network/keep-core#4281)) + $(call verify_abi,WalletProposalValidator.reservation-methods-fallback.json,cat WalletProposalValidator.reservation-methods-fallback.json,WalletProposalValidator,$(wallet_proposal_validator_reservation_abi),regenerate the vendored fragment (see threshold-network/keep-core#4281)) + $(call verify_abi,ReservationVault.fallback-artifact.json,jq .abi ReservationVault.fallback-artifact.json,ReservationVault,$(reservation_vault_abi),regenerate the vendored fallback (see threshold-network/keep-core#4281)) + $(call verify_abi,ReservationRouter binding ABI (abi/ReservationRouter.go),go run ./checkabi ReservationRouter,ReservationRouter,.abi,regenerate the bindings) + $(call verify_abi,Bridge binding ABI (abi/Bridge.go),go run ./checkabi Bridge isReservedDeposit,Bridge,$(bridge_reservation_abi),regenerate the bindings) + $(call verify_abi,WalletProposalValidator binding ABI (abi/WalletProposalValidator.go),go run ./checkabi WalletProposalValidator validateReservationAnchorProposal validateReservationReanchorProposal,WalletProposalValidator,$(wallet_proposal_validator_reservation_abi),regenerate the bindings) + $(call verify_abi,ReservationVault binding ABI (abi/ReservationVault.go),go run ./checkabi ReservationVault inKindFeeDebtSat tbtcToken,ReservationVault,$(reservation_vault_abi),regenerate the bindings) # @keep-network/tbtc-v2@development on npm publishes Bridge.json and # WalletProposalValidator.json, but both are stale relative to the From 001c43def246ebe469ec7912f376bfa0b0ec7937 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 30 Sep 2026 08:15:54 +0000 Subject: [PATCH 04/31] fix(tbtc): keep checkabi internalType unchanged when already spaced An internalType that already reads "struct X" became "struct X". Also terminate the unknown-contract error with a newline. --- pkg/chain/ethereum/tbtc/gen/checkabi/main.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pkg/chain/ethereum/tbtc/gen/checkabi/main.go b/pkg/chain/ethereum/tbtc/gen/checkabi/main.go index f8c5fd1deb..ecc664eba3 100644 --- a/pkg/chain/ethereum/tbtc/gen/checkabi/main.go +++ b/pkg/chain/ethereum/tbtc/gen/checkabi/main.go @@ -42,7 +42,7 @@ func main() { if !ok { fmt.Fprintf( os.Stderr, - "checkabi: unknown contract %q (known: Bridge, ReservationRouter, ReservationVault, WalletProposalValidator)", + "checkabi: unknown contract %q (known: Bridge, ReservationRouter, ReservationVault, WalletProposalValidator)\n", name, ) os.Exit(2) @@ -130,10 +130,13 @@ func restoreParams(v any) { // internalTypeWithSpace re-inserts the space between the struct/enum/ // contract prefix and the qualified type name that abigen's packer -// strips. Non-prefixed internalType values pass through unchanged, so -// an entry that already has the space is a no-op. +// strips. Non-prefixed values and values that already have the space +// pass through unchanged. func internalTypeWithSpace(v string) string { for _, prefix := range []string{"struct", "enum", "contract"} { + if strings.HasPrefix(v, prefix+" ") { + return v + } if strings.HasPrefix(v, prefix) { return prefix + " " + strings.TrimPrefix(v, prefix) } From 78760a8428dbec0eb51e4c0a21616b9860d24141 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 30 Sep 2026 08:15:59 +0000 Subject: [PATCH 05/31] chore(reservations): drop unused re-anchor event fee field and tidy docs Remove the duplicated tbtc.go file header, list DissolutionDelay among the intentionally unconverted action fields, document that TermSeconds and MinAmount are acceptance-only snapshots, drop the never-populated ReservationReanchoredEvent.MinerFee field, and move the deposit refund safety margin constant next to the test fake that uses it. --- pkg/chain/ethereum/tbtc.go | 13 ++----------- pkg/tbtc/chain.go | 1 - pkg/tbtc/reservation.go | 6 ++++-- pkg/tbtcpg/chain_test.go | 7 +++++++ pkg/tbtcpg/reservation_reanchor.go | 7 ------- 5 files changed, 13 insertions(+), 21 deletions(-) diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index d485318fbc..397917d1e8 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -9,17 +9,6 @@ // the per-concern files and reintroduce the old tbtc.go, silently dropping // whatever those later commits changed. Reconstructing the pre-split state // requires a manual merge, not a mechanical revert. -// tbtc.go: TbtcChain adapter construction and shared state. See tbtc_*.go for -// per-concern implementations (tbtc_deposit.go, tbtc_dkg.go, tbtc_moving_funds.go, -// tbtc_redemption.go, tbtc_wallet.go, tbtc_sortition.go, tbtc_inactivity.go). -// -// These files were split out of a single monolithic tbtc.go with no rename -// markers git can detect (each file is a fresh addition, not a tracked move), -// so a plain `git revert` of the split commit cannot be applied cleanly on -// top of any later commit that also touches this package: it would re-delete -// the per-concern files and reintroduce the old tbtc.go, silently dropping -// whatever those later commits changed. Reconstructing the pre-split state -// requires a manual merge, not a mechanical revert. package ethereum import ( @@ -689,6 +678,8 @@ func convertReservationFromAbiType( // - `UsedRetryCredit`, `Watchtower{Default,LevelOne,LevelTwo}Delay`, // `RetryCreditSourceNonce`: written for governance / late-settlement // reconciliation but not read by the operator client in m1. +// - `DissolutionDelay`: snapshotted by acceptance generations for the +// dissolution path, which the operator client does not drive in m1. // // The on-chain `actionDataHash` field is polymorphic across action types: // it carries the keccak256 of the redeemer output script for redemptions, diff --git a/pkg/tbtc/chain.go b/pkg/tbtc/chain.go index a70df7d0f7..62fb5f08e0 100644 --- a/pkg/tbtc/chain.go +++ b/pkg/tbtc/chain.go @@ -868,7 +868,6 @@ type ReservationReanchoredEvent struct { NewWalletPublicKeyHash [20]byte NewAnchorTxHash [32]byte NewAnchorAmount uint64 - MinerFee uint64 BlockNumber uint64 } diff --git a/pkg/tbtc/reservation.go b/pkg/tbtc/reservation.go index 1d1bcdc363..3944f5e538 100644 --- a/pkg/tbtc/reservation.go +++ b/pkg/tbtc/reservation.go @@ -226,11 +226,13 @@ type ReservationAction struct { // TermSeconds is the custody term snapshotted when the generation was // requested. It also bounds the late-acceptance settlement window // (timeoutAt + termSeconds), so proof submitters must read it from the - // action, not from the live governance parameter. + // action, not from the live governance parameter. Set only for + // acceptance generations; zero for other action types. TermSeconds uint32 // MinAmount is the minimum reservation amount snapshotted when the // generation was requested. Settlement and signer validation both - // enforce this snapshot, not the live governance parameter. + // enforce this snapshot, not the live governance parameter. Set only + // for acceptance generations; zero for other action types. MinAmount uint64 } diff --git a/pkg/tbtcpg/chain_test.go b/pkg/tbtcpg/chain_test.go index ee09562e3b..4d60c19b1f 100644 --- a/pkg/tbtcpg/chain_test.go +++ b/pkg/tbtcpg/chain_test.go @@ -21,6 +21,13 @@ import ( "github.com/keep-network/keep-core/pkg/tbtc" ) +// reservationDepositRefundSafetyMarginSeconds mirrors +// WalletProposalValidatorConstants.DEPOSIT_REFUND_SAFETY_MARGIN +// (24 hours): the on-chain acceptance validator refuses to sign an +// anchor whose refund becomes available less than a day from now, so a +// wallet signing later cannot race the depositor's refund. +const reservationDepositRefundSafetyMarginSeconds = 24 * 60 * 60 + type movingFundsCommitmentSubmission struct { WalletPublicKeyHash [20]byte WalletMainUtxo *bitcoin.UnspentTransactionOutput diff --git a/pkg/tbtcpg/reservation_reanchor.go b/pkg/tbtcpg/reservation_reanchor.go index 27b903ad05..ef7306f564 100644 --- a/pkg/tbtcpg/reservation_reanchor.go +++ b/pkg/tbtcpg/reservation_reanchor.go @@ -39,13 +39,6 @@ const reservationReanchorRequestWaitBlocks = uint64(6) // validator reject them. const reservationRequestTimeoutSafetyMarginSeconds = 2 * 60 * 60 -// reservationDepositRefundSafetyMarginSeconds mirrors -// WalletProposalValidatorConstants.DEPOSIT_REFUND_SAFETY_MARGIN -// (24 hours): the on-chain acceptance validator refuses to sign an -// anchor whose refund becomes available less than a day from now, so a -// wallet signing later cannot race the depositor's refund. -const reservationDepositRefundSafetyMarginSeconds = 24 * 60 * 60 - // reservationReanchorInFlightRequest tracks a RequestReservationReanchor // submission that has not yet been resolved by its receipt, keyed by the // reservation key in the task's inFlightReanchorRequests map. From 2517c1aa5459bd33fabc5bb0acb7ca109e06cad8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 30 Sep 2026 08:16:00 +0000 Subject: [PATCH 06/31] test(ethereum): cover action TermSeconds and MinAmount conversion --- pkg/chain/ethereum/tbtc_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/chain/ethereum/tbtc_test.go b/pkg/chain/ethereum/tbtc_test.go index 7aa3953672..669d45e4c1 100644 --- a/pkg/chain/ethereum/tbtc_test.go +++ b/pkg/chain/ethereum/tbtc_test.go @@ -201,6 +201,10 @@ func TestConvertReservationActionFromAbiType(t *testing.T) { ActionDataHash: actionDataHash, SourceAnchorUtxoHash: [32]byte{0x99, 0x88, 0x77, 0x66}, IsPartial: true, + // Distinct non-zero snapshots so a dropped or swapped mapping + // fails the comparison below instead of matching zero to zero. + TermSeconds: 1209600, + MinAmount: 25000, } // The action-type-to-hash-field routing (redemption -> redeemer output @@ -257,6 +261,8 @@ func TestConvertReservationActionFromAbiType(t *testing.T) { RedeemerOutputScriptHash: test.expectedRedeemerOutputScriptHash, ExpectedMainUtxoHash: test.expectedExpectedMainUtxoHash, IsPartial: true, + TermSeconds: 1209600, + MinAmount: 25000, } if !reflect.DeepEqual(expected, action) { From f391d9c3025f89f562637495218deb04753e3fcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 30 Sep 2026 08:18:38 +0000 Subject: [PATCH 07/31] fix(chain): pass the known vault address to reservation vault fee reads --- pkg/chain/ethereum/tbtc.go | 72 +++++++++---------- pkg/chain/ethereum/tbtc_reservation_test.go | 66 ++++------------- pkg/tbtc/chain.go | 22 +++--- pkg/tbtc/chain_test.go | 8 ++- pkg/tbtcpg/chain.go | 22 +++--- pkg/tbtcpg/chain_test.go | 8 ++- pkg/tbtcpg/reservation_acceptance.go | 4 +- .../reservation_acceptance_metrics_test.go | 8 ++- 8 files changed, 95 insertions(+), 115 deletions(-) diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index d485318fbc..c8527a977f 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -1681,18 +1681,19 @@ func (tc *TbtcChain) ActiveReservationsCount() (uint32, uint32, error) { return activeReservationsCount.Count, activeReservationsCount.MaxActive, nil } -// reservationVaultBindings returns the cached, per-vault-address bindings -// (the ReservationVault itself and the TBTC token it holds its fee -// reserve in) constructed against a vault address read from the Bridge's -// on-chain reservation parameters. Successful constructions are cached per -// vault address. Failures are not cached: they are usually transient RPC -// errors (for example reading tbtcToken()), and caching them would disable -// the fee gauges until the process restarts. +// reservationVaultBindings holds the ReservationVault contract and the TBTC +// token contract the vault keeps its fee reserve in. type reservationVaultBindings struct { vault *tbtccontract.ReservationVault tbtc *tbtccontract.TBTC } +// reservationVaultBindings returns the cached, per-vault-address bindings +// (the ReservationVault itself and the TBTC token it holds its fee +// reserve in) for the given vault address. Successful constructions are +// cached per vault address. Failures are not cached: they are usually +// transient RPC errors (for example reading tbtcToken()), and caching them +// would disable the fee gauges until the process restarts. func (tc *TbtcChain) reservationVaultBindings(vaultAddress common.Address) (*reservationVaultBindings, error) { if bindings, ok := tc.vaultBindings.Load(vaultAddress); ok { return bindings.(*reservationVaultBindings), nil @@ -1743,21 +1744,16 @@ func (tc *TbtcChain) reservationVaultBindings(vaultAddress common.Address) (*res return bindings, nil } -// reservationVaultAddress resolves the on-chain reservation vault address +// decodeReservationVaultAddress decodes a reservation vault address read // from the Bridge's reservation parameters. It returns the zero address // when the vault is not configured (zero address in the parameters) and an -// error only when the configured address cannot be decoded as a valid -// 20-byte address, so callers can distinguish "no vault" (a skip, not -// an error) from a genuinely malformed configuration. -func (tc *TbtcChain) reservationVaultAddress() (common.Address, error) { - reservationParameters, err := tc.ReservationParameters() - if err != nil { - return common.Address{}, fmt.Errorf( - "cannot get reservation parameters: [%v]", - err, - ) - } - vaultAddressString := string(reservationParameters.ReservationVault) +// error only when the address cannot be decoded as a valid 20-byte +// address, so callers can distinguish "no vault" (a skip, not an error) +// from a genuinely malformed configuration. +func decodeReservationVaultAddress( + reservationVault chain.Address, +) (common.Address, error) { + vaultAddressString := string(reservationVault) vaultAddressBytes, err := hexutil.Decode(vaultAddressString) if err != nil || len(vaultAddressBytes) != common.AddressLength { if err == nil { @@ -1775,14 +1771,16 @@ func (tc *TbtcChain) reservationVaultAddress() (common.Address, error) { return common.BytesToAddress(vaultAddressBytes), nil } -// ReservationVaultFeeDebtSat returns the ReservationVault's outstanding -// in-kind fee debt in satoshi, read from the vault's inKindFeeDebtSat -// view. The vault address is resolved from the on-chain reservation -// parameters; when it is the zero address the vault is not configured -// and the method returns 0 with a nil error: the skip sentinel the -// metrics side consumes, not a chain error. -func (tc *TbtcChain) ReservationVaultFeeDebtSat() (uint64, error) { - vaultAddress, err := tc.reservationVaultAddress() +// ReservationVaultFeeDebtSat returns the given ReservationVault's +// outstanding in-kind fee debt in satoshi, read from the vault's +// inKindFeeDebtSat view. The caller passes the vault address it already +// read from the reservation parameters; when it is the zero address the +// vault is not configured and the method returns 0 with a nil error: the +// skip sentinel the metrics side consumes, not a chain error. +func (tc *TbtcChain) ReservationVaultFeeDebtSat( + reservationVault chain.Address, +) (uint64, error) { + vaultAddress, err := decodeReservationVaultAddress(reservationVault) if err != nil { return 0, err } @@ -1800,14 +1798,16 @@ func (tc *TbtcChain) ReservationVaultFeeDebtSat() (uint64, error) { return debtSat, nil } -// ReservationVaultFeeReserveTbtcBaseUnits returns the ReservationVault's -// TBTC fee-reserve balance in TBTC base units (whole TBTC x 1e18). The -// value is a token balance of a 1e18-scale token, which can exceed -// uint64 range, so it is returned as *big.Int. When the vault is not -// configured (zero address) the method returns zero with a nil error, -// mirroring ReservationVaultFeeDebtSat's skip sentinel. -func (tc *TbtcChain) ReservationVaultFeeReserveTbtcBaseUnits() (*big.Int, error) { - vaultAddress, err := tc.reservationVaultAddress() +// ReservationVaultFeeReserveTbtcBaseUnits returns the given +// ReservationVault's TBTC fee-reserve balance in TBTC base units (whole +// TBTC x 1e18). The value is a token balance of a 1e18-scale token, which +// can exceed uint64 range, so it is returned as *big.Int. When the vault +// is not configured (zero address) the method returns zero with a nil +// error, mirroring ReservationVaultFeeDebtSat's skip sentinel. +func (tc *TbtcChain) ReservationVaultFeeReserveTbtcBaseUnits( + reservationVault chain.Address, +) (*big.Int, error) { + vaultAddress, err := decodeReservationVaultAddress(reservationVault) if err != nil { return new(big.Int), err } diff --git a/pkg/chain/ethereum/tbtc_reservation_test.go b/pkg/chain/ethereum/tbtc_reservation_test.go index e29745b564..c1e6cd0190 100644 --- a/pkg/chain/ethereum/tbtc_reservation_test.go +++ b/pkg/chain/ethereum/tbtc_reservation_test.go @@ -31,6 +31,7 @@ import ( "github.com/keep-network/keep-common/pkg/chain/ethereum" "github.com/keep-network/keep-common/pkg/chain/ethereum/ethutil" + "github.com/keep-network/keep-core/pkg/chain" tbtcabi "github.com/keep-network/keep-core/pkg/chain/ethereum/tbtc/gen/abi" tbtccontract "github.com/keep-network/keep-core/pkg/chain/ethereum/tbtc/gen/contract" "github.com/keep-network/keep-core/pkg/tbtc" @@ -373,7 +374,6 @@ func TestReservationVaultFeeGaugesReadVaultValues(t *testing.T) { tokenAddress := common.HexToAddress("0x00000000000000000000000000000000000000a3") otherAccount := common.HexToAddress("0x00000000000000000000000000000000000000a4") - routerABI := parseGenABI(t, tbtcabi.ReservationRouterABI) vaultABI := parseGenABI(t, tbtcabi.ReservationVaultABI) client := newReservationFakeClient(t) @@ -386,28 +386,9 @@ func TestReservationVaultFeeGaugesReadVaultValues(t *testing.T) { vaultTbtcBalance := new(big.Int).Lsh(big.NewInt(3), 18) // 3 TBTC otherTbtcBalance := new(big.Int).Lsh(big.NewInt(7), 18) // 7 TBTC - client.setView( - t, - routerAddress, - methodSelector(t, routerABI, "reservationParameters"), - func() []byte { - return packOutputs( - t, - routerABI, - "reservationParameters", - vaultAddress, - uint64(0), - uint64(0), - uint32(0), - uint32(0), - uint64(0), - uint64(0), - uint32(0), - uint32(0), - uint32(0), - ) - }, - ) + // The router's reservationParameters view is deliberately not + // scripted: the caller passes the vault address it already read, so + // the gauges must not re-read the reservation parameters. client.setView( t, vaultAddress, @@ -427,9 +408,10 @@ func TestReservationVaultFeeGaugesReadVaultValues(t *testing.T) { client.balances[vaultAddress] = vaultTbtcBalance client.balances[otherAccount] = otherTbtcBalance + vaultArg := chain.Address(vaultAddress.Hex()) chain := newReservationGaugeChain(t, client, routerAddress) - debt, err := chain.ReservationVaultFeeDebtSat() + debt, err := chain.ReservationVaultFeeDebtSat(vaultArg) if err != nil { t.Fatalf("unexpected error: [%v]", err) } @@ -437,7 +419,7 @@ func TestReservationVaultFeeGaugesReadVaultValues(t *testing.T) { t.Fatalf("expected the vault's in-kind fee debt %d, got %d", vaultDebtSat, debt) } - reserve, err := chain.ReservationVaultFeeReserveTbtcBaseUnits() + reserve, err := chain.ReservationVaultFeeReserveTbtcBaseUnits(vaultArg) if err != nil { t.Fatalf("unexpected error: [%v]", err) } @@ -463,37 +445,15 @@ func TestReservationVaultFeeGaugesReadVaultValues(t *testing.T) { func TestReservationVaultFeeGaugesSkipUnconfiguredVault(t *testing.T) { routerAddress := common.HexToAddress("0x00000000000000000000000000000000000000a1") - routerABI := parseGenABI(t, tbtcabi.ReservationRouterABI) client := newReservationFakeClient(t) - client.setView( - t, - routerAddress, - methodSelector(t, routerABI, "reservationParameters"), - func() []byte { - // Unconfigured vault: the reservation parameters carry the - // zero address, so the gauges must short-circuit before - // touching any vault or token view. - return packOutputs( - t, - routerABI, - "reservationParameters", - common.Address{}, - uint64(0), - uint64(0), - uint32(0), - uint32(0), - uint64(0), - uint64(0), - uint32(0), - uint32(0), - uint32(0), - ) - }, - ) + // Unconfigured vault: the reservation parameters carry the zero + // address, so the gauges must short-circuit before touching any vault + // or token view. + vaultArg := chain.Address(common.Address{}.Hex()) chain := newReservationGaugeChain(t, client, routerAddress) - debt, err := chain.ReservationVaultFeeDebtSat() + debt, err := chain.ReservationVaultFeeDebtSat(vaultArg) if err != nil { t.Fatalf("expected the unconfigured-vault skip, got error [%v]", err) } @@ -501,7 +461,7 @@ func TestReservationVaultFeeGaugesSkipUnconfiguredVault(t *testing.T) { t.Fatalf("expected the unconfigured-vault skip sentinel 0, got %d", debt) } - reserve, err := chain.ReservationVaultFeeReserveTbtcBaseUnits() + reserve, err := chain.ReservationVaultFeeReserveTbtcBaseUnits(vaultArg) if err != nil { t.Fatalf("expected the unconfigured-vault skip, got error [%v]", err) } diff --git a/pkg/tbtc/chain.go b/pkg/tbtc/chain.go index a70df7d0f7..e01a578a62 100644 --- a/pkg/tbtc/chain.go +++ b/pkg/tbtc/chain.go @@ -718,23 +718,27 @@ type ReservationChain interface { // reservations across all wallets and the cap on that count. ActiveReservationsCount() (count uint32, maxActive uint32, err error) - // ReservationVaultFeeDebtSat returns the ReservationVault's + // ReservationVaultFeeDebtSat returns the given ReservationVault's // outstanding in-kind fee debt in satoshi, read from the vault's - // inKindFeeDebtSat view. If the reservation vault address is + // inKindFeeDebtSat view. The caller passes the vault address it + // already read from ReservationParameters. If the address is // zero (vault not configured), it returns zero with a nil // error: the skip sentinel the metric side consumes, not an // error. - ReservationVaultFeeDebtSat() (uint64, error) + ReservationVaultFeeDebtSat(reservationVault chain.Address) (uint64, error) - // ReservationVaultFeeReserveTbtcBaseUnits returns the + // ReservationVaultFeeReserveTbtcBaseUnits returns the given // ReservationVault's TBTC fee-reserve balance in TBTC base // units: TBTC is a 1e18 base-unit token, so the returned value // is whole TBTC x 1e18. The value can exceed uint64 range, so - // it is returned as *big.Int. If the reservation vault address - // is zero (vault not configured), it returns zero with a nil - // error: the skip sentinel the metric side consumes, not an - // error. - ReservationVaultFeeReserveTbtcBaseUnits() (*big.Int, error) + // it is returned as *big.Int. The caller passes the vault + // address it already read from ReservationParameters. If the + // address is zero (vault not configured), it returns zero with + // a nil error: the skip sentinel the metric side consumes, not + // an error. + ReservationVaultFeeReserveTbtcBaseUnits( + reservationVault chain.Address, + ) (*big.Int, error) // IsReservedDeposit returns true if the given deposit was revealed // with the reservation vault address and is therefore a reservation diff --git a/pkg/tbtc/chain_test.go b/pkg/tbtc/chain_test.go index 7fe067b053..ee0255dfa7 100644 --- a/pkg/tbtc/chain_test.go +++ b/pkg/tbtc/chain_test.go @@ -1665,14 +1665,18 @@ func (lc *localChain) ActiveReservationsCount() (uint32, uint32, error) { // ReservationVaultFeeDebtSat is not exercised by this fake's tests; it // reports an unsupported error mirroring the sibling reservation view // methods on this fake. -func (lc *localChain) ReservationVaultFeeDebtSat() (uint64, error) { +func (lc *localChain) ReservationVaultFeeDebtSat( + reservationVault chain.Address, +) (uint64, error) { return 0, fmt.Errorf("unsupported") } // ReservationVaultFeeReserveTbtcBaseUnits is not exercised by this // fake's tests; it reports an unsupported error mirroring the sibling // reservation view methods on this fake. -func (lc *localChain) ReservationVaultFeeReserveTbtcBaseUnits() (*big.Int, error) { +func (lc *localChain) ReservationVaultFeeReserveTbtcBaseUnits( + reservationVault chain.Address, +) (*big.Int, error) { return nil, fmt.Errorf("unsupported") } diff --git a/pkg/tbtcpg/chain.go b/pkg/tbtcpg/chain.go index 55002a8b04..f2af4d7f91 100644 --- a/pkg/tbtcpg/chain.go +++ b/pkg/tbtcpg/chain.go @@ -251,23 +251,27 @@ type Chain interface { // reservations across all wallets and the cap on that count. ActiveReservationsCount() (count uint32, maxActive uint32, err error) - // ReservationVaultFeeDebtSat returns the ReservationVault's + // ReservationVaultFeeDebtSat returns the given ReservationVault's // outstanding in-kind fee debt in satoshi, read from the vault's - // inKindFeeDebtSat view. If the reservation vault address is + // inKindFeeDebtSat view. The caller passes the vault address it + // already read from ReservationParameters. If the address is // zero (vault not configured), it returns zero with a nil // error: the skip sentinel the metric side consumes, not an // error. - ReservationVaultFeeDebtSat() (uint64, error) + ReservationVaultFeeDebtSat(reservationVault chain.Address) (uint64, error) - // ReservationVaultFeeReserveTbtcBaseUnits returns the + // ReservationVaultFeeReserveTbtcBaseUnits returns the given // ReservationVault's TBTC fee-reserve balance in TBTC base // units: TBTC is a 1e18 base-unit token, so the returned value // is whole TBTC x 1e18. The value can exceed uint64 range, so - // it is returned as *big.Int. If the reservation vault address - // is zero (vault not configured), it returns zero with a nil - // error: the skip sentinel the metric side consumes, not an - // error. - ReservationVaultFeeReserveTbtcBaseUnits() (*big.Int, error) + // it is returned as *big.Int. The caller passes the vault + // address it already read from ReservationParameters. If the + // address is zero (vault not configured), it returns zero with + // a nil error: the skip sentinel the metric side consumes, not + // an error. + ReservationVaultFeeReserveTbtcBaseUnits( + reservationVault chain.Address, + ) (*big.Int, error) // IsReservedDeposit returns true if the given deposit was revealed // with the reservation vault address and is therefore a reservation diff --git a/pkg/tbtcpg/chain_test.go b/pkg/tbtcpg/chain_test.go index ee09562e3b..384d7a0e53 100644 --- a/pkg/tbtcpg/chain_test.go +++ b/pkg/tbtcpg/chain_test.go @@ -2202,7 +2202,9 @@ func (lc *LocalChain) SetReservationVaultFeeReserveBalance( // ReservationVaultFeeDebtSat returns the value configured via // SetReservationVaultFeeDebtSat; zero by default. -func (lc *LocalChain) ReservationVaultFeeDebtSat() (uint64, error) { +func (lc *LocalChain) ReservationVaultFeeDebtSat( + reservationVault chain.Address, +) (uint64, error) { lc.mutex.Lock() defer lc.mutex.Unlock() @@ -2211,7 +2213,9 @@ func (lc *LocalChain) ReservationVaultFeeDebtSat() (uint64, error) { // ReservationVaultFeeReserveTbtcBaseUnits returns the value configured // via SetReservationVaultFeeReserveBalance; zero by default. -func (lc *LocalChain) ReservationVaultFeeReserveTbtcBaseUnits() (*big.Int, error) { +func (lc *LocalChain) ReservationVaultFeeReserveTbtcBaseUnits( + reservationVault chain.Address, +) (*big.Int, error) { lc.mutex.Lock() defer lc.mutex.Unlock() diff --git a/pkg/tbtcpg/reservation_acceptance.go b/pkg/tbtcpg/reservation_acceptance.go index 81d20808a1..67f41691be 100644 --- a/pkg/tbtcpg/reservation_acceptance.go +++ b/pkg/tbtcpg/reservation_acceptance.go @@ -441,7 +441,7 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( // publishing the fee gauges as zero, so this block is unreachable // for that pass. if rat.metricsRecorder != nil { - feeDebtSat, err := rat.chain.ReservationVaultFeeDebtSat() + feeDebtSat, err := rat.chain.ReservationVaultFeeDebtSat(reservationVault) if err != nil { taskLogger.Warnf( "failed to get reservation vault fee debt: [%v]", @@ -455,7 +455,7 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( } feeReserve, err := - rat.chain.ReservationVaultFeeReserveTbtcBaseUnits() + rat.chain.ReservationVaultFeeReserveTbtcBaseUnits(reservationVault) if err != nil { taskLogger.Warnf( "failed to get reservation vault fee reserve: [%v]", diff --git a/pkg/tbtcpg/reservation_acceptance_metrics_test.go b/pkg/tbtcpg/reservation_acceptance_metrics_test.go index 341c399c3d..c95e255198 100644 --- a/pkg/tbtcpg/reservation_acceptance_metrics_test.go +++ b/pkg/tbtcpg/reservation_acceptance_metrics_test.go @@ -137,11 +137,15 @@ type feeObservabilityChain struct { feeReserveErr error } -func (c *feeObservabilityChain) ReservationVaultFeeDebtSat() (uint64, error) { +func (c *feeObservabilityChain) ReservationVaultFeeDebtSat( + reservationVault chain.Address, +) (uint64, error) { return c.feeDebtSat, c.feeDebtErr } -func (c *feeObservabilityChain) ReservationVaultFeeReserveTbtcBaseUnits() (*big.Int, error) { +func (c *feeObservabilityChain) ReservationVaultFeeReserveTbtcBaseUnits( + reservationVault chain.Address, +) (*big.Int, error) { return c.feeReserve, c.feeReserveErr } From 3085db18abb742cc0e3bd392d86676825f67c8a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 30 Sep 2026 08:19:43 +0000 Subject: [PATCH 08/31] ci(tbtc): pin tbtc-v2 eec999aa and check the harness validator against it The drift job compiled tbtc-v2 9f8f5ef1 while the harness validator was vendored from e635e229, whose validateReservationAnchorProposal logic differs under an identical ABI, so no check could notice. - Pin TBTC_V2_REF to eec999aa (merge of tbtc-v2#1161 into reservations-upgrade); the four reservation artifacts and the Go bindings still match it. - Re-vendor WalletProposalValidator.json from an eec999aa build (code unchanged apart from the metadata hash) and record that commit. - Add verify.sh and a job step comparing the vendored validator to the compiled one: ABI exactly, creation and deployed bytecode with solc's CBOR metadata removed, since the job's unfrozen yarn install can move the metadata hash. - regenerate.sh now requires TBTC_V2_BUILD_DIR instead of defaulting to a local sibling checkout. --- .github/workflows/client.yml | 42 ++++-- .../WalletProposalValidator.json | 4 +- .../walletproposalvalidator/regenerate.sh | 21 +-- .../walletproposalvalidator/verify.sh | 121 ++++++++++++++++++ 4 files changed, 164 insertions(+), 24 deletions(-) create mode 100755 pkg/chain/ethereum/testdata/walletproposalvalidator/verify.sh diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml index a426c83ad0..78bacb7b6c 100644 --- a/.github/workflows/client.yml +++ b/.github/workflows/client.yml @@ -165,27 +165,34 @@ jobs: # - the inKindFeeDebtSat and tbtcToken entries of the committed # ReservationVault binding against the compiled ReservationVault # artifact. + # It also checks the real validator that the in-memory EVM harness + # (pkg/chain/ethereum/tbtc_validator_harness_test.go) deploys: + # pkg/chain/ethereum/testdata/walletproposalvalidator/verify.sh + # compares the vendored WalletProposalValidator.json against the + # compiled artifact - the ABI exactly, and the creation and deployed + # bytecode with solc's trailing CBOR metadata removed (the metadata + # hashes the compiler input, which the unfrozen yarn install below + # can change without changing the code). An ABI-only diff cannot + # catch a validator whose logic changed under an identical ABI. # Cheap (checkout + yarn install + hardhat compile + a go-run ABI # extraction + jq diffs) and # runs whenever the Client workflow runs, without an additional # job-level path gate, mirroring btcec-vendor-byte-identity's # rationale for not gating by path. # - # tbtc-v2 ref: pinned to commit 9f8f5ef1 - the tip of threshold-network/tbtc-v2's - # `reservations-upgrade` integration branch (which merged - # threshold-network/tbtc-v2#1112, adding ReservationRouter.sol, into - # that branch, not `main`). A full commit hash is pinned rather than - # the branch name because a branch ref can move after this repo's - # generated bindings and vendored fragments are produced, and the - # diff above would then fail without any visible cause. `main` does - # not carry the reservation contracts yet (verified: no - # ReservationRouter.sol there as of this job's authoring), so - # pointing here at `main` would make every run of this job fail - # unconditionally. When the reservations work lands on tbtc-v2's - # `main`, switch `TBTC_V2_REF` back to `main`. + # tbtc-v2 ref: pinned to commit eec999aa, the merge of + # threshold-network/tbtc-v2#1161 into the `reservations-upgrade` + # integration branch, where the reservation contracts live (they are + # not on tbtc-v2's `main`). A full commit hash is pinned rather than + # a branch name because a branch can move after this repo's bindings, + # vendored fragments, and harness validator are produced, and the + # checks would then fail without any visible cause. The vendored + # harness validator is built from this same commit (see the + # provenance note in testdata/walletproposalvalidator/regenerate.sh), + # so bump both together. runs-on: ubuntu-latest env: - TBTC_V2_REF: 9f8f5ef1ca82f423571225066a291a6a0f963aac + TBTC_V2_REF: eec999aad43b3913df378840773348e17f68f1ba steps: - uses: actions/checkout@v4 @@ -254,6 +261,15 @@ jobs: cp "$BASE_VAULT/ReservationVault.sol/ReservationVault.json" "tmp/contracts/development/@keep-network/tbtc-v2/artifacts/ReservationVault.json" make -C pkg/chain/ethereum/tbtc/gen verify-vendored-fallback + - name: Verify the harness's vendored WalletProposalValidator matches the compiled validator + run: | + pkg/chain/ethereum/testdata/walletproposalvalidator/verify.sh \ + tbtc-v2-upstream/solidity/build/contracts/bridge/WalletProposalValidator.sol/WalletProposalValidator.json \ + || { + echo "::error::pkg/chain/ethereum/testdata/walletproposalvalidator/WalletProposalValidator.json does not match WalletProposalValidator compiled from tbtc-v2 ${TBTC_V2_REF} (see the output above). Regenerate it with regenerate.sh from that build, or align TBTC_V2_REF with its provenance note." + exit 1 + } + client-build-test-publish: needs: client-detect-changes if: | diff --git a/pkg/chain/ethereum/testdata/walletproposalvalidator/WalletProposalValidator.json b/pkg/chain/ethereum/testdata/walletproposalvalidator/WalletProposalValidator.json index 7753364808..a5d9b2b26f 100644 --- a/pkg/chain/ethereum/testdata/walletproposalvalidator/WalletProposalValidator.json +++ b/pkg/chain/ethereum/testdata/walletproposalvalidator/WalletProposalValidator.json @@ -530,6 +530,6 @@ "type": "function" } ], - "bytecode": "0x60a06040523480156200001157600080fd5b50604051620057ce380380620057ce833981016040819052620000349162000046565b6001600160a01b031660805262000078565b6000602082840312156200005957600080fd5b81516001600160a01b03811681146200007157600080fd5b9392505050565b60805161568b62000143600039600081816101b401528181610221015281816104110152818161059b0152818161077001528181610b9501528181610f5f0152818161110201528181611476015281816116c901528181611935015281816119af01528181611d7101528181611e9c01528181611f3101528181612032015281816120df015281816122e7015281816124b80152818161264c015281816128170152818161297501528181612bf601528181612d990152818161328001526137a8015261568b6000f3fe608060405234801561001057600080fd5b50600436106100ea5760003560e01c8063a57f734f1161008c578063e0276b2611610066578063e0276b2614610152578063e78cea92146101af578063f03a4bc5146101ee578063f9179ad21461020157600080fd5b8063a57f734f14610180578063d09520f714610193578063d6c7fa781461019c57600080fd5b80637405d7bf116100c85780637405d7bf14610135578063765f28f914610148578063996a756b146101525780639b337db21461016d57600080fd5b80630538ac1b146100ef5780630fde6c76146101125780636b562cec146100ef575b600080fd5b6100f8611c2081565b60405163ffffffff90911681526020015b60405180910390f35b61012561012036600461419c565b610214565b6040519015158152602001610109565b6101256101433660046141eb565b610b88565b6100f86201518081565b61015a601481565b60405161ffff9091168152602001610109565b61012561017b366004614285565b611469565b61012561018e3660046142e6565b6118f6565b6100f861025881565b6101256101aa366004614302565b6122da565b6101d67f000000000000000000000000000000000000000000000000000000000000000081565b6040516001600160a01b039091168152602001610109565b6101256101fc36600461431e565b6127a7565b61012561020f366004614359565b612812565b6000806001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d561025360208601866143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa158015610299573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906102bd9190614509565b905060018160e0015160058111156102d7576102d76145ac565b14806102f8575060028160e0015160058111156102f6576102f66145ac565b145b61035c5760405162461bcd60e51b815260206004820152602a60248201527f57616c6c6574206973206e6f7420696e204c697665206f72204d6f76696e6746604482015269756e647320737461746560b01b60648201526084015b60405180910390fd5b600061036b60208501856145c2565b915050806103bb5760405162461bcd60e51b815260206004820152601d60248201527f526564656d7074696f6e2062656c6f7720746865206d696e2073697a650000006044820152606401610353565b601481111561040c5760405162461bcd60e51b815260206004820152601f60248201527f526564656d7074696f6e206578636565647320746865206d61782073697a65006044820152606401610353565b6000807f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316636e70ce416040518163ffffffff1660e01b815260040160e060405180830381865afa15801561046d573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906104919190614628565b50509450945050505060008660400135116104fe5760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b8167ffffffffffffffff16866040013511156105685760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b60006105788460408901356146d6565b905060008461058b8360408b0135614700565b6105959190614713565b905060007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316635f3281ca6040518163ffffffff1660e01b8152600401602060405180830381865afa1580156105f7573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061061b919061473e565b905060008667ffffffffffffffff811115610638576106386143ee565b604051908082528060200260200182016040528015610661578160200160208202803683370190505b50905060005b87811015610b7757600061067e60208d018d6145c2565b8381811061068e5761068e614759565b90506020028101906106a0919061476f565b8080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509050600081805190602001208d600001602081019061070291906143d1565b6040516020016107269291909182526001600160601b031916602082015260340190565b60408051808303601f190181529082905280516020909101207f03d952f70000000000000000000000000000000000000000000000000000000082526004820181905291506000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906303d952f79060240160a060405180830381865afa1580156107bf573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906107e391906147b6565b9050806080015163ffffffff1660000361083f5760405162461bcd60e51b815260206004820181905260248201527f4e6f7420612070656e64696e6720726564656d7074696f6e20726571756573746044820152606401610353565b6102586001600160a01b038716156108f2576040517f0df5db70000000000000000000000000000000000000000000000000000000008152600481018490526000906001600160a01b03891690630df5db7090602401602060405180830381865afa1580156108b2573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906108d6919061484c565b90508163ffffffff168163ffffffff1611156108f0578091505b505b8082608001516109029190614869565b63ffffffff16421161097c5760405162461bcd60e51b815260206004820152602b60248201527f526564656d7074696f6e2072657175657374206d696e20616765206e6f74206160448201527f63686965766564207965740000000000000000000000000000000000000000006064820152608401610353565b60008a836080015161098e9190614869565b905061099c611c208261488d565b63ffffffff164210610a165760405162461bcd60e51b815260206004820152603960248201527f526564656d7074696f6e20726571756573742074696d656f757420736166657460448201527f79206d617267696e206973206e6f7420707265736572766564000000000000006064820152608401610353565b88610a2260018f614700565b8703610a3557610a328b826148aa565b90505b836060015167ffffffffffffffff16811115610ab95760405162461bcd60e51b815260206004820152603660248201527f50726f706f736564207472616e73616374696f6e207065722d7265717565737460448201527f2066656520736861726520697320746f6f2068696768000000000000000000006064820152608401610353565b60005b87811015610b3e5785898281518110610ad757610ad7614759565b602002602001015103610b2c5760405162461bcd60e51b815260206004820152601260248201527f4475706c696361746564207265717565737400000000000000000000000000006044820152606401610353565b80610b36816148bd565b915050610abc565b5084888881518110610b5257610b52614759565b6020026020010181815250505050505050508080610b6f906148bd565b915050610667565b5060019a9950505050505050505050565b6000806001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d5610bc760208801886143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa158015610c0d573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610c319190614509565b905060018160e001516005811115610c4b57610c4b6145ac565b1480610c6c575060028160e001516005811115610c6a57610c6a6145ac565b145b610ccb5760405162461bcd60e51b815260206004820152602a60248201527f57616c6c6574206973206e6f7420696e204c697665206f72204d6f76696e6746604482015269756e647320737461746560b01b6064820152608401610353565b6000610cda60208701876148d6565b905011610d295760405162461bcd60e51b815260206004820152601860248201527f53776565702062656c6f7720746865206d696e2073697a6500000000000000006044820152606401610353565b6014610d3860208701876148d6565b90501115610d885760405162461bcd60e51b815260206004820152601a60248201527f5377656570206578636565647320746865206d61782073697a650000000000006044820152606401610353565b82610d9660208701876148d6565b905014610e0b5760405162461bcd60e51b815260206004820152602e60248201527f45616368206465706f736974206b6579206d7573742068617665206d6174636860448201527f696e6720657874726120696e666f0000000000000000000000000000000000006064820152608401610353565b610e276040860135610e2060208801886148d6565b90506131f5565b600080610e3760208801886148d6565b905067ffffffffffffffff811115610e5157610e516143ee565b604051908082528060200260200182016040528015610e7a578160200160208202803683370190505b50905060005b610e8d60208901896148d6565b9050811015611459576000610ea560208a018a6148d6565b83818110610eb557610eb5614759565b905060400201803603810190610ecb9190614920565b90506000888884818110610ee157610ee1614759565b9050602002810190610ef39190614976565b610efc90614a53565b8251602080850151604051939450600093610f2e93920191825260e01b6001600160e01b031916602082015260240190565b60408051808303601f19018152908290528051602090910120630b02c43d60e41b82526004820181905291506000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063b02c43d09060240160e060405180830381865afa158015610fae573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610fd29190614b55565b9050806040015163ffffffff1660000361102e5760405162461bcd60e51b815260206004820152601460248201527f4465706f736974206e6f742072657665616c65640000000000000000000000006044820152606401610353565b611c2081604001516110409190614869565b63ffffffff1642116110945760405162461bcd60e51b815260206004820181905260248201527f4465706f736974206d696e20616765206e6f74206163686965766564207965746044820152606401610353565b60a081015163ffffffff16156110ec5760405162461bcd60e51b815260206004820152601560248201527f4465706f73697420616c726561647920737765707400000000000000000000006044820152606401610353565b6040516393df529b60e01b8152600481018390527f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906393df529b90602401602060405180830381865afa158015611151573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906111759190614c18565b156111e85760405162461bcd60e51b815260206004820152602360248201527f5265736572766564206465706f73697473206d757374206e6f7420626520737760448201527f65707400000000000000000000000000000000000000000000000000000000006064820152608401610353565b6111fc8482600001518360c001518661337c565b608083015160e881901c62ff00ff1663ff00ff0060d89290921c9190911617601081811c91901b17611231620151808261488d565b63ffffffff16421061129b5760405162461bcd60e51b815260206004820152602d60248201527f4465706f73697420726566756e6420736166657479206d617267696e2069732060448201526c1b9bdd081c1c995cd95c9d9959609a1b6064820152608401610353565b6112a860208e018e6143d1565b6bffffffffffffffffffffffff191684604001516bffffffffffffffffffffffff1916146113275760405162461bcd60e51b815260206004820152602660248201527f4465706f73697420636f6e74726f6c6c656420627920646966666572656e74206044820152651dd85b1b195d60d21b6064820152608401610353565b8560000361133757816060015197505b876001600160a01b031682606001516001600160a01b03161461139c5760405162461bcd60e51b815260206004820152601f60248201527f4465706f736974207461726765747320646966666572656e74207661756c74006044820152606401610353565b60005b8681101561142157838882815181106113ba576113ba614759565b60200260200101510361140f5760405162461bcd60e51b815260206004820152601260248201527f4475706c696361746564206465706f73697400000000000000000000000000006044820152606401610353565b80611419816148bd565b91505061139f565b508287878151811061143557611435614759565b60200260200101818152505050505050508080611451906148bd565b915050610e80565b50600193505050505b9392505050565b6000806001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d56114a860208701876143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa1580156114ee573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906115129190614509565b905060028160e00151600581111561152c5761152c6145ac565b1461159f5760405162461bcd60e51b815260206004820152602960248201527f536f757263652077616c6c6574206973206e6f7420696e204d6f76696e67467560448201527f6e647320737461746500000000000000000000000000000000000000000000006064820152608401610353565b6101008101516116175760405162461bcd60e51b815260206004820152602a60248201527f5461726765742077616c6c65747320636f6d6d69746d656e74206973206e6f7460448201527f207375626d6974746564000000000000000000000000000000000000000000006064820152608401610353565b61162460208501856145c2565b604051602001611635929190614c33565b60405160208183030381529060405280519060200120816101000151146116c45760405162461bcd60e51b815260206004820152603a60248201527f5461726765742077616c6c65747320646f206e6f74206d61746368207461726760448201527f65742077616c6c65747320636f6d6d69746d656e7420686173680000000000006064820152608401610353565b6000807f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663be05abe36040518163ffffffff1660e01b815260040161016060405180830381865afa158015611726573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061174a9190614c76565b50505050505050505091509150600061176784602001518761361a565b90508167ffffffffffffffff168167ffffffffffffffff1610156118195760405162461bcd60e51b815260206004820152604260248201527f536f757263652077616c6c6574204254432062616c616e63652069732062656c60448201527f6f7720746865206d6f76696e672066756e64732064757374207468726573686f60648201527f6c64000000000000000000000000000000000000000000000000000000000000608482015260a401610353565b600087604001351161187d5760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b8267ffffffffffffffff16876040013511156118e75760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b60019450505050505b92915050565b6040517f067cf8320000000000000000000000000000000000000000000000000000000081526020820135600482015260009081906001600160a01b037f0000000000000000000000000000000000000000000000000000000000000000169063067cf832906024016101c060405180830381865afa15801561197d573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906119a19190614d73565b905060006001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663cec8c6e960208601356119e96060880160408901614e7c565b6040516001600160e01b031960e085901b168152600481019290925267ffffffffffffffff16602482015260440161028060405180830381865afa158015611a35573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611a599190614e99565b9050600381608001516004811115611a7357611a736145ac565b14611ac05760405162461bcd60e51b815260206004820152601e60248201527f4e6f7420612070656e64696e672072652d616e63686f7220616374696f6e00006044820152606401610353565b60018160a001516005811115611ad857611ad86145ac565b14611b255760405162461bcd60e51b815260206004820152601f60248201527f52652d616e63686f7220616374696f6e206973206e6f742070656e64696e67006044820152606401610353565b611c208160400151611b37919061488d565b63ffffffff164210611b8b5760405162461bcd60e51b815260206004820152601e60248201527f52652d616e63686f7220616374696f6e206861732074696d6564206f757400006044820152606401610353565b611b9b60808501606086016143d1565b81516001600160601b0319908116911614611c1e5760405162461bcd60e51b815260206004820152603260248201527f5461726765742077616c6c657420646f6573206e6f74206d617463682074686560448201527f20617574686f72697a656420616374696f6e00000000000000000000000000006064820152608401610353565b611c2b60208501856143d1565b6bffffffffffffffffffffffff191682606001516bffffffffffffffffffffffff191614611cc15760405162461bcd60e51b815260206004820152602960248201527f5265736572766174696f6e20637573746f64696564206279206469666665726560448201527f6e742077616c6c657400000000000000000000000000000000000000000000006064820152608401610353565b611cce60208501856143d1565b6001600160601b031916611ce860808601606087016143d1565b6001600160601b03191603611d655760405162461bcd60e51b815260206004820152603060248201527f5461726765742077616c6c6574206d757374206469666665722066726f6d207460448201527f686520736f757263652077616c6c6574000000000000000000000000000000006064820152608401610353565b60016001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d5611da660808801606089016143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa158015611dec573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611e109190614509565b60e001516005811115611e2557611e256145ac565b14611e985760405162461bcd60e51b815260206004820152602360248201527f5461726765742077616c6c6574206d75737420626520696e204c69766520737460448201527f61746500000000000000000000000000000000000000000000000000000000006064820152608401610353565b60007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663f75b4b1c6040518163ffffffff1660e01b815260040161014060405180830381865afa158015611ef9573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611f1d9190614ffc565b50509750505050505050508063ffffffff167f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316631de0555a876060016020810190611f7191906143d1565b6040516001600160e01b031960e084901b1681526001600160601b03199091166004820152602401602060405180830381865afa158015611fb6573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611fda919061484c565b63ffffffff16111561202e5760405162461bcd60e51b815260206004820181905260248201527f57616c6c6574207265736572766174696f6e73206361702065786365656465646044820152606401610353565b60007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03166363dfb29c6040518163ffffffff1660e01b8152600401606060405180830381865afa15801561208e573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906120b291906150d2565b505090508067ffffffffffffffff166000148061218a575067ffffffffffffffff81166001600160a01b037f0000000000000000000000000000000000000000000000000000000000000000166363481e9861211460808a0160608b016143d1565b6040516001600160e01b031960e084901b1681526001600160601b03199091166004820152602401602060405180830381865afa158015612159573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061217d919061511f565b67ffffffffffffffff1611155b6121fc5760405162461bcd60e51b815260206004820152602360248201527f57616c6c657420726573657276656420616d6f756e742063617020657863656560448201527f64656400000000000000000000000000000000000000000000000000000000006064820152608401610353565b60008660800135116122605760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b826060015167ffffffffffffffff16866080013511156122ce5760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b50600195945050505050565b6000806001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d561231960208601866143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa15801561235f573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906123839190614509565b905060018160e00151600581111561239d5761239d6145ac565b14806123be575060028160e0015160058111156123bc576123bc6145ac565b145b6124305760405162461bcd60e51b815260206004820152603160248201527f536f757263652077616c6c6574206973206e6f7420696e204c697665206f722060448201527f4d6f76696e6746756e64732073746174650000000000000000000000000000006064820152608401610353565b60006020840135612447606086016040870161513c565b60405160200161246e92919091825260e01b6001600160e01b031916602082015260240190565b60408051808303601f190181529082905280516020909101207ff18cf1b10000000000000000000000000000000000000000000000000000000082526004820181905291506000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063f18cf1b190602401608060405180830381865afa158015612507573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061252b9190615159565b9050600181606001516003811115612545576125456145ac565b146125b85760405162461bcd60e51b815260206004820152602560248201527f53776565702072657175657374206973206e6f7420696e2050656e64696e672060448201527f73746174650000000000000000000000000000000000000000000000000000006064820152608401610353565b6125c560208601866143d1565b81516001600160601b03199081169116146126485760405162461bcd60e51b815260206004820152602b60248201527f5377656570207265717565737420646f6573206e6f742062656c6f6e6720746f60448201527f207468652077616c6c65740000000000000000000000000000000000000000006064820152608401610353565b60007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663be05abe36040518163ffffffff1660e01b815260040161016060405180830381865afa1580156126a9573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906126cd9190614c76565b505050975050505050505050600086606001351161273d5760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b8067ffffffffffffffff16866060013511156122ce5760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b60006127be6127b9602084018461476f565b6136f5565b61280a5760405162461bcd60e51b815260206004820152601d60248201527f4e6f7420612076616c696420686561727462656174206d6573736167650000006044820152606401610353565b506001919050565b6000807f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663f75b4b1c6040518163ffffffff1660e01b815260040161014060405180830381865afa158015612874573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906128989190614ffc565b50979850506001600160a01b03881696506128fc955050505050505760405162461bcd60e51b815260206004820152601960248201527f5265736572766174696f6e73206172652064697361626c6564000000000000006044820152606401610353565b61291161290c60208601866143d1565b61376d565b60006020850135612928606087016040880161513c565b60405160200161294f92919091825260e01b6001600160e01b031916602082015260240190565b60408051601f198184030181529190528051602090910120905060006001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663cec8c6e9836129ab60808a0160608b01614e7c565b6040516001600160e01b031960e085901b168152600481019290925267ffffffffffffffff16602482015260440161028060405180830381865afa1580156129f7573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612a1b9190614e99565b9050600181608001516004811115612a3557612a356145ac565b14612a825760405162461bcd60e51b815260206004820152601f60248201527f4e6f7420612070656e64696e6720616363657074616e636520616374696f6e006044820152606401610353565b60018160a001516005811115612a9a57612a9a6145ac565b14612ae75760405162461bcd60e51b815260206004820181905260248201527f416363657074616e636520616374696f6e206973206e6f742070656e64696e676044820152606401610353565b611c208160400151612af9919061488d565b63ffffffff164210612b4d5760405162461bcd60e51b815260206004820152601f60248201527f416363657074616e636520616374696f6e206861732074696d6564206f7574006044820152606401610353565b612b5a60208701876143d1565b81516001600160601b0319908116911614612bdd5760405162461bcd60e51b815260206004820152602b60248201527f57616c6c657420646f6573206e6f74206d617463682074686520617574686f7260448201527f697a656420616374696f6e0000000000000000000000000000000000000000006064820152608401610353565b604051630b02c43d60e41b8152600481018390526000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063b02c43d09060240160e060405180830381865afa158015612c45573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612c699190614b55565b9050806040015163ffffffff16600003612cc55760405162461bcd60e51b815260206004820152601460248201527f4465706f736974206e6f742072657665616c65640000000000000000000000006044820152606401610353565b611c208160400151612cd79190614869565b63ffffffff164211612d2b5760405162461bcd60e51b815260206004820181905260248201527f4465706f736974206d696e20616765206e6f74206163686965766564207965746044820152606401610353565b60a081015163ffffffff1615612d835760405162461bcd60e51b815260206004820152601560248201527f4465706f73697420616c726561647920737765707400000000000000000000006044820152606401610353565b6040516393df529b60e01b8152600481018490527f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906393df529b90602401602060405180830381865afa158015612de8573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612e0c9190614c18565b612e7d5760405162461bcd60e51b8152602060048201526024808201527f4465706f73697420776173206e6f742072657665616c6564206173207265736560448201527f72766564000000000000000000000000000000000000000000000000000000006064820152608401610353565b836001600160a01b031681606001516001600160a01b031614612f085760405162461bcd60e51b815260206004820152602b60248201527f4465706f736974206e6f7420726f7574656420746f207468652072657365727660448201527f6174696f6e207661756c740000000000000000000000000000000000000000006064820152608401610353565b6000876080013511612f6c5760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b816060015167ffffffffffffffff1687608001351115612fda5760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b866080013582610260015167ffffffffffffffff16612ff991906148aa565b816020015167ffffffffffffffff16101561307c5760405162461bcd60e51b815260206004820152602b60248201527f416e63686f7220616d6f756e742062656c6f772074686520726573657276617460448201527f696f6e206d696e696d756d0000000000000000000000000000000000000000006064820152608401610353565b6130a661309136899003890160208a01614920565b825160c08401516130a18a614a53565b61337c565b60006130e16130bb60a0890160808a016151e2565b60d881901c63ff00ff001662ff00ff60e89290921c9190911617601081811b91901c1790565b905063ffffffff81166130f762015180426148aa565b1061315a5760405162461bcd60e51b815260206004820152602d60248201527f4465706f73697420726566756e6420736166657479206d617267696e2069732060448201526c1b9bdd081c1c995cd95c9d9959609a1b6064820152608401610353565b61316760208901896143d1565b6001600160601b0319166131816060890160408a016143d1565b6001600160601b031916146131e75760405162461bcd60e51b815260206004820152602660248201527f4465706f73697420636f6e74726f6c6c656420627920646966666572656e74206044820152651dd85b1b195d60d21b6064820152608401610353565b506001979650505050505050565b600082116132555760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b600061326182846146d6565b90506000826132708386614700565b61327a9190614713565b905060007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663c42b64d06040518163ffffffff1660e01b8152600401608060405180830381865afa1580156132dc573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061330091906151fd565b5092505067ffffffffffffffff8216905061331b84846148aa565b11156133755760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b5050505050565b8051805160208083015160408085015160609095015190516000956133be956133aa9590949391920161528c565b6040516020818303038152906040526138b6565b855190915081146134375760405162461bcd60e51b815260206004820152602960248201527f457874726120696e666f2066756e64696e67207478206861736820646f65732060448201527f6e6f74206d6174636800000000000000000000000000000000000000000000006064820152608401610353565b60608361347a57602080840151604080860151606087015160808801519251613464958b959491016152c2565b60405160208183030381529060405290506134b7565b848484602001518560400151866060015187608001516040516020016134a5969594939291906154d6565b60405160208183030381529060405290505b60006134de876020015163ffffffff168560000151604001516138dd90919063ffffffff16565b905060006134eb82613abf565b905080516014148015613523575061350283613ad9565b6001600160601b031916613517826000613b00565b6001600160601b031916145b156135315750505050613614565b80516020148015613598575060028360405161354d91906155fe565b602060405180830381855afa15801561356a573d6000803e3d6000fd5b5050506040513d601f19601f8201168201806040525081019061358d919061560a565b61359682613b0f565b145b156135a65750505050613614565b60405162461bcd60e51b815260206004820152602f60248201527f457874726120696e666f2066756e64696e67206f75747075742073637269707460448201527f20646f6573206e6f74206d6174636800000000000000000000000000000000006064820152608401610353565b50505050565b600082156118f057828235613635604085016020860161513c565b6136456060860160408701614e7c565b6040516020016136829392919092835260e09190911b6001600160e01b031916602083015260c01b6001600160c01b0319166024820152602c0190565b60405160208183030381529060405280519060200120146136e55760405162461bcd60e51b815260206004820152601d60248201527f496e76616c69642077616c6c6574206d61696e205554584f20646174610000006044820152606401610353565b6114626060830160408401614e7c565b600060108214613707575060006118f0565b61374b600084848080601f0160208091040260200160405190810160405280939291908181526020018383808284376000920191909152509293925050613b009050565b6001600160c01b031990811614613764575060006118f0565b50600192915050565b6040517fe65e19d50000000000000000000000000000000000000000000000000000000081526001600160601b0319821660048201526000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063e65e19d59060240161012060405180830381865afa1580156137f8573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061381c9190614509565b60e0015190506001816005811115613836576138366145ac565b148061385357506002816005811115613851576138516145ac565b145b6138b25760405162461bcd60e51b815260206004820152602a60248201527f57616c6c6574206973206e6f7420696e204c697665206f72204d6f76696e6746604482015269756e647320737461746560b01b6064820152608401610353565b5050565b60006020600083516020850160025afa50602060006020600060025afa5050600051919050565b60606000806138eb85613b2a565b9092509050600182016139665760405162461bcd60e51b815260206004820152602260248201527f52656164206f76657272756e20647572696e6720566172496e7420706172736960448201527f6e670000000000000000000000000000000000000000000000000000000000006064820152608401610353565b8084106139b55760405162461bcd60e51b815260206004820152601160248201527f566f75742072656164206f76657272756e0000000000000000000000000000006044820152606401610353565b6000806139c38460016148aa565b905060005b86811015613a4b576139da8883613b41565b92506000198303613a2d5760405162461bcd60e51b815260206004820152601a60248201527f42616420566172496e7420696e207363726970745075626b65790000000000006044820152606401610353565b613a3783836148aa565b915080613a43816148bd565b9150506139c8565b50613a568782613b41565b91506000198203613aa95760405162461bcd60e51b815260206004820152601a60248201527f42616420566172496e7420696e207363726970745075626b65790000000000006044820152606401610353565b613ab4878284613bae565b979650505050505050565b60606118f0826008808551613ad49190614700565b613c7b565b60006020600083516020850160025afa50602060006020600060035afa5050600c51919050565b60006114628383016020015190565b60008151600003613b2257506000919050565b506020015190565b600080613b38836000613f80565b91509150915091565b6000613b4e8260096148aa565b83511015613b5f57506000196118f0565b600080613b7685613b718660086148aa565b613f80565b909250905060018201613b8f57600019925050506118f0565b80613b9b8360096148aa565b613ba591906148aa565b95945050505050565b606081600003613bcd5750604080516020810190915260008152611462565b6000613bd983856148aa565b90508381118015613beb575080855110155b613c375760405162461bcd60e51b815260206004820152601360248201527f536c696365206f7574206f6620626f756e6473000000000000000000000000006044820152606401610353565b604051915082604083010160405282825283850182038460208701018481015b80821015613c7057815183830152602082019150613c57565b505050509392505050565b60606000848481518110613c9157613c91614759565b016020015160f81c905082613ca7826001615623565b60ff1614613cc5575050604080516020810190915260008152611462565b84613cd18560016148aa565b81518110613ce157613ce1614759565b016020015160f81c600003613da05760028160ff161015613d12575050604080516020810190915260008152611462565b600085613d208660026148aa565b81518110613d3057613d30614759565b016020015160f81c9050613d4560028361563c565b60ff1681141580613d63575080602014158015613d63575080601414155b15613d81576040518060200160405280600081525092505050611462565b613d97613d8f8660036148aa565b879083613bae565b92505050611462565b6000613dac8686613b00565b90507fffffff000000000000000000000000000000000000000000000000000000000081167f1976a9000000000000000000000000000000000000000000000000000000000003613ec05785613e038660036148aa565b81518110613e1357613e13614759565b60209101015160f81c6014141580613e8b5750613e466002613e3586886148aa565b613e3f9190614700565b8790613b00565b7dffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff19167f88ac00000000000000000000000000000000000000000000000000000000000014155b15613ea9576040518060200160405280600081525092505050611462565b613d97613eb78660046148aa565b87906014613bae565b7fffffff000000000000000000000000000000000000000000000000000000000081167f17a914000000000000000000000000000000000000000000000000000000000003613f6757856001613f1686886148aa565b613f209190614700565b81518110613f3057613f30614759565b60209101015160f81c608714613f59576040518060200160405280600081525092505050611462565b613d97613eb78660036148aa565b5050506040805160208101909152600081529392505050565b6000806000613f8f85856140fe565b90508060ff16600003613fc4576000858581518110613fb057613fb0614759565b016020015190935060f81c91506140f79050565b83613fd0826001615623565b60ff16613fdd91906148aa565b85511015613ff457600019600092509250506140f7565b60008160ff166002036140385761402d6140196140128760016148aa565b8890613b00565b62ffff0060e882901c1660f89190911c1790565b61ffff1690506140ed565b8160ff16600403614061576140546130bb6140128760016148aa565b63ffffffff1690506140ed565b8160ff166008036140ed576140e061407d6140128760016148aa565b60c01c64ff000000ff600882811c91821665ff000000ff009390911b92831617601090811b67ffffffffffffffff1666ff00ff00ff00ff9290921667ff00ff00ff00ff009093169290921790911c65ffff0000ffff1617602081811c91901b1790565b67ffffffffffffffff1690505b60ff909116925090505b9250929050565b600082828151811061411257614112614759565b016020015160f81c60ff03614129575060086118f0565b82828151811061413b5761413b614759565b016020015160f81c60fe03614152575060046118f0565b82828151811061416457614164614759565b016020015160f81c60fd0361417b575060026118f0565b50600092915050565b60006060828403121561419657600080fd5b50919050565b6000602082840312156141ae57600080fd5b813567ffffffffffffffff8111156141c557600080fd5b6141d184828501614184565b949350505050565b60006080828403121561419657600080fd5b60008060006040848603121561420057600080fd5b833567ffffffffffffffff8082111561421857600080fd5b614224878388016141d9565b9450602086013591508082111561423a57600080fd5b818601915086601f83011261424e57600080fd5b81358181111561425d57600080fd5b8760208260051b850101111561427257600080fd5b6020830194508093505050509250925092565b6000806080838503121561429857600080fd5b823567ffffffffffffffff8111156142af57600080fd5b6142bb85828601614184565b9250506142cb8460208501614184565b90509250929050565b600060a0828403121561419657600080fd5b600060a082840312156142f857600080fd5b61146283836142d4565b60006080828403121561431457600080fd5b61146283836141d9565b60006020828403121561433057600080fd5b813567ffffffffffffffff81111561434757600080fd5b82016040818503121561146257600080fd5b60008060c0838503121561436c57600080fd5b61437684846142d4565b915060a083013567ffffffffffffffff81111561439257600080fd5b61439e858286016142d4565b9150509250929050565b6001600160601b0319811681146143be57600080fd5b50565b80356143cc816143a8565b919050565b6000602082840312156143e357600080fd5b8135611462816143a8565b634e487b7160e01b600052604160045260246000fd5b604051610120810167ffffffffffffffff81118282101715614428576144286143ee565b60405290565b60405160a0810167ffffffffffffffff81118282101715614428576144286143ee565b6040516080810167ffffffffffffffff81118282101715614428576144286143ee565b6040516101c0810167ffffffffffffffff81118282101715614428576144286143ee565b604051610280810167ffffffffffffffff81118282101715614428576144286143ee565b67ffffffffffffffff811681146143be57600080fd5b80516143cc816144bc565b63ffffffff811681146143be57600080fd5b80516143cc816144dd565b8051600681106143cc57600080fd5b6000610120828403121561451c57600080fd5b614524614404565b825181526020830151602082015261453e604084016144d2565b604082015261454f606084016144ef565b6060820152614560608084016144ef565b608082015261457160a084016144ef565b60a082015261458260c084016144ef565b60c082015261459360e084016144fa565b60e0820152610100928301519281019290925250919050565b634e487b7160e01b600052602160045260246000fd5b6000808335601e198436030181126145d957600080fd5b83018035915067ffffffffffffffff8211156145f457600080fd5b6020019150600581901b36038213156140f757600080fd5b80516bffffffffffffffffffffffff811681146143cc57600080fd5b600080600080600080600060e0888a03121561464357600080fd5b875161464e816144bc565b602089015190975061465f816144bc565b6040890151909650614670816144bc565b6060890151909550614681816144bc565b6080890151909450614692816144dd565b92506146a060a0890161460c565b915060c08801516146b0816144dd565b8091505092959891949750929550565b634e487b7160e01b600052601260045260246000fd5b6000826146e5576146e56146c0565b500690565b634e487b7160e01b600052601160045260246000fd5b818103818111156118f0576118f06146ea565b600082614722576147226146c0565b500490565b80516001600160a01b03811681146143cc57600080fd5b60006020828403121561475057600080fd5b61146282614727565b634e487b7160e01b600052603260045260246000fd5b6000808335601e1984360301811261478657600080fd5b83018035915067ffffffffffffffff8211156147a157600080fd5b6020019150368190038213156140f757600080fd5b600060a082840312156147c857600080fd5b60405160a0810181811067ffffffffffffffff821117156147eb576147eb6143ee565b6040526147f783614727565b81526020830151614807816144bc565b6020820152604083015161481a816144bc565b6040820152606083015161482d816144bc565b60608201526080830151614840816144dd565b60808201529392505050565b60006020828403121561485e57600080fd5b8151611462816144dd565b63ffffffff818116838216019080821115614886576148866146ea565b5092915050565b63ffffffff828116828216039080821115614886576148866146ea565b808201808211156118f0576118f06146ea565b6000600182016148cf576148cf6146ea565b5060010190565b6000808335601e198436030181126148ed57600080fd5b83018035915067ffffffffffffffff82111561490857600080fd5b6020019150600681901b36038213156140f757600080fd5b60006040828403121561493257600080fd5b6040516040810181811067ffffffffffffffff82111715614955576149556143ee565b60405282358152602083013561496a816144dd565b60208201529392505050565b60008235609e1983360301811261498c57600080fd5b9190910192915050565b80356001600160e01b0319811681146143cc57600080fd5b600082601f8301126149bf57600080fd5b813567ffffffffffffffff808211156149da576149da6143ee565b604051601f8301601f19908116603f01168101908282118183101715614a0257614a026143ee565b81604052838152866020858801011115614a1b57600080fd5b836020870160208301376000602085830101528094505050505092915050565b80356001600160c01b0319811681146143cc57600080fd5b600060a08236031215614a6557600080fd5b614a6d61442e565b823567ffffffffffffffff80821115614a8557600080fd5b818501915060808236031215614a9a57600080fd5b614aa2614451565b614aab83614996565b8152602083013582811115614abf57600080fd5b614acb368286016149ae565b602083015250604083013582811115614ae357600080fd5b614aef368286016149ae565b604083015250614b0160608401614996565b6060820152835250614b17905060208401614a3b565b6020820152614b28604084016143c1565b6040820152614b39606084016143c1565b6060820152614b4a60808401614996565b608082015292915050565b600060e08284031215614b6757600080fd5b60405160e0810181811067ffffffffffffffff82111715614b8a57614b8a6143ee565b604052614b9683614727565b81526020830151614ba6816144bc565b60208201526040830151614bb9816144dd565b6040820152614bca60608401614727565b60608201526080830151614bdd816144bc565b608082015260a0830151614bf0816144dd565b60a082015260c0928301519281019290925250919050565b805180151581146143cc57600080fd5b600060208284031215614c2a57600080fd5b61146282614c08565b60008184825b85811015614c6b578135614c4c816143a8565b6001600160601b03191683526020928301929190910190600101614c39565b509095945050505050565b60008060008060008060008060008060006101608c8e031215614c9857600080fd5b8b51614ca3816144bc565b60208d0151909b50614cb4816144bc565b60408d0151909a50614cc5816144dd565b60608d0151909950614cd6816144dd565b9750614ce460808d0161460c565b965060a08c0151614cf4816144dd565b60c08d015190965061ffff81168114614d0c57600080fd5b9450614d1a60e08d016144d2565b9350614d296101008d016144ef565b9250614d386101208d0161460c565b9150614d476101408d016144ef565b90509295989b509295989b9093969950565b80516143cc816143a8565b8051600581106143cc57600080fd5b60006101c08284031215614d8657600080fd5b614d8e614474565b614d9783614727565b8152614da5602084016144d2565b6020820152614db6604084016144ef565b6040820152614dc760608401614d59565b6060820152614dd8608084016144d2565b6080820152614de960a084016144ef565b60a082015260c083015160c0820152614e0460e084016144ef565b60e0820152610100614e17818501614d64565b90820152610120614e298482016144d2565b90820152610140614e3b848201614c08565b90820152610160614e4d8482016144ef565b90820152610180614e5f8482016144d2565b908201526101a0614e718482016144ef565b908201529392505050565b600060208284031215614e8e57600080fd5b8135611462816144bc565b60006102808284031215614eac57600080fd5b614eb4614498565b614ebd83614d59565b8152614ecb602084016144ef565b6020820152614edc604084016144ef565b6040820152614eed606084016144d2565b6060820152614efe60808401614d64565b6080820152614f0f60a084016144fa565b60a0820152614f2060c08401614c08565b60c0820152614f3160e08401614727565b60e082015261010083810151908201526101208084015190820152610140614f5a8185016144d2565b90820152610160614f6c848201614c08565b90820152610180614f7e8482016144ef565b908201526101a0614f908482016144ef565b908201526101c0614fa28482016144ef565b908201526101e0614fb48482016144d2565b90820152610200614fc6848201614c08565b90820152610220614fd88482016144ef565b90820152610240614fea8482016144ef565b90820152610260614e718482016144d2565b6000806000806000806000806000806101408b8d03121561501c57600080fd5b6150258b614727565b995060208b0151615035816144bc565b60408c0151909950615046816144bc565b60608c0151909850615057816144dd565b60808c0151909750615068816144dd565b60a08c0151909650615079816144bc565b60c08c015190955061508a816144bc565b60e08c015190945061509b816144dd565b6101008c01519093506150ad816144dd565b6101208c01519092506150bf816144dd565b809150509295989b9194979a5092959850565b6000806000606084860312156150e757600080fd5b83516150f2816144bc565b6020850151909350615103816144bc565b6040850151909250615114816144dd565b809150509250925092565b60006020828403121561513157600080fd5b8151611462816144bc565b60006020828403121561514e57600080fd5b8135611462816144dd565b60006080828403121561516b57600080fd5b6040516080810181811067ffffffffffffffff8211171561518e5761518e6143ee565b604052825161519c816143a8565b815260208301516151ac816144bc565b602082015260408301516151bf816144dd565b60408201526060830151600481106151d657600080fd5b60608201529392505050565b6000602082840312156151f457600080fd5b61146282614996565b6000806000806080858703121561521357600080fd5b845161521e816144bc565b602086015190945061522f816144bc565b6040860151909350615240816144bc565b6060860151909250615251816144dd565b939692955090935050565b6000815160005b8181101561527d5760208185018101518683015201615263565b50600093019283525090919050565b60006001600160e01b031980871683526152b26152ac600485018861525c565b8661525c565b9316835250506004019392505050565b600560fa1b8152606086901b6001600160601b0319166001820152607560f81b6015820152600160fb1b60168201526001600160c01b031985166017820152607560f81b601f820152600061537a61536d615344602085015b7f7600000000000000000000000000000000000000000000000000000000000000815260010190565b7fa900000000000000000000000000000000000000000000000000000000000000815260010190565b600560fa1b815260010190565b6001600160601b031986168152608760f81b601482015261542361536d61534461531b6153fa6153d1601587015b7f6300000000000000000000000000000000000000000000000000000000000000815260010190565b7fac00000000000000000000000000000000000000000000000000000000000000815260010190565b7f6700000000000000000000000000000000000000000000000000000000000000815260010190565b6001600160601b0319861681529050601160fb1b601482015261546d601582015b7f0400000000000000000000000000000000000000000000000000000000000000815260010190565b6001600160e01b031985168152905060b160f81b60048201526154ca6154a16153d1600584015b607560f81b815260010190565b7f6800000000000000000000000000000000000000000000000000000000000000815260010190565b98975050505050505050565b600560fa1b81526001600160601b0319606088901b166001820152607560f81b60158201527f20000000000000000000000000000000000000000000000000000000000000006016820152600060178201878152607560f81b6020820152600160fb1b6021820152602281016001600160c01b0319881681529050607560f81b600882015261556d61536d6153446009840161531b565b6001600160601b0319871681529050608760f81b60148201526155a161536d61534461531b6153fa6153d1601587016153a8565b6001600160601b0319861681529050601160fb1b60148201526155c660158201615444565b6001600160e01b031985168152905060b160f81b60048201526155f16154a16153d160058401615494565b9998505050505050505050565b6000611462828461525c565b60006020828403121561561c57600080fd5b5051919050565b60ff81811683821601908111156118f0576118f06146ea565b60ff82811682821603908111156118f0576118f06146ea56fea2646970667358221220a42086f4bb2ae452b1d286f3cc21e41b37b8545fb3793839aa4a3eb5087af85364736f6c63430008110033", - "deployedBytecode": "0x608060405234801561001057600080fd5b50600436106100ea5760003560e01c8063a57f734f1161008c578063e0276b2611610066578063e0276b2614610152578063e78cea92146101af578063f03a4bc5146101ee578063f9179ad21461020157600080fd5b8063a57f734f14610180578063d09520f714610193578063d6c7fa781461019c57600080fd5b80637405d7bf116100c85780637405d7bf14610135578063765f28f914610148578063996a756b146101525780639b337db21461016d57600080fd5b80630538ac1b146100ef5780630fde6c76146101125780636b562cec146100ef575b600080fd5b6100f8611c2081565b60405163ffffffff90911681526020015b60405180910390f35b61012561012036600461419c565b610214565b6040519015158152602001610109565b6101256101433660046141eb565b610b88565b6100f86201518081565b61015a601481565b60405161ffff9091168152602001610109565b61012561017b366004614285565b611469565b61012561018e3660046142e6565b6118f6565b6100f861025881565b6101256101aa366004614302565b6122da565b6101d67f000000000000000000000000000000000000000000000000000000000000000081565b6040516001600160a01b039091168152602001610109565b6101256101fc36600461431e565b6127a7565b61012561020f366004614359565b612812565b6000806001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d561025360208601866143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa158015610299573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906102bd9190614509565b905060018160e0015160058111156102d7576102d76145ac565b14806102f8575060028160e0015160058111156102f6576102f66145ac565b145b61035c5760405162461bcd60e51b815260206004820152602a60248201527f57616c6c6574206973206e6f7420696e204c697665206f72204d6f76696e6746604482015269756e647320737461746560b01b60648201526084015b60405180910390fd5b600061036b60208501856145c2565b915050806103bb5760405162461bcd60e51b815260206004820152601d60248201527f526564656d7074696f6e2062656c6f7720746865206d696e2073697a650000006044820152606401610353565b601481111561040c5760405162461bcd60e51b815260206004820152601f60248201527f526564656d7074696f6e206578636565647320746865206d61782073697a65006044820152606401610353565b6000807f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316636e70ce416040518163ffffffff1660e01b815260040160e060405180830381865afa15801561046d573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906104919190614628565b50509450945050505060008660400135116104fe5760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b8167ffffffffffffffff16866040013511156105685760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b60006105788460408901356146d6565b905060008461058b8360408b0135614700565b6105959190614713565b905060007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316635f3281ca6040518163ffffffff1660e01b8152600401602060405180830381865afa1580156105f7573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061061b919061473e565b905060008667ffffffffffffffff811115610638576106386143ee565b604051908082528060200260200182016040528015610661578160200160208202803683370190505b50905060005b87811015610b7757600061067e60208d018d6145c2565b8381811061068e5761068e614759565b90506020028101906106a0919061476f565b8080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509050600081805190602001208d600001602081019061070291906143d1565b6040516020016107269291909182526001600160601b031916602082015260340190565b60408051808303601f190181529082905280516020909101207f03d952f70000000000000000000000000000000000000000000000000000000082526004820181905291506000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906303d952f79060240160a060405180830381865afa1580156107bf573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906107e391906147b6565b9050806080015163ffffffff1660000361083f5760405162461bcd60e51b815260206004820181905260248201527f4e6f7420612070656e64696e6720726564656d7074696f6e20726571756573746044820152606401610353565b6102586001600160a01b038716156108f2576040517f0df5db70000000000000000000000000000000000000000000000000000000008152600481018490526000906001600160a01b03891690630df5db7090602401602060405180830381865afa1580156108b2573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906108d6919061484c565b90508163ffffffff168163ffffffff1611156108f0578091505b505b8082608001516109029190614869565b63ffffffff16421161097c5760405162461bcd60e51b815260206004820152602b60248201527f526564656d7074696f6e2072657175657374206d696e20616765206e6f74206160448201527f63686965766564207965740000000000000000000000000000000000000000006064820152608401610353565b60008a836080015161098e9190614869565b905061099c611c208261488d565b63ffffffff164210610a165760405162461bcd60e51b815260206004820152603960248201527f526564656d7074696f6e20726571756573742074696d656f757420736166657460448201527f79206d617267696e206973206e6f7420707265736572766564000000000000006064820152608401610353565b88610a2260018f614700565b8703610a3557610a328b826148aa565b90505b836060015167ffffffffffffffff16811115610ab95760405162461bcd60e51b815260206004820152603660248201527f50726f706f736564207472616e73616374696f6e207065722d7265717565737460448201527f2066656520736861726520697320746f6f2068696768000000000000000000006064820152608401610353565b60005b87811015610b3e5785898281518110610ad757610ad7614759565b602002602001015103610b2c5760405162461bcd60e51b815260206004820152601260248201527f4475706c696361746564207265717565737400000000000000000000000000006044820152606401610353565b80610b36816148bd565b915050610abc565b5084888881518110610b5257610b52614759565b6020026020010181815250505050505050508080610b6f906148bd565b915050610667565b5060019a9950505050505050505050565b6000806001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d5610bc760208801886143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa158015610c0d573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610c319190614509565b905060018160e001516005811115610c4b57610c4b6145ac565b1480610c6c575060028160e001516005811115610c6a57610c6a6145ac565b145b610ccb5760405162461bcd60e51b815260206004820152602a60248201527f57616c6c6574206973206e6f7420696e204c697665206f72204d6f76696e6746604482015269756e647320737461746560b01b6064820152608401610353565b6000610cda60208701876148d6565b905011610d295760405162461bcd60e51b815260206004820152601860248201527f53776565702062656c6f7720746865206d696e2073697a6500000000000000006044820152606401610353565b6014610d3860208701876148d6565b90501115610d885760405162461bcd60e51b815260206004820152601a60248201527f5377656570206578636565647320746865206d61782073697a650000000000006044820152606401610353565b82610d9660208701876148d6565b905014610e0b5760405162461bcd60e51b815260206004820152602e60248201527f45616368206465706f736974206b6579206d7573742068617665206d6174636860448201527f696e6720657874726120696e666f0000000000000000000000000000000000006064820152608401610353565b610e276040860135610e2060208801886148d6565b90506131f5565b600080610e3760208801886148d6565b905067ffffffffffffffff811115610e5157610e516143ee565b604051908082528060200260200182016040528015610e7a578160200160208202803683370190505b50905060005b610e8d60208901896148d6565b9050811015611459576000610ea560208a018a6148d6565b83818110610eb557610eb5614759565b905060400201803603810190610ecb9190614920565b90506000888884818110610ee157610ee1614759565b9050602002810190610ef39190614976565b610efc90614a53565b8251602080850151604051939450600093610f2e93920191825260e01b6001600160e01b031916602082015260240190565b60408051808303601f19018152908290528051602090910120630b02c43d60e41b82526004820181905291506000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063b02c43d09060240160e060405180830381865afa158015610fae573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610fd29190614b55565b9050806040015163ffffffff1660000361102e5760405162461bcd60e51b815260206004820152601460248201527f4465706f736974206e6f742072657665616c65640000000000000000000000006044820152606401610353565b611c2081604001516110409190614869565b63ffffffff1642116110945760405162461bcd60e51b815260206004820181905260248201527f4465706f736974206d696e20616765206e6f74206163686965766564207965746044820152606401610353565b60a081015163ffffffff16156110ec5760405162461bcd60e51b815260206004820152601560248201527f4465706f73697420616c726561647920737765707400000000000000000000006044820152606401610353565b6040516393df529b60e01b8152600481018390527f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906393df529b90602401602060405180830381865afa158015611151573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906111759190614c18565b156111e85760405162461bcd60e51b815260206004820152602360248201527f5265736572766564206465706f73697473206d757374206e6f7420626520737760448201527f65707400000000000000000000000000000000000000000000000000000000006064820152608401610353565b6111fc8482600001518360c001518661337c565b608083015160e881901c62ff00ff1663ff00ff0060d89290921c9190911617601081811c91901b17611231620151808261488d565b63ffffffff16421061129b5760405162461bcd60e51b815260206004820152602d60248201527f4465706f73697420726566756e6420736166657479206d617267696e2069732060448201526c1b9bdd081c1c995cd95c9d9959609a1b6064820152608401610353565b6112a860208e018e6143d1565b6bffffffffffffffffffffffff191684604001516bffffffffffffffffffffffff1916146113275760405162461bcd60e51b815260206004820152602660248201527f4465706f73697420636f6e74726f6c6c656420627920646966666572656e74206044820152651dd85b1b195d60d21b6064820152608401610353565b8560000361133757816060015197505b876001600160a01b031682606001516001600160a01b03161461139c5760405162461bcd60e51b815260206004820152601f60248201527f4465706f736974207461726765747320646966666572656e74207661756c74006044820152606401610353565b60005b8681101561142157838882815181106113ba576113ba614759565b60200260200101510361140f5760405162461bcd60e51b815260206004820152601260248201527f4475706c696361746564206465706f73697400000000000000000000000000006044820152606401610353565b80611419816148bd565b91505061139f565b508287878151811061143557611435614759565b60200260200101818152505050505050508080611451906148bd565b915050610e80565b50600193505050505b9392505050565b6000806001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d56114a860208701876143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa1580156114ee573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906115129190614509565b905060028160e00151600581111561152c5761152c6145ac565b1461159f5760405162461bcd60e51b815260206004820152602960248201527f536f757263652077616c6c6574206973206e6f7420696e204d6f76696e67467560448201527f6e647320737461746500000000000000000000000000000000000000000000006064820152608401610353565b6101008101516116175760405162461bcd60e51b815260206004820152602a60248201527f5461726765742077616c6c65747320636f6d6d69746d656e74206973206e6f7460448201527f207375626d6974746564000000000000000000000000000000000000000000006064820152608401610353565b61162460208501856145c2565b604051602001611635929190614c33565b60405160208183030381529060405280519060200120816101000151146116c45760405162461bcd60e51b815260206004820152603a60248201527f5461726765742077616c6c65747320646f206e6f74206d61746368207461726760448201527f65742077616c6c65747320636f6d6d69746d656e7420686173680000000000006064820152608401610353565b6000807f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663be05abe36040518163ffffffff1660e01b815260040161016060405180830381865afa158015611726573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061174a9190614c76565b50505050505050505091509150600061176784602001518761361a565b90508167ffffffffffffffff168167ffffffffffffffff1610156118195760405162461bcd60e51b815260206004820152604260248201527f536f757263652077616c6c6574204254432062616c616e63652069732062656c60448201527f6f7720746865206d6f76696e672066756e64732064757374207468726573686f60648201527f6c64000000000000000000000000000000000000000000000000000000000000608482015260a401610353565b600087604001351161187d5760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b8267ffffffffffffffff16876040013511156118e75760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b60019450505050505b92915050565b6040517f067cf8320000000000000000000000000000000000000000000000000000000081526020820135600482015260009081906001600160a01b037f0000000000000000000000000000000000000000000000000000000000000000169063067cf832906024016101c060405180830381865afa15801561197d573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906119a19190614d73565b905060006001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663cec8c6e960208601356119e96060880160408901614e7c565b6040516001600160e01b031960e085901b168152600481019290925267ffffffffffffffff16602482015260440161028060405180830381865afa158015611a35573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611a599190614e99565b9050600381608001516004811115611a7357611a736145ac565b14611ac05760405162461bcd60e51b815260206004820152601e60248201527f4e6f7420612070656e64696e672072652d616e63686f7220616374696f6e00006044820152606401610353565b60018160a001516005811115611ad857611ad86145ac565b14611b255760405162461bcd60e51b815260206004820152601f60248201527f52652d616e63686f7220616374696f6e206973206e6f742070656e64696e67006044820152606401610353565b611c208160400151611b37919061488d565b63ffffffff164210611b8b5760405162461bcd60e51b815260206004820152601e60248201527f52652d616e63686f7220616374696f6e206861732074696d6564206f757400006044820152606401610353565b611b9b60808501606086016143d1565b81516001600160601b0319908116911614611c1e5760405162461bcd60e51b815260206004820152603260248201527f5461726765742077616c6c657420646f6573206e6f74206d617463682074686560448201527f20617574686f72697a656420616374696f6e00000000000000000000000000006064820152608401610353565b611c2b60208501856143d1565b6bffffffffffffffffffffffff191682606001516bffffffffffffffffffffffff191614611cc15760405162461bcd60e51b815260206004820152602960248201527f5265736572766174696f6e20637573746f64696564206279206469666665726560448201527f6e742077616c6c657400000000000000000000000000000000000000000000006064820152608401610353565b611cce60208501856143d1565b6001600160601b031916611ce860808601606087016143d1565b6001600160601b03191603611d655760405162461bcd60e51b815260206004820152603060248201527f5461726765742077616c6c6574206d757374206469666665722066726f6d207460448201527f686520736f757263652077616c6c6574000000000000000000000000000000006064820152608401610353565b60016001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d5611da660808801606089016143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa158015611dec573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611e109190614509565b60e001516005811115611e2557611e256145ac565b14611e985760405162461bcd60e51b815260206004820152602360248201527f5461726765742077616c6c6574206d75737420626520696e204c69766520737460448201527f61746500000000000000000000000000000000000000000000000000000000006064820152608401610353565b60007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663f75b4b1c6040518163ffffffff1660e01b815260040161014060405180830381865afa158015611ef9573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611f1d9190614ffc565b50509750505050505050508063ffffffff167f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316631de0555a876060016020810190611f7191906143d1565b6040516001600160e01b031960e084901b1681526001600160601b03199091166004820152602401602060405180830381865afa158015611fb6573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611fda919061484c565b63ffffffff16111561202e5760405162461bcd60e51b815260206004820181905260248201527f57616c6c6574207265736572766174696f6e73206361702065786365656465646044820152606401610353565b60007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03166363dfb29c6040518163ffffffff1660e01b8152600401606060405180830381865afa15801561208e573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906120b291906150d2565b505090508067ffffffffffffffff166000148061218a575067ffffffffffffffff81166001600160a01b037f0000000000000000000000000000000000000000000000000000000000000000166363481e9861211460808a0160608b016143d1565b6040516001600160e01b031960e084901b1681526001600160601b03199091166004820152602401602060405180830381865afa158015612159573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061217d919061511f565b67ffffffffffffffff1611155b6121fc5760405162461bcd60e51b815260206004820152602360248201527f57616c6c657420726573657276656420616d6f756e742063617020657863656560448201527f64656400000000000000000000000000000000000000000000000000000000006064820152608401610353565b60008660800135116122605760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b826060015167ffffffffffffffff16866080013511156122ce5760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b50600195945050505050565b6000806001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d561231960208601866143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa15801561235f573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906123839190614509565b905060018160e00151600581111561239d5761239d6145ac565b14806123be575060028160e0015160058111156123bc576123bc6145ac565b145b6124305760405162461bcd60e51b815260206004820152603160248201527f536f757263652077616c6c6574206973206e6f7420696e204c697665206f722060448201527f4d6f76696e6746756e64732073746174650000000000000000000000000000006064820152608401610353565b60006020840135612447606086016040870161513c565b60405160200161246e92919091825260e01b6001600160e01b031916602082015260240190565b60408051808303601f190181529082905280516020909101207ff18cf1b10000000000000000000000000000000000000000000000000000000082526004820181905291506000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063f18cf1b190602401608060405180830381865afa158015612507573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061252b9190615159565b9050600181606001516003811115612545576125456145ac565b146125b85760405162461bcd60e51b815260206004820152602560248201527f53776565702072657175657374206973206e6f7420696e2050656e64696e672060448201527f73746174650000000000000000000000000000000000000000000000000000006064820152608401610353565b6125c560208601866143d1565b81516001600160601b03199081169116146126485760405162461bcd60e51b815260206004820152602b60248201527f5377656570207265717565737420646f6573206e6f742062656c6f6e6720746f60448201527f207468652077616c6c65740000000000000000000000000000000000000000006064820152608401610353565b60007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663be05abe36040518163ffffffff1660e01b815260040161016060405180830381865afa1580156126a9573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906126cd9190614c76565b505050975050505050505050600086606001351161273d5760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b8067ffffffffffffffff16866060013511156122ce5760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b60006127be6127b9602084018461476f565b6136f5565b61280a5760405162461bcd60e51b815260206004820152601d60248201527f4e6f7420612076616c696420686561727462656174206d6573736167650000006044820152606401610353565b506001919050565b6000807f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663f75b4b1c6040518163ffffffff1660e01b815260040161014060405180830381865afa158015612874573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906128989190614ffc565b50979850506001600160a01b03881696506128fc955050505050505760405162461bcd60e51b815260206004820152601960248201527f5265736572766174696f6e73206172652064697361626c6564000000000000006044820152606401610353565b61291161290c60208601866143d1565b61376d565b60006020850135612928606087016040880161513c565b60405160200161294f92919091825260e01b6001600160e01b031916602082015260240190565b60408051601f198184030181529190528051602090910120905060006001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663cec8c6e9836129ab60808a0160608b01614e7c565b6040516001600160e01b031960e085901b168152600481019290925267ffffffffffffffff16602482015260440161028060405180830381865afa1580156129f7573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612a1b9190614e99565b9050600181608001516004811115612a3557612a356145ac565b14612a825760405162461bcd60e51b815260206004820152601f60248201527f4e6f7420612070656e64696e6720616363657074616e636520616374696f6e006044820152606401610353565b60018160a001516005811115612a9a57612a9a6145ac565b14612ae75760405162461bcd60e51b815260206004820181905260248201527f416363657074616e636520616374696f6e206973206e6f742070656e64696e676044820152606401610353565b611c208160400151612af9919061488d565b63ffffffff164210612b4d5760405162461bcd60e51b815260206004820152601f60248201527f416363657074616e636520616374696f6e206861732074696d6564206f7574006044820152606401610353565b612b5a60208701876143d1565b81516001600160601b0319908116911614612bdd5760405162461bcd60e51b815260206004820152602b60248201527f57616c6c657420646f6573206e6f74206d617463682074686520617574686f7260448201527f697a656420616374696f6e0000000000000000000000000000000000000000006064820152608401610353565b604051630b02c43d60e41b8152600481018390526000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063b02c43d09060240160e060405180830381865afa158015612c45573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612c699190614b55565b9050806040015163ffffffff16600003612cc55760405162461bcd60e51b815260206004820152601460248201527f4465706f736974206e6f742072657665616c65640000000000000000000000006044820152606401610353565b611c208160400151612cd79190614869565b63ffffffff164211612d2b5760405162461bcd60e51b815260206004820181905260248201527f4465706f736974206d696e20616765206e6f74206163686965766564207965746044820152606401610353565b60a081015163ffffffff1615612d835760405162461bcd60e51b815260206004820152601560248201527f4465706f73697420616c726561647920737765707400000000000000000000006044820152606401610353565b6040516393df529b60e01b8152600481018490527f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906393df529b90602401602060405180830381865afa158015612de8573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612e0c9190614c18565b612e7d5760405162461bcd60e51b8152602060048201526024808201527f4465706f73697420776173206e6f742072657665616c6564206173207265736560448201527f72766564000000000000000000000000000000000000000000000000000000006064820152608401610353565b836001600160a01b031681606001516001600160a01b031614612f085760405162461bcd60e51b815260206004820152602b60248201527f4465706f736974206e6f7420726f7574656420746f207468652072657365727660448201527f6174696f6e207661756c740000000000000000000000000000000000000000006064820152608401610353565b6000876080013511612f6c5760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b816060015167ffffffffffffffff1687608001351115612fda5760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b866080013582610260015167ffffffffffffffff16612ff991906148aa565b816020015167ffffffffffffffff16101561307c5760405162461bcd60e51b815260206004820152602b60248201527f416e63686f7220616d6f756e742062656c6f772074686520726573657276617460448201527f696f6e206d696e696d756d0000000000000000000000000000000000000000006064820152608401610353565b6130a661309136899003890160208a01614920565b825160c08401516130a18a614a53565b61337c565b60006130e16130bb60a0890160808a016151e2565b60d881901c63ff00ff001662ff00ff60e89290921c9190911617601081811b91901c1790565b905063ffffffff81166130f762015180426148aa565b1061315a5760405162461bcd60e51b815260206004820152602d60248201527f4465706f73697420726566756e6420736166657479206d617267696e2069732060448201526c1b9bdd081c1c995cd95c9d9959609a1b6064820152608401610353565b61316760208901896143d1565b6001600160601b0319166131816060890160408a016143d1565b6001600160601b031916146131e75760405162461bcd60e51b815260206004820152602660248201527f4465706f73697420636f6e74726f6c6c656420627920646966666572656e74206044820152651dd85b1b195d60d21b6064820152608401610353565b506001979650505050505050565b600082116132555760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b600061326182846146d6565b90506000826132708386614700565b61327a9190614713565b905060007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663c42b64d06040518163ffffffff1660e01b8152600401608060405180830381865afa1580156132dc573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061330091906151fd565b5092505067ffffffffffffffff8216905061331b84846148aa565b11156133755760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b5050505050565b8051805160208083015160408085015160609095015190516000956133be956133aa9590949391920161528c565b6040516020818303038152906040526138b6565b855190915081146134375760405162461bcd60e51b815260206004820152602960248201527f457874726120696e666f2066756e64696e67207478206861736820646f65732060448201527f6e6f74206d6174636800000000000000000000000000000000000000000000006064820152608401610353565b60608361347a57602080840151604080860151606087015160808801519251613464958b959491016152c2565b60405160208183030381529060405290506134b7565b848484602001518560400151866060015187608001516040516020016134a5969594939291906154d6565b60405160208183030381529060405290505b60006134de876020015163ffffffff168560000151604001516138dd90919063ffffffff16565b905060006134eb82613abf565b905080516014148015613523575061350283613ad9565b6001600160601b031916613517826000613b00565b6001600160601b031916145b156135315750505050613614565b80516020148015613598575060028360405161354d91906155fe565b602060405180830381855afa15801561356a573d6000803e3d6000fd5b5050506040513d601f19601f8201168201806040525081019061358d919061560a565b61359682613b0f565b145b156135a65750505050613614565b60405162461bcd60e51b815260206004820152602f60248201527f457874726120696e666f2066756e64696e67206f75747075742073637269707460448201527f20646f6573206e6f74206d6174636800000000000000000000000000000000006064820152608401610353565b50505050565b600082156118f057828235613635604085016020860161513c565b6136456060860160408701614e7c565b6040516020016136829392919092835260e09190911b6001600160e01b031916602083015260c01b6001600160c01b0319166024820152602c0190565b60405160208183030381529060405280519060200120146136e55760405162461bcd60e51b815260206004820152601d60248201527f496e76616c69642077616c6c6574206d61696e205554584f20646174610000006044820152606401610353565b6114626060830160408401614e7c565b600060108214613707575060006118f0565b61374b600084848080601f0160208091040260200160405190810160405280939291908181526020018383808284376000920191909152509293925050613b009050565b6001600160c01b031990811614613764575060006118f0565b50600192915050565b6040517fe65e19d50000000000000000000000000000000000000000000000000000000081526001600160601b0319821660048201526000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063e65e19d59060240161012060405180830381865afa1580156137f8573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061381c9190614509565b60e0015190506001816005811115613836576138366145ac565b148061385357506002816005811115613851576138516145ac565b145b6138b25760405162461bcd60e51b815260206004820152602a60248201527f57616c6c6574206973206e6f7420696e204c697665206f72204d6f76696e6746604482015269756e647320737461746560b01b6064820152608401610353565b5050565b60006020600083516020850160025afa50602060006020600060025afa5050600051919050565b60606000806138eb85613b2a565b9092509050600182016139665760405162461bcd60e51b815260206004820152602260248201527f52656164206f76657272756e20647572696e6720566172496e7420706172736960448201527f6e670000000000000000000000000000000000000000000000000000000000006064820152608401610353565b8084106139b55760405162461bcd60e51b815260206004820152601160248201527f566f75742072656164206f76657272756e0000000000000000000000000000006044820152606401610353565b6000806139c38460016148aa565b905060005b86811015613a4b576139da8883613b41565b92506000198303613a2d5760405162461bcd60e51b815260206004820152601a60248201527f42616420566172496e7420696e207363726970745075626b65790000000000006044820152606401610353565b613a3783836148aa565b915080613a43816148bd565b9150506139c8565b50613a568782613b41565b91506000198203613aa95760405162461bcd60e51b815260206004820152601a60248201527f42616420566172496e7420696e207363726970745075626b65790000000000006044820152606401610353565b613ab4878284613bae565b979650505050505050565b60606118f0826008808551613ad49190614700565b613c7b565b60006020600083516020850160025afa50602060006020600060035afa5050600c51919050565b60006114628383016020015190565b60008151600003613b2257506000919050565b506020015190565b600080613b38836000613f80565b91509150915091565b6000613b4e8260096148aa565b83511015613b5f57506000196118f0565b600080613b7685613b718660086148aa565b613f80565b909250905060018201613b8f57600019925050506118f0565b80613b9b8360096148aa565b613ba591906148aa565b95945050505050565b606081600003613bcd5750604080516020810190915260008152611462565b6000613bd983856148aa565b90508381118015613beb575080855110155b613c375760405162461bcd60e51b815260206004820152601360248201527f536c696365206f7574206f6620626f756e6473000000000000000000000000006044820152606401610353565b604051915082604083010160405282825283850182038460208701018481015b80821015613c7057815183830152602082019150613c57565b505050509392505050565b60606000848481518110613c9157613c91614759565b016020015160f81c905082613ca7826001615623565b60ff1614613cc5575050604080516020810190915260008152611462565b84613cd18560016148aa565b81518110613ce157613ce1614759565b016020015160f81c600003613da05760028160ff161015613d12575050604080516020810190915260008152611462565b600085613d208660026148aa565b81518110613d3057613d30614759565b016020015160f81c9050613d4560028361563c565b60ff1681141580613d63575080602014158015613d63575080601414155b15613d81576040518060200160405280600081525092505050611462565b613d97613d8f8660036148aa565b879083613bae565b92505050611462565b6000613dac8686613b00565b90507fffffff000000000000000000000000000000000000000000000000000000000081167f1976a9000000000000000000000000000000000000000000000000000000000003613ec05785613e038660036148aa565b81518110613e1357613e13614759565b60209101015160f81c6014141580613e8b5750613e466002613e3586886148aa565b613e3f9190614700565b8790613b00565b7dffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff19167f88ac00000000000000000000000000000000000000000000000000000000000014155b15613ea9576040518060200160405280600081525092505050611462565b613d97613eb78660046148aa565b87906014613bae565b7fffffff000000000000000000000000000000000000000000000000000000000081167f17a914000000000000000000000000000000000000000000000000000000000003613f6757856001613f1686886148aa565b613f209190614700565b81518110613f3057613f30614759565b60209101015160f81c608714613f59576040518060200160405280600081525092505050611462565b613d97613eb78660036148aa565b5050506040805160208101909152600081529392505050565b6000806000613f8f85856140fe565b90508060ff16600003613fc4576000858581518110613fb057613fb0614759565b016020015190935060f81c91506140f79050565b83613fd0826001615623565b60ff16613fdd91906148aa565b85511015613ff457600019600092509250506140f7565b60008160ff166002036140385761402d6140196140128760016148aa565b8890613b00565b62ffff0060e882901c1660f89190911c1790565b61ffff1690506140ed565b8160ff16600403614061576140546130bb6140128760016148aa565b63ffffffff1690506140ed565b8160ff166008036140ed576140e061407d6140128760016148aa565b60c01c64ff000000ff600882811c91821665ff000000ff009390911b92831617601090811b67ffffffffffffffff1666ff00ff00ff00ff9290921667ff00ff00ff00ff009093169290921790911c65ffff0000ffff1617602081811c91901b1790565b67ffffffffffffffff1690505b60ff909116925090505b9250929050565b600082828151811061411257614112614759565b016020015160f81c60ff03614129575060086118f0565b82828151811061413b5761413b614759565b016020015160f81c60fe03614152575060046118f0565b82828151811061416457614164614759565b016020015160f81c60fd0361417b575060026118f0565b50600092915050565b60006060828403121561419657600080fd5b50919050565b6000602082840312156141ae57600080fd5b813567ffffffffffffffff8111156141c557600080fd5b6141d184828501614184565b949350505050565b60006080828403121561419657600080fd5b60008060006040848603121561420057600080fd5b833567ffffffffffffffff8082111561421857600080fd5b614224878388016141d9565b9450602086013591508082111561423a57600080fd5b818601915086601f83011261424e57600080fd5b81358181111561425d57600080fd5b8760208260051b850101111561427257600080fd5b6020830194508093505050509250925092565b6000806080838503121561429857600080fd5b823567ffffffffffffffff8111156142af57600080fd5b6142bb85828601614184565b9250506142cb8460208501614184565b90509250929050565b600060a0828403121561419657600080fd5b600060a082840312156142f857600080fd5b61146283836142d4565b60006080828403121561431457600080fd5b61146283836141d9565b60006020828403121561433057600080fd5b813567ffffffffffffffff81111561434757600080fd5b82016040818503121561146257600080fd5b60008060c0838503121561436c57600080fd5b61437684846142d4565b915060a083013567ffffffffffffffff81111561439257600080fd5b61439e858286016142d4565b9150509250929050565b6001600160601b0319811681146143be57600080fd5b50565b80356143cc816143a8565b919050565b6000602082840312156143e357600080fd5b8135611462816143a8565b634e487b7160e01b600052604160045260246000fd5b604051610120810167ffffffffffffffff81118282101715614428576144286143ee565b60405290565b60405160a0810167ffffffffffffffff81118282101715614428576144286143ee565b6040516080810167ffffffffffffffff81118282101715614428576144286143ee565b6040516101c0810167ffffffffffffffff81118282101715614428576144286143ee565b604051610280810167ffffffffffffffff81118282101715614428576144286143ee565b67ffffffffffffffff811681146143be57600080fd5b80516143cc816144bc565b63ffffffff811681146143be57600080fd5b80516143cc816144dd565b8051600681106143cc57600080fd5b6000610120828403121561451c57600080fd5b614524614404565b825181526020830151602082015261453e604084016144d2565b604082015261454f606084016144ef565b6060820152614560608084016144ef565b608082015261457160a084016144ef565b60a082015261458260c084016144ef565b60c082015261459360e084016144fa565b60e0820152610100928301519281019290925250919050565b634e487b7160e01b600052602160045260246000fd5b6000808335601e198436030181126145d957600080fd5b83018035915067ffffffffffffffff8211156145f457600080fd5b6020019150600581901b36038213156140f757600080fd5b80516bffffffffffffffffffffffff811681146143cc57600080fd5b600080600080600080600060e0888a03121561464357600080fd5b875161464e816144bc565b602089015190975061465f816144bc565b6040890151909650614670816144bc565b6060890151909550614681816144bc565b6080890151909450614692816144dd565b92506146a060a0890161460c565b915060c08801516146b0816144dd565b8091505092959891949750929550565b634e487b7160e01b600052601260045260246000fd5b6000826146e5576146e56146c0565b500690565b634e487b7160e01b600052601160045260246000fd5b818103818111156118f0576118f06146ea565b600082614722576147226146c0565b500490565b80516001600160a01b03811681146143cc57600080fd5b60006020828403121561475057600080fd5b61146282614727565b634e487b7160e01b600052603260045260246000fd5b6000808335601e1984360301811261478657600080fd5b83018035915067ffffffffffffffff8211156147a157600080fd5b6020019150368190038213156140f757600080fd5b600060a082840312156147c857600080fd5b60405160a0810181811067ffffffffffffffff821117156147eb576147eb6143ee565b6040526147f783614727565b81526020830151614807816144bc565b6020820152604083015161481a816144bc565b6040820152606083015161482d816144bc565b60608201526080830151614840816144dd565b60808201529392505050565b60006020828403121561485e57600080fd5b8151611462816144dd565b63ffffffff818116838216019080821115614886576148866146ea565b5092915050565b63ffffffff828116828216039080821115614886576148866146ea565b808201808211156118f0576118f06146ea565b6000600182016148cf576148cf6146ea565b5060010190565b6000808335601e198436030181126148ed57600080fd5b83018035915067ffffffffffffffff82111561490857600080fd5b6020019150600681901b36038213156140f757600080fd5b60006040828403121561493257600080fd5b6040516040810181811067ffffffffffffffff82111715614955576149556143ee565b60405282358152602083013561496a816144dd565b60208201529392505050565b60008235609e1983360301811261498c57600080fd5b9190910192915050565b80356001600160e01b0319811681146143cc57600080fd5b600082601f8301126149bf57600080fd5b813567ffffffffffffffff808211156149da576149da6143ee565b604051601f8301601f19908116603f01168101908282118183101715614a0257614a026143ee565b81604052838152866020858801011115614a1b57600080fd5b836020870160208301376000602085830101528094505050505092915050565b80356001600160c01b0319811681146143cc57600080fd5b600060a08236031215614a6557600080fd5b614a6d61442e565b823567ffffffffffffffff80821115614a8557600080fd5b818501915060808236031215614a9a57600080fd5b614aa2614451565b614aab83614996565b8152602083013582811115614abf57600080fd5b614acb368286016149ae565b602083015250604083013582811115614ae357600080fd5b614aef368286016149ae565b604083015250614b0160608401614996565b6060820152835250614b17905060208401614a3b565b6020820152614b28604084016143c1565b6040820152614b39606084016143c1565b6060820152614b4a60808401614996565b608082015292915050565b600060e08284031215614b6757600080fd5b60405160e0810181811067ffffffffffffffff82111715614b8a57614b8a6143ee565b604052614b9683614727565b81526020830151614ba6816144bc565b60208201526040830151614bb9816144dd565b6040820152614bca60608401614727565b60608201526080830151614bdd816144bc565b608082015260a0830151614bf0816144dd565b60a082015260c0928301519281019290925250919050565b805180151581146143cc57600080fd5b600060208284031215614c2a57600080fd5b61146282614c08565b60008184825b85811015614c6b578135614c4c816143a8565b6001600160601b03191683526020928301929190910190600101614c39565b509095945050505050565b60008060008060008060008060008060006101608c8e031215614c9857600080fd5b8b51614ca3816144bc565b60208d0151909b50614cb4816144bc565b60408d0151909a50614cc5816144dd565b60608d0151909950614cd6816144dd565b9750614ce460808d0161460c565b965060a08c0151614cf4816144dd565b60c08d015190965061ffff81168114614d0c57600080fd5b9450614d1a60e08d016144d2565b9350614d296101008d016144ef565b9250614d386101208d0161460c565b9150614d476101408d016144ef565b90509295989b509295989b9093969950565b80516143cc816143a8565b8051600581106143cc57600080fd5b60006101c08284031215614d8657600080fd5b614d8e614474565b614d9783614727565b8152614da5602084016144d2565b6020820152614db6604084016144ef565b6040820152614dc760608401614d59565b6060820152614dd8608084016144d2565b6080820152614de960a084016144ef565b60a082015260c083015160c0820152614e0460e084016144ef565b60e0820152610100614e17818501614d64565b90820152610120614e298482016144d2565b90820152610140614e3b848201614c08565b90820152610160614e4d8482016144ef565b90820152610180614e5f8482016144d2565b908201526101a0614e718482016144ef565b908201529392505050565b600060208284031215614e8e57600080fd5b8135611462816144bc565b60006102808284031215614eac57600080fd5b614eb4614498565b614ebd83614d59565b8152614ecb602084016144ef565b6020820152614edc604084016144ef565b6040820152614eed606084016144d2565b6060820152614efe60808401614d64565b6080820152614f0f60a084016144fa565b60a0820152614f2060c08401614c08565b60c0820152614f3160e08401614727565b60e082015261010083810151908201526101208084015190820152610140614f5a8185016144d2565b90820152610160614f6c848201614c08565b90820152610180614f7e8482016144ef565b908201526101a0614f908482016144ef565b908201526101c0614fa28482016144ef565b908201526101e0614fb48482016144d2565b90820152610200614fc6848201614c08565b90820152610220614fd88482016144ef565b90820152610240614fea8482016144ef565b90820152610260614e718482016144d2565b6000806000806000806000806000806101408b8d03121561501c57600080fd5b6150258b614727565b995060208b0151615035816144bc565b60408c0151909950615046816144bc565b60608c0151909850615057816144dd565b60808c0151909750615068816144dd565b60a08c0151909650615079816144bc565b60c08c015190955061508a816144bc565b60e08c015190945061509b816144dd565b6101008c01519093506150ad816144dd565b6101208c01519092506150bf816144dd565b809150509295989b9194979a5092959850565b6000806000606084860312156150e757600080fd5b83516150f2816144bc565b6020850151909350615103816144bc565b6040850151909250615114816144dd565b809150509250925092565b60006020828403121561513157600080fd5b8151611462816144bc565b60006020828403121561514e57600080fd5b8135611462816144dd565b60006080828403121561516b57600080fd5b6040516080810181811067ffffffffffffffff8211171561518e5761518e6143ee565b604052825161519c816143a8565b815260208301516151ac816144bc565b602082015260408301516151bf816144dd565b60408201526060830151600481106151d657600080fd5b60608201529392505050565b6000602082840312156151f457600080fd5b61146282614996565b6000806000806080858703121561521357600080fd5b845161521e816144bc565b602086015190945061522f816144bc565b6040860151909350615240816144bc565b6060860151909250615251816144dd565b939692955090935050565b6000815160005b8181101561527d5760208185018101518683015201615263565b50600093019283525090919050565b60006001600160e01b031980871683526152b26152ac600485018861525c565b8661525c565b9316835250506004019392505050565b600560fa1b8152606086901b6001600160601b0319166001820152607560f81b6015820152600160fb1b60168201526001600160c01b031985166017820152607560f81b601f820152600061537a61536d615344602085015b7f7600000000000000000000000000000000000000000000000000000000000000815260010190565b7fa900000000000000000000000000000000000000000000000000000000000000815260010190565b600560fa1b815260010190565b6001600160601b031986168152608760f81b601482015261542361536d61534461531b6153fa6153d1601587015b7f6300000000000000000000000000000000000000000000000000000000000000815260010190565b7fac00000000000000000000000000000000000000000000000000000000000000815260010190565b7f6700000000000000000000000000000000000000000000000000000000000000815260010190565b6001600160601b0319861681529050601160fb1b601482015261546d601582015b7f0400000000000000000000000000000000000000000000000000000000000000815260010190565b6001600160e01b031985168152905060b160f81b60048201526154ca6154a16153d1600584015b607560f81b815260010190565b7f6800000000000000000000000000000000000000000000000000000000000000815260010190565b98975050505050505050565b600560fa1b81526001600160601b0319606088901b166001820152607560f81b60158201527f20000000000000000000000000000000000000000000000000000000000000006016820152600060178201878152607560f81b6020820152600160fb1b6021820152602281016001600160c01b0319881681529050607560f81b600882015261556d61536d6153446009840161531b565b6001600160601b0319871681529050608760f81b60148201526155a161536d61534461531b6153fa6153d1601587016153a8565b6001600160601b0319861681529050601160fb1b60148201526155c660158201615444565b6001600160e01b031985168152905060b160f81b60048201526155f16154a16153d160058401615494565b9998505050505050505050565b6000611462828461525c565b60006020828403121561561c57600080fd5b5051919050565b60ff81811683821601908111156118f0576118f06146ea565b60ff82811682821603908111156118f0576118f06146ea56fea2646970667358221220a42086f4bb2ae452b1d286f3cc21e41b37b8545fb3793839aa4a3eb5087af85364736f6c63430008110033" + "bytecode": "0x60a06040523480156200001157600080fd5b50604051620057ce380380620057ce833981016040819052620000349162000046565b6001600160a01b031660805262000078565b6000602082840312156200005957600080fd5b81516001600160a01b03811681146200007157600080fd5b9392505050565b60805161568b62000143600039600081816101b401528181610221015281816104110152818161059b0152818161077001528181610b9501528181610f5f0152818161110201528181611476015281816116c901528181611935015281816119af01528181611d7101528181611e9c01528181611f3101528181612032015281816120df015281816122e7015281816124b80152818161264c015281816128170152818161297501528181612bf601528181612d990152818161328001526137a8015261568b6000f3fe608060405234801561001057600080fd5b50600436106100ea5760003560e01c8063a57f734f1161008c578063e0276b2611610066578063e0276b2614610152578063e78cea92146101af578063f03a4bc5146101ee578063f9179ad21461020157600080fd5b8063a57f734f14610180578063d09520f714610193578063d6c7fa781461019c57600080fd5b80637405d7bf116100c85780637405d7bf14610135578063765f28f914610148578063996a756b146101525780639b337db21461016d57600080fd5b80630538ac1b146100ef5780630fde6c76146101125780636b562cec146100ef575b600080fd5b6100f8611c2081565b60405163ffffffff90911681526020015b60405180910390f35b61012561012036600461419c565b610214565b6040519015158152602001610109565b6101256101433660046141eb565b610b88565b6100f86201518081565b61015a601481565b60405161ffff9091168152602001610109565b61012561017b366004614285565b611469565b61012561018e3660046142e6565b6118f6565b6100f861025881565b6101256101aa366004614302565b6122da565b6101d67f000000000000000000000000000000000000000000000000000000000000000081565b6040516001600160a01b039091168152602001610109565b6101256101fc36600461431e565b6127a7565b61012561020f366004614359565b612812565b6000806001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d561025360208601866143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa158015610299573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906102bd9190614509565b905060018160e0015160058111156102d7576102d76145ac565b14806102f8575060028160e0015160058111156102f6576102f66145ac565b145b61035c5760405162461bcd60e51b815260206004820152602a60248201527f57616c6c6574206973206e6f7420696e204c697665206f72204d6f76696e6746604482015269756e647320737461746560b01b60648201526084015b60405180910390fd5b600061036b60208501856145c2565b915050806103bb5760405162461bcd60e51b815260206004820152601d60248201527f526564656d7074696f6e2062656c6f7720746865206d696e2073697a650000006044820152606401610353565b601481111561040c5760405162461bcd60e51b815260206004820152601f60248201527f526564656d7074696f6e206578636565647320746865206d61782073697a65006044820152606401610353565b6000807f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316636e70ce416040518163ffffffff1660e01b815260040160e060405180830381865afa15801561046d573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906104919190614628565b50509450945050505060008660400135116104fe5760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b8167ffffffffffffffff16866040013511156105685760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b60006105788460408901356146d6565b905060008461058b8360408b0135614700565b6105959190614713565b905060007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316635f3281ca6040518163ffffffff1660e01b8152600401602060405180830381865afa1580156105f7573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061061b919061473e565b905060008667ffffffffffffffff811115610638576106386143ee565b604051908082528060200260200182016040528015610661578160200160208202803683370190505b50905060005b87811015610b7757600061067e60208d018d6145c2565b8381811061068e5761068e614759565b90506020028101906106a0919061476f565b8080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509050600081805190602001208d600001602081019061070291906143d1565b6040516020016107269291909182526001600160601b031916602082015260340190565b60408051808303601f190181529082905280516020909101207f03d952f70000000000000000000000000000000000000000000000000000000082526004820181905291506000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906303d952f79060240160a060405180830381865afa1580156107bf573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906107e391906147b6565b9050806080015163ffffffff1660000361083f5760405162461bcd60e51b815260206004820181905260248201527f4e6f7420612070656e64696e6720726564656d7074696f6e20726571756573746044820152606401610353565b6102586001600160a01b038716156108f2576040517f0df5db70000000000000000000000000000000000000000000000000000000008152600481018490526000906001600160a01b03891690630df5db7090602401602060405180830381865afa1580156108b2573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906108d6919061484c565b90508163ffffffff168163ffffffff1611156108f0578091505b505b8082608001516109029190614869565b63ffffffff16421161097c5760405162461bcd60e51b815260206004820152602b60248201527f526564656d7074696f6e2072657175657374206d696e20616765206e6f74206160448201527f63686965766564207965740000000000000000000000000000000000000000006064820152608401610353565b60008a836080015161098e9190614869565b905061099c611c208261488d565b63ffffffff164210610a165760405162461bcd60e51b815260206004820152603960248201527f526564656d7074696f6e20726571756573742074696d656f757420736166657460448201527f79206d617267696e206973206e6f7420707265736572766564000000000000006064820152608401610353565b88610a2260018f614700565b8703610a3557610a328b826148aa565b90505b836060015167ffffffffffffffff16811115610ab95760405162461bcd60e51b815260206004820152603660248201527f50726f706f736564207472616e73616374696f6e207065722d7265717565737460448201527f2066656520736861726520697320746f6f2068696768000000000000000000006064820152608401610353565b60005b87811015610b3e5785898281518110610ad757610ad7614759565b602002602001015103610b2c5760405162461bcd60e51b815260206004820152601260248201527f4475706c696361746564207265717565737400000000000000000000000000006044820152606401610353565b80610b36816148bd565b915050610abc565b5084888881518110610b5257610b52614759565b6020026020010181815250505050505050508080610b6f906148bd565b915050610667565b5060019a9950505050505050505050565b6000806001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d5610bc760208801886143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa158015610c0d573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610c319190614509565b905060018160e001516005811115610c4b57610c4b6145ac565b1480610c6c575060028160e001516005811115610c6a57610c6a6145ac565b145b610ccb5760405162461bcd60e51b815260206004820152602a60248201527f57616c6c6574206973206e6f7420696e204c697665206f72204d6f76696e6746604482015269756e647320737461746560b01b6064820152608401610353565b6000610cda60208701876148d6565b905011610d295760405162461bcd60e51b815260206004820152601860248201527f53776565702062656c6f7720746865206d696e2073697a6500000000000000006044820152606401610353565b6014610d3860208701876148d6565b90501115610d885760405162461bcd60e51b815260206004820152601a60248201527f5377656570206578636565647320746865206d61782073697a650000000000006044820152606401610353565b82610d9660208701876148d6565b905014610e0b5760405162461bcd60e51b815260206004820152602e60248201527f45616368206465706f736974206b6579206d7573742068617665206d6174636860448201527f696e6720657874726120696e666f0000000000000000000000000000000000006064820152608401610353565b610e276040860135610e2060208801886148d6565b90506131f5565b600080610e3760208801886148d6565b905067ffffffffffffffff811115610e5157610e516143ee565b604051908082528060200260200182016040528015610e7a578160200160208202803683370190505b50905060005b610e8d60208901896148d6565b9050811015611459576000610ea560208a018a6148d6565b83818110610eb557610eb5614759565b905060400201803603810190610ecb9190614920565b90506000888884818110610ee157610ee1614759565b9050602002810190610ef39190614976565b610efc90614a53565b8251602080850151604051939450600093610f2e93920191825260e01b6001600160e01b031916602082015260240190565b60408051808303601f19018152908290528051602090910120630b02c43d60e41b82526004820181905291506000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063b02c43d09060240160e060405180830381865afa158015610fae573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610fd29190614b55565b9050806040015163ffffffff1660000361102e5760405162461bcd60e51b815260206004820152601460248201527f4465706f736974206e6f742072657665616c65640000000000000000000000006044820152606401610353565b611c2081604001516110409190614869565b63ffffffff1642116110945760405162461bcd60e51b815260206004820181905260248201527f4465706f736974206d696e20616765206e6f74206163686965766564207965746044820152606401610353565b60a081015163ffffffff16156110ec5760405162461bcd60e51b815260206004820152601560248201527f4465706f73697420616c726561647920737765707400000000000000000000006044820152606401610353565b6040516393df529b60e01b8152600481018390527f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906393df529b90602401602060405180830381865afa158015611151573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906111759190614c18565b156111e85760405162461bcd60e51b815260206004820152602360248201527f5265736572766564206465706f73697473206d757374206e6f7420626520737760448201527f65707400000000000000000000000000000000000000000000000000000000006064820152608401610353565b6111fc8482600001518360c001518661337c565b608083015160e881901c62ff00ff1663ff00ff0060d89290921c9190911617601081811c91901b17611231620151808261488d565b63ffffffff16421061129b5760405162461bcd60e51b815260206004820152602d60248201527f4465706f73697420726566756e6420736166657479206d617267696e2069732060448201526c1b9bdd081c1c995cd95c9d9959609a1b6064820152608401610353565b6112a860208e018e6143d1565b6bffffffffffffffffffffffff191684604001516bffffffffffffffffffffffff1916146113275760405162461bcd60e51b815260206004820152602660248201527f4465706f73697420636f6e74726f6c6c656420627920646966666572656e74206044820152651dd85b1b195d60d21b6064820152608401610353565b8560000361133757816060015197505b876001600160a01b031682606001516001600160a01b03161461139c5760405162461bcd60e51b815260206004820152601f60248201527f4465706f736974207461726765747320646966666572656e74207661756c74006044820152606401610353565b60005b8681101561142157838882815181106113ba576113ba614759565b60200260200101510361140f5760405162461bcd60e51b815260206004820152601260248201527f4475706c696361746564206465706f73697400000000000000000000000000006044820152606401610353565b80611419816148bd565b91505061139f565b508287878151811061143557611435614759565b60200260200101818152505050505050508080611451906148bd565b915050610e80565b50600193505050505b9392505050565b6000806001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d56114a860208701876143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa1580156114ee573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906115129190614509565b905060028160e00151600581111561152c5761152c6145ac565b1461159f5760405162461bcd60e51b815260206004820152602960248201527f536f757263652077616c6c6574206973206e6f7420696e204d6f76696e67467560448201527f6e647320737461746500000000000000000000000000000000000000000000006064820152608401610353565b6101008101516116175760405162461bcd60e51b815260206004820152602a60248201527f5461726765742077616c6c65747320636f6d6d69746d656e74206973206e6f7460448201527f207375626d6974746564000000000000000000000000000000000000000000006064820152608401610353565b61162460208501856145c2565b604051602001611635929190614c33565b60405160208183030381529060405280519060200120816101000151146116c45760405162461bcd60e51b815260206004820152603a60248201527f5461726765742077616c6c65747320646f206e6f74206d61746368207461726760448201527f65742077616c6c65747320636f6d6d69746d656e7420686173680000000000006064820152608401610353565b6000807f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663be05abe36040518163ffffffff1660e01b815260040161016060405180830381865afa158015611726573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061174a9190614c76565b50505050505050505091509150600061176784602001518761361a565b90508167ffffffffffffffff168167ffffffffffffffff1610156118195760405162461bcd60e51b815260206004820152604260248201527f536f757263652077616c6c6574204254432062616c616e63652069732062656c60448201527f6f7720746865206d6f76696e672066756e64732064757374207468726573686f60648201527f6c64000000000000000000000000000000000000000000000000000000000000608482015260a401610353565b600087604001351161187d5760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b8267ffffffffffffffff16876040013511156118e75760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b60019450505050505b92915050565b6040517f067cf8320000000000000000000000000000000000000000000000000000000081526020820135600482015260009081906001600160a01b037f0000000000000000000000000000000000000000000000000000000000000000169063067cf832906024016101c060405180830381865afa15801561197d573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906119a19190614d73565b905060006001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663cec8c6e960208601356119e96060880160408901614e7c565b6040516001600160e01b031960e085901b168152600481019290925267ffffffffffffffff16602482015260440161028060405180830381865afa158015611a35573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611a599190614e99565b9050600381608001516004811115611a7357611a736145ac565b14611ac05760405162461bcd60e51b815260206004820152601e60248201527f4e6f7420612070656e64696e672072652d616e63686f7220616374696f6e00006044820152606401610353565b60018160a001516005811115611ad857611ad86145ac565b14611b255760405162461bcd60e51b815260206004820152601f60248201527f52652d616e63686f7220616374696f6e206973206e6f742070656e64696e67006044820152606401610353565b611c208160400151611b37919061488d565b63ffffffff164210611b8b5760405162461bcd60e51b815260206004820152601e60248201527f52652d616e63686f7220616374696f6e206861732074696d6564206f757400006044820152606401610353565b611b9b60808501606086016143d1565b81516001600160601b0319908116911614611c1e5760405162461bcd60e51b815260206004820152603260248201527f5461726765742077616c6c657420646f6573206e6f74206d617463682074686560448201527f20617574686f72697a656420616374696f6e00000000000000000000000000006064820152608401610353565b611c2b60208501856143d1565b6bffffffffffffffffffffffff191682606001516bffffffffffffffffffffffff191614611cc15760405162461bcd60e51b815260206004820152602960248201527f5265736572766174696f6e20637573746f64696564206279206469666665726560448201527f6e742077616c6c657400000000000000000000000000000000000000000000006064820152608401610353565b611cce60208501856143d1565b6001600160601b031916611ce860808601606087016143d1565b6001600160601b03191603611d655760405162461bcd60e51b815260206004820152603060248201527f5461726765742077616c6c6574206d757374206469666665722066726f6d207460448201527f686520736f757263652077616c6c6574000000000000000000000000000000006064820152608401610353565b60016001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d5611da660808801606089016143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa158015611dec573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611e109190614509565b60e001516005811115611e2557611e256145ac565b14611e985760405162461bcd60e51b815260206004820152602360248201527f5461726765742077616c6c6574206d75737420626520696e204c69766520737460448201527f61746500000000000000000000000000000000000000000000000000000000006064820152608401610353565b60007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663f75b4b1c6040518163ffffffff1660e01b815260040161014060405180830381865afa158015611ef9573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611f1d9190614ffc565b50509750505050505050508063ffffffff167f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316631de0555a876060016020810190611f7191906143d1565b6040516001600160e01b031960e084901b1681526001600160601b03199091166004820152602401602060405180830381865afa158015611fb6573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611fda919061484c565b63ffffffff16111561202e5760405162461bcd60e51b815260206004820181905260248201527f57616c6c6574207265736572766174696f6e73206361702065786365656465646044820152606401610353565b60007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03166363dfb29c6040518163ffffffff1660e01b8152600401606060405180830381865afa15801561208e573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906120b291906150d2565b505090508067ffffffffffffffff166000148061218a575067ffffffffffffffff81166001600160a01b037f0000000000000000000000000000000000000000000000000000000000000000166363481e9861211460808a0160608b016143d1565b6040516001600160e01b031960e084901b1681526001600160601b03199091166004820152602401602060405180830381865afa158015612159573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061217d919061511f565b67ffffffffffffffff1611155b6121fc5760405162461bcd60e51b815260206004820152602360248201527f57616c6c657420726573657276656420616d6f756e742063617020657863656560448201527f64656400000000000000000000000000000000000000000000000000000000006064820152608401610353565b60008660800135116122605760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b826060015167ffffffffffffffff16866080013511156122ce5760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b50600195945050505050565b6000806001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d561231960208601866143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa15801561235f573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906123839190614509565b905060018160e00151600581111561239d5761239d6145ac565b14806123be575060028160e0015160058111156123bc576123bc6145ac565b145b6124305760405162461bcd60e51b815260206004820152603160248201527f536f757263652077616c6c6574206973206e6f7420696e204c697665206f722060448201527f4d6f76696e6746756e64732073746174650000000000000000000000000000006064820152608401610353565b60006020840135612447606086016040870161513c565b60405160200161246e92919091825260e01b6001600160e01b031916602082015260240190565b60408051808303601f190181529082905280516020909101207ff18cf1b10000000000000000000000000000000000000000000000000000000082526004820181905291506000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063f18cf1b190602401608060405180830381865afa158015612507573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061252b9190615159565b9050600181606001516003811115612545576125456145ac565b146125b85760405162461bcd60e51b815260206004820152602560248201527f53776565702072657175657374206973206e6f7420696e2050656e64696e672060448201527f73746174650000000000000000000000000000000000000000000000000000006064820152608401610353565b6125c560208601866143d1565b81516001600160601b03199081169116146126485760405162461bcd60e51b815260206004820152602b60248201527f5377656570207265717565737420646f6573206e6f742062656c6f6e6720746f60448201527f207468652077616c6c65740000000000000000000000000000000000000000006064820152608401610353565b60007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663be05abe36040518163ffffffff1660e01b815260040161016060405180830381865afa1580156126a9573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906126cd9190614c76565b505050975050505050505050600086606001351161273d5760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b8067ffffffffffffffff16866060013511156122ce5760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b60006127be6127b9602084018461476f565b6136f5565b61280a5760405162461bcd60e51b815260206004820152601d60248201527f4e6f7420612076616c696420686561727462656174206d6573736167650000006044820152606401610353565b506001919050565b6000807f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663f75b4b1c6040518163ffffffff1660e01b815260040161014060405180830381865afa158015612874573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906128989190614ffc565b50979850506001600160a01b03881696506128fc955050505050505760405162461bcd60e51b815260206004820152601960248201527f5265736572766174696f6e73206172652064697361626c6564000000000000006044820152606401610353565b61291161290c60208601866143d1565b61376d565b60006020850135612928606087016040880161513c565b60405160200161294f92919091825260e01b6001600160e01b031916602082015260240190565b60408051601f198184030181529190528051602090910120905060006001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663cec8c6e9836129ab60808a0160608b01614e7c565b6040516001600160e01b031960e085901b168152600481019290925267ffffffffffffffff16602482015260440161028060405180830381865afa1580156129f7573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612a1b9190614e99565b9050600181608001516004811115612a3557612a356145ac565b14612a825760405162461bcd60e51b815260206004820152601f60248201527f4e6f7420612070656e64696e6720616363657074616e636520616374696f6e006044820152606401610353565b60018160a001516005811115612a9a57612a9a6145ac565b14612ae75760405162461bcd60e51b815260206004820181905260248201527f416363657074616e636520616374696f6e206973206e6f742070656e64696e676044820152606401610353565b611c208160400151612af9919061488d565b63ffffffff164210612b4d5760405162461bcd60e51b815260206004820152601f60248201527f416363657074616e636520616374696f6e206861732074696d6564206f7574006044820152606401610353565b612b5a60208701876143d1565b81516001600160601b0319908116911614612bdd5760405162461bcd60e51b815260206004820152602b60248201527f57616c6c657420646f6573206e6f74206d617463682074686520617574686f7260448201527f697a656420616374696f6e0000000000000000000000000000000000000000006064820152608401610353565b604051630b02c43d60e41b8152600481018390526000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063b02c43d09060240160e060405180830381865afa158015612c45573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612c699190614b55565b9050806040015163ffffffff16600003612cc55760405162461bcd60e51b815260206004820152601460248201527f4465706f736974206e6f742072657665616c65640000000000000000000000006044820152606401610353565b611c208160400151612cd79190614869565b63ffffffff164211612d2b5760405162461bcd60e51b815260206004820181905260248201527f4465706f736974206d696e20616765206e6f74206163686965766564207965746044820152606401610353565b60a081015163ffffffff1615612d835760405162461bcd60e51b815260206004820152601560248201527f4465706f73697420616c726561647920737765707400000000000000000000006044820152606401610353565b6040516393df529b60e01b8152600481018490527f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906393df529b90602401602060405180830381865afa158015612de8573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612e0c9190614c18565b612e7d5760405162461bcd60e51b8152602060048201526024808201527f4465706f73697420776173206e6f742072657665616c6564206173207265736560448201527f72766564000000000000000000000000000000000000000000000000000000006064820152608401610353565b836001600160a01b031681606001516001600160a01b031614612f085760405162461bcd60e51b815260206004820152602b60248201527f4465706f736974206e6f7420726f7574656420746f207468652072657365727660448201527f6174696f6e207661756c740000000000000000000000000000000000000000006064820152608401610353565b6000876080013511612f6c5760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b816060015167ffffffffffffffff1687608001351115612fda5760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b866080013582610260015167ffffffffffffffff16612ff991906148aa565b816020015167ffffffffffffffff16101561307c5760405162461bcd60e51b815260206004820152602b60248201527f416e63686f7220616d6f756e742062656c6f772074686520726573657276617460448201527f696f6e206d696e696d756d0000000000000000000000000000000000000000006064820152608401610353565b6130a661309136899003890160208a01614920565b825160c08401516130a18a614a53565b61337c565b60006130e16130bb60a0890160808a016151e2565b60d881901c63ff00ff001662ff00ff60e89290921c9190911617601081811b91901c1790565b905063ffffffff81166130f762015180426148aa565b1061315a5760405162461bcd60e51b815260206004820152602d60248201527f4465706f73697420726566756e6420736166657479206d617267696e2069732060448201526c1b9bdd081c1c995cd95c9d9959609a1b6064820152608401610353565b61316760208901896143d1565b6001600160601b0319166131816060890160408a016143d1565b6001600160601b031916146131e75760405162461bcd60e51b815260206004820152602660248201527f4465706f73697420636f6e74726f6c6c656420627920646966666572656e74206044820152651dd85b1b195d60d21b6064820152608401610353565b506001979650505050505050565b600082116132555760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b600061326182846146d6565b90506000826132708386614700565b61327a9190614713565b905060007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663c42b64d06040518163ffffffff1660e01b8152600401608060405180830381865afa1580156132dc573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061330091906151fd565b5092505067ffffffffffffffff8216905061331b84846148aa565b11156133755760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b5050505050565b8051805160208083015160408085015160609095015190516000956133be956133aa9590949391920161528c565b6040516020818303038152906040526138b6565b855190915081146134375760405162461bcd60e51b815260206004820152602960248201527f457874726120696e666f2066756e64696e67207478206861736820646f65732060448201527f6e6f74206d6174636800000000000000000000000000000000000000000000006064820152608401610353565b60608361347a57602080840151604080860151606087015160808801519251613464958b959491016152c2565b60405160208183030381529060405290506134b7565b848484602001518560400151866060015187608001516040516020016134a5969594939291906154d6565b60405160208183030381529060405290505b60006134de876020015163ffffffff168560000151604001516138dd90919063ffffffff16565b905060006134eb82613abf565b905080516014148015613523575061350283613ad9565b6001600160601b031916613517826000613b00565b6001600160601b031916145b156135315750505050613614565b80516020148015613598575060028360405161354d91906155fe565b602060405180830381855afa15801561356a573d6000803e3d6000fd5b5050506040513d601f19601f8201168201806040525081019061358d919061560a565b61359682613b0f565b145b156135a65750505050613614565b60405162461bcd60e51b815260206004820152602f60248201527f457874726120696e666f2066756e64696e67206f75747075742073637269707460448201527f20646f6573206e6f74206d6174636800000000000000000000000000000000006064820152608401610353565b50505050565b600082156118f057828235613635604085016020860161513c565b6136456060860160408701614e7c565b6040516020016136829392919092835260e09190911b6001600160e01b031916602083015260c01b6001600160c01b0319166024820152602c0190565b60405160208183030381529060405280519060200120146136e55760405162461bcd60e51b815260206004820152601d60248201527f496e76616c69642077616c6c6574206d61696e205554584f20646174610000006044820152606401610353565b6114626060830160408401614e7c565b600060108214613707575060006118f0565b61374b600084848080601f0160208091040260200160405190810160405280939291908181526020018383808284376000920191909152509293925050613b009050565b6001600160c01b031990811614613764575060006118f0565b50600192915050565b6040517fe65e19d50000000000000000000000000000000000000000000000000000000081526001600160601b0319821660048201526000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063e65e19d59060240161012060405180830381865afa1580156137f8573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061381c9190614509565b60e0015190506001816005811115613836576138366145ac565b148061385357506002816005811115613851576138516145ac565b145b6138b25760405162461bcd60e51b815260206004820152602a60248201527f57616c6c6574206973206e6f7420696e204c697665206f72204d6f76696e6746604482015269756e647320737461746560b01b6064820152608401610353565b5050565b60006020600083516020850160025afa50602060006020600060025afa5050600051919050565b60606000806138eb85613b2a565b9092509050600182016139665760405162461bcd60e51b815260206004820152602260248201527f52656164206f76657272756e20647572696e6720566172496e7420706172736960448201527f6e670000000000000000000000000000000000000000000000000000000000006064820152608401610353565b8084106139b55760405162461bcd60e51b815260206004820152601160248201527f566f75742072656164206f76657272756e0000000000000000000000000000006044820152606401610353565b6000806139c38460016148aa565b905060005b86811015613a4b576139da8883613b41565b92506000198303613a2d5760405162461bcd60e51b815260206004820152601a60248201527f42616420566172496e7420696e207363726970745075626b65790000000000006044820152606401610353565b613a3783836148aa565b915080613a43816148bd565b9150506139c8565b50613a568782613b41565b91506000198203613aa95760405162461bcd60e51b815260206004820152601a60248201527f42616420566172496e7420696e207363726970745075626b65790000000000006044820152606401610353565b613ab4878284613bae565b979650505050505050565b60606118f0826008808551613ad49190614700565b613c7b565b60006020600083516020850160025afa50602060006020600060035afa5050600c51919050565b60006114628383016020015190565b60008151600003613b2257506000919050565b506020015190565b600080613b38836000613f80565b91509150915091565b6000613b4e8260096148aa565b83511015613b5f57506000196118f0565b600080613b7685613b718660086148aa565b613f80565b909250905060018201613b8f57600019925050506118f0565b80613b9b8360096148aa565b613ba591906148aa565b95945050505050565b606081600003613bcd5750604080516020810190915260008152611462565b6000613bd983856148aa565b90508381118015613beb575080855110155b613c375760405162461bcd60e51b815260206004820152601360248201527f536c696365206f7574206f6620626f756e6473000000000000000000000000006044820152606401610353565b604051915082604083010160405282825283850182038460208701018481015b80821015613c7057815183830152602082019150613c57565b505050509392505050565b60606000848481518110613c9157613c91614759565b016020015160f81c905082613ca7826001615623565b60ff1614613cc5575050604080516020810190915260008152611462565b84613cd18560016148aa565b81518110613ce157613ce1614759565b016020015160f81c600003613da05760028160ff161015613d12575050604080516020810190915260008152611462565b600085613d208660026148aa565b81518110613d3057613d30614759565b016020015160f81c9050613d4560028361563c565b60ff1681141580613d63575080602014158015613d63575080601414155b15613d81576040518060200160405280600081525092505050611462565b613d97613d8f8660036148aa565b879083613bae565b92505050611462565b6000613dac8686613b00565b90507fffffff000000000000000000000000000000000000000000000000000000000081167f1976a9000000000000000000000000000000000000000000000000000000000003613ec05785613e038660036148aa565b81518110613e1357613e13614759565b60209101015160f81c6014141580613e8b5750613e466002613e3586886148aa565b613e3f9190614700565b8790613b00565b7dffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff19167f88ac00000000000000000000000000000000000000000000000000000000000014155b15613ea9576040518060200160405280600081525092505050611462565b613d97613eb78660046148aa565b87906014613bae565b7fffffff000000000000000000000000000000000000000000000000000000000081167f17a914000000000000000000000000000000000000000000000000000000000003613f6757856001613f1686886148aa565b613f209190614700565b81518110613f3057613f30614759565b60209101015160f81c608714613f59576040518060200160405280600081525092505050611462565b613d97613eb78660036148aa565b5050506040805160208101909152600081529392505050565b6000806000613f8f85856140fe565b90508060ff16600003613fc4576000858581518110613fb057613fb0614759565b016020015190935060f81c91506140f79050565b83613fd0826001615623565b60ff16613fdd91906148aa565b85511015613ff457600019600092509250506140f7565b60008160ff166002036140385761402d6140196140128760016148aa565b8890613b00565b62ffff0060e882901c1660f89190911c1790565b61ffff1690506140ed565b8160ff16600403614061576140546130bb6140128760016148aa565b63ffffffff1690506140ed565b8160ff166008036140ed576140e061407d6140128760016148aa565b60c01c64ff000000ff600882811c91821665ff000000ff009390911b92831617601090811b67ffffffffffffffff1666ff00ff00ff00ff9290921667ff00ff00ff00ff009093169290921790911c65ffff0000ffff1617602081811c91901b1790565b67ffffffffffffffff1690505b60ff909116925090505b9250929050565b600082828151811061411257614112614759565b016020015160f81c60ff03614129575060086118f0565b82828151811061413b5761413b614759565b016020015160f81c60fe03614152575060046118f0565b82828151811061416457614164614759565b016020015160f81c60fd0361417b575060026118f0565b50600092915050565b60006060828403121561419657600080fd5b50919050565b6000602082840312156141ae57600080fd5b813567ffffffffffffffff8111156141c557600080fd5b6141d184828501614184565b949350505050565b60006080828403121561419657600080fd5b60008060006040848603121561420057600080fd5b833567ffffffffffffffff8082111561421857600080fd5b614224878388016141d9565b9450602086013591508082111561423a57600080fd5b818601915086601f83011261424e57600080fd5b81358181111561425d57600080fd5b8760208260051b850101111561427257600080fd5b6020830194508093505050509250925092565b6000806080838503121561429857600080fd5b823567ffffffffffffffff8111156142af57600080fd5b6142bb85828601614184565b9250506142cb8460208501614184565b90509250929050565b600060a0828403121561419657600080fd5b600060a082840312156142f857600080fd5b61146283836142d4565b60006080828403121561431457600080fd5b61146283836141d9565b60006020828403121561433057600080fd5b813567ffffffffffffffff81111561434757600080fd5b82016040818503121561146257600080fd5b60008060c0838503121561436c57600080fd5b61437684846142d4565b915060a083013567ffffffffffffffff81111561439257600080fd5b61439e858286016142d4565b9150509250929050565b6001600160601b0319811681146143be57600080fd5b50565b80356143cc816143a8565b919050565b6000602082840312156143e357600080fd5b8135611462816143a8565b634e487b7160e01b600052604160045260246000fd5b604051610120810167ffffffffffffffff81118282101715614428576144286143ee565b60405290565b60405160a0810167ffffffffffffffff81118282101715614428576144286143ee565b6040516080810167ffffffffffffffff81118282101715614428576144286143ee565b6040516101c0810167ffffffffffffffff81118282101715614428576144286143ee565b604051610280810167ffffffffffffffff81118282101715614428576144286143ee565b67ffffffffffffffff811681146143be57600080fd5b80516143cc816144bc565b63ffffffff811681146143be57600080fd5b80516143cc816144dd565b8051600681106143cc57600080fd5b6000610120828403121561451c57600080fd5b614524614404565b825181526020830151602082015261453e604084016144d2565b604082015261454f606084016144ef565b6060820152614560608084016144ef565b608082015261457160a084016144ef565b60a082015261458260c084016144ef565b60c082015261459360e084016144fa565b60e0820152610100928301519281019290925250919050565b634e487b7160e01b600052602160045260246000fd5b6000808335601e198436030181126145d957600080fd5b83018035915067ffffffffffffffff8211156145f457600080fd5b6020019150600581901b36038213156140f757600080fd5b80516bffffffffffffffffffffffff811681146143cc57600080fd5b600080600080600080600060e0888a03121561464357600080fd5b875161464e816144bc565b602089015190975061465f816144bc565b6040890151909650614670816144bc565b6060890151909550614681816144bc565b6080890151909450614692816144dd565b92506146a060a0890161460c565b915060c08801516146b0816144dd565b8091505092959891949750929550565b634e487b7160e01b600052601260045260246000fd5b6000826146e5576146e56146c0565b500690565b634e487b7160e01b600052601160045260246000fd5b818103818111156118f0576118f06146ea565b600082614722576147226146c0565b500490565b80516001600160a01b03811681146143cc57600080fd5b60006020828403121561475057600080fd5b61146282614727565b634e487b7160e01b600052603260045260246000fd5b6000808335601e1984360301811261478657600080fd5b83018035915067ffffffffffffffff8211156147a157600080fd5b6020019150368190038213156140f757600080fd5b600060a082840312156147c857600080fd5b60405160a0810181811067ffffffffffffffff821117156147eb576147eb6143ee565b6040526147f783614727565b81526020830151614807816144bc565b6020820152604083015161481a816144bc565b6040820152606083015161482d816144bc565b60608201526080830151614840816144dd565b60808201529392505050565b60006020828403121561485e57600080fd5b8151611462816144dd565b63ffffffff818116838216019080821115614886576148866146ea565b5092915050565b63ffffffff828116828216039080821115614886576148866146ea565b808201808211156118f0576118f06146ea565b6000600182016148cf576148cf6146ea565b5060010190565b6000808335601e198436030181126148ed57600080fd5b83018035915067ffffffffffffffff82111561490857600080fd5b6020019150600681901b36038213156140f757600080fd5b60006040828403121561493257600080fd5b6040516040810181811067ffffffffffffffff82111715614955576149556143ee565b60405282358152602083013561496a816144dd565b60208201529392505050565b60008235609e1983360301811261498c57600080fd5b9190910192915050565b80356001600160e01b0319811681146143cc57600080fd5b600082601f8301126149bf57600080fd5b813567ffffffffffffffff808211156149da576149da6143ee565b604051601f8301601f19908116603f01168101908282118183101715614a0257614a026143ee565b81604052838152866020858801011115614a1b57600080fd5b836020870160208301376000602085830101528094505050505092915050565b80356001600160c01b0319811681146143cc57600080fd5b600060a08236031215614a6557600080fd5b614a6d61442e565b823567ffffffffffffffff80821115614a8557600080fd5b818501915060808236031215614a9a57600080fd5b614aa2614451565b614aab83614996565b8152602083013582811115614abf57600080fd5b614acb368286016149ae565b602083015250604083013582811115614ae357600080fd5b614aef368286016149ae565b604083015250614b0160608401614996565b6060820152835250614b17905060208401614a3b565b6020820152614b28604084016143c1565b6040820152614b39606084016143c1565b6060820152614b4a60808401614996565b608082015292915050565b600060e08284031215614b6757600080fd5b60405160e0810181811067ffffffffffffffff82111715614b8a57614b8a6143ee565b604052614b9683614727565b81526020830151614ba6816144bc565b60208201526040830151614bb9816144dd565b6040820152614bca60608401614727565b60608201526080830151614bdd816144bc565b608082015260a0830151614bf0816144dd565b60a082015260c0928301519281019290925250919050565b805180151581146143cc57600080fd5b600060208284031215614c2a57600080fd5b61146282614c08565b60008184825b85811015614c6b578135614c4c816143a8565b6001600160601b03191683526020928301929190910190600101614c39565b509095945050505050565b60008060008060008060008060008060006101608c8e031215614c9857600080fd5b8b51614ca3816144bc565b60208d0151909b50614cb4816144bc565b60408d0151909a50614cc5816144dd565b60608d0151909950614cd6816144dd565b9750614ce460808d0161460c565b965060a08c0151614cf4816144dd565b60c08d015190965061ffff81168114614d0c57600080fd5b9450614d1a60e08d016144d2565b9350614d296101008d016144ef565b9250614d386101208d0161460c565b9150614d476101408d016144ef565b90509295989b509295989b9093969950565b80516143cc816143a8565b8051600581106143cc57600080fd5b60006101c08284031215614d8657600080fd5b614d8e614474565b614d9783614727565b8152614da5602084016144d2565b6020820152614db6604084016144ef565b6040820152614dc760608401614d59565b6060820152614dd8608084016144d2565b6080820152614de960a084016144ef565b60a082015260c083015160c0820152614e0460e084016144ef565b60e0820152610100614e17818501614d64565b90820152610120614e298482016144d2565b90820152610140614e3b848201614c08565b90820152610160614e4d8482016144ef565b90820152610180614e5f8482016144d2565b908201526101a0614e718482016144ef565b908201529392505050565b600060208284031215614e8e57600080fd5b8135611462816144bc565b60006102808284031215614eac57600080fd5b614eb4614498565b614ebd83614d59565b8152614ecb602084016144ef565b6020820152614edc604084016144ef565b6040820152614eed606084016144d2565b6060820152614efe60808401614d64565b6080820152614f0f60a084016144fa565b60a0820152614f2060c08401614c08565b60c0820152614f3160e08401614727565b60e082015261010083810151908201526101208084015190820152610140614f5a8185016144d2565b90820152610160614f6c848201614c08565b90820152610180614f7e8482016144ef565b908201526101a0614f908482016144ef565b908201526101c0614fa28482016144ef565b908201526101e0614fb48482016144d2565b90820152610200614fc6848201614c08565b90820152610220614fd88482016144ef565b90820152610240614fea8482016144ef565b90820152610260614e718482016144d2565b6000806000806000806000806000806101408b8d03121561501c57600080fd5b6150258b614727565b995060208b0151615035816144bc565b60408c0151909950615046816144bc565b60608c0151909850615057816144dd565b60808c0151909750615068816144dd565b60a08c0151909650615079816144bc565b60c08c015190955061508a816144bc565b60e08c015190945061509b816144dd565b6101008c01519093506150ad816144dd565b6101208c01519092506150bf816144dd565b809150509295989b9194979a5092959850565b6000806000606084860312156150e757600080fd5b83516150f2816144bc565b6020850151909350615103816144bc565b6040850151909250615114816144dd565b809150509250925092565b60006020828403121561513157600080fd5b8151611462816144bc565b60006020828403121561514e57600080fd5b8135611462816144dd565b60006080828403121561516b57600080fd5b6040516080810181811067ffffffffffffffff8211171561518e5761518e6143ee565b604052825161519c816143a8565b815260208301516151ac816144bc565b602082015260408301516151bf816144dd565b60408201526060830151600481106151d657600080fd5b60608201529392505050565b6000602082840312156151f457600080fd5b61146282614996565b6000806000806080858703121561521357600080fd5b845161521e816144bc565b602086015190945061522f816144bc565b6040860151909350615240816144bc565b6060860151909250615251816144dd565b939692955090935050565b6000815160005b8181101561527d5760208185018101518683015201615263565b50600093019283525090919050565b60006001600160e01b031980871683526152b26152ac600485018861525c565b8661525c565b9316835250506004019392505050565b600560fa1b8152606086901b6001600160601b0319166001820152607560f81b6015820152600160fb1b60168201526001600160c01b031985166017820152607560f81b601f820152600061537a61536d615344602085015b7f7600000000000000000000000000000000000000000000000000000000000000815260010190565b7fa900000000000000000000000000000000000000000000000000000000000000815260010190565b600560fa1b815260010190565b6001600160601b031986168152608760f81b601482015261542361536d61534461531b6153fa6153d1601587015b7f6300000000000000000000000000000000000000000000000000000000000000815260010190565b7fac00000000000000000000000000000000000000000000000000000000000000815260010190565b7f6700000000000000000000000000000000000000000000000000000000000000815260010190565b6001600160601b0319861681529050601160fb1b601482015261546d601582015b7f0400000000000000000000000000000000000000000000000000000000000000815260010190565b6001600160e01b031985168152905060b160f81b60048201526154ca6154a16153d1600584015b607560f81b815260010190565b7f6800000000000000000000000000000000000000000000000000000000000000815260010190565b98975050505050505050565b600560fa1b81526001600160601b0319606088901b166001820152607560f81b60158201527f20000000000000000000000000000000000000000000000000000000000000006016820152600060178201878152607560f81b6020820152600160fb1b6021820152602281016001600160c01b0319881681529050607560f81b600882015261556d61536d6153446009840161531b565b6001600160601b0319871681529050608760f81b60148201526155a161536d61534461531b6153fa6153d1601587016153a8565b6001600160601b0319861681529050601160fb1b60148201526155c660158201615444565b6001600160e01b031985168152905060b160f81b60048201526155f16154a16153d160058401615494565b9998505050505050505050565b6000611462828461525c565b60006020828403121561561c57600080fd5b5051919050565b60ff81811683821601908111156118f0576118f06146ea565b60ff82811682821603908111156118f0576118f06146ea56fea2646970667358221220b6e5a79844d0f4fea2f7216bdbc256cab6427db737d76c0ea2bac56f2f1419be64736f6c63430008110033", + "deployedBytecode": "0x608060405234801561001057600080fd5b50600436106100ea5760003560e01c8063a57f734f1161008c578063e0276b2611610066578063e0276b2614610152578063e78cea92146101af578063f03a4bc5146101ee578063f9179ad21461020157600080fd5b8063a57f734f14610180578063d09520f714610193578063d6c7fa781461019c57600080fd5b80637405d7bf116100c85780637405d7bf14610135578063765f28f914610148578063996a756b146101525780639b337db21461016d57600080fd5b80630538ac1b146100ef5780630fde6c76146101125780636b562cec146100ef575b600080fd5b6100f8611c2081565b60405163ffffffff90911681526020015b60405180910390f35b61012561012036600461419c565b610214565b6040519015158152602001610109565b6101256101433660046141eb565b610b88565b6100f86201518081565b61015a601481565b60405161ffff9091168152602001610109565b61012561017b366004614285565b611469565b61012561018e3660046142e6565b6118f6565b6100f861025881565b6101256101aa366004614302565b6122da565b6101d67f000000000000000000000000000000000000000000000000000000000000000081565b6040516001600160a01b039091168152602001610109565b6101256101fc36600461431e565b6127a7565b61012561020f366004614359565b612812565b6000806001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d561025360208601866143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa158015610299573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906102bd9190614509565b905060018160e0015160058111156102d7576102d76145ac565b14806102f8575060028160e0015160058111156102f6576102f66145ac565b145b61035c5760405162461bcd60e51b815260206004820152602a60248201527f57616c6c6574206973206e6f7420696e204c697665206f72204d6f76696e6746604482015269756e647320737461746560b01b60648201526084015b60405180910390fd5b600061036b60208501856145c2565b915050806103bb5760405162461bcd60e51b815260206004820152601d60248201527f526564656d7074696f6e2062656c6f7720746865206d696e2073697a650000006044820152606401610353565b601481111561040c5760405162461bcd60e51b815260206004820152601f60248201527f526564656d7074696f6e206578636565647320746865206d61782073697a65006044820152606401610353565b6000807f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316636e70ce416040518163ffffffff1660e01b815260040160e060405180830381865afa15801561046d573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906104919190614628565b50509450945050505060008660400135116104fe5760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b8167ffffffffffffffff16866040013511156105685760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b60006105788460408901356146d6565b905060008461058b8360408b0135614700565b6105959190614713565b905060007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316635f3281ca6040518163ffffffff1660e01b8152600401602060405180830381865afa1580156105f7573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061061b919061473e565b905060008667ffffffffffffffff811115610638576106386143ee565b604051908082528060200260200182016040528015610661578160200160208202803683370190505b50905060005b87811015610b7757600061067e60208d018d6145c2565b8381811061068e5761068e614759565b90506020028101906106a0919061476f565b8080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f820116905080830192505050505050509050600081805190602001208d600001602081019061070291906143d1565b6040516020016107269291909182526001600160601b031916602082015260340190565b60408051808303601f190181529082905280516020909101207f03d952f70000000000000000000000000000000000000000000000000000000082526004820181905291506000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906303d952f79060240160a060405180830381865afa1580156107bf573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906107e391906147b6565b9050806080015163ffffffff1660000361083f5760405162461bcd60e51b815260206004820181905260248201527f4e6f7420612070656e64696e6720726564656d7074696f6e20726571756573746044820152606401610353565b6102586001600160a01b038716156108f2576040517f0df5db70000000000000000000000000000000000000000000000000000000008152600481018490526000906001600160a01b03891690630df5db7090602401602060405180830381865afa1580156108b2573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906108d6919061484c565b90508163ffffffff168163ffffffff1611156108f0578091505b505b8082608001516109029190614869565b63ffffffff16421161097c5760405162461bcd60e51b815260206004820152602b60248201527f526564656d7074696f6e2072657175657374206d696e20616765206e6f74206160448201527f63686965766564207965740000000000000000000000000000000000000000006064820152608401610353565b60008a836080015161098e9190614869565b905061099c611c208261488d565b63ffffffff164210610a165760405162461bcd60e51b815260206004820152603960248201527f526564656d7074696f6e20726571756573742074696d656f757420736166657460448201527f79206d617267696e206973206e6f7420707265736572766564000000000000006064820152608401610353565b88610a2260018f614700565b8703610a3557610a328b826148aa565b90505b836060015167ffffffffffffffff16811115610ab95760405162461bcd60e51b815260206004820152603660248201527f50726f706f736564207472616e73616374696f6e207065722d7265717565737460448201527f2066656520736861726520697320746f6f2068696768000000000000000000006064820152608401610353565b60005b87811015610b3e5785898281518110610ad757610ad7614759565b602002602001015103610b2c5760405162461bcd60e51b815260206004820152601260248201527f4475706c696361746564207265717565737400000000000000000000000000006044820152606401610353565b80610b36816148bd565b915050610abc565b5084888881518110610b5257610b52614759565b6020026020010181815250505050505050508080610b6f906148bd565b915050610667565b5060019a9950505050505050505050565b6000806001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d5610bc760208801886143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa158015610c0d573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610c319190614509565b905060018160e001516005811115610c4b57610c4b6145ac565b1480610c6c575060028160e001516005811115610c6a57610c6a6145ac565b145b610ccb5760405162461bcd60e51b815260206004820152602a60248201527f57616c6c6574206973206e6f7420696e204c697665206f72204d6f76696e6746604482015269756e647320737461746560b01b6064820152608401610353565b6000610cda60208701876148d6565b905011610d295760405162461bcd60e51b815260206004820152601860248201527f53776565702062656c6f7720746865206d696e2073697a6500000000000000006044820152606401610353565b6014610d3860208701876148d6565b90501115610d885760405162461bcd60e51b815260206004820152601a60248201527f5377656570206578636565647320746865206d61782073697a650000000000006044820152606401610353565b82610d9660208701876148d6565b905014610e0b5760405162461bcd60e51b815260206004820152602e60248201527f45616368206465706f736974206b6579206d7573742068617665206d6174636860448201527f696e6720657874726120696e666f0000000000000000000000000000000000006064820152608401610353565b610e276040860135610e2060208801886148d6565b90506131f5565b600080610e3760208801886148d6565b905067ffffffffffffffff811115610e5157610e516143ee565b604051908082528060200260200182016040528015610e7a578160200160208202803683370190505b50905060005b610e8d60208901896148d6565b9050811015611459576000610ea560208a018a6148d6565b83818110610eb557610eb5614759565b905060400201803603810190610ecb9190614920565b90506000888884818110610ee157610ee1614759565b9050602002810190610ef39190614976565b610efc90614a53565b8251602080850151604051939450600093610f2e93920191825260e01b6001600160e01b031916602082015260240190565b60408051808303601f19018152908290528051602090910120630b02c43d60e41b82526004820181905291506000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063b02c43d09060240160e060405180830381865afa158015610fae573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610fd29190614b55565b9050806040015163ffffffff1660000361102e5760405162461bcd60e51b815260206004820152601460248201527f4465706f736974206e6f742072657665616c65640000000000000000000000006044820152606401610353565b611c2081604001516110409190614869565b63ffffffff1642116110945760405162461bcd60e51b815260206004820181905260248201527f4465706f736974206d696e20616765206e6f74206163686965766564207965746044820152606401610353565b60a081015163ffffffff16156110ec5760405162461bcd60e51b815260206004820152601560248201527f4465706f73697420616c726561647920737765707400000000000000000000006044820152606401610353565b6040516393df529b60e01b8152600481018390527f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906393df529b90602401602060405180830381865afa158015611151573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906111759190614c18565b156111e85760405162461bcd60e51b815260206004820152602360248201527f5265736572766564206465706f73697473206d757374206e6f7420626520737760448201527f65707400000000000000000000000000000000000000000000000000000000006064820152608401610353565b6111fc8482600001518360c001518661337c565b608083015160e881901c62ff00ff1663ff00ff0060d89290921c9190911617601081811c91901b17611231620151808261488d565b63ffffffff16421061129b5760405162461bcd60e51b815260206004820152602d60248201527f4465706f73697420726566756e6420736166657479206d617267696e2069732060448201526c1b9bdd081c1c995cd95c9d9959609a1b6064820152608401610353565b6112a860208e018e6143d1565b6bffffffffffffffffffffffff191684604001516bffffffffffffffffffffffff1916146113275760405162461bcd60e51b815260206004820152602660248201527f4465706f73697420636f6e74726f6c6c656420627920646966666572656e74206044820152651dd85b1b195d60d21b6064820152608401610353565b8560000361133757816060015197505b876001600160a01b031682606001516001600160a01b03161461139c5760405162461bcd60e51b815260206004820152601f60248201527f4465706f736974207461726765747320646966666572656e74207661756c74006044820152606401610353565b60005b8681101561142157838882815181106113ba576113ba614759565b60200260200101510361140f5760405162461bcd60e51b815260206004820152601260248201527f4475706c696361746564206465706f73697400000000000000000000000000006044820152606401610353565b80611419816148bd565b91505061139f565b508287878151811061143557611435614759565b60200260200101818152505050505050508080611451906148bd565b915050610e80565b50600193505050505b9392505050565b6000806001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d56114a860208701876143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa1580156114ee573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906115129190614509565b905060028160e00151600581111561152c5761152c6145ac565b1461159f5760405162461bcd60e51b815260206004820152602960248201527f536f757263652077616c6c6574206973206e6f7420696e204d6f76696e67467560448201527f6e647320737461746500000000000000000000000000000000000000000000006064820152608401610353565b6101008101516116175760405162461bcd60e51b815260206004820152602a60248201527f5461726765742077616c6c65747320636f6d6d69746d656e74206973206e6f7460448201527f207375626d6974746564000000000000000000000000000000000000000000006064820152608401610353565b61162460208501856145c2565b604051602001611635929190614c33565b60405160208183030381529060405280519060200120816101000151146116c45760405162461bcd60e51b815260206004820152603a60248201527f5461726765742077616c6c65747320646f206e6f74206d61746368207461726760448201527f65742077616c6c65747320636f6d6d69746d656e7420686173680000000000006064820152608401610353565b6000807f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663be05abe36040518163ffffffff1660e01b815260040161016060405180830381865afa158015611726573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061174a9190614c76565b50505050505050505091509150600061176784602001518761361a565b90508167ffffffffffffffff168167ffffffffffffffff1610156118195760405162461bcd60e51b815260206004820152604260248201527f536f757263652077616c6c6574204254432062616c616e63652069732062656c60448201527f6f7720746865206d6f76696e672066756e64732064757374207468726573686f60648201527f6c64000000000000000000000000000000000000000000000000000000000000608482015260a401610353565b600087604001351161187d5760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b8267ffffffffffffffff16876040013511156118e75760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b60019450505050505b92915050565b6040517f067cf8320000000000000000000000000000000000000000000000000000000081526020820135600482015260009081906001600160a01b037f0000000000000000000000000000000000000000000000000000000000000000169063067cf832906024016101c060405180830381865afa15801561197d573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906119a19190614d73565b905060006001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663cec8c6e960208601356119e96060880160408901614e7c565b6040516001600160e01b031960e085901b168152600481019290925267ffffffffffffffff16602482015260440161028060405180830381865afa158015611a35573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611a599190614e99565b9050600381608001516004811115611a7357611a736145ac565b14611ac05760405162461bcd60e51b815260206004820152601e60248201527f4e6f7420612070656e64696e672072652d616e63686f7220616374696f6e00006044820152606401610353565b60018160a001516005811115611ad857611ad86145ac565b14611b255760405162461bcd60e51b815260206004820152601f60248201527f52652d616e63686f7220616374696f6e206973206e6f742070656e64696e67006044820152606401610353565b611c208160400151611b37919061488d565b63ffffffff164210611b8b5760405162461bcd60e51b815260206004820152601e60248201527f52652d616e63686f7220616374696f6e206861732074696d6564206f757400006044820152606401610353565b611b9b60808501606086016143d1565b81516001600160601b0319908116911614611c1e5760405162461bcd60e51b815260206004820152603260248201527f5461726765742077616c6c657420646f6573206e6f74206d617463682074686560448201527f20617574686f72697a656420616374696f6e00000000000000000000000000006064820152608401610353565b611c2b60208501856143d1565b6bffffffffffffffffffffffff191682606001516bffffffffffffffffffffffff191614611cc15760405162461bcd60e51b815260206004820152602960248201527f5265736572766174696f6e20637573746f64696564206279206469666665726560448201527f6e742077616c6c657400000000000000000000000000000000000000000000006064820152608401610353565b611cce60208501856143d1565b6001600160601b031916611ce860808601606087016143d1565b6001600160601b03191603611d655760405162461bcd60e51b815260206004820152603060248201527f5461726765742077616c6c6574206d757374206469666665722066726f6d207460448201527f686520736f757263652077616c6c6574000000000000000000000000000000006064820152608401610353565b60016001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d5611da660808801606089016143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa158015611dec573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611e109190614509565b60e001516005811115611e2557611e256145ac565b14611e985760405162461bcd60e51b815260206004820152602360248201527f5461726765742077616c6c6574206d75737420626520696e204c69766520737460448201527f61746500000000000000000000000000000000000000000000000000000000006064820152608401610353565b60007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663f75b4b1c6040518163ffffffff1660e01b815260040161014060405180830381865afa158015611ef9573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611f1d9190614ffc565b50509750505050505050508063ffffffff167f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316631de0555a876060016020810190611f7191906143d1565b6040516001600160e01b031960e084901b1681526001600160601b03199091166004820152602401602060405180830381865afa158015611fb6573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611fda919061484c565b63ffffffff16111561202e5760405162461bcd60e51b815260206004820181905260248201527f57616c6c6574207265736572766174696f6e73206361702065786365656465646044820152606401610353565b60007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03166363dfb29c6040518163ffffffff1660e01b8152600401606060405180830381865afa15801561208e573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906120b291906150d2565b505090508067ffffffffffffffff166000148061218a575067ffffffffffffffff81166001600160a01b037f0000000000000000000000000000000000000000000000000000000000000000166363481e9861211460808a0160608b016143d1565b6040516001600160e01b031960e084901b1681526001600160601b03199091166004820152602401602060405180830381865afa158015612159573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061217d919061511f565b67ffffffffffffffff1611155b6121fc5760405162461bcd60e51b815260206004820152602360248201527f57616c6c657420726573657276656420616d6f756e742063617020657863656560448201527f64656400000000000000000000000000000000000000000000000000000000006064820152608401610353565b60008660800135116122605760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b826060015167ffffffffffffffff16866080013511156122ce5760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b50600195945050505050565b6000806001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663e65e19d561231960208601866143d1565b6040516001600160e01b031960e084901b1681526001600160601b0319909116600482015260240161012060405180830381865afa15801561235f573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906123839190614509565b905060018160e00151600581111561239d5761239d6145ac565b14806123be575060028160e0015160058111156123bc576123bc6145ac565b145b6124305760405162461bcd60e51b815260206004820152603160248201527f536f757263652077616c6c6574206973206e6f7420696e204c697665206f722060448201527f4d6f76696e6746756e64732073746174650000000000000000000000000000006064820152608401610353565b60006020840135612447606086016040870161513c565b60405160200161246e92919091825260e01b6001600160e01b031916602082015260240190565b60408051808303601f190181529082905280516020909101207ff18cf1b10000000000000000000000000000000000000000000000000000000082526004820181905291506000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063f18cf1b190602401608060405180830381865afa158015612507573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061252b9190615159565b9050600181606001516003811115612545576125456145ac565b146125b85760405162461bcd60e51b815260206004820152602560248201527f53776565702072657175657374206973206e6f7420696e2050656e64696e672060448201527f73746174650000000000000000000000000000000000000000000000000000006064820152608401610353565b6125c560208601866143d1565b81516001600160601b03199081169116146126485760405162461bcd60e51b815260206004820152602b60248201527f5377656570207265717565737420646f6573206e6f742062656c6f6e6720746f60448201527f207468652077616c6c65740000000000000000000000000000000000000000006064820152608401610353565b60007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663be05abe36040518163ffffffff1660e01b815260040161016060405180830381865afa1580156126a9573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906126cd9190614c76565b505050975050505050505050600086606001351161273d5760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b8067ffffffffffffffff16866060013511156122ce5760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b60006127be6127b9602084018461476f565b6136f5565b61280a5760405162461bcd60e51b815260206004820152601d60248201527f4e6f7420612076616c696420686561727462656174206d6573736167650000006044820152606401610353565b506001919050565b6000807f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663f75b4b1c6040518163ffffffff1660e01b815260040161014060405180830381865afa158015612874573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906128989190614ffc565b50979850506001600160a01b03881696506128fc955050505050505760405162461bcd60e51b815260206004820152601960248201527f5265736572766174696f6e73206172652064697361626c6564000000000000006044820152606401610353565b61291161290c60208601866143d1565b61376d565b60006020850135612928606087016040880161513c565b60405160200161294f92919091825260e01b6001600160e01b031916602082015260240190565b60408051601f198184030181529190528051602090910120905060006001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663cec8c6e9836129ab60808a0160608b01614e7c565b6040516001600160e01b031960e085901b168152600481019290925267ffffffffffffffff16602482015260440161028060405180830381865afa1580156129f7573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612a1b9190614e99565b9050600181608001516004811115612a3557612a356145ac565b14612a825760405162461bcd60e51b815260206004820152601f60248201527f4e6f7420612070656e64696e6720616363657074616e636520616374696f6e006044820152606401610353565b60018160a001516005811115612a9a57612a9a6145ac565b14612ae75760405162461bcd60e51b815260206004820181905260248201527f416363657074616e636520616374696f6e206973206e6f742070656e64696e676044820152606401610353565b611c208160400151612af9919061488d565b63ffffffff164210612b4d5760405162461bcd60e51b815260206004820152601f60248201527f416363657074616e636520616374696f6e206861732074696d6564206f7574006044820152606401610353565b612b5a60208701876143d1565b81516001600160601b0319908116911614612bdd5760405162461bcd60e51b815260206004820152602b60248201527f57616c6c657420646f6573206e6f74206d617463682074686520617574686f7260448201527f697a656420616374696f6e0000000000000000000000000000000000000000006064820152608401610353565b604051630b02c43d60e41b8152600481018390526000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063b02c43d09060240160e060405180830381865afa158015612c45573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612c699190614b55565b9050806040015163ffffffff16600003612cc55760405162461bcd60e51b815260206004820152601460248201527f4465706f736974206e6f742072657665616c65640000000000000000000000006044820152606401610353565b611c208160400151612cd79190614869565b63ffffffff164211612d2b5760405162461bcd60e51b815260206004820181905260248201527f4465706f736974206d696e20616765206e6f74206163686965766564207965746044820152606401610353565b60a081015163ffffffff1615612d835760405162461bcd60e51b815260206004820152601560248201527f4465706f73697420616c726561647920737765707400000000000000000000006044820152606401610353565b6040516393df529b60e01b8152600481018490527f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316906393df529b90602401602060405180830381865afa158015612de8573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612e0c9190614c18565b612e7d5760405162461bcd60e51b8152602060048201526024808201527f4465706f73697420776173206e6f742072657665616c6564206173207265736560448201527f72766564000000000000000000000000000000000000000000000000000000006064820152608401610353565b836001600160a01b031681606001516001600160a01b031614612f085760405162461bcd60e51b815260206004820152602b60248201527f4465706f736974206e6f7420726f7574656420746f207468652072657365727660448201527f6174696f6e207661756c740000000000000000000000000000000000000000006064820152608401610353565b6000876080013511612f6c5760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b816060015167ffffffffffffffff1687608001351115612fda5760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b866080013582610260015167ffffffffffffffff16612ff991906148aa565b816020015167ffffffffffffffff16101561307c5760405162461bcd60e51b815260206004820152602b60248201527f416e63686f7220616d6f756e742062656c6f772074686520726573657276617460448201527f696f6e206d696e696d756d0000000000000000000000000000000000000000006064820152608401610353565b6130a661309136899003890160208a01614920565b825160c08401516130a18a614a53565b61337c565b60006130e16130bb60a0890160808a016151e2565b60d881901c63ff00ff001662ff00ff60e89290921c9190911617601081811b91901c1790565b905063ffffffff81166130f762015180426148aa565b1061315a5760405162461bcd60e51b815260206004820152602d60248201527f4465706f73697420726566756e6420736166657479206d617267696e2069732060448201526c1b9bdd081c1c995cd95c9d9959609a1b6064820152608401610353565b61316760208901896143d1565b6001600160601b0319166131816060890160408a016143d1565b6001600160601b031916146131e75760405162461bcd60e51b815260206004820152602660248201527f4465706f73697420636f6e74726f6c6c656420627920646966666572656e74206044820152651dd85b1b195d60d21b6064820152608401610353565b506001979650505050505050565b600082116132555760405162461bcd60e51b815260206004820152602760248201527f50726f706f736564207472616e73616374696f6e206665652063616e6e6f74206044820152666265207a65726f60c81b6064820152608401610353565b600061326182846146d6565b90506000826132708386614700565b61327a9190614713565b905060007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031663c42b64d06040518163ffffffff1660e01b8152600401608060405180830381865afa1580156132dc573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061330091906151fd565b5092505067ffffffffffffffff8216905061331b84846148aa565b11156133755760405162461bcd60e51b8152602060048201526024808201527f50726f706f736564207472616e73616374696f6e2066656520697320746f6f206044820152630d0d2ced60e31b6064820152608401610353565b5050505050565b8051805160208083015160408085015160609095015190516000956133be956133aa9590949391920161528c565b6040516020818303038152906040526138b6565b855190915081146134375760405162461bcd60e51b815260206004820152602960248201527f457874726120696e666f2066756e64696e67207478206861736820646f65732060448201527f6e6f74206d6174636800000000000000000000000000000000000000000000006064820152608401610353565b60608361347a57602080840151604080860151606087015160808801519251613464958b959491016152c2565b60405160208183030381529060405290506134b7565b848484602001518560400151866060015187608001516040516020016134a5969594939291906154d6565b60405160208183030381529060405290505b60006134de876020015163ffffffff168560000151604001516138dd90919063ffffffff16565b905060006134eb82613abf565b905080516014148015613523575061350283613ad9565b6001600160601b031916613517826000613b00565b6001600160601b031916145b156135315750505050613614565b80516020148015613598575060028360405161354d91906155fe565b602060405180830381855afa15801561356a573d6000803e3d6000fd5b5050506040513d601f19601f8201168201806040525081019061358d919061560a565b61359682613b0f565b145b156135a65750505050613614565b60405162461bcd60e51b815260206004820152602f60248201527f457874726120696e666f2066756e64696e67206f75747075742073637269707460448201527f20646f6573206e6f74206d6174636800000000000000000000000000000000006064820152608401610353565b50505050565b600082156118f057828235613635604085016020860161513c565b6136456060860160408701614e7c565b6040516020016136829392919092835260e09190911b6001600160e01b031916602083015260c01b6001600160c01b0319166024820152602c0190565b60405160208183030381529060405280519060200120146136e55760405162461bcd60e51b815260206004820152601d60248201527f496e76616c69642077616c6c6574206d61696e205554584f20646174610000006044820152606401610353565b6114626060830160408401614e7c565b600060108214613707575060006118f0565b61374b600084848080601f0160208091040260200160405190810160405280939291908181526020018383808284376000920191909152509293925050613b009050565b6001600160c01b031990811614613764575060006118f0565b50600192915050565b6040517fe65e19d50000000000000000000000000000000000000000000000000000000081526001600160601b0319821660048201526000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063e65e19d59060240161012060405180830381865afa1580156137f8573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061381c9190614509565b60e0015190506001816005811115613836576138366145ac565b148061385357506002816005811115613851576138516145ac565b145b6138b25760405162461bcd60e51b815260206004820152602a60248201527f57616c6c6574206973206e6f7420696e204c697665206f72204d6f76696e6746604482015269756e647320737461746560b01b6064820152608401610353565b5050565b60006020600083516020850160025afa50602060006020600060025afa5050600051919050565b60606000806138eb85613b2a565b9092509050600182016139665760405162461bcd60e51b815260206004820152602260248201527f52656164206f76657272756e20647572696e6720566172496e7420706172736960448201527f6e670000000000000000000000000000000000000000000000000000000000006064820152608401610353565b8084106139b55760405162461bcd60e51b815260206004820152601160248201527f566f75742072656164206f76657272756e0000000000000000000000000000006044820152606401610353565b6000806139c38460016148aa565b905060005b86811015613a4b576139da8883613b41565b92506000198303613a2d5760405162461bcd60e51b815260206004820152601a60248201527f42616420566172496e7420696e207363726970745075626b65790000000000006044820152606401610353565b613a3783836148aa565b915080613a43816148bd565b9150506139c8565b50613a568782613b41565b91506000198203613aa95760405162461bcd60e51b815260206004820152601a60248201527f42616420566172496e7420696e207363726970745075626b65790000000000006044820152606401610353565b613ab4878284613bae565b979650505050505050565b60606118f0826008808551613ad49190614700565b613c7b565b60006020600083516020850160025afa50602060006020600060035afa5050600c51919050565b60006114628383016020015190565b60008151600003613b2257506000919050565b506020015190565b600080613b38836000613f80565b91509150915091565b6000613b4e8260096148aa565b83511015613b5f57506000196118f0565b600080613b7685613b718660086148aa565b613f80565b909250905060018201613b8f57600019925050506118f0565b80613b9b8360096148aa565b613ba591906148aa565b95945050505050565b606081600003613bcd5750604080516020810190915260008152611462565b6000613bd983856148aa565b90508381118015613beb575080855110155b613c375760405162461bcd60e51b815260206004820152601360248201527f536c696365206f7574206f6620626f756e6473000000000000000000000000006044820152606401610353565b604051915082604083010160405282825283850182038460208701018481015b80821015613c7057815183830152602082019150613c57565b505050509392505050565b60606000848481518110613c9157613c91614759565b016020015160f81c905082613ca7826001615623565b60ff1614613cc5575050604080516020810190915260008152611462565b84613cd18560016148aa565b81518110613ce157613ce1614759565b016020015160f81c600003613da05760028160ff161015613d12575050604080516020810190915260008152611462565b600085613d208660026148aa565b81518110613d3057613d30614759565b016020015160f81c9050613d4560028361563c565b60ff1681141580613d63575080602014158015613d63575080601414155b15613d81576040518060200160405280600081525092505050611462565b613d97613d8f8660036148aa565b879083613bae565b92505050611462565b6000613dac8686613b00565b90507fffffff000000000000000000000000000000000000000000000000000000000081167f1976a9000000000000000000000000000000000000000000000000000000000003613ec05785613e038660036148aa565b81518110613e1357613e13614759565b60209101015160f81c6014141580613e8b5750613e466002613e3586886148aa565b613e3f9190614700565b8790613b00565b7dffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff19167f88ac00000000000000000000000000000000000000000000000000000000000014155b15613ea9576040518060200160405280600081525092505050611462565b613d97613eb78660046148aa565b87906014613bae565b7fffffff000000000000000000000000000000000000000000000000000000000081167f17a914000000000000000000000000000000000000000000000000000000000003613f6757856001613f1686886148aa565b613f209190614700565b81518110613f3057613f30614759565b60209101015160f81c608714613f59576040518060200160405280600081525092505050611462565b613d97613eb78660036148aa565b5050506040805160208101909152600081529392505050565b6000806000613f8f85856140fe565b90508060ff16600003613fc4576000858581518110613fb057613fb0614759565b016020015190935060f81c91506140f79050565b83613fd0826001615623565b60ff16613fdd91906148aa565b85511015613ff457600019600092509250506140f7565b60008160ff166002036140385761402d6140196140128760016148aa565b8890613b00565b62ffff0060e882901c1660f89190911c1790565b61ffff1690506140ed565b8160ff16600403614061576140546130bb6140128760016148aa565b63ffffffff1690506140ed565b8160ff166008036140ed576140e061407d6140128760016148aa565b60c01c64ff000000ff600882811c91821665ff000000ff009390911b92831617601090811b67ffffffffffffffff1666ff00ff00ff00ff9290921667ff00ff00ff00ff009093169290921790911c65ffff0000ffff1617602081811c91901b1790565b67ffffffffffffffff1690505b60ff909116925090505b9250929050565b600082828151811061411257614112614759565b016020015160f81c60ff03614129575060086118f0565b82828151811061413b5761413b614759565b016020015160f81c60fe03614152575060046118f0565b82828151811061416457614164614759565b016020015160f81c60fd0361417b575060026118f0565b50600092915050565b60006060828403121561419657600080fd5b50919050565b6000602082840312156141ae57600080fd5b813567ffffffffffffffff8111156141c557600080fd5b6141d184828501614184565b949350505050565b60006080828403121561419657600080fd5b60008060006040848603121561420057600080fd5b833567ffffffffffffffff8082111561421857600080fd5b614224878388016141d9565b9450602086013591508082111561423a57600080fd5b818601915086601f83011261424e57600080fd5b81358181111561425d57600080fd5b8760208260051b850101111561427257600080fd5b6020830194508093505050509250925092565b6000806080838503121561429857600080fd5b823567ffffffffffffffff8111156142af57600080fd5b6142bb85828601614184565b9250506142cb8460208501614184565b90509250929050565b600060a0828403121561419657600080fd5b600060a082840312156142f857600080fd5b61146283836142d4565b60006080828403121561431457600080fd5b61146283836141d9565b60006020828403121561433057600080fd5b813567ffffffffffffffff81111561434757600080fd5b82016040818503121561146257600080fd5b60008060c0838503121561436c57600080fd5b61437684846142d4565b915060a083013567ffffffffffffffff81111561439257600080fd5b61439e858286016142d4565b9150509250929050565b6001600160601b0319811681146143be57600080fd5b50565b80356143cc816143a8565b919050565b6000602082840312156143e357600080fd5b8135611462816143a8565b634e487b7160e01b600052604160045260246000fd5b604051610120810167ffffffffffffffff81118282101715614428576144286143ee565b60405290565b60405160a0810167ffffffffffffffff81118282101715614428576144286143ee565b6040516080810167ffffffffffffffff81118282101715614428576144286143ee565b6040516101c0810167ffffffffffffffff81118282101715614428576144286143ee565b604051610280810167ffffffffffffffff81118282101715614428576144286143ee565b67ffffffffffffffff811681146143be57600080fd5b80516143cc816144bc565b63ffffffff811681146143be57600080fd5b80516143cc816144dd565b8051600681106143cc57600080fd5b6000610120828403121561451c57600080fd5b614524614404565b825181526020830151602082015261453e604084016144d2565b604082015261454f606084016144ef565b6060820152614560608084016144ef565b608082015261457160a084016144ef565b60a082015261458260c084016144ef565b60c082015261459360e084016144fa565b60e0820152610100928301519281019290925250919050565b634e487b7160e01b600052602160045260246000fd5b6000808335601e198436030181126145d957600080fd5b83018035915067ffffffffffffffff8211156145f457600080fd5b6020019150600581901b36038213156140f757600080fd5b80516bffffffffffffffffffffffff811681146143cc57600080fd5b600080600080600080600060e0888a03121561464357600080fd5b875161464e816144bc565b602089015190975061465f816144bc565b6040890151909650614670816144bc565b6060890151909550614681816144bc565b6080890151909450614692816144dd565b92506146a060a0890161460c565b915060c08801516146b0816144dd565b8091505092959891949750929550565b634e487b7160e01b600052601260045260246000fd5b6000826146e5576146e56146c0565b500690565b634e487b7160e01b600052601160045260246000fd5b818103818111156118f0576118f06146ea565b600082614722576147226146c0565b500490565b80516001600160a01b03811681146143cc57600080fd5b60006020828403121561475057600080fd5b61146282614727565b634e487b7160e01b600052603260045260246000fd5b6000808335601e1984360301811261478657600080fd5b83018035915067ffffffffffffffff8211156147a157600080fd5b6020019150368190038213156140f757600080fd5b600060a082840312156147c857600080fd5b60405160a0810181811067ffffffffffffffff821117156147eb576147eb6143ee565b6040526147f783614727565b81526020830151614807816144bc565b6020820152604083015161481a816144bc565b6040820152606083015161482d816144bc565b60608201526080830151614840816144dd565b60808201529392505050565b60006020828403121561485e57600080fd5b8151611462816144dd565b63ffffffff818116838216019080821115614886576148866146ea565b5092915050565b63ffffffff828116828216039080821115614886576148866146ea565b808201808211156118f0576118f06146ea565b6000600182016148cf576148cf6146ea565b5060010190565b6000808335601e198436030181126148ed57600080fd5b83018035915067ffffffffffffffff82111561490857600080fd5b6020019150600681901b36038213156140f757600080fd5b60006040828403121561493257600080fd5b6040516040810181811067ffffffffffffffff82111715614955576149556143ee565b60405282358152602083013561496a816144dd565b60208201529392505050565b60008235609e1983360301811261498c57600080fd5b9190910192915050565b80356001600160e01b0319811681146143cc57600080fd5b600082601f8301126149bf57600080fd5b813567ffffffffffffffff808211156149da576149da6143ee565b604051601f8301601f19908116603f01168101908282118183101715614a0257614a026143ee565b81604052838152866020858801011115614a1b57600080fd5b836020870160208301376000602085830101528094505050505092915050565b80356001600160c01b0319811681146143cc57600080fd5b600060a08236031215614a6557600080fd5b614a6d61442e565b823567ffffffffffffffff80821115614a8557600080fd5b818501915060808236031215614a9a57600080fd5b614aa2614451565b614aab83614996565b8152602083013582811115614abf57600080fd5b614acb368286016149ae565b602083015250604083013582811115614ae357600080fd5b614aef368286016149ae565b604083015250614b0160608401614996565b6060820152835250614b17905060208401614a3b565b6020820152614b28604084016143c1565b6040820152614b39606084016143c1565b6060820152614b4a60808401614996565b608082015292915050565b600060e08284031215614b6757600080fd5b60405160e0810181811067ffffffffffffffff82111715614b8a57614b8a6143ee565b604052614b9683614727565b81526020830151614ba6816144bc565b60208201526040830151614bb9816144dd565b6040820152614bca60608401614727565b60608201526080830151614bdd816144bc565b608082015260a0830151614bf0816144dd565b60a082015260c0928301519281019290925250919050565b805180151581146143cc57600080fd5b600060208284031215614c2a57600080fd5b61146282614c08565b60008184825b85811015614c6b578135614c4c816143a8565b6001600160601b03191683526020928301929190910190600101614c39565b509095945050505050565b60008060008060008060008060008060006101608c8e031215614c9857600080fd5b8b51614ca3816144bc565b60208d0151909b50614cb4816144bc565b60408d0151909a50614cc5816144dd565b60608d0151909950614cd6816144dd565b9750614ce460808d0161460c565b965060a08c0151614cf4816144dd565b60c08d015190965061ffff81168114614d0c57600080fd5b9450614d1a60e08d016144d2565b9350614d296101008d016144ef565b9250614d386101208d0161460c565b9150614d476101408d016144ef565b90509295989b509295989b9093969950565b80516143cc816143a8565b8051600581106143cc57600080fd5b60006101c08284031215614d8657600080fd5b614d8e614474565b614d9783614727565b8152614da5602084016144d2565b6020820152614db6604084016144ef565b6040820152614dc760608401614d59565b6060820152614dd8608084016144d2565b6080820152614de960a084016144ef565b60a082015260c083015160c0820152614e0460e084016144ef565b60e0820152610100614e17818501614d64565b90820152610120614e298482016144d2565b90820152610140614e3b848201614c08565b90820152610160614e4d8482016144ef565b90820152610180614e5f8482016144d2565b908201526101a0614e718482016144ef565b908201529392505050565b600060208284031215614e8e57600080fd5b8135611462816144bc565b60006102808284031215614eac57600080fd5b614eb4614498565b614ebd83614d59565b8152614ecb602084016144ef565b6020820152614edc604084016144ef565b6040820152614eed606084016144d2565b6060820152614efe60808401614d64565b6080820152614f0f60a084016144fa565b60a0820152614f2060c08401614c08565b60c0820152614f3160e08401614727565b60e082015261010083810151908201526101208084015190820152610140614f5a8185016144d2565b90820152610160614f6c848201614c08565b90820152610180614f7e8482016144ef565b908201526101a0614f908482016144ef565b908201526101c0614fa28482016144ef565b908201526101e0614fb48482016144d2565b90820152610200614fc6848201614c08565b90820152610220614fd88482016144ef565b90820152610240614fea8482016144ef565b90820152610260614e718482016144d2565b6000806000806000806000806000806101408b8d03121561501c57600080fd5b6150258b614727565b995060208b0151615035816144bc565b60408c0151909950615046816144bc565b60608c0151909850615057816144dd565b60808c0151909750615068816144dd565b60a08c0151909650615079816144bc565b60c08c015190955061508a816144bc565b60e08c015190945061509b816144dd565b6101008c01519093506150ad816144dd565b6101208c01519092506150bf816144dd565b809150509295989b9194979a5092959850565b6000806000606084860312156150e757600080fd5b83516150f2816144bc565b6020850151909350615103816144bc565b6040850151909250615114816144dd565b809150509250925092565b60006020828403121561513157600080fd5b8151611462816144bc565b60006020828403121561514e57600080fd5b8135611462816144dd565b60006080828403121561516b57600080fd5b6040516080810181811067ffffffffffffffff8211171561518e5761518e6143ee565b604052825161519c816143a8565b815260208301516151ac816144bc565b602082015260408301516151bf816144dd565b60408201526060830151600481106151d657600080fd5b60608201529392505050565b6000602082840312156151f457600080fd5b61146282614996565b6000806000806080858703121561521357600080fd5b845161521e816144bc565b602086015190945061522f816144bc565b6040860151909350615240816144bc565b6060860151909250615251816144dd565b939692955090935050565b6000815160005b8181101561527d5760208185018101518683015201615263565b50600093019283525090919050565b60006001600160e01b031980871683526152b26152ac600485018861525c565b8661525c565b9316835250506004019392505050565b600560fa1b8152606086901b6001600160601b0319166001820152607560f81b6015820152600160fb1b60168201526001600160c01b031985166017820152607560f81b601f820152600061537a61536d615344602085015b7f7600000000000000000000000000000000000000000000000000000000000000815260010190565b7fa900000000000000000000000000000000000000000000000000000000000000815260010190565b600560fa1b815260010190565b6001600160601b031986168152608760f81b601482015261542361536d61534461531b6153fa6153d1601587015b7f6300000000000000000000000000000000000000000000000000000000000000815260010190565b7fac00000000000000000000000000000000000000000000000000000000000000815260010190565b7f6700000000000000000000000000000000000000000000000000000000000000815260010190565b6001600160601b0319861681529050601160fb1b601482015261546d601582015b7f0400000000000000000000000000000000000000000000000000000000000000815260010190565b6001600160e01b031985168152905060b160f81b60048201526154ca6154a16153d1600584015b607560f81b815260010190565b7f6800000000000000000000000000000000000000000000000000000000000000815260010190565b98975050505050505050565b600560fa1b81526001600160601b0319606088901b166001820152607560f81b60158201527f20000000000000000000000000000000000000000000000000000000000000006016820152600060178201878152607560f81b6020820152600160fb1b6021820152602281016001600160c01b0319881681529050607560f81b600882015261556d61536d6153446009840161531b565b6001600160601b0319871681529050608760f81b60148201526155a161536d61534461531b6153fa6153d1601587016153a8565b6001600160601b0319861681529050601160fb1b60148201526155c660158201615444565b6001600160e01b031985168152905060b160f81b60048201526155f16154a16153d160058401615494565b9998505050505050505050565b6000611462828461525c565b60006020828403121561561c57600080fd5b5051919050565b60ff81811683821601908111156118f0576118f06146ea565b60ff82811682821603908111156118f0576118f06146ea56fea2646970667358221220b6e5a79844d0f4fea2f7216bdbc256cab6427db737d76c0ea2bac56f2f1419be64736f6c63430008110033" } \ No newline at end of file diff --git a/pkg/chain/ethereum/testdata/walletproposalvalidator/regenerate.sh b/pkg/chain/ethereum/testdata/walletproposalvalidator/regenerate.sh index ad9600e9a3..ec0bcedca5 100755 --- a/pkg/chain/ethereum/testdata/walletproposalvalidator/regenerate.sh +++ b/pkg/chain/ethereum/testdata/walletproposalvalidator/regenerate.sh @@ -14,13 +14,17 @@ # StateDB), never reimplementing it in Go. # # Provenance of the currently vendored WalletProposalValidator.json: -# commit: e635e2292fbb6f2c39d835fb0b3861c032934bd0 -# (threshold-network/tbtc-v2 branch fix/m1-cross-repo-review, -# on top of reservations-upgrade @ 9f8f5ef1). Includes the -# snapshotted-minAmount check in -# validateReservationAnchorProposal. +# commit: eec999aad43b3913df378840773348e17f68f1ba +# (threshold-network/tbtc-v2, merge of #1161 into +# reservations-upgrade). The same commit is pinned as +# TBTC_V2_REF in the reservation-router-vendored-fallback- +# verify job of .github/workflows/client.yml, which checks +# this file against its own compile of that commit with +# verify.sh. Built with `yarn install && yarn build` in +# tbtc-v2's solidity/ directory, as that job does. # Re-run this script against a newer tbtc-v2 build whenever the -# validator changes, and update this note. +# validator changes, bump TBTC_V2_REF to the same commit, and update +# this note. # # Usage: # TBTC_V2_BUILD_DIR=/path/to/tbtc-v2/solidity/build ./regenerate.sh @@ -28,15 +32,14 @@ # TBTC_V2_BUILD_DIR must point at a hardhat build/ directory (i.e. the # directory containing contracts/bridge/WalletProposalValidator.sol/ # WalletProposalValidator.json) produced by `yarn build` in the tbtc-v2 -# solidity package. Defaults to the sibling tbtc-v2-m1fix worktree used -# during development of this harness. +# solidity package. set -euo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SOLC="${SOLC:-solc}" -TBTC_V2_BUILD_DIR="${TBTC_V2_BUILD_DIR:-$HERE/../../../../../../tbtc-v2-m1fix/solidity/build}" +: "${TBTC_V2_BUILD_DIR:?set TBTC_V2_BUILD_DIR to a tbtc-v2 solidity/build directory (run yarn build there first)}" VALIDATOR_ARTIFACT="$TBTC_V2_BUILD_DIR/contracts/bridge/WalletProposalValidator.sol/WalletProposalValidator.json" if [[ ! -f "$VALIDATOR_ARTIFACT" ]]; then diff --git a/pkg/chain/ethereum/testdata/walletproposalvalidator/verify.sh b/pkg/chain/ethereum/testdata/walletproposalvalidator/verify.sh new file mode 100755 index 0000000000..1e4ae01302 --- /dev/null +++ b/pkg/chain/ethereum/testdata/walletproposalvalidator/verify.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# +# Checks that the vendored WalletProposalValidator.json, the real tbtc-v2 +# validator that pkg/chain/ethereum/tbtc_validator_harness_test.go +# deploys, matches a fresh compile of the tbtc-v2 commit it was vendored +# from (see the provenance note in regenerate.sh): +# +# - abi: exactly equal, +# - deployedBytecode: equal once the trailing CBOR metadata is removed, +# - bytecode: (the creation code the harness deploys) equal once +# that same metadata blob is removed. +# +# The metadata is excluded because it hashes the full compiler input, and +# the CI job's tbtc-v2 `yarn install` is not lockfile-frozen, so the hash +# can move while the executed code stays the same. +# +# Usage: +# ./verify.sh /path/to/compiled/WalletProposalValidator.json +# +# Run by the reservation-router-vendored-fallback-verify job in +# .github/workflows/client.yml against its compile of TBTC_V2_REF. + +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [[ $# -ne 1 || ! -f "$1" ]]; then + echo "usage: $0 /path/to/compiled/WalletProposalValidator.json" >&2 + exit 2 +fi + +python3 - "$HERE/WalletProposalValidator.json" "$1" <<'PY' +import json +import sys + +vendored_path, compiled_path = sys.argv[1], sys.argv[2] +vendored = json.load(open(vendored_path)) +compiled = json.load(open(compiled_path)) + +REMEDY = ( + "the vendored harness validator ({}) does not match the compiled " + "tbtc-v2 artifact ({}). Re-run regenerate.sh against a build of " + "TBTC_V2_REF and update its provenance note, or pin TBTC_V2_REF to " + "the commit the vendored file came from." +).format(vendored_path, compiled_path) + + +def fail(message): + print("error: " + message, file=sys.stderr) + print("error: " + REMEDY, file=sys.stderr) + sys.exit(1) + + +def code_of(artifact, field, label): + value = artifact.get(field) + if not isinstance(value, str) or not value.startswith("0x") or len(value) < 6: + fail("{} {} is missing or not 0x-prefixed hex".format(label, field)) + return bytes.fromhex(value[2:]) + + +def split_metadata(code, label): + # solc appends CBOR-encoded metadata to the runtime code, followed by + # its length as a big-endian uint16. The encoding is a CBOR map, so + # its first byte is 0xa0-0xb7; anything else means the length read + # is wrong and stripping would compare garbage. + length = int.from_bytes(code[-2:], "big") + if length + 2 > len(code) or not 0xA0 <= code[-(length + 2)] <= 0xB7: + fail("{} deployedBytecode has no recognizable trailing CBOR metadata".format(label)) + return code[: -(length + 2)], code[-(length + 2) :] + + +def first_difference(a, b): + for i, (x, y) in enumerate(zip(a, b)): + if x != y: + return i + return min(len(a), len(b)) + + +def compare_code(field, vendored_code, compiled_code): + if vendored_code != compiled_code: + fail( + "{} differs (metadata excluded): vendored {} bytes, compiled {} " + "bytes, first difference at byte {}".format( + field, + len(vendored_code), + len(compiled_code), + first_difference(vendored_code, compiled_code), + ) + ) + + +if vendored.get("abi") != compiled.get("abi"): + fail("abi differs") + +vendored_runtime, vendored_meta = split_metadata( + code_of(vendored, "deployedBytecode", "vendored"), "vendored" +) +compiled_runtime, compiled_meta = split_metadata( + code_of(compiled, "deployedBytecode", "compiled"), "compiled" +) +compare_code("deployedBytecode", vendored_runtime, compiled_runtime) + + +def strip_embedded(code, meta, label): + # The creation code carries the runtime code, metadata included. + if meta not in code: + fail("{} bytecode does not embed its deployedBytecode metadata".format(label)) + return code.replace(meta, b"") + + +compare_code( + "bytecode", + strip_embedded(code_of(vendored, "bytecode", "vendored"), vendored_meta, "vendored"), + strip_embedded(code_of(compiled, "bytecode", "compiled"), compiled_meta, "compiled"), +) + +print( + "WalletProposalValidator.json harness artifact matches the compiled " + "artifact (abi exact; bytecode and deployedBytecode without metadata)" +) +PY From 2e7eb370ea3e7e6d4cd8f658658fcab372d10b13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 30 Sep 2026 08:19:50 +0000 Subject: [PATCH 09/31] refactor(reservations): drop re-anchor in-flight tracking and receipt lookup The in-memory in-flight map did not suppress duplicate requests across rounds: rounds are far apart, the leader rotates, restarts clear it, and the adapter never reports a mempool transaction as pending. Safety comes from re-reading chain state, since Reservation.sol only accepts a request against an Active reservation. Remove the map and its helpers, GetReservationReanchorRequestReceipt with its receipt enum, and restore RequestReservationReanchor to return only an error. Run now dispatches on chain state alone: a mined request shows up as an ActionPending reservation and takes the resume path. --- pkg/chain/ethereum/tbtc.go | 62 +--- pkg/chain/ethereum/tbtc_reservation_test.go | 141 +------- pkg/tbtc/chain.go | 38 +- pkg/tbtc/chain_test.go | 8 +- pkg/tbtcpg/chain.go | 15 +- pkg/tbtcpg/chain_test.go | 124 +------ pkg/tbtcpg/reservation_reanchor.go | 340 +----------------- .../reservation_reanchor_inflight_test.go | 304 +++------------- pkg/tbtcpg/reservation_reanchor_test.go | 8 +- 9 files changed, 90 insertions(+), 950 deletions(-) diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index 397917d1e8..da4effedf3 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -12,7 +12,6 @@ package ethereum import ( - "context" "crypto/ecdsa" "encoding/binary" "errors" @@ -24,7 +23,6 @@ import ( "sync" "time" - hostchain "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/crypto" @@ -834,81 +832,31 @@ func parseReservationActionState(value uint8) (tbtc.ReservationActionState, erro // RequestReservationReanchor asks the Bridge (via its ReservationRouter // delegatecall target) to start a new reservation re-anchor action generation -// for the given reservation, targeting the given wallet. The returned -// transaction hash lets the caller track the submission across coordination -// rounds via GetReservationReanchorRequestReceipt; the submission launches -// mining and gas bumping in the background and returns before the -// transaction is mined. +// for the given reservation, targeting the given wallet. func (tc *TbtcChain) RequestReservationReanchor( reservationKey *big.Int, targetWalletPublicKeyHash [20]byte, -) ([32]byte, error) { +) error { gasEstimate, err := tc.reservationRouter.RequestReservationReanchorGasEstimate( reservationKey, targetWalletPublicKeyHash, ) if err != nil { - return [32]byte{}, err + return err } // Here we add a 20% margin to overcome the gas problems. gasEstimateWithMargin := float64(gasEstimate) * float64(1.2) - tx, err := tc.reservationRouter.RequestReservationReanchor( + _, err = tc.reservationRouter.RequestReservationReanchor( reservationKey, targetWalletPublicKeyHash, ethutil.TransactionOptions{ GasLimit: uint64(gasEstimateWithMargin), }, ) - if err != nil { - return [32]byte{}, err - } - - return [32]byte(tx.Hash().Bytes()), nil -} - -// GetReservationReanchorRequestReceipt reports the mining status of a -// previously submitted RequestReservationReanchor transaction, as defined -// by the tbtc.ReservationReanchorRequestReceiptStatus values. The receipt -// lookup is bounded by a 30-second deadline, matching the baseChain header -// helpers. -// -// A receipt that does not exist yet - which go-ethereum reports as -// ethereum.NotFound for unknown or unmined hashes - maps to NotFound: the -// caller treats NotFound and Pending identically, bounded by the block the -// submission happened in. Every other lookup error (RPC outage, timeout, -// transport failure) is returned to the caller so an in-flight request is -// not mistaken for a dropped one. -func (tc *TbtcChain) GetReservationReanchorRequestReceipt( - txHash [32]byte, -) (tbtc.ReservationReanchorRequestReceiptStatus, error) { - ctx, cancelCtx := context.WithTimeout( - context.Background(), - 30*time.Second, - ) - defer cancelCtx() - receipt, err := tc.baseChain.client.TransactionReceipt( - ctx, - common.BytesToHash(txHash[:]), - ) - if err != nil { - if errors.Is(err, hostchain.NotFound) { - return tbtc.ReservationReanchorRequestReceiptNotFound, nil - } - return tbtc.ReservationReanchorRequestReceiptNotFound, fmt.Errorf( - "cannot fetch transaction receipt: %w", - err, - ) - } - if receipt == nil { - return tbtc.ReservationReanchorRequestReceiptNotFound, nil - } - if receipt.Status == 0 { - return tbtc.ReservationReanchorRequestReceiptReverted, nil - } - return tbtc.ReservationReanchorRequestReceiptMined, nil + return err } // SubmitReservationAcceptanceProof submits an SPV proof for the given diff --git a/pkg/chain/ethereum/tbtc_reservation_test.go b/pkg/chain/ethereum/tbtc_reservation_test.go index e29745b564..e967128ad6 100644 --- a/pkg/chain/ethereum/tbtc_reservation_test.go +++ b/pkg/chain/ethereum/tbtc_reservation_test.go @@ -4,9 +4,6 @@ package ethereum // Ethereum node, against a scripted fake client instead of a node or the // in-memory EVM harness: // -// - GetReservationReanchorRequestReceipt: the Mined/Reverted/NotFound -// mapping from TransactionReceipt results, error propagation for -// non-not-found RPC failures, and the bounded-lookup requirement. // - ReservationVaultFeeDebtSat / // ReservationVaultFeeReserveTbtcBaseUnits: that the gauges read the // vault's own on-chain values and that the fee reserve is the TBTC @@ -20,27 +17,24 @@ import ( "strings" "sync" "testing" - "time" hostchain "github.com/ethereum/go-ethereum" hostchainabi "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/accounts/keystore" "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" "github.com/keep-network/keep-common/pkg/chain/ethereum" "github.com/keep-network/keep-common/pkg/chain/ethereum/ethutil" tbtcabi "github.com/keep-network/keep-core/pkg/chain/ethereum/tbtc/gen/abi" tbtccontract "github.com/keep-network/keep-core/pkg/chain/ethereum/tbtc/gen/contract" - "github.com/keep-network/keep-core/pkg/tbtc" ) // reservationFakeClient is a scripted ethutil.EthereumClient: view calls -// are answered from a per-(address, selector) table, TransactionReceipt is -// answered from canned fields, and every other method is promoted from the -// embedded nil interface and would panic if the code under test reached -// it - keeping the fake honest about the RPC surface actually exercised. +// are answered from a per-(address, selector) table, and every other +// method is promoted from the embedded nil interface and would panic if +// the code under test reached it - keeping the fake honest about the RPC +// surface actually exercised. type reservationFakeClient struct { ethutil.EthereumClient @@ -55,14 +49,6 @@ type reservationFakeClient struct { balances map[common.Address]*big.Int balanceOfTargets []common.Address balanceOfSel [4]byte - - // receiptResult / receiptErr are the canned TransactionReceipt - // answer; receiptCtx and receiptHash record the lookup's context and - // the hash it targeted. - receiptResult *types.Receipt - receiptErr error - receiptCtx context.Context - receiptHash common.Hash } func newReservationFakeClient(t *testing.T) *reservationFakeClient { @@ -149,17 +135,6 @@ func (c *reservationFakeClient) CallContract( return respond(), nil } -func (c *reservationFakeClient) TransactionReceipt( - ctx context.Context, - txHash common.Hash, -) (*types.Receipt, error) { - c.mu.Lock() - c.receiptCtx = ctx - c.receiptHash = txHash - c.mu.Unlock() - return c.receiptResult, c.receiptErr -} - // CodeAt answers the contract-code probe go-ethereum issues when a view // call returns zero bytes; the fake never does, so this is defensive. func (c *reservationFakeClient) CodeAt( @@ -259,114 +234,6 @@ func newReservationGaugeChain( } } -func TestGetReservationReanchorRequestReceipt(t *testing.T) { - var txHash [32]byte - for i := range txHash { - txHash[i] = byte(i + 1) - } - lookupHash := common.BytesToHash(txHash[:]) - - t.Run("successful receipt maps to Mined", func(t *testing.T) { - client := newReservationFakeClient(t) - client.receiptResult = &types.Receipt{Status: types.ReceiptStatusSuccessful} - chain := &TbtcChain{baseChain: &baseChain{client: client}} - - status, err := chain.GetReservationReanchorRequestReceipt(txHash) - if err != nil { - t.Fatalf("unexpected error: [%v]", err) - } - if status != tbtc.ReservationReanchorRequestReceiptMined { - t.Fatalf("expected Mined, got %v", status) - } - }) - - t.Run("failed receipt maps to Reverted", func(t *testing.T) { - client := newReservationFakeClient(t) - client.receiptResult = &types.Receipt{Status: types.ReceiptStatusFailed} - chain := &TbtcChain{baseChain: &baseChain{client: client}} - - status, err := chain.GetReservationReanchorRequestReceipt(txHash) - if err != nil { - t.Fatalf("unexpected error: [%v]", err) - } - if status != tbtc.ReservationReanchorRequestReceiptReverted { - t.Fatalf("expected Reverted, got %v", status) - } - }) - - t.Run("not-found receipt maps to NotFound without error", func(t *testing.T) { - client := newReservationFakeClient(t) - client.receiptErr = hostchain.NotFound - chain := &TbtcChain{baseChain: &baseChain{client: client}} - - status, err := chain.GetReservationReanchorRequestReceipt(txHash) - if err != nil { - t.Fatalf("expected no error for a not-found receipt, got [%v]", err) - } - if status != tbtc.ReservationReanchorRequestReceiptNotFound { - t.Fatalf("expected NotFound, got %v", status) - } - }) - - t.Run("nil receipt without error maps to NotFound", func(t *testing.T) { - client := newReservationFakeClient(t) - chain := &TbtcChain{baseChain: &baseChain{client: client}} - - status, err := chain.GetReservationReanchorRequestReceipt(txHash) - if err != nil { - t.Fatalf("expected no error, got [%v]", err) - } - if status != tbtc.ReservationReanchorRequestReceiptNotFound { - t.Fatalf("expected NotFound, got %v", status) - } - }) - - t.Run("transient RPC failure propagates", func(t *testing.T) { - rpcFailure := errors.New("provider RPC failed") - client := newReservationFakeClient(t) - client.receiptErr = rpcFailure - chain := &TbtcChain{baseChain: &baseChain{client: client}} - - _, err := chain.GetReservationReanchorRequestReceipt(txHash) - if err == nil { - t.Fatal("expected the transient RPC failure to propagate, got nil error") - } - if !errors.Is(err, rpcFailure) { - t.Fatalf("expected the wrapped error to match the RPC failure, got [%v]", err) - } - }) - - t.Run("lookup is bounded by a deadline and uses the given hash", func(t *testing.T) { - client := newReservationFakeClient(t) - client.receiptResult = &types.Receipt{Status: types.ReceiptStatusSuccessful} - chain := &TbtcChain{baseChain: &baseChain{client: client}} - - before := time.Now() - if _, err := chain.GetReservationReanchorRequestReceipt(txHash); err != nil { - t.Fatalf("unexpected error: [%v]", err) - } - if client.receiptCtx == nil { - t.Fatal("the receipt lookup context was never recorded") - } - deadline, ok := client.receiptCtx.Deadline() - if !ok { - t.Fatal("expected the receipt lookup to run under a bounded context") - } - if deadline.Before(before) || deadline.After(before.Add(31*time.Second)) { - t.Fatalf("deadline %v outside the [start, start+30s] window", deadline) - } - // The receipt lookup must have targeted the exact hash that was - // passed in. - if client.receiptHash != lookupHash { - t.Fatalf( - "receipt lookup targeted %s, want %s", - client.receiptHash.Hex(), - lookupHash.Hex(), - ) - } - }) -} - func TestReservationVaultFeeGaugesReadVaultValues(t *testing.T) { routerAddress := common.HexToAddress("0x00000000000000000000000000000000000000a1") vaultAddress := common.HexToAddress("0x00000000000000000000000000000000000000a2") diff --git a/pkg/tbtc/chain.go b/pkg/tbtc/chain.go index 62fb5f08e0..275d50bb76 100644 --- a/pkg/tbtc/chain.go +++ b/pkg/tbtc/chain.go @@ -575,29 +575,6 @@ type Chain interface { ReservationChain } -// ReservationReanchorRequestReceiptStatus describes the outcome of a -// receipt lookup for a submitted RequestReservationReanchor transaction. -// The RequestReservationReanchor submission launches mining and gas -// bumping in the background and returns before the transaction is mined, -// so callers track the returned transaction hash across coordination -// rounds and resolve the request's fate from its receipt. -type ReservationReanchorRequestReceiptStatus int - -const ( - // ReservationReanchorRequestReceiptMined is the request transaction - // mined successfully: the new action generation exists on chain. - ReservationReanchorRequestReceiptMined ReservationReanchorRequestReceiptStatus = iota - // ReservationReanchorRequestReceiptReverted is the request transaction - // mined but reverted on chain: no action generation was written. - ReservationReanchorRequestReceiptReverted - // ReservationReanchorRequestReceiptPending is the request transaction - // is still in the mempool with no receipt yet. - ReservationReanchorRequestReceiptPending - // ReservationReanchorRequestReceiptNotFound is the request transaction - // was never observed on this node; it may have been dropped. - ReservationReanchorRequestReceiptNotFound -) - // ReservationChain defines the subset of the TBTC chain interface that pertains // specifically to UTXO reservation Bridge operations. The reservation state // machine is implemented behind Bridge.fallback's delegatecall to the @@ -608,23 +585,10 @@ const ( type ReservationChain interface { // RequestReservationReanchor requests a reservation re-anchor action // generation for the given reservation, targeting the given wallet. - // The returned bytes are the 32-byte hash of the submitted - // transaction; callers that submit the request track it across - // rounds via GetReservationReanchorRequestReceipt. A returned error - // means the submission itself failed before or at send time, so - // there is no transaction to track. RequestReservationReanchor( reservationKey *big.Int, targetWalletPublicKeyHash [20]byte, - ) ([32]byte, error) - - // GetReservationReanchorRequestReceipt reports the mining status of - // the RequestReservationReanchor transaction identified by txHash: - // mined successfully, mined but reverted, still pending in the - // mempool, or not observed. - GetReservationReanchorRequestReceipt( - txHash [32]byte, - ) (ReservationReanchorRequestReceiptStatus, error) + ) error // SubmitReservationAcceptanceProof submits an SPV proof for the given // reservation acceptance action generation. The call is restricted to diff --git a/pkg/tbtc/chain_test.go b/pkg/tbtc/chain_test.go index 7fe067b053..1391cf540b 100644 --- a/pkg/tbtc/chain_test.go +++ b/pkg/tbtc/chain_test.go @@ -1569,13 +1569,7 @@ func (lc *localChain) setValidateReservationReanchorProposalErr(err error) { func (lc *localChain) RequestReservationReanchor( reservationKey *big.Int, targetWalletPublicKeyHash [20]byte, -) ([32]byte, error) { - panic("unsupported") -} - -func (lc *localChain) GetReservationReanchorRequestReceipt( - txHash [32]byte, -) (ReservationReanchorRequestReceiptStatus, error) { +) error { panic("unsupported") } diff --git a/pkg/tbtcpg/chain.go b/pkg/tbtcpg/chain.go index 55002a8b04..367853bc51 100644 --- a/pkg/tbtcpg/chain.go +++ b/pkg/tbtcpg/chain.go @@ -187,22 +187,11 @@ type Chain interface { ) error // RequestReservationReanchor requests a reservation re-anchor action - // generation for the given reservation, targeting the given wallet. The - // returned bytes are the 32-byte hash of the submitted transaction; - // callers that submit the request track it across rounds via - // GetReservationReanchorRequestReceipt. + // generation for the given reservation, targeting the given wallet. RequestReservationReanchor( reservationKey *big.Int, targetWalletPublicKeyHash [20]byte, - ) ([32]byte, error) - - // GetReservationReanchorRequestReceipt reports the mining status of the - // RequestReservationReanchor transaction identified by txHash: mined - // successfully, mined but reverted, still pending in the mempool, or - // not observed. - GetReservationReanchorRequestReceipt( - txHash [32]byte, - ) (tbtc.ReservationReanchorRequestReceiptStatus, error) + ) error // NotifyMovingFundsBelowDust notifies the Bridge that the given wallet's // main UTXO has fallen below the moving funds dust threshold, ending diff --git a/pkg/tbtcpg/chain_test.go b/pkg/tbtcpg/chain_test.go index 4d60c19b1f..e450007dfd 100644 --- a/pkg/tbtcpg/chain_test.go +++ b/pkg/tbtcpg/chain_test.go @@ -41,7 +41,6 @@ type movingFundsCommitmentSubmission struct { type reservationReanchorRequestSubmission struct { ReservationKey *big.Int TargetWalletPublicKeyHash [20]byte - TxHash [32]byte } // belowDustNotification captures a submitted NotifyMovingFundsBelowDust @@ -93,13 +92,9 @@ type LocalChain struct { reservationParametersSet bool reservationProposalValidations map[[32]byte]bool reservationReanchorRequestSubmissions []*reservationReanchorRequestSubmission - reservationReanchorRequestReceipts map[[32]byte]tbtc.ReservationReanchorRequestReceiptStatus - // revertNextReanchorRequest makes the next RequestReservationReanchor - // submission record a Reverted receipt and write no generation. - revertNextReanchorRequest bool // pendingNextReanchorRequest makes the next RequestReservationReanchor - // submission record a Pending receipt and write no generation, - // modeling a submission still unconfirmed in the mempool. + // submission succeed without writing a generation, modeling a + // submission still unconfirmed in the mempool (or dropped from it). pendingNextReanchorRequest bool // reservationReanchorValidationCalls counts calls to // ValidateReservationReanchorProposal, so tests can observe whether @@ -144,7 +139,6 @@ func NewLocalChain() *LocalChain { reservationActions: make(map[string]*tbtc.ReservationAction), reservationProposalValidations: make(map[[32]byte]bool), reservationReanchorRequestSubmissions: make([]*reservationReanchorRequestSubmission, 0), - reservationReanchorRequestReceipts: make(map[[32]byte]tbtc.ReservationReanchorRequestReceiptStatus), belowDustNotifications: make([]*belowDustNotification, 0), reservationWalletKeys: make(map[[20]byte][]*big.Int), reservedDeposits: make(map[string]bool), @@ -1710,64 +1704,40 @@ func buildReservationReanchorProposalValidationKey( // reservation to be Active (fails closed otherwise, matching "Reservation // is not active"), then bumps its RequestNonce, flips its state to // ActionPending, and writes the new generation as a Pending Reanchor -// action authorizing targetWalletPublicKeyHash. This makes the new +// action authorizing targetWalletPublicKeyHash. This makes the // request-then-wait-then-read flow in ProposeReservationReanchor // observable in tests: reading the action before this call succeeds // returns "not found", exactly like an on-chain read of an // as-yet-unwritten generation would. // -// The fake models the submission as immediately mined, matching the -// production adapter's ForceMining fast path: it returns a deterministic -// transaction hash (derived from the submission contents plus the -// submission count) and registers the receipt as Mined, so the default -// GetReservationReanchorRequestReceipt answer for a recorded submission is -// Mined. Tests that need a different outcome (a reverted or still-pending -// receipt) override it via SetReservationReanchorRequestReceipt before -// driving the task. SetRevertNextReservationReanchorRequest makes the -// next submission succeed with a hash but record the receipt as Reverted -// instead of writing the on-chain generation, matching a request that -// reverts on-chain after submission. +// The fake models the submission as immediately mined. +// SetNextReservationReanchorRequestPending makes the next submission +// succeed without writing the generation, matching a request that has not +// been mined. func (lc *LocalChain) RequestReservationReanchor( reservationKey *big.Int, targetWalletPublicKeyHash [20]byte, -) ([32]byte, error) { +) error { lc.mutex.Lock() defer lc.mutex.Unlock() key := reservationKey.Text(16) existing, ok := lc.reservations[key] if !ok || existing == nil || existing.State != tbtc.ReservationStateActive { - return [32]byte{}, fmt.Errorf("reservation is not active") + return fmt.Errorf("reservation is not active") } - submissionCount := len(lc.reservationReanchorRequestSubmissions) - txHash := lc.nextReservationReanchorRequestHash( - reservationKey, - targetWalletPublicKeyHash, - submissionCount, - ) - lc.reservationReanchorRequestSubmissions = append( lc.reservationReanchorRequestSubmissions, &reservationReanchorRequestSubmission{ ReservationKey: new(big.Int).Set(reservationKey), TargetWalletPublicKeyHash: targetWalletPublicKeyHash, - TxHash: txHash, }, ) - if lc.revertNextReanchorRequest { - lc.revertNextReanchorRequest = false - lc.reservationReanchorRequestReceipts[txHash] = - tbtc.ReservationReanchorRequestReceiptReverted - return txHash, nil - } - if lc.pendingNextReanchorRequest { lc.pendingNextReanchorRequest = false - lc.reservationReanchorRequestReceipts[txHash] = - tbtc.ReservationReanchorRequestReceiptPending - return txHash, nil + return nil } // Mirror the request-time count-capacity check Reservation.sol's @@ -1786,7 +1756,7 @@ func (lc *LocalChain) RequestReservationReanchor( ) if currentTargetCount+1 > lc.reservationParametersValue.MaxReservationsPerWallet { - return [32]byte{}, fmt.Errorf("wallet reservations cap exceeded") + return fmt.Errorf("wallet reservations cap exceeded") } } @@ -1813,7 +1783,7 @@ func (lc *LocalChain) RequestReservationReanchor( anchorValue = uint64(existing.AnchorUtxo.Value) } if targetReservedAmount+anchorValue > maxAmount { - return [32]byte{}, fmt.Errorf("wallet reserved amount cap exceeded") + return fmt.Errorf("wallet reserved amount cap exceeded") } } } @@ -1839,76 +1809,11 @@ func (lc *LocalChain) RequestReservationReanchor( TermSeconds: lc.reservationParametersValue.ReservationTermSeconds, } - lc.reservationReanchorRequestReceipts[txHash] = - tbtc.ReservationReanchorRequestReceiptMined - - return txHash, nil -} - -// nextReservationReanchorRequestHash derives the fake's deterministic -// transaction hash for a re-anchor submission: the keccak-256 of the -// reservation key, the target wallet hash, and the submission count. -// Callers hold lc.mutex. -func (lc *LocalChain) nextReservationReanchorRequestHash( - reservationKey *big.Int, - targetWalletPublicKeyHash [20]byte, - submissionCount int, -) [32]byte { - var buffer bytes.Buffer - buffer.Write(reservationKey.Bytes()) - buffer.Write(targetWalletPublicKeyHash[:]) - for i := 0; i < 4; i++ { - buffer.Write([]byte{byte(uint32(submissionCount) >> (8 * i))}) - } - var hash [32]byte - copy(hash[:], crypto.Keccak256(buffer.Bytes())) - return hash -} - -// GetReservationReanchorRequestReceipt returns the recorded receipt status -// of a RequestReservationReanchor submission: a hash recorded via the -// submission itself (Mined, or Reverted when a revert was forced) or via -// SetReservationReanchorRequestPending reports its recorded status, and a -// hash never seen by the fake reports NotFound. -func (lc *LocalChain) GetReservationReanchorRequestReceipt( - txHash [32]byte, -) (tbtc.ReservationReanchorRequestReceiptStatus, error) { - lc.mutex.Lock() - defer lc.mutex.Unlock() - - if status, ok := lc.reservationReanchorRequestReceipts[txHash]; ok { - return status, nil - } - return tbtc.ReservationReanchorRequestReceiptNotFound, nil -} - -// SetReservationReanchorRequestReceipt overrides the receipt status the -// fake reports for the given submission hash (e.g. to model a request -// that is still pending in the mempool). -func (lc *LocalChain) SetReservationReanchorRequestReceipt( - txHash [32]byte, - status tbtc.ReservationReanchorRequestReceiptStatus, -) { - lc.mutex.Lock() - defer lc.mutex.Unlock() - - lc.reservationReanchorRequestReceipts[txHash] = status -} - -// SetRevertNextReservationReanchorRequest makes the next -// RequestReservationReanchor submission succeed (returning its hash) -// while recording its receipt as Reverted and writing no on-chain -// generation. -func (lc *LocalChain) SetRevertNextReservationReanchorRequest() { - lc.mutex.Lock() - defer lc.mutex.Unlock() - - lc.revertNextReanchorRequest = true + return nil } // SetNextReservationReanchorRequestPending makes the next -// RequestReservationReanchor submission succeed (returning its hash) -// while recording its receipt as Pending and writing no on-chain +// RequestReservationReanchor submission succeed while writing no on-chain // generation, matching a request still unconfirmed in the mempool. func (lc *LocalChain) SetNextReservationReanchorRequestPending() { lc.mutex.Lock() @@ -2281,7 +2186,6 @@ func (lc *LocalChain) GetReservationReanchorRequestSubmissions() []*reservationR copy[i] = &reservationReanchorRequestSubmission{ ReservationKey: new(big.Int).Set(s.ReservationKey), TargetWalletPublicKeyHash: s.TargetWalletPublicKeyHash, - TxHash: s.TxHash, } } return copy diff --git a/pkg/tbtcpg/reservation_reanchor.go b/pkg/tbtcpg/reservation_reanchor.go index ef7306f564..5b28d57825 100644 --- a/pkg/tbtcpg/reservation_reanchor.go +++ b/pkg/tbtcpg/reservation_reanchor.go @@ -24,9 +24,7 @@ const ReservationReanchorLookBackBlocks = uint64(216000) // waitForReservationReanchorRequestMined waits for a submitted // RequestReservationReanchor transaction to be mined, mirroring // MovingFundsTask.SubmitMovingFundsCommitment's bounded wait-then-re-read -// pattern. It doubles as the same-round fast path for a fresh request -// (below) and the "not observed for this many blocks means dropped" -// bound for the in-flight receipt check in Run. +// pattern. const reservationReanchorRequestWaitBlocks = uint64(6) // reservationRequestTimeoutSafetyMarginSeconds mirrors @@ -39,25 +37,6 @@ const reservationReanchorRequestWaitBlocks = uint64(6) // validator reject them. const reservationRequestTimeoutSafetyMarginSeconds = 2 * 60 * 60 -// reservationReanchorInFlightRequest tracks a RequestReservationReanchor -// submission that has not yet been resolved by its receipt, keyed by the -// reservation key in the task's inFlightReanchorRequests map. -type reservationReanchorInFlightRequest struct { - // txHash is the RequestReservationReanchor transaction hash the - // receipt check looks up. - txHash [32]byte - // submittedAtBlock is the current block observed when the submission - // was recorded. - submittedAtBlock uint64 - // sourceWalletPublicKeyHash is the wallet that hosted the reservation - // when the submission was recorded. It identifies the entry to - // forgetInFlightReanchorRequests if that wallet's reservation list no - // longer contains the key: a custody move that happened before the - // next Run round means nothing left to resolve there and the chain - // state alone drives any resume. - sourceWalletPublicKeyHash [20]byte -} - // ReservationReanchorTask is a task that may produce a reservation re-anchor // proposal. The wallet enters this task when the source wallet has begun a // move to a new wallet (state StateMovingFunds) or when the source wallet's @@ -90,21 +69,6 @@ type ReservationReanchorTask struct { // is excluded. Meaningful only when hasCachedTargetWallet is true. cachedTargetWalletPublicKeyHash [20]byte hasCachedTargetWallet bool - - // inFlightReanchorRequestsMutex guards inFlightReanchorRequests. - // This task instance is shared across concurrent Run calls for - // different source wallets, as targetWalletCacheMutex is, so the - // per-reservation tracking state needs its own mutex. - inFlightReanchorRequestsMutex sync.Mutex - // inFlightReanchorRequests tracks RequestReservationReanchor - // submissions that have not yet been resolved by their receipt, - // keyed by reservation key. The receipt check at the top of Run - // settles each entry: mined resumes from chain state, reverted or - // not-observed-past-the-bound allows a fresh request, still - // pending skips this round. State loss on restart is tolerable: a - // mined request shows up as ActionPending (resume path) and a - // dropped request leaves Active (a new request is safe). - inFlightReanchorRequests map[string]reservationReanchorInFlightRequest } // NewReservationReanchorTask returns a new ReservationReanchorTask bound to @@ -114,9 +78,8 @@ func NewReservationReanchorTask( btcChain bitcoin.Chain, ) *ReservationReanchorTask { return &ReservationReanchorTask{ - chain: chain, - btcChain: btcChain, - inFlightReanchorRequests: make(map[string]reservationReanchorInFlightRequest), + chain: chain, + btcChain: btcChain, } } @@ -212,13 +175,6 @@ func (rrt *ReservationReanchorTask) Run( ) } - // A reservation that left the wallet since the entry was recorded - // has nothing left to resolve under it: drop the in-flight entries - // that no longer appear in the wallet's reservation list before any - // path below (including the empty-list early return) can settle - // them. - rrt.forgetInFlightReanchorRequestsNotIn(walletPublicKeyHash, reservationKeys) - if len(reservationKeys) == 0 { taskLogger.Info("wallet has no reservations to re-anchor") // This duty stays embedded in Run() rather than becoming its own @@ -274,59 +230,6 @@ reservationLoop: ) } - // In-flight request tracking: if a RequestReservationReanchor - // submitted in an earlier round for this reservation is still - // unresolved, check its receipt before doing anything else. A - // mined request means the chain now holds the new generation - // (resume from chain state below); a reverted or dropped one - // means the chain still shows the old state and a fresh request - // is allowed; a still-pending one means this reservation is - // skipped this round. The submission's block number bounds the - // pending window: once the submission is not observed within - // reservationReanchorRequestWaitBlocks it is treated as dropped - // and a new request is allowed. - if inFlight, ok := rrt.getReanchorRequestInFlight(reservationKey); ok { - resolved, reReadReservation, err := - rrt.resolveReanchorRequestInFlight( - taskLogger, - reservationKey, - inFlight, - ) - if err != nil { - taskLogger.Errorf( - "cannot resolve in-flight re-anchor request for "+ - "[0x%x]: [%v]", - reservationKey, - err, - ) - continue reservationLoop - } - if !resolved { - taskLogger.Infof( - "re-anchor request for [0x%x] still in flight; "+ - "skipping this reservation this round", - reservationKey, - ) - continue reservationLoop - } - if reReadReservation { - // The request just resolved as mined: re-read the - // reservation record so the switch below dispatches on - // the state the mined generation actually advanced it - // to (ActionPending with the new nonce). - reservation, err = rrt.chain.GetReservation(reservationKey) - if err != nil { - taskLogger.Errorf( - "cannot re-read reservation [0x%x] after an "+ - "in-flight request resolved as mined: [%v]", - reservationKey, - err, - ) - continue reservationLoop - } - } - } - switch reservation.State { case tbtc.ReservationStateActionPending: // Resume path: this generation was already authorized (by @@ -471,14 +374,10 @@ reservationLoop: continue } - // Every other failure on the request path is safe to - // retry on the next round without re-requesting: a - // request that went on-chain is tracked in-flight by - // its transaction hash (resolved by the receipt check - // at the top of this pass), and a pre-request local - // failure (fee estimation, assembly, validation) - // changed no chain state. Skip this reservation this - // pass. + // Skip this reservation this pass. A request that went + // on-chain is picked up from chain state on a later + // round: once mined, the reservation is ActionPending + // and takes the resume path above. continue reservationLoop } @@ -495,18 +394,6 @@ reservationLoop: return nil, false, nil } -// errReservationReanchorRequestNotMined signals that a just-submitted -// RequestReservationReanchor was not observed as mined within the -// same-round fast-path wait. The submission itself succeeded: an -// in-flight tracking entry (with the submitted transaction hash and -// block) was recorded before the wait, so subsequent Run rounds -// settle it via its receipt instead of issuing a duplicate request. -// Run treats this as "skip this reservation this round" rather than -// as a window failure. -var errReservationReanchorRequestNotMined = errors.New( - "reservation re-anchor request not yet mined", -) - // isReservationCapRevertError reports whether err is a reservation // wallet-capacity revert from either RequestReservationReanchor or // ValidateReservationReanchorProposal, mirroring the two capacity @@ -612,32 +499,13 @@ func (rrt *ReservationReanchorTask) ProposeReservationReanchor( targetWalletPublicKeyHash, ) - txHash, err := rrt.chain.RequestReservationReanchor( + if err := rrt.chain.RequestReservationReanchor( reservationKey, targetWalletPublicKeyHash, - ) - if err != nil { + ); err != nil { return nil, fmt.Errorf("cannot request reservation re-anchor: [%w]", err) } - // Record the in-flight submission for cross-round receipt - // resolution (see Run's in-flight receipt check): a later round - // sees this entry and resolves the request via its receipt - // (mined -> resume, reverted/dropped -> allow a new request, - // pending -> skip this round). - rrt.recordReanchorRequestInFlight( - taskLogger, - sourceWalletPublicKeyHash, - reservationKey, - txHash, - ) - - // Same-round fast path: wait for the request to mine so this - // round builds the proposal directly rather than waiting for - // the next round's receipt resolution. A timeout is no longer a - // window-aborting post-write failure: the in-flight entry above - // carries the submission into the receipt check, which resolves - // it safely in subsequent rounds. reservation, action, err = rrt.waitForReservationReanchorRequestMined( taskLogger, reservationKey, @@ -645,7 +513,10 @@ func (rrt *ReservationReanchorTask) ProposeReservationReanchor( requestNonce, ) if err != nil { - return nil, errReservationReanchorRequestNotMined + return nil, fmt.Errorf( + "reservation re-anchor request not confirmed: [%w]", + err, + ) } requestNonce = reservation.RequestNonce } @@ -961,191 +832,6 @@ func (rrt *ReservationReanchorTask) evictCachedTargetWallet(target [20]byte) { } } -// recordReanchorRequestInFlight records the just-submitted -// RequestReservationReanchor transaction hash for the reservation under -// sourceWalletPublicKeyHash, the wallet that hosted the reservation when -// the submission was recorded. The current block (or 0 if the block -// counter is unavailable) bounds the receipt check's "not observed for -// this many blocks means dropped" logic. -func (rrt *ReservationReanchorTask) recordReanchorRequestInFlight( - taskLogger log.StandardLogger, - sourceWalletPublicKeyHash [20]byte, - reservationKey *big.Int, - txHash [32]byte, -) { - var submittedBlock uint64 - if blockCounter, err := rrt.chain.BlockCounter(); err == nil { - if currentBlock, err := blockCounter.CurrentBlock(); err == nil { - submittedBlock = currentBlock - } else { - taskLogger.Warnf( - "cannot read current block when recording in-flight re-anchor "+ - "request for [0x%x]: [%v]; the receipt check will treat "+ - "any pending status as past the window", - reservationKey, - err, - ) - } - } else { - taskLogger.Warnf( - "cannot get block counter when recording in-flight re-anchor "+ - "request for [0x%x]: [%v]; the receipt check will treat any "+ - "pending status as past the window", - reservationKey, - err, - ) - } - - rrt.inFlightReanchorRequestsMutex.Lock() - defer rrt.inFlightReanchorRequestsMutex.Unlock() - - rrt.inFlightReanchorRequests[reservationKey.Text(16)] = reservationReanchorInFlightRequest{ - txHash: txHash, - submittedAtBlock: submittedBlock, - sourceWalletPublicKeyHash: sourceWalletPublicKeyHash, - } -} - -// forgetInFlightReanchorRequestsNotIn drops the in-flight entries for -// sourceWalletPublicKeyHash whose reservation key is not in currentKeys, -// called at the top of Run, just after the wallet's reservation list is -// read: a reservation that has since left the wallet (moved to another -// custody) has nothing left to resume under this wallet, so the chain -// state alone drives any follow-up and the tracking entry would otherwise -// leak for the task's lifetime. Entries hosted by any other wallet are -// left untouched. -func (rrt *ReservationReanchorTask) forgetInFlightReanchorRequestsNotIn( - sourceWalletPublicKeyHash [20]byte, - currentKeys []*big.Int, -) { - present := make(map[string]bool, len(currentKeys)) - for _, key := range currentKeys { - present[key.Text(16)] = true - } - - rrt.inFlightReanchorRequestsMutex.Lock() - defer rrt.inFlightReanchorRequestsMutex.Unlock() - - for key, entry := range rrt.inFlightReanchorRequests { - if entry.sourceWalletPublicKeyHash == sourceWalletPublicKeyHash && - !present[key] { - delete(rrt.inFlightReanchorRequests, key) - } - } -} - -// getReanchorRequestInFlight returns the in-flight record for the -// reservation key, if any. -func (rrt *ReservationReanchorTask) getReanchorRequestInFlight( - reservationKey *big.Int, -) (reservationReanchorInFlightRequest, bool) { - rrt.inFlightReanchorRequestsMutex.Lock() - defer rrt.inFlightReanchorRequestsMutex.Unlock() - - entry, ok := rrt.inFlightReanchorRequests[reservationKey.Text(16)] - return entry, ok -} - -// forgetInFlightReanchorRequest drops the in-flight record for the -// reservation key, called once the receipt outcome has been resolved. -func (rrt *ReservationReanchorTask) forgetInFlightReanchorRequest( - reservationKey *big.Int, -) { - rrt.inFlightReanchorRequestsMutex.Lock() - defer rrt.inFlightReanchorRequestsMutex.Unlock() - - delete(rrt.inFlightReanchorRequests, reservationKey.Text(16)) -} - -// resolveReanchorRequestInFlight looks up the receipt for an in-flight -// RequestReservationReanchor submission and settles it. -// -// - Mined: the chain now holds the new generation; the entry is -// forgotten. Callers should re-read the reservation and take the -// ActionPending resume path. -// - Reverted: no generation was written; the entry is forgotten and a -// fresh request is allowed this round. -// - Pending / NotFound: the submission is still unobserved. Within -// reservationReanchorRequestWaitBlocks of submission the caller -// should skip this reservation for this round (the receipt may -// surface on a later one); past that bound the submission is -// treated as dropped and a new request is allowed. -// -// The returned values are: -// -// resolved -- the in-flight entry has been settled and the -// caller may continue past it; -// re-mustClear -- the chain state may have advanced (only true for -// the Mined case), so the caller should re-read the -// reservation record before dispatching. -func (rrt *ReservationReanchorTask) resolveReanchorRequestInFlight( - taskLogger log.StandardLogger, - reservationKey *big.Int, - inFlight reservationReanchorInFlightRequest, -) (resolved bool, reMustClear bool, err error) { - status, err := rrt.chain.GetReservationReanchorRequestReceipt(inFlight.txHash) - if err != nil { - return false, false, fmt.Errorf( - "receipt lookup: [%w]", - err, - ) - } - - switch status { - case tbtc.ReservationReanchorRequestReceiptMined: - taskLogger.Infof( - "in-flight re-anchor request [%x] for reservation [0x%x] mined; "+ - "resuming from chain state", - inFlight.txHash, - reservationKey, - ) - rrt.forgetInFlightReanchorRequest(reservationKey) - return true, true, nil - case tbtc.ReservationReanchorRequestReceiptReverted: - taskLogger.Infof( - "in-flight re-anchor request [%x] for reservation [0x%x] reverted; "+ - "allowing a new request", - inFlight.txHash, - reservationKey, - ) - rrt.forgetInFlightReanchorRequest(reservationKey) - return true, false, nil - case tbtc.ReservationReanchorRequestReceiptPending, - tbtc.ReservationReanchorRequestReceiptNotFound: - blockCounter, err := rrt.chain.BlockCounter() - if err != nil { - return false, false, fmt.Errorf( - "block counter: [%w]", - err, - ) - } - currentBlock, err := blockCounter.CurrentBlock() - if err != nil { - return false, false, fmt.Errorf( - "current block: [%w]", - err, - ) - } - if currentBlock > inFlight.submittedAtBlock && - currentBlock-inFlight.submittedAtBlock > - reservationReanchorRequestWaitBlocks { - taskLogger.Infof( - "in-flight re-anchor request [%x] for reservation [0x%x] "+ - "unobserved for [%d] blocks; treating as dropped and "+ - "allowing a new request", - inFlight.txHash, - reservationKey, - currentBlock-inFlight.submittedAtBlock, - ) - rrt.forgetInFlightReanchorRequest(reservationKey) - return true, false, nil - } - return false, false, nil - } - - return true, false, nil -} - // scanForTargetWallet performs the registration-event scan findTargetWallet // falls back to when no cached target wallet is usable. The primary scan // is bounded to ReservationReanchorLookBackBlocks (mirroring the other diff --git a/pkg/tbtcpg/reservation_reanchor_inflight_test.go b/pkg/tbtcpg/reservation_reanchor_inflight_test.go index 0023ce425e..7a39709a5d 100644 --- a/pkg/tbtcpg/reservation_reanchor_inflight_test.go +++ b/pkg/tbtcpg/reservation_reanchor_inflight_test.go @@ -12,8 +12,8 @@ import ( // newInFlightReanchorFixture wires a fresh LocalChain / LocalBitcoinChain // pair with a single Active reservation, its anchor UTXO, and a // registered Live target wallet, ready to drive -// ReservationReanchorTask.Run across the receipt-resolution scenarios in -// this file. The block counter starts at block 1000. +// ReservationReanchorTask.Run across the multi-round scenarios in this +// file. The block counter starts at block 1000. func newInFlightReanchorFixture(t *testing.T) ( tbtcChain *LocalChain, btcChain *LocalBitcoinChain, @@ -131,19 +131,20 @@ func seedResumableReanchorGeneration( }) } -// TestReservationReanchorTask_InFlight_PendingWithinBound_SkipsWithoutNewRequest -// pins the "still pending, within the drop bound" branch of -// resolveReanchorRequestInFlight: a submission that has not yet -// confirmed must suppress a second RequestReservationReanchor call for -// the same reservation, instead of re-requesting every round. -func TestReservationReanchorTask_InFlight_PendingWithinBound_SkipsWithoutNewRequest(t *testing.T) { - tbtcChain, _, blockCounter, task, sourceWalletPublicKeyHash, _, _ := +// TestReservationReanchorTask_UnminedRequest_NextRoundRequestsAgain pins +// what Run does with a request it submitted but did not see mined within +// its wait: the pass ends without a proposal, and because Run keeps no +// memory of the submission, the next round dispatches on chain state +// alone. A reservation still Active on-chain (the request reverted, was +// dropped, or is still unmined) is eligible for a fresh request, which +// is safe because Reservation.sol only accepts a request against an +// Active reservation. +func TestReservationReanchorTask_UnminedRequest_NextRoundRequestsAgain(t *testing.T) { + tbtcChain, _, blockCounter, task, sourceWalletPublicKeyHash, targetWalletPublicKeyHash, _ := newInFlightReanchorFixture(t) - // Round 1: the request is submitted but stays unconfirmed (Pending - // receipt, no generation written), so the same-round fast path - // cannot find the mined generation and this reservation is skipped - // for the round -- but the submission is now tracked in-flight. + // Round 1: the request is submitted but writes no generation, so the + // mined-wait cannot find it and the pass ends without a proposal. tbtcChain.SetNextReservationReanchorRequestPending() proposal, ok, err := task.Run(&tbtc.CoordinationProposalRequest{ @@ -162,65 +163,9 @@ func TestReservationReanchorTask_InFlight_PendingWithinBound_SkipsWithoutNewRequ t.Fatalf("round 1: expected exactly 1 submission, got %d", got) } - // Round 2: the block has advanced by less than the drop bound (6) - // and the receipt is still Pending, so the in-flight entry must - // resolve to "still unconfirmed" and this round must not submit a - // second request. - blockCounter.SetCurrentBlock(1003) - - proposal, ok, err = task.Run(&tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: sourceWalletPublicKeyHash, - }) - if err != nil { - t.Fatalf("round 2: unexpected error: %v", err) - } - if ok || proposal != nil { - t.Fatalf( - "round 2: expected no proposal while the pending request "+ - "remains within its window, got ok=%v proposal=%v", ok, proposal, - ) - } - if got := len(tbtcChain.GetReservationReanchorRequestSubmissions()); got != 1 { - t.Fatalf( - "round 2: expected the in-flight pending request to "+ - "suppress a second submission, got %d submissions", got, - ) - } -} - -// TestReservationReanchorTask_InFlight_Reverted_AllowsNewRequest pins the -// Reverted branch of resolveReanchorRequestInFlight: once a submission's -// receipt resolves as reverted, the reservation (left Active on-chain, -// since the revert wrote no generation) must be eligible for a fresh -// request on the very next round. -func TestReservationReanchorTask_InFlight_Reverted_AllowsNewRequest(t *testing.T) { - tbtcChain, _, blockCounter, task, sourceWalletPublicKeyHash, targetWalletPublicKeyHash, _ := - newInFlightReanchorFixture(t) - - // Round 1: the request reverts on-chain; no generation is written, - // so the fast path cannot find it and the reservation is skipped - // this round, tracked in-flight by its reverted receipt. - tbtcChain.SetRevertNextReservationReanchorRequest() - - proposal, ok, err := task.Run(&tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: sourceWalletPublicKeyHash, - }) - if err != nil { - t.Fatalf("round 1: unexpected error: %v", err) - } - if ok || proposal != nil { - t.Fatalf( - "round 1: expected no proposal from a reverted request, "+ - "got ok=%v proposal=%v", ok, proposal, - ) - } - if got := len(tbtcChain.GetReservationReanchorRequestSubmissions()); got != 1 { - t.Fatalf("round 1: expected exactly 1 submission, got %d", got) - } - - // Round 2: the receipt resolves as Reverted, so a fresh request - // must be allowed and mined via the same-round fast path. - blockCounter.SetCurrentBlock(1001) + // Round 2: the reservation is still Active on-chain, so a fresh + // request is issued and mined within the wait. + blockCounter.SetCurrentBlock(1900) proposal, ok, err = task.Run(&tbtc.CoordinationProposalRequest{ WalletPublicKeyHash: sourceWalletPublicKeyHash, @@ -230,8 +175,8 @@ func TestReservationReanchorTask_InFlight_Reverted_AllowsNewRequest(t *testing.T } if !ok || proposal == nil { t.Fatalf( - "round 2: expected a proposal from a freshly allowed "+ - "request, got ok=%v proposal=%v", ok, proposal, + "round 2: expected a proposal from a fresh request, got "+ + "ok=%v proposal=%v", ok, proposal, ) } reanchorProposal, ok := proposal.(*tbtc.ReservationReanchorProposal) @@ -251,79 +196,17 @@ func TestReservationReanchorTask_InFlight_Reverted_AllowsNewRequest(t *testing.T ) } if got := len(tbtcChain.GetReservationReanchorRequestSubmissions()); got != 2 { - t.Fatalf( - "round 2: expected the reverted request to allow a second "+ - "submission, got %d submissions", got, - ) - } -} - -// TestReservationReanchorTask_InFlight_DroppedPastBound_AllowsNewRequest -// pins the "unobserved past the drop bound" branch of -// resolveReanchorRequestInFlight: a submission whose receipt is no -// longer found at all, once the block count has advanced more than -// reservationReanchorRequestWaitBlocks (6) past the submission block, -// must be treated as dropped and a fresh request must be allowed. -func TestReservationReanchorTask_InFlight_DroppedPastBound_AllowsNewRequest(t *testing.T) { - tbtcChain, _, blockCounter, task, sourceWalletPublicKeyHash, _, _ := - newInFlightReanchorFixture(t) - - tbtcChain.SetNextReservationReanchorRequestPending() - - proposal, ok, err := task.Run(&tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: sourceWalletPublicKeyHash, - }) - if err != nil { - t.Fatalf("round 1: unexpected error: %v", err) - } - if ok || proposal != nil { - t.Fatalf( - "round 1: expected no proposal while the request is "+ - "unconfirmed, got ok=%v proposal=%v", ok, proposal, - ) - } - submissions := tbtcChain.GetReservationReanchorRequestSubmissions() - if len(submissions) != 1 { - t.Fatalf("round 1: expected exactly 1 submission, got %d", len(submissions)) - } - - // The submission is no longer observable at all (e.g. it fell out - // of the mempool) and the block has advanced past the drop bound - // (6): the in-flight entry must be treated as dropped and a fresh - // request must be allowed. - tbtcChain.SetReservationReanchorRequestReceipt( - submissions[0].TxHash, - tbtc.ReservationReanchorRequestReceiptNotFound, - ) - blockCounter.SetCurrentBlock(1007) - - proposal, ok, err = task.Run(&tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: sourceWalletPublicKeyHash, - }) - if err != nil { - t.Fatalf("round 2: unexpected error: %v", err) - } - if !ok || proposal == nil { - t.Fatalf( - "round 2: expected a proposal from a freshly allowed "+ - "request once the submission is treated as dropped, "+ - "got ok=%v proposal=%v", ok, proposal, - ) - } - if got := len(tbtcChain.GetReservationReanchorRequestSubmissions()); got != 2 { - t.Fatalf( - "round 2: expected the dropped request to allow a second "+ - "submission, got %d submissions", got, - ) + t.Fatalf("round 2: expected a second submission, got %d", got) } } -// TestReservationReanchorTask_InFlight_Mined_ResumesWithoutNewRequest -// pins the Mined branch of resolveReanchorRequestInFlight: once a -// submission's receipt resolves as mined, the next round must resume -// from the chain-written Pending Reanchor generation (validated with its -// real request nonce) instead of issuing a new request. -func TestReservationReanchorTask_InFlight_Mined_ResumesWithoutNewRequest(t *testing.T) { +// TestReservationReanchorTask_UnminedRequest_MinedBeforeNextRound_Resumes +// pins the other half of the chain-state dispatch: a request that was not +// seen mined within round 1's wait but is mined before round 2 leaves the +// reservation ActionPending with a Pending Reanchor generation, so round 2 +// must resume that generation at its real nonce instead of requesting +// again. +func TestReservationReanchorTask_UnminedRequest_MinedBeforeNextRound_Resumes(t *testing.T) { tbtcChain, _, blockCounter, task, sourceWalletPublicKeyHash, targetWalletPublicKeyHash, reservationKey := newInFlightReanchorFixture(t) @@ -341,23 +224,18 @@ func TestReservationReanchorTask_InFlight_Mined_ResumesWithoutNewRequest(t *test "unconfirmed, got ok=%v proposal=%v", ok, proposal, ) } - submissions := tbtcChain.GetReservationReanchorRequestSubmissions() - if len(submissions) != 1 { - t.Fatalf("round 1: expected exactly 1 submission, got %d", len(submissions)) + if got := len(tbtcChain.GetReservationReanchorRequestSubmissions()); got != 1 { + t.Fatalf("round 1: expected exactly 1 submission, got %d", got) } - // Simulate the submission being mined between rounds: the chain now - // holds the new generation (ActionPending, nonce 1, a Pending - // Reanchor authorizing the target) and the receipt reports Mined. + // The submission is mined between rounds: the chain now holds the + // new generation (ActionPending, nonce 1, a Pending Reanchor + // authorizing the target). seedResumableReanchorGeneration( t, tbtcChain, targetWalletPublicKeyHash, reservationKey, 1, time.Now().Add(24*time.Hour), ) - tbtcChain.SetReservationReanchorRequestReceipt( - submissions[0].TxHash, - tbtc.ReservationReanchorRequestReceiptMined, - ) - blockCounter.SetCurrentBlock(1001) + blockCounter.SetCurrentBlock(1900) proposal, ok, err = task.Run(&tbtc.CoordinationProposalRequest{ WalletPublicKeyHash: sourceWalletPublicKeyHash, @@ -394,109 +272,31 @@ func TestReservationReanchorTask_InFlight_Mined_ResumesWithoutNewRequest(t *test "new submission, got %d submissions", got, ) } - if got := len(task.inFlightReanchorRequests); got != 0 { - t.Fatalf( - "round 2: expected the mined in-flight entry to be "+ - "forgotten once resolved, got %d entries", got, - ) - } } -// TestReservationReanchorTask_InFlight_ReservationLeftWallet_ForgottenOnNextRun -// pins the in-flight reconciliation: a submission still unconfirmed when -// its reservation's custody has since moved away from the source wallet -// (the wallet's reservation list no longer names it) must be forgotten on -// the next Run rather than tracked for the task's lifetime, while a -// concurrently unconfirmed submission for a different wallet must survive -// the same pass untouched. -func TestReservationReanchorTask_InFlight_ReservationLeftWallet_ForgottenOnNextRun(t *testing.T) { - tbtcChain, btcChain, _, task, sourceWalletPublicKeyHash, _, reservationKey := +// TestReservationReanchorTask_UnminedRequest_ReservationLeftWallet pins +// that a reservation whose custody moved away from the source wallet +// between rounds is not requested again: round 2 reads the wallet's +// reservation list from chain state, finds it empty, and issues nothing. +func TestReservationReanchorTask_UnminedRequest_ReservationLeftWallet(t *testing.T) { + tbtcChain, _, blockCounter, task, sourceWalletPublicKeyHash, _, _ := newInFlightReanchorFixture(t) - // A second MovingFunds wallet with its own active reservation, so - // its own in-flight entry can be checked for survival. - secondSourceWalletPublicKeyHash := [20]byte{ - 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, - } - secondReservationKey := big.NewInt(7002) - tbtcChain.SetWallet( - secondSourceWalletPublicKeyHash, - &tbtc.WalletChainData{State: tbtc.StateMovingFunds}, - ) - secondAnchorTxHash, err := bitcoin.NewHashFromString( - "8888888888888888888888888888888888888888888888888888888888888888"[:64], - bitcoin.ReversedByteOrder, - ) - if err != nil { - t.Fatal(err) - } - secondSourceScript, err := bitcoin.PayToWitnessPublicKeyHash( - secondSourceWalletPublicKeyHash, - ) - if err != nil { - t.Fatal(err) - } - btcChain.SetTransaction(secondAnchorTxHash, &bitcoin.Transaction{ - Version: 1, - Outputs: []*bitcoin.TransactionOutput{ - {Value: 1}, - {Value: 200000, PublicKeyScript: secondSourceScript}, - }, - }) - tbtcChain.SetReservation(secondReservationKey, &tbtc.Reservation{ - WalletPublicKeyHash: secondSourceWalletPublicKeyHash, - AnchorUtxo: &bitcoin.UnspentTransactionOutput{ - Outpoint: &bitcoin.TransactionOutpoint{ - TransactionHash: secondAnchorTxHash, - OutputIndex: 1, - }, - Value: 200000, - }, - State: tbtc.ReservationStateActive, - RequestNonce: 0, - }) - tbtcChain.SetWalletReservations( - secondSourceWalletPublicKeyHash, - []*big.Int{secondReservationKey}, - ) - - // Round 1 for both wallets: each submits a request that stays - // unconfirmed (Pending receipt, no generation written), so both are - // tracked in-flight by their transaction hashes. tbtcChain.SetNextReservationReanchorRequestPending() if _, _, err := task.Run(&tbtc.CoordinationProposalRequest{ WalletPublicKeyHash: sourceWalletPublicKeyHash, }); err != nil { - t.Fatalf("round 1 (first wallet): unexpected error: %v", err) - } - if _, ok := task.getReanchorRequestInFlight(reservationKey); !ok { - t.Fatalf( - "round 1: expected the first wallet's unconfirmed request to be " + - "tracked in-flight", - ) - } - - tbtcChain.SetNextReservationReanchorRequestPending() - if _, _, err := task.Run(&tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: secondSourceWalletPublicKeyHash, - }); err != nil { - t.Fatalf("round 1 (second wallet): unexpected error: %v", err) + t.Fatalf("round 1: unexpected error: %v", err) } - if _, ok := task.getReanchorRequestInFlight(secondReservationKey); !ok { - t.Fatalf( - "round 1: expected the second wallet's unconfirmed request to be " + - "tracked in-flight", - ) + if got := len(tbtcChain.GetReservationReanchorRequestSubmissions()); got != 1 { + t.Fatalf("round 1: expected exactly 1 submission, got %d", got) } - // The first wallet's reservation moves custody elsewhere between - // rounds: it no longer appears in the wallet's reservation list. + // The reservation moves custody elsewhere between rounds: it no + // longer appears in the wallet's reservation list. tbtcChain.SetWalletReservations(sourceWalletPublicKeyHash, nil) + blockCounter.SetCurrentBlock(1900) - // Round 2 for the first wallet: the wallet's list is now empty, so - // the departure must be reconciled -- the first wallet's in-flight - // entry is forgotten, while the second wallet's entry (a different - // source wallet, untouched by this pass) survives. proposal, ok, err := task.Run(&tbtc.CoordinationProposalRequest{ WalletPublicKeyHash: sourceWalletPublicKeyHash, }) @@ -511,19 +311,7 @@ func TestReservationReanchorTask_InFlight_ReservationLeftWallet_ForgottenOnNextR proposal, ) } - if _, found := task.getReanchorRequestInFlight(reservationKey); found { - t.Error( - "round 2: expected the departed reservation's in-flight " + - "entry to be forgotten, but it was still tracked", - ) - } - if _, found := task.getReanchorRequestInFlight(secondReservationKey); !found { - t.Error( - "round 2: expected another wallet's in-flight entry to " + - "survive the reconciliation pass", - ) - } - if got := len(tbtcChain.GetReservationReanchorRequestSubmissions()); got != 2 { + if got := len(tbtcChain.GetReservationReanchorRequestSubmissions()); got != 1 { t.Fatalf( "round 2: expected no new re-anchor request submissions, "+ "got %d", diff --git a/pkg/tbtcpg/reservation_reanchor_test.go b/pkg/tbtcpg/reservation_reanchor_test.go index 35c9ea508e..6de652698d 100644 --- a/pkg/tbtcpg/reservation_reanchor_test.go +++ b/pkg/tbtcpg/reservation_reanchor_test.go @@ -708,16 +708,16 @@ func newReservationReanchorLocalChain() *reservationReanchorLocalChain { func (rrlc *reservationReanchorLocalChain) RequestReservationReanchor( reservationKey *big.Int, targetWalletPublicKeyHash [20]byte, -) ([32]byte, error) { +) error { if !rrlc.capRevertConsumed && rrlc.forceCapRevertFor == targetWalletPublicKeyHash { rrlc.capRevertConsumed = true - return [32]byte{}, errors.New("wallet reservations cap exceeded") + return errors.New("wallet reservations cap exceeded") } if !rrlc.amountCapRevertConsumed && rrlc.forceAmountCapRevertFor == targetWalletPublicKeyHash { rrlc.amountCapRevertConsumed = true - return [32]byte{}, errors.New("wallet reserved amount cap exceeded") + return errors.New("wallet reserved amount cap exceeded") } return rrlc.LocalChain.RequestReservationReanchor( reservationKey, @@ -1118,7 +1118,7 @@ func TestReservationReanchorTask_AmountCapFillLeadsToNextCandidate(t *testing.T) }) lc.SetWalletReservations(filledTargetWalletPublicKeyHash, []*big.Int{fillKey}) - if _, err := lc.RequestReservationReanchor( + if err := lc.RequestReservationReanchor( resKey, filledTargetWalletPublicKeyHash, ); err == nil || From 324cd92cfb966b43b3dedf0eb831654c06aee212 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 30 Sep 2026 08:21:47 +0000 Subject: [PATCH 10/31] feat(reservations): expose the re-anchor cooldown on tbtc.Reservation --- pkg/chain/ethereum/tbtc.go | 1 + pkg/chain/ethereum/tbtc_test.go | 2 ++ pkg/tbtc/reservation.go | 4 ++++ 3 files changed, 7 insertions(+) diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index da4effedf3..9a446c9317 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -664,6 +664,7 @@ func convertReservationFromAbiType( RequestNonce: abiReservation.RequestNonce, RetryCredit: abiReservation.RetryCredit, DissolutionEligibleAt: abiReservation.DissolutionEligibleAt, + ReanchorCooldownUntil: abiReservation.ReanchorCooldownUntil, }, nil } diff --git a/pkg/chain/ethereum/tbtc_test.go b/pkg/chain/ethereum/tbtc_test.go index 669d45e4c1..1fb25c780d 100644 --- a/pkg/chain/ethereum/tbtc_test.go +++ b/pkg/chain/ethereum/tbtc_test.go @@ -144,6 +144,7 @@ func TestConvertReservationFromAbiType(t *testing.T) { RetryCredit: true, DissolutionEligibleAt: 777, CumulativeReanchorFee: 888, // must not appear anywhere in the output + ReanchorCooldownUntil: 999, } expected := &tbtc.Reservation{ @@ -165,6 +166,7 @@ func TestConvertReservationFromAbiType(t *testing.T) { RequestNonce: 666, RetryCredit: true, DissolutionEligibleAt: 777, + ReanchorCooldownUntil: 999, } actual, err := convertReservationFromAbiType(abiReservation) diff --git a/pkg/tbtc/reservation.go b/pkg/tbtc/reservation.go index 3944f5e538..b64908f5e8 100644 --- a/pkg/tbtc/reservation.go +++ b/pkg/tbtc/reservation.go @@ -80,6 +80,10 @@ type Reservation struct { // DissolutionEligibleAt is the UNIX timestamp at which the current term // becomes eligible for dissolution. DissolutionEligibleAt uint32 + // ReanchorCooldownUntil is the UNIX timestamp before which a + // permissionless re-anchor request reverts. A re-anchor action timeout + // sets it to the timeout time plus the timed-out action's duration. + ReanchorCooldownUntil uint32 } // ReservationActionType represents the type of a reservation action From b6d7f5fd3b02c86df92a04c15e7f6b3f73429e07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 30 Sep 2026 08:22:10 +0000 Subject: [PATCH 11/31] test(ethereum): cover timeout margin, MovingFunds, caps and fees in validator harness Run the real WalletProposalValidator bytecode at the boundaries only it can check: an action timing out exactly REQUEST_TIMEOUT_SAFETY_MARGIN from now is rejected and one second later is accepted (anchor and re-anchor), a MovingFunds wallet can anchor, a re-anchor target at its reservation cap is accepted and one above it is rejected, and an anchor fee above the action's max fee or naming a wallet other than the action's target is rejected. Times use the harness block timestamp so the boundaries are exact. Also state the real reason the simulated backend is avoided: github.com/fjl/memsize's linkname to runtime.stopTheWorld is rejected by the Go 1.23+ linker. --- .../ethereum/tbtc_validator_harness_test.go | 230 +++++++++++++++++- 1 file changed, 224 insertions(+), 6 deletions(-) diff --git a/pkg/chain/ethereum/tbtc_validator_harness_test.go b/pkg/chain/ethereum/tbtc_validator_harness_test.go index 64f9c2085f..d22f1c3702 100644 --- a/pkg/chain/ethereum/tbtc_validator_harness_test.go +++ b/pkg/chain/ethereum/tbtc_validator_harness_test.go @@ -68,7 +68,8 @@ var _ ethutil.EthereumClient = (*harnessBackend)(nil) // Enum values mirrored from tbtc-v2's Wallets.WalletState, Reservation.Action- // Type, and Reservation.ActionState (see StubBridge.sol's header comment). const ( - walletStateLive uint8 = 1 + walletStateLive uint8 = 1 + walletStateMovingFunds uint8 = 2 actionTypeAcceptance uint8 = 1 actionTypeReanchor uint8 = 3 @@ -76,6 +77,15 @@ const ( actionStatePending uint8 = 1 ) +// requestTimeoutSafetyMarginSeconds is the margin keep-core assumes the +// validator keeps before an action's timeoutAt: tbtcpg gates proposals on +// reservationRequestTimeoutSafetyMarginSeconds, which must equal tbtc-v2's +// WalletProposalValidatorConstants.REQUEST_TIMEOUT_SAFETY_MARGIN. The +// validator accepts only while block.timestamp < timeoutAt - margin, so +// the boundary tests below fail against the real bytecode if the on-chain +// margin ever moves away from this value. +const requestTimeoutSafetyMarginSeconds uint32 = 2 * 60 * 60 + // ----- Stub bridge struct mirrors ----- // // Field names must capitalize only the first letter of the corresponding @@ -208,11 +218,12 @@ const harnessBlockNumber uint64 = 1 // state persists) and exposes it through the keep-common // ethutil.EthereumClient interface. // -// An in-memory EVM over one shared StateDB is used instead of an -// ethclient/simulated node so that the real, unmodified validator -// bytecode runs to completion in the test binary without pulling in the -// simulated node's heavy dependencies, which the test binary cannot link -// on recent Go toolchains. +// An in-memory EVM over one shared StateDB is used instead of +// go-ethereum's ethclient/simulated backend because, at the pinned +// go-ethereum v1.13.15, that backend depends on github.com/fjl/memsize, +// whose //go:linkname reference to runtime.stopTheWorld the Go 1.23+ +// linker rejects ("invalid reference to runtime.stopTheWorld"): a test +// binary importing it does not link with this module's toolchain. // // The chain context is a fixed block number (harnessBlockNumber) and a // block timestamp captured at construction: the validator compares @@ -876,6 +887,120 @@ func TestValidateReservationAnchorProposal(t *testing.T) { ) mustContainError(t, err, "Anchor amount below the reservation minimum") }) + + // anchorSeed holds the inputs the cases below vary; the defaults set + // by validateAnchor describe a proposal the validator accepts. + type anchorSeed struct { + walletState uint8 + actionTarget [20]byte + timeoutAt uint32 + txMaxFee uint64 + anchorTxFee int64 + } + + // validateAnchor seeds a valid acceptance, lets adjust change one + // input, and runs the real validator. now is the harness block + // timestamp the validator compares against, not a fresh time.Now(), + // so boundary values are exact. + validateAnchor := func( + t *testing.T, + adjust func(now uint32, s *anchorSeed), + ) error { + h := newValidatorHarness(t) + now := uint32(h.backend.timestamp) + + s := anchorSeed{ + walletState: walletStateLive, + actionTarget: walletPubKeyHash, + timeoutAt: now + uint32((24 * time.Hour).Seconds()), + txMaxFee: 20_000, + anchorTxFee: 10_000, + } + adjust(now, &s) + + deposit, fundingTx, depositKey := buildFundingDeposit( + t, depositor, walletPubKeyHash, refundPubKeyHash, + now+uint32((60*24*time.Hour).Seconds()), 1_000_000, + ) + + h.setReservationParameters(stubParameters{ReservationVault: vault}) + h.setWallet(walletPubKeyHash, stubWallet{State: s.walletState}) + h.setDeposit(depositKey, stubDepositReq{ + Depositor: depositor, + Amount: 1_000_000, + RevealedAt: now - uint32((8 * time.Hour).Seconds()), + Vault: vault, + }) + h.setReservedDeposit(depositKey, true) + h.setReservationAction(depositKey, 1, stubAction{ + TargetWalletPubKeyHash: s.actionTarget, + RequestedAt: now - 3600, + TimeoutAt: s.timeoutAt, + TxMaxFee: s.txMaxFee, + ActionType: actionTypeAcceptance, + State: actionStatePending, + MinAmount: 100_000, + }) + h.commit() + + proposal := &tbtc.ReservationAnchorProposal{ + DepositFundingTxHash: fundingTx.Hash(), + DepositFundingOutputIndex: 0, + RequestNonce: 1, + AnchorTxFee: big.NewInt(s.anchorTxFee), + } + + return h.tc.ValidateReservationAnchorProposal( + walletPubKeyHash, proposal, depositExtraInfoOf(deposit, fundingTx), + ) + } + + t.Run("action timing out exactly at the safety margin is rejected", func(t *testing.T) { + // The validator's check is strict (block.timestamp < timeoutAt - + // margin), so a proposal exactly at the margin is already too + // late; keep-core must not treat it as signable. + err := validateAnchor(t, func(now uint32, s *anchorSeed) { + s.timeoutAt = now + requestTimeoutSafetyMarginSeconds + }) + mustContainError(t, err, "Acceptance action has timed out") + }) + + t.Run("action timing out one second past the safety margin is accepted", func(t *testing.T) { + err := validateAnchor(t, func(now uint32, s *anchorSeed) { + s.timeoutAt = now + requestTimeoutSafetyMarginSeconds + 1 + }) + if err != nil { + t.Fatalf("unexpected validation error: [%v]", err) + } + }) + + t.Run("wallet in MovingFunds state is accepted", func(t *testing.T) { + // Acceptance must keep working while a wallet moves funds, or + // reserved deposits on a draining wallet could never be anchored. + err := validateAnchor(t, func(now uint32, s *anchorSeed) { + s.walletState = walletStateMovingFunds + }) + if err != nil { + t.Fatalf("unexpected validation error: [%v]", err) + } + }) + + t.Run("fee above the action's snapshotted max fee", func(t *testing.T) { + err := validateAnchor(t, func(now uint32, s *anchorSeed) { + s.anchorTxFee = int64(s.txMaxFee) + 1 + }) + mustContainError(t, err, "Proposed transaction fee is too high") + }) + + t.Run("wallet does not match the authorized action", func(t *testing.T) { + // The proposal's wallet is Live and controls the deposit, but the + // action authorizes another wallet: only the action's target may + // anchor the reservation. + err := validateAnchor(t, func(now uint32, s *anchorSeed) { + s.actionTarget = [20]byte{0xee, 0x01} + }) + mustContainError(t, err, "Wallet does not match the authorized action") + }) } func TestValidateReservationReanchorProposal(t *testing.T) { @@ -972,4 +1097,97 @@ func TestValidateReservationReanchorProposal(t *testing.T) { ) mustContainError(t, err, "Target wallet does not match the authorized action") }) + + // reanchorSeed holds the inputs the cases below vary; the defaults + // set by validateReanchor describe a proposal the validator accepts. + type reanchorSeed struct { + timeoutAt uint32 + maxReservationsPerWallet uint32 + targetReservationsCount uint32 + } + + // validateReanchor seeds a valid re-anchor, lets adjust change one + // input, and runs the real validator. now is the harness block + // timestamp, so boundary values are exact. + validateReanchor := func( + t *testing.T, + adjust func(now uint32, s *reanchorSeed), + ) error { + h := newValidatorHarness(t) + now := uint32(h.backend.timestamp) + + s := reanchorSeed{ + timeoutAt: now + uint32((24 * time.Hour).Seconds()), + maxReservationsPerWallet: 10, + targetReservationsCount: 1, + } + adjust(now, &s) + + h.setReservation(reservationKey, stubResReq{ + WalletPubKeyHash: sourceWalletPubKeyHash, + }) + h.setReservationAction(reservationKey, 1, stubAction{ + TargetWalletPubKeyHash: targetWalletPubKeyHash, + RequestedAt: now - 3600, + TimeoutAt: s.timeoutAt, + TxMaxFee: 10_000, + ActionType: actionTypeReanchor, + State: actionStatePending, + }) + h.setWallet(targetWalletPubKeyHash, stubWallet{State: walletStateLive}) + h.setReservationParameters(stubParameters{ + MaxReservationsPerWallet: s.maxReservationsPerWallet, + }) + h.setWalletReservationsCount( + targetWalletPubKeyHash, s.targetReservationsCount, + ) + h.commit() + + return h.tc.ValidateReservationReanchorProposal( + sourceWalletPubKeyHash, + &tbtc.ReservationReanchorProposal{ + ReservationKey: reservationKey, + RequestNonce: 1, + TargetWalletPublicKeyHash: targetWalletPubKeyHash, + ReanchorTxFee: big.NewInt(5_000), + }, + ) + } + + t.Run("action timing out exactly at the safety margin is rejected", func(t *testing.T) { + // Strict check, as for acceptance: exactly at the margin is too + // late. + err := validateReanchor(t, func(now uint32, s *reanchorSeed) { + s.timeoutAt = now + requestTimeoutSafetyMarginSeconds + }) + mustContainError(t, err, "Re-anchor action has timed out") + }) + + t.Run("action timing out one second past the safety margin is accepted", func(t *testing.T) { + err := validateReanchor(t, func(now uint32, s *reanchorSeed) { + s.timeoutAt = now + requestTimeoutSafetyMarginSeconds + 1 + }) + if err != nil { + t.Fatalf("unexpected validation error: [%v]", err) + } + }) + + t.Run("target wallet count at the cap is accepted", func(t *testing.T) { + // requestReservationReanchor already counted this reservation + // against the target wallet, so at signing time a count equal to + // the cap is the normal state of a full wallet, not an overflow. + err := validateReanchor(t, func(now uint32, s *reanchorSeed) { + s.targetReservationsCount = s.maxReservationsPerWallet + }) + if err != nil { + t.Fatalf("unexpected validation error: [%v]", err) + } + }) + + t.Run("target wallet count above the cap is rejected", func(t *testing.T) { + err := validateReanchor(t, func(now uint32, s *reanchorSeed) { + s.targetReservationsCount = s.maxReservationsPerWallet + 1 + }) + mustContainError(t, err, "Wallet reservations cap exceeded") + }) } From 1a477484e5bdc8ca583f1165f619c740ebcaa055 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 30 Sep 2026 08:25:15 +0000 Subject: [PATCH 12/31] fix(spv): scan reservation events from activation in resumable chunks behind the tip All three reservation loops now share one scan-range policy: the first scan starts at the reservation activation block (the action-timeout watcher no longer uses a 30-day lookback), networks without an activation entry skip the first scan, and every scan runs through fetchPastEventsInChunks and stops a few blocks behind the tip. The chunk helper hands each chunk to a callback so callers keep progress and advance their cursor chunk by chunk; a scan error no longer skips the proving or checking of already-tracked items, and the proof loop runs the re-anchor round even when the acceptance round fails. The stranding startup registration scan is chunked as well. --- .../spv/reservation_action_timeout_watch.go | 200 ++++++------- .../reservation_action_timeout_watch_test.go | 279 ++++++++---------- pkg/maintainer/spv/reservation_event_scan.go | 145 +++++---- .../spv/reservation_event_scan_test.go | 164 ++++++++-- pkg/maintainer/spv/reservation_proof_loop.go | 200 ++++++------- .../spv/reservation_proof_loop_test.go | 267 ++++++++++------- .../spv/reservation_stale_deposit_watch.go | 153 +++++----- .../reservation_stale_deposit_watch_test.go | 4 +- pkg/maintainer/spv/reservation_wiring.go | 207 +++++++------ pkg/maintainer/spv/reservation_wiring_test.go | 59 +++- 10 files changed, 938 insertions(+), 740 deletions(-) diff --git a/pkg/maintainer/spv/reservation_action_timeout_watch.go b/pkg/maintainer/spv/reservation_action_timeout_watch.go index ab0263d1f7..e988f04ec8 100644 --- a/pkg/maintainer/spv/reservation_action_timeout_watch.go +++ b/pkg/maintainer/spv/reservation_action_timeout_watch.go @@ -3,7 +3,6 @@ package spv import ( "context" "fmt" - "math" "math/big" "sort" "time" @@ -139,14 +138,13 @@ type ReservationActionTimeoutWatcher struct { operatorAddress common.Address // activationBlock is the network's reservation activation block - // (tbtc.ReservationsActivationBlock): the first event scan starts - // from max(activationBlock, currentBlock-lookback), so the watcher - // picks up every pending action requested since the feature went - // live without scanning genesis on a long-running deployment. math.MaxUint64 - // is the sentinel for a network without an entry (reservations - // inactive) - the first scan still runs, bounded to the lookback - // window, since the watcher may have other reasons to exist beyond - // reservations. + // (tbtc.ReservationsActivationBlock). The first event scan starts + // here and walks to the tip in chunks (see reservationScanRange), so + // a restart finds every action requested since activation - an + // action that timed out but was never notified still holds + // capacity, however old it is. math.MaxUint64 marks a network + // without an entry: reservations are inactive there, the first scan + // is skipped and later scans cover only new blocks. activationBlock uint64 } @@ -217,52 +215,43 @@ func defaultActionTimeoutNowFn() uint32 { return uint32(time.Now().Unix()) } -// nextScanRange calculates the start and current block numbers for the -// next event scan. Item 1 (restart recovery) is scoped to the SPV proof -// loop and the stale-deposit watcher only; this watcher keeps its -// original bounded 30-day catch-up window on the first pass -// (reservationDefaultLookBackBlocks), narrowed to the network's -// reservation activation block only when that block is both known -// (tbtc.ReservationsActivationBlock did not return math.MaxUint64) and -// inside the lookback window already computed - so a network whose -// activation happened more recently than 30 days ago does not scan -// further back than activation, but a network with no activation entry -// (or one older than the lookback window) is unaffected and keeps the -// original bounded scan. Every later pass starts exactly one block past -// the previous cursor, unchanged. -func (ratw *ReservationActionTimeoutWatcher) nextScanRange( - lastScannedBlock uint64, -) (startBlock uint64, currentBlock uint64, err error) { - blockCounter, err := ratw.spvChain.BlockCounter() - if err != nil { - return 0, 0, fmt.Errorf("failed to get block counter: [%v]", err) - } - - currentBlock, err = blockCounter.CurrentBlock() +// scanActionRequests scans one kind of action-request event from *cursor +// to the confirmed tip (see reservationScanRange), tracks every requested +// generation that is not tracked yet and advances *cursor after each +// chunk. fetch returns the generations requested in one chunk. +func (ratw *ReservationActionTimeoutWatcher) scanActionRequests( + cursor *uint64, + fetch func(startBlock, endBlock uint64) ([]*pendingAction, error), +) error { + startBlock, endBlock, scan, err := nextReservationScanRange( + ratw.spvChain, + *cursor, + ratw.activationBlock, + ) if err != nil { - return 0, 0, fmt.Errorf("failed to get current block: [%v]", err) + return err } - if lastScannedBlock != 0 { - return lastScannedBlock + 1, currentBlock, nil + if !scan { + *cursor = endBlock + return nil } - // First pass: bounded catch-up window. The clamp to the network's - // reservation activation block applies only when the activation is - // known (not math.MaxUint64) and falls at or below the current tip; - // an activation block above the current tip - or an unknown - // activation - leaves the bounded lookback range unchanged, so the - // scan is ordinary and simply finds nothing while reservations have - // not yet activated on this network. - start := uint64(0) - if currentBlock > reservationDefaultLookBackBlocks { - start = currentBlock - reservationDefaultLookBackBlocks - } - if ratw.activationBlock > start && ratw.activationBlock != math.MaxUint64 && - ratw.activationBlock <= currentBlock { - start = ratw.activationBlock - } - return start, currentBlock, nil + return fetchPastEventsInChunks( + fetch, + startBlock, + endBlock, + func(actions []*pendingAction, chunkEnd uint64) { + for _, action := range actions { + key := actionEventKey(action.reservationKey, action.requestNonce) + // An already-tracked generation keeps its notifiedAt. + if _, tracked := ratw.pendingActions[key]; !tracked { + ratw.pendingActions[key] = action + } + } + *cursor = chunkEnd + }, + ) } // Run starts the background poll loop. It returns when ctx is done or when @@ -304,65 +293,55 @@ func (ratw *ReservationActionTimeoutWatcher) Run(ctx context.Context) error { func (ratw *ReservationActionTimeoutWatcher) pollPendingActions() error { ratw.drainStrandingRechecks() - // 1. Scan new ReservationAcceptanceRequestedEvents - acceptanceStartBlock, acceptanceCurrentBlock, err := ratw.nextScanRange( - ratw.acceptanceLastScannedBlock, - ) - if err != nil { - return fmt.Errorf("failed to get acceptance scan range: [%v]", err) - } - - acceptanceEvents, err := ratw.spvChain.PastReservationAcceptanceRequestedEvents( - &tbtc.ReservationAcceptanceRequestedEventFilter{ - StartBlock: acceptanceStartBlock, - EndBlock: &acceptanceCurrentBlock, + // 1. Scan new acceptance and re-anchor action requests. A scan error + // does not skip the timeout checks below: generations found before + // the error are already tracked, and the error is returned at the end + // of the tick. + acceptanceScanErr := ratw.scanActionRequests( + &ratw.acceptanceLastScannedBlock, + func(startBlock, endBlock uint64) ([]*pendingAction, error) { + events, err := ratw.spvChain.PastReservationAcceptanceRequestedEvents( + &tbtc.ReservationAcceptanceRequestedEventFilter{ + StartBlock: startBlock, + EndBlock: &endBlock, + }, + ) + if err != nil { + return nil, err + } + actions := make([]*pendingAction, len(events)) + for i, event := range events { + actions[i] = &pendingAction{ + reservationKey: event.ReservationKey, + requestNonce: event.RequestNonce, + } + } + return actions, nil }, ) - if err != nil { - return fmt.Errorf( - "failed to get past reservation acceptance requested events: [%v]", - err, - ) - } - - for _, event := range acceptanceEvents { - key := actionEventKey(event.ReservationKey, event.RequestNonce) - ratw.pendingActions[key] = &pendingAction{ - reservationKey: event.ReservationKey, - requestNonce: event.RequestNonce, - } - } - ratw.acceptanceLastScannedBlock = acceptanceCurrentBlock - - // 2. Scan new ReservationReanchorRequestedEvents - reanchorStartBlock, reanchorCurrentBlock, err := ratw.nextScanRange( - ratw.reanchorLastScannedBlock, - ) - if err != nil { - return fmt.Errorf("failed to get reanchor scan range: [%v]", err) - } - reanchorEvents, err := ratw.spvChain.PastReservationReanchorRequestedEvents( - &tbtc.ReservationReanchorRequestedEventFilter{ - StartBlock: reanchorStartBlock, - EndBlock: &reanchorCurrentBlock, + reanchorScanErr := ratw.scanActionRequests( + &ratw.reanchorLastScannedBlock, + func(startBlock, endBlock uint64) ([]*pendingAction, error) { + events, err := ratw.spvChain.PastReservationReanchorRequestedEvents( + &tbtc.ReservationReanchorRequestedEventFilter{ + StartBlock: startBlock, + EndBlock: &endBlock, + }, + ) + if err != nil { + return nil, err + } + actions := make([]*pendingAction, len(events)) + for i, event := range events { + actions[i] = &pendingAction{ + reservationKey: event.ReservationKey, + requestNonce: event.RequestNonce, + } + } + return actions, nil }, ) - if err != nil { - return fmt.Errorf( - "failed to get past reservation reanchor requested events: [%v]", - err, - ) - } - - for _, event := range reanchorEvents { - key := actionEventKey(event.ReservationKey, event.RequestNonce) - ratw.pendingActions[key] = &pendingAction{ - reservationKey: event.ReservationKey, - requestNonce: event.RequestNonce, - } - } - ratw.reanchorLastScannedBlock = reanchorCurrentBlock now := ratw.nowFn() @@ -466,6 +445,19 @@ func (ratw *ReservationActionTimeoutWatcher) pollPendingActions() error { } } + if acceptanceScanErr != nil { + return fmt.Errorf( + "failed to scan reservation acceptance requests: [%v]", + acceptanceScanErr, + ) + } + if reanchorScanErr != nil { + return fmt.Errorf( + "failed to scan reservation re-anchor requests: [%v]", + reanchorScanErr, + ) + } + return nil } diff --git a/pkg/maintainer/spv/reservation_action_timeout_watch_test.go b/pkg/maintainer/spv/reservation_action_timeout_watch_test.go index 1326966878..d4da79827e 100644 --- a/pkg/maintainer/spv/reservation_action_timeout_watch_test.go +++ b/pkg/maintainer/spv/reservation_action_timeout_watch_test.go @@ -61,7 +61,7 @@ func TestReservationActionTimeoutWatcher_NotifiesTimedOutPendingAction(t *testin 1, ) - watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), math.MaxUint64) + watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), 0) if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -110,7 +110,7 @@ func TestReservationActionTimeoutWatcher_NotifiesAcceptanceTimeoutViaDedicatedEn 1, ) - watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), math.MaxUint64) + watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), 0) if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -156,7 +156,7 @@ func TestReservationActionTimeoutWatcher_SkipsUnrecognizedActionType(t *testing. 1, ) - watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), math.MaxUint64) + watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), 0) if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -196,7 +196,7 @@ func TestReservationActionTimeoutWatcher_DoesNotNotifyBeforeTimeout(t *testing.T 1, ) - watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), math.MaxUint64) + watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), 0) if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -238,7 +238,7 @@ func TestReservationActionTimeoutWatcher_NotifiesAtExactDeadline(t *testing.T) { 1, ) - watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), math.MaxUint64) + watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), 0) if err := watcher.CheckReservationActionTimeouts(key, 100); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -273,7 +273,7 @@ func TestReservationActionTimeoutWatcher_NotifiesAtExactDeadline(t *testing.T) { 1, ) - watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), math.MaxUint64) + watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), 0) if err := watcher.CheckReservationActionTimeouts(key, 100); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -316,7 +316,7 @@ func TestReservationActionTimeoutWatcher_IgnoresSettledOlderGeneration(t *testin 2, ) - watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), math.MaxUint64) + watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), 0) if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -360,7 +360,7 @@ func TestReservationActionTimeoutWatcher_NotifiesCurrentGenerationOnly(t *testin 2, ) - watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), math.MaxUint64) + watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), 0) if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -384,7 +384,7 @@ func TestReservationActionTimeoutWatcher_SkipsReservationWithoutWallet(t *testin RequestNonce: 0, }) - watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), math.MaxUint64) + watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), 0) if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -422,7 +422,7 @@ func TestReservationActionTimeoutWatcher_ReanchorNotifiesUnconditionally(t *test 1, ) - watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), math.MaxUint64) + watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), 0) if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { t.Fatalf("expected nil error, got: %v", err) } @@ -439,7 +439,7 @@ func TestReservationActionTimeoutWatcher_ReanchorNotifiesUnconditionally(t *test func TestReservationActionTimeoutWatcher_NilKeyError(t *testing.T) { spvChain := newLocalChain() - watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), math.MaxUint64) + watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), 0) if err := watcher.CheckReservationActionTimeouts(nil, 5_000); err == nil { t.Fatal("expected error for nil reservation key, got nil") } @@ -456,7 +456,7 @@ func TestReservationActionTimeoutWatcher_SkipsWalletZeroBranch(t *testing.T) { RequestNonce: 1, }) - watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), math.MaxUint64) + watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), 0) if err := watcher.CheckReservationActionTimeouts(key, 5_000); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -495,7 +495,7 @@ func TestReservationActionTimeoutWatcher_NotifierErrorPropagates(t *testing.T) { 1, ) - watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), math.MaxUint64) + watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), 0) err := watcher.CheckReservationActionTimeouts(key, 5_000) if err == nil { t.Fatal("expected the notifier error to propagate, got nil") @@ -546,7 +546,7 @@ func TestReservationActionTimeoutWatcher_StalePreloadNonceMismatchFallsBackToFre TimeoutAt: 100, } - watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), math.MaxUint64) + watcher := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), 0) notified, err := watcher.checkReservationActionTimeout(key, 5_000, stalePreload, 1) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -576,7 +576,7 @@ func TestReservationActionTimeoutWatcher_PollPendingActions_SkipsNotifiedAtStamp wallet1 := walletPKH() - ratw := NewReservationActionTimeoutWatcher(spvChain, time.Minute, common.Address{}, nil, math.MaxUint64) + ratw := NewReservationActionTimeoutWatcher(spvChain, time.Minute, common.Address{}, nil, 0) ratw.nowFn = func() uint32 { return 500 } key1 := reservationKey(0x3001) @@ -638,7 +638,7 @@ func TestReservationActionTimeoutWatcher_PollPendingActions_RetainsEntryAcrossLo wallet1 := walletPKH() - ratw := NewReservationActionTimeoutWatcher(spvChain, time.Minute, common.Address{}, nil, math.MaxUint64) + ratw := NewReservationActionTimeoutWatcher(spvChain, time.Minute, common.Address{}, nil, 0) // now is comfortably beyond TimeoutAt (100) plus the maximum // possible reservationOperatorStaggerOffset (bounded by // actionTimeoutRenotifyInterval, 600s) so the FIRST-attempt stagger @@ -708,54 +708,6 @@ func TestReservationActionTimeoutWatcher_PollPendingActions_RetainsEntryAcrossLo } } -func TestReservationActionTimeoutWatcher_NextScanRange(t *testing.T) { - spvChain := newLocalChain() - blockCounter := newMockBlockCounter() - spvChain.setBlockCounter(blockCounter) - - watcher := NewReservationActionTimeoutWatcher(spvChain, time.Minute, common.Address{}, nil, math.MaxUint64) - - // Case 1: First scan (lastScannedBlock == 0) and currentBlock > lookback. - blockCounter.SetCurrentBlock(300_000) - startBlock, currentBlock, err := watcher.nextScanRange(0) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - expectedStart := uint64(300_000) - reservationDefaultLookBackBlocks - if startBlock != expectedStart { - t.Errorf("expected start block %d, got %d", expectedStart, startBlock) - } - if currentBlock != 300_000 { - t.Errorf("expected current block 300000, got %d", currentBlock) - } - - // Case 2: First scan (lastScannedBlock == 0) and currentBlock <= lookback. - blockCounter.SetCurrentBlock(100_000) - startBlock, currentBlock, err = watcher.nextScanRange(0) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if startBlock != 0 { - t.Errorf("expected start block 0, got %d", startBlock) - } - if currentBlock != 100_000 { - t.Errorf("expected current block 100000, got %d", currentBlock) - } - - // Case 3: Subsequent scan (lastScannedBlock > 0). - blockCounter.SetCurrentBlock(500_000) - startBlock, currentBlock, err = watcher.nextScanRange(450_000) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if startBlock != 450_001 { - t.Errorf("expected start block 450001, got %d", startBlock) - } - if currentBlock != 500_000 { - t.Errorf("expected current block 500000, got %d", currentBlock) - } -} - func TestReservationActionTimeoutWatcher_RunLoop_IncrementalTracking(t *testing.T) { spvChain := newLocalChain() blockCounter := newMockBlockCounter() @@ -765,7 +717,7 @@ func TestReservationActionTimeoutWatcher_RunLoop_IncrementalTracking(t *testing. wallet1 := walletPKH() pollInterval := 10 * time.Millisecond - ratw := NewReservationActionTimeoutWatcher(spvChain, pollInterval, common.Address{}, nil, math.MaxUint64) + ratw := NewReservationActionTimeoutWatcher(spvChain, pollInterval, common.Address{}, nil, 0) // now is comfortably beyond every seeded TimeoutAt plus the maximum // possible reservationOperatorStaggerOffset (bounded by // actionTimeoutRenotifyInterval, 600s) so the FIRST-attempt stagger @@ -888,7 +840,7 @@ func TestReservationActionTimeoutWatcher_RunLoop_DoesNotRenotifyWhilePending(t * wallet1 := walletPKH() pollInterval := 10 * time.Millisecond - ratw := NewReservationActionTimeoutWatcher(spvChain, pollInterval, common.Address{}, nil, math.MaxUint64) + ratw := NewReservationActionTimeoutWatcher(spvChain, pollInterval, common.Address{}, nil, 0) // now is comfortably beyond TimeoutAt (100) plus the maximum // possible reservationOperatorStaggerOffset (see the identical // comment in RunLoop_IncrementalTracking above). @@ -990,7 +942,7 @@ func TestReservationActionTimeoutWatcher_RunLoop_RenotifiesAfterBackoffWindow(t wallet1 := walletPKH() pollInterval := 10 * time.Millisecond - ratw := NewReservationActionTimeoutWatcher(spvChain, pollInterval, common.Address{}, nil, math.MaxUint64) + ratw := NewReservationActionTimeoutWatcher(spvChain, pollInterval, common.Address{}, nil, 0) // initialNow is comfortably beyond TimeoutAt (100) plus the maximum // possible reservationOperatorStaggerOffset (bounded by @@ -1074,97 +1026,106 @@ func TestReservationActionTimeoutWatcher_RunLoop_RenotifiesAfterBackoffWindow(t } } -func TestReservationActionTimeoutWatcher_RunLoop_BoundedFirstScan(t *testing.T) { - spvChain := newLocalChain() - blockCounter := newMockBlockCounter() - // Set current block high enough that lookback applies. - currentBlock := uint64(500_000) - blockCounter.SetCurrentBlock(currentBlock) - spvChain.setBlockCounter(blockCounter) - - wallet1 := walletPKH() - - pollInterval := 10 * time.Millisecond - ratw := NewReservationActionTimeoutWatcher(spvChain, pollInterval, common.Address{}, nil, math.MaxUint64) - // now is comfortably beyond every seeded TimeoutAt plus the maximum - // possible reservationOperatorStaggerOffset (see the identical - // comment in RunLoop_IncrementalTracking above). - ratw.nowFn = func() uint32 { return 900 } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() +// TestReservationActionTimeoutWatcher_FirstScanStartsAtActivation verifies +// that the first action-request scan starts at the reservation activation +// block rather than a fixed lookback window: an action requested long +// before the last 30 days that timed out without being notified still +// holds capacity, so the watcher must find and notify it. Requests before +// activation are not scanned, and on a network with no activation entry +// the first scan is skipped entirely. +func TestReservationActionTimeoutWatcher_FirstScanStartsAtActivation(t *testing.T) { + const ( + currentBlock = uint64(500_000) + activationBlock = uint64(50_000) + ) - // Event 1 is old: block 100,000 (before startBlock = 500,000 - 216,000 = 284,000). - oldKey := reservationKey(0x9001) - spvChain.addReservationAcceptanceRequestedEvent(&tbtc.ReservationAcceptanceRequestedEvent{ - ReservationKey: oldKey, - RequestNonce: 1, - WalletPublicKeyHash: wallet1, - BlockNumber: 100_000, - }) - seededReservation( - t, - spvChain, - oldKey, - wallet1, - []*tbtc.ReservationAction{ - { - ActionType: tbtc.ReservationActionTypeReanchor, - State: tbtc.ReservationActionStatePending, - TimeoutAt: 100, + seed := func(spvChain *localChain, key *big.Int, blockNumber uint64) { + spvChain.addReservationReanchorRequestedEvent(&tbtc.ReservationReanchorRequestedEvent{ + ReservationKey: key, + RequestNonce: 1, + SourceWalletPublicKeyHash: walletPKH(), + BlockNumber: blockNumber, + }) + seededReservation( + t, + spvChain, + key, + walletPKH(), + []*tbtc.ReservationAction{ + { + ActionType: tbtc.ReservationActionTypeReanchor, + State: tbtc.ReservationActionStatePending, + TimeoutAt: 100, + }, }, - }, - 1, - ) + 1, + ) + } - // Event 2 is within lookback: block 300,000. + preActivationKey := reservationKey(0x9000) + oldKey := reservationKey(0x9001) recentKey := reservationKey(0x9002) - spvChain.addReservationAcceptanceRequestedEvent(&tbtc.ReservationAcceptanceRequestedEvent{ - ReservationKey: recentKey, - RequestNonce: 1, - WalletPublicKeyHash: wallet1, - BlockNumber: 300_000, - }) - seededReservation( - t, - spvChain, - recentKey, - wallet1, - []*tbtc.ReservationAction{ - { - ActionType: tbtc.ReservationActionTypeReanchor, - State: tbtc.ReservationActionStatePending, - TimeoutAt: 100, - }, - }, - 1, - ) - errChan := make(chan error, 1) - go func() { - errChan <- ratw.Run(ctx) - }() + newWatcher := func(activation uint64) (*localChain, *ReservationActionTimeoutWatcher) { + spvChain := newLocalChain() + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(currentBlock) + spvChain.setBlockCounter(blockCounter) - time.Sleep(50 * time.Millisecond) - cancel() - if err := <-errChan; err != nil { - t.Errorf("Run returned error: %v", err) - } + // Older than activation, older than the 30-day window, recent. + seed(spvChain, preActivationKey, activationBlock-10_000) + seed(spvChain, oldKey, currentBlock-reservationDefaultLookBackBlocks-100_000) + seed(spvChain, recentKey, 300_000) - // Only recentKey should have been discovered and notified. - calls := spvChain.getSubmittedReservationActionTimeouts() - if len(calls) != 1 { - t.Fatalf("expected exactly 1 notification (recent event only), got %d", len(calls)) - } - if diff := deep.Equal(recentKey, calls[0].reservationKey); diff != nil { - t.Errorf("unexpected notified key: %v", diff) + ratw := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, nil, activation) + // now is comfortably beyond TimeoutAt plus the maximum + // reservationOperatorStaggerOffset. + ratw.nowFn = func() uint32 { return 900 } + return spvChain, ratw } - // oldKey should not be tracked in pendingActions. - oldEventKey := actionEventKey(oldKey, 1) - if _, ok := ratw.pendingActions[oldEventKey]; ok { - t.Errorf("oldKey should not have been discovered by bounded initial scan") - } + t.Run("known activation", func(t *testing.T) { + spvChain, ratw := newWatcher(activationBlock) + + if err := ratw.pollPendingActions(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + notified := make(map[string]bool) + for _, call := range spvChain.getSubmittedReservationActionTimeouts() { + notified[call.reservationKey.String()] = true + } + if !notified[oldKey.String()] { + t.Error("expected the action requested before the 30-day window to be notified") + } + if !notified[recentKey.String()] { + t.Error("expected the recent action to be notified") + } + if notified[preActivationKey.String()] { + t.Error("expected the pre-activation request not to be scanned") + } + if expected := currentBlock - reservationEventScanConfirmationBlocks; ratw.reanchorLastScannedBlock != expected { + t.Errorf("expected the cursor at %d, got %d", expected, ratw.reanchorLastScannedBlock) + } + }) + + t.Run("unknown network", func(t *testing.T) { + spvChain, ratw := newWatcher(math.MaxUint64) + + if err := ratw.pollPendingActions(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if calls := spvChain.getSubmittedReservationActionTimeouts(); len(calls) != 0 { + t.Errorf("expected no first scan on an unknown network, got %d notifications", len(calls)) + } + if len(ratw.pendingActions) != 0 { + t.Errorf("expected nothing tracked, got %d", len(ratw.pendingActions)) + } + if expected := currentBlock - reservationEventScanConfirmationBlocks; ratw.reanchorLastScannedBlock != expected { + t.Errorf("expected the cursor to move to %d, got %d", expected, ratw.reanchorLastScannedBlock) + } + }) } // TestReservationActionTimeoutWatcher_RecheckStrandingAfterActionTimeout_NotifiesWhenWalletClosed @@ -1201,7 +1162,7 @@ func TestReservationActionTimeoutWatcher_RecheckStrandingAfterActionTimeout_Noti TimeoutAt: 100, }) ratw := NewReservationActionTimeoutWatcher( - spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), math.MaxUint64, + spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), 0, ) if err := ratw.CheckReservationActionTimeouts(key, 5_000); err != nil { @@ -1254,7 +1215,7 @@ func TestReservationActionTimeoutWatcher_RecheckStrandingAfterActionTimeout_Skip }) ratw := NewReservationActionTimeoutWatcher( - spvChain, 0, common.Address{}, nil, math.MaxUint64, + spvChain, 0, common.Address{}, nil, 0, ) if err := ratw.CheckReservationActionTimeouts(key, 5_000); err != nil { @@ -1299,7 +1260,7 @@ func TestReservationActionTimeoutWatcher_RecheckStrandingAfterActionTimeout_NilW TimeoutAt: 100, }) - ratw := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), math.MaxUint64) + ratw := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), 0) if err := ratw.CheckReservationActionTimeouts(key, 5_000); err != nil { t.Fatalf("unexpected error: %v", err) @@ -1343,7 +1304,7 @@ func TestReservationActionTimeoutWatcher_PollPendingActions_BoundsRPCVolumePerTi wrapped := &actionCallCountingChain{Chain: spvChain} - ratw := NewReservationActionTimeoutWatcher(wrapped, 0, common.Address{}, nil, math.MaxUint64) + ratw := NewReservationActionTimeoutWatcher(wrapped, 0, common.Address{}, nil, 0) // now is comfortably beyond every seeded TimeoutAt plus the maximum // possible reservationOperatorStaggerOffset for every one of the // tracked keys (see the identical comment in @@ -1406,7 +1367,7 @@ func TestReservationActionTimeoutWatcher_PollPendingActions_BoundsRPCVolumePerTi // reservationActionTimeoutMaxChecksPerTick and, via its rotating cursor, // covers every tracked action within ceil(tracked/cap) ticks. func TestReservationActionTimeoutWatcher_NextActionCheckBatch_CapsAndRotates(t *testing.T) { - ratw := NewReservationActionTimeoutWatcher(newLocalChain(), 0, common.Address{}, nil, math.MaxUint64) + ratw := NewReservationActionTimeoutWatcher(newLocalChain(), 0, common.Address{}, nil, 0) total := reservationActionTimeoutMaxChecksPerTick + 10 for i := range total { @@ -1458,7 +1419,7 @@ func TestReservationActionTimeoutWatcher_NextActionCheckBatch_CapsAndRotates(t * // beginning - preserving the pre-fix single-pass behavior for the common // case where the cap never actually binds. func TestReservationActionTimeoutWatcher_NextActionCheckBatch_NoCapNeeded(t *testing.T) { - ratw := NewReservationActionTimeoutWatcher(newLocalChain(), 0, common.Address{}, nil, math.MaxUint64) + ratw := NewReservationActionTimeoutWatcher(newLocalChain(), 0, common.Address{}, nil, 0) for i := range 5 { key := reservationKey(uint64(0xF100 + i)) @@ -1499,7 +1460,7 @@ func TestReservationActionTimeoutWatcher_PollPendingActions_RenotifyBackoffSurvi wallet := walletPKH() key := reservationKey(0x3010) - ratw := NewReservationActionTimeoutWatcher(spvChain, time.Minute, common.Address{}, nil, math.MaxUint64) + ratw := NewReservationActionTimeoutWatcher(spvChain, time.Minute, common.Address{}, nil, 0) seededReservation( t, @@ -1557,7 +1518,7 @@ func TestReservationActionTimeoutWatcher_DrainStrandingRechecks_RequeuesOnGetWal // No spvChain.setWallet call for this wallet: GetWallet returns "no // wallet for given PKH", simulating a transient RPC failure. ratw := NewReservationActionTimeoutWatcher( - spvChain, 0, common.Address{}, nil, math.MaxUint64, + spvChain, 0, common.Address{}, nil, 0, ) ratw.strandingRecheckWallets[wallet] = struct{}{} @@ -1585,7 +1546,7 @@ func TestReservationActionTimeoutWatcher_DrainStrandingRechecks_RequeuesOnStrand strandingWatcher := newReservationStrandingWatcher(spvChain) strandingWatcher.retryDelay = time.Millisecond - ratw := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), math.MaxUint64) + ratw := NewReservationActionTimeoutWatcher(spvChain, 0, common.Address{}, newReservationStrandingWatcher(spvChain), 0) ratw.strandingRecheckWallets[wallet] = struct{}{} ratw.drainStrandingRechecks() @@ -1608,7 +1569,7 @@ func TestReservationActionTimeoutWatcher_DrainStrandingRechecks_SucceedsDoesNotR spvChain.setWallet(wallet, &tbtc.WalletChainData{State: tbtc.StateLive}) ratw := NewReservationActionTimeoutWatcher( - spvChain, 0, common.Address{}, nil, math.MaxUint64, + spvChain, 0, common.Address{}, nil, 0, ) ratw.strandingRecheckWallets[wallet] = struct{}{} diff --git a/pkg/maintainer/spv/reservation_event_scan.go b/pkg/maintainer/spv/reservation_event_scan.go index 32a1bd5ad7..4ae78adb6f 100644 --- a/pkg/maintainer/spv/reservation_event_scan.go +++ b/pkg/maintainer/spv/reservation_event_scan.go @@ -6,91 +6,130 @@ import ( ) // reservationEventScanChunkSize bounds the inclusive block range of a -// single Past*Events call in the reservation startup catch-up scans. The -// startup scan can stretch from the network's reservation activation -// block to the current tip, which on a long-running network is millions -// of blocks; a single getLogs across that span is not usable. 10,000 -// blocks keeps every individual call within the eth_getLogs range cap -// enforced by many common third-party RPC providers, so a provider with -// that cap still makes progress chunk by chunk instead of the whole -// walk stalling on the first rejected range (an aborted chunk discards -// the earlier chunks' progress). +// single Past*Events call in the reservation event scans. The first scan +// can stretch from the network's reservation activation block to the +// current tip, which on a long-running network is millions of blocks; a +// single getLogs across that span is not usable. 10,000 blocks keeps +// every individual call within the eth_getLogs range cap enforced by +// many common third-party RPC providers, so a provider with that cap +// still makes progress chunk by chunk. // -// Steady-state incremental scans (cursor + 1 to currentBlock) are far -// smaller than this chunk size and continue to issue a single call. +// Steady-state incremental scans (cursor + 1 to the confirmed tip) are +// far smaller than this chunk size and issue a single call. const reservationEventScanChunkSize uint64 = 10000 -// reservationStartupScanStartBlock returns the first block the -// reservation startup catch-up scan covers, together with a flag -// indicating whether a scan is warranted at all. -// -// activationBlock is the network's reservation activation block height -// (see tbtc.ReservationsActivationBlock). When it equals math.MaxUint64 -// the network has no reservations-activation entry and the feature is -// inactive for that network; the startup scan is skipped entirely - no -// reservation events can exist when reservations are inactive, and -// silently scanning an arbitrary window of unrelated history would just -// burn RPC quota. +// reservationEventScanConfirmationBlocks is how far behind the current +// chain tip every reservation event scan stops. A scan cursor never +// moves past a block that could still be replaced by a short reorg, so +// an event that only appears in a replacement block is still picked up +// by a later scan. Every reservation event starts a deadline measured in +// hours or days, so discovering it a dozen blocks late costs nothing. +const reservationEventScanConfirmationBlocks uint64 = 12 + +// reservationScanRange returns the inclusive block range +// [startBlock, endBlock] the next reservation event scan covers, given +// the scan's cursor (the last block already scanned, 0 before the first +// scan), the network's reservation activation block (see +// tbtc.ReservationsActivationBlock) and the current chain tip. // -// Otherwise the scan starts at activationBlock. If activation is still -// in the future, startBlock is greater than the current tip; the chunk -// helper treats that inverted range as a no-op. Keeping the future -// activation block intact also prevents callers from scanning -// pre-activation history. +// endBlock trails currentBlock by reservationEventScanConfirmationBlocks. +// The first scan starts at activationBlock, so a process started long +// after activation still finds every event emitted since then; later +// scans start one block past the cursor. When activation is still ahead +// of endBlock the range is inverted, fetchPastEventsInChunks treats it +// as a no-op and the cursor stays 0, so the next scan starts at +// activation again. // -// The boolean is false only for an inactive network, where the caller -// should not attempt a scan at all. -func reservationStartupScanStartBlock(activationBlock uint64) (uint64, bool) { +// scan is false only for the first scan on a network with no activation +// entry (math.MaxUint64): reservations are inactive there, so no +// catch-up scan runs and the caller should move its cursor straight to +// endBlock. Later scans on such a network cover only new blocks. +func reservationScanRange( + lastScannedBlock uint64, + activationBlock uint64, + currentBlock uint64, +) (startBlock uint64, endBlock uint64, scan bool) { + if currentBlock > reservationEventScanConfirmationBlocks { + endBlock = currentBlock - reservationEventScanConfirmationBlocks + } + + if lastScannedBlock != 0 { + return lastScannedBlock + 1, endBlock, true + } + if activationBlock == math.MaxUint64 { - return 0, false + return 0, endBlock, false } - return activationBlock, true + + return activationBlock, endBlock, true +} + +// nextReservationScanRange reads the current block from spvChain and +// returns reservationScanRange for the given cursor and activation block. +func nextReservationScanRange( + spvChain Chain, + lastScannedBlock uint64, + activationBlock uint64, +) (startBlock uint64, endBlock uint64, scan bool, err error) { + blockCounter, err := spvChain.BlockCounter() + if err != nil { + return 0, 0, false, fmt.Errorf("failed to get block counter: [%v]", err) + } + + currentBlock, err := blockCounter.CurrentBlock() + if err != nil { + return 0, 0, false, fmt.Errorf("failed to get current block: [%v]", err) + } + + startBlock, endBlock, scan = reservationScanRange( + lastScannedBlock, + activationBlock, + currentBlock, + ) + return startBlock, endBlock, scan, nil } // fetchPastEventsInChunks walks the inclusive block range // [startBlock, endBlock] in chunks of at most -// reservationEventScanChunkSize blocks, calling fetch for each chunk -// and concatenating the returned events in ascending block order. A -// fetch error stops the walk and is returned wrapped with the chunk -// range that triggered it. An inverted range (startBlock > endBlock) -// is a no-op and yields a nil slice without calling fetch, matching -// the conventions of the Past*Events methods on the spv chain -// implementations and giving callers a single conditional-free call -// pattern: compute the range, hand it to this helper, done. +// reservationEventScanChunkSize blocks, in ascending order. After each +// chunk is fetched it calls onChunk with that chunk's events and last +// block, so the caller can merge the events and advance its cursor +// chunk by chunk: a fetch error stops the walk, but the chunks before it +// are kept and the next scan resumes after the last one delivered. The +// error is returned wrapped with the failing chunk range. An inverted +// range (startBlock > endBlock) is a no-op that calls neither fetch nor +// onChunk. // -// The generic parameter T lets a single implementation serve all three -// reservation Past*Events fetches (acceptance requested, reanchor -// requested, deposit revealed) without per-type boilerplate. The -// caller builds a closure that constructs the event-type-specific -// filter, so the helper does not need to know the filter shape. +// The caller builds a fetch closure that constructs the +// event-type-specific filter, so one implementation serves every +// reservation Past*Events call. func fetchPastEventsInChunks[T any]( fetch func(startBlock, endBlock uint64) ([]T, error), startBlock, endBlock uint64, -) ([]T, error) { + onChunk func(events []T, chunkEnd uint64), +) error { if startBlock > endBlock { - return nil, nil + return nil } - var all []T for chunkStart := startBlock; ; { chunkEnd := chunkStart + reservationEventScanChunkSize - 1 - if chunkEnd > endBlock { + if chunkEnd > endBlock || chunkEnd < chunkStart { chunkEnd = endBlock } chunk, err := fetch(chunkStart, chunkEnd) if err != nil { - return nil, fmt.Errorf( + return fmt.Errorf( "fetching past events in chunk [%d..%d]: [%w]", chunkStart, chunkEnd, err, ) } - all = append(all, chunk...) + onChunk(chunk, chunkEnd) if chunkEnd == endBlock { - break + return nil } chunkStart = chunkEnd + 1 } - return all, nil } diff --git a/pkg/maintainer/spv/reservation_event_scan_test.go b/pkg/maintainer/spv/reservation_event_scan_test.go index 0f3e4e6e9a..a82945aac0 100644 --- a/pkg/maintainer/spv/reservation_event_scan_test.go +++ b/pkg/maintainer/spv/reservation_event_scan_test.go @@ -3,6 +3,7 @@ package spv import ( "errors" "fmt" + "math" "strings" "testing" ) @@ -115,6 +116,27 @@ func assertEachEventReturnedOnce( } } +// collectChunks runs fetchPastEventsInChunks against f and returns every +// event delivered to onChunk together with the chunkEnd of each delivery, +// in delivery order. +func collectChunks( + f *rangeRecordingFetcher, + startBlock, endBlock uint64, +) ([]chunkTestEvent, []uint64, error) { + var events []chunkTestEvent + var chunkEnds []uint64 + err := fetchPastEventsInChunks( + f.fetch, + startBlock, + endBlock, + func(chunk []chunkTestEvent, chunkEnd uint64) { + events = append(events, chunk...) + chunkEnds = append(chunkEnds, chunkEnd) + }, + ) + return events, chunkEnds, err +} + // TestFetchPastEventsInChunks_ChunkBoundaries drives a range spanning three // chunks and asserts that events sitting on the chunk boundaries - the last // block of chunk 1, the first block of chunk 2, and the later chunk 2/3 @@ -140,7 +162,7 @@ func TestFetchPastEventsInChunks_ChunkBoundaries(t *testing.T) { }, } - got, err := fetchPastEventsInChunks(f.fetch, 0, endBlock) + got, chunkEnds, err := collectChunks(f, 0, endBlock) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -167,6 +189,14 @@ func TestFetchPastEventsInChunks_ChunkBoundaries(t *testing.T) { expectedRanges[i].end, ) } + if chunkEnds[i] != expectedRanges[i].end { + t.Errorf( + "chunk %d delivered chunkEnd %d, expected %d", + i, + chunkEnds[i], + expectedRanges[i].end, + ) + } } } @@ -181,11 +211,7 @@ func TestFetchPastEventsInChunks_SingleChunk(t *testing.T) { }, } - got, err := fetchPastEventsInChunks( - f.fetch, - 0, - reservationEventScanChunkSize-1, - ) + got, _, err := collectChunks(f, 0, reservationEventScanChunkSize-1) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -201,18 +227,19 @@ func TestFetchPastEventsInChunks_SingleChunk(t *testing.T) { } // TestFetchPastEventsInChunks_InvertedRange verifies that an inverted range -// is a no-op: no fetch is issued and an empty result is returned. +// is a no-op: neither fetch nor onChunk is called, so a caller's cursor +// does not move. func TestFetchPastEventsInChunks_InvertedRange(t *testing.T) { f := &rangeRecordingFetcher{ events: []chunkTestEvent{{BlockNumber: 4}}, } - got, err := fetchPastEventsInChunks(f.fetch, 10, 5) + got, chunkEnds, err := collectChunks(f, 10, 5) if err != nil { t.Fatalf("unexpected error: %v", err) } - if got != nil { - t.Errorf("expected no events for an inverted range, got %v", got) + if got != nil || chunkEnds != nil { + t.Errorf("expected no deliveries for an inverted range, got %v / %v", got, chunkEnds) } if len(f.ranges) != 0 { t.Errorf("expected no chunk fetches for an inverted range, got %v", f.ranges) @@ -221,7 +248,9 @@ func TestFetchPastEventsInChunks_InvertedRange(t *testing.T) { // TestFetchPastEventsInChunks_ErrorInSecondChunk verifies that an error on // the second chunk is returned, wrapped with the range that failed, and -// that no partial result is returned. +// that the first chunk's events were already delivered, so a caller keeps +// that progress and resumes after the first chunk instead of restarting +// the whole walk. func TestFetchPastEventsInChunks_ErrorInSecondChunk(t *testing.T) { f := &rangeRecordingFetcher{ events: []chunkTestEvent{ @@ -232,16 +261,17 @@ func TestFetchPastEventsInChunks_ErrorInSecondChunk(t *testing.T) { failErr: errors.New("provider rejected the range"), } - got, err := fetchPastEventsInChunks( - f.fetch, - 0, - 2*reservationEventScanChunkSize-1, - ) + got, chunkEnds, err := collectChunks(f, 0, 2*reservationEventScanChunkSize-1) if err == nil { t.Fatal("expected an error from the second chunk, got nil") } - if got != nil { - t.Errorf("expected no partial result, got %v events", len(got)) + assertEachEventReturnedOnce(t, got, []chunkTestEvent{{BlockNumber: 100}}) + if len(chunkEnds) != 1 || chunkEnds[0] != reservationEventScanChunkSize-1 { + t.Errorf( + "expected only the first chunk [..%d] to be delivered, got %v", + reservationEventScanChunkSize-1, + chunkEnds, + ) } expectedRange := fmt.Sprintf( "[%d..%d]", @@ -265,3 +295,101 @@ func TestFetchPastEventsInChunks_ErrorInSecondChunk(t *testing.T) { 2*reservationEventScanChunkSize-1, ) } + +// TestReservationScanRange covers the scan-range policy shared by the proof +// loop, the stale-deposit watcher and the action-timeout watcher. The +// first scan starts at the activation block wherever it sits relative to +// the tip - an old activation is not cut off by any lookback window, +// because an action that timed out but was never notified still holds +// capacity - an unknown network skips the first scan, and every range +// stops reservationEventScanConfirmationBlocks behind the tip. +func TestReservationScanRange(t *testing.T) { + const lag = reservationEventScanConfirmationBlocks + + tests := map[string]struct { + lastScannedBlock uint64 + activationBlock uint64 + currentBlock uint64 + expectedStart uint64 + expectedEnd uint64 + expectedScan bool + }{ + "first scan, activation at genesis (Developer)": { + activationBlock: 0, + currentBlock: 1000, + expectedStart: 0, + expectedEnd: 1000 - lag, + expectedScan: true, + }, + "first scan, activation inside the last 30 days": { + activationBlock: 250_000, + currentBlock: 300_000, + expectedStart: 250_000, + expectedEnd: 300_000 - lag, + expectedScan: true, + }, + "first scan, activation older than 30 days": { + activationBlock: 1_000, + currentBlock: 1_000_000, + expectedStart: 1_000, + expectedEnd: 1_000_000 - lag, + expectedScan: true, + }, + "first scan, activation above the tip": { + activationBlock: 500_000, + currentBlock: 300_000, + expectedStart: 500_000, + expectedEnd: 300_000 - lag, + expectedScan: true, + }, + "first scan, unknown network": { + activationBlock: math.MaxUint64, + currentBlock: 300_000, + expectedStart: 0, + expectedEnd: 300_000 - lag, + expectedScan: false, + }, + "later scan starts one block past the cursor": { + lastScannedBlock: 450_000, + activationBlock: 0, + currentBlock: 500_000, + expectedStart: 450_001, + expectedEnd: 500_000 - lag, + expectedScan: true, + }, + "later scan on an unknown network covers new blocks": { + lastScannedBlock: 450_000, + activationBlock: math.MaxUint64, + currentBlock: 500_000, + expectedStart: 450_001, + expectedEnd: 500_000 - lag, + expectedScan: true, + }, + "tip within the confirmation depth": { + activationBlock: 0, + currentBlock: lag, + expectedStart: 0, + expectedEnd: 0, + expectedScan: true, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + start, end, scan := reservationScanRange( + test.lastScannedBlock, + test.activationBlock, + test.currentBlock, + ) + if scan != test.expectedScan { + t.Errorf("unexpected scan: expected %v, got %v", test.expectedScan, scan) + } + if start != test.expectedStart { + t.Errorf("unexpected start: expected %d, got %d", test.expectedStart, start) + } + if end != test.expectedEnd { + t.Errorf("unexpected end: expected %d, got %d", test.expectedEnd, end) + } + }) + } +} diff --git a/pkg/maintainer/spv/reservation_proof_loop.go b/pkg/maintainer/spv/reservation_proof_loop.go index 1d50f1038b..56c5241860 100644 --- a/pkg/maintainer/spv/reservation_proof_loop.go +++ b/pkg/maintainer/spv/reservation_proof_loop.go @@ -200,47 +200,6 @@ func reservationEventKey(reservationKey *big.Int, requestNonce uint64) string { return fmt.Sprintf("%s:%d", reservationKey.String(), requestNonce) } -// reservationProofNextScanRange returns the block range to scan for new -// pending-action-request events this pass. On the very first pass -// (lastScannedBlock == 0) the range starts at the network's reservation -// activation block (see reservationStartupScanStartBlock) so a process -// restart that happens long after reservations activated still picks up -// every action generation requested since activation, instead of being -// bounded by a fixed 30-day lookback. Steady-state passes (lastScannedBlock -// > 0) scan only the delta since the previous cursor, so no full-history -// refetch happens on every config.IdleBackoffTime tick. -// -// The first pass is a no-op when activationBlock == math.MaxUint64 - the -// network has no reservations-activation entry and the feature is -// inactive for it - in which case startBlock is returned as 0 with no -// currentBlock advance so the caller skips the scan and immediately -// stores currentBlock as the new cursor. -func reservationProofNextScanRange( - spvChain Chain, - lastScannedBlock uint64, - activationBlock uint64, -) (startBlock uint64, currentBlock uint64, skipScan bool, err error) { - blockCounter, err := spvChain.BlockCounter() - if err != nil { - return 0, 0, false, fmt.Errorf("failed to get block counter: [%v]", err) - } - - currentBlock, err = blockCounter.CurrentBlock() - if err != nil { - return 0, 0, false, fmt.Errorf("failed to get current block: [%v]", err) - } - - if lastScannedBlock == 0 { - start, active := reservationStartupScanStartBlock(activationBlock) - if !active { - return 0, currentBlock, true, nil - } - return start, currentBlock, false, nil - } - - return lastScannedBlock + 1, currentBlock, false, nil -} - // maintainReservationProofs runs the SPV proof submission loop for // reservation acceptance and re-anchor action generations. It is a // dedicated loop, separate from spvMaintainer's generic proofTypes-driven @@ -331,7 +290,10 @@ func runReservationProofLoop( // One cache per pass, shared by the acceptance and re-anchor proof // rounds below; see proofInfoCache in spv.go. cache := newProofInfoCache() - if err := proveReservationAcceptanceActions( + // The re-anchor round runs even when the acceptance round fails, + // so one failing event scan does not hold back the other kind of + // proof; the first error is returned once both rounds are done. + acceptanceErr := proveReservationAcceptanceActions( state, config, spvChain, @@ -339,14 +301,9 @@ func runReservationProofLoop( btcChain, cache, metricsRecorder, - ); err != nil { - return fmt.Errorf( - "error while proving reservation acceptance actions: [%v]", - err, - ) - } + ) - if err := proveReservationReanchorActions( + reanchorErr := proveReservationReanchorActions( state, config, spvChain, @@ -354,12 +311,7 @@ func runReservationProofLoop( btcChain, cache, metricsRecorder, - ); err != nil { - return fmt.Errorf( - "error while proving reservation re-anchor actions: [%v]", - err, - ) - } + ) // Evict cache entries for wallets with no remaining pending // acceptance or re-anchor actions now that both proof-generation @@ -367,6 +319,19 @@ func runReservationProofLoop( // see evictStaleWalletTransactionCacheEntries. evictStaleWalletTransactionCacheEntries(state) + if acceptanceErr != nil { + return fmt.Errorf( + "error while proving reservation acceptance actions: [%v]", + acceptanceErr, + ) + } + if reanchorErr != nil { + return fmt.Errorf( + "error while proving reservation re-anchor actions: [%v]", + reanchorErr, + ) + } + select { case <-time.After(config.IdleBackoffTime): case <-ctx.Done(): @@ -389,7 +354,7 @@ func proveReservationAcceptanceActions( cache *proofInfoCache, metricsRecorder MetricsRecorder, ) error { - startBlock, currentBlock, skipScan, err := reservationProofNextScanRange( + startBlock, endBlock, scan, err := nextReservationScanRange( spvChain, state.acceptanceLastScannedBlock, tbtc.ReservationsActivationBlock(config.EthereumNetwork), @@ -398,34 +363,38 @@ func proveReservationAcceptanceActions( return err } - if skipScan { - state.acceptanceLastScannedBlock = currentBlock - return nil - } - - newEvents, err := fetchPastEventsInChunks[*tbtc.ReservationAcceptanceRequestedEvent]( - func(chunkStart, chunkEnd uint64) ([]*tbtc.ReservationAcceptanceRequestedEvent, error) { - return spvChain.PastReservationAcceptanceRequestedEvents( - &tbtc.ReservationAcceptanceRequestedEventFilter{ - StartBlock: chunkStart, - EndBlock: &chunkEnd, - }, + // A scan error does not stop this pass: the chunks fetched before it + // are already tracked, and every tracked generation is still proved + // below. The error is returned once the pass is done. + var scanErr error + if scan { + if err := fetchPastEventsInChunks( + func(chunkStart, chunkEnd uint64) ([]*tbtc.ReservationAcceptanceRequestedEvent, error) { + return spvChain.PastReservationAcceptanceRequestedEvents( + &tbtc.ReservationAcceptanceRequestedEventFilter{ + StartBlock: chunkStart, + EndBlock: &chunkEnd, + }, + ) + }, + startBlock, + endBlock, + func(events []*tbtc.ReservationAcceptanceRequestedEvent, chunkEnd uint64) { + for _, event := range events { + key := reservationEventKey(event.ReservationKey, event.RequestNonce) + state.pendingAcceptanceEvents[key] = event + } + state.acceptanceLastScannedBlock = chunkEnd + }, + ); err != nil { + scanErr = fmt.Errorf( + "failed to get past reservation acceptance requested "+ + "events: [%v]", + err, ) - }, - startBlock, - currentBlock, - ) - if err != nil { - return fmt.Errorf( - "failed to get past reservation acceptance requested "+ - "events: [%v]", - err, - ) - } - - for _, event := range newEvents { - key := reservationEventKey(event.ReservationKey, event.RequestNonce) - state.pendingAcceptanceEvents[key] = event + } + } else { + state.acceptanceLastScannedBlock = endBlock } // Re-check every tracked event's on-chain action state, evict the @@ -549,9 +518,7 @@ func proveReservationAcceptanceActions( } } - state.acceptanceLastScannedBlock = currentBlock - - return nil + return scanErr } func isMatchingReservationAcceptanceTransaction( @@ -677,7 +644,7 @@ func proveReservationReanchorActions( cache *proofInfoCache, metricsRecorder MetricsRecorder, ) error { - startBlock, currentBlock, skipScan, err := reservationProofNextScanRange( + startBlock, endBlock, scan, err := nextReservationScanRange( spvChain, state.reanchorLastScannedBlock, tbtc.ReservationsActivationBlock(config.EthereumNetwork), @@ -686,33 +653,36 @@ func proveReservationReanchorActions( return err } - if skipScan { - state.reanchorLastScannedBlock = currentBlock - return nil - } - - newEvents, err := fetchPastEventsInChunks[*tbtc.ReservationReanchorRequestedEvent]( - func(chunkStart, chunkEnd uint64) ([]*tbtc.ReservationReanchorRequestedEvent, error) { - return spvChain.PastReservationReanchorRequestedEvents( - &tbtc.ReservationReanchorRequestedEventFilter{ - StartBlock: chunkStart, - EndBlock: &chunkEnd, - }, + // See proveReservationAcceptanceActions: a scan error is returned + // after the already-tracked generations have been proved. + var scanErr error + if scan { + if err := fetchPastEventsInChunks( + func(chunkStart, chunkEnd uint64) ([]*tbtc.ReservationReanchorRequestedEvent, error) { + return spvChain.PastReservationReanchorRequestedEvents( + &tbtc.ReservationReanchorRequestedEventFilter{ + StartBlock: chunkStart, + EndBlock: &chunkEnd, + }, + ) + }, + startBlock, + endBlock, + func(events []*tbtc.ReservationReanchorRequestedEvent, chunkEnd uint64) { + for _, event := range events { + key := reservationEventKey(event.ReservationKey, event.RequestNonce) + state.pendingReanchorEvents[key] = event + } + state.reanchorLastScannedBlock = chunkEnd + }, + ); err != nil { + scanErr = fmt.Errorf( + "failed to get past reservation re-anchor requested events: [%v]", + err, ) - }, - startBlock, - currentBlock, - ) - if err != nil { - return fmt.Errorf( - "failed to get past reservation re-anchor requested events: [%v]", - err, - ) - } - - for _, event := range newEvents { - key := reservationEventKey(event.ReservationKey, event.RequestNonce) - state.pendingReanchorEvents[key] = event + } + } else { + state.reanchorLastScannedBlock = endBlock } // Re-check every tracked event's on-chain action state, evict the @@ -851,9 +821,7 @@ func proveReservationReanchorActions( } } - state.reanchorLastScannedBlock = currentBlock - - return nil + return scanErr } func isMatchingReservationReanchorTransaction( diff --git a/pkg/maintainer/spv/reservation_proof_loop_test.go b/pkg/maintainer/spv/reservation_proof_loop_test.go index 068bf0be5e..dce3e7ad37 100644 --- a/pkg/maintainer/spv/reservation_proof_loop_test.go +++ b/pkg/maintainer/spv/reservation_proof_loop_test.go @@ -67,104 +67,6 @@ func (c *reservationProofBitcoinChain) GetTxHashesForPublicKeyHash( return hashes, nil } -// TestReservationProofNextScanRange covers the incremental scan-range -// arithmetic for the proof loop's activation-block-aware catch-up scan. -// The first pass (lastScannedBlock == 0) now starts at the network's -// reservation activation block (see tbtc.ReservationsActivationBlock) -// instead of a fixed 30-day lookback. An activation block of -// math.MaxUint64 (unknown / inactive network) makes the first scan a -// no-op: the cursor jumps to the chain tip and no events are fetched. -// Steady-state passes (lastScannedBlock > 0) start exactly one block -// after the previous cursor and are unchanged. -func TestReservationProofNextScanRange(t *testing.T) { - tests := map[string]struct { - activationBlock uint64 - currentBlock uint64 - lastScannedBlock uint64 - expectedStart uint64 - expectedSkipScan bool - }{ - // Developer network (activation at block 0) - first scan from 0. - "first pass, Developer network, current block below lookback": { - activationBlock: 0, - currentBlock: 1000, - lastScannedBlock: 0, - expectedStart: 0, - expectedSkipScan: false, - }, - "first pass, Developer network, current block beyond lookback": { - activationBlock: 0, - currentBlock: reservationDefaultLookBackBlocks + 500, - lastScannedBlock: 0, - expectedStart: 0, - expectedSkipScan: false, - }, - // Unknown network (activation = MaxUint64) - first scan skipped. - "first pass, unknown network": { - activationBlock: math.MaxUint64, - currentBlock: 300_000, - lastScannedBlock: 0, - expectedStart: 300_000 + 1, - expectedSkipScan: true, - }, - // Public network with activation ahead of current tip. - "first pass, activation in future": { - activationBlock: 500_000, - currentBlock: 300_000, - lastScannedBlock: 0, - expectedStart: 500_000, - expectedSkipScan: false, - }, - // Steady-state passes are unchanged. - "later pass starts one block after the cursor": { - activationBlock: 0, - currentBlock: reservationDefaultLookBackBlocks * 3, - lastScannedBlock: reservationDefaultLookBackBlocks * 2, - expectedStart: reservationDefaultLookBackBlocks*2 + 1, - expectedSkipScan: false, - }, - } - - for testName, test := range tests { - t.Run(testName, func(t *testing.T) { - spvChain := newLocalChain() - blockCounter := newMockBlockCounter() - blockCounter.SetCurrentBlock(test.currentBlock) - spvChain.setBlockCounter(blockCounter) - - startBlock, currentBlock, skipScan, err := reservationProofNextScanRange( - spvChain, - test.lastScannedBlock, - test.activationBlock, - ) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if skipScan != test.expectedSkipScan { - t.Errorf( - "unexpected skipScan\nexpected: %v\nactual: %v", - test.expectedSkipScan, - skipScan, - ) - } - if !test.expectedSkipScan && startBlock != test.expectedStart { - t.Errorf( - "unexpected start block\nexpected: %v\nactual: %v", - test.expectedStart, - startBlock, - ) - } - if currentBlock != test.currentBlock { - t.Errorf( - "unexpected current block\nexpected: %v\nactual: %v", - test.currentBlock, - currentBlock, - ) - } - }) - } -} - // TestFindReservationAcceptanceTransaction verifies the acceptance // transaction matcher: it must find the 1-input-1-output transaction whose // sole input spends the deposit UTXO identified by event.ReservationKey (via @@ -1290,8 +1192,8 @@ func TestProveReservationAcceptanceActions_LeavesPendingOnChainError(t *testing. } } - if scanState.acceptanceLastScannedBlock != 1000 { - t.Fatalf("expected cursor to advance to current block 1000, got %d", scanState.acceptanceLastScannedBlock) + if scanState.acceptanceLastScannedBlock != 1000-reservationEventScanConfirmationBlocks { + t.Fatalf("expected cursor to advance to confirmed tip %d, got %d", 1000-reservationEventScanConfirmationBlocks, scanState.acceptanceLastScannedBlock) } } @@ -1343,8 +1245,8 @@ func TestProveReservationReanchorActions_LeavesPendingOnChainError(t *testing.T) } } - if scanState.reanchorLastScannedBlock != 1000 { - t.Fatalf("expected cursor to advance to current block 1000, got %d", scanState.reanchorLastScannedBlock) + if scanState.reanchorLastScannedBlock != 1000-reservationEventScanConfirmationBlocks { + t.Fatalf("expected cursor to advance to confirmed tip %d, got %d", 1000-reservationEventScanConfirmationBlocks, scanState.reanchorLastScannedBlock) } } @@ -2531,10 +2433,163 @@ func TestRunReservationProofLoop_ResumesScanAfterRestart(t *testing.T) { chain.reanchorScanCalls, ) } - if state.acceptanceLastScannedBlock != 1000 { - t.Errorf("expected the acceptance cursor to survive the restart at 1000, got %d", state.acceptanceLastScannedBlock) + if state.acceptanceLastScannedBlock != 1000-reservationEventScanConfirmationBlocks { + t.Errorf("expected the acceptance cursor to survive the restart at %d, got %d", 1000-reservationEventScanConfirmationBlocks, state.acceptanceLastScannedBlock) + } + if state.reanchorLastScannedBlock != 1000-reservationEventScanConfirmationBlocks { + t.Errorf("expected the re-anchor cursor to advance to %d on the restart, got %d", 1000-reservationEventScanConfirmationBlocks, state.reanchorLastScannedBlock) } - if state.reanchorLastScannedBlock != 1000 { - t.Errorf("expected the re-anchor cursor to advance to 1000 on the restart, got %d", state.reanchorLastScannedBlock) +} + +// reservationChunkFailingChain wraps localChain and fails the acceptance +// event fetch for any chunk starting at failChunkStart, so a test can +// drive a scan that breaks part-way through the activation-to-tip walk. +type reservationChunkFailingChain struct { + *localChain + + failChunkStart *uint64 +} + +func (c *reservationChunkFailingChain) PastReservationAcceptanceRequestedEvents( + filter *tbtc.ReservationAcceptanceRequestedEventFilter, +) ([]*tbtc.ReservationAcceptanceRequestedEvent, error) { + if c.failChunkStart != nil && filter.StartBlock == *c.failChunkStart { + return nil, errors.New("simulated provider failure") } + return c.localChain.PastReservationAcceptanceRequestedEvents(filter) +} + +// TestProveReservationAcceptanceActions_ChunkedScan verifies the proof +// loop's event scan across chunk boundaries: every chunk of the +// activation-to-tip walk is scanned, a failing chunk keeps the progress of +// the chunks before it (the cursor stops at the last good chunk and the +// next pass resumes after it), and the cursor stays +// reservationEventScanConfirmationBlocks behind the tip, so an event that +// only shows up later at a height the first pass could have covered - as +// after a short reorg - is still found by the next pass. +func TestProveReservationAcceptanceActions_ChunkedScan(t *testing.T) { + const currentBlock = 2*reservationEventScanChunkSize + 500 + confirmedTip := uint64(currentBlock) - reservationEventScanConfirmationBlocks + + newFixture := func() (*localChain, *reservationChunkFailingChain, *reservationProofScanState, Config) { + inner := newLocalChain() + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(currentBlock) + inner.setBlockCounter(blockCounter) + return inner, + &reservationChunkFailingChain{localChain: inner}, + newReservationProofScanState(), + Config{ + TransactionLimit: 100, + MaxProofHeaders: DefaultMaxProofHeaders, + EthereumNetwork: ethereum.Developer, + } + } + + // addEvent tracks a Pending acceptance generation requested at + // blockNumber; its wallet has no transactions, so nothing is proved + // and the generation simply stays tracked. + addEvent := func(inner *localChain, key int64, blockNumber uint64) string { + reservationKey := big.NewInt(key) + inner.addReservationAcceptanceRequestedEvent(&tbtc.ReservationAcceptanceRequestedEvent{ + ReservationKey: reservationKey, + RequestNonce: 1, + WalletPublicKeyHash: [20]byte{byte(key)}, + BlockNumber: blockNumber, + }) + inner.setReservationAction(reservationKey, 1, &tbtc.ReservationAction{ + State: tbtc.ReservationActionStatePending, + ActionType: tbtc.ReservationActionTypeAcceptance, + }) + return reservationEventKey(reservationKey, 1) + } + + prove := func(chain *reservationChunkFailingChain, state *reservationProofScanState, config Config) error { + return proveReservationAcceptanceActions( + state, + config, + chain, + chain, + newReservationProofBitcoinChain(), + newProofInfoCache(), + nil, + ) + } + + t.Run("every chunk is scanned", func(t *testing.T) { + inner, chain, state, config := newFixture() + firstChunkKey := addEvent(inner, 1, 100) + secondChunkKey := addEvent(inner, 2, reservationEventScanChunkSize+100) + + if err := prove(chain, state, config); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + for _, key := range []string{firstChunkKey, secondChunkKey} { + if _, ok := state.pendingAcceptanceEvents[key]; !ok { + t.Errorf("expected event [%s] to be tracked", key) + } + } + if state.acceptanceLastScannedBlock != confirmedTip { + t.Errorf("expected the cursor at %d, got %d", confirmedTip, state.acceptanceLastScannedBlock) + } + }) + + t.Run("a failing chunk keeps earlier progress", func(t *testing.T) { + inner, chain, state, config := newFixture() + firstChunkKey := addEvent(inner, 1, 100) + secondChunkKey := addEvent(inner, 2, reservationEventScanChunkSize+100) + + failStart := reservationEventScanChunkSize + chain.failChunkStart = &failStart + if err := prove(chain, state, config); err == nil { + t.Fatal("expected the failing chunk to be reported") + } + if _, ok := state.pendingAcceptanceEvents[firstChunkKey]; !ok { + t.Error("expected the first chunk's event to be tracked despite the later failure") + } + if _, ok := state.pendingAcceptanceEvents[secondChunkKey]; ok { + t.Error("expected the failed chunk's event not to be tracked yet") + } + if state.acceptanceLastScannedBlock != reservationEventScanChunkSize-1 { + t.Errorf( + "expected the cursor at the end of the first chunk %d, got %d", + reservationEventScanChunkSize-1, + state.acceptanceLastScannedBlock, + ) + } + + chain.failChunkStart = nil + if err := prove(chain, state, config); err != nil { + t.Fatalf("unexpected error on the resumed pass: %v", err) + } + if _, ok := state.pendingAcceptanceEvents[secondChunkKey]; !ok { + t.Error("expected the resumed pass to find the second chunk's event") + } + if state.acceptanceLastScannedBlock != confirmedTip { + t.Errorf("expected the cursor at %d, got %d", confirmedTip, state.acceptanceLastScannedBlock) + } + }) + + t.Run("the cursor trails the tip", func(t *testing.T) { + inner, chain, state, config := newFixture() + + if err := prove(chain, state, config); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // An event at a height the first pass could have reached if it + // had scanned to the tip, first visible only now. + lateKey := addEvent(inner, 3, uint64(currentBlock)-reservationEventScanConfirmationBlocks/2) + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(currentBlock + reservationEventScanConfirmationBlocks) + inner.setBlockCounter(blockCounter) + + if err := prove(chain, state, config); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := state.pendingAcceptanceEvents[lateKey]; !ok { + t.Error("expected the event below the old tip to be found by the next pass") + } + }) } diff --git a/pkg/maintainer/spv/reservation_stale_deposit_watch.go b/pkg/maintainer/spv/reservation_stale_deposit_watch.go index 7de06a8fed..5269c1ff68 100644 --- a/pkg/maintainer/spv/reservation_stale_deposit_watch.go +++ b/pkg/maintainer/spv/reservation_stale_deposit_watch.go @@ -5,13 +5,13 @@ import ( "encoding/binary" "errors" "fmt" - "math" "math/big" "strings" "time" "github.com/ethereum/go-ethereum/common" + "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/tbtc" ) @@ -649,42 +649,42 @@ func (rsdw *ReservationStaleDepositWatcher) pollTick(now uint32) (int, bool) { return rsdw.trackedCount(), false } - // queryStartBlock is the inclusive lower bound of the next reveal - // scan. On the very first tick (lastSeenBlock == 0) it is the - // network's reservation activation block, so a process restart - // after activation picks up every reveal since the feature went - // live instead of the fixed 30-day lookback. Networks without an - // activation entry (math.MaxUint64) skip the catch-up scan - // entirely: reservations are inactive, so there are no reveals - // to find, and the cursor jumps to the head. Steady-state ticks - // resume at lastSeenBlock+1, unchanged from the original - // incremental scan. - var queryStartBlock uint64 - switch rsdw.lastSeenBlock { - case 0: - switch { - case rsdw.activationBlock == math.MaxUint64, - rsdw.activationBlock > currentBlock: - // Skip the catch-up window entirely (cursor on the - // next tick starts from this head). Returning early here - // (before the ReservationParameters fetch) keeps the - // tick fast on networks where reservations are not - // active; pending is empty so the Check loop below is a - // no-op anyway. - rsdw.lastSeenBlock = currentBlock - return len(rsdw.pending), true - default: - queryStartBlock = rsdw.activationBlock - } - default: - queryStartBlock = rsdw.lastSeenBlock + 1 - } - - // Chunk the reveal-event scan so a wide catch-up window - // (activation block to chain tip) does not become a single huge - // PastDepositRevealedEvents query. Steady-state deltas are small - // enough to fall into a single chunk. - events, err := fetchPastEventsInChunks[*tbtc.DepositRevealedEvent]( + // The reveal scan follows reservationScanRange: the first tick + // starts at the network's reservation activation block and later + // ticks resume one block past lastSeenBlock, always stopping a few + // blocks behind the tip. + startBlock, endBlock, scan := reservationScanRange( + rsdw.lastSeenBlock, + rsdw.activationBlock, + currentBlock, + ) + if !scan { + // Reservations are inactive on this network, so there are no + // reveals to catch up on. Returning before the + // ReservationParameters fetch keeps the tick cheap; pending is + // empty on the first tick, so there is nothing to check. + rsdw.lastSeenBlock = endBlock + return len(rsdw.pending), true + } + if startBlock > endBlock && len(rsdw.pending) == 0 { + // No new confirmed blocks (or activation is still ahead) and + // nothing tracked: nothing to do this tick. + return 0, true + } + + // A failed parameter read or reveal scan does not skip the checks + // below: deposits tracked so far, including those from chunks + // fetched before the error, are still checked. + scanOK := true + params, err := rsdw.spvChain.ReservationParameters() + if err != nil { + reservationWiringLogger.Errorf( + "stale-deposit poll failed to fetch reservation "+ + "parameters: [%v]", + err, + ) + scanOK = false + } else if err := fetchPastEventsInChunks( func(chunkStart, chunkEnd uint64) ([]*tbtc.DepositRevealedEvent, error) { return rsdw.spvChain.PastDepositRevealedEvents( &tbtc.DepositRevealedEventFilter{ @@ -693,56 +693,21 @@ func (rsdw *ReservationStaleDepositWatcher) pollTick(now uint32) (int, bool) { }, ) }, - queryStartBlock, - currentBlock, - ) - if err != nil { + startBlock, + endBlock, + func(events []*tbtc.DepositRevealedEvent, chunkEnd uint64) { + rsdw.trackRevealedDeposits(events, params.ReservationVault) + rsdw.lastSeenBlock = chunkEnd + }, + ); err != nil { reservationWiringLogger.Errorf( "stale-deposit poll failed to fetch deposit revealed "+ "events: [%v]", err, ) - return rsdw.trackedCount(), false - } - - params, err := rsdw.spvChain.ReservationParameters() - if err != nil { - reservationWiringLogger.Errorf( - "stale-deposit poll failed to fetch reservation "+ - "parameters: [%v]", - err, - ) - return rsdw.trackedCount(), false - } - - for _, event := range events { - if event.Vault == nil || !strings.EqualFold(string(*event.Vault), string(params.ReservationVault)) { - continue - } - - depositKey := rsdw.spvChain.BuildDepositKey( - event.FundingTxHash, - event.FundingOutputIndex, - ) - - // Track every vault-matched reveal in memory only: the vault - // match (ReservationParameters().ReservationVault, fetched - // once per tick) is the discovery gate, and the refund - // deadline is memoized from the reveal event in hand, so a - // tracked deposit performs no per-deposit chain reads until - // its deadline passes - by which point CheckStaleReservedDeposit - // reads the record's wallet field first. Doing any read here - // would re-introduce the per-deposit RPC the deadline-aware - // scheduling was meant to eliminate. - - rsdw.pending[depositKey.String()] = depositKey - rsdw.refundDeadlineMemo[depositKey.String()] = reverseUint32( - event.RefundLocktime, - ) + scanOK = false } - rsdw.lastSeenBlock = currentBlock - for key, depositKey := range rsdw.pending { resolution, err := rsdw.CheckStaleReservedDeposit(depositKey, now) if err != nil { @@ -767,7 +732,33 @@ func (rsdw *ReservationStaleDepositWatcher) pollTick(now uint32) (int, bool) { } } - return len(rsdw.pending), true + return len(rsdw.pending), scanOK +} + +// trackRevealedDeposits adds every reveal routed to reservationVault to +// the tracked set, together with its refund deadline decoded from the +// reveal event. Tracking is in memory only: a tracked deposit performs no +// per-deposit chain reads until its deadline passes, by which point +// CheckStaleReservedDeposit reads the record's wallet field first. +func (rsdw *ReservationStaleDepositWatcher) trackRevealedDeposits( + events []*tbtc.DepositRevealedEvent, + reservationVault chain.Address, +) { + for _, event := range events { + if event.Vault == nil || !strings.EqualFold(string(*event.Vault), string(reservationVault)) { + continue + } + + depositKey := rsdw.spvChain.BuildDepositKey( + event.FundingTxHash, + event.FundingOutputIndex, + ) + + rsdw.pending[depositKey.String()] = depositKey + rsdw.refundDeadlineMemo[depositKey.String()] = reverseUint32( + event.RefundLocktime, + ) + } } // Run starts the background poll loop, mirroring diff --git a/pkg/maintainer/spv/reservation_stale_deposit_watch_test.go b/pkg/maintainer/spv/reservation_stale_deposit_watch_test.go index 62e8f2f717..263385b9a1 100644 --- a/pkg/maintainer/spv/reservation_stale_deposit_watch_test.go +++ b/pkg/maintainer/spv/reservation_stale_deposit_watch_test.go @@ -873,7 +873,7 @@ func TestRunStaleDepositPollTick_TracksVaultMatchWithoutImmediateWalletRead( fundingTxHash := bitcoin.Hash{0x05} fundingOutputIndex := uint32(0) - endBlock := currentBlock + endBlock := currentBlock - reservationEventScanConfirmationBlocks if err := inner.addPastDepositRevealedEvent( &tbtc.DepositRevealedEventFilter{StartBlock: 0, EndBlock: &endBlock}, &tbtc.DepositRevealedEvent{ @@ -983,7 +983,7 @@ func TestRunStaleDepositPollTick_PendingReservedDepositNotifiedAfterDeadline( fundingTxHash := bitcoin.Hash{0x05} fundingOutputIndex := uint32(1) - endBlock := currentBlock + endBlock := currentBlock - reservationEventScanConfirmationBlocks if err := inner.addPastDepositRevealedEvent( &tbtc.DepositRevealedEventFilter{StartBlock: 0, EndBlock: &endBlock}, &tbtc.DepositRevealedEvent{ diff --git a/pkg/maintainer/spv/reservation_wiring.go b/pkg/maintainer/spv/reservation_wiring.go index 86faeb8ba1..3baf53f99e 100644 --- a/pkg/maintainer/spv/reservation_wiring.go +++ b/pkg/maintainer/spv/reservation_wiring.go @@ -266,46 +266,22 @@ func WireReservationWatchers( // OnWalletClosed subscription only sees events from this point forward. // We scan past wallet registrations bounded by // reservationDefaultLookBackBlocks and check the ones already - // Closed/Terminated now. This is an accepted operational + // Closed/Terminated now. The window is not tied to the reservation + // activation block: a wallet registered before activation can still + // custody reservations. This is an accepted operational // limitation, not a gap covered elsewhere: a wallet that closed or // was terminated more than this bound before the process started // has no path to stranding notification (see the warning logged // below when the bound actually truncates the scan window). // Transient per-wallet errors log warnings rather than failing // client startup. - strandingStartupStartBlock := uint64(0) - if blockCounter, bcErr := spvChain.BlockCounter(); bcErr != nil { - reservationWiringLogger.Warnf( - "stranding startup scan failed to get block counter; "+ - "scanning full history: [%v]", - bcErr, - ) - } else if currentBlock, cbErr := blockCounter.CurrentBlock(); cbErr != nil { - reservationWiringLogger.Warnf( - "stranding startup scan failed to get current block; "+ - "scanning full history: [%v]", - cbErr, - ) - } else if currentBlock > reservationDefaultLookBackBlocks { - strandingStartupStartBlock = currentBlock - reservationDefaultLookBackBlocks - reservationWiringLogger.Warnf( - "stranding startup scan is bounded to wallets registered at "+ - "block [%d] or later; a wallet registered and already "+ - "closed/terminated before that block will not be caught "+ - "by this scan, nor by the live OnWalletClosed "+ - "subscription, which cannot replay an already-emitted "+ - "close event - this is an accepted operational "+ - "limitation, not a gap covered elsewhere", - strandingStartupStartBlock, - ) - } - - registeredEvents, err := spvChain.PastNewWalletRegisteredEvents( - &tbtc.NewWalletRegisteredEventFilter{StartBlock: strandingStartupStartBlock}, - ) + registeredEvents, err := scanReservationStrandingStartupRegistrations(spvChain) if err != nil { reservationWiringLogger.Warnf( - "stranding startup scan failed to fetch wallet registration events: [%v]", + "stranding startup scan failed to fetch wallet registration "+ + "events; checking the [%d] registrations fetched before "+ + "the failure: [%v]", + len(registeredEvents), err, ) } @@ -321,64 +297,62 @@ func WireReservationWatchers( // event. var unresolvedWallets [][20]byte - if err == nil { - for _, event := range registeredEvents { - var wallet *tbtc.WalletChainData - var walletErr error - for attempt := range 3 { - wallet, walletErr = spvChain.GetWallet(event.WalletPublicKeyHash) - if walletErr == nil { - break - } - reservationWiringLogger.Warnf( - "stranding startup scan attempt %d/3 failed to fetch wallet [0x%x]: [%v]", - attempt+1, - event.WalletPublicKeyHash, - walletErr, - ) - } - if walletErr != nil { - reservationWiringLogger.Warnf( - "stranding startup scan giving up on wallet [0x%x] after "+ - "3 fetch attempts; queuing it for a second-chance "+ - "retry pass once wiring completes instead of "+ - "assuming it is Live: [%v]", - event.WalletPublicKeyHash, - walletErr, - ) - unresolvedWallets = append(unresolvedWallets, event.WalletPublicKeyHash) - continue - } - if wallet.State != tbtc.StateClosed && - wallet.State != tbtc.StateTerminated { - continue - } - var checkErr error - for attempt := range 3 { - checkErr = strandingWatcher.checkReservationStrandingForWallet( - event.WalletPublicKeyHash, - ) - if checkErr == nil { - break - } - reservationWiringLogger.Warnf( - "stranding startup scan attempt %d/3 failed to check "+ - "wallet [0x%x]: [%v]", - attempt+1, - event.WalletPublicKeyHash, - checkErr, - ) + for _, event := range registeredEvents { + var wallet *tbtc.WalletChainData + var walletErr error + for attempt := range 3 { + wallet, walletErr = spvChain.GetWallet(event.WalletPublicKeyHash) + if walletErr == nil { + break } - if checkErr != nil { - reservationWiringLogger.Warnf( - "stranding startup scan giving up on wallet [0x%x] "+ - "after 3 check attempts; its stranded reservations, "+ - "if any, will not be notified by this startup scan: [%v]", - event.WalletPublicKeyHash, - checkErr, - ) - continue + reservationWiringLogger.Warnf( + "stranding startup scan attempt %d/3 failed to fetch wallet [0x%x]: [%v]", + attempt+1, + event.WalletPublicKeyHash, + walletErr, + ) + } + if walletErr != nil { + reservationWiringLogger.Warnf( + "stranding startup scan giving up on wallet [0x%x] after "+ + "3 fetch attempts; queuing it for a second-chance "+ + "retry pass once wiring completes instead of "+ + "assuming it is Live: [%v]", + event.WalletPublicKeyHash, + walletErr, + ) + unresolvedWallets = append(unresolvedWallets, event.WalletPublicKeyHash) + continue + } + if wallet.State != tbtc.StateClosed && + wallet.State != tbtc.StateTerminated { + continue + } + var checkErr error + for attempt := range 3 { + checkErr = strandingWatcher.checkReservationStrandingForWallet( + event.WalletPublicKeyHash, + ) + if checkErr == nil { + break } + reservationWiringLogger.Warnf( + "stranding startup scan attempt %d/3 failed to check "+ + "wallet [0x%x]: [%v]", + attempt+1, + event.WalletPublicKeyHash, + checkErr, + ) + } + if checkErr != nil { + reservationWiringLogger.Warnf( + "stranding startup scan giving up on wallet [0x%x] "+ + "after 3 check attempts; its stranded reservations, "+ + "if any, will not be notified by this startup scan: [%v]", + event.WalletPublicKeyHash, + checkErr, + ) + continue } } @@ -386,10 +360,8 @@ func WireReservationWatchers( // and the action-timeout watcher (first action-request scan) share a // single lookup. math.MaxUint64 is the sentinel from // tbtc.ReservationsActivationBlock for networks without an entry - // (incl. ethereum.Unknown): the watchers treat it as "reservations - // never activate" and either skip the catch-up scan (stale-deposit - // watcher) or fall back to the bounded lookback window - // (action-timeout watcher). + // (incl. ethereum.Unknown): both watchers skip their first catch-up + // scan there (see reservationScanRange). activationBlock := tbtc.ReservationsActivationBlock(ethNetwork) staleDepositWatcher := NewReservationStaleDepositWatcher( @@ -630,6 +602,57 @@ func reservationSelfCheckMisconfigured( registration.count == 0 && staleDeposit.count == 0 && actionTimeout.count == 0 } +// scanReservationStrandingStartupRegistrations fetches the wallet +// registrations of the last reservationDefaultLookBackBlocks blocks for +// the stranding startup scan, in chunks (see fetchPastEventsInChunks). On +// a chunk error it returns the registrations fetched before the failing +// chunk together with the error. +func scanReservationStrandingStartupRegistrations( + spvChain Chain, +) ([]*tbtc.NewWalletRegisteredEvent, error) { + blockCounter, err := spvChain.BlockCounter() + if err != nil { + return nil, fmt.Errorf("failed to get block counter: [%v]", err) + } + currentBlock, err := blockCounter.CurrentBlock() + if err != nil { + return nil, fmt.Errorf("failed to get current block: [%v]", err) + } + + startBlock := uint64(0) + if currentBlock > reservationDefaultLookBackBlocks { + startBlock = currentBlock - reservationDefaultLookBackBlocks + reservationWiringLogger.Warnf( + "stranding startup scan is bounded to wallets registered at "+ + "block [%d] or later; a wallet registered and already "+ + "closed/terminated before that block will not be caught "+ + "by this scan, nor by the live OnWalletClosed "+ + "subscription, which cannot replay an already-emitted "+ + "close event - this is an accepted operational "+ + "limitation, not a gap covered elsewhere", + startBlock, + ) + } + + var registeredEvents []*tbtc.NewWalletRegisteredEvent + err = fetchPastEventsInChunks( + func(chunkStart, chunkEnd uint64) ([]*tbtc.NewWalletRegisteredEvent, error) { + return spvChain.PastNewWalletRegisteredEvents( + &tbtc.NewWalletRegisteredEventFilter{ + StartBlock: chunkStart, + EndBlock: &chunkEnd, + }, + ) + }, + startBlock, + currentBlock, + func(events []*tbtc.NewWalletRegisteredEvent, _ uint64) { + registeredEvents = append(registeredEvents, events...) + }, + ) + return registeredEvents, err +} + // retryReservationStrandingStartupScan gives the startup catch-up scan's // unresolved wallets (see WireReservationWatchers) one more chance, after // waiting out delay, once the rest of the wiring has already completed. diff --git a/pkg/maintainer/spv/reservation_wiring_test.go b/pkg/maintainer/spv/reservation_wiring_test.go index 7c46042051..cb5b388c4c 100644 --- a/pkg/maintainer/spv/reservation_wiring_test.go +++ b/pkg/maintainer/spv/reservation_wiring_test.go @@ -550,12 +550,11 @@ func TestWireReservationWatchers_DrivesRealNotifications_NotJustWiringSuccess(t }) fundingTxHash := bitcoin.Hash{0x02} fundingOutputIndex := uint32(0) - // currentBlock (1000) does not exceed staleDepositRevealScanLookBackBlocks, - // so pollTick's own startBlock computation stays 0 - mirror that - // here rather than subtracting the lookback bound directly (which - // would underflow uint64 for a currentBlock this small). + // The reveal sits below the confirmed tip (currentBlock 1000 minus + // reservationEventScanConfirmationBlocks), inside the first scan's + // activation-to-tip range on the Developer network. startBlock := uint64(0) - endBlock := uint64(1000) + endBlock := uint64(900) if err := spvChain.addPastDepositRevealedEvent( &tbtc.DepositRevealedEventFilter{StartBlock: startBlock + 1, EndBlock: &endBlock}, &tbtc.DepositRevealedEvent{ @@ -685,7 +684,7 @@ func TestWireReservationWatchers_DrainsStrandingRecheckThroughRealWiring(t *test State: tbtc.ReservationStateActive, }) - if err := WireReservationWatchers(ctx, walletClosedChain, spvChain, true, nil, ethereum.Unknown); err != nil { + if err := WireReservationWatchers(ctx, walletClosedChain, spvChain, true, nil, ethereum.Developer); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -895,7 +894,7 @@ func TestRunStaleDepositPollTick_SnapshotsRefundDeadlineFromRevealEvent(t *testi }) startBlock := currentBlock - reservationDefaultLookBackBlocks - endBlock := currentBlock + endBlock := currentBlock - reservationEventScanConfirmationBlocks fundingTxHash := bitcoin.Hash{0x01} fundingOutputIndex := uint32(0) @@ -960,7 +959,7 @@ func TestRunStaleDepositPollTick_EvictsOnClearedWallet(t *testing.T) { }) startBlock := currentBlock - reservationDefaultLookBackBlocks - endBlock := currentBlock + endBlock := currentBlock - reservationEventScanConfirmationBlocks fundingTxHash := bitcoin.Hash{0x03} fundingOutputIndex := uint32(0) @@ -1014,7 +1013,7 @@ func TestRunStaleDepositPollTick_NotifiesAfterDeadline(t *testing.T) { }) startBlock := currentBlock - reservationDefaultLookBackBlocks - endBlock := currentBlock + endBlock := currentBlock - reservationEventScanConfirmationBlocks fundingTxHash := bitcoin.Hash{0x04} fundingOutputIndex := uint32(0) @@ -1216,3 +1215,45 @@ func TestWireReservationWatchers_TypedNilPerformanceMetricsSurvivesWatcherDeath( // a crash would take the whole test process down, so surviving to // here (with both signals observed) is the assertion. } + +// TestScanReservationStrandingStartupRegistrations verifies that the +// stranding startup scan walks the last reservationDefaultLookBackBlocks +// blocks in chunks and returns every registration inside that window +// exactly once, and none before it. +func TestScanReservationStrandingStartupRegistrations(t *testing.T) { + const windowStart = uint64(50_000) + spvChain := newLocalChain() + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(windowStart + reservationDefaultLookBackBlocks) + spvChain.setBlockCounter(blockCounter) + + blocks := []uint64{ + windowStart - 1, // before the window + windowStart + 100, // first chunk + windowStart + reservationEventScanChunkSize + 100, // second chunk + } + for i, blockNumber := range blocks { + spvChain.addNewWalletRegisteredEvent(&tbtc.NewWalletRegisteredEvent{ + WalletPublicKeyHash: walletPKHAt(byte(i + 1)), + BlockNumber: blockNumber, + }) + } + + events, err := scanReservationStrandingStartupRegistrations(spvChain) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(events) != 2 { + t.Fatalf("expected the 2 registrations inside the window, got %d", len(events)) + } + if events[0].BlockNumber != blocks[1] || events[1].BlockNumber != blocks[2] { + t.Errorf( + "expected registrations at blocks %d and %d, got %d and %d", + blocks[1], + blocks[2], + events[0].BlockNumber, + events[1].BlockNumber, + ) + } +} From f13b8e8f83a2a9a18ed36bd03b3f4d716ab3be64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 30 Sep 2026 08:25:25 +0000 Subject: [PATCH 13/31] test(tbtcpg): mirror Reservation.sol in the re-anchor request fake The LocalChain RequestReservationReanchor fake now enforces every requestReservationReanchor precondition in Solidity's order and with its revert reasons (cooldown, MovingFunds or Closing source, Live target distinct from the source, signing window, anchor floor, count cap where zero blocks all, amount cap), reserves the target's count and amount on request, and writes exactly the action fields Solidity writes. WalletReservationsCount and WalletReservationsAmount read a ledger made of the wallet's custodied reservations plus the capacity pending re-anchor requests reserved on it; new timeout and settlement helpers update it the way the contract does. Fixtures now set an action timeout and keep anchors above ReservationTxMaxFee + ReservationMinAmount, as a real request requires. --- pkg/tbtcpg/chain_test.go | 374 ++++++++++++++---- .../reservation_reanchor_scenario_0.json | 2 +- .../reservation_reanchor_scenario_8.json | 2 +- .../reservation_reanchor_inflight_test.go | 51 ++- pkg/tbtcpg/reservation_reanchor_test.go | 327 ++++++++++++++- 5 files changed, 641 insertions(+), 115 deletions(-) diff --git a/pkg/tbtcpg/chain_test.go b/pkg/tbtcpg/chain_test.go index e450007dfd..c0d7f3857f 100644 --- a/pkg/tbtcpg/chain_test.go +++ b/pkg/tbtcpg/chain_test.go @@ -43,6 +43,13 @@ type reservationReanchorRequestSubmission struct { TargetWalletPublicKeyHash [20]byte } +// reservationCapacity is a wallet's reservation count and amount, as in +// Reservation.sol's walletReservationInfo. +type reservationCapacity struct { + count uint32 + amount uint64 +} + // belowDustNotification captures a submitted NotifyMovingFundsBelowDust // call that tests can inspect for assertion. type belowDustNotification struct { @@ -92,6 +99,14 @@ type LocalChain struct { reservationParametersSet bool reservationProposalValidations map[[32]byte]bool reservationReanchorRequestSubmissions []*reservationReanchorRequestSubmission + // reservationReanchorRequestAttempts records every + // RequestReservationReanchor call, including reverted ones. + reservationReanchorRequestAttempts []*reservationReanchorRequestSubmission + // reanchorReservedCapacity is the count and amount capacity pending + // re-anchor requests reserved on their target wallets. Together with + // the wallet's custodied reservations it forms the wallet's + // walletReservationInfo ledger entry (see walletReservationInfoLocked). + reanchorReservedCapacity map[[20]byte]reservationCapacity // pendingNextReanchorRequest makes the next RequestReservationReanchor // submission succeed without writing a generation, modeling a // submission still unconfirmed in the mempool (or dropped from it). @@ -139,6 +154,7 @@ func NewLocalChain() *LocalChain { reservationActions: make(map[string]*tbtc.ReservationAction), reservationProposalValidations: make(map[[32]byte]bool), reservationReanchorRequestSubmissions: make([]*reservationReanchorRequestSubmission, 0), + reanchorReservedCapacity: make(map[[20]byte]reservationCapacity), belowDustNotifications: make([]*belowDustNotification, 0), reservationWalletKeys: make(map[[20]byte][]*big.Int), reservedDeposits: make(map[string]bool), @@ -1698,22 +1714,23 @@ func buildReservationReanchorProposalValidationKey( return sha256.Sum256(buffer.Bytes()), nil } -// RequestReservationReanchor records a submitted reservation re-anchor -// request for assertion in tests, and mirrors the on-chain effects of -// Reservation.sol's requestReservationReanchor: it requires the -// reservation to be Active (fails closed otherwise, matching "Reservation -// is not active"), then bumps its RequestNonce, flips its state to -// ActionPending, and writes the new generation as a Pending Reanchor -// action authorizing targetWalletPublicKeyHash. This makes the -// request-then-wait-then-read flow in ProposeReservationReanchor -// observable in tests: reading the action before this call succeeds -// returns "not found", exactly like an on-chain read of an -// as-yet-unwritten generation would. +// RequestReservationReanchor mirrors Reservation.sol's +// requestReservationReanchor for a permissionless caller. It reverts, in +// Solidity's order and with Solidity's reasons, unless the reservation is +// Active and out of its re-anchor cooldown, the source wallet is +// MovingFunds or Closing, the target differs from the source and is Live, +// the action timeout leaves a signing window before the validator's safety +// margin, the anchor stays above ReservationTxMaxFee + ReservationMinAmount, +// and the target has count headroom (a zero MaxReservationsPerWallet blocks +// every request) and amount headroom (a zero amount cap disables that +// check). A request that passes is recorded as a submission; it then +// reserves the target's count and amount capacity, bumps the nonce, moves +// the reservation to ActionPending and writes exactly the action fields +// Solidity writes. Every call, reverted or not, is recorded as an attempt. // -// The fake models the submission as immediately mined. -// SetNextReservationReanchorRequestPending makes the next submission -// succeed without writing the generation, matching a request that has not -// been mined. +// SetNextReservationReanchorRequestPending makes the next request that +// passes the checks succeed without writing anything, matching a +// transaction that has been sent but not mined. func (lc *LocalChain) RequestReservationReanchor( reservationKey *big.Int, targetWalletPublicKeyHash [20]byte, @@ -1721,10 +1738,76 @@ func (lc *LocalChain) RequestReservationReanchor( lc.mutex.Lock() defer lc.mutex.Unlock() + lc.reservationReanchorRequestAttempts = append( + lc.reservationReanchorRequestAttempts, + &reservationReanchorRequestSubmission{ + ReservationKey: new(big.Int).Set(reservationKey), + TargetWalletPublicKeyHash: targetWalletPublicKeyHash, + }, + ) + + if !lc.reservationParametersSet { + return fmt.Errorf("reservation parameters not set") + } + params := lc.reservationParametersValue + key := reservationKey.Text(16) existing, ok := lc.reservations[key] if !ok || existing == nil || existing.State != tbtc.ReservationStateActive { - return fmt.Errorf("reservation is not active") + return fmt.Errorf("Reservation is not active") + } + + now := uint32(time.Now().Unix()) + if now < existing.ReanchorCooldownUntil { + return fmt.Errorf("Reanchor cooldown in effect") + } + + // walletChainData is read directly: GetWallet takes lc.mutex, which + // this method already holds. + sourceState := tbtc.StateUnknown + if source, ok := lc.walletChainData[existing.WalletPublicKeyHash]; ok { + sourceState = source.State + } + if sourceState == tbtc.StateLive { + return fmt.Errorf("Only governance can rotate a Live wallet's anchor") + } + if sourceState != tbtc.StateMovingFunds && sourceState != tbtc.StateClosing { + return fmt.Errorf("Source wallet must be in MovingFunds or Closing state") + } + + if targetWalletPublicKeyHash == existing.WalletPublicKeyHash { + return fmt.Errorf("Target wallet must differ from the source wallet") + } + if target, ok := lc.walletChainData[targetWalletPublicKeyHash]; !ok || + target.State != tbtc.StateLive { + return fmt.Errorf("Target wallet must be in Live state") + } + + timeoutAt := now + params.ReservationActionTimeout + if !(uint64(timeoutAt) > reservationRequestTimeoutSafetyMarginSeconds && + uint64(now)+1 < uint64(timeoutAt)-reservationRequestTimeoutSafetyMarginSeconds) { + return fmt.Errorf("Reanchor authorization has no signing window") + } + + anchorAmount := uint64(0) + if existing.AnchorUtxo != nil { + anchorAmount = uint64(existing.AnchorUtxo.Value) + } + if anchorAmount <= params.ReservationTxMaxFee+params.ReservationMinAmount { + return fmt.Errorf( + "Reanchor would fall below the minimum reservation amount", + ) + } + + targetCount, targetAmount := lc.walletReservationInfoLocked( + targetWalletPublicKeyHash, + ) + if targetCount+1 > params.MaxReservationsPerWallet { + return fmt.Errorf("Wallet reservations cap exceeded") + } + if maxAmount := lc.reservationCapsLocked()[0]; maxAmount != 0 && + targetAmount+anchorAmount > maxAmount { + return fmt.Errorf("Wallet reserved amount cap exceeded") } lc.reservationReanchorRequestSubmissions = append( @@ -1740,81 +1823,184 @@ func (lc *LocalChain) RequestReservationReanchor( return nil } - // Mirror the request-time count-capacity check Reservation.sol's - // requestReservationReanchor performs before reserving target - // capacity. Computing against lc.reservationWalletKeys directly - // rather than via WalletReservationsCount keeps everything under - // the same mutex and avoids a re-entrant lock deadlock. The check - // is only enforced when the live parameters have been set with a - // nonzero MaxReservationsPerWallet (which the on-chain setter - // itself requires > 0, so a zero value here only appears in tests - // that have not yet configured parameters). - if lc.reservationParametersSet && - lc.reservationParametersValue.MaxReservationsPerWallet > 0 { - currentTargetCount := uint32( - len(lc.reservationWalletKeys[targetWalletPublicKeyHash]), - ) - if currentTargetCount+1 > - lc.reservationParametersValue.MaxReservationsPerWallet { - return fmt.Errorf("wallet reservations cap exceeded") - } - } - - // Mirror the request-time amount-capacity check Reservation.sol's - // requestReservationReanchor performs on the target: the target's - // already-reserved amount plus this anchor's value must stay at or - // below the configured per-wallet amount cap (a zero cap disables the - // check, matching Solidity's `maxReservationsAmountPerWallet == 0` - // convention). The reserved amount is computed against - // lc.reservationWalletKeys directly, like the count check above, so - // everything stays under the same mutex and sees the same state the - // headroom pre-check's WalletReservationsAmount read observed. - { - maxAmount := lc.reservationCapsLocked()[0] - if maxAmount > 0 { - targetReservedAmount := uint64(0) - for _, reservationKey := range lc.reservationWalletKeys[targetWalletPublicKeyHash] { - if reservation, ok := lc.reservations[reservationKey.Text(16)]; ok && reservation != nil && reservation.AnchorUtxo != nil { - targetReservedAmount += uint64(reservation.AnchorUtxo.Value) - } - } - anchorValue := uint64(0) - if existing.AnchorUtxo != nil { - anchorValue = uint64(existing.AnchorUtxo.Value) - } - if targetReservedAmount+anchorValue > maxAmount { - return fmt.Errorf("wallet reserved amount cap exceeded") - } - } - } + reserved := lc.reanchorReservedCapacity[targetWalletPublicKeyHash] + reserved.count++ + reserved.amount += anchorAmount + lc.reanchorReservedCapacity[targetWalletPublicKeyHash] = reserved updated := *existing updated.RequestNonce++ updated.State = tbtc.ReservationStateActionPending lc.reservations[key] = &updated - txMaxFee := lc.reservationParametersValue.ReservationTxMaxFee actionKey := buildReservationActionKey(reservationKey, updated.RequestNonce) lc.reservationActions[actionKey] = &tbtc.ReservationAction{ ActionType: tbtc.ReservationActionTypeReanchor, State: tbtc.ReservationActionStatePending, + RequestedAt: now, + TimeoutAt: timeoutAt, + TxMaxFee: params.ReservationTxMaxFee, TargetWalletPublicKeyHash: targetWalletPublicKeyHash, - TxMaxFee: txMaxFee, - MinAmount: lc.reservationParametersValue.ReservationMinAmount, - // A fresh generation's timeout sits far enough ahead that the - // validator's REQUEST_TIMEOUT_SAFETY_MARGIN gate (now plus - // two hours) cannot reject it; tests that exercise the gate - // seed their own actions with nearer TimeoutAt values. - TimeoutAt: uint32(time.Now().Add(24 * time.Hour).Unix()), - TermSeconds: lc.reservationParametersValue.ReservationTermSeconds, + Amount: anchorAmount, + SourceAnchorUtxoHash: reservationAnchorUtxoHash(existing), } return nil } +// TimeOutReservationReanchor mirrors Reservation.sol's +// notifyReservationActionTimeout for the reservation's current Pending +// Reanchor generation, as if called now: the action becomes TimedOut, the +// target's reserved capacity is released, the reservation returns to +// Active, and its re-anchor cooldown runs for the action's own duration. +// The timeoutAt check is skipped so tests need not wait for it. +func (lc *LocalChain) TimeOutReservationReanchor(reservationKey *big.Int) error { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + reservation, action, err := lc.pendingReanchorLocked(reservationKey) + if err != nil { + return err + } + + timedOut := *action + timedOut.State = tbtc.ReservationActionStateTimedOut + lc.reservationActions[buildReservationActionKey( + reservationKey, + reservation.RequestNonce, + )] = &timedOut + + lc.releaseReanchorReservedCapacityLocked(action) + + updated := *reservation + updated.State = tbtc.ReservationStateActive + updated.ReanchorCooldownUntil = uint32(time.Now().Unix()) + + (action.TimeoutAt - action.RequestedAt) + lc.reservations[reservationKey.Text(16)] = &updated + + return nil +} + +// SettleReservationReanchor mirrors the accounting of a proven re-anchor +// (ReservationProofs.sol's settleReanchorAccounting) for the reservation's +// current Pending Reanchor generation: the action becomes Settled, custody +// and the reservation key move from the source wallet to the target, the +// anchor becomes newAnchorUtxo, and the reservation returns to Active. The +// capacity reserved on the target at request time becomes the target's +// custodied reservation, so the target keeps count + 1 and its amount +// drops by the miner fee, while the source's count and amount are +// released. +func (lc *LocalChain) SettleReservationReanchor( + reservationKey *big.Int, + newAnchorUtxo *bitcoin.UnspentTransactionOutput, +) error { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + reservation, action, err := lc.pendingReanchorLocked(reservationKey) + if err != nil { + return err + } + + settled := *action + settled.State = tbtc.ReservationActionStateSettled + lc.reservationActions[buildReservationActionKey( + reservationKey, + reservation.RequestNonce, + )] = &settled + + lc.releaseReanchorReservedCapacityLocked(action) + + source := reservation.WalletPublicKeyHash + remaining := make([]*big.Int, 0, len(lc.reservationWalletKeys[source])) + for _, k := range lc.reservationWalletKeys[source] { + if k.Cmp(reservationKey) != 0 { + remaining = append(remaining, k) + } + } + lc.reservationWalletKeys[source] = remaining + lc.reservationWalletKeys[action.TargetWalletPublicKeyHash] = append( + lc.reservationWalletKeys[action.TargetWalletPublicKeyHash], + new(big.Int).Set(reservationKey), + ) + + updated := *reservation + updated.State = tbtc.ReservationStateActive + updated.WalletPublicKeyHash = action.TargetWalletPublicKeyHash + updated.AnchorUtxo = newAnchorUtxo + lc.reservations[reservationKey.Text(16)] = &updated + + return nil +} + +// pendingReanchorLocked returns the reservation and its current action, +// failing unless the reservation is ActionPending and the action is a +// Pending Reanchor. Callers hold lc.mutex. +func (lc *LocalChain) pendingReanchorLocked( + reservationKey *big.Int, +) (*tbtc.Reservation, *tbtc.ReservationAction, error) { + reservation, ok := lc.reservations[reservationKey.Text(16)] + if !ok || reservation.State != tbtc.ReservationStateActionPending { + return nil, nil, fmt.Errorf("Reservation is not in ActionPending state") + } + action, ok := lc.reservationActions[buildReservationActionKey( + reservationKey, + reservation.RequestNonce, + )] + if !ok || + action.ActionType != tbtc.ReservationActionTypeReanchor || + action.State != tbtc.ReservationActionStatePending { + return nil, nil, fmt.Errorf("Action is not a pending re-anchor") + } + return reservation, action, nil +} + +// releaseReanchorReservedCapacityLocked releases the target capacity a +// re-anchor request reserved. Callers hold lc.mutex. +func (lc *LocalChain) releaseReanchorReservedCapacityLocked( + action *tbtc.ReservationAction, +) { + reserved := lc.reanchorReservedCapacity[action.TargetWalletPublicKeyHash] + reserved.count-- + reserved.amount -= action.Amount + lc.reanchorReservedCapacity[action.TargetWalletPublicKeyHash] = reserved +} + +// reservationAnchorUtxoHash mirrors Reservation.sol's anchorUtxoHash: +// keccak256(anchorTxHash || uint32 anchorTxOutputIndex). +func reservationAnchorUtxoHash(reservation *tbtc.Reservation) [32]byte { + if reservation.AnchorUtxo == nil || reservation.AnchorUtxo.Outpoint == nil { + return [32]byte{} + } + outpoint := reservation.AnchorUtxo.Outpoint + packed := make([]byte, 36) + copy(packed, outpoint.TransactionHash[:]) + binary.BigEndian.PutUint32(packed[32:], outpoint.OutputIndex) + var hash [32]byte + copy(hash[:], crypto.Keccak256(packed)) + return hash +} + +// GetReservationReanchorRequestAttempts returns every +// RequestReservationReanchor call, including ones the fake reverted. +func (lc *LocalChain) GetReservationReanchorRequestAttempts() []*reservationReanchorRequestSubmission { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + attempts := make([]*reservationReanchorRequestSubmission, len(lc.reservationReanchorRequestAttempts)) + for i, a := range lc.reservationReanchorRequestAttempts { + attempts[i] = &reservationReanchorRequestSubmission{ + ReservationKey: new(big.Int).Set(a.ReservationKey), + TargetWalletPublicKeyHash: a.TargetWalletPublicKeyHash, + } + } + return attempts +} + // SetNextReservationReanchorRequestPending makes the next -// RequestReservationReanchor submission succeed while writing no on-chain -// generation, matching a request still unconfirmed in the mempool. +// RequestReservationReanchor submission that passes the checks succeed +// while writing nothing, matching a request still unconfirmed in the +// mempool. func (lc *LocalChain) SetNextReservationReanchorRequestPending() { lc.mutex.Lock() defer lc.mutex.Unlock() @@ -1993,31 +2179,49 @@ func (lc *LocalChain) SetReservationCaps( lc.reservationCapsSet = true } -// WalletReservationsAmount returns the sum of anchor values for the -// wallet's reservations. +// WalletReservationsAmount returns the wallet's reserved amount: the +// anchor values of its custodied reservations plus the amount pending +// re-anchor requests reserved on it. func (lc *LocalChain) WalletReservationsAmount( walletPublicKeyHash [20]byte, ) (uint64, error) { lc.mutex.Lock() defer lc.mutex.Unlock() - var total uint64 - for _, reservationKey := range lc.reservationWalletKeys[walletPublicKeyHash] { - if r, ok := lc.reservations[reservationKey.Text(16)]; ok && r != nil && r.AnchorUtxo != nil { - total += uint64(r.AnchorUtxo.Value) - } - } - return total, nil + _, amount := lc.walletReservationInfoLocked(walletPublicKeyHash) + return amount, nil } -// WalletReservationsCount returns the count of reservations for the wallet. +// WalletReservationsCount returns the wallet's reservation count: its +// custodied reservations plus the pending re-anchor requests targeting it. func (lc *LocalChain) WalletReservationsCount( walletPublicKeyHash [20]byte, ) (uint32, error) { lc.mutex.Lock() defer lc.mutex.Unlock() - return uint32(len(lc.reservationWalletKeys[walletPublicKeyHash])), nil + count, _ := lc.walletReservationInfoLocked(walletPublicKeyHash) + return count, nil +} + +// walletReservationInfoLocked returns the wallet's walletReservationInfo +// ledger entry. Reservation.sol counts a reservation against the wallet +// custodying it and, while a re-anchor request is pending, also against +// the request's target; the fake derives the first part from the wallet's +// reservation keys and tracks the second in reanchorReservedCapacity. +// Callers hold lc.mutex. +func (lc *LocalChain) walletReservationInfoLocked( + walletPublicKeyHash [20]byte, +) (uint32, uint64) { + reserved := lc.reanchorReservedCapacity[walletPublicKeyHash] + count := uint32(len(lc.reservationWalletKeys[walletPublicKeyHash])) + reserved.count + amount := reserved.amount + for _, reservationKey := range lc.reservationWalletKeys[walletPublicKeyHash] { + if r, ok := lc.reservations[reservationKey.Text(16)]; ok && r != nil && r.AnchorUtxo != nil { + amount += uint64(r.AnchorUtxo.Value) + } + } + return count, amount } // WalletReservations returns the configured reservation keys for the wallet. diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_0.json b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_0.json index f4c379d8a4..f0a7b1f09d 100644 --- a/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_0.json +++ b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_0.json @@ -19,7 +19,7 @@ "WalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", "AnchorTxHash": "3333333333333333333333333333333333333333333333333333333333333333", "AnchorTxOutputIndex": 1, - "AnchorValue": 100000, + "AnchorValue": 200000, "State": "Active", "RequestNonce": 0, "HasPendingAction": false, diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_8.json b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_8.json index 0a64c2a85d..6c26bad389 100644 --- a/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_8.json +++ b/pkg/tbtcpg/internal/test/testdata/reservation_reanchor_scenario_8.json @@ -19,7 +19,7 @@ "WalletPublicKeyHash": "0xffb3f7538bfa98a511495dd96027cfbd57baf2fa", "AnchorTxHash": "3333333333333333333333333333333333333333333333333333333333333333", "AnchorTxOutputIndex": 1, - "AnchorValue": 100000, + "AnchorValue": 200000, "State": "Active", "RequestNonce": 0, "HasPendingAction": false, diff --git a/pkg/tbtcpg/reservation_reanchor_inflight_test.go b/pkg/tbtcpg/reservation_reanchor_inflight_test.go index 7a39709a5d..6ce0b55bec 100644 --- a/pkg/tbtcpg/reservation_reanchor_inflight_test.go +++ b/pkg/tbtcpg/reservation_reanchor_inflight_test.go @@ -57,6 +57,7 @@ func newInFlightReanchorFixture(t *testing.T) ( ReservationTxMaxFee: 100000, MaxReservationsPerWallet: 5, ReservationMinAmount: 1000, + ReservationActionTimeout: 86400, }) btcChain.SetEstimateSatPerVByteFee(1, 1) @@ -125,8 +126,7 @@ func seedResumableReanchorGeneration( State: tbtc.ReservationActionStatePending, TargetWalletPublicKeyHash: targetWalletPublicKeyHash, TxMaxFee: 100000, - MinAmount: 1000, - TermSeconds: 86400, + Amount: 200000, TimeoutAt: uint32(timeoutAt.Unix()), }) } @@ -274,30 +274,42 @@ func TestReservationReanchorTask_UnminedRequest_MinedBeforeNextRound_Resumes(t * } } -// TestReservationReanchorTask_UnminedRequest_ReservationLeftWallet pins -// that a reservation whose custody moved away from the source wallet -// between rounds is not requested again: round 2 reads the wallet's -// reservation list from chain state, finds it empty, and issues nothing. -func TestReservationReanchorTask_UnminedRequest_ReservationLeftWallet(t *testing.T) { - tbtcChain, _, blockCounter, task, sourceWalletPublicKeyHash, _, _ := +// TestReservationReanchorTask_ReservationLeftWallet_NotRequestedAgain +// pins that a reservation whose custody moved away from the source wallet +// between rounds is not requested again: round 1 requests and proposes the +// re-anchor, its proof settles before round 2, and round 2 reads the +// source's now-empty reservation list from chain state and issues nothing. +func TestReservationReanchorTask_ReservationLeftWallet_NotRequestedAgain(t *testing.T) { + tbtcChain, _, blockCounter, task, sourceWalletPublicKeyHash, targetWalletPublicKeyHash, reservationKey := newInFlightReanchorFixture(t) - tbtcChain.SetNextReservationReanchorRequestPending() - if _, _, err := task.Run(&tbtc.CoordinationProposalRequest{ + proposal, ok, err := task.Run(&tbtc.CoordinationProposalRequest{ WalletPublicKeyHash: sourceWalletPublicKeyHash, - }); err != nil { + }) + if err != nil { t.Fatalf("round 1: unexpected error: %v", err) } + if !ok || proposal == nil { + t.Fatalf("round 1: expected a proposal, got ok=%v proposal=%v", ok, proposal) + } if got := len(tbtcChain.GetReservationReanchorRequestSubmissions()); got != 1 { t.Fatalf("round 1: expected exactly 1 submission, got %d", got) } - // The reservation moves custody elsewhere between rounds: it no - // longer appears in the wallet's reservation list. - tbtcChain.SetWalletReservations(sourceWalletPublicKeyHash, nil) + // The re-anchor proof settles between rounds: custody moves to the + // target and the source's reservation list no longer names the key. + if err := tbtcChain.SettleReservationReanchor( + reservationKey, + &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{TransactionHash: bitcoin.Hash{0x99}}, + Value: 199450, + }, + ); err != nil { + t.Fatal(err) + } blockCounter.SetCurrentBlock(1900) - proposal, ok, err := task.Run(&tbtc.CoordinationProposalRequest{ + proposal, ok, err = task.Run(&tbtc.CoordinationProposalRequest{ WalletPublicKeyHash: sourceWalletPublicKeyHash, }) if err != nil { @@ -311,13 +323,16 @@ func TestReservationReanchorTask_UnminedRequest_ReservationLeftWallet(t *testing proposal, ) } - if got := len(tbtcChain.GetReservationReanchorRequestSubmissions()); got != 1 { + if got := len(tbtcChain.GetReservationReanchorRequestAttempts()); got != 1 { t.Fatalf( - "round 2: expected no new re-anchor request submissions, "+ - "got %d", + "round 2: expected no new re-anchor request, got %d attempts "+ + "in total", got, ) } + if count, err := tbtcChain.WalletReservationsCount(targetWalletPublicKeyHash); err != nil || count != 1 { + t.Fatalf("expected the target to custody the reservation, got count %d (err %v)", count, err) + } } // TestReservationReanchorTask_ResumeMarginGate pins the resume path's diff --git a/pkg/tbtcpg/reservation_reanchor_test.go b/pkg/tbtcpg/reservation_reanchor_test.go index 6de652698d..09c19f1dc3 100644 --- a/pkg/tbtcpg/reservation_reanchor_test.go +++ b/pkg/tbtcpg/reservation_reanchor_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/ethereum/go-ethereum/crypto" "github.com/ipfs/go-log/v2" "github.com/keep-network/keep-core/pkg/bitcoin" @@ -178,8 +179,7 @@ func TestReservationReanchorTask_Run(t *testing.T) { if r.HasPendingAction { action.TargetWalletPublicKeyHash = scenario.TargetWalletPublicKeyHash action.TxMaxFee = scenario.ReservationTxMaxFee - action.MinAmount = 1000 - action.TermSeconds = 86400 + action.Amount = uint64(r.AnchorValue) } tbtcChain.SetReservationAction( r.ReservationKey, @@ -205,6 +205,7 @@ func TestReservationReanchorTask_Run(t *testing.T) { tbtcChain.SetReservationParameters(tbtc.ReservationParameters{ ReservationTxMaxFee: scenario.ReservationTxMaxFee, MaxReservationsPerWallet: maxPerWallet, + ReservationActionTimeout: 86400, }) btcChain.SetEstimateSatPerVByteFee(1, scenario.EstimateSatPerVByteFee) @@ -386,8 +387,9 @@ func TestReservationReanchorTask_TargetWalletExclusion_SharedTask(t *testing.T) 0, ) tbtcChain.SetReservationParameters(tbtc.ReservationParameters{ - ReservationTxMaxFee: 100000, + ReservationTxMaxFee: 10000, MaxReservationsPerWallet: 5, + ReservationActionTimeout: 86400, }) btcChain.SetEstimateSatPerVByteFee(1, 1) @@ -557,8 +559,9 @@ func TestReservationReanchorTask_Run_SkipNonActiveReservations(t *testing.T) { 0, ) tbtcChain.SetReservationParameters(tbtc.ReservationParameters{ - ReservationTxMaxFee: 100000, + ReservationTxMaxFee: 10000, MaxReservationsPerWallet: 5, + ReservationActionTimeout: 86400, }) btcChain.SetEstimateSatPerVByteFee(1, 1) @@ -979,8 +982,9 @@ func TestReservationReanchorTask_CapRevertLeadsToNextCandidate(t *testing.T) { 0, ) tbtcChain.SetReservationParameters(tbtc.ReservationParameters{ - ReservationTxMaxFee: 100000, + ReservationTxMaxFee: 10000, MaxReservationsPerWallet: 5, + ReservationActionTimeout: 86400, }) btcChain.SetEstimateSatPerVByteFee(1, 1) @@ -1079,6 +1083,13 @@ func TestReservationReanchorTask_AmountCapFillLeadsToNextCandidate(t *testing.T) t.Run("fake chain enforces the target amount cap", func(t *testing.T) { lc := tbtcpg.NewLocalChain() + lc.SetReservationParameters(tbtc.ReservationParameters{ + ReservationTxMaxFee: 10000, + MaxReservationsPerWallet: 5, + ReservationActionTimeout: 86400, + }) + lc.SetWallet(sourceWalletPublicKeyHash, &tbtc.WalletChainData{State: tbtc.StateMovingFunds}) + lc.SetWallet(filledTargetWalletPublicKeyHash, &tbtc.WalletChainData{State: tbtc.StateLive}) // Configure a per-wallet reserved-amount cap the filled target // (250000 reserved + 100000 anchor = 350000) breaches. lc.SetReservationCaps(300000, 1000000) @@ -1122,7 +1133,7 @@ func TestReservationReanchorTask_AmountCapFillLeadsToNextCandidate(t *testing.T) resKey, filledTargetWalletPublicKeyHash, ); err == nil || - !strings.Contains(err.Error(), "wallet reserved amount cap exceeded") { + !strings.Contains(err.Error(), "Wallet reserved amount cap exceeded") { t.Fatalf( "expected the fake to reject the re-anchor request with "+ "the amount-cap revert when the target's reserved "+ @@ -1178,8 +1189,9 @@ func TestReservationReanchorTask_AmountCapFillLeadsToNextCandidate(t *testing.T) 0, ) tbtcChain.SetReservationParameters(tbtc.ReservationParameters{ - ReservationTxMaxFee: 100000, + ReservationTxMaxFee: 10000, MaxReservationsPerWallet: 5, + ReservationActionTimeout: 86400, }) btcChain.SetEstimateSatPerVByteFee(1, 1) @@ -1291,6 +1303,7 @@ func TestReservationReanchorTask_ResumesPendingReanchorWithoutNewRequest(t *test ReservationTxMaxFee: 100000, MaxReservationsPerWallet: 5, ReservationMinAmount: 1000, + ReservationActionTimeout: 86400, }) btcChain.SetEstimateSatPerVByteFee(1, 1) @@ -1331,8 +1344,7 @@ func TestReservationReanchorTask_ResumesPendingReanchorWithoutNewRequest(t *test State: tbtc.ReservationActionStatePending, TargetWalletPublicKeyHash: targetWalletPublicKeyHash, TxMaxFee: 100000, - MinAmount: 1000, - TermSeconds: 86400, + Amount: 200000, // Far-future TimeoutAt so the safety-margin gate does not // skip the resumed generation. TimeoutAt: uint32(time.Now().Add(24 * time.Hour).Unix()), @@ -1423,8 +1435,9 @@ func TestProposeReservationReanchor_ValidatesOnlyAfterRequestMined(t *testing.T) 0, ) tbtcChain.SetReservationParameters(tbtc.ReservationParameters{ - ReservationTxMaxFee: 100000, + ReservationTxMaxFee: 10000, MaxReservationsPerWallet: 5, + ReservationActionTimeout: 86400, }) btcChain.SetEstimateSatPerVByteFee(1, 1) @@ -1524,3 +1537,297 @@ func TestProposeReservationReanchor_ValidatesOnlyAfterRequestMined(t *testing.T) ) } } + +// TestLocalChain_RequestReservationReanchor_MirrorsSolidity pins the +// LocalChain re-anchor request fake to Reservation.sol's +// requestReservationReanchor, notifyReservationActionTimeout and re-anchor +// settlement accounting. The task tests above rely on the fake rejecting +// exactly what the contract rejects and reserving exactly the capacity the +// contract reserves; a looser fake would let a task regression pass. +func TestLocalChain_RequestReservationReanchor_MirrorsSolidity(t *testing.T) { + sourceWallet := hexToByte20("1111111111111111111111111111111111111111") + targetWallet := hexToByte20("2222222222222222222222222222222222222222") + otherWallet := hexToByte20("3333333333333333333333333333333333333333") + reservationKey := big.NewInt(4242) + anchorTxHash := bitcoin.Hash{0x42} + + newFixture := func() *tbtcpg.LocalChain { + lc := tbtcpg.NewLocalChain() + lc.SetReservationParameters(tbtc.ReservationParameters{ + ReservationTxMaxFee: 10000, + ReservationMinAmount: 1000, + MaxReservationsPerWallet: 2, + ReservationActionTimeout: 86400, + }) + lc.SetWallet(sourceWallet, &tbtc.WalletChainData{State: tbtc.StateMovingFunds}) + lc.SetWallet(targetWallet, &tbtc.WalletChainData{State: tbtc.StateLive}) + lc.SetReservation(reservationKey, &tbtc.Reservation{ + WalletPublicKeyHash: sourceWallet, + AnchorUtxo: &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHash, + OutputIndex: 3, + }, + Value: 100000, + }, + State: tbtc.ReservationStateActive, + }) + lc.SetWalletReservations(sourceWallet, []*big.Int{reservationKey}) + return lc + } + + walletInfo := func(t *testing.T, lc *tbtcpg.LocalChain, wallet [20]byte) (uint32, uint64) { + t.Helper() + count, err := lc.WalletReservationsCount(wallet) + if err != nil { + t.Fatal(err) + } + amount, err := lc.WalletReservationsAmount(wallet) + if err != nil { + t.Fatal(err) + } + return count, amount + } + + t.Run("request reserves target capacity and writes the action", func(t *testing.T) { + lc := newFixture() + before := uint32(time.Now().Unix()) + + if err := lc.RequestReservationReanchor(reservationKey, targetWallet); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if count, amount := walletInfo(t, lc, targetWallet); count != 1 || amount != 100000 { + t.Fatalf("expected target capacity (1, 100000), got (%d, %d)", count, amount) + } + if count, amount := walletInfo(t, lc, sourceWallet); count != 1 || amount != 100000 { + t.Fatalf("expected source capacity unchanged (1, 100000), got (%d, %d)", count, amount) + } + + reservation, err := lc.GetReservation(reservationKey) + if err != nil { + t.Fatal(err) + } + if reservation.State != tbtc.ReservationStateActionPending || reservation.RequestNonce != 1 { + t.Fatalf( + "expected ActionPending at nonce 1, got %v at nonce %d", + reservation.State, + reservation.RequestNonce, + ) + } + + action, err := lc.GetReservationAction(reservationKey, 1) + if err != nil { + t.Fatal(err) + } + packed := make([]byte, 36) + copy(packed, anchorTxHash[:]) + packed[35] = 3 + var expectedSourceAnchorUtxoHash [32]byte + copy(expectedSourceAnchorUtxoHash[:], crypto.Keccak256(packed)) + expected := tbtc.ReservationAction{ + ActionType: tbtc.ReservationActionTypeReanchor, + State: tbtc.ReservationActionStatePending, + RequestedAt: action.RequestedAt, + TimeoutAt: action.RequestedAt + 86400, + TxMaxFee: 10000, + TargetWalletPublicKeyHash: targetWallet, + Amount: 100000, + SourceAnchorUtxoHash: expectedSourceAnchorUtxoHash, + } + if *action != expected { + t.Fatalf("unexpected action\nexpected: %+v\nactual: %+v", expected, *action) + } + if action.RequestedAt < before || action.RequestedAt > uint32(time.Now().Unix()) { + t.Fatalf("RequestedAt [%d] is not the request time", action.RequestedAt) + } + }) + + t.Run("closing source is accepted", func(t *testing.T) { + lc := newFixture() + lc.SetWallet(sourceWallet, &tbtc.WalletChainData{State: tbtc.StateClosing}) + if err := lc.RequestReservationReanchor(reservationKey, targetWallet); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + reverts := map[string]struct { + mutate func(lc *tbtcpg.LocalChain) + target [20]byte + expectedReason string + }{ + "reservation not active": { + mutate: func(lc *tbtcpg.LocalChain) { + r, _ := lc.GetReservation(reservationKey) + updated := *r + updated.State = tbtc.ReservationStateActionPending + lc.SetReservation(reservationKey, &updated) + }, + target: targetWallet, + expectedReason: "Reservation is not active", + }, + "cooldown in effect": { + mutate: func(lc *tbtcpg.LocalChain) { + r, _ := lc.GetReservation(reservationKey) + updated := *r + updated.ReanchorCooldownUntil = uint32(time.Now().Add(time.Hour).Unix()) + lc.SetReservation(reservationKey, &updated) + }, + target: targetWallet, + expectedReason: "Reanchor cooldown in effect", + }, + "live source": { + mutate: func(lc *tbtcpg.LocalChain) { + lc.SetWallet(sourceWallet, &tbtc.WalletChainData{State: tbtc.StateLive}) + }, + target: targetWallet, + expectedReason: "Only governance can rotate a Live wallet's anchor", + }, + "terminated source": { + mutate: func(lc *tbtcpg.LocalChain) { + lc.SetWallet(sourceWallet, &tbtc.WalletChainData{State: tbtc.StateTerminated}) + }, + target: targetWallet, + expectedReason: "Source wallet must be in MovingFunds or Closing state", + }, + "target equals source": { + mutate: func(lc *tbtcpg.LocalChain) {}, + target: sourceWallet, + expectedReason: "Target wallet must differ from the source wallet", + }, + "target not live": { + mutate: func(lc *tbtcpg.LocalChain) {}, + target: otherWallet, + expectedReason: "Target wallet must be in Live state", + }, + "no signing window": { + mutate: func(lc *tbtcpg.LocalChain) { + lc.SetReservationParameters(tbtc.ReservationParameters{ + ReservationTxMaxFee: 10000, + ReservationMinAmount: 1000, + MaxReservationsPerWallet: 2, + ReservationActionTimeout: 7201, + }) + }, + target: targetWallet, + expectedReason: "Reanchor authorization has no signing window", + }, + "anchor at the floor": { + mutate: func(lc *tbtcpg.LocalChain) { + r, _ := lc.GetReservation(reservationKey) + updated := *r + anchor := *r.AnchorUtxo + anchor.Value = 11000 + updated.AnchorUtxo = &anchor + lc.SetReservation(reservationKey, &updated) + }, + target: targetWallet, + expectedReason: "Reanchor would fall below the minimum reservation amount", + }, + "zero count cap blocks every request": { + mutate: func(lc *tbtcpg.LocalChain) { + lc.SetReservationParameters(tbtc.ReservationParameters{ + ReservationTxMaxFee: 10000, + ReservationMinAmount: 1000, + MaxReservationsPerWallet: 0, + ReservationActionTimeout: 86400, + }) + }, + target: targetWallet, + expectedReason: "Wallet reservations cap exceeded", + }, + "target count at the cap": { + mutate: func(lc *tbtcpg.LocalChain) { + lc.SetWalletReservations(targetWallet, []*big.Int{big.NewInt(1), big.NewInt(2)}) + }, + target: targetWallet, + expectedReason: "Wallet reservations cap exceeded", + }, + "target amount over the cap": { + mutate: func(lc *tbtcpg.LocalChain) { + lc.SetReservationCaps(99999, 0) + }, + target: targetWallet, + expectedReason: "Wallet reserved amount cap exceeded", + }, + } + for name, test := range reverts { + t.Run("reverts: "+name, func(t *testing.T) { + lc := newFixture() + test.mutate(lc) + before, _ := lc.GetReservation(reservationKey) + + err := lc.RequestReservationReanchor(reservationKey, test.target) + if err == nil || !strings.Contains(err.Error(), test.expectedReason) { + t.Fatalf("expected revert [%s], got [%v]", test.expectedReason, err) + } + if got := len(lc.GetReservationReanchorRequestSubmissions()); got != 0 { + t.Fatalf("expected no submission for a reverted request, got %d", got) + } + if got := len(lc.GetReservationReanchorRequestAttempts()); got != 1 { + t.Fatalf("expected the reverted request to be recorded as an attempt, got %d", got) + } + after, _ := lc.GetReservation(reservationKey) + if after.RequestNonce != before.RequestNonce || after.State != before.State { + t.Fatalf("a reverted request changed the reservation: %+v", after) + } + }) + } + + t.Run("timeout releases target capacity and starts the cooldown", func(t *testing.T) { + lc := newFixture() + if err := lc.RequestReservationReanchor(reservationKey, targetWallet); err != nil { + t.Fatal(err) + } + if err := lc.TimeOutReservationReanchor(reservationKey); err != nil { + t.Fatal(err) + } + + if count, amount := walletInfo(t, lc, targetWallet); count != 0 || amount != 0 { + t.Fatalf("expected target capacity released, got (%d, %d)", count, amount) + } + reservation, _ := lc.GetReservation(reservationKey) + if reservation.State != tbtc.ReservationStateActive { + t.Fatalf("expected the reservation back in Active, got %v", reservation.State) + } + if reservation.ReanchorCooldownUntil < uint32(time.Now().Add(86399*time.Second).Unix()) { + t.Fatalf( + "expected a cooldown of the action's 86400s duration, got until [%d]", + reservation.ReanchorCooldownUntil, + ) + } + action, _ := lc.GetReservationAction(reservationKey, 1) + if action.State != tbtc.ReservationActionStateTimedOut { + t.Fatalf("expected the action TimedOut, got %v", action.State) + } + err := lc.RequestReservationReanchor(reservationKey, targetWallet) + if err == nil || !strings.Contains(err.Error(), "Reanchor cooldown in effect") { + t.Fatalf("expected the cooldown to block a new request, got [%v]", err) + } + }) + + t.Run("settlement moves custody and keeps the miner fee out of the target amount", func(t *testing.T) { + lc := newFixture() + if err := lc.RequestReservationReanchor(reservationKey, targetWallet); err != nil { + t.Fatal(err) + } + if err := lc.SettleReservationReanchor(reservationKey, &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{TransactionHash: bitcoin.Hash{0x43}}, + Value: 99000, + }); err != nil { + t.Fatal(err) + } + + if count, amount := walletInfo(t, lc, sourceWallet); count != 0 || amount != 0 { + t.Fatalf("expected source capacity released, got (%d, %d)", count, amount) + } + if count, amount := walletInfo(t, lc, targetWallet); count != 1 || amount != 99000 { + t.Fatalf("expected target capacity (1, 99000), got (%d, %d)", count, amount) + } + reservation, _ := lc.GetReservation(reservationKey) + if reservation.WalletPublicKeyHash != targetWallet || + reservation.State != tbtc.ReservationStateActive { + t.Fatalf("unexpected reservation after settlement: %+v", reservation) + } + }) +} From 0cbca48e5f9803d6322c452ca00c388d9ec35401 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 30 Sep 2026 08:47:48 +0000 Subject: [PATCH 14/31] fix(tbtcpg): re-anchor reservations from Closing source wallets Reservation.sol accepts a permissionless re-anchor request from a MovingFunds or Closing source, and a wallet can reach Closing while it still holds reservations. Run only acted on MovingFunds wallets, so a re-anchor missed during MovingFunds, including the resume of an already authorized generation, stayed stuck and blocked wallet closing. Accept Closing on both paths; the below-dust notification stays MovingFunds only. --- pkg/tbtcpg/reservation_reanchor.go | 39 ++++++++----- .../reservation_reanchor_inflight_test.go | 58 +++++++++++++++++++ pkg/tbtcpg/reservation_reanchor_test.go | 25 ++++++++ 3 files changed, 106 insertions(+), 16 deletions(-) diff --git a/pkg/tbtcpg/reservation_reanchor.go b/pkg/tbtcpg/reservation_reanchor.go index 5b28d57825..e44ee5a6ce 100644 --- a/pkg/tbtcpg/reservation_reanchor.go +++ b/pkg/tbtcpg/reservation_reanchor.go @@ -39,11 +39,11 @@ const reservationRequestTimeoutSafetyMarginSeconds = 2 * 60 * 60 // ReservationReanchorTask is a task that may produce a reservation re-anchor // proposal. The wallet enters this task when the source wallet has begun a -// move to a new wallet (state StateMovingFunds) or when the source wallet's -// main UTXO has dropped below the moving funds dust threshold (below-dust -// re-anchor). For every reservation currently custodied by the wallet, the -// task picks a destination wallet and assembles a 1-input-1-output re-anchor -// transaction moving the anchor outpoint into that destination wallet. +// move to a new wallet (state StateMovingFunds) or is closing +// (state StateClosing). For every reservation currently custodied by the +// wallet, the task picks a destination wallet and assembles a +// 1-input-1-output re-anchor transaction moving the anchor outpoint into +// that destination wallet. type ReservationReanchorTask struct { chain Chain btcChain bitcoin.Chain @@ -99,13 +99,15 @@ func (rrt *ReservationReanchorTask) ActionType() tbtc.WalletActionType { // Run evaluates whether the given wallet needs to re-anchor any of its // reservations and returns a single ReservationReanchorProposal for the // first reservation found to be re-anchorable. A wallet is a candidate for -// re-anchor only once it has entered the StateMovingFunds state (the -// wallet is migrating and reservations must be released to a live -// wallet); tbtc-v2's Reservation.requestReservationReanchor requires a -// privileged (governance) caller for StateLive sources (enforced on-chain -// in the tbtc-v2 contracts repo, outside keep-core), which the -// client's ordinary operator key can never satisfy, so no below-dust -// re-anchor trigger is attempted for Live wallets. +// re-anchor once it has entered the StateMovingFunds or StateClosing state +// (the wallet is winding down and reservations must be released to a live +// wallet), matching the source states tbtc-v2's permissionless +// Reservation.requestReservationReanchor accepts. A wallet can reach +// Closing while it still holds reservations, and it cannot finish closing +// until they are gone, so a re-anchor missed during MovingFunds must still +// be possible from Closing. Live sources require a privileged +// (governance) caller on-chain, which the client's ordinary operator key +// can never satisfy, so Live wallets are skipped. // // Each reservation is either Active (needs a freshly requested generation) // or already ActionPending with its current generation a Pending Reanchor @@ -146,7 +148,7 @@ func (rrt *ReservationReanchorTask) Run( // live_wallets_count is published unconditionally on every Run pass, // mirroring the sibling active_reservations_count/max_active_reservations // gauges that ReservationAcceptanceTask publishes every coordination - // window: it must not depend on this task's StateMovingFunds/ + // window: it must not depend on this task's wallet-state/ // non-empty-reservations guards below, which are false for most // wallets in steady state. Gating the publish on those guards would // leave the gauge stuck at its registered-zero value indefinitely and @@ -162,7 +164,8 @@ func (rrt *ReservationReanchorTask) Run( rrt.metricsRecorder.SetGauge("live_wallets_count", float64(liveWalletsCount)) } - if walletChainData.State != tbtc.StateMovingFunds { + if walletChainData.State != tbtc.StateMovingFunds && + walletChainData.State != tbtc.StateClosing { taskLogger.Info("wallet is not eligible for reservation re-anchor") return nil, false, nil } @@ -182,8 +185,12 @@ func (rrt *ReservationReanchorTask) Run( // scheduling wired up wherever coordination tasks are registered // (outside this package), which is a larger cross-package change // than justified here, whereas embedding it costs only piggybacking - // on this task's own already-scheduled invocation cadence. - rrt.notifyMovingFundsBelowDustIfEligible(taskLogger, walletPublicKeyHash) + // on this task's own already-scheduled invocation cadence. A + // Closing wallet is already past the moving funds process, so the + // notification only applies to MovingFunds wallets. + if walletChainData.State == tbtc.StateMovingFunds { + rrt.notifyMovingFundsBelowDustIfEligible(taskLogger, walletPublicKeyHash) + } return nil, false, nil } diff --git a/pkg/tbtcpg/reservation_reanchor_inflight_test.go b/pkg/tbtcpg/reservation_reanchor_inflight_test.go index 6ce0b55bec..a054d88755 100644 --- a/pkg/tbtcpg/reservation_reanchor_inflight_test.go +++ b/pkg/tbtcpg/reservation_reanchor_inflight_test.go @@ -423,3 +423,61 @@ func TestReservationReanchorTask_ResumeMarginGate(t *testing.T) { } }) } + +// TestReservationReanchorTask_ClosingSource pins that a Closing source +// wallet is re-anchored like a MovingFunds one. Reservation.sol accepts a +// permissionless request from a Closing source, a wallet can reach Closing +// while it still holds reservations, and it cannot finish closing until +// they are gone, so both a fresh request and the resume of an +// already-authorized generation must still happen from Closing. +func TestReservationReanchorTask_ClosingSource(t *testing.T) { + t.Run("active reservation is requested and proposed", func(t *testing.T) { + tbtcChain, _, _, task, sourceWalletPublicKeyHash, targetWalletPublicKeyHash, _ := + newInFlightReanchorFixture(t) + tbtcChain.SetWallet(sourceWalletPublicKeyHash, &tbtc.WalletChainData{State: tbtc.StateClosing}) + + proposal, ok, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: sourceWalletPublicKeyHash, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok || proposal == nil { + t.Fatalf("expected a proposal for a Closing source, got ok=%v proposal=%v", ok, proposal) + } + reanchorProposal := proposal.(*tbtc.ReservationReanchorProposal) + if reanchorProposal.RequestNonce != 1 || + reanchorProposal.TargetWalletPublicKeyHash != targetWalletPublicKeyHash { + t.Fatalf("unexpected proposal: %+v", reanchorProposal) + } + if got := len(tbtcChain.GetReservationReanchorRequestSubmissions()); got != 1 { + t.Fatalf("expected exactly 1 submission, got %d", got) + } + }) + + t.Run("pending generation is resumed", func(t *testing.T) { + tbtcChain, _, _, task, sourceWalletPublicKeyHash, targetWalletPublicKeyHash, reservationKey := + newInFlightReanchorFixture(t) + tbtcChain.SetWallet(sourceWalletPublicKeyHash, &tbtc.WalletChainData{State: tbtc.StateClosing}) + seedResumableReanchorGeneration( + t, tbtcChain, targetWalletPublicKeyHash, reservationKey, 3, + time.Now().Add(24*time.Hour), + ) + + proposal, ok, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: sourceWalletPublicKeyHash, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok || proposal == nil { + t.Fatalf("expected the pending generation to be resumed, got ok=%v proposal=%v", ok, proposal) + } + if nonce := proposal.(*tbtc.ReservationReanchorProposal).RequestNonce; nonce != 3 { + t.Fatalf("expected the resumed proposal at nonce 3, got [%d]", nonce) + } + if got := len(tbtcChain.GetReservationReanchorRequestAttempts()); got != 0 { + t.Fatalf("expected no request on the resume path, got %d", got) + } + }) +} diff --git a/pkg/tbtcpg/reservation_reanchor_test.go b/pkg/tbtcpg/reservation_reanchor_test.go index 09c19f1dc3..084ecc0b30 100644 --- a/pkg/tbtcpg/reservation_reanchor_test.go +++ b/pkg/tbtcpg/reservation_reanchor_test.go @@ -876,6 +876,31 @@ func TestReservationReanchorTask_Run_NotifiesMovingFundsBelowDust(t *testing.T) } }) + t.Run("closing wallet: no notification even below dust", func(t *testing.T) { + tbtcChain, btcChain := newFixture(500000, nil, true) + wallet, err := tbtcChain.GetWallet(walletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + closing := *wallet + closing.State = tbtc.StateClosing + tbtcChain.SetWallet(walletPublicKeyHash, &closing) + task := tbtcpg.NewReservationReanchorTask(tbtcChain, btcChain) + + if _, _, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + }); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if notifications := tbtcChain.GetBelowDustNotifications(); len(notifications) != 0 { + t.Fatalf( + "expected no below-dust notifications for a Closing wallet, got %d", + len(notifications), + ) + } + }) + t.Run("wallet never reservation-touched: no notification even below dust", func(t *testing.T) { tbtcChain, btcChain := newFixture(500000, nil, false) task := tbtcpg.NewReservationReanchorTask(tbtcChain, btcChain) From cb72d013fe2be4afafd63f76721c4bccae14cee3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 30 Sep 2026 08:47:50 +0000 Subject: [PATCH 15/31] fix(ethereum): compile the harness StubBridge by relative path for reproducible metadata --- .../ethereum/testdata/walletproposalvalidator/regenerate.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/chain/ethereum/testdata/walletproposalvalidator/regenerate.sh b/pkg/chain/ethereum/testdata/walletproposalvalidator/regenerate.sh index ec0bcedca5..6ce9d4c204 100755 --- a/pkg/chain/ethereum/testdata/walletproposalvalidator/regenerate.sh +++ b/pkg/chain/ethereum/testdata/walletproposalvalidator/regenerate.sh @@ -49,8 +49,10 @@ if [[ ! -f "$VALIDATOR_ARTIFACT" ]]; then fi echo "compiling StubBridge.sol with $($SOLC --version | tail -1)..." -"$SOLC" --optimize --optimize-runs 200 --via-ir --combined-json abi,bin \ - "$HERE/StubBridge.sol" >"$HERE/.stub-combined.json" +# Compile by relative path: solc embeds the source path in the bytecode +# metadata, so an absolute path would change StubBridge.json per machine. +(cd "$HERE" && "$SOLC" --optimize --optimize-runs 200 --via-ir \ + --combined-json abi,bin StubBridge.sol) >"$HERE/.stub-combined.json" python3 - "$HERE/.stub-combined.json" "$HERE/StubBridge.json" <<'PY' import json From f9cb95c55c4ee3c097d60c0c0331cbc3baf8e1df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 30 Sep 2026 08:51:11 +0000 Subject: [PATCH 16/31] fix(tbtc): locate anchor deposit reveals from reveal time, not a 30-day window --- pkg/tbtc/deposit_reveal_lookup.go | 177 ++++++++++++++++ pkg/tbtc/deposit_reveal_lookup_test.go | 266 +++++++++++++++++++++++++ pkg/tbtc/reservation.go | 60 +++--- pkg/tbtc/reservation_test.go | 214 ++++++++++---------- 4 files changed, 571 insertions(+), 146 deletions(-) create mode 100644 pkg/tbtc/deposit_reveal_lookup.go create mode 100644 pkg/tbtc/deposit_reveal_lookup_test.go diff --git a/pkg/tbtc/deposit_reveal_lookup.go b/pkg/tbtc/deposit_reveal_lookup.go new file mode 100644 index 0000000000..b0451a46d3 --- /dev/null +++ b/pkg/tbtc/deposit_reveal_lookup.go @@ -0,0 +1,177 @@ +package tbtc + +import ( + "fmt" + "time" +) + +// DepositRevealLookupChunkBlocks is the maximum block range a single +// PastDepositRevealedEvents call made by FindDepositRevealedEvent spans. +// Many Ethereum providers reject or truncate log queries over wider +// ranges. +const DepositRevealLookupChunkBlocks = uint64(10000) + +// DepositRevealLookupDefaultBlockTime is the block time +// FindDepositRevealedEventByRevealTime assumes when the caller does not +// know one: Ethereum's post-merge slot time. +const DepositRevealLookupDefaultBlockTime = 12 * time.Second + +// depositRevealLookupMinMarginBlocks is the smallest padding +// FindDepositRevealedEventByRevealTime applies on each side of the +// estimated reveal block. +const depositRevealLookupMinMarginBlocks = uint64(1000) + +// depositRevealLookupWidenFactor is how much wider than the first window +// the single retry window of FindDepositRevealedEventByRevealTime is. +const depositRevealLookupWidenFactor = uint64(4) + +// DepositRevealedEventSource is the chain surface the deposit reveal +// lookup helpers need. +type DepositRevealedEventSource interface { + PastDepositRevealedEvents( + filter *DepositRevealedEventFilter, + ) ([]*DepositRevealedEvent, error) +} + +// FindDepositRevealedEvent scans the DepositRevealed events of the given +// wallet in the block range [fromBlock, toBlock] and returns the first +// event for which match returns true, or nil if none does. The range is +// scanned newest chunk first, at most DepositRevealLookupChunkBlocks per +// call, and the scan stops at the first match. +func FindDepositRevealedEvent( + source DepositRevealedEventSource, + walletPublicKeyHash [20]byte, + fromBlock uint64, + toBlock uint64, + match func(event *DepositRevealedEvent) bool, +) (*DepositRevealedEvent, error) { + if fromBlock > toBlock { + return nil, nil + } + + chunkEnd := toBlock + for { + chunkStart := fromBlock + if chunkEnd-fromBlock >= DepositRevealLookupChunkBlocks { + chunkStart = chunkEnd - DepositRevealLookupChunkBlocks + 1 + } + + end := chunkEnd + events, err := source.PastDepositRevealedEvents( + &DepositRevealedEventFilter{ + StartBlock: chunkStart, + EndBlock: &end, + WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + }, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot get deposit revealed events in blocks [%d, %d]: [%w]", + chunkStart, + chunkEnd, + err, + ) + } + + for _, event := range events { + if match(event) { + return event, nil + } + } + + if chunkStart == fromBlock { + return nil, nil + } + chunkEnd = chunkStart - 1 + } +} + +// FindDepositRevealedEventByRevealTime returns the DepositRevealed event +// of the given wallet for which match returns true, locating it from the +// deposit's on-chain reveal timestamp instead of scanning the wallet's +// whole reveal history. The reveal block is estimated as +// currentBlock - (now - revealedAt) / averageBlockTime and padded on each +// side by 5% of the estimated distance (at least +// depositRevealLookupMinMarginBlocks). If the event is not in that window, +// the two outer strips of a window depositRevealLookupWidenFactor times +// wider are scanned once before giving up with a nil event. A +// non-positive averageBlockTime is replaced by +// DepositRevealLookupDefaultBlockTime. +func FindDepositRevealedEventByRevealTime( + source DepositRevealedEventSource, + walletPublicKeyHash [20]byte, + revealedAt time.Time, + now time.Time, + currentBlock uint64, + averageBlockTime time.Duration, + match func(event *DepositRevealedEvent) bool, +) (*DepositRevealedEvent, error) { + if averageBlockTime <= 0 { + averageBlockTime = DepositRevealLookupDefaultBlockTime + } + + elapsed := now.Sub(revealedAt) + if elapsed < 0 { + elapsed = 0 + } + blocksAgo := uint64(elapsed / averageBlockTime) + + estimatedBlock := uint64(0) + if currentBlock > blocksAgo { + estimatedBlock = currentBlock - blocksAgo + } + + margin := blocksAgo / 20 + if margin < depositRevealLookupMinMarginBlocks { + margin = depositRevealLookupMinMarginBlocks + } + + window := func(margin uint64) (uint64, uint64) { + from := uint64(0) + if estimatedBlock > margin { + from = estimatedBlock - margin + } + to := estimatedBlock + margin + if to > currentBlock { + to = currentBlock + } + return from, to + } + + from, to := window(margin) + event, err := FindDepositRevealedEvent( + source, + walletPublicKeyHash, + from, + to, + match, + ) + if err != nil || event != nil { + return event, err + } + + wideFrom, wideTo := window(margin * depositRevealLookupWidenFactor) + if to < wideTo { + event, err = FindDepositRevealedEvent( + source, + walletPublicKeyHash, + to+1, + wideTo, + match, + ) + if err != nil || event != nil { + return event, err + } + } + if wideFrom < from { + return FindDepositRevealedEvent( + source, + walletPublicKeyHash, + wideFrom, + from-1, + match, + ) + } + + return nil, nil +} diff --git a/pkg/tbtc/deposit_reveal_lookup_test.go b/pkg/tbtc/deposit_reveal_lookup_test.go new file mode 100644 index 0000000000..d542906996 --- /dev/null +++ b/pkg/tbtc/deposit_reveal_lookup_test.go @@ -0,0 +1,266 @@ +package tbtc + +import ( + "fmt" + "testing" + "time" + + "github.com/keep-network/keep-core/pkg/bitcoin" +) + +// rangeDepositRevealedEvents answers PastDepositRevealedEvents the way an +// Ethereum node does: every registered event whose block lies in +// [StartBlock, EndBlock] and whose wallet matches the filter. It rejects +// unbounded queries and queries wider than DepositRevealLookupChunkBlocks, +// so a lookup that stops chunking fails loudly instead of silently +// passing against a lenient fake. It records every queried range. +type rangeDepositRevealedEvents struct { + events []*DepositRevealedEvent + queries [][2]uint64 +} + +func (r *rangeDepositRevealedEvents) PastDepositRevealedEvents( + filter *DepositRevealedEventFilter, +) ([]*DepositRevealedEvent, error) { + if filter == nil || filter.EndBlock == nil { + return nil, fmt.Errorf("unbounded deposit revealed events query") + } + if filter.StartBlock > *filter.EndBlock { + return nil, fmt.Errorf( + "inverted block range [%d, %d]", + filter.StartBlock, + *filter.EndBlock, + ) + } + if *filter.EndBlock-filter.StartBlock+1 > DepositRevealLookupChunkBlocks { + return nil, fmt.Errorf( + "block range [%d, %d] wider than one chunk", + filter.StartBlock, + *filter.EndBlock, + ) + } + r.queries = append(r.queries, [2]uint64{filter.StartBlock, *filter.EndBlock}) + + var result []*DepositRevealedEvent + for _, event := range r.events { + if event.BlockNumber < filter.StartBlock || + event.BlockNumber > *filter.EndBlock { + continue + } + walletMatch := len(filter.WalletPublicKeyHash) == 0 + for _, wallet := range filter.WalletPublicKeyHash { + if wallet == event.WalletPublicKeyHash { + walletMatch = true + } + } + if walletMatch { + result = append(result, event) + } + } + return result, nil +} + +// rangeRevealChain is a localChain whose DepositRevealed events are served +// by rangeDepositRevealedEvents. +type rangeRevealChain struct { + *localChain + reveals *rangeDepositRevealedEvents +} + +func newRangeRevealChain(events ...*DepositRevealedEvent) *rangeRevealChain { + return &rangeRevealChain{ + localChain: Connect(), + reveals: &rangeDepositRevealedEvents{events: events}, + } +} + +func (c *rangeRevealChain) PastDepositRevealedEvents( + filter *DepositRevealedEventFilter, +) ([]*DepositRevealedEvent, error) { + return c.reveals.PastDepositRevealedEvents(filter) +} + +func matchFundingTxHash(hash bitcoin.Hash) func(*DepositRevealedEvent) bool { + return func(event *DepositRevealedEvent) bool { + return event.FundingTxHash == hash + } +} + +// TestFindDepositRevealedEvent_ChunkBoundaries pins the chunked scan: the +// range is covered newest chunk first without gaps or overlaps, an event +// on either edge of a chunk is found, and the scan stops at the first +// match instead of reading older chunks. +func TestFindDepositRevealedEvent_ChunkBoundaries(t *testing.T) { + wallet := [20]byte{0x01} + target := bitcoin.Hash{0xaa} + + tests := map[string]struct { + eventBlock uint64 + expectedQueries [][2]uint64 + }{ + "newest block of the newest chunk": { + eventBlock: 50000, + expectedQueries: [][2]uint64{{40001, 50000}}, + }, + "oldest block of the newest chunk": { + eventBlock: 40001, + expectedQueries: [][2]uint64{{40001, 50000}}, + }, + "newest block of the second chunk": { + eventBlock: 40000, + expectedQueries: [][2]uint64{ + {40001, 50000}, + {30001, 40000}, + }, + }, + "range start inside a partial last chunk": { + eventBlock: 25000, + expectedQueries: [][2]uint64{ + {40001, 50000}, + {30001, 40000}, + {25000, 30000}, + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + source := &rangeDepositRevealedEvents{ + events: []*DepositRevealedEvent{{ + BlockNumber: test.eventBlock, + WalletPublicKeyHash: wallet, + FundingTxHash: target, + }}, + } + + event, err := FindDepositRevealedEvent( + source, + wallet, + 25000, + 50000, + matchFundingTxHash(target), + ) + if err != nil { + t.Fatal(err) + } + if event == nil || event.BlockNumber != test.eventBlock { + t.Fatalf("expected the event at block %d, got %+v", test.eventBlock, event) + } + if fmt.Sprint(source.queries) != fmt.Sprint(test.expectedQueries) { + t.Fatalf( + "unexpected queried ranges\nexpected: %v\nactual: %v", + test.expectedQueries, + source.queries, + ) + } + }) + } + + t.Run("not found covers the whole range once", func(t *testing.T) { + source := &rangeDepositRevealedEvents{} + event, err := FindDepositRevealedEvent( + source, + wallet, + 25000, + 50000, + matchFundingTxHash(target), + ) + if err != nil || event != nil { + t.Fatalf("expected no event and no error, got %+v, %v", event, err) + } + expected := [][2]uint64{{40001, 50000}, {30001, 40000}, {25000, 30000}} + if fmt.Sprint(source.queries) != fmt.Sprint(expected) { + t.Fatalf("expected queries %v, got %v", expected, source.queries) + } + }) + + t.Run("range starting at block zero does not underflow", func(t *testing.T) { + source := &rangeDepositRevealedEvents{} + if _, err := FindDepositRevealedEvent( + source, + wallet, + 0, + 12000, + matchFundingTxHash(target), + ); err != nil { + t.Fatal(err) + } + expected := [][2]uint64{{2001, 12000}, {0, 2000}} + if fmt.Sprint(source.queries) != fmt.Sprint(expected) { + t.Fatalf("expected queries %v, got %v", expected, source.queries) + } + }) +} + +// TestFindDepositRevealedEventByRevealTime pins the reveal-time lookup a +// signer uses for an anchor proposal: a reveal far older than any fixed +// look-back window is found from its on-chain timestamp, block-time drift +// inside the margin is tolerated, and a reveal outside the first window +// but inside the widened one is found by the single retry. +func TestFindDepositRevealedEventByRevealTime(t *testing.T) { + wallet := [20]byte{0x01} + target := bitcoin.Hash{0xaa} + now := time.Now() + currentBlock := uint64(10_000_000) + // 300000 blocks (about 41 days at 12 s) before the current block, + // older than the 216000-block window the lookup replaced. The + // margin for this distance is 5% of 300000 = 15000 blocks. + revealedAt := now.Add(-300000 * 12 * time.Second) + estimatedBlock := currentBlock - 300000 + + tests := map[string]struct { + eventBlock uint64 + expectHit bool + }{ + "exact estimate": {eventBlock: estimatedBlock, expectHit: true}, + "drift inside the first window": {eventBlock: estimatedBlock + 14000, expectHit: true}, + "later block found by widening": {eventBlock: estimatedBlock + 40000, expectHit: true}, + "earlier block found by widening": {eventBlock: estimatedBlock - 40000, expectHit: true}, + "outside the widened window": {eventBlock: estimatedBlock - 70000, expectHit: false}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + source := &rangeDepositRevealedEvents{ + events: []*DepositRevealedEvent{{ + BlockNumber: test.eventBlock, + WalletPublicKeyHash: wallet, + FundingTxHash: target, + }}, + } + + event, err := FindDepositRevealedEventByRevealTime( + source, + wallet, + revealedAt, + now, + currentBlock, + 0, + matchFundingTxHash(target), + ) + if err != nil { + t.Fatal(err) + } + if test.expectHit && (event == nil || event.BlockNumber != test.eventBlock) { + t.Fatalf("expected the event at block %d, got %+v", test.eventBlock, event) + } + if !test.expectHit && event != nil { + t.Fatalf("expected no event, got %+v", event) + } + // No block may be queried twice: the retry scans only the + // two outer strips of the widened window. + for i, a := range source.queries { + for _, b := range source.queries[i+1:] { + if a[0] <= b[1] && b[0] <= a[1] { + t.Fatalf( + "ranges %v and %v overlap: %v", + a, + b, + source.queries, + ) + } + } + } + }) + } +} diff --git a/pkg/tbtc/reservation.go b/pkg/tbtc/reservation.go index 1d1bcdc363..22888c95e2 100644 --- a/pkg/tbtc/reservation.go +++ b/pkg/tbtc/reservation.go @@ -12,12 +12,6 @@ import ( ) const ( - // reservationLookBackBlocks bounds how far back the reservation anchor - // action's DepositRevealed event lookup scans. 216000 blocks is ~30 - // days at 12s/block, the same convention used by - // MovingFundsCommitmentLookBackBlocks in moving_funds.go. - reservationLookBackBlocks = uint64(216000) - // reservationAnchorProposalValidityBlocks determines the reservation // anchor proposal validity time expressed in blocks. reservationAnchorProposalValidityBlocks = 600 @@ -502,36 +496,6 @@ func (raa *reservationAnchorAction) execute() error { return fmt.Errorf("cannot fetch funding transaction: [%v]", err) } - // The proposal carries only the deposit's funding outpoint, not the - // block it was revealed at, so the DepositRevealed event lookup cannot - // be block-range narrowed the way the deposit sweep validation path - // narrows it via DepositsRevealBlocks. Narrow by wallet PKH instead and - // match the exact funding outpoint among the returned events. - eventsStartBlock := uint64(0) - if raa.startBlock > reservationLookBackBlocks { - eventsStartBlock = raa.startBlock - reservationLookBackBlocks - } - - events, err := raa.chain.PastDepositRevealedEvents(&DepositRevealedEventFilter{ - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - StartBlock: eventsStartBlock, - }) - if err != nil { - return fmt.Errorf("cannot fetch deposit revealed events: [%v]", err) - } - - var matchingEvent *DepositRevealedEvent - for _, event := range events { - if event.FundingTxHash == raa.proposal.DepositFundingTxHash && - event.FundingOutputIndex == raa.proposal.DepositFundingOutputIndex { - matchingEvent = event - break - } - } - if matchingEvent == nil { - return fmt.Errorf("no matching DepositRevealed event for deposit") - } - depositRequest, found, err := raa.chain.GetDepositRequest( raa.proposal.DepositFundingTxHash, raa.proposal.DepositFundingOutputIndex, @@ -543,6 +507,30 @@ func (raa *reservationAnchorAction) execute() error { return fmt.Errorf("deposit request not found") } + // The proposal carries only the deposit's funding outpoint, not the + // block it was revealed at. A depositor may request acceptance long + // after the reveal, so the DepositRevealed event is located from the + // deposit's on-chain reveal timestamp rather than from a fixed + // look-back window, and matched on the exact funding outpoint. + matchingEvent, err := FindDepositRevealedEventByRevealTime( + raa.chain, + walletPublicKeyHash, + depositRequest.RevealedAt, + time.Now(), + raa.startBlock, + DepositRevealLookupDefaultBlockTime, + func(event *DepositRevealedEvent) bool { + return event.FundingTxHash == raa.proposal.DepositFundingTxHash && + event.FundingOutputIndex == raa.proposal.DepositFundingOutputIndex + }, + ) + if err != nil { + return fmt.Errorf("cannot fetch deposit revealed events: [%v]", err) + } + if matchingEvent == nil { + return fmt.Errorf("no matching DepositRevealed event for deposit") + } + deposit := matchingEvent.unpack(depositRequest.ExtraData) // m1 identity: the reservation key is the deposit key, mirroring the diff --git a/pkg/tbtc/reservation_test.go b/pkg/tbtc/reservation_test.go index 473903cadf..cd411a1336 100644 --- a/pkg/tbtc/reservation_test.go +++ b/pkg/tbtc/reservation_test.go @@ -748,7 +748,6 @@ func TestReservationAnchorAction_Execute(t *testing.T) { } t.Run("no matching DepositRevealed event", func(t *testing.T) { - chain := Connect() btcChain := newLocalBitcoinChain() fundingTx := &bitcoin.Transaction{ @@ -759,22 +758,20 @@ func TestReservationAnchorAction_Execute(t *testing.T) { } fundingTxHash := fundingTx.Hash() - // A DepositRevealed event exists for this wallet, but for a - // different funding outpoint - the matching loop must walk past - // it and still report no match, not silently accept it. - if err := chain.setPastDepositRevealedEvents( - &DepositRevealedEventFilter{ - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - StartBlock: 300000 - reservationLookBackBlocks, - }, - []*DepositRevealedEvent{{ - FundingTxHash: bitcoin.Hash{0x99}, - FundingOutputIndex: 0, - WalletPublicKeyHash: walletPublicKeyHash, - }}, - ); err != nil { - t.Fatal(err) - } + // A DepositRevealed event exists for this wallet in the searched + // window, but for a different funding outpoint - the matching + // must walk past it and still report no match, not silently + // accept it. + chain := newRangeRevealChain(&DepositRevealedEvent{ + BlockNumber: 299990, + FundingTxHash: bitcoin.Hash{0x99}, + FundingOutputIndex: 0, + WalletPublicKeyHash: walletPublicKeyHash, + }) + chain.setDepositRequest(fundingTxHash, fundingOutputIndex, &DepositChainRequest{ + Amount: 100000, + RevealedAt: time.Now(), + }) err := newAction(chain, btcChain, fundingTxHash).execute() if err == nil || err.Error() != "no matching DepositRevealed event for deposit" { @@ -786,7 +783,6 @@ func TestReservationAnchorAction_Execute(t *testing.T) { }) t.Run("deposit request not found", func(t *testing.T) { - chain := Connect() btcChain := newLocalBitcoinChain() fundingTx := &bitcoin.Transaction{ @@ -797,19 +793,12 @@ func TestReservationAnchorAction_Execute(t *testing.T) { } fundingTxHash := fundingTx.Hash() - if err := chain.setPastDepositRevealedEvents( - &DepositRevealedEventFilter{ - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - StartBlock: 300000 - reservationLookBackBlocks, - }, - []*DepositRevealedEvent{{ - FundingTxHash: fundingTxHash, - FundingOutputIndex: fundingOutputIndex, - WalletPublicKeyHash: walletPublicKeyHash, - }}, - ); err != nil { - t.Fatal(err) - } + chain := newRangeRevealChain(&DepositRevealedEvent{ + BlockNumber: 299990, + FundingTxHash: fundingTxHash, + FundingOutputIndex: fundingOutputIndex, + WalletPublicKeyHash: walletPublicKeyHash, + }) // Deliberately no setDepositRequest call: the Bridge has no // request record for this funding outpoint. @@ -822,81 +811,93 @@ func TestReservationAnchorAction_Execute(t *testing.T) { } }) - t.Run("full happy path up to the signing boundary", func(t *testing.T) { - chain := Connect() - btcChain := newLocalBitcoinChain() + // The happy path runs for a recent reveal and for a reveal older than + // the 216000-block (30-day) window the lookup used to be bounded by: + // a depositor may request acceptance long after revealing, and the + // signer must still find the reveal. The old reveal's event sits + // 3000 blocks off the block estimated from its timestamp, inside the + // lookup margin, as block-time drift would place it. + happyPathCases := map[string]struct { + revealAge time.Duration + revealBlock uint64 + }{ + "recent reveal": { + revealAge: 0, + revealBlock: 299990, + }, + "reveal older than 216000 blocks": { + revealAge: 250000 * 12 * time.Second, + revealBlock: 300000 - 250000 + 3000, + }, + } + for name, happyPathCase := range happyPathCases { + t.Run("full happy path up to the signing boundary: "+name, func(t *testing.T) { + btcChain := newLocalBitcoinChain() - depositForScript := &Deposit{ - Depositor: "0x0000000000000000000000000000000000000001", - WalletPublicKeyHash: walletPublicKeyHash, - } - depositScript, err := depositForScript.Script() - if err != nil { - t.Fatal(err) - } - scriptHash := sha256.Sum256(depositScript) - fundingOutputScript, err := bitcoin.PayToWitnessScriptHash(scriptHash) - if err != nil { - t.Fatal(err) - } + depositForScript := &Deposit{ + Depositor: "0x0000000000000000000000000000000000000001", + WalletPublicKeyHash: walletPublicKeyHash, + } + depositScript, err := depositForScript.Script() + if err != nil { + t.Fatal(err) + } + scriptHash := sha256.Sum256(depositScript) + fundingOutputScript, err := bitcoin.PayToWitnessScriptHash(scriptHash) + if err != nil { + t.Fatal(err) + } - fundingTx := &bitcoin.Transaction{ - Outputs: []*bitcoin.TransactionOutput{{ - Value: 100000, - PublicKeyScript: fundingOutputScript, - }}, - } - if err := btcChain.BroadcastTransaction(fundingTx); err != nil { - t.Fatal(err) - } - fundingTxHash := fundingTx.Hash() + fundingTx := &bitcoin.Transaction{ + Outputs: []*bitcoin.TransactionOutput{{ + Value: 100000, + PublicKeyScript: fundingOutputScript, + }}, + } + if err := btcChain.BroadcastTransaction(fundingTx); err != nil { + t.Fatal(err) + } + fundingTxHash := fundingTx.Hash() - if err := chain.setPastDepositRevealedEvents( - &DepositRevealedEventFilter{ - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - StartBlock: 300000 - reservationLookBackBlocks, - }, - []*DepositRevealedEvent{{ + chain := newRangeRevealChain(&DepositRevealedEvent{ + BlockNumber: happyPathCase.revealBlock, FundingTxHash: fundingTxHash, FundingOutputIndex: fundingOutputIndex, WalletPublicKeyHash: walletPublicKeyHash, Amount: 100000, Depositor: "0x0000000000000000000000000000000000000001", - }}, - ); err != nil { - t.Fatal(err) - } - chain.setDepositRequest(fundingTxHash, fundingOutputIndex, &DepositChainRequest{ - Amount: 100000, - RevealedAt: time.Now(), - }) - chain.setReservationAction(&ReservationAction{ - ActionType: ReservationActionTypeAcceptance, - State: ReservationActionStatePending, - TargetWalletPublicKeyHash: walletPublicKeyHash, - TxMaxFee: 2000, + }) + chain.setDepositRequest(fundingTxHash, fundingOutputIndex, &DepositChainRequest{ + Amount: 100000, + RevealedAt: time.Now().Add(-happyPathCase.revealAge), + }) + chain.setReservationAction(&ReservationAction{ + ActionType: ReservationActionTypeAcceptance, + State: ReservationActionStatePending, + TargetWalletPublicKeyHash: walletPublicKeyHash, + TxMaxFee: 2000, + }) + + 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, + ) + } }) - - action := newAction(chain, btcChain, fundingTxHash) - // Below reservationActionSigningTimeoutSafetyMarginBlocks (300): - // every real upstream step (event match, deposit request fetch, - // reservation key derivation, action load, target wallet match, - // on-chain validation, transaction assembly) must succeed before - // this guard is reached and rejects the proposal - reaching this - // exact error is the test's proof that all of it worked. - action.expiryBlock = 100 - - err = action.execute() - if err == nil || err.Error() != "invalid proposal expiry block" { - t.Errorf( - "unexpected error\nexpected: [invalid proposal expiry block]\nactual: [%v]", - err, - ) - } - }) + } t.Run("target wallet mismatch is rejected before signing", func(t *testing.T) { - chain := Connect() btcChain := newLocalBitcoinChain() depositForScript := &Deposit{ @@ -924,21 +925,14 @@ func TestReservationAnchorAction_Execute(t *testing.T) { } fundingTxHash := fundingTx.Hash() - if err := chain.setPastDepositRevealedEvents( - &DepositRevealedEventFilter{ - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - StartBlock: 300000 - reservationLookBackBlocks, - }, - []*DepositRevealedEvent{{ - FundingTxHash: fundingTxHash, - FundingOutputIndex: fundingOutputIndex, - WalletPublicKeyHash: walletPublicKeyHash, - Amount: 100000, - Depositor: "0x0000000000000000000000000000000000000001", - }}, - ); err != nil { - t.Fatal(err) - } + chain := newRangeRevealChain(&DepositRevealedEvent{ + BlockNumber: 299990, + FundingTxHash: fundingTxHash, + FundingOutputIndex: fundingOutputIndex, + WalletPublicKeyHash: walletPublicKeyHash, + Amount: 100000, + Depositor: "0x0000000000000000000000000000000000000001", + }) chain.setDepositRequest(fundingTxHash, fundingOutputIndex, &DepositChainRequest{ Amount: 100000, RevealedAt: time.Now(), From 5019414f953cd7baf6ca9c0b65e3ea901402fe48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 30 Sep 2026 08:51:46 +0000 Subject: [PATCH 17/31] fix(tbtcpg): send at most one re-anchor request per Run pass Estimate the fee and assemble the re-anchor transaction against the live ReservationTxMaxFee, which Solidity snapshots into the new action, before sending RequestReservationReanchor, so a fee spike no longer leaves an unusable request holding the target's capacity. Any failure after the request was sent now ends the pass instead of moving on to another reservation; only pre-submit capacity reverts retry the next target. The mined-wait stops as soon as the nonce advances to another requester's action, and the not-confirmed error now wraps its cause. --- pkg/tbtcpg/reservation_reanchor.go | 247 +++++++++++++----- .../reservation_reanchor_inflight_test.go | 204 +++++++++++++++ 2 files changed, 389 insertions(+), 62 deletions(-) diff --git a/pkg/tbtcpg/reservation_reanchor.go b/pkg/tbtcpg/reservation_reanchor.go index e44ee5a6ce..50e035f98c 100644 --- a/pkg/tbtcpg/reservation_reanchor.go +++ b/pkg/tbtcpg/reservation_reanchor.go @@ -116,6 +116,9 @@ func (rrt *ReservationReanchorTask) ActionType() tbtc.WalletActionType { // resumed at its real nonce and authorized target rather than skipped, so // an in-flight authorization is not abandoned and re-requested. // +// A pass sends at most one RequestReservationReanchor: once a request has +// been sent, any later failure ends the pass without a proposal. +// // Once a MovingFunds wallet's reservations are fully drained, Run also // checks whether its main UTXO has fallen below the moving funds dust // threshold and, if so, notifies the Bridge so wallet closing can proceed @@ -371,6 +374,15 @@ reservationLoop: err, ) + var postSubmitErr *errReservationReanchorPostSubmitFailure + if errors.As(err, &postSubmitErr) { + // The request was sent. End the pass so it sends at + // most one request; a later round picks the request + // up from chain state (resume path once mined, a new + // request if the reservation is still Active). + return nil, false, nil + } + if isReservationCapRevertError(err) { // The target's capacity changed since our headroom // pre-check (a concurrent re-anchor or acceptance @@ -381,10 +393,9 @@ reservationLoop: continue } - // Skip this reservation this pass. A request that went - // on-chain is picked up from chain state on a later - // round: once mined, the reservation is ActionPending - // and takes the resume path above. + // Nothing was sent (fee estimation, assembly, or another + // request revert), so no chain state changed: skip this + // reservation this pass. continue reservationLoop } @@ -416,24 +427,48 @@ func isReservationCapRevertError(err error) bool { return strings.Contains(err.Error(), "cap exceeded") } +// errReservationReanchorPostSubmitFailure marks a ProposeReservationReanchor +// failure that happened after RequestReservationReanchor was sent. Run +// checks for it with errors.As and ends the pass instead of moving on to +// another target or reservation, so a pass sends at most one re-anchor +// request. A failure before the request was sent leaves no chain state +// behind and is not wrapped. +type errReservationReanchorPostSubmitFailure struct { + err error +} + +func (e *errReservationReanchorPostSubmitFailure) Error() string { + return e.err.Error() +} + +func (e *errReservationReanchorPostSubmitFailure) Unwrap() error { + return e.err +} + // ProposeReservationReanchor assembles a single reservation re-anchor // proposal for the given reservation, targeting the given wallet. // // If the reservation's current generation is not already a Pending // Reanchor action authorizing this exact target, this call first requests // one on-chain (RequestReservationReanchor) and waits for it to be mined -// before re-reading the reservation and action for the real request nonce -// and snapshotted fee bound; only then does it build and validate the -// proposal. This ordering is required by the on-chain validator, which -// requires the action at proposal.RequestNonce to already be a Pending -// Reanchor -- Reservation.sol's requestReservationReanchor is what writes -// that record, so validating first would read the zero action and always -// revert. +// before re-reading the reservation and action for the real request nonce; +// only then does it build and validate the proposal. This ordering is +// required by the on-chain validator, which requires the action at +// proposal.RequestNonce to already be a Pending Reanchor -- +// Reservation.sol's requestReservationReanchor is what writes that record, +// so validating first would read the zero action and always revert. +// +// Before sending the request, the fee is estimated and the transaction is +// assembled against the live ReservationTxMaxFee, the value Solidity +// snapshots into the action the request creates. A fee that cannot fit +// therefore fails before anything is sent instead of leaving an unusable +// request on-chain. Any failure after the request was sent is returned as +// an *errReservationReanchorPostSubmitFailure. // // A caller resuming an already-Pending generation (Run does this for // reservations in ActionPending state) passes that generation's own -// authorized target and reaches this method with no chain write of its -// own; a failure here is therefore a pre-write failure, safe to retry. +// authorized target; the fee is then checked against the action's own +// snapshotted TxMaxFee and no chain write happens. // // The supplied fee may be 0 to trigger on-chain-driven fee estimation. func (rrt *ReservationReanchorTask) ProposeReservationReanchor( @@ -489,58 +524,120 @@ func (rrt *ReservationReanchorTask) ProposeReservationReanchor( action.ActionType == tbtc.ReservationActionTypeReanchor && action.State == tbtc.ReservationActionStatePending && action.TargetWalletPublicKeyHash == targetWalletPublicKeyHash - requestNonce := reservation.RequestNonce - if !resuming { - if reservation.State != tbtc.ReservationStateActive { - return nil, fmt.Errorf( - "reservation [0x%x] is not active and has no matching "+ - "pending re-anchor action to resume", - reservationKey, - ) + if resuming { + // The fee bound is the action's own snapshotted txMaxFee: + // WalletProposalValidator.validateReservationReanchorProposal + // checks the proposed fee against action.txMaxFee, not the live + // parameter. + fee, err = rrt.prepareReservationReanchorTransaction( + taskLogger, + reservation.AnchorUtxo, + targetWalletPublicKeyHash, + action.TxMaxFee, + fee, + ) + if err != nil { + return nil, err } - taskLogger.Infof( - "requesting reservation re-anchor for [0x%x] to wallet [0x%x]", + return rrt.validatedReservationReanchorProposal( + taskLogger, + sourceWalletPublicKeyHash, reservationKey, + reservation.RequestNonce, targetWalletPublicKeyHash, + fee, ) + } - if err := rrt.chain.RequestReservationReanchor( + if reservation.State != tbtc.ReservationStateActive { + return nil, fmt.Errorf( + "reservation [0x%x] is not active and has no matching "+ + "pending re-anchor action to resume", reservationKey, - targetWalletPublicKeyHash, - ); err != nil { - return nil, fmt.Errorf("cannot request reservation re-anchor: [%w]", err) - } + ) + } - reservation, action, err = rrt.waitForReservationReanchorRequestMined( - taskLogger, - reservationKey, - targetWalletPublicKeyHash, - requestNonce, + params, err := rrt.chain.ReservationParameters() + if err != nil { + return nil, fmt.Errorf( + "cannot get reservation parameters: [%w]", + err, ) - if err != nil { - return nil, fmt.Errorf( + } + + fee, err = rrt.prepareReservationReanchorTransaction( + taskLogger, + reservation.AnchorUtxo, + targetWalletPublicKeyHash, + params.ReservationTxMaxFee, + fee, + ) + if err != nil { + return nil, err + } + + taskLogger.Infof( + "requesting reservation re-anchor for [0x%x] to wallet [0x%x]", + reservationKey, + targetWalletPublicKeyHash, + ) + + if err := rrt.chain.RequestReservationReanchor( + reservationKey, + targetWalletPublicKeyHash, + ); err != nil { + return nil, fmt.Errorf("cannot request reservation re-anchor: [%w]", err) + } + + minedReservation, err := rrt.waitForReservationReanchorRequestMined( + taskLogger, + reservationKey, + targetWalletPublicKeyHash, + reservation.RequestNonce, + ) + if err != nil { + return nil, &errReservationReanchorPostSubmitFailure{ + err: fmt.Errorf( "reservation re-anchor request not confirmed: [%w]", err, - ) + ), } - requestNonce = reservation.RequestNonce } - // The fee bound is the action's own snapshotted txMaxFee, not a live - // parameter value: WalletProposalValidator.validateReservationReanchorProposal - // checks the proposed fee against action.txMaxFee specifically (mirroring - // how it checks a redemption's fee against the request's own stored - // txMaxFee rather than a live Bridge parameter). - feeBoundAction := &tbtc.ReservationAction{TxMaxFee: action.TxMaxFee} + proposal, err := rrt.validatedReservationReanchorProposal( + taskLogger, + sourceWalletPublicKeyHash, + reservationKey, + minedReservation.RequestNonce, + targetWalletPublicKeyHash, + fee, + ) + if err != nil { + return nil, &errReservationReanchorPostSubmitFailure{err: err} + } + + return proposal, nil +} +// prepareReservationReanchorTransaction estimates the re-anchor fee when +// fee is not positive, and checks that the re-anchor transaction can be +// assembled with that fee under txMaxFee. It returns the fee to propose. +func (rrt *ReservationReanchorTask) prepareReservationReanchorTransaction( + taskLogger log.StandardLogger, + anchorUtxo *bitcoin.UnspentTransactionOutput, + targetWalletPublicKeyHash [20]byte, + txMaxFee uint64, + fee int64, +) (int64, error) { if fee <= 0 { taskLogger.Infof("estimating reservation re-anchor transaction fee") - fee, err = estimateReservationReanchorFee(rrt.btcChain, action.TxMaxFee) + var err error + fee, err = estimateReservationReanchorFee(rrt.btcChain, txMaxFee) if err != nil { - return nil, fmt.Errorf( + return 0, fmt.Errorf( "cannot estimate reservation re-anchor transaction fee: [%w]", err, ) @@ -551,17 +648,30 @@ func (rrt *ReservationReanchorTask) ProposeReservationReanchor( if _, err := tbtc.AssembleReservationReanchorTransaction( rrt.btcChain, - reservation.AnchorUtxo, + anchorUtxo, targetWalletPublicKeyHash, - feeBoundAction, + &tbtc.ReservationAction{TxMaxFee: txMaxFee}, fee, ); err != nil { - return nil, fmt.Errorf( + return 0, fmt.Errorf( "cannot assemble reservation re-anchor transaction: [%v]", err, ) } + return fee, nil +} + +// validatedReservationReanchorProposal builds the proposal for the given +// generation and validates it on-chain. +func (rrt *ReservationReanchorTask) validatedReservationReanchorProposal( + taskLogger log.StandardLogger, + sourceWalletPublicKeyHash [20]byte, + reservationKey *big.Int, + requestNonce uint64, + targetWalletPublicKeyHash [20]byte, + fee int64, +) (*tbtc.ReservationReanchorProposal, error) { proposal := &tbtc.ReservationReanchorProposal{ ReservationKey: new(big.Int).Set(reservationKey), RequestNonce: requestNonce, @@ -589,26 +699,28 @@ func (rrt *ReservationReanchorTask) ProposeReservationReanchor( // RequestReservationReanchor transaction to be mined: the reservation's // RequestNonce must have advanced past preRequestNonce, and the action at // the new nonce must be the Pending Reanchor generation authorizing -// targetWalletPublicKeyHash. +// targetWalletPublicKeyHash. If the nonce advanced to any other action, +// another request was mined first and this one can only revert, so the +// wait ends immediately with an error. func (rrt *ReservationReanchorTask) waitForReservationReanchorRequestMined( taskLogger log.StandardLogger, reservationKey *big.Int, targetWalletPublicKeyHash [20]byte, preRequestNonce uint64, -) (*tbtc.Reservation, *tbtc.ReservationAction, error) { +) (*tbtc.Reservation, error) { blockCounter, err := rrt.chain.BlockCounter() if err != nil { - return nil, nil, fmt.Errorf("error getting block counter: [%w]", err) + return nil, fmt.Errorf("error getting block counter: [%w]", err) } currentBlock, err := blockCounter.CurrentBlock() if err != nil { - return nil, nil, fmt.Errorf("error getting current block: [%w]", err) + return nil, fmt.Errorf("error getting current block: [%w]", err) } for blockHeight := currentBlock + 1; blockHeight <= currentBlock+reservationReanchorRequestWaitBlocks; blockHeight++ { if err := blockCounter.WaitForBlockHeight(blockHeight); err != nil { - return nil, nil, fmt.Errorf("error while waiting for block height: [%w]", err) + return nil, fmt.Errorf("error while waiting for block height: [%w]", err) } reservation, err := rrt.chain.GetReservation(reservationKey) @@ -636,18 +748,29 @@ func (rrt *ReservationReanchorTask) waitForReservationReanchorRequestMined( continue } - if action.ActionType == tbtc.ReservationActionTypeReanchor && - action.State == tbtc.ReservationActionStatePending && - action.TargetWalletPublicKeyHash == targetWalletPublicKeyHash { - taskLogger.Infof( - "reservation re-anchor request for [0x%x] confirmed at "+ - "block [%d], nonce [%d]", + if action.ActionType != tbtc.ReservationActionTypeReanchor || + action.State != tbtc.ReservationActionStatePending || + action.TargetWalletPublicKeyHash != targetWalletPublicKeyHash { + return nil, fmt.Errorf( + "reservation [0x%x] advanced to nonce [%d] with a "+ + "different action (type=%v, state=%v, target=0x%x); "+ + "another request was mined first", reservationKey, - blockHeight, reservation.RequestNonce, + action.ActionType, + action.State, + action.TargetWalletPublicKeyHash, ) - return reservation, action, nil } + + taskLogger.Infof( + "reservation re-anchor request for [0x%x] confirmed at "+ + "block [%d], nonce [%d]", + reservationKey, + blockHeight, + reservation.RequestNonce, + ) + return reservation, nil } taskLogger.Infof( @@ -658,7 +781,7 @@ func (rrt *ReservationReanchorTask) waitForReservationReanchorRequestMined( ) } - return nil, nil, fmt.Errorf( + return nil, fmt.Errorf( "reservation re-anchor request for [0x%x] not confirmed within "+ "[%d] blocks", reservationKey, diff --git a/pkg/tbtcpg/reservation_reanchor_inflight_test.go b/pkg/tbtcpg/reservation_reanchor_inflight_test.go index a054d88755..2bd7c8804b 100644 --- a/pkg/tbtcpg/reservation_reanchor_inflight_test.go +++ b/pkg/tbtcpg/reservation_reanchor_inflight_test.go @@ -481,3 +481,207 @@ func TestReservationReanchorTask_ClosingSource(t *testing.T) { } }) } + +// addActiveReanchorReservation adds another Active reservation, with its +// anchor transaction, to the source wallet of a newInFlightReanchorFixture. +func addActiveReanchorReservation( + t *testing.T, + tbtcChain *LocalChain, + btcChain *LocalBitcoinChain, + sourceWalletPublicKeyHash [20]byte, + reservationKey *big.Int, + anchorTxHash bitcoin.Hash, + anchorValue int64, +) { + t.Helper() + + sourceWalletScript, err := bitcoin.PayToWitnessPublicKeyHash(sourceWalletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + btcChain.SetTransaction(anchorTxHash, &bitcoin.Transaction{ + Version: 1, + Outputs: []*bitcoin.TransactionOutput{ + {Value: anchorValue, PublicKeyScript: sourceWalletScript}, + }, + }) + tbtcChain.SetReservation(reservationKey, &tbtc.Reservation{ + WalletPublicKeyHash: sourceWalletPublicKeyHash, + AnchorUtxo: &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: anchorTxHash, + OutputIndex: 0, + }, + Value: anchorValue, + }, + State: tbtc.ReservationStateActive, + }) + keys, err := tbtcChain.WalletReservations(sourceWalletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + tbtcChain.SetWalletReservations( + sourceWalletPublicKeyHash, + append(keys, reservationKey), + ) +} + +// TestReservationReanchorTask_FeeAboveCap_SendsNoRequest pins that the fee +// is estimated against the live ReservationTxMaxFee before the request is +// sent. During a Bitcoin fee spike the estimate exceeds the cap, and the +// pass must end without an on-chain request: a request sent first would +// only hold the target's capacity until the action times out and then +// start a cooldown. +func TestReservationReanchorTask_FeeAboveCap_SendsNoRequest(t *testing.T) { + tbtcChain, btcChain, _, task, sourceWalletPublicKeyHash, _, _ := + newInFlightReanchorFixture(t) + // About 110 vbytes at 1000 sat/vbyte is well above the 100000 cap. + btcChain.SetEstimateSatPerVByteFee(1, 1000) + + proposal, ok, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: sourceWalletPublicKeyHash, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok || proposal != nil { + t.Fatalf("expected no proposal, got ok=%v proposal=%v", ok, proposal) + } + if got := len(tbtcChain.GetReservationReanchorRequestAttempts()); got != 0 { + t.Fatalf("expected no re-anchor request when the fee exceeds the cap, got %d", got) + } +} + +// TestReservationReanchorTask_OneRequestPerPass pins that a pass sends at +// most one RequestReservationReanchor. Once a request has been sent, any +// later failure (not mined within the wait, or proposal validation +// failing) must end the pass instead of moving on to the next +// reservation, which would send a second request in the same pass. +func TestReservationReanchorTask_OneRequestPerPass(t *testing.T) { + t.Run("request not mined within the wait", func(t *testing.T) { + tbtcChain, btcChain, _, task, sourceWalletPublicKeyHash, _, _ := + newInFlightReanchorFixture(t) + addActiveReanchorReservation( + t, tbtcChain, btcChain, sourceWalletPublicKeyHash, + big.NewInt(7002), bitcoin.Hash{0x72}, 200000, + ) + tbtcChain.SetNextReservationReanchorRequestPending() + + proposal, ok, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: sourceWalletPublicKeyHash, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok || proposal != nil { + t.Fatalf("expected no proposal, got ok=%v proposal=%v", ok, proposal) + } + if got := len(tbtcChain.GetReservationReanchorRequestAttempts()); got != 1 { + t.Fatalf("expected exactly 1 re-anchor request in the pass, got %d", got) + } + }) + + t.Run("validation fails after the request was mined", func(t *testing.T) { + tbtcChain, btcChain, _, task, sourceWalletPublicKeyHash, targetWalletPublicKeyHash, reservationKey := + newInFlightReanchorFixture(t) + addActiveReanchorReservation( + t, tbtcChain, btcChain, sourceWalletPublicKeyHash, + big.NewInt(7002), bitcoin.Hash{0x72}, 200000, + ) + if err := tbtcChain.SetReservationReanchorProposalValidationResult( + sourceWalletPublicKeyHash, + &tbtc.ReservationReanchorProposal{ + ReservationKey: reservationKey, + RequestNonce: 1, + TargetWalletPublicKeyHash: targetWalletPublicKeyHash, + ReanchorTxFee: big.NewInt(550), + }, + false, + ); err != nil { + t.Fatal(err) + } + + proposal, ok, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: sourceWalletPublicKeyHash, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok || proposal != nil { + t.Fatalf("expected no proposal, got ok=%v proposal=%v", ok, proposal) + } + if got := tbtcChain.GetReservationReanchorValidationCallCount(); got != 1 { + t.Fatalf("expected the forced validation failure to be reached once, got %d", got) + } + if got := len(tbtcChain.GetReservationReanchorRequestAttempts()); got != 1 { + t.Fatalf("expected exactly 1 re-anchor request in the pass, got %d", got) + } + }) +} + +// frontRunReanchorChain models another requester whose re-anchor request +// for the same reservation, to a different target, is mined before ours: +// our request call succeeds, but the chain holds the other generation. +type frontRunReanchorChain struct { + *LocalChain + + frontRunTarget [20]byte +} + +func (c *frontRunReanchorChain) RequestReservationReanchor( + reservationKey *big.Int, + targetWalletPublicKeyHash [20]byte, +) error { + return c.LocalChain.RequestReservationReanchor(reservationKey, c.frontRunTarget) +} + +// countingBlockCounter counts WaitForBlockHeight calls. +type countingBlockCounter struct { + *MockBlockCounter + + waits int +} + +func (c *countingBlockCounter) WaitForBlockHeight(blockNumber uint64) error { + c.waits++ + return c.MockBlockCounter.WaitForBlockHeight(blockNumber) +} + +// TestReservationReanchorTask_FrontRunRequest_StopsWaiting pins that the +// mined-wait stops as soon as the reservation's nonce advances to an +// action that is not ours: our request can then only revert, so waiting +// out the remaining blocks would only delay the coordination window. +func TestReservationReanchorTask_FrontRunRequest_StopsWaiting(t *testing.T) { + tbtcChain, btcChain, _, _, sourceWalletPublicKeyHash, _, _ := + newInFlightReanchorFixture(t) + + frontRunTarget := [20]byte{9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9} + tbtcChain.SetWallet(frontRunTarget, &tbtc.WalletChainData{State: tbtc.StateLive}) + + blockCounter := &countingBlockCounter{MockBlockCounter: NewMockBlockCounter()} + blockCounter.SetCurrentBlock(1000) + tbtcChain.SetBlockCounter(blockCounter) + + chain := &frontRunReanchorChain{LocalChain: tbtcChain, frontRunTarget: frontRunTarget} + task := NewReservationReanchorTask(chain, btcChain) + + proposal, ok, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: sourceWalletPublicKeyHash, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok || proposal != nil { + t.Fatalf("expected no proposal for a front-run request, got ok=%v proposal=%v", ok, proposal) + } + if blockCounter.waits != 1 { + t.Fatalf( + "expected the wait to stop at the first block showing the "+ + "other generation, waited for %d blocks", + blockCounter.waits, + ) + } + if got := len(tbtcChain.GetReservationReanchorRequestAttempts()); got != 1 { + t.Fatalf("expected exactly 1 re-anchor request in the pass, got %d", got) + } +} From ff1254bfc9cf71d55d766f2a435613d1a46584d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 30 Sep 2026 08:52:26 +0000 Subject: [PATCH 18/31] fix(spv): submit one reservation proof per key per pass and drop unsettleable generations A TimedOut acceptance generation is evicted once its reservation exists, since the Bridge then rejects every other acceptance proof. Each pass walks a reservation's generations Pending first, then TimedOut by descending nonce, and stops after the first submission attempt, so two generations matching the same Bitcoin transaction no longer broadcast a second, reverting proof. A TimedOut re-anchor generation is evicted when the reservation is no longer Active, ActionPending or Stranded, its reservation record is read once per pass and reused for proving, and a TimedOut generation with no matching or provable transaction is re-checked on a doubling backoff capped at 32 passes, ended early when the source wallet's transaction list changes. --- pkg/maintainer/spv/reservation_proof_loop.go | 611 ++++++++++++------ .../spv/reservation_proof_loop_test.go | 326 +++++++++- 2 files changed, 738 insertions(+), 199 deletions(-) diff --git a/pkg/maintainer/spv/reservation_proof_loop.go b/pkg/maintainer/spv/reservation_proof_loop.go index 56c5241860..4f5d10aba4 100644 --- a/pkg/maintainer/spv/reservation_proof_loop.go +++ b/pkg/maintainer/spv/reservation_proof_loop.go @@ -5,6 +5,7 @@ import ( "encoding/binary" "fmt" "math/big" + "sort" "time" "github.com/ethereum/go-ethereum/crypto" @@ -29,6 +30,13 @@ type reservationProofScanState struct { reanchorLastScannedBlock uint64 pendingReanchorEvents map[string]*tbtc.ReservationReanchorRequestedEvent + // reanchorPass counts proveReservationReanchorActions passes; + // reanchorBackoff holds, per pendingReanchorEvents key, the backoff + // delay of a TimedOut generation whose last check found nothing to + // prove (see reanchorProofBackoff). + reanchorPass uint64 + reanchorBackoff map[string]*reanchorProofBackoff + // walletTransactionCache caches each wallet's confirmed Bitcoin // transaction hash set and bodies from the pass that fetched them, so // walletTransactionsForProof can skip the full GetTransactionsForPublicKeyHash @@ -61,6 +69,7 @@ func newReservationProofScanState() *reservationProofScanState { return &reservationProofScanState{ pendingAcceptanceEvents: make(map[string]*tbtc.ReservationAcceptanceRequestedEvent), pendingReanchorEvents: make(map[string]*tbtc.ReservationReanchorRequestedEvent), + reanchorBackoff: make(map[string]*reanchorProofBackoff), walletTransactionCache: make(map[[20]byte]*walletTransactionCacheEntry), nowFn: defaultReservationProofNowFn, } @@ -397,16 +406,15 @@ func proveReservationAcceptanceActions( state.acceptanceLastScannedBlock = endBlock } - // Re-check every tracked event's on-chain action state, evict the - // non-settleable ones, and group the still-settleable events by - // wallet public key hash. The Bridge settles a generation while its + // Re-check every tracked event's on-chain action state and evict the + // non-settleable ones. The Bridge settles a generation while its // action is Pending or TimedOut: a TimedOut acceptance generation // stays settleable only through timeoutAt + termSeconds (see // loadSettleableAction in ReservationProofs.sol), so the scans keep // TimedOut candidates until that window has closed instead of // deleting them the moment the state flips. now := state.nowFn() - walletEvents := make(map[[20]byte][]*tbtc.ReservationAcceptanceRequestedEvent) + var generations []reservationProofGeneration[*tbtc.ReservationAcceptanceRequestedEvent] for key, event := range state.pendingAcceptanceEvents { action, err := spvChain.GetReservationAction( event.ReservationKey, @@ -435,6 +443,28 @@ func proveReservationAcceptanceActions( delete(state.pendingAcceptanceEvents, key) continue } + + // Once any generation of this reservation has settled, the + // Bridge rejects every other one ("Reservation already + // exists" in submitReservationAcceptanceProof). Settling + // also marks the deposit swept, and a reserved deposit + // cannot be swept any other way, so the reservation state + // alone decides it. + reservation, err := spvChain.GetReservation(event.ReservationKey) + if err != nil { + logger.Errorf( + "failed to load reservation [%v] for timed-out "+ + "acceptance generation %d: [%v]", + event.ReservationKey, + event.RequestNonce, + err, + ) + continue + } + if reservation.State != tbtc.ReservationStateUnknown { + delete(state.pendingAcceptanceEvents, key) + continue + } case tbtc.ReservationActionStatePending: default: // Settled, Superseded, Vetoed and absent generations are not @@ -443,78 +473,101 @@ func proveReservationAcceptanceActions( continue } - walletEvents[event.WalletPublicKeyHash] = append( - walletEvents[event.WalletPublicKeyHash], - event, + generations = append( + generations, + reservationProofGeneration[*tbtc.ReservationAcceptanceRequestedEvent]{ + event: event, + reservationKey: event.ReservationKey, + requestNonce: event.RequestNonce, + pending: action.State == tbtc.ReservationActionStatePending, + }, ) } - - for walletPublicKeyHash, events := range walletEvents { - walletTransactions, err := walletTransactionsForProof( - state, - btcChain, - walletPublicKeyHash, - config.TransactionLimit, - ) - if err != nil { - logger.Errorf("failed to get transactions for wallet: [%v]", err) + sortReservationProofGenerations(generations) + + // Each wallet's candidate transactions are indexed once per pass, by + // deposit key. A wallet that broadcasts an RBF replacement chain for + // the same deposit key produces multiple candidates per key, so + // every candidate is kept rather than only the last one seen. A nil + // entry marks a wallet whose transactions could not be fetched this + // pass. + walletCandidates := make(map[[20]byte]map[string][]*bitcoin.Transaction) + submittedReservations := make(map[string]struct{}) + for _, generation := range generations { + event := generation.event + reservationKey := event.ReservationKey.String() + if _, submitted := submittedReservations[reservationKey]; submitted { continue } - // Index wallet transactions by deposit key for O(1) matching. A - // wallet that broadcasts an RBF replacement chain for the same - // deposit key produces multiple candidates per key, so every - // candidate is kept rather than only the last one seen. - candidateTransactions := make(map[string][]*bitcoin.Transaction) - for _, transaction := range walletTransactions { - if len(transaction.Inputs) == 1 && len(transaction.Outputs) == 1 && transaction.Inputs[0].Outpoint != nil { - input := transaction.Inputs[0] - depositKey := spvChain.BuildDepositKey( - input.Outpoint.TransactionHash, - input.Outpoint.OutputIndex, - ) - key := depositKey.String() - candidateTransactions[key] = append(candidateTransactions[key], transaction) + candidateTransactions, indexed := walletCandidates[event.WalletPublicKeyHash] + if !indexed { + walletTransactions, err := walletTransactionsForProof( + state, + btcChain, + event.WalletPublicKeyHash, + config.TransactionLimit, + ) + if err != nil { + logger.Errorf("failed to get transactions for wallet: [%v]", err) + } else { + candidateTransactions = make(map[string][]*bitcoin.Transaction) + for _, transaction := range walletTransactions { + if len(transaction.Inputs) == 1 && len(transaction.Outputs) == 1 && transaction.Inputs[0].Outpoint != nil { + input := transaction.Inputs[0] + depositKey := spvChain.BuildDepositKey( + input.Outpoint.TransactionHash, + input.Outpoint.OutputIndex, + ).String() + candidateTransactions[depositKey] = append( + candidateTransactions[depositKey], + transaction, + ) + } + } } + walletCandidates[event.WalletPublicKeyHash] = candidateTransactions } - for _, event := range events { - candidates, ok := candidateTransactions[event.ReservationKey.String()] - if !ok { - continue - } + candidates, ok := candidateTransactions[reservationKey] + if !ok { + continue + } - if err := proveReservationTransaction( - candidates, - func(transaction *bitcoin.Transaction) bool { - return isMatchingReservationAcceptanceTransaction(spvChain, event, transaction) - }, - btcChain, - spvChain, - btcDiffChain, - config.MaxProofHeaders, - cache, - metricsRecorder, - func(transactionHash bitcoin.Hash, requiredConfirmations uint) error { - return SubmitReservationAcceptanceProof( - transactionHash, - requiredConfirmations, - event.ReservationKey, - event.RequestNonce, - btcChain, - spvChain, - metricsRecorder, - ) - }, - ); err != nil { - logger.Errorf( - "failed to prove reservation acceptance transaction "+ - "for reservation [%v]: [%v]", + outcome, err := proveReservationTransaction( + candidates, + func(transaction *bitcoin.Transaction) bool { + return isMatchingReservationAcceptanceTransaction(spvChain, event, transaction) + }, + btcChain, + spvChain, + btcDiffChain, + config.MaxProofHeaders, + cache, + metricsRecorder, + func(transactionHash bitcoin.Hash, requiredConfirmations uint) error { + return SubmitReservationAcceptanceProof( + transactionHash, + requiredConfirmations, event.ReservationKey, - err, + event.RequestNonce, + btcChain, + spvChain, + metricsRecorder, ) - continue - } + }, + ) + if outcome == reservationProofSubmitted { + submittedReservations[reservationKey] = struct{}{} + } + if err != nil { + logger.Errorf( + "failed to prove reservation acceptance transaction "+ + "for reservation [%v]: [%v]", + event.ReservationKey, + err, + ) + continue } } @@ -571,70 +624,185 @@ func isMatchingReservationAcceptanceTransaction( return true } -// evictReanchorGenerationIfSourceAnchorMoved reports whether the TimedOut -// re-anchor generation identified by key must be evicted from state -// because the reservation's current anchor outpoint no longer matches -// the source anchor snapshotted on-chain when the generation was -// requested. +// reservationProofGeneration is one still-settleable action generation a +// proof loop pass may prove. reservation is the reservation record read +// while classifying a re-anchor generation, reused when proving it; it is +// nil for acceptance generations. +type reservationProofGeneration[E any] struct { + key string + event E + reservationKey *big.Int + requestNonce uint64 + pending bool + reservation *tbtc.Reservation +} + +// sortReservationProofGenerations orders generations for proof +// submission: grouped by reservation key, the Pending generation first, +// then TimedOut generations from the highest nonce down. Several +// generations of one reservation can match the same Bitcoin transaction, +// but only one proof can settle it; the proof loops submit at most one +// proof per reservation key per pass, walking generations in this order. +func sortReservationProofGenerations[E any]( + generations []reservationProofGeneration[E], +) { + sort.Slice(generations, func(i, j int) bool { + a, b := generations[i], generations[j] + if c := a.reservationKey.Cmp(b.reservationKey); c != 0 { + return c < 0 + } + if a.pending != b.pending { + return a.pending + } + return a.requestNonce > b.requestNonce + }) +} + +// settleableReanchorReservation loads the reservation of the tracked +// re-anchor generation identified by key and reports whether the +// generation is still worth proving this pass. A read error keeps the +// generation tracked but skips it for this pass. // -// The Bridge settles a re-anchor generation's late proof only while its -// snapshotted source anchor still matches the reservation's current -// anchor (requireCurrentSourceAnchor in ReservationProofs.sol rejects -// the submit otherwise, so once the anchor moves the generation can -// never settle again). The comparison is keyed on the action's -// on-chain sourceAnchorUtxoHash snapshot (written by -// anchorUtxoHash(reservation) at request time in Reservation.sol), not -// on any Go-side first-observation snapshot, so a process restart -// still evicts a generation whose source anchor was already replaced -// before the loop ever observed it. A generation whose on-chain source -// anchor still matches the current anchor outpoint is kept regardless -// of age - re-anchor late settlement is unbounded - so age alone never -// triggers an eviction. -func evictReanchorGenerationIfSourceAnchorMoved( +// A TimedOut generation that can never settle again is evicted from +// state. The Bridge settles a late re-anchor proof only while the +// reservation is Active, ActionPending or Stranded +// (prepareReservationForSettlement in ReservationProofs.sol) and while +// the generation's snapshotted source anchor still matches the +// reservation's current anchor (requireCurrentSourceAnchor). The +// comparison is keyed on the action's on-chain sourceAnchorUtxoHash +// snapshot, not on any Go-side first-observation snapshot, so a process +// restart still evicts a generation whose source anchor was already +// replaced before the loop ever observed it. Re-anchor late settlement +// is otherwise unbounded, so age alone never triggers an eviction. +func settleableReanchorReservation( state *reservationProofScanState, spvChain Chain, key string, event *tbtc.ReservationReanchorRequestedEvent, action *tbtc.ReservationAction, -) bool { +) (*tbtc.Reservation, bool) { reservation, err := spvChain.GetReservation(event.ReservationKey) if err != nil { - // The source-anchor comparison cannot be evaluated without a - // readable reservation record; keep tracking this generation - // and retry on the next pass. logger.Errorf( - "failed to load reservation for re-anchor eviction check [%v]: [%v]", + "failed to load reservation [%v]: [%v]", event.ReservationKey, err, ) + return nil, false + } + + if action.State != tbtc.ReservationActionStateTimedOut { + return reservation, true + } + + switch reservation.State { + case tbtc.ReservationStateActive, + tbtc.ReservationStateActionPending, + tbtc.ReservationStateStranded: + default: + forgetReanchorGeneration(state, key) + return nil, false + } + + if reanchorSourceAnchorHash(reservation) != action.SourceAnchorUtxoHash { + // The anchor outpoint moved past this generation's on-chain + // source anchor; the Bridge will reject its late settlement + // forever. + forgetReanchorGeneration(state, key) + return nil, false + } + + return reservation, true +} + +// reanchorProofBackoffMaxPasses caps the number of proof loop passes a +// TimedOut re-anchor generation is skipped for after a check that found +// nothing to prove. +const reanchorProofBackoffMaxPasses = 32 + +// reanchorProofBackoff delays re-checking a TimedOut re-anchor generation +// whose last check found no matching transaction, or only transactions +// whose proof cannot be built (outside the relay's difficulty range or +// longer than MaxProofHeaders). Such a generation can stay tracked +// indefinitely while its source anchor does not move, and re-checking it +// costs chain reads every pass. Some of those skip reasons clear with +// time (a transaction too fresh for the relay), so the delay is bounded. +type reanchorProofBackoff struct { + // retryPass is the first proof loop pass that checks the generation + // again. + retryPass uint64 + // skipPasses is the current delay in passes; it doubles after each + // fruitless check, up to reanchorProofBackoffMaxPasses. + skipPasses uint64 + // walletTxHashes is the source wallet's confirmed transaction hash + // list when the delay was set; a different list in the wallet + // transaction cache ends the delay early. + walletTxHashes []bitcoin.Hash +} + +// reanchorGenerationBackedOff reports whether the re-anchor generation +// identified by key is still inside its backoff delay this pass. The +// delay ends early once the source wallet's cached transaction list +// differs from the one recorded when the delay was set. +func reanchorGenerationBackedOff( + state *reservationProofScanState, + key string, + sourceWalletPublicKeyHash [20]byte, +) bool { + backoff, ok := state.reanchorBackoff[key] + if !ok { return false } - if reanchorSourceAnchorHash(reservation) == action.SourceAnchorUtxoHash { - // The reservation's current anchor outpoint is still the source - // anchor the generation was requested against; this TimedOut - // generation can still settle a late re-anchor proof on the - // Bridge. + if cached, ok := state.walletTransactionCache[sourceWalletPublicKeyHash]; ok && + !reservationTransactionHashesEqual(cached.txHashes, backoff.walletTxHashes) { + delete(state.reanchorBackoff, key) return false } - // The anchor outpoint moved past this generation's on-chain source - // anchor; the Bridge will reject its late settlement forever. + return state.reanchorPass < backoff.retryPass +} + +// backOffReanchorGeneration starts or extends the backoff delay of the +// re-anchor generation identified by key after a fruitless check. +func backOffReanchorGeneration( + state *reservationProofScanState, + key string, + sourceWalletPublicKeyHash [20]byte, +) { + skipPasses := uint64(1) + if previous, ok := state.reanchorBackoff[key]; ok { + skipPasses = min(previous.skipPasses*2, reanchorProofBackoffMaxPasses) + } + + var walletTxHashes []bitcoin.Hash + if cached, ok := state.walletTransactionCache[sourceWalletPublicKeyHash]; ok { + walletTxHashes = cached.txHashes + } + + state.reanchorBackoff[key] = &reanchorProofBackoff{ + retryPass: state.reanchorPass + skipPasses + 1, + skipPasses: skipPasses, + walletTxHashes: walletTxHashes, + } +} + +// forgetReanchorGeneration stops tracking the re-anchor generation +// identified by key. +func forgetReanchorGeneration(state *reservationProofScanState, key string) { delete(state.pendingReanchorEvents, key) - return true + delete(state.reanchorBackoff, key) } // proveReservationReanchorActions finds settleable ReservationReanchor // action generations, locates each one's already-broadcast re-anchor // transaction on the Bitcoin chain (if any), and submits its SPV proof // once it has accumulated enough confirmations. A TimedOut generation -// remains a candidate for late settlement without bound while its -// on-chain source anchor is still the reservation's current anchor -// outpoint; once a later settled generation has re-anchored the -// reservation past the source anchor the generation snapshotted at -// request time, the Bridge rejects this generation's late settlement -// and it is evicted instead (see -// evictReanchorGenerationIfSourceAnchorMoved). +// remains a candidate for late settlement without bound while the +// reservation is settleable and its on-chain source anchor is still the +// reservation's current anchor outpoint; otherwise it is evicted (see +// settleableReanchorReservation). A TimedOut generation with nothing to +// prove is re-checked on a bounded backoff (see reanchorProofBackoff). func proveReservationReanchorActions( state *reservationProofScanState, config Config, @@ -685,19 +853,20 @@ func proveReservationReanchorActions( state.reanchorLastScannedBlock = endBlock } - // Re-check every tracked event's on-chain action state, evict the - // non-settleable ones, and group the still-settleable events by - // source wallet public key hash. The Bridge settles a re-anchor - // generation while its action is Pending or TimedOut, and re-anchor - // late settlement is unbounded (see loadSettleableAction in - // ReservationProofs.sol), so a TimedOut generation is kept as a - // candidate for as long as its on-chain source anchor is still the - // reservation's current anchor outpoint; once a later settled - // generation has re-anchored the reservation past the source - // anchor the generation snapshotted at request time, the Bridge - // rejects its late settlement and it is evicted. - walletEvents := make(map[[20]byte][]*tbtc.ReservationReanchorRequestedEvent) + state.reanchorPass++ + + // Re-check every tracked event's on-chain action state and evict the + // non-settleable ones. The Bridge settles a re-anchor generation + // while its action is Pending or TimedOut, and re-anchor late + // settlement is unbounded (see loadSettleableAction in + // ReservationProofs.sol), so a TimedOut generation is kept for as + // long as settleableReanchorReservation finds it can still settle. + var generations []reservationProofGeneration[*tbtc.ReservationReanchorRequestedEvent] for key, event := range state.pendingReanchorEvents { + if reanchorGenerationBackedOff(state, key, event.SourceWalletPublicKeyHash) { + continue + } + action, err := spvChain.GetReservationAction( event.ReservationKey, event.RequestNonce, @@ -713,81 +882,95 @@ func proveReservationReanchorActions( } switch action.State { - case tbtc.ReservationActionStatePending: - case tbtc.ReservationActionStateTimedOut: - if evictReanchorGenerationIfSourceAnchorMoved( - state, - spvChain, - key, - event, - action, - ) { - continue - } + case tbtc.ReservationActionStatePending, tbtc.ReservationActionStateTimedOut: default: // Settled, Superseded, Vetoed and absent generations are not // settleable on the Bridge; stop tracking them. - delete(state.pendingReanchorEvents, key) + forgetReanchorGeneration(state, key) continue } - walletEvents[event.SourceWalletPublicKeyHash] = append( - walletEvents[event.SourceWalletPublicKeyHash], - event, - ) - } - - for walletPublicKeyHash, events := range walletEvents { - walletTransactions, err := walletTransactionsForProof( + reservation, settleable := settleableReanchorReservation( state, - btcChain, - walletPublicKeyHash, - config.TransactionLimit, + spvChain, + key, + event, + action, ) - if err != nil { - logger.Errorf("failed to get transactions for wallet: [%v]", err) + if !settleable { continue } - // Index wallet transactions by spent outpoint for O(1) matching. A - // wallet that broadcasts an RBF replacement chain for the same - // anchor UTXO produces multiple candidates per outpoint, so every - // candidate is kept rather than only the last one seen. - candidateTransactions := make(map[bitcoin.TransactionOutpoint][]*bitcoin.Transaction) - for _, transaction := range walletTransactions { - if len(transaction.Inputs) == 1 && len(transaction.Outputs) == 1 && transaction.Inputs[0].Outpoint != nil { - outpoint := *transaction.Inputs[0].Outpoint - candidateTransactions[outpoint] = append(candidateTransactions[outpoint], transaction) - } + generations = append( + generations, + reservationProofGeneration[*tbtc.ReservationReanchorRequestedEvent]{ + key: key, + event: event, + reservationKey: event.ReservationKey, + requestNonce: event.RequestNonce, + pending: action.State == tbtc.ReservationActionStatePending, + reservation: reservation, + }, + ) + } + sortReservationProofGenerations(generations) + + // Each source wallet's candidate transactions are indexed once per + // pass, by spent outpoint. A wallet that broadcasts an RBF + // replacement chain for the same anchor UTXO produces multiple + // candidates per outpoint, so every candidate is kept rather than + // only the last one seen. A nil entry marks a wallet whose + // transactions could not be fetched this pass. + walletCandidates := make(map[[20]byte]map[bitcoin.TransactionOutpoint][]*bitcoin.Transaction) + submittedReservations := make(map[string]struct{}) + for _, generation := range generations { + event := generation.event + reservation := generation.reservation + reservationKey := event.ReservationKey.String() + if _, submitted := submittedReservations[reservationKey]; submitted { + continue } - for _, event := range events { - reservation, err := spvChain.GetReservation(event.ReservationKey) + candidateTransactions, indexed := walletCandidates[event.SourceWalletPublicKeyHash] + if !indexed { + walletTransactions, err := walletTransactionsForProof( + state, + btcChain, + event.SourceWalletPublicKeyHash, + config.TransactionLimit, + ) if err != nil { - logger.Errorf( - "failed to load reservation [%v]: [%v]", - event.ReservationKey, - err, - ) - continue - } - if reservation.AnchorUtxo == nil || - reservation.AnchorUtxo.Value == 0 || - reservation.AnchorUtxo.Outpoint == nil || - reservation.AnchorUtxo.Outpoint.TransactionHash == (bitcoin.Hash{}) { - logger.Errorf( - "reservation [%v] has no anchor UTXO to re-anchor from", - event.ReservationKey, - ) - continue + logger.Errorf("failed to get transactions for wallet: [%v]", err) + } else { + candidateTransactions = make(map[bitcoin.TransactionOutpoint][]*bitcoin.Transaction) + for _, transaction := range walletTransactions { + if len(transaction.Inputs) == 1 && len(transaction.Outputs) == 1 && transaction.Inputs[0].Outpoint != nil { + outpoint := *transaction.Inputs[0].Outpoint + candidateTransactions[outpoint] = append(candidateTransactions[outpoint], transaction) + } + } } + walletCandidates[event.SourceWalletPublicKeyHash] = candidateTransactions + } + if candidateTransactions == nil { + continue + } - candidates, ok := candidateTransactions[*reservation.AnchorUtxo.Outpoint] - if !ok { - continue - } + if reservation.AnchorUtxo == nil || + reservation.AnchorUtxo.Value == 0 || + reservation.AnchorUtxo.Outpoint == nil || + reservation.AnchorUtxo.Outpoint.TransactionHash == (bitcoin.Hash{}) { + logger.Errorf( + "reservation [%v] has no anchor UTXO to re-anchor from", + event.ReservationKey, + ) + continue + } - if err := proveReservationTransaction( + outcome := reservationProofNoMatch + var err error + if candidates, ok := candidateTransactions[*reservation.AnchorUtxo.Outpoint]; ok { + outcome, err = proveReservationTransaction( candidates, func(transaction *bitcoin.Transaction) bool { return isMatchingReservationReanchorTransaction(event, reservation.AnchorUtxo, transaction) @@ -809,15 +992,26 @@ func proveReservationReanchorActions( metricsRecorder, ) }, - ); err != nil { - logger.Errorf( - "failed to prove reservation re-anchor transaction "+ - "for reservation [%v]: [%v]", - event.ReservationKey, - err, - ) - continue - } + ) + } + if outcome == reservationProofSubmitted { + submittedReservations[reservationKey] = struct{}{} + } + if err != nil { + logger.Errorf( + "failed to prove reservation re-anchor transaction "+ + "for reservation [%v]: [%v]", + event.ReservationKey, + err, + ) + continue + } + + if !generation.pending && + (outcome == reservationProofNoMatch || outcome == reservationProofUnprovable) { + backOffReanchorGeneration(state, generation.key, event.SourceWalletPublicKeyHash) + } else { + delete(state.reanchorBackoff, generation.key) } } @@ -858,6 +1052,26 @@ func isMatchingReservationReanchorTransaction( return true } +// reservationProofOutcome reports what proveReservationTransaction did +// with one action generation's candidate transactions. +type reservationProofOutcome uint8 + +const ( + // reservationProofNoMatch means no candidate matched the generation + // (or proof info could not be read, reported with an error). + reservationProofNoMatch reservationProofOutcome = iota + // reservationProofAwaitingConfirmations means a matching candidate is + // still accumulating confirmations. + reservationProofAwaitingConfirmations + // reservationProofUnprovable means every matching candidate was + // skipped because its proof falls outside the relay's difficulty + // range or needs more than maxProofHeaders headers. + reservationProofUnprovable + // reservationProofSubmitted means a proof submission was attempted; + // the returned error reports whether it failed. + reservationProofSubmitted +) + // proveReservationTransaction assembles and submits the SPV proof for a // reservation acceptance or re-anchor transaction, once it has accumulated // enough confirmations and its proof falls within the relay's difficulty @@ -877,7 +1091,9 @@ func isMatchingReservationReanchorTransaction( // behavior. // // cache carries the pass-invariant chain reads shared with every other -// transaction proved in the same pass; see proofInfoCache. +// transaction proved in the same pass; see proofInfoCache. The returned +// outcome tells callers whether a submission was attempted and, if not, +// whether a later pass can expect anything to prove. func proveReservationTransaction( candidates []*bitcoin.Transaction, isMatch func(transaction *bitcoin.Transaction) bool, @@ -888,8 +1104,9 @@ func proveReservationTransaction( cache *proofInfoCache, metricsRecorder MetricsRecorder, submit func(transactionHash bitcoin.Hash, requiredConfirmations uint) error, -) error { +) (reservationProofOutcome, error) { var pending *bitcoin.Transaction + awaitingConfirmations := false var pendingConfirmations, pendingRequired uint var pendingSkipReason proofSkipReason @@ -907,12 +1124,12 @@ func proveReservationTransaction( cache, ) if err != nil { - return fmt.Errorf("failed to get proof info: [%v]", err) + return reservationProofNoMatch, fmt.Errorf("failed to get proof info: [%v]", err) } if skipReason == proofSkipNone && accumulatedConfirmations >= requiredConfirmations { if err := submit(transaction.Hash(), requiredConfirmations); err != nil { - return err + return reservationProofSubmitted, err } logger.Infof( @@ -920,9 +1137,12 @@ func proveReservationTransaction( transaction.Hash().Hex(bitcoin.ReversedByteOrder), ) - return nil + return reservationProofSubmitted, nil } + if skipReason == proofSkipNone { + awaitingConfirmations = true + } if pending == nil { pending = transaction pendingConfirmations = accumulatedConfirmations @@ -932,7 +1152,12 @@ func proveReservationTransaction( } if pending == nil { - return nil + return reservationProofNoMatch, nil + } + + outcome := reservationProofUnprovable + if awaitingConfirmations { + outcome = reservationProofAwaitingConfirmations } transactionHashStr := pending.Hash().Hex(bitcoin.ReversedByteOrder) @@ -951,7 +1176,7 @@ func proveReservationTransaction( 1, ) } - return nil + return outcome, nil case proofSkipExceededMaxHeaders: logger.Errorf( "skipped proving transaction [%s]; could not find a decisive "+ @@ -966,7 +1191,7 @@ func proveReservationTransaction( 1, ) } - return nil + return outcome, nil case proofSkipNone: logger.Infof( "skipped proving transaction [%s]; transaction has [%v/%v] "+ @@ -975,9 +1200,9 @@ func proveReservationTransaction( pendingConfirmations, pendingRequired, ) - return nil + return outcome, nil default: - return fmt.Errorf( + return reservationProofNoMatch, fmt.Errorf( "unexpected proof skip reason [%d] for transaction [%s]", pendingSkipReason, transactionHashStr, diff --git a/pkg/maintainer/spv/reservation_proof_loop_test.go b/pkg/maintainer/spv/reservation_proof_loop_test.go index dce3e7ad37..6711fe8e55 100644 --- a/pkg/maintainer/spv/reservation_proof_loop_test.go +++ b/pkg/maintainer/spv/reservation_proof_loop_test.go @@ -587,7 +587,7 @@ func TestProveReservationTransaction(t *testing.T) { spvChain, btcChain := newFixture(20) submitted := false - err := proveReservationTransaction( + outcome, err := proveReservationTransaction( []*bitcoin.Transaction{transaction}, alwaysMatchReservationTransaction, btcChain, @@ -621,13 +621,16 @@ func TestProveReservationTransaction(t *testing.T) { if !submitted { t.Error("expected submit to be called") } + if outcome != reservationProofSubmitted { + t.Errorf("expected outcome %v, got %v", reservationProofSubmitted, outcome) + } }) t.Run("skips without submitting when confirmations are insufficient", func(t *testing.T) { spvChain, btcChain := newFixture(2) submitted := false - err := proveReservationTransaction( + outcome, err := proveReservationTransaction( []*bitcoin.Transaction{transaction}, alwaysMatchReservationTransaction, btcChain, @@ -647,12 +650,15 @@ func TestProveReservationTransaction(t *testing.T) { if submitted { t.Error("expected submit not to be called for insufficient confirmations") } + if outcome != reservationProofAwaitingConfirmations { + t.Errorf("expected outcome %v, got %v", reservationProofAwaitingConfirmations, outcome) + } }) t.Run("propagates submit errors", func(t *testing.T) { spvChain, btcChain := newFixture(20) - err := proveReservationTransaction( + outcome, err := proveReservationTransaction( []*bitcoin.Transaction{transaction}, alwaysMatchReservationTransaction, btcChain, @@ -668,6 +674,9 @@ func TestProveReservationTransaction(t *testing.T) { if err == nil { t.Fatal("expected submit error to propagate") } + if outcome != reservationProofSubmitted { + t.Errorf("a failed submission is still an attempt: expected outcome %v, got %v", reservationProofSubmitted, outcome) + } }) t.Run("skips a non-matching candidate to reach a later unconfirmed matching one", func(t *testing.T) { @@ -689,7 +698,7 @@ func TestProveReservationTransaction(t *testing.T) { } submitted := false - err := proveReservationTransaction( + outcome, err := proveReservationTransaction( []*bitcoin.Transaction{nonMatching, matching}, isMatch, btcChain, @@ -714,6 +723,9 @@ func TestProveReservationTransaction(t *testing.T) { if submitted { t.Error("expected submit not to be called for insufficient confirmations") } + if outcome != reservationProofAwaitingConfirmations { + t.Errorf("expected outcome %v, got %v", reservationProofAwaitingConfirmations, outcome) + } }) } @@ -778,7 +790,7 @@ func TestProveReservationTransaction_RecordsMetrics(t *testing.T) { } submitted := false - if err := proveReservationTransaction( + if _, err := proveReservationTransaction( []*bitcoin.Transaction{transaction}, alwaysMatchReservationTransaction, btcChain, @@ -1293,7 +1305,7 @@ func TestProveReservationTransaction_SelectsConfirmedRBFCandidate(t *testing.T) var submittedHash bitcoin.Hash submissions := 0 - err := proveReservationTransaction( + _, err := proveReservationTransaction( []*bitcoin.Transaction{unconfirmedReplaced, confirmedReplacement}, alwaysMatchReservationTransaction, btcChain, @@ -1757,6 +1769,7 @@ func newTimedOutReanchorFixture( }, ) spvChain.setReservation(reservationKey, &tbtc.Reservation{ + State: tbtc.ReservationStateActive, AnchorUtxo: anchorUtxo, }) @@ -2124,6 +2137,7 @@ func TestProveReservationReanchorActions_TimedOutSourceAnchor(t *testing.T) { fixture.spvChain.setReservation( fixture.reservationKey, &tbtc.Reservation{ + State: tbtc.ReservationStateActive, AnchorUtxo: &bitcoin.UnspentTransactionOutput{ Outpoint: &bitcoin.TransactionOutpoint{ TransactionHash: replacementAnchorTx.Hash(), @@ -2593,3 +2607,303 @@ func TestProveReservationAcceptanceActions_ChunkedScan(t *testing.T) { } }) } + +// TestProveReservationAcceptanceActions_TimedOutEvictedOnceReservationExists +// verifies that a TimedOut acceptance generation still inside its late +// window is dropped once the reservation exists: the Bridge rejects every +// acceptance proof after one generation settled ("Reservation already +// exists"), so proving it again would only burn proof-assembly work. +func TestProveReservationAcceptanceActions_TimedOutEvictedOnceReservationExists(t *testing.T) { + fixture := newTimedOutAcceptanceFixture(t, encodingP2WPKH) + fixture.state.nowFn = func() uint32 { return 1050 } + fixture.spvChain.setReservation(fixture.reservationKey, &tbtc.Reservation{ + State: tbtc.ReservationStateActive, + }) + + if err := proveReservationAcceptanceActions( + fixture.state, + fixture.config, + fixture.spvChain, + fixture.spvChain, + fixture.btcChain, + newProofInfoCache(), + nil, + ); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if fixture.submissions != 0 { + t.Errorf("expected no submission, got %d", fixture.submissions) + } + key := reservationEventKey(fixture.reservationKey, fixture.requestNonce) + if _, tracked := fixture.state.pendingAcceptanceEvents[key]; tracked { + t.Error("expected the generation to be evicted once the reservation exists") + } +} + +// TestProveReservationActions_OneSubmissionPerReservation verifies that +// when a Pending generation and a TimedOut generation of the same +// reservation both match the same Bitcoin transaction, a pass submits one +// proof only - for the Pending generation - instead of broadcasting a +// second, reverting transaction. +func TestProveReservationActions_OneSubmissionPerReservation(t *testing.T) { + t.Run("acceptance", func(t *testing.T) { + fixture := newTimedOutAcceptanceFixture(t, encodingP2WPKH) + fixture.state.nowFn = func() uint32 { return 1050 } + + const pendingNonce = 2 + fixture.spvChain.addReservationAcceptanceRequestedEvent(&tbtc.ReservationAcceptanceRequestedEvent{ + ReservationKey: fixture.reservationKey, + RequestNonce: pendingNonce, + WalletPublicKeyHash: fixture.walletPKH, + DepositAmount: 150000, + BlockNumber: 600, + }) + fixture.spvChain.setReservationAction( + fixture.reservationKey, + pendingNonce, + &tbtc.ReservationAction{ + State: tbtc.ReservationActionStatePending, + ActionType: tbtc.ReservationActionTypeAcceptance, + TargetWalletPublicKeyHash: fixture.walletPKH, + TimeoutAt: 5000, + MinAmount: 1000, + }, + ) + + var submittedNonces []uint64 + fixture.spvChain.submitReservationAcceptanceProofHook = func( + _ *tbtc.BitcoinTxInfo, + _ *tbtc.BitcoinTxProof, + _ *big.Int, + requestNonce uint64, + ) error { + submittedNonces = append(submittedNonces, requestNonce) + return nil + } + + if err := proveReservationAcceptanceActions( + fixture.state, + fixture.config, + fixture.spvChain, + fixture.spvChain, + fixture.btcChain, + newProofInfoCache(), + nil, + ); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(submittedNonces) != 1 || submittedNonces[0] != pendingNonce { + t.Errorf("expected one submission for nonce %d, got %v", pendingNonce, submittedNonces) + } + }) + + t.Run("re-anchor", func(t *testing.T) { + fixture := newTimedOutReanchorFixture(t, encodingP2WPKH) + + pendingNonce := fixture.requestNonce + 1 + fixture.spvChain.addReservationReanchorRequestedEvent(&tbtc.ReservationReanchorRequestedEvent{ + ReservationKey: fixture.reservationKey, + RequestNonce: pendingNonce, + SourceWalletPublicKeyHash: fixture.sourceWalletPKH, + TargetWalletPublicKeyHash: fixture.sourceWalletPKH, + TxMaxFee: 20000, + BlockNumber: 600, + }) + fixture.spvChain.setReservationAction( + fixture.reservationKey, + pendingNonce, + &tbtc.ReservationAction{ + State: tbtc.ReservationActionStatePending, + ActionType: tbtc.ReservationActionTypeReanchor, + TargetWalletPublicKeyHash: fixture.sourceWalletPKH, + TimeoutAt: 5000, + }, + ) + + var submittedNonces []uint64 + fixture.spvChain.submitReservationReanchorProofHook = func( + _ *tbtc.BitcoinTxInfo, + _ *tbtc.BitcoinTxProof, + _ *big.Int, + requestNonce uint64, + ) error { + submittedNonces = append(submittedNonces, requestNonce) + return nil + } + + if err := proveReservationReanchorActions( + fixture.state, + fixture.config, + fixture.spvChain, + fixture.spvChain, + fixture.btcChain, + newProofInfoCache(), + nil, + ); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(submittedNonces) != 1 || submittedNonces[0] != pendingNonce { + t.Errorf("expected one submission for nonce %d, got %v", pendingNonce, submittedNonces) + } + }) +} + +// TestProveReservationReanchorActions_TimedOutReservationState verifies +// that a TimedOut re-anchor generation is dropped once its reservation can +// no longer settle a late proof (Closed or Unknown), while a Stranded +// reservation - which the Bridge still lets a late proof settle - keeps +// the generation and gets it proved. +func TestProveReservationReanchorActions_TimedOutReservationState(t *testing.T) { + tests := map[string]struct { + state tbtc.ReservationState + expectTracked bool + expectSubmitted int + }{ + "closed": {tbtc.ReservationStateClosed, false, 0}, + "unknown": {tbtc.ReservationStateUnknown, false, 0}, + "stranded": {tbtc.ReservationStateStranded, true, 1}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + fixture := newTimedOutReanchorFixture(t, encodingP2WPKH) + reservation, err := fixture.spvChain.GetReservation(fixture.reservationKey) + if err != nil { + t.Fatal(err) + } + updated := *reservation + updated.State = test.state + fixture.spvChain.setReservation(fixture.reservationKey, &updated) + + if err := proveReservationReanchorActions( + fixture.state, + fixture.config, + fixture.spvChain, + fixture.spvChain, + fixture.btcChain, + newProofInfoCache(), + nil, + ); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if fixture.submissions != test.expectSubmitted { + t.Errorf("expected %d submissions, got %d", test.expectSubmitted, fixture.submissions) + } + key := reservationEventKey(fixture.reservationKey, fixture.requestNonce) + if _, tracked := fixture.state.pendingReanchorEvents[key]; tracked != test.expectTracked { + t.Errorf("expected tracked=%v, got %v", test.expectTracked, tracked) + } + }) + } +} + +// TestProveReservationReanchorActions_ReadsReservationOncePerGeneration +// verifies that proving a re-anchor generation reads its reservation once +// per pass: the record read to decide whether the generation can settle +// is the one used to find its anchor. +func TestProveReservationReanchorActions_ReadsReservationOncePerGeneration(t *testing.T) { + fixture := newTimedOutReanchorFixture(t, encodingP2WPKH) + + if err := proveReservationReanchorActions( + fixture.state, + fixture.config, + fixture.spvChain, + fixture.spvChain, + fixture.btcChain, + newProofInfoCache(), + nil, + ); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if fixture.submissions != 1 { + t.Fatalf("expected the generation to be proved, got %d submissions", fixture.submissions) + } + if calls := fixture.spvChain.getReservationCallCount(); calls != 1 { + t.Errorf("expected one GetReservation read, got %d", calls) + } +} + +// TestProveReservationReanchorActions_TimedOutBackoff verifies that a +// TimedOut re-anchor generation with no matching transaction is not +// re-read every pass: after each fruitless check it is skipped for a +// doubling number of passes, a change in the source wallet's transaction +// list ends the delay early, and a Pending generation is never delayed. +func TestProveReservationReanchorActions_TimedOutBackoff(t *testing.T) { + newFixture := func(t *testing.T) *timedOutReanchorFixture { + fixture := newTimedOutReanchorFixture(t, encodingP2WPKH) + // The wallet's re-anchor transaction pays the source wallet, so + // pointing the event at another target wallet leaves it with no + // matching transaction. + for _, event := range fixture.spvChain.reservationReanchorRequestedEvents { + event.TargetWalletPublicKeyHash = [20]byte{0xEE} + } + return fixture + } + + prove := func(t *testing.T, fixture *timedOutReanchorFixture) { + if err := proveReservationReanchorActions( + fixture.state, + fixture.config, + fixture.spvChain, + fixture.spvChain, + fixture.btcChain, + newProofInfoCache(), + nil, + ); err != nil { + t.Fatalf("unexpected error: %v", err) + } + } + + t.Run("timed-out generation backs off", func(t *testing.T) { + fixture := newFixture(t) + + // Pass 1 checks; pass 2 is skipped; pass 3 checks and doubles + // the delay, so passes 4 and 5 are skipped and pass 6 checks. + expectedReads := []int{1, 1, 2, 2, 2, 3} + for pass, expected := range expectedReads { + prove(t, fixture) + if calls := fixture.spvChain.getReservationCallCount(); calls != expected { + t.Fatalf("pass %d: expected %d reservation reads, got %d", pass+1, expected, calls) + } + } + + key := reservationEventKey(fixture.reservationKey, fixture.requestNonce) + if _, tracked := fixture.state.pendingReanchorEvents[key]; !tracked { + t.Fatal("expected the backed-off generation to stay tracked") + } + + // A new transaction in the wallet's cached history ends the + // delay: the next pass checks the generation again. + cached := fixture.state.walletTransactionCache[fixture.sourceWalletPKH] + cached.txHashes = append(append([]bitcoin.Hash{}, cached.txHashes...), bitcoin.Hash{0x01}) + prove(t, fixture) + if calls := fixture.spvChain.getReservationCallCount(); calls != 4 { + t.Fatalf("expected the changed wallet history to end the delay, got %d reads", calls) + } + }) + + t.Run("pending generation is checked every pass", func(t *testing.T) { + fixture := newFixture(t) + fixture.spvChain.setReservationAction( + fixture.reservationKey, + fixture.requestNonce, + &tbtc.ReservationAction{ + State: tbtc.ReservationActionStatePending, + ActionType: tbtc.ReservationActionTypeReanchor, + TimeoutAt: 5000, + }, + ) + + for pass := 1; pass <= 3; pass++ { + prove(t, fixture) + if calls := fixture.spvChain.getReservationCallCount(); calls != pass { + t.Fatalf("pass %d: expected %d reservation reads, got %d", pass, pass, calls) + } + } + }) +} From c497786075ef5d0059d056b542d2fa136b2a45dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 30 Sep 2026 08:52:28 +0000 Subject: [PATCH 19/31] fix(tbtcpg): skip re-anchor requests the cooldown or anchor floor would revert Run now skips an Active reservation while block time is before its reanchorCooldownUntil or when its anchor is not above ReservationTxMaxFee + ReservationMinAmount, the two request-time gates Reservation.sol applies to permissionless callers. Previously such reservations re-ran the target search and a reverting gas estimate on every window. --- pkg/tbtcpg/reservation_reanchor.go | 27 +++++++ .../reservation_reanchor_inflight_test.go | 74 +++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/pkg/tbtcpg/reservation_reanchor.go b/pkg/tbtcpg/reservation_reanchor.go index 50e035f98c..6b38f5ce67 100644 --- a/pkg/tbtcpg/reservation_reanchor.go +++ b/pkg/tbtcpg/reservation_reanchor.go @@ -334,6 +334,33 @@ reservationLoop: } anchorValue := uint64(reservation.AnchorUtxo.Value) + // Mirror Reservation.sol's request-time gates for a + // permissionless caller, which would otherwise revert the + // request after a target search: no request before + // reanchorCooldownUntil, and the anchor must stay above + // txMaxFee + minAmount. + if uint64(time.Now().Unix()) < uint64(reservation.ReanchorCooldownUntil) { + taskLogger.Infof( + "reservation [0x%x] is in its re-anchor cooldown until "+ + "[%d], skipping", + reservationKey, + reservation.ReanchorCooldownUntil, + ) + continue reservationLoop + } + if anchorValue <= params.ReservationTxMaxFee+params.ReservationMinAmount { + taskLogger.Infof( + "reservation [0x%x] anchor [%d] is not above the "+ + "re-anchor floor (tx max fee [%d] + min amount [%d]), "+ + "skipping", + reservationKey, + anchorValue, + params.ReservationTxMaxFee, + params.ReservationMinAmount, + ) + continue reservationLoop + } + for { target, err := rrt.findTargetWallet( taskLogger, diff --git a/pkg/tbtcpg/reservation_reanchor_inflight_test.go b/pkg/tbtcpg/reservation_reanchor_inflight_test.go index 2bd7c8804b..c21eae5fbc 100644 --- a/pkg/tbtcpg/reservation_reanchor_inflight_test.go +++ b/pkg/tbtcpg/reservation_reanchor_inflight_test.go @@ -685,3 +685,77 @@ func TestReservationReanchorTask_FrontRunRequest_StopsWaiting(t *testing.T) { t.Fatalf("expected exactly 1 re-anchor request in the pass, got %d", got) } } + +// TestReservationReanchorTask_RequestTimeGates pins that Run applies +// Reservation.sol's request-time gates before sending a request: a +// reservation in its re-anchor cooldown, or whose anchor is not above +// ReservationTxMaxFee + ReservationMinAmount, is skipped without a request +// that would revert. +func TestReservationReanchorTask_RequestTimeGates(t *testing.T) { + t.Run("cooldown after a timed-out request", func(t *testing.T) { + tbtcChain, _, _, task, sourceWalletPublicKeyHash, _, reservationKey := + newInFlightReanchorFixture(t) + + if _, ok, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: sourceWalletPublicKeyHash, + }); err != nil || !ok { + t.Fatalf("round 1: expected a proposal, got ok=%v err=%v", ok, err) + } + // The generation is never proven and times out, which starts the + // reservation's re-anchor cooldown. + if err := tbtcChain.TimeOutReservationReanchor(reservationKey); err != nil { + t.Fatal(err) + } + + proposal, ok, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: sourceWalletPublicKeyHash, + }) + if err != nil { + t.Fatalf("round 2: unexpected error: %v", err) + } + if ok || proposal != nil { + t.Fatalf("round 2: expected no proposal during the cooldown, got ok=%v proposal=%v", ok, proposal) + } + if got := len(tbtcChain.GetReservationReanchorRequestAttempts()); got != 1 { + t.Fatalf("round 2: expected no request during the cooldown, got %d attempts in total", got) + } + }) + + // The fixture's floor is ReservationTxMaxFee (100000) + + // ReservationMinAmount (1000). + floorTests := map[string]struct { + anchorValue int64 + expectedProposal bool + }{ + "anchor at the floor is skipped": {101000, false}, + "anchor above the floor is proposed": {101001, true}, + } + for name, test := range floorTests { + t.Run(name, func(t *testing.T) { + tbtcChain, btcChain, _, task, sourceWalletPublicKeyHash, _, _ := + newInFlightReanchorFixture(t) + tbtcChain.SetWalletReservations(sourceWalletPublicKeyHash, nil) + addActiveReanchorReservation( + t, tbtcChain, btcChain, sourceWalletPublicKeyHash, + big.NewInt(7003), bitcoin.Hash{0x73}, test.anchorValue, + ) + + _, ok, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: sourceWalletPublicKeyHash, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok != test.expectedProposal { + t.Fatalf("expected proposal=%v, got %v", test.expectedProposal, ok) + } + expectedAttempts := 0 + if test.expectedProposal { + expectedAttempts = 1 + } + if got := len(tbtcChain.GetReservationReanchorRequestAttempts()); got != expectedAttempts { + t.Fatalf("expected %d request attempts, got %d", expectedAttempts, got) + } + }) + } +} From 4ee3587d6713ddc47aff2321aea0667bec9c474d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 30 Sep 2026 08:55:03 +0000 Subject: [PATCH 20/31] refactor(spv): keep each tracked stale deposit's refund deadline in its pending entry pollTick always recorded the deadline from the reveal event at discovery, so the chain-read fallback that recovered it for an unmemoized deposit (with its own unchunked 30-day reveal scan and sentinel error) never ran in production. The deadline now lives in the tracked entry and is passed to CheckStaleReservedDeposit; the memo, the fallback helpers and their tests are removed. --- .../spv/reservation_stale_deposit_watch.go | 221 ++++-------------- .../reservation_stale_deposit_watch_test.go | 212 +++-------------- pkg/maintainer/spv/reservation_wiring_test.go | 14 +- 3 files changed, 79 insertions(+), 368 deletions(-) diff --git a/pkg/maintainer/spv/reservation_stale_deposit_watch.go b/pkg/maintainer/spv/reservation_stale_deposit_watch.go index 5269c1ff68..6362845f67 100644 --- a/pkg/maintainer/spv/reservation_stale_deposit_watch.go +++ b/pkg/maintainer/spv/reservation_stale_deposit_watch.go @@ -3,7 +3,6 @@ package spv import ( "context" "encoding/binary" - "errors" "fmt" "math/big" "strings" @@ -23,9 +22,7 @@ type StaleDepositResolution uint8 const ( // StaleDepositResolutionUnknown is the zero value, returned alongside // a non-nil error whenever the check could not be completed (a chain - // call failed, the deposit key was invalid, or the reveal-time refund - // deadline could not be snapshotted - for instance because the - // matching DepositRevealed event predates the catch-up window). The + // call failed or the deposit key was invalid). The // deposit is never added to or evicted from the caller's tracking // set on this resolution - the caller must retain it and retry on // the next tick. @@ -33,9 +30,9 @@ const ( // StaleDepositResolutionKeep indicates the deposit is still // pending-stale and must be retained in the caller's tracking set // for the next tick: its snapshotted refund deadline has not yet - // passed - for a pollTick-discovered deposit the deadline is - // memoized from the reveal event, so no per-deposit chain read is - // made before it does - its current action generation is still + // passed - the deadline comes from the reveal event, so no + // per-deposit chain read is made before it does - its current action + // generation is still // Pending (the contract would // revert the notification while an acceptance authorization is // pending), a previously submitted notification is inside its @@ -77,11 +74,9 @@ const ( // reverseUint32) at reveal. Until that deadline the deposit cannot // become stale - the contract rejects // NotifyStaleReservedDeposit with "Deposit refund deadline has not -// elapsed" - so for a deposit discovered by pollTick, whose deadline is -// memoized from the reveal event, the watcher makes no per-deposit -// chain read at all before the deadline (an in-memory gate only); a -// deposit checked directly without that memo first recovers its -// deadline from chain state (see getRefundDeadline). Once the deadline +// elapsed" - so the watcher, which keeps each tracked deposit's +// deadline from its reveal event, makes no per-deposit chain read at +// all before the deadline (an in-memory gate only). Once the deadline // has passed, the watcher notifies whenever the current action // generation is not Pending, regardless of the assigned wallet's // state: a Live wallet that never accepts its deposit is exactly the @@ -112,17 +107,6 @@ type ReservationStaleDepositWatcher struct { // notification. notifiedAt map[string]uint32 - // refundDeadlineMemo caches each tracked deposit's snapshotted - // on-chain refund deadline, derived from its DepositRevealed - // event's RefundLocktime via reverseUint32. pollTick fills it at - // discovery time, when the reveal event it schedules from is - // already in hand; CheckStaleReservedDeposit fills it lazily for - // deposits checked directly (the snapshot's one-time reads run - // only while this memo has no entry for the deposit), so in - // steady operation no per-deposit chain read happens before the - // deadline. - refundDeadlineMemo map[string]uint32 - // operatorAddress identifies this process for // reservationOperatorStaggerOffset (see reservation_wiring.go), // used to stagger a deposit's FIRST NotifyStaleReservedDeposit @@ -155,7 +139,7 @@ type ReservationStaleDepositWatcher struct { // every tracked deposit - including ones whose wallet is Live, // since the notification is wallet-state-independent - must be // re-checked every tick until its record clears on-chain. - pending map[string]*big.Int + pending map[string]*trackedStaleDeposit lastSeenBlock uint64 // activationBlock is the network's reservation activation block @@ -169,11 +153,13 @@ type ReservationStaleDepositWatcher struct { activationBlock uint64 } -// errStaleDepositCleared signals that the reserved deposit record was -// already released on-chain (its wallet field is zero) when a refund -// deadline snapshot was attempted: there is nothing left to schedule or -// notify for this deposit. -var errStaleDepositCleared = errors.New("reserved deposit record already cleared") +// trackedStaleDeposit is one deposit tracked by the stale-deposit poll +// loop: its key and the refund deadline the Bridge snapshotted at reveal, +// decoded from its DepositRevealed event (see reverseUint32). +type trackedStaleDeposit struct { + depositKey *big.Int + refundDeadline uint32 +} // NewReservationStaleDepositWatcher constructs a stale-deposit watcher // bound to the given chain. operatorAddress is required: it is used by @@ -189,13 +175,12 @@ func NewReservationStaleDepositWatcher( activationBlock uint64, ) *ReservationStaleDepositWatcher { return &ReservationStaleDepositWatcher{ - spvChain: spvChain, - operatorAddress: operatorAddress, - notifiedAt: make(map[string]uint32), - refundDeadlineMemo: make(map[string]uint32), - attempted: make(map[string]struct{}), - pending: make(map[string]*big.Int), - activationBlock: activationBlock, + spvChain: spvChain, + operatorAddress: operatorAddress, + notifiedAt: make(map[string]uint32), + attempted: make(map[string]struct{}), + pending: make(map[string]*trackedStaleDeposit), + activationBlock: activationBlock, } } @@ -215,9 +200,8 @@ func reverseUint32(locktime [4]byte) uint32 { // deposit. // // The function may submit a Bridge notification -// (NotifyStaleReservedDeposit) as a side effect, and it caches the -// snapshotted refund deadline and notification state on the receiver -// across calls. There is no internal scheduling; the caller owns +// (NotifyStaleReservedDeposit) as a side effect, and it caches +// notification state on the receiver across calls. There is no internal scheduling; the caller owns // invocation lifecycle and synchronization. // // Notification eligibility mirrors the contract's @@ -233,10 +217,8 @@ func reverseUint32(locktime [4]byte) uint32 { // 2. The snapshotted refund deadline (the reveal-time RefundLocktime, // byte-reversed; see reverseUint32) has STRICTLY passed: the // contract's requirement is block.timestamp > refundDeadline. At -// or before the deadline, a deposit discovered by pollTick -// performs no per-deposit chain reads at all (in-memory gate -// only); a deposit checked directly without that memo first -// recovers its deadline from chain state (see getRefundDeadline). +// or before the deadline the check performs no chain reads at all +// (in-memory gate only). // 3. The current action generation is not Pending: while an // acceptance authorization is pending, the contract reverts the // notification with "Acceptance authorization pending", so the @@ -258,12 +240,15 @@ func reverseUint32(locktime [4]byte) uint32 { // // Parameters: // - depositKey: the deposit identifier reported by the Bridge. +// - refundDeadline: the deposit's refund deadline snapshotted at +// reveal, decoded from its DepositRevealed event. // - now: the UNIX timestamp against which the refund deadline // and the notifiedAt renotify backoff are compared. Tests pass an // explicit value; production passes time.Now().Unix() cast to // uint32. func (rsdw *ReservationStaleDepositWatcher) CheckStaleReservedDeposit( depositKey *big.Int, + refundDeadline uint32, now uint32, ) (StaleDepositResolution, error) { if depositKey == nil { @@ -271,24 +256,7 @@ func (rsdw *ReservationStaleDepositWatcher) CheckStaleReservedDeposit( } depositKeyStr := depositKey.String() - - deadline, err := rsdw.getRefundDeadline(depositKey) - if errors.Is(err, errStaleDepositCleared) { - // The record was already released on-chain before its deadline - // could be snapshotted: there is nothing to release. - logger.Debugf( - "reserved deposit [%v] record already cleared; skipping "+ - "stale check", - depositKey, - ) - if _, outstanding := rsdw.notifiedAt[depositKeyStr]; outstanding { - return StaleDepositResolutionNotified, nil - } - return StaleDepositResolutionDrop, nil - } - if err != nil { - return StaleDepositResolutionUnknown, err - } + deadline := refundDeadline // Until the snapshotted refund deadline has STRICTLY passed the // contract rejects the notification and nothing about this deposit @@ -476,124 +444,14 @@ func (rsdw *ReservationStaleDepositWatcher) CheckStaleReservedDeposit( return StaleDepositResolutionKeep, nil } -// getRefundDeadline returns the deposit's snapshotted on-chain refund -// deadline, memoized on the receiver (see refundDeadlineMemo): -// pollTick fills it at discovery time, and this call back-fills it -// lazily for a deposit checked directly, paying the snapshot's one-time -// reads only while no entry exists yet. -func (rsdw *ReservationStaleDepositWatcher) getRefundDeadline( - depositKey *big.Int, -) (uint32, error) { - if deadline, ok := rsdw.refundDeadlineMemo[depositKey.String()]; ok { - return deadline, nil - } - - deadline, err := rsdw.snapshotRefundDeadline(depositKey) - if err != nil { - return 0, err - } - rsdw.refundDeadlineMemo[depositKey.String()] = deadline - return deadline, nil -} - -// snapshotRefundDeadline snapshots a deposit's on-chain refund deadline -// for the first time, from the RefundLocktime field of its own -// DepositRevealed event (decoded via reverseUint32 to the exact -// deadline the Bridge snapshotted at reveal - see tbtc-v2's -// Deposit.sol reveal-time snapshot of validateDepositRefundLocktime's -// result). The deposit's assigned wallet is read first, both to key the -// reveal-event scan and as an early exit: a zero wallet field means the -// record was already released, and there is nothing to schedule. -// -// The event scan is bounded by reservationDefaultLookBackBlocks, the -// same catch-up window every reservation watcher shares; a reveal that -// predates it (and was never seen by pollTick) cannot be snapshotted -// here and surfaces as an error the caller retries. -func (rsdw *ReservationStaleDepositWatcher) snapshotRefundDeadline( - depositKey *big.Int, -) (uint32, error) { - walletPublicKeyHash, err := rsdw.spvChain.ReservedDepositWallet(depositKey) - if err != nil { - return 0, fmt.Errorf( - "failed to fetch wallet for reserved deposit [%v]: [%w]", - depositKey, - err, - ) - } - if walletPublicKeyHash == ([20]byte{}) { - return 0, fmt.Errorf("%w for deposit [%v]", errStaleDepositCleared, depositKey) - } - - blockCounter, err := rsdw.spvChain.BlockCounter() - if err != nil { - return 0, fmt.Errorf( - "failed to get block counter for refund deadline snapshot: [%w]", - err, - ) - } - if blockCounter == nil { - return 0, fmt.Errorf( - "failed to get block counter for refund deadline snapshot: nil block counter", - ) - } - currentBlock, err := blockCounter.CurrentBlock() - if err != nil { - return 0, fmt.Errorf( - "failed to get current block for refund deadline snapshot: [%w]", - err, - ) - } - - startBlock := uint64(0) - if currentBlock > reservationDefaultLookBackBlocks { - startBlock = currentBlock - reservationDefaultLookBackBlocks - } - - events, eventsErr := rsdw.spvChain.PastDepositRevealedEvents( - &tbtc.DepositRevealedEventFilter{ - StartBlock: startBlock, - EndBlock: ¤tBlock, - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - }, - ) - if eventsErr != nil { - return 0, fmt.Errorf( - "failed to fetch deposit revealed events for refund deadline "+ - "snapshot: [%w]", - eventsErr, - ) - } - - var matchingEvent *tbtc.DepositRevealedEvent - for _, event := range events { - if rsdw.spvChain.BuildDepositKey( - event.FundingTxHash, - event.FundingOutputIndex, - ).Cmp(depositKey) == 0 { - matchingEvent = event - break - } - } - if matchingEvent == nil { - return 0, fmt.Errorf( - "no matching DepositRevealed event for deposit [%v] within the "+ - "catch-up window", - depositKey, - ) - } - - return reverseUint32(matchingEvent.RefundLocktime), nil -} - -// forgetDeposit clears any cached notification state and snapshotted -// refund deadline held for the given deposit key. The poller invokes +// forgetDeposit clears any cached notification state held for the given +// deposit key. The poller invokes // this once a deposit resolves to Drop or Notified, so a resolved // deposit's per-call cache entries do not linger in these maps for the // remaining life of the process. func (rsdw *ReservationStaleDepositWatcher) forgetDeposit(depositKey *big.Int) { key := depositKey.String() delete(rsdw.notifiedAt, key) - delete(rsdw.refundDeadlineMemo, key) delete(rsdw.attempted, key) } @@ -708,8 +566,13 @@ func (rsdw *ReservationStaleDepositWatcher) pollTick(now uint32) (int, bool) { scanOK = false } - for key, depositKey := range rsdw.pending { - resolution, err := rsdw.CheckStaleReservedDeposit(depositKey, now) + for key, tracked := range rsdw.pending { + depositKey := tracked.depositKey + resolution, err := rsdw.CheckStaleReservedDeposit( + depositKey, + tracked.refundDeadline, + now, + ) if err != nil { reservationWiringLogger.Errorf( "stale-deposit poll failed to check deposit "+ @@ -754,10 +617,10 @@ func (rsdw *ReservationStaleDepositWatcher) trackRevealedDeposits( event.FundingOutputIndex, ) - rsdw.pending[depositKey.String()] = depositKey - rsdw.refundDeadlineMemo[depositKey.String()] = reverseUint32( - event.RefundLocktime, - ) + rsdw.pending[depositKey.String()] = &trackedStaleDeposit{ + depositKey: depositKey, + refundDeadline: reverseUint32(event.RefundLocktime), + } } } diff --git a/pkg/maintainer/spv/reservation_stale_deposit_watch_test.go b/pkg/maintainer/spv/reservation_stale_deposit_watch_test.go index 263385b9a1..05b8805c88 100644 --- a/pkg/maintainer/spv/reservation_stale_deposit_watch_test.go +++ b/pkg/maintainer/spv/reservation_stale_deposit_watch_test.go @@ -37,58 +37,6 @@ func locktimeForDeadline(deadline uint32) [4]byte { return locktime } -// seedPastDepositRevealedEvent installs a DepositRevealed event for the -// given wallet/funding outpoint at refundLocktime, discoverable by -// snapshotRefundDeadline's wallet-keyed event scan. -func seedPastDepositRevealedEvent( - t *testing.T, - spvChain *localChain, - wallet [20]byte, - fundingTxHash bitcoin.Hash, - fundingOutputIndex uint32, - currentBlock uint64, - refundLocktime [4]byte, -) { - t.Helper() - blockCounter := newMockBlockCounter() - blockCounter.SetCurrentBlock(currentBlock) - spvChain.setBlockCounter(blockCounter) - - startBlock := uint64(0) - if currentBlock > reservationDefaultLookBackBlocks { - startBlock = currentBlock - reservationDefaultLookBackBlocks - } - endBlock := currentBlock - if err := spvChain.addPastDepositRevealedEvent( - &tbtc.DepositRevealedEventFilter{ - StartBlock: startBlock, - EndBlock: &endBlock, - WalletPublicKeyHash: [][20]byte{wallet}, - }, - &tbtc.DepositRevealedEvent{ - FundingTxHash: fundingTxHash, - FundingOutputIndex: fundingOutputIndex, - WalletPublicKeyHash: wallet, - RefundLocktime: refundLocktime, - }, - ); err != nil { - t.Fatal(err) - } -} - -// seedStaleDeadline pre-populates the watcher's refund-deadline memo -// directly, standing in for a pollTick discovery pass: production -// snapshots the deadline from the DepositRevealed event at discovery -// time, so most of these tests - which exercise CheckStaleReservedDeposit -// directly - do not need to also seed a matching reveal event. -func seedStaleDeadline( - watcher *ReservationStaleDepositWatcher, - depositKey *big.Int, - deadline uint32, -) { - watcher.refundDeadlineMemo[depositKey.String()] = deadline -} - // TestReverseUint32 pins the exact byte order the Bridge derives from a // reveal-time RefundLocktime. Deposit.sol documents the event field as // "the deposit refund locktime as 4-byte LE", and the on-chain deadline @@ -117,7 +65,7 @@ func TestReservationStaleDepositWatcher_NonReservedDepositIsSkipped(t *testing.T spvChain.setReservedDeposit(reservationDepositKey(0xB001), walletPKH(), false) watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) - res, err := watcher.CheckStaleReservedDeposit(reservationDepositKey(0xB001), 5_000) + res, err := watcher.CheckStaleReservedDeposit(reservationDepositKey(0xB001), 100, 5_000) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -151,9 +99,8 @@ func TestReservationStaleDepositWatcher_LiveWalletNotifiedAfterDeadline(t *testi }) watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) - seedStaleDeadline(watcher, key, 100) - res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + res, err := watcher.CheckStaleReservedDeposit(key, 100, 5_000) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -190,9 +137,8 @@ func TestReservationStaleDepositWatcher_TimedOutActionNotifiedAfterDeadline(t *t }) watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) - seedStaleDeadline(watcher, key, 100) - res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + res, err := watcher.CheckStaleReservedDeposit(key, 100, 5_000) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -231,9 +177,8 @@ func TestReservationStaleDepositWatcher_PendingActionNotNotifiedAfterDeadline(t }) watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) - seedStaleDeadline(watcher, key, 100) - res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + res, err := watcher.CheckStaleReservedDeposit(key, 100, 5_000) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -254,8 +199,8 @@ func TestReservationStaleDepositWatcher_PendingActionNotNotifiedAfterDeadline(t // (ReservedDepositWallet, GetReservation, GetReservationAction), // delegating every other method to the embedded Chain. It verifies the // "no chain reads before the deadline" invariant: with the deadline -// memoized, the pre-deadline check stays in-memory only, and only the -// post-deadline check reads on-chain state. +// taken from the reveal, the pre-deadline check stays in-memory only, +// and only the post-deadline check reads on-chain state. type staleReadCountingChain struct { Chain reservedDepositWalletCalls int @@ -312,9 +257,8 @@ func TestReservationStaleDepositWatcher_NoChainReadsBeforeDeadline(t *testing.T) // gate (see the identical comment in // TestReservationActionTimeoutWatcher_RunLoop_IncrementalTracking) // never masks the notification this test asserts. - seedStaleDeadline(watcher, key, 10_000) - res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + res, err := watcher.CheckStaleReservedDeposit(key, 10_000, 5_000) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -333,7 +277,7 @@ func TestReservationStaleDepositWatcher_NoChainReadsBeforeDeadline(t *testing.T) ) } - res, err = watcher.CheckStaleReservedDeposit(key, 10_601) + res, err = watcher.CheckStaleReservedDeposit(key, 10_000, 10_601) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -376,9 +320,8 @@ func TestReservationStaleDepositWatcher_CompletionDetectedByClearedWalletField(t }) watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) - seedStaleDeadline(watcher, key, 100) - res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + res, err := watcher.CheckStaleReservedDeposit(key, 100, 5_000) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -392,7 +335,7 @@ func TestReservationStaleDepositWatcher_CompletionDetectedByClearedWalletField(t // The notify transaction mines: the record's wallet field clears. spvChain.setReservedDeposit(key, [20]byte{}, true) - res, err = watcher.CheckStaleReservedDeposit(key, 5_060) + res, err = watcher.CheckStaleReservedDeposit(key, 100, 5_060) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -414,9 +357,8 @@ func TestReservationStaleDepositWatcher_CompletionDetectedByClearedWalletField(t spvChain.setReservedDeposit(key, [20]byte{}, true) watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) - seedStaleDeadline(watcher, key, 100) - res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + res, err := watcher.CheckStaleReservedDeposit(key, 100, 5_000) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -449,10 +391,9 @@ func TestReservationStaleDepositWatcher_NotifiedNotConfirmedIsRetried(t *testing }) watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) - seedStaleDeadline(watcher, key, 100) // First tick: submit the notification, awaiting confirmation. - res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + res, err := watcher.CheckStaleReservedDeposit(key, 100, 5_000) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -464,7 +405,7 @@ func TestReservationStaleDepositWatcher_NotifiedNotConfirmedIsRetried(t *testing // neither resubmit nor evict: the wallet field is still non-zero // (the notify transaction has not been observed to take effect), so // the deposit stays tracked rather than being lost. - res, err = watcher.CheckStaleReservedDeposit(key, 5_060) + res, err = watcher.CheckStaleReservedDeposit(key, 100, 5_060) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -480,7 +421,7 @@ func TestReservationStaleDepositWatcher_NotifiedNotConfirmedIsRetried(t *testing // actionTimeoutRenotifyInterval elapses, the watcher must retry // rather than treat the deposit as permanently resolved or lose it. renotifyAfter := 5_000 + uint32(actionTimeoutRenotifyInterval.Seconds()) + 1 - res, err = watcher.CheckStaleReservedDeposit(key, renotifyAfter) + res, err = watcher.CheckStaleReservedDeposit(key, 100, renotifyAfter) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -516,10 +457,9 @@ func TestReservationStaleDepositWatcher_RenotifyBackoffSurvivesNowBeforeNotified }) watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) - seedStaleDeadline(watcher, key, 100) // First tick: submit the notification, recording notifiedAt = 5_000. - res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + res, err := watcher.CheckStaleReservedDeposit(key, 100, 5_000) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -533,7 +473,7 @@ func TestReservationStaleDepositWatcher_RenotifyBackoffSurvivesNowBeforeNotified // than the renotify interval, so the code falls through and // resubmits immediately. The guard must keep this call in the // backoff window instead. - res, err = watcher.CheckStaleReservedDeposit(key, 4_000) + res, err = watcher.CheckStaleReservedDeposit(key, 100, 4_000) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -553,7 +493,7 @@ func TestReservationStaleDepositWatcher_NilDepositKeyError(t *testing.T) { spvChain := newLocalChain() watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) - res, err := watcher.CheckStaleReservedDeposit(nil, 5_000) + res, err := watcher.CheckStaleReservedDeposit(nil, 100, 5_000) if err == nil { t.Fatal("expected error for nil deposit key, got nil") } @@ -570,9 +510,9 @@ func TestReservationStaleDepositWatcher_ReservedDepositWalletChainError(t *testi spvChain.reservedDepositWalletErr = fmt.Errorf("rpc unavailable") watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) - // No memo seeded: the deadline snapshot's own ReservedDepositWallet - // read fails first. - res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + // Past the deadline, the first chain read - ReservedDepositWallet - + // fails. + res, err := watcher.CheckStaleReservedDeposit(key, 100, 5_000) if err == nil { t.Fatal("expected error when ReservedDepositWallet fails, got nil") } @@ -594,9 +534,8 @@ func TestReservationStaleDepositWatcher_GetReservationChainError(t *testing.T) { spvChain.getReservationErr = fmt.Errorf("transient RPC failure") watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) - seedStaleDeadline(watcher, key, 100) - res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + res, err := watcher.CheckStaleReservedDeposit(key, 100, 5_000) if err == nil { t.Fatal("expected error when GetReservation fails, got nil") } @@ -624,9 +563,8 @@ func TestReservationStaleDepositWatcher_GetReservationActionChainError_DoesNotNo spvChain.getReservationActionErr = fmt.Errorf("transient RPC failure") watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) - seedStaleDeadline(watcher, key, 100) - res, err := watcher.CheckStaleReservedDeposit(key, 10_000) + res, err := watcher.CheckStaleReservedDeposit(key, 100, 10_000) if err == nil { t.Fatal("expected error on transient GetReservationAction RPC failure, got nil") } @@ -667,9 +605,8 @@ func TestReservationStaleDepositWatcher_AdvancingNonceEvaluatesCurrentGeneration }) watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) - seedStaleDeadline(watcher, key, 100) - res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + res, err := watcher.CheckStaleReservedDeposit(key, 100, 5_000) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -686,98 +623,6 @@ func TestReservationStaleDepositWatcher_AdvancingNonceEvaluatesCurrentGeneration } } -// TestReservationStaleDepositWatcher_RefundDeadlineFromRevealEvent covers -// the deadline snapshot's derivation path: a deposit checked without a -// pre-populated memo (not yet discovered by pollTick) has its deadline -// recovered from its own DepositRevealed event's RefundLocktime, and -// that decoded value must be honored precisely, both for "not yet -// reached" and "notify". -func TestReservationStaleDepositWatcher_RefundDeadlineFromRevealEvent(t *testing.T) { - spvChain := newLocalChain() - - wallet := walletPKH() - fundingTxHash, err := bitcoin.NewHashFromString( - "585b6699f42291d1a9d0776b75f04c295ea203f83504349db11e94fdae7d1b2c", - bitcoin.InternalByteOrder, - ) - if err != nil { - t.Fatal(err) - } - fundingOutputIndex := uint32(0) - - key := spvChain.BuildDepositKey(fundingTxHash, fundingOutputIndex) - spvChain.setReservedDeposit(key, wallet, true) - spvChain.setWallet(wallet, &tbtc.WalletChainData{State: tbtc.StateUnknown}) - spvChain.setReservation(key, &tbtc.Reservation{RequestNonce: 0}) - - seedPastDepositRevealedEvent( - t, spvChain, wallet, fundingTxHash, fundingOutputIndex, 0, - locktimeForDeadline(4_600), - ) - - watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) - - // now == the exact snapshotted deadline: the contract's gate is - // strictly-greater-than, so this must still defer. - res, err := watcher.CheckStaleReservedDeposit(key, 4_600) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if res != StaleDepositResolutionKeep { - t.Fatalf("expected resolution %v, got %v", StaleDepositResolutionKeep, res) - } - if calls := spvChain.getSubmittedStaleReservedDeposits(); len(calls) != 0 { - t.Fatalf("expected zero notifications at the exact deadline, got %d", len(calls)) - } - - // now strictly past the deadline, with no requested action - // generation (RequestNonce == 0): notification-eligible. now must - // also clear the first-attempt stagger window: deadline (4_600) - // plus the maximum possible reservationOperatorStaggerOffset - // (bounded by actionTimeoutRenotifyInterval, 600s), so 5_201. - res, err = watcher.CheckStaleReservedDeposit(key, 5_201) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if res != StaleDepositResolutionKeep { - t.Fatalf("expected resolution %v, got %v", StaleDepositResolutionKeep, res) - } - calls := spvChain.getSubmittedStaleReservedDeposits() - if len(calls) != 1 { - t.Fatalf("expected one stale notification, got %d", len(calls)) - } - if diff := deep.Equal(key, calls[0]); diff != nil { - t.Errorf("unexpected notified key: %v", diff) - } -} - -// TestReservationStaleDepositWatcher_NoMatchingRevealEventPropagatesError -// covers the derivation miss path: no DepositRevealed event can be found -// for the deposit within the catch-up window, so the deadline cannot be -// snapshotted and the check must propagate an error rather than guess. -func TestReservationStaleDepositWatcher_NoMatchingRevealEventPropagatesError(t *testing.T) { - spvChain := newLocalChain() - spvChain.setBlockCounter(newMockBlockCounter()) - - key := reservationDepositKey(0xB013) - wallet := walletPKH() - spvChain.setReservedDeposit(key, wallet, true) - // No seedPastDepositRevealedEvent call: the scan finds nothing. - - watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) - res, err := watcher.CheckStaleReservedDeposit(key, 5_000) - if err == nil { - t.Fatal("expected error when no matching deposit revealed event exists, got nil") - } - if res != StaleDepositResolutionUnknown { - t.Fatalf("expected resolution %v, got %v", StaleDepositResolutionUnknown, res) - } - - if calls := spvChain.getSubmittedStaleReservedDeposits(); len(calls) != 0 { - t.Fatalf("expected no notifications on chain error, got %d", len(calls)) - } -} - func TestReservationStaleDepositWatcher_NotifierError(t *testing.T) { spvChain := newLocalChain() @@ -793,9 +638,8 @@ func TestReservationStaleDepositWatcher_NotifierError(t *testing.T) { spvChain.notifyStaleReservedDepositErr = fmt.Errorf("notifier unavailable") watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) - seedStaleDeadline(watcher, key, 100) - res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + res, err := watcher.CheckStaleReservedDeposit(key, 100, 5_000) if err == nil { t.Fatal("expected error when the notifier fails, got nil") } @@ -806,7 +650,7 @@ func TestReservationStaleDepositWatcher_NotifierError(t *testing.T) { // TestReservationStaleDepositWatcher_ZeroWalletSkips verifies that a // deposit whose reserved-deposit record is already cleared (wallet field -// zero) when the deadline snapshot is attempted resolves Drop without +// zero) once its deadline has passed resolves Drop without // any notification. func TestReservationStaleDepositWatcher_ZeroWalletSkips(t *testing.T) { spvChain := newLocalChain() @@ -815,7 +659,7 @@ func TestReservationStaleDepositWatcher_ZeroWalletSkips(t *testing.T) { spvChain.setReservedDeposit(key, [20]byte{}, true) watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) - res, err := watcher.CheckStaleReservedDeposit(key, 5_000) + res, err := watcher.CheckStaleReservedDeposit(key, 100, 5_000) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -849,7 +693,7 @@ func (c *staleDepositEventCountingChain) PastDepositRevealedEvents( // TestRunStaleDepositPollTick_TracksVaultMatchWithoutImmediateWalletRead // pins the discovery gate: a newly observed reveal whose vault matches // the reservation vault is tracked directly from the event, with its -// refund deadline memoized from the event's RefundLocktime, so the +// refund deadline taken from the event's RefundLocktime, so the // pre-deadline check performs no per-deposit chain read - and a // deposit whose record has since cleared on-chain is retired by the // post-deadline check's ReservedDepositWallet read alone, without @@ -910,7 +754,7 @@ func TestRunStaleDepositPollTick_TracksVaultMatchWithoutImmediateWalletRead( watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) // First tick, now (1_000) is far before the deadline (50_000): the - // reveal is discovered and tracked, with its deadline memoized, + // reveal is discovered and tracked, with its deadline taken from the event, // and the check stays in-memory. trackedCount, ok := watcher.pollTick(1_000) if !ok { diff --git a/pkg/maintainer/spv/reservation_wiring_test.go b/pkg/maintainer/spv/reservation_wiring_test.go index cb5b388c4c..3677a94969 100644 --- a/pkg/maintainer/spv/reservation_wiring_test.go +++ b/pkg/maintainer/spv/reservation_wiring_test.go @@ -187,9 +187,12 @@ func TestCheckStaleReservedDeposit_Resolution(t *testing.T) { State: test.actionState, }) watcher := NewReservationStaleDepositWatcher(spvChain, common.Address{}, 0) - seedStaleDeadline(watcher, depositKey, test.refundDeadline) - resolution, err := watcher.CheckStaleReservedDeposit(depositKey, test.now) + resolution, err := watcher.CheckStaleReservedDeposit( + depositKey, + test.refundDeadline, + test.now, + ) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -929,11 +932,12 @@ func TestRunStaleDepositPollTick_SnapshotsRefundDeadlineFromRevealEvent(t *testi if trackedCount != 1 { t.Fatalf("expected 1 tracked deposit after the first tick, got %d", trackedCount) } - if _, ok := watcher.pending[depositKey.String()]; !ok { + tracked, ok := watcher.pending[depositKey.String()] + if !ok { t.Fatalf("expected the deposit to be tracked, got %v", watcher.pending) } - if deadline, ok := watcher.refundDeadlineMemo[depositKey.String()]; !ok || deadline != 5_000 { - t.Fatalf("expected the refund deadline snapshotted from the reveal event (5000), got %d (ok=%v)", deadline, ok) + if tracked.refundDeadline != 5_000 { + t.Fatalf("expected the refund deadline decoded from the reveal event (5000), got %d", tracked.refundDeadline) } if calls := spvChain.getSubmittedStaleReservedDeposits(); len(calls) != 0 { t.Fatalf("deposit is before its deadline; expected zero notifications, got %d", len(calls)) From 99a6803476b9698a1a3949d6d3024ad801eace82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 30 Sep 2026 08:55:50 +0000 Subject: [PATCH 21/31] perf(tbtcpg): search re-anchor targets once per Run pass Run searched for a target per reservation and per capacity retry, each time re-running the bounded registration scan, the unbounded fallback, and GetWallet plus capacity reads for every wallet, exactly when caps are saturated. A per-pass target search now reads registrations and wallet states at most once, lazily on the first reservation that needs a target, rules out count-capped wallets for the rest of the pass, remembers the smallest anchor that fits nowhere, and skips already-read wallets in the unbounded fallback. --- pkg/tbtcpg/reservation_reanchor.go | 527 ++++++++++-------- .../reservation_reanchor_metrics_test.go | 255 +++++++-- pkg/tbtcpg/reservation_reanchor_test.go | 23 +- 3 files changed, 485 insertions(+), 320 deletions(-) diff --git a/pkg/tbtcpg/reservation_reanchor.go b/pkg/tbtcpg/reservation_reanchor.go index 6b38f5ce67..71245b894a 100644 --- a/pkg/tbtcpg/reservation_reanchor.go +++ b/pkg/tbtcpg/reservation_reanchor.go @@ -61,12 +61,12 @@ type ReservationReanchorTask struct { // TestReservationReanchorTask_TargetWalletExclusion_SharedTask). targetWalletCacheMutex sync.Mutex // cachedTargetWalletPublicKeyHash is the most recently selected - // re-anchor target wallet. findTargetWallet reuses it across Run - // calls -- validating headroom and liveness only for this one wallet - // -- instead of repeating its O(W) GetWallet-per-registration scan on - // every re-anchor window; a fresh full scan only runs when the cache - // is empty, the cached wallet fails validation, or the cached wallet - // is excluded. Meaningful only when hasCachedTargetWallet is true. + // re-anchor target wallet. reanchorTargetSearch tries it first in + // every Run -- validating headroom and liveness only for this one + // wallet -- instead of repeating its O(W) GetWallet-per-registration + // scan on every re-anchor window; a registration scan only runs when + // the cache is empty or the cached wallet is unusable. Meaningful only + // when hasCachedTargetWallet is true. cachedTargetWalletPublicKeyHash [20]byte hasCachedTargetWallet bool } @@ -222,12 +222,14 @@ func (rrt *ReservationReanchorTask) Run( ) } - // triedTargets accumulates target wallets a cap-related revert has - // already ruled out during this Run pass. Capacity only grows during - // a pass (nothing here releases it), so a wallet excluded for one - // reservation is correctly excluded for every later reservation in - // the same pass too. - triedTargets := make(map[[20]byte]bool) + // One target search serves every reservation of this pass, so + // registrations and wallet states are read at most once per pass. + targets := rrt.newReanchorTargetSearch( + taskLogger, + walletPublicKeyHash, + params.MaxReservationsPerWallet, + maxReservationsAmountPerWallet, + ) reservationLoop: for _, reservationKey := range reservationKeys { @@ -362,14 +364,7 @@ reservationLoop: } for { - target, err := rrt.findTargetWallet( - taskLogger, - walletPublicKeyHash, - anchorValue, - params.MaxReservationsPerWallet, - maxReservationsAmountPerWallet, - triedTargets, - ) + target, err := targets.find(anchorValue) if err != nil { if errors.Is(err, errNoLiveTargetWallet) { taskLogger.Infof( @@ -413,10 +408,9 @@ reservationLoop: if isReservationCapRevertError(err) { // The target's capacity changed since our headroom // pre-check (a concurrent re-anchor or acceptance - // consumed it); evict it and try the next candidate + // consumed it); exclude it and try the next candidate // for this same reservation instead of giving up. - triedTargets[target] = true - rrt.evictCachedTargetWallet(target) + targets.exclude(target) continue } @@ -816,28 +810,34 @@ func (rrt *ReservationReanchorTask) waitForReservationReanchorRequestMined( ) } -// errNoLiveTargetWallet signals that no eligible re-anchor destination -// wallet was found -- either no wallet is Live at all, or every candidate -// lacks count/amount headroom -- a legitimate "nothing to do yet" outcome, -// not a chain-read failure. Run treats it as a benign no-op for the -// current reservation; any other error returned by findTargetWallet is a -// genuine RPC/chain-read failure and is propagated so the coordinator -// retries. -var errNoLiveTargetWallet = errors.New("no live wallet available for re-anchor target") - -// findTargetWallet picks a live destination wallet, with count and amount -// headroom for anchorValue, from the on-chain wallet registry for the -// re-anchor transaction's output. The new wallet must be in StateLive, -// must not be the source wallet itself, must not be in excluded (wallets -// a cap-related revert already ruled out earlier in this Run pass), and -// must have room under maxReservationsPerWallet / -// maxReservationsAmountPerWallet for one more reservation of anchorValue -// satoshi -- mirroring the capacity Reservation.sol's +// reanchorTargetSearch picks live destination wallets for the re-anchor +// requests of one Run pass. A target must be in StateLive, must not be the +// source wallet itself, and must have room under maxReservationsPerWallet / +// maxReservationsAmountPerWallet for one more reservation of the anchor +// value -- mirroring the capacity Reservation.sol's // requestReservationReanchor reserves on the target at request time. // +// The search keeps its state for the whole pass and is local to one Run +// call (the task instance is shared across concurrent Runs for different +// source wallets). Registration events and wallet states are read at most +// once per pass, lazily on the first reservation that needs a target, and +// capacity reads are not repeated for wallets or anchor values already +// ruled out. Nothing in a pass releases target capacity, and a pass ends +// after its first request, so what was ruled out stays ruled out. +// +// The previously selected target wallet is cached across passes and is +// validated first; the registration scan only runs when the cached wallet +// is missing or unusable. The scan is bounded to +// ReservationReanchorLookBackBlocks (mirroring the other look-back scans in +// this package) and falls back to an unbounded scan only when no recently +// registered wallet has headroom: GetLiveWalletsCount (checked by Run) can +// confirm live wallets exist even when none of them registered within the +// look-back window. The fallback skips wallets the bounded scan already +// read. +// // Selection here is independent of the source wallet's own moving funds // commitment (SubmitMovingFundsCommitment / -// PastMovingFundsCommitmentSubmittedEvents): this method does not attempt +// PastMovingFundsCommitmentSubmittedEvents): the search does not attempt // to route the reservation's anchor UTXO to one of the specific wallets // the source wallet has committed to for its Bitcoin funds move. A // reservation re-anchored here may therefore end up under different @@ -850,98 +850,262 @@ var errNoLiveTargetWallet = errors.New("no live wallet available for re-anchor t // and disambiguate among possibly several committed target wallets at // proposal time; that is not worth building unless the chain interface // already exposed the mapping trivially, which it does not today. -// -// The previously selected target wallet is cached and, when present and -// not excluded, is the only wallet validated (GetWallet + StateLive + -// not-the-source + headroom) before reuse -- avoiding the O(W) -// GetWallet-per-registration scan below on every re-anchor window. A -// fresh scan runs only when the cache is empty, the cached wallet fails -// validation (it since went non-Live, lost its headroom, or the caller's -// own source wallet now matches it), or the cached wallet is excluded. -func (rrt *ReservationReanchorTask) findTargetWallet( +type reanchorTargetSearch struct { + rrt *ReservationReanchorTask + logger log.StandardLogger + sourceWalletPublicKeyHash [20]byte + maxReservationsPerWallet uint32 + maxReservationsAmountPerWallet uint64 + + // checked holds every wallet whose state was already read this pass. + checked map[[20]byte]bool + // excluded holds wallets ruled out for every anchor value this pass: + // count cap reached, or rejected by a request-time capacity revert. + excluded map[[20]byte]bool + // candidates are the Live wallets found so far, in selection order: + // the cached wallet first, then newest registration first. + candidates [][20]byte + + cachedChecked bool + recentScanned bool + fullScanned bool + + // noHeadroomFromAnchor is the smallest anchor value no candidate had + // headroom for; zero means no such value is known yet. The amount + // check only gets harder as the anchor grows, so every anchor at or + // above it is ruled out too. + noHeadroomFromAnchor uint64 +} + +// newReanchorTargetSearch returns a target search for one Run pass. It +// reads nothing until find is first called. +func (rrt *ReservationReanchorTask) newReanchorTargetSearch( taskLogger log.StandardLogger, sourceWalletPublicKeyHash [20]byte, - anchorValue uint64, maxReservationsPerWallet uint32, maxReservationsAmountPerWallet uint64, - excluded map[[20]byte]bool, -) ([20]byte, error) { - if cached, ok := rrt.cachedTargetWallet(sourceWalletPublicKeyHash); ok && !excluded[cached] { - walletChainData, err := rrt.chain.GetWallet(cached) - if err == nil && walletChainData.State == tbtc.StateLive { - hasHeadroom, err := rrt.walletHasReanchorHeadroom( - cached, anchorValue, maxReservationsPerWallet, maxReservationsAmountPerWallet, +) *reanchorTargetSearch { + return &reanchorTargetSearch{ + rrt: rrt, + logger: taskLogger, + sourceWalletPublicKeyHash: sourceWalletPublicKeyHash, + maxReservationsPerWallet: maxReservationsPerWallet, + maxReservationsAmountPerWallet: maxReservationsAmountPerWallet, + checked: make(map[[20]byte]bool), + excluded: make(map[[20]byte]bool), + } +} + +// errNoLiveTargetWallet signals that no eligible re-anchor destination +// wallet was found -- either no wallet is Live at all, or every candidate +// lacks count/amount headroom -- a legitimate "nothing to do yet" outcome, +// not a chain-read failure. Run treats it as a benign no-op for the +// current reservation; any other error returned by the target search is a +// genuine RPC/chain-read failure and is propagated so the coordinator +// retries. +var errNoLiveTargetWallet = errors.New("no live wallet available for re-anchor target") + +// find returns a target wallet with headroom for anchorValue, loading more +// candidates only when the ones already found have none. +func (s *reanchorTargetSearch) find(anchorValue uint64) ([20]byte, error) { + if s.noHeadroomFromAnchor != 0 && anchorValue >= s.noHeadroomFromAnchor { + return [20]byte{}, errNoLiveTargetWallet + } + + // Candidates already checked for this anchor value are not checked + // again after more candidates are loaded. + from := 0 + for { + target, found, err := s.pick(anchorValue, from) + if err != nil { + return [20]byte{}, err + } + if found { + s.rrt.setCachedTargetWallet(target) + return target, nil + } + + from = len(s.candidates) + loaded, err := s.loadMoreCandidates() + if err != nil { + return [20]byte{}, err + } + if !loaded { + s.noHeadroomFromAnchor = anchorValue + return [20]byte{}, errNoLiveTargetWallet + } + } +} + +// exclude rules target out for the rest of the pass, after a request-time +// capacity revert showed its capacity changed since the headroom check. +func (s *reanchorTargetSearch) exclude(target [20]byte) { + s.excluded[target] = true + s.rrt.evictCachedTargetWallet(target) +} + +// pick returns the first candidate, starting at index from, with headroom +// for anchorValue. A candidate whose count cap is reached is excluded for +// the rest of the pass. +func (s *reanchorTargetSearch) pick( + anchorValue uint64, + from int, +) ([20]byte, bool, error) { + for _, candidate := range s.candidates[from:] { + if s.excluded[candidate] { + continue + } + + count, err := s.rrt.chain.WalletReservationsCount(candidate) + if err != nil { + return [20]byte{}, false, fmt.Errorf( + "cannot get wallet reservations count: [%w]", + err, ) + } + if count+1 > s.maxReservationsPerWallet { + s.logger.Infof( + "candidate re-anchor target wallet [0x%x] has no count "+ + "headroom, skipping", + candidate, + ) + s.exclude(candidate) + continue + } + + // Zero disables the amount cap, matching Solidity's + // `maxReservationsAmountPerWallet == 0` convention. + if s.maxReservationsAmountPerWallet > 0 { + amount, err := s.rrt.chain.WalletReservationsAmount(candidate) if err != nil { - return [20]byte{}, err + return [20]byte{}, false, fmt.Errorf( + "cannot get wallet reservations amount: [%w]", + err, + ) } - if hasHeadroom { - return cached, nil + if amount+anchorValue > s.maxReservationsAmountPerWallet { + s.logger.Infof( + "candidate re-anchor target wallet [0x%x] has no amount "+ + "headroom for [%d] satoshi, skipping", + candidate, + anchorValue, + ) + s.rrt.evictCachedTargetWallet(candidate) + continue } - taskLogger.Infof( - "cached re-anchor target wallet [0x%x] has no headroom; "+ - "scanning for a new one", - cached, - ) - } else { - taskLogger.Infof( - "cached re-anchor target wallet [0x%x] is no longer valid; "+ - "scanning for a new one", - cached, - ) } - rrt.evictCachedTargetWallet(cached) - } - targetWalletPublicKeyHash, err := rrt.scanForTargetWallet( - taskLogger, - sourceWalletPublicKeyHash, - anchorValue, - maxReservationsPerWallet, - maxReservationsAmountPerWallet, - excluded, - ) - if err != nil { - return [20]byte{}, err + return candidate, true, nil } - rrt.setCachedTargetWallet(targetWalletPublicKeyHash) - return targetWalletPublicKeyHash, nil + return [20]byte{}, false, nil } -// walletHasReanchorHeadroom mirrors the capacity check -// Reservation.sol's requestReservationReanchor performs on the target -// wallet at request time: the wallet's count of currently-custodied -// reservations plus one must not exceed maxReservationsPerWallet, and -// (only when the amount cap is configured -- zero disables it, matching -// Solidity's `maxReservationsAmountPerWallet == 0` disable convention) -// its current reserved amount plus anchorValue must not exceed -// maxReservationsAmountPerWallet. -func (rrt *ReservationReanchorTask) walletHasReanchorHeadroom( - walletPublicKeyHash [20]byte, - anchorValue uint64, - maxReservationsPerWallet uint32, - maxReservationsAmountPerWallet uint64, -) (bool, error) { - count, err := rrt.chain.WalletReservationsCount(walletPublicKeyHash) - if err != nil { - return false, fmt.Errorf("cannot get wallet reservations count: [%w]", err) - } - if count+1 > maxReservationsPerWallet { - return false, nil +// loadMoreCandidates adds the next batch of Live wallets to the +// candidates: the cached wallet, then the wallets registered within the +// look-back window, then every other registered wallet. It reports false +// once there is nothing left to load. +func (s *reanchorTargetSearch) loadMoreCandidates() (bool, error) { + if !s.cachedChecked { + s.cachedChecked = true + if cached, ok := s.rrt.cachedTargetWallet(s.sourceWalletPublicKeyHash); ok { + if !s.addCandidateIfLive(cached) { + s.logger.Infof( + "cached re-anchor target wallet [0x%x] is no longer "+ + "valid; scanning for a new one", + cached, + ) + s.rrt.evictCachedTargetWallet(cached) + } + return true, nil + } } - if maxReservationsAmountPerWallet > 0 { - amount, err := rrt.chain.WalletReservationsAmount(walletPublicKeyHash) + if !s.recentScanned { + s.recentScanned = true + + blockCounter, err := s.rrt.chain.BlockCounter() + if err != nil { + return false, fmt.Errorf("failed to get block counter: [%v]", err) + } + currentBlock, err := blockCounter.CurrentBlock() if err != nil { - return false, fmt.Errorf("cannot get wallet reservations amount: [%w]", err) + return false, fmt.Errorf("failed to get current block: [%v]", err) } - if amount+anchorValue > maxReservationsAmountPerWallet { - return false, nil + + startBlock := uint64(0) + if currentBlock > ReservationReanchorLookBackBlocks { + startBlock = currentBlock - ReservationReanchorLookBackBlocks + } + if startBlock == 0 { + // The bounded scan already covers full chain history. + s.fullScanned = true } + + return true, s.addRegisteredCandidates(startBlock) + } + + if !s.fullScanned { + s.fullScanned = true + + s.logger.Infof( + "no live re-anchor target with headroom registered within the "+ + "last [%d] blocks, falling back to an unbounded registration "+ + "event scan", + ReservationReanchorLookBackBlocks, + ) + + return true, s.addRegisteredCandidates(0) + } + + return false, nil +} + +// addRegisteredCandidates scans new-wallet-registered events from +// startBlock and adds, newest first, the Live wallets not read yet. +func (s *reanchorTargetSearch) addRegisteredCandidates(startBlock uint64) error { + events, err := s.rrt.chain.PastNewWalletRegisteredEvents( + &tbtc.NewWalletRegisteredEventFilter{StartBlock: startBlock}, + ) + if err != nil { + return fmt.Errorf( + "failed to get past new wallet registered events: [%v]", + err, + ) + } + + for i := len(events) - 1; i >= 0; i-- { + s.addCandidateIfLive(events[i].WalletPublicKeyHash) + } + + return nil +} + +// addCandidateIfLive reads the wallet's state, unless it was already read +// this pass, and adds it to the candidates if it is Live and not the +// source wallet. It reports whether the wallet was added. +func (s *reanchorTargetSearch) addCandidateIfLive(walletPublicKeyHash [20]byte) bool { + if walletPublicKeyHash == s.sourceWalletPublicKeyHash || + s.checked[walletPublicKeyHash] { + return false + } + s.checked[walletPublicKeyHash] = true + + wallet, err := s.rrt.chain.GetWallet(walletPublicKeyHash) + if err != nil { + s.logger.Errorf( + "failed to get wallet data for wallet with PKH [0x%x]: [%v]", + walletPublicKeyHash, + err, + ) + return false + } + if wallet.State != tbtc.StateLive { + return false } - return true, nil + s.candidates = append(s.candidates, walletPublicKeyHash) + return true } // cachedTargetWallet returns the cached re-anchor target wallet, if any, @@ -965,7 +1129,7 @@ func (rrt *ReservationReanchorTask) cachedTargetWallet( } // setCachedTargetWallet records the most recently selected re-anchor -// target wallet for reuse by future findTargetWallet calls. +// target wallet for reuse by future Run passes. func (rrt *ReservationReanchorTask) setCachedTargetWallet( targetWalletPublicKeyHash [20]byte, ) { @@ -977,9 +1141,9 @@ func (rrt *ReservationReanchorTask) setCachedTargetWallet( } // evictCachedTargetWallet clears the cached re-anchor target wallet if it -// currently equals target, so the next findTargetWallet call performs a -// fresh scan instead of reusing a wallet just proven to have no headroom -// or to have reverted on capacity. +// currently equals target, so the next Run pass performs a fresh scan +// instead of reusing a wallet just proven to have no headroom or to have +// reverted on capacity. func (rrt *ReservationReanchorTask) evictCachedTargetWallet(target [20]byte) { rrt.targetWalletCacheMutex.Lock() defer rrt.targetWalletCacheMutex.Unlock() @@ -989,137 +1153,6 @@ func (rrt *ReservationReanchorTask) evictCachedTargetWallet(target [20]byte) { } } -// scanForTargetWallet performs the registration-event scan findTargetWallet -// falls back to when no cached target wallet is usable. The primary scan -// is bounded to ReservationReanchorLookBackBlocks (mirroring the other -// look-back scans in this package): an unbounded eth_getLogs scan on -// every re-anchor attempt is too expensive to run every window. -// GetLiveWalletsCount (checked by the caller before findTargetWallet -// runs) can confirm live wallets exist even when none of them registered -// within the look-back window, so a bounded scan that finds no candidate -// falls back to an unbounded one instead of leaving Run stuck returning -// no proposal indefinitely. -func (rrt *ReservationReanchorTask) scanForTargetWallet( - taskLogger log.StandardLogger, - sourceWalletPublicKeyHash [20]byte, - anchorValue uint64, - maxReservationsPerWallet uint32, - maxReservationsAmountPerWallet uint64, - excluded map[[20]byte]bool, -) ([20]byte, error) { - blockCounter, err := rrt.chain.BlockCounter() - if err != nil { - return [20]byte{}, fmt.Errorf("failed to get block counter: [%v]", err) - } - - 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, - anchorValue, - maxReservationsPerWallet, - maxReservationsAmountPerWallet, - excluded, - ) - if err == nil { - return targetWalletPublicKeyHash, nil - } - if startBlock == 0 { - // The bounded scan above already covered full chain history. - return [20]byte{}, err - } - - taskLogger.Infof( - "no live re-anchor target with headroom registered within the "+ - "last [%d] blocks, falling back to an unbounded registration "+ - "event scan", - ReservationReanchorLookBackBlocks, - ) - - return rrt.findLiveWalletFromRegistrationEvents( - taskLogger, - sourceWalletPublicKeyHash, - 0, - anchorValue, - maxReservationsPerWallet, - maxReservationsAmountPerWallet, - excluded, - ) -} - -// findLiveWalletFromRegistrationEvents scans new-wallet-registered events -// starting at startBlock and returns the most-recently-registered Live -// wallet, other than sourceWalletPublicKeyHash or any wallet in excluded, -// that has count/amount headroom for anchorValue. -func (rrt *ReservationReanchorTask) findLiveWalletFromRegistrationEvents( - taskLogger log.StandardLogger, - sourceWalletPublicKeyHash [20]byte, - startBlock uint64, - anchorValue uint64, - maxReservationsPerWallet uint32, - maxReservationsAmountPerWallet uint64, - excluded map[[20]byte]bool, -) ([20]byte, error) { - events, err := rrt.chain.PastNewWalletRegisteredEvents( - &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 || excluded[walletPubKeyHash] { - 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 { - continue - } - - hasHeadroom, err := rrt.walletHasReanchorHeadroom( - walletPubKeyHash, anchorValue, maxReservationsPerWallet, maxReservationsAmountPerWallet, - ) - if err != nil { - return [20]byte{}, err - } - if !hasHeadroom { - taskLogger.Infof( - "candidate re-anchor target wallet [0x%x] has no headroom, skipping", - walletPubKeyHash, - ) - continue - } - - return walletPubKeyHash, nil - } - - return [20]byte{}, errNoLiveTargetWallet -} - // 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 diff --git a/pkg/tbtcpg/reservation_reanchor_metrics_test.go b/pkg/tbtcpg/reservation_reanchor_metrics_test.go index 2524e592ab..282b5c52de 100644 --- a/pkg/tbtcpg/reservation_reanchor_metrics_test.go +++ b/pkg/tbtcpg/reservation_reanchor_metrics_test.go @@ -4,12 +4,13 @@ import ( "math/big" "testing" + "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/tbtc" ) // scanningLocalChain wraps LocalChain with a PastNewWalletRegisteredEvents // call counter so a cache-vs-scan test can observe how many registration -// scans findTargetWallet actually triggered. +// scans the re-anchor target search actually triggered. type scanningLocalChain struct { *LocalChain @@ -82,17 +83,15 @@ func TestReservationReanchorTask_RecordsLiveWalletsCountGauge(t *testing.T) { } } -// TestReservationReanchorTask_FindTargetWallet_CachesAcrossRuns is a -// regression test for the target-wallet cache added to findTargetWallet: -// once a re-anchor target wallet has been selected for a source wallet, -// a subsequent call to findTargetWallet for the same source wallet must +// TestReservationReanchorTask_TargetSearch_CachesAcrossRuns is a +// regression test for the target-wallet cache: once a re-anchor target +// wallet has been selected for a source wallet, the next Run pass must // reuse the cached target -- validating only that one wallet via // GetWallet -- instead of repeating the O(W) GetWallet-per-registration -// scan. A cached target whose headroom has since dropped must be -// evicted so the next call performs a fresh scan, and that fresh scan -// must pick the most recently registered Live wallet that still has -// room. -func TestReservationReanchorTask_FindTargetWallet_CachesAcrossRuns(t *testing.T) { +// scan. A cached target whose count headroom has since dropped must be +// evicted so that pass performs a fresh scan, and that fresh scan must +// pick the most recently registered Live wallet that still has room. +func TestReservationReanchorTask_TargetSearch_CachesAcrossRuns(t *testing.T) { walletA := [20]byte{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1} walletB := [20]byte{2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2} walletC := [20]byte{3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3} @@ -120,56 +119,37 @@ func TestReservationReanchorTask_FindTargetWallet_CachesAcrossRuns(t *testing.T) lc.SetWallet(walletB, &tbtc.WalletChainData{State: tbtc.StateLive}) lc.SetWallet(walletC, &tbtc.WalletChainData{State: tbtc.StateLive}) - lc.SetReservationParameters(tbtc.ReservationParameters{ - ReservationTxMaxFee: 100000, - MaxReservationsPerWallet: 5, - }) - task := NewReservationReanchorTask(lc, NewLocalBitcoinChain()) - params, err := task.chain.ReservationParameters() - if err != nil { - t.Fatalf("ReservationParameters: %v", err) - } - - // First call: no cache, must scan the registration events. - got, err := task.findTargetWallet( - logger, - walletA, - 100000, - params.MaxReservationsPerWallet, - 100000000, - map[[20]byte]bool{}, - ) + // Each pass gets its own search, as each Run does. + find := func() ([20]byte, error) { + return task.newReanchorTargetSearch(logger, walletA, 5, 100000000).find(100000) + } + + // First pass: no cache, must scan the registration events. + got, err := find() if err != nil { - t.Fatalf("first findTargetWallet: unexpected error: %v", err) + t.Fatalf("first pass: unexpected error: %v", err) } if got != walletC { - t.Fatalf("first call: expected target wallet C [%x], got [%x]", walletC, got) + t.Fatalf("first pass: expected target wallet C [%x], got [%x]", walletC, got) } if lc.calls != 1 { t.Fatalf( - "first call should have triggered exactly 1 registration-event "+ + "first pass should have triggered exactly 1 registration-event "+ "scan, got %d", lc.calls, ) } - // Second call: cache hit. No new registration-event scan. - got, err = task.findTargetWallet( - logger, - walletA, - 100000, - params.MaxReservationsPerWallet, - 100000000, - map[[20]byte]bool{}, - ) + // Second pass: cache hit. No new registration-event scan. + got, err = find() if err != nil { - t.Fatalf("second findTargetWallet (cache hit): unexpected error: %v", err) + t.Fatalf("second pass (cache hit): unexpected error: %v", err) } if got != walletC { t.Fatalf( - "second call: expected the cached target wallet C [%x], got [%x]", + "second pass: expected the cached target wallet C [%x], got [%x]", walletC, got, ) @@ -177,34 +157,27 @@ func TestReservationReanchorTask_FindTargetWallet_CachesAcrossRuns(t *testing.T) if lc.calls != 1 { t.Fatalf( "cache hit must not trigger another registration-event scan; "+ - "observed %d total scans after 2 calls (want 1)", + "observed %d total scans after 2 passes (want 1)", lc.calls, ) } - // Third call: cached target C now has no headroom (count = cap), - // which forces eviction and a fresh scan. The fresh scan walks B - // and C newest-first; C is excluded by the caller-supplied - // triedTargets map, B has headroom -- so B wins. + // Third pass: cached target C now has no count headroom (count = + // cap), which forces eviction and a fresh scan. The fresh scan walks + // C and B newest-first; C was already read this pass and is + // excluded, B has headroom -- so B wins. walletKeys := make([]*big.Int, 5) for i := range walletKeys { walletKeys[i] = big.NewInt(int64(1000 + i)) } lc.SetWalletReservations(walletC, walletKeys) - got, err = task.findTargetWallet( - logger, - walletA, - 100000, - params.MaxReservationsPerWallet, - 100000000, - map[[20]byte]bool{walletC: true}, - ) + got, err = find() if err != nil { - t.Fatalf("third findTargetWallet (evict + rescan): unexpected error: %v", err) + t.Fatalf("third pass (evict + rescan): unexpected error: %v", err) } if got != walletB { t.Fatalf( - "third call: expected the fresh-scan target wallet B [%x] "+ + "third pass: expected the fresh-scan target wallet B [%x] "+ "after the cached C lost headroom, got [%x]", walletB, got, @@ -213,8 +186,172 @@ func TestReservationReanchorTask_FindTargetWallet_CachesAcrossRuns(t *testing.T) if lc.calls != 2 { t.Fatalf( "eviction must trigger a fresh scan; observed %d total "+ - "scans after 3 calls (want 2)", + "scans after 3 passes (want 2)", lc.calls, ) } } + +// targetSearchCountingChain counts the chain reads the re-anchor target +// search makes. +type targetSearchCountingChain struct { + *LocalChain + + registrationScans int + walletReads map[[20]byte]int + countReads map[[20]byte]int +} + +func (c *targetSearchCountingChain) PastNewWalletRegisteredEvents( + filter *tbtc.NewWalletRegisteredEventFilter, +) ([]*tbtc.NewWalletRegisteredEvent, error) { + c.registrationScans++ + return c.LocalChain.PastNewWalletRegisteredEvents(filter) +} + +func (c *targetSearchCountingChain) GetWallet( + walletPublicKeyHash [20]byte, +) (*tbtc.WalletChainData, error) { + c.walletReads[walletPublicKeyHash]++ + return c.LocalChain.GetWallet(walletPublicKeyHash) +} + +func (c *targetSearchCountingChain) WalletReservationsCount( + walletPublicKeyHash [20]byte, +) (uint32, error) { + c.countReads[walletPublicKeyHash]++ + return c.LocalChain.WalletReservationsCount(walletPublicKeyHash) +} + +// TestReservationReanchorTask_TargetSearch_ReadsOncePerRun pins that when +// no Live wallet has headroom, a Run pass with several reservations +// searches for a target once: one bounded and one unbounded registration +// scan, one state read per wallet, one count read for a count-capped +// wallet, and no capacity reads at all for a reservation whose anchor is +// at least one already found to fit nowhere. Saturated caps are exactly when this +// search repeats, so the pass must not repeat it per reservation. +func TestReservationReanchorTask_TargetSearch_ReadsOncePerRun(t *testing.T) { + source := [20]byte{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1} + countFull := [20]byte{2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2} + amountFull := [20]byte{3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3} + oldCountFull := [20]byte{4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4} + + lc := NewLocalChain() + chain := &targetSearchCountingChain{ + LocalChain: lc, + walletReads: make(map[[20]byte]int), + countReads: make(map[[20]byte]int), + } + + // A current block past the look-back window makes the bounded scan + // start above zero, so the unbounded fallback also runs. + blockCounter := NewMockBlockCounter() + blockCounter.SetCurrentBlock(300000) + lc.SetBlockCounter(blockCounter) + recent := &tbtc.NewWalletRegisteredEventFilter{ + StartBlock: 300000 - ReservationReanchorLookBackBlocks, + } + all := &tbtc.NewWalletRegisteredEventFilter{StartBlock: 0} + for _, registration := range []struct { + filter *tbtc.NewWalletRegisteredEventFilter + wallet [20]byte + }{ + {recent, countFull}, + {recent, amountFull}, + {all, oldCountFull}, + {all, countFull}, + {all, amountFull}, + } { + if err := lc.AddPastNewWalletRegisteredEvent( + registration.filter, + &tbtc.NewWalletRegisteredEvent{WalletPublicKeyHash: registration.wallet}, + ); err != nil { + t.Fatal(err) + } + } + + lc.SetWallet(source, &tbtc.WalletChainData{State: tbtc.StateMovingFunds}) + for _, wallet := range [][20]byte{countFull, amountFull, oldCountFull} { + lc.SetWallet(wallet, &tbtc.WalletChainData{State: tbtc.StateLive}) + } + lc.SetLiveWalletsCount(3) + lc.SetReservationParameters(tbtc.ReservationParameters{ + ReservationTxMaxFee: 10000, + MaxReservationsPerWallet: 2, + ReservationActionTimeout: 86400, + }) + lc.SetReservationCaps(300000, 0) + + reservation := func(key int64, wallet [20]byte, value int64) *big.Int { + reservationKey := big.NewInt(key) + lc.SetReservation(reservationKey, &tbtc.Reservation{ + WalletPublicKeyHash: wallet, + AnchorUtxo: &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{byte(key)}, + }, + Value: value, + }, + State: tbtc.ReservationStateActive, + }) + return reservationKey + } + // countFull and oldCountFull are at the count cap; amountFull has + // count room but 250000 reserved, so no anchor of 200000 or more fits. + lc.SetWalletReservations(countFull, []*big.Int{ + reservation(11, countFull, 1), reservation(12, countFull, 1), + }) + lc.SetWalletReservations(oldCountFull, []*big.Int{ + reservation(13, oldCountFull, 1), reservation(14, oldCountFull, 1), + }) + lc.SetWalletReservations(amountFull, []*big.Int{ + reservation(15, amountFull, 250000), + }) + // The 300000 anchor is ruled out by the 200000 one without any read; + // the smaller 100000 anchor re-checks only amountFull, since the + // count-capped wallets are ruled out for every anchor. + lc.SetWalletReservations(source, []*big.Int{ + reservation(21, source, 200000), + reservation(22, source, 300000), + reservation(23, source, 100000), + }) + + task := NewReservationReanchorTask(chain, NewLocalBitcoinChain()) + proposal, ok, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: source, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok || proposal != nil { + t.Fatalf("expected no proposal, got ok=%v proposal=%v", ok, proposal) + } + + if chain.registrationScans != 2 { + t.Errorf( + "expected one bounded and one unbounded registration scan, got %d", + chain.registrationScans, + ) + } + expectedCountReads := map[[20]byte]int{ + countFull: 1, + oldCountFull: 1, + amountFull: 2, + } + for wallet, expected := range expectedCountReads { + if got := chain.walletReads[wallet]; got != 1 { + t.Errorf("expected wallet [%x] state to be read once, got %d", wallet, got) + } + if got := chain.countReads[wallet]; got != expected { + t.Errorf( + "expected wallet [%x] count to be read %d times, got %d", + wallet, + expected, + got, + ) + } + } + if got := len(lc.GetReservationReanchorRequestAttempts()); got != 0 { + t.Errorf("expected no re-anchor request, got %d", got) + } +} diff --git a/pkg/tbtcpg/reservation_reanchor_test.go b/pkg/tbtcpg/reservation_reanchor_test.go index 084ecc0b30..d5b174c951 100644 --- a/pkg/tbtcpg/reservation_reanchor_test.go +++ b/pkg/tbtcpg/reservation_reanchor_test.go @@ -29,7 +29,7 @@ func TestReservationReanchorTask_Run(t *testing.T) { tbtcChain := tbtcpg.NewLocalChain() btcChain := tbtcpg.NewLocalBitcoinChain() - // findTargetWallet now bounds its wallet-registration scan to + // The target search bounds its wallet-registration scan to // ReservationReanchorLookBackBlocks; a small current block keeps // the computed StartBlock at 0, matching the filter used below. blockCounter := tbtcpg.NewMockBlockCounter() @@ -195,7 +195,7 @@ func TestReservationReanchorTask_Run(t *testing.T) { // Fall back to a non-zero MaxReservationsPerWallet when the // scenario omits it: the headroom pre-check used by - // findTargetWallet rejects every candidate when the cap is + // the target search rejects every candidate when the cap is // zero (count + 1 > 0 is always true), so a zero default // would silently turn every scenario into "no live target". maxPerWallet := scenario.MaxReservationsPerWallet @@ -211,10 +211,10 @@ func TestReservationReanchorTask_Run(t *testing.T) { btcChain.SetEstimateSatPerVByteFee(1, scenario.EstimateSatPerVByteFee) // Unconditionally register the source wallet itself in the - // same past-registration-events bucket findTargetWallet + // same past-registration-events bucket the target search // queries (filter{StartBlock: 0}), even for scenarios with no - // target wallet. findLiveWalletFromRegistrationEvents always - // skips a registration matching the source wallet, so this is + // target wallet. The target search always skips a + // registration matching the source wallet, so this is // inert for target selection; its only purpose is to give the // mock chain a populated entry so PastNewWalletRegisteredEvents // returns an (empty-after-filtering) slice instead of its @@ -669,7 +669,7 @@ type reservationReanchorLocalChain struct { // pastNewWalletRegisteredEventsCalls counts calls to // PastNewWalletRegisteredEvents, letting tests assert on how many - // times findTargetWallet's registration-event scan actually ran + // times the target search's registration-event scan actually ran // (e.g. that a cached target wallet suppressed a repeat scan). pastNewWalletRegisteredEventsCalls int @@ -947,14 +947,9 @@ func TestReservationReanchorTask_Run_NotifiesMovingFundsBelowDust(t *testing.T) }) } -// TestReservationReanchorTask_FindTargetWallet_CachesAcrossRuns was moved -// to reservation_reanchor_metrics_test.go, which is in package tbtcpg and -// can therefore reach the unexported findTargetWallet method directly. -// See that file for the test body. - // TestReservationReanchorTask_CapRevertLeadsToNextCandidate is a -// regression test for the cap-revert branch Run adds on top of -// findTargetWallet: a request-time capacity revert (modeled here as a +// regression test for the cap-revert branch Run adds on top of the +// target search: a request-time capacity revert (modeled here as a // concurrent consumer eating the cached target's headroom between the // headroom pre-check and the request submission) must not abort the // coordination window. Run must evict the reverted target, mark it as @@ -1178,7 +1173,7 @@ func TestReservationReanchorTask_AmountCapFillLeadsToNextCandidate(t *testing.T) tbtcChain.SetBlockCounter(blockCounter) // Register the alternate target (oldest), then the filled - // target (newest): findTargetWallet scans registration events + // target (newest): the target search scans registration events // newest-first, so the filled target is the first candidate // examined -- it passes the headroom pre-check and its request // reverts on the amount cap, forcing eviction to the alternate. From add36ce3cdefcb35603f2cc66e0dae9c07d6a602 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 30 Sep 2026 08:56:14 +0000 Subject: [PATCH 22/31] test(spv): wait for the watcher recover path in the typed-nil metrics test The end-to-end typed-nil test returned once the forced panic fired, before the deferred recover had called recordReservationWatcherDeath. A no-op hook now runs after that call and the test waits on it. --- pkg/maintainer/spv/reservation_wiring.go | 9 ++++++ pkg/maintainer/spv/reservation_wiring_test.go | 32 +++++++++---------- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/pkg/maintainer/spv/reservation_wiring.go b/pkg/maintainer/spv/reservation_wiring.go index 3baf53f99e..b8160c10c1 100644 --- a/pkg/maintainer/spv/reservation_wiring.go +++ b/pkg/maintainer/spv/reservation_wiring.go @@ -57,6 +57,13 @@ const DefaultReservationActionTimeoutPollInterval = 1 * time.Minute // without waiting on the real one-minute interval. var reservationActionTimeoutPollInterval = DefaultReservationActionTimeoutPollInterval +// reservationWatcherPanicRecovered runs at the end of a watcher +// goroutine's recovered-panic path, right after +// recordReservationWatcherDeath. It does nothing in production; declared +// as a var solely so tests in this package can wait until that path has +// finished. +var reservationWatcherPanicRecovered = func() {} + // reservationDefaultLookBackBlocks bounds every reservation watcher's // startup/first-pass catch-up scan window: 30 days at 12s/block. It is // the single source of truth for this bound, replacing what were @@ -439,6 +446,7 @@ func WireReservationWatchers( r, ) recordReservationWatcherDeath(metricsRecorder) + reservationWatcherPanicRecovered() } if !resultSent { resultCh <- result @@ -489,6 +497,7 @@ func WireReservationWatchers( r, ) recordReservationWatcherDeath(metricsRecorder) + reservationWatcherPanicRecovered() } if !resultSent { resultCh <- result diff --git a/pkg/maintainer/spv/reservation_wiring_test.go b/pkg/maintainer/spv/reservation_wiring_test.go index 3677a94969..f3c8043ea7 100644 --- a/pkg/maintainer/spv/reservation_wiring_test.go +++ b/pkg/maintainer/spv/reservation_wiring_test.go @@ -1171,14 +1171,19 @@ func TestRecordReservationWatcherDeath_TypedNilPerformanceMetrics(t *testing.T) // process crash) is the proof the guard holds end-to-end, not just at the // unit level above. // -// The watcher goroutines' completion is signalled, not slept for: each -// intercepted chain read sends on deathSignal before it panics, so the -// test waits - with a bounded timeout - for both goroutines to actually -// reach their forced panic instead of assuming 100ms is enough. +// The test waits - with a bounded timeout - until both goroutines have +// finished their recover path (reservationWatcherPanicRecovered runs +// right after recordReservationWatcherDeath), so it cannot pass before +// the guarded call has actually run. func TestWireReservationWatchers_TypedNilPerformanceMetricsSurvivesWatcherDeath(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + recovered := make(chan struct{}, 2) + previousHook := reservationWatcherPanicRecovered + reservationWatcherPanicRecovered = func() { recovered <- struct{}{} } + t.Cleanup(func() { reservationWatcherPanicRecovered = previousHook }) + spvChain := newLocalChain() blockCounter := newMockBlockCounter() blockCounter.SetCurrentBlock(1000) @@ -1199,25 +1204,18 @@ func TestWireReservationWatchers_TypedNilPerformanceMetricsSurvivesWatcherDeath( t.Fatalf("unexpected error: %v", err) } - // One signal per watcher goroutine at its forced panic; waiting for - // both (bounded) is the assertion setup - a goroutine that never - // reaches its chain read means the death path was not actually - // exercised, and the test fails loudly rather than passing by - // timing. + // One signal per watcher goroutine once its recover path, including + // recordReservationWatcherDeath against the typed-nil recorder, has + // completed. A crash there would take the whole test process down, + // so receiving both signals is the assertion. deadline := time.After(5 * time.Second) for range 2 { select { - case <-deathSignal: + case <-recovered: case <-deadline: - t.Fatal("a watcher goroutine never reached its forced panic") + t.Fatal("a watcher goroutine never completed its recovered-panic path") } } - - // Both watcher goroutines have reached their forced panic and - // dispatched their recover handlers, which run - // recordReservationWatcherDeath against the typed-nil recorder; - // a crash would take the whole test process down, so surviving to - // here (with both signals observed) is the assertion. } // TestScanReservationStrandingStartupRegistrations verifies that the From 253271ee14464496cd52e71abe89bbfe8eeba99f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 30 Sep 2026 08:56:56 +0000 Subject: [PATCH 23/31] test(tbtcpg): cover the re-anchor amount headroom pre-check and safety margin Add a Run test where only the amount pre-check rules out a partly filled target, with the boundary where the target lands exactly on the cap and the zero-cap-is-unlimited case, each asserting which target received the single request. Pin the Go copy of the validator's 7200-second request timeout safety margin. --- .../reservation_reanchor_inflight_test.go | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/pkg/tbtcpg/reservation_reanchor_inflight_test.go b/pkg/tbtcpg/reservation_reanchor_inflight_test.go index c21eae5fbc..bccfc8f789 100644 --- a/pkg/tbtcpg/reservation_reanchor_inflight_test.go +++ b/pkg/tbtcpg/reservation_reanchor_inflight_test.go @@ -759,3 +759,109 @@ func TestReservationReanchorTask_RequestTimeGates(t *testing.T) { }) } } + +// TestReservationReanchorTask_AmountHeadroomPrecheck pins the amount half +// of the target headroom pre-check on its own: a Live target with count +// room but whose reserved amount plus the anchor exceeds +// maxReservationsAmountPerWallet must be skipped without a request (the +// request would revert on-chain), a target landing exactly on the cap must +// be accepted (Solidity reverts only above it), and a zero cap must not +// limit the amount at all. +func TestReservationReanchorTask_AmountHeadroomPrecheck(t *testing.T) { + tests := map[string]struct { + amountCap uint64 + filledReserved int64 + expectFilled bool + }{ + "target over the amount cap is skipped": { + amountCap: 300000, + filledReserved: 150000, + expectFilled: false, + }, + "target landing exactly on the amount cap is accepted": { + amountCap: 300000, + filledReserved: 100000, + expectFilled: true, + }, + "zero amount cap is unlimited": { + amountCap: 0, + filledReserved: 1000000000000, + expectFilled: true, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + tbtcChain, _, _, task, sourceWalletPublicKeyHash, alternateTarget, _ := + newInFlightReanchorFixture(t) + + // The partly filled target registered last, so the search + // examines it before the fixture's target. + filledTarget := [20]byte{8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8} + if err := tbtcChain.AddPastNewWalletRegisteredEvent( + &tbtc.NewWalletRegisteredEventFilter{StartBlock: 0}, + &tbtc.NewWalletRegisteredEvent{WalletPublicKeyHash: filledTarget}, + ); err != nil { + t.Fatal(err) + } + tbtcChain.SetWallet(filledTarget, &tbtc.WalletChainData{State: tbtc.StateLive}) + tbtcChain.SetLiveWalletsCount(2) + filledKey := big.NewInt(8801) + tbtcChain.SetReservation(filledKey, &tbtc.Reservation{ + WalletPublicKeyHash: filledTarget, + AnchorUtxo: &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{TransactionHash: bitcoin.Hash{0x88}}, + Value: test.filledReserved, + }, + State: tbtc.ReservationStateActive, + }) + tbtcChain.SetWalletReservations(filledTarget, []*big.Int{filledKey}) + tbtcChain.SetReservationCaps(test.amountCap, 0) + + proposal, ok, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: sourceWalletPublicKeyHash, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok || proposal == nil { + t.Fatalf("expected a proposal, got ok=%v proposal=%v", ok, proposal) + } + + expectedTarget := alternateTarget + if test.expectFilled { + expectedTarget = filledTarget + } + if got := proposal.(*tbtc.ReservationReanchorProposal).TargetWalletPublicKeyHash; got != expectedTarget { + t.Fatalf("expected target [%x], got [%x]", expectedTarget, got) + } + attempts := tbtcChain.GetReservationReanchorRequestAttempts() + if len(attempts) != 1 { + t.Fatalf("expected exactly 1 re-anchor request, got %d", len(attempts)) + } + if attempts[0].TargetWalletPublicKeyHash != expectedTarget { + t.Fatalf( + "expected the only request to target [%x], got [%x]", + expectedTarget, + attempts[0].TargetWalletPublicKeyHash, + ) + } + }) + } +} + +// TestReservationRequestTimeoutSafetyMarginMatchesValidator pins the Go +// copy of WalletProposalValidatorConstants.REQUEST_TIMEOUT_SAFETY_MARGIN +// (tbtc-v2 solidity/contracts/bridge/WalletProposalValidatorConstants.sol, +// 2 hours). The EVM harness test checks the on-chain value; this checks +// the constant the re-anchor and acceptance tasks use to skip generations +// the validator would no longer sign. +func TestReservationRequestTimeoutSafetyMarginMatchesValidator(t *testing.T) { + if reservationRequestTimeoutSafetyMarginSeconds != 7200 { + t.Fatalf( + "expected the request timeout safety margin to be 7200 "+ + "seconds, got %d", + reservationRequestTimeoutSafetyMarginSeconds, + ) + } +} From c575313d605b0eb5734493e57527df1e3ec901f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 30 Sep 2026 08:57:58 +0000 Subject: [PATCH 24/31] test(cmd): cover the Ethereum network handed to the SPV maintainer The copy of [ethereum] Network into the SPV maintainer config moves into maintainerConfig, so a test can pin it: without it the network is Unknown and every reservation catch-up scan is skipped. --- cmd/maintainer.go | 15 ++++++++++++--- cmd/maintainer_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/cmd/maintainer.go b/cmd/maintainer.go index 3729d4afef..3aad88b38c 100644 --- a/cmd/maintainer.go +++ b/cmd/maintainer.go @@ -79,11 +79,9 @@ func maintainers(cmd *cobra.Command, args []string) error { metricsRecorder := initializeMaintainerMetrics(ctx, blockCounter, tbtcChain, btcChain) - clientConfig.Maintainer.Spv.EthereumNetwork = clientConfig.Ethereum.Network - maintainer.Initialize( ctx, - clientConfig.Maintainer, + maintainerConfig(clientConfig), btcChain, btcDiffChain, tbtcChain, @@ -94,6 +92,17 @@ func maintainers(cmd *cobra.Command, args []string) error { return fmt.Errorf("unexpected context cancellation") } +// maintainerConfig returns the maintainers' config with the SPV +// maintainer's Ethereum network copied from the [ethereum] section. The +// SPV maintainer looks up the reservation activation block by that +// network; left unset, the network is Unknown and every reservation +// catch-up scan is skipped. +func maintainerConfig(cfg *config.Config) maintainer.Config { + maintainerConfig := cfg.Maintainer + maintainerConfig.Spv.EthereumNetwork = cfg.Ethereum.Network + return maintainerConfig +} + // initializeMaintainerMetrics sets up the client info registry and performance // metrics for the maintainer command. It returns a metrics recorder wired to // the SPV maintainer, or nil when the client info endpoint is not configured diff --git a/cmd/maintainer_test.go b/cmd/maintainer_test.go index 76863c2289..8ef976635a 100644 --- a/cmd/maintainer_test.go +++ b/cmd/maintainer_test.go @@ -5,8 +5,13 @@ import ( "net" "testing" + commonEthereum "github.com/keep-network/keep-common/pkg/chain/ethereum" + + "github.com/keep-network/keep-core/config" "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/clientinfo" + "github.com/keep-network/keep-core/pkg/maintainer" + "github.com/keep-network/keep-core/pkg/maintainer/spv" ) // stubBlockCounter is a minimal chain.BlockCounter implementation used to @@ -216,3 +221,30 @@ func TestInitializeMaintainerMetricsEnabledWhenPortSet(t *testing.T) { ) } } + +// TestMaintainerConfig verifies that the maintainer command hands the SPV +// maintainer the Ethereum network from the [ethereum] section: without it +// the network is Unknown, the reservation activation block lookup fails +// and every reservation catch-up scan is skipped. The rest of the +// maintainer config passes through unchanged. +func TestMaintainerConfig(t *testing.T) { + cfg := &config.Config{ + Ethereum: commonEthereum.Config{Network: commonEthereum.Sepolia}, + Maintainer: maintainer.Config{ + Spv: spv.Config{Enabled: true, ReservationProofsEnabled: true}, + }, + } + + got := maintainerConfig(cfg) + + if got.Spv.EthereumNetwork != commonEthereum.Sepolia { + t.Errorf( + "expected the SPV maintainer network %v, got %v", + commonEthereum.Sepolia, + got.Spv.EthereumNetwork, + ) + } + if !got.Spv.Enabled || !got.Spv.ReservationProofsEnabled { + t.Errorf("expected the rest of the SPV config to pass through, got %+v", got.Spv) + } +} From 76dca4f3c7545c013eb3c86b253d32c1bff8d21d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 30 Sep 2026 08:59:07 +0000 Subject: [PATCH 25/31] docs(spv): describe the shared reservation scan-start policy Correct the EthereumNetwork, ethNetwork, lookback-constant, stale-deposit and anchor-hash comments: the reservation proof loop and the stale-deposit and action-timeout watchers all start their first scan at the activation block and skip it on networks without an entry, the 30-day lookback now bounds only the stranding registration scan, and the Go anchor hash differs from the Bridge's for a reservation without an anchor. Drop history wording from the touched comments. --- pkg/maintainer/spv/config.go | 13 +++---- .../spv/reservation_action_timeout_watch.go | 4 +-- pkg/maintainer/spv/reservation_proof_loop.go | 15 ++++---- .../spv/reservation_stale_deposit_watch.go | 20 +++++------ pkg/maintainer/spv/reservation_wiring.go | 34 +++++++------------ 5 files changed, 38 insertions(+), 48 deletions(-) diff --git a/pkg/maintainer/spv/config.go b/pkg/maintainer/spv/config.go index c4cd22fc70..79b49328b0 100644 --- a/pkg/maintainer/spv/config.go +++ b/pkg/maintainer/spv/config.go @@ -99,11 +99,12 @@ type Config struct { // command copies it from its own [ethereum] config section) rather // than read from a config file or CLI flag. It feeds the // reservation activation-block lookup (see - // tbtc.ReservationsActivationBlock), which bounds every - // reservation watcher's startup catch-up scan: a network without - // an activation entry is treated as reservations-inactive and its - // startup scans are skipped. The zero value (an unknown network) - // behaves exactly like a missing activation entry: reservation - // startup scans are skipped on it. + // tbtc.ReservationsActivationBlock): the reservation proof loop and + // the stale-deposit and action-timeout watchers start their first + // event scan at the activation block and walk to the tip in chunks. + // A network without an activation entry is treated as + // reservations-inactive: those first scans are skipped and later + // scans cover only new blocks. The zero value (an unknown network) + // behaves exactly like a missing activation entry. EthereumNetwork ethereum.Network } diff --git a/pkg/maintainer/spv/reservation_action_timeout_watch.go b/pkg/maintainer/spv/reservation_action_timeout_watch.go index e988f04ec8..f252754308 100644 --- a/pkg/maintainer/spv/reservation_action_timeout_watch.go +++ b/pkg/maintainer/spv/reservation_action_timeout_watch.go @@ -168,9 +168,7 @@ type pendingAction struct { // actionEventKey identifies one reservation action generation. It // delegates to reservationEventKey (see reservation_proof_loop.go), the // canonical (reservationKey, requestNonce) key formatter shared by -// every pending-event map in this package; this watcher previously used -// its own "%s#%d" format, now consolidated onto reservationEventKey's -// pre-existing "%s:%d" format. +// every pending-event map in this package. func actionEventKey(reservationKey *big.Int, requestNonce uint64) string { return reservationEventKey(reservationKey, requestNonce) } diff --git a/pkg/maintainer/spv/reservation_proof_loop.go b/pkg/maintainer/spv/reservation_proof_loop.go index 4f5d10aba4..b40cc45026 100644 --- a/pkg/maintainer/spv/reservation_proof_loop.go +++ b/pkg/maintainer/spv/reservation_proof_loop.go @@ -20,9 +20,9 @@ import ( // the set of still-settleable action-request events across successive // passes of runReservationProofLoop, so proveReservationAcceptanceActions // and proveReservationReanchorActions scan only the event/Bitcoin history -// that has appeared since the previous pass instead of rescanning the full -// reservationDefaultLookBackBlocks window - and refetching Bitcoin history -// for every wallet in it - every config.IdleBackoffTime. +// that has appeared since the previous pass (see reservationScanRange) +// instead of rescanning from the activation block - and refetching +// Bitcoin history for every wallet - every config.IdleBackoffTime. type reservationProofScanState struct { acceptanceLastScannedBlock uint64 pendingAcceptanceEvents map[string]*tbtc.ReservationAcceptanceRequestedEvent @@ -82,8 +82,9 @@ func defaultReservationProofNowFn() uint32 { return uint32(time.Now().Unix()) } -// reanchorSourceAnchorHash computes exactly the Bridge's -// anchorUtxoHash(reservation) from Reservation.sol: +// reanchorSourceAnchorHash computes the Bridge's +// anchorUtxoHash(reservation) from Reservation.sol for a reservation with +// an anchor outpoint: // keccak256(abi.encodePacked(anchorTxHash, uint32 anchorTxOutputIndex)) - // the 32-byte anchor transaction hash in its Bitcoin internal byte order // (the order the Bridge stores it, matching the @@ -91,7 +92,9 @@ func defaultReservationProofNowFn() uint32 { // big-endian uint32. It is the hash the Bridge's // requireCurrentSourceAnchor check in ReservationProofs.sol compares // against each tracked generation's on-chain source anchor snapshot. A -// reservation with no anchor outpoint yields the zero hash. +// reservation with no anchor outpoint yields the zero hash, where the +// Bridge would hash the zeroed fields instead; neither value can equal a +// generation's snapshot, which is always taken from a real anchor. func reanchorSourceAnchorHash(reservation *tbtc.Reservation) [32]byte { if reservation == nil || reservation.AnchorUtxo == nil || diff --git a/pkg/maintainer/spv/reservation_stale_deposit_watch.go b/pkg/maintainer/spv/reservation_stale_deposit_watch.go index 6362845f67..cfc59c82ab 100644 --- a/pkg/maintainer/spv/reservation_stale_deposit_watch.go +++ b/pkg/maintainer/spv/reservation_stale_deposit_watch.go @@ -131,11 +131,9 @@ type ReservationStaleDepositWatcher struct { // through free functions - mirrors // ReservationActionTimeoutWatcher's self-contained Run loop. // - // The set is a single one: the old split between an actively- - // polled set and a slower-reconciled set of Live-wallet deposits - // is gone, because deadline-aware scheduling makes it redundant. - // Before a deposit's refund deadline passes it costs no chain - // reads at all (nothing to amortize away), and after the deadline + // Every tracked deposit is polled the same way, whatever its + // wallet state. Before a deposit's refund deadline passes it costs + // no chain reads at all, and after the deadline // every tracked deposit - including ones whose wallet is Live, // since the notification is wallet-state-independent - must be // re-checked every tick until its record clears on-chain. @@ -144,12 +142,12 @@ type ReservationStaleDepositWatcher struct { // activationBlock is the network's reservation activation block // (see tbtc.ReservationsActivationBlock): the first reveal scan - // starts here instead of a fixed lookback window, so a process - // restart after activation picks up every reveal that has appeared - // since the feature went live. math.MaxUint64 is the sentinel - // for a network without an entry (e.g. ethereum.Unknown): the - // watcher skips the startup scan entirely - reservations are - // inactive - and the cursor jumps to the chain head. + // starts here and walks to the tip in chunks (see + // reservationScanRange), so a process restart picks up every + // reveal since the feature went live. math.MaxUint64 is the + // sentinel for a network without an entry (e.g. ethereum.Unknown): + // reservations are inactive there, the first scan is skipped and + // the cursor moves to the confirmed tip. activationBlock uint64 } diff --git a/pkg/maintainer/spv/reservation_wiring.go b/pkg/maintainer/spv/reservation_wiring.go index b8160c10c1..1106272e1f 100644 --- a/pkg/maintainer/spv/reservation_wiring.go +++ b/pkg/maintainer/spv/reservation_wiring.go @@ -64,22 +64,11 @@ var reservationActionTimeoutPollInterval = DefaultReservationActionTimeoutPollIn // finished. var reservationWatcherPanicRecovered = func() {} -// reservationDefaultLookBackBlocks bounds every reservation watcher's -// startup/first-pass catch-up scan window: 30 days at 12s/block. It is -// the single source of truth for this bound, replacing what were -// previously 4+ independently-defined constants (this file's own -// reservationStrandingStartupScanLookBackBlocks and -// reservationStaleDepositLookBackBlocks, -// reservation_action_timeout_watch.go's -// reservationActionTimeoutLookBackBlocks, and -// reservation_proof_loop.go's reservationProofLookBackBlocks) all -// independently set to the identical literal value with near-duplicate -// doc comments. Every former duplicate, including the two thin aliases -// that used to remain solely for test-file references -// (reservationProofLookBackBlocks and -// reservation_stale_deposit_watch.go's -// staleDepositRevealScanLookBackBlocks), now references this constant -// directly. +// reservationDefaultLookBackBlocks bounds the stranding startup scan of +// wallet registrations (see scanReservationStrandingStartupRegistrations): +// 30 days at 12s/block. The reservation event scans are not bounded by it; +// they start at the reservation activation block (see +// reservationScanRange). const reservationDefaultLookBackBlocks = uint64(216000) // reservationStrandingStartupScanRetryDelay bounds how long the startup @@ -225,14 +214,15 @@ func recordReservationWatcherDeath(recorder MetricsRecorder) { // interface. // // `ethNetwork` is the Ethereum network this wiring run targets; it -// drives the reservation activation-block lookup shared by the proof -// loop's startup catch-up scan and the stale-deposit watcher's startup -// reveal scan (see tbtc.ReservationsActivationBlock). Passing +// drives the reservation activation-block lookup (see +// tbtc.ReservationsActivationBlock) where the stale-deposit and +// action-timeout watchers start their first event scan. Passing // ethereum.Unknown (or any network without an entry in // reservationsActivationBlocks) returns math.MaxUint64 from the lookup, -// which the proof loop and stale-deposit watcher treat as "reservations -// never activate on this network" and skip their startup scans -// accordingly. +// which both watchers treat as "reservations inactive on this network": +// they skip their first catch-up scan and later scan only new blocks. +// The SPV proof loop does the same lookup from its own +// Config.EthereumNetwork. func WireReservationWatchers( ctx context.Context, walletClosedChain WalletClosedChain, From 07bbb049b659067cf882dd207ef72f8307a4faa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 30 Sep 2026 09:02:38 +0000 Subject: [PATCH 26/31] docs(tbtcpg): note the cooldown in the re-anchor resume margin comment --- pkg/tbtcpg/reservation_reanchor.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/tbtcpg/reservation_reanchor.go b/pkg/tbtcpg/reservation_reanchor.go index 71245b894a..5cda987479 100644 --- a/pkg/tbtcpg/reservation_reanchor.go +++ b/pkg/tbtcpg/reservation_reanchor.go @@ -283,8 +283,8 @@ reservationLoop: // block.timestamp < action.TimeoutAt - // REQUEST_TIMEOUT_SAFETY_MARGIN, so a generation at or past // that boundary is skipped rather than proposed and rejected; - // a fresh request may be issued instead once the reservation - // is back in the Active state. + // a fresh request may be issued instead once the generation + // times out and the resulting re-anchor cooldown ends. if uint64(time.Now().Unix())+ uint64(reservationRequestTimeoutSafetyMarginSeconds) >= uint64(action.TimeoutAt) { From 6519e63036997d493e067af3567f64c9165eeffa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 30 Sep 2026 09:02:54 +0000 Subject: [PATCH 27/31] test(spv): drain watcher recover paths before the death-hook tests end The watcher-death metric test left its recovered goroutines reading reservationWatcherPanicRecovered while the typed-nil test replaced it, a data race under -race. Both tests now install the hook through one helper and wait for both recover paths before returning. --- pkg/maintainer/spv/reservation_wiring_test.go | 65 ++++++++++--------- 1 file changed, 34 insertions(+), 31 deletions(-) diff --git a/pkg/maintainer/spv/reservation_wiring_test.go b/pkg/maintainer/spv/reservation_wiring_test.go index f3c8043ea7..7fc9af0684 100644 --- a/pkg/maintainer/spv/reservation_wiring_test.go +++ b/pkg/maintainer/spv/reservation_wiring_test.go @@ -1059,19 +1059,45 @@ func TestRunStaleDepositPollTick_NotifiesAfterDeadline(t *testing.T) { } } +// awaitReservationWatcherPanicRecoveries installs +// reservationWatcherPanicRecovered for the duration of the test and +// returns a function that waits, bounded, until count watcher goroutines +// have finished their recovered-panic path. Waiting before the test ends +// also keeps those goroutines from reading the hook while the next test +// replaces it. +func awaitReservationWatcherPanicRecoveries(t *testing.T, count int) func() { + recovered := make(chan struct{}, count) + previous := reservationWatcherPanicRecovered + reservationWatcherPanicRecovered = func() { recovered <- struct{}{} } + t.Cleanup(func() { reservationWatcherPanicRecovered = previous }) + + return func() { + t.Helper() + deadline := time.After(5 * time.Second) + for range count { + select { + case <-recovered: + case <-deadline: + t.Fatal("a watcher goroutine never completed its recovered-panic path") + } + } + } +} + // TestWireReservationWatchers_WatcherDeathIncrementsMetric verifies that // when a watcher goroutine dies (here: a panic during its initial poll // pass, recovered by the goroutine's panic-recover), the watcher-death // counter is incremented on the supplied MetricsRecorder - not just a // log line - so a dead watcher is observable in metrics. Both watcher // goroutines are forced through the death path, and the test asserts -// exactly two increments (one per goroutine), read through the -// recorder's mutex-safe accessor so the polling race is real but -// data-race-free. +// exactly two increments (one per goroutine) once both goroutines have +// finished their recover path. func TestWireReservationWatchers_WatcherDeathIncrementsMetric(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + awaitRecoveries := awaitReservationWatcherPanicRecoveries(t, 2) + spvChain := newLocalChain() blockCounter := newMockBlockCounter() blockCounter.SetCurrentBlock(1000) @@ -1084,9 +1110,7 @@ func TestWireReservationWatchers_WatcherDeathIncrementsMetric(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - waitForReservationWiringCondition(t, 500*time.Millisecond, func() bool { - return recorder.Counter(clientinfo.MetricSpvReservationWatcherDeathsTotal) >= 2 - }) + awaitRecoveries() if got := recorder.Counter(clientinfo.MetricSpvReservationWatcherDeathsTotal); got != 2 { t.Fatalf("expected exactly two watcher-death increments (one per watcher goroutine), got %v", got) @@ -1096,30 +1120,20 @@ func TestWireReservationWatchers_WatcherDeathIncrementsMetric(t *testing.T) { // watcherDeathPanickingChain wraps a *localChain and panics on the first // chain read each watcher's initial poll pass performs, so the // goroutine's panic-recover (and thus the watcher-death counter) fires -// deterministically. Each intercepted read also signals deathSignal before -// it panics, so an end-to-end test can wait for both watcher goroutines to -// actually reach their forced panic - a real completion signal in place of -// a sleep. The channel is buffered enough for one send from each watcher. +// deterministically. type watcherDeathPanickingChain struct { *localChain - deathSignal chan struct{} } func (c *watcherDeathPanickingChain) PastDepositRevealedEvents( filter *tbtc.DepositRevealedEventFilter, ) ([]*tbtc.DepositRevealedEvent, error) { - if c.deathSignal != nil { - c.deathSignal <- struct{}{} - } panic("stale-deposit watcher boom") } func (c *watcherDeathPanickingChain) PastReservationAcceptanceRequestedEvents( filter *tbtc.ReservationAcceptanceRequestedEventFilter, ) ([]*tbtc.ReservationAcceptanceRequestedEvent, error) { - if c.deathSignal != nil { - c.deathSignal <- struct{}{} - } panic("action-timeout watcher boom") } @@ -1179,17 +1193,13 @@ func TestWireReservationWatchers_TypedNilPerformanceMetricsSurvivesWatcherDeath( ctx, cancel := context.WithCancel(context.Background()) defer cancel() - recovered := make(chan struct{}, 2) - previousHook := reservationWatcherPanicRecovered - reservationWatcherPanicRecovered = func() { recovered <- struct{}{} } - t.Cleanup(func() { reservationWatcherPanicRecovered = previousHook }) + awaitRecoveries := awaitReservationWatcherPanicRecoveries(t, 2) spvChain := newLocalChain() blockCounter := newMockBlockCounter() blockCounter.SetCurrentBlock(1000) spvChain.setBlockCounter(blockCounter) - deathSignal := make(chan struct{}, 2) - panickingChain := &watcherDeathPanickingChain{localChain: spvChain, deathSignal: deathSignal} + panickingChain := &watcherDeathPanickingChain{localChain: spvChain} var typedNilMetrics *clientinfo.PerformanceMetrics @@ -1208,14 +1218,7 @@ func TestWireReservationWatchers_TypedNilPerformanceMetricsSurvivesWatcherDeath( // recordReservationWatcherDeath against the typed-nil recorder, has // completed. A crash there would take the whole test process down, // so receiving both signals is the assertion. - deadline := time.After(5 * time.Second) - for range 2 { - select { - case <-recovered: - case <-deadline: - t.Fatal("a watcher goroutine never completed its recovered-panic path") - } - } + awaitRecoveries() } // TestScanReservationStrandingStartupRegistrations verifies that the From 303d14721b496537072a04ec94d548d40aae0ffc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 30 Sep 2026 09:08:15 +0000 Subject: [PATCH 28/31] fix(tbtcpg): discover acceptance candidates from depositor requests Candidates now come from the wallet's ReservationAcceptanceRequested events within the on-chain action timeout plus a margin, instead of DepositRevealed events from the last 30 days. Only generations the chain confirms are a Pending Acceptance for this wallet count toward the per-run budget, and that check runs before any reveal search or Bitcoin lookup, so never-requested reveals cannot starve real requests. The reveal is located by a chunked backward scan from the request block bounded by the generation's term, so requests made long after the reveal are still proposed. Run performs the per-window setup and gauge publication once, then tries candidates in timeout order; a fee-estimate or proposal failure skips that candidate instead of aborting the window. The pre-write error wrapper is removed. Tests delegate to the strict anchor validator, which now also checks the funding transaction hash and deposit locking script. Fixtures use real funding transactions, future refund locktimes, the 2-hour deposit minimum age and a 90-day term. Scenarios that only rejected for lack of a pending action are removed, the below-minimum scenario becomes the reachable boundary case, and a MovingFunds scenario is added. --- pkg/tbtcpg/chain_test.go | 52 +- .../internal/test/reservation_acceptance.go | 99 +- .../reservation_acceptance_scenario_0.json | 8 +- .../reservation_acceptance_scenario_1.json | 38 +- .../reservation_acceptance_scenario_2.json | 20 +- .../reservation_acceptance_scenario_3.json | 54 +- .../reservation_acceptance_scenario_4.json | 36 +- .../reservation_acceptance_scenario_5.json | 52 - .../reservation_acceptance_scenario_6.json | 52 - .../reservation_acceptance_scenario_7.json | 52 - .../reservation_acceptance_scenario_8.json | 86 - pkg/tbtcpg/reservation_acceptance.go | 1219 ++++--- .../reservation_acceptance_export_test.go | 11 + .../reservation_acceptance_metrics_test.go | 216 +- pkg/tbtcpg/reservation_acceptance_test.go | 3208 +++++++---------- 15 files changed, 2131 insertions(+), 3072 deletions(-) delete mode 100644 pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_5.json delete mode 100644 pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_6.json delete mode 100644 pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_7.json delete mode 100644 pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_8.json create mode 100644 pkg/tbtcpg/reservation_acceptance_export_test.go diff --git a/pkg/tbtcpg/chain_test.go b/pkg/tbtcpg/chain_test.go index 384d7a0e53..35b9b24f7c 100644 --- a/pkg/tbtcpg/chain_test.go +++ b/pkg/tbtcpg/chain_test.go @@ -1406,8 +1406,10 @@ func (mbc *MockBlockCounter) WatchBlocks(ctx context.Context) <-chan uint64 { // would pass a lenient one: the wallet state, the pending acceptance action // record and its timeout margin, the deposit's reveal/age/sweep/reserved // state and vault routing, the anchor fee bounds against the generation's -// snapshotted max fee, the snapshotted minimum, the refund safety margin, -// and the deposit's controlling wallet. +// snapshotted max fee, the snapshotted minimum, the deposit extra info +// (funding transaction hash and deposit locking script, as +// validateDepositExtraInfo checks them), the refund safety margin, and +// the deposit's controlling wallet. func (lc *LocalChain) ValidateReservationAnchorProposal( walletPublicKeyHash [20]byte, proposal *tbtc.ReservationAnchorProposal, @@ -1469,7 +1471,9 @@ func (lc *LocalChain) ValidateReservationAnchorProposal( ) { return fmt.Errorf("deposit min age not achieved yet") } - if !depositRequest.SweptAt.IsZero() { + // The chain adapter reports an unswept deposit as the UNIX epoch + // (sweptAt == 0 on-chain); a zero time.Time is accepted as well. + if !depositRequest.SweptAt.IsZero() && depositRequest.SweptAt.Unix() != 0 { return fmt.Errorf("deposit already swept") } @@ -1511,6 +1515,48 @@ func (lc *LocalChain) ValidateReservationAnchorProposal( if deposit == nil { return fmt.Errorf("deposit extra info is required") } + + // Mirror validateDepositExtraInfo: the extra info's funding + // transaction must hash to the proposal's funding transaction hash, + // and its output at the proposal's index must lock funds with the + // deposit script rebuilt from the on-chain depositor and extra data + // plus the extra info's reveal fields, as P2SH or P2WSH. + fundingTx := depositExtraInfo.FundingTx + if fundingTx == nil || + fundingTx.Hash() != proposal.DepositFundingTxHash { + return fmt.Errorf("extra info funding tx hash does not match") + } + if int(proposal.DepositFundingOutputIndex) >= len(fundingTx.Outputs) { + return fmt.Errorf("extra info funding output script does not match") + } + depositScript, err := (&tbtc.Deposit{ + Depositor: depositRequest.Depositor, + ExtraData: depositRequest.ExtraData, + BlindingFactor: deposit.BlindingFactor, + WalletPublicKeyHash: deposit.WalletPublicKeyHash, + RefundPublicKeyHash: deposit.RefundPublicKeyHash, + RefundLocktime: deposit.RefundLocktime, + }).Script() + if err != nil { + return fmt.Errorf("cannot build deposit script: [%v]", err) + } + p2wsh, err := bitcoin.PayToWitnessScriptHash( + bitcoin.WitnessScriptHash(depositScript), + ) + if err != nil { + return err + } + p2sh, err := bitcoin.PayToScriptHash(bitcoin.ScriptHash(depositScript)) + if err != nil { + return err + } + fundingOutputScript := + fundingTx.Outputs[proposal.DepositFundingOutputIndex].PublicKeyScript + if !bytes.Equal(fundingOutputScript, p2wsh) && + !bytes.Equal(fundingOutputScript, p2sh) { + return fmt.Errorf("extra info funding output script does not match") + } + // The refund locktime is stored little-endian on-chain; reverse it // back to a UNIX timestamp and preserve the 24-hour refund safety // margin the on-chain validator enforces. diff --git a/pkg/tbtcpg/internal/test/reservation_acceptance.go b/pkg/tbtcpg/internal/test/reservation_acceptance.go index 2882b8e81e..26c12cb1e2 100644 --- a/pkg/tbtcpg/internal/test/reservation_acceptance.go +++ b/pkg/tbtcpg/internal/test/reservation_acceptance.go @@ -39,15 +39,14 @@ type ReservedDepositScenario struct { // PendingAcceptanceAction, when non-nil, instructs the test driver to // seed the deposit's current generation as a Pending Acceptance action - // record targeting WalletPublicKeyHash. The acceptance task only - // proposes against deposits whose current generation is exactly that - // record (mirroring WalletProposalValidator.sol's - // validateReservationAnchorProposal precondition), so scenarios that - // expect a proposal must seed one here with the live parameters' - // snapshots as they stood at the depositor's request time. Scenarios - // that expect rejection at any earlier cap gate (wallet state, per-wallet - // count, per-wallet amount, single-amount, global total, per-deposit - // minimum) leave this nil and reject before any action lookup happens. + // record targeting TargetWalletPublicKeyHash, together with the + // ReservationAcceptanceRequested event the depositor's request emits. + // The acceptance task only discovers deposits through that event and + // only proposes against deposits whose current generation is exactly + // that record (mirroring WalletProposalValidator.sol's + // validateReservationAnchorProposal precondition), so every scenario + // that should reach a signer-time rule must seed one here, with the + // parameter snapshots as they stood at the depositor's request time. PendingAcceptanceAction *PendingAcceptanceActionScenario parsedFundingTxHash bitcoin.Hash @@ -191,6 +190,7 @@ func (rats *ReservationAcceptanceTestScenario) UnmarshalJSON( MinAmount uint64 TermSeconds uint32 TargetWalletPublicKeyHash string + TimeoutAt uint32 } type reservedDepositScenarioJSON struct { @@ -316,8 +316,6 @@ func (rats *ReservationAcceptanceTestScenario) UnmarshalJSON( rats.PendingReservedDeposits = unmarshaled.PendingReservedDeposits - now := time.Now() - rats.ReservedDeposits = make([]*ReservedDepositScenario, 0) for _, rd := range unmarshaled.ReservedDeposits { fundingTxHash, err := bitcoin.NewHashFromString( @@ -344,6 +342,7 @@ func (rats *ReservationAcceptanceTestScenario) UnmarshalJSON( MinAmount: rd.PendingAcceptanceAction.MinAmount, TermSeconds: rd.PendingAcceptanceAction.TermSeconds, TargetWalletPublicKeyHash: rd.PendingAcceptanceAction.TargetWalletPublicKeyHash, + TimeoutAt: rd.PendingAcceptanceAction.TimeoutAt, } } @@ -377,17 +376,25 @@ func (rats *ReservationAcceptanceTestScenario) UnmarshalJSON( rats.ExpectedErr = errors.New(unmarshaled.ExpectedErr) } - _ = now return nil } // ReservedDeposit is the materialized form of a reserved deposit scenario, // populated by the test driver once the chain state is set up. type ReservedDeposit struct { + // FundingTxHash is the hash of FundingTx, the deposit's real funding + // transaction. LabelFundingTxHash is the scenario's FundingTxHash, + // which only labels the deposit when the scenario gives no + // FundingTxHex; ExpectedAnchorProposal refers to deposits by label. FundingTxHash bitcoin.Hash + LabelFundingTxHash bitcoin.Hash FundingOutputIndex uint32 FundingTx *bitcoin.Transaction WalletPublicKeyHash [20]byte + Depositor chain.Address + BlindingFactor [8]byte + RefundPublicKeyHash [20]byte + RefundLocktime [4]byte RevealBlock uint64 RevealedAt time.Time SweptAt time.Time @@ -396,7 +403,11 @@ type ReservedDeposit struct { } // Materialize converts a scenario row into a fully-typed ReservedDeposit -// the test driver can wire into the local chain. +// the test driver can wire into the local chain. When the scenario gives +// no FundingTxHex, a funding transaction whose output at +// FundingOutputIndex locks Amount with the deposit's P2WSH script is +// built from the row's reveal fields, so the deposit passes the strict +// validator's extra-info checks. func (rds *ReservedDepositScenario) Materialize() (*ReservedDeposit, error) { if rds == nil { return nil, fmt.Errorf("nil scenario deposit") @@ -418,6 +429,58 @@ func (rds *ReservedDepositScenario) Materialize() (*ReservedDeposit, error) { vault = &addr } + var blindingFactor [8]byte + copy(blindingFactor[:], hexToSlice(rds.BlindingFactor)) + var refundPublicKeyHash [20]byte + copy(refundPublicKeyHash[:], hexToSlice(rds.RefundPublicKeyHash)) + var refundLocktime [4]byte + copy(refundLocktime[:], hexToSlice(rds.RefundLocktime)) + + fundingTx := rds.parsedFundingTx + if fundingTx == nil { + depositScript, err := (&tbtc.Deposit{ + Depositor: chain.Address(rds.Depositor), + BlindingFactor: blindingFactor, + WalletPublicKeyHash: walletHash, + RefundPublicKeyHash: refundPublicKeyHash, + RefundLocktime: refundLocktime, + }).Script() + if err != nil { + return nil, fmt.Errorf("cannot build deposit script: [%w]", err) + } + depositLockingScript, err := bitcoin.PayToWitnessScriptHash( + bitcoin.WitnessScriptHash(depositScript), + ) + if err != nil { + return nil, err + } + + outputs := make([]*bitcoin.TransactionOutput, rds.FundingOutputIndex+1) + for i := range outputs { + outputs[i] = &bitcoin.TransactionOutput{ + PublicKeyScript: append([]byte{0x00, 0x14}, make([]byte, 20)...), + } + } + outputs[rds.FundingOutputIndex] = &bitcoin.TransactionOutput{ + Value: int64(rds.Amount), + PublicKeyScript: depositLockingScript, + } + + fundingTx = &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + // The label makes every scenario deposit's funding + // transaction, and so its hash, distinct. + TransactionHash: rds.parsedFundingTxHash, + OutputIndex: 0, + }, + Sequence: 0xffffffff, + }}, + Outputs: outputs, + } + } + age := time.Duration(rds.Age) * time.Second revealedAt := time.Now().Add(-age) @@ -445,14 +508,20 @@ func (rds *ReservedDepositScenario) Materialize() (*ReservedDeposit, error) { MinAmount: rds.PendingAcceptanceAction.MinAmount, TermSeconds: rds.PendingAcceptanceAction.TermSeconds, TimeoutAt: timeoutAt, + Amount: rds.Amount, } } return &ReservedDeposit{ - FundingTxHash: rds.parsedFundingTxHash, + FundingTxHash: fundingTx.Hash(), + LabelFundingTxHash: rds.parsedFundingTxHash, FundingOutputIndex: rds.FundingOutputIndex, - FundingTx: rds.parsedFundingTx, + FundingTx: fundingTx, WalletPublicKeyHash: walletHash, + Depositor: chain.Address(rds.Depositor), + BlindingFactor: blindingFactor, + RefundPublicKeyHash: refundPublicKeyHash, + RefundLocktime: refundLocktime, RevealBlock: rds.RevealBlock, RevealedAt: revealedAt, SweptAt: time.Unix(rds.SweptAt, 0), diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_0.json b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_0.json index f977855386..c180bd5887 100644 --- a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_0.json +++ b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_0.json @@ -3,7 +3,7 @@ "ChainParameters": { "AverageBlockTime": 12, "CurrentBlock": 300000, - "DepositMinAge": 3600 + "DepositMinAge": 7200 }, "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", "ReservationVault": "0xReservationVaultAddress1234567890abcdef12345678", @@ -39,17 +39,17 @@ "Depositor": "934b98637ca318a4d6e7ca6ffd1690b8e77df637", "BlindingFactor": "f9f0c90d00039523", "RefundPublicKeyHash": "e257eccafbc07c381642ce6e7e55120fb077fbed", - "RefundLocktime": "e0250162", + "RefundLocktime": "80d8db70", "Amount": 2000000, "RevealBlock": 290000, - "Age": 7200, + "Age": 10800, "SweptAt": 0, "Vault": "0xReservationVaultAddress1234567890abcdef12345678", "PendingAcceptanceAction": { "RequestNonce": 1, "TxMaxFee": 5000, "MinAmount": 100000, - "TermSeconds": 86400, + "TermSeconds": 7776000, "TargetWalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6" } } diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_1.json b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_1.json index 464de89f3a..eb68d5bf17 100644 --- a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_1.json +++ b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_1.json @@ -1,9 +1,9 @@ { - "Title": "cap rejection - wallet already at max active reservations count", + "Title": "boundary - deposit amount exactly the snapshotted minimum plus max fee is accepted", "ChainParameters": { "AverageBlockTime": 12, "CurrentBlock": 300000, - "DepositMinAge": 3600 + "DepositMinAge": 7200 }, "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", "ReservationVault": "0xReservationVaultAddress1234567890abcdef12345678", @@ -11,7 +11,7 @@ "State": "Live" }, "ReservationParameters": { - "ReservationMinAmount": 100000, + "ReservationMinAmount": 1000000, "ReservationTxMaxFee": 5000, "ReservationMaxTotalAmount": 100000000, "ReservationTotalAmount": 0, @@ -22,31 +22,43 @@ "ReservationMaxSingleAmount": 5000000 }, "WalletCustody": { - "Count": 1, - "Amount": 2000000 + "Count": 0, + "Amount": 0 }, "Global": { - "ActiveCount": 100, + "ActiveCount": 0, "MaxActive": 100 }, "PendingReservedDeposits": 0, "ReservedDeposits": [ { - "FundingTxHash": "b1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f00", + "FundingTxHash": "c1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f02", "FundingOutputIndex": 0, "FundingTxConfirmations": 6, "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", "Depositor": "934b98637ca318a4d6e7ca6ffd1690b8e77df637", - "BlindingFactor": "f9f0c90d00039524", + "BlindingFactor": "f9f0c90d00039523", "RefundPublicKeyHash": "e257eccafbc07c381642ce6e7e55120fb077fbed", - "RefundLocktime": "e0250162", - "Amount": 2000000, + "RefundLocktime": "80d8db70", + "Amount": 1005000, "RevealBlock": 290000, - "Age": 7200, + "Age": 10800, "SweptAt": 0, - "Vault": "0xReservationVaultAddress1234567890abcdef12345678" + "Vault": "0xReservationVaultAddress1234567890abcdef12345678", + "PendingAcceptanceAction": { + "RequestNonce": 1, + "TxMaxFee": 5000, + "MinAmount": 1000000, + "TermSeconds": 7776000, + "TargetWalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6" + } } ], - "ExpectedAnchorProposal": null, + "ExpectedAnchorProposal": { + "DepositFundingTxHash": "c1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f02", + "DepositFundingOutputIndex": 0, + "RequestNonce": 1, + "AnchorTxFee": 710 + }, "ExpectedErr": "" } diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_2.json b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_2.json index 416d7714f0..5b21bfa785 100644 --- a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_2.json +++ b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_2.json @@ -1,17 +1,17 @@ { - "Title": "below-min rejection - deposit amount below ReservationMinAmount", + "Title": "wallet-state rejection - a Closing wallet does not consume its pending acceptance", "ChainParameters": { "AverageBlockTime": 12, "CurrentBlock": 300000, - "DepositMinAge": 3600 + "DepositMinAge": 7200 }, "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", "ReservationVault": "0xReservationVaultAddress1234567890abcdef12345678", "Wallet": { - "State": "Live" + "State": "Closing" }, "ReservationParameters": { - "ReservationMinAmount": 1000000, + "ReservationMinAmount": 100000, "ReservationTxMaxFee": 5000, "ReservationMaxTotalAmount": 100000000, "ReservationTotalAmount": 0, @@ -32,24 +32,24 @@ "PendingReservedDeposits": 0, "ReservedDeposits": [ { - "FundingTxHash": "c1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f02", + "FundingTxHash": "d1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f03", "FundingOutputIndex": 0, "FundingTxConfirmations": 6, "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", "Depositor": "934b98637ca318a4d6e7ca6ffd1690b8e77df637", "BlindingFactor": "f9f0c90d00039523", "RefundPublicKeyHash": "e257eccafbc07c381642ce6e7e55120fb077fbed", - "RefundLocktime": "e0250162", - "Amount": 500000, + "RefundLocktime": "80d8db70", + "Amount": 2000000, "RevealBlock": 290000, - "Age": 7200, + "Age": 10800, "SweptAt": 0, "Vault": "0xReservationVaultAddress1234567890abcdef12345678", "PendingAcceptanceAction": { "RequestNonce": 1, "TxMaxFee": 5000, - "MinAmount": 1000000, - "TermSeconds": 86400, + "MinAmount": 100000, + "TermSeconds": 7776000, "TargetWalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6" } } diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_3.json b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_3.json index 6da58123e9..ca2ebe4dec 100644 --- a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_3.json +++ b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_3.json @@ -1,14 +1,14 @@ { - "Title": "stale-deposit rejection - wallet state is Closing, not Live", + "Title": "scan continues past an earlier candidate with too few funding confirmations to a later eligible one", "ChainParameters": { "AverageBlockTime": 12, "CurrentBlock": 300000, - "DepositMinAge": 3600 + "DepositMinAge": 7200 }, "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", "ReservationVault": "0xReservationVaultAddress1234567890abcdef12345678", "Wallet": { - "State": "Closing" + "State": "Live" }, "ReservationParameters": { "ReservationMinAmount": 100000, @@ -32,21 +32,55 @@ "PendingReservedDeposits": 0, "ReservedDeposits": [ { - "FundingTxHash": "d1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f00", + "FundingTxHash": "c1c2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f01", "FundingOutputIndex": 0, - "FundingTxConfirmations": 6, + "FundingTxConfirmations": 1, "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", "Depositor": "934b98637ca318a4d6e7ca6ffd1690b8e77df637", - "BlindingFactor": "f9f0c90d00039526", + "BlindingFactor": "f9f0c90d00039523", "RefundPublicKeyHash": "e257eccafbc07c381642ce6e7e55120fb077fbed", - "RefundLocktime": "e0250162", + "RefundLocktime": "80d8db70", "Amount": 2000000, "RevealBlock": 290000, - "Age": 7200, + "Age": 10800, + "SweptAt": 0, + "Vault": "0xReservationVaultAddress1234567890abcdef12345678", + "PendingAcceptanceAction": { + "RequestNonce": 1, + "TxMaxFee": 5000, + "MinAmount": 100000, + "TermSeconds": 7776000, + "TargetWalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6" + } + }, + { + "FundingTxHash": "c2c2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f02", + "FundingOutputIndex": 0, + "FundingTxConfirmations": 6, + "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", + "Depositor": "934b98637ca318a4d6e7ca6ffd1690b8e77df637", + "BlindingFactor": "a9f0c90d00039524", + "RefundPublicKeyHash": "e257eccafbc07c381642ce6e7e55120fb077fbed", + "RefundLocktime": "80d8db70", + "Amount": 2000000, + "RevealBlock": 290001, + "Age": 10800, "SweptAt": 0, - "Vault": "0xReservationVaultAddress1234567890abcdef12345678" + "Vault": "0xReservationVaultAddress1234567890abcdef12345678", + "PendingAcceptanceAction": { + "RequestNonce": 1, + "TxMaxFee": 5000, + "MinAmount": 100000, + "TermSeconds": 7776000, + "TargetWalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6" + } } ], - "ExpectedAnchorProposal": null, + "ExpectedAnchorProposal": { + "DepositFundingTxHash": "c2c2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f02", + "DepositFundingOutputIndex": 0, + "RequestNonce": 1, + "AnchorTxFee": 710 + }, "ExpectedErr": "" } diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_4.json b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_4.json index 53d9a51bc9..29db881ac6 100644 --- a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_4.json +++ b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_4.json @@ -1,29 +1,29 @@ { - "Title": "cap rejection - wallet already at max reservations per wallet", + "Title": "moving funds wallet - a MovingFunds wallet consumes its pending acceptance at the generation's nonce", "ChainParameters": { "AverageBlockTime": 12, "CurrentBlock": 300000, - "DepositMinAge": 3600 + "DepositMinAge": 7200 }, "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", "ReservationVault": "0xReservationVaultAddress1234567890abcdef12345678", "Wallet": { - "State": "Live" + "State": "MovingFunds" }, "ReservationParameters": { "ReservationMinAmount": 100000, "ReservationTxMaxFee": 5000, "ReservationMaxTotalAmount": 100000000, "ReservationTotalAmount": 0, - "MaxReservationsPerWallet": 3 + "MaxReservationsPerWallet": 5 }, "Caps": { "MaxReservationsAmountPerWallet": 50000000, "ReservationMaxSingleAmount": 5000000 }, "WalletCustody": { - "Count": 3, - "Amount": 2000000 + "Count": 0, + "Amount": 0 }, "Global": { "ActiveCount": 0, @@ -32,21 +32,33 @@ "PendingReservedDeposits": 0, "ReservedDeposits": [ { - "FundingTxHash": "e1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f00", + "FundingTxHash": "e1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f04", "FundingOutputIndex": 0, "FundingTxConfirmations": 6, "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", "Depositor": "934b98637ca318a4d6e7ca6ffd1690b8e77df637", - "BlindingFactor": "f9f0c90d00039524", + "BlindingFactor": "f9f0c90d00039523", "RefundPublicKeyHash": "e257eccafbc07c381642ce6e7e55120fb077fbed", - "RefundLocktime": "e0250162", + "RefundLocktime": "80d8db70", "Amount": 2000000, "RevealBlock": 290000, - "Age": 7200, + "Age": 10800, "SweptAt": 0, - "Vault": "0xReservationVaultAddress1234567890abcdef12345678" + "Vault": "0xReservationVaultAddress1234567890abcdef12345678", + "PendingAcceptanceAction": { + "RequestNonce": 2, + "TxMaxFee": 5000, + "MinAmount": 100000, + "TermSeconds": 7776000, + "TargetWalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6" + } } ], - "ExpectedAnchorProposal": null, + "ExpectedAnchorProposal": { + "DepositFundingTxHash": "e1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f04", + "DepositFundingOutputIndex": 0, + "RequestNonce": 2, + "AnchorTxFee": 710 + }, "ExpectedErr": "" } diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_5.json b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_5.json deleted file mode 100644 index 3f628af58b..0000000000 --- a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_5.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "Title": "cap rejection - deposit amount exceeds ReservationMaxSingleAmount", - "ChainParameters": { - "AverageBlockTime": 12, - "CurrentBlock": 300000, - "DepositMinAge": 3600 - }, - "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", - "ReservationVault": "0xReservationVaultAddress1234567890abcdef12345678", - "Wallet": { - "State": "Live" - }, - "ReservationParameters": { - "ReservationMinAmount": 100000, - "ReservationTxMaxFee": 5000, - "ReservationMaxTotalAmount": 100000000, - "ReservationTotalAmount": 0, - "MaxReservationsPerWallet": 5 - }, - "Caps": { - "MaxReservationsAmountPerWallet": 50000000, - "ReservationMaxSingleAmount": 1000000 - }, - "WalletCustody": { - "Count": 1, - "Amount": 0 - }, - "Global": { - "ActiveCount": 0, - "MaxActive": 100 - }, - "PendingReservedDeposits": 0, - "ReservedDeposits": [ - { - "FundingTxHash": "f1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f00", - "FundingOutputIndex": 0, - "FundingTxConfirmations": 6, - "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", - "Depositor": "934b98637ca318a4d6e7ca6ffd1690b8e77df637", - "BlindingFactor": "f9f0c90d00039525", - "RefundPublicKeyHash": "e257eccafbc07c381642ce6e7e55120fb077fbed", - "RefundLocktime": "e0250162", - "Amount": 2000000, - "RevealBlock": 290000, - "Age": 7200, - "SweptAt": 0, - "Vault": "0xReservationVaultAddress1234567890abcdef12345678" - } - ], - "ExpectedAnchorProposal": null, - "ExpectedErr": "" -} diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_6.json b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_6.json deleted file mode 100644 index 0692e7b9d2..0000000000 --- a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_6.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "Title": "cap rejection - accepting would exceed wallet aggregate amount cap", - "ChainParameters": { - "AverageBlockTime": 12, - "CurrentBlock": 300000, - "DepositMinAge": 3600 - }, - "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", - "ReservationVault": "0xReservationVaultAddress1234567890abcdef12345678", - "Wallet": { - "State": "Live" - }, - "ReservationParameters": { - "ReservationMinAmount": 100000, - "ReservationTxMaxFee": 5000, - "ReservationMaxTotalAmount": 100000000, - "ReservationTotalAmount": 0, - "MaxReservationsPerWallet": 5 - }, - "Caps": { - "MaxReservationsAmountPerWallet": 3000000, - "ReservationMaxSingleAmount": 5000000 - }, - "WalletCustody": { - "Count": 1, - "Amount": 2000000 - }, - "Global": { - "ActiveCount": 0, - "MaxActive": 100 - }, - "PendingReservedDeposits": 0, - "ReservedDeposits": [ - { - "FundingTxHash": "11b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f00", - "FundingOutputIndex": 0, - "FundingTxConfirmations": 6, - "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", - "Depositor": "934b98637ca318a4d6e7ca6ffd1690b8e77df637", - "BlindingFactor": "f9f0c90d00039526", - "RefundPublicKeyHash": "e257eccafbc07c381642ce6e7e55120fb077fbed", - "RefundLocktime": "e0250162", - "Amount": 2000000, - "RevealBlock": 290000, - "Age": 7200, - "SweptAt": 0, - "Vault": "0xReservationVaultAddress1234567890abcdef12345678" - } - ], - "ExpectedAnchorProposal": null, - "ExpectedErr": "" -} diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_7.json b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_7.json deleted file mode 100644 index be65953866..0000000000 --- a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_7.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "Title": "cap rejection - accepting would exceed global reservation total cap", - "ChainParameters": { - "AverageBlockTime": 12, - "CurrentBlock": 300000, - "DepositMinAge": 3600 - }, - "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", - "ReservationVault": "0xReservationVaultAddress1234567890abcdef12345678", - "Wallet": { - "State": "Live" - }, - "ReservationParameters": { - "ReservationMinAmount": 100000, - "ReservationTxMaxFee": 5000, - "ReservationMaxTotalAmount": 3000000, - "ReservationTotalAmount": 2000000, - "MaxReservationsPerWallet": 5 - }, - "Caps": { - "MaxReservationsAmountPerWallet": 50000000, - "ReservationMaxSingleAmount": 5000000 - }, - "WalletCustody": { - "Count": 1, - "Amount": 2000000 - }, - "Global": { - "ActiveCount": 0, - "MaxActive": 100 - }, - "PendingReservedDeposits": 0, - "ReservedDeposits": [ - { - "FundingTxHash": "21b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f00", - "FundingOutputIndex": 0, - "FundingTxConfirmations": 6, - "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", - "Depositor": "934b98637ca318a4d6e7ca6ffd1690b8e77df637", - "BlindingFactor": "f9f0c90d00039527", - "RefundPublicKeyHash": "e257eccafbc07c381642ce6e7e55120fb077fbed", - "RefundLocktime": "e0250162", - "Amount": 2000000, - "RevealBlock": 290000, - "Age": 7200, - "SweptAt": 0, - "Vault": "0xReservationVaultAddress1234567890abcdef12345678" - } - ], - "ExpectedAnchorProposal": null, - "ExpectedErr": "" -} diff --git a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_8.json b/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_8.json deleted file mode 100644 index 768e723797..0000000000 --- a/pkg/tbtcpg/internal/test/testdata/reservation_acceptance_scenario_8.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "Title": "scan continues past an earlier ineligible candidate to a later eligible one", - "ChainParameters": { - "AverageBlockTime": 12, - "CurrentBlock": 300000, - "DepositMinAge": 3600 - }, - "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", - "ReservationVault": "0xReservationVaultAddress1234567890abcdef12345678", - "Wallet": { - "State": "Live" - }, - "ReservationParameters": { - "ReservationMinAmount": 100000, - "ReservationTxMaxFee": 5000, - "ReservationMaxTotalAmount": 100000000, - "ReservationTotalAmount": 0, - "MaxReservationsPerWallet": 5 - }, - "Caps": { - "MaxReservationsAmountPerWallet": 50000000, - "ReservationMaxSingleAmount": 5000000 - }, - "WalletCustody": { - "Count": 0, - "Amount": 0 - }, - "Global": { - "ActiveCount": 0, - "MaxActive": 100 - }, - "PendingReservedDeposits": 0, - "ReservedDeposits": [ - { - "FundingTxHash": "c1c2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f01", - "FundingOutputIndex": 0, - "FundingTxConfirmations": 6, - "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", - "Depositor": "934b98637ca318a4d6e7ca6ffd1690b8e77df637", - "BlindingFactor": "f9f0c90d00039523", - "RefundPublicKeyHash": "e257eccafbc07c381642ce6e7e55120fb077fbed", - "RefundLocktime": "e0250162", - "Amount": 50000, - "RevealBlock": 290000, - "Age": 7200, - "SweptAt": 0, - "Vault": "0xReservationVaultAddress1234567890abcdef12345678", - "PendingAcceptanceAction": { - "RequestNonce": 1, - "TxMaxFee": 5000, - "MinAmount": 100000, - "TermSeconds": 86400, - "TargetWalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6" - } - }, - { - "FundingTxHash": "c2c2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f02", - "FundingOutputIndex": 0, - "FundingTxConfirmations": 6, - "WalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6", - "Depositor": "934b98637ca318a4d6e7ca6ffd1690b8e77df637", - "BlindingFactor": "f9f0c90d00039523", - "RefundPublicKeyHash": "e257eccafbc07c381642ce6e7e55120fb077fbed", - "RefundLocktime": "e0250162", - "Amount": 2000000, - "RevealBlock": 290001, - "Age": 7200, - "SweptAt": 0, - "Vault": "0xReservationVaultAddress1234567890abcdef12345678", - "PendingAcceptanceAction": { - "RequestNonce": 1, - "TxMaxFee": 5000, - "MinAmount": 100000, - "TermSeconds": 86400, - "TargetWalletPublicKeyHash": "8db50eb52063ea9d98b3eac91489a90f738986f6" - } - } - ], - "ExpectedAnchorProposal": { - "DepositFundingTxHash": "c2c2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f02", - "DepositFundingOutputIndex": 0, - "RequestNonce": 1, - "AnchorTxFee": 710 - }, - "ExpectedErr": "" -} diff --git a/pkg/tbtcpg/reservation_acceptance.go b/pkg/tbtcpg/reservation_acceptance.go index 67f41691be..7950b42745 100644 --- a/pkg/tbtcpg/reservation_acceptance.go +++ b/pkg/tbtcpg/reservation_acceptance.go @@ -2,7 +2,6 @@ package tbtcpg import ( "context" - "errors" "fmt" "math/big" "sort" @@ -18,10 +17,25 @@ import ( "github.com/keep-network/keep-core/pkg/tbtc" ) -// ReservationAcceptanceLookBackBlocks is the look-back period in blocks used -// when searching for reservation candidate deposits. It mirrors the deposit -// sweep look-back window: 30 days at 12 seconds per block. -const ReservationAcceptanceLookBackBlocks = uint64(216000) +// reservationAcceptanceRequestLookBackMarginBlocks is added to the +// on-chain reservation action timeout (converted to blocks) to form the +// look-back window of the ReservationAcceptanceRequested scan. Solidity's +// requestReservationAcceptance sets timeoutAt = request time + the action +// timeout, so a generation that can still be signed was requested within +// that timeout; the margin (about one day at 12 seconds per block) absorbs +// block-time drift and a moderate governance decrease of the timeout. +const reservationAcceptanceRequestLookBackMarginBlocks = uint64(7200) + +// reservationAcceptanceRevealLookBackMargin is added to a pending +// acceptance generation's snapshotted term to bound how far before the +// request the deposit's DepositRevealed event is searched for. +// Deposit.sol caps a reserved deposit's refund deadline at reveal time + +// term + 24 hours, and requestReservationAcceptance requires timeoutAt + +// 24 hours <= refund deadline, so the reveal happened at most one term +// before the request. The cap uses the term in force at reveal time, +// which governance may have lowered before the request; the margin +// absorbs such a decrease of up to about a week, plus block-time drift. +const reservationAcceptanceRevealLookBackMargin = 7 * 24 * time.Hour // zeroAddressHex is the Ethereum zero address as returned by the chain // adapter's address converter (chain.Address(common.Address{}.String()) @@ -30,11 +44,11 @@ const ReservationAcceptanceLookBackBlocks = uint64(216000) const zeroAddressHex = "0x0000000000000000000000000000000000000000" // ReservationAcceptanceTask is a task that may produce a reservation -// acceptance (anchor) proposal. It scans the chain for reserved deposits -// revealed to the operator's wallet, validates the wallet's eligibility -// against the active reservation caps, and emits a proposal whose resulting -// transaction is a 1-input-1-output anchor that disables the deposit's -// refund path. +// acceptance (anchor) proposal. It discovers the Pending Acceptance +// generations depositors requested for the operator's wallet from +// ReservationAcceptanceRequested events, and emits a proposal whose +// resulting transaction is a 1-input-1-output anchor that disables the +// deposit's refund path. type ReservationAcceptanceTask struct { chain Chain btcChain bitcoin.Chain @@ -42,22 +56,27 @@ type ReservationAcceptanceTask struct { // metricsRecorder is optional and used for recording performance // metrics: active_reservations_count, max_active_reservations, // wallet_reservations_count, reservation_vault_fee_debt_sat, and - // reservation_vault_fee_reserve_tbtc_base_units, sourced from the chain calls - // this task already makes in findReservationAcceptanceCandidate. - // These are leading indicators of reservation capacity saturation. + // reservation_vault_fee_reserve_tbtc_base_units, published once per + // Run. The occupancy gauges reuse chain reads the task makes anyway; + // the two vault fee gauges add two reads of the vault per Run. They + // are leading indicators of reservation capacity saturation and of + // reservation fee pressure. metricsRecorder interface { SetGauge(name string, value float64) } - // scanStateMutex guards scanState. Run() may be invoked for different - // wallets concurrently, and every call shares this one task instance - // (see NewProposalGenerator), so the per-wallet scan-state map needs - // its own lock rather than relying on a single caller goroutine. - scanStateMutex sync.Mutex - // scanState holds, per wallet, the incremental deposit-reveal scan - // cursor and its cached candidate events (see - // reservationAcceptanceScanState). - scanState map[[20]byte]*reservationAcceptanceScanState + // revealCacheMutex guards revealCache. Run() may be invoked for + // different wallets concurrently, and every call shares this one task + // instance (see NewProposalGenerator). + revealCacheMutex sync.Mutex + // revealCache holds, per wallet, the DepositRevealed events found for + // the previous Run's pending acceptance candidates, keyed by + // depositKey.Text(16). Reveal events never change, and a depositor may + // request acceptance long after the reveal, so a candidate re-examined + // in a later window does not repeat its reveal search. Each Run + // replaces its wallet's entry with the reveals of its own candidates, + // so the cache never outgrows the current candidate set. + revealCache map[[20]byte]map[string]*tbtc.DepositRevealedEvent // fundingTxLookupTimeout bounds a single candidate's // GetTransactionConfirmations call in @@ -78,7 +97,7 @@ func NewReservationAcceptanceTask( return &ReservationAcceptanceTask{ chain: chain, btcChain: btcChain, - scanState: make(map[[20]byte]*reservationAcceptanceScanState), + revealCache: make(map[[20]byte]map[string]*tbtc.DepositRevealedEvent), fundingTxLookupTimeout: reservationAcceptanceFundingTxLookupTimeout, } } @@ -91,11 +110,13 @@ func (rat *ReservationAcceptanceTask) setMetricsRecorder(recorder interface { rat.metricsRecorder = recorder } -// maxReservationAcceptanceCandidatesPerRun bounds the number of reserved -// deposits examined by findReservationAcceptanceCandidate in a single -// Run() call. Reveals are gas-only (no SPV proof required to appear), so -// reveal volume is not bounded by anything else; this cap keeps per-window -// work bounded even if a wallet's reveal volume spikes. +// maxReservationAcceptanceCandidatesPerRun bounds the number of pending +// acceptance generations examined past the on-chain pending-acceptance +// check in a single Run() call. Only generations a depositor requested +// for this wallet count toward it; each such request reserved wallet +// capacity on-chain, so the per-wallet reservation caps also bound how +// many can exist. The cap keeps per-window reveal lookups, Ethereum reads +// and Bitcoin lookups bounded. const maxReservationAcceptanceCandidatesPerRun = 50 // reservationAcceptanceFundingTxLookupWorkers bounds the number of reserved @@ -125,13 +146,17 @@ const reservationAcceptanceFundingTxLookupWorkers = 8 // directly. const reservationAcceptanceFundingTxLookupTimeout = 30 * time.Second -// reservationAcceptanceFundingTxCandidate is a phase-one-eligible reserved -// deposit awaiting the concurrent funding-transaction lookup performed by +// reservationAcceptanceFundingTxCandidate is a reserved deposit whose +// current generation is a Pending Acceptance targeting the operator's +// wallet and whose reveal and deposit request passed every Ethereum-side +// check, awaiting the concurrent funding-transaction lookup performed by // fetchReservationAcceptanceFundingTxs. type reservationAcceptanceFundingTxCandidate struct { event *tbtc.DepositRevealedEvent depositKey *big.Int depositRequest *tbtc.DepositChainRequest + requestNonce uint64 + action *tbtc.ReservationAction } // reservationAcceptanceFundingTxLookup is the outcome of one candidate's @@ -147,100 +172,22 @@ type reservationAcceptanceFundingTxLookup struct { confirmationsErr error } -// reservationAcceptanceScanState is the per-wallet incremental deposit- -// reveal scan cursor and its in-memory candidate cache, mirroring the -// cursor/cache split used by pkg/maintainer/spv/reservation_proof_loop.go's -// reservationProofScanState: only the block-range delta since the previous -// Run() call is fetched from the chain, while the cached event set is -// still fully re-evaluated against live eligibility state on every call, -// since an already-cached event's temporal maturity, applicable caps, and -// reservation state can all change between calls. -type reservationAcceptanceScanState struct { - // mutex guards the fields below across the entire read-fetch-merge- - // prune sequence in depositRevealedEventsSince, not just the map - // lookup in the caller: two concurrent Run() calls for the same - // wallet must serialize on this wallet's cursor rather than racing - // on lastScannedBlock/events. - mutex sync.Mutex - lastScannedBlock uint64 - events []*tbtc.DepositRevealedEvent -} - -// depositRevealedEventsSince returns every DepositRevealedEvent within the -// ReservationAcceptanceLookBackBlocks window for walletPublicKeyHash, using -// this task's per-wallet incremental cursor (see reservationAcceptanceScanState): -// the first call for a wallet performs the full look-back scan; every call -// after fetches only the block-range delta since the previous call and -// merges it into the cached set. Events that have aged out of the -// look-back window are pruned from the cache on every call. -func (rat *ReservationAcceptanceTask) depositRevealedEventsSince( - walletPublicKeyHash [20]byte, - currentBlock uint64, -) ([]*tbtc.DepositRevealedEvent, error) { - rat.scanStateMutex.Lock() - state, ok := rat.scanState[walletPublicKeyHash] - if !ok { - state = &reservationAcceptanceScanState{} - rat.scanState[walletPublicKeyHash] = state - } - rat.scanStateMutex.Unlock() - - state.mutex.Lock() - defer state.mutex.Unlock() - - windowStartBlock := uint64(0) - if currentBlock > ReservationAcceptanceLookBackBlocks { - windowStartBlock = currentBlock - ReservationAcceptanceLookBackBlocks - } - - fetchStartBlock := windowStartBlock - if state.lastScannedBlock != 0 { - fetchStartBlock = state.lastScannedBlock + 1 - } - - if fetchStartBlock <= currentBlock { - newEvents, err := rat.chain.PastDepositRevealedEvents( - &tbtc.DepositRevealedEventFilter{ - StartBlock: fetchStartBlock, - EndBlock: ¤tBlock, - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - }, - ) - if err != nil { - return nil, fmt.Errorf( - "failed to get past deposit revealed events: [%w]", - err, - ) - } - - state.events = append(state.events, newEvents...) - state.lastScannedBlock = currentBlock - } - - // Prune events that have aged out of the look-back window and build a - // fresh slice, so the caller's in-place sort does not reorder the - // cached backing array shared across Run() calls for this wallet. - prunedEvents := make([]*tbtc.DepositRevealedEvent, 0, len(state.events)) - for _, event := range state.events { - if event.BlockNumber >= windowStartBlock { - prunedEvents = append(prunedEvents, event) - } - } - state.events = prunedEvents - - events := make([]*tbtc.DepositRevealedEvent, len(prunedEvents)) - copy(events, prunedEvents) - return events, nil +// reservationAcceptanceFeeEstimate is a cached anchor fee estimate for one +// snapshotted fee cap. +type reservationAcceptanceFeeEstimate struct { + fee int64 + err error } -// Run inspects the chain for an acceptance candidate reserved deposit and, -// if one passes the eligibility gate, returns the resulting anchor proposal. -// The task is a no-op (proposal == nil, shouldExecute == false) when no -// candidate exists. A candidate whose proposal generation fails before any -// chain-state-mutating call (assemble/validate) is skipped in favor of the -// next candidate rather than aborting the window outright -- see -// reservationAcceptancePreWriteError. A failure after a write still aborts -// the window, since a partial on-chain effect may already exist. +// Run inspects the chain for a reserved deposit whose depositor requested +// acceptance by the operator's wallet and, if one passes every signer-time +// rule, returns the resulting anchor proposal. The per-window setup +// (parameters, wallet, gauges, candidate discovery) runs once; candidates +// are then tried in order. A candidate that fails a later check, the fee +// estimate, or proposal generation is logged and skipped in favor of the +// next one: the task performs no chain-state-mutating call, so a failed +// candidate leaves nothing behind. The task is a no-op (proposal == nil, +// shouldExecute == false) when no candidate succeeds. func (rat *ReservationAcceptanceTask) Run(request *tbtc.CoordinationProposalRequest) ( tbtc.CoordinationProposal, bool, @@ -253,54 +200,64 @@ func (rat *ReservationAcceptanceTask) Run(request *tbtc.CoordinationProposalRequ zap.String("walletPKH", fmt.Sprintf("0x%x", walletPublicKeyHash)), ) - skipDepositKeys := make(map[string]bool) - - for { - candidate, err := rat.findReservationAcceptanceCandidate( + pendingCandidates, reservationParameters, err := + rat.findReservationAcceptanceCandidates( taskLogger, walletPublicKeyHash, - skipDepositKeys, ) - if err != nil { - return nil, false, fmt.Errorf( - "cannot find reservation acceptance candidate: [%w]", - err, - ) - } + if err != nil { + return nil, false, fmt.Errorf( + "cannot find reservation acceptance candidate: [%w]", + err, + ) + } + + // The two Electrum calls (GetTransaction and + // GetTransactionConfirmations) are looked up through a bounded, + // lazily-scheduled pipeline (see fetchReservationAcceptanceFundingTxs), + // so an unhealthy Bitcoin backend cannot turn the candidate set into a + // fully serial multi-hour retry chain, and the common case -- the + // first candidate succeeds -- does not pay for fetching every other + // candidate's funding transaction. fundingTxLookups.next(i) hands back + // results in candidate order; deferring stop() tells the pipeline to + // abandon any lookup it has not yet dispatched once Run returns. + fundingTxLookups := rat.fetchReservationAcceptanceFundingTxs(pendingCandidates) + defer fundingTxLookups.stop() + + feeEstimates := make(map[uint64]reservationAcceptanceFeeEstimate) + + for i, pendingCandidate := range pendingCandidates { + candidate := rat.completeReservationAcceptanceCandidate( + taskLogger, + pendingCandidate, + fundingTxLookups.next(i), + feeEstimates, + reservationParameters, + ) if candidate == nil { - taskLogger.Info("no reservation acceptance candidate") - return nil, false, nil + continue } - proposal, shouldExecute, err := rat.proposeReservationAcceptance( + proposal, err := rat.proposeReservationAcceptance( taskLogger, walletPublicKeyHash, candidate, ) if err != nil { - var preWriteErr *reservationAcceptancePreWriteError - if errors.As(err, &preWriteErr) { - taskLogger.Warnf( - "reservation acceptance candidate [%v] failed before "+ - "any chain-state-mutating call, trying next "+ - "candidate: [%v]", - candidate.DepositKey, - err, - ) - skipDepositKeys[candidate.DepositKey.Text(16)] = true - continue - } - return nil, false, fmt.Errorf( - "cannot prepare reservation acceptance proposal: [%w]", + taskLogger.Warnf( + "reservation acceptance candidate [%v] failed, trying "+ + "next candidate: [%v]", + candidate.DepositKey, err, ) + continue } - if proposal == nil { - return nil, shouldExecute, nil - } - return proposal, shouldExecute, nil + return proposal, true, nil } + + taskLogger.Info("no reservation acceptance candidate") + return nil, false, nil } // ActionType returns the wallet action type this task proposes. @@ -311,8 +268,8 @@ func (rat *ReservationAcceptanceTask) ActionType() tbtc.WalletActionType { // reservationAcceptanceCandidate is the bundle a candidate reserved deposit // for acceptance carries through the proposal builder. It captures the // deposit's reveal context, the pending acceptance generation's real request -// nonce and snapshotted fields, plus the on-chain cap snapshot taken at scan -// time. +// nonce and snapshotted fields, plus the reservation parameters read at +// scan time. // // On-chain, only the deposit's depositor may call requestReservationAcceptance, // and that call is what writes the Pending Acceptance action record. This @@ -331,24 +288,34 @@ type reservationAcceptanceCandidate struct { AnchorFee int64 } -// findReservationAcceptanceCandidate returns the first reserved deposit -// that the operator's wallet may accept, or nil when none qualifies. -// skipDepositKeys (keyed by depositKey.Text(16)) excludes deposits the -// caller already tried and rejected earlier in the same Run() call, so a -// deposit whose proposal generation fails pre-write does not block every -// other candidate on the wallet. -func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( +// findReservationAcceptanceCandidates performs the per-window setup (it +// reads the reservation parameters and wallet, publishes the gauges) and +// returns, in the order they should be tried, the reserved deposits whose +// current generation is a Pending Acceptance targeting the wallet and +// whose reveal and deposit request pass every Ethereum-side check. +// +// Candidates are discovered from the wallet's ReservationAcceptanceRequested +// events rather than from deposit reveals: only a depositor's request +// creates an anchorable generation, and it may come long after the +// reveal. The on-chain pending-acceptance check runs before a candidate +// counts toward maxReservationAcceptanceCandidatesPerRun and before any +// reveal search or Bitcoin lookup, so reveals that were never requested +// cannot use up the budget. +func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidates( taskLogger log.StandardLogger, walletPublicKeyHash [20]byte, - skipDepositKeys map[string]bool, -) (*reservationAcceptanceCandidate, error) { +) ( + []*reservationAcceptanceFundingTxCandidate, + *tbtc.ReservationParameters, + error, +) { if walletPublicKeyHash == [20]byte{} { - return nil, fmt.Errorf("wallet public key hash is required") + return nil, nil, fmt.Errorf("wallet public key hash is required") } reservationParameters, err := rat.chain.ReservationParameters() if err != nil { - return nil, fmt.Errorf( + return nil, nil, fmt.Errorf( "failed to get reservation parameters: [%w]", err, ) @@ -371,103 +338,23 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( ) } taskLogger.Info("reservation vault not configured") - return nil, nil + return nil, reservationParameters, nil } wallet, err := rat.chain.GetWallet(walletPublicKeyHash) if err != nil { - return nil, fmt.Errorf( + return nil, nil, fmt.Errorf( "failed to load wallet chain data: [%w]", err, ) } - // wallet_reservations_count / active_reservations_count / - // max_active_reservations are published unconditionally on every - // findReservationAcceptanceCandidate pass, mirroring the sibling - // live_wallets_count gauge that ReservationReanchorTask publishes every - // coordination window: they must not depend on the StateLive guard - // below, which is false for a wallet mid-rotation (moving funds or - // closing) -- exactly when gauge staleness matters most. Gating the - // publish on that guard would leave these gauges stuck at their - // registered-zero value for as long as the wallet is not Live. - walletReservationsCount, err := rat.chain.WalletReservationsCount( + if err := rat.publishReservationGauges( + taskLogger, walletPublicKeyHash, - ) - if err != nil { - return nil, fmt.Errorf( - "failed to get wallet reservations count: [%w]", - err, - ) - } - if rat.metricsRecorder != nil { - rat.metricsRecorder.SetGauge( - "wallet_reservations_count", - float64(walletReservationsCount), - ) - } - - activeReservationsCount, maxActiveReservations, err := - rat.chain.ActiveReservationsCount() - if err != nil { - return nil, fmt.Errorf( - "failed to get active reservations count: [%w]", - err, - ) - } - if rat.metricsRecorder != nil { - rat.metricsRecorder.SetGauge( - "active_reservations_count", - float64(activeReservationsCount), - ) - rat.metricsRecorder.SetGauge( - "max_active_reservations", - float64(maxActiveReservations), - ) - } - - // reservation_vault_fee_debt_sat / - // reservation_vault_fee_reserve_tbtc_base_units publish the ReservationVault's - // outstanding in-kind fee debt (in satoshi) and fee-reserve balance - // (in TBTC base units, 1e18 per whole TBTC; a gauge value of N - // means N / 1e18 whole TBTC), unconditionally on every pass, - // next to the occupancy gauges above: a vault whose fee debt or - // reserve is growing is the leading indicator of reservation fee - // pressure. A read error is logged and the gauge keeps its - // previously recorded value: the gauges are observability-only, - // so an RPC failure here must never abort proposal generation. - // When the reservation vault is not configured (zero address), - // findReservationAcceptanceCandidate has already returned above, - // publishing the fee gauges as zero, so this block is unreachable - // for that pass. - if rat.metricsRecorder != nil { - feeDebtSat, err := rat.chain.ReservationVaultFeeDebtSat(reservationVault) - if err != nil { - taskLogger.Warnf( - "failed to get reservation vault fee debt: [%v]", - err, - ) - } else { - rat.metricsRecorder.SetGauge( - "reservation_vault_fee_debt_sat", - float64(feeDebtSat), - ) - } - - feeReserve, err := - rat.chain.ReservationVaultFeeReserveTbtcBaseUnits(reservationVault) - if err != nil { - taskLogger.Warnf( - "failed to get reservation vault fee reserve: [%v]", - err, - ) - } else if feeReserve != nil { - reserveFloat, _ := feeReserve.Float64() - rat.metricsRecorder.SetGauge( - "reservation_vault_fee_reserve_tbtc_base_units", - reserveFloat, - ) - } + reservationVault, + ); err != nil { + return nil, nil, err } // Mirror WalletProposalValidator.sol's @@ -483,16 +370,16 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( "cannot accept reservation", wallet.State, ) - return nil, nil + return nil, reservationParameters, nil } blockCounter, err := rat.chain.BlockCounter() if err != nil { - return nil, fmt.Errorf("failed to get block counter: [%w]", err) + return nil, nil, fmt.Errorf("failed to get block counter: [%w]", err) } currentBlock, err := blockCounter.CurrentBlock() if err != nil { - return nil, fmt.Errorf( + return nil, nil, fmt.Errorf( "failed to get current block: [%w]", err, ) @@ -504,15 +391,14 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( // when the Pending Acceptance generation this task consumes was // created. Re-applying the caps at consumption time would // double-count that generation against them, so only the signer-time - // rules the validator enforces are mirrored here (see - // findReservationAcceptanceCandidate and - // proposeReservationAcceptance). Request-time cap checks remain in - // place where a fresh request is made: the re-anchor task's target - // headroom pre-check and its request flow. + // rules the validator enforces are mirrored here and in + // completeReservationAcceptanceCandidate. Request-time cap checks + // remain in place where a fresh request is made: the re-anchor task's + // target headroom pre-check and its request flow. depositMinAgeSeconds, err := rat.chain.GetDepositMinAge() if err != nil { - return nil, fmt.Errorf( + return nil, nil, fmt.Errorf( "failed to get deposit minimum age: [%w]", err, ) @@ -529,193 +415,57 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( ) } - depositRevealedEvents, err := rat.depositRevealedEventsSince( + averageBlockTime := rat.chain.AverageBlockTime() + if averageBlockTime <= 0 { + averageBlockTime = tbtc.DepositRevealLookupDefaultBlockTime + } + + requestEvents, err := rat.reservationAcceptanceRequestedEvents( walletPublicKeyHash, currentBlock, + reservationParameters.ReservationActionTimeout, + averageBlockTime, ) if err != nil { - return nil, err + return nil, nil, err } - // Take the oldest first. - sort.SliceStable(depositRevealedEvents, func(i, j int) bool { - return depositRevealedEvents[i].BlockNumber < depositRevealedEvents[j].BlockNumber - }) - - // The anchor fee is identical for every candidate examined below (same - // fixed-size 1-input-1-output anchor transaction, same fee-rate oracle - // and cap). It is estimated at most once per Run() call -- on the - // first candidate that survives every earlier per-candidate gate -- - // and the result is cached in anchorFee/anchorFeeComputed for reuse by - // every subsequent candidate, instead of being unconditionally - // recomputed for each of the up to - // maxReservationAcceptanceCandidatesPerRun candidates examined by the - // loop. It is intentionally not computed further up in this function: - // a wallet with no candidate that reaches this gate (e.g. no reserved - // deposits at all) must remain a clean no-op, without paying for a fee - // estimate it will never use. - var anchorFee int64 - anchorFeeComputed := false - var lastFeeCap uint64 now := time.Now() + rat.revealCacheMutex.Lock() + previousReveals := rat.revealCache[walletPublicKeyHash] + rat.revealCacheMutex.Unlock() + currentReveals := make(map[string]*tbtc.DepositRevealedEvent) + defer func() { + rat.revealCacheMutex.Lock() + rat.revealCache[walletPublicKeyHash] = currentReveals + rat.revealCacheMutex.Unlock() + }() + var pendingCandidates []*reservationAcceptanceFundingTxCandidate candidatesExamined := 0 - for _, event := range depositRevealedEvents { - if !depositTargetsReservationVault(event.Vault, reservationVault) { - continue - } + for _, requestEvent := range requestEvents { + depositKey := requestEvent.ReservationKey - depositKey := rat.chain.BuildDepositKey( - event.FundingTxHash, - event.FundingOutputIndex, - ) - - if skipDepositKeys[depositKey.Text(16)] { - continue - } - - if candidatesExamined >= maxReservationAcceptanceCandidatesPerRun { - taskLogger.Warnf( - "reached max reservation acceptance candidates per run "+ - "[%d]; remaining reserved deposits will be examined "+ - "on a subsequent run", - maxReservationAcceptanceCandidatesPerRun, - ) - break - } - candidatesExamined++ - - depositRequest, foundRequest, err := rat.chain.GetDepositRequest( - event.FundingTxHash, - event.FundingOutputIndex, - ) - if err != nil { - taskLogger.Errorf( - "failed to get deposit request for [%v]: [%v]", - depositKey, - err, - ) - continue - } - if !foundRequest { - taskLogger.Warnf( - "no deposit request for reserved deposit [%v]", - depositKey, - ) - continue - } - - matureAt := depositRequest.RevealedAt.Add(depositMinAge) - if !now.After(matureAt) { - taskLogger.Infof( - "reserved deposit [%v] is not old enough: now=%v, matureAt=%v", - depositKey, - now, matureAt, - ) - continue - } - - if depositRequest.SweptAt.Unix() != 0 { - taskLogger.Debugf( - "reserved deposit [%v] is already swept", - depositKey, - ) - continue - } - - // Phase one filters on liveness-only conditions; the - // pending-acceptance precondition, the generation's timeout - // safety margin, and its snapshotted minimum plus anchor fee - // are all enforced per candidate in the second pass below, - // where the generation's own record is read. - - pendingCandidates = append( - pendingCandidates, - &reservationAcceptanceFundingTxCandidate{ - event: event, - depositKey: depositKey, - depositRequest: depositRequest, - }, - ) - } - - // The two Electrum calls below (GetTransaction and - // GetTransactionConfirmations) are looked up through a bounded, - // lazily-scheduled pipeline (see fetchReservationAcceptanceFundingTxs) - // instead of eagerly for every phase-one-eligible candidate collected - // above, so an unhealthy Bitcoin backend cannot turn this bounded - // candidate set into a fully serial multi-hour retry chain, AND a - // caller that only needs the first fully eligible candidate -- the - // common case, since candidates are examined oldest-first -- does not - // pay for fetching every other pending candidate's funding - // transaction. fundingTxLookups.next(i) still hands back each - // candidate's result in the original oldest-first order, so the loop - // below still returns the first fully eligible one deterministically, - // exactly as the previous strictly serial implementation did; - // deferring stop() here ensures every exit from this loop -- a - // candidate found, or every candidate exhausted -- tells the pipeline - // to abandon any lookup it has not yet dispatched. - fundingTxLookups := rat.fetchReservationAcceptanceFundingTxs(pendingCandidates) - defer fundingTxLookups.stop() - - for i, pendingCandidate := range pendingCandidates { - event := pendingCandidate.event - depositKey := pendingCandidate.depositKey - depositRequest := pendingCandidate.depositRequest - lookup := fundingTxLookups.next(i) - - if lookup.fundingTxErr != nil { - taskLogger.Errorf( - "failed to get funding tx for reserved deposit [%v]: [%v]", - depositKey, - lookup.fundingTxErr, - ) - continue - } - fundingTx := lookup.fundingTx - - if lookup.confirmationsErr != nil { - taskLogger.Errorf( - "failed to get funding tx confirmations for [%v]: [%v]", - depositKey, - lookup.confirmationsErr, - ) - continue - } - if lookup.confirmations < tbtc.DepositSweepRequiredFundingTxConfirmations { - taskLogger.Debugf( - "reserved deposit [%v] funding tx confirmations [%d/%d] below required", - depositKey, - lookup.confirmations, - tbtc.DepositSweepRequiredFundingTxConfirmations, - ) + // The event's own timeout lets a generation whose signing window + // has closed be dropped without any RPC. + if uint64(now.Unix())+ + uint64(reservationRequestTimeoutSafetyMarginSeconds) >= + uint64(requestEvent.TimeoutAt) { continue } // Determine the acceptance authorization generation from the - // reservation's own on-chain state, which authoritatively reflects - // whether the depositor has requested this deposit's acceptance -- - // not from acceptance-requested event history, which would still - // show a first generation that has since timed out and become - // eligible for a retry. - // - // The deposit's current generation is the only one this task may - // anchor: on-chain, only the deposit's own depositor may call - // requestReservationAcceptance, and that call is what writes the - // Pending Acceptance action record this task validates against. - // Deposits whose current generation is not a Pending Acceptance - // record (yet unknown, a timed-out or settled prior generation, or - // a reservation already anchored) are skipped, because the owner - // has not requested this deposit's acceptance. + // reservation's own on-chain state, which authoritatively + // reflects whether the requested generation is still the + // current one and still pending. reservation, err := rat.chain.GetReservation(depositKey) if err != nil { // Fail safe: the production chain adapter never errors for // "not found" (it returns a zero record with State == // Unknown), so a non-nil error here can only be an RPC/decode - // failure, not a signal that the reservation is not yet - // created. Skip this deposit for the current coordination + // failure. Skip this deposit for the current coordination // window instead; the next window retries. taskLogger.Errorf( "cannot get reservation [%v], skipping deposit for this window: [%v]", @@ -726,20 +476,17 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( } // The pending-generation precondition: a successful read of a - // generation whose record is not yet a Pending Acceptance action - // (RequestNonce == 0 with the zero record, or any non-acceptance - // / non-pending record) means the depositor has not requested this - // deposit's acceptance and it is not anchorable. + // generation whose record is not a Pending Acceptance action + // (a timed-out or settled generation, or any non-acceptance + // record) is not anchorable. action, err := rat.chain.GetReservationAction( depositKey, reservation.RequestNonce, ) if err != nil { // Fail safe: a lookup error is indistinguishable from "still - // pending" here, and treating it as not-pending would let the - // task build a proposal for a generation that may already be - // in flight. Skip this deposit for the current coordination - // window instead; the next window retries. + // pending" here. Skip this deposit for the current + // coordination window instead; the next window retries. taskLogger.Errorf( "cannot get reservation action for [0x%x] nonce [%d]: [%v]", depositKey, @@ -753,8 +500,7 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( action.State != tbtc.ReservationActionStatePending { taskLogger.Infof( "reservation [%v] generation [%d] is not a pending "+ - "acceptance action (type=%v, state=%v); the depositor "+ - "has not requested acceptance, skipping", + "acceptance action (type=%v, state=%v); skipping", depositKey, reservation.RequestNonce, action.ActionType, @@ -794,107 +540,439 @@ func (rat *ReservationAcceptanceTask) findReservationAcceptanceCandidate( continue } - // The action record holds the generation's real nonce and its - // snapshotted fee bound; the proposal and its net-of-fee check - // below are built from them, not from live parameter values. - requestNonce := reservation.RequestNonce - txMaxFee := action.TxMaxFee - - // Check net-of-fee viability here, as part of candidate selection, - // rather than after a single candidate has already been chosen. A - // candidate that fails this check is skipped in favor of the next - // one; nothing marks it retried, so leaving this check in - // proposeReservationAcceptance (which is called for exactly one - // already-selected candidate) would cause the same doomed deposit - // to be re-selected and abort on every subsequent Run() until it - // aged out of the look-back window. - // The estimate is cached per fee cap: the cap is snapshotted per - // action generation, so a different generation's bound invalidates - // the cached estimate. - if !anchorFeeComputed || txMaxFee != lastFeeCap { - var feeErr error - anchorFee, feeErr = estimateReservationAcceptanceFee( - rat.btcChain, - txMaxFee, + if candidatesExamined >= maxReservationAcceptanceCandidatesPerRun { + taskLogger.Warnf( + "reached max reservation acceptance candidates per run "+ + "[%d]; remaining pending acceptances will be examined "+ + "on a subsequent run", + maxReservationAcceptanceCandidatesPerRun, ) - if feeErr != nil { - return nil, fmt.Errorf( - "failed to estimate reservation acceptance transaction fee: [%w]", - feeErr, - ) - } - anchorFeeComputed = true - lastFeeCap = txMaxFee + break } + candidatesExamined++ - // Mirror WalletProposalValidator.validateReservationAnchorProposal's - // minimum check against this generation's own snapshotted values: - // the deposit amount must satisfy - // amount >= action.MinAmount + anchorFee before the on-chain - // validation can pass. The addition-based formulation avoids the - // underflow the contract guards against when the estimated fee - // exceeds a small deposit's amount. The live ReservationMinAmount - // is deliberately not used: the snapshot is what the validator - // enforces for this generation. - feeSats := uint64(anchorFee) - minPlusFee := action.MinAmount + feeSats - if minPlusFee < action.MinAmount || depositRequest.Amount < minPlusFee { + revealEvent, err := rat.findReservationAcceptanceReveal( + walletPublicKeyHash, + depositKey, + requestEvent.BlockNumber, + action.TermSeconds, + averageBlockTime, + previousReveals, + ) + if err != nil { + taskLogger.Errorf( + "cannot find deposit reveal for reservation [%v], skipping "+ + "deposit for this window: [%v]", + depositKey, + err, + ) + continue + } + if revealEvent == nil { + taskLogger.Warnf( + "no deposit reveal found for reservation [%v] in the "+ + "blocks before its acceptance request; skipping", + depositKey, + ) + continue + } + currentReveals[depositKey.Text(16)] = revealEvent + + if !depositTargetsReservationVault(revealEvent.Vault, reservationVault) { + taskLogger.Warnf( + "reserved deposit [%v] was not revealed to the reservation "+ + "vault; skipping", + depositKey, + ) + continue + } + + depositRequest, foundRequest, err := rat.chain.GetDepositRequest( + revealEvent.FundingTxHash, + revealEvent.FundingOutputIndex, + ) + if err != nil { + taskLogger.Errorf( + "failed to get deposit request for [%v]: [%v]", + depositKey, + err, + ) + continue + } + if !foundRequest { + taskLogger.Warnf( + "no deposit request for reserved deposit [%v]", + depositKey, + ) + continue + } + + matureAt := depositRequest.RevealedAt.Add(depositMinAge) + if !now.After(matureAt) { taskLogger.Infof( - "reserved deposit [%v] amount [%d] below the generation's "+ - "snapshotted minimum [%d] plus anchor fee [%d]; skipping", + "reserved deposit [%v] is not old enough: now=%v, matureAt=%v", + depositKey, + now, matureAt, + ) + continue + } + + if depositRequest.SweptAt.Unix() != 0 { + taskLogger.Debugf( + "reserved deposit [%v] is already swept", depositKey, - depositRequest.Amount, - action.MinAmount, - anchorFee, ) continue } + pendingCandidates = append( + pendingCandidates, + &reservationAcceptanceFundingTxCandidate{ + event: revealEvent, + depositKey: depositKey, + depositRequest: depositRequest, + requestNonce: reservation.RequestNonce, + action: action, + }, + ) + } + + return pendingCandidates, reservationParameters, nil +} + +// publishReservationGauges publishes the occupancy and vault fee gauges. +// wallet_reservations_count / active_reservations_count / +// max_active_reservations are published on every Run regardless of the +// wallet state, mirroring the sibling live_wallets_count gauge that +// ReservationReanchorTask publishes every coordination window: a wallet +// mid-rotation (moving funds or closing) is exactly when gauge staleness +// matters most. +// +// reservation_vault_fee_debt_sat / reservation_vault_fee_reserve_tbtc_base_units +// publish the ReservationVault's outstanding in-kind fee debt (in +// satoshi) and fee-reserve balance (in TBTC base units, 1e18 per whole +// TBTC; a gauge value of N means N / 1e18 whole TBTC): a vault whose fee +// debt or reserve is growing is the leading indicator of reservation fee +// pressure. The vault address the caller already read is passed to the +// chain, so each gauge costs a single vault read. +// +// A failed occupancy read is returned as an error. The vault fee gauges +// are observability-only: a read error is logged, the gauge keeps its +// previously recorded value, and proposal generation goes on. +func (rat *ReservationAcceptanceTask) publishReservationGauges( + taskLogger log.StandardLogger, + walletPublicKeyHash [20]byte, + reservationVault chain.Address, +) error { + walletReservationsCount, err := rat.chain.WalletReservationsCount( + walletPublicKeyHash, + ) + if err != nil { + return fmt.Errorf( + "failed to get wallet reservations count: [%w]", + err, + ) + } + + activeReservationsCount, maxActiveReservations, err := + rat.chain.ActiveReservationsCount() + if err != nil { + return fmt.Errorf( + "failed to get active reservations count: [%w]", + err, + ) + } + + if rat.metricsRecorder == nil { + return nil + } + + rat.metricsRecorder.SetGauge( + "wallet_reservations_count", + float64(walletReservationsCount), + ) + rat.metricsRecorder.SetGauge( + "active_reservations_count", + float64(activeReservationsCount), + ) + rat.metricsRecorder.SetGauge( + "max_active_reservations", + float64(maxActiveReservations), + ) + + feeDebtSat, err := rat.chain.ReservationVaultFeeDebtSat(reservationVault) + if err != nil { + taskLogger.Warnf( + "failed to get reservation vault fee debt: [%v]", + err, + ) + } else { + rat.metricsRecorder.SetGauge( + "reservation_vault_fee_debt_sat", + float64(feeDebtSat), + ) + } + + feeReserve, err := + rat.chain.ReservationVaultFeeReserveTbtcBaseUnits(reservationVault) + if err != nil { + taskLogger.Warnf( + "failed to get reservation vault fee reserve: [%v]", + err, + ) + } else if feeReserve != nil { + reserveFloat, _ := feeReserve.Float64() + rat.metricsRecorder.SetGauge( + "reservation_vault_fee_reserve_tbtc_base_units", + reserveFloat, + ) + } + + return nil +} + +// reservationAcceptanceRequestedEvents returns the wallet's +// ReservationAcceptanceRequested events that may still describe a +// signable generation, one per reservation (the highest request nonce), +// ordered by timeout, the generation closest to timing out first. The +// look-back window is the on-chain action timeout converted to blocks plus +// reservationAcceptanceRequestLookBackMarginBlocks. +func (rat *ReservationAcceptanceTask) reservationAcceptanceRequestedEvents( + walletPublicKeyHash [20]byte, + currentBlock uint64, + actionTimeoutSeconds uint32, + averageBlockTime time.Duration, +) ([]*tbtc.ReservationAcceptanceRequestedEvent, error) { + lookBackBlocks := uint64( + time.Duration(actionTimeoutSeconds)*time.Second/averageBlockTime, + ) + reservationAcceptanceRequestLookBackMarginBlocks + + startBlock := uint64(0) + if currentBlock > lookBackBlocks { + startBlock = currentBlock - lookBackBlocks + } + + events, err := rat.chain.PastReservationAcceptanceRequestedEvents( + &tbtc.ReservationAcceptanceRequestedEventFilter{ + StartBlock: startBlock, + EndBlock: ¤tBlock, + WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + }, + ) + if err != nil { + return nil, fmt.Errorf( + "failed to get past reservation acceptance requested events: [%w]", + err, + ) + } + + latest := make(map[string]*tbtc.ReservationAcceptanceRequestedEvent) + for _, event := range events { + if event.ReservationKey == nil || + event.WalletPublicKeyHash != walletPublicKeyHash { + continue + } + key := event.ReservationKey.Text(16) + if existing, ok := latest[key]; !ok || + event.RequestNonce > existing.RequestNonce { + latest[key] = event + } + } + + result := make([]*tbtc.ReservationAcceptanceRequestedEvent, 0, len(latest)) + for _, event := range latest { + result = append(result, event) + } + sort.Slice(result, func(i, j int) bool { + if result[i].TimeoutAt != result[j].TimeoutAt { + return result[i].TimeoutAt < result[j].TimeoutAt + } + if result[i].BlockNumber != result[j].BlockNumber { + return result[i].BlockNumber < result[j].BlockNumber + } + return result[i].ReservationKey.Cmp(result[j].ReservationKey) < 0 + }) + + return result, nil +} + +// findReservationAcceptanceReveal returns the DepositRevealed event of the +// reserved deposit identified by depositKey, or nil when it is not found. +// A reveal found on the previous Run is reused. Otherwise the wallet's +// reveals are scanned backward from the acceptance request's block down +// to one snapshotted term plus reservationAcceptanceRevealLookBackMargin +// before it (see that constant for why the reveal cannot be older). +func (rat *ReservationAcceptanceTask) findReservationAcceptanceReveal( + walletPublicKeyHash [20]byte, + depositKey *big.Int, + requestBlock uint64, + termSeconds uint32, + averageBlockTime time.Duration, + previousReveals map[string]*tbtc.DepositRevealedEvent, +) (*tbtc.DepositRevealedEvent, error) { + if cached, ok := previousReveals[depositKey.Text(16)]; ok { + return cached, nil + } + + lookBackBlocks := uint64( + (time.Duration(termSeconds)*time.Second + + reservationAcceptanceRevealLookBackMargin) / averageBlockTime, + ) + fromBlock := uint64(0) + if requestBlock > lookBackBlocks { + fromBlock = requestBlock - lookBackBlocks + } + + return tbtc.FindDepositRevealedEvent( + rat.chain, + walletPublicKeyHash, + fromBlock, + requestBlock, + func(event *tbtc.DepositRevealedEvent) bool { + return rat.chain.BuildDepositKey( + event.FundingTxHash, + event.FundingOutputIndex, + ).Cmp(depositKey) == 0 + }, + ) +} + +// completeReservationAcceptanceCandidate applies the Bitcoin-side and fee +// checks to a candidate from findReservationAcceptanceCandidates and +// returns the complete candidate, or nil (after logging why) when it does +// not qualify. feeEstimates caches the anchor fee estimate per snapshotted +// fee cap for the duration of one Run. +func (rat *ReservationAcceptanceTask) completeReservationAcceptanceCandidate( + taskLogger log.StandardLogger, + pendingCandidate *reservationAcceptanceFundingTxCandidate, + lookup reservationAcceptanceFundingTxLookup, + feeEstimates map[uint64]reservationAcceptanceFeeEstimate, + reservationParameters *tbtc.ReservationParameters, +) *reservationAcceptanceCandidate { + event := pendingCandidate.event + depositKey := pendingCandidate.depositKey + depositRequest := pendingCandidate.depositRequest + action := pendingCandidate.action + + if lookup.fundingTxErr != nil { + taskLogger.Errorf( + "failed to get funding tx for reserved deposit [%v]: [%v]", + depositKey, + lookup.fundingTxErr, + ) + return nil + } + + if lookup.confirmationsErr != nil { + taskLogger.Errorf( + "failed to get funding tx confirmations for [%v]: [%v]", + depositKey, + lookup.confirmationsErr, + ) + return nil + } + if lookup.confirmations < tbtc.DepositSweepRequiredFundingTxConfirmations { + taskLogger.Debugf( + "reserved deposit [%v] funding tx confirmations [%d/%d] below required", + depositKey, + lookup.confirmations, + tbtc.DepositSweepRequiredFundingTxConfirmations, + ) + return nil + } + + // The anchor transaction has a fixed size, so its fee only depends on + // the fee-rate oracle and the generation's snapshotted fee cap. The + // estimate (or its error) is cached per cap, so candidates sharing a + // cap do not repeat it. A fee error -- including an estimate above + // this generation's cap -- skips the candidate: another generation + // with a higher cap may still be viable. The estimator does not + // separate oracle failures from cap overruns, so both are skipped. + txMaxFee := action.TxMaxFee + feeEstimate, ok := feeEstimates[txMaxFee] + if !ok { + feeEstimate.fee, feeEstimate.err = estimateReservationAcceptanceFee( + rat.btcChain, + txMaxFee, + ) + feeEstimates[txMaxFee] = feeEstimate + } + if feeEstimate.err != nil { + taskLogger.Warnf( + "cannot estimate reservation acceptance transaction fee for "+ + "reserved deposit [%v] (max fee [%d]); skipping: [%v]", + depositKey, + txMaxFee, + feeEstimate.err, + ) + return nil + } + anchorFee := feeEstimate.fee + + // Mirror WalletProposalValidator.validateReservationAnchorProposal's + // minimum check against this generation's own snapshotted values: + // the deposit amount must satisfy + // amount >= action.MinAmount + anchorFee before the on-chain + // validation can pass. The addition-based formulation avoids the + // underflow the contract guards against when the estimated fee + // exceeds a small deposit's amount. The live ReservationMinAmount + // is deliberately not used: the snapshot is what the validator + // enforces for this generation. + feeSats := uint64(anchorFee) + minPlusFee := action.MinAmount + feeSats + if minPlusFee < action.MinAmount || depositRequest.Amount < minPlusFee { taskLogger.Infof( - "selected reserved deposit [%v] for acceptance", + "reserved deposit [%v] amount [%d] below the generation's "+ + "snapshotted minimum [%d] plus anchor fee [%d]; skipping", depositKey, + depositRequest.Amount, + action.MinAmount, + anchorFee, ) + return nil + } - return &reservationAcceptanceCandidate{ - DepositKey: depositKey, - Deposit: &tbtc.Deposit{ - Utxo: &bitcoin.UnspentTransactionOutput{ - Outpoint: &bitcoin.TransactionOutpoint{ - TransactionHash: event.FundingTxHash, - OutputIndex: event.FundingOutputIndex, - }, - Value: int64(depositRequest.Amount), + taskLogger.Infof( + "selected reserved deposit [%v] for acceptance", + depositKey, + ) + + return &reservationAcceptanceCandidate{ + DepositKey: depositKey, + Deposit: &tbtc.Deposit{ + Utxo: &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: event.FundingTxHash, + OutputIndex: event.FundingOutputIndex, }, - Depositor: depositRequest.Depositor, - BlindingFactor: event.BlindingFactor, - WalletPublicKeyHash: event.WalletPublicKeyHash, - RefundPublicKeyHash: event.RefundPublicKeyHash, - RefundLocktime: event.RefundLocktime, - Vault: depositRequest.Vault, - ExtraData: depositRequest.ExtraData, + Value: int64(depositRequest.Amount), }, - FundingTx: fundingTx, - ReservationParameters: reservationParameters, - TxMaxFee: txMaxFee, - RequestNonce: requestNonce, - AnchorFee: anchorFee, - }, nil + Depositor: depositRequest.Depositor, + BlindingFactor: event.BlindingFactor, + WalletPublicKeyHash: event.WalletPublicKeyHash, + RefundPublicKeyHash: event.RefundPublicKeyHash, + RefundLocktime: event.RefundLocktime, + Vault: depositRequest.Vault, + ExtraData: depositRequest.ExtraData, + }, + FundingTx: lookup.fundingTx, + ReservationParameters: reservationParameters, + TxMaxFee: txMaxFee, + RequestNonce: pendingCandidate.requestNonce, + AnchorFee: anchorFee, } - - return nil, nil } // reservationAcceptanceFundingTxPipeline is a lazily-scheduled, bounded, -// oldest-first funding-transaction lookup pipeline returned by +// in-order funding-transaction lookup pipeline returned by // fetchReservationAcceptanceFundingTxs. A naive worker pool launches every // candidate's GetTransaction/GetTransactionConfirmations call // unconditionally, so a caller that only needs the first fully eligible -// candidate -- the common case, since candidates are examined oldest-first -// -- still pays for fetching every other pending candidate's funding -// transaction. This pipeline instead hands results back one index at a -// time via next(), and stop() tells it to abandon every lookup it has not -// yet dispatched, so a caller that stops asking for more results after an -// early match never causes those later fetches to happen at all. +// candidate still pays for fetching every other pending candidate's +// funding transaction. This pipeline instead hands results back one index +// at a time via next(), and stop() tells it to abandon every lookup it has +// not yet dispatched, so a caller that stops asking for more results after +// an early match never causes those later fetches to happen at all. type reservationAcceptanceFundingTxPipeline struct { // results holds one buffered (capacity 1) channel per candidate index. // Buffering lets a fetch that completes after stop() has already been @@ -908,11 +986,11 @@ type reservationAcceptanceFundingTxPipeline struct { // next blocks until candidate index i's funding-transaction lookup // completes and returns its result. Callers MUST consume indexes in -// increasing order (0, 1, 2, ...) -- the same oldest-first order -// candidates was given to fetchReservationAcceptanceFundingTxs in. Because -// each candidate's result is delivered on its own per-index channel, a -// later candidate's fetch racing ahead and completing first can never be -// mistaken for an earlier candidate's result. +// increasing order (0, 1, 2, ...) -- the same order candidates was given +// to fetchReservationAcceptanceFundingTxs in. Because each candidate's +// result is delivered on its own per-index channel, a later candidate's +// fetch racing ahead and completing first can never be mistaken for an +// earlier candidate's result. func (p *reservationAcceptanceFundingTxPipeline) next( i int, ) reservationAcceptanceFundingTxLookup { @@ -938,22 +1016,17 @@ func (p *reservationAcceptanceFundingTxPipeline) stop() { // fetchReservationAcceptanceFundingTxs starts looking up the funding // transaction and its confirmation count for every candidate in -// candidates, in oldest-first order, and returns a +// candidates, in order, and returns a // reservationAcceptanceFundingTxPipeline the caller pulls results from one // index at a time via next(). At most // reservationAcceptanceFundingTxLookupWorkers lookups ever run -// concurrently -- a strict cap, exactly as the previous eager -// implementation enforced with its shared worker goroutines. This is a -// bound on concurrency, not on the total number of Bitcoin RPCs issued -// over the pipeline's lifetime: unlike that implementation, which -// launched every candidate's fetch before the caller could inspect any -// result, fetches here are dispatched lazily and the dispatcher only -// stops handing out new candidates once the caller calls the returned -// pipeline's stop() (see findReservationAcceptanceCandidate, which does -// so via defer once it either finds a fully eligible candidate or -// exhausts every candidate). Because the dispatcher waits on stop(), not -// on the caller actually consuming each result via next(), a caller whose -// own per-candidate work (e.g. Ethereum round-trips) is slower than the +// concurrently. This is a bound on concurrency, not on the total number +// of Bitcoin RPCs issued over the pipeline's lifetime: fetches are +// dispatched lazily and the dispatcher only stops handing out new +// candidates once the caller calls the returned pipeline's stop() (Run +// does so via defer). Because the dispatcher waits on stop(), not on the +// caller actually consuming each result via next(), a caller whose own +// per-candidate work (e.g. proposal validation) is slower than the // Bitcoin lookups can still see up to every one of // maxReservationAcceptanceCandidatesPerRun (50) pending candidates // dispatched before stop() is observed; the early-stop savings this @@ -981,11 +1054,9 @@ func (rat *ReservationAcceptanceTask) fetchReservationAcceptanceFundingTxs( } dispatchSlots := make(chan struct{}, workers) - // The dispatcher below walks candidates in their given (oldest-first) - // order, acquiring a dispatchSlots slot before starting each one's - // fetch, so at most `workers` fetches ever run concurrently -- a - // strict bound, the same one the previous eager implementation - // enforced with its shared worker goroutines. It gives stop() + // The dispatcher below walks candidates in their given order, + // acquiring a dispatchSlots slot before starting each one's fetch, so + // at most `workers` fetches ever run concurrently. It gives stop() // priority over dispatching another candidate: it checks // pipeline.stopCh non-blockingly before attempting to acquire a slot, // and returns immediately without acquiring or launching if stop has @@ -1046,52 +1117,30 @@ func (rat *ReservationAcceptanceTask) fetchReservationAcceptanceFundingTxs( return pipeline } -// reservationAcceptancePreWriteError wraps a proposeReservationAcceptance -// failure that occurred before any chain-state-mutating call. The pending -// acceptance generation this proposal consumes was created on-chain by -// the deposit's own depositor, not by this task, so candidate selection -// and proposal building are read-only: an assemble or validation failure -// can be retried on the next window and can never leave a partial -// on-chain effect. The caller (Run) skips the doomed deposit in favor of -// the next candidate instead of aborting the whole coordination window. -type reservationAcceptancePreWriteError struct { - err error -} - -func (e *reservationAcceptancePreWriteError) Error() string { - return e.err.Error() -} - -func (e *reservationAcceptancePreWriteError) Unwrap() error { - return e.err -} - +// proposeReservationAcceptance assembles and validates the anchor proposal +// for an already selected candidate. The pending acceptance generation the +// proposal consumes was created on-chain by the deposit's own depositor +// (the only caller permitted to request acceptance), so this builder +// performs no chain-state-mutating call and any error leaves nothing +// behind; Run skips the candidate on error. func (rat *ReservationAcceptanceTask) proposeReservationAcceptance( taskLogger log.StandardLogger, walletPublicKeyHash [20]byte, candidate *reservationAcceptanceCandidate, -) (*tbtc.ReservationAnchorProposal, bool, error) { +) (*tbtc.ReservationAnchorProposal, error) { if candidate == nil || candidate.Deposit == nil { - return nil, false, fmt.Errorf("candidate is required") + return nil, fmt.Errorf("candidate is required") } taskLogger.Infof("preparing a reservation acceptance proposal") - // The anchor fee and its net-of-fee viability were already computed and - // validated during candidate selection in findReservationAcceptanceCandidate; - // re-checking here (after exactly one candidate has already been chosen) - // would abort this Run() outright on failure instead of trying the next - // candidate, causing the same doomed deposit to be re-selected on every - // subsequent Run() until it aged out of the look-back window. + // The anchor fee and its net-of-fee viability were already computed + // and validated during candidate selection, so a doomed candidate is + // skipped there in favor of the next one. anchorFee := candidate.AnchorFee taskLogger.Infof("anchor transaction fee: [%d]", anchorFee) - // The pending acceptance generation this proposal consumes was already - // created on-chain by the deposit's own depositor (the only caller - // permitted to request acceptance); candidate selection read it, so - // this builder performs no chain-state-mutating call of its own and - // its validation is a clean pre-write check. feeBoundAction := &tbtc.ReservationAction{ TxMaxFee: candidate.TxMaxFee, } @@ -1103,12 +1152,10 @@ func (rat *ReservationAcceptanceTask) proposeReservationAcceptance( feeBoundAction, anchorFee, ); err != nil { - return nil, false, &reservationAcceptancePreWriteError{ - err: fmt.Errorf( - "cannot assemble reservation anchor transaction: [%v]", - err, - ), - } + return nil, fmt.Errorf( + "cannot assemble reservation anchor transaction: [%v]", + err, + ) } proposal := &tbtc.ReservationAnchorProposal{ @@ -1131,15 +1178,13 @@ func (rat *ReservationAcceptanceTask) proposeReservationAcceptance( FundingTx: candidate.FundingTx, }, ); err != nil { - return nil, false, &reservationAcceptancePreWriteError{ - err: fmt.Errorf( - "failed to verify reservation anchor proposal: %v", - err, - ), - } + return nil, fmt.Errorf( + "failed to verify reservation anchor proposal: %v", + err, + ) } - return proposal, true, nil + return proposal, nil } func estimateReservationAcceptanceFee( diff --git a/pkg/tbtcpg/reservation_acceptance_export_test.go b/pkg/tbtcpg/reservation_acceptance_export_test.go new file mode 100644 index 0000000000..b464d91d80 --- /dev/null +++ b/pkg/tbtcpg/reservation_acceptance_export_test.go @@ -0,0 +1,11 @@ +package tbtcpg + +// SetMetricsRecorderForTest exposes setMetricsRecorder to the external +// tbtcpg_test package, whose acceptance fixtures build full candidates. +func (rat *ReservationAcceptanceTask) SetMetricsRecorderForTest( + recorder interface { + SetGauge(name string, value float64) + }, +) { + rat.setMetricsRecorder(recorder) +} diff --git a/pkg/tbtcpg/reservation_acceptance_metrics_test.go b/pkg/tbtcpg/reservation_acceptance_metrics_test.go index c95e255198..b9aa58584f 100644 --- a/pkg/tbtcpg/reservation_acceptance_metrics_test.go +++ b/pkg/tbtcpg/reservation_acceptance_metrics_test.go @@ -61,31 +61,6 @@ func TestReservationAcceptanceTask_RecordsSaturationGauges(t *testing.T) { // distinguish a real wired value from a coincidental zero default. lc.SetWalletReservations(walletPublicKeyHash, []*big.Int{big.NewInt(1), big.NewInt(2)}) - // Run scans PastDepositRevealedEvents with a filter bounded by - // ReservationAcceptanceLookBackBlocks; register an empty match so the - // call succeeds and Run proceeds to (correctly) report no candidate, - // rather than erroring on an unregistered filter. - currentBlock := uint64(300000) - filterStartBlock := currentBlock - ReservationAcceptanceLookBackBlocks - if err := lc.AddPastDepositRevealedEvent( - &tbtc.DepositRevealedEventFilter{ - StartBlock: filterStartBlock, - EndBlock: ¤tBlock, - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - }, - &tbtc.DepositRevealedEvent{ - // Targets a different vault so it is filtered out immediately - // without needing a matching deposit request/funding tx. - BlockNumber: filterStartBlock, - WalletPublicKeyHash: walletPublicKeyHash, - Vault: &[]chain.Address{chain.Address( - "0xOtherVaultAddress1234567890abcdef123456789012", - )}[0], - }, - ); err != nil { - t.Fatal(err) - } - task := NewReservationAcceptanceTask(lc, btcChain) recorder := newFakeMetricsRecorder() task.setMetricsRecorder(recorder) @@ -253,7 +228,7 @@ func TestReservationAcceptanceTask_VaultFeeReadErrorKeepsGauges(t *testing.T) { // TestReservationAcceptanceTask_ZeroVaultAddressZeroesFeeGauges asserts // an unconfigured (zero-address) reservation vault short-circuits -// findReservationAcceptanceCandidate before any fee-gauge read chain +// findReservationAcceptanceCandidates before any fee-gauge read chain // call (the wrapper's nonzero fee values below are tripwires: // publishing them would mean the fee methods ran on the unconfigured // path), while the two fee gauges are still published as zero: a @@ -378,9 +353,9 @@ func TestReservationAcceptanceTask_UnconfiguredVaultZeroesFeeGauges(t *testing.T } // runFeeGaugeFixture configures a LocalChain with a live wallet, a -// configured (non-zero) reservation vault, a block counter, and one -// DepositRevealed event targeting a different vault so the scan -// completes with no candidate. Shared by the fee-gauge tests. +// configured (non-zero) reservation vault and a block counter. The +// embedded LocalChain reports no acceptance requests, so Run completes +// with no candidate. Shared by the fee-gauge tests. func runFeeGaugeFixture(t *testing.T, lc *LocalChain) { t.Helper() @@ -401,30 +376,11 @@ func runFeeGaugeFixture(t *testing.T, lc *LocalChain) { blockCounter := NewMockBlockCounter() blockCounter.SetCurrentBlock(300000) lc.SetBlockCounter(blockCounter) - - currentBlock := uint64(300000) - filterStartBlock := currentBlock - ReservationAcceptanceLookBackBlocks - if err := lc.AddPastDepositRevealedEvent( - &tbtc.DepositRevealedEventFilter{ - StartBlock: filterStartBlock, - EndBlock: ¤tBlock, - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - }, - &tbtc.DepositRevealedEvent{ - BlockNumber: filterStartBlock, - WalletPublicKeyHash: walletPublicKeyHash, - Vault: &[]chain.Address{chain.Address( - "0xOtherVaultAddress1234567890abcdef123456789012", - )}[0], - }, - ); err != nil { - t.Fatal(err) - } } // runZeroVaultFixture configures a LocalChain with a live wallet and a // zero-address reservation vault, so -// findReservationAcceptanceCandidate returns at the "reservation +// findReservationAcceptanceCandidates returns at the "reservation // vault not configured" gate before any chain read (including the // fee reads) or any gauge publication. func runZeroVaultFixture(t *testing.T, lc *LocalChain) { @@ -446,7 +402,7 @@ func runZeroVaultFixture(t *testing.T, lc *LocalChain) { // through Run(), so these tests can hold each candidate's Bitcoin RPCs // under precise, deterministic control (artificial concurrency tracking, // artificial per-candidate latency, and a context that is never -// resolved) without depending on findReservationAcceptanceCandidate's +// resolved) without depending on findReservationAcceptanceCandidates' // unrelated eligibility gates. // concurrencyTrackingBTCChain wraps LocalBitcoinChain and records, across @@ -637,9 +593,9 @@ func (c *latencyBTCChain) GetTransaction( // TestFetchReservationAcceptanceFundingTxs_OldestFirstOrderingPreserved is // a regression test asserting that next(i) always returns candidate i's -// own lookup result, even when a later (in oldest-first order) +// own lookup result, even when a later (in candidate order) // candidate's fetch races ahead and completes first -- the property -// findReservationAcceptanceCandidate's oldest-first selection loop +// Run's in-order candidate loop // depends on to return the correct candidate regardless of which // underlying network call happens to finish first. func TestFetchReservationAcceptanceFundingTxs_OldestFirstOrderingPreserved(t *testing.T) { @@ -725,7 +681,7 @@ func (c *countingBTCChain) callCount() int { // TestFetchReservationAcceptanceFundingTxs_StopsAfterEarlyMatch is a // regression test for the eager-fetch cost the PR-4324 review flagged: a -// caller that only needs the oldest-first candidate's result and then +// caller that only needs the first candidate's result and then // calls stop() must not cause every remaining candidate's Bitcoin RPCs to // be dispatched, even with a full maxReservationAcceptanceCandidatesPerRun // backlog pending. Before the fix, this scenario issued a GetTransaction @@ -791,157 +747,3 @@ func TestFetchReservationAcceptanceFundingTxs_StopsAfterEarlyMatch(t *testing.T) ) } } - -// activeReservationsCapChain wraps LocalChain, overriding -// ActiveReservationsCount to report a usable (count, cap) pair. The -// embedded LocalChain's own ActiveReservationsCount always reports (0, 0), -// and checkReservationAcceptanceEligibility fails closed whenever -// maxActiveReservations is 0 (treating it as "not configured" rather than -// unlimited), so no candidate can ever pass eligibility against the bare -// fixture -- this override is required for any test that needs -// findReservationAcceptanceCandidate to actually find a candidate. -type activeReservationsCapChain struct { - *LocalChain -} - -func (c *activeReservationsCapChain) ActiveReservationsCount() (uint32, uint32, error) { - return 0, 1000, nil -} - -// TestFindReservationAcceptanceCandidate_SkippedCandidatesDoNotConsumeCap -// is a regression test for the cap-vs-skip ordering bug the PR-4324 review -// flagged: candidatesExamined used to be incremented before the -// skipDepositKeys check, so a deposit already rejected earlier in the same -// Run() call -- and therefore present in skipDepositKeys on Run's retry -- -// still consumed a slot of the maxReservationAcceptanceCandidatesPerRun -// budget even though it was never actually re-examined this pass. This -// registers exactly maxReservationAcceptanceCandidatesPerRun already- -// skipped candidates ahead of one genuinely eligible candidate, in -// oldest-first order: under the pre-fix ordering the cap would be -// exhausted by the skipped candidates alone, and the eligible one -- which -// sorts after all of them -- would never be reached. Under the fix, -// skipped candidates cost nothing against the cap and the eligible one is -// found. -func TestFindReservationAcceptanceCandidate_SkippedCandidatesDoNotConsumeCap(t *testing.T) { - lc := &activeReservationsCapChain{LocalChain: NewLocalChain()} - btcChain := NewLocalBitcoinChain() - btcChain.SetEstimateSatPerVByteFee(1, 1) - - walletPublicKeyHash := [20]byte{9, 8, 7, 6, 5, 4, 3, 2, 1} - vault := chain.Address("0xReservationVaultAddress1234567890abcdef12345678") - - lc.SetReservationParameters(tbtc.ReservationParameters{ - ReservationVault: vault, - ReservationMinAmount: 1000, - ReservationTxMaxFee: 5000, - MaxReservationsPerWallet: 5, - ReservationMaxTotalAmount: 100000000, - }) - lc.SetWallet(walletPublicKeyHash, &tbtc.WalletChainData{State: tbtc.StateLive}) - lc.SetDepositMinAge(3600) - - blockCounter := NewMockBlockCounter() - blockCounter.SetCurrentBlock(300000) - lc.SetBlockCounter(blockCounter) - - currentBlock := uint64(300000) - filterStartBlock := currentBlock - ReservationAcceptanceLookBackBlocks - filter := &tbtc.DepositRevealedEventFilter{ - StartBlock: filterStartBlock, - EndBlock: ¤tBlock, - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - } - - // maxReservationAcceptanceCandidatesPerRun candidates, all pre-marked - // as already skipped and given the earliest block numbers, so - // oldest-first order walks every one of them before the eligible - // candidate registered below. - skipDepositKeys := make(map[string]bool) - for i := range maxReservationAcceptanceCandidatesPerRun { - fundingTxHash := bitcoin.Hash{byte(i)} - if err := lc.AddPastDepositRevealedEvent(filter, &tbtc.DepositRevealedEvent{ - BlockNumber: filterStartBlock + uint64(i), - WalletPublicKeyHash: walletPublicKeyHash, - Vault: &vault, - FundingTxHash: fundingTxHash, - FundingOutputIndex: 0, - }); err != nil { - t.Fatal(err) - } - depositKey := lc.BuildDepositKey(fundingTxHash, 0) - skipDepositKeys[depositKey.Text(16)] = true - } - - // One genuinely eligible candidate, given the latest block number so - // it is walked last, after every skipped candidate above. - eligibleFundingTxHash := bitcoin.Hash{0xEE} - eligibleAmount := uint64(2000000) - if err := lc.AddPastDepositRevealedEvent(filter, &tbtc.DepositRevealedEvent{ - BlockNumber: filterStartBlock + uint64(maxReservationAcceptanceCandidatesPerRun), - WalletPublicKeyHash: walletPublicKeyHash, - Vault: &vault, - FundingTxHash: eligibleFundingTxHash, - FundingOutputIndex: 0, - }); err != nil { - t.Fatal(err) - } - lc.SetDepositRequest(eligibleFundingTxHash, 0, &tbtc.DepositChainRequest{ - Amount: eligibleAmount, - RevealedAt: time.Now().Add(-2 * time.Hour), - SweptAt: time.Unix(0, 0), - Vault: &vault, - }) - btcChain.SetTransaction(eligibleFundingTxHash, &bitcoin.Transaction{}) - btcChain.SetTransactionConfirmations( - eligibleFundingTxHash, - tbtc.DepositSweepRequiredFundingTxConfirmations, - ) - eligibleDepositKey := lc.BuildDepositKey(eligibleFundingTxHash, 0) - lc.SetReservation(eligibleDepositKey, &tbtc.Reservation{ - State: tbtc.ReservationStateUnknown, - RequestNonce: 0, - }) - // Seed a Pending Acceptance action at nonce 0 targeting the wallet - // so the candidate clears findReservationAcceptanceCandidate's - // action-record gate -- the precondition the production validator - // enforces -- and the cap-vs-skip ordering being tested is the only - // thing that can keep this candidate from being returned. - lc.SetReservationAction(eligibleDepositKey, 0, &tbtc.ReservationAction{ - ActionType: tbtc.ReservationActionTypeAcceptance, - State: tbtc.ReservationActionStatePending, - TargetWalletPublicKeyHash: walletPublicKeyHash, - TxMaxFee: 5000, - MinAmount: 1000, - TermSeconds: 86400, - // Far-future TimeoutAt so the validator's timeout safety-margin - // gate (REQUEST_TIMEOUT_SAFETY_MARGIN, 2 hours) does not skip - // this candidate; the cap-vs-skip ordering is the only thing - // under test here. - TimeoutAt: uint32(time.Now().Add(24 * time.Hour).Unix()), - }) - - task := NewReservationAcceptanceTask(lc, btcChain) - - candidate, err := task.findReservationAcceptanceCandidate( - logger, - walletPublicKeyHash, - skipDepositKeys, - ) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if candidate == nil { - t.Fatal( - "expected the eligible candidate past the skipped window to " + - "be found; got nil, which means the skipped candidates " + - "consumed the per-run cap before it was ever examined", - ) - } - if candidate.DepositKey.Cmp(eligibleDepositKey) != 0 { - t.Errorf( - "expected the found candidate's deposit key to be [%v], got [%v]", - eligibleDepositKey, - candidate.DepositKey, - ) - } -} diff --git a/pkg/tbtcpg/reservation_acceptance_test.go b/pkg/tbtcpg/reservation_acceptance_test.go index 60240f3081..23080c06c5 100644 --- a/pkg/tbtcpg/reservation_acceptance_test.go +++ b/pkg/tbtcpg/reservation_acceptance_test.go @@ -3,7 +3,7 @@ package tbtcpg_test import ( "crypto/ecdsa" "crypto/rand" - "crypto/sha256" + "encoding/binary" "fmt" "math/big" "testing" @@ -33,56 +33,146 @@ const testReservationVaultAddress = chain.Address( "0xReservationVaultAddress1234567890abcdef12345678", ) +// testDepositor is the depositor of every deposit built by this file's +// fixtures. +const testDepositor = chain.Address("934b98637ca318a4d6e7ca6ffd1690b8e77df637") + +// testReservationTermSeconds is the snapshotted custody term of the +// fixtures' pending acceptance generations: MIN_RESERVATION_TERM (90 +// days), the shortest term Reservation.sol accepts. +const testReservationTermSeconds = uint32(90 * 24 * 60 * 60) + +// testDepositMinAgeSeconds is WalletProposalValidator.sol's DEPOSIT_MIN_AGE +// (2 hours). +const testDepositMinAgeSeconds = uint32(7200) + // reservationAcceptanceLocalChain is a test-only mock of tbtcpg.Chain that // embeds the production LocalChain and adds reservation-specific behavior. -// It exists as a separate type so this test file does not need to edit the -// shared chain_test.go fixture used by sibling builders. +// Its PastDepositRevealedEvents and PastReservationAcceptanceRequestedEvents +// answer range queries the way an Ethereum node does, from events +// registered on this type. type reservationAcceptanceLocalChain struct { *tbtcpg.LocalChain - maxPerWalletAmount uint64 - maxSingleAmount uint64 - walletReservationsAmount uint64 - walletReservationsCount uint32 - activeCount uint32 - maxActive uint32 - pendingReserved uint64 - validateErr error - getWalletErr error - getReservationErr error - acceptanceEvents []*tbtc.ReservationAcceptanceRequestedEvent - acceptanceEventsErr error + maxPerWalletAmount uint64 + maxSingleAmount uint64 + walletReservationsAmount uint64 + walletReservationsCount uint32 + activeCount uint32 + maxActive uint32 + pendingReserved uint64 + validateErr error + getWalletErr error + getReservationErr error + + acceptanceEvents []*tbtc.ReservationAcceptanceRequestedEvent + acceptanceEventsErr error + acceptanceEventFilters []tbtc.ReservationAcceptanceRequestedEventFilter + + revealEvents []*tbtc.DepositRevealedEvent pastDepositRevealedEventsErr error + + getReservationCalls int + validateCalls int + feeDebtVaults []chain.Address + feeReserveVaults []chain.Address } func newReservationAcceptanceLocalChain() *reservationAcceptanceLocalChain { - lc := tbtcpg.NewLocalChain() return &reservationAcceptanceLocalChain{ - LocalChain: lc, + LocalChain: tbtcpg.NewLocalChain(), } } -// PastDepositRevealedEvents overrides the embedded LocalChain -// implementation to narrow its "no events for given filter" sentinel -// error (the mock's signal for "nothing registered for this filter yet") -// into an empty slice, matching a real chain's behavior of returning an -// empty event list rather than an error when no deposits match. Any -// other error - including one injected via pastDepositRevealedEventsErr - -// is propagated unchanged. +// PastDepositRevealedEvents returns every registered reveal whose block is +// in [StartBlock, EndBlock] and whose wallet matches the filter. It +// rejects unbounded queries and queries wider than +// tbtc.DepositRevealLookupChunkBlocks, the range many providers cap log +// queries at, so a lookup that stops chunking fails loudly. func (ralc *reservationAcceptanceLocalChain) PastDepositRevealedEvents( filter *tbtc.DepositRevealedEventFilter, ) ([]*tbtc.DepositRevealedEvent, error) { if ralc.pastDepositRevealedEventsErr != nil { return nil, ralc.pastDepositRevealedEventsErr } - events, err := ralc.LocalChain.PastDepositRevealedEvents(filter) - if err != nil { - if err.Error() == "no events for given filter" { - return []*tbtc.DepositRevealedEvent{}, nil + if filter == nil || filter.EndBlock == nil { + return nil, fmt.Errorf("unbounded deposit revealed events query") + } + if *filter.EndBlock < filter.StartBlock || + *filter.EndBlock-filter.StartBlock+1 > tbtc.DepositRevealLookupChunkBlocks { + return nil, fmt.Errorf( + "invalid deposit revealed events block range [%d, %d]", + filter.StartBlock, + *filter.EndBlock, + ) + } + + var result []*tbtc.DepositRevealedEvent + for _, event := range ralc.revealEvents { + if event.BlockNumber < filter.StartBlock || + event.BlockNumber > *filter.EndBlock { + continue } - return nil, err + if !walletMatches(filter.WalletPublicKeyHash, event.WalletPublicKeyHash) { + continue + } + result = append(result, event) + } + return result, nil +} + +// PastReservationAcceptanceRequestedEvents returns every registered request +// matching the filter's block range, wallets and reservation keys, and +// records the filter. +func (ralc *reservationAcceptanceLocalChain) PastReservationAcceptanceRequestedEvents( + filter *tbtc.ReservationAcceptanceRequestedEventFilter, +) ([]*tbtc.ReservationAcceptanceRequestedEvent, error) { + if ralc.acceptanceEventsErr != nil { + return nil, ralc.acceptanceEventsErr + } + if filter == nil { + return nil, fmt.Errorf("unfiltered acceptance requested events query") + } + ralc.acceptanceEventFilters = append(ralc.acceptanceEventFilters, *filter) + + var results []*tbtc.ReservationAcceptanceRequestedEvent + for _, event := range ralc.acceptanceEvents { + if event.BlockNumber < filter.StartBlock { + continue + } + if filter.EndBlock != nil && event.BlockNumber > *filter.EndBlock { + continue + } + if !walletMatches(filter.WalletPublicKeyHash, event.WalletPublicKeyHash) { + continue + } + if len(filter.ReservationKey) > 0 { + match := false + for _, k := range filter.ReservationKey { + if k != nil && k.Cmp(event.ReservationKey) == 0 { + match = true + break + } + } + if !match { + continue + } + } + results = append(results, event) + } + return results, nil +} + +func walletMatches(filter [][20]byte, wallet [20]byte) bool { + if len(filter) == 0 { + return true } - return events, nil + for _, w := range filter { + if w == wallet { + return true + } + } + return false } func (ralc *reservationAcceptanceLocalChain) ReservationCaps() ( @@ -129,27 +219,21 @@ func (ralc *reservationAcceptanceLocalChain) GetWallet( return ralc.LocalChain.GetWallet(walletPublicKeyHash) } -// GetReservation delegates to the embedded LocalChain, except that a -// non-nil getReservationErr is consumed exactly once: it fires on the -// very next call and then clears itself, simulating a transient RPC -// failure rather than a permanent one. This lets -// TestReservationAcceptanceTask_GetReservationError exercise production's -// fail-safe candidate-selection skip path (see production's documented -// deviation above the call site): the failing candidate is skipped for -// the window rather than treated as "not yet created". +// GetReservation delegates to the embedded LocalChain and counts calls, +// except that a non-nil getReservationErr is consumed exactly once: it +// fires on the very next call and then clears itself, simulating a +// transient RPC failure rather than a permanent one. // // The embedded LocalChain.GetReservation errors for a reservation key that // was never registered via SetReservation, but the real chain adapter -// (pkg/chain/ethereum/tbtc_reservation.go's GetReservation) reads a Solidity mapping, -// which never errors for an absent key -- it returns the zero-value -// struct (State == ReservationStateUnknown, RequestNonce == 0). This -// override normalizes the embedded mock's "not found" error into that -// same zero-value record so every other test in this file (none of which -// pre-register a reservation for a brand-new candidate deposit) continues -// to exercise the "not yet created" path production actually takes. +// reads a Solidity mapping, which never errors for an absent key -- it +// returns the zero-value struct (State == ReservationStateUnknown, +// RequestNonce == 0). This override normalizes the embedded mock's "not +// found" error into that same zero-value record. func (ralc *reservationAcceptanceLocalChain) GetReservation( reservationKey *big.Int, ) (*tbtc.Reservation, error) { + ralc.getReservationCalls++ if ralc.getReservationErr != nil { err := ralc.getReservationErr ralc.getReservationErr = nil @@ -165,12 +249,12 @@ func (ralc *reservationAcceptanceLocalChain) GetReservation( return reservation, nil } -// ValidateReservationAnchorProposal overrides the embedded LocalChain -// implementation. When validateErr is set it returns that error -// unconditionally (see TestReservationAcceptanceTask_ValidateProposalError). -// Otherwise it genuinely exercises the candidate-deposit mapping step by -// checking the proposal's funding outpoint against the candidate deposit's -// own funding outpoint, rather than unconditionally succeeding. +// ValidateReservationAnchorProposal counts calls and returns validateErr +// when set. Otherwise it checks the proposal's funding outpoint against +// the candidate deposit's own outpoint (the candidate-deposit mapping +// step) and then applies the embedded LocalChain's strict validator, +// which mirrors every precondition of +// WalletProposalValidator.validateReservationAnchorProposal. func (ralc *reservationAcceptanceLocalChain) ValidateReservationAnchorProposal( walletPublicKeyHash [20]byte, proposal *tbtc.ReservationAnchorProposal, @@ -179,6 +263,7 @@ func (ralc *reservationAcceptanceLocalChain) ValidateReservationAnchorProposal( FundingTx *bitcoin.Transaction }, ) error { + ralc.validateCalls++ if ralc.validateErr != nil { return ralc.validateErr } @@ -190,53 +275,256 @@ func (ralc *reservationAcceptanceLocalChain) ValidateReservationAnchorProposal( ) } outpoint := depositExtraInfo.Deposit.Utxo.Outpoint - if outpoint.TransactionHash != proposal.DepositFundingTxHash { - return fmt.Errorf( - "validate reservation anchor proposal: funding tx hash mismatch: "+ - "proposal=[%x] candidate=[%x]", - proposal.DepositFundingTxHash, - outpoint.TransactionHash, - ) - } - if outpoint.OutputIndex != proposal.DepositFundingOutputIndex { + if outpoint.TransactionHash != proposal.DepositFundingTxHash || + outpoint.OutputIndex != proposal.DepositFundingOutputIndex { return fmt.Errorf( - "validate reservation anchor proposal: funding output index mismatch: "+ - "proposal=[%d] candidate=[%d]", - proposal.DepositFundingOutputIndex, - outpoint.OutputIndex, + "validate reservation anchor proposal: funding outpoint mismatch", ) } - return nil + return ralc.LocalChain.ValidateReservationAnchorProposal( + walletPublicKeyHash, + proposal, + depositExtraInfo, + ) } -func (ralc *reservationAcceptanceLocalChain) PastReservationAcceptanceRequestedEvents( - filter *tbtc.ReservationAcceptanceRequestedEventFilter, -) ([]*tbtc.ReservationAcceptanceRequestedEvent, error) { - if ralc.acceptanceEventsErr != nil { - return nil, ralc.acceptanceEventsErr + +// ReservationVaultFeeDebtSat records the vault it was asked about and +// delegates to the embedded LocalChain. +func (ralc *reservationAcceptanceLocalChain) ReservationVaultFeeDebtSat( + reservationVault chain.Address, +) (uint64, error) { + ralc.feeDebtVaults = append(ralc.feeDebtVaults, reservationVault) + return ralc.LocalChain.ReservationVaultFeeDebtSat(reservationVault) +} + +// ReservationVaultFeeReserveTbtcBaseUnits records the vault it was asked +// about and delegates to the embedded LocalChain. +func (ralc *reservationAcceptanceLocalChain) ReservationVaultFeeReserveTbtcBaseUnits( + reservationVault chain.Address, +) (*big.Int, error) { + ralc.feeReserveVaults = append(ralc.feeReserveVaults, reservationVault) + return ralc.LocalChain.ReservationVaultFeeReserveTbtcBaseUnits(reservationVault) +} + +// futureRefundLocktime returns a refund locktime 180 days ahead, +// little-endian as the deposit script and the on-chain validator hold it, +// so the validator's 24-hour refund safety margin holds. +func futureRefundLocktime() [4]byte { + var locktime [4]byte + binary.LittleEndian.PutUint32( + locktime[:], + uint32(time.Now().Add(180*24*time.Hour).Unix()), + ) + return locktime +} + +// buildReservedDeposit builds a deposit revealed to walletPublicKeyHash +// through the test reservation vault, together with a funding transaction +// whose output 0 locks amount with the deposit's P2WSH script. seed makes +// the deposit, and so its funding transaction hash, unique. +func buildReservedDeposit( + t *testing.T, + walletPublicKeyHash [20]byte, + amount uint64, + seed byte, + vault chain.Address, +) (*tbtc.Deposit, *bitcoin.Transaction) { + t.Helper() + + deposit := &tbtc.Deposit{ + Depositor: testDepositor, + BlindingFactor: [8]byte{seed, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}, + WalletPublicKeyHash: walletPublicKeyHash, + RefundPublicKeyHash: [20]byte{0x02, seed}, + RefundLocktime: futureRefundLocktime(), + Vault: &vault, } - var results []*tbtc.ReservationAcceptanceRequestedEvent - for _, event := range ralc.acceptanceEvents { - if filter != nil && len(filter.ReservationKey) > 0 { - match := false - for _, k := range filter.ReservationKey { - if k != nil && event.ReservationKey != nil && k.Cmp(event.ReservationKey) == 0 { - match = true - break - } - } - if !match { - continue - } - } - results = append(results, event) + + depositScript, err := deposit.Script() + if err != nil { + t.Fatal(err) } - return results, nil + depositLockingScript, err := bitcoin.PayToWitnessScriptHash( + bitcoin.WitnessScriptHash(depositScript), + ) + if err != nil { + t.Fatal(err) + } + + fundingTx := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x09, seed}, + OutputIndex: 0, + }, + Sequence: 0xffffffff, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: int64(amount), + PublicKeyScript: depositLockingScript, + }}, + } + + deposit.Utxo = &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTx.Hash(), + OutputIndex: 0, + }, + Value: int64(amount), + } + + return deposit, fundingTx } -func (ralc *reservationAcceptanceLocalChain) AddPastReservationAcceptanceRequestedEvent( - event *tbtc.ReservationAcceptanceRequestedEvent, -) { - ralc.acceptanceEvents = append(ralc.acceptanceEvents, event) +// pendingDepositOptions describes a reserved deposit whose depositor +// requested acceptance, as addPendingDeposit seeds it. +type pendingDepositOptions struct { + amount uint64 + seed byte + revealBlock uint64 + requestBlock uint64 + revealedAt time.Time + confirmations uint + requestNonce uint64 + txMaxFee uint64 + minAmount uint64 + termSeconds uint32 + timeoutAt uint32 + actionState tbtc.ReservationActionState + // withoutAction seeds the request event but no reservation or + // action record, as when the generation's record is gone. + withoutAction bool + // revealVault, when set, overrides the vault in the reveal event. + revealVault *chain.Address +} + +// defaultPendingDepositOptions returns options for a mature, confirmed +// 2,000,000 sat deposit revealed 1000 blocks and requested 10 blocks +// before currentBlock, whose nonce-1 Pending Acceptance generation +// snapshots a 5000 sat max fee, a 1000 sat minimum, a 90-day term and a +// timeout 24 hours ahead. +func defaultPendingDepositOptions(currentBlock uint64) pendingDepositOptions { + return pendingDepositOptions{ + amount: 2000000, + seed: 0x01, + revealBlock: currentBlock - 1000, + requestBlock: currentBlock - 10, + revealedAt: time.Now().Add(-3 * time.Hour), + confirmations: tbtc.DepositSweepRequiredFundingTxConfirmations, + requestNonce: 1, + txMaxFee: 5000, + minAmount: 1000, + termSeconds: testReservationTermSeconds, + timeoutAt: uint32(time.Now().Add(24 * time.Hour).Unix()), + actionState: tbtc.ReservationActionStatePending, + } +} + +// pendingDeposit is a deposit seeded by addPendingDeposit. +type pendingDeposit struct { + fundingTxHash bitcoin.Hash + depositKey *big.Int + deposit *tbtc.Deposit +} + +// addPendingDeposit seeds everything the chain and the Bitcoin chain hold +// for a reserved deposit whose depositor requested acceptance by +// walletPublicKeyHash: the funding transaction and its confirmations, the +// deposit request, the reserved flag, the DepositRevealed event, the +// reservation and its acceptance action record, and the +// ReservationAcceptanceRequested event. +func addPendingDeposit( + t *testing.T, + ralc *reservationAcceptanceLocalChain, + btcChain *tbtcpg.LocalBitcoinChain, + walletPublicKeyHash [20]byte, + opts pendingDepositOptions, +) *pendingDeposit { + t.Helper() + + vault := testReservationVaultAddress + if params, err := ralc.ReservationParameters(); err == nil && + params.ReservationVault != "" { + vault = params.ReservationVault + } + + deposit, fundingTx := buildReservedDeposit( + t, + walletPublicKeyHash, + opts.amount, + opts.seed, + vault, + ) + fundingTxHash := fundingTx.Hash() + depositKey := ralc.BuildDepositKey(fundingTxHash, 0) + + btcChain.SetEstimateSatPerVByteFee(1, 1) + btcChain.SetTransaction(fundingTxHash, fundingTx) + btcChain.SetTransactionConfirmations(fundingTxHash, opts.confirmations) + + ralc.SetDepositRequest(fundingTxHash, 0, &tbtc.DepositChainRequest{ + Depositor: testDepositor, + Amount: opts.amount, + RevealedAt: opts.revealedAt, + SweptAt: time.Unix(0, 0), + Vault: &vault, + }) + ralc.SetReservedDeposit(depositKey, true) + + revealVault := &vault + if opts.revealVault != nil { + revealVault = opts.revealVault + } + ralc.revealEvents = append(ralc.revealEvents, &tbtc.DepositRevealedEvent{ + BlockNumber: opts.revealBlock, + WalletPublicKeyHash: walletPublicKeyHash, + FundingTxHash: fundingTxHash, + FundingOutputIndex: 0, + Depositor: testDepositor, + Amount: opts.amount, + BlindingFactor: deposit.BlindingFactor, + RefundPublicKeyHash: deposit.RefundPublicKeyHash, + RefundLocktime: deposit.RefundLocktime, + Vault: revealVault, + }) + + if !opts.withoutAction { + ralc.SetReservation(depositKey, &tbtc.Reservation{ + WalletPublicKeyHash: walletPublicKeyHash, + State: tbtc.ReservationStateUnknown, + RequestNonce: opts.requestNonce, + }) + ralc.SetReservationAction(depositKey, opts.requestNonce, &tbtc.ReservationAction{ + ActionType: tbtc.ReservationActionTypeAcceptance, + State: opts.actionState, + TargetWalletPublicKeyHash: walletPublicKeyHash, + TxMaxFee: opts.txMaxFee, + MinAmount: opts.minAmount, + TermSeconds: opts.termSeconds, + TimeoutAt: opts.timeoutAt, + Amount: opts.amount, + }) + } + + ralc.acceptanceEvents = append( + ralc.acceptanceEvents, + &tbtc.ReservationAcceptanceRequestedEvent{ + ReservationKey: depositKey, + RequestNonce: opts.requestNonce, + WalletPublicKeyHash: walletPublicKeyHash, + DepositAmount: opts.amount, + TxMaxFee: opts.txMaxFee, + TimeoutAt: opts.timeoutAt, + BlockNumber: opts.requestBlock, + }, + ) + + return &pendingDeposit{ + fundingTxHash: fundingTxHash, + depositKey: depositKey, + deposit: deposit, + } } // scenarioReservationAcceptanceChain wires a scenario's on-chain state @@ -261,6 +549,8 @@ func scenarioReservationAcceptanceChain( ReservationMaxTotalAmount: scenario.ReservationParameters.ReservationMaxTotalAmount, ReservationTotalAmount: scenario.ReservationParameters.ReservationTotalAmount, MaxReservationsPerWallet: scenario.ReservationParameters.MaxReservationsPerWallet, + ReservationTermSeconds: testReservationTermSeconds, + ReservationActionTimeout: 24 * 60 * 60, }) ralc.maxPerWalletAmount = scenario.Caps.MaxReservationsAmountPerWallet @@ -272,16 +562,21 @@ func scenarioReservationAcceptanceChain( ralc.pendingReserved = scenario.PendingReservedDeposits ralc.SetDepositMinAge(scenario.ChainParameters.DepositMinAge) + ralc.SetAverageBlockTime(scenario.ChainParameters.AverageBlockTime) blockCounter := tbtcpg.NewMockBlockCounter() blockCounter.SetCurrentBlock(scenario.ChainParameters.CurrentBlock) ralc.SetBlockCounter(blockCounter) - // Map a WalletState string back to the tbtc constant. + // Map a WalletState string back to the tbtc constant. An unknown + // string fails the test rather than silently running as another + // state. var walletState tbtc.WalletState switch scenario.WalletState { case "Live": walletState = tbtc.StateLive + case "MovingFunds": + walletState = tbtc.StateMovingFunds case "Closing": walletState = tbtc.StateClosing case "Closed": @@ -289,7 +584,7 @@ func scenarioReservationAcceptanceChain( case "Terminated": walletState = tbtc.StateTerminated default: - walletState = tbtc.StateLive + t.Fatalf("unknown scenario wallet state [%s]", scenario.WalletState) } ralc.SetWallet( @@ -301,27 +596,25 @@ func scenarioReservationAcceptanceChain( } // registerReservedDeposits wires the scenario's reserved deposits into the -// mock chain as deposit requests and past DepositRevealedEvents. Bitcoin -// transaction registrations live on the btcChain mock. +// mock chains: the funding transaction, the deposit request, the reserved +// flag, the DepositRevealed event and, for rows with a +// PendingAcceptanceAction, the reservation, the action record and the +// ReservationAcceptanceRequested event (requested 10 blocks after the +// reveal). It returns the map from each row's label hash to its real +// funding transaction hash. func registerReservedDeposits( t *testing.T, scenario *test.ReservationAcceptanceTestScenario, ralc *reservationAcceptanceLocalChain, btcChain *tbtcpg.LocalBitcoinChain, -) { +) map[bitcoin.Hash]bitcoin.Hash { t.Helper() - // Configure the fee oracle rate. proposeReservationAcceptance now - // estimates the anchor fee dynamically (see estimateReservationAcceptanceFee); - // 1 sat/vByte hits the applyWalletTxFeeFloor minimum, matching the - // convention used by the sibling reservation re-anchor test fixtures. + // proposeReservationAcceptance estimates the anchor fee dynamically; + // 1 sat/vByte hits the applyWalletTxFeeFloor minimum. btcChain.SetEstimateSatPerVByteFee(1, 1) - filterStartBlock := uint64(0) - if scenario.ChainParameters.CurrentBlock > tbtcpg.ReservationAcceptanceLookBackBlocks { - filterStartBlock = scenario.ChainParameters.CurrentBlock - - tbtcpg.ReservationAcceptanceLookBackBlocks - } + hashesByLabel := make(map[bitcoin.Hash]bitcoin.Hash) for _, rd := range scenario.ReservedDeposits { materialized, err := rd.Materialize() @@ -331,222 +624,90 @@ func registerReservedDeposits( err, ) } + hashesByLabel[materialized.LabelFundingTxHash] = materialized.FundingTxHash + + depositKey := ralc.BuildDepositKey( + materialized.FundingTxHash, + materialized.FundingOutputIndex, + ) ralc.SetDepositRequest( materialized.FundingTxHash, materialized.FundingOutputIndex, &tbtc.DepositChainRequest{ - Depositor: chain.Address(rd.Depositor), - Amount: rd.Amount, + Depositor: materialized.Depositor, + Amount: materialized.Amount, RevealedAt: materialized.RevealedAt, SweptAt: materialized.SweptAt, Vault: materialized.Vault, }, ) + ralc.SetReservedDeposit(depositKey, true) - // When the scenario seeds a Pending Acceptance action, also - // install the reservation record and the action record so - // findReservationAcceptanceCandidate's read of - // GetReservationAction(depositKey, reservation.RequestNonce) - // observes a Pending Acceptance action record targeting this - // wallet -- the precondition the production validator enforces. - // A reservation's State in production stays Unknown until - // settlement, so the seeded record follows that convention. - if rd.PendingAcceptanceAction != nil { - if pendingAction := rd.PendingAcceptanceActionValue(); pendingAction != nil { - depositKey := ralc.BuildDepositKey( - materialized.FundingTxHash, - materialized.FundingOutputIndex, - ) - ralc.SetReservation(depositKey, &tbtc.Reservation{ - WalletPublicKeyHash: materialized.WalletPublicKeyHash, - State: tbtc.ReservationStateUnknown, - RequestNonce: rd.PendingAcceptanceAction.RequestNonce, - }) - ralc.SetReservationAction( - depositKey, - rd.PendingAcceptanceAction.RequestNonce, - pendingAction, - ) - } - } - - if materialized.FundingTx != nil { - btcChain.SetTransaction( - materialized.FundingTxHash, - materialized.FundingTx, - ) - } else { - dummyTx := &bitcoin.Transaction{ - Outputs: []*bitcoin.TransactionOutput{{ - Value: 0, - PublicKeyScript: append([]byte{0x00, 0x20}, make([]byte, 32)...), - }}, - } - btcChain.SetTransaction( - materialized.FundingTxHash, - dummyTx, - ) - } + btcChain.SetTransaction( + materialized.FundingTxHash, + materialized.FundingTx, + ) btcChain.SetTransactionConfirmations( materialized.FundingTxHash, rd.FundingTxConfirmations, ) - currentBlock := scenario.ChainParameters.CurrentBlock - err = ralc.AddPastDepositRevealedEvent( - &tbtc.DepositRevealedEventFilter{ - StartBlock: filterStartBlock, - EndBlock: ¤tBlock, - WalletPublicKeyHash: [][20]byte{materialized.WalletPublicKeyHash}, - }, - &tbtc.DepositRevealedEvent{ - BlockNumber: materialized.RevealBlock, - WalletPublicKeyHash: materialized.WalletPublicKeyHash, - FundingTxHash: materialized.FundingTxHash, - FundingOutputIndex: materialized.FundingOutputIndex, - Vault: materialized.Vault, + ralc.revealEvents = append(ralc.revealEvents, &tbtc.DepositRevealedEvent{ + BlockNumber: materialized.RevealBlock, + WalletPublicKeyHash: materialized.WalletPublicKeyHash, + FundingTxHash: materialized.FundingTxHash, + FundingOutputIndex: materialized.FundingOutputIndex, + Depositor: materialized.Depositor, + Amount: materialized.Amount, + BlindingFactor: materialized.BlindingFactor, + RefundPublicKeyHash: materialized.RefundPublicKeyHash, + RefundLocktime: materialized.RefundLocktime, + Vault: materialized.Vault, + }) + + pendingAction := rd.PendingAcceptanceActionValue() + if pendingAction == nil { + continue + } + // A reservation's State in production stays Unknown until + // settlement, so the seeded record follows that convention. + ralc.SetReservation(depositKey, &tbtc.Reservation{ + WalletPublicKeyHash: materialized.WalletPublicKeyHash, + State: tbtc.ReservationStateUnknown, + RequestNonce: rd.PendingAcceptanceAction.RequestNonce, + }) + ralc.SetReservationAction( + depositKey, + rd.PendingAcceptanceAction.RequestNonce, + pendingAction, + ) + ralc.acceptanceEvents = append( + ralc.acceptanceEvents, + &tbtc.ReservationAcceptanceRequestedEvent{ + ReservationKey: depositKey, + RequestNonce: rd.PendingAcceptanceAction.RequestNonce, + WalletPublicKeyHash: pendingAction.TargetWalletPublicKeyHash, + DepositAmount: materialized.Amount, + TxMaxFee: pendingAction.TxMaxFee, + TimeoutAt: pendingAction.TimeoutAt, + BlockNumber: materialized.RevealBlock + 10, }, ) - if err != nil { - t.Fatalf( - "failed to register past deposit revealed event: [%v]", - err, - ) - } - } -} - -// setupEligibleDeposit registers an eligible deposit funding transaction, -// deposit request, and matching DepositRevealedEvent on the mock chains. -// It returns the funding transaction hash. -func setupEligibleDeposit( - t *testing.T, - ralc *reservationAcceptanceLocalChain, - btcChain *tbtcpg.LocalBitcoinChain, - walletPublicKeyHash [20]byte, - currentBlock uint64, - depositAmount uint64, -) bitcoin.Hash { - t.Helper() - - fundingTxHash := hashFromString( - "2222222222222222222222222222222222222222222222222222222222222222", - ) - - dummyTx := &bitcoin.Transaction{ - Outputs: []*bitcoin.TransactionOutput{{ - Value: int64(depositAmount), - PublicKeyScript: append([]byte{0x00, 0x20}, make([]byte, 32)...), - }}, - } - btcChain.SetTransaction(fundingTxHash, dummyTx) - btcChain.SetEstimateSatPerVByteFee(1, 1) - btcChain.SetTransactionConfirmations( - fundingTxHash, - tbtc.DepositSweepRequiredFundingTxConfirmations, - ) - - vaultAddress := testReservationVaultAddress - if params, err := ralc.ReservationParameters(); err == nil && - params.ReservationVault != "" { - vaultAddress = params.ReservationVault - } - - ralc.SetDepositRequest( - fundingTxHash, - 0, - &tbtc.DepositChainRequest{ - Depositor: chain.Address("934b98637ca318a4d6e7ca6ffd1690b8e77df637"), - Amount: depositAmount, - RevealedAt: time.Now().Add(-2 * time.Hour), - SweptAt: time.Unix(0, 0), - Vault: &vaultAddress, - }, - ) - - filterStartBlock := uint64(0) - if currentBlock > tbtcpg.ReservationAcceptanceLookBackBlocks { - filterStartBlock = currentBlock - tbtcpg.ReservationAcceptanceLookBackBlocks - } - - revealBlock := filterStartBlock - if revealBlock == 0 { - revealBlock = 1 - } - - err := ralc.AddPastDepositRevealedEvent( - &tbtc.DepositRevealedEventFilter{ - StartBlock: filterStartBlock, - EndBlock: ¤tBlock, - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - }, - &tbtc.DepositRevealedEvent{ - BlockNumber: revealBlock, - WalletPublicKeyHash: walletPublicKeyHash, - FundingTxHash: fundingTxHash, - FundingOutputIndex: 0, - Vault: &vaultAddress, - }, - ) - if err != nil { - t.Fatalf("failed to add past deposit revealed event: [%v]", err) } - return fundingTxHash -} - -// seedPendingAcceptanceAction wires a Pending Acceptance action record -// for the deposit at fundingTxHash/index into the local chain, matching -// the snapshot a depositor's requestReservationAcceptance call would have -// written on-chain at the time of the request. Tests then exercise -// findReservationAcceptanceCandidate's read of the action record at -// reservation.RequestNonce -- the precondition the production validator -// enforces -- instead of taking the operator-request path that was -// removed. -func seedPendingAcceptanceAction( - t *testing.T, - ralc *reservationAcceptanceLocalChain, - fundingTxHash bitcoin.Hash, - fundingOutputIndex uint32, - walletPublicKeyHash [20]byte, - requestNonce uint64, - txMaxFee uint64, - minAmount uint64, - termSeconds uint32, -) { - t.Helper() - - depositKey := ralc.BuildDepositKey(fundingTxHash, fundingOutputIndex) - ralc.SetReservation(depositKey, &tbtc.Reservation{ - WalletPublicKeyHash: walletPublicKeyHash, - State: tbtc.ReservationStateUnknown, - RequestNonce: requestNonce, - }) - ralc.SetReservationAction(depositKey, requestNonce, &tbtc.ReservationAction{ - ActionType: tbtc.ReservationActionTypeAcceptance, - State: tbtc.ReservationActionStatePending, - TargetWalletPublicKeyHash: walletPublicKeyHash, - TxMaxFee: txMaxFee, - MinAmount: minAmount, - TermSeconds: termSeconds, - // Far-future TimeoutAt so the validator's safety-margin gate - // (REQUEST_TIMEOUT_SAFETY_MARGIN, 2 hours) does not reject the - // action. Tests that intentionally exercise the gate set - // TimeoutAt via SetReservationAction directly. - TimeoutAt: uint32(time.Now().Add(24 * time.Hour).Unix()), - }) + return hashesByLabel } // newBoundaryTestChain builds a reservationAcceptanceLocalChain with the // reservation-parameters/caps/wallet/block-counter setup shared by most of // this file's Run()-based tests: a live wallet at walletPublicKeyHash, a // ReservationParameters of {vault: testReservationVaultAddress, minAmount: -// 1000, txMaxFee: 5000, maxPerWallet: 5}, per-wallet/single caps of -// 5000000, an active-reservations cap of 100, and a deposit minimum age of -// one hour. overrides, when non-nil, runs after these defaults so a call -// site can customize only what it varies (e.g. re-set ReservationParameters -// with different values, raise a cap, or inject an error field). +// 1000, txMaxFee: 5000, maxPerWallet: 5, term: 90 days, action timeout: 24 +// hours}, per-wallet/single caps of 5000000, an active-reservations cap of +// 100, a deposit minimum age of DEPOSIT_MIN_AGE (2 hours) and a 12-second +// block time. overrides, when non-nil, runs after these defaults so a +// call site can customize only what it varies. func newBoundaryTestChain( t *testing.T, walletPublicKeyHash [20]byte, @@ -556,18 +717,13 @@ func newBoundaryTestChain( t.Helper() ralc := newReservationAcceptanceLocalChain() - ralc.SetReservationParameters(tbtc.ReservationParameters{ - ReservationVault: testReservationVaultAddress, - ReservationMinAmount: 1000, - ReservationTxMaxFee: 5000, - MaxReservationsPerWallet: 5, - ReservationMaxTotalAmount: 100000000, - }) + ralc.SetReservationParameters(defaultTestReservationParameters()) ralc.maxPerWalletAmount = 5000000 ralc.maxSingleAmount = 5000000 ralc.maxActive = 100 - ralc.SetDepositMinAge(3600) + ralc.SetDepositMinAge(testDepositMinAgeSeconds) + ralc.SetAverageBlockTime(12 * time.Second) ralc.SetWallet( walletPublicKeyHash, &tbtc.WalletChainData{State: tbtc.StateLive}, @@ -584,15 +740,120 @@ func newBoundaryTestChain( return ralc } -// uint64Ptr and uint32Ptr let a TestReservationAcceptanceTask_BoundaryChecks -// table row distinguish an explicit cap value of 0 (production's -// "unlimited" semantic for these caps) from the field's unset zero value. -func uint64Ptr(v uint64) *uint64 { return &v } -func uint32Ptr(v uint32) *uint32 { return &v } - -// expectedAnchorsEqual compares two proposal objects field-by-field. -// deep.Equal cannot be used for this: by default it does not descend into -// unexported fields, and *big.Int's representation is entirely unexported, +// defaultTestReservationParameters returns the reservation parameters +// newBoundaryTestChain installs. +func defaultTestReservationParameters() tbtc.ReservationParameters { + return tbtc.ReservationParameters{ + ReservationVault: testReservationVaultAddress, + ReservationMinAmount: 1000, + ReservationTxMaxFee: 5000, + MaxReservationsPerWallet: 5, + ReservationMaxTotalAmount: 100000000, + ReservationTermSeconds: testReservationTermSeconds, + ReservationActionTimeout: 24 * 60 * 60, + } +} + +// runTask runs a fresh acceptance task once for walletPublicKeyHash. +func runTask( + t *testing.T, + ralc *reservationAcceptanceLocalChain, + btcChain *tbtcpg.LocalBitcoinChain, + walletPublicKeyHash [20]byte, +) (*tbtc.ReservationAnchorProposal, bool, error) { + t.Helper() + + task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + return runExistingTask(t, task, walletPublicKeyHash) +} + +// runExistingTask runs the given acceptance task once for +// walletPublicKeyHash. +func runExistingTask( + t *testing.T, + task *tbtcpg.ReservationAcceptanceTask, + walletPublicKeyHash [20]byte, +) (*tbtc.ReservationAnchorProposal, bool, error) { + t.Helper() + + proposal, shouldExecute, err := task.Run(&tbtc.CoordinationProposalRequest{ + WalletPublicKeyHash: walletPublicKeyHash, + }) + if proposal == nil { + return nil, shouldExecute, err + } + anchorProposal, ok := proposal.(*tbtc.ReservationAnchorProposal) + if !ok { + t.Fatalf("expected *ReservationAnchorProposal, got %T", proposal) + } + return anchorProposal, shouldExecute, err +} + +// expectProposalFor fails the test unless the run produced a proposal for +// the given deposit at the given nonce. +func expectProposalFor( + t *testing.T, + proposal *tbtc.ReservationAnchorProposal, + shouldExecute bool, + err error, + deposit *pendingDeposit, + requestNonce uint64, +) { + t.Helper() + + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + if !shouldExecute || proposal == nil { + t.Fatalf("expected a proposal, got shouldExecute=%v", shouldExecute) + } + if proposal.DepositFundingTxHash != deposit.fundingTxHash { + t.Fatalf( + "unexpected deposit funding tx hash\nexpected: %s\nactual: %s", + deposit.fundingTxHash.Hex(bitcoin.ReversedByteOrder), + proposal.DepositFundingTxHash.Hex(bitcoin.ReversedByteOrder), + ) + } + if proposal.RequestNonce != requestNonce { + t.Fatalf( + "expected the proposal to carry the pending generation's nonce "+ + "[%d], got [%d]", + requestNonce, + proposal.RequestNonce, + ) + } +} + +// expectNoProposal fails the test unless the run was a clean no-op. +func expectNoProposal( + t *testing.T, + proposal *tbtc.ReservationAnchorProposal, + shouldExecute bool, + err error, +) { + t.Helper() + + if err != nil { + t.Fatalf("unexpected error: [%v]", err) + } + if shouldExecute || proposal != nil { + t.Fatalf( + "expected no proposal, got shouldExecute=%v proposal=%+v", + shouldExecute, + proposal, + ) + } +} + +// uint64Ptr and uint32Ptr let a TestReservationAcceptanceTask_BoundaryChecks +// table row distinguish an explicit cap value of 0 (production's +// "unlimited" semantic for these caps) from the field's unset zero value. +func uint64Ptr(v uint64) *uint64 { return &v } +func uint32Ptr(v uint32) *uint32 { return &v } + +// expectedAnchorsEqual compares two proposal objects field-by-field. +// deep.Equal cannot be used for this: by default it does not descend into +// unexported fields, and *big.Int's representation is entirely unexported, // so it silently reports "no difference" for any two distinct AnchorTxFee // values. AnchorTxFee therefore needs an explicit .Cmp(). func expectedAnchorsEqual( @@ -620,19 +881,9 @@ func expectedAnchorsEqual( return expected.AnchorTxFee.Cmp(actual.AnchorTxFee) == 0 } -func TestReservationAcceptanceLookBackBlocks(t *testing.T) { - expectedValue := uint64(216000) - - if tbtcpg.ReservationAcceptanceLookBackBlocks != expectedValue { - t.Errorf( - "unexpected ReservationAcceptanceLookBackBlocks\n"+ - "expected: %d\n"+ - "actual: %d", - expectedValue, - tbtcpg.ReservationAcceptanceLookBackBlocks, - ) - } -} +var testWalletPublicKeyHash = hexToByte20( + "8db50eb52063ea9d98b3eac91489a90f738986f6", +) func TestReservationAcceptanceTask_ActionType(t *testing.T) { task := tbtcpg.NewReservationAcceptanceTask( @@ -664,15 +915,14 @@ func TestReservationAcceptanceTask_Run(t *testing.T) { t.Run(scenario.Title, func(t *testing.T) { ralc := scenarioReservationAcceptanceChain(t, scenario) btcChain := tbtcpg.NewLocalBitcoinChain() - registerReservedDeposits(t, scenario, ralc, btcChain) - - task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) - - request := &tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: scenario.WalletPublicKeyHash, - } + hashesByLabel := registerReservedDeposits(t, scenario, ralc, btcChain) - proposal, shouldExecute, err := task.Run(request) + proposal, shouldExecute, err := runTask( + t, + ralc, + btcChain, + scenario.WalletPublicKeyHash, + ) if err != nil { if scenario.ExpectedErr == nil { t.Fatalf("unexpected error: [%v]", err) @@ -692,20 +942,8 @@ func TestReservationAcceptanceTask_Run(t *testing.T) { t.Fatalf("expected error [%v], got nil", scenario.ExpectedErr) } - expectedProposal := scenario.ExpectedAnchorProposal - - if expectedProposal == nil { - if shouldExecute { - t.Errorf( - "unexpected proposal returned when none expected", - ) - } - if proposal != nil { - t.Errorf( - "expected nil proposal, got [%+v]", - proposal, - ) - } + if scenario.ExpectedAnchorProposal == nil { + expectNoProposal(t, proposal, shouldExecute, nil) return } @@ -716,16 +954,18 @@ func TestReservationAcceptanceTask_Run(t *testing.T) { t.Fatal("expected proposal, got nil") } - actualProposal, ok := proposal.(*tbtc.ReservationAnchorProposal) + expectedProposal := *scenario.ExpectedAnchorProposal + realHash, ok := hashesByLabel[expectedProposal.DepositFundingTxHash] if !ok { - t.Fatalf("expected *ReservationAnchorProposal, got %T", proposal) + t.Fatalf("expected proposal refers to an unknown deposit label") } + expectedProposal.DepositFundingTxHash = realHash - if !expectedAnchorsEqual(expectedProposal, actualProposal) { + if !expectedAnchorsEqual(&expectedProposal, proposal) { t.Errorf( "invalid anchor proposal\nexpected: %+v\nactual: %+v", expectedProposal, - actualProposal, + proposal, ) } }) @@ -739,7 +979,6 @@ func TestReservationAcceptanceTask_Run(t *testing.T) { // value equal to deposit amount minus the estimated anchor fee. func TestReservationAcceptanceTask_AnchorTransactionAssembly(t *testing.T) { btcChain := tbtcpg.NewLocalBitcoinChain() - btcChain.SetEstimateSatPerVByteFee(1, 1) privateKey, err := ecdsa.GenerateKey(btcec.S256(), rand.Reader) if err != nil { @@ -750,145 +989,17 @@ func TestReservationAcceptanceTask_AnchorTransactionAssembly(t *testing.T) { depositAmount := uint64(2000000) currentBlock := uint64(300000) - ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, func(ralc *reservationAcceptanceLocalChain) { - ralc.maxPerWalletAmount = 50000000 - ralc.maxSingleAmount = 50000000 - }) - - deposit := &tbtc.Deposit{ - Depositor: chain.Address("934b98637ca318a4d6e7ca6ffd1690b8e77df637"), - BlindingFactor: [8]byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}, - WalletPublicKeyHash: walletPublicKeyHash, - RefundPublicKeyHash: [20]byte{0x02}, - RefundLocktime: [4]byte{0x03, 0x04, 0x05, 0x06}, - Vault: &[]chain.Address{testReservationVaultAddress}[0], - } - - depositScript, err := deposit.Script() - if err != nil { - t.Fatal(err) - } - - depositScriptHash := sha256.Sum256(depositScript) - depositLockingScript, err := bitcoin.PayToWitnessScriptHash(depositScriptHash) - if err != nil { - t.Fatal(err) - } - - fundingTx := &bitcoin.Transaction{ - Version: 1, - Inputs: []*bitcoin.TransactionInput{ - { - Outpoint: &bitcoin.TransactionOutpoint{ - TransactionHash: bitcoin.Hash{0x09}, - OutputIndex: 0, - }, - Sequence: 0xffffffff, - }, - }, - Outputs: []*bitcoin.TransactionOutput{ - { - Value: int64(depositAmount), - PublicKeyScript: depositLockingScript, - }, - }, - } - fundingTxHash := fundingTx.Hash() - btcChain.SetTransaction(fundingTxHash, fundingTx) - btcChain.SetTransactionConfirmations( - fundingTxHash, - tbtc.DepositSweepRequiredFundingTxConfirmations, - ) - - deposit.Utxo = &bitcoin.UnspentTransactionOutput{ - Outpoint: &bitcoin.TransactionOutpoint{ - TransactionHash: fundingTxHash, - OutputIndex: 0, - }, - Value: int64(depositAmount), - } - - ralc.SetDepositRequest( - fundingTxHash, - 0, - &tbtc.DepositChainRequest{ - Depositor: deposit.Depositor, - Amount: depositAmount, - RevealedAt: time.Now().Add(-2 * time.Hour), - SweptAt: time.Unix(0, 0), - Vault: deposit.Vault, - }, - ) - - // Seed a Pending Acceptance action at nonce 1 with the boundary - // chain's snapshot parameters so the candidate passes - // findReservationAcceptanceCandidate's action-record gate. The - // wallet here is the deposit's own WalletPublicKeyHash (the wallet - // that revealed the deposit), which is also the chain's active - // wallet set above. - seedPendingAcceptanceAction( + ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, nil) + deposit := addPendingDeposit( t, ralc, - fundingTxHash, - 0, + btcChain, walletPublicKeyHash, - 1, - 5000, - 1000, - 86400, + defaultPendingDepositOptions(currentBlock), ) - filterStartBlock := currentBlock - tbtcpg.ReservationAcceptanceLookBackBlocks - if err := ralc.AddPastDepositRevealedEvent( - &tbtc.DepositRevealedEventFilter{ - StartBlock: filterStartBlock, - EndBlock: ¤tBlock, - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - }, - &tbtc.DepositRevealedEvent{ - BlockNumber: 200000, - WalletPublicKeyHash: walletPublicKeyHash, - FundingTxHash: fundingTxHash, - FundingOutputIndex: 0, - Vault: deposit.Vault, - BlindingFactor: deposit.BlindingFactor, - RefundPublicKeyHash: deposit.RefundPublicKeyHash, - RefundLocktime: deposit.RefundLocktime, - }, - ); err != nil { - t.Fatal(err) - } - - task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) - proposal, shouldExecute, err := task.Run(&tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: walletPublicKeyHash, - }) - if err != nil { - t.Fatalf("unexpected error running task: [%v]", err) - } - if !shouldExecute { - t.Fatalf("expected shouldExecute=true, got false") - } - if proposal == nil { - t.Fatalf("expected non-nil proposal") - } - - anchorProposal, ok := proposal.(*tbtc.ReservationAnchorProposal) - if !ok { - t.Fatalf("expected *ReservationAnchorProposal, got %T", proposal) - } - - // Assert on the candidate-derived proposal's own fields, exercising the - // candidate-deposit mapping step (also checked by the fixture's - // ValidateReservationAnchorProposal override), rather than only - // reassembling from this test's own hand-built deposit object below. - if anchorProposal.DepositFundingTxHash != fundingTxHash { - t.Errorf( - "unexpected DepositFundingTxHash\nexpected: %x\nactual: %x", - fundingTxHash, - anchorProposal.DepositFundingTxHash, - ) - } + anchorProposal, shouldExecute, err := runTask(t, ralc, btcChain, walletPublicKeyHash) + expectProposalFor(t, anchorProposal, shouldExecute, err, deposit, 1) if anchorProposal.DepositFundingOutputIndex != 0 { t.Errorf( "unexpected DepositFundingOutputIndex\nexpected: 0\nactual: %d", @@ -899,7 +1010,7 @@ func TestReservationAcceptanceTask_AnchorTransactionAssembly(t *testing.T) { // Re-assemble and sign to verify transaction builder output properties. builder, err := tbtc.AssembleReservationAnchorTransaction( btcChain, - deposit, + deposit.deposit, walletPublicKeyHash, &tbtc.ReservationAction{TxMaxFee: 5000}, anchorProposal.AnchorTxFee.Int64(), @@ -960,38 +1071,13 @@ func TestReservationAcceptanceTask_AnchorTransactionAssembly(t *testing.T) { } // TestReservationAcceptanceTask_NoCandidates verifies that the task is a -// no-op when the chain has no reserved deposits. +// no-op when no depositor requested acceptance by the wallet. func TestReservationAcceptanceTask_NoCandidates(t *testing.T) { btcChain := tbtcpg.NewLocalBitcoinChain() + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, 300000, nil) - walletPublicKeyHash := hexToByte20( - "8db50eb52063ea9d98b3eac91489a90f738986f6", - ) - currentBlock := uint64(300000) - - ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, func(ralc *reservationAcceptanceLocalChain) { - ralc.SetReservationParameters(tbtc.ReservationParameters{ - ReservationVault: testReservationVaultAddress, - }) - ralc.maxPerWalletAmount = 1000000 - }) - - task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) - - request := &tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: walletPublicKeyHash, - } - - proposal, shouldExecute, err := task.Run(request) - if err != nil { - t.Fatalf("unexpected error: [%v]", err) - } - if shouldExecute { - t.Errorf("expected shouldExecute=false, got true") - } - if proposal != nil { - t.Errorf("expected nil proposal, got [%+v]", proposal) - } + proposal, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) + expectNoProposal(t, proposal, shouldExecute, err) } // TestReservationAcceptanceTask_VaultNotConfigured_ZeroAddress verifies @@ -999,406 +1085,176 @@ func TestReservationAcceptanceTask_NoCandidates(t *testing.T) { // chain.Address converter emits for an unset vault, never an empty // string) is correctly treated as "not configured". func TestReservationAcceptanceTask_VaultNotConfigured_ZeroAddress(t *testing.T) { - ralc := newReservationAcceptanceLocalChain() btcChain := tbtcpg.NewLocalBitcoinChain() + currentBlock := uint64(300000) - walletPublicKeyHash := hexToByte20( - "8db50eb52063ea9d98b3eac91489a90f738986f6", + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) + // A request exists, but the vault is unset: the task must not look. + addPendingDeposit( + t, + ralc, + btcChain, + testWalletPublicKeyHash, + defaultPendingDepositOptions(currentBlock), ) - - ralc.SetReservationParameters(tbtc.ReservationParameters{ - ReservationVault: chain.Address( - "0x0000000000000000000000000000000000000000", - ), - }) - ralc.maxPerWalletAmount = 1000000 - ralc.maxSingleAmount = 5000000 - ralc.maxActive = 100 - - ralc.SetDepositMinAge(3600) - ralc.SetWallet( - walletPublicKeyHash, - &tbtc.WalletChainData{State: tbtc.StateLive}, + params := defaultTestReservationParameters() + params.ReservationVault = chain.Address( + "0x0000000000000000000000000000000000000000", ) + ralc.SetReservationParameters(params) - currentBlock := uint64(300000) - blockCounter := tbtcpg.NewMockBlockCounter() - blockCounter.SetCurrentBlock(currentBlock) - ralc.SetBlockCounter(blockCounter) - - task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) - - request := &tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: walletPublicKeyHash, - } - - proposal, shouldExecute, err := task.Run(request) - if err != nil { - t.Fatalf("unexpected error: [%v]", err) - } - if shouldExecute { - t.Errorf("expected shouldExecute=false, got true") - } - if proposal != nil { - t.Errorf("expected nil proposal, got [%+v]", proposal) + proposal, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) + expectNoProposal(t, proposal, shouldExecute, err) + if len(ralc.acceptanceEventFilters) != 0 { + t.Errorf("expected no acceptance request scan for an unset vault") } } -// TestReservationAcceptanceTask_IgnoresRequestTimeCaps is a regression -// test for the cross-repo review change: the acceptance task consumes the -// depositor's Pending Acceptance generation rather than issuing its own -// request, so it must not re-apply the request-time capacity caps -// (wallet count, active count) that Solidity's requestReservationAcceptance -// already reserved when that generation was created. Re-applying them -// here would double-count the very generation being consumed against -// them and block every otherwise eligible pending acceptance. -// -// This test pins that: with the wallet's own reservation count already at -// the per-wallet cap and the active-reservations count already at the -// active cap (the request-time capacity reserved by this very pending -// generation), the acceptance task still proposes the anchor. A regression -// that re-added the request-time cap gate at consumption time would make -// this test fail. +// TestReservationAcceptanceTask_IgnoresRequestTimeCaps pins that the +// acceptance task, which consumes the depositor's Pending Acceptance +// generation rather than issuing its own request, does not re-apply the +// request-time capacity caps (wallet count, active count) that Solidity's +// requestReservationAcceptance already reserved when that generation was +// created. Re-applying them would double-count the very generation being +// consumed and block every otherwise eligible pending acceptance. func TestReservationAcceptanceTask_IgnoresRequestTimeCaps(t *testing.T) { btcChain := tbtcpg.NewLocalBitcoinChain() - - walletPublicKeyHash := hexToByte20( - "8db50eb52063ea9d98b3eac91489a90f738986f6", - ) currentBlock := uint64(300000) - ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, nil) - - // The pending acceptance generation this candidate consumes was - // already reserved against the capacity caps at request time, so - // the wallet's own count and the global active count are at their - // caps even before this anchor is signed: the wallet holds the cap - // number of reservations (including this one) and the active - // reservations count equals the max. A fresh request would be - // rejected on-chain; consuming the reserved generation must not - // be. - fundingTxHash := setupEligibleDeposit( + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) + deposit := addPendingDeposit( t, ralc, btcChain, - walletPublicKeyHash, - currentBlock, - 2000000, - ) - seedPendingAcceptanceAction( - t, - ralc, - fundingTxHash, - 0, - walletPublicKeyHash, - 1, - 5000, - 1000, - 86400, + testWalletPublicKeyHash, + defaultPendingDepositOptions(currentBlock), ) // The wallet's own reservation count is at the cap (this pending // generation counts toward it) and the active count is at the // active cap: exactly the state Solidity's request-time // reserveAcceptanceCapacity leaves behind. - ralc.SetWalletReservations(walletPublicKeyHash, []*big.Int{ + ralc.SetWalletReservations(testWalletPublicKeyHash, []*big.Int{ new(big.Int).SetInt64(7), }) ralc.maxActive = 100 ralc.activeCount = 100 ralc.walletReservationsCount = 5 - task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) - request := &tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: walletPublicKeyHash, - } + proposal, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) + expectProposalFor(t, proposal, shouldExecute, err, deposit, 1) +} - proposal, shouldExecute, err := task.Run(request) - if err != nil { - t.Fatalf("unexpected error: [%v]", err) - } - if !shouldExecute || proposal == nil { +// TestReservationAcceptanceTask_RequestLookBackWindow pins how far back +// acceptance requests are scanned. Solidity sets a generation's timeoutAt +// to request time + the action timeout, so a generation that can still be +// signed was requested within that timeout: the scan covers the on-chain +// action timeout in blocks plus a one-day margin, filtered by wallet, and +// a request inside that window is proposed. +func TestReservationAcceptanceTask_RequestLookBackWindow(t *testing.T) { + btcChain := tbtcpg.NewLocalBitcoinChain() + currentBlock := uint64(300000) + + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) + // 24-hour action timeout at 12 s per block is 7200 blocks; the + // margin adds another 7200. + expectedStartBlock := currentBlock - 14400 + + opts := defaultPendingDepositOptions(currentBlock) + opts.requestBlock = expectedStartBlock + opts.revealBlock = expectedStartBlock - 100 + deposit := addPendingDeposit(t, ralc, btcChain, testWalletPublicKeyHash, opts) + + proposal, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) + expectProposalFor(t, proposal, shouldExecute, err, deposit, 1) + + if len(ralc.acceptanceEventFilters) != 1 { t.Fatalf( - "expected the pending acceptance to be consumed even though " + - "the request-time capacity caps (wallet count, active " + - "count) are already at their limits; the caps were " + - "reserved when the generation was requested", + "expected exactly one acceptance request scan, got %d", + len(ralc.acceptanceEventFilters), ) } - if proposal.(*tbtc.ReservationAnchorProposal).RequestNonce != 1 { + filter := ralc.acceptanceEventFilters[0] + if filter.StartBlock != expectedStartBlock || + filter.EndBlock == nil || *filter.EndBlock != currentBlock { t.Fatalf( - "expected the proposal to carry the pending generation's "+ - "real nonce (1), got [%d]", - proposal.(*tbtc.ReservationAnchorProposal).RequestNonce, + "unexpected scan range: start=%d end=%v, expected [%d, %d]", + filter.StartBlock, + filter.EndBlock, + expectedStartBlock, + currentBlock, ) } + if len(filter.WalletPublicKeyHash) != 1 || + filter.WalletPublicKeyHash[0] != testWalletPublicKeyHash { + t.Fatalf("expected the scan to be filtered by the wallet") + } } -// TestReservationAcceptanceTask_BoundedLookback verifies that the bounded -// look-back window is applied when the current block exceeds it. -func TestReservationAcceptanceTask_BoundedLookback(t *testing.T) { - initialBlock := uint64(400000) - +// TestReservationAcceptanceTask_RevealOlderThanFormerLookBack is a +// regression test for acceptance requests made long after the reveal. A +// depositor may request acceptance up to one term after revealing, so a +// deposit revealed 300000 blocks (about 41 days) ago -- beyond the +// 216000-block window the task used to scan reveals in -- must still be +// found and proposed. The reveal sits exactly on the oldest block of a +// 10000-block lookup chunk to catch off-by-one errors at chunk edges. +func TestReservationAcceptanceTask_RevealOlderThanFormerLookBack(t *testing.T) { btcChain := tbtcpg.NewLocalBitcoinChain() + currentBlock := uint64(1000000) - walletPublicKeyHash := hexToByte20( - "8db50eb52063ea9d98b3eac91489a90f738986f6", - ) + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) - // A block counter this test can advance between runs is created up - // front (rather than letting newBoundaryTestChain own an opaque one), - // so the second run below can simulate a later coordination window - // the way production actually progresses, exercising the task's - // per-wallet incremental scan cursor (see depositRevealedEventsSince) - // instead of re-querying the exact same already-scanned range twice. - blockCounter := tbtcpg.NewMockBlockCounter() - blockCounter.SetCurrentBlock(initialBlock) + opts := defaultPendingDepositOptions(currentBlock) + opts.requestBlock = currentBlock - 10 + // Chunks run backward from the request block: the 30th covers + // [requestBlock - 300000 + 1, requestBlock - 290000]. + opts.revealBlock = opts.requestBlock - 300000 + 1 + opts.revealedAt = time.Now().Add(-300000 * 12 * time.Second) + deposit := addPendingDeposit(t, ralc, btcChain, testWalletPublicKeyHash, opts) - ralc := newBoundaryTestChain( - t, - walletPublicKeyHash, - initialBlock, - func(ralc *reservationAcceptanceLocalChain) { - ralc.SetBlockCounter(blockCounter) - }, - ) + proposal, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) + expectProposalFor(t, proposal, shouldExecute, err, deposit, 1) +} - // Register an event below the look-back start block (block 1), under - // the unbounded filter a buggy filterStartBlock=0 computation would - // query with. - oldFundingTxHash := hashFromString( - "1111111111111111111111111111111111111111111111111111111111111111", - ) - if err := ralc.AddPastDepositRevealedEvent( - &tbtc.DepositRevealedEventFilter{ - StartBlock: 0, - EndBlock: &initialBlock, - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - }, - &tbtc.DepositRevealedEvent{ - BlockNumber: 1, - WalletPublicKeyHash: walletPublicKeyHash, - FundingTxHash: oldFundingTxHash, - FundingOutputIndex: 0, - }, - ); err != nil { - t.Fatal(err) - } +// TestReservationAcceptanceTask_DepositNotRoutedToReservationVault +// confirms that a candidate whose reveal routed the deposit to another +// vault is skipped. +func TestReservationAcceptanceTask_DepositNotRoutedToReservationVault(t *testing.T) { + btcChain := tbtcpg.NewLocalBitcoinChain() + currentBlock := uint64(300000) - task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) - request := &tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: walletPublicKeyHash, - } + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) + otherVault := chain.Address("0xOtherVaultAddress1234567890abcdef123456789012") + opts := defaultPendingDepositOptions(currentBlock) + opts.revealVault = &otherVault + addPendingDeposit(t, ralc, btcChain, testWalletPublicKeyHash, opts) - // First run: only the old deposit exists, revealed at block 1 - before - // the look-back start block. No candidate is found on this run; the - // second run below is what actually proves the look-back start block - // is honored, by advancing the block counter and registering an - // eligible deposit within the resulting incremental scan delta. - proposal, shouldExecute, err := task.Run(request) - if err != nil { - t.Fatalf("unexpected error on old deposit run: [%v]", err) - } - if shouldExecute { - t.Errorf("expected shouldExecute=false for deposit below lookback window, got true") - } - if proposal != nil { - t.Errorf("expected nil proposal for deposit below lookback window, got [%+v]", proposal) + proposal, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) + expectNoProposal(t, proposal, shouldExecute, err) + if ralc.validateCalls != 0 { + t.Errorf("expected no validation, got %d calls", ralc.validateCalls) } - - // Advance the block counter (as a later coordination window would) - // and register an eligible deposit within the resulting incremental - // delta range [initialBlock+1, nextBlock]. - nextBlock := initialBlock + 10 - blockCounter.SetCurrentBlock(nextBlock) - - fundingTxHash := hashFromString( - "2222222222222222222222222222222222222222222222222222222222222222", - ) - dummyTx := &bitcoin.Transaction{ - Outputs: []*bitcoin.TransactionOutput{{ - Value: 2000000, - PublicKeyScript: append([]byte{0x00, 0x20}, make([]byte, 32)...), - }}, - } - btcChain.SetTransaction(fundingTxHash, dummyTx) - btcChain.SetEstimateSatPerVByteFee(1, 1) - btcChain.SetTransactionConfirmations( - fundingTxHash, - tbtc.DepositSweepRequiredFundingTxConfirmations, - ) - ralc.SetDepositRequest( - fundingTxHash, - 0, - &tbtc.DepositChainRequest{ - Depositor: chain.Address("934b98637ca318a4d6e7ca6ffd1690b8e77df637"), - Amount: 2000000, - RevealedAt: time.Now().Add(-2 * time.Hour), - SweptAt: time.Unix(0, 0), - Vault: &[]chain.Address{testReservationVaultAddress}[0], - }, - ) - // Seed a Pending Acceptance action matching the boundary chain's - // parameters so this freshly-revealed deposit is eligible to be - // accepted by the second run. - seedPendingAcceptanceAction( - t, - ralc, - fundingTxHash, - 0, - walletPublicKeyHash, - 1, - 5000, - 1000, - 86400, - ) - if err := ralc.AddPastDepositRevealedEvent( - &tbtc.DepositRevealedEventFilter{ - StartBlock: initialBlock + 1, - EndBlock: &nextBlock, - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - }, - &tbtc.DepositRevealedEvent{ - BlockNumber: nextBlock, - WalletPublicKeyHash: walletPublicKeyHash, - FundingTxHash: fundingTxHash, - FundingOutputIndex: 0, - Vault: &[]chain.Address{testReservationVaultAddress}[0], - }, - ); err != nil { - t.Fatal(err) - } - - // Second run: the newly revealed deposit must be found and accepted. - proposal, shouldExecute, err = task.Run(request) - if err != nil { - t.Fatalf("unexpected error: [%v]", err) - } - if !shouldExecute { - t.Fatalf("expected shouldExecute=true, got false") - } - if proposal == nil { - t.Fatalf("expected proposal, got nil") - } - actualProposal, ok := proposal.(*tbtc.ReservationAnchorProposal) - if !ok { - t.Fatalf("expected *ReservationAnchorProposal, got %T", proposal) - } - if actualProposal.DepositFundingTxHash != fundingTxHash { - t.Errorf( - "unexpected deposit funding tx hash\n"+ - "expected: %s\n"+ - "actual: %s", - fundingTxHash.Hex(bitcoin.ReversedByteOrder), - actualProposal.DepositFundingTxHash.Hex( - bitcoin.ReversedByteOrder, - ), - ) - } -} - -// TestReservationAcceptanceTask_DepositNotReserved confirms that a deposit -// that fails IsReservedDeposit is filtered out. -func TestReservationAcceptanceTask_DepositNotReserved(t *testing.T) { - btcChain := tbtcpg.NewLocalBitcoinChain() - - walletPublicKeyHash := hexToByte20( - "8db50eb52063ea9d98b3eac91489a90f738986f6", - ) - currentBlock := uint64(300000) - - ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, func(ralc *reservationAcceptanceLocalChain) { - ralc.SetReservationParameters(tbtc.ReservationParameters{ - ReservationVault: testReservationVaultAddress, - }) - }) - - fundingTxHash := hashFromString( - "3333333333333333333333333333333333333333333333333333333333333333", - ) - btcChain.SetTransaction(fundingTxHash, &bitcoin.Transaction{}) - btcChain.SetTransactionConfirmations( - fundingTxHash, - tbtc.DepositSweepRequiredFundingTxConfirmations, - ) - ralc.SetDepositRequest( - fundingTxHash, - 0, - &tbtc.DepositChainRequest{ - Amount: 2000000, - RevealedAt: time.Now().Add(-2 * time.Hour), - }, - ) - if err := ralc.AddPastDepositRevealedEvent( - &tbtc.DepositRevealedEventFilter{ - StartBlock: 0, - EndBlock: ¤tBlock, - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - }, - &tbtc.DepositRevealedEvent{ - BlockNumber: 290000, - WalletPublicKeyHash: walletPublicKeyHash, - FundingTxHash: fundingTxHash, - FundingOutputIndex: 0, - Vault: &[]chain.Address{chain.Address( - "0xReservationVaultAddress1234567890abcdef12345678", - )}[0], - }, - ); err != nil { - t.Fatal(err) - } - - task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) - - request := &tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: walletPublicKeyHash, - } - - proposal, shouldExecute, err := task.Run(request) - if err != nil { - t.Fatalf("unexpected error: [%v]", err) - } - if shouldExecute { - t.Errorf("expected shouldExecute=false, got true") - } - if proposal != nil { - t.Errorf("expected no proposal for non-reserved deposit, got %v", proposal) - } -} +} // TestReservationAcceptanceTask_GetWalletError exercises the GetWallet -// error propagation inside findReservationAcceptanceCandidate: a -// reserved deposit candidate is discovered and matches the reservation -// vault, but the candidate wallet's chain data fails to load. Production -// now propagates this RPC failure instead of masking it as "no eligible -// candidate", so the coordinator can retry rather than silently treating -// a transient chain-read failure as a benign no-op. +// error propagation: the candidate wallet's chain data fails to load and +// the RPC failure is propagated instead of being masked as "no eligible +// candidate", so the coordinator can retry. func TestReservationAcceptanceTask_GetWalletError(t *testing.T) { btcChain := tbtcpg.NewLocalBitcoinChain() - - walletPublicKeyHash := hexToByte20( - "8db50eb52063ea9d98b3eac91489a90f738986f6", - ) currentBlock := uint64(300000) - // getWalletErr forces GetWallet to fail for the candidate wallet. - ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, func(ralc *reservationAcceptanceLocalChain) { + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, func(ralc *reservationAcceptanceLocalChain) { ralc.getWalletErr = fmt.Errorf("boom") }) - - setupEligibleDeposit( + addPendingDeposit( t, ralc, btcChain, - walletPublicKeyHash, - currentBlock, - 2000000, + testWalletPublicKeyHash, + defaultPendingDepositOptions(currentBlock), ) - task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) - - proposal, shouldExecute, err := task.Run(&tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: walletPublicKeyHash, - }) + proposal, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) if err == nil { t.Fatal("expected error, got nil") } @@ -1411,354 +1267,110 @@ func TestReservationAcceptanceTask_GetWalletError(t *testing.T) { err, ) } - if shouldExecute { - t.Errorf("expected shouldExecute=false, got true") - } - if proposal != nil { - t.Errorf("expected no proposal, got %v", proposal) + if shouldExecute || proposal != nil { + t.Errorf("expected no proposal") } } // TestReservationAcceptanceTask_GetReservationError verifies the fail-safe -// policy documented above the production call site: since the production -// chain adapter never errors for "not found" (it returns a zero record -// with State == Unknown), a GetReservation error can only be an RPC/decode -// failure, and the task must skip the affected deposit for this window -// rather than fail open and treat it as "not yet created". +// policy: since the production chain adapter never errors for "not found" +// (it returns a zero record with State == Unknown), a GetReservation error +// can only be an RPC/decode failure, and the task must skip the affected +// deposit for this window rather than fail open. func TestReservationAcceptanceTask_GetReservationError(t *testing.T) { btcChain := tbtcpg.NewLocalBitcoinChain() - - walletPublicKeyHash := hexToByte20( - "8db50eb52063ea9d98b3eac91489a90f738986f6", - ) currentBlock := uint64(300000) - ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, nil) - - setupEligibleDeposit( + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) + addPendingDeposit( t, ralc, btcChain, - walletPublicKeyHash, - currentBlock, - 2000000, + testWalletPublicKeyHash, + defaultPendingDepositOptions(currentBlock), ) - - // Force GetReservation to return an error. ralc.getReservationErr = fmt.Errorf("simulated get reservation error") - task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) - - proposal, shouldExecute, err := task.Run(&tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: walletPublicKeyHash, - }) - if err != nil { - t.Fatalf("unexpected error: [%v]", err) - } - if shouldExecute { - t.Errorf("expected shouldExecute=false, got true") - } - if proposal != nil { - t.Fatalf("expected nil proposal, got [%+v]", proposal) - } + proposal, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) + expectNoProposal(t, proposal, shouldExecute, err) } -// TestReservationAcceptanceTask_Stateless_Maturity verifies the stateless -// observable contract across two consecutive Run calls on the same task instance: -// an immature candidate is skipped on the first run, but when time advances and -// the candidate matures, the second run on the same task instance proposes it -// without any cache-state interference. +// TestReservationAcceptanceTask_Stateless_Maturity verifies that an +// immature candidate is skipped on one run, and proposed by a later run of +// the same task instance once it matures, without cache interference. func TestReservationAcceptanceTask_Stateless_Maturity(t *testing.T) { btcChain := tbtcpg.NewLocalBitcoinChain() - - walletPublicKeyHash := hexToByte20( - "8db50eb52063ea9d98b3eac91489a90f738986f6", - ) - currentBlock := uint64(300000) - ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, nil) - - fundingTxHash := hashFromString( - "5555555555555555555555555555555555555555555555555555555555555555", - ) - dummyTx := &bitcoin.Transaction{ - Outputs: []*bitcoin.TransactionOutput{{ - Value: 0, - PublicKeyScript: append([]byte{0x00, 0x20}, make([]byte, 32)...), - }}, - } - btcChain.SetTransaction(fundingTxHash, dummyTx) - btcChain.SetEstimateSatPerVByteFee(1, 1) - btcChain.SetTransactionConfirmations( - fundingTxHash, - tbtc.DepositSweepRequiredFundingTxConfirmations, - ) - - // Candidate revealed only 10 minutes ago (depositMinAge is 1 hour). - ralc.SetDepositRequest( - fundingTxHash, - 0, - &tbtc.DepositChainRequest{ - Depositor: chain.Address("934b98637ca318a4d6e7ca6ffd1690b8e77df637"), - Amount: 2000000, - RevealedAt: time.Now().Add(-10 * time.Minute), - SweptAt: time.Unix(0, 0), - Vault: &[]chain.Address{testReservationVaultAddress}[0], - }, - ) - - // Seed the Pending Acceptance action so once the deposit matures, - // the candidate passes findReservationAcceptanceCandidate's - // action-record gate. - seedPendingAcceptanceAction( - t, - ralc, - fundingTxHash, - 0, - walletPublicKeyHash, - 1, - 5000, - 1000, - 86400, - ) - - filterStartBlock := uint64(0) - if currentBlock > tbtcpg.ReservationAcceptanceLookBackBlocks { - filterStartBlock = currentBlock - tbtcpg.ReservationAcceptanceLookBackBlocks - } + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) - if err := ralc.AddPastDepositRevealedEvent( - &tbtc.DepositRevealedEventFilter{ - StartBlock: filterStartBlock, - EndBlock: ¤tBlock, - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - }, - &tbtc.DepositRevealedEvent{ - BlockNumber: 290000, - WalletPublicKeyHash: walletPublicKeyHash, - FundingTxHash: fundingTxHash, - FundingOutputIndex: 0, - Vault: &[]chain.Address{testReservationVaultAddress}[0], - }, - ); err != nil { - t.Fatal(err) - } + // Revealed only 10 minutes ago (DEPOSIT_MIN_AGE is 2 hours). + opts := defaultPendingDepositOptions(currentBlock) + opts.revealedAt = time.Now().Add(-10 * time.Minute) + deposit := addPendingDeposit(t, ralc, btcChain, testWalletPublicKeyHash, opts) task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) - request := &tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: walletPublicKeyHash, - } - // First run: deposit is immature, should not be proposed. - proposal, shouldExecute, err := task.Run(request) - if err != nil { - t.Fatalf("first run error: [%v]", err) - } - if shouldExecute || proposal != nil { - t.Fatalf("expected no proposal on first run for immature deposit") - } - - // Advance deposit age (simulating passage of time to 2 hours ago). - ralc.SetDepositRequest( - fundingTxHash, - 0, - &tbtc.DepositChainRequest{ - Depositor: chain.Address("934b98637ca318a4d6e7ca6ffd1690b8e77df637"), - Amount: 2000000, - RevealedAt: time.Now().Add(-2 * time.Hour), - SweptAt: time.Unix(0, 0), - Vault: &[]chain.Address{testReservationVaultAddress}[0], - }, - ) + proposal, shouldExecute, err := runExistingTask(t, task, testWalletPublicKeyHash) + expectNoProposal(t, proposal, shouldExecute, err) - // Second run on the same task instance: deposit is now mature and proposed. - proposal, shouldExecute, err = task.Run(request) - if err != nil { - t.Fatalf("second run error: [%v]", err) - } - if !shouldExecute || proposal == nil { - t.Fatalf("expected proposal on second run after deposit matured") - } + // Time passes: the deposit was revealed 3 hours ago. + ralc.SetDepositRequest(deposit.fundingTxHash, 0, &tbtc.DepositChainRequest{ + Depositor: testDepositor, + Amount: opts.amount, + RevealedAt: time.Now().Add(-3 * time.Hour), + SweptAt: time.Unix(0, 0), + Vault: &[]chain.Address{testReservationVaultAddress}[0], + }) - actualProposal, ok := proposal.(*tbtc.ReservationAnchorProposal) - if !ok { - t.Fatalf("expected *ReservationAnchorProposal, got %T", proposal) - } - if actualProposal.DepositFundingTxHash != fundingTxHash { - t.Errorf( - "unexpected deposit funding tx hash\nexpected: %s\nactual: %s", - fundingTxHash.Hex(bitcoin.ReversedByteOrder), - actualProposal.DepositFundingTxHash.Hex(bitcoin.ReversedByteOrder), - ) - } + proposal, shouldExecute, err = runExistingTask(t, task, testWalletPublicKeyHash) + expectProposalFor(t, proposal, shouldExecute, err, deposit, 1) } // TestReservationAcceptanceTask_ReservationParametersFetchedLive verifies -// that each Run() call reflects the chain's current live state rather than -// anything cached from a prior run on the same task instance. It also -// pins two behaviors of the snapshot-driven minimum-amount gate and the -// depositor-action-driven proposal consumption that supersede the -// pre-fix operator-side request path: -// - the live ReservationParameters is consulted on every Run -- a -// governance mutation takes effect on the very next call; -// - the minimum-amount gate uses the generation's snapshotted minimum -// rather than the live ReservationMinAmount, so a live raise that -// crosses the deposit does not retroactively invalidate a pending -// generation whose own snapshot still clears it. +// that each Run() call reflects the chain's current live state, and that +// the minimum-amount gate uses the generation's snapshotted minimum rather +// than the live ReservationMinAmount: a live raise that crosses the +// deposit does not retroactively invalidate a pending generation whose own +// snapshot still clears it. func TestReservationAcceptanceTask_ReservationParametersFetchedLive(t *testing.T) { - walletPublicKeyHash := hexToByte20( - "8db50eb52063ea9d98b3eac91489a90f738986f6", - ) + btcChain := tbtcpg.NewLocalBitcoinChain() currentBlock := uint64(300000) - t.Run("live parameters are re-fetched each Run", func(t *testing.T) { - btcChain := tbtcpg.NewLocalBitcoinChain() - - ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, nil) - - setupEligibleDeposit( - t, - ralc, - btcChain, - walletPublicKeyHash, - currentBlock, - 2000000, - ) - - task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) - request := &tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: walletPublicKeyHash, - } - - // Seed a Pending Acceptance action at nonce 1 with snapshot - // MinAmount=1000 (matching the live min at the moment of the - // depositor's request), so the candidate is eligible on both - // runs. - seedPendingAcceptanceAction( - t, - ralc, - setupEligibleDeposit( - t, - ralc, - btcChain, - walletPublicKeyHash, - currentBlock, - 2000000, - ), - 0, - walletPublicKeyHash, - 1, - 5000, - 1000, - 86400, - ) - - // First run: snapshot min (1000) plus fee (710) is well below - // the deposit (2000000) -- must accept. - _, shouldExecute, err := task.Run(request) - if err != nil { - t.Fatalf("unexpected error on first run: [%v]", err) - } - if !shouldExecute { - t.Fatalf("expected shouldExecute=true on first run, got false") - } - - // Raise the live ReservationMinAmount above the deposit's value. - // The snapshotted MinAmount on the seeded action (1000) is - // unchanged, so the deposit must still pass the gate on the - // second run. - ralc.SetReservationParameters(tbtc.ReservationParameters{ - ReservationVault: testReservationVaultAddress, - ReservationMinAmount: 3000000, - ReservationTxMaxFee: 5000, - MaxReservationsPerWallet: 5, - ReservationMaxTotalAmount: 100000000, - }) - - proposal, shouldExecute, err := task.Run(request) - if err != nil { - t.Fatalf("unexpected error on second run: [%v]", err) - } - if !shouldExecute { - t.Fatalf( - "expected shouldExecute=true on second run after raising " + - "the live ReservationMinAmount above the deposit's " + - "value -- the snapshot min (1000) still clears the " + - "gate, so a false here means the live value was used", - ) - } - if proposal == nil { - t.Fatalf("expected a non-nil proposal on second run") - } - }) - - t.Run("operator never requests acceptance; the depositor's pending action is consumed", func(t *testing.T) { - btcChain := tbtcpg.NewLocalBitcoinChain() + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) + deposit := addPendingDeposit( + t, + ralc, + btcChain, + testWalletPublicKeyHash, + defaultPendingDepositOptions(currentBlock), + ) - ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, nil) + task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) - fundingTxHash := setupEligibleDeposit( - t, - ralc, - btcChain, - walletPublicKeyHash, - currentBlock, - 2000000, - ) + proposal, shouldExecute, err := runExistingTask(t, task, testWalletPublicKeyHash) + expectProposalFor(t, proposal, shouldExecute, err, deposit, 1) - // Seed a Pending Acceptance action the depositor would have - // requested via requestReservationAcceptance. Production now - // consumes that record rather than issuing a fresh one. - seedPendingAcceptanceAction( - t, - ralc, - fundingTxHash, - 0, - walletPublicKeyHash, - 1, - 5000, - 1000, - 86400, - ) + // Raise the live ReservationMinAmount above the deposit's value. The + // snapshotted MinAmount on the seeded action (1000) is unchanged, so + // the deposit must still pass the gate on the second run. + params := defaultTestReservationParameters() + params.ReservationMinAmount = 3000000 + ralc.SetReservationParameters(params) - task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) - request := &tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: walletPublicKeyHash, - } - - proposal, shouldExecute, err := task.Run(request) - if err != nil { - t.Fatalf("unexpected error on first run: [%v]", err) - } - if !shouldExecute || proposal == nil { - t.Fatalf("expected shouldExecute=true on first run") - } - anchorProposal, ok := proposal.(*tbtc.ReservationAnchorProposal) - if !ok { - t.Fatalf("expected *ReservationAnchorProposal, got %T", proposal) - } - if anchorProposal.RequestNonce != 1 { - t.Fatalf( - "expected proposal to carry the generation's actual "+ - "nonce (1), got [%d] -- a value of N+1 means the "+ - "task invented the next nonce instead of consuming "+ - "the depositor's action", - anchorProposal.RequestNonce, - ) - } - }) + proposal, shouldExecute, err = runExistingTask(t, task, testWalletPublicKeyHash) + expectProposalFor(t, proposal, shouldExecute, err, deposit, 1) } // TestReservationAcceptanceTask_BoundaryChecks exercises explicit -// at-limit/one-over-limit boundary crossings for the snapshotted -// ReservationMinAmount the acceptance candidate enforces against its -// own action record (the gross gate is depositAmount >= ReservationMinAmount, -// and the net-of-fee gate is depositAmount >= MinAmount + fee). The -// request-time capacity caps (active count, per-wallet count and -// amount, single amount, global total) are no longer re-checked here: -// Solidity's requestReservationAcceptance already reserved them when -// the Pending Acceptance generation was created. +// at-limit/one-over-limit boundary crossings for the snapshotted minimum +// the acceptance candidate enforces against its own action record +// (depositAmount >= MinAmount + anchor fee). A generation below that bound +// cannot be created on-chain (requestReservationAcceptance requires +// amount >= MinAmount + txMaxFee and the anchor fee never exceeds +// txMaxFee), so the rejecting rows pin a defensive check. The +// request-time capacity caps are not re-checked here: Solidity's +// requestReservationAcceptance already reserved them. func TestReservationAcceptanceTask_BoundaryChecks(t *testing.T) { tests := map[string]struct { depositAmount uint64 @@ -1786,18 +1398,10 @@ func TestReservationAcceptanceTask_BoundaryChecks(t *testing.T) { reservationMaxTotal: 100000000, expectAccept: true, }, - // checkReservationAcceptanceEligibility's gross-amount gate only - // requires depositAmount >= reservationMinAmount, but - // proposeReservationAcceptance additionally requires the - // *net-of-fee* anchor value (deposit - anchorFee) to also clear - // reservationMinAmount. Even though the test fixture sets a 1 sat/vByte - // oracle rate, applyWalletTxFeeFloor (see fee.go) clamps the rate to - // minWalletTxSatPerVByteFee (5 sat/vByte), resulting in a 710 sat fee - // (5 * 142 vsize = testAnchorFeeSat). Deposit amounts are offset by + // The fixture's 1 sat/vByte oracle rate is clamped by + // applyWalletTxFeeFloor (see fee.go) to 5 sat/vByte, a 710 sat + // fee (testAnchorFeeSat). Deposit amounts are offset by // testAnchorFeeSat to test the exact net-of-fee boundary. - // The snapshot min on the seeded action mirrors the live - // ReservationMinAmount so the gate is governed by the row's - // value, not by the previous row's. "ReservationMinAmount: exactly at minimum accepts": { depositAmount: 100000 + testAnchorFeeSat, maxReservationsPerWallet: 5, @@ -1831,83 +1435,38 @@ func TestReservationAcceptanceTask_BoundaryChecks(t *testing.T) { for testName, test := range tests { t.Run(testName, func(t *testing.T) { - ralc := newReservationAcceptanceLocalChain() btcChain := tbtcpg.NewLocalBitcoinChain() - btcChain.SetEstimateSatPerVByteFee(1, 1) - - walletPublicKeyHash := hexToByte20( - "8db50eb52063ea9d98b3eac91489a90f738986f6", - ) - - ralc.SetReservationParameters(tbtc.ReservationParameters{ - ReservationVault: testReservationVaultAddress, - ReservationMinAmount: test.reservationMinAmount, - ReservationTxMaxFee: 5000, - MaxReservationsPerWallet: test.maxReservationsPerWallet, - ReservationMaxTotalAmount: test.reservationMaxTotal, - ReservationTotalAmount: test.reservationTotal, - }) - ralc.maxPerWalletAmount = 50000000 - if test.maxPerWalletAmount != nil { - ralc.maxPerWalletAmount = *test.maxPerWalletAmount - } - ralc.maxSingleAmount = 50000000 - if test.maxSingleAmount != nil { - ralc.maxSingleAmount = *test.maxSingleAmount - } - ralc.maxActive = 100 - if test.maxActive != nil { - ralc.maxActive = *test.maxActive - } - ralc.activeCount = test.activeCount - ralc.walletReservationsAmount = test.walletReservationsAmount - ralc.walletReservationsCount = test.walletReservationsCount - - ralc.SetDepositMinAge(3600) - ralc.SetWallet( - walletPublicKeyHash, - &tbtc.WalletChainData{State: tbtc.StateLive}, - ) - currentBlock := uint64(300000) - blockCounter := tbtcpg.NewMockBlockCounter() - blockCounter.SetCurrentBlock(currentBlock) - ralc.SetBlockCounter(blockCounter) - - fundingTxHash := setupEligibleDeposit( - t, - ralc, - btcChain, - walletPublicKeyHash, - currentBlock, - test.depositAmount, - ) - // Seed a Pending Acceptance action with the row's snapshot - // min so this test exercises the per-row cap configuration, - // not the previous row's leftover state. Reject rows trip a - // cap gate before the action lookup, so the snapshot value - // only matters for the accept rows; seeding it consistently - // keeps the driver uniform. - seedPendingAcceptanceAction( - t, - ralc, - fundingTxHash, - 0, - walletPublicKeyHash, - 1, - 5000, - test.reservationMinAmount, - 86400, - ) - - task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, func(ralc *reservationAcceptanceLocalChain) { + params := defaultTestReservationParameters() + params.ReservationMinAmount = test.reservationMinAmount + params.MaxReservationsPerWallet = test.maxReservationsPerWallet + params.ReservationMaxTotalAmount = test.reservationMaxTotal + params.ReservationTotalAmount = test.reservationTotal + ralc.SetReservationParameters(params) + ralc.maxPerWalletAmount = 50000000 + if test.maxPerWalletAmount != nil { + ralc.maxPerWalletAmount = *test.maxPerWalletAmount + } + ralc.maxSingleAmount = 50000000 + if test.maxSingleAmount != nil { + ralc.maxSingleAmount = *test.maxSingleAmount + } + if test.maxActive != nil { + ralc.maxActive = *test.maxActive + } + ralc.activeCount = test.activeCount + ralc.walletReservationsAmount = test.walletReservationsAmount + ralc.walletReservationsCount = test.walletReservationsCount + }) - request := &tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: walletPublicKeyHash, - } + opts := defaultPendingDepositOptions(currentBlock) + opts.amount = test.depositAmount + opts.minAmount = test.reservationMinAmount + addPendingDeposit(t, ralc, btcChain, testWalletPublicKeyHash, opts) - _, shouldExecute, err := task.Run(request) + _, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) if err != nil { t.Fatalf("unexpected error: [%v]", err) } @@ -1923,146 +1482,55 @@ func TestReservationAcceptanceTask_BoundaryChecks(t *testing.T) { } // TestReservationAcceptanceTask_Stateless_NoReRequest pins the two -// surviving "no double-acceptance" cases after the operator-side -// RequestReservationAcceptance path was removed. The acceptance task -// consumes, rather than creates, the depositor's request record, so a -// double-proposal can no longer be caused by repeated operator requests: -// it can only happen if the task builds a proposal for a generation it -// is not allowed to anchor. The two cases below cover that: -// - a Pending Acceptance action that targets another wallet must not -// be consumed by this operator; -// - a Settled (or otherwise non-Pending) action at the current nonce -// must not be re-anchored. +// "no double-acceptance" cases: the acceptance task consumes, rather than +// creates, the depositor's request record, so a double proposal can only +// happen if it builds a proposal for a generation it may not anchor: +// - a Pending Acceptance action that targets another wallet; +// - a Settled (or otherwise non-Pending) action at the current nonce. func TestReservationAcceptanceTask_Stateless_NoReRequest(t *testing.T) { - btcChain := tbtcpg.NewLocalBitcoinChain() - - walletPublicKeyHash := hexToByte20( - "8db50eb52063ea9d98b3eac91489a90f738986f6", - ) otherWalletPublicKeyHash := hexToByte20( "7c1c4dbaaf8d75b08f9ea8e3f9a2c3a98e1f0d77", ) - currentBlock := uint64(300000) - ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, nil) - - fundingTxHash := hashFromString( - "6666666666666666666666666666666666666666666666666666666666666666", - ) - dummyTx := &bitcoin.Transaction{ - Outputs: []*bitcoin.TransactionOutput{{ - Value: 0, - PublicKeyScript: append([]byte{0x00, 0x20}, make([]byte, 32)...), - }}, - } - btcChain.SetTransaction(fundingTxHash, dummyTx) - btcChain.SetEstimateSatPerVByteFee(1, 1) - btcChain.SetTransactionConfirmations( - fundingTxHash, - tbtc.DepositSweepRequiredFundingTxConfirmations, - ) - - ralc.SetDepositRequest( - fundingTxHash, - 0, - &tbtc.DepositChainRequest{ - Depositor: chain.Address("934b98637ca318a4d6e7ca6ffd1690b8e77df637"), - Amount: 2000000, - RevealedAt: time.Now().Add(-2 * time.Hour), - SweptAt: time.Unix(0, 0), - Vault: &[]chain.Address{testReservationVaultAddress}[0], - }, - ) - - filterStartBlock := uint64(0) - if currentBlock > tbtcpg.ReservationAcceptanceLookBackBlocks { - filterStartBlock = currentBlock - tbtcpg.ReservationAcceptanceLookBackBlocks - } - - if err := ralc.AddPastDepositRevealedEvent( - &tbtc.DepositRevealedEventFilter{ - StartBlock: filterStartBlock, - EndBlock: ¤tBlock, - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + tests := map[string]func(action *tbtc.ReservationAction){ + "pending acceptance targeting another wallet is not consumed": func(action *tbtc.ReservationAction) { + action.TargetWalletPublicKeyHash = otherWalletPublicKeyHash }, - &tbtc.DepositRevealedEvent{ - BlockNumber: 290000, - WalletPublicKeyHash: walletPublicKeyHash, - FundingTxHash: fundingTxHash, - FundingOutputIndex: 0, - Vault: &[]chain.Address{testReservationVaultAddress}[0], + "settled generation is not re-anchored": func(action *tbtc.ReservationAction) { + action.State = tbtc.ReservationActionStateSettled }, - ); err != nil { - t.Fatal(err) } - task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) - request := &tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: walletPublicKeyHash, - } - - depositKey := ralc.BuildDepositKey(fundingTxHash, 0) - - t.Run("pending acceptance targeting another wallet is not consumed", func(t *testing.T) { - ralc.SetReservation(depositKey, &tbtc.Reservation{ - WalletPublicKeyHash: walletPublicKeyHash, - State: tbtc.ReservationStateUnknown, - RequestNonce: 1, - }) - ralc.SetReservationAction(depositKey, 1, &tbtc.ReservationAction{ - ActionType: tbtc.ReservationActionTypeAcceptance, - State: tbtc.ReservationActionStatePending, - TargetWalletPublicKeyHash: otherWalletPublicKeyHash, - TxMaxFee: 5000, - MinAmount: 1000, - TermSeconds: 86400, - }) - - proposal, shouldExecute, err := task.Run(request) - if err != nil { - t.Fatalf("unexpected error: [%v]", err) - } - if shouldExecute || proposal != nil { - t.Fatalf( - "expected no proposal for a pending acceptance generation " + - "authorizing a different wallet", + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + btcChain := tbtcpg.NewLocalBitcoinChain() + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) + deposit := addPendingDeposit( + t, + ralc, + btcChain, + testWalletPublicKeyHash, + defaultPendingDepositOptions(currentBlock), ) - } - }) + action, err := ralc.GetReservationAction(deposit.depositKey, 1) + if err != nil { + t.Fatal(err) + } + mutated := *action + mutate(&mutated) + ralc.SetReservationAction(deposit.depositKey, 1, &mutated) - t.Run("settled generation is not re-anchored", func(t *testing.T) { - ralc.SetReservation(depositKey, &tbtc.Reservation{ - WalletPublicKeyHash: walletPublicKeyHash, - State: tbtc.ReservationStateUnknown, - RequestNonce: 1, - }) - ralc.SetReservationAction(depositKey, 1, &tbtc.ReservationAction{ - ActionType: tbtc.ReservationActionTypeAcceptance, - State: tbtc.ReservationActionStateSettled, - TargetWalletPublicKeyHash: walletPublicKeyHash, - TxMaxFee: 5000, - MinAmount: 1000, - TermSeconds: 86400, + proposal, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) + expectNoProposal(t, proposal, shouldExecute, err) }) - - proposal, shouldExecute, err := task.Run(request) - if err != nil { - t.Fatalf("unexpected error: [%v]", err) - } - if shouldExecute || proposal != nil { - t.Fatalf( - "expected no proposal for a Settled generation -- the " + - "task must not re-anchor a generation whose action " + - "is no longer Pending", - ) - } - }) + } } -// TestReservationAcceptanceTask_Stateless_NonEligibleReservationState verifies that -// a reservation whose on-chain state is Active, ActionPending, Closed, or Stranded -// is skipped from acceptance proposals. +// TestReservationAcceptanceTask_Stateless_NonEligibleReservationState +// verifies that a reservation whose on-chain state is Active, +// ActionPending, Closed, or Stranded, with no pending acceptance action +// at its current nonce, is skipped from acceptance proposals. func TestReservationAcceptanceTask_Stateless_NonEligibleReservationState(t *testing.T) { nonEligibleStates := []tbtc.ReservationState{ tbtc.ReservationStateActive, @@ -2074,81 +1542,19 @@ func TestReservationAcceptanceTask_Stateless_NonEligibleReservationState(t *test for _, state := range nonEligibleStates { t.Run(fmt.Sprintf("state_%v", state), func(t *testing.T) { btcChain := tbtcpg.NewLocalBitcoinChain() - - walletPublicKeyHash := hexToByte20( - "8db50eb52063ea9d98b3eac91489a90f738986f6", - ) currentBlock := uint64(300000) - ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, nil) - - fundingTxHash := hashFromString( - "8888888888888888888888888888888888888888888888888888888888888888", - ) - dummyTx := &bitcoin.Transaction{ - Outputs: []*bitcoin.TransactionOutput{{ - Value: 0, - PublicKeyScript: append([]byte{0x00, 0x20}, make([]byte, 32)...), - }}, - } - btcChain.SetTransaction(fundingTxHash, dummyTx) - btcChain.SetEstimateSatPerVByteFee(1, 1) - btcChain.SetTransactionConfirmations( - fundingTxHash, - tbtc.DepositSweepRequiredFundingTxConfirmations, - ) - - ralc.SetDepositRequest( - fundingTxHash, - 0, - &tbtc.DepositChainRequest{ - Amount: 2000000, - RevealedAt: time.Now().Add(-2 * time.Hour), - SweptAt: time.Unix(0, 0), - Vault: &[]chain.Address{testReservationVaultAddress}[0], - }, - ) - - filterStartBlock := uint64(0) - if currentBlock > tbtcpg.ReservationAcceptanceLookBackBlocks { - filterStartBlock = currentBlock - tbtcpg.ReservationAcceptanceLookBackBlocks - } - - if err := ralc.AddPastDepositRevealedEvent( - &tbtc.DepositRevealedEventFilter{ - StartBlock: filterStartBlock, - EndBlock: ¤tBlock, - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - }, - &tbtc.DepositRevealedEvent{ - BlockNumber: 290000, - WalletPublicKeyHash: walletPublicKeyHash, - FundingTxHash: fundingTxHash, - FundingOutputIndex: 0, - Vault: &[]chain.Address{testReservationVaultAddress}[0], - }, - ); err != nil { - t.Fatal(err) - } - - depositKey := ralc.BuildDepositKey(fundingTxHash, 0) - ralc.SetReservation(depositKey, &tbtc.Reservation{ + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) + opts := defaultPendingDepositOptions(currentBlock) + opts.withoutAction = true + deposit := addPendingDeposit(t, ralc, btcChain, testWalletPublicKeyHash, opts) + ralc.SetReservation(deposit.depositKey, &tbtc.Reservation{ State: state, - RequestNonce: 1, + RequestNonce: 2, }) - task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) - request := &tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: walletPublicKeyHash, - } - - proposal, shouldExecute, err := task.Run(request) - if err != nil { - t.Fatalf("unexpected task error: [%v]", err) - } - if shouldExecute || proposal != nil { - t.Fatalf("expected candidate with state %v to be skipped", state) - } + proposal, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) + expectNoProposal(t, proposal, shouldExecute, err) }) } } @@ -2157,228 +1563,75 @@ func TestReservationAcceptanceTask_Stateless_NonEligibleReservationState(t *test // each generation of a reservation carries its own snapshotted minimum // amount: a deposit whose first generation's snapshot is above the // deposit is skipped for that generation, but a fresh generation written -// after governance lowered the live minimum (so the new snapshot clears -// the deposit) becomes eligible and is consumed at its own real nonce. +// after governance lowered the live minimum becomes eligible and is +// consumed at its own real nonce. func TestReservationAcceptanceTask_Stateless_DynamicMinAmount(t *testing.T) { btcChain := tbtcpg.NewLocalBitcoinChain() - - walletPublicKeyHash := hexToByte20( - "8db50eb52063ea9d98b3eac91489a90f738986f6", - ) currentBlock := uint64(300000) - ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, func(ralc *reservationAcceptanceLocalChain) { - ralc.SetReservationParameters(tbtc.ReservationParameters{ - ReservationVault: testReservationVaultAddress, - ReservationMinAmount: 5000000, - ReservationTxMaxFee: 5000, - MaxReservationsPerWallet: 5, - ReservationMaxTotalAmount: 100000000, - }) - ralc.maxPerWalletAmount = 50000000 - ralc.maxSingleAmount = 50000000 - }) - - fundingTxHash := hashFromString( - "9999999999999999999999999999999999999999999999999999999999999999", - ) - dummyTx := &bitcoin.Transaction{ - Outputs: []*bitcoin.TransactionOutput{{ - Value: 0, - PublicKeyScript: append([]byte{0x00, 0x20}, make([]byte, 32)...), - }}, - } - btcChain.SetTransaction(fundingTxHash, dummyTx) - btcChain.SetEstimateSatPerVByteFee(1, 1) - btcChain.SetTransactionConfirmations( - fundingTxHash, - tbtc.DepositSweepRequiredFundingTxConfirmations, - ) - - // Deposit amount is 2,000,000 (below the first generation's - // snapshotted minimum of 5,000,000). - ralc.SetDepositRequest( - fundingTxHash, - 0, - &tbtc.DepositChainRequest{ - Depositor: chain.Address("934b98637ca318a4d6e7ca6ffd1690b8e77df637"), - Amount: 2000000, - RevealedAt: time.Now().Add(-2 * time.Hour), - SweptAt: time.Unix(0, 0), - Vault: &[]chain.Address{testReservationVaultAddress}[0], - }, - ) - - filterStartBlock := uint64(0) - if currentBlock > tbtcpg.ReservationAcceptanceLookBackBlocks { - filterStartBlock = currentBlock - tbtcpg.ReservationAcceptanceLookBackBlocks - } - - if err := ralc.AddPastDepositRevealedEvent( - &tbtc.DepositRevealedEventFilter{ - StartBlock: filterStartBlock, - EndBlock: ¤tBlock, - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - }, - &tbtc.DepositRevealedEvent{ - BlockNumber: 290000, - WalletPublicKeyHash: walletPublicKeyHash, - FundingTxHash: fundingTxHash, - FundingOutputIndex: 0, - Vault: &[]chain.Address{testReservationVaultAddress}[0], - }, - ); err != nil { - t.Fatal(err) - } - - depositKey := ralc.BuildDepositKey(fundingTxHash, 0) + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) // First generation: snapshot min (5,000,000) is above the deposit // (2,000,000); the candidate must be skipped. - ralc.SetReservation(depositKey, &tbtc.Reservation{ - WalletPublicKeyHash: walletPublicKeyHash, - State: tbtc.ReservationStateUnknown, - RequestNonce: 1, - }) - ralc.SetReservationAction(depositKey, 1, &tbtc.ReservationAction{ - ActionType: tbtc.ReservationActionTypeAcceptance, - State: tbtc.ReservationActionStatePending, - TargetWalletPublicKeyHash: walletPublicKeyHash, - TxMaxFee: 5000, - MinAmount: 5000000, - TermSeconds: 86400, - // Far-future TimeoutAt so the timeout safety-margin gate is - // not the reason generation 1 is skipped: the subject of this - // test is the snapshotted minimum, and generation 1's snapshot - // (5,000,000) is what must reject the 2,000,000 deposit. - TimeoutAt: uint32(time.Now().Add(24 * time.Hour).Unix()), - }) + opts := defaultPendingDepositOptions(currentBlock) + opts.minAmount = 5000000 + deposit := addPendingDeposit(t, ralc, btcChain, testWalletPublicKeyHash, opts) task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) - request := &tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: walletPublicKeyHash, - } - - proposal, shouldExecute, err := task.Run(request) - if err != nil { - t.Fatalf("first run error: [%v]", err) - } - if shouldExecute || proposal != nil { - t.Fatalf( - "expected no proposal when the first generation's snapshotted " + - "minimum (5,000,000) is above the deposit (2,000,000)", - ) - } - // Governance lowers the live minimum. The depositor issues a fresh - // request -- production bumps the reservation's RequestNonce and - // writes a new action record carrying the new snapshot -- and the - // candidate becomes eligible. - ralc.SetReservationParameters(tbtc.ReservationParameters{ - ReservationVault: testReservationVaultAddress, - ReservationMinAmount: 1000000, - ReservationTxMaxFee: 5000, - MaxReservationsPerWallet: 5, - ReservationMaxTotalAmount: 100000000, - }) + proposal, shouldExecute, err := runExistingTask(t, task, testWalletPublicKeyHash) + expectNoProposal(t, proposal, shouldExecute, err) - ralc.SetReservation(depositKey, &tbtc.Reservation{ - WalletPublicKeyHash: walletPublicKeyHash, + // The depositor issues a fresh request: the reservation's nonce is + // bumped, a new action record carries the new snapshot, and a new + // ReservationAcceptanceRequested event is emitted. + timeoutAt := uint32(time.Now().Add(24 * time.Hour).Unix()) + ralc.SetReservation(deposit.depositKey, &tbtc.Reservation{ + WalletPublicKeyHash: testWalletPublicKeyHash, State: tbtc.ReservationStateUnknown, RequestNonce: 2, }) - ralc.SetReservationAction(depositKey, 2, &tbtc.ReservationAction{ + ralc.SetReservationAction(deposit.depositKey, 2, &tbtc.ReservationAction{ ActionType: tbtc.ReservationActionTypeAcceptance, State: tbtc.ReservationActionStatePending, - TargetWalletPublicKeyHash: walletPublicKeyHash, + TargetWalletPublicKeyHash: testWalletPublicKeyHash, TxMaxFee: 5000, MinAmount: 1000000, - TermSeconds: 86400, - // Far-future TimeoutAt so the timeout safety-margin gate does - // not reject the second generation. - TimeoutAt: uint32(time.Now().Add(24 * time.Hour).Unix()), + TermSeconds: testReservationTermSeconds, + TimeoutAt: timeoutAt, + }) + ralc.acceptanceEvents = append(ralc.acceptanceEvents, &tbtc.ReservationAcceptanceRequestedEvent{ + ReservationKey: deposit.depositKey, + RequestNonce: 2, + WalletPublicKeyHash: testWalletPublicKeyHash, + DepositAmount: 2000000, + TxMaxFee: 5000, + TimeoutAt: timeoutAt, + BlockNumber: currentBlock - 5, }) - proposal, shouldExecute, err = task.Run(request) - if err != nil { - t.Fatalf("second run error: [%v]", err) - } - if !shouldExecute || proposal == nil { - t.Fatalf( - "expected a proposal on the second run after governance " + - "lowered the live minimum and the depositor issued a new " + - "generation", - ) - } - anchorProposal, ok := proposal.(*tbtc.ReservationAnchorProposal) - if !ok { - t.Fatalf("expected *ReservationAnchorProposal, got %T", proposal) - } - if anchorProposal.RequestNonce != 2 { - t.Fatalf( - "expected the proposal to carry the second generation's actual "+ - "nonce (2), got [%d]", - anchorProposal.RequestNonce, - ) - } + proposal, shouldExecute, err = runExistingTask(t, task, testWalletPublicKeyHash) + expectProposalFor(t, proposal, shouldExecute, err, deposit, 2) } -// TestReservationAcceptanceTask_Stateless_RequestNonceIncremented verified -// that an existing reservation record at RequestNonce N produced a -// proposal at RequestNonce N + 1 -- the operator-side -// RequestReservationAcceptance path that was removed in the cross-repo -// review. The acceptance task now consumes, rather than creates, the -// depositor's action record: a successful proposal always carries the -// reservation's current RequestNonce, never an invented N + 1. That -// behavior is pinned by: -// -// - TestReservationAcceptanceTask_Run/happy_path (the JSON scenario 0), -// whose ExpectedAnchorProposal.RequestNonce equals the seeded -// action's nonce (1); -// - TestReservationAcceptanceTask_ReservationParametersFetchedLive, -// which asserts the proposal's RequestNonce equals the generation's -// real nonce (1) when the operator-side path is in scope; -// - TestReservationAcceptanceTask_Stateless_DynamicMinAmount, which -// pins RequestNonce = 2 once the depositor issues a second -// generation. -// -// The strict-fake corollary -- a proposal at nonce + 1 is rejected by -// LocalChain.ValidateReservationAnchorProposal, the precondition the -// production validator mirrors -- is exercised by -// TestLocalChain_ValidateReservationAnchorProposal_StrictNonceGate. - -// TestReservationAcceptanceTask_PastDepositRevealedEventsError verifies that -// a genuine (non-sentinel) error from PastDepositRevealedEvents is -// propagated as a hard error, rather than being swallowed like the mock's -// "no events for given filter" sentinel. -func TestReservationAcceptanceTask_PastDepositRevealedEventsError(t *testing.T) { +// TestReservationAcceptanceTask_AcceptanceRequestedEventsError verifies +// that a failure to fetch the wallet's acceptance requests aborts the Run +// with an error, rather than being reported as "no candidate". +func TestReservationAcceptanceTask_AcceptanceRequestedEventsError(t *testing.T) { btcChain := tbtcpg.NewLocalBitcoinChain() - - walletPublicKeyHash := hexToByte20( - "8db50eb52063ea9d98b3eac91489a90f738986f6", - ) currentBlock := uint64(300000) - ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, nil) - - // Otherwise-eligible deposit; the injected error must still short - // circuit before any candidate is ever evaluated. - setupEligibleDeposit( + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) + addPendingDeposit( t, ralc, - btcChain, - walletPublicKeyHash, - currentBlock, - 2000000, - ) - - ralc.pastDepositRevealedEventsErr = fmt.Errorf("simulated rpc failure") - - task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + btcChain, + testWalletPublicKeyHash, + defaultPendingDepositOptions(currentBlock), + ) + ralc.acceptanceEventsErr = fmt.Errorf("simulated rpc failure") - _, shouldExecute, err := task.Run(&tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: walletPublicKeyHash, - }) + _, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) if err == nil { t.Fatalf("expected a non-nil error, got nil") } @@ -2387,89 +1640,75 @@ func TestReservationAcceptanceTask_PastDepositRevealedEventsError(t *testing.T) } } -// TestReservationAcceptanceTask_ValidateProposalError verifies that a -// ValidateReservationAnchorProposal failure is treated as a pre-write -// failure (see reservationAcceptancePreWriteError): the doomed candidate -// is skipped rather than aborting the whole coordination window, so with -// no other candidate available Run reports a clean no-op instead of an -// error. -func TestReservationAcceptanceTask_ValidateProposalError(t *testing.T) { +// TestReservationAcceptanceTask_RevealLookupErrorSkipsCandidate verifies +// that a failed DepositRevealed lookup for one candidate skips that +// candidate for the window instead of aborting the Run. +func TestReservationAcceptanceTask_RevealLookupErrorSkipsCandidate(t *testing.T) { btcChain := tbtcpg.NewLocalBitcoinChain() - - walletPublicKeyHash := hexToByte20( - "8db50eb52063ea9d98b3eac91489a90f738986f6", - ) currentBlock := uint64(300000) - ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, nil) - - fundingTxHash := setupEligibleDeposit( + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) + addPendingDeposit( t, ralc, btcChain, - walletPublicKeyHash, - currentBlock, - 2000000, + testWalletPublicKeyHash, + defaultPendingDepositOptions(currentBlock), ) + ralc.pastDepositRevealedEventsErr = fmt.Errorf("simulated rpc failure") + + proposal, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) + expectNoProposal(t, proposal, shouldExecute, err) +} + +// TestReservationAcceptanceTask_ValidateProposalError verifies that a +// ValidateReservationAnchorProposal failure skips the candidate rather +// than aborting the coordination window: with no other candidate, Run +// reports a clean no-op instead of an error. +func TestReservationAcceptanceTask_ValidateProposalError(t *testing.T) { + btcChain := tbtcpg.NewLocalBitcoinChain() + currentBlock := uint64(300000) - // Seed a Pending Acceptance action so the candidate actually - // reaches proposeReservationAcceptance and the validate call fires. - seedPendingAcceptanceAction( + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) + addPendingDeposit( t, ralc, - fundingTxHash, - 0, - walletPublicKeyHash, - 1, - 5000, - 1000, - 86400, + btcChain, + testWalletPublicKeyHash, + defaultPendingDepositOptions(currentBlock), ) - ralc.validateErr = fmt.Errorf("simulated validation failure") - task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) - - proposal, shouldExecute, err := task.Run(&tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: walletPublicKeyHash, - }) - if err != nil { - t.Fatalf("unexpected error: [%v]", err) - } - if shouldExecute { - t.Errorf("expected shouldExecute=false, got true") - } - if proposal != nil { - t.Errorf("expected nil proposal, got %v", proposal) + proposal, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) + expectNoProposal(t, proposal, shouldExecute, err) + if ralc.validateCalls != 1 { + t.Errorf("expected the validator to be called once, got %d", ralc.validateCalls) } } -// TestLocalChain_ValidateReservationAnchorProposal_StrictNonceGate is a -// regression test for the action-record precondition -// WalletProposalValidator.sol's validateReservationAnchorProposal enforces: -// the action at the proposal's RequestNonce must already be a Pending -// Acceptance action targeting this wallet. Production sets the proposal's -// RequestNonce from the action record it just read, so the validator -// always agrees -- but a regression that let production invent a nonce -// (the pre-fix operator-side request path) would silently pass against a -// lenient fake. This test exercises the underlying fake validator -// directly: a proposal at the seeded nonce passes, while nonce + 1 is -// rejected with the precondition message. -func TestLocalChain_ValidateReservationAnchorProposal_StrictNonceGate(t *testing.T) { - lc := tbtcpg.NewLocalChain() - - walletPublicKeyHash := [20]byte{0x8d, 0xb5, 0x0e, 0xb5, 0x20, 0x63, 0xea, 0x9d, 0x98, 0xb3, 0xea, 0xc9, 0x14, 0x89, 0xa9, 0x0f, 0x73, 0x89, 0x86, 0xf6} +// strictValidatorFixture seeds a bare LocalChain with everything its +// strict anchor validator checks for a reserved deposit revealed to +// walletPublicKeyHash, with a Pending Acceptance action at nonce 2, and +// returns a valid proposal and its deposit extra info. +func strictValidatorFixture( + t *testing.T, + lc *tbtcpg.LocalChain, + walletPublicKeyHash [20]byte, +) ( + *tbtc.ReservationAnchorProposal, + struct { + *tbtc.Deposit + FundingTx *bitcoin.Transaction + }, + *big.Int, +) { + t.Helper() - fundingTxHash := bitcoin.Hash{0xab} - fundingOutputIndex := uint32(0) - // The validator keys action records by the deposit key derived from - // the proposal's funding outpoint, not by some pre-chosen big.Int; - // build the key through the same helper so the seed and the - // validator agree. - depositKey := lc.BuildDepositKey(fundingTxHash, fundingOutputIndex) + vault := testReservationVaultAddress + deposit, fundingTx := buildReservedDeposit(t, walletPublicKeyHash, 2000000, 0x33, vault) + fundingTxHash := fundingTx.Hash() + depositKey := lc.BuildDepositKey(fundingTxHash, 0) - // Seed a Pending Acceptance action at nonce 2 -- the canonical - // generation the production task would consume. lc.SetReservation(depositKey, &tbtc.Reservation{ WalletPublicKeyHash: walletPublicKeyHash, State: tbtc.ReservationStateUnknown, @@ -2481,73 +1720,65 @@ func TestLocalChain_ValidateReservationAnchorProposal_StrictNonceGate(t *testing TargetWalletPublicKeyHash: walletPublicKeyHash, TxMaxFee: 5000, MinAmount: 1000, - TermSeconds: 86400, - // Far-future TimeoutAt so the fake validator's timeout - // safety-margin gate does not reject the OK proposal; the - // strict-nonce precondition is the only gate under test. - TimeoutAt: uint32(time.Now().Add(24 * time.Hour).Unix()), + TermSeconds: testReservationTermSeconds, + TimeoutAt: uint32(time.Now().Add(24 * time.Hour).Unix()), }) - // Seed the remaining state the strict fake mirrors from the - // on-chain validator: a Live wallet, a reserved deposit revealed - // through the reservation vault, and the configured parameters. - vault := testReservationVaultAddress lc.SetWallet(walletPublicKeyHash, &tbtc.WalletChainData{State: tbtc.StateLive}) lc.SetReservationParameters(tbtc.ReservationParameters{ ReservationVault: vault, }) - lc.SetDepositRequest( - fundingTxHash, - fundingOutputIndex, - &tbtc.DepositChainRequest{ - Amount: 2000000, - RevealedAt: time.Now().Add(-48 * time.Hour), - Vault: &vault, - }, - ) + lc.SetDepositRequest(fundingTxHash, 0, &tbtc.DepositChainRequest{ + Depositor: testDepositor, + Amount: 2000000, + RevealedAt: time.Now().Add(-48 * time.Hour), + SweptAt: time.Unix(0, 0), + Vault: &vault, + }) lc.SetReservedDeposit(depositKey, true) - lc.SetDepositMinAge(3600) - - depositExtraInfo := struct { - *tbtc.Deposit - FundingTx *bitcoin.Transaction - }{ - Deposit: &tbtc.Deposit{ - WalletPublicKeyHash: walletPublicKeyHash, - // Far-future refund locktime, little-endian as stored on - // chain, so the 24-hour refund safety margin holds. - RefundLocktime: [4]byte{0x00, 0x79, 0xf7, 0x77}, - }, - } + lc.SetDepositMinAge(testDepositMinAgeSeconds) - proposalOK := &tbtc.ReservationAnchorProposal{ + proposal := &tbtc.ReservationAnchorProposal{ DepositFundingTxHash: fundingTxHash, - DepositFundingOutputIndex: fundingOutputIndex, + DepositFundingOutputIndex: 0, RequestNonce: 2, AnchorTxFee: big.NewInt(710), } + extraInfo := struct { + *tbtc.Deposit + FundingTx *bitcoin.Transaction + }{Deposit: deposit, FundingTx: fundingTx} + + return proposal, extraInfo, depositKey +} + +// TestLocalChain_ValidateReservationAnchorProposal_StrictNonceGate is a +// regression test for the action-record precondition +// WalletProposalValidator.sol's validateReservationAnchorProposal enforces: +// the action at the proposal's RequestNonce must already be a Pending +// Acceptance action targeting this wallet. A proposal at the seeded nonce +// passes, while nonce + 1 is rejected. +func TestLocalChain_ValidateReservationAnchorProposal_StrictNonceGate(t *testing.T) { + lc := tbtcpg.NewLocalChain() + proposal, extraInfo, _ := strictValidatorFixture(t, lc, testWalletPublicKeyHash) + if err := lc.ValidateReservationAnchorProposal( - walletPublicKeyHash, - proposalOK, - depositExtraInfo, + testWalletPublicKeyHash, + proposal, + extraInfo, ); err != nil { t.Fatalf( "strict fake rejected a proposal at the seeded action's nonce "+ - "(2): %v -- the validator must agree with the production "+ - "task's action-record lookup", + "(2): %v", err, ) } - proposalOff := &tbtc.ReservationAnchorProposal{ - DepositFundingTxHash: fundingTxHash, - DepositFundingOutputIndex: fundingOutputIndex, - RequestNonce: 3, - AnchorTxFee: big.NewInt(710), - } + proposalOff := *proposal + proposalOff.RequestNonce = 3 if err := lc.ValidateReservationAnchorProposal( - walletPublicKeyHash, - proposalOff, - depositExtraInfo, + testWalletPublicKeyHash, + &proposalOff, + extraInfo, ); err == nil { t.Fatalf( "strict fake accepted a proposal at nonce + 1 -- the " + @@ -2557,167 +1788,139 @@ func TestLocalChain_ValidateReservationAnchorProposal_StrictNonceGate(t *testing } } +// TestLocalChain_ValidateReservationAnchorProposal_DepositExtraInfo pins +// the strict fake's mirror of validateDepositExtraInfo: a funding +// transaction that does not hash to the proposal's funding transaction +// hash, or whose output does not carry the deposit script rebuilt from +// the extra info's reveal fields, is rejected. +func TestLocalChain_ValidateReservationAnchorProposal_DepositExtraInfo(t *testing.T) { + t.Run("another funding transaction", func(t *testing.T) { + lc := tbtcpg.NewLocalChain() + proposal, extraInfo, _ := strictValidatorFixture(t, lc, testWalletPublicKeyHash) + _, otherFundingTx := buildReservedDeposit( + t, + testWalletPublicKeyHash, + 2000000, + 0x44, + testReservationVaultAddress, + ) + extraInfo.FundingTx = otherFundingTx + + err := lc.ValidateReservationAnchorProposal( + testWalletPublicKeyHash, + proposal, + extraInfo, + ) + if err == nil || err.Error() != "extra info funding tx hash does not match" { + t.Fatalf("expected a funding tx hash mismatch, got [%v]", err) + } + }) + + t.Run("reveal fields that do not rebuild the locking script", func(t *testing.T) { + lc := tbtcpg.NewLocalChain() + proposal, extraInfo, _ := strictValidatorFixture(t, lc, testWalletPublicKeyHash) + wrongDeposit := *extraInfo.Deposit + wrongDeposit.BlindingFactor = [8]byte{0xff} + extraInfo.Deposit = &wrongDeposit + + err := lc.ValidateReservationAnchorProposal( + testWalletPublicKeyHash, + proposal, + extraInfo, + ) + if err == nil || err.Error() != "extra info funding output script does not match" { + t.Fatalf("expected a funding output script mismatch, got [%v]", err) + } + }) +} + // TestReservationAcceptanceTask_DepositWithoutPendingActionIsSkipped is a -// regression test for the action-record gate -// findReservationAcceptanceCandidate enforces: a deposit with no current -// Pending Acceptance action -- whether because the depositor never -// requested acceptance or because a prior generation has already settled -// -- is never proposed. A regression that fell back to the operator-side -// request path could accidentally trigger a request here, which would -// show up as a non-zero submission count. +// regression test for the action-record gate: a requested deposit whose +// current generation has no Pending Acceptance action -- because it timed +// out, settled, or its record is gone -- is never proposed. func TestReservationAcceptanceTask_DepositWithoutPendingActionIsSkipped(t *testing.T) { btcChain := tbtcpg.NewLocalBitcoinChain() - - walletPublicKeyHash := hexToByte20( - "8db50eb52063ea9d98b3eac91489a90f738986f6", - ) currentBlock := uint64(300000) - ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, nil) - - setupEligibleDeposit( - t, - ralc, - btcChain, - walletPublicKeyHash, - currentBlock, - 2000000, - ) - - // Deliberately no seedPendingAcceptanceAction call: the deposit's - // current generation is the zero record (no reservation, no action). - // The acceptance task must skip it -- it consumes the depositor's - // pending action, never requests one on the operator's behalf -- and - // return nil. - task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) + opts := defaultPendingDepositOptions(currentBlock) + opts.withoutAction = true + addPendingDeposit(t, ralc, btcChain, testWalletPublicKeyHash, opts) - proposal, shouldExecute, err := task.Run(&tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: walletPublicKeyHash, - }) - if err != nil { - t.Fatalf("unexpected error: [%v]", err) - } - if shouldExecute { - t.Errorf("expected shouldExecute=false for a deposit without a pending action, got true") - } - if proposal != nil { - t.Errorf("expected nil proposal, got %v", proposal) - } + proposal, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) + expectNoProposal(t, proposal, shouldExecute, err) } // TestReservationAcceptanceTask_SkipsCandidateInsideTimeoutSafetyMargin is a -// regression test for the timeout safety margin gate added in the cross-repo -// review: findReservationAcceptanceCandidate skips any candidate whose +// regression test for the timeout safety margin gate: a candidate whose // pending acceptance generation has TimeoutAt at or before -// now + REQUEST_TIMEOUT_SAFETY_MARGIN (2 hours). A candidate just outside -// the margin (TimeoutAt = now + 7202) must be found and proposed. The -// validator fake mirrors the same gate so the direct unit test covers -// both the task and the fake. +// now + REQUEST_TIMEOUT_SAFETY_MARGIN (2 hours) is skipped before any +// proposal is validated. The request event's own timeout drops such a +// generation without any chain read; the action record, which is +// authoritative, is checked again. A candidate comfortably outside the +// margin is proposed. func TestReservationAcceptanceTask_SkipsCandidateInsideTimeoutSafetyMargin(t *testing.T) { - t.Run("inside margin: skipped", func(t *testing.T) { - btcChain := tbtcpg.NewLocalBitcoinChain() - walletPublicKeyHash := hexToByte20( - "8db50eb52063ea9d98b3eac91489a90f738986f6", - ) - currentBlock := uint64(300000) - - ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, nil) - - fundingTxHash := setupEligibleDeposit( - t, - ralc, - btcChain, - walletPublicKeyHash, - currentBlock, - 2000000, - ) - - // TimeoutAt = now + 7198 seconds: the margin gate - // (now + 7200 >= TimeoutAt) fires and the candidate is skipped. - depositKey := ralc.BuildDepositKey(fundingTxHash, 0) - ralc.SetReservation(depositKey, &tbtc.Reservation{ - WalletPublicKeyHash: walletPublicKeyHash, - State: tbtc.ReservationStateUnknown, - RequestNonce: 0, - }) - ralc.SetReservationAction(depositKey, 0, &tbtc.ReservationAction{ - ActionType: tbtc.ReservationActionTypeAcceptance, - State: tbtc.ReservationActionStatePending, - TargetWalletPublicKeyHash: walletPublicKeyHash, - TxMaxFee: 5000, - MinAmount: 1000, - TermSeconds: 86400, - TimeoutAt: uint32(time.Now().Add(7198 * time.Second).Unix()), - }) - - task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + currentBlock := uint64(300000) - proposal, shouldExecute, err := task.Run(&tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: walletPublicKeyHash, - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if shouldExecute || proposal != nil { - t.Fatalf( - "expected no proposal for candidate inside the timeout "+ - "safety margin; got shouldExecute=%v", - shouldExecute, + t.Run("inside margin: skipped without chain reads", func(t *testing.T) { + btcChain := tbtcpg.NewLocalBitcoinChain() + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) + opts := defaultPendingDepositOptions(currentBlock) + opts.timeoutAt = uint32(time.Now().Add(7198 * time.Second).Unix()) + addPendingDeposit(t, ralc, btcChain, testWalletPublicKeyHash, opts) + + proposal, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) + expectNoProposal(t, proposal, shouldExecute, err) + if ralc.getReservationCalls != 0 { + t.Errorf( + "expected no reservation read for a request inside the "+ + "margin, got %d", + ralc.getReservationCalls, ) } + if ralc.validateCalls != 0 { + t.Errorf("expected no validation, got %d calls", ralc.validateCalls) + } }) - t.Run("just outside margin: proposed", func(t *testing.T) { + t.Run("action record inside margin: skipped", func(t *testing.T) { btcChain := tbtcpg.NewLocalBitcoinChain() - walletPublicKeyHash := hexToByte20( - "8db50eb52063ea9d98b3eac91489a90f738986f6", - ) - currentBlock := uint64(300000) - - ralc := newBoundaryTestChain(t, walletPublicKeyHash, currentBlock, nil) - - fundingTxHash := setupEligibleDeposit( + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) + deposit := addPendingDeposit( t, ralc, btcChain, - walletPublicKeyHash, - currentBlock, - 2000000, + testWalletPublicKeyHash, + defaultPendingDepositOptions(currentBlock), ) - - // TimeoutAt = now + 7202 seconds: the margin gate - // (now + 7200 >= TimeoutAt) does not fire and the candidate - // is found and proposed. - depositKey := ralc.BuildDepositKey(fundingTxHash, 0) - ralc.SetReservation(depositKey, &tbtc.Reservation{ - WalletPublicKeyHash: walletPublicKeyHash, - State: tbtc.ReservationStateUnknown, - RequestNonce: 0, - }) - ralc.SetReservationAction(depositKey, 0, &tbtc.ReservationAction{ - ActionType: tbtc.ReservationActionTypeAcceptance, - State: tbtc.ReservationActionStatePending, - TargetWalletPublicKeyHash: walletPublicKeyHash, - TxMaxFee: 5000, - MinAmount: 1000, - TermSeconds: 86400, - TimeoutAt: uint32(time.Now().Add(7202 * time.Second).Unix()), - }) - - task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) - - proposal, shouldExecute, err := task.Run(&tbtc.CoordinationProposalRequest{ - WalletPublicKeyHash: walletPublicKeyHash, - }) + action, err := ralc.GetReservationAction(deposit.depositKey, 1) if err != nil { - t.Fatalf("unexpected error: %v", err) + t.Fatal(err) } - if !shouldExecute || proposal == nil { - t.Fatalf( - "expected a proposal for candidate just outside the timeout "+ - "safety margin; got shouldExecute=%v", - shouldExecute, - ) + insideMargin := *action + insideMargin.TimeoutAt = uint32(time.Now().Add(7198 * time.Second).Unix()) + ralc.SetReservationAction(deposit.depositKey, 1, &insideMargin) + + proposal, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) + expectNoProposal(t, proposal, shouldExecute, err) + if ralc.validateCalls != 0 { + t.Errorf("expected no validation, got %d calls", ralc.validateCalls) + } + }) + + t.Run("outside margin: proposed", func(t *testing.T) { + btcChain := tbtcpg.NewLocalBitcoinChain() + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) + opts := defaultPendingDepositOptions(currentBlock) + // 100 seconds of slack past the 7200-second margin, so a slow + // host cannot push the candidate inside it. + opts.timeoutAt = uint32(time.Now().Add(7300 * time.Second).Unix()) + deposit := addPendingDeposit(t, ralc, btcChain, testWalletPublicKeyHash, opts) + + proposal, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) + expectProposalFor(t, proposal, shouldExecute, err, deposit, 1) + if ralc.validateCalls != 1 { + t.Errorf("expected one validation, got %d calls", ralc.validateCalls) } }) } @@ -2725,98 +1928,32 @@ func TestReservationAcceptanceTask_SkipsCandidateInsideTimeoutSafetyMargin(t *te // TestLocalChain_ValidateReservationAnchorProposal_RejectsInsideTimeoutMargin // is a regression test for the timeout safety margin gate in the LocalChain // validator fake: a proposal whose action TimeoutAt is at or before -// now + REQUEST_TIMEOUT_SAFETY_MARGIN is rejected with "timed out". -// Just outside the margin (TimeoutAt = now + 7202) passes. +// now + REQUEST_TIMEOUT_SAFETY_MARGIN is rejected. func TestLocalChain_ValidateReservationAnchorProposal_RejectsInsideTimeoutMargin(t *testing.T) { lc := tbtcpg.NewLocalChain() - - walletPublicKeyHash := [20]byte{0x8d, 0xb5, 0x0e, 0xb5, 0x20, 0x63, 0xea, 0x9d, 0x98, 0xb3, 0xea, 0xc9, 0x14, 0x89, 0xa9, 0x0f, 0x73, 0x89, 0x86, 0xf6} - - fundingTxHash := bitcoin.Hash{0xab} - fundingOutputIndex := uint32(0) - depositKey := lc.BuildDepositKey(fundingTxHash, fundingOutputIndex) - - // Seed the action at nonce 1 with a far-future TimeoutAt; the nonce - // gate is not under test here (covered by StrictNonceGate). - lc.SetReservation(depositKey, &tbtc.Reservation{ - WalletPublicKeyHash: walletPublicKeyHash, - State: tbtc.ReservationStateUnknown, - RequestNonce: 1, - }) - lc.SetReservationAction(depositKey, 1, &tbtc.ReservationAction{ - ActionType: tbtc.ReservationActionTypeAcceptance, - State: tbtc.ReservationActionStatePending, - TargetWalletPublicKeyHash: walletPublicKeyHash, - TxMaxFee: 5000, - MinAmount: 1000, - TermSeconds: 86400, - TimeoutAt: uint32(time.Now().Add(24 * time.Hour).Unix()), - }) - // Seed the state the strict fake mirrors from the on-chain - // validator so the far-future call below can pass every gate - // except the timeout margin: a Live wallet, a reserved deposit - // revealed through the reservation vault, and the configured - // parameters. - vault := testReservationVaultAddress - lc.SetWallet(walletPublicKeyHash, &tbtc.WalletChainData{State: tbtc.StateLive}) - lc.SetReservationParameters(tbtc.ReservationParameters{ - ReservationVault: vault, - }) - lc.SetDepositRequest( - fundingTxHash, - fundingOutputIndex, - &tbtc.DepositChainRequest{ - Amount: 2000000, - RevealedAt: time.Now().Add(-48 * time.Hour), - Vault: &vault, - }, - ) - lc.SetReservedDeposit(depositKey, true) - lc.SetDepositMinAge(3600) - - depositExtraInfo := struct { - *tbtc.Deposit - FundingTx *bitcoin.Transaction - }{ - Deposit: &tbtc.Deposit{ - WalletPublicKeyHash: walletPublicKeyHash, - // Far-future refund locktime, little-endian as stored on - // chain, so the 24-hour refund safety margin holds. - RefundLocktime: [4]byte{0x00, 0x79, 0xf7, 0x77}, - }, - } - - proposal := &tbtc.ReservationAnchorProposal{ - DepositFundingTxHash: fundingTxHash, - DepositFundingOutputIndex: fundingOutputIndex, - RequestNonce: 1, - AnchorTxFee: big.NewInt(710), - } + proposal, extraInfo, depositKey := strictValidatorFixture(t, lc, testWalletPublicKeyHash) // Far-future TimeoutAt: validation passes. if err := lc.ValidateReservationAnchorProposal( - walletPublicKeyHash, + testWalletPublicKeyHash, proposal, - depositExtraInfo, + extraInfo, ); err != nil { t.Fatalf("far-future TimeoutAt should pass: %v", err) } - // Override TimeoutAt to be just inside the 2-hour margin. - lc.SetReservationAction(depositKey, 1, &tbtc.ReservationAction{ - ActionType: tbtc.ReservationActionTypeAcceptance, - State: tbtc.ReservationActionStatePending, - TargetWalletPublicKeyHash: walletPublicKeyHash, - TxMaxFee: 5000, - MinAmount: 1000, - TermSeconds: 86400, - TimeoutAt: uint32(time.Now().Add(7198 * time.Second).Unix()), - }) + action, err := lc.GetReservationAction(depositKey, 2) + if err != nil { + t.Fatal(err) + } + insideMargin := *action + insideMargin.TimeoutAt = uint32(time.Now().Add(7198 * time.Second).Unix()) + lc.SetReservationAction(depositKey, 2, &insideMargin) if err := lc.ValidateReservationAnchorProposal( - walletPublicKeyHash, + testWalletPublicKeyHash, proposal, - depositExtraInfo, + extraInfo, ); err == nil { t.Fatal( "expected timeout margin rejection for TimeoutAt = now + 7198, got nil", @@ -2897,3 +2034,136 @@ func TestLocalChain_ValidateReservationReanchorProposal_RejectsInsideTimeoutMarg ) } } + +// TestReservationAcceptanceTask_BudgetCountsOnlyPendingAcceptances is a +// regression test for budget starvation: the per-run candidate budget +// (50) must only be spent on generations the on-chain check confirms are +// a Pending Acceptance targeting this wallet. Here 51 requested deposits +// whose generations have since timed out sort ahead of one real pending +// acceptance; if they consumed the budget, the real request would never +// be reached and would time out. +func TestReservationAcceptanceTask_BudgetCountsOnlyPendingAcceptances(t *testing.T) { + btcChain := tbtcpg.NewLocalBitcoinChain() + currentBlock := uint64(300000) + + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) + + // Candidates are tried in timeout order, so the timed-out generations + // get the earlier timeouts. Their records say TimedOut, but their + // request events still carry a timeout outside the margin, as a + // generation reported timed out early by a notifier would. + for i := 0; i < 51; i++ { + opts := defaultPendingDepositOptions(currentBlock) + opts.seed = byte(0x40 + i) + opts.timeoutAt = uint32(time.Now().Add(20 * time.Hour).Unix()) + opts.actionState = tbtc.ReservationActionStateTimedOut + addPendingDeposit(t, ralc, btcChain, testWalletPublicKeyHash, opts) + } + realOpts := defaultPendingDepositOptions(currentBlock) + realOpts.seed = 0x01 + realOpts.timeoutAt = uint32(time.Now().Add(23 * time.Hour).Unix()) + real := addPendingDeposit(t, ralc, btcChain, testWalletPublicKeyHash, realOpts) + + proposal, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) + expectProposalFor(t, proposal, shouldExecute, err, real, 1) +} + +// TestReservationAcceptanceTask_FeeErrorSkipsCandidate is a regression test +// for per-generation fee caps: a generation whose snapshotted max fee is +// below the current anchor fee estimate is skipped, and the next +// generation, with a higher cap, is proposed instead of the whole window +// aborting. +func TestReservationAcceptanceTask_FeeErrorSkipsCandidate(t *testing.T) { + btcChain := tbtcpg.NewLocalBitcoinChain() + currentBlock := uint64(300000) + + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) + + lowCap := defaultPendingDepositOptions(currentBlock) + lowCap.seed = 0x10 + lowCap.txMaxFee = 500 // below the 710 sat estimate + lowCap.timeoutAt = uint32(time.Now().Add(20 * time.Hour).Unix()) + addPendingDeposit(t, ralc, btcChain, testWalletPublicKeyHash, lowCap) + + highCap := defaultPendingDepositOptions(currentBlock) + highCap.seed = 0x11 + highCap.timeoutAt = uint32(time.Now().Add(23 * time.Hour).Unix()) + viable := addPendingDeposit(t, ralc, btcChain, testWalletPublicKeyHash, highCap) + + proposal, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) + expectProposalFor(t, proposal, shouldExecute, err, viable, 1) +} + +// gaugeRecorder counts SetGauge calls per gauge. +type gaugeRecorder struct { + calls map[string]int +} + +func (g *gaugeRecorder) SetGauge(name string, value float64) { + g.calls[name]++ +} + +// TestReservationAcceptanceTask_GaugesPublishedOncePerRun pins the cost of +// the vault fee gauges: they are read once per Run, with the vault address +// the task already read from the reservation parameters, even when an +// earlier candidate fails validation and the task moves on to the next. +func TestReservationAcceptanceTask_GaugesPublishedOncePerRun(t *testing.T) { + btcChain := tbtcpg.NewLocalBitcoinChain() + currentBlock := uint64(300000) + + ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) + + // The first candidate carries a funding transaction whose output does + // not hold its deposit script, so the strict validator rejects it. + broken := defaultPendingDepositOptions(currentBlock) + broken.seed = 0x20 + broken.timeoutAt = uint32(time.Now().Add(20 * time.Hour).Unix()) + brokenDeposit := addPendingDeposit(t, ralc, btcChain, testWalletPublicKeyHash, broken) + for _, event := range ralc.revealEvents { + if event.FundingTxHash == brokenDeposit.fundingTxHash { + event.BlindingFactor = [8]byte{0xff} + } + } + + good := defaultPendingDepositOptions(currentBlock) + good.seed = 0x21 + good.timeoutAt = uint32(time.Now().Add(23 * time.Hour).Unix()) + goodDeposit := addPendingDeposit(t, ralc, btcChain, testWalletPublicKeyHash, good) + + task := tbtcpg.NewReservationAcceptanceTask(ralc, btcChain) + recorder := &gaugeRecorder{calls: make(map[string]int)} + task.SetMetricsRecorderForTest(recorder) + + proposal, shouldExecute, err := runExistingTask(t, task, testWalletPublicKeyHash) + expectProposalFor(t, proposal, shouldExecute, err, goodDeposit, 1) + + if ralc.validateCalls != 2 { + t.Fatalf("expected both candidates to be validated, got %d", ralc.validateCalls) + } + if len(ralc.feeDebtVaults) != 1 || len(ralc.feeReserveVaults) != 1 { + t.Fatalf( + "expected one read per vault fee gauge, got debt=%d reserve=%d", + len(ralc.feeDebtVaults), + len(ralc.feeReserveVaults), + ) + } + if ralc.feeDebtVaults[0] != testReservationVaultAddress || + ralc.feeReserveVaults[0] != testReservationVaultAddress { + t.Fatalf( + "expected the fee reads to use the parameters' vault, got %v / %v", + ralc.feeDebtVaults, + ralc.feeReserveVaults, + ) + } + for _, gauge := range []string{ + "wallet_reservations_count", + "active_reservations_count", + "max_active_reservations", + "reservation_vault_fee_debt_sat", + "reservation_vault_fee_reserve_tbtc_base_units", + } { + if recorder.calls[gauge] != 1 { + t.Errorf("expected gauge %s published once, got %d", gauge, recorder.calls[gauge]) + } + } +} From 0c1a2619e45a50c149f6cc820bdc13b958d68280 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 30 Sep 2026 09:15:23 +0000 Subject: [PATCH 29/31] fix(tbtc): use the chain's block time when locating an anchor's deposit reveal --- pkg/tbtc/deposit_reveal_lookup_test.go | 13 +++++++++-- pkg/tbtc/reservation.go | 11 ++++++++-- pkg/tbtc/reservation_test.go | 30 +++++++++++++++++++------- 3 files changed, 42 insertions(+), 12 deletions(-) diff --git a/pkg/tbtc/deposit_reveal_lookup_test.go b/pkg/tbtc/deposit_reveal_lookup_test.go index d542906996..c0dc5b88e2 100644 --- a/pkg/tbtc/deposit_reveal_lookup_test.go +++ b/pkg/tbtc/deposit_reveal_lookup_test.go @@ -80,6 +80,16 @@ func (c *rangeRevealChain) PastDepositRevealedEvents( return c.reveals.PastDepositRevealedEvents(filter) } +// timedRangeRevealChain is a rangeRevealChain that reports a block time. +type timedRangeRevealChain struct { + *rangeRevealChain + blockTime time.Duration +} + +func (c *timedRangeRevealChain) AverageBlockTime() time.Duration { + return c.blockTime +} + func matchFundingTxHash(hash bitcoin.Hash) func(*DepositRevealedEvent) bool { return func(event *DepositRevealedEvent) bool { return event.FundingTxHash == hash @@ -202,8 +212,7 @@ func TestFindDepositRevealedEventByRevealTime(t *testing.T) { target := bitcoin.Hash{0xaa} now := time.Now() currentBlock := uint64(10_000_000) - // 300000 blocks (about 41 days at 12 s) before the current block, - // older than the 216000-block window the lookup replaced. The + // 300000 blocks (about 41 days at 12 s) before the current block. The // margin for this distance is 5% of 300000 = 15000 blocks. revealedAt := now.Add(-300000 * 12 * time.Second) estimatedBlock := currentBlock - 300000 diff --git a/pkg/tbtc/reservation.go b/pkg/tbtc/reservation.go index 22888c95e2..989e793571 100644 --- a/pkg/tbtc/reservation.go +++ b/pkg/tbtc/reservation.go @@ -511,14 +511,21 @@ func (raa *reservationAnchorAction) execute() error { // block it was revealed at. A depositor may request acceptance long // after the reveal, so the DepositRevealed event is located from the // deposit's on-chain reveal timestamp rather than from a fixed - // look-back window, and matched on the exact funding outpoint. + // look-back window, and matched on the exact funding outpoint. The + // chain's configured block time is used when it exposes one. + averageBlockTime := DepositRevealLookupDefaultBlockTime + if timedChain, ok := raa.chain.(interface { + AverageBlockTime() time.Duration + }); ok { + averageBlockTime = timedChain.AverageBlockTime() + } matchingEvent, err := FindDepositRevealedEventByRevealTime( raa.chain, walletPublicKeyHash, depositRequest.RevealedAt, time.Now(), raa.startBlock, - DepositRevealLookupDefaultBlockTime, + averageBlockTime, func(event *DepositRevealedEvent) bool { return event.FundingTxHash == raa.proposal.DepositFundingTxHash && event.FundingOutputIndex == raa.proposal.DepositFundingOutputIndex diff --git a/pkg/tbtc/reservation_test.go b/pkg/tbtc/reservation_test.go index cd411a1336..3fb8574838 100644 --- a/pkg/tbtc/reservation_test.go +++ b/pkg/tbtc/reservation_test.go @@ -811,24 +811,31 @@ func TestReservationAnchorAction_Execute(t *testing.T) { } }) - // The happy path runs for a recent reveal and for a reveal older than - // the 216000-block (30-day) window the lookup used to be bounded by: - // a depositor may request acceptance long after revealing, and the - // signer must still find the reveal. The old reveal's event sits - // 3000 blocks off the block estimated from its timestamp, inside the - // lookup margin, as block-time drift would place it. + // The happy path runs for a recent reveal, for a reveal 250000 blocks + // (about 35 days) back, and on a chain with 1-second blocks: a + // depositor may request acceptance long after revealing, and the + // signer locates the reveal from its timestamp and the chain's block + // time. The 250000-block reveal's event sits 3000 blocks off the block + // estimated from its timestamp, inside the lookup margin, as + // block-time drift would place it. happyPathCases := map[string]struct { revealAge time.Duration revealBlock uint64 + blockTime time.Duration }{ "recent reveal": { revealAge: 0, revealBlock: 299990, }, - "reveal older than 216000 blocks": { + "reveal 250000 blocks back": { revealAge: 250000 * 12 * time.Second, revealBlock: 300000 - 250000 + 3000, }, + "reveal 7200 one-second blocks back": { + revealAge: 2 * time.Hour, + revealBlock: 300000 - 7200, + blockTime: time.Second, + }, } for name, happyPathCase := range happyPathCases { t.Run("full happy path up to the signing boundary: "+name, func(t *testing.T) { @@ -878,7 +885,14 @@ func TestReservationAnchorAction_Execute(t *testing.T) { TxMaxFee: 2000, }) - action := newAction(chain, btcChain, fundingTxHash) + var actionChain Chain = chain + if happyPathCase.blockTime != 0 { + actionChain = &timedRangeRevealChain{ + rangeRevealChain: chain, + blockTime: happyPathCase.blockTime, + } + } + action := newAction(actionChain, btcChain, fundingTxHash) // Below reservationActionSigningTimeoutSafetyMarginBlocks (300): // every real upstream step (event match, deposit request fetch, // reservation key derivation, action load, target wallet match, From 9d7fe18b7dda71a126c7104be5ca252c50b68e9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 30 Sep 2026 09:15:24 +0000 Subject: [PATCH 30/31] test(tbtcpg): model budget fillers as settled generations and name tests by invariant --- pkg/tbtcpg/reservation_acceptance_test.go | 26 +++++++++++------------ 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/pkg/tbtcpg/reservation_acceptance_test.go b/pkg/tbtcpg/reservation_acceptance_test.go index 23080c06c5..92c6ed64a0 100644 --- a/pkg/tbtcpg/reservation_acceptance_test.go +++ b/pkg/tbtcpg/reservation_acceptance_test.go @@ -1190,14 +1190,13 @@ func TestReservationAcceptanceTask_RequestLookBackWindow(t *testing.T) { } } -// TestReservationAcceptanceTask_RevealOlderThanFormerLookBack is a -// regression test for acceptance requests made long after the reveal. A -// depositor may request acceptance up to one term after revealing, so a -// deposit revealed 300000 blocks (about 41 days) ago -- beyond the -// 216000-block window the task used to scan reveals in -- must still be -// found and proposed. The reveal sits exactly on the oldest block of a -// 10000-block lookup chunk to catch off-by-one errors at chunk edges. -func TestReservationAcceptanceTask_RevealOlderThanFormerLookBack(t *testing.T) { +// TestReservationAcceptanceTask_RevealLongBeforeRequest is a regression +// test for acceptance requests made long after the reveal. A depositor may +// request acceptance up to one term after revealing, so a deposit revealed +// 300000 blocks (about 41 days) before its request must be found and +// proposed. The reveal sits exactly on the oldest block of a 10000-block +// lookup chunk to catch off-by-one errors at chunk edges. +func TestReservationAcceptanceTask_RevealLongBeforeRequest(t *testing.T) { btcChain := tbtcpg.NewLocalBitcoinChain() currentBlock := uint64(1000000) @@ -2039,7 +2038,7 @@ func TestLocalChain_ValidateReservationReanchorProposal_RejectsInsideTimeoutMarg // regression test for budget starvation: the per-run candidate budget // (50) must only be spent on generations the on-chain check confirms are // a Pending Acceptance targeting this wallet. Here 51 requested deposits -// whose generations have since timed out sort ahead of one real pending +// whose generations have already settled sort ahead of one real pending // acceptance; if they consumed the budget, the real request would never // be reached and would time out. func TestReservationAcceptanceTask_BudgetCountsOnlyPendingAcceptances(t *testing.T) { @@ -2048,15 +2047,14 @@ func TestReservationAcceptanceTask_BudgetCountsOnlyPendingAcceptances(t *testing ralc := newBoundaryTestChain(t, testWalletPublicKeyHash, currentBlock, nil) - // Candidates are tried in timeout order, so the timed-out generations - // get the earlier timeouts. Their records say TimedOut, but their - // request events still carry a timeout outside the margin, as a - // generation reported timed out early by a notifier would. + // Candidates are tried in timeout order, so the settled generations + // get the earlier timeouts. They were accepted before their timeout, + // so their request events are still inside the scan window. for i := 0; i < 51; i++ { opts := defaultPendingDepositOptions(currentBlock) opts.seed = byte(0x40 + i) opts.timeoutAt = uint32(time.Now().Add(20 * time.Hour).Unix()) - opts.actionState = tbtc.ReservationActionStateTimedOut + opts.actionState = tbtc.ReservationActionStateSettled addPendingDeposit(t, ralc, btcChain, testWalletPublicKeyHash, opts) } realOpts := defaultPendingDepositOptions(currentBlock) From 42bde46864fc40ee558db13543b0a38a036cf527 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 30 Sep 2026 09:17:05 +0000 Subject: [PATCH 31/31] fix(tbtcpg): scan acceptance requests in bounded chunks --- pkg/tbtcpg/reservation_acceptance.go | 38 +++++++++++++++------- pkg/tbtcpg/reservation_acceptance_test.go | 39 +++++++++++++++-------- 2 files changed, 52 insertions(+), 25 deletions(-) diff --git a/pkg/tbtcpg/reservation_acceptance.go b/pkg/tbtcpg/reservation_acceptance.go index 7950b42745..008b12a15c 100644 --- a/pkg/tbtcpg/reservation_acceptance.go +++ b/pkg/tbtcpg/reservation_acceptance.go @@ -752,18 +752,34 @@ func (rat *ReservationAcceptanceTask) reservationAcceptanceRequestedEvents( startBlock = currentBlock - lookBackBlocks } - events, err := rat.chain.PastReservationAcceptanceRequestedEvents( - &tbtc.ReservationAcceptanceRequestedEventFilter{ - StartBlock: startBlock, - EndBlock: ¤tBlock, - WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, - }, - ) - if err != nil { - return nil, fmt.Errorf( - "failed to get past reservation acceptance requested events: [%w]", - err, + // The window is wider than many providers accept for one log query, so + // it is scanned in chunks of the reveal lookup's size. + var events []*tbtc.ReservationAcceptanceRequestedEvent + for chunkStart := startBlock; chunkStart <= currentBlock; { + chunkEnd := currentBlock + if currentBlock-chunkStart >= tbtc.DepositRevealLookupChunkBlocks { + chunkEnd = chunkStart + tbtc.DepositRevealLookupChunkBlocks - 1 + } + + chunkEvents, err := rat.chain.PastReservationAcceptanceRequestedEvents( + &tbtc.ReservationAcceptanceRequestedEventFilter{ + StartBlock: chunkStart, + EndBlock: &chunkEnd, + WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + }, ) + if err != nil { + return nil, fmt.Errorf( + "failed to get past reservation acceptance requested "+ + "events in blocks [%d, %d]: [%w]", + chunkStart, + chunkEnd, + err, + ) + } + events = append(events, chunkEvents...) + + chunkStart = chunkEnd + 1 } latest := make(map[string]*tbtc.ReservationAcceptanceRequestedEvent) diff --git a/pkg/tbtcpg/reservation_acceptance_test.go b/pkg/tbtcpg/reservation_acceptance_test.go index 92c6ed64a0..060cb19fe2 100644 --- a/pkg/tbtcpg/reservation_acceptance_test.go +++ b/pkg/tbtcpg/reservation_acceptance_test.go @@ -1148,8 +1148,9 @@ func TestReservationAcceptanceTask_IgnoresRequestTimeCaps(t *testing.T) { // acceptance requests are scanned. Solidity sets a generation's timeoutAt // to request time + the action timeout, so a generation that can still be // signed was requested within that timeout: the scan covers the on-chain -// action timeout in blocks plus a one-day margin, filtered by wallet, and -// a request inside that window is proposed. +// action timeout in blocks plus a one-day margin, filtered by wallet and +// split into 10000-block chunks, and a request inside that window is +// proposed. func TestReservationAcceptanceTask_RequestLookBackWindow(t *testing.T) { btcChain := tbtcpg.NewLocalBitcoinChain() currentBlock := uint64(300000) @@ -1167,23 +1168,33 @@ func TestReservationAcceptanceTask_RequestLookBackWindow(t *testing.T) { proposal, shouldExecute, err := runTask(t, ralc, btcChain, testWalletPublicKeyHash) expectProposalFor(t, proposal, shouldExecute, err, deposit, 1) - if len(ralc.acceptanceEventFilters) != 1 { + // The 14400-block window is scanned in 10000-block chunks. + expectedRanges := [][2]uint64{ + {expectedStartBlock, expectedStartBlock + 9999}, + {expectedStartBlock + 10000, currentBlock}, + } + if len(ralc.acceptanceEventFilters) != len(expectedRanges) { t.Fatalf( - "expected exactly one acceptance request scan, got %d", + "expected %d acceptance request scan chunks, got %d", + len(expectedRanges), len(ralc.acceptanceEventFilters), ) } - filter := ralc.acceptanceEventFilters[0] - if filter.StartBlock != expectedStartBlock || - filter.EndBlock == nil || *filter.EndBlock != currentBlock { - t.Fatalf( - "unexpected scan range: start=%d end=%v, expected [%d, %d]", - filter.StartBlock, - filter.EndBlock, - expectedStartBlock, - currentBlock, - ) + for i, expected := range expectedRanges { + filter := ralc.acceptanceEventFilters[i] + if filter.StartBlock != expected[0] || + filter.EndBlock == nil || *filter.EndBlock != expected[1] { + t.Fatalf( + "unexpected scan chunk %d: start=%d end=%v, expected [%d, %d]", + i, + filter.StartBlock, + filter.EndBlock, + expected[0], + expected[1], + ) + } } + filter := ralc.acceptanceEventFilters[0] if len(filter.WalletPublicKeyHash) != 1 || filter.WalletPublicKeyHash[0] != testWalletPublicKeyHash { t.Fatalf("expected the scan to be filtered by the wallet")