@@ -292,6 +295,7 @@ export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: ()
{walletFunding !== null ? b.setOriginChainId(walletFunding)}>Your wallet is on {BRIDGE_CHAINS[walletFunding].name}. Send from {BRIDGE_CHAINS[walletFunding].name} : null}
{convertsAsset ? {inputCurrency.symbol} {outputCurrency.symbol}Converted by Relay
: null}
{b.originChainId === 4663 || b.destinationChainId === 4663 ? USDC on Robinhood is unavailable: its routes do not pass our current safety checks. ETH and USDG are available.
: null}
+ {b.destinationChainId === 99901 && b.destinationAsset !== "HYPE" ? b.setDestinationAsset("HYPE")}>Gas on Elysium is paid in HYPE. With none there yet, receive HYPE instead : null}
{erc20Input && b.approval?.chainId === b.originChainId && b.approval.status === "confirmed" && !b.quote ? {inputCurrency.symbol} approval confirmed. Review a fresh quote before bridging. No funds have been bridged yet.
: null}
diff --git a/app/src/components/bridge/BridgeToBuy.tsx b/app/src/components/bridge/BridgeToBuy.tsx
index d919fff7..8ab9fef4 100644
--- a/app/src/components/bridge/BridgeToBuy.tsx
+++ b/app/src/components/bridge/BridgeToBuy.tsx
@@ -110,7 +110,7 @@ function Follow({ destination, asset, symbol, pay, short, shortBy, ethUsd, onArr
const fallbackOrigin: BridgeChainId = destination === 8453 ? 1 : 8453;
const link: BridgeLink = suggestion
? { originChainId: suggestion.chainId, destinationChainId: destination, originAsset: suggestion.asset, destinationAsset: asset }
- : { originChainId: fallbackOrigin, destinationChainId: destination, ...(isBridgeAssetSupported(fallbackOrigin, asset) ? { originAsset: asset } : {}), destinationAsset: asset };
+ : { originChainId: fallbackOrigin, destinationChainId: destination, ...(isBridgeAssetSupported(fallbackOrigin, asset, "input") ? { originAsset: asset } : {}), destinationAsset: asset };
const dismiss = () => {
if (!row) return;
const next = [...dismissed, row.requestId].slice(-20);
diff --git a/app/src/components/bridge/bridge-ui.test.ts b/app/src/components/bridge/bridge-ui.test.ts
index f726c478..5f22c7e5 100644
--- a/app/src/components/bridge/bridge-ui.test.ts
+++ b/app/src/components/bridge/bridge-ui.test.ts
@@ -181,13 +181,19 @@ test("USDC approval is visibly separate from the bridge and has chain-aware reco
test("USDC selectors separate selected token units from native gas and disallow unsafe Robinhood routes", () => {
assert.match(panel, /AssetSelect label="Send"[^>]+onChange=\{b.setOriginAsset\}/);
assert.match(panel, /AssetSelect label="Receive"[^>]+onChange=\{b.setDestinationAsset\}/);
- assert.match(panel, /BRIDGE_ASSETS\[chain\]/);
- assert.match(panel, /isBridgeAssetSupported\(chain, value\)/);
+ // each picker lists its own side: Elysium's Receive offers HYPE, its Send never does
+ assert.ok(panel.includes(`const side = label === "Send" ? "input" : "output";`));
+ assert.match(panel, /bridgeAssets\(chain, side\)/);
+ assert.match(panel, /isBridgeAssetSupported\(chain, value, side\)/);
+ assert.doesNotMatch(panel, /BRIDGE_ASSETS\b/);
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: its routes do not pass our current safety checks\. ETH and USDG are available\./);
assert.match(panel, /chain === 99901 \? USDC only · Gas in HYPE<\/span>/);
+ // into Elysium, a one-tap switch to HYPE for a wallet with no gas there
+ assert.ok(panel.includes(`{b.destinationChainId === 99901 && b.destinationAsset !== "HYPE" ? b.setDestinationAsset("HYPE")}>Gas on Elysium is paid in HYPE. With none there yet, receive HYPE instead : null}`));
+ assert.match(panel, /if \(asset === "HYPE"\) return {
+ const toElysium = { originChainId: 8453, destinationChainId: 99901, originAsset: "USDC", destinationAsset: "USDC", amount: "25" } as const;
+ const hype = changeBridgeRoute(toElysium, { side: "destination-asset", asset: "HYPE" });
+ assert.deepEqual(hype, { ...toElysium, destinationAsset: "HYPE" }, "the amount stays: the source asset did not change");
+ // reversing would send HYPE, so Elysium sends its USDC instead: still USDC, so the amount stands
+ assert.deepEqual(changeBridgeRoute(hype, { side: "reverse" }), { originChainId: 99901, destinationChainId: 8453, originAsset: "USDC", destinationAsset: "USDC", amount: "25" });
+ assert.deepEqual(changeBridgeRoute(hype, { side: "destination", chainId: 8453 }), { originChainId: 99901, destinationChainId: 8453, originAsset: "USDC", destinationAsset: "USDC", amount: "25" });
+ // from Ether, the source becomes USDC, so the amount clears
+ const fromEther = { ...hype, originAsset: "ETH", amount: "0.01" } as const;
+ assert.deepEqual(changeBridgeRoute(fromEther, { side: "reverse" }), { originChainId: 99901, destinationChainId: 8453, originAsset: "USDC", destinationAsset: "ETH", amount: "" });
+ // another destination has no HYPE: it takes that chain's default
+ assert.deepEqual(changeBridgeRoute(hype, { side: "destination", chainId: 4663 }), { ...hype, destinationChainId: 4663, destinationAsset: "ETH" });
+ assert.deepEqual(changeBridgeRoute(hype, { side: "origin", chainId: 42161 }), { ...hype, originChainId: 42161 });
+ const fromElysium = { originChainId: 99901, destinationChainId: 8453, originAsset: "USDC", destinationAsset: "ETH", amount: "25" } as const;
+ assert.deepEqual(changeBridgeRoute(fromElysium, { side: "origin-asset", asset: "HYPE" }), fromElysium, "HYPE is never sent");
+ assert.deepEqual(changeBridgeRoute(toElysium, { side: "destination-asset", asset: "ETH" }), toElysium);
+ assert.deepEqual(changeBridgeRoute({ ...toElysium, destinationChainId: 4663, destinationAsset: "ETH" }, { side: "destination-asset", asset: "HYPE" }), { ...toElysium, destinationChainId: 4663, destinationAsset: "ETH" });
+ assert.equal(bridgeRequest(ADDRESS, 8453, "25", 99901, "USDC", "HYPE").destinationAsset, "HYPE");
+ assert.equal(bridgeRequest(ADDRESS, 99901, "25", 8453, "HYPE", "ETH"), null);
+ assert.equal(bridgeRequest(ADDRESS, 8453, "25", 4663, "USDC", "HYPE"), null);
+});
+
test("Robinhood USDG is a six-decimal token of its own, never USDC and never Ether", () => {
const usdg = bridgeRequest(ADDRESS, 4663, "25.000001", 8453, "USDG", "USDC");
assert.equal(usdg?.amount, "25000001");
diff --git a/app/src/lib/bridge/client.ts b/app/src/lib/bridge/client.ts
index f6daf010..f77bfecd 100644
--- a/app/src/lib/bridge/client.ts
+++ b/app/src/lib/bridge/client.ts
@@ -1,7 +1,7 @@
import { BaseError, decodeEventLog, decodeFunctionData, encodeFunctionData, InsufficientFundsError, isAddress, parseUnits, type Address, type Hex } from "viem";
import { friendlyError } from "../errors";
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";
+import { BRIDGE_DESTINATION_IDS, bridgeAssets, 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 & {
@@ -52,7 +52,7 @@ export function parseBridgeAmount(value: string, decimals: 6 | 18 = 18): bigint
}
export function bridgeRequest(address: Address | undefined, originChainId: BridgeChainId, amount: string, destinationChainId: BridgeChainId, originAsset?: BridgeAsset, destinationAsset?: BridgeAsset): BridgeQuoteRequest | null {
- if (!address || !isAddress(address) || !isBridgeChainId(originChainId) || !isBridgeDestination(destinationChainId) || originChainId === destinationChainId || !isBridgeAssetSupported(originChainId, originAsset ?? defaultBridgeAsset(originChainId)) || !isBridgeAssetSupported(destinationChainId, destinationAsset ?? defaultBridgeAsset(destinationChainId))) return null;
+ if (!address || !isAddress(address) || !isBridgeChainId(originChainId) || !isBridgeDestination(destinationChainId) || originChainId === destinationChainId || !isBridgeAssetSupported(originChainId, originAsset ?? defaultBridgeAsset(originChainId), "input") || !isBridgeAssetSupported(destinationChainId, destinationAsset ?? defaultBridgeAsset(destinationChainId), "output")) return null;
const units = parseBridgeAmount(amount, bridgeCurrency(originChainId, originAsset, "input").decimals);
return units === null ? null : { address, originChainId, destinationChainId, amount: units.toString(), ...(originAsset ? { originAsset } : {}), ...(destinationAsset ? { destinationAsset } : {}) };
}
@@ -78,7 +78,7 @@ export function changeBridgeRoute(current: BridgeRouteInputs, change: BridgeRout
[originAsset, destinationAsset] = [destinationAsset, originAsset];
} else if (change.side === "origin-asset" || change.side === "destination-asset") {
const chainId = change.side === "origin-asset" ? originChainId : destinationChainId;
- if (!isBridgeAssetSupported(chainId, change.asset)) return current;
+ if (!isBridgeAssetSupported(chainId, change.asset, change.side === "origin-asset" ? "input" : "output")) return current;
if (change.side === "origin-asset") originAsset = change.asset;
else destinationAsset = change.asset;
} else if ("chainId" in change) {
@@ -97,12 +97,12 @@ export function changeBridgeRoute(current: BridgeRouteInputs, change: BridgeRout
destinationChainId = change.chainId;
}
}
- // An asset the new chain lacks falls back to that chain's default, except that a dollar stays a dollar: USDC on
- // Base or Arc becomes USDG on Robinhood Chain, and back.
- const kept = (chainId: BridgeChainId, asset: BridgeAsset): BridgeAsset => isBridgeAssetSupported(chainId, asset) ? asset
- : (isDollarAsset(asset) ? BRIDGE_ASSETS[chainId].find(isDollarAsset) : undefined) ?? defaultBridgeAsset(chainId);
- originAsset = kept(originChainId, originAsset);
- destinationAsset = kept(destinationChainId, destinationAsset);
+ // An asset the new chain lacks on that side falls back to the chain's default, except that a dollar stays a dollar:
+ // USDC on Base or Arc becomes USDG on Robinhood Chain, and back. HYPE, which Elysium only receives, cannot be sent.
+ const kept = (chainId: BridgeChainId, asset: BridgeAsset, side: "input" | "output"): BridgeAsset => isBridgeAssetSupported(chainId, asset, side) ? asset
+ : (isDollarAsset(asset) ? bridgeAssets(chainId, side).find(isDollarAsset) : undefined) ?? defaultBridgeAsset(chainId);
+ originAsset = kept(originChainId, originAsset, "input");
+ destinationAsset = kept(destinationChainId, destinationAsset, "output");
const amount = previousOriginAsset === originAsset ? current.amount : "";
return { originChainId, destinationChainId, amount, ...(explicitAssets ? { originAsset, destinationAsset } : {}) };
}
@@ -133,7 +133,7 @@ export function parseBridgeQuoteRejection(value: unknown, request: BridgeQuoteRe
const rejected = value as BridgeQuoteRejection;
const allowed = ["address", "originChainId", "destinationChainId", "originAsset", "destinationAsset", "amount", "reason", "relayFee", "relayFeePercent", "sourceGas", "totalImpactPercent"];
if (Object.keys(value).some((key) => !allowed.includes(key)) || !isAddress(rejected.address ?? "") || !isBridgeChainId(rejected.originChainId) || !isBridgeDestination(rejected.destinationChainId) || rejected.originChainId === rejected.destinationChainId || !isWei(rejected.amount, true)) return null;
- if ((rejected.originAsset !== undefined && !isBridgeAssetSupported(rejected.originChainId, rejected.originAsset)) || (rejected.destinationAsset !== undefined && !isBridgeAssetSupported(rejected.destinationChainId, rejected.destinationAsset)) || bridgeRequestKey(rejected) !== bridgeRequestKey(request)) return null;
+ if ((rejected.originAsset !== undefined && !isBridgeAssetSupported(rejected.originChainId, rejected.originAsset, "input")) || (rejected.destinationAsset !== undefined && !isBridgeAssetSupported(rejected.destinationChainId, rejected.destinationAsset, "output")) || bridgeRequestKey(rejected) !== bridgeRequestKey(request)) return null;
const decimals = bridgeCurrency(rejected.originChainId, rejected.originAsset, "input").decimals;
for (const [fee, precision] of [[rejected.relayFee, decimals], [rejected.sourceGas, 18]] as const) {
if (typeof fee !== "string" || fee.length > 100 || !new RegExp(`^\\d+(?:\\.\\d{1,${precision}})?$`).test(fee) || parseUnits(fee, precision) > MAX_UINT) return null;
@@ -151,7 +151,7 @@ export function parseBridgeQuoteRejection(value: unknown, request: BridgeQuoteRe
export function validateBridgeQuote(value: unknown, request: BridgeQuoteRequest, now: number): BridgeQuote {
if (!value || typeof value !== "object") throw new Error("The bridge returned an invalid quote. Request a new quote.");
const quote = value as BridgeQuote;
- if (!isAddress(quote.address ?? "") || !isBridgeChainId(quote.originChainId) || !isBridgeDestination(quote.destinationChainId) || !isBridgeAssetSupported(quote.originChainId, quote.originAsset ?? defaultBridgeAsset(quote.originChainId)) || !isBridgeAssetSupported(quote.destinationChainId, quote.destinationAsset ?? defaultBridgeAsset(quote.destinationChainId)) || bridgeRequestKey(quote) !== bridgeRequestKey(request) || quote.destinationChainId === quote.originChainId) {
+ if (!isAddress(quote.address ?? "") || !isBridgeChainId(quote.originChainId) || !isBridgeDestination(quote.destinationChainId) || !isBridgeAssetSupported(quote.originChainId, quote.originAsset ?? defaultBridgeAsset(quote.originChainId), "input") || !isBridgeAssetSupported(quote.destinationChainId, quote.destinationAsset ?? defaultBridgeAsset(quote.destinationChainId), "output") || bridgeRequestKey(quote) !== bridgeRequestKey(request) || quote.destinationChainId === quote.originChainId) {
throw new Error("The quote no longer matches this wallet, amount, or route. Review a new quote.");
}
if (!isHash(quote.requestId) || !isWei(quote.amount, true) || !isWei(quote.amountOut, true) || !isWei(quote.minimumAmountOut, true) || BigInt(quote.minimumAmountOut) > BigInt(quote.amountOut)) {
@@ -446,7 +446,7 @@ export function parseStoredTransfer(raw: string, address: Address): TrackedBridg
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 assetsValid = isBridgeChainId(entry?.originChainId) && isBridgeChainId(entry?.destinationChainId) && isBridgeAssetSupported(entry.originChainId, entry.originAsset, "input") && isBridgeAssetSupported(entry.destinationChainId, entry.destinationAsset, "output");
const legacyAssets = entry?.originAsset === undefined && entry?.destinationAsset === undefined;
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)));
diff --git a/app/src/lib/bridge/fund.test.ts b/app/src/lib/bridge/fund.test.ts
index 69ca195d..7a1bad17 100644
--- a/app/src/lib/bridge/fund.test.ts
+++ b/app/src/lib/bridge/fund.test.ts
@@ -3,7 +3,7 @@ import test from "node:test";
import type { Address, Hex } from "viem";
import { FUND_FOLLOW_MS, bridgeAssetForPay, followedBridge, formatFundAmount, fundStep, pickBridgeSource, readBridgeHoldings, type BridgeHolding } from "./fund.ts";
import type { BridgeHistoryEntry } from "./history.ts";
-import { ARBITRUM_USDC, ARC_USDC, BASE_USDC, BRIDGE_ASSETS, BRIDGE_CHAIN_IDS, ELYSIUM_USDC, ETHEREUM_USDC, OPTIMISM_USDC, ROBINHOOD_USDG, type BridgeChainId } from "./types.ts";
+import { ARBITRUM_USDC, ARC_USDC, BASE_USDC, BRIDGE_CHAIN_IDS, BRIDGE_SEND_ASSETS, ELYSIUM_USDC, ETHEREUM_USDC, OPTIMISM_USDC, ROBINHOOD_USDG, type BridgeChainId } from "./types.ts";
const WALLET = "0x1111111111111111111111111111111111111111" as Address;
const NATIVE = "0x0000000000000000000000000000000000000000" as Address;
@@ -21,7 +21,7 @@ test("a buy can be bridged for only when it pays with something the bridge deliv
assert.equal(bridgeAssetForPay(4663, { address: BASE_USDC, native: false }), null, "Base's USDC address means nothing on Robinhood");
assert.equal(bridgeAssetForPay(8453, { address: "0x4200000000000000000000000000000000000006", native: false }), null, "WETH is not a bridge asset");
assert.equal(bridgeAssetForPay(8453, { address: "0x9999999999999999999999999999999999999999", native: false }), null, "a pair token or stock is not either");
- assert.equal(bridgeAssetForPay(99901, { address: NATIVE, native: true }), null, "Elysium's HYPE is not bridged");
+ assert.equal(bridgeAssetForPay(99901, { address: NATIVE, native: true }), "HYPE", "HYPE arrives on Elysium, so a HYPE-priced buy can be funded");
assert.equal(bridgeAssetForPay(99901, { address: ELYSIUM_USDC, native: false }), "USDC");
assert.equal(bridgeAssetForPay(99901, { address: BASE_USDC, native: false }), null);
});
@@ -33,8 +33,8 @@ test("holdings are read on every other network, each read standing alone", async
token: async (chainId, token) => { asked.push(`${chainId}:${token}`); return chainId === 1 ? (-1n as bigint) : 5n; },
}, WALLET, 4663);
assert.ok(!asked.some((call) => call.startsWith("4663:")), "never the destination itself");
- // every bridge asset, plus HYPE on Elysium: the gas its USDC needs to be sent, and not a bridge asset itself
- const expected = BRIDGE_CHAIN_IDS.filter((id) => id !== 4663).reduce((sum, id) => sum + BRIDGE_ASSETS[id].length, 0) + 1;
+ // every asset the bridge can send, plus HYPE on Elysium: the gas its USDC needs to be sent, which can arrive but never leave
+ const expected = BRIDGE_CHAIN_IDS.filter((id) => id !== 4663).reduce((sum, id) => sum + BRIDGE_SEND_ASSETS[id].length, 0) + 1;
assert.equal(asked.length, expected);
assert.ok(asked.includes("99901:native"));
for (const token of [BASE_USDC, ARC_USDC, ELYSIUM_USDC, ETHEREUM_USDC, ARBITRUM_USDC, OPTIMISM_USDC]) assert.ok(asked.some((call) => call.endsWith(token)), token);
diff --git a/app/src/lib/bridge/fund.ts b/app/src/lib/bridge/fund.ts
index 0952a868..30108e66 100644
--- a/app/src/lib/bridge/fund.ts
+++ b/app/src/lib/bridge/fund.ts
@@ -1,6 +1,6 @@
import { formatUnits, type Address } from "viem";
import type { BridgeHistoryEntry } from "./history";
-import { BRIDGE_ASSETS, BRIDGE_CHAIN_IDS, bridgeCurrency, bridgeCurrencyAsset, bridgesNativeCurrency, isDollarAsset, type BridgeAsset, type BridgeChainId, type BridgeDestinationId } from "./types";
+import { BRIDGE_CHAIN_IDS, BRIDGE_RECEIVE_ASSETS, BRIDGE_SEND_ASSETS, bridgeCurrency, bridgeCurrencyAsset, bridgesNativeCurrency, isDollarAsset, type BridgeAsset, type BridgeChainId, type BridgeDestinationId } from "./types";
/**
* Bridge, then buy: the token page's way to fund a purchase from another network. Two separate steps by design (a
@@ -9,19 +9,16 @@ import { BRIDGE_ASSETS, BRIDGE_CHAIN_IDS, bridgeCurrency, bridgeCurrencyAsset, b
* which of those to suggest, and which recent transfer the trade panel should follow.
*/
-/**
- * The bridge asset a buy pays with on `chainId`, or null when the bridge cannot deliver it (TWIG, GITLAWB, pairs, stocks,
- * and Elysium's HYPE).
- */
+/** The bridge asset a buy pays with on `chainId`, or null when the bridge cannot deliver it (TWIG, GITLAWB, pairs, stocks). */
export function bridgeAssetForPay(chainId: BridgeDestinationId, pay: { address: Address; native: boolean }): BridgeAsset | null {
// the native asset is whichever bridge asset arrives as the native balance: ETH on Base and Robinhood, USDC on Arc,
- // and nothing on Elysium, where HYPE is not bridged
- if (pay.native) return BRIDGE_ASSETS[chainId].find((asset) => /^0x0{40}$/.test(bridgeCurrency(chainId, asset, "output").address)) ?? null;
+ // HYPE on Elysium
+ if (pay.native) return BRIDGE_RECEIVE_ASSETS[chainId].find((asset) => /^0x0{40}$/.test(bridgeCurrency(chainId, asset, "output").address)) ?? null;
return bridgeCurrencyAsset(chainId, pay.address)?.asset ?? null;
}
/**
- * `gas` is the chain's native balance, read only where the bridge does not carry the native currency (Elysium's HYPE),
+ * `gas` is the chain's native balance, read only where the bridge cannot send the native currency (Elysium's HYPE),
* since a token there can be sent only with some of it. Left out when that read fails.
*/
export type BridgeHolding = { chainId: BridgeChainId; asset: BridgeAsset; amount: bigint; decimals: 6 | 18; gas?: bigint };
@@ -39,7 +36,7 @@ export async function readBridgeHoldings(read: HoldingReader, wallet: Address, d
for (const chainId of BRIDGE_CHAIN_IDS) {
if (chainId === destination) continue;
const gas = bridgesNativeCurrency(chainId) ? null : read.native(chainId, wallet).then((amount) => typeof amount === "bigint" && amount >= 0n ? amount : null, () => null);
- for (const asset of BRIDGE_ASSETS[chainId]) {
+ for (const asset of BRIDGE_SEND_ASSETS[chainId]) {
const currency = bridgeCurrency(chainId, asset, "input");
const native = /^0x0{40}$/.test(currency.address);
jobs.push(Promise.all([native ? read.native(chainId, wallet) : read.token(chainId, currency.address, wallet), gas]).then(
diff --git a/app/src/lib/bridge/history.test.ts b/app/src/lib/bridge/history.test.ts
index acd313b0..d691f950 100644
--- a/app/src/lib/bridge/history.test.ts
+++ b/app/src/lib/bridge/history.test.ts
@@ -141,14 +141,19 @@ test("Relay's records become rows only for this wallet, on this bridge's network
["no route at all", relayRequest({ data: {} })],
] as const) assert.equal(relayHistoryEntry(request, WALLET), null, label);
assert.ok(bridgeCurrencyAsset(5042, ARC_USDC)?.decimals === 6 && bridgeCurrencyAsset(5042, NATIVE)?.decimals === 18);
- // Elysium's USDC reads both ways; a record that names its native HYPE is not this bridge's
+ // Elysium's USDC reads both ways; its native HYPE reads only as delivered, since the bridge never sends it
const elysiumRequest = relayRequest({}, { from: currency(99901, ELYSIUM_USDC.toLowerCase(), 6, "25000000") });
elysiumRequest.data.inTxs = [{ txHash: hash(2), chainId: 99901, status: "success" }];
const fromElysium = relayHistoryEntry(elysiumRequest, WALLET);
assert.deepEqual([fromElysium?.originChainId, fromElysium?.originAsset, fromElysium?.inDecimals], [99901, "USDC", 6]);
const toElysium = relayHistoryEntry(relayRequest({}, { to: currency(99901, ELYSIUM_USDC, 6, "24970000"), actualTo: currency(99901, ELYSIUM_USDC, 6, "24975000") }), WALLET);
assert.deepEqual([toElysium?.destinationChainId, toElysium?.destinationAsset, toElysium?.amountOut], [99901, "USDC", "24975000"]);
- assert.equal(relayHistoryEntry(relayRequest({}, { to: currency(99901, NATIVE, 18, "1000000000000000000"), actualTo: currency(99901, NATIVE, 18, "1000000000000000000") }), WALLET), null);
+ const hype = relayHistoryEntry(relayRequest({}, { to: currency(99901, NATIVE, 18, "281757734567595363"), actualTo: currency(99901, NATIVE, 18, "281800000000000000") }), WALLET);
+ assert.deepEqual([hype?.destinationChainId, hype?.destinationAsset, hype?.outDecimals, hype?.amountOut], [99901, "HYPE", 18, "281800000000000000"]);
+ assert.equal(relayHistoryEntry(relayRequest({}, { to: currency(99901, NATIVE, 6, "1000000"), actualTo: currency(99901, NATIVE, 6, "1000000") }), WALLET), null, "HYPE has eighteen decimals");
+ const sentHype = relayRequest({}, { from: currency(99901, NATIVE, 18, "1000000000000000000") });
+ sentHype.data.inTxs = [{ txHash: hash(2), chainId: 99901, status: "success" }];
+ assert.equal(relayHistoryEntry(sentHype, WALLET), null, "HYPE sent from Elysium is not this bridge's");
assert.equal(parseRelayHistory({ requests: [relayRequest(), relayRequest({ id: hash(7), user: OTHER, sender: OTHER })], continuation: "x" }, WALLET).length, 1);
assert.throws(() => parseRelayHistory({ data: [] }, WALLET));
diff --git a/app/src/lib/bridge/history.ts b/app/src/lib/bridge/history.ts
index 1ede24a2..099324f3 100644
--- a/app/src/lib/bridge/history.ts
+++ b/app/src/lib/bridge/history.ts
@@ -73,7 +73,7 @@ export function parseHistoryEntry(value: unknown): BridgeHistoryEntry | null {
if (!isObject(value)) return null;
const v = value;
if (!isHash(v.requestId) || !isBridgeChainId(v.originChainId) || !isBridgeDestination(v.destinationChainId) || v.originChainId === v.destinationChainId) return null;
- if (!isBridgeAssetSupported(v.originChainId, v.originAsset) || !isBridgeAssetSupported(v.destinationChainId, v.destinationAsset)) return null;
+ if (!isBridgeAssetSupported(v.originChainId, v.originAsset, "input") || !isBridgeAssetSupported(v.destinationChainId, v.destinationAsset, "output")) return null;
// Arc's first deposits were native, 18-decimal USDC; every other source amount uses the asset's input decimals.
const inputDecimals = bridgeCurrency(v.originChainId, v.originAsset, "input").decimals;
if (v.inDecimals !== inputDecimals && !(v.originChainId === 5042 && v.inDecimals === 18)) return null;
diff --git a/app/src/lib/bridge/link.ts b/app/src/lib/bridge/link.ts
index ef2ce976..63e4b075 100644
--- a/app/src/lib/bridge/link.ts
+++ b/app/src/lib/bridge/link.ts
@@ -24,8 +24,8 @@ export function parseBridgeLink(query: Record ({ chainId, recipient: input.address, currency: chainId === source ? inputCurrency.address : outputCurrency.address, minimumAmount: "0", deadline: order.output.deadline, extraData }));
+ order.inputs[0].refunds = [source, destination].map((chainId) => ({ chainId, recipient: input.address, currency: chainId === source ? inputCurrency.address : bridgeDestinationRefund(destinationChainId, destinationAsset).address, minimumAmount: "0", deadline: order.output.deadline, extraData }));
order.output.chainId = destination;
order.output.payments[0] = { recipient: input.address, currency: outputCurrency.address, minimumAmount, expectedAmount: amountOut };
quote.protocol.v2.paymentDetails.chainId = source;
diff --git a/app/src/lib/bridge/relay.live.test.ts b/app/src/lib/bridge/relay.live.test.ts
index 888c686d..3948dff1 100644
--- a/app/src/lib/bridge/relay.live.test.ts
+++ b/app/src/lib/bridge/relay.live.test.ts
@@ -3,14 +3,15 @@ import { test } from "node:test";
import { getBridgeHistory, getBridgeQuote, getBridgeStatus } from "./relay.ts";
import { relayHistoryEntry } from "./history.ts";
import { formatUnits } from "viem";
-import { BRIDGE_ASSETS, BRIDGE_CHAINS, BRIDGE_CHAIN_IDS, BRIDGE_DESTINATION_IDS, bridgeCurrency } from "./types.ts";
+import { BRIDGE_CHAINS, BRIDGE_CHAIN_IDS, BRIDGE_DESTINATION_IDS, BRIDGE_RECEIVE_ASSETS, BRIDGE_SEND_ASSETS, bridgeCurrency } from "./types.ts";
// Explicit opt-in. This only requests quotes/status using a public dummy address;
// it never connects a wallet, signs, or submits a transaction.
-// Every source (the launch chains and the funding networks) into every launch chain: sixty-two routes.
+// Every source (the launch chains and the funding networks) into every launch chain: seventy-three routes, eleven of
+// them HYPE received on Elysium.
for (const originChainId of BRIDGE_CHAIN_IDS) for (const destinationChainId of BRIDGE_DESTINATION_IDS) {
if (originChainId === destinationChainId) continue;
- for (const originAsset of BRIDGE_ASSETS[originChainId]) for (const destinationAsset of BRIDGE_ASSETS[destinationChainId]) {
+ for (const originAsset of BRIDGE_SEND_ASSETS[originChainId]) for (const destinationAsset of BRIDGE_RECEIVE_ASSETS[destinationChainId]) {
test(`read-only live Relay ${originChainId}:${originAsset}→${destinationChainId}:${destinationAsset}`, { skip: process.env.RUN_BRIDGE_LIVE_TESTS !== "1" }, async (t) => {
// 25 units of a six-decimal token (USDC, USDG), or 0.01 ETH
const amount = bridgeCurrency(originChainId, originAsset, "input").decimals === 6 ? "25000000" : "10000000000000000";
@@ -18,9 +19,11 @@ for (const originChainId of BRIDGE_CHAIN_IDS) for (const destinationChainId of B
assert.ok(BigInt(quote.amountOut) > 0n);
assert.equal(quote.transaction.chainId, originChainId);
assert.ok(quote.expiresAt > Date.now());
- const status = await getBridgeStatus(quote.requestId);
+ // Circle wins some USDC routes to Base and Arc (relay.ts): its quote has no Relay request, and its status is read
+ // from the burn hash once one exists, so only a Relay quote is checked against Relay's status endpoint
+ const status = quote.provider === "cctp" ? { status: "waiting" as const } : await getBridgeStatus(quote.requestId);
assert.equal(status.status, "waiting");
- t.diagnostic(JSON.stringify({ route: `${BRIDGE_CHAINS[originChainId].name}:${originAsset}→${BRIDGE_CHAINS[destinationChainId].name}:${destinationAsset}`, input: formatUnits(BigInt(amount), bridgeCurrency(originChainId, originAsset, "input").decimals), output: formatUnits(BigInt(quote.amountOut), bridgeCurrency(destinationChainId, destinationAsset, "output").decimals), minimumOutput: formatUnits(BigInt(quote.minimumAmountOut), bridgeCurrency(destinationChainId, destinationAsset, "output").decimals), sourceInputRelayFee: quote.relayFee, sourceNativeGas: quote.sourceGas, totalImpactPercent: quote.totalImpactPercent, approval: quote.approval ?? null, status: status.status }));
+ t.diagnostic(JSON.stringify({ provider: quote.provider ?? "relay", route: `${BRIDGE_CHAINS[originChainId].name}:${originAsset}→${BRIDGE_CHAINS[destinationChainId].name}:${destinationAsset}`, input: formatUnits(BigInt(amount), bridgeCurrency(originChainId, originAsset, "input").decimals), output: formatUnits(BigInt(quote.amountOut), bridgeCurrency(destinationChainId, destinationAsset, "output").decimals), minimumOutput: formatUnits(BigInt(quote.minimumAmountOut), bridgeCurrency(destinationChainId, destinationAsset, "output").decimals), sourceInputRelayFee: quote.relayFee, sourceNativeGas: quote.sourceGas, totalImpactPercent: quote.totalImpactPercent, approval: quote.approval ?? null, status: status.status }));
});
}
}
diff --git a/app/src/lib/bridge/relay.test.ts b/app/src/lib/bridge/relay.test.ts
index 09d3068e..fa63b4e5 100644
--- a/app/src/lib/bridge/relay.test.ts
+++ b/app/src/lib/bridge/relay.test.ts
@@ -3,7 +3,7 @@ import { test } from "node:test";
import { encodeFunctionData, parseAbi, zeroAddress } from "viem";
import { APPROVAL_ABI, BridgeApiError, ERC20_DEPOSIT_ABI, RELAY_DEPOSITORY, parseBridgeRequest, validateRelayChains, validateRelayQuote } from "./validation.ts";
import { getOrderId, type Order } from "./relay-order.ts";
-import { ARBITRUM_USDC, ARC_USDC, BASE_USDC, BRIDGE_ASSETS, BRIDGE_DESTINATION_IDS, BRIDGE_FUNDING_IDS, BRIDGE_INPUT_CURRENCIES, ELYSIUM_USDC, ETHEREUM_USDC, OPTIMISM_USDC, ROBINHOOD_USDG, bridgeCurrency, type BridgeAsset, type BridgeChainId } from "./types.ts";
+import { ARBITRUM_USDC, ARC_USDC, BASE_USDC, BRIDGE_DESTINATION_IDS, BRIDGE_FUNDING_IDS, BRIDGE_INPUT_CURRENCIES, BRIDGE_RECEIVE_ASSETS, BRIDGE_SEND_ASSETS, ELYSIUM_USDC, ETHEREUM_USDC, OPTIMISM_USDC, ROBINHOOD_USDG, bridgeCurrency, type BridgeAsset, type BridgeChainId, type BridgeQuoteRequest } from "./types.ts";
import { BRIDGE_PRIVATE_HEADERS, bridgeErrorResponse, getBridgeQuote, getBridgeStatus, readBridgeJson } from "./relay.ts";
import { ARC_FIXTURE_NOW, ARC_FIXTURE_ORDER_ID, ARC_OUTBOUND_FIXTURE_NOW, ARC_OUTBOUND_ORDER_ID, BASE_USDC_FIXTURE_NOW, BASE_USDC_ORDER_ID, FIXTURE_INPUT, FIXTURE_NOW, FIXTURE_REQUEST_ID, addApprovalFixture, relayArcOutboundFixture, relayArcQuoteFixture, relayBaseUsdcFixture, relayChainsFixture, relayQuoteFixture, relayRouteFixture } from "./relay.fixture.ts";
import { parseBridgeQuoteRejection } from "./client.ts";
@@ -476,7 +476,7 @@ test("funding networks verify into every launch chain, approving exactly their o
const usdc = { 1: ETHEREUM_USDC, 42161: ARBITRUM_USDC, 10: OPTIMISM_USDC } as const;
let routes = 0;
for (const origin of BRIDGE_FUNDING_IDS) for (const destination of BRIDGE_DESTINATION_IDS) {
- for (const originAsset of BRIDGE_ASSETS[origin]) for (const destinationAsset of BRIDGE_ASSETS[destination]) {
+ for (const originAsset of BRIDGE_SEND_ASSETS[origin]) for (const destinationAsset of BRIDGE_RECEIVE_ASSETS[destination]) {
const route = `${origin}:${originAsset}→${destination}:${destinationAsset}`;
const { input, quote } = relayRouteFixture(origin, destination, originAsset, destinationAsset);
const metadata = validateRelayChains(relayChainsFixture(), input);
@@ -490,7 +490,8 @@ test("funding networks verify into every launch chain, approving exactly their o
routes++;
}
}
- assert.equal(routes, 36);
+ // two assets each into seven launch-chain assets, HYPE on Elysium among them
+ assert.equal(routes, 42);
});
test("Elysium stands on Relay's USDC listing, since Relay does not bridge its native HYPE", () => {
@@ -551,14 +552,61 @@ test("Elysium routes move its USDC both ways, approve only that USDC, and take g
assert.throws(() => validateRelayQuote(hypeOut, input, validateRelayChains(relayChainsFixture(), input), FIXTURE_NOW), BridgeApiError, label);
}
}
- // HYPE is never requested, on either side
+ // HYPE is never sent, and arrives only on Elysium
for (const body of [
- { originChainId: 8453, destinationChainId: 99901, destinationAsset: "HYPE" },
{ originChainId: 99901, destinationChainId: 8453, originAsset: "HYPE" },
+ { originChainId: 99901, destinationChainId: 8453, originAsset: "HYPE", destinationAsset: "ETH" },
+ { originChainId: 8453, destinationChainId: 4663, destinationAsset: "HYPE" },
+ { originChainId: 4663, destinationChainId: 5042, originAsset: "ETH", destinationAsset: "HYPE" },
{ originChainId: 99901, destinationChainId: 8453, originAsset: "ETH" },
{ originChainId: 99901, destinationChainId: 1 },
]) assert.throws(() => parseBridgeRequest({ address: FIXTURE_INPUT.address, amount: "1000000", ...body }), (error: unknown) => error instanceof BridgeApiError && error.status === 400, JSON.stringify(body));
assert.equal(parseBridgeRequest({ address: FIXTURE_INPUT.address, originChainId: 99901, destinationChainId: 8453, amount: "1000000" }).originChainId, 99901);
+ assert.equal(parseBridgeRequest({ address: FIXTURE_INPUT.address, originChainId: 8453, destinationChainId: 99901, destinationAsset: "HYPE", amount: "1000000" }).destinationAsset, "HYPE");
+});
+
+test("HYPE arrives on Elysium as its native balance, and a failed fill refunds only in Elysium USDC", () => {
+ const verify = (quote: unknown, input: BridgeQuoteRequest) => validateRelayQuote(quote, input, validateRelayChains(relayChainsFixture(), input), FIXTURE_NOW);
+ const routes: [BridgeChainId, BridgeAsset][] = [[8453, "ETH"], [8453, "USDC"], [4663, "ETH"], [4663, "USDG"], [5042, "USDC"], [1, "ETH"], [42161, "USDC"], [10, "USDC"]];
+ for (const [origin, originAsset] of routes) {
+ const label = `${origin}:${originAsset}→99901:HYPE`;
+ const { input, quote } = relayRouteFixture(origin, 99901, originAsset, "HYPE");
+ if (originAsset !== "ETH") addApprovalFixture(quote, input);
+ const order = quote.protocol.v2.orderData;
+ // paid out in native HYPE, eighteen decimals, to the wallet itself
+ assert.equal(order.output.payments[0].currency, zeroAddress, label);
+ assert.deepEqual(order.inputs[0].refunds.map((refund) => refund.currency), [bridgeCurrency(origin, originAsset, "input").address, ELYSIUM_USDC], label);
+ const validated = verify(quote, input);
+ assert.equal(validated.destinationAsset, "HYPE", label);
+ assert.equal(validated.transaction.chainId, origin, label);
+ // the Elysium refund is pinned: not native HYPE, not any other token, never the origin's currency on Elysium
+ for (const currency of [zeroAddress, BASE_USDC, bridgeCurrency(origin, originAsset, "input").address]) {
+ if (currency.toLowerCase() === ELYSIUM_USDC.toLowerCase()) continue;
+ const refunded = structuredClone(quote);
+ refunded.protocol.v2.orderData.inputs[0].refunds[1].currency = currency;
+ assert.throws(() => verify(refunded, input), BridgeApiError, `${label} refund ${currency}`);
+ }
+ // nor may the origin refund take Elysium's USDC instead of what was sent
+ const swappedRefund = structuredClone(quote);
+ swappedRefund.protocol.v2.orderData.inputs[0].refunds[0].currency = ELYSIUM_USDC;
+ assert.throws(() => verify(swappedRefund, input), BridgeApiError, label);
+ // a HYPE request paid out in USDC, or labelled as anything but eighteen-decimal HYPE, is refused
+ const usdcOut = structuredClone(quote);
+ usdcOut.protocol.v2.orderData.output.payments[0].currency = ELYSIUM_USDC;
+ assert.throws(() => verify(usdcOut, input), BridgeApiError, label);
+ for (const [key, value] of [["symbol", "USDC"], ["decimals", 6], ["address", ELYSIUM_USDC]] as const) {
+ const relabelled = structuredClone(quote);
+ (relabelled.details.currencyOut.currency as Record)[key] = value;
+ assert.throws(() => verify(relabelled, input), BridgeApiError, `${label} currencyOut.${key}`);
+ }
+ }
+ // the exception is HYPE's alone: USDC into Elysium still refunds in that USDC, and refuses anything else
+ const { input, quote } = relayRouteFixture(8453, 99901, "USDC", "USDC");
+ addApprovalFixture(quote, input);
+ assert.equal(verify(quote, input).destinationAsset, "USDC");
+ const nativeRefund = structuredClone(quote);
+ nativeRefund.protocol.v2.orderData.inputs[0].refunds[1].currency = zeroAddress;
+ assert.throws(() => verify(nativeRefund, input), BridgeApiError);
});
test("a funding network is never a destination, and its USDC cannot be swapped for another network's", () => {
diff --git a/app/src/lib/bridge/signal.test.ts b/app/src/lib/bridge/signal.test.ts
index 0bf05cef..d51948fa 100644
--- a/app/src/lib/bridge/signal.test.ts
+++ b/app/src/lib/bridge/signal.test.ts
@@ -134,7 +134,10 @@ test("a bridge link preselects a valid route and is ignored whole when anything
assert.deepEqual(parseBridgeLink({ to: "base" }), { originChainId: 1, destinationChainId: 8453 });
assert.deepEqual(parseBridgeLink({ from: "elysium", to: "base", asset: "USDC", toAsset: "ETH" }), { originChainId: 99901, destinationChainId: 8453, originAsset: "USDC", destinationAsset: "ETH" });
assert.deepEqual(parseBridgeLink({ to: "elysium" }), { originChainId: 8453, destinationChainId: 99901 });
- for (const query of [{ to: "elysium", toAsset: "HYPE" }, { from: "elysium", to: "base", asset: "HYPE" }, { from: "elysium", to: "ethereum" }]) assert.equal(parseBridgeLink(query), null, JSON.stringify(query));
+ // HYPE can be linked as what arrives on Elysium, never as what is sent, and nowhere else
+ assert.deepEqual(parseBridgeLink({ to: "elysium", toAsset: "HYPE" }), { originChainId: 8453, destinationChainId: 99901, destinationAsset: "HYPE" });
+ assert.deepEqual(parseBridgeLink({ from: "arbitrum", to: "elysium", asset: "USDC", toAsset: "HYPE" }), { originChainId: 42161, destinationChainId: 99901, originAsset: "USDC", destinationAsset: "HYPE" });
+ for (const query of [{ from: "elysium", to: "base", asset: "HYPE" }, { to: "base", toAsset: "HYPE" }, { from: "base", to: "elysium", asset: "HYPE" }, { from: "elysium", to: "ethereum" }]) assert.equal(parseBridgeLink(query), null, JSON.stringify(query));
for (const query of [
{}, { from: "base" }, { to: "ethereum" }, { from: "base", to: "arbitrum" }, { from: "base", to: "base" }, { from: "polygon", to: "base" },
{ from: "base", to: "robinhood", toAsset: "USDC" }, { from: "arc", to: "base", asset: "ETH" }, { from: "base", to: "arc", asset: "usdc" },
diff --git a/app/src/lib/bridge/transaction-preflight.test.ts b/app/src/lib/bridge/transaction-preflight.test.ts
index 9e97e092..2a9bd1e1 100644
--- a/app/src/lib/bridge/transaction-preflight.test.ts
+++ b/app/src/lib/bridge/transaction-preflight.test.ts
@@ -4,7 +4,7 @@ import { encodeFunctionData, type Address, type Hex } from "viem";
import { submitExactApproval, type ApprovalDependencies, type ApprovalRequest } from "./approval";
import { ERC20_DEPOSIT_ABI, NATIVE_DEPOSIT_ABI, RELAY_DEPOSITORY, submitBridgeDeposit, type DepositDependencies } from "./client";
import { assertBridgeWalletQueueClear } from "./transaction-preflight";
-import { BRIDGE_ASSETS, BRIDGE_CHAINS, BRIDGE_CHAIN_IDS, BRIDGE_DESTINATION_IDS, bridgeCurrency, type BridgeAsset, type BridgeChainId, type BridgeQuote } from "./types";
+import { BRIDGE_CHAINS, BRIDGE_CHAIN_IDS, BRIDGE_DESTINATION_IDS, BRIDGE_RECEIVE_ASSETS, BRIDGE_SEND_ASSETS, bridgeCurrency, type BridgeAsset, type BridgeChainId, type BridgeQuote } from "./types";
const ADDRESS = "0x1111111111111111111111111111111111111111" as Address;
const HASH = `0x${"a".repeat(64)}` as Hex;
@@ -159,12 +159,13 @@ function depositScenario(q: BridgeQuote, pending: number, latest: number) {
return { deps, events };
}
-test("all sixty-two supported deposit routes block queued nonces before journaling or sending", async () => {
+test("all seventy-three supported deposit routes block queued nonces before journaling or sending", async () => {
let routes = 0;
- // twenty-six between the launch chains, thirty-six from the funding networks (two assets each to six launch-chain assets)
+ // thirty-one between the launch chains, forty-two from the funding networks (two assets each to seven launch-chain
+ // assets): HYPE is received on Elysium, never sent
for (const origin of BRIDGE_CHAIN_IDS) for (const destination of BRIDGE_DESTINATION_IDS) {
if (origin === destination) continue;
- for (const originAsset of BRIDGE_ASSETS[origin]) for (const destinationAsset of BRIDGE_ASSETS[destination]) {
+ for (const originAsset of BRIDGE_SEND_ASSETS[origin]) for (const destinationAsset of BRIDGE_RECEIVE_ASSETS[destination]) {
const q = quote(origin, destination, originAsset, destinationAsset);
const scenario = depositScenario(q, 3, 0);
await assert.rejects(submitBridgeDeposit(q, scenario.deps), /pending transaction/);
@@ -172,7 +173,7 @@ test("all sixty-two supported deposit routes block queued nonces before journali
routes++;
}
}
- assert.equal(routes, 62);
+ assert.equal(routes, 73);
});
test("clear queues preserve explicit approval and deposit submission order", async () => {
diff --git a/app/src/lib/bridge/types.test.ts b/app/src/lib/bridge/types.test.ts
index a9a496ff..db22cf55 100644
--- a/app/src/lib/bridge/types.test.ts
+++ b/app/src/lib/bridge/types.test.ts
@@ -1,26 +1,31 @@
import assert from "node:assert/strict";
import test from "node:test";
import { formatUnits, zeroAddress } from "viem";
-import { ARC_USDC, BASE_USDC, BRIDGE_ASSETS, BRIDGE_ASSET_NAMES, BRIDGE_CHAIN_IDS, BRIDGE_CHAINS, BRIDGE_DESTINATION_IDS, ELYSIUM_USDC, ROBINHOOD_USDG, bridgeCurrency, bridgeCurrencyAsset, bridgeFeePercent, bridgeTokenAsset, bridgeTokens, bridgeTransferInputCurrency, bridgesNativeCurrency, defaultBridgeAsset, isBridgeAssetSupported, isBridgeDestination, isDollarAsset } from "./types";
+import { ARC_USDC, BASE_USDC, BRIDGE_ASSET_NAMES, BRIDGE_CHAIN_IDS, BRIDGE_CHAINS, BRIDGE_DESTINATION_IDS, BRIDGE_RECEIVE_ASSETS, BRIDGE_SEND_ASSETS, ELYSIUM_USDC, ROBINHOOD_USDG, bridgeAssets, bridgeCurrency, bridgeCurrencyAsset, bridgeDestinationRefund, bridgeFeePercent, bridgeTokenAsset, bridgeTokens, bridgeTransferInputCurrency, bridgesNativeCurrency, defaultBridgeAsset, isBridgeAssetSupported, isBridgeDestination, isDollarAsset } from "./types";
import { launchpad } from "../launchpad/config";
test("bridge assets are chain-scoped and never inferred from a symbol alone", () => {
- assert.deepEqual(BRIDGE_ASSETS[8453], ["ETH", "USDC"]);
- assert.deepEqual(BRIDGE_ASSETS[4663], ["ETH", "USDG"]);
- assert.deepEqual(BRIDGE_ASSETS[5042], ["USDC"]);
- assert.equal(isBridgeAssetSupported(4663, "USDC"), false);
- assert.equal(isBridgeAssetSupported(8453, BASE_USDC), false);
- assert.equal(isBridgeAssetSupported(8453, "usdc"), false);
+ // Base, Robinhood Chain and Arc send exactly what they receive
+ for (const side of ["input", "output"] as const) {
+ assert.deepEqual(bridgeAssets(8453, side), ["ETH", "USDC"]);
+ assert.deepEqual(bridgeAssets(4663, side), ["ETH", "USDG"]);
+ assert.deepEqual(bridgeAssets(5042, side), ["USDC"]);
+ assert.equal(isBridgeAssetSupported(4663, "USDC", side), false);
+ assert.equal(isBridgeAssetSupported(8453, BASE_USDC, side), false);
+ assert.equal(isBridgeAssetSupported(8453, "usdc", side), false);
+ assert.equal(isBridgeAssetSupported(4663, "usdg", side), false);
+ assert.equal(isBridgeAssetSupported(4663, ROBINHOOD_USDG, side), false);
+ }
+ assert.equal(bridgeAssets(8453, "input"), BRIDGE_SEND_ASSETS[8453]);
+ assert.equal(bridgeAssets(8453, "output"), BRIDGE_RECEIVE_ASSETS[8453]);
assert.throws(() => bridgeCurrency(4663, "USDC", "input"));
assert.throws(() => bridgeCurrency(4663, "USDC", "output"));
assert.throws(() => bridgeCurrency(5042, "ETH", "input"));
// USDG is Robinhood Chain's alone, and USDC is still not offered there
for (const chain of [8453, 5042] as const) {
- assert.equal(isBridgeAssetSupported(chain, "USDG"), false);
+ assert.equal(isBridgeAssetSupported(chain, "USDG", "output"), false);
assert.throws(() => bridgeCurrency(chain, "USDG", "output"));
}
- assert.equal(isBridgeAssetSupported(4663, "usdg"), false);
- assert.equal(isBridgeAssetSupported(4663, ROBINHOOD_USDG), false);
});
test("Robinhood USDG is the launch form's own six-decimal contract in both directions", () => {
@@ -40,38 +45,65 @@ test("Robinhood USDG is the launch form's own six-decimal contract in both direc
test("every chain keeps its gas token as the default where the bridge carries it, and has at most one ERC-20", () => {
assert.deepEqual(BRIDGE_CHAIN_IDS.map(bridgeTokenAsset), ["USDC", "USDG", "USDC", "USDC", "USDC", "USDC", "USDC"]);
- // Elysium alone has a gas token the bridge does not carry
+ // Elysium alone has a gas token the bridge cannot send: HYPE only arrives there, swapped from its USDC
assert.deepEqual(BRIDGE_CHAIN_IDS.filter((chain) => !bridgesNativeCurrency(chain)), [99901]);
for (const chain of BRIDGE_CHAIN_IDS) {
assert.equal(defaultBridgeAsset(chain), bridgesNativeCurrency(chain) ? BRIDGE_CHAINS[chain].symbol : bridgeTokenAsset(chain));
- assert.ok(isBridgeAssetSupported(chain, defaultBridgeAsset(chain)), String(chain));
+ assert.ok(isBridgeAssetSupported(chain, defaultBridgeAsset(chain), "input"), String(chain));
+ if (isBridgeDestination(chain)) assert.ok(isBridgeAssetSupported(chain, defaultBridgeAsset(chain), "output"), String(chain));
// approvals are journaled per chain (approval.ts), so a second ERC-20 on one chain would be ambiguous there
- const tokens = BRIDGE_ASSETS[chain].filter((asset) => bridgeCurrency(chain, asset, "input").address !== zeroAddress);
+ const tokens = BRIDGE_SEND_ASSETS[chain].filter((asset) => bridgeCurrency(chain, asset, "input").address !== zeroAddress);
assert.ok(tokens.length <= 1, `${chain}: ${tokens.join(", ")}`);
- for (const asset of BRIDGE_ASSETS[chain]) assert.ok(BRIDGE_ASSET_NAMES[asset].length > 0, asset);
+ for (const asset of [...BRIDGE_SEND_ASSETS[chain], ...BRIDGE_RECEIVE_ASSETS[chain]]) assert.ok(BRIDGE_ASSET_NAMES[asset].length > 0, asset);
}
- assert.deepEqual(["ETH", "USDC", "USDG"].map((asset) => isDollarAsset(asset as "ETH")), [false, true, true]);
+ assert.deepEqual(["ETH", "USDC", "USDG", "HYPE"].map((asset) => isDollarAsset(asset as "ETH")), [false, true, true, false]);
+ assert.equal(BRIDGE_ASSET_NAMES.HYPE, "Hyperliquid");
assert.equal(BRIDGE_ASSET_NAMES.USDG, "Global Dollar");
});
-test("Elysium is a launch chain that carries its USDC both ways and never HYPE", () => {
+test("Elysium sends its USDC and receives USDC or HYPE, never sending HYPE", () => {
assert.ok(isBridgeDestination(99901) && (BRIDGE_DESTINATION_IDS as readonly number[]).includes(99901));
- assert.deepEqual(BRIDGE_ASSETS[99901], ["USDC"]);
+ assert.deepEqual(BRIDGE_SEND_ASSETS[99901], ["USDC"]);
+ assert.deepEqual(BRIDGE_RECEIVE_ASSETS[99901], ["USDC", "HYPE"]);
assert.equal(defaultBridgeAsset(99901), "USDC");
for (const side of ["input", "output"] as const) {
assert.deepEqual(bridgeCurrency(99901, "USDC", side), { address: ELYSIUM_USDC, symbol: "USDC", decimals: 6 });
assert.deepEqual(bridgeCurrency(99901, undefined, side), { address: ELYSIUM_USDC, symbol: "USDC", decimals: 6 });
}
- for (const asset of ["HYPE", "ETH", "USDG"]) assert.equal(isBridgeAssetSupported(99901, asset), false, asset);
+ // HYPE arrives as Elysium's native balance, and is never sent from it
+ assert.deepEqual(bridgeCurrency(99901, "HYPE", "output"), { address: zeroAddress, symbol: "HYPE", decimals: 18 });
+ assert.equal(isBridgeAssetSupported(99901, "HYPE", "output"), true);
+ assert.equal(isBridgeAssetSupported(99901, "HYPE", "input"), false);
+ assert.throws(() => bridgeCurrency(99901, "HYPE", "input"), /not supported/);
+ for (const side of ["input", "output"] as const) {
+ for (const asset of ["ETH", "USDG", "hype"]) assert.equal(isBridgeAssetSupported(99901, asset, side), false, asset);
+ // HYPE is Elysium's alone
+ for (const chain of BRIDGE_CHAIN_IDS.filter((id) => id !== 99901)) assert.equal(isBridgeAssetSupported(chain, "HYPE", side), false, String(chain));
+ }
assert.throws(() => bridgeCurrency(99901, "ETH", "output"), /not supported/);
- assert.equal(bridgeCurrencyAsset(99901, zeroAddress), null, "native HYPE is gas, not a bridge asset");
+ assert.deepEqual(bridgeCurrencyAsset(99901, zeroAddress), { asset: "HYPE", decimals: 18 }, "Relay's records name delivered HYPE by the native address");
assert.deepEqual(bridgeCurrencyAsset(99901, ELYSIUM_USDC.toLowerCase()), { asset: "USDC", decimals: 6 });
assert.equal(bridgeCurrencyAsset(8453, ELYSIUM_USDC), null);
- assert.deepEqual(bridgeTokens(99901), [{ address: ELYSIUM_USDC, symbol: "USDC", decimals: 6 }]);
+ assert.deepEqual(bridgeTokens(99901), [{ address: ELYSIUM_USDC, symbol: "USDC", decimals: 6 }], "HYPE's refund is that same USDC");
assert.deepEqual(bridgeTokens(5042), [{ address: ARC_USDC, symbol: "USDC", decimals: 6 }], "Arc's native USDC output is not a token");
assert.equal(BRIDGE_CHAINS[99901].symbol, "HYPE");
});
+test("a failed fill refunds in its own output on the destination, except HYPE, which refunds in Elysium USDC", () => {
+ for (const chain of BRIDGE_DESTINATION_IDS) for (const asset of BRIDGE_RECEIVE_ASSETS[chain]) {
+ const expected = chain === 99901 && asset === "HYPE" ? { address: ELYSIUM_USDC, symbol: "USDC", decimals: 6 } : bridgeCurrency(chain, asset, "output");
+ assert.deepEqual(bridgeDestinationRefund(chain, asset), expected, `${chain}:${asset}`);
+ }
+ // Arc still refunds its native USDC, and an omitted asset is the chain's default
+ assert.deepEqual(bridgeDestinationRefund(5042, "USDC"), { address: zeroAddress, symbol: "USDC", decimals: 18 });
+ assert.deepEqual(bridgeDestinationRefund(99901, undefined), { address: ELYSIUM_USDC, symbol: "USDC", decimals: 6 });
+ assert.throws(() => bridgeDestinationRefund(8453, "HYPE"), /not supported/);
+ // a copy, like every currency the table hands out
+ const copy = bridgeDestinationRefund(99901, "HYPE");
+ copy.decimals = 18;
+ assert.equal(bridgeDestinationRefund(99901, "HYPE").decimals, 6);
+});
+
test("Base USDC uses the same six-decimal contract in both directions", () => {
for (const side of ["input", "output"] as const) {
assert.deepEqual(bridgeCurrency(8453, "USDC", side), { address: BASE_USDC, symbol: "USDC", decimals: 6 });
diff --git a/app/src/lib/bridge/types.ts b/app/src/lib/bridge/types.ts
index 87effcc8..5c4f41ef 100644
--- a/app/src/lib/bridge/types.ts
+++ b/app/src/lib/bridge/types.ts
@@ -38,60 +38,84 @@ export const ARBITRUM_USDC = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831" as con
export const OPTIMISM_USDC = "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85" as const;
// USDC on Elysium: the contract the launch form quotes there (lib/launchpad/config.ts), symbol and name USDC, six
// decimals, checked on-chain, and the one ERC-20 Relay lists as bridgeable on Elysium. Elysium has no Circle CCTP domain,
-// so this USDC moves through Relay alone. HYPE, the gas currency, is not carried: Relay does not bridge it as a native
-// currency, and its HYPE routes need a different deposit flow and refund (docs/bridge.md, "Elysium").
+// so this USDC moves through Relay alone.
export const ELYSIUM_USDC = "0xb47c4C8F73F009CC0C0d3f92B388d13E293F0B65" as const;
-export type BridgeAsset = "ETH" | "USDC" | "USDG";
+export type BridgeAsset = "ETH" | "USDC" | "USDG" | "HYPE";
+export type BridgeSide = "input" | "output";
export type BridgeCurrency = { address: Address; symbol: BridgeAsset; decimals: 6 | 18 };
const NATIVE_ADDRESS = "0x0000000000000000000000000000000000000000" as const;
const native = (symbol: BridgeAsset): BridgeCurrency => ({ address: NATIVE_ADDRESS, symbol, decimals: 18 });
const token = (address: Address, symbol: BridgeAsset): BridgeCurrency => ({ address, symbol, decimals: 6 });
const bothWays = (currency: BridgeCurrency) => ({ input: currency, output: currency });
+/**
+ * How an asset is sent (`input`) and how it arrives (`output`). An asset without an input can be received but never
+ * sent. `refund` is the one currency a failed fill may refund in on the destination, when that is not the output itself.
+ */
+type BridgeEntry = { input?: BridgeCurrency; output: BridgeCurrency; refund?: BridgeCurrency };
/**
* Every asset the bridge moves, per chain, as the wallet sends it (input) and as it arrives (output). Only assets
* whose identity has been independently verified belong here; a chain's first entry is listed first in its picker.
- * The two sides differ on Arc alone, where USDC is sent through its ERC-20 interface and arrives as the native balance.
- * Elysium carries USDC only, as an ERC-20 both ways; its native HYPE is gas, never a bridge asset.
- * A chain has at most one ERC-20 here: approvals are journaled per chain (approval.ts), so a second one needs that
+ * The two sides differ on Arc, where USDC is sent through its ERC-20 interface and arrives as the native balance, and on
+ * Elysium, where HYPE can only be received. Relay's solvers hold USDC there, not HYPE: they deliver HYPE by swapping
+ * from Elysium USDC, so a failed fill refunds that USDC on Elysium (pinned here, and checked on every quote). Sending HYPE
+ * goes through Relay's router with a swap call, not the depository deposit this bridge verifies, so it is never an input.
+ * A chain has at most one ERC-20 to send: approvals are journaled per chain (approval.ts), so a second one needs that
* journal to carry the token first.
*/
-const CURRENCIES: Record>> = {
+const CURRENCIES: Record>> = {
8453: { ETH: bothWays(native("ETH")), USDC: bothWays(token(BASE_USDC, "USDC")) },
4663: { ETH: bothWays(native("ETH")), USDG: bothWays(token(ROBINHOOD_USDG, "USDG")) },
5042: { USDC: { input: token(ARC_USDC, "USDC"), output: native("USDC") } },
- 99901: { USDC: bothWays(token(ELYSIUM_USDC, "USDC")) },
+ 99901: { USDC: bothWays(token(ELYSIUM_USDC, "USDC")), HYPE: { output: native("HYPE"), refund: token(ELYSIUM_USDC, "USDC") } },
// funding networks: only the input side is ever used, since nothing is sent to them
1: { ETH: bothWays(native("ETH")), USDC: bothWays(token(ETHEREUM_USDC, "USDC")) },
42161: { ETH: bothWays(native("ETH")), USDC: bothWays(token(ARBITRUM_USDC, "USDC")) },
10: { ETH: bothWays(native("ETH")), USDC: bothWays(token(OPTIMISM_USDC, "USDC")) },
};
-export const BRIDGE_ASSETS = Object.fromEntries(BRIDGE_CHAIN_IDS.map((id) => [id, Object.keys(CURRENCIES[id]) as BridgeAsset[]])) as unknown as Record;
+const entries = (chainId: BridgeChainId) => Object.entries(CURRENCIES[chainId]) as [BridgeAsset, BridgeEntry][];
+const assetsWith = (side: BridgeSide) => Object.fromEntries(BRIDGE_CHAIN_IDS.map((id) => [id, entries(id).filter(([, entry]) => entry[side]).map(([asset]) => asset)])) as unknown as Record;
+/** What a wallet can send from each chain, in picker order. */
+export const BRIDGE_SEND_ASSETS = assetsWith("input");
+/** What can arrive on each chain, in picker order. Only the launch chains ever receive. */
+export const BRIDGE_RECEIVE_ASSETS = assetsWith("output");
+export const bridgeAssets = (chainId: BridgeChainId, side: BridgeSide) => (side === "input" ? BRIDGE_SEND_ASSETS : BRIDGE_RECEIVE_ASSETS)[chainId];
/** What each asset is called in full, for pickers and screen readers. */
-export const BRIDGE_ASSET_NAMES: Record = { ETH: "Ether", USDC: "USD Coin", USDG: "Global Dollar" };
-/** A chain's first bridge asset: its native currency where the bridge carries it, otherwise its one token. */
+export const BRIDGE_ASSET_NAMES: Record = { ETH: "Ether", USDC: "USD Coin", USDG: "Global Dollar", HYPE: "Hyperliquid" };
+/** A chain's first bridge asset, which it both sends and receives: its native currency where it sends it, otherwise its one token. */
export function defaultBridgeAsset(chainId: BridgeChainId): BridgeAsset {
- return BRIDGE_ASSETS[chainId][0];
+ return BRIDGE_SEND_ASSETS[chainId][0];
}
-/** Whether the bridge carries this chain's native currency at all, on either side (false on Elysium). */
+/**
+ * Whether Relay must bridge this chain's native currency itself: true wherever the bridge sends or receives it directly.
+ * False on Elysium, where HYPE is only received, delivered through a swap from USDC (its refund), so Relay's listing of
+ * that USDC is what the route depends on.
+ */
export function bridgesNativeCurrency(chainId: BridgeChainId): boolean {
- return BRIDGE_ASSETS[chainId].some((asset) => [CURRENCIES[chainId][asset]!.input, CURRENCIES[chainId][asset]!.output].some((currency) => currency.address === NATIVE_ADDRESS));
+ return entries(chainId).some(([, entry]) => entry.input?.address === NATIVE_ADDRESS || (entry.output.address === NATIVE_ADDRESS && !entry.refund));
}
-/** The ERC-20s the bridge carries on a chain, from either side, for checking Relay's own chain listing. */
+/** The ERC-20s a chain's routes depend on (sent, received or refunded), for checking Relay's own chain listing. */
export function bridgeTokens(chainId: BridgeChainId): BridgeCurrency[] {
const seen = new Map();
- for (const asset of BRIDGE_ASSETS[chainId]) for (const currency of [CURRENCIES[chainId][asset]!.input, CURRENCIES[chainId][asset]!.output]) {
- if (currency.address !== NATIVE_ADDRESS) seen.set(currency.address.toLowerCase(), { ...currency });
+ for (const [, entry] of entries(chainId)) for (const currency of [entry.input, entry.output, entry.refund]) {
+ if (currency && currency.address !== NATIVE_ADDRESS) seen.set(currency.address.toLowerCase(), { ...currency });
}
return [...seen.values()];
}
-export function isBridgeAssetSupported(chainId: BridgeChainId, asset: unknown): asset is BridgeAsset {
- return typeof asset === "string" && BRIDGE_ASSETS[chainId].includes(asset as BridgeAsset);
+/** Whether `asset` can be sent from (`input`) or received on (`output`) this chain. */
+export function isBridgeAssetSupported(chainId: BridgeChainId, asset: unknown, side: BridgeSide): asset is BridgeAsset {
+ return typeof asset === "string" && bridgeAssets(chainId, side).includes(asset as BridgeAsset);
}
-export function bridgeCurrency(chainId: BridgeChainId, asset: BridgeAsset | undefined, side: "input" | "output"): BridgeCurrency {
+export function bridgeCurrency(chainId: BridgeChainId, asset: BridgeAsset | undefined, side: BridgeSide): BridgeCurrency {
const symbol = asset ?? defaultBridgeAsset(chainId);
- const entry = isBridgeAssetSupported(chainId, symbol) ? CURRENCIES[chainId][symbol] : undefined;
- if (!entry) throw new Error("This token is not supported on this bridge network.");
- return { ...entry[side] };
+ const currency = isBridgeAssetSupported(chainId, symbol, side) ? CURRENCIES[chainId][symbol]?.[side] : undefined;
+ if (!currency) throw new Error("This token is not supported on this bridge network.");
+ return { ...currency };
+}
+/** The currency a failed fill may refund in on the destination: the output itself, except where the registry pins another. */
+export function bridgeDestinationRefund(chainId: BridgeChainId, asset: BridgeAsset | undefined): BridgeCurrency {
+ const output = bridgeCurrency(chainId, asset, "output");
+ const refund = CURRENCIES[chainId][output.symbol]?.refund;
+ return refund ? { ...refund } : output;
}
/**
* Which bridge asset a currency address is on `chainId`, from either side, with its decimals; null when the bridge
@@ -99,14 +123,14 @@ export function bridgeCurrency(chainId: BridgeChainId, asset: BridgeAsset | unde
*/
export function bridgeCurrencyAsset(chainId: BridgeChainId, address: string): { asset: BridgeAsset; decimals: 6 | 18 } | null {
const wanted = address.toLowerCase();
- for (const asset of BRIDGE_ASSETS[chainId]) for (const currency of [CURRENCIES[chainId][asset]!.input, CURRENCIES[chainId][asset]!.output]) {
- if (currency.address.toLowerCase() === wanted) return { asset, decimals: currency.decimals };
+ for (const [asset, entry] of entries(chainId)) for (const currency of [entry.input, entry.output]) {
+ if (currency && currency.address.toLowerCase() === wanted) return { asset, decimals: currency.decimals };
}
return null;
}
/** The ERC-20 a chain's bridge can send (it needs an exact approval first), or null where every asset is native. */
export function bridgeTokenAsset(chainId: BridgeChainId): BridgeAsset | null {
- return BRIDGE_ASSETS[chainId].find((asset) => CURRENCIES[chainId][asset]!.input.address !== NATIVE_ADDRESS) ?? null;
+ return BRIDGE_SEND_ASSETS[chainId].find((asset) => CURRENCIES[chainId][asset]!.input!.address !== NATIVE_ADDRESS) ?? null;
}
/** USDC and USDG are both dollars: one unit of either is compared with one unit of the other. */
export function isDollarAsset(asset: BridgeAsset): boolean {
diff --git a/app/src/lib/bridge/validation.ts b/app/src/lib/bridge/validation.ts
index 05e6e6e5..d6aed259 100644
--- a/app/src/lib/bridge/validation.ts
+++ b/app/src/lib/bridge/validation.ts
@@ -1,7 +1,7 @@
import "server-only";
import { getOrderId, type Order } from "./relay-order";
import { encodeFunctionData, formatEther, formatUnits, isAddress, parseAbi, zeroAddress, type Hex } from "viem";
-import { BRIDGE_CHAINS, bridgeCurrency, bridgeFeePercent, bridgeTokens, bridgesNativeCurrency, isBridgeAssetSupported, isBridgeChainId, isBridgeDestination, isDollarAsset, type BridgeCurrency, type BridgeChainId, type BridgeQuote, type BridgeQuoteRejection, type BridgeQuoteRequest, type BridgeStatus, type BridgeStatusResponse } from "./types";
+import { BRIDGE_CHAINS, bridgeCurrency, bridgeDestinationRefund, bridgeFeePercent, bridgeTokens, bridgesNativeCurrency, isBridgeAssetSupported, isBridgeChainId, isBridgeDestination, isDollarAsset, type BridgeCurrency, type BridgeChainId, type BridgeQuote, type BridgeQuoteRejection, type BridgeQuoteRequest, type BridgeStatus, type BridgeStatusResponse } from "./types";
// Independently pinned, then checked against GET /chains. Never trust a quote to
// supply the address against which that same quote is validated.
@@ -60,8 +60,9 @@ export function parseBridgeRequest(value: unknown): BridgeQuoteRequest {
ensure(typeof b.address === "string" && isAddress(b.address, { strict: false }) && !sameAddress(b.address, zeroAddress));
// funding networks (Ethereum, Arbitrum, Optimism) send but never receive
ensure(isBridgeChainId(b.originChainId) && isBridgeDestination(b.destinationChainId) && b.originChainId !== b.destinationChainId);
- ensure(b.originAsset === undefined || isBridgeAssetSupported(b.originChainId, b.originAsset));
- ensure(b.destinationAsset === undefined || isBridgeAssetSupported(b.destinationChainId, b.destinationAsset));
+ // HYPE arrives on Elysium but is never sent (types.ts): each side is checked against its own list
+ ensure(b.originAsset === undefined || isBridgeAssetSupported(b.originChainId, b.originAsset, "input"));
+ ensure(b.destinationAsset === undefined || isBridgeAssetSupported(b.destinationChainId, b.destinationAsset, "output"));
const amount = uint(b.amount, true);
// ERC20 max is an unlimited-allowance sentinel, never an exact approval.
ensure(sameAddress(bridgeCurrency(b.originChainId, b.originAsset, "input").address, zeroAddress) || BigInt(amount) < UINT256_MAX);
@@ -104,7 +105,7 @@ export function validateRelayChains(value: unknown, route: Pick [BRIDGE_CHAINS[id].key, "ethereum-vm" as const])), solverAddresses: solvers as string[] };
}
-/** Implements Relay's protocol.v2 input validation for the allowlisted assets (types.ts): ETH, USDC and USDG. Gas is always the origin's native currency, which on Elysium is HYPE. */
+/** Implements Relay's protocol.v2 input validation for the allowlisted assets (types.ts): ETH, USDC, USDG and, received on Elysium only, HYPE. Gas is always the origin's native currency. */
export function validateRelayQuote(value: unknown, input: BridgeQuoteRequest, chains: RelayChainMetadata, now = Date.now()): BridgeQuote {
const quote = object(value);
const protocol = object(object(quote.protocol).v2);
@@ -113,6 +114,8 @@ export function validateRelayQuote(value: unknown, input: BridgeQuoteRequest, ch
const destination = BRIDGE_CHAINS[input.destinationChainId].key;
const inputCurrency = bridgeCurrency(input.originChainId, input.originAsset, "input");
const outputCurrency = bridgeCurrency(input.destinationChainId, input.destinationAsset, "output");
+ // the output itself, except HYPE on Elysium, which Relay delivers by swapping USDC there and so refunds in that USDC
+ const destinationRefund = bridgeDestinationRefund(input.destinationChainId, input.destinationAsset);
const erc20Input = !sameAddress(inputCurrency.address, zeroAddress);
ensure(!erc20Input || BigInt(uint(input.amount, true)) < UINT256_MAX);
const sameAsset = inputCurrency.symbol === outputCurrency.symbol;
@@ -136,12 +139,12 @@ export function validateRelayQuote(value: unknown, input: BridgeQuoteRequest, ch
ensure(BigInt(minimumAmountOut) <= BigInt(amountOut));
if (sameAsset) ensure(BigInt(amountOut) * inputScale <= BigInt(input.amount) * outputScale);
ensure(BigInt(minimumAmountOut) >= BigInt(amountOut) * 9950n / 10000n);
- // A failed fill may refund on either chain, but can never redirect the refund.
+ // A failed fill may refund on either chain, in the registry's currency for each, but can never redirect the refund.
const refunds = list(orderInput.refunds, 2).map(object);
ensure(new Set(refunds.map((refund) => refund.chainId)).size === 2);
for (const refund of refunds) {
ensure((refund.chainId === source || refund.chainId === destination) && sameAddress(refund.recipient, input.address));
- const refundCurrency = refund.chainId === source ? inputCurrency.address : outputCurrency.address;
+ const refundCurrency = refund.chainId === source ? inputCurrency.address : destinationRefund.address;
ensure(sameAddress(refund.currency, refundCurrency) && uint(refund.minimumAmount) === "0" && refund.deadline === output.deadline && isRelayRouterWord(refund.extraData));
}
const paymentDetails = object(protocol.paymentDetails);
diff --git a/docs/bridge.md b/docs/bridge.md
index e09836c0..c8fb7cc2 100644
--- a/docs/bridge.md
+++ b/docs/bridge.md
@@ -1,11 +1,11 @@
# Base, Robinhood, Arc and Elysium bridge (funded from Ethereum, Arbitrum and Optimism)
-The header's **Bridge** action opens a focused funding panel without leaving the current token or launch page; `/bridge` shows the same panel as a page. It moves funds between Base (8453), Robinhood Chain (4663), Arc mainnet (5042) and Elysium (99901), and into them from Ethereum (1), Arbitrum (42161) and Optimism (10), always to the same connected wallet address. The three funding networks send only: nothing is ever bridged to them. Base sends/receives ETH or official USDC; Robinhood sends/receives ETH or USDG (Global Dollar, the chain's main stablecoin and the launch form's default quote there). Arc receives native USDC and sends USDC through its ERC-20 interface. Elysium sends and receives its USDC only; HYPE, its gas token, is not bridged. Ethereum, Arbitrum and Optimism send ETH or Circle's native USDC. Relay converts between ETH, USDC and USDG at the reviewed quote. This does not add launched tokens or GITLAWB, change the launch-chain registry, or deploy contracts.
+The header's **Bridge** action opens a focused funding panel without leaving the current token or launch page; `/bridge` shows the same panel as a page. It moves funds between Base (8453), Robinhood Chain (4663), Arc mainnet (5042) and Elysium (99901), and into them from Ethereum (1), Arbitrum (42161) and Optimism (10), always to the same connected wallet address. The three funding networks send only: nothing is ever bridged to them. Base sends/receives ETH or official USDC; Robinhood sends/receives ETH or USDG (Global Dollar, the chain's main stablecoin and the launch form's default quote there). Arc receives native USDC and sends USDC through its ERC-20 interface. Elysium sends its USDC only, and receives USDC or HYPE, its gas token, which is never sent. Ethereum, Arbitrum and Optimism send ETH or Circle's native USDC. Relay converts between ETH, USDC, USDG and HYPE at the reviewed quote. This does not add launched tokens or GITLAWB, change the launch-chain registry, or deploy contracts.
## Integration
- 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, Elysium 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, Robinhood USDG and Elysium USDC, 18 for ETH or Arc native USDC. `GET /api/bridge/status?requestId=…` returns normalized provider state and transaction hashes.
+- `POST /api/bridge/quote` accepts `{ address, originChainId, destinationChainId, originAsset, destinationAsset, amount }`. Asset choices are allowlisted `ETH`, `USDC` or `USDG` for that chain, plus `HYPE` as an output on Elysium, never arbitrary addresses. Omitted asset fields retain legacy defaults: Base/Robinhood ETH, Arc USDC, Elysium 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, Robinhood USDG and Elysium USDC, 18 for ETH, Elysium HYPE 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.
- Arc's native USDC (18 decimals) and ERC-20 interface `0x3600000000000000000000000000000000000000` (6 decimals) share one balance but are **not interchangeable transfer interfaces**. Input parsing, deposit verification and recovery distinguish them. Gas budgeting converts the USDC input into native units and leaves a reserve for network fees.
@@ -32,7 +32,7 @@ An interrupted wallet response is **uncertain**, not failed. The app must not si
## Limits and rollout
-- Four launch networks both ways and three funding networks one way, same-wallet recipient only. ETH on Base/Robinhood, USDC on Base/Arc/Elysium, USDG on Robinhood, and ETH or native USDC from Ethereum/Arbitrum/Optimism. No arbitrary tokens, recipients, unlimited allowances, deposits from exchanges, custom swap calls, bridged USDC.e, HYPE, or other networks.
+- Four launch networks both ways and three funding networks one way, same-wallet recipient only. ETH on Base/Robinhood, USDC on Base/Arc/Elysium, USDG on Robinhood, HYPE received on Elysium, and ETH or native USDC from Ethereum/Arbitrum/Optimism. No arbitrary tokens, recipients, unlimited allowances, deposits from exchanges, custom swap calls, bridged USDC.e, sending HYPE, gas top-ups, or other networks.
- Robinhood USDC remains unavailable. On 2026-09-16, the official canonical gateway mapped Ethereum USDC to bridged `0x80e0e24718dbFcad49ECAA6F1e6C89A190586cA8` (6 decimals); this is not Circle-native issuance. Read-only Relay probes at 25 USDC returned roughly 21% loss outbound, while inbound routes were rejected for roughly 92% swap impact. Outbound responses required an approval-proxy/swap flow, not our validated direct deposit. Do not enable this asset or loosen safeguards without fresh route validation and separately reviewed support. USDG is offered as its own named asset, never as a silent stand-in for USDC.
- Relay is a third-party bridge protocol. Its availability, liquidity, estimates and settlement are not guaranteed by Openlaunch. Provider response changes fail closed until reviewed.
- Funds never pass through an Openlaunch wallet or new Openlaunch contract.
@@ -55,6 +55,30 @@ 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.
+### HYPE on Elysium (2026-11-10)
+
+HYPE, Elysium's gas token, can now arrive on Elysium as the asset of any route there. It is never sent. With it the bridge has seventy-three routes: thirty-one between the launch chains and forty-two from the funding networks, eleven of them new (into HYPE from Base ETH and USDC, Robinhood ETH and USDG, Arc USDC, and ETH or USDC from each funding network).
+
+- **How Relay delivers HYPE.** Relay's solvers hold USDC on Elysium, not HYPE, and swap it into HYPE to fill the order. The order is the same depository deposit as every other route (`depositNative`, or an exact approval then `depositErc20`) and pays native HYPE to the wallet, but its Elysium refund is in Elysium USDC: a fill that fails there refunds the USDC that would have been swapped. Asking for `refundOnOrigin` changes only the quote's `details.refundCurrency`, not the refunds in the signed order.
+- **The one exception.** The registry pins that refund: HYPE on Elysium refunds there in USDC `0xb47c…0B65` and in nothing else. Everything else about the order is checked as before: the output is native HYPE with 18 decimals and labelled HYPE, the origin refund is in the currency sent, and both refunds go to the wallet with a zero minimum, the order's deadline and the pinned router word. Native HYPE, any other token, or the origin's currency as the Elysium refund is refused, and so is a HYPE request paid out in USDC. Every other output still refunds in itself, USDC into Elysium included.
+- **Chain check.** Unchanged. Relay still lists HYPE as not bridgeable, so Elysium routes stand on Relay listing its USDC, which is also the HYPE refund.
+- **Why not a gas top-up.** Relay's `topupGas` adds some HYPE to a USDC route, but that HYPE appears only in the quote's details (`currencyGasTopup`), not in the signed order's payments, so nothing this bridge verifies guarantees it arrives. Its $2 default also comes out of the relayer fee: 8% of a $25 transfer. Receiving HYPE as the route's own asset is checked end to end instead.
+- **Why not sending HYPE.** Quotes out of HYPE take a deposit to Relay's router (`0x0729…97f7`, selector `0xcd6e13f7`) and are paid from USDC, not the depository deposit this bridge verifies. One HYPE to Base also showed 2.38% price impact. Not offered.
+- **Panel.** Elysium's Receive menu lists USDC and HYPE (HYPE with the coin glyph the launch form uses, marked as the gas token); its Send side stays USDC only. Choosing Elysium with USDC to receive shows "Gas on Elysium is paid in HYPE. With none there yet, receive HYPE instead", which switches the received asset. Reversing a route into HYPE sends Elysium USDC. The /bridge page lists HYPE on Elysium as receive only. Links accept `toAsset=HYPE` into Elysium and refuse HYPE anywhere else. History reads delivered HYPE from Relay's records; HYPE sent from Elysium is not this bridge's.
+- **Bridge, then buy.** A buy paid in HYPE on Elysium now offers to bridge HYPE there, suggesting the largest source by value.
+- **Live cost** (read-only, 25 USDC or USDG, or 0.01 ETH; 2026-11-10). All eleven HYPE routes passed the server validator, each guaranteeing 99.5% of its estimate:
+
+ | Route | Receives | Relay fee | Relay's total estimate |
+ | --- | --- | --- | --- |
+ | Base, Ethereum, Arbitrum or Optimism USDC to HYPE | 0.3433 to 0.3435 HYPE | 0.34% | +0.11% to -0.14% |
+ | Robinhood USDG to HYPE | 0.3432 HYPE | 0.35% | +0.06% |
+ | Arc USDC to HYPE | 0.3432 HYPE | 0.38% | +0.06% |
+ | ETH from Base, Robinhood, Ethereum, Arbitrum or Optimism to HYPE | 0.2828 to 0.2831 HYPE | 0.37% to 0.46% | -0.02% to -0.29% |
+
+ Relay's fee is a share of what is sent and includes the swap on Elysium; USDC arriving as USDC costs 0.11% to 0.15% (below). The total estimate is Relay's own, comparing dollar values in and out, so it can come out positive.
+- **Live test.** USDC to Arc USDC is now quoted by Circle on every source, which has no Relay request to read, so the live test reads Relay's status only for Relay quotes. All seventy-three routes passed; several needed a second run after Relay's unauthenticated rate limit.
+- **Verification.** Tests cover the send and receive lists, the pinned refund, HYPE being Elysium's alone, every wrong refund, output and label on all eight sources into HYPE, request parsing, route changes, links, history, bridge-then-buy and all seventy-three deposit routes. Browser check: the Receive menu, the HYPE switch, the reversed route, the networks list and a phone width. No wallet signed anything; a small real transfer into HYPE is still needed before this is described as exercised.
+
### Elysium (2026-11-10)
Elysium (99901, gas in HYPE) is the fourth launch chain, so the bridge now sends from and delivers to it like Base, Robinhood and Arc. It carries one asset there: USDC `0xb47c4C8F73F009CC0C0d3f92B388d13E293F0B65` (symbol and name USDC, 6 decimals, checked on-chain), the same contract the launch form quotes. It moves through Relay alone, since Elysium has no Circle CCTP domain. With Elysium, the bridge has sixty-two routes: twenty-six between the launch chains and thirty-six from the funding networks.