diff --git a/app/src/app/api/bridge/status/route.ts b/app/src/app/api/bridge/status/route.ts index 118caaae..8b1cfe5a 100644 --- a/app/src/app/api/bridge/status/route.ts +++ b/app/src/app/api/bridge/status/route.ts @@ -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"; @@ -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); } diff --git a/app/src/app/bridge/page.tsx b/app/src/app/bridge/page.tsx index 8954436d..1112c28c 100644 --- a/app/src/app/bridge/page.tsx +++ b/app/src/app/bridge/page.tsx @@ -57,7 +57,8 @@ export default async function BridgePage({ searchParams }: { searchParams: Promi

What it costs

@@ -65,8 +66,8 @@ export default async function BridgePage({ searchParams }: { searchParams: Promi

Good to know

diff --git a/app/src/app/ui-review-bridge/BridgeReview.tsx b/app/src/app/ui-review-bridge/BridgeReview.tsx index 8605f785..ca1faee8 100644 --- a/app/src/app/ui-review-bridge/BridgeReview.tsx +++ b/app/src/app/ui-review-bridge/BridgeReview.tsx @@ -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"]; @@ -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"]; @@ -56,12 +59,15 @@ export default function BridgeReview() { const [scene, setScene] = useState("disconnected"); const [route, setRoute] = useState({ 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()); @@ -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; @@ -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, @@ -112,13 +121,14 @@ export default function BridgeReview() {

Bridge visual review

Development-only synthetic data. No wallet requests, API calls, or funds.

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.

+
{scenes.map((value) => )}
Bridge
Preview only · {scene} · no funds move {tracking ? : setScene("quote")} />} -
Powered by Relay0 Openlaunch fee
+
{viaCircle ? "Powered by Circle CCTP" : "Powered by Relay"}0 Openlaunch fee
{HISTORY_SCENES.map((value) => )}
diff --git a/app/src/components/bridge/BridgeDialog.tsx b/app/src/components/bridge/BridgeDialog.tsx index df5d72c6..25207826 100644 --- a/app/src/components/bridge/BridgeDialog.tsx +++ b/app/src/components/bridge/BridgeDialog.tsx @@ -8,8 +8,9 @@ import { Dialog } from "@base-ui/react/dialog"; import { Select } from "@base-ui/react/select"; import { ArrowLeftRight, ArrowRight, ArrowUpRight, Check, ChevronDown, ChevronRight, CircleAlert, CircleDollarSign, Clock3, LoaderCircle, Wallet, X } from "lucide-react"; import { formatEther, formatUnits, zeroAddress } from "viem"; -import { BRIDGE_ASSETS, BRIDGE_ASSET_NAMES, BRIDGE_CHAINS, BRIDGE_CHAIN_IDS, BRIDGE_DESTINATION_IDS, bridgeCurrency, bridgeTokenAsset, bridgeTransferInputCurrency, isBridgeAssetSupported, isBridgeChainId, isBridgeFundingChain, type BridgeAsset, type BridgeChainId, type BridgeQuoteRejection } from "@/lib/bridge/types"; +import { BRIDGE_ASSETS, BRIDGE_ASSET_NAMES, BRIDGE_CHAINS, BRIDGE_CHAIN_IDS, BRIDGE_DESTINATION_IDS, bridgeCurrency, bridgeProviderName, bridgeTokenAsset, bridgeTransferInputCurrency, isBridgeAssetSupported, isBridgeChainId, isBridgeFundingChain, type BridgeAsset, type BridgeChainId, type BridgeQuoteRejection } from "@/lib/bridge/types"; import { DISCARD_AFTER_MS } from "@/lib/bridge/client"; +import { CCTP_APPROVAL_SPENDER } from "@/lib/bridge/approval"; import { bridgeLinkKey, type BridgeLink } from "@/lib/bridge/link"; import { signalCaption, signalMode } from "@/lib/bridge/signal"; import { formatFeeWarningPercent } from "@/lib/bridge/fee-display"; @@ -204,9 +205,12 @@ export default function BridgeDialog({ open, onOpenChange, restoreFocus, slot = setConnecting(true); } + const circle = (bridge.tracked?.provider ?? bridge.quote?.provider) === "cctp"; const footer = (
- Powered by Relay + {circle + ? Powered by Circle CCTP + : Powered by Relay } {onPage ? null : onOpenChange(false)}>Your transfers} 0 Openlaunch fee
@@ -247,6 +251,8 @@ export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: () const outputCurrency = bridgeCurrency(b.destinationChainId, b.destinationAsset, "output"); const erc20Input = inputCurrency.address !== zeroAddress; const convertsAsset = inputCurrency.symbol !== outputCurrency.symbol; + const provider = bridgeProviderName(b.quote?.provider); + const circle = b.quote?.provider === "cctp"; // a wallet sitting on a funding network most likely wants to send from it const walletFunding = b.address && isBridgeFundingChain(b.walletChainId) && b.walletChainId !== b.originChainId ? b.walletChainId : null; const outputAmount = (value: string) => nativeAmount(formatUnits(BigInt(value), outputCurrency.decimals)); @@ -299,13 +305,14 @@ export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: ()

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

Minimum received
{outputAmount(b.quote.minimumAmountOut)} {outputCurrency.symbol}
-
Relay fee (included)
{nativeAmount(b.quote.relayFee)} {inputCurrency.symbol}
+
Route
{circle ? "Circle CCTP" : "Relay"}
+
{provider} fee (included)
{nativeAmount(b.quote.relayFee)} {inputCurrency.symbol}
Source gas (extra, estimated)
{nativeAmount(b.quote.sourceGas)} {origin.symbol}
{convertsAsset ?
Value change (conversion + fees)
{Number(b.quote.totalImpactPercent).toFixed(2)}%
: null}
Estimated arrival
{Math.max(1, Math.ceil(b.quote.timeEstimate))} seconds
-

{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)} {inputCurrency.symbol} 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} +

{needsRefresh ? "This quote expired. Refresh and review the new amounts." : circle ? "Circle takes one fixed fee, so this is the exact amount you receive. Arrival time and gas can change." : "0.5% slippage limit. Arrival time and gas can change."}

+ {b.approvalRequired ?

First, approve only {nativeAmount(b.amount)} {inputCurrency.symbol} for {circle ? "Circle’s transfer contract" : "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} ) : b.quoteRejection ? :
{b.quoteLoading ? : } @@ -321,7 +328,7 @@ export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: () {b.address ? label : "Connect wallet"} {!loading && b.address ? : 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 ${inputCurrency.symbol} approval remains until used or revoked.` : "No token approvals required."}

+

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

); } @@ -347,7 +354,7 @@ function ApprovalProgress({ bridge: b }: { bridge: Bridge }) {
Approval limit
{nativeAmount(formatUnits(BigInt(approval.amount), 6))} {approvalSymbol}
{b.address ? : null} -

No bridge deposit has been requested. An approval permits Relay’s deposit contract to use up to this amount; it does not bridge it.

+

No bridge deposit has been requested. An approval permits {approval.spender.toLowerCase() === CCTP_APPROVAL_SPENDER ? "Circle’s transfer contract" : "Relay’s deposit contract"} to use up to this amount; it does not bridge it.

{b.approvalError || b.storageError ?

{b.approvalError || b.storageError}

: null} {approval.approvalHash ? View approval on {approvalChain.name} : null} {!b.approvalBusy ?
@@ -382,8 +389,10 @@ export function Transfer({ bridge: b }: { bridge: Bridge }) { const destination = BRIDGE_CHAINS[transfer.destinationChainId]; const inputCurrency = bridgeTransferInputCurrency(transfer); const outputCurrency = bridgeCurrency(transfer.destinationChainId, transfer.destinationAsset, "output"); - const title = success ? `Arrived on ${destination.name}` : refund ? "Relay reports a refund" : reverted ? "The deposit reverted" : failed ? "Transfer needs attention" : uncertain ? "Checking your transfer" : waitingForWallet ? "Check your wallet" : "Your transfer is on its way"; - const detail = success ? `Relay has confirmed ${outputCurrency.symbol} delivery to your receiving wallet.` : refund ? "Check your wallets on both networks and Relay’s transfer details before trying again." : reverted ? `The source network rejected the deposit. Your ${inputCurrency.symbol} was not bridged; network gas was still charged.` : failed ? "Relay couldn’t complete this transfer. Check the transfer details for recovery before sending again." : uncertain ? "The wallet response was interrupted. Don’t send again while we check whether your deposit was submitted." : waitingForWallet ? "Review the network, amount and transaction in your wallet. Nothing moves without your confirmation." : "You can close this panel. Reopen Bridge with this wallet to check its status."; + const circle = transfer.provider === "cctp"; + const provider = bridgeProviderName(transfer.provider); + const title = success ? `Arrived on ${destination.name}` : refund ? `${provider} reports a refund` : reverted ? "The deposit reverted" : failed ? "Transfer needs attention" : uncertain ? "Checking your transfer" : waitingForWallet ? "Check your wallet" : "Your transfer is on its way"; + const detail = success ? `${provider} has confirmed ${outputCurrency.symbol} delivery to your receiving wallet.` : refund ? `Check your wallets on both networks and ${provider}’s transfer details before trying again.` : reverted ? `The source network rejected the deposit. Your ${inputCurrency.symbol} was not bridged; network gas was still charged.` : failed ? `${provider} couldn’t complete this transfer. Check the transfer details for recovery before sending again.` : uncertain ? "The wallet response was interrupted. Don’t send again while we check whether your deposit was submitted." : waitingForWallet ? "Review the network, amount and transaction in your wallet. Nothing moves without your confirmation." : "You can close this panel. Reopen Bridge with this wallet to check its status."; const mode = signalMode({ phase: b.phase, tracked: true, approvalActive: false, quoteLoading: false, reviewing: false, quoteExpired: false, blocked: false }); @@ -401,11 +410,11 @@ export function Transfer({ bridge: b }: { bridge: Bridge }) {
  1. {!reverted && (transfer.sourceHash || success || refund) ? : "1"}
    Deposit on {origin.name}{reverted ? "Reverted. Deposit not made." : transfer.sourceHash || success || refund ? "Submitted to the source network" : waitingForWallet ? "Awaiting wallet confirmation" : "Checking for your deposit"}
  2. -
  3. {success ? : "2"}
    {refund ? "Refund reported by Relay" : "Relay processes the transfer"}{transfer.status === "delayed" ? "Taking longer than expected. Tracking continues." : failed ? "Open transfer details for help" : success ? "Transfer complete" : "Live status from the bridge provider"}
  4. +
  5. {success ? : "2"}
    {refund ? `Refund reported by ${provider}` : circle ? `Circle mints on ${destination.name}` : "Relay processes the transfer"}{transfer.status === "delayed" ? "Taking longer than expected. Tracking continues." : failed ? "Open transfer details for help" : success ? "Transfer complete" : "Live status from the bridge provider"}
  6. {success ? : "3"}
    Receive {outputCurrency.symbol} on {destination.name}{success ? "Delivered to the same wallet address" : "Delivery will be confirmed here"}
{b.error || b.statusError || b.storageError ?

{b.error || b.statusError || b.storageError}{b.statusError ? : null}

: null} - View transfer on Relay + {circle ? null : View transfer on Relay}
{transfer.sourceHash ? Source transaction : null} {success && transfer.destinationHashes[0] ? Destination transaction : null} @@ -413,10 +422,10 @@ export function Transfer({ bridge: b }: { bridge: Bridge }) { {b.canReset ? : null} {b.canDiscard ?
No deposit after {DISCARD_AFTER_MS / 60_000} minutes? -

Relay has not seen a deposit for this transfer and nothing is confirmed on {origin.name}. If your wallet shows the transaction as dropped or cancelled, you can discard this record and start over. Keep the Transfer ID below in case you need Relay support.

+

{provider} has not seen a deposit for this transfer and nothing is confirmed on {origin.name}. If your wallet shows the transaction as dropped or cancelled, you can discard this record and start over.{circle ? "" : " Keep the Transfer ID below in case you need Relay support."}

: null} -

Transfer ID {transfer.requestId}

+ {circle ? null :

Transfer ID {transfer.requestId}

}
); } diff --git a/app/src/components/bridge/BridgeHistory.tsx b/app/src/components/bridge/BridgeHistory.tsx index 8932eb48..f3a0565b 100644 --- a/app/src/components/bridge/BridgeHistory.tsx +++ b/app/src/components/bridge/BridgeHistory.tsx @@ -118,7 +118,7 @@ function Row({ entry }: { entry: BridgeHistoryEntry }) { {received.label}{received.amount ?? received.note} {status.label} - Relay + {entry.provider === "cctp" ? Circle : Relay} {entry.sourceHash ? Source : null} {entry.status === "success" && entry.destinationHashes[0] ? Destination : null} diff --git a/app/src/components/bridge/History.module.css b/app/src/components/bridge/History.module.css index 49cafe56..177e8fb7 100644 --- a/app/src/components/bridge/History.module.css +++ b/app/src/components/bridge/History.module.css @@ -49,6 +49,8 @@ .links a { display: inline-flex; align-items: center; gap: 3px; color: #8b95a7; text-decoration: none; } .links a:hover { color: #e7ecf3; } .links a:focus-visible { border-radius: 4px; outline: 2px solid #6d9dff; outline-offset: 2px; } +/* A Circle transfer has no tracking page of its own; its source and destination links carry it. */ +.via { color: #6d7a90; } .recipient { grid-column: 2 / -1; margin-top: 8px; color: #fbbf24; font-size: 12px; } .empty { display: grid; justify-items: center; gap: 8px; padding: 46px 20px 44px; text-align: center; } diff --git a/app/src/components/bridge/bridge-ui.test.ts b/app/src/components/bridge/bridge-ui.test.ts index e77641bd..a92b63f1 100644 --- a/app/src/components/bridge/bridge-ui.test.ts +++ b/app/src/components/bridge/bridge-ui.test.ts @@ -200,13 +200,14 @@ test("stuck records have a bounded discard path, and the hook avoids APIs missin assert.match(hook, /if \(!transferCanDiscard\(current, fresh, Date\.now\(\)\)\) throw/); assert.match(hook, /if \(!approvalCanDiscard\(current, fresh, Date\.now\(\)\)\) throw/); 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.match(panel, /Openlaunch does not operate \{provider\}, holds no funds in transit, and is not responsible for delays, refunds or losses/); assert.doesNotMatch(hook, /AbortSignal\.(any|timeout)/); 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 walletHashUnmined = receipt\.status === "fulfilled" && receipt\.value === null;/); + assert.match(hook, /replacementSourceHash\(current, status, walletHashUnmined\)/); assert.match(hook, /const messageOf = bridgeErrorMessage;/); }); @@ -283,3 +284,20 @@ test("pending approvals explain missing/queued transactions and accept a verifie assert.match(hook, /value: APPROVAL_HEALTH_UNAVAILABLE/); assert.equal((hook.match(/await assertBridgeWalletQueueClear\(/g) ?? []).length, 2); }); + +test("a Circle transfer is named as Circle's, reads allowance for Circle's spender, and never links to a Relay page", () => { + const hook = read("./useBridge.ts"); + const board = read("./BridgeHistory.tsx"); + // allowances are read for whichever contract the approval names, never a hard-coded Relay depository + assert.doesNotMatch(hook, /RELAY_DEPOSITORY/); + assert.match(hook, /args: \[quote\.address, quote\.approval\.spender\]/); + assert.match(hook, /const statusUrl = observed \? bridgeStatusUrl\(observed\)/); + assert.match(hook, /findCctpBurn\(pub, current\)/); + assert.match(panel, /const circle = \(bridge\.tracked\?\.provider \?\? bridge\.quote\?\.provider\) === "cctp";/); + assert.match(panel, /Powered by Circle CCTP/); + assert.match(panel, /\{circle \? null : Transfer ID/); + assert.match(panel, /Circle takes one fixed fee, so this is the exact amount you receive\./); + assert.match(board, /entry\.provider === "cctp" \? Circle<\/span> : { + void Promise.all([pub.getChainId(), pub.readContract({ address: quote.approval.token, abi: erc20Abi, functionName: "allowance", args: [quote.address, quote.approval.spender] })]).then(([chainId, value]) => { if (chainId !== quote.originChainId) throw new Error("The approval RPC reported a different network."); if (!stopped) setAllowanceResult({ key: quote.requestId, value, error: null }); }).catch((error) => { @@ -159,7 +159,7 @@ export function useBridge(open: boolean) { if (!pub) throw new Error("Could not connect to the source network to check the approval."); const [chainId, allowance, receipt] = await Promise.all([ pub.getChainId(), - pub.readContract({ address: observed.token, abi: erc20Abi, functionName: "allowance", args: [address, RELAY_DEPOSITORY] }), + pub.readContract({ address: observed.token, abi: erc20Abi, functionName: "allowance", args: [address, observed.spender] }), pub.getTransactionReceipt({ hash: approvalHash }).catch((error) => { if (error instanceof TransactionReceiptNotFoundError) return null; throw error; @@ -217,8 +217,9 @@ export function useBridge(open: boolean) { try { const observed = transfers.getSnapshot().transfers[address.toLowerCase()]; const pub = observed ? getPublicClient(config, { chainId: observed.originChainId }) : undefined; + const statusUrl = observed ? bridgeStatusUrl(observed) : `/api/bridge/status?requestId=${encodeURIComponent(trackedId)}`; const [provider, receipt] = await Promise.allSettled([ - fetch(`/api/bridge/status?requestId=${encodeURIComponent(trackedId)}`, { cache: "no-store", signal: linkedTimeoutSignal(controller.signal, 15_000) }).then(responseBody).then(validateBridgeStatus), + statusUrl ? fetch(statusUrl, { cache: "no-store", signal: linkedTimeoutSignal(controller.signal, 15_000) }).then(responseBody).then(validateBridgeStatus) : Promise.resolve(CCTP_NOT_SEEN), // "Not found" is a real answer (unmined, dropped or replaced); other RPC failures stay unknown. observed?.sourceHash && pub ? Promise.all([pub.getChainId(), pub.getTransactionReceipt({ hash: observed.sourceHash }).catch((error) => { if (error instanceof TransactionReceiptNotFoundError) return null; @@ -235,13 +236,16 @@ export function useBridge(open: boolean) { } else { if (provider.status === "rejected") throw provider.reason; const status = provider.value; - // The candidate must still be this wallet's exact deposit for this order. - const candidate = pub ? replacementSourceHash(current, status, receipt.status === "fulfilled" && receipt.value === null) : null; + // The candidate must still be this wallet's exact deposit for this order. Circle never reports another hash, so a + // Circle burn the wallet did not return (or replaced) is looked for in the messenger's own events instead. + const walletHashUnmined = receipt.status === "fulfilled" && receipt.value === null; + const found = pub && current.provider === "cctp" && status.status === "waiting" && (!current.sourceHash || walletHashUnmined) ? await findCctpBurn(pub, current) : null; + const candidate = !pub ? null : current.provider === "cctp" ? (found && found !== current.sourceHash?.toLowerCase() ? found : null) : replacementSourceHash(current, status, walletHashUnmined); if (candidate && pub) { const [chainId, transaction] = await Promise.all([pub.getChainId(), pub.getTransaction({ hash: candidate })]); if (chainId !== current.originChainId) throw new Error("The source RPC reported a different network. Tracking will retry."); const matched = isMatchingSourceDeposit(current, transaction) || hasMatchingDepositEvent(current, await pub.getTransactionReceipt({ hash: candidate })); - if (!matched) throw new Error("Relay's source transaction could not be matched to this deposit. Tracking will retry."); + if (!matched) throw new Error("The source transaction could not be matched to this deposit. Tracking will retry."); current = { ...current, sourceHash: candidate }; } next = mergeBridgeStatus(current, status); @@ -412,7 +416,7 @@ export function useBridge(open: boolean) { const call = { account: owner.address, to: transaction.to, data: transaction.data, value: transaction.value }; const [chainId, balance, estimate, fees, allowance, selectedBalance, additional] = await Promise.all([ pub.getChainId(), pub.getBalance({ address: owner.address }), pub.estimateGas(call), pub.estimateFeesPerGas(), - pub.readContract({ address: transaction.to, abi: erc20Abi, functionName: "allowance", args: [owner.address, RELAY_DEPOSITORY] }), + pub.readContract({ address: transaction.to, abi: erc20Abi, functionName: "allowance", args: [owner.address, owner.spender ?? RELAY_APPROVAL_SPENDER] }), pub.readContract({ address: transaction.to, abi: erc20Abi, functionName: "balanceOf", args: [owner.address] }), reviewed.originChainId === 8453 ? estimateTotalFee(pub, call) : Promise.resolve(0n), ]); @@ -469,7 +473,7 @@ export function useBridge(open: boolean) { if (!pub) throw new Error("Could not connect to the approval's network. Try checking again."); const [chainId, transaction, receipt, allowance] = await Promise.all([ pub.getChainId(), pub.getTransaction({ hash }), pub.getTransactionReceipt({ hash }), - pub.readContract({ address: current.token, abi: erc20Abi, functionName: "allowance", args: [address, RELAY_DEPOSITORY] }), + pub.readContract({ address: current.token, abi: erc20Abi, functionName: "allowance", args: [address, current.spender] }), ]); if (receipt.transactionHash.toLowerCase() !== hash.toLowerCase()) throw new Error("The transaction receipt did not match the supplied hash."); const block = await pub.getBlock({ blockNumber: receipt.blockNumber }); @@ -526,7 +530,7 @@ export function useBridge(open: boolean) { // Base charges L1 data/operator fees in addition to execution gas. // Reserving the total estimate here is intentionally conservative. q.originChainId === 8453 ? estimateTotalFee(pub, transaction) : Promise.resolve(0n), - q.approval ? pub.readContract({ address: q.approval.token, abi: erc20Abi, functionName: "allowance", args: [q.address, RELAY_DEPOSITORY] }) : Promise.resolve(null), + q.approval ? pub.readContract({ address: q.approval.token, abi: erc20Abi, functionName: "allowance", args: [q.address, q.approval.spender] }) : Promise.resolve(null), q.approval ? pub.readContract({ address: q.approval.token, abi: erc20Abi, functionName: "balanceOf", args: [q.address] }) : Promise.resolve(null), ]); if (rpcChain !== q.originChainId) throw new Error("The source RPC reported a different network. Try again later."); @@ -539,6 +543,11 @@ export function useBridge(open: boolean) { readTransfer: (owner) => transfers.read(owner), saveTransfer: (transfer) => transfers.save(transfer), removeTransfer: (owner) => transfers.remove(owner), + sourceBlock: async (q) => { + const pub = getPublicClient(config, { chainId: q.originChainId }); + if (!pub) throw new Error("no client"); + return pub.getBlockNumber(); + }, send: (q, gas) => { const connected = getAccount(config); if (!wallet || connected.address?.toLowerCase() !== q.address.toLowerCase() || connected.chainId !== q.originChainId) throw new Error("Wallet changed before submission"); @@ -615,14 +624,19 @@ export function useBridge(open: boolean) { if (!lock) throw new Error("Another bridge action is in progress. Try again in a moment."); const current = transfers.read(address); if (current?.requestId !== requestId || transferIsTerminal(current)) return; - const status = await fetch(`/api/bridge/status?requestId=${encodeURIComponent(requestId)}`, { cache: "no-store", signal: linkedTimeoutSignal(undefined, 15_000) }).then(responseBody).then(validateBridgeStatus); + const statusUrl = bridgeStatusUrl(current); + const status = statusUrl ? await fetch(statusUrl, { cache: "no-store", signal: linkedTimeoutSignal(undefined, 15_000) }).then(responseBody).then(validateBridgeStatus) : CCTP_NOT_SEEN; let sourceMined = false; - if (current.sourceHash) { - const pub = getPublicClient(config, { chainId: current.originChainId }); + const pub = getPublicClient(config, { chainId: current.originChainId }); + if (current.sourceHash || current.provider === "cctp") { if (!pub) throw new Error("Could not connect to the source network. Try again."); - const [chainId, receipt] = await Promise.all([pub.getChainId(), receiptOrNull(pub, current.sourceHash)]); + const [chainId, receipt, burn] = await Promise.all([ + pub.getChainId(), + current.sourceHash ? receiptOrNull(pub, current.sourceHash) : Promise.resolve(null), + current.provider === "cctp" ? findCctpBurn(pub, current) : Promise.resolve(null), + ]); if (chainId !== current.originChainId) throw new Error("The source RPC reported a different network. Try again."); - sourceMined = receipt !== null; + sourceMined = receipt !== null || burn !== null; } const fresh = { requestId, status, sourceMined, observedAt: Date.now() }; setObservation(fresh); diff --git a/app/src/lib/bridge/approval.test.ts b/app/src/lib/bridge/approval.test.ts index cc198cfb..c689e67f 100644 --- a/app/src/lib/bridge/approval.test.ts +++ b/app/src/lib/bridge/approval.test.ts @@ -3,7 +3,8 @@ import test from "node:test"; import { decodeFunctionData, encodeAbiParameters, encodeEventTopics, type Address, type Hex } from "viem"; import { APPROVAL_DISCARD_AFTER_MS, APPROVAL_STORAGE_PREFIX, ARC_APPROVAL_CHAIN_ID, ARC_USDC, EXACT_APPROVAL_ABI, RELAY_APPROVAL_SPENDER, USDC_APPROVAL_EVENT, approvalBlocksSubmission, approvalCanDiscard, approvalRequestKey, approvalStorageKey, createApprovalStore, exactApprovalTransaction, hasMatchingApprovalEvent, isMatchingApprovalTransaction, parseStoredApproval, reconcileApproval, serializeApproval, submitExactApproval, validateApprovalMetadata, type ApprovalDependencies, type ApprovalRequest, type TrackedApproval } from "./approval"; import { BASE_USDC, ROBINHOOD_USDG } from "./types"; -import { canApplyApprovalPoll, recoverApprovalFromEvidence } from "./approval"; +import { canApplyApprovalPoll, CCTP_APPROVAL_SPENDER, recoverApprovalFromEvidence } from "./approval"; +import { CCTP_TOKEN_MESSENGER } from "./cctp"; const ADDRESS = "0x1111111111111111111111111111111111111111" as Address; const OTHER = "0x2222222222222222222222222222222222222222" as Address; @@ -373,3 +374,28 @@ test("a delayed missing-receipt poll cannot undo same-hash confirmation or anoth for (const change of [{ approvalHash: OTHER_HASH }, { address: OTHER }, { amount: "1" }, { createdAt: NOW + 1 }, { chainId: 8453 as const }]) assert.equal(canApplyApprovalPoll({ ...observed, ...change }, observed), false); assert.equal(canApplyApprovalPoll(null, observed), false); }); + +test("a Circle approval pins Circle's messenger as spender, and no other contract is ever accepted", () => { + const circle = { ...REQUEST, spender: CCTP_APPROVAL_SPENDER }; + assert.deepEqual(validateApprovalMetadata({ token: ARC_USDC, spender: CCTP_TOKEN_MESSENGER, amount: REQUEST.amount }, ADDRESS), circle); + // Relay stays the implicit spender, so Relay's records and keys are unchanged + assert.deepEqual(validateApprovalMetadata({ token: ARC_USDC, spender: RELAY_APPROVAL_SPENDER, amount: REQUEST.amount }, ADDRESS), REQUEST); + assert.equal(approvalRequestKey(REQUEST).includes(CCTP_APPROVAL_SPENDER), false); + assert.notEqual(approvalRequestKey(circle), approvalRequestKey(REQUEST)); + assert.equal(decodeFunctionData({ abi: EXACT_APPROVAL_ABI, data: exactApprovalTransaction(circle).data }).args[0].toLowerCase(), CCTP_APPROVAL_SPENDER); + assert.throws(() => validateApprovalMetadata({ token: ARC_USDC, spender: OTHER, amount: REQUEST.amount }, ADDRESS)); + assert.throws(() => exactApprovalTransaction({ ...REQUEST, spender: OTHER })); + + const approval = tracked({ spender: CCTP_APPROVAL_SPENDER }); + assert.deepEqual(parseStoredApproval(serializeApproval(approval), ADDRESS), approval); + assert.throws(() => parseStoredApproval(serializeApproval(tracked({ spender: OTHER })), ADDRESS)); + const event = (spender: Address) => ({ + address: ARC_USDC as Address, + topics: encodeEventTopics({ abi: USDC_APPROVAL_EVENT, eventName: "Approval", args: { owner: ADDRESS, spender } }) as Hex[], + data: encodeAbiParameters([{ type: "uint256" }], [25_000_000n]), + }); + assert.equal(hasMatchingApprovalEvent(approval, { status: "success", logs: [event(CCTP_TOKEN_MESSENGER)] }), true); + // an approval of Relay's depository never stands in for Circle's, nor the reverse + assert.equal(hasMatchingApprovalEvent(approval, { status: "success", logs: [event(RELAY_APPROVAL_SPENDER)] }), false); + assert.equal(hasMatchingApprovalEvent(tracked(), { status: "success", logs: [event(CCTP_TOKEN_MESSENGER)] }), false); +}); diff --git a/app/src/lib/bridge/approval.ts b/app/src/lib/bridge/approval.ts index 5ac5c1f5..a92f30d9 100644 --- a/app/src/lib/bridge/approval.ts +++ b/app/src/lib/bridge/approval.ts @@ -1,5 +1,6 @@ import { decodeEventLog, encodeFunctionData, isAddress, type Address, type Hex } from "viem"; import { bridgeCurrency, bridgeTokenAsset, isBridgeChainId, type BridgeChainId } from "./types"; +import { CCTP_TOKEN_MESSENGER } from "./cctp"; // Independently pinned: provider calldata never chooses the token or spender. // https://docs.arc.io/arc/references/contract-addresses @@ -7,6 +8,11 @@ import { bridgeCurrency, bridgeTokenAsset, isBridgeChainId, type BridgeChainId } export const ARC_APPROVAL_CHAIN_ID = 5042 as const; export const ARC_USDC = "0x3600000000000000000000000000000000000000" as const; export const RELAY_APPROVAL_SPENDER = "0x4cd00e387622c35bddb9b4c962c136462338bc31" as const; +// Circle's TokenMessengerV2 (lib/bridge/cctp.ts), which pulls the exact amount it burns. +export const CCTP_APPROVAL_SPENDER = CCTP_TOKEN_MESSENGER.toLowerCase() as Address; +/** The only contracts an approval may name. Anything else is refused before the wallet is asked. */ +export const APPROVAL_SPENDERS: readonly Address[] = [RELAY_APPROVAL_SPENDER, CCTP_APPROVAL_SPENDER]; +const spenderOf = (value: unknown): Address | null => typeof value === "string" ? APPROVAL_SPENDERS.find((spender) => spender === value.toLowerCase()) ?? null : null; export const EXACT_APPROVAL_ABI = [{ type: "function", name: "approve", stateMutability: "nonpayable", inputs: [{ name: "spender", type: "address" }, { name: "amount", type: "uint256" }], outputs: [{ name: "", type: "bool" }] }] as const; export const USDC_APPROVAL_EVENT = [{ type: "event", name: "Approval", inputs: [{ name: "owner", type: "address", indexed: true }, { name: "spender", type: "address", indexed: true }, { name: "value", type: "uint256", indexed: false }] }] as const; export const APPROVAL_STORAGE_PREFIX = "openlaunch.bridge.approval.v1:"; @@ -16,13 +22,14 @@ const MAX_APPROVAL_AMOUNT = MAX_UINT / 10n ** 12n; const HASH = /^0x[0-9a-fA-F]{64}$/; const STATUSES = ["uncertain", "pending", "confirmed", "reverted", "insufficient"] as const; -export type ApprovalRequest = { address: Address; amount: string; chainId?: BridgeChainId }; // Omitted chain is legacy Arc; amounts are token units, 6 decimals. +// Omitted chain is legacy Arc; omitted spender is Relay's depository (every record before Circle). Amounts are token units, 6 decimals. +export type ApprovalRequest = { address: Address; amount: string; chainId?: BridgeChainId; spender?: Address }; export type ApprovalStatus = typeof STATUSES[number]; export type TrackedApproval = ApprovalRequest & { version: 1; chainId: BridgeChainId; token: Address; - spender: typeof RELAY_APPROVAL_SPENDER; + spender: Address; createdAt: number; status: ApprovalStatus; approvalHash?: Hex; @@ -52,20 +59,23 @@ function validateRequest(value: ApprovalRequest): ApprovalRequest { const chainId = value.chainId ?? ARC_APPROVAL_CHAIN_ID; approvalToken(chainId); if (chainId === ARC_APPROVAL_CHAIN_ID && BigInt(value.amount) > MAX_APPROVAL_AMOUNT) throw new Error("The USDC approval amount exceeds Arc's native balance range."); - return { address: value.address, amount: value.amount, ...(value.chainId !== undefined ? { chainId } : {}) }; + const spender = value.spender === undefined ? RELAY_APPROVAL_SPENDER : spenderOf(value.spender); + if (!spender) throw new Error("The token approval names an unsupported spender."); + return { address: value.address, amount: value.amount, ...(value.chainId !== undefined ? { chainId } : {}), ...(spender !== RELAY_APPROVAL_SPENDER ? { spender } : {}) }; } /** Validate provider metadata before adapting a quote to this fixed approval flow. */ export function validateApprovalMetadata(value: unknown, address: Address, chainId?: BridgeChainId): ApprovalRequest { const metadata = value as { token?: unknown; spender?: unknown; amount?: unknown } | null; - if (!metadata || typeof metadata.token !== "string" || metadata.token.toLowerCase() !== approvalToken(chainId).toLowerCase() || typeof metadata.spender !== "string" || metadata.spender.toLowerCase() !== RELAY_APPROVAL_SPENDER || !validAmount(metadata.amount)) { + if (!metadata || typeof metadata.token !== "string" || metadata.token.toLowerCase() !== approvalToken(chainId).toLowerCase() || !spenderOf(metadata.spender) || !validAmount(metadata.amount)) { throw new Error("The bridge requested an unsupported token, spender, or approval amount."); } - return validateRequest({ address, amount: metadata.amount, ...(chainId !== undefined ? { chainId } : {}) }); + return validateRequest({ address, amount: metadata.amount, ...(chainId !== undefined ? { chainId } : {}), spender: spenderOf(metadata.spender)! }); } export function approvalRequestKey(request: ApprovalRequest | null): string { - return request ? `${request.address.toLowerCase()}:${request.chainId ?? ARC_APPROVAL_CHAIN_ID}:${request.amount}` : ""; + const spender = request?.spender?.toLowerCase(); + return request ? `${request.address.toLowerCase()}:${request.chainId ?? ARC_APPROVAL_CHAIN_ID}:${request.amount}${spender && spender !== RELAY_APPROVAL_SPENDER ? `:${spender}` : ""}` : ""; } export const approvalStorageKey = (address: Address) => `${APPROVAL_STORAGE_PREFIX}${address.toLowerCase()}`; @@ -99,7 +109,7 @@ export function canApplyApprovalPoll(current: TrackedApproval | null, observed: export function exactApprovalTransaction(request: ApprovalRequest): ApprovalTransaction { const checked = validateRequest(request); - return { chainId: checked.chainId ?? ARC_APPROVAL_CHAIN_ID, to: approvalToken(checked.chainId), value: 0n, data: encodeFunctionData({ abi: EXACT_APPROVAL_ABI, functionName: "approve", args: [RELAY_APPROVAL_SPENDER, BigInt(checked.amount)] }) }; + return { chainId: checked.chainId ?? ARC_APPROVAL_CHAIN_ID, to: approvalToken(checked.chainId), value: 0n, data: encodeFunctionData({ abi: EXACT_APPROVAL_ABI, functionName: "approve", args: [checked.spender ?? RELAY_APPROVAL_SPENDER, BigInt(checked.amount)] }) }; } /** Also bind candidate chain and block time to the journal in the RPC caller. */ @@ -119,7 +129,7 @@ export function hasMatchingApprovalEvent(approval: TrackedApproval, receipt: { s if (log.address.toLowerCase() !== checked.token.toLowerCase() || log.topics.length !== 3 || !log.topics.every((topic) => HASH.test(topic)) || !/^0x[0-9a-fA-F]{64}$/.test(log.data)) return false; try { const event = decodeEventLog({ abi: USDC_APPROVAL_EVENT, data: log.data, topics: [log.topics[0], ...log.topics.slice(1)], strict: true }); - return event.args.owner.toLowerCase() === checked.address.toLowerCase() && event.args.spender.toLowerCase() === RELAY_APPROVAL_SPENDER && event.args.value.toString() === checked.amount; + return event.args.owner.toLowerCase() === checked.address.toLowerCase() && event.args.spender.toLowerCase() === checked.spender && event.args.value.toString() === checked.amount; } catch { return false; } }); } catch { return false; } @@ -129,8 +139,8 @@ export function parseStoredApproval(raw: string, address: Address): TrackedAppro try { const entry = JSON.parse(raw) as TrackedApproval; const request = validateRequest(entry); - if (entry.version !== 1 || request.address.toLowerCase() !== address.toLowerCase() || !isBridgeChainId(entry.chainId) || typeof entry.token !== "string" || entry.token.toLowerCase() !== approvalToken(entry.chainId).toLowerCase() || entry.spender !== RELAY_APPROVAL_SPENDER || !Number.isSafeInteger(entry.createdAt) || entry.createdAt <= 0 || !STATUSES.includes(entry.status) || (entry.approvalHash !== undefined && !validHash(entry.approvalHash)) || (entry.status !== "uncertain" && !entry.approvalHash)) throw new Error("invalid record"); - return { ...request, version: 1, chainId: entry.chainId, token: approvalToken(entry.chainId), spender: RELAY_APPROVAL_SPENDER, createdAt: entry.createdAt, status: entry.status, ...(entry.approvalHash ? { approvalHash: entry.approvalHash } : {}) }; + if (entry.version !== 1 || request.address.toLowerCase() !== address.toLowerCase() || !isBridgeChainId(entry.chainId) || typeof entry.token !== "string" || entry.token.toLowerCase() !== approvalToken(entry.chainId).toLowerCase() || !spenderOf(entry.spender) || !Number.isSafeInteger(entry.createdAt) || entry.createdAt <= 0 || !STATUSES.includes(entry.status) || (entry.approvalHash !== undefined && !validHash(entry.approvalHash)) || (entry.status !== "uncertain" && !entry.approvalHash)) throw new Error("invalid record"); + return { ...request, version: 1, chainId: entry.chainId, token: approvalToken(entry.chainId), spender: spenderOf(entry.spender)!, createdAt: entry.createdAt, status: entry.status, ...(entry.approvalHash ? { approvalHash: entry.approvalHash } : {}) }; } catch { throw new Error("Saved token approval recovery data could not be read. Check the approval before trying again."); } } @@ -293,7 +303,7 @@ export async function submitExactApproval(input: ApprovalRequest, deps: Approval const latest = deps.readApproval(request.address); if (approvalBlocksSubmission(latest)) throw new Error("Another approval is now being tracked. Check it before continuing."); const createdAt = Math.max(deps.now(), (latest?.createdAt ?? 0) + 1); - const tracked: TrackedApproval = { ...request, version: 1, chainId, token: approvalToken(chainId), spender: RELAY_APPROVAL_SPENDER, createdAt, status: "uncertain" }; + const tracked: TrackedApproval = { ...request, version: 1, chainId, token: approvalToken(chainId), spender: request.spender ?? RELAY_APPROVAL_SPENDER, createdAt, status: "uncertain" }; serializeApproval(tracked); const restore = () => { if (latest) deps.saveApproval(latest); else deps.removeApproval(request.address); }; try { deps.saveApproval(tracked); } diff --git a/app/src/lib/bridge/cctp-server.test.ts b/app/src/lib/bridge/cctp-server.test.ts new file mode 100644 index 00000000..35a6efdb --- /dev/null +++ b/app/src/lib/bridge/cctp-server.test.ts @@ -0,0 +1,282 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { decodeFunctionData } from "viem"; +import { CCTP_BURN_ABI, CCTP_FORWARD_HOOK, CCTP_TOKEN_MESSENGER, cctpBurnData } from "./cctp"; +import { CCTP_MAX_BURN, CCTP_UPSTREAM_PER_MINUTE, cctpMaxFee, getCctpQuote, getCctpStatus, parseCctpFees, parseCctpStatus, parseCctpStatusRequest, type CctpStatusRequest } from "./cctp-server"; +import { validateBridgeQuote } from "./client"; +import { getBridgeQuote } from "./relay"; +import { FIXTURE_INPUT, FIXTURE_NOW, addApprovalFixture, relayChainsFixture, relayRouteFixture } from "./relay.fixture"; +import { ARBITRUM_USDC, ARC_USDC, type BridgeQuoteRequest } from "./types"; +import { BridgeApiError } from "./validation"; +import { GET as STATUS } from "../../app/api/bridge/status/route"; + +const WALLET = "0x6376ca7e839f1c3dd7f23cc8a1a58329f60db125" as const; +const NOW = 1_790_000_000_000; +const REQUEST: BridgeQuoteRequest = { address: WALLET, originChainId: 42161, destinationChainId: 8453, originAsset: "USDC", destinationAsset: "USDC", amount: "25000000" }; +// The shape Circle's fee endpoint returned on 2026-11-10 (basis points, then the forward in USDC's smallest unit). +const FEES = [ + { finalityThreshold: 1000, minimumFee: 1.3, forwardFee: { low: 10_000, med: 11_000, high: 12_000 } }, + { finalityThreshold: 2000, minimumFee: 0, forwardFee: { low: 10_000, med: 11_000, high: 12_000 } }, +]; + +type Call = { url: string; init: RequestInit }; +type Answers = { fees?: unknown; allowance?: unknown; gas?: unknown; message?: unknown; failFees?: number }; +function circle(answers: Answers = {}) { + const calls: Call[] = []; + const fetcher = async (url: string, init: RequestInit) => { + calls.push({ url, init }); + if (init.method === "POST") return Response.json({ jsonrpc: "2.0", id: 1, result: answers.gas ?? "0x5f5e100" }); + const path = url.replace("https://iris-api.circle.com", ""); + if (path.startsWith("/v2/burn/USDC/fees/")) return answers.failFees ? new Response("", { status: answers.failFees }) : Response.json(answers.fees ?? FEES); + if (path === "/v2/fastBurn/USDC/allowance") return Response.json({ allowance: answers.allowance ?? 53_837_639.760122, lastUpdated: "2026-11-10T13:15:36.376Z" }); + if (path.startsWith("/v2/messages/")) return answers.message === undefined ? new Response("", { status: 404 }) : Response.json(answers.message); + throw new Error(`unexpected ${url}`); + }; + return { fetcher, calls, paths: () => calls.map((call) => call.init.method === "POST" ? `rpc:${JSON.parse(String(call.init.body)).method}` : call.url.replace("https://iris-api.circle.com", "")) }; +} + +test("Circle's fee is read for the burn's finality, bounded, and never from an exponent or fraction of a unit", () => { + assert.deepEqual(parseCctpFees(FEES, 1000), { microBps: 1_300_000n, forwardFee: 12_000n }); + assert.deepEqual(parseCctpFees(FEES, 2000), { microBps: 0n, forwardFee: 12_000n }); + assert.deepEqual(parseCctpFees([{ finalityThreshold: 1000, minimumFee: 0.325, forwardFee: { low: 18509, med: 18959, high: 18959 } }], 1000), { microBps: 325_000n, forwardFee: 18_959n }); + const entry = FEES[0]; + for (const [name, value] of [ + ["missing finality", [FEES[1]]], + ["empty", []], + ["too many", Array(9).fill(entry)], + ["not an array", entry], + ["exponent", [{ ...entry, minimumFee: 1e-7 }]], + ["negative", [{ ...entry, minimumFee: -1 }]], + ["string", [{ ...entry, minimumFee: "1.3" }]], + ["seven decimals", [{ ...entry, minimumFee: 1.0000001 }]], + ["over 1%", [{ ...entry, minimumFee: 100.000001 }]], + ["forward over $5", [{ ...entry, forwardFee: { high: 5_000_001 } }]], + ["forward fraction", [{ ...entry, forwardFee: { high: 12_000.5 } }]], + ["no forward", [{ ...entry, forwardFee: null }]], + ] as const) assert.throws(() => parseCctpFees(value, 1000), (error) => error instanceof BridgeApiError && error.status === 502, name); +}); + +test("the whole fee is Circle's share of the amount rounded up, plus the forward", () => { + assert.equal(cctpMaxFee(25_000_000n, { microBps: 1_300_000n, forwardFee: 12_000n }), 15_250n); + assert.equal(cctpMaxFee(25_000_000n, { microBps: 325_000n, forwardFee: 18_959n }), 813n + 18_959n); + assert.equal(cctpMaxFee(1n, { microBps: 1n, forwardFee: 0n }), 1n); + assert.equal(cctpMaxFee(25_000_000n, { microBps: 0n, forwardFee: 63_895n }), 63_895n); +}); + +test("a Circle quote is built from pinned contracts and the bounded fee, and passes the browser's own check", async () => { + const c = circle(); + const quote = await getCctpQuote(REQUEST, c.fetcher, () => NOW); + assert.ok(quote); + assert.deepEqual(c.paths().sort(), ["/v2/burn/USDC/fees/3/6?forward=true", "/v2/fastBurn/USDC/allowance", "rpc:eth_gasPrice"]); + assert.equal(quote.provider, "cctp"); + assert.match(quote.requestId, /^0x[0-9a-f]{64}$/); + assert.equal(quote.amountOut, "24984750"); + assert.equal(quote.minimumAmountOut, "24984750"); + assert.equal(quote.relayFee, "0.01525"); + assert.equal(quote.sourceGas, "0.000022"); + assert.equal(quote.totalImpactPercent, "-0.061"); + assert.equal(quote.timeEstimate, 25); + assert.equal(quote.expiresAt, NOW + 45_000); + assert.deepEqual(quote.approval, { token: ARBITRUM_USDC, spender: CCTP_TOKEN_MESSENGER, amount: "25000000" }); + assert.deepEqual(quote.transaction, { to: CCTP_TOKEN_MESSENGER, data: cctpBurnData(REQUEST, 15_250n), value: "0", chainId: 42161 }); + const args = decodeFunctionData({ abi: CCTP_BURN_ABI, data: quote.transaction.data }).args; + assert.deepEqual([args[0], args[1], args[5], args[6], args[7]], [25_000_000n, 6, 15_250n, 1000, CCTP_FORWARD_HOOK]); + assert.deepEqual(validateBridgeQuote(quote, REQUEST, NOW), quote); + // every request is a plain GET or one read-only RPC call, with no redirects followed + for (const call of c.calls) assert.equal(call.init.redirect, "error"); +}); + +test("an Arc burn is Standard: no Fast capacity is asked, and Arc's 18-decimal balance receives it", async () => { + const c = circle({ fees: [{ finalityThreshold: 2000, minimumFee: 0, forwardFee: { low: 54811, med: 62505, high: 63895 } }] }); + const request: BridgeQuoteRequest = { address: WALLET, originChainId: 5042, destinationChainId: 8453, destinationAsset: "USDC", amount: "25000000" }; + const quote = await getCctpQuote(request, c.fetcher, () => NOW); + assert.ok(quote); + assert.deepEqual(c.paths().sort(), ["/v2/burn/USDC/fees/26/6?forward=true", "rpc:eth_gasPrice"]); + assert.equal(decodeFunctionData({ abi: CCTP_BURN_ABI, data: quote.transaction.data }).args[6], 2000); + assert.equal(quote.approval?.token, ARC_USDC); + assert.equal(quote.amountOut, (25_000_000n - 63_895n).toString()); + assert.equal(quote.timeEstimate, 15); + const inbound = await getCctpQuote({ address: WALLET, originChainId: 8453, destinationChainId: 5042, originAsset: "USDC", amount: "25000000" }, circle().fetcher, () => NOW); + assert.equal(inbound?.amountOut, ((25_000_000n - 15_250n) * 10n ** 12n).toString()); +}); + +test("Circle offers nothing it cannot carry right now, without asking when the answer is already known", async () => { + const none = circle(); + for (const request of [ + { ...REQUEST, destinationAsset: "ETH" as const }, + { ...REQUEST, originChainId: 4663 as const, originAsset: "USDG" as const }, + { ...REQUEST, amount: (CCTP_MAX_BURN + 1n).toString() }, + ]) assert.equal(await getCctpQuote(request, none.fetcher, () => NOW), null); + assert.equal(none.calls.length, 0); + // Fast capacity below the amount: Circle would fall back to Standard and charge the Fast fee + assert.equal(await getCctpQuote(REQUEST, circle({ allowance: 24.999999 }).fetcher, () => NOW), null); + assert.ok(await getCctpQuote(REQUEST, circle({ allowance: 25 }).fetcher, () => NOW)); + // above 5% in fees: 0.2 USDC pays a 0.012 forward + assert.equal(await getCctpQuote({ ...REQUEST, amount: "200000" }, circle().fetcher, () => NOW), null); +}); + +test("Circle and RPC failures throw, and a failure is never reused", async () => { + const busy = circle({ failFees: 429 }); + await assert.rejects(getCctpQuote(REQUEST, busy.fetcher, () => NOW), (error) => error instanceof BridgeApiError && error.status === 429); + await assert.rejects(getCctpQuote(REQUEST, circle({ failFees: 500 }).fetcher, () => NOW), (error) => error instanceof BridgeApiError && error.status === 502); + await assert.rejects(getCctpQuote(REQUEST, circle({ fees: [FEES[1]] }).fetcher, () => NOW), (error) => error instanceof BridgeApiError && error.status === 502); + await assert.rejects(getCctpQuote(REQUEST, circle({ gas: "0x0" }).fetcher, () => NOW), BridgeApiError); + await assert.rejects(getCctpQuote(REQUEST, circle({ gas: 1 }).fetcher, () => NOW), BridgeApiError); + await assert.rejects(getCctpQuote(REQUEST, circle({ allowance: "lots" }).fetcher, () => NOW), BridgeApiError); + await assert.rejects(getCctpQuote(REQUEST, busy.fetcher, () => NOW + 1), BridgeApiError); + assert.equal(busy.paths().filter((path) => path.startsWith("/v2/burn")).length, 2); +}); + +test("Circle's answers are reused briefly and every caller shares one request budget", async () => { + const c = circle(); + await getCctpQuote(REQUEST, c.fetcher, () => NOW); + await getCctpQuote({ ...REQUEST, amount: "30000000" }, c.fetcher, () => NOW + 10_000); + assert.equal(c.paths().filter((path) => path.startsWith("/v2/burn")).length, 1); + assert.equal(c.paths().filter((path) => path.startsWith("/v2/fastBurn")).length, 1); + await getCctpQuote(REQUEST, c.fetcher, () => NOW + 31_000); + assert.equal(c.paths().filter((path) => path.startsWith("/v2/burn")).length, 2); + + const status = circle(); + const request = (n: number): CctpStatusRequest => ({ originChainId: 42161, destinationChainId: 8453, hash: `0x${n.toString(16).padStart(64, "0")}`, address: WALLET, amount: "25000000" }); + for (let n = 1; n <= CCTP_UPSTREAM_PER_MINUTE; n++) await getCctpStatus(request(n), status.fetcher, () => NOW); + await assert.rejects(getCctpStatus(request(0), status.fetcher, () => NOW + 1_000), (error) => error instanceof BridgeApiError && error.status === 429); + // a reused answer costs nothing, and the budget refills after a minute + assert.deepEqual(await getCctpStatus(request(1), status.fetcher, () => NOW + 1_000), { status: "waiting", inTxHashes: [], txHashes: [] }); + await getCctpStatus(request(0), status.fetcher, () => NOW + 60_000); + assert.equal(status.calls.length, CCTP_UPSTREAM_PER_MINUTE + 1); +}); + +test("the status route takes exactly one Circle burn: route, transaction, wallet and amount", () => { + const hash = `0x${"AB".repeat(32)}`; + const params = (patch: Record = {}) => { + const values: Record = { provider: "cctp", origin: "42161", destination: "8453", hash, address: WALLET.toUpperCase().replace("0X", "0x"), amount: "25000000", ...patch }; + return new URLSearchParams(Object.entries(values).filter((entry): entry is [string, string] => entry[1] !== null)); + }; + assert.deepEqual(parseCctpStatusRequest(params()), { originChainId: 42161, destinationChainId: 8453, hash: hash.toLowerCase(), address: WALLET, amount: "25000000" }); + const duplicate = params(); + duplicate.append("hash", hash); + for (const [name, value] of [ + ["provider", params({ provider: "relay" })], + ["missing", params({ amount: null })], + ["extra", new URLSearchParams([...params(), ["requestId", hash]])], + ["duplicate", duplicate], + ["not a Circle route", params({ origin: "4663" })], + ["funding destination", params({ destination: "1" })], + ["origin text", params({ origin: "42161.0" })], + ["hash", params({ hash: "0x1234" })], + ["zero address", params({ address: `0x${"0".repeat(40)}` })], + ["address", params({ address: "0x1234" })], + ["zero amount", params({ amount: "0" })], + ["leading zero", params({ amount: "025000000" })], + ["over the limit", params({ amount: (CCTP_MAX_BURN + 1n).toString() })], + ] as const) assert.throws(() => parseCctpStatusRequest(value), (error) => error instanceof BridgeApiError && error.status === 400, name); +}); + +// Circle's record of a real forwarded burn, Ethereum USDC to Arc (transaction 0x0095…ce01), read on 2026-11-10. +const BURN_HASH = "0x009551050ec596b833a8957d635c759dbd22b63d45977c9d4cde5dcc79bece01" as const; +const MINT_HASH = "0xec343f34384181675084e7bb39863235c060fa075521984a2a22eedd05029531" as const; +const RECORD = { + eventNonce: "0x355ab67f28b1f2bc40b323e4278f3995aba6858f3d7f7a2d6e161dd44bdbbaea", cctpVersion: 2, status: "complete", + decodedMessage: { + sourceDomain: "0", destinationDomain: "26", nonce: "0x355ab67f28b1f2bc40b323e4278f3995aba6858f3d7f7a2d6e161dd44bdbbaea", + sender: "0x28b5a0e9c621a5badaa536219b3a228c8168cf5d", recipient: "0x28b5a0e9c621a5badaa536219b3a228c8168cf5d", + destinationCaller: `0x${"0".repeat(64)}`, minFinalityThreshold: "1000", finalityThresholdExecuted: "1000", + decodedMessageBody: { + burnToken: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", mintRecipient: WALLET, amount: "2343317655", messageSender: WALLET, + maxFee: "89262", feeExecuted: "89262", expirationBlock: "30692704", hookData: CCTP_FORWARD_HOOK, + }, + decodedCctpXMessageBody: null, + }, + delayReason: null, forwardState: "COMPLETE", forwardTxHash: MINT_HASH, destinationMintTxHash: MINT_HASH, +}; +const STATUS_REQUEST: CctpStatusRequest = { originChainId: 1, destinationChainId: 5042, hash: BURN_HASH, address: WALLET, amount: "2343317655" }; +type Record_ = typeof RECORD; +const record = (patch: Partial = {}, body: Partial = {}, message: Partial = {}) => + ({ ...RECORD, ...patch, decodedMessage: { ...RECORD.decodedMessage, ...message, decodedMessageBody: { ...RECORD.decodedMessage.decodedMessageBody, ...body } } }); + +test("Circle's record maps onto the bridge's statuses only when it is this wallet's burn", () => { + const status = (value: unknown) => parseCctpStatus({ messages: [value] }, STATUS_REQUEST); + assert.deepEqual(status(RECORD), { status: "success", inTxHashes: [BURN_HASH], txHashes: [MINT_HASH] }); + assert.equal(status(record({ forwardState: "PENDING", forwardTxHash: null as unknown as string })).status, "submitted"); + assert.equal(status(record({ forwardState: "FAILED", forwardTxHash: null as unknown as string })).status, "delayed"); + assert.equal(status(record({ forwardState: "PENDING", delayReason: "insufficient_fee" as unknown as null })).status, "delayed"); + assert.equal(status(record({ status: "pending_confirmations", forwardState: "PENDING" })).status, "pending"); + assert.equal(status(record({ status: "pending_confirmations", forwardState: "PENDING", delayReason: "insufficient_allowance_available" as unknown as null })).status, "delayed"); + // before Circle decodes the burn, a lone record only says it is pending + assert.deepEqual(status({ status: "pending_confirmations", decodedMessage: null }), { status: "pending", inTxHashes: [BURN_HASH], txHashes: [] }); + for (const [name, value] of [ + ["recipient", record({}, { mintRecipient: "0x1111111111111111111111111111111111111111" })], + ["sender", record({}, { messageSender: "0x1111111111111111111111111111111111111111" })], + ["amount", record({}, { amount: "2343317656" })], + ["token", record({}, { burnToken: ARBITRUM_USDC.toLowerCase() })], + ["hook", record({}, { hookData: "0x" })], + ["destination", record({}, {}, { destinationDomain: "6" })], + ["source", record({}, {}, { sourceDomain: "3" })], + ["finality", record({}, {}, { minFinalityThreshold: "2000" })], + ["delivered without a hash", record({ forwardTxHash: "0x1234" })], + ["unknown status", record({ status: "lost" })], + ["undecoded and complete", { status: "complete", decodedMessage: null }], + ] as const) assert.throws(() => status(value), (error) => error instanceof BridgeApiError && error.status === 502, name); + assert.throws(() => parseCctpStatus({ messages: [RECORD, RECORD] }, STATUS_REQUEST), BridgeApiError); + assert.throws(() => parseCctpStatus({ messages: [] }, STATUS_REQUEST), BridgeApiError); + assert.deepEqual(parseCctpStatus({ messages: [record({}, { amount: "1" }), RECORD] }, STATUS_REQUEST).status, "success"); +}); + +test("Circle status asks by source domain and transaction, and an unknown burn is still waiting", async () => { + const unseen = circle(); + assert.deepEqual(await getCctpStatus(STATUS_REQUEST, unseen.fetcher, () => NOW), { status: "waiting", inTxHashes: [], txHashes: [] }); + assert.deepEqual(unseen.paths(), [`/v2/messages/0?transactionHash=${BURN_HASH}`]); + assert.equal((await getCctpStatus(STATUS_REQUEST, circle({ message: { messages: [RECORD] } }).fetcher, () => NOW)).status, "success"); +}); + +function relayAndCircle(circleAnswers: Answers, relayFails = false) { + const { input, quote } = relayRouteFixture(8453, 5042, "USDC", "USDC"); + addApprovalFixture(quote, input); + const relay = async (url: string) => { + if (url.endsWith("/chains")) return Response.json(relayChainsFixture()); + return relayFails ? new Response("", { status: 500 }) : Response.json(quote); + }; + return { input, relay, circle: circle(circleAnswers) }; +} +// Base to Arc, 25 USDC: Relay delivers 24.94; Circle's Fast fee here is 0.325 bp plus the forward. +const BASE_ARC_FEES = (forward: number) => [{ finalityThreshold: 1000, minimumFee: 0.325, forwardFee: { low: forward, med: forward, high: forward } }]; + +test("the bridge quotes Relay and Circle together and offers whichever delivers more", async () => { + const cheaper = relayAndCircle({ fees: BASE_ARC_FEES(18_959) }); + const viaCircle = await getBridgeQuote(cheaper.input, cheaper.relay, () => FIXTURE_NOW, cheaper.circle.fetcher); + assert.equal(viaCircle.provider, "cctp"); + assert.equal(viaCircle.amountOut, ((25_000_000n - 813n - 18_959n) * 10n ** 12n).toString()); + + const dearer = relayAndCircle({ fees: BASE_ARC_FEES(100_000) }); + const viaRelay = await getBridgeQuote(dearer.input, dearer.relay, () => FIXTURE_NOW, dearer.circle.fetcher); + assert.equal(viaRelay.provider, undefined); + assert.equal(viaRelay.amountOut, ((25_000_000n - 60_000n) * 10n ** 12n).toString()); + + // one side failing leaves the other; Relay's error stands when neither can quote + const relayDown = relayAndCircle({ fees: BASE_ARC_FEES(100_000) }, true); + assert.equal((await getBridgeQuote(relayDown.input, relayDown.relay, () => FIXTURE_NOW, relayDown.circle.fetcher)).provider, "cctp"); + const circleDown = relayAndCircle({ failFees: 500 }); + assert.equal((await getBridgeQuote(circleDown.input, circleDown.relay, () => FIXTURE_NOW, circleDown.circle.fetcher)).provider, undefined); + const bothDown = relayAndCircle({ failFees: 500 }, true); + await assert.rejects(getBridgeQuote(bothDown.input, bothDown.relay, () => FIXTURE_NOW, bothDown.circle.fetcher), (error) => error instanceof BridgeApiError && /No bridge quote/.test(error.message)); +}); + +test("routes Circle cannot carry never ask Circle, and injected fetchers leave Circle out by default", async () => { + const c = circle(); + const { quote } = relayRouteFixture(8453, 4663); + await getBridgeQuote(FIXTURE_INPUT, async (url) => Response.json(url.endsWith("/chains") ? relayChainsFixture() : quote), () => FIXTURE_NOW, c.fetcher); + assert.equal(c.calls.length, 0); + const { input, relay } = relayAndCircle({}); + assert.equal((await getBridgeQuote(input, relay, () => FIXTURE_NOW)).provider, undefined); +}); + + +test("HTTP status boundary sends a malformed Circle request nowhere and answers privately", async () => { + const hash = `0x${"ab".repeat(32)}`; + const base = `provider=cctp&origin=42161&destination=8453&hash=${hash}&address=${WALLET}&amount=25000000`; + for (const query of [`${base}&requestId=${hash}`, base.replace("cctp", "relay"), base.replace("8453", "4663"), base.replace("25000000", "0"), "provider=cctp"]) { + const response = await STATUS(new Request(`https://openlaunch.example/api/bridge/status?${query}`, { headers: { "fly-client-ip": "bridge-cctp-status-invalid" } })); + assert.equal(response.status, 400, query); + assert.match(response.headers.get("cache-control")!, /private, no-store/); + } +}); diff --git a/app/src/lib/bridge/cctp-server.ts b/app/src/lib/bridge/cctp-server.ts new file mode 100644 index 00000000..810a89bb --- /dev/null +++ b/app/src/lib/bridge/cctp-server.ts @@ -0,0 +1,229 @@ +import "server-only"; +import { formatEther, formatUnits, isAddress, parseUnits, toHex, type Hex } from "viem"; +import { CCTP_FAST_FINALITY, CCTP_FORWARD_HOOK, CCTP_MAX_FEE_PERCENT, CCTP_TOKEN_MESSENGER, cctpAmountOut, cctpBurnData, cctpRoute, type CctpRoute } from "./cctp"; +import { readBridgeJson } from "./read-json"; +import { bridgeRpcUpstream } from "./rpc"; +import { bridgeFeePercent, isBridgeChainId, type BridgeChainId, type BridgeQuote, type BridgeQuoteRequest, type BridgeStatus, type BridgeStatusResponse } from "./types"; +import { BridgeApiError, QUOTE_TTL_MS, isRequestId } from "./validation"; + +/** + * Circle's side of the bridge: a CCTP quote built entirely from pinned values plus Circle's published fee, and the + * status of a burn read from Circle's attestation service. Circle never supplies calldata, an address or a recipient + * here; its answers only set the fee (bounded below) and report progress. Any failure returns no Circle quote, so + * Relay's quote or error stands exactly as before. + */ +const IRIS = "https://iris-api.circle.com"; +const TIMEOUT_MS = 8_000; +type Fetcher = (url: string, init: RequestInit) => Promise; +type ObjectValue = Record; + +/** Circle burns at most 10,000,000 USDC per message (TokenMinterV2.burnLimitsPerMessage, read on all five networks). */ +export const CCTP_MAX_BURN = 10_000_000_000_000n; +/** The fee tier never goes above 1% of the amount, nor the forwarding fee above $5, whatever the API says. */ +const MAX_FEE_MICRO_BPS = 100_000_000n; +const MAX_FORWARD_FEE = 5_000_000n; +/** Gas a forwarded burn uses on the source network, for the estimate shown before the wallet is asked (~150k observed). */ +export const CCTP_BURN_GAS = 220_000n; +/** Seconds to arrival: Fast attestation (about 8 s on the rollups, 20 s on Ethereum, 0.5 s on Arc) plus Circle's forward. */ +const CCTP_SECONDS: Record = { 8453: 25, 42161: 25, 10: 25, 1: 40, 5042: 15, 4663: 0 }; + +// Circle allows 40 requests a second per IP and blocks for five minutes past that. Every caller here shares one budget +// well under it, and answers are reused briefly, so many open panels can never get the server blocked. +export const CCTP_UPSTREAM_PER_MINUTE = 600; +const FEES_TTL_MS = 30_000; +const ALLOWANCE_TTL_MS = 15_000; +const STATUS_TTL_MS = 5_000; +type Cached = { at: number; value: Promise }; +const caches = new WeakMap>>(); +const budgets = new WeakMap(); + +function ensure(condition: unknown, message = "Circle returned an unreadable answer."): asserts condition { + if (!condition) throw new BridgeApiError(message, 502); +} +function object(value: unknown): ObjectValue { + ensure(value && typeof value === "object" && !Array.isArray(value)); + return value as ObjectValue; +} +/** A JSON number as an exact decimal with at most `decimals` places: no exponents, no negatives. */ +function decimal(value: unknown, decimals: number): bigint { + ensure(typeof value === "number" && Number.isFinite(value) && value >= 0); + const text = String(value); + ensure(text.length <= 40 && (decimals > 0 ? new RegExp(`^\\d+(?:\\.\\d{1,${decimals}})?$`) : /^\d+$/).test(text)); + return parseUnits(text, decimals); +} + +function spend(fetcher: Fetcher, at: number) { + const budget = budgets.get(fetcher); + if (budget && at - budget.at < 60_000 && at >= budget.at) { + if (budget.used >= CCTP_UPSTREAM_PER_MINUTE) throw new BridgeApiError("Circle is busy. Please try again shortly.", 429); + budget.used++; + } else budgets.set(fetcher, { at, used: 1 }); +} + +/** One reused answer per key for `ttl`; a failure is never kept, so the next caller asks again. */ +function cached(fetcher: Fetcher, key: string, ttl: number, at: number, load: () => Promise): Promise { + let cache = caches.get(fetcher); + if (!cache) caches.set(fetcher, cache = new Map()); + const hit = cache.get(key) as Cached | undefined; + if (hit && at - hit.at < ttl && at >= hit.at) return hit.value; + spend(fetcher, at); + const value = load(); + if (cache.size >= 1_000) cache.clear(); + cache.set(key, { at, value }); + value.catch(() => { if (cache.get(key)?.value === value) cache.delete(key); }); + return value; +} + +async function iris(path: string, fetcher: Fetcher, maxBytes: number): Promise<{ status: number; body: unknown }> { + let response: Response; + try { + response = await fetcher(`${IRIS}${path}`, { method: "GET", cache: "no-store", redirect: "error", signal: AbortSignal.timeout(TIMEOUT_MS), headers: { Accept: "application/json" } }); + } catch { throw new BridgeApiError("Circle is unavailable right now.", 502); } + if (response.status === 429) throw new BridgeApiError("Circle is busy. Please try again shortly.", 429); + if (response.status === 404) { void response.body?.cancel().catch(() => {}); return { status: 404, body: null }; } + if (!response.ok) { void response.body?.cancel().catch(() => {}); throw new BridgeApiError("Circle is unavailable right now.", 502); } + return { status: response.status, body: await readBridgeJson(response.body, maxBytes, TIMEOUT_MS) }; +} + +export type CctpFee = { microBps: bigint; forwardFee: bigint }; +/** Circle's fee for this route at the burn's finality: basis points (kept to six decimals) and the forwarding fee. */ +export function parseCctpFees(value: unknown, finality: number): CctpFee { + ensure(Array.isArray(value) && value.length > 0 && value.length <= 8); + const entry = object(value.find((item) => object(item).finalityThreshold === finality)); + const microBps = decimal(entry.minimumFee, 6); + // Circle recommends its middle tier or higher; the high tier costs fractions of a cent more and never under-pays a + // forward when gas moves between the quote and the burn. + const forwardFee = decimal(object(entry.forwardFee).high, 0); + ensure(microBps <= MAX_FEE_MICRO_BPS && forwardFee <= MAX_FORWARD_FEE); + return { microBps, forwardFee }; +} + +/** The whole fee a burn may take: Circle's fee on the amount, rounded up, plus the forward. */ +export function cctpMaxFee(amount: bigint, fee: CctpFee): bigint { + const protocol = (amount * fee.microBps + 10_000_000_000n - 1n) / 10_000_000_000n; + return protocol + fee.forwardFee; +} + +async function gasPrice(chainId: BridgeChainId, fetcher: Fetcher): Promise { + const upstream = bridgeRpcUpstream(chainId); + ensure(upstream, "No RPC for this network."); + const response = await fetcher(upstream, { method: "POST", cache: "no-store", redirect: "error", signal: AbortSignal.timeout(TIMEOUT_MS), headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_gasPrice", params: [] }) }); + ensure(response.ok); + const result = object(await readBridgeJson(response.body, 4_096, TIMEOUT_MS)).result; + ensure(typeof result === "string" && /^0x[0-9a-fA-F]{1,32}$/.test(result)); + const price = BigInt(result); + ensure(price > 0n && price < 10n ** 15n); + return price; +} + +const randomId = (): Hex => toHex(crypto.getRandomValues(new Uint8Array(32))); + +/** + * A Circle quote for a USDC to USDC route between Circle networks, or null when Circle cannot carry it right now: + * not a Circle route, above the per-burn limit, more than 5% in fees, or Fast Transfer capacity below the amount. + * Throws only when Circle or the source RPC fails; the caller then keeps Relay's answer. + */ +export async function getCctpQuote(input: BridgeQuoteRequest, fetcher: Fetcher, now: () => number = Date.now): Promise { + const route = cctpRoute(input); + const amount = BigInt(input.amount); + if (!route || amount <= 0n || amount > CCTP_MAX_BURN) return null; + const at = now(); + const [fees, allowance, price] = await Promise.all([ + cached(fetcher, `fees:${route.sourceDomain}:${route.destinationDomain}`, FEES_TTL_MS, at, () => iris(`/v2/burn/USDC/fees/${route.sourceDomain}/${route.destinationDomain}?forward=true`, fetcher, 8_192).then(({ status, body }) => { ensure(status === 200); return parseCctpFees(body, route.finality); })), + // Fast Transfer draws on one global allowance; a Standard burn (Arc) does not. + route.finality === CCTP_FAST_FINALITY ? cached(fetcher, "allowance", ALLOWANCE_TTL_MS, at, () => iris("/v2/fastBurn/USDC/allowance", fetcher, 1_024).then(({ status, body }) => { ensure(status === 200); return decimal(object(body).allowance, 6); })) : Promise.resolve(null), + gasPrice(input.originChainId, fetcher), + ]); + if (allowance !== null && allowance < amount) return null; + const maxFee = cctpMaxFee(amount, fees); + if (maxFee <= 0n || maxFee >= amount || maxFee * 100n > amount * CCTP_MAX_FEE_PERCENT) return null; + return cctpQuoteFrom(input, route, maxFee, price, at); +} + +/** The quote itself, from pinned contracts and the bounded fee alone. */ +export function cctpQuoteFrom(input: BridgeQuoteRequest, route: CctpRoute, maxFee: bigint, gasPriceWei: bigint, at: number): BridgeQuote { + const amount = BigInt(input.amount); + const amountOut = cctpAmountOut(amount, maxFee, route.outputDecimals).toString(); + const expiresAt = at + QUOTE_TTL_MS; + return { + ...input, provider: "cctp", requestId: randomId(), amountOut, minimumAmountOut: amountOut, + relayFee: formatUnits(maxFee, 6), sourceGas: formatEther(gasPriceWei * CCTP_BURN_GAS), + totalImpactPercent: `-${bridgeFeePercent(maxFee, amount)}`, + approval: { token: route.burnToken, spender: CCTP_TOKEN_MESSENGER, amount: input.amount }, + timeEstimate: CCTP_SECONDS[input.originChainId], expiresAt, ttlMs: QUOTE_TTL_MS, + transaction: { to: CCTP_TOKEN_MESSENGER, data: cctpBurnData(input, maxFee), value: "0", chainId: input.originChainId }, + }; +} + +export type CctpStatusRequest = { originChainId: BridgeChainId; destinationChainId: BridgeChainId; hash: Hex; address: Hex; amount: string }; + +/** The status route's Circle parameters, exactly: provider, origin, destination, hash, address and amount. */ +export function parseCctpStatusRequest(params: URLSearchParams): CctpStatusRequest { + const keys = ["provider", "origin", "destination", "hash", "address", "amount"]; + const invalid = () => new BridgeApiError("Invalid Circle transfer.", 400); + if (params.size !== keys.length || !keys.every((key) => params.getAll(key).length === 1) || params.get("provider") !== "cctp") throw invalid(); + const originChainId = Number(params.get("origin")); + const destinationChainId = Number(params.get("destination")); + const hash = params.get("hash")!; + const address = params.get("address")!; + const amount = params.get("amount")!; + if (!/^\d{1,6}$/.test(params.get("origin")!) || !/^\d{1,6}$/.test(params.get("destination")!) || !isBridgeChainId(originChainId) || !isBridgeChainId(destinationChainId)) throw invalid(); + if (!cctpRoute({ originChainId, destinationChainId, originAsset: "USDC", destinationAsset: "USDC" })) throw invalid(); + if (!isRequestId(hash) || !isAddress(address, { strict: false }) || /^0x0{40}$/i.test(address) || !/^[1-9]\d{0,77}$/.test(amount) || BigInt(amount) > CCTP_MAX_BURN) throw invalid(); + return { originChainId, destinationChainId, hash: hash.toLowerCase() as Hex, address: address.toLowerCase() as Hex, amount }; +} + +const lower = (value: unknown) => typeof value === "string" ? value.toLowerCase() : null; + +/** + * Circle's record of one burn, mapped onto the bridge's statuses. Not found yet is "waiting". A record must name this + * wallet, amount, token, route and the forwarding hook before it counts; one that names anything else is an error, + * never progress. Delivered means attested, and the mint forwarded with a destination transaction hash. + */ +export function parseCctpStatus(value: unknown, request: CctpStatusRequest): BridgeStatusResponse { + const route = cctpRoute({ ...request, originAsset: "USDC", destinationAsset: "USDC" })!; + const messages = object(value).messages; + ensure(Array.isArray(messages) && messages.length >= 1 && messages.length <= 10); + const matches = messages.map(object).filter((message) => { + const decoded = message.decodedMessage; + if (!decoded || typeof decoded !== "object") return false; + const m = decoded as ObjectValue; + const body = m.decodedMessageBody && typeof m.decodedMessageBody === "object" ? m.decodedMessageBody as ObjectValue : null; + return !!body && m.sourceDomain === String(route.sourceDomain) && m.destinationDomain === String(route.destinationDomain) + && lower(body.burnToken) === route.burnToken.toLowerCase() && lower(body.mintRecipient) === request.address && lower(body.messageSender) === request.address + && body.amount === request.amount && lower(body.hookData) === CCTP_FORWARD_HOOK && m.minFinalityThreshold === String(route.finality); + }); + // Before attestation Circle may not have decoded the burn yet; a lone undecoded record only says it is pending. + if (matches.length === 0) { + const pending = messages.length === 1 && !object(messages[0]).decodedMessage && ["pending_confirmations", "pending"].includes(String(object(messages[0]).status)); + ensure(pending, "Circle's record does not match this transfer."); + return { status: "pending", inTxHashes: [request.hash], txHashes: [] }; + } + ensure(matches.length === 1, "Circle's record does not match this transfer."); + const message = matches[0]; + const delayed = typeof message.delayReason === "string" && message.delayReason.length > 0; + let status: BridgeStatus; + const txHashes: Hex[] = []; + if (message.status === "complete") { + const forwardHash = message.forwardTxHash; + if (message.forwardState === "COMPLETE") { + ensure(isRequestId(forwardHash)); + status = "success"; + txHashes.push(forwardHash); + } else status = delayed || (message.forwardState !== undefined && message.forwardState !== null && message.forwardState !== "PENDING") ? "delayed" : "submitted"; + } else { + ensure(["pending_confirmations", "pending"].includes(String(message.status))); + status = delayed ? "delayed" : "pending"; + } + return { status, inTxHashes: [request.hash], txHashes }; +} + +export async function getCctpStatus(request: CctpStatusRequest, fetcher: Fetcher = fetch, now: () => number = Date.now): Promise { + const domain = cctpRoute({ ...request, originAsset: "USDC", destinationAsset: "USDC" })!.sourceDomain; + const key = `status:${domain}:${request.hash}:${request.address}:${request.amount}:${request.destinationChainId}`; + return cached(fetcher, key, STATUS_TTL_MS, now(), async () => { + const { status, body } = await iris(`/v2/messages/${domain}?transactionHash=${request.hash}`, fetcher, 64_000); + if (status === 404) return { status: "waiting", inTxHashes: [], txHashes: [] } satisfies BridgeStatusResponse; + return parseCctpStatus(body, request); + }); +} diff --git a/app/src/lib/bridge/cctp.live.test.ts b/app/src/lib/bridge/cctp.live.test.ts new file mode 100644 index 00000000..b7a4a73c --- /dev/null +++ b/app/src/lib/bridge/cctp.live.test.ts @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createPublicClient, http } from "viem"; +import { CCTP_DESTINATIONS, CCTP_DOMAINS, CCTP_TOKEN_MESSENGER, cctpBurnData, cctpRoute, isCctpBurnLog } from "./cctp.ts"; +import { getCctpQuote, getCctpStatus } from "./cctp-server.ts"; +import { validateBridgeQuote, type TrackedBridgeTransfer } from "./client.ts"; +import { BRIDGE_CHAINS, type BridgeChainId } from "./types.ts"; + +// Explicit opt-in, read-only: Circle's public fee and message endpoints and public RPC nodes. No wallet, no signing. +const live = { skip: process.env.RUN_BRIDGE_LIVE_TESTS !== "1" }; +const DUMMY = "0x1111111111111111111111111111111111111111" as const; + +for (const originChainId of Object.keys(CCTP_DOMAINS).map(Number) as BridgeChainId[]) for (const destinationChainId of CCTP_DESTINATIONS) { + if (originChainId === destinationChainId) continue; + test(`read-only live Circle quote ${BRIDGE_CHAINS[originChainId].name} USDC to ${BRIDGE_CHAINS[destinationChainId].name} USDC`, live, async (t) => { + const request = { address: DUMMY, originChainId, destinationChainId, originAsset: "USDC" as const, destinationAsset: "USDC" as const, amount: "25000000" }; + assert.ok(cctpRoute(request)); + const quote = await getCctpQuote(request, fetch); + assert.ok(quote, "Circle offered no quote"); + assert.deepEqual(validateBridgeQuote(quote, request, Date.now()), quote); + t.diagnostic(`fee ${quote.relayFee} USDC, receives ${quote.amountOut}, about ${quote.timeEstimate}s`); + }); +} + +// A real forwarded burn, Ethereum USDC to Arc: the calldata this bridge builds must produce exactly its event, and +// Circle's record of it must read as delivered with the mint transaction it reported on 2026-11-10. +test("read-only live Circle burn matches this bridge's own encoding and reads as delivered", live, async () => { + const hash = "0x009551050ec596b833a8957d635c759dbd22b63d45977c9d4cde5dcc79bece01" as const; + const transfer: TrackedBridgeTransfer = { + provider: "cctp", address: "0x6376ca7e839f1c3dd7f23cc8a1a58329f60db125", originChainId: 1, destinationChainId: 5042, originAsset: "USDC", destinationAsset: "USDC", + amount: "2343317655", requestId: `0x${"1".repeat(64)}`, destinationHashes: [], status: "pending", createdAt: 1, depositKind: "erc20", + depositData: cctpBurnData({ address: "0x6376ca7e839f1c3dd7f23cc8a1a58329f60db125", originChainId: 1, destinationChainId: 5042, originAsset: "USDC", destinationAsset: "USDC", amount: "2343317655" }, 89_262n), + }; + const client = createPublicClient({ transport: http("https://ethereum-rpc.publicnode.com", { timeout: 30_000 }) }); + const receipt = await client.getTransactionReceipt({ hash }); + const burns = receipt.logs.filter((log) => log.address.toLowerCase() === CCTP_TOKEN_MESSENGER.toLowerCase()); + assert.equal(burns.length, 1); + assert.equal(isCctpBurnLog(burns[0], transfer), true); + const status = await getCctpStatus({ originChainId: 1, destinationChainId: 5042, hash, address: transfer.address, amount: transfer.amount }, fetch); + assert.deepEqual(status, { status: "success", inTxHashes: [hash], txHashes: ["0xec343f34384181675084e7bb39863235c060fa075521984a2a22eedd05029531"] }); +}); diff --git a/app/src/lib/bridge/cctp.test.ts b/app/src/lib/bridge/cctp.test.ts new file mode 100644 index 00000000..e993ec07 --- /dev/null +++ b/app/src/lib/bridge/cctp.test.ts @@ -0,0 +1,207 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { encodeAbiParameters, encodeEventTopics, encodeFunctionData, pad, parseAbi, type Address, type Hex } from "viem"; +import { CCTP_BURN_ABI, CCTP_BURN_EVENT, CCTP_FORWARD_HOOK, CCTP_TOKEN_MESSENGER, cctpAmountOut, cctpBurnData, cctpBurnFee, cctpRoute, isCctpBurnLog } from "./cctp"; +import { bridgeStatusUrl, CCTP_SCAN_BLOCKS, findCctpBurn, hasMatchingDepositEvent, isMatchingSourceDeposit, parseStoredTransfer, serializeTransfer, submitBridgeDeposit, validateBridgeQuote, type DepositDependencies, type TrackedBridgeTransfer } from "./client"; +import { ARBITRUM_USDC, ARC_USDC, BASE_USDC, ETHEREUM_USDC, OPTIMISM_USDC, bridgeFeePercent, type BridgeQuote, type BridgeQuoteRequest } from "./types"; + +const WALLET = "0x6376ca7e839f1c3dd7f23cc8a1a58329f60db125" as const; +const OTHER = "0x1111111111111111111111111111111111111111" as const; +const ZERO_WORD = `0x${"0".repeat(64)}` as Hex; +const HASH = `0x${"ab".repeat(32)}` as Hex; +// Arbitrum USDC to Base USDC: 25 USDC, the fee Circle quoted for Fast plus its forward +const REQUEST: BridgeQuoteRequest = { address: WALLET, originChainId: 42161, destinationChainId: 8453, originAsset: "USDC", destinationAsset: "USDC", amount: "25000000" }; +const FEE = 21_211n; + +function burn(overrides: Partial<{ amount: bigint; domain: number; recipient: Hex; token: Address; caller: Hex; maxFee: bigint; finality: number; hook: Hex }> = {}): Hex { + return encodeFunctionData({ abi: CCTP_BURN_ABI, functionName: "depositForBurnWithHook", args: [ + overrides.amount ?? 25_000_000n, overrides.domain ?? 6, overrides.recipient ?? pad(WALLET), overrides.token ?? ARBITRUM_USDC, + overrides.caller ?? ZERO_WORD, overrides.maxFee ?? FEE, overrides.finality ?? 1000, overrides.hook ?? CCTP_FORWARD_HOOK, + ] }); +} + +test("Circle carries only USDC to USDC into Base or Arc, from the five Circle networks", () => { + assert.deepEqual(cctpRoute(REQUEST), { sourceDomain: 3, destinationDomain: 6, burnToken: ARBITRUM_USDC, outputDecimals: 6, finality: 1000 }); + assert.deepEqual(cctpRoute({ originChainId: 1, destinationChainId: 5042, originAsset: "USDC" }), { sourceDomain: 0, destinationDomain: 26, burnToken: ETHEREUM_USDC, outputDecimals: 18, finality: 1000 }); + assert.deepEqual(cctpRoute({ originChainId: 10, destinationChainId: 8453, originAsset: "USDC", destinationAsset: "USDC" })?.burnToken, OPTIMISM_USDC); + // Arc's own asset is USDC, so the default asset counts; Arc burns at Standard finality (final in about half a second) + assert.deepEqual(cctpRoute({ originChainId: 5042, destinationChainId: 8453, destinationAsset: "USDC" }), { sourceDomain: 26, destinationDomain: 6, burnToken: ARC_USDC, outputDecimals: 6, finality: 2000 }); + assert.equal(cctpRoute({ originChainId: 8453, destinationChainId: 5042, originAsset: "USDC" })?.burnToken, BASE_USDC); + for (const route of [ + { originChainId: 8453, destinationChainId: 5042 }, // Base ETH + { originChainId: 8453, destinationChainId: 5042, originAsset: "USDC", destinationAsset: "ETH" }, + { originChainId: 42161, destinationChainId: 8453, originAsset: "USDC" }, // Base default asset is ETH + { originChainId: 8453, destinationChainId: 8453, originAsset: "USDC", destinationAsset: "USDC" }, + { originChainId: 8453, destinationChainId: 4663, originAsset: "USDC", destinationAsset: "USDG" }, // Robinhood has no USDC route + { originChainId: 4663, destinationChainId: 8453, originAsset: "USDG", destinationAsset: "USDC" }, + { originChainId: 8453, destinationChainId: 1, originAsset: "USDC", destinationAsset: "USDC" }, // funding networks only send + ] as const) assert.equal(cctpRoute(route), null, JSON.stringify(route)); +}); + +test("a burn's fee is read back only from the exact calldata this bridge builds", () => { + assert.equal(cctpBurnData(REQUEST, FEE), burn()); + assert.equal(cctpBurnFee(burn(), REQUEST), FEE); + // exactly 5% is the ceiling; a wei above it is refused + assert.equal(cctpBurnFee(burn({ maxFee: 1_250_000n }), REQUEST), 1_250_000n); + assert.equal(cctpBurnFee(burn({ maxFee: 1_250_001n }), REQUEST), null); + for (const [name, data] of [ + ["recipient", burn({ recipient: pad(OTHER) })], + ["amount", burn({ amount: 25_000_001n })], + ["domain", burn({ domain: 26 })], + ["token", burn({ token: BASE_USDC })], + ["caller", burn({ caller: pad(OTHER) })], + ["finality", burn({ finality: 2000 })], + ["hook", burn({ hook: "0x" })], + ["hook payload", burn({ hook: `${CCTP_FORWARD_HOOK}00000001` })], + ["trailing bytes", `${burn()}00` as Hex], + ["other function", encodeFunctionData({ abi: parseAbi(["function approve(address,uint256)"]), functionName: "approve", args: [CCTP_TOKEN_MESSENGER, 25_000_000n] })], + ] as const) assert.equal(cctpBurnFee(data, REQUEST), null, name); + for (const data of [undefined, 1, "", "0x", "not hex", `0x${"00".repeat(500)}`]) assert.equal(cctpBurnFee(data, REQUEST), null); + assert.throws(() => cctpBurnData(REQUEST, 0n)); + assert.throws(() => cctpBurnData(REQUEST, 25_000_000n)); + assert.throws(() => cctpBurnData({ ...REQUEST, destinationAsset: "ETH" }, FEE)); +}); + +test("what arrives is the amount less the whole fee, in the destination's own units", () => { + assert.equal(cctpAmountOut(25_000_000n, FEE, 6), 24_978_789n); + assert.equal(cctpAmountOut(25_000_000n, FEE, 18), 24_978_789_000_000_000_000n); +}); + +function burnLog(overrides: Partial<{ address: Address; depositor: Address; finality: number; amount: bigint; maxFee: bigint; hook: Hex; recipient: Hex; domain: number; caller: Hex }> = {}) { + const topics = encodeEventTopics({ abi: CCTP_BURN_EVENT, eventName: "DepositForBurn", args: { burnToken: ARBITRUM_USDC, depositor: overrides.depositor ?? WALLET, minFinalityThreshold: overrides.finality ?? 1000 } }) as Hex[]; + const data = encodeAbiParameters( + [{ type: "uint256" }, { type: "bytes32" }, { type: "uint32" }, { type: "bytes32" }, { type: "bytes32" }, { type: "uint256" }, { type: "bytes" }], + [overrides.amount ?? 25_000_000n, overrides.recipient ?? pad(WALLET), overrides.domain ?? 6, pad(CCTP_TOKEN_MESSENGER), overrides.caller ?? ZERO_WORD, overrides.maxFee ?? FEE, overrides.hook ?? CCTP_FORWARD_HOOK], + ); + return { address: overrides.address ?? CCTP_TOKEN_MESSENGER, data, topics, transactionHash: HASH }; +} + +const TRANSFER: TrackedBridgeTransfer = { ...REQUEST, provider: "cctp", sourceBlock: "1000", requestId: `0x${"12".repeat(32)}`, destinationHashes: [], status: "pending", createdAt: 1, depositData: burn(), depositKind: "erc20" }; + +test("only Circle's own burn event with every saved field counts as this transfer's burn", () => { + assert.equal(isCctpBurnLog(burnLog(), TRANSFER), true); + for (const [name, log] of [ + ["emitter", burnLog({ address: OTHER })], + ["depositor", burnLog({ depositor: OTHER })], + ["finality", burnLog({ finality: 2000 })], + ["amount", burnLog({ amount: 1n })], + ["fee", burnLog({ maxFee: FEE + 1n })], + ["hook", burnLog({ hook: "0x" })], + ["recipient", burnLog({ recipient: pad(OTHER) })], + ["domain", burnLog({ domain: 26 })], + ["caller", burnLog({ caller: pad(OTHER) })], + ] as const) assert.equal(isCctpBurnLog(log, TRANSFER), false, name); + assert.equal(isCctpBurnLog({ ...burnLog(), topics: burnLog().topics.slice(0, 3) }, TRANSFER), false); + assert.equal(isCctpBurnLog(burnLog(), { ...TRANSFER, depositData: undefined }), false); + assert.equal(hasMatchingDepositEvent(TRANSFER, { status: "success", logs: [burnLog({ amount: 1n }), burnLog()] }), true); + assert.equal(hasMatchingDepositEvent(TRANSFER, { status: "reverted", logs: [burnLog()] }), false); + assert.equal(isMatchingSourceDeposit(TRANSFER, { from: WALLET, to: CCTP_TOKEN_MESSENGER, input: burn(), value: 0n }), true); + assert.equal(isMatchingSourceDeposit(TRANSFER, { from: OTHER, to: CCTP_TOKEN_MESSENGER, input: burn(), value: 0n }), false); + assert.equal(isMatchingSourceDeposit(TRANSFER, { from: WALLET, to: OTHER, input: burn(), value: 0n }), false); + assert.equal(isMatchingSourceDeposit(TRANSFER, { from: WALLET, to: CCTP_TOKEN_MESSENGER, input: burn(), value: 1n }), false); +}); + +function circleQuote(overrides: Partial = {}, now = 1_000): BridgeQuote { + const out = cctpAmountOut(25_000_000n, FEE, 6).toString(); + return { + ...REQUEST, provider: "cctp", requestId: `0x${"12".repeat(32)}`, amountOut: out, minimumAmountOut: out, relayFee: "0.021211", sourceGas: "0.0000022", + totalImpactPercent: `-${bridgeFeePercent(FEE, 25_000_000n)}`, approval: { token: ARBITRUM_USDC, spender: CCTP_TOKEN_MESSENGER, amount: REQUEST.amount }, + timeEstimate: 25, expiresAt: now + 45_000, ttlMs: 45_000, transaction: { to: CCTP_TOKEN_MESSENGER, data: burn(), value: "0", chainId: 42161 }, + ...overrides, + }; +} + +test("the browser accepts a Circle quote only when it is exactly the burn it would build itself", () => { + assert.deepEqual(validateBridgeQuote(circleQuote(), REQUEST, 1_000), circleQuote()); + const tx = circleQuote().transaction; + for (const [name, quote] of [ + ["provider", circleQuote({ provider: "other" as "cctp" })], + ["spender", circleQuote({ approval: { token: ARBITRUM_USDC, spender: "0x4cd00e387622c35bddb9b4c962c136462338bc31", amount: REQUEST.amount } })], + ["approval token", circleQuote({ approval: { token: BASE_USDC, spender: CCTP_TOKEN_MESSENGER, amount: REQUEST.amount } })], + ["approval amount", circleQuote({ approval: { token: ARBITRUM_USDC, spender: CCTP_TOKEN_MESSENGER, amount: "25000001" } })], + ["no approval", circleQuote({ approval: undefined })], + ["target", circleQuote({ transaction: { ...tx, to: OTHER } })], + ["value", circleQuote({ transaction: { ...tx, value: "1" } })], + ["chain", circleQuote({ transaction: { ...tx, chainId: 8453 } })], + ["recipient", circleQuote({ transaction: { ...tx, data: burn({ recipient: pad(OTHER) }) } })], + ["hook", circleQuote({ transaction: { ...tx, data: burn({ hook: "0x" }) } })], + ["output", circleQuote({ amountOut: "24978790", minimumAmountOut: "24978790" })], + ["minimum", circleQuote({ minimumAmountOut: "24978788" })], + ["fee shown", circleQuote({ relayFee: "0.02" })], + ] as const) assert.throws(() => validateBridgeQuote(quote, REQUEST, 1_000), Error, name); + // a different fee is fine when every number agrees with it + const cheaper = burn({ maxFee: 20_000n }); + const out = cctpAmountOut(25_000_000n, 20_000n, 6).toString(); + assert.doesNotThrow(() => validateBridgeQuote(circleQuote({ transaction: { ...tx, data: cheaper }, amountOut: out, minimumAmountOut: out, relayFee: "0.02", totalImpactPercent: "-0.08" }), REQUEST, 1_000)); +}); + +test("a Circle record survives storage only with its burn intact, and old Relay records are unchanged", () => { + const sent = { ...TRANSFER, sourceHash: HASH }; + const saved = serializeTransfer(sent); + assert.equal(JSON.parse(saved).version, 4); + assert.deepEqual(parseStoredTransfer(saved, WALLET), sent); + const withoutBlock: TrackedBridgeTransfer = { ...sent }; + delete withoutBlock.sourceBlock; + assert.deepEqual(parseStoredTransfer(serializeTransfer(withoutBlock), WALLET), withoutBlock); + const tamper = (patch: Record) => JSON.stringify({ ...JSON.parse(saved), ...patch }); + for (const [name, raw] of [ + ["version", tamper({ version: 3 })], + ["recipient", tamper({ depositData: burn({ recipient: pad(OTHER) }) })], + ["amount", tamper({ amount: "26000000" })], + ["kind", tamper({ depositKind: undefined })], + ["block", tamper({ sourceBlock: "-1" })], + ["block type", tamper({ sourceBlock: 1000 })], + ["provider", tamper({ provider: "relay" })], + ] as const) assert.throws(() => parseStoredTransfer(raw, WALLET), Error, name); + // a Relay record never carries Circle's fields + const relay = { address: WALLET, originChainId: 8453, destinationChainId: 4663, amount: "1", requestId: TRANSFER.requestId, destinationHashes: [], status: "pending", createdAt: 1, version: 1 }; + assert.doesNotThrow(() => parseStoredTransfer(JSON.stringify(relay), WALLET)); + assert.throws(() => parseStoredTransfer(JSON.stringify({ ...relay, sourceBlock: "1" }), WALLET)); +}); + +test("Circle status is asked by the burn's own transaction, and not at all before there is one", () => { + assert.equal(bridgeStatusUrl({ ...TRANSFER, provider: undefined }), `/api/bridge/status?requestId=${TRANSFER.requestId}`); + assert.equal(bridgeStatusUrl(TRANSFER), null); + const url = new URL(bridgeStatusUrl({ ...TRANSFER, sourceHash: HASH })!, "https://openlaunch.lol"); + assert.deepEqual(Object.fromEntries(url.searchParams), { provider: "cctp", origin: "42161", destination: "8453", hash: HASH, address: WALLET, amount: "25000000" }); +}); + +test("a burn the wallet never reported is found by its own event, once, inside the scan window", async () => { + const calls: { fromBlock: bigint; toBlock: bigint; args: unknown }[] = []; + const reader = (latest: bigint, logs: ReturnType[]) => ({ + getBlockNumber: async () => latest, + getLogs: async (args: { fromBlock: bigint; toBlock: bigint; args: unknown }) => { calls.push(args); return logs; }, + }); + assert.equal(await findCctpBurn(reader(1_050n, [burnLog({ amount: 1n }), burnLog()]), TRANSFER), HASH); + assert.deepEqual(calls.at(-1), { address: CCTP_TOKEN_MESSENGER, event: CCTP_BURN_EVENT[0], args: { burnToken: ARBITRUM_USDC, depositor: WALLET }, fromBlock: 1_000n, toBlock: 1_050n }); + await findCctpBurn(reader(1_000_000n, []), TRANSFER); + assert.equal(calls.at(-1)!.toBlock, 1_000n + CCTP_SCAN_BLOCKS); + // the same burn in two logs is one burn; two different burns are ambiguous + assert.equal(await findCctpBurn(reader(1_050n, [burnLog(), burnLog()]), TRANSFER), HASH); + assert.equal(await findCctpBurn(reader(1_050n, [burnLog(), { ...burnLog(), transactionHash: `0x${"cd".repeat(32)}` as Hex }]), TRANSFER), null); + assert.equal(await findCctpBurn(reader(1_050n, [burnLog({ depositor: OTHER })]), TRANSFER), null); + const before = calls.length; + assert.equal(await findCctpBurn(reader(999n, [burnLog()]), TRANSFER), null); + assert.equal(await findCctpBurn(reader(1_050n, [burnLog()]), { ...TRANSFER, sourceBlock: undefined }), null); + assert.equal(await findCctpBurn(reader(1_050n, [burnLog()]), { ...TRANSFER, provider: undefined }), null); + assert.equal(calls.length, before); +}); + +test("a Circle deposit records its provider and the block to look from, and still sends if that block is unknown", async () => { + const quote = circleQuote({}, Date.now()); + const saved: TrackedBridgeTransfer[] = []; + const deps = (sourceBlock: DepositDependencies["sourceBlock"]): DepositDependencies => ({ + now: () => quote.expiresAt - 45_000, currentRequest: () => REQUEST, readWallet: async () => ({ address: WALLET, chainId: 42161 }), + switchChain: async () => {}, prepare: async () => ({ gas: 1n, maxFeePerGas: 1n, maxPriorityFeePerGas: 1n }), readTransfer: () => null, + saveTransfer: (transfer) => { saved.push(transfer); }, removeTransfer: () => {}, sourceBlock, send: async () => HASH, phase: () => {}, + }); + const sent = await submitBridgeDeposit(quote, deps(async () => 77n)); + assert.equal(sent.kind, "sent"); + assert.equal(saved[0].provider, "cctp"); + assert.equal(saved[0].sourceBlock, "77"); + saved.length = 0; + const unknown = await submitBridgeDeposit(quote, deps(async () => { throw new Error("rpc down"); })); + assert.equal(unknown.kind, "sent"); + assert.equal(saved[0].provider, "cctp"); + assert.equal(saved[0].sourceBlock, undefined); +}); diff --git a/app/src/lib/bridge/cctp.ts b/app/src/lib/bridge/cctp.ts new file mode 100644 index 00000000..5d7d3f8b --- /dev/null +++ b/app/src/lib/bridge/cctp.ts @@ -0,0 +1,130 @@ +import { decodeEventLog, decodeFunctionData, encodeFunctionData, pad, type Address, type Hex } from "viem"; +import { bridgeCurrency, defaultBridgeAsset, type BridgeAsset, type BridgeChainId, type BridgeQuoteRequest } from "./types"; + +/** + * Circle's CCTP v2 as the bridge's second route, for USDC to USDC only. The wallet burns USDC on the source network + * through Circle's TokenMessengerV2 with a Fast finality threshold and Circle's Forwarding Service hook, and Circle + * mints the same USDC (less one fixed fee) to the same wallet on the destination. There is no second wallet step and + * no gas is needed on the destination. + * + * Everything here is pinned and pure, shared by the server (which builds the quote) and the browser (which rebuilds + * the exact calldata before the wallet is asked): no response from Circle ever chooses a contract, a recipient or a + * hook. Facts checked on-chain on 2026-11-10: the two contracts below are deployed at these addresses on all five + * networks, `localDomain()` returns the domains listed, and live forwarded burns carry exactly `CCTP_FORWARD_HOOK` + * with a zero destination caller. + * https://developers.circle.com/cctp/references/contract-addresses + */ +export const CCTP_TOKEN_MESSENGER = "0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d" as const; +export const CCTP_MESSAGE_TRANSMITTER = "0x81D40F21F12A8F0E3252Bccb954D722d4c464B64" as const; +/** Circle's domain for each network the bridge can burn on. Robinhood Chain has a domain (35) but no live USDC route. */ +export const CCTP_DOMAINS = { 1: 0, 10: 2, 42161: 3, 8453: 6, 5042: 26 } as const; +export type CctpChainId = keyof typeof CCTP_DOMAINS; +/** Launch chains Circle can mint to. */ +export const CCTP_DESTINATIONS = [8453, 5042] as const; +/** Fast Transfer: attested at soft finality (about 8 seconds on the rollups, 20 on Ethereum) for a small fee in basis points. */ +export const CCTP_FAST_FINALITY = 1000; +/** Standard Transfer. Arc has no Fast Transfer as a source: its blocks are final in about half a second anyway. */ +export const CCTP_STANDARD_FINALITY = 2000; +/** The finality a burn from this network asks for. */ +export const cctpFinality = (originChainId: BridgeChainId) => originChainId === 5042 ? CCTP_STANDARD_FINALITY : CCTP_FAST_FINALITY; +/** "cctp-forward" right-padded to 32 bytes: version 0, no payload. Circle relays the mint to the recipient. */ +export const CCTP_FORWARD_HOOK = "0x636374702d666f72776172640000000000000000000000000000000000000000" as const; +const ZERO_WORD = `0x${"0".repeat(64)}` as const; +/** Burns above this share of the amount are never offered: the same 5% ceiling as Relay's fee. */ +export const CCTP_MAX_FEE_PERCENT = 5n; + +export const CCTP_BURN_ABI = [{ + type: "function", name: "depositForBurnWithHook", stateMutability: "nonpayable", + inputs: [ + { name: "amount", type: "uint256" }, { name: "destinationDomain", type: "uint32" }, { name: "mintRecipient", type: "bytes32" }, + { name: "burnToken", type: "address" }, { name: "destinationCaller", type: "bytes32" }, { name: "maxFee", type: "uint256" }, + { name: "minFinalityThreshold", type: "uint32" }, { name: "hookData", type: "bytes" }, + ], + outputs: [], +}] as const; +export const CCTP_BURN_EVENT = [{ + type: "event", name: "DepositForBurn", + inputs: [ + { name: "burnToken", type: "address", indexed: true }, { name: "amount", type: "uint256", indexed: false }, + { name: "depositor", type: "address", indexed: true }, { name: "mintRecipient", type: "bytes32", indexed: false }, + { name: "destinationDomain", type: "uint32", indexed: false }, { name: "destinationTokenMessenger", type: "bytes32", indexed: false }, + { name: "destinationCaller", type: "bytes32", indexed: false }, { name: "maxFee", type: "uint256", indexed: false }, + { name: "minFinalityThreshold", type: "uint32", indexed: true }, { name: "hookData", type: "bytes", indexed: false }, + ], +}] as const; + +type Route = Pick; +export type CctpRoute = { sourceDomain: number; destinationDomain: number; burnToken: Address; outputDecimals: 6 | 18; finality: number }; + +const isCctpChain = (chainId: BridgeChainId): chainId is CctpChainId => Object.hasOwn(CCTP_DOMAINS, chainId); +const asset = (chainId: BridgeChainId, value: BridgeAsset | undefined) => value ?? defaultBridgeAsset(chainId); + +/** The CCTP leg for a USDC to USDC route between Circle networks, or null when Circle cannot carry it. */ +export function cctpRoute(route: Route): CctpRoute | null { + const { originChainId, destinationChainId } = route; + if (originChainId === destinationChainId || !isCctpChain(originChainId) || !(CCTP_DESTINATIONS as readonly BridgeChainId[]).includes(destinationChainId)) return null; + if (asset(originChainId, route.originAsset) !== "USDC" || asset(destinationChainId, route.destinationAsset) !== "USDC") return null; + return { + sourceDomain: CCTP_DOMAINS[originChainId], + destinationDomain: CCTP_DOMAINS[destinationChainId as CctpChainId], + burnToken: bridgeCurrency(originChainId, "USDC", "input").address, + outputDecimals: bridgeCurrency(destinationChainId, "USDC", "output").decimals, + finality: cctpFinality(originChainId), + }; +} + +/** The one burn this bridge ever asks for: the exact amount, to the same wallet, at the source's finality, forwarded by Circle. */ +export function cctpBurnData(request: Route & Pick, maxFee: bigint): Hex { + const route = cctpRoute(request); + if (!route) throw new Error("Circle does not carry this route."); + const amount = BigInt(request.amount); + if (amount <= 0n || maxFee <= 0n || maxFee >= amount) throw new Error("Invalid Circle burn amounts."); + return encodeFunctionData({ + abi: CCTP_BURN_ABI, functionName: "depositForBurnWithHook", + args: [amount, route.destinationDomain, pad(request.address), route.burnToken, ZERO_WORD, maxFee, route.finality, CCTP_FORWARD_HOOK], + }); +} + +/** + * The fee a burn's calldata allows, when and only when that calldata is exactly the burn `cctpBurnData` builds for + * this request (re-encoding also rejects trailing bytes). Null for anything else, including a fee at or above the + * 5% ceiling. + */ +export function cctpBurnFee(data: unknown, request: Route & Pick): bigint | null { + if (typeof data !== "string" || !/^0x[0-9a-fA-F]+$/.test(data) || data.length > 2 + 2 * 420) return null; + try { + const { functionName, args } = decodeFunctionData({ abi: CCTP_BURN_ABI, data: data as Hex }); + if (functionName !== "depositForBurnWithHook") return null; + const maxFee = args[5]; + if (maxFee * 100n > BigInt(request.amount) * CCTP_MAX_FEE_PERCENT) return null; + return cctpBurnData(request, maxFee).toLowerCase() === data.toLowerCase() ? maxFee : null; + } catch { return null; } +} + +/** + * What arrives, in the destination's output units (Arc's native balance has 18 decimals): the amount less the whole + * fee. With forwarding, Circle executes the full maxFee (every live forwarded burn checked showed feeExecuted equal to + * maxFee), so this is what the wallet receives, not a floor under it. + */ +export function cctpAmountOut(amount: bigint, maxFee: bigint, outputDecimals: 6 | 18): bigint { + return (amount - maxFee) * 10n ** BigInt(outputDecimals - 6); +} + +type Log = { address: Address; data: Hex; topics: readonly Hex[]; transactionHash?: Hex | null }; +/** + * Whether a source-chain log is this transfer's burn: Circle's own event, from the pinned messenger, with every field + * the saved calldata set. Used to adopt a hash the wallet never returned, and for smart wallets that burn from an + * inner call. + */ +export function isCctpBurnLog(log: Log, transfer: Route & Pick & { depositData?: Hex }): boolean { + if (!transfer.depositData || log.address.toLowerCase() !== CCTP_TOKEN_MESSENGER.toLowerCase() || log.topics.length !== 4) return false; + const maxFee = cctpBurnFee(transfer.depositData, transfer); + const route = cctpRoute(transfer); + if (maxFee === null || !route) return false; + try { + const { eventName, args } = decodeEventLog({ abi: CCTP_BURN_EVENT, data: log.data, topics: log.topics as [Hex, ...Hex[]], strict: true }); + return eventName === "DepositForBurn" && args.burnToken.toLowerCase() === route.burnToken.toLowerCase() && args.depositor.toLowerCase() === transfer.address.toLowerCase() + && args.amount === BigInt(transfer.amount) && args.mintRecipient.toLowerCase() === pad(transfer.address).toLowerCase() && args.destinationDomain === route.destinationDomain + && args.destinationCaller === ZERO_WORD && args.maxFee === maxFee && args.minFinalityThreshold === route.finality && args.hookData.toLowerCase() === CCTP_FORWARD_HOOK; + } catch { return false; } +} diff --git a/app/src/lib/bridge/client.ts b/app/src/lib/bridge/client.ts index f43383d9..f6daf010 100644 --- a/app/src/lib/bridge/client.ts +++ b/app/src/lib/bridge/client.ts @@ -1,9 +1,14 @@ import { BaseError, decodeEventLog, decodeFunctionData, encodeFunctionData, InsufficientFundsError, isAddress, parseUnits, type Address, type Hex } from "viem"; import { friendlyError } from "../errors"; -import { BRIDGE_ASSETS, BRIDGE_DESTINATION_IDS, bridgeCurrency, bridgeFeePercent, defaultBridgeAsset, isBridgeAssetSupported, isBridgeChainId, isBridgeDestination, isDollarAsset, type BridgeAsset, type BridgeChainId, type BridgeQuote, type BridgeQuoteRejection, type BridgeQuoteRequest, type BridgeStatus, type BridgeStatusResponse } from "./types"; +import { CCTP_BURN_EVENT, CCTP_TOKEN_MESSENGER, cctpAmountOut, cctpBurnFee, cctpRoute, isCctpBurnLog } from "./cctp"; +import { BRIDGE_ASSETS, BRIDGE_DESTINATION_IDS, bridgeCurrency, bridgeFeePercent, defaultBridgeAsset, isBridgeAssetSupported, isBridgeChainId, isBridgeDestination, isDollarAsset, type BridgeAsset, type BridgeChainId, type BridgeProvider, 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 & { + /** Circle's CCTP, or Relay when omitted. A Circle record is tracked by its source transaction, not by `requestId`. */ + provider?: BridgeProvider; + /** Circle only: the source block before the wallet was asked, where a burn with no returned hash is looked for. */ + sourceBlock?: string; requestId: Hex; sourceHash?: Hex; destinationHashes: Hex[]; @@ -167,6 +172,7 @@ export function validateBridgeQuote(value: unknown, request: BridgeQuoteRequest, const tx = quote.transaction; const erc20 = !/^0x0{40}$/.test(inputCurrency.address); if (erc20 && BigInt(request.amount) === MAX_UINT) throw new Error("Unlimited token approvals are not supported. Enter an exact transfer amount."); + if (quote.provider !== undefined) return validateCctpQuote(quote, request); if (erc20 ? !quote.approval || quote.approval.token?.toLowerCase() !== inputCurrency.address.toLowerCase() || quote.approval.spender?.toLowerCase() !== RELAY_DEPOSITORY || quote.approval.amount !== request.amount : quote.approval !== undefined) { throw new Error("The approval did not match the exact token deposit on this network."); } @@ -183,6 +189,25 @@ export function validateBridgeQuote(value: unknown, request: BridgeQuoteRequest, return quote; } +/** + * A Circle quote is only ever the one burn `cctpBurnData` builds: the pinned messenger, this wallet as recipient, the + * exact amount, Circle's forwarding hook, and a fee under 5%. What arrives is the amount less that fee, exactly. + */ +function validateCctpQuote(quote: BridgeQuote, request: BridgeQuoteRequest): BridgeQuote { + const route = quote.provider === "cctp" ? cctpRoute(request) : null; + const tx = quote.transaction; + const maxFee = route && tx ? cctpBurnFee(tx.data, request) : null; + if (!route || maxFee === null || !quote.approval || quote.approval.token?.toLowerCase() !== route.burnToken.toLowerCase() || quote.approval.spender?.toLowerCase() !== CCTP_TOKEN_MESSENGER.toLowerCase() || quote.approval.amount !== request.amount) { + throw new Error("The approval did not match the exact Circle burn on this network."); + } + if (tx.chainId !== request.originChainId || typeof tx.to !== "string" || tx.to.toLowerCase() !== CCTP_TOKEN_MESSENGER.toLowerCase() || tx.value !== "0") { + throw new Error("The bridge transaction did not pass the deposit safety check."); + } + const out = cctpAmountOut(BigInt(request.amount), maxFee, route.outputDecimals).toString(); + if (quote.amountOut !== out || quote.minimumAmountOut !== out || parseUnits(quote.relayFee, 6) !== maxFee) throw new Error("The Circle quote did not match its own burn."); + return quote; +} + /** * The server states how long a quote stays valid; only this device's clock * decides when that runs out. Anchoring on the request time also absorbs the @@ -276,12 +301,14 @@ export function mergeBridgeStatus(transfer: TrackedBridgeTransfer, status: Bridg /** Recovery without a wallet-returned hash must bind the provider's hash on-chain. */ export function isMatchingSourceDeposit(transfer: TrackedBridgeTransfer, transaction: { from: Address; to: Address | null; input: Hex; value: bigint }): boolean { + if (transfer.provider === "cctp") return !!transfer.depositData && transaction.from.toLowerCase() === transfer.address.toLowerCase() && transaction.to?.toLowerCase() === CCTP_TOKEN_MESSENGER.toLowerCase() && transaction.value === 0n && transaction.input.toLowerCase() === transfer.depositData.toLowerCase(); return !!transfer.depositData && transaction.from.toLowerCase() === transfer.address.toLowerCase() && transaction.to?.toLowerCase() === RELAY_DEPOSITORY && transaction.value.toString() === (transfer.depositKind === "erc20" ? "0" : transfer.amount) && transaction.input.toLowerCase() === transfer.depositData.toLowerCase(); } /** Smart wallets may deposit through an internal call rather than the outer tx. */ export function hasMatchingDepositEvent(transfer: TrackedBridgeTransfer, receipt: { status: "success" | "reverted"; logs: readonly { address: Address; data: Hex; topics: readonly Hex[] }[] }): boolean { if (!transfer.depositData || receipt.status !== "success") return false; + if (transfer.provider === "cctp") return receipt.logs.some((log) => isCctpBurnLog(log, transfer)); try { const erc20 = transfer.depositKind === "erc20"; const orderId = erc20 ? decodeFunctionData({ abi: ERC20_DEPOSIT_ABI, data: transfer.depositData }).args[3] : decodeFunctionData({ abi: NATIVE_DEPOSIT_ABI, data: transfer.depositData }).args[1]; @@ -349,6 +376,8 @@ export type DepositDependencies = { readTransfer: (address: Address) => TrackedBridgeTransfer | null; saveTransfer: (transfer: TrackedBridgeTransfer) => void; removeTransfer: (address: Address) => void; + /** Circle only: the source network's current block, so a burn whose hash the wallet never returned can be found. */ + sourceBlock?: (quote: BridgeQuote) => Promise; send: (quote: BridgeQuote, gas: PreparedBridgeGas) => Promise; phase: (phase: "switching" | "confirming") => void; }; @@ -378,7 +407,12 @@ export async function submitBridgeDeposit(quote: BridgeQuote, deps: DepositDepen if (wallet.address?.toLowerCase() !== request.address.toLowerCase() || wallet.chainId !== request.originChainId) throw new Error("Your wallet account or network changed. Review a new quote."); const latest = deps.readTransfer(request.address); if (latest && !transferIsTerminal(latest)) throw new Error("Another transfer is now being tracked for this wallet. Check its status before continuing."); - const tracked: TrackedBridgeTransfer = { ...request, requestId: quote.requestId, destinationHashes: [], status: "uncertain", createdAt: deps.now(), depositData: quote.transaction.data, ...(quote.approval ? { depositKind: "erc20" as const } : {}) }; + let sourceBlock: string | undefined; + if (quote.provider === "cctp" && deps.sourceBlock) { + try { sourceBlock = (await deps.sourceBlock(quote)).toString(); } catch { /* recovery then relies on the wallet's hash */ } + checkRequest(); + } + const tracked: TrackedBridgeTransfer = { ...request, ...(quote.provider ? { provider: quote.provider } : {}), ...(sourceBlock ? { sourceBlock } : {}), requestId: quote.requestId, destinationHashes: [], status: "uncertain", createdAt: deps.now(), depositData: quote.transaction.data, ...(quote.approval ? { depositKind: "erc20" as const } : {}) }; // This write must succeed before invoking the wallet. A reload during its // prompt resumes read-only tracking by requestId, even without a tx hash. try { deps.saveTransfer(tracked); } catch { @@ -411,13 +445,16 @@ export async function submitBridgeDeposit(quote: BridgeQuote, deps: DepositDepen export function parseStoredTransfer(raw: string, address: Address): TrackedBridgeTransfer { const entry = JSON.parse(raw) as TrackedBridgeTransfer & { version?: unknown }; const erc20 = entry?.depositKind === "erc20"; + const cctp = entry?.provider === "cctp"; const assetsValid = isBridgeChainId(entry?.originChainId) && isBridgeChainId(entry?.destinationChainId) && isBridgeAssetSupported(entry.originChainId, entry.originAsset) && isBridgeAssetSupported(entry.destinationChainId, entry.destinationAsset); const legacyAssets = entry?.originAsset === undefined && entry?.destinationAsset === undefined; - const validVersion = (entry?.version === 1 && legacyAssets && entry.depositKind === undefined) || (entry?.version === 2 && legacyAssets && erc20 && entry.originChainId === 5042 && typeof entry.depositData === "string") || (entry?.version === 3 && assetsValid && typeof entry.depositData === "string" && (entry.depositKind === undefined || erc20) && erc20 === !/^0x0{40}$/.test(bridgeCurrency(entry.originChainId, entry.originAsset, "input").address)); - if (!validVersion || typeof entry.address !== "string" || entry.address.toLowerCase() !== address.toLowerCase() || !isAddress(entry.address) || !isBridgeChainId(entry.originChainId) || !isBridgeDestination(entry.destinationChainId) || entry.destinationChainId === entry.originChainId || !isHash(entry.requestId) || !isWei(entry.amount, true) || !Number.isFinite(entry.createdAt) || entry.createdAt <= 0 || (entry.sourceHash !== undefined && !isHash(entry.sourceHash)) || !Array.isArray(entry.destinationHashes) || entry.destinationHashes.length > 50 || !entry.destinationHashes.every(isHash) || (entry.status !== "uncertain" && !STATUSES.includes(entry.status)) || (entry.depositData !== undefined && !new RegExp(`^0x[0-9a-fA-F]{${erc20 ? 264 : 136}}$`).test(entry.depositData)) || (entry.failureReason !== undefined && entry.failureReason !== "source-reverted")) { + const validVersion = cctp ? entry.version === 4 && assetsValid && erc20 && typeof entry.depositData === "string" && cctpBurnFee(entry.depositData, entry) !== null && (entry.sourceBlock === undefined || (typeof entry.sourceBlock === "string" && /^\d{1,20}$/.test(entry.sourceBlock))) + : entry?.provider === undefined && entry?.sourceBlock === undefined && ((entry?.version === 1 && legacyAssets && entry.depositKind === undefined) || (entry?.version === 2 && legacyAssets && erc20 && entry.originChainId === 5042 && typeof entry.depositData === "string") || (entry?.version === 3 && assetsValid && typeof entry.depositData === "string" && (entry.depositKind === undefined || erc20) && erc20 === !/^0x0{40}$/.test(bridgeCurrency(entry.originChainId, entry.originAsset, "input").address))); + if (!validVersion || typeof entry.address !== "string" || entry.address.toLowerCase() !== address.toLowerCase() || !isAddress(entry.address) || !isBridgeChainId(entry.originChainId) || !isBridgeDestination(entry.destinationChainId) || entry.destinationChainId === entry.originChainId || !isHash(entry.requestId) || !isWei(entry.amount, true) || !Number.isFinite(entry.createdAt) || entry.createdAt <= 0 || (entry.sourceHash !== undefined && !isHash(entry.sourceHash)) || !Array.isArray(entry.destinationHashes) || entry.destinationHashes.length > 50 || !entry.destinationHashes.every(isHash) || (entry.status !== "uncertain" && !STATUSES.includes(entry.status)) || (entry.depositData !== undefined && !cctp && !new RegExp(`^0x[0-9a-fA-F]{${erc20 ? 264 : 136}}$`).test(entry.depositData)) || (entry.failureReason !== undefined && entry.failureReason !== "source-reverted")) { throw new Error("Saved bridge recovery data could not be read. Check your transfer on Relay before trying again."); } - if (erc20) { + if (cctp) { /* the burn was re-encoded and bound to this wallet, amount and route above */ } + else if (erc20) { try { const order = decodeFunctionData({ abi: ERC20_DEPOSIT_ABI, data: entry.depositData! }).args[3]; const canonical = encodeFunctionData({ abi: ERC20_DEPOSIT_ABI, functionName: "depositErc20", args: [entry.address, bridgeCurrency(entry.originChainId, entry.originAsset, "input").address, BigInt(entry.amount), order] }); @@ -430,10 +467,45 @@ export function parseStoredTransfer(raw: string, address: Address): TrackedBridg if (/^0x0+$/.test(order) || canonical.toLowerCase() !== entry.depositData.toLowerCase()) throw new Error("binding"); } catch { throw new Error("Saved native deposit details could not be verified. Check Relay before trying again."); } } - return { address: entry.address, originChainId: entry.originChainId, destinationChainId: entry.destinationChainId, ...(entry.originAsset ? { originAsset: entry.originAsset } : {}), ...(entry.destinationAsset ? { destinationAsset: entry.destinationAsset } : {}), amount: entry.amount, requestId: entry.requestId, sourceHash: entry.sourceHash, destinationHashes: entry.destinationHashes, status: entry.status, createdAt: entry.createdAt, ...(entry.depositData ? { depositData: entry.depositData } : {}), ...(erc20 ? { depositKind: "erc20" as const } : {}), ...(entry.failureReason ? { failureReason: entry.failureReason } : {}) }; + return { ...(cctp ? { provider: "cctp" as const, ...(entry.sourceBlock ? { sourceBlock: entry.sourceBlock } : {}) } : {}), address: entry.address, originChainId: entry.originChainId, destinationChainId: entry.destinationChainId, ...(entry.originAsset ? { originAsset: entry.originAsset } : {}), ...(entry.destinationAsset ? { destinationAsset: entry.destinationAsset } : {}), amount: entry.amount, requestId: entry.requestId, sourceHash: entry.sourceHash, destinationHashes: entry.destinationHashes, status: entry.status, createdAt: entry.createdAt, ...(entry.depositData ? { depositData: entry.depositData } : {}), ...(erc20 ? { depositKind: "erc20" as const } : {}), ...(entry.failureReason ? { failureReason: entry.failureReason } : {}) }; } export function serializeTransfer(transfer: TrackedBridgeTransfer): string { const explicitAssets = transfer.originAsset !== undefined || transfer.destinationAsset !== undefined; - return JSON.stringify({ ...transfer, ...(explicitAssets ? { originAsset: transfer.originAsset ?? defaultBridgeAsset(transfer.originChainId), destinationAsset: transfer.destinationAsset ?? defaultBridgeAsset(transfer.destinationChainId) } : {}), version: explicitAssets ? 3 : transfer.depositKind === "erc20" ? 2 : 1 }); + return JSON.stringify({ ...transfer, ...(explicitAssets ? { originAsset: transfer.originAsset ?? defaultBridgeAsset(transfer.originChainId), destinationAsset: transfer.destinationAsset ?? defaultBridgeAsset(transfer.destinationChainId) } : {}), version: transfer.provider === "cctp" ? 4 : explicitAssets ? 3 : transfer.depositKind === "erc20" ? 2 : 1 }); +} + +/** Where a tracked transfer's status is read: Relay by request ID, Circle by the burn's source transaction. */ +export function bridgeStatusUrl(transfer: TrackedBridgeTransfer): string | null { + if (transfer.provider !== "cctp") return `/api/bridge/status?requestId=${encodeURIComponent(transfer.requestId)}`; + if (!transfer.sourceHash) return null; + const params = new URLSearchParams({ provider: "cctp", origin: String(transfer.originChainId), destination: String(transfer.destinationChainId), hash: transfer.sourceHash, address: transfer.address.toLowerCase(), amount: transfer.amount }); + return `/api/bridge/status?${params}`; +} + +/** What a Circle record says before any burn is known: nothing seen yet. */ +export const CCTP_NOT_SEEN: BridgeStatusResponse = { status: "waiting", inTxHashes: [], txHashes: [] }; + +type LogReader = { + getBlockNumber: () => Promise; + getLogs: (args: { address: Address; event: (typeof CCTP_BURN_EVENT)[0]; args: { burnToken: Address; depositor: Address }; fromBlock: bigint; toBlock: bigint }) => Promise; +}; +/** Blocks searched after the saved source block: about 40 minutes on Arbitrum, hours elsewhere. */ +export const CCTP_SCAN_BLOCKS = 9_999n; + +/** + * The hash of this record's burn found on the source network, for a wallet that never returned one or replaced it + * (speed-up). Only Circle's own event from the pinned messenger, matching every saved field, counts; two matches are + * ambiguous and return null so tracking keeps waiting rather than guess. + */ +export async function findCctpBurn(reader: LogReader, transfer: TrackedBridgeTransfer): Promise { + const route = transfer.provider === "cctp" ? cctpRoute(transfer) : null; + if (!route || !transfer.sourceBlock) return null; + const from = BigInt(transfer.sourceBlock); + const latest = await reader.getBlockNumber(); + if (latest < from) return null; + const to = latest - from > CCTP_SCAN_BLOCKS ? from + CCTP_SCAN_BLOCKS : latest; + const logs = await reader.getLogs({ address: CCTP_TOKEN_MESSENGER, event: CCTP_BURN_EVENT[0], args: { burnToken: route.burnToken, depositor: transfer.address }, fromBlock: from, toBlock: to }); + const hashes = [...new Set(logs.filter((log) => isCctpBurnLog(log, transfer) && isHash(log.transactionHash)).map((log) => log.transactionHash!.toLowerCase()))]; + return hashes.length === 1 ? hashes[0] as Hex : null; } diff --git a/app/src/lib/bridge/history.test.ts b/app/src/lib/bridge/history.test.ts index 671dc53d..45d9006e 100644 --- a/app/src/lib/bridge/history.test.ts +++ b/app/src/lib/bridge/history.test.ts @@ -278,3 +278,14 @@ test("HTTP history boundary accepts one wallet address only and answers privatel assert.equal(limited.headers.get("retry-after"), "5"); }); }); + +test("a Circle row keeps its provider through the ledger, and no other provider is read back", () => { + const circle = historyFromTransfer(tracked({ provider: "cctp", sourceBlock: "10" }), "24984750")!; + assert.equal(circle.provider, "cctp"); + assert.equal(historyFromTransfer(tracked())!.provider, undefined); + assert.deepEqual(parseHistoryLedger(serializeHistoryLedger([circle])), [circle]); + assert.equal(parseHistoryEntry({ ...circle, provider: "relay" }), null); + assert.equal(parseHistoryEntry({ ...circle, provider: 1 }), null); + assert.equal(combineHistory(circle, row({ status: "success" })).provider, "cctp"); + assert.equal(combineHistory(row(), row({ status: "success" })).provider, undefined); +}); diff --git a/app/src/lib/bridge/history.ts b/app/src/lib/bridge/history.ts index 9f73a0ad..1ede24a2 100644 --- a/app/src/lib/bridge/history.ts +++ b/app/src/lib/bridge/history.ts @@ -12,6 +12,8 @@ import { bridgeCurrency, bridgeCurrencyAsset, bridgeTransferInputCurrency, isBri export type BridgeHistoryStatus = BridgeStatus | "uncertain"; export type BridgeHistoryEntry = { + /** Circle's CCTP, or Relay when omitted. A Circle row has no Relay page; its `requestId` is this app's own. */ + provider?: "cctp"; requestId: Hex; originChainId: BridgeChainId; destinationChainId: BridgeDestinationId; @@ -57,6 +59,7 @@ export function historyFromTransfer(transfer: TrackedBridgeTransfer, quotedOut: const output = bridgeCurrency(transfer.destinationChainId, transfer.destinationAsset, "output"); const out = quotedOut !== null && isUint(quotedOut) ? quotedOut : null; return { + ...(transfer.provider ? { provider: transfer.provider } : {}), requestId: transfer.requestId, originChainId: transfer.originChainId, destinationChainId: transfer.destinationChainId, originAsset: input.symbol, destinationAsset: output.symbol, amountIn: transfer.amount, inDecimals: input.decimals, amountOut: out, outDecimals: output.decimals, outKind: out === null ? null : "quoted", @@ -80,7 +83,9 @@ export function parseHistoryEntry(value: unknown): BridgeHistoryEntry | null { if (typeof v.createdAt !== "number" || !Number.isFinite(v.createdAt) || v.createdAt <= 0) return null; if (!(v.sourceHash === null || isHash(v.sourceHash)) || !Array.isArray(v.destinationHashes) || v.destinationHashes.length > 50 || !v.destinationHashes.every(isHash)) return null; if (!(v.recipient === null || (typeof v.recipient === "string" && ADDRESS.test(v.recipient)))) return null; + if (v.provider !== undefined && v.provider !== "cctp") return null; return { + ...(v.provider === "cctp" ? { provider: "cctp" as const } : {}), requestId: v.requestId, originChainId: v.originChainId, destinationChainId: v.destinationChainId, originAsset: v.originAsset, destinationAsset: v.destinationAsset, amountIn: v.amountIn, inDecimals: v.inDecimals as 6 | 18, amountOut: v.amountOut as string | null, outDecimals: v.outDecimals as 6 | 18, outKind: v.outKind as BridgeHistoryEntry["outKind"], status: v.status as BridgeHistoryStatus, reverted: v.reverted, createdAt: v.createdAt, sourceHash: v.sourceHash as Hex | null, @@ -100,7 +105,7 @@ export function combineHistory(prior: BridgeHistoryEntry, next: BridgeHistoryEnt const status = rank(prior.status) > rank(next.status) ? prior : next; const out = outRank(prior.outKind) > outRank(next.outKind) ? prior : next; return { - ...next, status: status.status, reverted: status.reverted, + ...prior, ...next, status: status.status, reverted: status.reverted, amountOut: out.amountOut, outKind: out.outKind, createdAt: Math.min(prior.createdAt, next.createdAt), sourceHash: next.sourceHash ?? prior.sourceHash, diff --git a/app/src/lib/bridge/read-json.ts b/app/src/lib/bridge/read-json.ts new file mode 100644 index 00000000..42964176 --- /dev/null +++ b/app/src/lib/bridge/read-json.ts @@ -0,0 +1,31 @@ +import "server-only"; +import { BridgeApiError } from "./validation"; + +/** Count streamed bytes, not Content-Length alone; cap reads even for chunked bodies. */ +export async function readBridgeJson(body: ReadableStream | null, maxBytes: number, timeoutMs = 8_000): Promise { + if (!body) throw new BridgeApiError("Invalid JSON body.", 400); + const reader = body.getReader(); + const decoder = new TextDecoder("utf-8", { fatal: true }); + let bytes = 0; + let text = ""; + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new BridgeApiError("The bridge request timed out. Please try again.", 504)), timeoutMs); }); + try { + while (true) { + const chunk = await Promise.race([reader.read(), timeout]); + if (chunk.done) break; + bytes += chunk.value.byteLength; + if (bytes > maxBytes) throw new BridgeApiError("The request is too large.", 413); + text += decoder.decode(chunk.value, { stream: true }); + } + text += decoder.decode(); + return JSON.parse(text); + } catch (error) { + void reader.cancel().catch(() => {}); + if (error instanceof BridgeApiError) throw error; + throw new BridgeApiError("Invalid JSON body.", 400); + } finally { + clearTimeout(timer); + reader.releaseLock(); + } +} diff --git a/app/src/lib/bridge/relay.ts b/app/src/lib/bridge/relay.ts index a1f370db..f7089778 100644 --- a/app/src/lib/bridge/relay.ts +++ b/app/src/lib/bridge/relay.ts @@ -2,41 +2,17 @@ import "server-only"; import { BridgeApiError, isRequestId, parseBridgeRequest, parseRelayStatus, validateRelayChains, validateRelayQuote } from "./validation"; import { bridgeCurrency, type BridgeQuote, type BridgeQuoteRequest, type BridgeStatusResponse } from "./types"; import { parseRelayHistory, type BridgeHistoryResponse } from "./history"; +import { readBridgeJson } from "./read-json"; +import { cctpRoute } from "./cctp"; +import { getCctpQuote } from "./cctp-server"; + +export { readBridgeJson }; const RELAY_API = "https://api.relay.link"; const UPSTREAM_TIMEOUT_MS = 12_000; export const BRIDGE_PRIVATE_HEADERS = { "Cache-Control": "private, no-store, max-age=0", "CDN-Cache-Control": "no-store" }; type Fetcher = (url: string, init: RequestInit) => Promise; -/** Count streamed bytes, not Content-Length alone; cap reads even for chunked bodies. */ -export async function readBridgeJson(body: ReadableStream | null, maxBytes: number, timeoutMs = 8_000): Promise { - if (!body) throw new BridgeApiError("Invalid JSON body.", 400); - const reader = body.getReader(); - const decoder = new TextDecoder("utf-8", { fatal: true }); - let bytes = 0; - let text = ""; - let timer: ReturnType | undefined; - const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new BridgeApiError("The bridge request timed out. Please try again.", 504)), timeoutMs); }); - try { - while (true) { - const chunk = await Promise.race([reader.read(), timeout]); - if (chunk.done) break; - bytes += chunk.value.byteLength; - if (bytes > maxBytes) throw new BridgeApiError("The request is too large.", 413); - text += decoder.decode(chunk.value, { stream: true }); - } - text += decoder.decode(); - return JSON.parse(text); - } catch (error) { - void reader.cancel().catch(() => {}); - if (error instanceof BridgeApiError) throw error; - throw new BridgeApiError("Invalid JSON body.", 400); - } finally { - clearTimeout(timer); - reader.releaseLock(); - } -} - async function relayJson(path: string, init: RequestInit, maxBytes: number, fetcher: Fetcher): Promise { try { const response = await fetcher(`${RELAY_API}${path}`, { @@ -70,8 +46,25 @@ function forgetChains(fetcher: Fetcher, value: Promise) { if (chainsCache.get(fetcher)?.value === value) chainsCache.delete(fetcher); } -export async function getBridgeQuote(request: BridgeQuoteRequest, fetcher: Fetcher = fetch, now: () => number = Date.now): Promise { +/** + * The better of Relay's and Circle's quotes. A USDC to USDC route between Circle networks also asks Circle; its quote + * wins only when it delivers strictly more. Circle failing, or declining the route, changes nothing: Relay's quote or + * error stands. `circle` reaches Circle and the source RPC; it is the real network only when Relay's fetcher is, so a + * test that fakes Relay never reaches Circle by accident. + */ +export async function getBridgeQuote(request: BridgeQuoteRequest, fetcher: Fetcher = fetch, now: () => number = Date.now, circle: Fetcher | null = fetcher === fetch ? fetch : null): Promise { const input = parseBridgeRequest(request); + if (!circle || !cctpRoute(input)) return getRelayQuote(input, fetcher, now); + const [relay, cctp] = await Promise.allSettled([getRelayQuote(input, fetcher, now), getCctpQuote(input, circle, now)]); + const viaCircle = cctp.status === "fulfilled" ? cctp.value : null; + if (relay.status === "rejected") { + if (viaCircle) return viaCircle; + throw relay.reason; + } + return viaCircle && BigInt(viaCircle.amountOut) > BigInt(relay.value.amountOut) ? viaCircle : relay.value; +} + +async function getRelayQuote(input: BridgeQuoteRequest, fetcher: Fetcher, now: () => number): Promise { const chainsPromise = relayChains(fetcher, now()); const [chains, quote] = await Promise.all([ chainsPromise, diff --git a/app/src/lib/bridge/rpc.ts b/app/src/lib/bridge/rpc.ts index 5d1c97e2..e5a45983 100644 --- a/app/src/lib/bridge/rpc.ts +++ b/app/src/lib/bridge/rpc.ts @@ -1,5 +1,6 @@ import "server-only"; -import { BRIDGE_CHAINS, BRIDGE_FUNDING_IDS } from "./types"; +import { CHAINS, isChainKey, rpcUrl } from "@/lib/chain"; +import { BRIDGE_CHAINS, BRIDGE_FUNDING_IDS, type BridgeChainId } from "./types"; /** * Upstreams for the bridge's funding networks behind the same-origin RPC proxy (api/rpc). A keyed URL from the @@ -18,3 +19,10 @@ export function bridgeFundingUpstream(key: string): string | null { if (id === undefined) return null; return process.env[FUNDING_RPC[id].env]?.trim() || FUNDING_RPC[id].fallback; } + +/** The server's own upstream for any bridge network: the launch chains' keyed RPC or public node, else a funding upstream. */ +export function bridgeRpcUpstream(chainId: BridgeChainId): string | null { + const key = BRIDGE_CHAINS[chainId].key; + if (isChainKey(key)) return rpcUrl(key) ?? CHAINS[key].rpcUrls.default.http[0] ?? null; + return bridgeFundingUpstream(key); +} diff --git a/app/src/lib/bridge/types.ts b/app/src/lib/bridge/types.ts index 44281080..ba058d59 100644 --- a/app/src/lib/bridge/types.ts +++ b/app/src/lib/bridge/types.ts @@ -114,7 +114,14 @@ export type BridgeQuoteRequest = { amount: string; // input currency base units, never native-gas units for ERC-20s }; +/** Who carries a transfer. Omitted means Relay (every record written before Circle was added). */ +export type BridgeProvider = "cctp"; +export const BRIDGE_PROVIDER_NAMES = { relay: "Relay", cctp: "Circle" } as const; +export const bridgeProviderName = (provider: BridgeProvider | undefined) => BRIDGE_PROVIDER_NAMES[provider ?? "relay"]; + export type BridgeQuote = BridgeQuoteRequest & { + /** Circle's CCTP (lib/bridge/cctp.ts), or Relay when omitted. */ + provider?: BridgeProvider; requestId: Hex; amountOut: string; minimumAmountOut: string; diff --git a/docs/bridge.md b/docs/bridge.md index be7add60..13aa6b3c 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -4,7 +4,7 @@ The header's **Bridge** action opens a focused funding panel without leaving the ## Integration -- Relay supplies quotes and transfer status. Openlaunch charges no application fee; Relay and source-network gas still cost money. +- Relay supplies quotes and transfer status. For USDC to USDC into Base or Arc, Circle CCTP is quoted alongside and used when it delivers more (see Circle CCTP below). Openlaunch charges no application fee; the provider fee and source-network gas still cost money. - `POST /api/bridge/quote` accepts `{ address, originChainId, destinationChainId, originAsset, destinationAsset, amount }`. Asset choices are allowlisted `ETH`, `USDC` or `USDG` for that chain, never arbitrary addresses. Omitted asset fields retain legacy defaults: Base/Robinhood ETH, Arc USDC. The integer amount uses 18 decimals for ETH inputs and **6 decimals for USDC and USDG inputs**. Outputs use 6 decimals for Base USDC and Robinhood USDG, 18 for ETH or Arc native USDC. `GET /api/bridge/status?requestId=…` returns normalized provider state and transaction hashes. - Base USDC is Circle's `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913`. Its ERC-20 balance and allowance are checked separately from native ETH gas. An approval and deposit both require an ETH reserve; a USDC balance cannot pay Base gas. The picker, fee labels, quote identity and recovery journal all retain the selected asset. - Requests use a fixed upstream, timeout and streamed body-size bounds, existing per-IP limits, and private/no-store caching. The API does not need a wallet signature. Relay quotes/status stay server-side. Arc balance, allowance, gas and receipt reads go through the same-origin `/api/rpc?chain=arc` proxy (upstream `ARC_RPC_URL`, else the official public node), so the CSP gains no third-party origin and script restrictions are unchanged. The funding networks read the same way through `/api/rpc?chain=ethereum|arbitrum|optimism` (upstreams `ETHEREUM_RPC_URL`, `ARBITRUM_RPC_URL`, `OPTIMISM_RPC_URL`, else publicnode for Ethereum and the official Arbitrum and Optimism nodes), with the same read-only allowlist and per-IP budget. Public Base and Arc nodes rate-limit a single quote's burst of reads; the proxy turns rate-limit and malformed replies into HTTP 429/502 so viem retries them, and production should point `BASE_RPC_URL` and `ARC_RPC_URL` at keyed providers. @@ -14,7 +14,7 @@ The header's **Bridge** action opens a focused funding panel without leaving the ## Transfer safety -Quotes request `explicitDeposit`, `includeProtocolData`, and an explicit `refundTo` matching the connected wallet. The server verifies the order hash, route-specific chain metadata, independently pinned depository/router, exact input/output currencies, recipient/depositor, refund destinations, minimum output, deadline, and canonical calldata. ETH sources accept only `depositNative`. USDC sources accept only an exact-amount approval to the pinned Relay depository and the four-argument `depositErc20(depositor, currency, amount, orderId)`. Unlimited approvals, generic router/multicall transactions, extra steps, output calls and application fees are rejected. An unavailable route fails closed rather than silently changing assets or providers. +Quotes request `explicitDeposit`, `includeProtocolData`, and an explicit `refundTo` matching the connected wallet. The server verifies the order hash, route-specific chain metadata, independently pinned depository/router, exact input/output currencies, recipient/depositor, refund destinations, minimum output, deadline, and canonical calldata. ETH sources accept only `depositNative`. USDC sources accept only an exact-amount approval to the pinned Relay depository and the four-argument `depositErc20(depositor, currency, amount, orderId)`. Unlimited approvals, generic router/multicall transactions, extra steps, output calls and application fees are rejected. An unavailable route fails closed rather than silently changing assets. The provider is chosen only between Relay and Circle on USDC to USDC routes, and the panel names it. 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. @@ -48,13 +48,40 @@ From `app/`, run `npm test`, `npm run lint`, `npm run typecheck`, and `npm run b ```powershell $env:RUN_BRIDGE_LIVE_TESTS='1' -npx tsx --conditions=react-server --tsconfig tsconfig.test.json --test src/lib/bridge/relay.live.test.ts +npx tsx --conditions=react-server --tsconfig tsconfig.test.json --test src/lib/bridge/relay.live.test.ts src/lib/bridge/cctp.live.test.ts ``` These tests never connect a wallet or submit a transaction. `/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. +### Circle CCTP (2026-11-10) + +USDC to USDC into Base or Arc is also quoted through Circle's Cross-Chain Transfer Protocol v2. For these routes both quotes are requested together and the panel offers whichever delivers more. Every other route is Relay alone, exactly as before. The panel names the provider in use: the Route row, the fee label, the approval notice, the footer and the transfer steps. + +- **What is sent.** One transaction, `depositForBurnWithHook` on Circle's TokenMessengerV2 `0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d` (the same address on Ethereum, Optimism, Arbitrum, Base and Arc). It burns the exact amount for the same wallet on the destination, with a zero destination caller, Fast finality (1000) and Circle's Forwarding Service hook (`cctp-forward`, version 0, no payload). Arc has no Fast Transfer as a source, so a burn from Arc asks for Standard (2000); Arc blocks are final in about half a second. Circle attests and mints on the destination itself: there is no second wallet step and no destination gas. Domains, read on-chain with `localDomain()`: Ethereum 0, Optimism 2, Arbitrum 3, Base 6, Arc 26. +- **Fee and amount.** The fee is Circle's published fee for the route at the burn's finality (`GET /v2/burn/USDC/fees/{src}/{dst}?forward=true`, in basis points, rounded up) plus the high tier of its forwarding fee, set as the burn's `maxFee`. With forwarding, Circle takes the whole `maxFee` (every live forwarded burn checked had `feeExecuted` equal to `maxFee`), so the quote shows one fixed fee and the exact amount received, with no slippage. The server refuses a rate above 1% or a forward above $5 before using them, and the client applies the same 5% ceiling as for Relay. +- **What Circle decides.** Only the fee, within those bounds, and progress. The server builds the calldata from pinned values (`lib/bridge/cctp.ts`), and the browser rebuilds it byte for byte from the wallet, amount and route before the wallet is asked (`validateCctpQuote` in `lib/bridge/client.ts`). Approval is the same exact-amount flow; Circle's messenger is the only spender allowed besides Relay's depository. +- **When Circle is not offered.** A route other than USDC to USDC into Base or Arc (Robinhood has a Circle domain, 35, but no live USDC route); more than 10,000,000 USDC, Circle's per-burn limit; Circle's global Fast Transfer allowance below the amount (Circle would fall back to Standard and still charge the Fast fee); a fee outside the bounds; or Circle or the source RPC failing. In each case Relay's quote, or its error, stands as before. +- **Status and recovery.** A Circle transfer is tracked by its source transaction. `GET /api/bridge/status?provider=cctp&origin=…&destination=…&hash=…&address=…&amount=…` reads Circle's message record and accepts it only when it names this wallet as sender and recipient, this amount, token, route, finality and hook. Not found yet is waiting. Attested with the forward complete is arrived, with the destination mint hash. A reported delay or a forward that is neither pending nor complete is delayed. The recovery record (version 4) keeps the burn calldata and the source block from before the wallet was asked. A burn whose hash the wallet never returned, or a sped-up replacement, is then found from the messenger's own `DepositForBurn` event: within 9,999 blocks, and only when exactly one burn matches. History rows carry the provider and show "Circle" where Relay rows link to Relay. +- **Requests.** Circle allows 40 requests a second per IP and blocks for five minutes past that. The server shares one budget of 600 a minute across all callers and reuses fees for 30 seconds, the Fast allowance for 15 and a status for 5. Circle needs no API key. +- **Cost.** Live on 2026-11-10, fees in USDC: Relay's quoted loss, then Circle's fee and forward. + + | Route | $10 | $100 | $1,000 | $10,000 | + |---|---|---|---|---| + | Base to Arc | 0.0226 / 0.0161 | 0.0406 / 0.0190 | 0.2206 / 0.0483 | 2.0207 / 0.3408 | + | Ethereum to Arc | 0.0226 / 0.0165 | 0.0406 / 0.0187 | 0.2206 / 0.0412 | 2.0206 / 0.2662 | + | Arbitrum to Arc | 0.0226 / 0.0166 | 0.0406 / 0.0197 | 0.2206 / 0.0512 | 2.0206 / 0.3662 | + | Optimism to Arc | 0.0226 / 0.0166 | 0.0406 / 0.0195 | 0.2206 / 0.0487 | 2.0206 / 0.3412 | + | Arc to Base | 0.0259 / 0.0542 | 0.0709 / 0.0542 | 0.5209 / 0.0542 | 5.0209 / 0.0542 | + | Ethereum to Base | 0.0219 / 0.0552 | 0.0309 / 0.0642 | 0.1209 / 0.1542 | 1.0209 / 1.0542 | + | Arbitrum to Base | 0.0219 / 0.0556 | 0.0309 / 0.0682 | 0.1209 / 0.1942 | 1.0209 / 1.4542 | + | Optimism to Base | 0.0219 / 0.0555 | 0.0309 / 0.0672 | 0.1209 / 0.1842 | 1.0209 / 1.3542 | + + Circle delivers more on every route into Arc and from Arc to Base above about $30. Relay delivers more from Ethereum, Arbitrum and Optimism into Base, where Circle's Fast fee is 1 to 1.4 basis points plus a $0.054 forward. Relay quoted about a second for most routes; Circle's estimate is 25 seconds (40 from Ethereum, 15 from Arc). The choice is by amount delivered alone, and the arrival estimate is shown on the quote. +- **Not built.** A manual mint (`receiveMessage` with Circle's attestation) when Circle's forward stalls: the transfer shows Delayed and tracking continues. Elysium, which has no Circle domain. Near the crossover amount a quote can move between providers, and the approval's spender with it, so an exact approval granted for one can be followed by a second approval step for the other. +- Checked with unit tests for the route rules, the calldata and every tampered field, the event match, the fee parser and its bounds, quote selection, the status mapping against Circle's real record, the stored record, the approval spender and the log search. The opt-in live test quotes all eight routes from Circle, matches a real forwarded Ethereum to Arc burn (`0x0095…ce01`) against this bridge's own calldata, and reads it from Circle as delivered with its mint hash. `/ui-review-bridge` has a "quote from: Circle" toggle for its quote, pending and arrived scenes, and a Circle row in the history board. No wallet signed anything; a small real Circle transfer is still needed before this is described as exercised. + ### Bridge, then buy (2026-11-10) A buyer on a token page who is short of what the buy pays with is offered the bridge, then the buy. These are two separate steps with two separate wallet prompts. An atomic bridge-and-buy is not built: a Relay destination call runs from Relay's contract, not the buyer's wallet, so it would need its own reviewed design.