mirror of
https://github.com/labrinyang/lp-terminal.git
synced 2026-08-14 05:08:04 +00:00
feat: SHEEP CHOICE routing, cross-chain deposits, and any-token ZAP
One commit because the pieces do not compile apart: the shared copy, the tab shell and the data layer all changed together, and splitting them further would mean inventing intermediate states that never existed. SHEEP CHOICE — the terminal's own swap. Quotes come from a solver that splits one trade across several pools instead of forcing it down a single path, and returns a ready-to-sign Settler transaction; the UI draws the split leg by leg and scores every venue against one shared fee-free baseline, so the card that says it pays most actually does. The Kyber transaction path is gone — Kyber is read-only USD valuation now, and there is no Kyber calldata to sign. BRIDGE — deposits from other chains over Relay, Across and the native portal, priced side by side and sorted by what actually reaches you. No fee on any of them. In-flight transfers get a countdown and survive a reload. ZAP — add liquidity holding neither side of the pair; whatever needs swapping is done in the same flow, with an optional stake-after step. Uniswap V2 liquidity now shows up under POSITIONS. Swaps in flight are persisted, so a refresh mid-swap no longer loses the transaction. Pair labels copy their token and pool addresses, and jump to DexScreener. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
d73eaf873a
commit
bca538e7e3
@@ -0,0 +1,122 @@
|
||||
// Across quote + status via the Swap API (works for plain bridges AND
|
||||
// composed any-to-any routes — e.g. ETH out of Robinhood becomes
|
||||
// ETH→USDG→bridge→ETH in ONE origin tx, ~5x cheaper than a direct ETH exit).
|
||||
// Keyless, CORS-open. Fee note: appFee is a DECIMAL FRACTION (0.001 = 0.1%)
|
||||
// and settles instantly in the destination-side output token — see
|
||||
// docs/bridge-research.md.
|
||||
import { encodeFunctionData, erc20Abi, type Address } from 'viem'
|
||||
import { NATIVE_SENTINEL, type ResolvedIntent } from '../../config/bridge'
|
||||
import { BridgeQuoteError, type BridgeFee, type BridgeQuote, type BridgeStep } from './types'
|
||||
import { QUOTE_PLACEHOLDER } from './relay'
|
||||
|
||||
const ACROSS_API = 'https://app.across.to/api'
|
||||
|
||||
/** Across's appFee unit is a decimal fraction: 10 bps -> "0.001" */
|
||||
export const acrossAppFee = (bps: number): string => (bps / 10_000).toString()
|
||||
|
||||
export type AcrossQuoteJson = {
|
||||
checks?: {
|
||||
allowance?: { token?: Address; spender?: Address; actual?: string; expected?: string }
|
||||
}
|
||||
swapTx?: { chainId: number; to: Address; data: `0x${string}`; value?: string | null }
|
||||
expectedOutputAmount?: string
|
||||
minOutputAmount?: string
|
||||
expectedFillTime?: number
|
||||
quoteExpiryTimestamp?: number
|
||||
message?: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
/** pure mapper. Across's own approvalTxns use infinite allowances — this
|
||||
* terminal's invariant is exact approvals, so the approve step is rebuilt
|
||||
* from checks.allowance for exactly the required amount. */
|
||||
export function mapAcrossQuote(json: AcrossQuoteJson): BridgeQuote {
|
||||
const tx = json.swapTx
|
||||
if (!tx || !json.expectedOutputAmount) {
|
||||
throw new BridgeQuoteError(json.message ?? 'across quote response is missing swapTx', json.code ?? null)
|
||||
}
|
||||
const steps: BridgeStep[] = []
|
||||
const allowance = json.checks?.allowance
|
||||
if (
|
||||
allowance?.token &&
|
||||
allowance.spender &&
|
||||
allowance.token.toLowerCase() !== NATIVE_SENTINEL.toLowerCase() &&
|
||||
BigInt(allowance.actual ?? '0') < BigInt(allowance.expected ?? '0')
|
||||
) {
|
||||
steps.push({
|
||||
kind: 'approve',
|
||||
chainId: tx.chainId,
|
||||
to: allowance.token,
|
||||
data: encodeFunctionData({
|
||||
abi: erc20Abi,
|
||||
functionName: 'approve',
|
||||
args: [allowance.spender, BigInt(allowance.expected ?? '0')],
|
||||
}),
|
||||
value: 0n,
|
||||
})
|
||||
}
|
||||
steps.push({
|
||||
kind: 'deposit',
|
||||
chainId: tx.chainId,
|
||||
to: tx.to,
|
||||
data: tx.data,
|
||||
value: BigInt(tx.value ?? '0'),
|
||||
})
|
||||
return {
|
||||
provider: 'across',
|
||||
outputAmount: BigInt(json.expectedOutputAmount),
|
||||
minOutput: BigInt(json.minOutputAmount ?? json.expectedOutputAmount),
|
||||
etaSec: json.expectedFillTime ?? 0,
|
||||
steps,
|
||||
tracker: { provider: 'across', originChainId: tx.chainId },
|
||||
expiresAt: json.quoteExpiryTimestamp ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function quoteAcross(
|
||||
leg: ResolvedIntent,
|
||||
amount: bigint,
|
||||
fee: BridgeFee,
|
||||
user: Address | null,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BridgeQuote> {
|
||||
const payer = user ?? QUOTE_PLACEHOLDER
|
||||
const params = new URLSearchParams({
|
||||
amount: amount.toString(),
|
||||
inputToken: leg.inputToken,
|
||||
outputToken: leg.outputToken,
|
||||
originChainId: String(leg.originChainId),
|
||||
destinationChainId: String(leg.destChainId),
|
||||
depositor: payer,
|
||||
refundAddress: payer,
|
||||
})
|
||||
// zero-fee mode omits appFee — don't bet on providers accepting "0"
|
||||
if (fee.bps > 0) {
|
||||
params.set('appFee', acrossAppFee(fee.bps))
|
||||
params.set('appFeeRecipient', fee.receiver)
|
||||
}
|
||||
const res = await fetch(`${ACROSS_API}/swap/approval?${params}`, { signal: signal ?? null })
|
||||
const json = (await res.json()) as AcrossQuoteJson
|
||||
if (!res.ok) {
|
||||
throw new BridgeQuoteError(json.message ?? `across quote failed (${res.status})`, json.code ?? null)
|
||||
}
|
||||
return mapAcrossQuote(json)
|
||||
}
|
||||
|
||||
// ---- fill tracking ----
|
||||
|
||||
export type AcrossStatus = 'pending' | 'filled' | 'expired' | 'refunded'
|
||||
|
||||
export async function fetchAcrossStatus(
|
||||
originChainId: number,
|
||||
depositTxHash: string,
|
||||
): Promise<{ status: AcrossStatus; fillTx?: string; destinationChainId?: number }> {
|
||||
const res = await fetch(
|
||||
`${ACROSS_API}/deposit/status?originChainId=${originChainId}&depositTxnRef=${depositTxHash}`,
|
||||
)
|
||||
// the indexer lags the deposit tx by a few seconds — treat not-found as pending
|
||||
if (!res.ok) return { status: 'pending' }
|
||||
const json = (await res.json()) as { status?: string; fillTx?: string; destinationChainId?: number }
|
||||
const status = (json.status === 'unfilled' ? 'pending' : json.status) as AcrossStatus | undefined
|
||||
return { status: status ?? 'pending', fillTx: json.fillTx, destinationChainId: json.destinationChainId }
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { encodeAbiParameters, encodeFunctionData, erc20Abi, pad, toFunctionSelector, toHex, type Address, type Hex, type TransactionReceipt } from 'viem'
|
||||
import { NATIVE_SENTINEL, PORTAL_ETA_SEC, PORTAL_INBOX, REMOTE_CHAINS, resolveIntent, type BridgeTokenOption } from '../../config/bridge'
|
||||
import { acrossAppFee, mapAcrossQuote, quoteAcross, type AcrossQuoteJson } from './across'
|
||||
import { checkPendingTransfer, fmtEtaShort, nextCheckAt, pendingBridges, type PendingTransfer } from './pending'
|
||||
import { aliasL1Address, childEthDepositTxHash, DEPOSIT_ETH_CALLDATA, parseEthDepositReceipt, quotePortal } from './portal'
|
||||
import { mapRelayQuote, quoteRelay, relayAppFee, type RelayQuoteJson } from './relay'
|
||||
import { mergeTokenSupports, providersFor, sameSymbolLoose, type AcrossRouteJson, type RelayChainsJson, type RelayCurrencyV2 } from './tokens'
|
||||
import { BridgeQuoteError, type BridgeFee } from './types'
|
||||
|
||||
const ETH_REMOTE = REMOTE_CHAINS[0] // ETHEREUM
|
||||
const BASE_REMOTE = REMOTE_CHAINS.find((r) => r.label === 'BASE')!
|
||||
|
||||
const RH_USDG = '0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168' as Address
|
||||
const L1_USDG = '0xe343167631d89B6Ffc58B88d6b7fB0228795491D' as Address
|
||||
const RH_WETH = '0x0Bd7D308f8E1639FAb988df18A8011f41EAcAD73' as Address
|
||||
const L1_WETH = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' as Address
|
||||
const L1_USDC = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Address
|
||||
|
||||
const USDG_OPTION: BridgeTokenOption = {
|
||||
symbol: 'USDG',
|
||||
decimals: 6,
|
||||
robinhoodToken: RH_USDG,
|
||||
remoteToken: L1_USDG,
|
||||
providers: ['relay', 'across'],
|
||||
}
|
||||
const ETH_OPTION: BridgeTokenOption = {
|
||||
symbol: 'ETH',
|
||||
decimals: 18,
|
||||
robinhoodToken: NATIVE_SENTINEL,
|
||||
remoteToken: NATIVE_SENTINEL,
|
||||
providers: ['portal', 'relay', 'across'],
|
||||
}
|
||||
|
||||
// fixtures are trimmed live API captures (2026-07-18), shapes verbatim
|
||||
const RELAY_USDG_OUT: RelayQuoteJson = {
|
||||
steps: [
|
||||
{
|
||||
id: 'approve',
|
||||
kind: 'transaction',
|
||||
requestId: '0xcfb2f6aa3bbe7a8fedc5a7ab20a87c0d97f39136250a935da21d925e8aeabb34',
|
||||
items: [
|
||||
{
|
||||
data: {
|
||||
to: '0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168',
|
||||
data: '0x095ea7b3',
|
||||
value: '0',
|
||||
chainId: 4663,
|
||||
},
|
||||
check: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'deposit',
|
||||
kind: 'transaction',
|
||||
requestId: '0xcfb2f6aa3bbe7a8fedc5a7ab20a87c0d97f39136250a935da21d925e8aeabb34',
|
||||
items: [
|
||||
{
|
||||
data: {
|
||||
to: '0x4cd00e387622c35bddb9b4c962c136462338bc31',
|
||||
data: '0xe8017952',
|
||||
value: null,
|
||||
chainId: 4663,
|
||||
},
|
||||
check: { endpoint: '/intents/status?requestId=0xcfb2…' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
details: {
|
||||
currencyOut: { amount: '99831382', minimumAmount: '97834754' },
|
||||
timeEstimate: 1,
|
||||
},
|
||||
}
|
||||
|
||||
const ACROSS_USDG_OUT: AcrossQuoteJson = {
|
||||
checks: {
|
||||
allowance: {
|
||||
token: '0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168',
|
||||
spender: '0xD29C85F15DF544bA632C9E25829fd29d767d7978',
|
||||
actual: '0',
|
||||
expected: '100000000',
|
||||
},
|
||||
},
|
||||
swapTx: {
|
||||
chainId: 4663,
|
||||
to: '0xD29C85F15DF544bA632C9E25829fd29d767d7978',
|
||||
data: '0xad5425c6',
|
||||
value: null,
|
||||
},
|
||||
expectedOutputAmount: '99830520',
|
||||
minOutputAmount: '99830520',
|
||||
expectedFillTime: 3,
|
||||
quoteExpiryTimestamp: 1784358599,
|
||||
}
|
||||
|
||||
const ACROSS_ETH_IN: AcrossQuoteJson = {
|
||||
checks: {
|
||||
allowance: {
|
||||
token: '0x0000000000000000000000000000000000000000',
|
||||
spender: '0x10D8b8DaA26d307489803e10477De69C0492B610',
|
||||
actual: '115792089237316195423570985008687907853269984665640564039457584007913129639935',
|
||||
expected: '10000000000000000',
|
||||
},
|
||||
},
|
||||
swapTx: {
|
||||
chainId: 8453,
|
||||
to: '0x10D8b8DaA26d307489803e10477De69C0492B610',
|
||||
data: '0x1a2b3c4d',
|
||||
value: '10000000000000000',
|
||||
},
|
||||
expectedOutputAmount: '9963410317377467',
|
||||
minOutputAmount: '9963410317377467',
|
||||
expectedFillTime: 1,
|
||||
}
|
||||
|
||||
test('relay mapper: approve+deposit steps, requestId, bigint outputs', () => {
|
||||
const q = mapRelayQuote(RELAY_USDG_OUT)
|
||||
assert.equal(q.provider, 'relay')
|
||||
assert.deepEqual(
|
||||
q.steps.map((s) => s.kind),
|
||||
['approve', 'deposit'],
|
||||
)
|
||||
assert.equal(q.steps[0].chainId, 4663)
|
||||
assert.equal(q.steps[1].value, 0n) // null value -> 0n
|
||||
assert.equal(q.outputAmount, 99831382n)
|
||||
assert.equal(q.minOutput, 97834754n)
|
||||
assert.equal(q.etaSec, 1)
|
||||
assert.deepEqual(q.tracker, {
|
||||
provider: 'relay',
|
||||
requestId: '0xcfb2f6aa3bbe7a8fedc5a7ab20a87c0d97f39136250a935da21d925e8aeabb34',
|
||||
})
|
||||
assert.equal(q.expiresAt, null)
|
||||
})
|
||||
|
||||
test('relay mapper refuses non-transaction step kinds', () => {
|
||||
const bad: RelayQuoteJson = {
|
||||
...RELAY_USDG_OUT,
|
||||
steps: [{ id: 'authorize', kind: 'signature', items: [] }],
|
||||
}
|
||||
assert.throws(() => mapRelayQuote(bad), BridgeQuoteError)
|
||||
})
|
||||
|
||||
test('across mapper rebuilds an EXACT approve (never the API infinite one)', () => {
|
||||
const q = mapAcrossQuote(ACROSS_USDG_OUT)
|
||||
assert.deepEqual(
|
||||
q.steps.map((s) => s.kind),
|
||||
['approve', 'deposit'],
|
||||
)
|
||||
const approve = q.steps[0]
|
||||
assert.equal(approve.to, '0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168')
|
||||
assert.equal(
|
||||
approve.data,
|
||||
encodeFunctionData({
|
||||
abi: erc20Abi,
|
||||
functionName: 'approve',
|
||||
args: ['0xD29C85F15DF544bA632C9E25829fd29d767d7978', 100000000n],
|
||||
}),
|
||||
)
|
||||
assert.equal(q.steps[1].value, 0n) // swapTx.value null -> 0n
|
||||
assert.equal(q.outputAmount, 99830520n)
|
||||
assert.equal(q.expiresAt, 1784358599)
|
||||
assert.deepEqual(q.tracker, { provider: 'across', originChainId: 4663 })
|
||||
})
|
||||
|
||||
test('across mapper: native input needs no approve, keeps value', () => {
|
||||
const q = mapAcrossQuote(ACROSS_ETH_IN)
|
||||
assert.deepEqual(
|
||||
q.steps.map((s) => s.kind),
|
||||
['deposit'],
|
||||
)
|
||||
assert.equal(q.steps[0].value, 10000000000000000n)
|
||||
assert.equal(q.tracker.provider === 'across' && q.tracker.originChainId, 8453)
|
||||
})
|
||||
|
||||
test('across mapper surfaces provider errors', () => {
|
||||
assert.throws(() => mapAcrossQuote({ message: 'Unable to find tokenDetails', code: 'ROUTE_NOT_ENABLED' }), BridgeQuoteError)
|
||||
})
|
||||
|
||||
test('fee units per provider derive from one bps number', () => {
|
||||
// Relay: bps as string; Across: decimal fraction — mixing these up would
|
||||
// charge 100x, so pin both encodings (9 bps as the example rate)
|
||||
assert.equal(relayAppFee(9), '9')
|
||||
assert.equal(acrossAppFee(9), '0.0009')
|
||||
})
|
||||
|
||||
test('zero-fee quotes omit provider fee params entirely', async () => {
|
||||
// fee.bps 0 must drop appFees/appFee from the requests — sending "0" would
|
||||
// gamble on both providers' validation instead of our own
|
||||
const leg = resolveIntent({ dir: 'out', token: USDG_OPTION, remote: ETH_REMOTE, amount: 1_000_000n })
|
||||
const fee: BridgeFee = { bps: 0, receiver: '0x1111111111111111111111111111111111111111' as Address }
|
||||
const seen: { relayBody?: Record<string, unknown>; acrossUrl?: string } = {}
|
||||
const orig = globalThis.fetch
|
||||
globalThis.fetch = (async (url: RequestInfo | URL, init?: RequestInit) => {
|
||||
const u = String(url)
|
||||
if (init?.body) seen.relayBody = JSON.parse(String(init.body)) as Record<string, unknown>
|
||||
else seen.acrossUrl = u
|
||||
return new Response(JSON.stringify({ message: 'stub reject' }), { status: 500 })
|
||||
}) as typeof fetch
|
||||
try {
|
||||
await assert.rejects(quoteRelay(leg, 1_000_000n, fee, null), BridgeQuoteError)
|
||||
await assert.rejects(quoteAcross(leg, 1_000_000n, fee, null), BridgeQuoteError)
|
||||
} finally {
|
||||
globalThis.fetch = orig
|
||||
}
|
||||
assert.ok(seen.relayBody && !('appFees' in seen.relayBody))
|
||||
assert.ok(seen.acrossUrl && !seen.acrossUrl.includes('appFee'))
|
||||
})
|
||||
|
||||
test('intent resolution: robinhood is always one leg, same token both sides', () => {
|
||||
const out = resolveIntent({ dir: 'out', token: ETH_OPTION, remote: ETH_REMOTE, amount: 1n })
|
||||
assert.equal(out.originChainId, 4663)
|
||||
assert.equal(out.destChainId, ETH_REMOTE.chain.id)
|
||||
const usdgIn = resolveIntent({ dir: 'in', token: USDG_OPTION, remote: ETH_REMOTE, amount: 1n })
|
||||
assert.equal(usdgIn.originChainId, ETH_REMOTE.chain.id)
|
||||
assert.equal(usdgIn.inputToken, L1_USDG)
|
||||
assert.equal(usdgIn.outputToken, RH_USDG)
|
||||
assert.equal(usdgIn.inputDecimals, 6)
|
||||
assert.equal(usdgIn.inputSymbol, 'USDG')
|
||||
assert.equal(usdgIn.outputSymbol, 'USDG')
|
||||
})
|
||||
|
||||
// ---- canonical bridge (portal) ----
|
||||
|
||||
// real deposit pair (L1 tx 0x4bbe5afd…, validated live 2026-07-18): the bridge
|
||||
// event's aliased sender, the InboxMessageDelivered payload and the resulting
|
||||
// child tx hash on Robinhood
|
||||
const FIXTURE = {
|
||||
msgNum: 43494n,
|
||||
aliasedSender: '0xc66B13d57560540773956d0A78D2f18f0e30F8FF' as Address,
|
||||
dest: '0xb55a13d57560540773956D0a78d2F18f0e30E7EE' as Address,
|
||||
value: 90658430000000000n,
|
||||
childTxHash: '0x5ed39f3bf5979435ead99b4b45aa62be82434caf60872dacc0d4b4d21764eb2e' as Hex,
|
||||
}
|
||||
|
||||
test('portal: depositEth calldata is the real selector', () => {
|
||||
assert.equal(DEPOSIT_ETH_CALLDATA, toFunctionSelector('function depositEth()'))
|
||||
})
|
||||
|
||||
test('portal: L1→L2 alias matches a real deposit (sender = alias(dest) for EOA self-deposit)', () => {
|
||||
assert.equal(aliasL1Address(FIXTURE.dest), FIXTURE.aliasedSender)
|
||||
})
|
||||
|
||||
test('portal: child EthDeposit tx hash derivation matches a real fill', () => {
|
||||
const h = childEthDepositTxHash(4663n, FIXTURE.msgNum, FIXTURE.aliasedSender, FIXTURE.dest, FIXTURE.value)
|
||||
assert.equal(h, FIXTURE.childTxHash)
|
||||
})
|
||||
|
||||
test('portal: deposit receipt parses to the derived child hash', () => {
|
||||
const packed = `0x${FIXTURE.dest.slice(2)}${pad(toHex(FIXTURE.value), { size: 32 }).slice(2)}` as Hex
|
||||
const receipt = {
|
||||
from: FIXTURE.dest, // EOA self-deposit: tx sender == destination
|
||||
logs: [
|
||||
{
|
||||
address: PORTAL_INBOX,
|
||||
topics: [
|
||||
'0xff64905f73a67fb594e0f940a8075a860db489ad991e032f48c81123eb52d60b',
|
||||
pad(toHex(FIXTURE.msgNum), { size: 32 }),
|
||||
],
|
||||
data: encodeAbiParameters([{ type: 'bytes' }], [packed]),
|
||||
},
|
||||
],
|
||||
} as unknown as TransactionReceipt
|
||||
assert.equal(parseEthDepositReceipt(receipt), FIXTURE.childTxHash)
|
||||
})
|
||||
|
||||
test('portal: quotes are lossless 1:1 with the measured ETA, deposits-from-Ethereum only', () => {
|
||||
const leg = resolveIntent({ dir: 'in', token: ETH_OPTION, remote: ETH_REMOTE, amount: 123n })
|
||||
const q = quotePortal(leg, 123n)
|
||||
assert.equal(q.outputAmount, 123n)
|
||||
assert.equal(q.minOutput, 123n)
|
||||
assert.equal(q.etaSec, PORTAL_ETA_SEC)
|
||||
assert.equal(q.expiresAt, null)
|
||||
assert.deepEqual(q.steps, [
|
||||
{ kind: 'deposit', chainId: 1, to: PORTAL_INBOX, data: DEPOSIT_ETH_CALLDATA, value: 123n },
|
||||
])
|
||||
// guards: no withdrawals, no non-Ethereum remotes, no ERC-20s
|
||||
assert.throws(() => quotePortal(resolveIntent({ dir: 'out', token: ETH_OPTION, remote: ETH_REMOTE, amount: 1n }), 1n), BridgeQuoteError)
|
||||
assert.throws(() => quotePortal(resolveIntent({ dir: 'in', token: ETH_OPTION, remote: BASE_REMOTE, amount: 1n }), 1n), BridgeQuoteError)
|
||||
assert.throws(() => quotePortal(resolveIntent({ dir: 'in', token: USDG_OPTION, remote: ETH_REMOTE, amount: 1n }), 1n), BridgeQuoteError)
|
||||
})
|
||||
|
||||
// ---- token discovery (same-token only, engine-reported support) ----
|
||||
|
||||
const RELAY_CHAINS_FIX: RelayChainsJson = {
|
||||
chains: [
|
||||
{
|
||||
id: 4663,
|
||||
currency: { symbol: 'ETH', decimals: 18, supportsBridging: true },
|
||||
erc20Currencies: [
|
||||
{ symbol: 'USDG', address: RH_USDG, decimals: 6, supportsBridging: true },
|
||||
{ symbol: 'DEAD', address: '0x00000000000000000000000000000000000dead1' as Address, decimals: 18, supportsBridging: false },
|
||||
],
|
||||
},
|
||||
{ id: 1, currency: { symbol: 'ETH', decimals: 18, supportsBridging: true } },
|
||||
{ id: 8453, currency: { symbol: 'ETH', decimals: 18, supportsBridging: true } },
|
||||
],
|
||||
}
|
||||
|
||||
const RELAY_CUR_FIX: Record<string, RelayCurrencyV2[]> = {
|
||||
USDG: [
|
||||
{ chainId: 4663, address: RH_USDG, symbol: 'USDG', decimals: 6, metadata: { verified: true } },
|
||||
// mainnet USDG is listed UNVERIFIED by relay — trusted only via the across route address
|
||||
{ chainId: 1, address: L1_USDG, symbol: 'USDG', decimals: 6, metadata: { verified: false } },
|
||||
],
|
||||
WETH: [
|
||||
{ chainId: 4663, address: RH_WETH, symbol: 'WETH', decimals: 18, metadata: { verified: false } },
|
||||
{ chainId: 1, address: L1_WETH, symbol: 'WETH', decimals: 18, metadata: { verified: true } },
|
||||
// scam twin on mainnet — must never be picked
|
||||
{ chainId: 1, address: '0x00000000000000000000000000000000000dead2' as Address, symbol: 'WETH', decimals: 18, metadata: { verified: false } },
|
||||
],
|
||||
}
|
||||
|
||||
const ACROSS_ROUTES_FIX: AcrossRouteJson[] = [
|
||||
// ethereum pair
|
||||
{ originChainId: 1, originToken: L1_WETH, destinationChainId: 4663, destinationToken: NATIVE_SENTINEL, originTokenSymbol: 'ETH', destinationTokenSymbol: 'ETH', isNative: true },
|
||||
{ originChainId: 1, originToken: L1_WETH, destinationChainId: 4663, destinationToken: RH_WETH, originTokenSymbol: 'WETH', destinationTokenSymbol: 'WETH' },
|
||||
// across labels mainnet USDG "USDG-MAINNET" — still the same token
|
||||
{ originChainId: 1, originToken: L1_USDG, destinationChainId: 4663, destinationToken: RH_USDG, originTokenSymbol: 'USDG-MAINNET', destinationTokenSymbol: 'USDG' },
|
||||
// cross-token legs (dropped by product decision): USDC→USDG and USDG→USDC
|
||||
{ originChainId: 1, originToken: L1_USDC, destinationChainId: 4663, destinationToken: RH_USDG, originTokenSymbol: 'USDC', destinationTokenSymbol: 'USDG' },
|
||||
{ originChainId: 4663, originToken: RH_USDG, destinationChainId: 1, destinationToken: L1_USDC, originTokenSymbol: 'USDG', destinationTokenSymbol: 'USDC' },
|
||||
// base pair: only ETH + cross-token USDC→USDG
|
||||
{ originChainId: 8453, originToken: '0x4200000000000000000000000000000000000006' as Address, destinationChainId: 4663, destinationToken: NATIVE_SENTINEL, originTokenSymbol: 'ETH', destinationTokenSymbol: 'ETH', isNative: true },
|
||||
{ originChainId: 8453, originToken: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' as Address, destinationChainId: 4663, destinationToken: RH_USDG, originTokenSymbol: 'USDC', destinationTokenSymbol: 'USDG' },
|
||||
]
|
||||
|
||||
test('discovery: ethereum pair = ETH(portal+relay+across) + USDG(relay+across) + WETH(across only)', () => {
|
||||
const list = mergeTokenSupports({
|
||||
remoteChainId: 1,
|
||||
relayChains: RELAY_CHAINS_FIX,
|
||||
relayCurrencies: RELAY_CUR_FIX,
|
||||
acrossRoutes: ACROSS_ROUTES_FIX,
|
||||
})
|
||||
assert.deepEqual(
|
||||
list.map((s) => s.symbol),
|
||||
['ETH', 'USDG', 'WETH'],
|
||||
)
|
||||
const [eth, usdg, weth] = list
|
||||
assert.deepEqual({ relay: eth.relay, across: eth.across, portal: eth.portal }, { relay: true, across: true, portal: true })
|
||||
assert.equal(usdg.remoteToken, L1_USDG) // route-confirmed, NOT the USDC leg
|
||||
assert.deepEqual({ relay: usdg.relay, across: usdg.across, portal: usdg.portal }, { relay: true, across: true, portal: false })
|
||||
assert.equal(weth.remoteToken, L1_WETH) // verified twin wins, scam twin ignored
|
||||
assert.deepEqual({ relay: weth.relay, across: weth.across, portal: weth.portal }, { relay: false, across: true, portal: false })
|
||||
// direction resolution: portal is deposit-only
|
||||
assert.deepEqual(providersFor(eth, 'in'), ['portal', 'relay', 'across'])
|
||||
assert.deepEqual(providersFor(eth, 'out'), ['relay', 'across'])
|
||||
})
|
||||
|
||||
test('discovery: cross-token USDC→USDG legs are gone; base pair has no USDG', () => {
|
||||
const list = mergeTokenSupports({
|
||||
remoteChainId: 8453,
|
||||
relayChains: RELAY_CHAINS_FIX,
|
||||
relayCurrencies: RELAY_CUR_FIX,
|
||||
acrossRoutes: ACROSS_ROUTES_FIX,
|
||||
})
|
||||
// USDG exists on the 4663 side but base has no same-token counterpart →
|
||||
// it must NOT appear (the old USDC→USDG mapping is exactly what was removed)
|
||||
assert.deepEqual(
|
||||
list.map((s) => s.symbol),
|
||||
['ETH'],
|
||||
)
|
||||
assert.equal(list[0].portal, false) // canonical bridge pairs with Ethereum only
|
||||
})
|
||||
|
||||
test('discovery: a failed live Inbox verification demotes the canonical route', () => {
|
||||
const list = mergeTokenSupports({
|
||||
remoteChainId: 1,
|
||||
relayChains: RELAY_CHAINS_FIX,
|
||||
relayCurrencies: RELAY_CUR_FIX,
|
||||
acrossRoutes: ACROSS_ROUTES_FIX,
|
||||
portalOk: false,
|
||||
})
|
||||
const eth = list.find((s) => s.symbol === 'ETH')!
|
||||
assert.equal(eth.portal, false)
|
||||
assert.equal(eth.relay, true) // the other engines are untouched
|
||||
})
|
||||
|
||||
test('discovery: loose symbol guard accepts chain-suffixed labels, rejects different assets', () => {
|
||||
assert.ok(sameSymbolLoose('USDG-MAINNET', 'USDG'))
|
||||
assert.ok(sameSymbolLoose('USDG', 'USDG'))
|
||||
assert.ok(!sameSymbolLoose('USDC', 'USDG'))
|
||||
assert.ok(!sameSymbolLoose('WETH', 'ETH'))
|
||||
})
|
||||
|
||||
// ---- pending transfers: conservative scheduling ----
|
||||
|
||||
const basePending: PendingTransfer = {
|
||||
id: '0xdep',
|
||||
provider: 'relay',
|
||||
tracker: { provider: 'relay', requestId: '0xreq' },
|
||||
createdAt: 1_000_000,
|
||||
etaSec: 5,
|
||||
originChainId: 1,
|
||||
destChainId: 4663,
|
||||
symbol: 'ETH',
|
||||
amountIn: '0.05',
|
||||
expectedOut: '50000000000000000',
|
||||
decimals: 18,
|
||||
depositTxHash: '0xdep',
|
||||
status: 'pending',
|
||||
}
|
||||
|
||||
test('pending: first check waits ~90% of ETA (min 8s), then slow cadence by speed class', () => {
|
||||
// fast bridge (5s eta): first check at the 8s floor, then every 20s
|
||||
assert.equal(nextCheckAt(basePending), 1_000_000 + 8_000)
|
||||
assert.equal(nextCheckAt({ ...basePending, checkedAt: 1_010_000 }), 1_030_000)
|
||||
// canonical (~600s): first check at 540s, then every 60s — a 10-min transfer
|
||||
// costs a handful of status reads, not hundreds
|
||||
const portal: PendingTransfer = { ...basePending, etaSec: 600 }
|
||||
assert.equal(nextCheckAt(portal), 1_000_000 + 540_000)
|
||||
assert.equal(nextCheckAt({ ...portal, checkedAt: 1_000_000 + 540_000 }), 1_000_000 + 600_000)
|
||||
})
|
||||
|
||||
test('pending: short eta formatter is locale-neutral', () => {
|
||||
assert.equal(fmtEtaShort(600), '~10m')
|
||||
assert.equal(fmtEtaShort(7), '~7s')
|
||||
})
|
||||
|
||||
test('pending: portal check resolves via child receipt probe; missing hash goes stale', async () => {
|
||||
const portalT: PendingTransfer = {
|
||||
...basePending,
|
||||
provider: 'portal',
|
||||
tracker: { provider: 'portal', childTxHash: FIXTURE.childTxHash },
|
||||
}
|
||||
const found = await checkPendingTransfer(portalT, async () => true)
|
||||
assert.equal(found.status, 'filled')
|
||||
assert.equal(found.fillTxHash, FIXTURE.childTxHash)
|
||||
const notYet = await checkPendingTransfer(portalT, async () => false)
|
||||
assert.equal(notYet.status, undefined)
|
||||
assert.ok(typeof notYet.checkedAt === 'number')
|
||||
const unparseable = await checkPendingTransfer(
|
||||
{ ...portalT, tracker: { provider: 'portal', childTxHash: null } },
|
||||
async () => true,
|
||||
)
|
||||
assert.equal(unparseable.status, 'stale')
|
||||
})
|
||||
|
||||
test('pending store: cross-tab writes merge instead of last-writer-wins', () => {
|
||||
// node has no localStorage — install a stub BEFORE the store's first lazy read
|
||||
const backing = new Map<string, string>()
|
||||
const stub = {
|
||||
getItem: (k: string) => backing.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void backing.set(k, v),
|
||||
removeItem: (k: string) => void backing.delete(k),
|
||||
}
|
||||
const g = globalThis as { localStorage?: unknown }
|
||||
g.localStorage = stub
|
||||
const KEY = 'up33.bridgePending.v1'
|
||||
const A: PendingTransfer = { ...basePending, id: 'A', depositTxHash: 'A' }
|
||||
const B: PendingTransfer = { ...basePending, id: 'B', depositTxHash: 'B' }
|
||||
try {
|
||||
backing.set(KEY, JSON.stringify([A]))
|
||||
assert.deepEqual(pendingBridges.get().map((e) => e.id), ['A']) // lazy first load
|
||||
|
||||
// another tab fills A and adds B; this tab then applies a stale pending-check patch
|
||||
backing.set(KEY, JSON.stringify([{ ...A, status: 'filled', fillTxHash: '0xf' }, B]))
|
||||
pendingBridges.update('A', { checkedAt: 123, status: 'stale' })
|
||||
const merged = pendingBridges.get()
|
||||
const a1 = merged.find((e) => e.id === 'A')!
|
||||
assert.equal(a1.status, 'filled') // terminal state never regresses
|
||||
assert.equal(a1.fillTxHash, '0xf')
|
||||
assert.equal(a1.checkedAt, 123) // non-status patch still lands
|
||||
assert.ok(merged.some((e) => e.id === 'B')) // the other tab's add survived
|
||||
|
||||
// another tab dismisses B → our next mutation drops it too
|
||||
backing.set(KEY, JSON.stringify([{ ...A, status: 'filled', fillTxHash: '0xf' }]))
|
||||
pendingBridges.update('A', { checkedAt: 456 })
|
||||
assert.ok(!pendingBridges.get().some((e) => e.id === 'B'))
|
||||
|
||||
// storage becomes unreadable → memory carries the session (never wiped)
|
||||
g.localStorage = {
|
||||
getItem: () => {
|
||||
throw new Error('blocked')
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error('blocked')
|
||||
},
|
||||
}
|
||||
pendingBridges.update('A', { checkedAt: 789 })
|
||||
assert.equal(pendingBridges.get().find((e) => e.id === 'A')?.checkedAt, 789)
|
||||
} finally {
|
||||
delete g.localStorage
|
||||
}
|
||||
})
|
||||
|
||||
test('pending: relay/across status mapping', async () => {
|
||||
const orig = globalThis.fetch
|
||||
try {
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ status: 'success', txHashes: ['0xa', '0xfill'] }), { status: 200 })) as typeof fetch
|
||||
const r = await checkPendingTransfer(basePending, async () => false)
|
||||
assert.equal(r.status, 'filled')
|
||||
assert.equal(r.fillTxHash, '0xfill')
|
||||
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ status: 'expired' }), { status: 200 })) as typeof fetch
|
||||
const acrossT: PendingTransfer = {
|
||||
...basePending,
|
||||
provider: 'across',
|
||||
tracker: { provider: 'across', originChainId: 1, depositTxHash: '0xdep' },
|
||||
}
|
||||
const a = await checkPendingTransfer(acrossT, async () => false)
|
||||
assert.equal(a.status, 'refunded')
|
||||
|
||||
// transient API failure → only checkedAt moves; cadence retries later
|
||||
globalThis.fetch = (async () => {
|
||||
throw new Error('network down')
|
||||
}) as typeof fetch
|
||||
const quiet = await checkPendingTransfer(basePending, async () => false)
|
||||
assert.equal(quiet.status, undefined)
|
||||
assert.ok(typeof quiet.checkedAt === 'number')
|
||||
} finally {
|
||||
globalThis.fetch = orig
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
// Bridge execution: send the provider-built origin-chain txs through the
|
||||
// shared step() runner, then hand the transfer to the persistent pending
|
||||
// registry (pending.ts). Fills are verified there on a conservative cadence —
|
||||
// this function returns as soon as the deposit lands, and the wallet is
|
||||
// switched back to Robinhood right away.
|
||||
import type { Address, TransactionReceipt } from 'viem'
|
||||
import { getChainId, sendTransaction, switchChain } from 'wagmi/actions'
|
||||
import { CHAIN_ID } from '../../config/addresses'
|
||||
import { explorerOf, type ResolvedIntent } from '../../config/bridge'
|
||||
import { asConfiguredChain, wagmiConfig } from '../../config/wagmi'
|
||||
import { t } from '../../i18n'
|
||||
import { invalidateAll, shortErr, step } from '../tx'
|
||||
import { txlog } from '../txlog'
|
||||
import { fmtEtaShort, pendingBridges, type PendingTracker } from './pending'
|
||||
import { parseEthDepositReceipt } from './portal'
|
||||
import type { BridgeQuote } from './types'
|
||||
|
||||
/** 'sent' = deposit confirmed and the transfer is tracked as pending */
|
||||
export type BridgeOutcome = 'sent' | null
|
||||
|
||||
/** live position inside an executing bridge, for inline UI progress */
|
||||
export type BridgeStage = 'approve' | 'deposit'
|
||||
|
||||
async function ensureWalletChain(chainId: number): Promise<boolean> {
|
||||
const target = asConfiguredChain(chainId)
|
||||
if (getChainId(wagmiConfig) === target) return true
|
||||
try {
|
||||
await switchChain(wagmiConfig, { chainId: target })
|
||||
return true
|
||||
} catch (e) {
|
||||
txlog.push('err', t('bridge.switchFailed', { err: shortErr(e) }))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function buildTracker(quote: BridgeQuote, depositRcpt: TransactionReceipt): PendingTracker {
|
||||
if (quote.tracker.provider === 'relay') return quote.tracker
|
||||
if (quote.tracker.provider === 'across')
|
||||
return { provider: 'across', originChainId: quote.tracker.originChainId, depositTxHash: depositRcpt.transactionHash }
|
||||
return { provider: 'portal', childTxHash: parseEthDepositReceipt(depositRcpt) }
|
||||
}
|
||||
|
||||
export async function executeBridge(
|
||||
quote: BridgeQuote,
|
||||
sender: Address,
|
||||
ctx: { leg: ResolvedIntent; amountInStr: string; depositLabel: string },
|
||||
onStage?: (stage: BridgeStage) => void,
|
||||
): Promise<BridgeOutcome> {
|
||||
if (quote.expiresAt !== null && Date.now() / 1000 > quote.expiresAt - 30) {
|
||||
txlog.push('err', t('bridge.quoteExpired'))
|
||||
return null
|
||||
}
|
||||
let depositRcpt: TransactionReceipt | null = null
|
||||
const originChain = quote.steps[0]?.chainId ?? CHAIN_ID
|
||||
if (!(await ensureWalletChain(originChain))) return null
|
||||
|
||||
for (const s of quote.steps) {
|
||||
onStage?.(s.kind)
|
||||
const rcpt = await step(
|
||||
s.kind === 'approve' ? t('tx.approve', { sym: ctx.leg.inputSymbol }) : ctx.depositLabel,
|
||||
() =>
|
||||
sendTransaction(wagmiConfig, {
|
||||
account: sender,
|
||||
to: s.to,
|
||||
data: s.data,
|
||||
value: s.value,
|
||||
chainId: asConfiguredChain(s.chainId),
|
||||
}),
|
||||
{ chainId: asConfiguredChain(s.chainId), explorer: explorerOf(s.chainId) },
|
||||
)
|
||||
if (!rcpt) return null
|
||||
if (s.kind === 'deposit') depositRcpt = rcpt
|
||||
}
|
||||
if (!depositRcpt) return null
|
||||
|
||||
pendingBridges.add({
|
||||
id: depositRcpt.transactionHash,
|
||||
provider: quote.provider,
|
||||
tracker: buildTracker(quote, depositRcpt),
|
||||
createdAt: Date.now(),
|
||||
etaSec: quote.etaSec,
|
||||
originChainId: ctx.leg.originChainId,
|
||||
destChainId: ctx.leg.destChainId,
|
||||
symbol: ctx.leg.outputSymbol,
|
||||
amountIn: ctx.amountInStr,
|
||||
expectedOut: quote.outputAmount.toString(),
|
||||
decimals: ctx.leg.outputDecimals,
|
||||
depositTxHash: depositRcpt.transactionHash,
|
||||
status: 'pending',
|
||||
})
|
||||
txlog.push('info', t('bridge.pendingTracked', { eta: fmtEtaShort(quote.etaSec) }))
|
||||
|
||||
// bring the wallet home after an off-Robinhood origin (best effort)
|
||||
if (originChain !== CHAIN_ID) {
|
||||
try {
|
||||
await switchChain(wagmiConfig, { chainId: CHAIN_ID })
|
||||
} catch {
|
||||
/* user declined — the header banner will offer the switch */
|
||||
}
|
||||
}
|
||||
invalidateAll()
|
||||
return 'sent'
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// Persistent registry of in-flight bridge transfers, polled CONSERVATIVELY.
|
||||
// Product decision (2026-07-18): after the deposit confirms we do NOT sit in a
|
||||
// tight status loop — the transfer becomes a PENDING entry on the bridge tab,
|
||||
// the first status check waits until ~90% of the provider's ETA, and follow-ups
|
||||
// run on a slow cadence (20s for fast engines, 60s for the ~10-min canonical
|
||||
// bridge). Entries survive reloads via localStorage; after an hour still
|
||||
// pending they go 'stale' (auto-polling stops, manual recheck stays).
|
||||
import type { Hex } from 'viem'
|
||||
import { fetchAcrossStatus } from './across'
|
||||
import { fetchRelayStatus } from './relay'
|
||||
import type { BridgeProviderId } from './types'
|
||||
|
||||
export type PendingTracker =
|
||||
| { provider: 'relay'; requestId: string }
|
||||
| { provider: 'across'; originChainId: number; depositTxHash: string }
|
||||
/** null childTxHash = receipt parse failed; untrackable, surfaces as stale */
|
||||
| { provider: 'portal'; childTxHash: Hex | null }
|
||||
|
||||
export type PendingStatus = 'pending' | 'filled' | 'refunded' | 'failed' | 'stale'
|
||||
|
||||
export type PendingTransfer = {
|
||||
/** deposit tx hash — unique per transfer */
|
||||
id: string
|
||||
provider: BridgeProviderId
|
||||
tracker: PendingTracker
|
||||
/** ms epoch of the deposit confirmation */
|
||||
createdAt: number
|
||||
etaSec: number
|
||||
originChainId: number
|
||||
destChainId: number
|
||||
/** same-token model: one symbol describes both legs */
|
||||
symbol: string
|
||||
/** origin-side amount, display units */
|
||||
amountIn: string
|
||||
/** expected destination amount, raw units as string */
|
||||
expectedOut: string
|
||||
decimals: number
|
||||
depositTxHash: string
|
||||
status: PendingStatus
|
||||
fillTxHash?: string
|
||||
checkedAt?: number
|
||||
}
|
||||
|
||||
const KEY = 'up33.bridgePending.v1'
|
||||
const MAX = 20
|
||||
|
||||
/** null = storage unavailable (blocked/absent) — distinct from an empty list,
|
||||
* so an unreadable disk is never mistaken for "another tab dismissed all" */
|
||||
function load(): PendingTransfer[] | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed.filter(
|
||||
(t): t is PendingTransfer =>
|
||||
!!t &&
|
||||
typeof (t as PendingTransfer).id === 'string' &&
|
||||
typeof (t as PendingTransfer).createdAt === 'number' &&
|
||||
['pending', 'filled', 'refunded', 'failed', 'stale'].includes((t as PendingTransfer).status) &&
|
||||
typeof (t as PendingTransfer).tracker === 'object',
|
||||
)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function save(list: PendingTransfer[]) {
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(list))
|
||||
} catch {
|
||||
/* storage blocked/full — the in-memory list still works this session */
|
||||
}
|
||||
}
|
||||
|
||||
let entries: PendingTransfer[] | null = null // lazy so module load never touches storage
|
||||
const subs = new Set<() => void>()
|
||||
let storageHooked = false
|
||||
|
||||
const isTerminal = (s: PendingStatus) => s === 'filled' || s === 'refunded' || s === 'failed'
|
||||
|
||||
function all(): PendingTransfer[] {
|
||||
entries ??= load() ?? []
|
||||
return entries
|
||||
}
|
||||
|
||||
/** re-sync with storage before mutating: several tabs share this store, and
|
||||
* whole-list last-writer-wins would drop the other tab's entries or status
|
||||
* advances. Disk is the shared truth: disk-only entries are another tab's
|
||||
* adds (keep), memory-only entries were dismissed there (drop), and an entry
|
||||
* present in both keeps its most-advanced form. */
|
||||
function fresh(): PendingTransfer[] {
|
||||
const disk = load()
|
||||
const mem = entries
|
||||
if (disk === null) return mem ?? [] // unreadable storage — memory carries on
|
||||
if (mem === null) return disk
|
||||
return disk.map((d) => {
|
||||
const m = mem.find((e) => e.id === d.id)
|
||||
if (!m) return d
|
||||
if (isTerminal(d.status) !== isTerminal(m.status)) return isTerminal(d.status) ? d : m
|
||||
return (m.checkedAt ?? 0) > (d.checkedAt ?? 0) ? m : d
|
||||
})
|
||||
}
|
||||
|
||||
function emit() {
|
||||
save(all())
|
||||
subs.forEach((f) => f())
|
||||
}
|
||||
|
||||
export const pendingBridges = {
|
||||
add(t: PendingTransfer) {
|
||||
const base = fresh()
|
||||
entries = base.some((e) => e.id === t.id) ? base : [t, ...base].slice(0, MAX)
|
||||
emit()
|
||||
},
|
||||
update(id: string, patch: Partial<PendingTransfer>) {
|
||||
entries = fresh().map((e) => {
|
||||
if (e.id !== id) return e
|
||||
const next = { ...e, ...patch }
|
||||
// a terminal state never regresses (e.g. a stale verdict computed from a
|
||||
// pre-fill snapshot, or a slow check racing another tab's fill)
|
||||
if (isTerminal(e.status) && patch.status && !isTerminal(patch.status)) next.status = e.status
|
||||
return next
|
||||
})
|
||||
emit()
|
||||
},
|
||||
dismiss(id: string) {
|
||||
entries = fresh().filter((e) => e.id !== id)
|
||||
emit()
|
||||
},
|
||||
get(): PendingTransfer[] {
|
||||
return all()
|
||||
},
|
||||
subscribe(f: () => void): () => void {
|
||||
// cross-tab sync: adopt another tab's write when it lands ('storage' only
|
||||
// fires in OTHER tabs — exactly the direction we need)
|
||||
if (!storageHooked && typeof window !== 'undefined') {
|
||||
storageHooked = true
|
||||
window.addEventListener('storage', (ev) => {
|
||||
if (ev.key !== KEY) return
|
||||
entries = load() ?? entries
|
||||
subs.forEach((s) => s())
|
||||
})
|
||||
}
|
||||
subs.add(f)
|
||||
return () => subs.delete(f)
|
||||
},
|
||||
}
|
||||
|
||||
// ---- conservative scheduling (pure, unit-tested) ----
|
||||
|
||||
/** pending this long → 'stale': auto-polling stops, manual recheck remains */
|
||||
export const PENDING_STALE_MS = 60 * 60_000
|
||||
|
||||
/** first check ≈90% of ETA (never under 8s), then 20s/60s cadence by speed class */
|
||||
export function nextCheckAt(t: PendingTransfer): number {
|
||||
const eta = t.etaSec * 1000
|
||||
const first = t.createdAt + Math.max(Math.round(eta * 0.9), 8_000)
|
||||
if (!t.checkedAt) return first
|
||||
const cadence = eta >= 120_000 ? 60_000 : 20_000
|
||||
return Math.max(first, t.checkedAt + cadence)
|
||||
}
|
||||
|
||||
export const isStale = (t: PendingTransfer, now: number) => now - t.createdAt > PENDING_STALE_MS
|
||||
|
||||
/** locale-neutral short ETA for terminal rows: 600 → "~10m", 7 → "~7s" */
|
||||
export const fmtEtaShort = (sec: number): string =>
|
||||
sec >= 90 ? `~${Math.round(sec / 60)}m` : `~${Math.max(1, Math.round(sec))}s`
|
||||
|
||||
// ---- status checking (portal receipt probe injected: wagmi stays out of here) ----
|
||||
|
||||
export type PortalReceiptProbe = (childTxHash: Hex) => Promise<boolean>
|
||||
|
||||
/** one status check → patch to merge (always bumps checkedAt; transient API
|
||||
* errors resolve to just that, so the cadence retries silently) */
|
||||
export async function checkPendingTransfer(
|
||||
t: PendingTransfer,
|
||||
portalReceipt: PortalReceiptProbe,
|
||||
): Promise<Partial<PendingTransfer>> {
|
||||
const base: Partial<PendingTransfer> = { checkedAt: Date.now() }
|
||||
try {
|
||||
if (t.tracker.provider === 'relay') {
|
||||
const s = await fetchRelayStatus(t.tracker.requestId)
|
||||
if (s.status === 'success') return { ...base, status: 'filled', fillTxHash: s.txHashes?.at(-1) }
|
||||
if (s.status === 'refund') return { ...base, status: 'refunded' }
|
||||
if (s.status === 'failure') return { ...base, status: 'failed' }
|
||||
return base
|
||||
}
|
||||
if (t.tracker.provider === 'across') {
|
||||
const s = await fetchAcrossStatus(t.tracker.originChainId, t.tracker.depositTxHash)
|
||||
if (s.status === 'filled') return { ...base, status: 'filled', fillTxHash: s.fillTx }
|
||||
if (s.status === 'refunded' || s.status === 'expired') return { ...base, status: 'refunded' }
|
||||
return base
|
||||
}
|
||||
// portal: the child tx hash was derived from the deposit receipt — arrival
|
||||
// is one receipt lookup on our own RPC
|
||||
if (t.tracker.childTxHash === null) return { ...base, status: 'stale' }
|
||||
const found = await portalReceipt(t.tracker.childTxHash)
|
||||
return found ? { ...base, status: 'filled', fillTxHash: t.tracker.childTxHash } : base
|
||||
} catch {
|
||||
return base
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Canonical Arbitrum bridge ("portal") provider: native ETH deposits
|
||||
// Ethereum → Robinhood via Inbox.depositEth(). Lossless 1:1 — no solver, no
|
||||
// bridge fee, only L1 gas — but slow (real deposits measured 484–689s on
|
||||
// 2026-07-18, so it quotes PORTAL_ETA_SEC). Scope is deliberately narrow:
|
||||
// - ETH only. ERC-20s canonically bridge into the gateway's OWN wrapped
|
||||
// tokens: verified on-chain that calculateL2TokenAddress(mainnet USDG)
|
||||
// != the real Robinhood USDG (which is LayerZero-OFT-issued instead).
|
||||
// - deposits only. Canonical withdrawals sit out the rollup challenge
|
||||
// period (days) — the external portal link stays the escape hatch.
|
||||
// Fill tracking needs no status API at all: an EthDeposit's child tx hash is
|
||||
// derivable from the deposit receipt, so "did it arrive" is one receipt read
|
||||
// on our own Robinhood RPC. Formula validated against 3 real deposit pairs.
|
||||
import {
|
||||
concatHex,
|
||||
decodeAbiParameters,
|
||||
getAddress,
|
||||
keccak256,
|
||||
pad,
|
||||
toHex,
|
||||
toRlp,
|
||||
type Address,
|
||||
type Hex,
|
||||
type TransactionReceipt,
|
||||
} from 'viem'
|
||||
import {
|
||||
NATIVE_SENTINEL,
|
||||
PORTAL_ETA_SEC,
|
||||
PORTAL_INBOX,
|
||||
PORTAL_PARENT_CHAIN_ID,
|
||||
type ResolvedIntent,
|
||||
} from '../../config/bridge'
|
||||
import { CHAIN_ID } from '../../config/addresses'
|
||||
import { BridgeQuoteError, type BridgeQuote } from './types'
|
||||
|
||||
/** Inbox.depositEth() — credits msg.sender (its alias for contract wallets) on the child chain */
|
||||
export const DEPOSIT_ETH_CALLDATA = '0x439370b1' as Hex
|
||||
|
||||
/** topic0 of Inbox's InboxMessageDelivered(uint256 indexed messageNum, bytes data) */
|
||||
const INBOX_MSG_TOPIC = '0xff64905f73a67fb594e0f940a8075a860db489ad991e032f48c81123eb52d60b'
|
||||
|
||||
export function quotePortal(leg: ResolvedIntent, amount: bigint): BridgeQuote {
|
||||
if (
|
||||
leg.originChainId !== PORTAL_PARENT_CHAIN_ID ||
|
||||
leg.destChainId !== CHAIN_ID ||
|
||||
leg.inputToken.toLowerCase() !== NATIVE_SENTINEL.toLowerCase()
|
||||
) {
|
||||
throw new BridgeQuoteError('canonical bridge only deposits native ETH from Ethereum')
|
||||
}
|
||||
return {
|
||||
provider: 'portal',
|
||||
outputAmount: amount,
|
||||
minOutput: amount,
|
||||
etaSec: PORTAL_ETA_SEC,
|
||||
steps: [
|
||||
{
|
||||
kind: 'deposit',
|
||||
chainId: PORTAL_PARENT_CHAIN_ID,
|
||||
to: PORTAL_INBOX,
|
||||
data: DEPOSIT_ETH_CALLDATA,
|
||||
value: amount,
|
||||
},
|
||||
],
|
||||
tracker: { provider: 'portal' },
|
||||
expiresAt: null,
|
||||
}
|
||||
}
|
||||
|
||||
/** L1→L2 sender aliasing (AddressAliasHelper) — the Inbox applies it to every
|
||||
* delayed message's sender, and the child EthDeposit tx carries the alias */
|
||||
export function aliasL1Address(addr: Address): Address {
|
||||
const OFFSET = 0x1111000000000000000000000000000000001111n
|
||||
return getAddress(toHex((BigInt(addr) + OFFSET) & ((1n << 160n) - 1n), { size: 20 }))
|
||||
}
|
||||
|
||||
const rlpNum = (x: bigint): Hex => (x === 0n ? '0x' : toHex(x))
|
||||
|
||||
/** child-chain tx hash of an EthDeposit message: keccak256(0x64 ‖ rlp([chainId,
|
||||
* msgNum₃₂, aliasedSender, dest, value])) — 0x64 = ArbitrumDepositTx type.
|
||||
* Validated 3/3 against real Ethereum→Robinhood deposits (2026-07-18). */
|
||||
export function childEthDepositTxHash(
|
||||
chainId: bigint,
|
||||
messageNum: bigint,
|
||||
aliasedSender: Address,
|
||||
dest: Address,
|
||||
value: bigint,
|
||||
): Hex {
|
||||
return keccak256(
|
||||
concatHex(['0x64', toRlp([rlpNum(chainId), pad(toHex(messageNum), { size: 32 }), aliasedSender, dest, rlpNum(value)])]),
|
||||
)
|
||||
}
|
||||
|
||||
/** derive the child tx hash from a confirmed depositEth receipt: messageNum +
|
||||
* packed (dest ‖ value) come from the Inbox's InboxMessageDelivered log, the
|
||||
* sender is the alias of the tx sender (the Inbox aliases unconditionally) */
|
||||
export function parseEthDepositReceipt(receipt: TransactionReceipt): Hex | null {
|
||||
const log = receipt.logs.find(
|
||||
(l) => l.address.toLowerCase() === PORTAL_INBOX.toLowerCase() && l.topics[0] === INBOX_MSG_TOPIC,
|
||||
)
|
||||
if (!log || !log.topics[1] || !receipt.from) return null
|
||||
const [packed] = decodeAbiParameters([{ type: 'bytes' }], log.data)
|
||||
if (packed.length < 2 + 40) return null
|
||||
const dest = getAddress(`0x${packed.slice(2, 42)}`)
|
||||
const value = BigInt(`0x${packed.slice(42) || '0'}`)
|
||||
return childEthDepositTxHash(BigInt(CHAIN_ID), BigInt(log.topics[1]), aliasL1Address(receipt.from), dest, value)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// Relay (relay.link) quote + status. Keyless, CORS-open; quotes are POSTed and
|
||||
// return ready-to-send origin-chain txs. Fee note: appFees takes BPS AS A
|
||||
// STRING ("10" = 0.1%) and accrues off-chain as USDC (claim on Base is free) —
|
||||
// see docs/bridge-research.md.
|
||||
import type { Address } from 'viem'
|
||||
import type { ResolvedIntent } from '../../config/bridge'
|
||||
import { BridgeQuoteError, type BridgeFee, type BridgeQuote, type BridgeStep } from './types'
|
||||
|
||||
const RELAY_API = 'https://api.relay.link'
|
||||
/** placeholder payer for pre-connect display quotes (Relay requires a user) */
|
||||
export const QUOTE_PLACEHOLDER = '0x000000000000000000000000000000000000dEaD' as Address
|
||||
|
||||
/** Relay's appFees unit is BPS as a string: 10 bps -> "10" */
|
||||
export const relayAppFee = (bps: number): string => String(bps)
|
||||
|
||||
type RelayTxData = {
|
||||
to: Address
|
||||
data: `0x${string}`
|
||||
value?: string | null
|
||||
chainId: number
|
||||
}
|
||||
type RelayStep = {
|
||||
id: string
|
||||
kind: string
|
||||
requestId?: string
|
||||
items?: { data?: RelayTxData; check?: { endpoint?: string } | null }[]
|
||||
}
|
||||
export type RelayQuoteJson = {
|
||||
steps?: RelayStep[]
|
||||
details?: {
|
||||
currencyOut?: { amount?: string; minimumAmount?: string }
|
||||
timeEstimate?: number
|
||||
}
|
||||
message?: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
/** pure mapper — throws BridgeQuoteError on shapes we refuse to execute */
|
||||
export function mapRelayQuote(json: RelayQuoteJson): BridgeQuote {
|
||||
const steps: BridgeStep[] = []
|
||||
let requestId: string | null = null
|
||||
for (const s of json.steps ?? []) {
|
||||
if (s.kind !== 'transaction') {
|
||||
throw new BridgeQuoteError(`relay quote needs unsupported step kind "${s.kind}"`)
|
||||
}
|
||||
requestId ??= s.requestId ?? null
|
||||
for (const item of s.items ?? []) {
|
||||
const d = item.data
|
||||
if (!d) continue
|
||||
steps.push({
|
||||
kind: s.id === 'approve' ? 'approve' : 'deposit',
|
||||
chainId: d.chainId,
|
||||
to: d.to,
|
||||
data: d.data,
|
||||
value: BigInt(d.value ?? '0'),
|
||||
})
|
||||
}
|
||||
}
|
||||
const out = json.details?.currencyOut
|
||||
if (!steps.length || !requestId || !out?.amount) {
|
||||
throw new BridgeQuoteError('relay quote response is missing steps/output')
|
||||
}
|
||||
return {
|
||||
provider: 'relay',
|
||||
outputAmount: BigInt(out.amount),
|
||||
minOutput: BigInt(out.minimumAmount ?? out.amount),
|
||||
etaSec: json.details?.timeEstimate ?? 0,
|
||||
steps,
|
||||
tracker: { provider: 'relay', requestId },
|
||||
expiresAt: null, // relay re-validates at execution; minOutput guards the fill
|
||||
}
|
||||
}
|
||||
|
||||
export async function quoteRelay(
|
||||
leg: ResolvedIntent,
|
||||
amount: bigint,
|
||||
fee: BridgeFee,
|
||||
user: Address | null,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BridgeQuote> {
|
||||
const payer = user ?? QUOTE_PLACEHOLDER
|
||||
const res = await fetch(`${RELAY_API}/quote`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: signal ?? null,
|
||||
body: JSON.stringify({
|
||||
user: payer,
|
||||
recipient: payer,
|
||||
// research: refunds are NOT automatic unless refundTo is explicit
|
||||
refundTo: payer,
|
||||
originChainId: leg.originChainId,
|
||||
destinationChainId: leg.destChainId,
|
||||
originCurrency: leg.inputToken,
|
||||
destinationCurrency: leg.outputToken,
|
||||
amount: amount.toString(),
|
||||
tradeType: 'EXACT_INPUT',
|
||||
referrer: 'lp-terminal',
|
||||
// zero-fee mode omits appFees — don't bet on providers accepting "0"
|
||||
...(fee.bps > 0 ? { appFees: [{ recipient: fee.receiver, fee: relayAppFee(fee.bps) }] } : {}),
|
||||
}),
|
||||
})
|
||||
const json = (await res.json()) as RelayQuoteJson
|
||||
if (!res.ok) {
|
||||
throw new BridgeQuoteError(json.message ?? `relay quote failed (${res.status})`, json.code ?? null)
|
||||
}
|
||||
return mapRelayQuote(json)
|
||||
}
|
||||
|
||||
// ---- fill tracking ----
|
||||
|
||||
export type RelayStatus =
|
||||
| 'waiting'
|
||||
| 'pending'
|
||||
| 'delayed'
|
||||
| 'success'
|
||||
| 'failure'
|
||||
| 'refund'
|
||||
| 'unknown'
|
||||
|
||||
export async function fetchRelayStatus(requestId: string): Promise<{ status: RelayStatus; txHashes?: string[] }> {
|
||||
const res = await fetch(`${RELAY_API}/intents/status?requestId=${requestId}`)
|
||||
if (!res.ok) return { status: 'unknown' }
|
||||
const json = (await res.json()) as { status?: string; txHashes?: string[] }
|
||||
return { status: (json.status as RelayStatus) ?? 'unknown', txHashes: json.txHashes }
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
// Which tokens can bridge between Robinhood and a given remote — discovered
|
||||
// from each engine's own support surface, never hardcoded, and SAME-TOKEN
|
||||
// routes only (USDC→USDG cross-token legs were removed by product decision
|
||||
// 2026-07-18; a route must pay out the symbol it took in).
|
||||
// Sources per remote:
|
||||
// Relay: GET /chains → Robinhood-side bridgeable set (currency +
|
||||
// erc20Currencies with supportsBridging) and
|
||||
// the remote's native-ETH support
|
||||
// POST /currencies/v2 → remote-side ERC-20 membership + decimals
|
||||
// Across: GET /available-routes → same-symbol pairs with BOTH addresses.
|
||||
// The Swap API composes same-token exits even where no native route
|
||||
// exists (probed 2026-07-18: USDG/ETH/WETH out all quote), so
|
||||
// across is attempted whenever the symbol exists on both sides.
|
||||
// Portal: canonical depositEth — native ETH, Ethereum→Robinhood only.
|
||||
// Scam-token guard: a relay currencies/v2 match is only trusted when relay
|
||||
// marks it verified OR its address is confirmed by an across route (term
|
||||
// search returns fake same-symbol tokens — observed live for WETH).
|
||||
import type { Address } from 'viem'
|
||||
import { CHAIN_ID } from '../../config/addresses'
|
||||
import {
|
||||
PORTAL_INBOX,
|
||||
PORTAL_PARENT_CHAIN_ID,
|
||||
NATIVE_SENTINEL,
|
||||
type BridgeDir,
|
||||
type BridgeTokenOption,
|
||||
type RemoteChain,
|
||||
} from '../../config/bridge'
|
||||
import type { BridgeProviderId } from './types'
|
||||
|
||||
const RELAY_API = 'https://api.relay.link'
|
||||
const ACROSS_API = 'https://app.across.to/api'
|
||||
|
||||
export type RelayChainCurrency = {
|
||||
symbol?: string
|
||||
address?: Address
|
||||
decimals?: number
|
||||
supportsBridging?: boolean
|
||||
}
|
||||
export type RelayChainsJson = {
|
||||
chains?: { id: number; currency?: RelayChainCurrency; erc20Currencies?: RelayChainCurrency[] }[]
|
||||
}
|
||||
export type RelayCurrencyV2 = {
|
||||
chainId: number
|
||||
address: Address
|
||||
symbol: string
|
||||
decimals: number
|
||||
metadata?: { verified?: boolean }
|
||||
}
|
||||
export type AcrossRouteJson = {
|
||||
originChainId: number
|
||||
originToken: Address
|
||||
destinationChainId: number
|
||||
destinationToken: Address
|
||||
originTokenSymbol: string
|
||||
destinationTokenSymbol: string
|
||||
isNative?: boolean
|
||||
}
|
||||
|
||||
/** direction-agnostic support facts for one same-token route */
|
||||
export type BridgeTokenSupport = {
|
||||
symbol: string
|
||||
decimals: number
|
||||
robinhoodToken: Address
|
||||
remoteToken: Address
|
||||
relay: boolean
|
||||
across: boolean
|
||||
portal: boolean
|
||||
}
|
||||
|
||||
/** provider order here is only the pre-quote render order — the UI re-sorts by price */
|
||||
export function providersFor(s: BridgeTokenSupport, dir: BridgeDir): BridgeProviderId[] {
|
||||
const out: BridgeProviderId[] = []
|
||||
if (s.portal && dir === 'in') out.push('portal')
|
||||
if (s.relay) out.push('relay')
|
||||
if (s.across) out.push('across')
|
||||
return out
|
||||
}
|
||||
|
||||
export function toTokenOption(s: BridgeTokenSupport, dir: BridgeDir): BridgeTokenOption {
|
||||
return {
|
||||
symbol: s.symbol,
|
||||
decimals: s.decimals,
|
||||
robinhoodToken: s.robinhoodToken,
|
||||
remoteToken: s.remoteToken,
|
||||
providers: providersFor(s, dir),
|
||||
}
|
||||
}
|
||||
|
||||
const eq = (a?: string, b?: string) => !!a && !!b && a.toLowerCase() === b.toLowerCase()
|
||||
|
||||
/** same-token symbol guard for across route labels: across suffixes chain
|
||||
* variants ("USDG-MAINNET" is mainnet USDG), so accept exact or dash-suffixed
|
||||
* forms while still rejecting different assets (USDC vs USDG) */
|
||||
export const sameSymbolLoose = (a: string, b: string) => a === b || a.startsWith(`${b}-`) || b.startsWith(`${a}-`)
|
||||
|
||||
/** pure merge over the raw source payloads — unit-tested against live captures */
|
||||
export function mergeTokenSupports(args: {
|
||||
remoteChainId: number
|
||||
relayChains: RelayChainsJson | null
|
||||
/** currencies/v2 lookups (queried with both chain ids), keyed by symbol */
|
||||
relayCurrencies: Record<string, RelayCurrencyV2[]>
|
||||
acrossRoutes: AcrossRouteJson[] | null
|
||||
/** live Inbox-bytecode verification result; defaults to the parent-chain predicate */
|
||||
portalOk?: boolean
|
||||
}): BridgeTokenSupport[] {
|
||||
const { remoteChainId, relayChains, relayCurrencies, acrossRoutes } = args
|
||||
const relayHome = relayChains?.chains?.find((c) => c.id === CHAIN_ID)
|
||||
const relayRemote = relayChains?.chains?.find((c) => c.id === remoteChainId)
|
||||
|
||||
// across: rows for this pair, either direction (same-token filtering happens
|
||||
// per candidate by ADDRESS, with a loose-symbol guard against cross-token rows)
|
||||
const pairRoutes = (acrossRoutes ?? []).filter(
|
||||
(r) =>
|
||||
(r.originChainId === remoteChainId && r.destinationChainId === CHAIN_ID) ||
|
||||
(r.originChainId === CHAIN_ID && r.destinationChainId === remoteChainId),
|
||||
)
|
||||
|
||||
const out: BridgeTokenSupport[] = []
|
||||
|
||||
// ---- native ETH ----
|
||||
const acrossEth = pairRoutes.some((r) => r.isNative && sameSymbolLoose(r.originTokenSymbol, r.destinationTokenSymbol))
|
||||
const relayEth =
|
||||
relayHome?.currency?.symbol === 'ETH' &&
|
||||
relayHome.currency.supportsBridging === true &&
|
||||
relayRemote?.currency?.symbol === 'ETH' &&
|
||||
relayRemote.currency.supportsBridging === true
|
||||
const portalEth = args.portalOk ?? remoteChainId === PORTAL_PARENT_CHAIN_ID
|
||||
if (acrossEth || relayEth || portalEth) {
|
||||
out.push({
|
||||
symbol: 'ETH',
|
||||
decimals: 18, // native-currency constant on every leg we pair
|
||||
robinhoodToken: NATIVE_SENTINEL,
|
||||
remoteToken: NATIVE_SENTINEL,
|
||||
relay: !!relayEth,
|
||||
across: acrossEth,
|
||||
portal: portalEth,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- ERC-20 candidates: relay's bridgeable home set ∪ across's 4663-side
|
||||
// tokens. Identity is ADDRESS-first: the candidate's Robinhood-side address
|
||||
// anchors both engines' data, and route symbols only act as a cross-token
|
||||
// guard (rejects USDC→USDG while keeping USDG-MAINNET→USDG).
|
||||
const relayHomeErc20 = (relayHome?.erc20Currencies ?? []).filter(
|
||||
(c) => c.symbol && c.address && c.supportsBridging !== false,
|
||||
)
|
||||
type Candidate = { symbol: string; homeAddr: Address; relayHomeDec?: number; relayHome: boolean }
|
||||
const cands = new Map<string, Candidate>()
|
||||
for (const c of relayHomeErc20) {
|
||||
cands.set((c.address as Address).toLowerCase(), {
|
||||
symbol: c.symbol as string,
|
||||
homeAddr: c.address as Address,
|
||||
relayHomeDec: c.decimals,
|
||||
relayHome: true,
|
||||
})
|
||||
}
|
||||
for (const r of pairRoutes) {
|
||||
if (r.isNative) continue
|
||||
const side =
|
||||
r.destinationChainId === CHAIN_ID
|
||||
? { addr: r.destinationToken, symbol: r.destinationTokenSymbol }
|
||||
: { addr: r.originToken, symbol: r.originTokenSymbol }
|
||||
if (side.symbol === 'ETH') continue
|
||||
const k = side.addr.toLowerCase()
|
||||
if (!cands.has(k)) cands.set(k, { symbol: side.symbol, homeAddr: side.addr, relayHome: false })
|
||||
}
|
||||
|
||||
for (const cand of cands.values()) {
|
||||
const inRow = pairRoutes.find(
|
||||
(r) =>
|
||||
!r.isNative &&
|
||||
r.destinationChainId === CHAIN_ID &&
|
||||
eq(r.destinationToken, cand.homeAddr) &&
|
||||
sameSymbolLoose(r.originTokenSymbol, cand.symbol),
|
||||
)
|
||||
const outRow = pairRoutes.find(
|
||||
(r) =>
|
||||
!r.isNative &&
|
||||
r.originChainId === CHAIN_ID &&
|
||||
eq(r.originToken, cand.homeAddr) &&
|
||||
sameSymbolLoose(r.destinationTokenSymbol, cand.symbol),
|
||||
)
|
||||
const acrossRemoteAddr = inRow?.originToken ?? outRow?.destinationToken
|
||||
|
||||
const lookup = relayCurrencies[cand.symbol] ?? []
|
||||
const exact = lookup.filter((c) => c.symbol === cand.symbol)
|
||||
const trusted = (c: RelayCurrencyV2, confirmAddr?: Address) =>
|
||||
c.metadata?.verified === true || (confirmAddr !== undefined && eq(c.address, confirmAddr))
|
||||
// remote-side identity: across route wins; else a unique trusted relay match
|
||||
const remoteTrusted = exact.filter((c) => c.chainId === remoteChainId && trusted(c, acrossRemoteAddr))
|
||||
const remoteAddr = acrossRemoteAddr ?? (remoteTrusted.length === 1 ? remoteTrusted[0].address : undefined)
|
||||
if (!remoteAddr) continue
|
||||
|
||||
// decimals must be discoverable on both sides and equal — never guessed
|
||||
const homeDec =
|
||||
cand.relayHomeDec ?? exact.find((c) => c.chainId === CHAIN_ID && eq(c.address, cand.homeAddr))?.decimals
|
||||
const remoteDec = exact.find((c) => c.chainId === remoteChainId && eq(c.address, remoteAddr))?.decimals
|
||||
if (homeDec === undefined || remoteDec === undefined || homeDec !== remoteDec) continue
|
||||
|
||||
const relaySupported =
|
||||
cand.relayHome &&
|
||||
exact.some((c) => c.chainId === remoteChainId && eq(c.address, remoteAddr) && trusted(c, acrossRemoteAddr))
|
||||
const acrossSupported = acrossRemoteAddr !== undefined
|
||||
if (!relaySupported && !acrossSupported) continue
|
||||
|
||||
out.push({
|
||||
symbol: cand.symbol,
|
||||
decimals: homeDec,
|
||||
robinhoodToken: cand.homeAddr,
|
||||
remoteToken: remoteAddr,
|
||||
relay: relaySupported,
|
||||
across: acrossSupported,
|
||||
portal: false,
|
||||
})
|
||||
}
|
||||
|
||||
// stable order for the dropdown: native first, then alphabetical
|
||||
return out.sort((a, b) => (a.symbol === 'ETH' ? -1 : b.symbol === 'ETH' ? 1 : a.symbol.localeCompare(b.symbol)))
|
||||
}
|
||||
|
||||
// ---- fetch layer (shared payloads memoized for the session) ----
|
||||
|
||||
let chainsMemo: Promise<RelayChainsJson> | null = null
|
||||
let routesMemo: Promise<AcrossRouteJson[]> | null = null
|
||||
|
||||
function fetchRelayChains(): Promise<RelayChainsJson> {
|
||||
chainsMemo ??= fetch(`${RELAY_API}/chains`)
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(`relay chains ${r.status}`)
|
||||
return r.json() as Promise<RelayChainsJson>
|
||||
})
|
||||
.catch((e) => {
|
||||
chainsMemo = null // do not cache failures
|
||||
throw e
|
||||
})
|
||||
return chainsMemo
|
||||
}
|
||||
|
||||
function fetchAcrossRoutes(): Promise<AcrossRouteJson[]> {
|
||||
routesMemo ??= fetch(`${ACROSS_API}/available-routes`)
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(`across routes ${r.status}`)
|
||||
return r.json() as Promise<AcrossRouteJson[]>
|
||||
})
|
||||
.catch((e) => {
|
||||
routesMemo = null
|
||||
throw e
|
||||
})
|
||||
return routesMemo
|
||||
}
|
||||
|
||||
/** the canonical bridge has no support API — its availability claim is checked
|
||||
* against the chain itself (Inbox bytecode on the parent chain's default
|
||||
* public RPC). Fail-open on RPC trouble: a transient outage must not hide the
|
||||
* route; only a positive "no code at that address" demotes it. */
|
||||
async function verifyPortalInbox(remote: RemoteChain, signal?: AbortSignal): Promise<boolean> {
|
||||
if (remote.chain.id !== PORTAL_PARENT_CHAIN_ID) return false
|
||||
const rpc = remote.chain.rpcUrls.default.http[0]
|
||||
if (!rpc) return true
|
||||
try {
|
||||
const res = await fetch(rpc, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: signal ?? null,
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_getCode', params: [PORTAL_INBOX, 'latest'] }),
|
||||
})
|
||||
const json = (await res.json()) as { result?: unknown }
|
||||
if (typeof json.result !== 'string') return true
|
||||
return json.result.length > 2 // '0x' = genuinely no contract there
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRelayCurrencies(chainIds: number[], term: string, signal?: AbortSignal): Promise<RelayCurrencyV2[]> {
|
||||
const res = await fetch(`${RELAY_API}/currencies/v2`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: signal ?? null,
|
||||
body: JSON.stringify({ chainIds, term, limit: 30 }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`relay currencies ${res.status}`)
|
||||
return (await res.json()) as RelayCurrencyV2[]
|
||||
}
|
||||
|
||||
/** discovery entrypoint — degrades per-source (one engine down only narrows
|
||||
* the list; both sources down throws so the UI can offer a retry) */
|
||||
export async function fetchBridgeTokens(remote: RemoteChain, signal?: AbortSignal): Promise<BridgeTokenSupport[]> {
|
||||
const [chainsR, routesR] = await Promise.allSettled([fetchRelayChains(), fetchAcrossRoutes()])
|
||||
const relayChains = chainsR.status === 'fulfilled' ? chainsR.value : null
|
||||
const acrossRoutes = routesR.status === 'fulfilled' ? routesR.value : null
|
||||
if (!relayChains && !acrossRoutes) throw new Error('bridge token discovery failed — both engines unreachable')
|
||||
|
||||
const remoteChainId = remote.chain.id
|
||||
const relayHome = relayChains?.chains?.find((c) => c.id === CHAIN_ID)
|
||||
// symbols needing a currencies/v2 lookup = every 4663-side ERC-20 candidate
|
||||
const symbols = new Set<string>(
|
||||
(relayHome?.erc20Currencies ?? [])
|
||||
.filter((c) => c.symbol && c.supportsBridging !== false)
|
||||
.map((c) => c.symbol as string),
|
||||
)
|
||||
for (const r of acrossRoutes ?? []) {
|
||||
if (r.isNative) continue
|
||||
if (r.originChainId === remoteChainId && r.destinationChainId === CHAIN_ID && r.destinationTokenSymbol !== 'ETH')
|
||||
symbols.add(r.destinationTokenSymbol)
|
||||
if (r.originChainId === CHAIN_ID && r.destinationChainId === remoteChainId && r.originTokenSymbol !== 'ETH')
|
||||
symbols.add(r.originTokenSymbol)
|
||||
}
|
||||
|
||||
const relayCurrencies: Record<string, RelayCurrencyV2[]> = {}
|
||||
const [portalOk] = await Promise.all([
|
||||
verifyPortalInbox(remote, signal),
|
||||
...[...symbols].map(async (sym) => {
|
||||
relayCurrencies[sym] = await fetchRelayCurrencies([CHAIN_ID, remoteChainId], sym, signal).catch(() => [])
|
||||
}),
|
||||
])
|
||||
|
||||
return mergeTokenSupports({ remoteChainId, relayChains, relayCurrencies, acrossRoutes, portalOk })
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Address, Hex } from 'viem'
|
||||
|
||||
export type BridgeProviderId = 'relay' | 'across' | 'portal'
|
||||
|
||||
/** injected by the caller (config/env bridgeFee) so provider modules stay
|
||||
* env-free and unit-testable outside vite */
|
||||
export type BridgeFee = { bps: number; receiver: Address }
|
||||
|
||||
/** one pre-built origin-chain transaction from a provider quote */
|
||||
export type BridgeStep = {
|
||||
kind: 'approve' | 'deposit'
|
||||
chainId: number
|
||||
to: Address
|
||||
data: Hex
|
||||
value: bigint
|
||||
}
|
||||
|
||||
/** how to poll fill status once the deposit tx is confirmed (the portal's
|
||||
* child tx hash only becomes derivable from the deposit receipt) */
|
||||
export type BridgeTracker =
|
||||
| { provider: 'relay'; requestId: string }
|
||||
| { provider: 'across'; originChainId: number }
|
||||
| { provider: 'portal' }
|
||||
|
||||
export type BridgeQuote = {
|
||||
provider: BridgeProviderId
|
||||
/** destination-side amounts, output-token units (terminal fee, if any, already deducted) */
|
||||
outputAmount: bigint
|
||||
minOutput: bigint
|
||||
etaSec: number
|
||||
steps: BridgeStep[]
|
||||
tracker: BridgeTracker
|
||||
/** epoch seconds after which the quote must not be executed (null = provider
|
||||
* re-validates at fill time) */
|
||||
expiresAt: number | null
|
||||
}
|
||||
|
||||
/** provider error with the upstream machine code preserved for UI mapping */
|
||||
export class BridgeQuoteError extends Error {
|
||||
code: string | null
|
||||
constructor(message: string, code: string | null = null) {
|
||||
super(message)
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
export type BridgeQuoteState = {
|
||||
quote: BridgeQuote | null
|
||||
error: BridgeQuoteError | null
|
||||
}
|
||||
Reference in New Issue
Block a user