Skip to content
Closed
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
3 changes: 3 additions & 0 deletions app/src/app/api/bridge/status/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { rateLimited } from "@/lib/launchpad/editServer";
import { BRIDGE_PRIVATE_HEADERS, bridgeErrorResponse, getBridgeStatus } from "@/lib/bridge/relay";
import { BridgeApiError } from "@/lib/bridge/validation";
import { getCctpStatus, parseCctpStatusRequest } from "@/lib/bridge/cctp-server";

export const dynamic = "force-dynamic";
export const runtime = "nodejs";
Expand All @@ -10,6 +11,8 @@ export async function GET(req: Request) {
if (rateLimited(`bridge:status:ip:${ip}`, 100)) return bridgeErrorResponse(new BridgeApiError("Too many status requests. Please wait a moment.", 429));
try {
const params = new URL(req.url).searchParams;
// A Circle burn is found by its source transaction and checked against the wallet, amount and route it was for.
if (params.has("provider")) return Response.json(await getCctpStatus(parseCctpStatusRequest(params)), { headers: BRIDGE_PRIVATE_HEADERS });
if (params.size !== 1 || !params.has("requestId")) throw new BridgeApiError("Invalid bridge request ID.", 400);
return Response.json(await getBridgeStatus(params.get("requestId")!), { headers: BRIDGE_PRIVATE_HEADERS });
} catch (error) { return bridgeErrorResponse(error); }
Expand Down
7 changes: 4 additions & 3 deletions app/src/app/bridge/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,16 +57,17 @@ export default async function BridgePage({ searchParams }: { searchParams: Promi
<h2 id="bridge-costs">What it costs</h2>
<ul className={styles.points}>
<li>openlaunch takes no fee on a transfer.</li>
<li>The bridge provider, Relay, charges a fee that is shown before you confirm and is already taken out of the amount you receive.</li>
<li>The bridge provider charges a fee that is shown before you confirm and is already taken out of the amount you receive.</li>
<li>Relay quotes every route. For USDC to USDC into Base or Arc, Circle is quoted too, and you get whichever delivers more. The quote names which one.</li>
<li>Gas on the network you send from is extra.</li>
<li>A quote that would cost more than 5% in fees, or lose more than 5% in conversion, is blocked.</li>
</ul>
</section>
<section aria-labelledby="bridge-know">
<h2 id="bridge-know">Good to know</h2>
<ul className={styles.points}>
<li>Funds go from your wallet to Relay&apos;s deposit contract. They never pass through an openlaunch wallet or contract.</li>
<li>Relay is a third-party protocol. openlaunch does not operate it and cannot reverse a transfer.</li>
<li>Funds go from your wallet to Relay&apos;s deposit contract, or to Circle&apos;s transfer contract on a Circle route. They never pass through an openlaunch wallet or contract.</li>
<li>Relay and Circle are third parties. openlaunch operates neither and cannot reverse a transfer.</li>
<li>A transfer is saved in this browser while it is in progress. Reopen the bridge with the same wallet to pick it up.</li>
<li>Finished transfers stay on the board below. It never asks your wallet for anything.</li>
</ul>
Expand Down
22 changes: 16 additions & 6 deletions app/src/app/ui-review-bridge/BridgeReview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ import type { BridgeHistoryEntry } from "@/lib/bridge/history";
import { FundCard } from "@/components/bridge/BridgeToBuy";
import type { BridgeHolding, FundStep } from "@/lib/bridge/fund";
import type { BridgeAsset, BridgeDestinationId } from "@/lib/bridge/types";
import { CCTP_TOKEN_MESSENGER, cctpRoute } from "@/lib/bridge/cctp";

const wallet = "0x03508bB71268BBA25ECaCC8F620e01866650532c" as const;
const RELAY = "0x4cd00e387622c35bddb9b4c962c136462338bc31" as const;
const requestId = `0x${"1".repeat(64)}` as const;
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"];
Expand All @@ -34,6 +36,7 @@ const HISTORY_ROWS: BridgeHistoryEntry[] = [
{ requestId: hash(51), originChainId: 1, destinationChainId: 5042, originAsset: "ETH", destinationAsset: "USDC", amountIn: "50000000000000000", inDecimals: 18, amountOut: "155300000000000000000", outDecimals: 18, outKind: "quoted", status: "refund", reverted: false, createdAt: minutesAgo(60 * 26), sourceHash: hash(52), destinationHashes: [], recipient: null },
{ requestId: hash(61), originChainId: 10, destinationChainId: 8453, originAsset: "ETH", destinationAsset: "ETH", amountIn: "8000000000000000", inDecimals: 18, amountOut: null, outDecimals: 18, outKind: null, status: "failure", reverted: true, createdAt: minutesAgo(60 * 50), sourceHash: hash(62), destinationHashes: [], recipient: null },
{ requestId: hash(71), originChainId: 4663, destinationChainId: 8453, originAsset: "ETH", destinationAsset: "ETH", amountIn: "4000000000000000", inDecimals: 18, amountOut: "3991000000000000", outDecimals: 18, outKind: "received", status: "success", reverted: false, createdAt: minutesAgo(60 * 80), sourceHash: hash(72), destinationHashes: [hash(73)], recipient: "0x9f3a2bE0c1D4e5F6a7b8C9d0E1f2A3b4C5d6E7F8" },
{ provider: "cctp", requestId: hash(81), originChainId: 42161, destinationChainId: 5042, originAsset: "USDC", destinationAsset: "USDC", amountIn: "40000000", inDecimals: 6, amountOut: "39983361000000000000", outDecimals: 18, outKind: "received", status: "success", reverted: false, createdAt: minutesAgo(60 * 96), sourceHash: hash(82), destinationHashes: [hash(83)], recipient: null },
];
type HistoryScene = "rows" | "empty" | "loading" | "no wallet" | "unsaved";
const HISTORY_SCENES: HistoryScene[] = ["rows", "empty", "loading", "no wallet", "unsaved"];
Expand All @@ -56,12 +59,15 @@ export default function BridgeReview() {
const [scene, setScene] = useState<Scene>("disconnected");
const [route, setRoute] = useState<BridgeRouteInputs>({ originChainId: 8453, destinationChainId: 5042, originAsset: "USDC", destinationAsset: "USDC", amount: "25" });
const [approved, setApproved] = useState(false);
// Which provider the synthetic quote comes from. Circle only carries USDC to USDC into Base or Arc.
const [circle, setCircle] = useState(false);
const { originChainId: origin, destinationChainId: destination, amount } = route;
const originAsset = route.originAsset ?? defaultBridgeAsset(origin);
const destinationAsset = route.destinationAsset ?? defaultBridgeAsset(destination);
const inputCurrency = bridgeCurrency(origin, originAsset, "input");
const outputCurrency = bridgeCurrency(destination, destinationAsset, "output");
const erc20Input = inputCurrency.address !== zeroAddress;
const viaCircle = circle && cctpRoute({ ...route, originAsset, destinationAsset }) !== null;
const changeRoute = (change: BridgeRouteChange) => { setRoute((current) => changeBridgeRoute(current, change)); setApproved(false); setScene((current) => current === "disconnected" ? current : "idle"); };
const setAmount = (value: string) => { setRoute((current) => ({ ...current, amount: value })); setApproved(false); setScene((current) => current === "disconnected" ? current : "idle"); };
const [clock] = useState(() => Date.now());
Expand All @@ -75,14 +81,17 @@ export default function BridgeReview() {
const normalizedInput = inputAmount * 10n ** BigInt(18 - inputCurrency.decimals);
const netAmount = normalizedInput * 9975n / 10000n;
const converted = originAsset === destinationAsset ? netAmount : originAsset === "USDC" ? netAmount / 2400n : netAmount * 2400n;
const outputAmount = converted / 10n ** BigInt(18 - outputCurrency.decimals);
// Circle: one synthetic fixed fee (the size of the live Fast plus forward fee), delivered exactly
const circleFee = destination === 5042 ? 16_577n : 54_447n;
const outputAmount = viaCircle ? (inputAmount > circleFee ? inputAmount - circleFee : 0n) * 10n ** BigInt(outputCurrency.decimals - 6) : converted / 10n ** BigInt(18 - outputCurrency.decimals);
const quote: BridgeQuote = {
address: wallet, originChainId: origin, destinationChainId: destination, originAsset, destinationAsset, amount: inputAmount.toString(),
requestId, amountOut: outputAmount.toString(), minimumAmountOut: (outputAmount * 995n / 1000n).toString(), relayFee: formatUnits(inputAmount * 25n / 10000n, inputCurrency.decimals), sourceGas: origin === 5042 ? "0.001" : "0.0000007", totalImpactPercent: "-0.25", timeEstimate: 2, expiresAt: clock + 45_000, ttlMs: 45_000,
requestId, amountOut: outputAmount.toString(), minimumAmountOut: (viaCircle ? outputAmount : outputAmount * 995n / 1000n).toString(), relayFee: formatUnits(viaCircle ? circleFee : inputAmount * 25n / 10000n, inputCurrency.decimals), sourceGas: origin === 5042 ? "0.001" : "0.0000007", totalImpactPercent: viaCircle && inputAmount > 0n ? `-${bridgeFeePercent(circleFee, inputAmount)}` : "-0.25", timeEstimate: viaCircle ? (origin === 5042 ? 15 : 25) : 2, expiresAt: clock + 45_000, ttlMs: 45_000,
...(viaCircle ? { provider: "cctp" as const } : {}),
transaction: { to: wallet, data: "0x", value: "0", chainId: origin }, // deliberately non-executable fixture
...(erc20Input ? { approval: { token: inputCurrency.address, spender: "0x4cd00e387622c35bddb9b4c962c136462338bc31" as const, amount: inputAmount.toString() } } : {}),
...(erc20Input ? { approval: { token: inputCurrency.address, spender: viaCircle ? CCTP_TOKEN_MESSENGER : RELAY, amount: inputAmount.toString() } } : {}),
};
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 approval: TrackedApproval | null = erc20Input && (origin === 5042 || origin === 8453) && (scene.startsWith("approval_") || approved) ? { version: 1, chainId: origin, address: wallet, token: inputCurrency.address, spender: viaCircle ? CCTP_TOKEN_MESSENGER.toLowerCase() as typeof RELAY : RELAY, 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;
Expand All @@ -97,7 +106,7 @@ export default function BridgeReview() {
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,
tracked: tracking ? { ...(viaCircle ? { provider: "cctp" as const } : {}), 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",
approval, approvalRequired: erc20Input && !approved, allowanceLoading: false, approvalBusy: false, approvalError: null,
approvalHealth: ["approval_queued", "approval_missing", "approval_fee"].includes(scene) ? describePendingApproval({ createdAt: clock - 70_000, now: clock, transaction: scene === "approval_missing" ? null : { nonce: 2, maxFeePerGas: 30_000_000_000n }, latestNonce: scene === "approval_queued" ? 0 : 2, baseFeePerGas: 166_000_000_000n }) : null,
Expand All @@ -112,13 +121,14 @@ export default function BridgeReview() {
<h1 className="text-2xl font-semibold">Bridge visual review</h1>
<p className="mt-3 text-sm text-body">Development-only synthetic data. No wallet requests, API calls, or funds.</p>
<p className="mt-2 text-sm text-muted">Base offers ETH and USDC. Arc uses USDC. Robinhood offers ETH; its USDC routes currently fail our safety checks. USDC sends use an exact-amount approval.</p>
<button type="button" aria-pressed={circle} className="mt-6 min-h-11 rounded-lg border border-line-strong px-4 text-sm aria-pressed:border-ink" onClick={() => { setCircle((value) => !value); if (!cctpRoute({ ...route, originAsset, destinationAsset })) setRoute({ originChainId: 8453, destinationChainId: 5042, originAsset: "USDC", destinationAsset: "USDC", amount: "25" }); }}>quote from: {circle ? "Circle" : "Relay"}</button>
<div className="my-6 flex flex-wrap gap-2">{scenes.map((value) => <button key={value} type="button" className="min-h-11 rounded-lg border border-line-strong px-4 text-sm" onClick={() => { if (value.startsWith("approval_")) { if (!erc20Input) setRoute({ originChainId: 8453, destinationChainId: 5042, originAsset: "USDC", destinationAsset: "USDC", amount: "25" }); setApproved(value === "approval_confirmed"); } else if (!parseBridgeAmount(amount, inputCurrency.decimals)) setRoute((current) => ({ ...current, amount: originAsset === "USDC" ? "25" : "0.01" })); setScene(value); setOpen(true); }}>{value.replaceAll("_", " ")}</button>)}</div>
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Portal><Dialog.Backdrop className={styles.backdrop} /><Dialog.Popup className={styles.panel} initialFocus={false}>
<div className={styles.heading}><Dialog.Title className={styles.title}>Bridge</Dialog.Title><Dialog.Close className={styles.close} aria-label="Close preview"><X size={19} /></Dialog.Close></div>
<Dialog.Description className={styles.description}>Preview only · {scene} · no funds move</Dialog.Description>
{tracking ? <Transfer bridge={bridge} /> : <BridgeForm bridge={bridge} connect={() => setScene("quote")} />}
<div className={styles.footer}><span>Powered by Relay</span><span>0 Openlaunch fee</span></div>
<div className={styles.footer}><span>{viaCircle ? "Powered by Circle CCTP" : "Powered by Relay"}</span><span>0 Openlaunch fee</span></div>
</Dialog.Popup></Dialog.Portal>
</Dialog.Root>
<div className="mt-16 flex flex-wrap gap-2">{HISTORY_SCENES.map((value) => <button key={value} type="button" aria-pressed={historyScene === value} className="min-h-11 rounded-lg border border-line-strong px-4 text-sm aria-pressed:border-ink" onClick={() => setHistoryScene(value)}>history: {value}</button>)}</div>
Expand Down
Loading
Loading