Repository navigation
fix(intents): close funds-handling gaps in the escrow order flow #28
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,13 +24,17 @@ import { | |
| requestIntentQuote, | ||
| isDeliveredOrSettled, | ||
| readDestinationTxHash, | ||
| readQuoteOutputAmount, | ||
| type IntentQuote, | ||
| } from "./intentsApi"; | ||
| import { fetchComposerQuote } from "./earnApi"; | ||
| import { IntentStatusTimeline } from "./IntentStatusTimeline"; | ||
| import { useIntentOrderStatus } from "./useIntentOrderStatus"; | ||
| import { encodeEip7930EvmAddress } from "../../../lib/intents/eip7930"; | ||
| import { buildDeadlinePlan } from "../../../lib/intents/deadlines"; | ||
| import { | ||
| buildDeadlinePlan, | ||
| assertFillWindowOpen, | ||
| } from "../../../lib/intents/deadlines"; | ||
| import { nextOrderNonce } from "../../../lib/intents/nonce"; | ||
| import { | ||
| buildStandardOrder, | ||
|
|
@@ -134,7 +138,17 @@ export function IntentBridgeStep({ | |
| setDepositError(null); | ||
| lastIntentStatusEventRef.current = null; | ||
| lastDeliveredEventRef.current = null; | ||
| }, [sourceChainId, sourceToken.address, sourceAmountRaw, vault.address, recipient]); | ||
| // `address` matters: the order records the connected account as escrow | ||
| // depositor and refund payee, so a stale quote must not survive an account | ||
| // switch. | ||
| }, [ | ||
| sourceChainId, | ||
| sourceToken.address, | ||
| sourceAmountRaw, | ||
| vault.address, | ||
| recipient, | ||
| address, | ||
| ]); | ||
|
|
||
| const explorerByChain = useMemo(() => { | ||
| const map = new Map<number, string>(); | ||
|
|
@@ -200,6 +214,14 @@ export function IntentBridgeStep({ | |
|
|
||
| async function handleQuote() { | ||
| if (!recipientAddr || !outputToken) return; | ||
| // A broadcast open() may still mine after its receipt wait timed out. | ||
| // Re-quoting mints a fresh nonce, so both orders could fill. | ||
| if (openTxHash) { | ||
| setError( | ||
| "An order was already broadcast for this quote. Check that transaction before starting a new one — opening again could escrow your funds twice.", | ||
| ); | ||
| return; | ||
| } | ||
| try { | ||
| setStage("quoting"); | ||
| setError(null); | ||
|
|
@@ -229,13 +251,13 @@ export function IntentBridgeStep({ | |
| }); | ||
|
|
||
| const q = res.quotes?.[0]; | ||
| const previewAmount = q?.preview?.outputs?.[0]?.amount; | ||
| if (!q || !previewAmount) { | ||
| const previewAmount = readQuoteOutputAmount(q); | ||
| if (!q || previewAmount === null) { | ||
| throw new Error("No quote available for this route"); | ||
| } | ||
|
|
||
| const deadlines = buildDeadlinePlan({ | ||
| quoteValidUntilIso: q.validUntil ?? null, | ||
| quoteValidUntil: q.validUntil ?? null, | ||
| }); | ||
|
|
||
| const built = buildStandardOrder({ | ||
|
|
@@ -246,7 +268,7 @@ export function IntentBridgeStep({ | |
| inputAmount: BigInt(sourceAmountRaw), | ||
| targetChainId: vault.chainId, | ||
| outputToken: outputToken.address as Address, | ||
| outputAmount: BigInt(previewAmount), | ||
| outputAmount: previewAmount, | ||
| recipient: recipientAddr, | ||
| expires: deadlines.expires, | ||
| fillDeadline: deadlines.fillDeadline, | ||
|
|
@@ -273,6 +295,18 @@ export function IntentBridgeStep({ | |
| }); | ||
| if (!walletClient) throw new Error("No wallet client for source chain"); | ||
|
|
||
| // open() collects from msg.sender but delivers and refunds to order.user. | ||
| // If they diverge, the signer funds an order that pays someone else. | ||
| if ( | ||
| walletClient.account.address.toLowerCase() !== order.user.toLowerCase() | ||
| ) { | ||
| throw new Error( | ||
| "The connected account changed after this quote was built — request a new quote before opening the order.", | ||
| ); | ||
| } | ||
|
|
||
| assertFillWindowOpen(order.fillDeadline); | ||
|
|
||
| // Snapshot the destination underlying balance BEFORE we open the order. | ||
| // CRITICAL: a failed pre-read must HARD-FAIL — otherwise the post-fill | ||
| // delta calculation can't distinguish solver-delivered tokens from the | ||
|
|
@@ -322,6 +356,9 @@ export function IntentBridgeStep({ | |
| phase: "intent-open", | ||
| txHash: hash, | ||
| }); | ||
| // 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); | ||
|
Comment on lines
+359
to
+361
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the receipt resolves with Useful? React with 👍 / 👎. |
||
| const receipt = await wagmiWaitForReceipt(config, { | ||
| hash, | ||
| chainId: sourceChainId, | ||
|
|
@@ -331,7 +368,6 @@ export function IntentBridgeStep({ | |
| throw new Error("open() reverted on-chain"); | ||
| } | ||
|
|
||
| setOpenTxHash(hash); | ||
| const decodedOrderId = extractOpenOrderId(receipt.logs); | ||
| if (!decodedOrderId) { | ||
| // Without an orderId we can't poll status; fail loudly instead of | ||
|
|
@@ -818,7 +854,7 @@ export function IntentBridgeStep({ | |
| size="sm" | ||
| className="h-8 w-full gap-1 text-xs" | ||
| onClick={handleQuote} | ||
| disabled={!isConnected} | ||
| disabled={!isConnected || !!openTxHash} | ||
| > | ||
| <ArrowsClockwise size={12} weight="bold" /> | ||
| Retry intent quote | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 beforesendTransaction; the same premature ordering also occurs inWithdrawIntentRouteStep.handleOpenanduseIntentLegPipeline.openOne.Useful? React with 👍 / 👎.