Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 44 additions & 8 deletions src/components/integrations/lifi-earn/IntentBridgeStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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>();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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({
Expand All @@ -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,
Expand All @@ -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);

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 👍 / 👎.


// 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
Expand Down Expand Up @@ -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

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 👍 / 👎.

const receipt = await wagmiWaitForReceipt(config, {
hash,
chainId: sourceChainId,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
44 changes: 37 additions & 7 deletions src/components/integrations/lifi-earn/WithdrawIntentRouteStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,16 @@ import { IntentStatusTimeline } from "./IntentStatusTimeline";
import {
isDeliveredOrSettled,
readDestinationTxHash,
readQuoteOutputAmount,
requestIntentQuote,
type IntentQuote,
} from "./intentsApi";
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,
Expand Down Expand Up @@ -111,13 +115,17 @@ export function WithdrawIntentRouteStep({
setOpenTxHash(null);
setOrderId(null);
deliveredNotifiedRef.current = false;
// `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,
destinationChainId,
destinationToken.address,
recipient,
address,
]);

useEffect(() => {
Expand Down Expand Up @@ -151,6 +159,14 @@ export function WithdrawIntentRouteStep({

async function handleQuote() {
if (!recipientAddr) 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);
Expand Down Expand Up @@ -189,13 +205,13 @@ export function WithdrawIntentRouteStep({
});

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 intent quote available for this receive route");
}

const deadlines = buildDeadlinePlan({
quoteValidUntilIso: q.validUntil ?? null,
quoteValidUntil: q.validUntil ?? null,
});

const built = buildStandardOrder({
Expand All @@ -206,7 +222,7 @@ export function WithdrawIntentRouteStep({
inputAmount: BigInt(sourceAmountRaw),
targetChainId: destinationChainId,
outputToken: destinationToken.address as Address,
outputAmount: BigInt(previewAmount),
outputAmount: previewAmount,
recipient: recipientAddr,
expires: deadlines.expires,
fillDeadline: deadlines.fillDeadline,
Expand Down Expand Up @@ -234,6 +250,18 @@ export function WithdrawIntentRouteStep({
});
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);

setStage("approving");
await safeApproveErc20({
wagmiConfig: config,
Expand All @@ -255,6 +283,9 @@ export function WithdrawIntentRouteStep({
to: INPUT_SETTLER_ESCROW,
data: openData,
});
// 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);
const receipt = await wagmiWaitForReceipt(config, {
hash,
chainId: sourceChainId,
Expand All @@ -264,7 +295,6 @@ export function WithdrawIntentRouteStep({
throw new Error("open() reverted on-chain");
}

setOpenTxHash(hash);
const decodedOrderId = extractOpenOrderId(receipt.logs);
if (!decodedOrderId) {
throw new Error(
Expand Down Expand Up @@ -509,7 +539,7 @@ export function WithdrawIntentRouteStep({
size="sm"
className="h-8 w-full gap-1 text-xs"
onClick={handleQuote}
disabled={!isConnected}
disabled={!isConnected || !!openTxHash}
>
<ArrowsClockwise size={12} weight="bold" />
Retry route
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,15 @@ import {
import { useConfig } from "wagmi";
import {
requestIntentQuote,
readQuoteOutputAmount,
type IntentQuote,
} from "../../intentsApi";
import { fetchComposerQuote } from "../../earnApi";
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,
Expand All @@ -31,7 +35,7 @@ import {
extractOpenOrderId,
inputSettlerEscrowAbi,
} from "../../../../../lib/intents/contracts";
import { safeApproveErc20 } from "../../txUtils";
import { safeApproveErc20, formatTxError } from "../../txUtils";
import type { IntentLegSpec } from "./intentLegs";

// Quote requests fan out in parallel; on-chain open() runs sequentially —
Expand Down Expand Up @@ -158,17 +162,17 @@ export function useIntentLegPipeline(): UseIntentLegPipelineReturn {
return { ...run, status: "failed", error: "No quote returned" };
}

const previewAmount = quote.preview?.outputs?.[0]?.amount;
if (!previewAmount) {
const previewAmount = readQuoteOutputAmount(quote);
if (previewAmount === null) {
return {
...run,
status: "failed",
error: "Quote missing preview output amount",
error: "Quote returned no usable output amount",
};
}

const deadlines = buildDeadlinePlan({
quoteValidUntilIso: quote.validUntil ?? null,
quoteValidUntil: quote.validUntil ?? null,
});

const order = buildStandardOrder({
Expand All @@ -179,7 +183,7 @@ export function useIntentLegPipeline(): UseIntentLegPipelineReturn {
inputAmount: BigInt(spec.source.amountRaw),
targetChainId: spec.destination.chainId,
outputToken: spec.destination.outputToken,
outputAmount: BigInt(previewAmount),
outputAmount: previewAmount,
recipient: spec.destination.recipient,
expires: deadlines.expires,
fillDeadline: deadlines.fillDeadline,
Expand Down Expand Up @@ -235,6 +239,9 @@ export function useIntentLegPipeline(): UseIntentLegPipelineReturn {
async (run: IntentLegRun): Promise<IntentLegRun> => {
if (!run.order) return run;
const chainId = run.spec.source.chainId;
// Held outside the try so a receipt-wait timeout still reports the
// broadcast hash instead of losing the escrow.
let broadcastHash: Hex | undefined;

try {
const currentChain = wagmiGetAccount(config).chainId;
Expand All @@ -246,6 +253,16 @@ export function useIntentLegPipeline(): UseIntentLegPipelineReturn {
if (!walletClient) throw new Error("No wallet client for source chain");
const walletAddress = walletClient.account.address as Address;

// open() collects from msg.sender but delivers and refunds to
// order.user. If they diverge, the signer funds someone else's order.
if (walletAddress.toLowerCase() !== run.order.user.toLowerCase()) {
throw new Error(
"The connected account changed after this quote was built — re-quote this leg before opening it.",
);
}

assertFillWindowOpen(run.order.fillDeadline);

// Snapshot destination-chain balance of the underlying so the
// post-delivery deposit step can use the actual delta. CRITICAL:
// a failed read must HARD-FAIL — otherwise the post-fill delta
Expand Down Expand Up @@ -290,6 +307,7 @@ export function useIntentLegPipeline(): UseIntentLegPipelineReturn {
to: INPUT_SETTLER_ESCROW,
data: openData,
});
broadcastHash = openHash;
const receipt = await wagmiWaitForReceipt(config, {
hash: openHash,
chainId,
Expand Down Expand Up @@ -323,7 +341,8 @@ export function useIntentLegPipeline(): UseIntentLegPipelineReturn {
return {
...run,
status: "failed",
error: err instanceof Error ? err.message : String(err),
openTxHash: broadcastHash ?? run.openTxHash,
error: formatTxError(err),
};
}
},
Expand All @@ -349,6 +368,15 @@ export function useIntentLegPipeline(): UseIntentLegPipelineReturn {
if (!walletAddress) return;
const current = runsRef.current.find((r) => r.spec.id === id);
if (!current) 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 (current.openTxHash) {
patch(id, {
error:
"An order was already broadcast for this leg. Check that transaction before retrying — opening again could escrow your funds twice.",
});
return;
}
patch(id, { status: "quoting", error: undefined });
const next = await quoteOne(
{ ...current, status: "quoting" },
Expand Down
Loading