diff --git a/app/src/app/ui-review-bridge/BridgeReview.tsx b/app/src/app/ui-review-bridge/BridgeReview.tsx index d82ec12..419241a 100644 --- a/app/src/app/ui-review-bridge/BridgeReview.tsx +++ b/app/src/app/ui-review-bridge/BridgeReview.tsx @@ -1,21 +1,22 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Dialog } from "@base-ui/react/dialog"; import { X } from "lucide-react"; import { BridgeForm, Transfer } from "@/components/bridge/BridgeDialog"; import type useBridge from "@/components/bridge/useBridge"; import { formatUnits, parseEther, parseUnits, zeroAddress } from "viem"; import { changeBridgeRoute, parseBridgeAmount, type BridgeRouteChange, type BridgeRouteInputs } from "@/lib/bridge/client"; -import { bridgeCurrency, defaultBridgeAsset, type BridgeQuote } from "@/lib/bridge/types"; +import { bridgeCurrency, bridgeFeePercent, defaultBridgeAsset, type BridgeQuote } from "@/lib/bridge/types"; import type { TrackedApproval } from "@/lib/bridge/approval"; import { describePendingApproval } from "@/lib/bridge/approval-health"; +import { BRIDGE_QUOTE_DEBOUNCE_MS } from "@/lib/bridge/quote-session"; import styles from "@/components/bridge/BridgeDialog.module.css"; const wallet = "0x03508bB71268BBA25ECaCC8F620e01866650532c" as const; const requestId = `0x${"1".repeat(64)}` as const; -type Scene = "idle" | "disconnected" | "quote" | "expired" | "error" | "pending" | "success" | "uncertain" | "refund" | "approval_pending" | "approval_uncertain" | "approval_confirmed" | "approval_queued" | "approval_missing" | "approval_fee"; -const scenes: Scene[] = ["disconnected", "quote", "expired", "error", "pending", "success", "uncertain", "refund", "approval_pending", "approval_uncertain", "approval_confirmed", "approval_queued", "approval_missing", "approval_fee"]; +type Scene = "idle" | "disconnected" | "quote" | "quoting" | "high_fee" | "fee_boundary" | "impact_limit" | "expired" | "error" | "pending" | "success" | "uncertain" | "refund" | "approval_pending" | "approval_uncertain" | "approval_confirmed" | "approval_queued" | "approval_missing" | "approval_fee"; +const scenes: Scene[] = ["disconnected", "quote", "quoting", "high_fee", "fee_boundary", "impact_limit", "expired", "error", "pending", "success", "uncertain", "refund", "approval_pending", "approval_uncertain", "approval_confirmed", "approval_queued", "approval_missing", "approval_fee"]; export default function BridgeReview() { const [open, setOpen] = useState(false); @@ -33,6 +34,11 @@ export default function BridgeReview() { const [clock] = useState(() => Date.now()); // Synthetic fixed conversion, not a market quote. This page never calls Relay. const inputAmount = parseBridgeAmount(amount, inputCurrency.decimals) ?? 0n; + useEffect(() => { + if (!open || (scene !== "idle" && scene !== "approval_confirmed") || !inputAmount) return; + const timer = setTimeout(() => setScene("quote"), BRIDGE_QUOTE_DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [open, scene, inputAmount, amount, origin, destination, originAsset, destinationAsset]); const normalizedInput = inputAmount * 10n ** BigInt(18 - inputCurrency.decimals); const netAmount = normalizedInput * 9975n / 10000n; const converted = originAsset === destinationAsset ? netAmount : originAsset === "USDC" ? netAmount / 2400n : netAmount * 2400n; @@ -45,6 +51,8 @@ export default function BridgeReview() { }; const approval: TrackedApproval | null = erc20Input && (origin === 5042 || origin === 8453) && (scene.startsWith("approval_") || approved) ? { version: 1, chainId: origin, address: wallet, token: inputCurrency.address, spender: "0x4cd00e387622c35bddb9b4c962c136462338bc31", amount: inputAmount.toString(), createdAt: clock, status: scene === "approval_uncertain" ? "uncertain" : scene === "approval_confirmed" || approved ? "confirmed" : "pending", ...(scene === "approval_uncertain" ? {} : { approvalHash: requestId }) } : null; const tracking = ["pending", "success", "uncertain", "refund"].includes(scene); + const feeWarning = ["high_fee", "fee_boundary", "impact_limit"].includes(scene); + const rejectedFee = scene === "fee_boundary" ? inputAmount / 20n + 1n : scene === "impact_limit" ? inputAmount / 400n : inputAmount * 55548n / 1_000_000n; const bridge: ReturnType = { address: scene === "disconnected" ? undefined : wallet, walletChainId: origin, originChainId: origin, destinationChainId: destination, setOriginChainId: (chainId) => changeRoute({ side: "origin", chainId }), setDestinationChainId: (chainId) => changeRoute({ side: "destination", chainId }), reverseRoute: () => changeRoute({ side: "reverse" }), amount, setAmount, @@ -52,7 +60,9 @@ export default function BridgeReview() { balance: parseUnits(originAsset === "USDC" ? "125" : "0.05", inputCurrency.decimals), nativeBalance: parseEther(origin === 5042 ? "125" : "0.05"), balanceLoading: false, balanceError: null, quote: scene === "quote" || scene === "expired" ? quote : null, phase: tracking ? scene as "pending" | "success" | "uncertain" | "refund" : scene === "quote" || scene === "expired" ? "review" : "idle", - error: scene === "error" ? "Relay is temporarily unavailable. Try requesting a quote again." : null, quoteError: null, + error: null, quoteError: scene === "error" ? "Relay is temporarily unavailable. Try requesting a quote again." : feeWarning ? "This quote exceeds the 5% safety limit." : null, + quoteRejection: feeWarning && inputAmount > 0n ? { address: wallet, originChainId: origin, destinationChainId: destination, originAsset, destinationAsset, amount: inputAmount.toString(), reason: scene === "impact_limit" ? "total-impact" : "relay-fee", relayFee: formatUnits(rejectedFee, inputCurrency.decimals), relayFeePercent: bridgeFeePercent(rejectedFee, inputAmount), sourceGas: quote.sourceGas, totalImpactPercent: scene === "impact_limit" ? "-5.000000000000000001" : "-5.5548" } : null, + quoteLoading: scene === "quoting" || (scene === "idle" || scene === "approval_confirmed") && inputAmount > 0n, canQuote: inputAmount > 0n, quoteExpired: scene === "expired", requestQuote: async () => setScene("quote"), confirm: async () => setScene("pending"), reset: () => setScene("quote"), tracked: tracking ? { address: wallet, requestId, amount: quote.amount, originChainId: origin, destinationChainId: destination, originAsset, destinationAsset, destinationHashes: [], status: scene as "pending" | "success" | "uncertain" | "refund", createdAt: clock } : null, statusError: null, retryStatus: () => {}, storageError: null, busy: false, canReset: scene === "success" || scene === "refund", diff --git a/app/src/components/bridge/BridgeDialog.module.css b/app/src/components/bridge/BridgeDialog.module.css index 8e2356e..a179c3f 100644 --- a/app/src/components/bridge/BridgeDialog.module.css +++ b/app/src/components/bridge/BridgeDialog.module.css @@ -60,6 +60,28 @@ .previewNote { display: flex; align-items: flex-start; gap: 10px; padding: 17px 0; border-top: 1px solid var(--color-line); color: var(--color-body); font-size: 12px; line-height: 1.65; } .previewNote svg { flex-shrink: 0; margin-top: 2px; color: var(--color-brand); } .previewNote span { color: var(--color-muted); } +.feeLimit { padding-top: 20px; border-top: 1px solid var(--color-line); font-size: 12px; font-weight: 400; line-height: 1.5; } +.feeLimitHeading { display: grid; grid-template-columns: 18px minmax(0, 1fr) auto; align-items: start; gap: 10px; } +.feeLimitIcon { margin-top: 3px; color: var(--color-down-ink); } +.feeLimitCopy h3 { margin: 0; color: var(--color-ink); font-size: 14px; font-weight: 600; line-height: 1.5; text-wrap: balance; } +.feeLimitCopy p { margin: 4px 0 0; color: var(--color-muted); font-size: 11px; font-weight: 400; line-height: 1.5; } +.feeLimitPercent { display: grid; justify-items: end; gap: 4px; } +.feeLimitPercent strong { color: var(--color-down-ink); font-size: 22px; font-weight: 600; line-height: 1.1; letter-spacing: -.025em; font-variant-numeric: tabular-nums; white-space: nowrap; } +.feeLimitPercent strong span { margin-left: 1px; font-size: 14px; font-weight: 500; } +.feeLimitPercent > span { color: var(--color-muted); font-size: 11px; } +.feeLimitCosts { display: grid; gap: 12px; margin: 20px 0 14px; } +.feeLimitCosts > div { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: start; column-gap: 16px; } +.feeLimitCosts dt { color: var(--color-body); font-size: 12px; font-weight: 400; } +.feeLimitCosts dt span { display: block; margin-top: 2px; color: var(--color-muted); font-size: 11px; } +.feeLimitCosts dd { margin: 0; color: var(--color-ink); text-align: right; font-weight: 500; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } +.feeLimitCosts dd span { color: var(--color-muted); font-size: 11px; font-weight: 400; } +.feeLimitReason { padding-top: 2px; border-top: 1px solid var(--color-line); } +.feeLimitReason summary { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 44px; color: var(--color-body); font-size: 12px; font-weight: 500; list-style: none; cursor: pointer; } +.feeLimitReason summary::-webkit-details-marker { display: none; } +.feeLimitReason summary svg { flex-shrink: 0; color: var(--color-muted); } +.feeLimitReason[open] summary svg { transform: rotate(180deg); } +.feeLimitReason p { margin: 0 0 12px; color: var(--color-body); font-size: 12px; font-weight: 400; line-height: 1.65; overflow-wrap: anywhere; } +.feeLimit .feeLimitHint { margin: 0; color: var(--color-muted); font-size: 11px; font-weight: 400; line-height: 1.65; text-wrap: pretty; } .quote { padding: 18px 0 0; border-top: 1px solid var(--color-line); } .receiveLabel { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; font-size: 12px; color: var(--color-body); } .estimate { font-size: 11px; color: var(--color-muted); } diff --git a/app/src/components/bridge/BridgeDialog.tsx b/app/src/components/bridge/BridgeDialog.tsx index 8961973..d04eaac 100644 --- a/app/src/components/bridge/BridgeDialog.tsx +++ b/app/src/components/bridge/BridgeDialog.tsx @@ -6,8 +6,9 @@ import { Dialog } from "@base-ui/react/dialog"; import { Select } from "@base-ui/react/select"; import { ArrowLeftRight, ArrowRight, ArrowUpRight, Check, ChevronDown, ChevronRight, CircleAlert, Clock3, LoaderCircle, Wallet, X } from "lucide-react"; import { formatEther, formatUnits, zeroAddress } from "viem"; -import { BRIDGE_ASSETS, BRIDGE_CHAINS, BRIDGE_CHAIN_IDS, bridgeCurrency, bridgeTransferInputCurrency, isBridgeAssetSupported, isBridgeChainId, type BridgeAsset, type BridgeChainId } from "@/lib/bridge/types"; +import { BRIDGE_ASSETS, BRIDGE_CHAINS, BRIDGE_CHAIN_IDS, bridgeCurrency, bridgeTransferInputCurrency, isBridgeAssetSupported, isBridgeChainId, type BridgeAsset, type BridgeChainId, type BridgeQuoteRejection } from "@/lib/bridge/types"; import { DISCARD_AFTER_MS } from "@/lib/bridge/client"; +import { formatFeeWarningPercent } from "@/lib/bridge/fee-display"; import { shortAddr } from "@/lib/chainPublic"; import WalletPicker from "../WalletPicker"; import WalletAvatar from "../WalletAvatar"; @@ -110,12 +111,39 @@ function Recipient({ address }: { address: string }) { ); } +function FeeLimitNotice({ rejection, inputSymbol, gasSymbol }: { rejection: BridgeQuoteRejection; inputSymbol: BridgeAsset; gasSymbol: BridgeAsset }) { + const feeTooHigh = rejection.reason === "relay-fee"; + const exactPercent = feeTooHigh ? rejection.relayFeePercent : rejection.totalImpactPercent.replace(/^-/, ""); + return
+
+ +
+

{feeTooHigh ? "Bridge fee too high" : "Quote loss too high"}

+

Nothing has been submitted.

+
+
+ {formatFeeWarningPercent(exactPercent)}% + 5% safety limit +
+
+
+
Relay fee Included
{nativeAmount(rejection.relayFee)} {inputSymbol}
+
Source gas Extra · estimated
{nativeAmount(rejection.sourceGas)} {gasSymbol}
+
+
+ Why is this blocked? +

{feeTooHigh ? `Relay’s fee is ${exactPercent}% of your amount.` : `This quote loses ${exactPercent}% in conversion and fees.`} Openlaunch blocks quotes above 5%. Source gas is extra and is not included in this limit.

+
+

Change the amount or route. Your quote updates automatically.

+
; +} + export default function BridgeDialog({ open, onOpenChange, restoreFocus }: { open: boolean; onOpenChange: (open: boolean) => void; restoreFocus: () => HTMLElement | null; }) { - const bridge = useBridge(); + const bridge = useBridge(open); const popup = useRef(null); const [connecting, setConnecting] = useState(false); const transferring = bridge.tracked !== null; @@ -152,6 +180,7 @@ export default function BridgeDialog({ open, onOpenChange, restoreFocus }: { export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: () => void }) { if (b.approval && (b.approval.status === "pending" || b.approval.status === "uncertain")) return ; const locked = b.busy || b.approvalBusy; + const loading = locked || b.quoteLoading; const reviewing = !!b.quote && !b.quoteExpired; const needsRefresh = !!b.quote && b.quoteExpired; const origin = BRIDGE_CHAINS[b.originChainId]; @@ -161,15 +190,21 @@ export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: () const erc20Input = inputCurrency.address !== zeroAddress; const convertsAsset = inputCurrency.symbol !== outputCurrency.symbol; const outputAmount = (value: string) => nativeAmount(formatUnits(BigInt(value), outputCurrency.decimals)); - const label = b.approvalBusy ? "Confirm USDC approval…" : b.allowanceLoading ? "Checking USDC permission…" : b.phase === "quoting" ? "Finding your route…" + const label = b.approvalBusy ? "Confirm USDC approval…" : b.allowanceLoading ? "Checking USDC permission…" : b.quoteLoading ? "Getting your quote…" : b.phase === "switching" ? "Confirm network switch…" : b.phase === "confirming" ? "Confirm in your wallet…" : needsRefresh ? "Refresh quote" : reviewing && b.approvalRequired ? `Approve ${nativeAmount(b.amount)} USDC` - : reviewing ? `Bridge to ${destination.name}` : "Review bridge"; + : reviewing ? `Bridge to ${destination.name}` : b.quoteError ? "Retry quote" : !b.canQuote ? "Enter an amount" : "Get quote"; + const error = (!b.quoteRejection && b.quoteError) || b.error || b.storageError || b.approvalError; return ( -
{ event.preventDefault(); if (b.address) void (reviewing ? b.approvalRequired ? b.approve() : b.confirm() : b.requestQuote()); else connect(); }}> + { + event.preventDefault(); + if (!b.address) { connect(); return; } + if (loading || b.allowanceLoading || !b.canQuote) return; + void (reviewing ? b.approvalRequired ? b.approve() : b.confirm() : b.requestQuote()); + }}>
@@ -193,7 +228,7 @@ export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: () {b.quote ? (
-
Estimated output
+
Estimated output{needsRefresh ? "Quote expired" : "Live quote"}

{outputAmount(b.quote.amountOut)}{outputCurrency.symbol}

Minimum received
{outputAmount(b.quote.minimumAmountOut)} {outputCurrency.symbol}
@@ -205,14 +240,18 @@ export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: ()

{needsRefresh ? "This quote expired. Refresh and review the new amounts." : "0.5% slippage limit. Arrival time and gas can change."}

{b.approvalRequired ?

First, approve only {nativeAmount(b.amount)} USDC for Relay’s deposit contract. Then review a fresh quote and confirm the bridge separately. Approval alone does not move your funds and uses additional {origin.symbol} for gas.

: null}
- ) :

Get a live quote before you commit.
Fees and the minimum received shown upfront.

} + ) : b.quoteRejection ? :
+ {b.quoteLoading ? : } +

{b.quoteLoading ? "Updating your quote…" : b.quoteError ? "No quote available yet." : b.address ? "Enter an amount. We’ll find your route." : "See your route before you commit."}
+ {b.quoteLoading ? "Keep typing. We’ll use your latest amount." : "Quotes update automatically. No wallet request until you confirm."}

+
} {b.address ? : null} - {b.quoteError || b.error || b.storageError || b.approvalError ?

{b.quoteError || b.error || b.storageError || b.approvalError}

: null} -

Keep some {origin.symbol} on {origin.name} for gas. {convertsAsset ? "Relay converts the asset at the quoted rate. " : ""}Bridging uses Relay, a third-party protocol, and carries risk. Openlaunch does not operate Relay, holds no funds in transit, and is not responsible for delays, refunds or losses. {erc20Input ? "An unspent USDC approval remains until used or revoked." : "No token approvals required."}

diff --git a/app/src/components/bridge/bridge-ui.test.ts b/app/src/components/bridge/bridge-ui.test.ts index c7ef273..dea13f7 100644 --- a/app/src/components/bridge/bridge-ui.test.ts +++ b/app/src/components/bridge/bridge-ui.test.ts @@ -133,14 +133,47 @@ test("stuck records have a bounded discard path, and the hook avoids APIs missin assert.match(panel, /Keep the Transfer ID below/); assert.match(panel, /Openlaunch does not operate Relay, holds no funds in transit, and is not responsible for delays, refunds or losses/); assert.doesNotMatch(hook, /AbortSignal\.(any|timeout)/); - assert.match(hook, /linkedTimeoutSignal\(abort\.signal, 20_000\)/); - assert.match(hook, /anchorQuoteExpiry\(await responseBody\(response\), requestedAt\)/); + assert.match(hook, /linkedTimeoutSignal\(signal, 20_000\)/); + assert.match(hook, /anchorQuoteExpiry\(body, requestedAt\)/); assert.match(hook, /setActivity\(activityAfterWalletChange\)/); assert.match(hook, /error instanceof TransactionReceiptNotFoundError\) return null/); assert.match(hook, /replacementSourceHash\(current, status, receipt\.status === "fulfilled" && receipt\.value === null\)/); assert.match(hook, /const messageOf = bridgeErrorMessage;/); }); +test("quotes update without locking typing or automatically submitting wallet actions", () => { + const hook = read("./useBridge.ts"); + assert.match(panel, /useBridge\(open\)/); + assert.match(hook, /autoQuoteEnabled = open && !!request && !sending && !approvalSending && !approvalPending && !tracked/); + assert.match(hook, /quoteSession.schedule\(\(\) => \{ void requestQuote\(\); \}\)/); + assert.match(hook, /\[autoQuoteEnabled, amount, walletChainId, requestQuote, quoteSession\]/); + assert.match(hook, /if \(!canApply\(\)\) return/); + assert.match(hook, /if \(value === inputs.current.amount\) return/); + assert.match(hook, /next\.originChainId === inputs\.current\.originChainId[^\n]+next\.amount === inputs\.current\.amount\) return/); + assert.match(hook, /busy: sending \|\| approvalSending \|\| approvalPending,/); + assert.match(panel, /const loading = locked \|\| b.quoteLoading/); + assert.match(panel, /input id="bridge-amount"[^\n]+disabled=\{locked\}/); + assert.match(panel, /if \(loading \|\| b.allowanceLoading \|\| !b.canQuote\) return/); + assert.match(panel, /Your quote updates automatically/); + assert.match(panel, /rejection.reason === "relay-fee"/); + assert.match(panel, /rejection.relayFeePercent/); + assert.doesNotMatch(hook, /busy:.*quoting/); +}); + +test("fee warnings separate the percentage, costs and touch-accessible exact explanation", () => { + assert.match(panel, /[\s\S]+Why is this blocked\?/); + assert.match(panel, /Relay’s fee is \$\{exactPercent\}%/); + assert.match(panel, /quote loses \$\{exactPercent\}% in conversion and fees/); + assert.match(panel, /Source gas is extra and is not included in this limit/); + assert.match(css, /\.feeLimitHeading \{[^}]*display: grid[^}]*align-items: start/); + assert.match(css, /\.feeLimitReason summary \{[^}]*min-height: 44px/); + assert.match(css, /\.feeLimitReason p \{[^}]*overflow-wrap: anywhere/); +}); + test("pending approvals explain missing/queued transactions and accept a verified replacement without a new wallet call", () => { const hook = read("./useBridge.ts"); assert.match(panel, /health\?\.title/); diff --git a/app/src/components/bridge/useBridge.ts b/app/src/components/bridge/useBridge.ts index 2e94c2e..c08f2e0 100644 --- a/app/src/components/bridge/useBridge.ts +++ b/app/src/components/bridge/useBridge.ts @@ -6,7 +6,9 @@ import { getAccount, getPublicClient, getWalletClient } from "wagmi/actions"; import { estimateTotalFee } from "viem/op-stack"; import { erc20Abi, parseEther, TransactionReceiptNotFoundError, type Address } from "viem"; import { BRIDGE_WALLET_CHAINS } from "@/lib/bridge/chains"; -import { BRIDGE_CHAINS, bridgeCurrency, defaultBridgeAsset, isBridgeAssetSupported, isBridgeChainId, type BridgeAsset, type BridgeChainId, type BridgeQuote } from "@/lib/bridge/types"; +import { BRIDGE_CHAINS, bridgeCurrency, defaultBridgeAsset, isBridgeAssetSupported, isBridgeChainId, type BridgeAsset, type BridgeChainId, type BridgeQuote, type BridgeQuoteRejection } from "@/lib/bridge/types"; +import { parseBridgeQuoteRejection } from "@/lib/bridge/client"; +import { createBridgeQuoteSession } from "@/lib/bridge/quote-session"; import { activityAfterWalletChange, anchorQuoteExpiry, bridgeErrorMessage, bridgeGasBudget, bridgeRequest, bridgeRequestKey, changeBridgeRoute, hasMatchingDepositEvent, isHash, isMatchingSourceDeposit, linkedTimeoutSignal, mergeBridgeStatus, nativeSourceAmount, RELAY_DEPOSITORY, replacementSourceHash, submitBridgeDeposit, transferCanDiscard, transferIsTerminal, transferPhase, validateBridgeQuote, validateBridgeStatus, type BridgeActivity, type BridgePhase, type BridgeRouteChange, type BridgeRouteInputs, type ProviderObservation, type TrackedBridgeTransfer } from "@/lib/bridge/client"; import { BRIDGE_STORAGE_PREFIX, createBridgeTransferStore } from "@/lib/bridge/client-storage"; import { APPROVAL_STORAGE_PREFIX, approvalBlocksSubmission, approvalCanDiscard, canApplyApprovalPoll, createApprovalStore, reconcileApproval, recoverApprovalFromEvidence, submitExactApproval, validateApprovalMetadata, type ApprovalReceiptObservation } from "@/lib/bridge/approval"; @@ -21,6 +23,7 @@ const transfers = createBridgeTransferStore(() => window.localStorage); const approvals = createApprovalStore(() => window.localStorage); type QuoteEnvelope = { quote: BridgeQuote; key: string; walletChainId?: number; requestedAt: number }; type Issue = { key: string; message: string } | null; +type QuoteIssue = { key: string; walletChainId?: number; message: string; rejection?: BridgeQuoteRejection | null } | null; const messageOf = bridgeErrorMessage; const transferLockName = (address: Address) => `openlaunch:bridge:${address.toLowerCase()}`; @@ -34,7 +37,7 @@ async function responseBody(response: Response): Promise { } /** Mount once above the dialog so closing it never interrupts transfer recovery. */ -export function useBridge() { +export function useBridge(open: boolean) { const config = useConfig(); const { address, chainId: walletChainId } = useAccount(); const { switchChainAsync } = useSwitchChain(); @@ -47,7 +50,7 @@ export function useBridge() { const [envelope, setEnvelope] = useState(null); const [activity, setActivity] = useState({ key: "", phase: "idle" }); const [issue, setIssue] = useState(null); - const [quoteIssue, setQuoteIssue] = useState(null); + const [quoteIssue, setQuoteIssue] = useState(null); const [statusIssue, setStatusIssue] = useState(null); const [expiredId, setExpiredId] = useState(null); const [sending, setSending] = useState(false); @@ -62,8 +65,7 @@ export function useBridge() { const [now, setNow] = useState(0); const [discarding, setDiscarding] = useState(false); const actionLock = useRef(false); - const quoteSequence = useRef(0); - const quoteAbort = useRef(null); + const [quoteSession] = useState(createBridgeQuoteSession); const inputs = useRef({ originChainId: 8453, destinationChainId: 4663, originAsset: "ETH", destinationAsset: "ETH", amount: "" }); const snapshot = useSyncExternalStore(transfers.subscribe, transfers.getSnapshot, transfers.getServerSnapshot); const approvalSnapshot = useSyncExternalStore(approvals.subscribe, approvals.getSnapshot, approvals.getServerSnapshot); @@ -80,7 +82,8 @@ export function useBridge() { const approvalRequired = !!quote?.approval && (allowanceResult?.key !== quote.requestId || allowanceResult.value === null || allowanceResult.value < BigInt(quote.approval.amount)); const sourceBalance = useBalance({ address, chainId: originChainId, query: { enabled: !!address, refetchInterval: 15_000 } }); const tokenBalance = useReadContract({ address: inputCurrency.address, abi: erc20Abi, functionName: "balanceOf", args: address ? [address] : undefined, chainId: originChainId, query: { enabled: !!address && inputIsToken, refetchInterval: 15_000 } }); - const cancelQuote = useCallback(() => { quoteSequence.current++; quoteAbort.current?.abort(); }, []); + const cancelQuote = quoteSession.cancel; + const currentQuoteIssue = quoteIssue?.key === requestKey && quoteIssue.walletChainId === walletChainId ? quoteIssue : null; useEffect(() => { if (!address) return; @@ -271,6 +274,7 @@ export function useBridge() { function updateRoute(change: BridgeRouteChange) { if (actionLock.current || approvalPending || (tracked && !transferIsTerminal(tracked))) return; const next = changeBridgeRoute(inputs.current, change); + if (next.originChainId === inputs.current.originChainId && next.destinationChainId === inputs.current.destinationChainId && next.originAsset === inputs.current.originAsset && next.destinationAsset === inputs.current.destinationAsset && next.amount === inputs.current.amount) return; inputs.current = next; setRoute(next); invalidateQuote(); @@ -296,54 +300,72 @@ export function useBridge() { function setAmount(value: string) { if (actionLock.current || approvalPending || (tracked && !transferIsTerminal(tracked))) return; + if (value === inputs.current.amount) return; const next = { ...inputs.current, amount: value }; inputs.current = next; setRoute(next); invalidateQuote(); } - async function requestQuote() { + const requestQuote = useCallback(async () => { if (actionLock.current || approvalPending || (tracked && !transferIsTerminal(tracked))) return; const connected = getAccount(config); const current = bridgeRequest(connected.address, inputs.current.originChainId, inputs.current.amount, inputs.current.destinationChainId, inputs.current.originAsset, inputs.current.destinationAsset); if (!current) { const currency = bridgeCurrency(inputs.current.originChainId, inputs.current.originAsset, "input"); - setQuoteIssue({ key: requestKey, message: !connected.address ? "Connect your wallet to get a quote." : `Enter a ${currency.symbol} amount greater than zero, with at most ${currency.decimals} decimal places.` }); + setQuoteIssue({ key: requestKey, walletChainId: connected.chainId, message: !connected.address ? "Connect your wallet to get a quote." : `Enter a ${currency.symbol} amount greater than zero, with at most ${currency.decimals} decimal places.` }); return; } const key = bridgeRequestKey(current); - const sequence = ++quoteSequence.current; const requestedAt = Date.now(); - quoteAbort.current?.abort(); - const abort = new AbortController(); - quoteAbort.current = abort; setEnvelope(null); setExpiredId(null); - setIssue(null); setQuoteIssue(null); - setApprovalIssue(null); setActivity({ key, phase: "quoting" }); - try { - if (approvalBlocksSubmission(approvals.read(current.address))) throw new Error("An approval is still being tracked. Wait for its confirmation before requesting a new quote."); - const response = await fetch("/api/bridge/quote", { - method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(current), cache: "no-store", - signal: linkedTimeoutSignal(abort.signal, 20_000), - }); - const next = validateBridgeQuote(anchorQuoteExpiry(await responseBody(response), requestedAt), current, Date.now()); - const wallet = getAccount(config); - if (sequence !== quoteSequence.current || wallet.address?.toLowerCase() !== current.address.toLowerCase() || wallet.chainId !== connected.chainId) return; - setEnvelope({ quote: next, key, walletChainId: connected.chainId, requestedAt }); - } catch (error) { - if (sequence !== quoteSequence.current || abort.signal.aborted) return; - setQuoteIssue({ key, message: messageOf(error, "Could not get a quote. Try again.") }); - } finally { - if (sequence === quoteSequence.current) setActivity({ key, phase: "idle" }); - } - } + await quoteSession.run(async ({ signal, isCurrent }) => { + const canApply = () => { + const wallet = getAccount(config); + return isCurrent() && wallet.address?.toLowerCase() === current.address.toLowerCase() && wallet.chainId === connected.chainId; + }; + try { + if (approvalBlocksSubmission(approvals.read(current.address))) throw new Error("An approval is still being tracked. Wait for its confirmation before requesting a new quote."); + const response = await fetch("/api/bridge/quote", { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(current), cache: "no-store", + signal: linkedTimeoutSignal(signal, 20_000), + }); + const body: unknown = await response.json(); + if (!canApply()) return; + if (!response.ok) { + const data = body && typeof body === "object" ? body : {}; + const message = "error" in data && typeof data.error === "string" ? data.error : "Could not get a quote. Try again."; + const rejection = response.status === 422 && "quoteRejection" in data ? parseBridgeQuoteRejection(data.quoteRejection, current) : null; + setQuoteIssue({ key, walletChainId: connected.chainId, message, rejection }); + return; + } + const next = validateBridgeQuote(anchorQuoteExpiry(body, requestedAt), current, Date.now()); + setEnvelope({ quote: next, key, walletChainId: connected.chainId, requestedAt }); + } catch (error) { + if (!canApply()) return; + setQuoteIssue({ key, walletChainId: connected.chainId, message: messageOf(error, "Could not get a quote. Try again.") }); + } finally { + if (isCurrent()) setActivity({ key, phase: "idle" }); + } + }); + }, [approvalPending, tracked, config, requestKey, quoteSession]); + + const autoQuoteEnabled = open && !!request && !sending && !approvalSending && !approvalPending && !tracked; + useEffect(() => { + if (!autoQuoteEnabled) return; + const cancel = quoteSession.schedule(() => { void requestQuote(); }); + return () => { cancel(); setActivity(activityAfterWalletChange); }; + // Include the raw amount: editing "1" to "1.0" invalidates the old display + // even though both normalize to the same request key. Balance polls do not. + }, [autoQuoteEnabled, amount, walletChainId, requestQuote, quoteSession]); async function approve() { if (actionLock.current || !quote?.approval || quoteExpired || approvalPending || (tracked && !transferIsTerminal(tracked))) return; actionLock.current = true; + cancelQuote(); setApprovalSending(true); setApprovalIssue(null); const reviewed = quote; @@ -453,6 +475,7 @@ export function useBridge() { async function confirm() { if (actionLock.current || !quote || quoteExpired || approvalPending || allowanceLoading || approvalRequired || (tracked && !transferIsTerminal(tracked))) return; actionLock.current = true; + cancelQuote(); setSending(true); setIssue(null); const reviewed = quote; @@ -637,6 +660,7 @@ export function useBridge() { const retryStatus = useCallback(() => setPollRevision((value) => value + 1), []); const retryApproval = useCallback(() => setApprovalPollRevision((value) => value + 1), []); const activePhase = activity.key === requestKey ? activity.phase : "idle"; + const quoteLoading = activePhase === "quoting" || (autoQuoteEnabled && !quote && !currentQuoteIssue); const phase: BridgePhase = sending && (activePhase === "switching" || activePhase === "confirming") ? activePhase : tracked ? transferPhase(tracked) : activePhase === "quoting" ? "quoting" : quote ? "review" : "idle"; return { address, walletChainId, originChainId, destinationChainId, originAsset, destinationAsset, setOriginAsset, setDestinationAsset, setOriginChainId, setDestinationChainId, reverseRoute, amount, setAmount, @@ -645,7 +669,9 @@ export function useBridge() { balanceLoading: !!address && (inputIsToken ? tokenBalance.isPending : sourceBalance.isPending), balanceError: (inputIsToken ? tokenBalance.isError : sourceBalance.isError) ? "Could not read your source balance. It will be checked again before submission." : null, quote, phase, error: tracked?.failureReason === "source-reverted" ? "The source transaction reverted on-chain. The deposit was not made; network gas was still charged." : issue?.key === walletKey ? issue.message : null, - quoteError: quoteIssue?.key === requestKey ? quoteIssue.message : null, + quoteError: currentQuoteIssue?.message ?? null, + quoteRejection: currentQuoteIssue?.rejection ?? null, + quoteLoading, canQuote: !!request, quoteExpired, requestQuote, confirm, reset, tracked, approval, approvalRequired, allowanceLoading, approvalBusy: approvalSending, approvalHealth: approvalPending && approvalHealthResult?.wallet === walletKey && approvalHealthResult.createdAt === approvalId && approvalHealthResult.hash === approvalHash ? approvalHealthResult.value : null, @@ -653,7 +679,7 @@ export function useBridge() { approve, retryApproval, recoverApproval, approvalCanBeDiscarded, discardApproval, canDiscard, discard, discarding, statusError: statusIssue && statusIssue.key === trackedId ? statusIssue.message : null, - retryStatus, storageError, busy: sending || approvalSending || approvalPending || activePhase === "quoting", + retryStatus, storageError, busy: sending || approvalSending || approvalPending, canReset: !sending && !approvalSending && !approvalPending && (!tracked || transferIsTerminal(tracked)), }; } diff --git a/app/src/lib/bridge/client.test.ts b/app/src/lib/bridge/client.test.ts index bae93d7..767e8be 100644 --- a/app/src/lib/bridge/client.test.ts +++ b/app/src/lib/bridge/client.test.ts @@ -3,7 +3,8 @@ import test from "node:test"; import { encodeAbiParameters, encodeEventTopics, encodeFunctionData, HttpRequestError, InsufficientFundsError, RpcRequestError, SwitchChainError, UnknownRpcError, UserRejectedRequestError, type Address, type Hex } from "viem"; import { activityAfterWalletChange, anchorQuoteExpiry, bridgeErrorMessage, DISCARD_AFTER_MS, linkedTimeoutSignal, replacementSourceHash, transferCanDiscard, bridgeGasBudget, bridgeRequest, bridgeRequestKey, changeBridgeRoute, ERC20_DEPOSIT_ABI, ERC20_DEPOSIT_EVENT, hasMatchingDepositEvent, isMatchingSourceDeposit, isWalletRejection, mergeBridgeStatus, nativeSourceAmount, NATIVE_DEPOSIT_ABI, NATIVE_DEPOSIT_EVENT, parseBridgeAmount, parseStoredTransfer, RELAY_DEPOSITORY, serializeTransfer, submitBridgeDeposit, transferIsTerminal, validateBridgeQuote, validateBridgeStatus, type DepositDependencies, type TrackedBridgeTransfer } from "./client"; import { bridgeStorageKey, createBridgeTransferStore } from "./client-storage"; -import { ARC_USDC, BASE_USDC, BRIDGE_CHAIN_IDS, type BridgeQuote, type BridgeQuoteRequest } from "./types"; +import { parseBridgeQuoteRejection } from "./client"; +import { ARC_USDC, BASE_USDC, BRIDGE_CHAIN_IDS, type BridgeQuote, type BridgeQuoteRejection, type BridgeQuoteRequest } from "./types"; const ADDRESS = "0x1111111111111111111111111111111111111111" as Address; const OTHER = "0x2222222222222222222222222222222222222222" as Address; @@ -262,6 +263,36 @@ test("fee and total-impact guards reject excessive loss, nonnumeric amounts and assert.throws(() => validateBridgeQuote({ ...q, totalImpactPercent: undefined }, q, NOW), /impact/); }); +test("fee rejection diagnostics bind the request and keep Arc input6 separate from native gas18", () => { + const request = bridgeRequest(ADDRESS, 5042, "1", 8453, "USDC", "ETH")!; + const rejected: BridgeQuoteRejection = { ...request, reason: "relay-fee", relayFee: "0.059123", relayFeePercent: "5.9123", sourceGas: "0.003000000000000001", totalImpactPercent: "-6.24" }; + assert.deepEqual(parseBridgeQuoteRejection(rejected, request), rejected); + assert.notEqual(parseBridgeQuoteRejection(rejected, request), rejected); + for (const change of [ + { address: OTHER }, { amount: "2000000" }, { destinationChainId: 4663 }, { originChainId: 8453 }, + { originAsset: "ETH" }, { originAsset: null }, { destinationAsset: "USDC" }, { destinationAsset: null }, + { reason: "total-impact" }, { relayFeePercent: "5" }, { relayFeePercent: 5.9123 }, + { relayFee: "0.0591231" }, { relayFee: "1e-1" }, { relayFee: "-0.1" }, { relayFee: "1" }, + { sourceGas: "0.0000000000000000001" }, { sourceGas: "9".repeat(80) }, + { totalImpactPercent: "-100.01" }, { totalImpactPercent: "+1" }, { totalImpactPercent: "NaN" }, + { transaction: arcQuote().transaction }, { approval: arcQuote().approval }, { requestId: REQUEST }, + ]) assert.equal(parseBridgeQuoteRejection({ ...rejected, ...change }, request), null, JSON.stringify(change)); + for (const invalid of [null, [], "bad", {}, { ...rejected, relayFee: undefined }]) assert.equal(parseBridgeQuoteRejection(invalid, request), null); + assert.throws(() => validateBridgeQuote(rejected, request, NOW)); +}); + +test("total-impact rejections are display-only and neither lower nor bypass either 5% guard", () => { + const request = bridgeRequest(ADDRESS, 5042, "1", 4663)!; + const rejected: BridgeQuoteRejection = { ...request, reason: "total-impact", relayFee: "0.05", relayFeePercent: "5", sourceGas: "0.001", totalImpactPercent: "-5.000000000000000001" }; + assert.deepEqual(parseBridgeQuoteRejection(rejected, request), rejected); + assert.equal(parseBridgeQuoteRejection({ ...rejected, totalImpactPercent: "-5" }, request), null); + assert.equal(parseBridgeQuoteRejection({ ...rejected, totalImpactPercent: "100" }, request), null); + assert.equal(parseBridgeQuoteRejection({ ...rejected, relayFee: "0.050001", relayFeePercent: "5.0001" }, request), null); + const ethRequest = bridgeRequest(ADDRESS, 8453, "0.01", 5042)!; + const ethRejected: BridgeQuoteRejection = { ...ethRequest, reason: "relay-fee", relayFee: "0.000500000000000001", relayFeePercent: "5.000001", sourceGas: "0.000000000000000001", totalImpactPercent: "0" }; + assert.deepEqual(parseBridgeQuoteRejection(ethRejected, ethRequest), ethRejected); +}); + test("gas preflight reserves fresh fees and rejects spending the full native balance", () => { const budget = bridgeGasBudget(1000n, 10_000n, 100n, 2n, 50n); assert.deepEqual(budget, { gas: 120n, reserve: 300n }); diff --git a/app/src/lib/bridge/client.ts b/app/src/lib/bridge/client.ts index a8713d1..33418c5 100644 --- a/app/src/lib/bridge/client.ts +++ b/app/src/lib/bridge/client.ts @@ -1,6 +1,6 @@ import { BaseError, decodeEventLog, decodeFunctionData, encodeFunctionData, InsufficientFundsError, isAddress, parseUnits, type Address, type Hex } from "viem"; import { friendlyError } from "../errors"; -import { bridgeCurrency, defaultBridgeAsset, isBridgeAssetSupported, isBridgeChainId, type BridgeAsset, type BridgeChainId, type BridgeQuote, type BridgeQuoteRequest, type BridgeStatus, type BridgeStatusResponse } from "./types"; +import { bridgeCurrency, bridgeFeePercent, defaultBridgeAsset, isBridgeAssetSupported, isBridgeChainId, type BridgeAsset, type BridgeChainId, type BridgeQuote, type BridgeQuoteRejection, type BridgeQuoteRequest, type BridgeStatus, type BridgeStatusResponse } from "./types"; export type BridgePhase = "idle" | "quoting" | "review" | "switching" | "confirming" | "pending" | "success" | "refund" | "failure" | "uncertain"; export type TrackedBridgeTransfer = BridgeQuoteRequest & { @@ -96,13 +96,35 @@ export function nativeSourceAmount(request: BridgeQuoteRequest): bigint { return request.originChainId === 5042 ? BigInt(request.amount) * 10n ** 12n : /^0x0{40}$/.test(currency.address) ? BigInt(request.amount) : 0n; } -function validImpactPercent(value: unknown): boolean { +function validImpactPercent(value: unknown, lossLimit = 5n): boolean { if (typeof value !== "string" || value.length > 32 || !/^-?\d+(?:\.\d+)?$/.test(value)) return false; const negative = value.startsWith("-"); const [whole, fractional = ""] = (negative ? value.slice(1) : value).split("."); const scaled = BigInt(whole + fractional); const scale = 10n ** BigInt(fractional.length); - return scaled <= (negative ? 5n : 100n) * scale; + return scaled <= (negative ? lossLimit : 100n) * scale; +} + +/** Rejections are display-only, request-bound and never usable as wallet quotes. */ +export function parseBridgeQuoteRejection(value: unknown, request: BridgeQuoteRequest): BridgeQuoteRejection | null { + try { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const rejected = value as BridgeQuoteRejection; + const allowed = ["address", "originChainId", "destinationChainId", "originAsset", "destinationAsset", "amount", "reason", "relayFee", "relayFeePercent", "sourceGas", "totalImpactPercent"]; + if (Object.keys(value).some((key) => !allowed.includes(key)) || !isAddress(rejected.address ?? "") || !isBridgeChainId(rejected.originChainId) || !isBridgeChainId(rejected.destinationChainId) || rejected.originChainId === rejected.destinationChainId || !isWei(rejected.amount, true)) return null; + if ((rejected.originAsset !== undefined && !isBridgeAssetSupported(rejected.originChainId, rejected.originAsset)) || (rejected.destinationAsset !== undefined && !isBridgeAssetSupported(rejected.destinationChainId, rejected.destinationAsset)) || bridgeRequestKey(rejected) !== bridgeRequestKey(request)) return null; + const decimals = bridgeCurrency(rejected.originChainId, rejected.originAsset, "input").decimals; + for (const [fee, precision] of [[rejected.relayFee, decimals], [rejected.sourceGas, 18]] as const) { + if (typeof fee !== "string" || fee.length > 100 || !new RegExp(`^\\d+(?:\\.\\d{1,${precision}})?$`).test(fee) || parseUnits(fee, precision) > MAX_UINT) return null; + } + const fee = parseUnits(rejected.relayFee, decimals); + const amount = BigInt(rejected.amount); + if (fee >= amount || rejected.relayFeePercent !== bridgeFeePercent(fee, amount) || !validImpactPercent(rejected.totalImpactPercent, 100n)) return null; + const reason = fee * 100n > amount * 5n ? "relay-fee" : !validImpactPercent(rejected.totalImpactPercent) ? "total-impact" : null; + if (reason === null || rejected.reason !== reason) return null; + // Copy only the display schema, never retain untrusted object references. + return { address: rejected.address, originChainId: rejected.originChainId, destinationChainId: rejected.destinationChainId, ...(rejected.originAsset === undefined ? {} : { originAsset: rejected.originAsset }), ...(rejected.destinationAsset === undefined ? {} : { destinationAsset: rejected.destinationAsset }), amount: rejected.amount, reason, relayFee: rejected.relayFee, relayFeePercent: rejected.relayFeePercent, sourceGas: rejected.sourceGas, totalImpactPercent: rejected.totalImpactPercent }; + } catch { return null; } } export function validateBridgeQuote(value: unknown, request: BridgeQuoteRequest, now: number): BridgeQuote { diff --git a/app/src/lib/bridge/fee-display.test.ts b/app/src/lib/bridge/fee-display.test.ts new file mode 100644 index 0000000..6cebaf2 --- /dev/null +++ b/app/src/lib/bridge/fee-display.test.ts @@ -0,0 +1,13 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { formatFeeWarningPercent } from "./fee-display"; + +test("warning percentages are compact while retaining just-over-limit meaning", () => { + assert.equal(formatFeeWarningPercent("5.5548"), "5.55"); + assert.equal(formatFeeWarningPercent("6"), "6"); + assert.equal(formatFeeWarningPercent("22.279"), "22.28"); + assert.equal(formatFeeWarningPercent("5.000001"), ">5"); + assert.equal(formatFeeWarningPercent("-5.000000000000000001"), ">5"); + assert.equal(formatFeeWarningPercent("-12.1234567890123456789012345678"), "12.12"); + assert.equal(formatFeeWarningPercent("5.0000"), "5"); +}); diff --git a/app/src/lib/bridge/fee-display.ts b/app/src/lib/bridge/fee-display.ts new file mode 100644 index 0000000..ee6d22f --- /dev/null +++ b/app/src/lib/bridge/fee-display.ts @@ -0,0 +1,9 @@ +/** Compact display only. Exact percentages remain in the warning disclosure. */ +export function formatFeeWarningPercent(value: string): string { + const absolute = value.replace(/^-/, ""); + const [whole, fraction = ""] = absolute.split("."); + const rounded = Number(absolute).toLocaleString("en-US", { maximumFractionDigits: 2 }); + // Decimal-string comparison retains breaches too small for Number to represent. + const aboveLimit = BigInt(whole) > 5n || (BigInt(whole) === 5n && /[1-9]/.test(fraction)); + return aboveLimit && rounded === "5" ? ">5" : rounded; +} diff --git a/app/src/lib/bridge/quote-session.test.ts b/app/src/lib/bridge/quote-session.test.ts new file mode 100644 index 0000000..dabcd56 --- /dev/null +++ b/app/src/lib/bridge/quote-session.test.ts @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { BRIDGE_QUOTE_DEBOUNCE_MS, createBridgeQuoteSession } from "./quote-session"; + +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +test("typing debounces quotes and only requests the latest amount", (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const session = createBridgeQuoteSession(); + const requests: string[] = []; + session.schedule(() => { requests.push("1"); }); + t.mock.timers.tick(BRIDGE_QUOTE_DEBOUNCE_MS - 1); + assert.deepEqual(requests, []); + session.schedule(() => { requests.push("10"); }); + t.mock.timers.tick(BRIDGE_QUOTE_DEBOUNCE_MS - 1); + assert.deepEqual(requests, []); + t.mock.timers.tick(1); + assert.deepEqual(requests, ["10"]); + t.mock.timers.tick(60_000); + assert.deepEqual(requests, ["10"], "no automatic retry loop after completion"); +}); + +test("invalid input, closing the dialog, or a wallet action cancels a scheduled quote", (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const session = createBridgeQuoteSession(); + let calls = 0; + const cancel = session.schedule(() => { calls++; }); + cancel(); + t.mock.timers.tick(BRIDGE_QUOTE_DEBOUNCE_MS); + assert.equal(calls, 0); +}); + +test("manual refresh replaces the pending debounce instead of requesting twice", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const session = createBridgeQuoteSession(); + let calls = 0; + session.schedule(() => { calls++; }); + await session.run(async () => { calls++; }); + t.mock.timers.tick(BRIDGE_QUOTE_DEBOUNCE_MS); + assert.equal(calls, 1); +}); + +test("late success and error responses cannot replace a newer amount's result", async () => { + for (const oldResult of ["old quote", "old error"]) { + const session = createBridgeQuoteSession(); + const old = deferred(); + let shown = ""; + let oldSignal: AbortSignal | undefined; + const first = session.run(async ({ signal, isCurrent }) => { + oldSignal = signal; + await old.promise; // a transport that ignores cancellation + if (isCurrent()) shown = oldResult; + }); + await session.run(async ({ isCurrent }) => { if (isCurrent()) shown = "new quote"; }); + old.resolve(); + await first; + assert.equal(oldSignal?.aborted, true); + assert.equal(shown, "new quote"); + } +}); + +test("editing aborts in-flight work immediately, before the next debounce finishes", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const session = createBridgeQuoteSession(); + const old = deferred(); + let signal: AbortSignal | undefined; + let applied = false; + const first = session.run(async (context) => { + signal = context.signal; + await old.promise; + applied = context.isCurrent(); + }); + const cleanup = session.schedule(() => {}); + assert.equal(signal?.aborted, true); + old.resolve(); + await first; + assert.equal(applied, false); + cleanup(); +}); + +test("closing or changing wallets discards a response even without a replacement request", async () => { + const session = createBridgeQuoteSession(); + const response = deferred(); + let applied = false; + const running = session.run(async ({ isCurrent }) => { + await response.promise; + applied = isCurrent(); + }); + session.cancel(); + response.resolve(); + await running; + assert.equal(applied, false); +}); diff --git a/app/src/lib/bridge/quote-session.ts b/app/src/lib/bridge/quote-session.ts new file mode 100644 index 0000000..a388d43 --- /dev/null +++ b/app/src/lib/bridge/quote-session.ts @@ -0,0 +1,39 @@ +export const BRIDGE_QUOTE_DEBOUNCE_MS = 700; + +type QuoteTask = (context: { signal: AbortSignal; isCurrent: () => boolean }) => Promise; + +/** One unsigned quote at a time. Aborted transports may still resolve, so every + * result must also pass isCurrent before updating the form. No wallet actions. */ +export function createBridgeQuoteSession() { + let revision = 0; + let timer: ReturnType | undefined; + let controller: AbortController | undefined; + + function cancel() { + revision++; + clearTimeout(timer); + timer = undefined; + controller?.abort(); + controller = undefined; + } + + return { + cancel, + schedule(request: () => void) { + cancel(); + timer = setTimeout(() => { + timer = undefined; + request(); + }, BRIDGE_QUOTE_DEBOUNCE_MS); + return cancel; + }, + async run(task: QuoteTask) { + cancel(); + const current = revision; + const abort = new AbortController(); + controller = abort; + await task({ signal: abort.signal, isCurrent: () => revision === current && !abort.signal.aborted }); + if (revision === current) controller = undefined; + }, + }; +} diff --git a/app/src/lib/bridge/relay.test.ts b/app/src/lib/bridge/relay.test.ts index 00b4088..8e05281 100644 --- a/app/src/lib/bridge/relay.test.ts +++ b/app/src/lib/bridge/relay.test.ts @@ -6,6 +6,7 @@ import { getOrderId, type Order } from "./relay-order.ts"; import { ARC_USDC, BASE_USDC, BRIDGE_INPUT_CURRENCIES, bridgeCurrency, type BridgeAsset, type BridgeChainId } from "./types.ts"; import { BRIDGE_PRIVATE_HEADERS, bridgeErrorResponse, getBridgeQuote, getBridgeStatus, readBridgeJson } from "./relay.ts"; import { ARC_FIXTURE_NOW, ARC_FIXTURE_ORDER_ID, ARC_OUTBOUND_FIXTURE_NOW, ARC_OUTBOUND_ORDER_ID, BASE_USDC_FIXTURE_NOW, BASE_USDC_ORDER_ID, FIXTURE_INPUT, FIXTURE_NOW, FIXTURE_REQUEST_ID, addApprovalFixture, relayArcOutboundFixture, relayArcQuoteFixture, relayBaseUsdcFixture, relayChainsFixture, relayQuoteFixture, relayRouteFixture } from "./relay.fixture.ts"; +import { parseBridgeQuoteRejection } from "./client.ts"; test("quote adapter uses only fixed provider endpoints and requests native verification data without app fees", async () => { const seen: { url: string; init: RequestInit }[] = []; @@ -161,6 +162,59 @@ test("rejects excessive source fees even when provider impact claims no loss", ( assert.throws(() => validateRelayQuote(quote, input, validateRelayChains(relayChainsFixture(), input), FIXTURE_NOW), (error) => error instanceof BridgeApiError && error.status === 422); }); +test("verified fee rejection returns request-bound cost diagnostics but never executable quote data", async () => { + const { input, quote } = relayRouteFixture(5042, 8453, "USDC", "ETH"); + quote.fees.relayer.amount = "1875000"; + quote.details.totalImpact.percent = "-8.12"; + let caught: unknown; + try { await getBridgeQuote(input, async (url) => Response.json(url.endsWith("/chains") ? relayChainsFixture() : quote), () => FIXTURE_NOW); } + catch (error) { caught = error; } + assert.ok(caught instanceof BridgeApiError); + const response = bridgeErrorResponse(caught); + assert.equal(response.status, 422); + assert.match(response.headers.get("cache-control")!, /private, no-store/); + const body = await response.json(); + assert.deepEqual(Object.keys(body).sort(), ["error", "quoteRejection"]); + assert.deepEqual(body.quoteRejection, { ...input, reason: "relay-fee", relayFee: "1.875", relayFeePercent: "7.5", sourceGas: "0.003", totalImpactPercent: "-8.12" }); + assert.deepEqual(parseBridgeQuoteRejection(body.quoteRejection, input), body.quoteRejection); + assert.doesNotMatch(JSON.stringify(body), /transaction|approval|requestId|orderId|calldata/); +}); + +test("unverifiable quotes never gain trusted cost diagnostics just because their fees exceed the cap", async () => { + for (const mutate of [ + (quote: ReturnType) => { quote.fees.app.amount = "1"; }, + (quote: ReturnType) => { quote.details.totalImpact.percent = "NaN"; }, + (quote: ReturnType) => { quote.details.timeEstimate = 86401; }, + (quote: ReturnType) => { quote.fees.gas.currency.decimals = 6; }, + (quote: ReturnType) => { quote.fees.relayer.currency.decimals = 18; }, + (quote: ReturnType) => { quote.steps[0].items[0].data.to = zeroAddress; }, + ]) { + const { input, quote } = relayRouteFixture(5042, 8453); + quote.fees.relayer.amount = "1875000"; + mutate(quote); + let caught: unknown; + try { validateRelayQuote(quote, input, validateRelayChains(relayChainsFixture(), input), FIXTURE_NOW); } + catch (error) { caught = error; } + assert.ok(caught instanceof BridgeApiError); + assert.equal(caught.status, 502); + assert.equal(caught.quoteRejection, undefined); + assert.deepEqual(Object.keys(await bridgeErrorResponse(caught).json()), ["error"]); + } +}); + +test("both safety thresholds stay inclusive at 5% and impact-only rejection has its own reason", () => { + const { input, quote } = relayRouteFixture(5042, 8453); + const metadata = validateRelayChains(relayChainsFixture(), input); + quote.fees.relayer.amount = "1250000"; + quote.details.totalImpact.percent = "-5"; + assert.doesNotThrow(() => validateRelayQuote(quote, input, metadata, FIXTURE_NOW)); + quote.details.totalImpact.percent = "-5.000000000000000001"; + assert.throws(() => validateRelayQuote(quote, input, metadata, FIXTURE_NOW), (error) => error instanceof BridgeApiError && error.status === 422 && error.quoteRejection?.reason === "total-impact" && error.quoteRejection.relayFeePercent === "5"); + quote.details.totalImpact.percent = "0"; + quote.fees.relayer.amount = "1250001"; + assert.throws(() => validateRelayQuote(quote, input, metadata, FIXTURE_NOW), (error) => error instanceof BridgeApiError && error.quoteRejection?.reason === "relay-fee" && error.quoteRejection.relayFeePercent === "5.000004"); +}); + test("captured Arc ERC-20 quote binds the independent order hash and exact approval", () => { const { input, quote } = relayArcOutboundFixture(); const metadata = validateRelayChains(relayChainsFixture(), input); diff --git a/app/src/lib/bridge/relay.ts b/app/src/lib/bridge/relay.ts index 5d0ded5..5a5f051 100644 --- a/app/src/lib/bridge/relay.ts +++ b/app/src/lib/bridge/relay.ts @@ -101,5 +101,5 @@ export async function getBridgeStatus(requestId: string, fetcher: Fetcher = fetc export function bridgeErrorResponse(error: unknown): Response { const known = error instanceof BridgeApiError ? error : new BridgeApiError("The bridge service is unavailable. Please try again."); - return Response.json({ error: known.message }, { status: known.status, headers: { ...BRIDGE_PRIVATE_HEADERS, ...(known.status === 429 ? { "Retry-After": "5" } : {}) } }); + return Response.json({ error: known.message, ...(known.status === 422 && known.quoteRejection ? { quoteRejection: known.quoteRejection } : {}) }, { status: known.status, headers: { ...BRIDGE_PRIVATE_HEADERS, ...(known.status === 429 ? { "Retry-After": "5" } : {}) } }); } diff --git a/app/src/lib/bridge/types.test.ts b/app/src/lib/bridge/types.test.ts index ce21381..080028e 100644 --- a/app/src/lib/bridge/types.test.ts +++ b/app/src/lib/bridge/types.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { formatUnits, zeroAddress } from "viem"; -import { ARC_USDC, BASE_USDC, BRIDGE_ASSETS, bridgeCurrency, bridgeTransferInputCurrency, isBridgeAssetSupported } from "./types"; +import { ARC_USDC, BASE_USDC, BRIDGE_ASSETS, bridgeCurrency, bridgeFeePercent, bridgeTransferInputCurrency, isBridgeAssetSupported } from "./types"; test("bridge assets are chain-scoped and never inferred from a symbol alone", () => { assert.deepEqual(BRIDGE_ASSETS[8453], ["ETH", "USDC"]); @@ -46,3 +46,13 @@ test("recovered Arc native and ERC20 journals both display the original USDC amo assert.equal(bridgeTransferInputCurrency({ originChainId: 8453 }).decimals, 18); assert.equal(bridgeTransferInputCurrency({ originChainId: 8453, originAsset: "USDC", depositKind: "erc20" }).decimals, 6); }); + +test("relay fee percentages preserve exact boundaries and round upward without floats", () => { + assert.equal(bridgeFeePercent(50_000n, 1_000_000n), "5"); + assert.equal(bridgeFeePercent(50_001n, 1_000_000n), "5.0001"); + assert.equal(bridgeFeePercent(500_000_000_000_001n, 10_000_000_000_000_000n), "5.000001"); + assert.equal(bridgeFeePercent(1n, 3n), "33.333334"); + assert.equal(bridgeFeePercent(0n, 1n), "0"); + assert.throws(() => bridgeFeePercent(-1n, 1n)); + assert.throws(() => bridgeFeePercent(1n, 0n)); +}); diff --git a/app/src/lib/bridge/types.ts b/app/src/lib/bridge/types.ts index 6cac6b5..00d99c8 100644 --- a/app/src/lib/bridge/types.ts +++ b/app/src/lib/bridge/types.ts @@ -73,6 +73,23 @@ export type BridgeQuote = BridgeQuoteRequest & { transaction: { to: Address; data: Hex; value: string; chainId: BridgeChainId }; }; +/** Display-only costs from a verified quote that fails the unchanged 5% limit. */ +export type BridgeQuoteRejection = BridgeQuoteRequest & { + reason: "relay-fee" | "total-impact"; + relayFee: string; // source input currency, not native-gas units + relayFeePercent: string; // percentage rounded up to six decimal places + sourceGas: string; // source native currency, always 18 decimals + totalImpactPercent: string; +}; + +/** Round upward so an actual fee just above 5% is never displayed as 5%. */ +export function bridgeFeePercent(fee: bigint, amount: bigint): string { + if (fee < 0n || amount <= 0n) throw new Error("Invalid bridge fee amounts."); + const scaled = (fee * 100_000_000n + amount - 1n) / amount; + const fraction = (scaled % 1_000_000n).toString().padStart(6, "0").replace(/0+$/, ""); + return `${scaled / 1_000_000n}${fraction ? `.${fraction}` : ""}`; +} + export type BridgeStatus = "waiting" | "depositing" | "pending" | "submitted" | "delayed" | "success" | "refund" | "failure"; export type BridgeStatusResponse = { status: BridgeStatus; diff --git a/app/src/lib/bridge/validation.ts b/app/src/lib/bridge/validation.ts index b1b7f71..a974c32 100644 --- a/app/src/lib/bridge/validation.ts +++ b/app/src/lib/bridge/validation.ts @@ -1,7 +1,7 @@ import "server-only"; import { getOrderId, type Order } from "./relay-order"; import { encodeFunctionData, formatEther, formatUnits, isAddress, parseAbi, zeroAddress, type Hex } from "viem"; -import { BRIDGE_CHAINS, bridgeCurrency, isBridgeAssetSupported, isBridgeChainId, type BridgeCurrency, type BridgeChainId, type BridgeQuote, type BridgeQuoteRequest, type BridgeStatus, type BridgeStatusResponse } from "./types"; +import { BRIDGE_CHAINS, bridgeCurrency, bridgeFeePercent, isBridgeAssetSupported, isBridgeChainId, type BridgeCurrency, type BridgeChainId, type BridgeQuote, type BridgeQuoteRejection, type BridgeQuoteRequest, type BridgeStatus, type BridgeStatusResponse } from "./types"; // Independently pinned, then checked against GET /chains. Never trust a quote to // supply the address against which that same quote is validated. @@ -19,7 +19,7 @@ const MAX_TOTAL_LOSS_PERCENT = 5; type ObjectValue = Record; export class BridgeApiError extends Error { - constructor(message: string, public readonly status = 502) { super(message); } + constructor(message: string, public readonly status = 502, public readonly quoteRejection?: BridgeQuoteRejection) { super(message); } } function ensure(condition: unknown): asserts condition { @@ -179,9 +179,6 @@ export function validateRelayQuote(value: unknown, input: BridgeQuoteRequest, ch checkCurrency(fees.relayer, input.originChainId, relayerAmount, inputCurrency); checkCurrency(fees.gas, input.originChainId, gasAmount, { address: zeroAddress, decimals: 18, symbol: NATIVE_SYMBOLS[input.originChainId] }); ensure(BigInt(relayerAmount) < BigInt(input.amount)); - if (BigInt(relayerAmount) * 100n > BigInt(input.amount) * BigInt(MAX_TOTAL_LOSS_PERCENT)) { - throw new BridgeApiError("This quote charges more than 5% in bridge fees. Try a different amount or wait for a better quote.", 422); - } // Same-symbol USDC still has 6/18-decimal interfaces. Cross multiplication // preserves exact fee equality without truncating either amount. ETH↔USDC // instead uses the bounded source fee and Relay's market-impact estimate. @@ -195,11 +192,22 @@ export function validateRelayQuote(value: unknown, input: BridgeQuoteRequest, ch const impactMagnitude = BigInt(wholeImpact + fractionalImpact); const impactScale = 10n ** BigInt(fractionalImpact.length); ensure(impactMagnitude <= 100n * impactScale); - if (negativeImpact && impactMagnitude > BigInt(MAX_TOTAL_LOSS_PERCENT) * impactScale) throw new BridgeApiError("This quote loses more than 5% in fees and price impact. Try a different amount or wait for a better quote.", 422); ensure(typeof details.timeEstimate === "number" && Number.isFinite(details.timeEstimate) && details.timeEstimate >= 0 && details.timeEstimate <= 86400); + // Complete every structural, currency and order check before exposing costs. + // A rejected quote never exposes its approval, deposit calldata or request ID. + const relayFee = formatUnits(BigInt(relayerAmount), inputCurrency.decimals); + const sourceGas = formatEther(BigInt(gasAmount)); + const reason = BigInt(relayerAmount) * 100n > BigInt(input.amount) * BigInt(MAX_TOTAL_LOSS_PERCENT) ? "relay-fee" + : negativeImpact && impactMagnitude > BigInt(MAX_TOTAL_LOSS_PERCENT) * impactScale ? "total-impact" : null; + if (reason) { + const quoteRejection: BridgeQuoteRejection = { ...input, reason, relayFee, relayFeePercent: bridgeFeePercent(BigInt(relayerAmount), BigInt(input.amount)), sourceGas, totalImpactPercent }; + throw new BridgeApiError(reason === "relay-fee" + ? "This quote charges more than 5% in bridge fees. Try a different amount or wait for a better quote." + : "This quote loses more than 5% in fees and price impact. Try a different amount or wait for a better quote.", 422, quoteRejection); + } return { ...input, requestId: step.requestId, amountOut, minimumAmountOut, - relayFee: formatUnits(BigInt(relayerAmount), inputCurrency.decimals), sourceGas: formatEther(BigInt(gasAmount)), totalImpactPercent, + relayFee, sourceGas, totalImpactPercent, ...(erc20Input ? { approval: { token: inputCurrency.address, spender: RELAY_DEPOSITORY, amount: input.amount } } : {}), timeEstimate: details.timeEstimate, expiresAt, ttlMs: expiresAt - now, transaction: { to: RELAY_DEPOSITORY, data: calldata, value: depositValue, chainId: input.originChainId }, diff --git a/app/src/lib/chainKeys.test.ts b/app/src/lib/chainKeys.test.ts index 822305f..a2ad1e3 100644 --- a/app/src/lib/chainKeys.test.ts +++ b/app/src/lib/chainKeys.test.ts @@ -65,8 +65,8 @@ test("no source branches on a chain name or hardcodes a chain id outside the reg const allowed = new Set(["lib/chain.ts:B20", "app/api/rpc/route.ts:B20"]); const offenders: string[] = []; for (const file of files) { - const rel = path.relative(root, file); - readFileSync(file, "utf8").split("\n").forEach((line, i) => { + const rel = path.relative(root, file).split(path.sep).join("/"); + readFileSync(file, "utf8").split(/\r?\n/).forEach((line, i) => { const code = line.replace(/^\s*\*.*$/, "").replace(/\/\*.*?\*\//g, "").replace(/\/\/.*$/, ""); // comments (and anything after // such as a URL) do not count // the bridge speaks Relay's own chain ids and names (numeric ids, solverChainId "base") by design: not our registry if (/^(lib\/bridge|components\/bridge|app\/ui-review-bridge)\//.test(rel)) return; diff --git a/docs/bridge.md b/docs/bridge.md index cca48cf..b56bef1 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -18,6 +18,10 @@ Quotes request `explicitDeposit`, `includeProtocolData`, and an explicit `refund The client binds the quote to the displayed account, direction and amount, rejects changed/expired quotes, then rechecks the wallet and source balance with fresh gas estimates before asking the wallet to send. Quotes are available for at most 45 seconds, with 0.5% output slippage. It rejects Relay fees above 5% of input and provider-reported total value loss above 5%. The latter is Relay's market estimate, not an independent fair-price oracle. ETH and USDC raw amounts are never subtracted from one another. No wallet request happens automatically. Inputs must leave room for source gas. +While the panel is open and no approval or transfer is in progress, valid amount/route edits automatically request an unsigned quote after a 700 ms pause. Inputs remain editable during loading. A new edit, account/network change or dismissal cancels old quote work; late responses cannot replace the current quote or error. Background balance polling does not request new quotes, and errors do not trigger a retry loop. An expired quote still needs an explicit refresh. Approval confirmation automatically loads a fresh quote, but approval and deposit remain separate, explicit wallet actions. + +Fee-limit rejections include display-only costs after all protocol and currency checks pass. The panel shows the actual Relay fee and percentage, with native source gas separate. HTTP 422 diagnostics contain no transaction, approval or request ID and are checked against the current wallet/route/amount again in the client. The 5% limits are unchanged; a $1 input is not inherently blocked, but a small amount can fail when the quoted fee is too large relative to it. + Base and Arc USDC sends check the exact allowance. If it is insufficient, approval is a separate user action with its own durable recovery record and transaction hash. Approval alone does not create a bridge transfer. Once approval confirms, the user reviews a **fresh quote** and explicitly confirms the deposit. An interrupted approval response must be resolved before another approval is requested. An unspent approval remains on-chain until used or revoked; rejection of a later deposit does not revoke it. An approval without a returned hash can be recovered using the actual mined transaction hash from the wallet. Recovery verifies the saved source chain, exact token/spender/amount, transaction or canonical approval event, receipt, fresh allowance, and a block timestamp no earlier than 30 seconds before the saved attempt. That clock check is a bounded heuristic, not unique nonce proof. Allowance alone cannot resolve an unknown broadcast; replaced or cancelled transactions without a matching recoverable hash remain blocked for manual investigation. Approval gas is additional to the later deposit gas estimate. @@ -49,7 +53,7 @@ npx tsx --conditions=react-server --tsconfig tsconfig.test.json --test src/lib/b These tests never connect a wallet or submit a transaction. -`/ui-review-bridge` is a development-only visual fixture with disconnected, quote, expired, error, pending, success, uncertain and refund states, plus pending/uncertain/confirmed approval states. Its synthetic data cannot execute a transaction, and the route returns 404 outside development. The real header action uses the actual integration. +`/ui-review-bridge` is a development-only visual fixture with disconnected, quote, quoting, high-fee, expired, error, pending, success, uncertain and refund states, plus approval/recovery states. Its synthetic data cannot execute a transaction, and the route returns 404 outside development. The real header action uses the actual integration. ### Wallet test findings (2026-09-16)