UMIP-179: refund deposits to abandoned (orphaned/disabled) destinations#631
UMIP-179: refund deposits to abandoned (orphaned/disabled) destinations#631droplet-rl wants to merge 2 commits into
Conversation
Adds a "Finding Abandoned-Destination Deposits" rule so that a Deposit whose destinationChainId is not an active protocol chain (never onboarded, or in DISABLED_CHAINS) is refunded to the depositor on the origin SpokePool once its fillDeadline elapses, measured on the ORIGIN chain clock. Today such deposits are filtered out of bundle consideration and stranded indefinitely. Safety: - Orphan destination: absence from CHAIN_ID_INDICES resolved at the fillDeadline HubPool block; append-only CHAIN_ID_INDICES => absent through the whole window => no SpokePool, no repayable fill can exist. - Disabled destination: in DISABLED_CHAINS at both the deposit block.timestamp and the fillDeadline; combined with the new bundle-range rule that a re-enabled chain resumes from its re-enablement block (disabled-gap fills never bundled), a depositor refund can never be double-paid against a relayer repayment. - Membership uses on-chain event timestamps (deposit block / fillDeadline), never quoteTimestamp, so the rule is independent of depositQuoteTimeBuffer. - Forward-only: gated behind a ConfigStore VERSION bump; no retroactive sweep. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 875ebaa455
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
| A `Deposit` shall be considered to have an abandoned destination when either of the following holds: | ||
| 1. The `destinationChainId` is **not** present in the `CHAIN_ID_INDICES` list as of the HubPool block resolved from the `Deposit`'s `fillDeadline` timestamp (i.e. the destination was never onboarded through the `fillDeadline`). Because `CHAIN_ID_INDICES` is append-only, absence at the `fillDeadline` implies absence for the entire interval between the `Deposit` and its `fillDeadline`. | ||
| 2. The `destinationChainId` is present in the `DISABLED_CHAINS` list as of the HubPool block resolved from the `Deposit`'s origin `block.timestamp`, **and** remains present in the `DISABLED_CHAINS` list as of the HubPool block resolved from the `Deposit`'s `fillDeadline` (i.e. the destination was disabled for the entire interval between the `Deposit` and its `fillDeadline`). |
There was a problem hiding this comment.
Require a continuous disabled interval before refunding
When a destination is disabled at the deposit timestamp and again at fillDeadline but is re-enabled in between, this endpoint-only test marks the deposit abandoned even though fills during the enabled interval can be included in a Bundle Block Range and repaid. Since DISABLED_CHAINS is not append-only (the new re-enable clause explicitly allows removal), the spec needs to verify that the chain stayed continuously disabled for the whole deposit-to-deadline window; otherwise a depositor refund can double-pay a valid relayer fill.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Good catch — fixed in f56614b. You're right that the two endpoint checks did not deliver the "disabled for the entire interval" property the parenthetical claimed. Because DISABLED_CHAINS is not append-only (unlike CHAIN_ID_INDICES, chains can be re-enabled), a disable → re-enable → disable sequence satisfies membership at both the deposit block and the fillDeadline block while exposing a middle window in which a Fill lands on an active destination, gets included in a Bundle Block Range, and is repaid — so an origin refund issued on the endpoint test could double-pay.
Condition 2 now requires continuous membership across the whole [deposit block, fillDeadline block] HubPool interval: present at both endpoints and not removed by any DISABLED_CHAINS update in between (i.e. every DISABLED_CHAINS value effective during the interval contains the destinationChainId). This is what the "disabled across the whole window" intent always required; the spec text just under-specified the verification.
| 1. The `fillDeadline` timestamp elapsed within the `Bundle Block Range` on the **origin** `SpokePool` (i.e. the `fillDeadline` expired between the `block.timestamp` of the origin chain's bundle start and end block). | ||
|
|
||
| #### Note | ||
| - Abandoned-destination refunds shall be issued to the `depositor` address on the origin `SpokePool` as part of the relayer refund procedure, identically to expired-deposit refunds. |
There was a problem hiding this comment.
Add abandoned refunds to the bundle accounting
Although this new section says abandoned-destination deposits are refunded via the relayer refund procedure, the actual accounting rules still only add deposit refunds for deposits that “expired or were deemed unfillable” and the procedure list above likewise only names expired/unfillable deposits. In bundles where an abandoned-destination deposit expires, a proposer following the spec can identify it here but never add its inputAmount/inputToken to the origin-chain relayer refund root, so the intended refund is omitted unless the relayer-refund/running-balance rules are updated to include abandoned-destination deposits explicitly.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in f56614b. You're right that the section declared abandoned-destination deposits are refunded "identically to expired-deposit refunds" but the accounting rules only named expired/unfillable deposits, so a proposer following the letter of §Computing Running Balances would never add the inputAmount/inputToken. Wired them in explicitly:
- §Computing Relayer Repayments & Depositor Refunds procedure list — added Finding Abandoned-Destination Deposits.
- §Computing Deposit Refunds — abandoned-destination deposits refund
inputAmountunits ofinputTokenon the origin chain, identically to an expired deposit. - §Computing Running Balances & Net Send Amounts, step 3 (Add deposit refunds) — the sum now includes deposits "deemed abandoned-destination refundable," with a note that these are evaluated against the origin
Bundle Block Range(theirfillDeadlineis resolved on the origin clock), matching the reference-clock rule in the new section. In all other respects they are accounted for identically to an expired deposit — consistent with the SDK implementation, which reuses the existingupdateExpiredDepositsV3path (no new leaf type).
| - Proposers may opt to reduce the size of the proposal block range for each chain in the event that RPC provider data inconsistencies are detected, and | ||
| - A "soft pause" of a chain is permitted in the event that the proposer cannot safely increment the bundle block range, or has no events to propose beyond the previous bundle block range. In this case, the proposer may repeat the procedure for | ||
| DISABLED_CHAINS by proposing from and to the previous bundle end block. | ||
| - When a chain is removed from the `DISABLED_CHAINS` list (i.e. re-enabled), the proposer shall resume that chain's `Bundle Block Range` from the block at which it was re-enabled. The range shall not span the interval during which the chain was in `DISABLED_CHAINS`. Consequently, any `Fill` that occurred on the chain while it was in `DISABLED_CHAINS` is never included in a `Bundle Block Range` and is not eligible for repayment. This ensures that a depositor refund issued for a disabled destination (see [Finding Abandoned-Destination Deposits](#finding-abandoned-destination-deposits)) can never be accompanied by a later relayer repayment for the same `Deposit`. |
There was a problem hiding this comment.
Cover active deposits that expire while the destination is disabled
If a deposit is made while the destination is still active and the chain is then disabled before a bundle covers its fillDeadline, this skip makes the re-enabled destination range start after the disabled gap, so the deadline is never processed by the normal expired-deposit rule. The new abandoned-destination rule also excludes it because DISABLED_CHAINS was not set at the deposit timestamp, leaving the origin funds stranded for exactly the class of deposits that become unprocessable during a later disablement.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
You've correctly identified a real class, but declining to auto-refund it here is deliberate rather than an oversight — and I've added an explicit scope note in f56614b so the boundary is stated rather than silent.
The class you describe is: destination active at deposit, added to DISABLED_CHAINS later, fillDeadline elapses inside the disablement gap. The reason it can't be folded into this rule is the same reason the disabled arm is anchored at deposit time in the first place: a deposit made while the destination was active may already have been validly filled and repaid before the chain was disabled (that pre-disable Fill lands in an active window and is bundled normally). Refunding such a deposit on the origin clock without a destination FillStatus check would double-pay against that repaid Fill. And a FillStatus check is exactly what this rule cannot rely on — the whole premise is that a disabled/orphaned destination's SpokePool may be unresolvable (dead RPC, or never existed). So this class is provably unsafe to handle under the "zero destination reads" constraint that makes the rest of the rule sound; it is not merely unhandled.
Note also this is narrower than "origin funds stranded whenever a destination is later disabled": deposits whose fillDeadline is still in the future when the chain is re-enabled settle normally on the live destination. Only deposits whose deadline falls inside the gap are affected, and only when the chain is disabled (not re-enabled) across that deadline.
Those deposits remain recoverable via a discretionary admin refund, exactly as today — this PR's goal is to prevent recurrence of the deposit-into-an-already-disabled-destination shape (the Scroll/ZeroWay incident), not to auto-recover every stranding path. Extending coverage to the active-at-deposit class would need a separate, destination-reading mechanism and is out of scope here.
…ting wiring - Disabled arm (P1): the abandoned-destination test previously checked DISABLED_CHAINS membership only at the deposit block and the fillDeadline block. Because DISABLED_CHAINS is not append-only (chains can be re-enabled), a disable -> re-enable -> disable sequence passes both endpoint checks while exposing a window in which a Fill can be bundled and repaid -> double-pay. Tighten the condition to require CONTINUOUS membership across the whole [deposit block, fillDeadline block] HubPool interval (no re-enablement in between), matching the design's stated "disabled across the whole window". - Accounting wiring (P2): abandoned-destination refunds flow through the same path as expired-deposit refunds but were not named in the procedure list, Computing Deposit Refunds, or the running-balance deposit-refund step. Add them explicitly so a proposer following the accounting rules includes their inputAmount/inputToken (evaluated against the ORIGIN Bundle Block Range). - Scope note: document that deposits made while the destination was active and only later disabled are intentionally excluded (would require a destination FillStatus check the design forbids); these remain covered by discretionary admin refund. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@codex thanks for the review — all three findings triaged, two fixed and one addressed with a scope clarification. Pushed as f56614b. P1 — continuous disabled interval (correct, fixed). The disabled arm previously checked P2 — abandoned refunds in bundle accounting (correct, fixed). Added Finding Abandoned-Destination Deposits to the procedure list, extended Computing Deposit Refunds to abandoned deposits, and updated the running-balance deposit-refund step to include them — evaluated against the origin P1 — active-at-deposit deposits that expire during a later disablement (intentionally out of scope). Declined the auto-refund but added an explicit scope note. Such a deposit may have been validly filled and repaid before the destination was disabled; refunding it on the origin clock without a destination |
|
To use Codex here, create an environment for this repo. |
|
Noted — this is the automated notice that Codex can't run a re-review here until a Codex cloud environment is configured for the repo (a maintainer action via the linked settings). No code changes are warranted for this message. Nothing is pending on my side: the substantive review feedback was already addressed in |
Problem
A
DepositwhosedestinationChainIdis not an active protocol chain — either never onboarded (e.g. a typo/unsupported chain id) or currently inDISABLED_CHAINS(e.g. Scroll) — is accepted on-chain (SpokePool._depositV3does not validatedestinationChainId) and then silently dropped from bundle consideration: expired-deposit refunds are defined against the destination chain's clock, which doesn't exist for these deposits. The input funds sit on the originSpokePoolindefinitely.This was hit in production (a Base→Scroll deposit routed via a third-party integrator, ~$300 stranded). Recovering it today requires a manual admin action.
Change
Adds §Finding Abandoned-Destination Deposits: such a deposit is refunded to the
depositoron the originSpokePoolonce itsfillDeadlineelapses within the originBundle Block Range(origin clock — no destination reads). Plus one clause in §Identifying Bundle Block Ranges: a re-enabled chain resumes its bundle range from the re-enablement block.Safety (no double-pay)
The refund is issued without a destination
FillStatuscheck (the destination is unresolvable), so the spec must guarantee no repayableFillcan exist:∉ CHAIN_ID_INDICESat thefillDeadlineHubPool block):CHAIN_ID_INDICESis append-only, so absence at thefillDeadline⇒ absence for the whole[deposit … fillDeadline]window ⇒ noSpokePoolever existed ⇒ no fill.∈ DISABLED_CHAINSat both the depositblock.timestampand thefillDeadline): the destination was disabled for the whole window; anyFillin a disabled interval is never bundled (the new re-enablement range clause), so a depositor refund can never be double-paid.fillDeadline), neverquoteTimestamp— so the rule is independent ofdepositQuoteTimeBuffer(which may be raised).AcrossConfigStoreVERSIONbump; deposits whosefillDeadlinealready elapsed are not retroactively swept.Notes
@across-protocol/sdkBundleDataClient) in a separate PR.🤖 Generated with Claude Code