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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 16 additions & 12 deletions app/src/components/bridge/BridgeDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { useRef, useState } from "react";
import Image from "next/image";
import { Dialog } from "@base-ui/react/dialog";
import { Select } from "@base-ui/react/select";
import { ArrowLeftRight, ArrowRight, ArrowUpRight, Check, ChevronDown, ChevronRight, CircleAlert, Clock3, LoaderCircle, Wallet, X } from "lucide-react";
import { 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_CHAINS, BRIDGE_CHAIN_IDS, bridgeCurrency, bridgeTransferInputCurrency, isBridgeAssetSupported, isBridgeChainId, type BridgeAsset, type BridgeChainId, type BridgeQuoteRejection } from "@/lib/bridge/types";
import { BRIDGE_ASSETS, BRIDGE_ASSET_NAMES, BRIDGE_CHAINS, BRIDGE_CHAIN_IDS, bridgeCurrency, bridgeTokenAsset, bridgeTransferInputCurrency, isBridgeAssetSupported, isBridgeChainId, type BridgeAsset, type BridgeChainId, type BridgeQuoteRejection } from "@/lib/bridge/types";
import { DISCARD_AFTER_MS } from "@/lib/bridge/client";
import { formatFeeWarningPercent } from "@/lib/bridge/fee-display";
import { shortAddr } from "@/lib/chainPublic";
Expand Down Expand Up @@ -36,6 +36,8 @@ function NetworkMark({ chain }: { chain: BridgeChainId }) {
}

function AssetMark({ asset, size = 24 }: { asset: BridgeAsset; size?: number }) {
// USDG has no bundled logo: it takes the plain dollar glyph the launch form gives it
if (asset === "USDG") return <CircleDollarSign size={size} strokeWidth={1.75} aria-hidden />;
return <Image src={asset === "USDC" ? "/brand/usdc.svg" : "/brand/ethereum.svg"} alt="" width={size} height={size} draggable={false} aria-hidden />;
}

Expand All @@ -56,7 +58,7 @@ function AssetSelect({ label, chain, asset, disabled, onChange }: { label: "Send
<Select.List className={styles.networkList}>
{choices.map((value) => <Select.Item key={value} value={value} label={value} className={styles.networkOption}>
<AssetMark asset={value} size={30} />
<span className={styles.networkCopy}><Select.ItemText className={styles.networkName}>{value}</Select.ItemText><span className={styles.gasCurrency}>{value === "ETH" ? "Ether · Gas token" : "USD Coin · Gas in ETH"}</span></span>
<span className={styles.networkCopy}><Select.ItemText className={styles.networkName}>{value}</Select.ItemText><span className={styles.gasCurrency}>{BRIDGE_ASSET_NAMES[value]} · {value === BRIDGE_CHAINS[chain].symbol ? "Gas token" : `Gas in ${BRIDGE_CHAINS[chain].symbol}`}</span></span>
<Select.ItemIndicator className={styles.networkCheck}><Check size={17} strokeWidth={2.5} aria-hidden /></Select.ItemIndicator>
</Select.Item>)}
</Select.List>
Expand Down Expand Up @@ -190,11 +192,11 @@ export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: ()
const erc20Input = inputCurrency.address !== zeroAddress;
const convertsAsset = inputCurrency.symbol !== outputCurrency.symbol;
const outputAmount = (value: string) => nativeAmount(formatUnits(BigInt(value), outputCurrency.decimals));
const label = b.approvalBusy ? "Confirm USDC approval…" : b.allowanceLoading ? "Checking USDC permission…" : b.quoteLoading ? "Getting your quote…"
const label = b.approvalBusy ? `Confirm ${inputCurrency.symbol} approval…` : b.allowanceLoading ? `Checking ${inputCurrency.symbol} permission…` : b.quoteLoading ? "Getting your quote…"
: b.phase === "switching" ? "Confirm network switch…"
: b.phase === "confirming" ? "Confirm in your wallet…"
: needsRefresh ? "Refresh quote"
: reviewing && b.approvalRequired ? `Approve ${nativeAmount(b.amount)} USDC`
: reviewing && b.approvalRequired ? `Approve ${nativeAmount(b.amount)} ${inputCurrency.symbol}`
: reviewing ? `Bridge to ${destination.name}` : b.quoteError ? "Retry quote" : !b.canQuote ? "Enter an amount" : "Get quote";
const error = (!b.quoteRejection && b.quoteError) || b.error || b.storageError || b.approvalError;

Expand All @@ -211,8 +213,8 @@ export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: ()
<NetworkSelect label="To" chain={b.destinationChainId} disabled={locked} onChange={b.setDestinationChainId} />
</div>
{convertsAsset ? <p className={styles.conversionNote}>{inputCurrency.symbol} <ArrowRight size={12} aria-hidden /> {outputCurrency.symbol}<span>Converted by Relay</span></p> : null}
{b.originChainId === 4663 || b.destinationChainId === 4663 ? <p className={styles.routeNotice}>USDC on Robinhood is unavailable: its routes do not pass our current safety checks. ETH remains available.</p> : null}
{erc20Input && b.approval?.chainId === b.originChainId && b.approval.status === "confirmed" && !b.quote ? <p className={styles.approvalNotice} role="status">USDC approval confirmed. Review a fresh quote before bridging. No funds have been bridged yet.</p> : null}
{b.originChainId === 4663 || b.destinationChainId === 4663 ? <p className={styles.routeNotice}>USDC on Robinhood is unavailable: its routes do not pass our current safety checks. ETH and USDG are available.</p> : null}
{erc20Input && b.approval?.chainId === b.originChainId && b.approval.status === "confirmed" && !b.quote ? <p className={styles.approvalNotice} role="status">{inputCurrency.symbol} approval confirmed. Review a fresh quote before bridging. No funds have been bridged yet.</p> : null}

<div className={styles.amountSection}>
<label htmlFor="bridge-amount" className={styles.smallLabel}>You send</label>
Expand All @@ -238,7 +240,7 @@ export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: ()
<div><dt>Estimated arrival</dt><dd><Clock3 size={12} aria-hidden />{Math.max(1, Math.ceil(b.quote.timeEstimate))} seconds</dd></div>
</dl>
<p className={styles.quoteNotice}>{needsRefresh ? "This quote expired. Refresh and review the new amounts." : "0.5% slippage limit. Arrival time and gas can change."}</p>
{b.approvalRequired ? <p className={styles.approvalNotice}>First, approve only {nativeAmount(b.amount)} USDC for Relay’s deposit contract. Then review a fresh quote and confirm the bridge separately. Approval alone does not move your funds and uses additional {origin.symbol} for gas.</p> : null}
{b.approvalRequired ? <p className={styles.approvalNotice}>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.</p> : null}
</div>
) : b.quoteRejection ? <FeeLimitNotice rejection={b.quoteRejection} inputSymbol={inputCurrency.symbol} gasSymbol={origin.symbol} /> : <div className={styles.previewNote} role="status" aria-live="polite">
{b.quoteLoading ? <LoaderCircle size={16} className={styles.spinner} aria-hidden /> : <ArrowRight size={16} aria-hidden />}
Expand All @@ -253,27 +255,29 @@ export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: ()
{b.address ? label : "Connect wallet"}
{!loading && b.address ? <ArrowRight size={17} aria-hidden /> : null}
</button>
<p id="bridge-gas-note" className={styles.disclaimer}>Keep some {origin.symbol} on {origin.name} for gas. {convertsAsset ? "Relay converts the asset at the quoted rate. " : ""}Bridging uses Relay, a third-party protocol, and carries risk. Openlaunch does not operate Relay, holds no funds in transit, and is not responsible for delays, refunds or losses. {erc20Input ? "An unspent USDC approval remains until used or revoked." : "No token approvals required."}</p>
<p id="bridge-gas-note" className={styles.disclaimer}>Keep some {origin.symbol} on {origin.name} for gas. {convertsAsset ? "Relay converts the asset at the quoted rate. " : ""}Bridging uses Relay, a third-party protocol, and carries risk. Openlaunch does not operate Relay, holds no funds in transit, and is not responsible for delays, refunds or losses. {erc20Input ? `An unspent ${inputCurrency.symbol} approval remains until used or revoked.` : "No token approvals required."}</p>
</form>
);
}

function ApprovalProgress({ bridge: b }: { bridge: Bridge }) {
const approval = b.approval!;
const approvalChain = BRIDGE_CHAINS[approval.chainId];
// the chain's one ERC-20: USDC on Base and Arc, USDG on Robinhood Chain
const approvalSymbol = bridgeTokenAsset(approval.chainId) ?? "token";
const uncertain = approval.status === "uncertain";
const health = b.approvalHealth;
const needsAttention = uncertain || (!!health && health.kind !== "waiting");
const [hash, setHash] = useState("");
const [verifying, setVerifying] = useState(false);
return <div className={styles.transfer}>
<div className={styles.transferRoute}><NetworkMark chain={approval.chainId} /><span>USDC approval on {approvalChain.name}</span></div>
<div className={styles.transferRoute}><NetworkMark chain={approval.chainId} /><span>{approvalSymbol} approval on {approvalChain.name}</span></div>
<div className={styles.statusHeading} role="status">
<span className={styles.statusIcon}>{needsAttention ? <CircleAlert size={26} aria-hidden /> : <LoaderCircle size={26} className={styles.spinner} aria-hidden />}</span>
<h3>{verifying ? "Verifying transaction" : b.approvalBusy ? "Check your wallet" : uncertain ? "Check your approval" : health?.title ?? "Approval submitted"}</h3>
<p>{verifying ? `Checking the transaction on ${approvalChain.name}. No wallet request will be made.` : b.approvalBusy ? "Approve the exact USDC amount in your wallet. This is not the bridge deposit." : uncertain ? "The wallet response was interrupted. Check your wallet’s activity and verify the transaction hash below. Do not approve again." : health?.detail ?? `Waiting for ${approvalChain.name} to confirm. You’ll review a fresh bridge quote next.`}</p>
<p>{verifying ? `Checking the transaction on ${approvalChain.name}. No wallet request will be made.` : b.approvalBusy ? `Approve the exact ${approvalSymbol} amount in your wallet. This is not the bridge deposit.` : uncertain ? "The wallet response was interrupted. Check your wallet’s activity and verify the transaction hash below. Do not approve again." : health?.detail ?? `Waiting for ${approvalChain.name} to confirm. You’ll review a fresh bridge quote next.`}</p>
</div>
<dl className={styles.transferDetails}><div><dt>Approval limit</dt><dd>{nativeAmount(formatUnits(BigInt(approval.amount), 6))} USDC</dd></div></dl>
<dl className={styles.transferDetails}><div><dt>Approval limit</dt><dd>{nativeAmount(formatUnits(BigInt(approval.amount), 6))} {approvalSymbol}</dd></div></dl>
{b.address ? <Recipient address={b.address} /> : null}
<p className={styles.approvalNotice}>No bridge deposit has been requested. An approval permits Relay’s deposit contract to use up to this amount; it does not bridge it.</p>
{b.approvalError || b.storageError ? <p className={styles.error} role="alert"><CircleAlert size={16} aria-hidden /><span>{b.approvalError || b.storageError}</span></p> : null}
Expand Down
11 changes: 9 additions & 2 deletions app/src/components/bridge/bridge-ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,11 @@ test("synthetic bridge review is development-only and cannot execute a transacti
});

test("USDC approval is visibly separate from the bridge and has chain-aware recovery", () => {
assert.match(panel, /Approve \$\{nativeAmount\(b.amount\)\} USDC/);
// the approval names the token being approved: USDC on Base and Arc, USDG on Robinhood Chain
assert.match(panel, /Approve \$\{nativeAmount\(b.amount\)\} \$\{inputCurrency\.symbol\}/);
assert.match(panel, /const approvalSymbol = bridgeTokenAsset\(approval\.chainId\) \?\? "token";/);
assert.match(panel, /<span>\{approvalSymbol\} approval on \{approvalChain\.name\}<\/span>/);
assert.doesNotMatch(panel, /USDC approval|exact USDC amount|\} USDC[<`"]/, "no approval copy assumes the token is USDC");
assert.match(panel, /Approval alone does not move your funds/);
assert.match(panel, /No bridge deposit has been requested/);
assert.match(panel, /Review a fresh quote before bridging/);
Expand All @@ -117,7 +121,10 @@ test("USDC selectors separate selected token units from native gas and disallow
assert.match(panel, /formatUnits\(b.balance, inputCurrency.decimals\)/);
assert.match(panel, /formatUnits\(BigInt\(value\), outputCurrency.decimals\)/);
assert.match(panel, /For gas: \{nativeAmount\(b.nativeBalance\)\} \{origin.symbol\}/);
assert.match(panel, /USDC on Robinhood is unavailable/);
assert.match(panel, /USDC on Robinhood is unavailable: its routes do not pass our current safety checks\. ETH and USDG are available\./);
// each asset is named in full, and says which token pays its chain's gas
assert.match(panel, /\{BRIDGE_ASSET_NAMES\[value\]\} · \{value === BRIDGE_CHAINS\[chain\]\.symbol \? "Gas token" : `Gas in \$\{BRIDGE_CHAINS\[chain\]\.symbol\}`\}/);
assert.match(panel, /if \(asset === "USDG"\) return <CircleDollarSign/, "USDG has no bundled logo, so it never borrows USDC's or Ether's");
assert.match(panel, /uses additional \{origin.symbol\} for gas/);
assert.match(panel, /const inputCurrency = bridgeTransferInputCurrency\(transfer\)/);
});
Expand Down
17 changes: 9 additions & 8 deletions app/src/components/bridge/useBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export function useBridge(open: boolean) {
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) => {
if (!stopped) setAllowanceResult({ key: quote.requestId, value: null, error: messageOf(error, "Could not read USDC allowance. Request a new quote.") });
if (!stopped) setAllowanceResult({ key: quote.requestId, value: null, error: messageOf(error, "Could not read the token allowance. Request a new quote.") });
});
return () => { stopped = true; };
}, [quote, config]);
Expand Down Expand Up @@ -406,11 +406,12 @@ export function useBridge(open: boolean) {
reviewed.originChainId === 8453 ? estimateTotalFee(pub, call) : Promise.resolve(0n),
]);
if (chainId !== reviewed.originChainId) throw new Error("The source RPC reported a different network.");
if (selectedBalance < BigInt(owner.amount)) throw new Error("Not enough USDC for this approval amount. Reduce the amount and request a new quote.");
if (allowance >= BigInt(owner.amount)) throw new Error("USDC allowance is already sufficient. Request a new quote to review the deposit.");
const tokenSymbol = bridgeCurrency(reviewed.originChainId, reviewed.originAsset, "input").symbol;
if (selectedBalance < BigInt(owner.amount)) throw new Error(`Not enough ${tokenSymbol} for this approval amount. Reduce the amount and request a new quote.`);
if (allowance >= BigInt(owner.amount)) throw new Error(`${tokenSymbol} allowance is already sufficient. Request a new quote to review the deposit.`);
if (fees.maxFeePerGas === undefined || fees.maxPriorityFeePerGas === undefined) throw new Error("Could not estimate approval fees. Request a new quote.");
// Arc's token shares its gas balance; Base USDC has a separate ETH
// gas balance. Reserve approval and quoted deposit costs in native units.
// Arc's token shares its gas balance; Base USDC and Robinhood USDG have a
// separate ETH gas balance. Reserve approval and quoted deposit costs in native units.
const budget = bridgeGasBudget(nativeSourceAmount(reviewed), balance, estimate, fees.maxFeePerGas, parseEther(reviewed.sourceGas) + additional, BRIDGE_CHAINS[reviewed.originChainId].symbol);
return { gas: budget.gas, maxFeePerGas: fees.maxFeePerGas, maxPriorityFeePerGas: fees.maxPriorityFeePerGas };
},
Expand All @@ -428,7 +429,7 @@ export function useBridge(open: boolean) {
if (result.kind === "uncertain") setApprovalIssue({ key: reviewed.address.toLowerCase(), message: "The wallet did not return an approval hash. Check this approval before trying again; no bridge deposit was requested." });
});
} catch (error) {
setApprovalIssue({ key: reviewed.address.toLowerCase(), message: messageOf(error, "Could not prepare USDC approval. Request a new quote.", BRIDGE_CHAINS[reviewed.originChainId].symbol) });
setApprovalIssue({ key: reviewed.address.toLowerCase(), message: messageOf(error, "Could not prepare the approval. Request a new quote.", BRIDGE_CHAINS[reviewed.originChainId].symbol) });
} finally {
actionLock.current = false;
setApprovalSending(false);
Expand Down Expand Up @@ -517,8 +518,8 @@ export function useBridge(open: boolean) {
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.");
if (q.approval && (allowance === null || allowance < BigInt(q.amount))) throw new Error("USDC allowance is no longer sufficient. Request a new quote and approve the exact amount.");
if (q.approval && (selectedBalance === null || selectedBalance < BigInt(q.amount))) throw new Error("Not enough USDC for this deposit. Reduce the amount and request a new quote.");
if (q.approval && (allowance === null || allowance < BigInt(q.amount))) throw new Error(`${bridgeCurrency(q.originChainId, q.originAsset, "input").symbol} allowance is no longer sufficient. Request a new quote and approve the exact amount.`);
if (q.approval && (selectedBalance === null || selectedBalance < BigInt(q.amount))) throw new Error(`Not enough ${bridgeCurrency(q.originChainId, q.originAsset, "input").symbol} for this deposit. Reduce the amount and request a new quote.`);
if (fees.maxFeePerGas === undefined || fees.maxPriorityFeePerGas === undefined) throw new Error("Could not estimate network fees. Try again.");
const budget = bridgeGasBudget(nativeSourceAmount(q), balance, estimate, fees.maxFeePerGas, additional, BRIDGE_CHAINS[q.originChainId].symbol);
return { gas: budget.gas, maxFeePerGas: fees.maxFeePerGas, maxPriorityFeePerGas: fees.maxPriorityFeePerGas };
Expand Down
Loading
Loading