Skip to content

fix(intents): close funds-handling gaps in the escrow order flow - #28

Merged
Timidan merged 1 commit into
feat/lifi-intentsfrom
fix/intents-hardening
Aug 23, 2026
Merged

Timidan merged 1 commit into
feat/lifi-intentsfrom
fix/intents-hardening

Conversation

@Timidan

@Timidan Timidan commented Aug 23, 2026

Copy link
Copy Markdown
Owner

Six defects found while reviewing the intents integration before merge. The first three can cost users funds; the rest remove footguns.

Six defects found while reviewing the intents integration before merge.
The first three can cost users funds; the rest remove footguns.

- Reject non-positive quote outputs. `amount` is a decimal string, so the
  `!previewAmount` guard let "0" through and built an order offering the
  whole input for nothing, which a solver can fill by transferring zero.
  Adds readQuoteOutputAmount() and uses it in all three quote paths.

- Bind the order to the signing account. open() collects from msg.sender
  but delivers and refunds to order.user; the reset effects keyed on the
  `recipient` prop and not the connected address, so switching accounts
  after quoting let one account fund an order that pays another. Adds
  `address` to the reset deps and asserts the two match before signing.

- Parse validUntil by shape. It arrives as a numeric Unix timestamp but
  was typed as a string and fed to Date.parse, which returns NaN for
  numeric input in either unit — the real expiry was discarded and
  replaced by an arbitrary now+15m.

- Revalidate the fill window immediately before open(). Deadlines were
  fixed at quote time and never rechecked, and legs open sequentially, so
  a later leg could escrow into an order no solver would fill.

- Record the open() hash before awaiting its receipt, on both the step
  components and the concierge pipeline. A receipt timeout on a tx that
  later mined left the escrow invisible, and retry minted a fresh nonce —
  two live orders, both fillable. Retry now refuses while a hash is held.

- Approve the requested amount instead of MaxUint256 in the composer
  helper, and stop treating an unreadable allowance as zero, which
  skipped the USDT-style reset and sent a reverting approve.
Copilot AI lite review requested due to automatic review settings August 23, 2026 04:52
@vercel

vercel Bot commented Aug 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
web3-toolkit Ready Ready Preview Aug 23, 2026 4:52am

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@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: a7d2827cc1

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

);
}

assertFillWindowOpen(order.fillDeadline);

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 Recheck the fill deadline after approvals

When the ERC-20 allowance is insufficient—especially for a token requiring a reset transaction—this check runs before the destination balance read and one or two approval confirmations. A quote with slightly more than 30 seconds remaining can therefore expire before open() is submitted, leaving funds in an order that cannot be filled until refund expiry. Move or repeat the assertion immediately before sendTransaction; the same premature ordering also occurs in WithdrawIntentRouteStep.handleOpen and useIntentLegPipeline.openOne.

Useful? React with 👍 / 👎.

Comment on lines +714 to +716
throw new Error(
"Couldn't read the current token allowance — refusing to approve blindly. Try again in a moment.",
);

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 Report allowance-read failures through the recovery state

If this RPC read fails after the bridge has settled, throwing directly bypasses fail(), which is responsible for emitting phase: "failed", failedAfterBridge, and the bridge metadata. DepositFlow consequently retains phase: "quoting-deposit", treats the operation as perpetually in flight, disables the main CTA, and never exposes the retry-deposit action even though the user's funds have already reached the destination chain. Route this error through fail() as the surrounding failure paths do.

Useful? React with 👍 / 👎.

Comment on lines +359 to +361
// Record before waiting: a receipt timeout on a tx that later mines must
// not leave the escrow invisible, or retry would open a second order.
setOpenTxHash(hash);

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 Clear the broadcast lock after a confirmed revert

When the receipt resolves with status === "reverted", this hash has already been persisted; the catch then enters failed, while the retry button is disabled whenever openTxHash is set. A confirmed revert cannot later create an escrow, so retaining the timeout-oriented lock unnecessarily prevents obtaining another intent quote until the component is reset or reloaded. Clear the hash on the explicit reverted-receipt path, with the analogous path in WithdrawIntentRouteStep needing the same treatment.

Useful? React with 👍 / 👎.

@Timidan
Timidan merged commit 094e5f8 into feat/lifi-intents Aug 23, 2026
2 checks passed
Timidan added a commit that referenced this pull request Aug 26, 2026
* feat: implement LI.FI intents API integration and enhance composer route handling

- Added intentsApi.ts for handling intents-related API calls.
- Introduced useIntentOrderStatus.ts for managing intent order status with React Query.
- Created withdrawComposerRoute.ts to manage the withdrawal process through composer routes.
- Enhanced error handling and user feedback during the withdrawal process.
- Updated earnApi.ts to separate quote URL building from fetching logic.
- Improved type definitions in types.ts and added nullable symbol handling for EarnToken.
- Implemented nonce generation for unique order identification in nonce.ts.
- Added deadline management for orders in deadlines.ts.
- Created standardOrder.ts for building and managing standard order structures.
- Updated Vercel and Vite configurations to support new API endpoints.

* fix(concierge): chunk per-chain multicall so ERC-20 balances don't silently drop

Chains with many underlyings (Base has ~300) blew past the public-RPC
per-eth_call gas limit when `aggregate3` fanned out in a single request.
viem's multicall returns ALL-failure (no throw) in that case, so every
ERC-20 balance silently disappeared from the idle-asset list while
native `getBalance` still worked — visible symptom was "ETH on Base
shows $4 but my $200 of USDC doesn't appear."

Cap each multicall at 50 sub-calls and fan the chunks out in parallel.
Per-chunk catch returns failure shape so one bad chunk doesn't kill the
rest. Outer withTimeout gets 2x the per-call budget to absorb the
serialization cost of multiple round-trips on slow public RPCs.

* fix(concierge): drop non-hex token addresses + chunk multicall for idle scan

The LI.FI Earn /vaults feed occasionally ships non-EVM identifiers in
`underlyingTokens[].address` (seen in the wild on Base: `coingecko:universal-btc`).
viem's multicall ABI-encodes each entry as `address`, and a single
malformed entry corrupts the entire aggregate3 calldata. The RPC
rejects with "invalid hex string", viem maps the whole batch to
all-failure, and every legitimate balance silently disappears from
the idle-asset list (e.g. USDC on Base — native ETH still shows up
because it's a separate getBalance call).

Pre-validate each address against /^0x[a-f0-9]{40}$/i before building
the multicall, so one bad upstream entry no longer takes the whole
chain offline. Also chunks the multicall into batches of 50 to keep
the aggregate3 payload below the tighter eth_call gas budgets some
RPCs enforce.

vite.config.ts: ignore the edb submodule's 15GB Rust target/ dir
(and other generated dirs) from the file watcher — they blow the
Linux inotify per-process instance cap on startup.

* feat(deposit): route-aware source picker for LI.FI Intent options

Cross-chain holdings under "BRIDGE VIA LI.FI INTENT" now check the
live LI.FI route registry before being offered as selectable. Sources
without a routable solver path from origin to the vault's underlying
appear in the group disabled, with an inline "· no Intent route"
suffix. Radix's data-[disabled]:pointer-events-none disqualifies
tooltip-on-disabled, so the reason is shown inline alongside the
balance instead. A middle-dot separator is wrapped in aria-hidden
so screen readers announce the suffix cleanly.

A selection guard useEffect resets to the canonical DIRECT source
(plus clears the amount + sim result) if a previously selected
cross-chain token becomes known-unavailable after routes resolve —
covers the race where the user picks during the loading window and
the registry later reveals the route doesn't exist.

Shares React Query's cache via queryKey: ["lifi-intent-routes"]
(same key IntentPanel uses) so we don't double-fetch routes when
both the concierge and the deposit drawer mount in the same session.

buildRoutesIndex now treats both `undefined` (loading) AND `[]`
(transient empty cache resolution) as optimistic — returns
{isEmpty: true, has: () => true} for both. The earlier strict
semantics disabled every cross-chain source for the ~200ms window
between cache resolution and the populated fetch landing. The
authoritative runtime gate remains IntentBridgeStep's "No quote
available" panel, so optimistic-during-load is safe.

* fix(intents): allow missing-Origin same-origin GETs + check approval receipts

api/lifi-intents.ts: when PROXY_SECRET is unset, the gate previously
required an Origin header on every request and 403'd otherwise. Browsers
omit Origin on many same-origin fetches (especially GETs), so production
returned 403 on `/routes`, `/orders/status`, and `/chains/supported`
for any client whose browser didn't attach Origin. Mirrors the
lifi-composer proxy contract: allow missing Origin, reject only present
but unapproved origins.

src/components/integrations/lifi-earn/txUtils.ts: safeApproveErc20 was
not inspecting receipt.status — wagmi's waitForTransactionReceipt
resolves on reverted txs too, so a reverted reset-approve or final
approve let the caller proceed assuming allowance was granted.
The downstream open()/route execution would then fail with a confusing
error far from the actual cause. Capture the receipt and throw on
status === "reverted" for both legs.

* chore(deposit): relabel cross-chain picker group as engine-agnostic

The picker section was named "Bridge via LI.FI Intent" but the actual
cross-chain route is decided by the toggle below the picker — either
LI.FI Intent (1 signature, solver auction) or LI.FI Composer 2-step
(bridge tx + deposit tx). Promising one engine in the source-group
label hides the alternative and confuses users when they expect a
separate Composer entry. Rename to "Bridge via Composer or LI.FI
Intent" so the group label matches what the underlying flow can
actually pick.

* fix(lifi-earn): guard refund receipts + balance-delta correctness

- IntentBridgeStep / WithdrawIntentRouteStep / useIntentLegPipeline:
  throw on receipt.status === "reverted" after refund() — wagmiWaitForReceipt
  resolves on reverted txs too, so a reverted refund was silently treated
  as success.
- useIntentLegPipeline: wrap depositLeg body in outer try/finally so every
  early return (predeliveryBalance missing, delta zero, balance read fail)
  releases the in-flight lock instead of permanently blocking deposits.
- crossChainComposerDeposit: readBalance now throws on missing provider
  (a silent BigNumber(0) caused post-pre deltas to include unrelated user
  funds); delta clamped via gt-guard; resume path requires a known
  originalBridged amount instead of falling back to entire live balance.
- RebalancePlanCard: balance delta clamps to 0n when post <= pre instead
  of falling back to the entire post balance.

* fix(intents): close funds-handling gaps in the escrow order flow

Seven defects found while reviewing the intents integration before merge.
The first three can cost users funds; the rest remove footguns.

- Reject non-positive quote outputs. `amount` is a decimal string, so the
  `!previewAmount` guard let "0" through and built an order offering the
  whole input for nothing, which a solver can fill by transferring zero.
  Adds readQuoteOutputAmount() and uses it in all three quote paths.

- Bind the order to the signing account. open() collects from msg.sender
  but delivers and refunds to order.user; the reset effects keyed on the
  `recipient` prop and not the connected address, so switching accounts
  after quoting let one account fund an order that pays another. Adds
  `address` to the reset deps and asserts the two match before signing.

- Parse validUntil by shape. It arrives as a numeric Unix timestamp but
  was typed as a string and fed to Date.parse, which returns NaN for
  numeric input in either unit — the real expiry was discarded and
  replaced by an arbitrary now+15m.

- Revalidate the fill window immediately before open(). Deadlines were
  fixed at quote time and never rechecked, and legs open sequentially, so
  a later leg could escrow into an order no solver would fill.

- Record the open() hash before awaiting its receipt, on both the step
  components and the concierge pipeline. A receipt timeout on a tx that
  later mined left the escrow invisible, and retry minted a fresh nonce —
  two live orders, both fillable. Retry now refuses while a hash is held.

- Approve the requested amount instead of MaxUint256 in the composer
  helper, and stop treating an unreadable allowance as zero, which
  skipped the USDT-style reset and sent a reverting approve.

- Document that PROXY_SECRET breaks browser deployments: the web client
  never sends x-proxy-secret, so setting it 403s every proxied call.

Adds unit tests for the amount and deadline parsing. These are the first
tracked tests in the repo: .gitignore matched *test*, so every spec was
silently dropped. Narrowed to keep test sources under src/.

* fix(intents): close funds-handling gaps in the escrow order flow (#28)

Six defects found while reviewing the intents integration before merge.
The first three can cost users funds; the rest remove footguns.

- Reject non-positive quote outputs. `amount` is a decimal string, so the
  `!previewAmount` guard let "0" through and built an order offering the
  whole input for nothing, which a solver can fill by transferring zero.
  Adds readQuoteOutputAmount() and uses it in all three quote paths.

- Bind the order to the signing account. open() collects from msg.sender
  but delivers and refunds to order.user; the reset effects keyed on the
  `recipient` prop and not the connected address, so switching accounts
  after quoting let one account fund an order that pays another. Adds
  `address` to the reset deps and asserts the two match before signing.

- Parse validUntil by shape. It arrives as a numeric Unix timestamp but
  was typed as a string and fed to Date.parse, which returns NaN for
  numeric input in either unit — the real expiry was discarded and
  replaced by an arbitrary now+15m.

- Revalidate the fill window immediately before open(). Deadlines were
  fixed at quote time and never rechecked, and legs open sequentially, so
  a later leg could escrow into an order no solver would fill.

- Record the open() hash before awaiting its receipt, on both the step
  components and the concierge pipeline. A receipt timeout on a tx that
  later mined left the escrow invisible, and retry minted a fresh nonce —
  two live orders, both fillable. Retry now refuses while a hash is held.

- Approve the requested amount instead of MaxUint256 in the composer
  helper, and stop treating an unreadable allowance as zero, which
  skipped the USDT-style reset and sent a reverting approve.

* chore: drop out-of-scope changes from the intents hardening pass

Restores .env.example and .gitignore, and untracks the two spec files
that came in with them.

The PROXY_SECRET warning was already documented on master, and this
repo excludes test sources on purpose. Both were mine to begin with and
neither belongs in this PR; the .env.example edit was also what made the
branch conflict with master.

Leaves only the six code fixes.

This branch was successfully deployed

1 active deployment
Preview — a7d2827c Deployed Aug 23, 2026 by vercel[bot]
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