Skip to content

Clamp L1->L2 OFT rebalances to the path's remaining capacity - #3765

Open
droplet-rl wants to merge 3 commits into
masterfrom
droplet/T90K0AL22-C073ELHSDQ8-1788349303-071809
Open

Clamp L1->L2 OFT rebalances to the path's remaining capacity#3765
droplet-rl wants to merge 3 commits into
masterfrom
droplet/T90K0AL22-C073ELHSDQ8-1788349303-071809

Conversation

@droplet-rl

Copy link
Copy Markdown
Contributor

Metered OFT paths (Stargate pools, legacy-mesh adapters) revert rather than size down when a send exceeds the destination path's available capacity, so an over-capacity rebalance fails to construct instead of moving what the path can take.

OFTBridge now implements the existing getMaxL1ToL2TransferAmount hook, reading capacity from quoteOFT's oftLimit.maxAmountLD. That's the only field reporting capacity on every path type: Stargate pools also cap oftReceipt.amountSentLD, but legacy-mesh adapters echo the requested amount there, and vanilla OFTs report type(uint64).max to mean "no limit". The rebalance loop already clamps to this hook, so an over-capacity deficit is chunked across runs.

Also skips a rebalance clamped to zero, rather than initiating a transfer that pays full per-message costs to move nothing.

Verified against mainnet: on a metered USDT path oftLimit.maxAmountLD tracks the adapter's per-destination credit exactly, and quoteSend succeeds at that amount and reverts just above it; vanilla paths report type(uint64).max.

🤖 Generated with Claude Code

Metered OFT paths (Stargate pools, legacy-mesh adapters) revert rather than
size down when a send exceeds the destination path's available capacity, so
an over-capacity rebalance fails to construct instead of moving what the
path can take.

Implement the existing getMaxL1ToL2TransferAmount hook on OFTBridge, reading
capacity from quoteOFT's oftLimit.maxAmountLD - the only field that reports
it on every path type, since Stargate pools also cap oftReceipt.amountSentLD
but legacy-mesh adapters echo the requested amount there, and vanilla OFTs
report type(uint64).max to mean no limit. The rebalance loop already clamps
to this hook, so an over-capacity deficit is chunked across runs.

Also skip a rebalance clamped to zero rather than initiating a transfer that
pays full per-message costs to move nothing.

Co-Authored-By: Claude <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-02T12:37:47.262797Z c033bc4 PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c033bc4a2e

ℹ️ 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".

Comment thread src/adapter/bridges/OFTBridge.ts Outdated
OFT.buildSimpleSendParamEvm(recipient, this.l2ChainEid, bnZero)
);
const maxAmountLD = BigNumber.from(oftLimit.maxAmountLD);
return maxAmountLD.gte(OFT_UNLIMITED_CAPACITY) ? undefined : maxAmountLD;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Treat only the exact sentinel as uncapped

When a metered 18-decimal path reports legitimate capacity above 2^64 - 1 local units, this gte check misclassifies it as unlimited. For example, Plasma's OFTWethBridge inherits this method, and any available capacity above roughly 18.45 WETH returns undefined; if a requested rebalance exceeds that capacity, the InventoryClient does not clamp it and quoteSend still reverts, defeating this change for larger WETH capacities. Compare the value for equality with the vanilla-OFT sentinel instead.

Useful? React with 👍 / 👎.

Comment on lines 1019 to 1021
const maxTransferAmount = await this.adapterManager
.getMaxL1ToL2TransferAmount(rebalance.chainId, rebalance.l1Token)
.catch(() => undefined);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reserve capacity across same-path rebalances

When one run plans multiple rebalances for the same token and destination path, each iteration reads the same pre-transfer capacity and clamps independently. This occurs, for example, when rebalanceShortfalls adds a shortfall rebalance and _getPossibleInventoryRebalances then adds an ordinary rebalance for the same route: with capacity 100 and planned amounts 60 and 80, both pass planning, but after the first confirmed send leaves capacity 40, construction of the second send still uses 80 and reverts. Track and subtract capacity already allocated to earlier same-path rebalances, or re-query immediately before each send.

Useful? React with 👍 / 👎.

@droplet-rl

Copy link
Copy Markdown
Contributor Author

I've used my 1 automated round on this PR, so I'm ignoring further automated-reviewer activity. To re-activate me: have a human @-mention @droplet-rl in a comment (resets the budget), or assign me to the PR (raises the budget to 3 rounds).

`maxAmountLD` reports `type(uint64).max` to mean "no limit", but not always
verbatim: the mainnet WETH messenger scales it by the shared -> local decimal
conversion rate. Treating anything at or above the unscaled value as uncapped
therefore misread genuine capacity above `type(uint64).max` local units as
unlimited and skipped the clamp -- on an 18-decimal path that starts at only
~18.45 tokens. Match the placeholder exactly, in both encodings.

Also documents that capacity is not reserved across rebalances planned in the
same run, since a per-transfer ceiling and a shared pool cannot be decremented
alike behind one hook.

Co-Authored-By: Claude <noreply@anthropic.com>
@droplet-rl

Copy link
Copy Markdown
Contributor Author

Both findings were real. P1 is fixed in 219af1b; P2 I've documented rather than fixed, reasoning below.

P1 — uncapped sentinel (fixed, but not the suggested way)

Correct diagnosis, and I'd independently hit the same thing while verifying this branch against mainnet. The suggested fix — compare for equality with the vanilla-OFT sentinel — is incomplete though, because type(uint64).max isn't always reported verbatim. Zero-amount quoteOFT on mainnet today:

path messenger maxAmountLD
USDT → Arbitrum (6 local / 6 shared) 0x6C96dE32… 18446744073709551615 (verbatim)
WETH → Plasma (18 local / 6 shared) 0x77b20437… 18446744073709551615000000000000 (× 1e12)
USDT → Tron, legacy mesh (metered) 0x1F748c76… 9253852342 (real credit)

The WETH messenger scales the sentinel by the shared → local conversion rate. A strict eq against the unscaled value would classify that genuinely-uncapped path as having a ~1.8e13 WETH cap — never binding in practice, so harmless, but it reports a limit where there is none. So I match the placeholder exactly in both encodings instead, which fixes the misread you identified without inventing a phantom cap.

Worth noting the bug was slightly narrower than described: OFTWethBridge doesn't currently exist, and WETH → Plasma reports uncapped today, so nothing was mis-clamped in production. It was a live trap for any metered 18-decimal path, which is exactly where this hook would matter.

Added a regression test at the misread threshold (50 WETH capacity, above the ~18.45 unscaled limit); confirmed it fails against the old gte and passes now.

P2 — reserving capacity across same-path rebalances (documented, not fixed)

Also real: getPossibleRebalances can emit a shortfall and an ordinary rebalance for one route, and 1:many token mappings can put two L2 tokens on one path, so each iteration clamps against the same pre-transfer figure.

I didn't implement reservation because the naive version breaks the other caller. getMaxL1ToL2TransferAmount carries two different meanings today: Binance's is a per-transfer ceiling, OFT's is a shared pool. Subtracting planned amounts is correct for the pool and wrong for the ceiling — two 60-unit Binance transfers under a 100 cap are both legal, but reservation would clamp the second to 40. Fixing it properly means teaching the hook to distinguish the two (a flag or a separate hook, plumbed through BaseBridgeAdapterBaseChainAdapterAdapterManager), or merging same-route rebalances upstream in getPossibleRebalances. Either is a design call I'd rather not make unilaterally inside this PR. The failure mode is also no worse than the status quo: the second send fails to construct exactly as it did before this hook existed. Documented in src/clients/README.md and at the call site so it isn't silently forgotten.

Verification: tsc and eslint clean; 100 passing in test/generic-adapters/, 31 in the two affected files, Binance bridge tests unaffected (6/6).

That's my one automated round used, so I won't re-summon Codex. If you'd like P2 handled in this PR rather than a follow-up, assign me and I'll pick a direction.

@droplet-rl

Copy link
Copy Markdown
Contributor Author

🔎 View trace

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have duplicate implementation here? I think I've seen very similar logic (for rounding) previously on the withdrawal path.

@droplet-rl droplet-rl Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes — mine to fix. roundAmountToSend already owned that arithmetic; extracted it as OFT.getDecimalConversionRate and pointed both at it (ee09eda), plus folded OFTBridge's two sharedDecimals fetches into one accessor. Added tests on both branches — the rounding path had none.

Left alone: the OFTBridge/OFTL2Bridge roundAmountToSend wrappers (different hierarchies, needs a mixin — pre-existing, happy to do it here or later). Capacity reading is deliberately not shared: withdrawals size down from amountSentLD, which legacy-mesh adapters don't report capacity in, hence maxAmountLD here.

The placeholder scaling added in the previous commit recomputed
10 ** (tokenDecimals - sharedDecimals), which OFT.roundAmountToSend already
owned. Extract it as getDecimalConversionRate and have both use it, and fold
OFTBridge's two copies of the sharedDecimals fetch-and-cache into one accessor.

Behaviour-preserving: the helper returns 1 rather than a sub-unit divisor when
the token carries no more precision than the path, so rounding leaves such
amounts untouched as before. Covered by tests on both branches, since the
rounding path (withdrawals, dataworker refund leaves, rebalancer) had none.

Co-Authored-By: Claude <noreply@anthropic.com>
@droplet-rl

Copy link
Copy Markdown
Contributor Author

Fixed in ee09eda — you were right, roundAmountToSend already owned that arithmetic. Extracted as OFT.getDecimalConversionRate, both callers use it, and OFTBridge's duplicated sharedDecimals fetch is now one accessor. Added tests on both branches since the rounding path had none. Details on the thread.

@droplet-rl

Copy link
Copy Markdown
Contributor Author

🔎 View trace

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants