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
20 changes: 15 additions & 5 deletions app/src/app/ui-review-bridge/BridgeReview.tsx
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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;
Expand All @@ -45,14 +51,18 @@ 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<typeof useBridge> = {
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,
originAsset, destinationAsset, setOriginAsset: (asset) => changeRoute({ side: "origin-asset", asset }), setDestinationAsset: (asset) => changeRoute({ side: "destination-asset", asset }),
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",
Expand Down
22 changes: 22 additions & 0 deletions app/src/components/bridge/BridgeDialog.module.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

61 changes: 50 additions & 11 deletions app/src/components/bridge/BridgeDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 <section className={styles.feeLimit} aria-label="Quote above safety limit">
<div className={styles.feeLimitHeading} role="status" aria-atomic="true">
<CircleAlert className={styles.feeLimitIcon} size={18} aria-hidden />
<div className={styles.feeLimitCopy}>
<h3>{feeTooHigh ? "Bridge fee too high" : "Quote loss too high"}</h3>
<p>Nothing has been submitted.</p>
</div>
<div className={styles.feeLimitPercent}>
<strong aria-label={`${feeTooHigh ? "Relay fee" : "Quote loss"}: ${exactPercent}%`}>{formatFeeWarningPercent(exactPercent)}<span>%</span></strong>
<span>5% safety limit</span>
</div>
</div>
<dl className={styles.feeLimitCosts}>
<div><dt>Relay fee <span>Included</span></dt><dd title={`${rejection.relayFee} ${inputSymbol}`}>{nativeAmount(rejection.relayFee)} <span>{inputSymbol}</span></dd></div>
<div><dt>Source gas <span>Extra · estimated</span></dt><dd title={`${rejection.sourceGas} ${gasSymbol}`}>{nativeAmount(rejection.sourceGas)} <span>{gasSymbol}</span></dd></div>
</dl>
<details className={styles.feeLimitReason}>
<summary>Why is this blocked?<ChevronDown size={14} aria-hidden /></summary>
<p>{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.</p>
</details>
<p className={styles.feeLimitHint}>Change the amount or route. Your quote updates automatically.</p>
</section>;
}

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<HTMLDivElement>(null);
const [connecting, setConnecting] = useState(false);
const transferring = bridge.tracked !== null;
Expand Down Expand Up @@ -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 <ApprovalProgress bridge={b} />;
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];
Expand All @@ -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 (
<form onSubmit={(event) => { event.preventDefault(); if (b.address) void (reviewing ? b.approvalRequired ? b.approve() : b.confirm() : b.requestQuote()); else connect(); }}>
<form onSubmit={(event) => {
event.preventDefault();
if (!b.address) { connect(); return; }
if (loading || b.allowanceLoading || !b.canQuote) return;
void (reviewing ? b.approvalRequired ? b.approve() : b.confirm() : b.requestQuote());
}}>
<div className={styles.route}>
<NetworkSelect label="From" chain={b.originChainId} disabled={locked} onChange={b.setOriginChainId} />
<button type="button" className={styles.reverse} aria-label={`Reverse route: ${destination.name} to ${origin.name}`} disabled={locked} onClick={b.reverseRoute}><ArrowLeftRight size={18} aria-hidden /></button>
Expand All @@ -193,7 +228,7 @@ export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: ()

{b.quote ? (
<div className={styles.quote} aria-live="polite">
<div className={styles.receiveLabel}><span>Estimated output</span></div>
<div className={styles.receiveLabel}><span>Estimated output</span><span className={styles.estimate}>{needsRefresh ? "Quote expired" : "Live quote"}</span></div>
<p className={styles.receiveAmount}>{outputAmount(b.quote.amountOut)}<span><AssetMark asset={outputCurrency.symbol} />{outputCurrency.symbol}</span></p>
<dl className={styles.fees}>
<div><dt>Minimum received</dt><dd title={`${formatUnits(BigInt(b.quote.minimumAmountOut), outputCurrency.decimals)} ${outputCurrency.symbol}`}>{outputAmount(b.quote.minimumAmountOut)} {outputCurrency.symbol}</dd></div>
Expand All @@ -205,14 +240,18 @@ export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: ()
<p className={styles.quoteNotice}>{needsRefresh ? "This quote expired. Refresh and review the new amounts." : "0.5% slippage limit. Arrival time and gas can change."}</p>
{b.approvalRequired ? <p className={styles.approvalNotice}>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.</p> : null}
</div>
) : <div className={styles.previewNote}><ArrowRight size={16} aria-hidden /><p>Get a live quote before you commit.<br /><span>Fees and the minimum received shown upfront.</span></p></div>}
) : b.quoteRejection ? <FeeLimitNotice rejection={b.quoteRejection} inputSymbol={inputCurrency.symbol} gasSymbol={origin.symbol} /> : <div className={styles.previewNote} role="status" aria-live="polite">
{b.quoteLoading ? <LoaderCircle size={16} className={styles.spinner} aria-hidden /> : <ArrowRight size={16} aria-hidden />}
<p>{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."}<br />
<span>{b.quoteLoading ? "Keep typing. We’ll use your latest amount." : "Quotes update automatically. No wallet request until you confirm."}</span></p>
</div>}

{b.address ? <Recipient address={b.address} /> : null}
{b.quoteError || b.error || b.storageError || b.approvalError ? <p className={styles.error} role="alert"><CircleAlert size={16} aria-hidden /><span>{b.quoteError || b.error || b.storageError || b.approvalError}</span></p> : null}
<button type="submit" className={styles.primary} disabled={locked || b.allowanceLoading || (!!b.address && !b.amount.trim())}>
{locked ? <LoaderCircle size={17} className={styles.spinner} aria-hidden /> : !b.address ? <Wallet size={17} aria-hidden /> : null}
{error ? <p className={styles.error} role="alert"><CircleAlert size={16} aria-hidden /><span>{error}</span></p> : null}
<button type="submit" className={styles.primary} disabled={loading || b.allowanceLoading || (!!b.address && !b.canQuote)}>
{loading ? <LoaderCircle size={17} className={styles.spinner} aria-hidden /> : !b.address ? <Wallet size={17} aria-hidden /> : null}
{b.address ? label : "Connect wallet"}
{!locked && b.address ? <ArrowRight size={17} aria-hidden /> : null}
{!loading && b.address ? <ArrowRight size={17} aria-hidden /> : null}
</button>
<p id="bridge-gas-note" className={styles.disclaimer}>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."}</p>
</form>
Expand Down
Loading
Loading