diff --git a/.gitignore b/.gitignore index 3fabf7c..7018024 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,7 @@ next-env.d.ts .agent/ /documentation/ /inspiration/ +provider-logs/ + +# Local planning/specs — never commit +docs/ diff --git a/src/app/api/quotes/route.ts b/src/app/api/quotes/route.ts index b4df421..a87cdb4 100644 --- a/src/app/api/quotes/route.ts +++ b/src/app/api/quotes/route.ts @@ -11,6 +11,7 @@ const quoteSchema = z.object({ fromAddress: z.string().optional(), toAddress: z.string().optional(), fromTokenDecimals: z.number().optional(), + toTokenDecimals: z.number().optional(), }) export async function POST(request: Request) { @@ -31,10 +32,12 @@ export async function POST(request: Request) { fromTokenDecimals: params.fromTokenDecimals, }) - return NextResponse.json({ - success: true, + return NextResponse.json({ + success: true, routes: result.quotes, bestQuote: result.bestQuote, + unsupported: result.unsupported ?? false, + reason: result.reason, expiresAt: result.expiresAt, fetchedAt: result.fetchedAt, providerStats: result.providerStats, diff --git a/src/components/Dither.tsx b/src/components/Dither.tsx deleted file mode 100644 index f01cbc3..0000000 --- a/src/components/Dither.tsx +++ /dev/null @@ -1,333 +0,0 @@ -import { useRef, useState, useEffect, forwardRef } from 'react'; -import { Canvas, useFrame, useThree, ThreeEvent } from '@react-three/fiber'; -import { EffectComposer, wrapEffect } from '@react-three/postprocessing'; -import { Effect } from 'postprocessing'; -import * as THREE from 'three'; - -const waveVertexShader = ` -precision highp float; -varying vec2 vUv; -void main() { - vUv = uv; - vec4 modelPosition = modelMatrix * vec4(position, 1.0); - vec4 viewPosition = viewMatrix * modelPosition; - gl_Position = projectionMatrix * viewPosition; -} -`; - -const waveFragmentShader = ` -precision highp float; -uniform vec2 resolution; -uniform float time; -uniform float waveSpeed; -uniform float waveFrequency; -uniform float waveAmplitude; -uniform vec3 waveColor; -uniform vec2 mousePos; -uniform int enableMouseInteraction; -uniform float mouseRadius; - -vec4 mod289(vec4 x) { return x - floor(x * (1.0/289.0)) * 289.0; } -vec4 permute(vec4 x) { return mod289(((x * 34.0) + 1.0) * x); } -vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; } -vec2 fade(vec2 t) { return t*t*t*(t*(t*6.0-15.0)+10.0); } - -float cnoise(vec2 P) { - vec4 Pi = floor(P.xyxy) + vec4(0.0,0.0,1.0,1.0); - vec4 Pf = fract(P.xyxy) - vec4(0.0,0.0,1.0,1.0); - Pi = mod289(Pi); - vec4 ix = Pi.xzxz; - vec4 iy = Pi.yyww; - vec4 fx = Pf.xzxz; - vec4 fy = Pf.yyww; - vec4 i = permute(permute(ix) + iy); - vec4 gx = fract(i * (1.0/41.0)) * 2.0 - 1.0; - vec4 gy = abs(gx) - 0.5; - vec4 tx = floor(gx + 0.5); - gx = gx - tx; - vec2 g00 = vec2(gx.x, gy.x); - vec2 g10 = vec2(gx.y, gy.y); - vec2 g01 = vec2(gx.z, gy.z); - vec2 g11 = vec2(gx.w, gy.w); - vec4 norm = taylorInvSqrt(vec4(dot(g00,g00), dot(g01,g01), dot(g10,g10), dot(g11,g11))); - g00 *= norm.x; g01 *= norm.y; g10 *= norm.z; g11 *= norm.w; - float n00 = dot(g00, vec2(fx.x, fy.x)); - float n10 = dot(g10, vec2(fx.y, fy.y)); - float n01 = dot(g01, vec2(fx.z, fy.z)); - float n11 = dot(g11, vec2(fx.w, fy.w)); - vec2 fade_xy = fade(Pf.xy); - vec2 n_x = mix(vec2(n00, n01), vec2(n10, n11), fade_xy.x); - return 2.3 * mix(n_x.x, n_x.y, fade_xy.y); -} - -const int OCTAVES = 4; -float fbm(vec2 p) { - float value = 0.0; - float amp = 1.0; - float freq = waveFrequency; - for (int i = 0; i < OCTAVES; i++) { - value += amp * abs(cnoise(p)); - p *= freq; - amp *= waveAmplitude; - } - return value; -} - -float pattern(vec2 p) { - vec2 p2 = p - time * waveSpeed; - return fbm(p + fbm(p2)); -} - -void main() { - vec2 uv = gl_FragCoord.xy / resolution.xy; - uv -= 0.5; - uv.x *= resolution.x / resolution.y; - float f = pattern(uv); - if (enableMouseInteraction == 1) { - vec2 mouseNDC = (mousePos / resolution - 0.5) * vec2(1.0, -1.0); - mouseNDC.x *= resolution.x / resolution.y; - float dist = length(uv - mouseNDC); - float effect = 1.0 - smoothstep(0.0, mouseRadius, dist); - f -= 0.5 * effect; - } - vec3 col = mix(vec3(0.0), waveColor, f); - gl_FragColor = vec4(col, 1.0); -} -`; - -const ditherFragmentShader = ` -precision highp float; -uniform float colorNum; -uniform float pixelSize; -const float bayerMatrix8x8[64] = float[64]( - 0.0/64.0, 48.0/64.0, 12.0/64.0, 60.0/64.0, 3.0/64.0, 51.0/64.0, 15.0/64.0, 63.0/64.0, - 32.0/64.0,16.0/64.0, 44.0/64.0, 28.0/64.0, 35.0/64.0,19.0/64.0, 47.0/64.0, 31.0/64.0, - 8.0/64.0, 56.0/64.0, 4.0/64.0, 52.0/64.0, 11.0/64.0,59.0/64.0, 7.0/64.0, 55.0/64.0, - 40.0/64.0,24.0/64.0, 36.0/64.0, 20.0/64.0, 43.0/64.0,27.0/64.0, 39.0/64.0, 23.0/64.0, - 2.0/64.0, 50.0/64.0, 14.0/64.0, 62.0/64.0, 1.0/64.0,49.0/64.0, 13.0/64.0, 61.0/64.0, - 34.0/64.0,18.0/64.0, 46.0/64.0, 30.0/64.0, 33.0/64.0,17.0/64.0, 45.0/64.0, 29.0/64.0, - 10.0/64.0,58.0/64.0, 6.0/64.0, 54.0/64.0, 9.0/64.0,57.0/64.0, 5.0/64.0, 53.0/64.0, - 42.0/64.0,26.0/64.0, 38.0/64.0, 22.0/64.0, 41.0/64.0,25.0/64.0, 37.0/64.0, 21.0/64.0 -); - -vec3 dither(vec2 uv, vec3 color) { - vec2 scaledCoord = floor(uv * resolution / pixelSize); - int x = int(mod(scaledCoord.x, 8.0)); - int y = int(mod(scaledCoord.y, 8.0)); - float threshold = bayerMatrix8x8[y * 8 + x] - 0.25; - float step = 1.0 / (colorNum - 1.0); - color += threshold * step; - float bias = 0.2; - color = clamp(color - bias, 0.0, 1.0); - return floor(color * (colorNum - 1.0) + 0.5) / (colorNum - 1.0); -} - -void mainImage(in vec4 inputColor, in vec2 uv, out vec4 outputColor) { - vec2 normalizedPixelSize = pixelSize / resolution; - vec2 uvPixel = normalizedPixelSize * floor(uv / normalizedPixelSize); - vec4 color = texture2D(inputBuffer, uvPixel); - color.rgb = dither(uv, color.rgb); - outputColor = color; -} -`; - -class RetroEffectImpl extends Effect { - public uniforms: Map>; - constructor() { - const uniforms = new Map>([ - ['colorNum', new THREE.Uniform(4.0)], - ['pixelSize', new THREE.Uniform(2.0)] - ]); - super('RetroEffect', ditherFragmentShader, { uniforms }); - this.uniforms = uniforms; - } - set colorNum(value: number) { - this.uniforms.get('colorNum')!.value = value; - } - get colorNum(): number { - return this.uniforms.get('colorNum')!.value; - } - set pixelSize(value: number) { - this.uniforms.get('pixelSize')!.value = value; - } - get pixelSize(): number { - return this.uniforms.get('pixelSize')!.value; - } -} - -const WrappedRetroEffect = wrapEffect(RetroEffectImpl); - -const RetroEffect = forwardRef((props, ref) => { - const { colorNum, pixelSize } = props; - return ; -}); - -RetroEffect.displayName = 'RetroEffect'; - -interface WaveUniforms { - [key: string]: THREE.Uniform; - time: THREE.Uniform; - resolution: THREE.Uniform; - waveSpeed: THREE.Uniform; - waveFrequency: THREE.Uniform; - waveAmplitude: THREE.Uniform; - waveColor: THREE.Uniform; - mousePos: THREE.Uniform; - enableMouseInteraction: THREE.Uniform; - mouseRadius: THREE.Uniform; -} - -interface DitheredWavesProps { - waveSpeed: number; - waveFrequency: number; - waveAmplitude: number; - waveColor: [number, number, number]; - colorNum: number; - pixelSize: number; - disableAnimation: boolean; - enableMouseInteraction: boolean; - mouseRadius: number; -} - -function DitheredWaves({ - waveSpeed, - waveFrequency, - waveAmplitude, - waveColor, - colorNum, - pixelSize, - disableAnimation, - enableMouseInteraction, - mouseRadius -}: DitheredWavesProps) { - const mesh = useRef(null); - const mouseRef = useRef(new THREE.Vector2()); - const { viewport, size, gl } = useThree(); - - const waveUniformsRef = useRef({ - time: new THREE.Uniform(0), - resolution: new THREE.Uniform(new THREE.Vector2(0, 0)), - waveSpeed: new THREE.Uniform(waveSpeed), - waveFrequency: new THREE.Uniform(waveFrequency), - waveAmplitude: new THREE.Uniform(waveAmplitude), - waveColor: new THREE.Uniform(new THREE.Color(...waveColor)), - mousePos: new THREE.Uniform(new THREE.Vector2(0, 0)), - enableMouseInteraction: new THREE.Uniform(enableMouseInteraction ? 1 : 0), - mouseRadius: new THREE.Uniform(mouseRadius) - }); - - useEffect(() => { - const dpr = gl.getPixelRatio(); - const newWidth = Math.floor(size.width * dpr); - const newHeight = Math.floor(size.height * dpr); - const currentRes = waveUniformsRef.current.resolution.value; - if (currentRes.x !== newWidth || currentRes.y !== newHeight) { - currentRes.set(newWidth, newHeight); - } - }, [size, gl]); - - const prevColor = useRef([...waveColor]); - useFrame(({ clock }) => { - const u = waveUniformsRef.current; - - if (!disableAnimation) { - u.time.value = clock.getElapsedTime(); - } - - if (u.waveSpeed.value !== waveSpeed) u.waveSpeed.value = waveSpeed; - if (u.waveFrequency.value !== waveFrequency) u.waveFrequency.value = waveFrequency; - if (u.waveAmplitude.value !== waveAmplitude) u.waveAmplitude.value = waveAmplitude; - - if (!prevColor.current.every((v, i) => v === waveColor[i])) { - u.waveColor.value.set(...waveColor); - prevColor.current = [...waveColor]; - } - - u.enableMouseInteraction.value = enableMouseInteraction ? 1 : 0; - u.mouseRadius.value = mouseRadius; - - if (enableMouseInteraction) { - u.mousePos.value.copy(mouseRef.current); - } - }); - - const handlePointerMove = (e: ThreeEvent) => { - if (!enableMouseInteraction) return; - const rect = gl.domElement.getBoundingClientRect(); - const dpr = gl.getPixelRatio(); - mouseRef.current.set((e.clientX - rect.left) * dpr, (e.clientY - rect.top) * dpr); - }; - - return ( - <> - - - - - - - - - - - - - - - ); -} - -interface DitherProps { - waveSpeed?: number; - waveFrequency?: number; - waveAmplitude?: number; - waveColor?: [number, number, number]; - colorNum?: number; - pixelSize?: number; - disableAnimation?: boolean; - enableMouseInteraction?: boolean; - mouseRadius?: number; -} - -export default function Dither({ - waveSpeed = 0.05, - waveFrequency = 3, - waveAmplitude = 0.3, - waveColor = [0.5, 0.5, 0.5], - colorNum = 4, - pixelSize = 2, - disableAnimation = false, - enableMouseInteraction = true, - mouseRadius = 1 -}: DitherProps) { - return ( - - - - ); -} diff --git a/src/components/PaymentInterface.tsx b/src/components/PaymentInterface.tsx index 84f6e7d..8763e4a 100644 --- a/src/components/PaymentInterface.tsx +++ b/src/components/PaymentInterface.tsx @@ -14,6 +14,7 @@ import { QuoteResponse } from '@/types/provider' import { useTransactionExecutor } from '@/hooks/useTransactionExecutor' import { useToast } from '@/components/ui/use-toast' import type { UnifiedChain, UnifiedToken } from '@/lib/chain-registry' +import { collapseTokensBySymbol } from '@/lib/token-filter' // Chain type group labels for the dropdown const CHAIN_TYPE_LABELS: Record = { @@ -57,9 +58,15 @@ export default function PaymentInterface({ link, onSuccess }: PaymentInterfacePr const { chain: connectedChain } = useAccount() const { switchChain } = useSwitchChain() const { toast } = useToast() - + // Custom Executor Hook - const { execute, status: executorStatus, step: executorStep, error: executorError, txHash } = useTransactionExecutor() + const { + execute, + status: executorStatus, + step: executorStep, + error: executorError, + txHash, + } = useTransactionExecutor() // Dynamic chain/token state const [dynamicChains, setDynamicChains] = useState([]) @@ -68,12 +75,16 @@ export default function PaymentInterface({ link, onSuccess }: PaymentInterfacePr const [tokensLoading, setTokensLoading] = useState(false) const [fromChainKey, setFromChainKey] = useState( - connectedChain?.id ? String(connectedChain.id) : '42161' + connectedChain?.id ? String(connectedChain.id) : '42161', + ) + const [fromTokenAddress, setFromTokenAddress] = useState( + '0x0000000000000000000000000000000000000000', ) - const [fromTokenAddress, setFromTokenAddress] = useState('0x0000000000000000000000000000000000000000') // Derive chain type from dynamic chains - const selectedChain = dynamicChains.find(c => String(c.chainId) === fromChainKey || c.key === fromChainKey) + const selectedChain = dynamicChains.find( + (c) => String(c.chainId) === fromChainKey || c.key === fromChainKey, + ) const chainType = selectedChain?.type || 'evm' // Derive numeric chainId for wagmi compatibility @@ -82,7 +93,12 @@ export default function PaymentInterface({ link, onSuccess }: PaymentInterfacePr return !isNaN(num) && Number.isInteger(num) ? num : 0 })() - const toChainKey = link.receive_mode === 'same_chain' ? fromChainKey : (link.receive_chain_id ? String(link.receive_chain_id) : '42161') + const toChainKey = + link.receive_mode === 'same_chain' + ? fromChainKey + : link.receive_chain_id + ? String(link.receive_chain_id) + : '42161' const [quotes, setQuotes] = useState([]) const [selectedQuote, setSelectedQuote] = useState(null) @@ -103,10 +119,20 @@ export default function PaymentInterface({ link, onSuccess }: PaymentInterfacePr const isFixedAmount = typeof link.amount === 'number' && link.amount > 0 const displayAmountUSD = isFixedAmount ? link.amount! : parseFloat(manualAmount) || 0 - const fromToken = dynamicTokens.find(t => t.address.toLowerCase() === fromTokenAddress.toLowerCase()) + const fromToken = dynamicTokens.find( + (t) => t.address.toLowerCase() === fromTokenAddress.toLowerCase(), + ) + + // Defensive client-side collapse: one canonical row per symbol so the dropdown + // never shows duplicate USDC/SOL even if an unmerged cache row slips through. + // Selection still resolves against the full dynamicTokens list above. + const displayTokens = collapseTokensBySymbol(dynamicTokens, fromChainKey) // Dynamic USDC address resolution for destination chain const [resolvedUSDCAddress, setResolvedUSDCAddress] = useState(undefined) + // Destination-token decimals — sent to the quote API so ranking normalizes each + // provider's output against the real dest decimals (USDC = 6). + const [resolvedUSDCDecimals, setResolvedUSDCDecimals] = useState(undefined) useEffect(() => { if (link.receive_token || link.stealth_chain_native) return @@ -116,11 +142,10 @@ export default function PaymentInterface({ link, onSuccess }: PaymentInterfacePr const res = await fetch(`/api/tokens?chainKey=${toChainKey}`) const data = await res.json() if (data.success && data.tokens) { - const usdc = data.tokens.find((t: any) => - t.symbol?.toUpperCase() === 'USDC' - ) + const usdc = data.tokens.find((t: any) => t.symbol?.toUpperCase() === 'USDC') if (usdc?.address) { setResolvedUSDCAddress(usdc.address) + if (typeof usdc.decimals === 'number') setResolvedUSDCDecimals(usdc.decimals) return } } @@ -136,7 +161,7 @@ export default function PaymentInterface({ link, onSuccess }: PaymentInterfacePr // If stealth mode, destination is always native token on the receive chain const destinationToken = link.stealth_chain_native ? '0x0000000000000000000000000000000000000000' - : (link.receive_token || resolvedUSDCAddress || getUSDCAddress(toChainKey)) + : link.receive_token || resolvedUSDCAddress || getUSDCAddress(toChainKey) // Fetch chains on mount useEffect(() => { @@ -185,11 +210,14 @@ export default function PaymentInterface({ link, onSuccess }: PaymentInterfacePr const { data: balanceData } = useBalance({ address: address, chainId: fromChainId, - token: fromTokenAddress === '0x0000000000000000000000000000000000000000' ? undefined : fromTokenAddress as `0x${string}`, + token: + fromTokenAddress === '0x0000000000000000000000000000000000000000' + ? undefined + : (fromTokenAddress as `0x${string}`), query: { enabled: !!address && !!fromChainId && chainType === 'evm', - refetchInterval: 10000 - } + refetchInterval: 10000, + }, }) const refreshIntervalRef = useRef(null) @@ -222,7 +250,7 @@ export default function PaymentInterface({ link, onSuccess }: PaymentInterfacePr // Calculate converted amount if we have a USD amount if (displayAmountUSD > 0 && data.priceUSD > 0) { const toSymbol = link.receive_token_symbol || 'USDC' - const slippage = (STABLECOINS.has(fromToken.symbol) && STABLECOINS.has(toSymbol)) ? 0.5 : 1.0 + const slippage = STABLECOINS.has(fromToken.symbol) && STABLECOINS.has(toSymbol) ? 0.5 : 1.0 const rawAmount = displayAmountUSD / data.priceUSD const withSlippage = rawAmount * (1 + slippage / 100) @@ -294,7 +322,9 @@ export default function PaymentInterface({ link, onSuccess }: PaymentInterfacePr const isEvmChain = chainType === 'evm' const isEvmAddress = address?.startsWith('0x') if (isEvmChain && !isEvmAddress) { - setError('Your connected wallet is not an EVM wallet. Please switch to an EVM wallet (MetaMask, etc.) using the wallet button to pay on this chain.') + setError( + 'Your connected wallet is not an EVM wallet. Please switch to an EVM wallet (MetaMask, etc.) using the wallet button to pay on this chain.', + ) setIsLoading(false) return } @@ -314,6 +344,7 @@ export default function PaymentInterface({ link, onSuccess }: PaymentInterfacePr fromAddress: address, toAddress: link.recipient_address, fromTokenDecimals: fromToken.decimals, + toTokenDecimals: resolvedUSDCDecimals, }), }) @@ -322,8 +353,14 @@ export default function PaymentInterface({ link, onSuccess }: PaymentInterfacePr setQuotes(data.routes) const best = data.bestQuote || data.routes[0] setSelectedQuote(best) + } else if (data.unsupported && data.reason) { + throw new Error(data.reason) } else { - throw new Error(data.error || 'No routes found for this swap. Try a different token or chain.') + throw new Error( + data.reason || + data.error || + 'No routes found for this swap. Try a different token or chain.', + ) } } catch (e) { console.error(e) @@ -336,15 +373,14 @@ export default function PaymentInterface({ link, onSuccess }: PaymentInterfacePr useEffect(() => { if (selectedQuote?.metadata?.insufficientBalance) { - toast({ - variant: "destructive", - title: "Insufficient Balance", - description: `You don't have enough balance for the ${selectedQuote.provider} route. Try a different token or add funds.` - }) + toast({ + variant: 'destructive', + title: 'Insufficient Balance', + description: `You don't have enough balance for the ${selectedQuote.provider} route. Try a different token or add funds.`, + }) } }, [selectedQuote]) - const handleExecute = async () => { if (!selectedQuote || !address) return setIsLoading(true) @@ -377,7 +413,7 @@ export default function PaymentInterface({ link, onSuccess }: PaymentInterfacePr // 2. Execute via Executor Hook (Handles LiFi/Rango logic) const hash = await execute(selectedQuote, link.recipient_address) - + if (!hash) throw new Error('Execution completed but no hash returned') // 3. Update Backend with Hash @@ -385,7 +421,7 @@ export default function PaymentInterface({ link, onSuccess }: PaymentInterfacePr await fetch(`/api/transactions/${transactionId}/hash`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ txHash: hash }) + body: JSON.stringify({ txHash: hash }), }) } catch (err) { console.error('Failed to update backend with hash:', err) @@ -394,7 +430,6 @@ export default function PaymentInterface({ link, onSuccess }: PaymentInterfacePr if (onSuccess) { onSuccess(hash, transactionId) } - } catch (e) { console.error(e) const message = e instanceof Error ? e.message : 'Transaction Failed' @@ -410,12 +445,17 @@ export default function PaymentInterface({ link, onSuccess }: PaymentInterfacePr setError(executorError) setIsLoading(false) } - // We don't auto-handle success status here because handleExecute awaits execute() + // We don't auto-handle success status here because handleExecute awaits execute() // and calls onSuccess manually. }, [executorError]) - const timeSinceUpdate = lastPriceUpdate > 0 ? Math.floor((Date.now() - lastPriceUpdate) / 1000) : null - const isExecuting = isLoading || executorStatus === 'approving' || executorStatus === 'executing' + const timeSinceUpdate = + lastPriceUpdate > 0 ? Math.floor((Date.now() - lastPriceUpdate) / 1000) : null + const isExecuting = + isLoading || + executorStatus === 'approving' || + executorStatus === 'executing' || + executorStatus === 'submitted' return (
@@ -467,7 +507,8 @@ export default function PaymentInterface({ link, onSuccess }: PaymentInterfacePr ≈ {convertedAmount} {fromToken.symbol} - (${tokenPriceUSD?.toLocaleString(undefined, { maximumFractionDigits: 2 })} / {fromToken.symbol}) + (${tokenPriceUSD?.toLocaleString(undefined, { maximumFractionDigits: 2 })} /{' '} + {fromToken.symbol})
- {/* Controls Grid */} -
- {/* Network Selection */} +
+ {/* Controls Grid */} +
+ {/* Network Selection */}
- +
{selectedChain?.logoUrl ? ( - {selectedChain.name} + {selectedChain.name} ) : (
)} @@ -546,32 +594,46 @@ export default function PaymentInterface({ link, onSuccess }: PaymentInterfacePr {(() => { const popularIds = ['42161', '8453', '137', '10', '1', 'solana'] const popularChains = popularIds - .map(id => dynamicChains.find(c => c.key === id || String(c.chainId) === id)) + .map((id) => + dynamicChains.find((c) => c.key === id || String(c.chainId) === id), + ) .filter(Boolean) as UnifiedChain[] - const remainingChains = dynamicChains.filter(c => - !popularIds.includes(c.key) && !popularIds.includes(String(c.chainId || '')) + const remainingChains = dynamicChains.filter( + (c) => + !popularIds.includes(c.key) && + !popularIds.includes(String(c.chainId || '')), ) return ( <> {popularChains.length > 0 && ( - {popularChains.map(c => ( - + {popularChains.map((c) => ( + ))} )} {Object.entries( - remainingChains.reduce((groups, chain) => { - const type = chain.type || 'evm' - if (!groups[type]) groups[type] = [] - groups[type].push(chain) - return groups - }, {} as Record) + remainingChains.reduce( + (groups, chain) => { + const type = chain.type || 'evm' + if (!groups[type]) groups[type] = [] + groups[type].push(chain) + return groups + }, + {} as Record, + ), ).map(([type, chains]) => ( - - {chains.map(c => ( - + + {chains.map((c) => ( + ))} ))} @@ -581,8 +643,21 @@ export default function PaymentInterface({ link, onSuccess }: PaymentInterfacePr )}
- - + +
@@ -590,7 +665,9 @@ export default function PaymentInterface({ link, onSuccess }: PaymentInterfacePr {/* Asset Selection */}
- +
{tokensLoading ? (
@@ -603,16 +680,30 @@ export default function PaymentInterface({ link, onSuccess }: PaymentInterfacePr value={fromTokenAddress} onChange={(e) => handleTokenChange(e.target.value)} > - {dynamicTokens.map(t => ( + {displayTokens.map((t) => ( ))} )}
- - + +
@@ -623,7 +714,9 @@ export default function PaymentInterface({ link, onSuccess }: PaymentInterfacePr