From ce46ffc272ad34ec004bad187f3ae8636e9d32b1 Mon Sep 17 00:00:00 2001 From: Shreyas Patil Date: Sat, 30 May 2026 22:09:14 +0530 Subject: [PATCH 01/34] chore: remove unused browser supabase client --- src/lib/supabase.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index 9f907d8..365cb4d 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -1,14 +1,6 @@ -import { createBrowserClient } from '@supabase/ssr' import { createClient as createSupabaseClient, SupabaseClient } from '@supabase/supabase-js' import { Database } from '@/types/database.types' -// Client-side (Browser) -export const createClient = () => - createBrowserClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! - ) - // Server-side (API Routes, Services, Background Jobs) export const createServerClient = () => createSupabaseClient( From 50e031b8196a8a283b6840aceee8ef8e67eae82f Mon Sep 17 00:00:00 2001 From: Shreyas Patil Date: Sat, 30 May 2026 22:09:14 +0530 Subject: [PATCH 02/34] chore: remove orphaned wallet store --- src/store/user.ts | 25 ------------------------- 1 file changed, 25 deletions(-) delete mode 100644 src/store/user.ts diff --git a/src/store/user.ts b/src/store/user.ts deleted file mode 100644 index 3d83ea9..0000000 --- a/src/store/user.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { create } from 'zustand' - -interface WalletState { - walletAddress: string | null - isConnected: boolean - chainType: 'evm' | 'solana' | 'bitcoin' | null - - setWallet: (address: string | null, chainType?: 'evm' | 'solana' | 'bitcoin' | null) => void - disconnect: () => void -} - -export const useWalletStore = create((set) => ({ - walletAddress: null, - isConnected: false, - chainType: null, - - setWallet: (address, chainType = null) => - set({ walletAddress: address, isConnected: !!address, chainType }), - - disconnect: () => - set({ walletAddress: null, isConnected: false, chainType: null }), -})) - -// Keep backward compat alias -export const useUserStore = useWalletStore From 38b0a65012132575ef3d7f5b9bf0128a3a068587 Mon Sep 17 00:00:00 2001 From: Shreyas Patil Date: Sat, 30 May 2026 22:09:14 +0530 Subject: [PATCH 03/34] chore: remove unused Dither background component --- src/components/Dither.tsx | 333 -------------------------------------- 1 file changed, 333 deletions(-) delete mode 100644 src/components/Dither.tsx 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 ( - - - - ); -} From 189305b83a3f9a97b5e9845795167df0f7789708 Mon Sep 17 00:00:00 2001 From: Shreyas Patil Date: Sat, 30 May 2026 22:09:15 +0530 Subject: [PATCH 04/34] chore: remove unused UpdatePaymentLinkInput type --- src/lib/validations/payment-link.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/validations/payment-link.ts b/src/lib/validations/payment-link.ts index 8b962c0..f0c7cf4 100644 --- a/src/lib/validations/payment-link.ts +++ b/src/lib/validations/payment-link.ts @@ -25,4 +25,3 @@ export const updatePaymentLinkSchema = createPaymentLinkSchema.partial().extend( }) export type CreatePaymentLinkInput = z.infer -export type UpdatePaymentLinkInput = z.infer From f4becbaf65b06274697dcd3b47eae3bf7c7bb2c5 Mon Sep 17 00:00:00 2001 From: Shreyas Patil Date: Sat, 30 May 2026 23:16:51 +0530 Subject: [PATCH 05/34] feat(tokens): add collapseTokensBySymbol to dedupe same-symbol variants --- src/lib/token-filter.ts | 84 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/src/lib/token-filter.ts b/src/lib/token-filter.ts index a004c88..dfb8a7b 100644 --- a/src/lib/token-filter.ts +++ b/src/lib/token-filter.ts @@ -127,3 +127,87 @@ export function filterSpamTokens( return !isSpamToken(token, count, canonical) }) } + +/** + * Known bridged/variant stablecoin symbols folded into their canonical base + * for display. Only stablecoins — never fold arbitrary tokens by symbol. + */ +const SYMBOL_VARIANT_MAP: Record = { + 'USDC.E': 'USDC', + USDBC: 'USDC', + FUSDT: 'USDT', +} + +/** + * Normalize a token symbol to its display family key (case-insensitive, + * folds known bridged variants like USDC.e → USDC). + */ +export function normalizeSymbolKey(symbol: string): string { + const upper = (symbol || '').toUpperCase() + return SYMBOL_VARIANT_MAP[upper] || upper +} + +/** + * Collapse same-symbol tokens into a single canonical row per symbol family. + * + * Fixes the "2-3 USDC / multiple SOL on one chain" problem: providers return the + * same logical token at different addresses (native USDC vs USDC.e vs another + * provider's USDC), and without this they each render as a separate selectable row. + * + * Within each family the winner is scored by: native > exact base-symbol match > + * canonical address > provider count > has-logo. A family with only a bridged + * variant (e.g. USDC.e but no USDC) keeps that variant — never drops a whole family. + * + * @param tokens - Tokens for a single chain (already spam-filtered) + * @param chainKey - Chain key for canonical address lookup + * @param providerCounts - Optional map of lowercase address → provider count; + * falls back to providerIds._count on each token. + */ +export function collapseTokensBySymbol( + tokens: UnifiedToken[], + chainKey?: string, + providerCounts?: Map, +): UnifiedToken[] { + const canonical = chainKey ? buildCanonicalAddresses(chainKey) : new Set() + const groups = new Map() + + for (const token of tokens) { + const key = normalizeSymbolKey(token.symbol) + const group = groups.get(key) + if (group) group.push(token) + else groups.set(key, [token]) + } + + const countOf = (t: UnifiedToken): number => { + if (providerCounts) { + const c = providerCounts.get(t.address.toLowerCase()) + if (c) return c + } + const ids = t.providerIds as Record | undefined + if (ids && typeof ids._count === 'number') return ids._count as number + return 1 + } + + const result: UnifiedToken[] = [] + for (const group of groups.values()) { + if (group.length === 1) { + result.push(group[0]) + continue + } + const familyKey = normalizeSymbolKey(group[0].symbol) + const score = (t: UnifiedToken): number => { + let s = 0 + if (t.isNative) s += 1000 + // Base symbol (USDC) beats bridged variant (USDC.e) within the same family + if ((t.symbol || '').toUpperCase() === familyKey) s += 100 + if (canonical.has(t.address.toLowerCase())) s += 50 + s += Math.min(countOf(t), 9) * 2 + if (t.logoUrl) s += 1 + return s + } + const winner = group.reduce((best, t) => (score(t) > score(best) ? t : best), group[0]) + result.push(winner) + } + + return result +} From fa5ce8419a3ec38eb99eae8e79535b98b9b2077d Mon Sep 17 00:00:00 2001 From: Shreyas Patil Date: Sat, 30 May 2026 23:16:51 +0530 Subject: [PATCH 06/34] fix(tokens): collapse duplicate USDC/SOL across live and cache token paths --- src/lib/api/get-tokens.ts | 13 +++++++++++-- src/services/chain-token-service.ts | 6 ++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/lib/api/get-tokens.ts b/src/lib/api/get-tokens.ts index 643bfcd..f47250c 100644 --- a/src/lib/api/get-tokens.ts +++ b/src/lib/api/get-tokens.ts @@ -1,6 +1,7 @@ import { ChainTokenService } from '@/services/chain-token-service' import { TOKENS } from '@/lib/tokens' -import { isSpamToken, buildCanonicalAddresses } from '@/lib/token-filter' +import { isSpamToken, buildCanonicalAddresses, collapseTokensBySymbol } from '@/lib/token-filter' +import type { UnifiedToken } from '@/lib/chain-registry' /** Shape of a row in the cached_tokens Supabase table */ interface CachedTokenRow { @@ -67,7 +68,7 @@ export async function getTokens(supabase: SupabaseClient, chainKey: string) { // Filter spam from cached data (catches pre-existing unfiltered tokens) const spamCanonical = buildCanonicalAddresses(chainKey) - const mapped = rawMapped.filter((t) => { + let mapped = rawMapped.filter((t) => { const count = t.providerIds && typeof (t.providerIds as Record)._count === 'number' ? ((t.providerIds as Record)._count as number) @@ -94,6 +95,14 @@ export async function getTokens(supabase: SupabaseClient, chainKey: string) { } } + // Collapse same-symbol variants (USDC/USDC.e, un-deduped native SOL rows) + // into one canonical row per symbol — the cache table stores one row per + // address, so this is where the duplicate USDC/SOL entries get merged. + mapped = collapseTokensBySymbol( + mapped as unknown as UnifiedToken[], + chainKey, + ) as unknown as MappedToken[] + // Provider count: stored as _count in providerIds by mergeTokens(), or count object keys const getProviderCount = (t: MappedToken) => { if (!t.providerIds || typeof t.providerIds !== 'object') return 0 diff --git a/src/services/chain-token-service.ts b/src/services/chain-token-service.ts index 89c3fa7..f720716 100644 --- a/src/services/chain-token-service.ts +++ b/src/services/chain-token-service.ts @@ -4,7 +4,7 @@ import { OneClickService, OpenAPI } from '@defuse-protocol/one-click-sdk-typescr import { SYMBIOSIS_CONFIG } from '@/services/providers/symbiosis-data' import { CHAINS } from '@/lib/chains' import { TOKENS } from '@/lib/tokens' -import { filterSpamTokens } from '@/lib/token-filter' +import { filterSpamTokens, collapseTokensBySymbol } from '@/lib/token-filter' import { UnifiedChain, UnifiedToken, @@ -811,7 +811,9 @@ function mergeTokens(tokenSets: UnifiedToken[][], chainKey?: string): UnifiedTok addressCounts, chainKey, ) - const result = filtered + // Collapse same-symbol variants (USDC/USDC.e, multiple native SOL) into one + // canonical row per symbol so the UI shows a single selectable entry. + const result = collapseTokensBySymbol(filtered, chainKey, addressCounts) const canonical = chainKey ? buildCanonicalAddresses(chainKey) : new Set() // Sort: native first, then canonical stablecoins, then multi-provider stablecoins, From 3af9396b1a6105859c398857e23168a16fc61d32 Mon Sep 17 00:00:00 2001 From: Shreyas Patil Date: Sat, 30 May 2026 23:19:51 +0530 Subject: [PATCH 07/34] fix(ui): show one canonical row per token symbol in payment selector --- src/components/PaymentInterface.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/PaymentInterface.tsx b/src/components/PaymentInterface.tsx index 84f6e7d..cdb6f78 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 = { @@ -105,6 +106,11 @@ export default function PaymentInterface({ link, onSuccess }: PaymentInterfacePr 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) @@ -603,7 +609,7 @@ export default function PaymentInterface({ link, onSuccess }: PaymentInterfacePr value={fromTokenAddress} onChange={(e) => handleTokenChange(e.target.value)} > - {dynamicTokens.map(t => ( + {displayTokens.map(t => ( From bfb1aafe4236ad09615ac0120d1c97ba28bbd105 Mon Sep 17 00:00:00 2001 From: Shreyas Patil Date: Sat, 30 May 2026 23:19:51 +0530 Subject: [PATCH 08/34] test(tokens): cover collapseTokensBySymbol and normalizeSymbolKey --- src/lib/__tests__/token-filter.test.ts | 112 +++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/src/lib/__tests__/token-filter.test.ts b/src/lib/__tests__/token-filter.test.ts index 38e33d4..554f555 100644 --- a/src/lib/__tests__/token-filter.test.ts +++ b/src/lib/__tests__/token-filter.test.ts @@ -4,7 +4,10 @@ import { filterSpamTokens, STABLECOIN_SYMBOLS, MAX_SYMBOL_LENGTH, + normalizeSymbolKey, + collapseTokensBySymbol, } from '../token-filter' +import type { UnifiedToken } from '../chain-registry' // ─── Helpers ─────────────────────────────────────────────────────── @@ -167,3 +170,112 @@ describe('filterSpamTokens', () => { expect(result[0].symbol).toBe('WETH') }) }) + +// ─── normalizeSymbolKey ─────────────────────────────────────────── + +describe('normalizeSymbolKey', () => { + it('uppercases plain symbols', () => { + expect(normalizeSymbolKey('usdc')).toBe('USDC') + expect(normalizeSymbolKey('Weth')).toBe('WETH') + }) + + it('folds known bridged stablecoin variants to their base', () => { + expect(normalizeSymbolKey('USDC.e')).toBe('USDC') + expect(normalizeSymbolKey('usdbc')).toBe('USDC') + expect(normalizeSymbolKey('fUSDT')).toBe('USDT') + }) + + it('does not fold non-stablecoin lookalikes', () => { + expect(normalizeSymbolKey('WETH.e')).toBe('WETH.E') + expect(normalizeSymbolKey('SOL')).toBe('SOL') + }) + + it('handles empty symbol', () => { + expect(normalizeSymbolKey('')).toBe('') + }) +}) + +// ─── collapseTokensBySymbol ─────────────────────────────────────── + +describe('collapseTokensBySymbol', () => { + const t = (over: Partial): UnifiedToken => ({ + address: '0x0', + symbol: 'X', + name: 'X', + decimals: 18, + chainKey: '1', + ...over, + }) + + it('collapses multiple same-symbol USDC into one row', () => { + const tokens = [ + t({ address: '0xAAA', symbol: 'USDC', name: 'USD Coin' }), + t({ address: '0xBBB', symbol: 'USDC', name: 'USD Coin (provider 2)' }), + t({ address: '0xCCC', symbol: 'USDC', name: 'USD Coin (provider 3)' }), + ] + const result = collapseTokensBySymbol(tokens) + expect(result.filter((x) => x.symbol.toUpperCase() === 'USDC')).toHaveLength(1) + }) + + it('prefers the canonical address when collapsing', () => { + const canonicalAddr = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' // Ethereum USDC + const tokens = [ + t({ address: '0xSPAM', symbol: 'USDC', name: 'Fake USDC' }), + t({ address: canonicalAddr, symbol: 'USDC', name: 'USD Coin' }), + ] + const result = collapseTokensBySymbol(tokens, '1') + const usdc = result.find((x) => x.symbol === 'USDC') + expect(usdc?.address).toBe(canonicalAddr) + }) + + it('hides USDC.e when canonical USDC is present (base symbol wins)', () => { + const tokens = [ + t({ address: '0xUSDC', symbol: 'USDC', name: 'USD Coin' }), + t({ address: '0xBRIDGED', symbol: 'USDC.e', name: 'Bridged USDC' }), + ] + const result = collapseTokensBySymbol(tokens) + const usdcFamily = result.filter((x) => normalizeSymbolKey(x.symbol) === 'USDC') + expect(usdcFamily).toHaveLength(1) + expect(usdcFamily[0].symbol).toBe('USDC') + }) + + it('keeps USDC.e when no plain USDC exists (never drops a whole family)', () => { + const tokens = [t({ address: '0xBRIDGED', symbol: 'USDC.e', name: 'Bridged USDC' })] + const result = collapseTokensBySymbol(tokens) + expect(result).toHaveLength(1) + expect(result[0].symbol).toBe('USDC.e') + }) + + it('collapses multiple native SOL entries into one', () => { + const tokens = [ + t({ address: '11111111111111111111111111111111', symbol: 'SOL', name: 'Solana', isNative: true, chainKey: 'solana' }), + t({ address: 'So11111111111111111111111111111111111111112', symbol: 'SOL', name: 'Wrapped SOL', isNative: true, chainKey: 'solana' }), + ] + const result = collapseTokensBySymbol(tokens, 'solana') + expect(result.filter((x) => x.symbol === 'SOL')).toHaveLength(1) + }) + + it('prefers higher provider count when no canonical/native distinction', () => { + const tokens = [ + t({ address: '0xLOW', symbol: 'WIF', name: 'dogwifhat', logoUrl: 'l', providerIds: { _count: 1 } as never }), + t({ address: '0xHIGH', symbol: 'WIF', name: 'dogwifhat', logoUrl: 'l', providerIds: { _count: 3 } as never }), + ] + const result = collapseTokensBySymbol(tokens) + expect(result).toHaveLength(1) + expect(result[0].address).toBe('0xHIGH') + }) + + it('leaves distinct symbols untouched', () => { + const tokens = [ + t({ address: '0x1', symbol: 'USDC', name: 'USD Coin' }), + t({ address: '0x2', symbol: 'USDT', name: 'Tether' }), + t({ address: '0x3', symbol: 'WETH', name: 'Wrapped Ether' }), + ] + const result = collapseTokensBySymbol(tokens) + expect(result).toHaveLength(3) + }) + + it('handles empty input', () => { + expect(collapseTokensBySymbol([])).toEqual([]) + }) +}) From 1c10b26e20906bf218b223dd9d79a812cba640d5 Mon Sep 17 00:00:00 2001 From: Shreyas Patil Date: Sat, 30 May 2026 23:38:54 +0530 Subject: [PATCH 09/34] chore: ignore provider-logs probe output --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 3fabf7c..7e08532 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,4 @@ next-env.d.ts .agent/ /documentation/ /inspiration/ +provider-logs/ From f6c353258964548e4a64c7c60c1e1dc582271b95 Mon Sep 17 00:00:00 2001 From: Shreyas Patil Date: Sun, 31 May 2026 10:00:02 +0530 Subject: [PATCH 10/34] chore: ignore docs/ planning folder --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 7e08532..7018024 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,6 @@ next-env.d.ts /documentation/ /inspiration/ provider-logs/ + +# Local planning/specs — never commit +docs/ From a2a537ab471476130b3b71719b9fd6e771ed65dd Mon Sep 17 00:00:00 2001 From: Shreyas Patil Date: Sun, 31 May 2026 10:19:11 +0530 Subject: [PATCH 11/34] feat(quotes): add decimal-normalized netOutput ranking with consensus-decimals guard --- src/services/__tests__/ranking.test.ts | 123 +++++++++++++++++++++++++ src/services/ranking.ts | 123 +++++++++++++++++++++++++ src/types/provider.ts | 15 ++- 3 files changed, 257 insertions(+), 4 deletions(-) create mode 100644 src/services/__tests__/ranking.test.ts create mode 100644 src/services/ranking.ts diff --git a/src/services/__tests__/ranking.test.ts b/src/services/__tests__/ranking.test.ts new file mode 100644 index 0000000..01deda7 --- /dev/null +++ b/src/services/__tests__/ranking.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from 'vitest' +import { rankQuotes, netOutput, consensusDecimals } from '../ranking' +import type { QuoteResponse } from '@/types/provider' + +/** + * Minimal quote builder. Decimals are declared via top-level `toTokenDecimals` + * (preferred) falling back to routes[0].action.toToken.decimals. + */ +function q(over: Partial & { provider: string; toAmount: string }): QuoteResponse { + return { + id: over.provider + '-' + over.toAmount, + fromAmount: '0', + toAmountMin: over.toAmount, + estimatedGas: '0', + estimatedDuration: 60, + routes: [], + ...over, + } as QuoteResponse +} + +describe('consensusDecimals', () => { + it('returns the most common declared decimals across quotes', () => { + const quotes = [ + q({ provider: 'a', toAmount: '1', toTokenDecimals: 6 }), + q({ provider: 'b', toAmount: '1', toTokenDecimals: 6 }), + q({ provider: 'c', toAmount: '1', toTokenDecimals: 18 }), + ] + expect(consensusDecimals(quotes)).toBe(6) + }) +}) + +describe('netOutput', () => { + it('normalizes toAmount by the destination decimals', () => { + // 10_000_000 at 6 decimals = 10.0 human units + expect(netOutput(q({ provider: 'a', toAmount: '10000000', toTokenDecimals: 6 }), 6)).toBeCloseTo(10, 6) + }) + + it('subtracts on-top (included:false) fee costs in USD', () => { + const quote = q({ + provider: 'a', + toAmount: '10000000', + toTokenDecimals: 6, + routes: [ + { + type: 'swap', + tool: 't', + action: { + fromToken: { address: '0x', chainId: 1, symbol: 'X', decimals: 6 }, + toToken: { address: '0x', chainId: 1, symbol: 'USDC', decimals: 6 }, + fromAmount: '10000000', + toAmount: '10000000', + }, + estimate: { + feeCosts: [ + { type: 'PROTOCOL', name: 'on-top', amount: '0', amountUSD: '0.50', included: false }, + { type: 'BRIDGE', name: 'deducted', amount: '0', amountUSD: '0.20', included: true }, + ], + }, + }, + ], + }) + // 10.0 - 0.50 (only the included:false fee) = 9.5 + expect(netOutput(quote, 6)).toBeCloseTo(9.5, 6) + }) +}) + +describe('rankQuotes', () => { + it('A1 (catastrophic): a quote that MISLABELS decimals as 18 with a huge raw toAmount cannot beat a correct 6-decimal quote', () => { + const good = q({ provider: 'lifi', toAmount: '10000000', toTokenDecimals: 6 }) // 10.0 USDC + const bad = q({ provider: 'evil', toAmount: '9000000000000000000', toTokenDecimals: 18 }) // declares 18 → 9.0 + const ranked = rankQuotes([bad, good]) + // Old BigInt(toAmount) logic ranked `bad` first (9e18 > 1e7). Correct logic: good wins (10.0 > 9.0). + expect(ranked[0].provider).toBe('lifi') + }) + + it('A1 (guard): a quote whose decimals disagree with the consensus is ranked below all trusted quotes', () => { + const trustedA = q({ provider: 'lifi', toAmount: '9986386', toTokenDecimals: 6 }) // 9.986 + const trustedB = q({ provider: 'near-intents', toAmount: '9952218', toTokenDecimals: 6 }) // 9.952 + const mislabeled = q({ provider: 'symbiosis', toAmount: '10010197', toTokenDecimals: 18 }) // claims 18 (really 6); untrusted + const ranked = rankQuotes([mislabeled, trustedA, trustedB]) + // Symbiosis declares decimals that disagree with consensus(6) → untrusted → must NOT be best. + expect(ranked[0].provider).not.toBe('symbiosis') + expect(ranked[ranked.length - 1].provider).toBe('symbiosis') + }) + + it('A2 (fees): a lower-gross quote with no on-top fee beats a higher-gross quote with a large on-top fee', () => { + const grossHighFee = q({ + provider: 'rubic', + toAmount: '10000000', // 10.0 gross + toTokenDecimals: 6, + routes: [ + { + type: 'swap', tool: 't', + action: { + fromToken: { address: '0x', chainId: 1, symbol: 'X', decimals: 6 }, + toToken: { address: '0x', chainId: 1, symbol: 'USDC', decimals: 6 }, + fromAmount: '0', toAmount: '10000000', + }, + estimate: { feeCosts: [{ type: 'PROTOCOL', name: 'fee', amount: '0', amountUSD: '0.65', included: false }] }, + }, + ], + }) // net 9.35 + const lowerNoFee = q({ provider: 'lifi', toAmount: '9900000', toTokenDecimals: 6 }) // net 9.9 + const ranked = rankQuotes([grossHighFee, lowerNoFee]) + expect(ranked[0].provider).toBe('lifi') + }) + + it('no regression: same decimals, no fees → higher output wins', () => { + const ranked = rankQuotes([ + q({ provider: 'a', toAmount: '9000000', toTokenDecimals: 6 }), + q({ provider: 'b', toAmount: '9500000', toTokenDecimals: 6 }), + ]) + expect(ranked[0].provider).toBe('b') + }) + + it('A3 (tie-break reachable): near-equal net outputs are broken by reliability', () => { + // Same net output, different reliability (lifi 95 > rubic 85) + const r = q({ provider: 'rubic', toAmount: '10000000', toTokenDecimals: 6, estimatedDuration: 60 }) + const l = q({ provider: 'lifi', toAmount: '10000000', toTokenDecimals: 6, estimatedDuration: 60 }) + const ranked = rankQuotes([r, l]) + expect(ranked[0].provider).toBe('lifi') + }) +}) diff --git a/src/services/ranking.ts b/src/services/ranking.ts new file mode 100644 index 0000000..3c2f40e --- /dev/null +++ b/src/services/ranking.ts @@ -0,0 +1,123 @@ +import type { QuoteResponse } from '@/types/provider' + +/** + * Quote ranking — pure, no provider/SDK imports (so it is unit-testable offline). + * + * Why this exists: ranking quotes by raw `BigInt(toAmount)` is unsafe because + * providers disagree on the destination token's decimals (e.g. Symbiosis/Rubic + * mislabel 6-decimal USDC as 18). Comparing raw integers across mismatched + * decimals can rank a dust-output route as "best". Instead we: + * 1. normalize each quote's output to human units using its declared decimals, + * 2. distrust any quote whose decimals disagree with the cross-provider + * consensus (the destination token is the same for every quote in a request), + * 3. subtract clearly on-top fees (FeeCost.included === false), + * 4. break near-ties with a fee/speed/reliability score. + */ + +const PROVIDER_RELIABILITY: Record = { + cctp: 98, + lifi: 95, + rango: 90, + symbiosis: 88, + rubic: 85, + 'near-intents': 80, +} + +// Net outputs within this relative gap are treated as a tie → use the score. +const TIE_EPSILON = 1e-6 + +/** Destination-token decimals a quote declares (top-level field wins, else route token). */ +export function declaredDecimals(quote: QuoteResponse): number { + if (typeof quote.toTokenDecimals === 'number') return quote.toTokenDecimals + const routeDec = quote.routes?.[0]?.action?.toToken?.decimals + if (typeof routeDec === 'number') return routeDec + return 18 +} + +/** + * The decimals the majority of quotes agree on for the (shared) destination + * token. Returns undefined if there are no quotes. Ties broken toward the + * smaller value (conservative). + */ +export function consensusDecimals(quotes: QuoteResponse[]): number | undefined { + if (quotes.length === 0) return undefined + const counts = new Map() + for (const q of quotes) { + const d = declaredDecimals(q) + counts.set(d, (counts.get(d) || 0) + 1) + } + let best: number | undefined + let bestCount = -1 + for (const [dec, count] of counts) { + if (count > bestCount || (count === bestCount && (best === undefined || dec < best))) { + best = dec + bestCount = count + } + } + return best +} + +/** Sum of on-top fee costs (included === false) in USD across a quote's routes. */ +export function onTopCostUSD(quote: QuoteResponse): number { + let total = 0 + for (const route of quote.routes || []) { + for (const fee of route.estimate?.feeCosts || []) { + if (fee.included === false) total += parseFloat(fee.amountUSD || '0') || 0 + } + } + return total +} + +/** + * Net output of a quote in human units of the destination token, minus on-top + * costs. `decimals` is the canonical decimals to normalize by (the consensus for + * trusted quotes; the quote's own for untrusted). + */ +export function netOutput(quote: QuoteResponse, decimals: number): number { + const raw = Number(quote.toAmount || '0') + const human = raw / Math.pow(10, decimals) + return human - onTopCostUSD(quote) +} + +/** Tie-breaker when net outputs are within epsilon. Higher = better. */ +export function tieBreakerScore(quote: QuoteResponse): number { + const totalFeeUSD = parseFloat(quote.fees?.totalFeeUSD || '0') || 0 + const duration = quote.estimatedDuration && quote.estimatedDuration > 0 ? quote.estimatedDuration : 600 + const reliability = PROVIDER_RELIABILITY[quote.provider] || 50 + + const feeScore = Math.max(0, 30 * (1 - Math.min(totalFeeUSD, 30) / 30)) + const speedScore = Math.max(0, 10 * (1 - Math.min(duration, 1800) / 1800)) + const reliabilityScore = (reliability / 100) * 10 + + return feeScore + speedScore + reliabilityScore +} + +/** + * Rank quotes best-first. Quotes whose declared decimals match the consensus are + * "trusted" and always rank above "untrusted" (mismatched-decimals) quotes, + * regardless of raw amount — a mislabeled quote can never win. Within each group, + * sort by net output desc, breaking near-ties with the tie-breaker score. + */ +export function rankQuotes(quotes: QuoteResponse[]): QuoteResponse[] { + if (quotes.length <= 1) return [...quotes] + const consensus = consensusDecimals(quotes) + + const decimalsFor = (q: QuoteResponse) => declaredDecimals(q) + const isTrusted = (q: QuoteResponse) => consensus === undefined || decimalsFor(q) === consensus + // Trusted quotes normalize by the consensus; untrusted by their own (best effort). + const netFor = (q: QuoteResponse) => netOutput(q, isTrusted(q) ? (consensus as number) : decimalsFor(q)) + + return [...quotes].sort((a, b) => { + const ta = isTrusted(a) + const tb = isTrusted(b) + if (ta !== tb) return ta ? -1 : 1 // trusted before untrusted + + const na = netFor(a) + const nb = netFor(b) + const denom = Math.max(1, Math.abs(na), Math.abs(nb)) + if (Math.abs(na - nb) / denom > TIE_EPSILON) { + return nb - na // higher net output first + } + return tieBreakerScore(b) - tieBreakerScore(a) // near-tie → score + }) +} diff --git a/src/types/provider.ts b/src/types/provider.ts index 833e7bd..78c7ebc 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -54,13 +54,20 @@ export interface QuoteStep { } export interface QuoteResponse { - provider: string - id: string + provider: string + id: string fromAmount: string toAmount: string toAmountMin: string - estimatedGas: string - estimatedDuration: number + estimatedGas: string + estimatedDuration: number + /** REAL decimals of the destination token. Providers MUST set this from the + * live response (never hardcode). Used to normalize toAmount for fair ranking. */ + toTokenDecimals?: number + /** OPTIONAL refinement — USD price of 1 whole destination token (only some providers expose it). */ + toTokenPriceUSD?: number + /** OPTIONAL — provider-supplied USD value of the output (LiFi/NEAR have it; Rubic/Symbiosis do not). */ + toAmountUSD?: string routes: QuoteStep[] // eslint-disable-next-line @typescript-eslint/no-explicit-any transactionRequest?: any From 8d7adc5f726265c3ef37d0f76c67faa67db567b7 Mon Sep 17 00:00:00 2001 From: Shreyas Patil Date: Sun, 31 May 2026 10:19:11 +0530 Subject: [PATCH 12/34] refactor(quotes): use shared ranking module in aggregator --- src/services/quote-aggregator.ts | 50 +------------------------------- 1 file changed, 1 insertion(+), 49 deletions(-) diff --git a/src/services/quote-aggregator.ts b/src/services/quote-aggregator.ts index b4968cb..009457a 100644 --- a/src/services/quote-aggregator.ts +++ b/src/services/quote-aggregator.ts @@ -1,5 +1,6 @@ import { providers } from './providers' import { QuoteRequest, QuoteResponse, ChainId } from '@/types/provider' +import { rankQuotes } from './ranking' const PROVIDER_TIMEOUT_MS = 30000 const QUOTE_VALIDITY_MS = 60000 @@ -25,16 +26,6 @@ function normalizeChainId(id: ChainId): ChainId { return id } -// Provider reliability scores -const PROVIDER_RELIABILITY: Record = { - 'cctp': 98, - 'lifi': 95, - 'rango': 90, - 'symbiosis': 88, - 'rubic': 85, - 'near-intents': 80, -} - export interface AggregatedQuoteResponse { quotes: QuoteResponse[] bestQuote: QuoteResponse | null @@ -75,45 +66,6 @@ async function withTimeout( } } -/** - * Calculate a secondary score for tie-breaking when output amounts are equal. - * Factors in: total fees (USD), gas cost, speed, and provider reliability. - * Higher score = better route. - */ -function calculateTieBreakerScore(quote: QuoteResponse): number { - const totalFeeUSD = parseFloat(quote.fees?.totalFeeUSD || '0') - const gasCostUSD = parseFloat(quote.estimatedGas || '0') - const duration = quote.estimatedDuration || 600 - const reliability = PROVIDER_RELIABILITY[quote.provider] || 50 - - // Fee score: 0-30 points, lower fees = higher score - const totalCost = totalFeeUSD + gasCostUSD - const feeScore = Math.max(0, 30 * (1 - Math.min(totalCost, 30) / 30)) - - // Speed score: 0-10 points, faster = higher score - const speedScore = Math.max(0, 10 * (1 - Math.min(duration, 1800) / 1800)) - - // Reliability score: 0-10 points - const reliabilityScore = (reliability / 100) * 10 - - return feeScore + speedScore + reliabilityScore -} - -function rankQuotes(quotes: QuoteResponse[]): QuoteResponse[] { - return quotes.sort((a, b) => { - const amountA = BigInt(a.toAmount || '0') - const amountB = BigInt(b.toAmount || '0') - - // Primary: Output amount (higher = better) - if (amountA !== amountB) { - return amountA > amountB ? -1 : 1 - } - - // Equal output: use fee-aware tie-breaker score (higher = better) - return calculateTieBreakerScore(b) - calculateTieBreakerScore(a) - }) -} - export const QuoteAggregator = { async getQuotes(request: QuoteRequest): Promise { const fetchedAt = Date.now() From dfbba43e9ea86dbe5899f4b70cc8cfe3f305a7c2 Mon Sep 17 00:00:00 2001 From: Shreyas Patil Date: Sun, 31 May 2026 10:19:11 +0530 Subject: [PATCH 13/34] fix(providers): emit real toTokenDecimals so ranking compares like-for-like --- src/services/providers/cctp.ts | 1 + src/services/providers/lifi.ts | 3 +++ src/services/providers/near-intents.ts | 4 +++- src/services/providers/rango.ts | 2 ++ src/services/providers/rubic.ts | 1 + src/services/providers/symbiosis.ts | 7 +++++-- 6 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/services/providers/cctp.ts b/src/services/providers/cctp.ts index 6d27d1d..b67c966 100644 --- a/src/services/providers/cctp.ts +++ b/src/services/providers/cctp.ts @@ -125,6 +125,7 @@ export class CCTPProvider implements IProvider { fromAmount: request.fromAmount, toAmount: outputAmount.toString(), toAmountMin: outputAmount.toString(), + toTokenDecimals: 6, // CCTP only ever bridges USDC (6 decimals on all supported chains) estimatedGas: '0', estimatedDuration, bridgeFee: feeAmount.toString(), diff --git a/src/services/providers/lifi.ts b/src/services/providers/lifi.ts index 37dd656..c876cfc 100644 --- a/src/services/providers/lifi.ts +++ b/src/services/providers/lifi.ts @@ -151,6 +151,9 @@ export class LifiProvider implements IProvider { fromAmount: route.fromAmount, toAmount: route.toAmount, toAmountMin: route.toAmountMin, + toTokenDecimals: route.toToken?.decimals, + toTokenPriceUSD: route.toToken?.priceUSD ? parseFloat(route.toToken.priceUSD) : undefined, + toAmountUSD: route.toAmountUSD, estimatedGas: route.gasCostUSD || '0', estimatedDuration: route.steps.reduce((acc, step) => acc + (step.estimate.executionDuration || 0), 0), transactionRequest: txData || route, diff --git a/src/services/providers/near-intents.ts b/src/services/providers/near-intents.ts index 502c435..bd07125 100644 --- a/src/services/providers/near-intents.ts +++ b/src/services/providers/near-intents.ts @@ -284,8 +284,10 @@ export class NearIntentsProvider implements IProvider { fromAmount: request.fromAmount, toAmount: quote.amountOut, toAmountMin: quote.minAmountOut || quote.amountOut, + toTokenDecimals: toTokenMeta?.decimals, + toAmountUSD: quote.amountOutUsd?.toString(), estimatedGas: '0', // NEAR Intents handles gas internally - estimatedDuration: quote.timeEstimate || 120, + estimatedDuration: quote.timeEstimate || 120, transactionRequest: quote.depositAddress ? { depositAddress: quote.depositAddress, memo: quote.depositMemo diff --git a/src/services/providers/rango.ts b/src/services/providers/rango.ts index b6a9f68..435dd82 100644 --- a/src/services/providers/rango.ts +++ b/src/services/providers/rango.ts @@ -183,6 +183,8 @@ export class RangoProvider implements IProvider { fromAmount: fromAmount, toAmount: quote.route.outputAmount, toAmountMin: quote.route.outputAmountMin, + toTokenDecimals: quote.route.to?.decimals, + toTokenPriceUSD: quote.route.to?.usdPrice ?? undefined, estimatedGas: quote.route.feeUsd?.toString() || '0', estimatedDuration: estimatedTime, transactionRequest: null, diff --git a/src/services/providers/rubic.ts b/src/services/providers/rubic.ts index 4100c85..b3b93f2 100644 --- a/src/services/providers/rubic.ts +++ b/src/services/providers/rubic.ts @@ -242,6 +242,7 @@ export class RubicProvider implements IProvider { fromAmount: request.fromAmount, toAmount: this.toWei(toAmountHuman, toDecimals), toAmountMin: this.toWei(toAmountMinHuman, toDecimals), + toTokenDecimals: toDecimals, estimatedGas: gasCostUSD, estimatedDuration: data.estimate.estimatedTime || 300, transactionRequest, diff --git a/src/services/providers/symbiosis.ts b/src/services/providers/symbiosis.ts index 232fbf3..498f2a7 100644 --- a/src/services/providers/symbiosis.ts +++ b/src/services/providers/symbiosis.ts @@ -34,12 +34,14 @@ export class SymbiosisProvider implements IProvider { chainId: fromChainId, address: request.fromToken, amount: request.fromAmount, - decimals: 18 + // Real source-token decimals (hardcoding 18 mis-prices 6/8-dec tokens like USDC/WBTC) + decimals: request.fromTokenDecimals ?? 18 }, tokenOut: { chainId: toChainId, address: request.toToken, - decimals: 18 + // Hint only — Symbiosis resolves real decimals from the address; response is authoritative + decimals: 18 }, from: request.fromAddress, to: request.toAddress || request.fromAddress, @@ -134,6 +136,7 @@ export class SymbiosisProvider implements IProvider { fromAmount: request.fromAmount, toAmount: data.tokenAmountOut.amount, toAmountMin: data.tokenAmountOutMin?.amount || data.tokenAmountOut.amount, + toTokenDecimals: data.tokenAmountOut?.decimals, estimatedGas: bridgeFeeUSD, estimatedDuration: data.estimatedTime || 65, transactionRequest: data.tx, From 50b43b42aac9833ebb2ef5ab6aa49e1f702c97aa Mon Sep 17 00:00:00 2001 From: Shreyas Patil Date: Sun, 31 May 2026 11:10:04 +0530 Subject: [PATCH 14/34] feat(executor): add deriveChainFamily to classify deposit source chains --- src/lib/__tests__/chain-family.test.ts | 47 ++++++++++++++++++++++++++ src/lib/chain-family.ts | 33 ++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 src/lib/__tests__/chain-family.test.ts create mode 100644 src/lib/chain-family.ts diff --git a/src/lib/__tests__/chain-family.test.ts b/src/lib/__tests__/chain-family.test.ts new file mode 100644 index 0000000..bc9351b --- /dev/null +++ b/src/lib/__tests__/chain-family.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest' +import { deriveChainFamily, isExecutableDepositFamily } from '../chain-family' + +describe('deriveChainFamily', () => { + it('numeric chainId → evm', () => { + expect(deriveChainFamily(1)).toBe('evm') + expect(deriveChainFamily(42161)).toBe('evm') + }) + + it('numeric string chainId → evm', () => { + expect(deriveChainFamily('137')).toBe('evm') + expect(deriveChainFamily('8453')).toBe('evm') + }) + + it('solana keys → solana', () => { + expect(deriveChainFamily('solana')).toBe('solana') + expect(deriveChainFamily('sol')).toBe('solana') + }) + + it('bitcoin keys → bitcoin', () => { + expect(deriveChainFamily('bitcoin')).toBe('bitcoin') + expect(deriveChainFamily('btc')).toBe('bitcoin') + }) + + it('exotic / memo-required chains → other (NOT evm — this is the fund-loss guard)', () => { + for (const c of ['xrp', 'ton', 'stellar', 'xlm', 'tron', 'sui', 'dogecoin', 'near', 'aptos', 'cosmos']) { + expect(deriveChainFamily(c), c).toBe('other') + } + }) + + it('is case-insensitive', () => { + expect(deriveChainFamily('SOLANA')).toBe('solana') + expect(deriveChainFamily('XRP')).toBe('other') + }) +}) + +describe('isExecutableDepositFamily', () => { + it('evm/solana/bitcoin are executable', () => { + expect(isExecutableDepositFamily('evm')).toBe(true) + expect(isExecutableDepositFamily('solana')).toBe(true) + expect(isExecutableDepositFamily('bitcoin')).toBe(true) + }) + + it('other is NOT executable (must be blocked, not misrouted)', () => { + expect(isExecutableDepositFamily('other')).toBe(false) + }) +}) diff --git a/src/lib/chain-family.ts b/src/lib/chain-family.ts new file mode 100644 index 0000000..690b76e --- /dev/null +++ b/src/lib/chain-family.ts @@ -0,0 +1,33 @@ +import type { ChainId } from '@/types/provider' + +/** + * The wallet family the app can actually sign a deposit/transaction with. + * 'other' means we have NO working signing path for that chain today — and + * several such chains (XRP, TON, Stellar) REQUIRE a memo/tag we can't safely + * attach. Callers MUST block execution for 'other' rather than misroute it to + * the EVM signer (which would send funds with a wrong-format address / no memo + * → unrecoverable loss). + */ +export type ChainFamily = 'evm' | 'solana' | 'bitcoin' | 'other' + +const SOLANA_KEYS = new Set(['solana', 'sol']) +const BITCOIN_KEYS = new Set(['bitcoin', 'btc']) + +/** + * Map a chain identifier (numeric chainId or string key) to its executable + * wallet family. Numeric (or numeric-string) ids are EVM; 'solana'/'bitcoin' + * map to themselves; everything else is 'other'. + */ +export function deriveChainFamily(chain: ChainId): ChainFamily { + if (typeof chain === 'number') return 'evm' + const s = String(chain).toLowerCase().trim() + if (/^\d+$/.test(s)) return 'evm' + if (SOLANA_KEYS.has(s)) return 'solana' + if (BITCOIN_KEYS.has(s)) return 'bitcoin' + return 'other' +} + +/** Whether a deposit can be executed for this family with a real wallet today. */ +export function isExecutableDepositFamily(family: ChainFamily): boolean { + return family === 'evm' || family === 'solana' || family === 'bitcoin' +} From a7faa9d6c0bc151ae8a4f7029c0ac4fb9a71f21c Mon Sep 17 00:00:00 2001 From: Shreyas Patil Date: Sun, 31 May 2026 11:10:04 +0530 Subject: [PATCH 15/34] fix(near): derive real chain family + forward deposit memo to status lookup --- src/services/providers/near-intents.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/services/providers/near-intents.ts b/src/services/providers/near-intents.ts index bd07125..6c6f19e 100644 --- a/src/services/providers/near-intents.ts +++ b/src/services/providers/near-intents.ts @@ -1,5 +1,6 @@ import { IProvider, QuoteRequest, QuoteResponse, StatusRequest, StatusResponse, TransactionStatus, FeeCost } from '@/types/provider' import { OneClickService, QuoteRequest as DefuseQuoteRequest, OpenAPI, TokenResponse } from '@defuse-protocol/one-click-sdk-typescript' +import { deriveChainFamily } from '@/lib/chain-family' OpenAPI.BASE = 'https://1click.chaindefuser.com' @@ -299,9 +300,9 @@ export class NearIntentsProvider implements IProvider { deadline: quote.deadline, amountOutFormatted: quote.amountOutFormatted, amountOutUsd: quote.amountOutUsd, - chainType: String(request.fromChain) === 'solana' ? 'solana' as const - : String(request.fromChain) === 'bitcoin' ? 'bitcoin' as const - : 'evm' as const, + // Real family — XRP/TON/Stellar/Tron/Sui resolve to 'other' (not 'evm'), + // so the executor blocks them instead of misrouting to the EVM signer. + chainType: deriveChainFamily(request.fromChain), isDepositTrade: true, amountToSend: request.fromAmount, }, @@ -352,7 +353,7 @@ export class NearIntentsProvider implements IProvider { } async getStatus(request: StatusRequest): Promise { - const { depositAddress } = request + const { depositAddress, depositMemo } = request if (!depositAddress) { console.error('NearIntentsProvider: depositAddress missing for status check') @@ -363,7 +364,8 @@ export class NearIntentsProvider implements IProvider { } try { - const response = await OneClickService.getExecutionStatus(depositAddress) + // Pass the memo — memo-required deposit chains need it for status lookup. + const response = await OneClickService.getExecutionStatus(depositAddress, depositMemo) let finalStatus: TransactionStatus = 'PENDING' const status = (response.status || '').toUpperCase() From c17ae0c02d5701e758fb030f91cfded750989b76 Mon Sep 17 00:00:00 2001 From: Shreyas Patil Date: Sun, 31 May 2026 11:10:04 +0530 Subject: [PATCH 16/34] fix(executor): block unsupported deposit chains and forward NEAR deposit memo --- src/hooks/useTransactionExecutor.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/hooks/useTransactionExecutor.ts b/src/hooks/useTransactionExecutor.ts index 473b631..5539e0b 100644 --- a/src/hooks/useTransactionExecutor.ts +++ b/src/hooks/useTransactionExecutor.ts @@ -159,11 +159,12 @@ export function useTransactionExecutor() { setTxHash(hash) setStep('Submitting Transaction Hash...') - // 3. Submit Hash to Solver Network + // 3. Submit Hash to Solver Network (include memo for memo-required chains) try { await OneClickService.submitDepositTx({ txHash: hash, - depositAddress + depositAddress, + memo: quote.metadata?.depositMemo, }) } catch (e) { console.warn('Failed to submit tx hash to Near Intents:', e) @@ -409,7 +410,7 @@ export function useTransactionExecutor() { if (quote.provider === 'near-intents' && depositAddress) { try { - await OneClickService.submitDepositTx({ txHash, depositAddress }) + await OneClickService.submitDepositTx({ txHash, depositAddress, memo: quote.metadata?.depositMemo }) } catch (e) { console.warn('Failed to submit Solana deposit hash:', e) } @@ -459,7 +460,7 @@ export function useTransactionExecutor() { if (quote.provider === 'near-intents' && depositAddress) { try { - await OneClickService.submitDepositTx({ txHash, depositAddress }) + await OneClickService.submitDepositTx({ txHash, depositAddress, memo: quote.metadata?.depositMemo }) } catch (e) { console.warn('Failed to submit BTC deposit hash:', e) } @@ -492,6 +493,17 @@ export function useTransactionExecutor() { if (chainType === 'bitcoin') { return await executeBitcoinDeposit(quote) } + // Fund-safety: never let a non-EVM/non-Solana/non-Bitcoin deposit + // (XRP/TON/Stellar/Tron/Sui — often memo/tag-required) fall through to + // the EVM signer below. We have no working signing path for these, and + // misrouting would send funds to a wrong-format address / without the + // required memo → unrecoverable loss. + if (chainType === 'other') { + throw new Error( + 'Paying from this source chain is not supported yet. ' + + 'Please choose an EVM, Solana, or Bitcoin source token.', + ) + } } // EVM execution paths From 9e28398e94fc572f5bf696ccdd7c562bccd605c9 Mon Sep 17 00:00:00 2001 From: Shreyas Patil Date: Sun, 31 May 2026 11:24:57 +0530 Subject: [PATCH 17/34] fix(types): add depositMemo + 'other' chainType so Phase 2 type-checks --- src/hooks/useTransactionExecutor.ts | 6 +++--- src/types/provider.ts | 11 +++++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/hooks/useTransactionExecutor.ts b/src/hooks/useTransactionExecutor.ts index 5539e0b..effd87a 100644 --- a/src/hooks/useTransactionExecutor.ts +++ b/src/hooks/useTransactionExecutor.ts @@ -164,7 +164,7 @@ export function useTransactionExecutor() { await OneClickService.submitDepositTx({ txHash: hash, depositAddress, - memo: quote.metadata?.depositMemo, + memo: quote.metadata?.depositMemo as string | undefined, }) } catch (e) { console.warn('Failed to submit tx hash to Near Intents:', e) @@ -410,7 +410,7 @@ export function useTransactionExecutor() { if (quote.provider === 'near-intents' && depositAddress) { try { - await OneClickService.submitDepositTx({ txHash, depositAddress, memo: quote.metadata?.depositMemo }) + await OneClickService.submitDepositTx({ txHash, depositAddress, memo: quote.metadata?.depositMemo as string | undefined }) } catch (e) { console.warn('Failed to submit Solana deposit hash:', e) } @@ -460,7 +460,7 @@ export function useTransactionExecutor() { if (quote.provider === 'near-intents' && depositAddress) { try { - await OneClickService.submitDepositTx({ txHash, depositAddress, memo: quote.metadata?.depositMemo }) + await OneClickService.submitDepositTx({ txHash, depositAddress, memo: quote.metadata?.depositMemo as string | undefined }) } catch (e) { console.warn('Failed to submit BTC deposit hash:', e) } diff --git a/src/types/provider.ts b/src/types/provider.ts index 78c7ebc..53b82b6 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -82,21 +82,24 @@ export interface QuoteResponse { } toolsUsed?: string[] metadata?: { - chainType?: 'evm' | 'solana' | 'bitcoin' + chainType?: 'evm' | 'solana' | 'bitcoin' | 'other' isDepositTrade?: boolean depositAddress?: string + depositMemo?: string amountToSend?: string [key: string]: unknown - } + } } export interface StatusRequest { txHash: string fromChainId: ChainId toChainId: ChainId - bridge?: string - requestId?: string + bridge?: string + requestId?: string depositAddress?: string + /** Memo/tag for deposit chains that require it (NEAR Intents status lookup). */ + depositMemo?: string } export type TransactionStatus = 'PENDING' | 'DONE' | 'FAILED' | 'NOT_FOUND' From 08d1072b8ea6ac5cb6c69d94657a9f7b670a1463 Mon Sep 17 00:00:00 2001 From: Shreyas Patil Date: Sun, 31 May 2026 11:53:03 +0530 Subject: [PATCH 18/34] fix(quotes): rank against authoritative destination decimals, not majority vote --- src/app/api/quotes/route.ts | 1 + src/services/__tests__/ranking.test.ts | 19 +++++++++++++++++++ src/services/quote-aggregator.ts | 5 ++++- src/services/ranking.ts | 26 +++++++++++++++++--------- src/types/provider.ts | 5 ++++- 5 files changed, 45 insertions(+), 11 deletions(-) diff --git a/src/app/api/quotes/route.ts b/src/app/api/quotes/route.ts index b4df421..f74e771 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) { diff --git a/src/services/__tests__/ranking.test.ts b/src/services/__tests__/ranking.test.ts index 01deda7..384c557 100644 --- a/src/services/__tests__/ranking.test.ts +++ b/src/services/__tests__/ranking.test.ts @@ -83,6 +83,25 @@ describe('rankQuotes', () => { expect(ranked[ranked.length - 1].provider).toBe('symbiosis') }) + it('A1 (majority-mislabel): when MOST providers mislabel decimals, the authoritative refDecimals still wins', () => { + // Real dest token is 6-dec USDC. Two providers mislabel as 18, one correct at 6. + // Majority-vote consensus would pick 18 and demote the correct quote — the bug. + const correct = q({ provider: 'lifi', toAmount: '10000000', toTokenDecimals: 6 }) // 10.0 USDC + const wrong1 = q({ provider: 'symbiosis', toAmount: '9000000000000000000', toTokenDecimals: 18 }) // claims 18 → 9.0 + const wrong2 = q({ provider: 'rubic', toAmount: '9500000000000000000', toTokenDecimals: 18 }) // claims 18 → 9.5 + // refDecimals=6 is the authoritative destination-token decimals from the request. + const ranked = rankQuotes([wrong1, wrong2, correct], 6) + expect(ranked[0].provider).toBe('lifi') // correct 6-dec quote wins despite being the minority + }) + + it('refDecimals: quotes matching the authoritative decimals are trusted; mismatches demoted', () => { + const good = q({ provider: 'lifi', toAmount: '10000000', toTokenDecimals: 6 }) + const bad = q({ provider: 'symbiosis', toAmount: '99999999999999999999', toTokenDecimals: 18 }) + const ranked = rankQuotes([bad, good], 6) + expect(ranked[0].provider).toBe('lifi') + expect(ranked[ranked.length - 1].provider).toBe('symbiosis') + }) + it('A2 (fees): a lower-gross quote with no on-top fee beats a higher-gross quote with a large on-top fee', () => { const grossHighFee = q({ provider: 'rubic', diff --git a/src/services/quote-aggregator.ts b/src/services/quote-aggregator.ts index 009457a..f2b9bd1 100644 --- a/src/services/quote-aggregator.ts +++ b/src/services/quote-aggregator.ts @@ -138,7 +138,10 @@ export const QuoteAggregator = { console.log('Total Quotes:', allQuotes.length) // Rank quotes with tie-breakers - const rankedQuotes = rankQuotes(allQuotes) + // Pass the authoritative destination-token decimals so a provider that + // mislabels them (e.g. reports 6-dec USDC as 18) can't win the ranking, + // even if it's the majority. Falls back to consensus when not supplied. + const rankedQuotes = rankQuotes(allQuotes, request.toTokenDecimals) return { quotes: rankedQuotes, diff --git a/src/services/ranking.ts b/src/services/ranking.ts index 3c2f40e..1d14719 100644 --- a/src/services/ranking.ts +++ b/src/services/ranking.ts @@ -93,19 +93,27 @@ export function tieBreakerScore(quote: QuoteResponse): number { } /** - * Rank quotes best-first. Quotes whose declared decimals match the consensus are - * "trusted" and always rank above "untrusted" (mismatched-decimals) quotes, - * regardless of raw amount — a mislabeled quote can never win. Within each group, - * sort by net output desc, breaking near-ties with the tie-breaker score. + * Rank quotes best-first. + * + * Decimals are a FACT about the (shared) destination token, not a vote. When the + * caller knows the authoritative destination-token decimals (the aggregator does — + * every quote in a request targets the same token), pass `refDecimals`: any quote + * whose declared decimals disagree is "untrusted" and ranks below all trusted + * quotes, regardless of raw amount. This holds even when the MAJORITY of providers + * mislabel the token (majority-vote consensus would fail there). + * + * When `refDecimals` is omitted (e.g. offline tests with no known reference), + * fall back to the most-common declared decimals as a best-effort consensus. */ -export function rankQuotes(quotes: QuoteResponse[]): QuoteResponse[] { +export function rankQuotes(quotes: QuoteResponse[], refDecimals?: number): QuoteResponse[] { if (quotes.length <= 1) return [...quotes] - const consensus = consensusDecimals(quotes) + // Authoritative reference wins; otherwise best-effort majority consensus. + const reference = typeof refDecimals === 'number' ? refDecimals : consensusDecimals(quotes) const decimalsFor = (q: QuoteResponse) => declaredDecimals(q) - const isTrusted = (q: QuoteResponse) => consensus === undefined || decimalsFor(q) === consensus - // Trusted quotes normalize by the consensus; untrusted by their own (best effort). - const netFor = (q: QuoteResponse) => netOutput(q, isTrusted(q) ? (consensus as number) : decimalsFor(q)) + const isTrusted = (q: QuoteResponse) => reference === undefined || decimalsFor(q) === reference + // Trusted quotes normalize by the reference; untrusted by their own (best effort). + const netFor = (q: QuoteResponse) => netOutput(q, isTrusted(q) ? (reference as number) : decimalsFor(q)) return [...quotes].sort((a, b) => { const ta = isTrusted(a) diff --git a/src/types/provider.ts b/src/types/provider.ts index 53b82b6..6a13469 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -15,8 +15,11 @@ export interface QuoteRequest { fromAmount: string fromAddress: string toAddress?: string - slippage?: number + slippage?: number fromTokenDecimals?: number // Required for Rubic + /** Authoritative destination-token decimals — used to rank quotes fairly + * (a provider that mislabels the dest token's decimals can't win). */ + toTokenDecimals?: number } export interface FeeCost { From fd1911a98f26ddf39bf9e25e3891abe7361cf843 Mon Sep 17 00:00:00 2001 From: Shreyas Patil Date: Sun, 31 May 2026 11:53:03 +0530 Subject: [PATCH 19/34] fix(near): forward deposit memo through status-poll path end-to-end --- src/inngest/functions/poll-transaction.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/inngest/functions/poll-transaction.ts b/src/inngest/functions/poll-transaction.ts index 7761e09..6773502 100644 --- a/src/inngest/functions/poll-transaction.ts +++ b/src/inngest/functions/poll-transaction.ts @@ -55,7 +55,7 @@ export const pollTransactionStatus = inngest.createFunction( { id: 'poll-transaction-status' }, { event: 'transaction/poll' }, async ({ event, step }) => { - const { transactionId, txHash, fromChainId, toChainId, bridge, provider: providerName, requestId, depositAddress, attempt = 1 } = event.data + const { transactionId, txHash, fromChainId, toChainId, bridge, provider: providerName, requestId, depositAddress, depositMemo, attempt = 1 } = event.data // Skip if no txHash yet if (!txHash) { @@ -99,6 +99,7 @@ export const pollTransactionStatus = inngest.createFunction( bridge, requestId, // For Rango depositAddress, // For Near Intents + depositMemo, // For memo-required NEAR deposit chains } return provider.getStatus(request) }) From 27c604bb3310c169a8c45b64068ccdc36299c04a Mon Sep 17 00:00:00 2001 From: Shreyas Patil Date: Sun, 31 May 2026 11:55:24 +0530 Subject: [PATCH 20/34] fix(quotes): convert on-top fees to token units via price for non-stablecoin dests --- src/services/__tests__/ranking.test.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/services/__tests__/ranking.test.ts b/src/services/__tests__/ranking.test.ts index 384c557..48f6784 100644 --- a/src/services/__tests__/ranking.test.ts +++ b/src/services/__tests__/ranking.test.ts @@ -35,6 +35,29 @@ describe('netOutput', () => { expect(netOutput(q({ provider: 'a', toAmount: '10000000', toTokenDecimals: 6 }), 6)).toBeCloseTo(10, 6) }) + it('converts on-top USD fee to destination-token units when toTokenPriceUSD is known', () => { + // Dest token worth $2000 (e.g. WETH). 1.0 token out, $20 on-top fee = 0.01 token. + const quote = q({ + provider: 'a', + toAmount: '1000000000000000000', // 1.0 at 18 dec + toTokenDecimals: 18, + toTokenPriceUSD: 2000, + routes: [ + { + type: 'swap', tool: 't', + action: { + fromToken: { address: '0x', chainId: 1, symbol: 'X', decimals: 18 }, + toToken: { address: '0x', chainId: 1, symbol: 'WETH', decimals: 18 }, + fromAmount: '0', toAmount: '1000000000000000000', + }, + estimate: { feeCosts: [{ type: 'GAS', name: 'gas', amount: '0', amountUSD: '20', included: false }] }, + }, + ], + }) + // 1.0 WETH - ($20 / $2000) = 1.0 - 0.01 = 0.99 (NOT 1.0 - 20 = -19) + expect(netOutput(quote, 18)).toBeCloseTo(0.99, 6) + }) + it('subtracts on-top (included:false) fee costs in USD', () => { const quote = q({ provider: 'a', From ab12dfa840a6f5e891029ba6073e417ef552c3cb Mon Sep 17 00:00:00 2001 From: Shreyas Patil Date: Sun, 31 May 2026 11:55:24 +0530 Subject: [PATCH 21/34] test(quotes): drop stale duplicate ranking tests, keep normalizeChainId --- src/lib/__tests__/quote-aggregator.test.ts | 140 ++------------------- 1 file changed, 7 insertions(+), 133 deletions(-) diff --git a/src/lib/__tests__/quote-aggregator.test.ts b/src/lib/__tests__/quote-aggregator.test.ts index 3cd6ad5..a130065 100644 --- a/src/lib/__tests__/quote-aggregator.test.ts +++ b/src/lib/__tests__/quote-aggregator.test.ts @@ -1,16 +1,15 @@ /** - * Tests for pure utility functions extracted from the quote aggregator. + * Tests for the pure chain-alias normalization used by the quote aggregator. * - * We cannot import the QuoteAggregator directly because it pulls in providers - * with heavy SDK deps (LiFi, Rango, etc). Instead we test the exported pure logic: - * normalizeChainId, calculateTieBreakerScore, rankQuotes - * - * These are rewritten here since they're not exported from the module. - * If they get exported in the future, switch to direct imports. + * NOTE: quote RANKING is now tested in src/services/__tests__/ranking.test.ts + * against the real `ranking.ts` module. This file previously re-implemented the + * OLD raw-BigInt(toAmount) ranking inline; that logic was replaced (it ranked + * mislabeled-decimals quotes as best) so those duplicated tests were removed to + * avoid a maintenance trap. Only the still-valid normalizeChainId tests remain. */ import { describe, it, expect } from 'vitest' -// ─── Recreate pure functions from quote-aggregator ──────────────── +// ─── Recreate the pure normalizeChainId from quote-aggregator ────── const CHAIN_ID_ALIASES: Record = { sol: 'solana', @@ -30,52 +29,6 @@ function normalizeChainId(id: ChainId): ChainId { return id } -const PROVIDER_RELIABILITY: Record = { - 'cctp': 98, - 'lifi': 95, - 'rango': 90, - 'symbiosis': 88, - 'rubic': 85, - 'near-intents': 80, -} - -interface MockQuote { - provider: string - toAmount: string - fees?: { totalFeeUSD?: string } - estimatedGas?: string - estimatedDuration?: number -} - -function calculateTieBreakerScore(quote: MockQuote): number { - const totalFeeUSD = parseFloat(quote.fees?.totalFeeUSD || '0') - const gasCostUSD = parseFloat(quote.estimatedGas || '0') - const duration = quote.estimatedDuration || 600 - const reliability = PROVIDER_RELIABILITY[quote.provider] || 50 - - const totalCost = totalFeeUSD + gasCostUSD - const feeScore = Math.max(0, 30 * (1 - Math.min(totalCost, 30) / 30)) - const speedScore = Math.max(0, 10 * (1 - Math.min(duration, 1800) / 1800)) - const reliabilityScore = (reliability / 100) * 10 - - return feeScore + speedScore + reliabilityScore -} - -function rankQuotes(quotes: MockQuote[]): MockQuote[] { - return [...quotes].sort((a, b) => { - const amountA = BigInt(a.toAmount || '0') - const amountB = BigInt(b.toAmount || '0') - - if (amountA !== amountB) { - return amountA > amountB ? -1 : 1 - } - - return calculateTieBreakerScore(b) - calculateTieBreakerScore(a) - }) -} - -// ─── normalizeChainId ───────────────────────────────────────────── - describe('normalizeChainId', () => { it('maps "sol" → "solana"', () => { expect(normalizeChainId('sol')).toBe('solana') @@ -103,82 +56,3 @@ describe('normalizeChainId', () => { expect(normalizeChainId('ethereum')).toBe('ethereum') }) }) - -// ─── calculateTieBreakerScore ───────────────────────────────────── - -describe('calculateTieBreakerScore', () => { - it('higher reliability = higher score (same fees/speed)', () => { - const cctp = calculateTieBreakerScore({ provider: 'cctp', toAmount: '100', estimatedDuration: 300 }) - const rubic = calculateTieBreakerScore({ provider: 'rubic', toAmount: '100', estimatedDuration: 300 }) - expect(cctp).toBeGreaterThan(rubic) - }) - - it('lower fees = higher score', () => { - const lowFee = calculateTieBreakerScore({ - provider: 'lifi', toAmount: '100', - fees: { totalFeeUSD: '1' }, estimatedGas: '0', - }) - const highFee = calculateTieBreakerScore({ - provider: 'lifi', toAmount: '100', - fees: { totalFeeUSD: '25' }, estimatedGas: '0', - }) - expect(lowFee).toBeGreaterThan(highFee) - }) - - it('faster routes score higher', () => { - const fast = calculateTieBreakerScore({ - provider: 'lifi', toAmount: '100', estimatedDuration: 60, - }) - const slow = calculateTieBreakerScore({ - provider: 'lifi', toAmount: '100', estimatedDuration: 1800, - }) - expect(fast).toBeGreaterThan(slow) - }) - - it('returns non-negative for worst-case inputs', () => { - const score = calculateTieBreakerScore({ - provider: 'unknown', toAmount: '0', - fees: { totalFeeUSD: '999' }, estimatedGas: '999', estimatedDuration: 99999, - }) - expect(score).toBeGreaterThanOrEqual(0) - }) -}) - -// ─── rankQuotes ─────────────────────────────────────────────────── - -describe('rankQuotes', () => { - it('ranks by output amount (highest first)', () => { - const quotes: MockQuote[] = [ - { provider: 'rubic', toAmount: '100' }, - { provider: 'lifi', toAmount: '200' }, - { provider: 'rango', toAmount: '150' }, - ] - - const ranked = rankQuotes(quotes) - expect(ranked[0].provider).toBe('lifi') - expect(ranked[1].provider).toBe('rango') - expect(ranked[2].provider).toBe('rubic') - }) - - it('uses tie-breaker when amounts are equal', () => { - const quotes: MockQuote[] = [ - { provider: 'rubic', toAmount: '100', fees: { totalFeeUSD: '10' } }, - { provider: 'cctp', toAmount: '100', fees: { totalFeeUSD: '1' } }, - ] - - const ranked = rankQuotes(quotes) - // cctp has higher reliability AND lower fees - expect(ranked[0].provider).toBe('cctp') - }) - - it('handles single quote', () => { - const quotes: MockQuote[] = [{ provider: 'lifi', toAmount: '500' }] - const ranked = rankQuotes(quotes) - expect(ranked).toHaveLength(1) - expect(ranked[0].provider).toBe('lifi') - }) - - it('handles empty array', () => { - expect(rankQuotes([])).toEqual([]) - }) -}) From a6ea47af762854df17fc5d4831b6557716e411d4 Mon Sep 17 00:00:00 2001 From: Shreyas Patil Date: Sun, 31 May 2026 12:05:08 +0530 Subject: [PATCH 22/34] fix(ui): send destination token decimals to quote API for fair ranking --- src/components/PaymentInterface.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/components/PaymentInterface.tsx b/src/components/PaymentInterface.tsx index cdb6f78..c2fa9b5 100644 --- a/src/components/PaymentInterface.tsx +++ b/src/components/PaymentInterface.tsx @@ -113,6 +113,9 @@ export default function PaymentInterface({ link, onSuccess }: PaymentInterfacePr // 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 @@ -127,6 +130,7 @@ export default function PaymentInterface({ link, onSuccess }: PaymentInterfacePr ) if (usdc?.address) { setResolvedUSDCAddress(usdc.address) + if (typeof usdc.decimals === 'number') setResolvedUSDCDecimals(usdc.decimals) return } } @@ -320,6 +324,7 @@ export default function PaymentInterface({ link, onSuccess }: PaymentInterfacePr fromAddress: address, toAddress: link.recipient_address, fromTokenDecimals: fromToken.decimals, + toTokenDecimals: resolvedUSDCDecimals, }), }) From 6b75dde3ed801190c60078aa221d8ccdd6cf70ef Mon Sep 17 00:00:00 2001 From: Shreyas Patil Date: Sun, 31 May 2026 12:17:31 +0530 Subject: [PATCH 23/34] feat(executor): add real Solana execution path for non-NEAR providers --- src/hooks/useTransactionExecutor.ts | 85 ++++++++++++++- src/lib/__tests__/execution-path.test.ts | 132 +++++++++++++++++++++++ src/lib/execution-path.ts | 32 ++++++ 3 files changed, 245 insertions(+), 4 deletions(-) create mode 100644 src/lib/__tests__/execution-path.test.ts create mode 100644 src/lib/execution-path.ts diff --git a/src/hooks/useTransactionExecutor.ts b/src/hooks/useTransactionExecutor.ts index effd87a..6840d51 100644 --- a/src/hooks/useTransactionExecutor.ts +++ b/src/hooks/useTransactionExecutor.ts @@ -16,7 +16,8 @@ import { getSolanaConnection, isSolNative, } from '@/lib/solana' -import { PublicKey } from '@solana/web3.js' +import { resolveExecutionPath } from '@/lib/execution-path' +import { PublicKey, VersionedTransaction, Transaction } from '@solana/web3.js' OpenAPI.BASE = 'https://1click.chaindefuser.com' @@ -371,6 +372,73 @@ export function useTransactionExecutor() { } }, []) + // Solana execution for atomic swap providers (primarily LI.FI) that return a + // base64 serialized (Versioned)Transaction in transactionRequest.data. + const executeLifiSolana = useCallback(async (quote: QuoteResponse) => { + const solana = (window as any).phantom?.solana || (window as any).solana + if (!solana) throw new Error('Solana wallet not connected') + if (!solana.signAndSendTransaction) { + throw new Error('Connected Solana wallet does not support signAndSendTransaction.') + } + + const serialized = quote.transactionRequest?.data as string | undefined + if (!serialized) throw new Error('No serialized Solana transaction data in quote') + + setStatus('executing') + setStep('Signing Solana Transaction...') + + try { + let transaction: VersionedTransaction | Transaction + try { + transaction = deserializeSolanaTransaction(serialized) + } catch { + // Fallback: legacy (non-versioned) Solana transaction + transaction = Transaction.from(Buffer.from(serialized, 'base64')) + } + + const result = await solana.signAndSendTransaction(transaction) + const hash = typeof result === 'string' ? result : result?.signature || result?.toString() + + setTxHash(hash) + setStep('Confirming Solana Transaction...') + + try { + const connection = getSolanaConnection() + const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash('confirmed') + await connection.confirmTransaction( + { signature: hash, blockhash, lastValidBlockHeight }, + 'confirmed' + ) + } catch (confirmErr) { + console.warn('Solana confirmation polling failed (tx may still succeed):', confirmErr) + } + + // Deposit-based providers (e.g. NEAR via Solana) need the hash submitted to + // the solver; LI.FI is self-contained and needs no extra submission. + const depositAddress = + quote.metadata?.depositAddress || quote.transactionRequest?.depositAddress + if (quote.provider === 'near-intents' && depositAddress) { + try { + await OneClickService.submitDepositTx({ + txHash: hash, + depositAddress: depositAddress as string, + memo: quote.metadata?.depositMemo as string | undefined, + }) + } catch (e) { + console.warn('Failed to submit Solana swap hash:', e) + } + } + + setStatus('completed') + return hash + } catch (e: any) { + console.error('Solana Swap Execution Error:', e) + setError(e.message || 'Solana Swap Execution Failed') + setStatus('failed') + throw e + } + }, []) + const executeSolanaDeposit = useCallback(async (quote: QuoteResponse) => { const solana = (window as any).phantom?.solana || (window as any).solana if (!solana || !address) throw new Error('Solana wallet not connected') @@ -506,11 +574,20 @@ export function useTransactionExecutor() { } } + // Non-deposit Solana-source swaps (primarily LI.FI) sign a serialized + // Solana transaction. Without this branch they fall through to the EVM + // signer below and become unexecutable. + if (!isDepositTrade && quote.provider !== 'near-intents' && quote.provider !== 'cctp') { + if (resolveExecutionPath(quote) === 'solana') { + return await executeLifiSolana(quote) + } + } + // EVM execution paths if (quote.provider === 'lifi') { const route = (quote.metadata?.lifiRoute as Route) || quote.routes[0] || quote.transactionRequest - return await executeLifi(route) - } + return await executeLifi(route) + } else if (quote.provider === 'rango') { return await executeRango(quote, recipientAddress) } @@ -577,7 +654,7 @@ export function useTransactionExecutor() { setStatus('failed') throw e } - }, [executeLifi, executeRango, executeNearIntents, executeSolanaDeposit, executeBitcoinDeposit, walletClient, publicClient]) + }, [executeLifi, executeRango, executeNearIntents, executeLifiSolana, executeSolanaDeposit, executeBitcoinDeposit, walletClient, publicClient]) return { execute, status, error, txHash, step } } diff --git a/src/lib/__tests__/execution-path.test.ts b/src/lib/__tests__/execution-path.test.ts new file mode 100644 index 0000000..47a6f36 --- /dev/null +++ b/src/lib/__tests__/execution-path.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from 'vitest' +import { resolveExecutionPath } from '../execution-path' +import type { QuoteResponse } from '@/types/provider' + +function makeQuote(overrides: Partial): QuoteResponse { + return { + provider: 'lifi', + id: 'q1', + fromAmount: '1000000', + toAmount: '990000', + toAmountMin: '980000', + estimatedGas: '0', + estimatedDuration: 60, + toolsUsed: [], + routes: [], + ...overrides, + } as QuoteResponse +} + +describe('resolveExecutionPath', () => { + it('routes near-intents quotes to the near path', () => { + const quote = makeQuote({ provider: 'near-intents' }) + expect(resolveExecutionPath(quote)).toBe('near') + }) + + it('routes a LiFi Solana-source quote to the solana path (via metadata.chainType)', () => { + const quote = makeQuote({ + provider: 'lifi', + metadata: { chainType: 'solana' }, + routes: [ + { + type: 'swap', + tool: 'jupiter', + action: { + fromToken: { address: 'So111', chainId: 1151111081099710, symbol: 'SOL', decimals: 9 }, + toToken: { address: '0xabc', chainId: 8453, symbol: 'USDC', decimals: 6 }, + fromAmount: '1000000000', + toAmount: '990000', + }, + estimate: {}, + }, + ], + }) + expect(resolveExecutionPath(quote)).toBe('solana') + }) + + it('routes a non-NEAR Solana-source quote to the solana path (via string chain id)', () => { + const quote = makeQuote({ + provider: 'rubic', + routes: [ + { + type: 'bridge', + tool: 'rubic', + action: { + fromToken: { address: 'So111', chainId: 'solana', symbol: 'SOL', decimals: 9 }, + toToken: { address: '0xabc', chainId: 8453, symbol: 'USDC', decimals: 6 }, + fromAmount: '1000000000', + toAmount: '990000', + }, + estimate: {}, + }, + ], + }) + expect(resolveExecutionPath(quote)).toBe('solana') + }) + + it('does NOT send a LiFi Solana-source quote to the evm path', () => { + const quote = makeQuote({ + provider: 'lifi', + metadata: { chainType: 'solana' }, + routes: [ + { + type: 'swap', + tool: 'jupiter', + action: { + fromToken: { address: 'So111', chainId: 1151111081099710, symbol: 'SOL', decimals: 9 }, + toToken: { address: '0xabc', chainId: 8453, symbol: 'USDC', decimals: 6 }, + fromAmount: '1000000000', + toAmount: '990000', + }, + estimate: {}, + }, + ], + }) + expect(resolveExecutionPath(quote)).not.toBe('evm') + }) + + it('routes an EVM LiFi quote to the evm path', () => { + const quote = makeQuote({ + provider: 'lifi', + metadata: { chainType: 'evm' }, + routes: [ + { + type: 'swap', + tool: 'uniswap', + action: { + fromToken: { address: '0xdef', chainId: 1, symbol: 'WETH', decimals: 18 }, + toToken: { address: '0xabc', chainId: 8453, symbol: 'USDC', decimals: 6 }, + fromAmount: '1000000000000000000', + toAmount: '990000', + }, + estimate: {}, + }, + ], + }) + expect(resolveExecutionPath(quote)).toBe('evm') + }) + + it('blocks a deposit-trade quote from an unsupported (other) source chain', () => { + const quote = makeQuote({ + provider: 'rubic', + metadata: { chainType: 'other', isDepositTrade: true }, + }) + expect(resolveExecutionPath(quote)).toBe('blocked') + }) + + it('routes a bitcoin deposit-trade quote to the bitcoin path', () => { + const quote = makeQuote({ + provider: 'symbiosis', + metadata: { chainType: 'bitcoin', isDepositTrade: true }, + }) + expect(resolveExecutionPath(quote)).toBe('bitcoin') + }) + + it('routes a solana deposit-trade quote to the solana path', () => { + const quote = makeQuote({ + provider: 'rubic', + metadata: { chainType: 'solana', isDepositTrade: true }, + }) + expect(resolveExecutionPath(quote)).toBe('solana') + }) +}) diff --git a/src/lib/execution-path.ts b/src/lib/execution-path.ts new file mode 100644 index 0000000..bdee462 --- /dev/null +++ b/src/lib/execution-path.ts @@ -0,0 +1,32 @@ +import type { QuoteResponse } from '@/types/provider' +import { deriveChainFamily } from './chain-family' + +/** + * The execution branch the transaction executor should dispatch a quote to. + * 'blocked' means we have no safe signing path for the source chain and must + * refuse rather than misroute funds. + */ +export type ExecutionPath = 'near' | 'solana' | 'bitcoin' | 'evm' | 'blocked' + +/** + * Resolve which execution branch a quote belongs to. + * + * Solana detection is intentionally tolerant: LiFi reports its Solana chain id + * as a numeric value (1151111081099710), which deriveChainFamily classifies as + * 'evm', so we also honor the provider-set metadata.chainType. Without this, a + * LiFi Solana-source quote would fall through to the EVM signer and be + * unexecutable. + */ +export function resolveExecutionPath(quote: QuoteResponse): ExecutionPath { + if (quote.provider === 'near-intents') return 'near' + + const chainType = quote.metadata?.chainType as string | undefined + const sourceFamily = deriveChainFamily(quote.routes?.[0]?.action?.fromToken?.chainId ?? '') + + if (chainType === 'solana' || sourceFamily === 'solana') return 'solana' + if (chainType === 'bitcoin' || sourceFamily === 'bitcoin') return 'bitcoin' + + if (quote.metadata?.isDepositTrade && chainType === 'other') return 'blocked' + + return 'evm' +} From ac3463918bce2479f0f4f103f87e3692620aed53 Mon Sep 17 00:00:00 2001 From: Shreyas Patil Date: Sun, 31 May 2026 12:20:56 +0530 Subject: [PATCH 24/34] fix(rubic): drop quotes with no executable transaction data --- src/services/__tests__/rubic.test.ts | 89 ++++++++++++++++++++++++++++ src/services/providers/rubic.ts | 13 ++++ 2 files changed, 102 insertions(+) create mode 100644 src/services/__tests__/rubic.test.ts diff --git a/src/services/__tests__/rubic.test.ts b/src/services/__tests__/rubic.test.ts new file mode 100644 index 0000000..cf88823 --- /dev/null +++ b/src/services/__tests__/rubic.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { rubicProvider } from '../providers/rubic' +import type { QuoteRequest } from '@/types/provider' + +const EVM_REQUEST: QuoteRequest = { + fromChain: 1, + toChain: 8453, + fromToken: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + toToken: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + fromAmount: '1000000', + fromAddress: '0x1111111111111111111111111111111111111111', + toAddress: '0x2222222222222222222222222222222222222222', + fromTokenDecimals: 6, +} + +function jsonResponse(body: unknown, ok = true, status = 200): Response { + return { + ok, + status, + json: async () => body, + text: async () => JSON.stringify(body), + } as unknown as Response +} + +function routeBy(url: string, handlers: Record Response>): Response { + for (const key of Object.keys(handlers)) { + if (url.includes(key)) return handlers[key]() + } + throw new Error(`Unhandled fetch URL in test: ${url}`) +} + +const QUOTE_BODY = { + id: 'rubic-quote-1', + type: 'across', + estimate: { destinationTokenAmount: '0.99', destinationTokenMinAmount: '0.98', estimatedTime: 120 }, + tokens: { from: { symbol: 'USDC', decimals: 6 }, to: { symbol: 'USDC', decimals: 6 } }, +} + +describe('RubicProvider.getQuote — null transactionRequest hygiene', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('excludes an EVM quote when the swap call yields no transactionRequest', async () => { + vi.spyOn(global, 'fetch').mockImplementation(async (input: any) => { + const url = String(input) + return routeBy(url, { + '/info/chains': () => jsonResponse({ status: 'fail' }, false, 500), + '/routes/quoteBest': () => jsonResponse(QUOTE_BODY), + // swap fails -> transactionRequest stays null + '/routes/swap': () => + jsonResponse({ error: { code: 3003, reason: 'not enough balance' } }, false, 400), + }) + }) + + const quotes = await rubicProvider.getQuote(EVM_REQUEST) + expect(quotes.every((q) => q.transactionRequest != null)).toBe(true) + expect(quotes.length).toBe(0) + }) + + it('keeps an EVM quote when the swap call returns a transactionRequest', async () => { + vi.spyOn(global, 'fetch').mockImplementation(async (input: any) => { + const url = String(input) + return routeBy(url, { + '/info/chains': () => jsonResponse({ status: 'fail' }, false, 500), + '/routes/quoteBest': () => jsonResponse(QUOTE_BODY), + '/routes/swap': () => + jsonResponse({ + transaction: { + to: '0x3333333333333333333333333333333333333333', + data: '0xabcdef', + value: '0', + }, + }), + }) + }) + + const quotes = await rubicProvider.getQuote(EVM_REQUEST) + expect(quotes.length).toBe(1) + expect(quotes[0].transactionRequest).toBeTruthy() + expect((quotes[0].transactionRequest as { to?: string }).to).toBe( + '0x3333333333333333333333333333333333333333' + ) + }) +}) diff --git a/src/services/providers/rubic.ts b/src/services/providers/rubic.ts index b3b93f2..05530b9 100644 --- a/src/services/providers/rubic.ts +++ b/src/services/providers/rubic.ts @@ -231,6 +231,19 @@ export class RubicProvider implements IProvider { } } + // Drop non-executable quotes: Rubic cannot be executed without tx data. + // EVM swaps need transactionRequest.to/data (from /routes/swap); deposit + // trades need a depositAddress (from /routes/swapDepositTrade). When the + // swap call fails (e.g. insufficient balance) these are absent, and the + // quote would be unexecutable yet could still poison ranking as "best". + const hasExecutableTx = isNonEvmSource + ? Boolean(transactionRequest?.depositAddress) + : Boolean(transactionRequest?.to && transactionRequest?.data) + if (!hasExecutableTx) { + console.log('Rubic: dropping quote with no executable transaction data') + return [] + } + // Calculate total fees const protocolFeeUSD = data.fees?.gasTokenFees?.protocol?.fixedUsdAmount || 0 const providerFeeUSD = data.fees?.gasTokenFees?.provider?.fixedUsdAmount || 0 From c1022544047f675868f0d31590e0225437c0c235 Mon Sep 17 00:00:00 2001 From: Shreyas Patil Date: Sun, 31 May 2026 12:40:26 +0530 Subject: [PATCH 25/34] fix(quotes): convert on-top fees to dest-token units via price in netOutput --- src/services/ranking.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/services/ranking.ts b/src/services/ranking.ts index 1d14719..f97da51 100644 --- a/src/services/ranking.ts +++ b/src/services/ranking.ts @@ -76,7 +76,13 @@ export function onTopCostUSD(quote: QuoteResponse): number { export function netOutput(quote: QuoteResponse, decimals: number): number { const raw = Number(quote.toAmount || '0') const human = raw / Math.pow(10, decimals) - return human - onTopCostUSD(quote) + const onTopUSD = onTopCostUSD(quote) + // Convert on-top USD fees into destination-token units when the token's USD + // price is known (a $20 fee on a $2000 token costs 0.01 token, not 20). Falls + // back to USD ≈ token units, correct for the common ~$1 stablecoin destination. + const price = quote.toTokenPriceUSD + const onTopInTokenUnits = typeof price === 'number' && price > 0 ? onTopUSD / price : onTopUSD + return human - onTopInTokenUnits } /** Tie-breaker when net outputs are within epsilon. Higher = better. */ From 2acadb81e1d409b1af25184f17e9a61522caa46f Mon Sep 17 00:00:00 2001 From: Shreyas Patil Date: Sun, 31 May 2026 13:18:22 +0530 Subject: [PATCH 26/34] feat(quotes): validate chain address-space and reject unsupported chains before fan-out --- src/app/api/quotes/route.ts | 6 +- src/lib/__tests__/chain-address.test.ts | 121 ++++++++++++++++++ src/lib/chain-address.ts | 81 ++++++++++++ .../quote-aggregator-validation.test.ts | 58 +++++++++ src/services/quote-aggregator.ts | 64 +++++++++ 5 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 src/lib/__tests__/chain-address.test.ts create mode 100644 src/lib/chain-address.ts create mode 100644 src/services/__tests__/quote-aggregator-validation.test.ts diff --git a/src/app/api/quotes/route.ts b/src/app/api/quotes/route.ts index f74e771..a87cdb4 100644 --- a/src/app/api/quotes/route.ts +++ b/src/app/api/quotes/route.ts @@ -32,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/lib/__tests__/chain-address.test.ts b/src/lib/__tests__/chain-address.test.ts new file mode 100644 index 0000000..6c3138a --- /dev/null +++ b/src/lib/__tests__/chain-address.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect } from 'vitest' +import { isValidAddressForChain, validateQuoteRequest } from '../chain-address' + +describe('isValidAddressForChain', () => { + it('accepts a valid EVM address on an evm chain', () => { + expect(isValidAddressForChain('0x1111111111111111111111111111111111111111', 1)).toBe(true) + expect(isValidAddressForChain('0xaF88d065e77c8cC2239327C5EDb3A432268e5831', 42161)).toBe(true) + }) + + it('rejects a non-EVM address on an evm chain', () => { + expect(isValidAddressForChain('4Nd1mYsL-not-an-evm-addr', 1)).toBe(false) + expect(isValidAddressForChain('0x123', 1)).toBe(false) + expect(isValidAddressForChain('0x11111111111111111111111111111111111111', 1)).toBe(false) + }) + + it('accepts a valid Solana (base58) address on solana', () => { + expect(isValidAddressForChain('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', 'solana')).toBe( + true + ) + }) + + it('rejects an EVM 0x address on solana', () => { + expect(isValidAddressForChain('0x1111111111111111111111111111111111111111', 'solana')).toBe( + false + ) + }) + + it('accepts valid Bitcoin addresses (bech32 and legacy) on bitcoin', () => { + expect(isValidAddressForChain('bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq', 'bitcoin')).toBe( + true + ) + expect(isValidAddressForChain('1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', 'bitcoin')).toBe(true) + expect(isValidAddressForChain('3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy', 'bitcoin')).toBe(true) + }) + + it('rejects an EVM 0x address on bitcoin', () => { + expect(isValidAddressForChain('0x1111111111111111111111111111111111111111', 'bitcoin')).toBe( + false + ) + }) + + it("returns true for 'other'-family chains (A5 handles those)", () => { + expect(isValidAddressForChain('anything-goes', 'stellar')).toBe(true) + expect(isValidAddressForChain('rXYZ', 'xrp')).toBe(true) + }) + + it('allows empty/whitespace addresses through (quotes without a wallet)', () => { + expect(isValidAddressForChain('', 1)).toBe(true) + expect(isValidAddressForChain(' ', 1)).toBe(true) + expect(isValidAddressForChain('', 'solana')).toBe(true) + }) +}) + +describe('validateQuoteRequest', () => { + it('returns ok for a valid evm->evm request', () => { + expect( + validateQuoteRequest({ + fromChain: 42161, + toChain: 8453, + fromAddress: '0x1111111111111111111111111111111111111111', + toAddress: '0x2222222222222222222222222222222222222222', + }) + ).toEqual({ ok: true }) + }) + + it('returns ok when addresses are missing', () => { + expect(validateQuoteRequest({ fromChain: 1, toChain: 1 })).toEqual({ ok: true }) + }) + + it('flags an exotic source chain as unsupported', () => { + const r = validateQuoteRequest({ fromChain: 'stellar', toChain: 8453 }) + expect(r.ok).toBe(false) + if (!r.ok) { + expect(r.unsupported).toBe(true) + expect(r.reason).toMatch(/stellar/i) + } + }) + + it('flags an exotic destination chain as unsupported', () => { + const r = validateQuoteRequest({ fromChain: 1, toChain: 'xrp' }) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.unsupported).toBe(true) + }) + + it('runs the unsupported check BEFORE the address check', () => { + const r = validateQuoteRequest({ + fromChain: 'stellar', + toChain: 8453, + fromAddress: 'totally-bogus', + }) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.unsupported).toBe(true) + }) + + it('flags a mismatched fromAddress as invalid (not unsupported)', () => { + const r = validateQuoteRequest({ + fromChain: 1, + toChain: 1, + fromAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + }) + expect(r.ok).toBe(false) + if (!r.ok) { + expect(r.unsupported).toBeFalsy() + expect(r.reason).toMatch(/source address/i) + } + }) + + it('flags a mismatched toAddress as invalid (not unsupported)', () => { + const r = validateQuoteRequest({ + fromChain: 1, + toChain: 'solana', + fromAddress: '0x1111111111111111111111111111111111111111', + toAddress: '0x2222222222222222222222222222222222222222', + }) + expect(r.ok).toBe(false) + if (!r.ok) { + expect(r.unsupported).toBeFalsy() + expect(r.reason).toMatch(/destination address/i) + } + }) +}) diff --git a/src/lib/chain-address.ts b/src/lib/chain-address.ts new file mode 100644 index 0000000..1646806 --- /dev/null +++ b/src/lib/chain-address.ts @@ -0,0 +1,81 @@ +import type { ChainId } from '@/types/provider' +import { deriveChainFamily } from './chain-family' + +const EVM_ADDRESS = /^0x[0-9a-fA-F]{40}$/ +const SOLANA_ADDRESS = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/ +const BTC_BECH32 = /^(bc1|tb1)[02-9ac-hj-np-z]{6,87}$/i +const BTC_LEGACY = /^[13mn2][a-km-zA-HJ-NP-Z1-9]{25,39}$/ + +/** + * Whether `address` is a syntactically valid address for `chain`'s wallet family. + * Empty/whitespace addresses are allowed (quotes can be requested without a + * connected wallet). 'other'-family chains return true here — A5 (unsupported) + * handles them so we don't double-reject. + */ +export function isValidAddressForChain(address: string, chain: ChainId): boolean { + const addr = (address ?? '').trim() + if (addr === '') return true + + const family = deriveChainFamily(chain) + switch (family) { + case 'evm': + return EVM_ADDRESS.test(addr) + case 'solana': + return !addr.startsWith('0x') && SOLANA_ADDRESS.test(addr) + case 'bitcoin': + return BTC_BECH32.test(addr) || BTC_LEGACY.test(addr) + default: + return true + } +} + +export type ValidateQuoteResult = + | { ok: true } + | { ok: false; unsupported?: boolean; reason: string } + +/** + * Pure central validation for a quote request. Surfaces 'other'-family chains + * as `unsupported` (no executable signing path — see chain-family / N2) BEFORE + * address validation, so an exotic chain reads as "unsupported" rather than + * "bad address". Then rejects addresses that don't match the chain's address + * space. + */ +export function validateQuoteRequest(req: { + fromChain: ChainId + toChain: ChainId + fromAddress?: string + toAddress?: string +}): ValidateQuoteResult { + const fromFamily = deriveChainFamily(req.fromChain) + const toFamily = deriveChainFamily(req.toChain) + + if (fromFamily === 'other') { + return { + ok: false, + unsupported: true, + reason: `Source chain "${req.fromChain}" is not supported for payments yet.`, + } + } + if (toFamily === 'other') { + return { + ok: false, + unsupported: true, + reason: `Destination chain "${req.toChain}" is not supported for payments yet.`, + } + } + + if (req.fromAddress && !isValidAddressForChain(req.fromAddress, req.fromChain)) { + return { + ok: false, + reason: `Source address is not a valid ${fromFamily} address for this chain.`, + } + } + if (req.toAddress && !isValidAddressForChain(req.toAddress, req.toChain)) { + return { + ok: false, + reason: `Destination address is not a valid ${toFamily} address for this chain.`, + } + } + + return { ok: true } +} diff --git a/src/services/__tests__/quote-aggregator-validation.test.ts b/src/services/__tests__/quote-aggregator-validation.test.ts new file mode 100644 index 0000000..21f7c20 --- /dev/null +++ b/src/services/__tests__/quote-aggregator-validation.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest' +import { QuoteAggregator } from '@/services/quote-aggregator' + +/** + * These exercise the central request validation that runs BEFORE provider + * fan-out, so they make no network calls — an unsupported chain or a bad + * address returns early. + */ +describe('QuoteAggregator.getQuotes — request validation (no fan-out)', () => { + it('returns unsupported with a reason for an exotic source chain', async () => { + const result = await QuoteAggregator.getQuotes({ + fromChain: 'stellar', + toChain: 8453, + fromToken: 'native', + toToken: '0xUSDC', + fromAmount: '10', + fromAddress: '', + }) + + expect(result.unsupported).toBe(true) + expect(result.reason).toMatch(/not supported/i) + expect(result.quotes).toEqual([]) + expect(result.bestQuote).toBeNull() + expect(result.providerStats.succeeded).toEqual([]) + expect(result.providerStats.failed).toEqual([]) + }) + + it('returns unsupported for an exotic destination chain', async () => { + const result = await QuoteAggregator.getQuotes({ + fromChain: 1, + toChain: 'xrp', + fromToken: '0xToken', + toToken: 'native', + fromAmount: '10', + fromAddress: '', + }) + + expect(result.unsupported).toBe(true) + expect(result.reason).toMatch(/not supported/i) + expect(result.quotes).toEqual([]) + }) + + it('returns an address-mismatch reason (not unsupported) for a bad fromAddress', async () => { + const result = await QuoteAggregator.getQuotes({ + fromChain: 1, + toChain: 1, + fromToken: '0xToken', + toToken: '0xOther', + fromAmount: '1000000', + fromAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + }) + + expect(result.unsupported).toBeFalsy() + expect(result.reason).toMatch(/source address/i) + expect(result.quotes).toEqual([]) + expect(result.bestQuote).toBeNull() + }) +}) diff --git a/src/services/quote-aggregator.ts b/src/services/quote-aggregator.ts index f2b9bd1..36fdc51 100644 --- a/src/services/quote-aggregator.ts +++ b/src/services/quote-aggregator.ts @@ -1,6 +1,8 @@ import { providers } from './providers' import { QuoteRequest, QuoteResponse, ChainId } from '@/types/provider' import { rankQuotes } from './ranking' +import { validateQuoteRequest } from '@/lib/chain-address' +import { ChainTokenService } from './chain-token-service' const PROVIDER_TIMEOUT_MS = 30000 const QUOTE_VALIDITY_MS = 60000 @@ -31,6 +33,11 @@ export interface AggregatedQuoteResponse { bestQuote: QuoteResponse | null expiresAt: number fetchedAt: number + /** True when the request was rejected before fan-out (e.g. unsupported chain). */ + unsupported?: boolean + /** Human-readable reason when the request can't be quoted (unsupported chain + * or an address that doesn't match the chain's address space). */ + reason?: string providerStats: { succeeded: string[] failed: string[] @@ -39,6 +46,28 @@ export interface AggregatedQuoteResponse { } } +/** + * Best-effort resolve the source token's decimals when the caller omitted them. + * Rubic falls back to 18 (rubic.ts) which over/under-scales the input amount by + * 10^(realDecimals-18) → wildly wrong quotes. Resolving from the cached token + * list (ChainTokenService, 5-min cache) avoids that footgun. Returns undefined + * if it can't resolve, leaving providers on their own fallback. + */ +async function resolveFromTokenDecimals( + fromChain: ChainId, + fromToken: string +): Promise { + try { + const tokens = await ChainTokenService.getTokens(String(fromChain)) + const target = fromToken.toLowerCase() + const match = tokens.find((t) => t.address.toLowerCase() === target) + return match?.decimals + } catch (e) { + console.warn('[QuoteAggregator] Could not resolve fromTokenDecimals:', String(e)) + return undefined + } +} + // Wrap provider call with timeout async function withTimeout( promise: Promise, @@ -85,6 +114,41 @@ export const QuoteAggregator = { errors: {} as Record, } + // Central request validation BEFORE fan-out: reject unsupported chains + // (A5 — 'other' family has no executable signing path) and addresses that + // don't match the chain's address space (A4). Returning early surfaces an + // honest reason instead of silently fanning out → "no routes". + const validation = validateQuoteRequest(normalizedRequest) + if (!validation.ok) { + console.warn(`[QuoteAggregator] Request rejected: ${validation.reason}`) + return { + quotes: [], + bestQuote: null, + unsupported: !!validation.unsupported, + reason: validation.reason, + expiresAt, + fetchedAt, + providerStats, + } + } + + // U3: ensure fromTokenDecimals so Rubic (rubic.ts: || 18) can't mis-scale + // the input amount. Best-effort, cached; leaves it undefined if unresolved. + if (normalizedRequest.fromTokenDecimals === undefined) { + const resolved = await resolveFromTokenDecimals( + normalizedRequest.fromChain, + normalizedRequest.fromToken + ) + if (resolved !== undefined) { + normalizedRequest.fromTokenDecimals = resolved + console.log(`[QuoteAggregator] Resolved fromTokenDecimals=${resolved}`) + } else { + console.warn( + '[QuoteAggregator] fromTokenDecimals missing and unresolved; providers will use their fallback' + ) + } + } + console.log('=== QUOTE AGGREGATOR START ===') console.log(`Querying ${providers.length} providers:`, providers.map(p => p.name)) console.log('Request:', JSON.stringify(normalizedRequest, null, 2)) From a2842849b01f47b81355b76434eb9dda40ea2611 Mon Sep 17 00:00:00 2001 From: Shreyas Patil Date: Sun, 31 May 2026 13:24:37 +0530 Subject: [PATCH 27/34] feat(pay): surface unsupported-chain reason in payment UI instead of generic no-routes --- src/components/PaymentInterface.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/PaymentInterface.tsx b/src/components/PaymentInterface.tsx index c2fa9b5..6083b74 100644 --- a/src/components/PaymentInterface.tsx +++ b/src/components/PaymentInterface.tsx @@ -333,8 +333,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) From 4e123d7beae4ac42454ad5168f5f4628b84012c8 Mon Sep 17 00:00:00 2001 From: Shreyas Patil Date: Sun, 31 May 2026 13:26:34 +0530 Subject: [PATCH 28/34] chore(test): apply prettier formatting to chain-address tests --- src/lib/__tests__/chain-address.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/lib/__tests__/chain-address.test.ts b/src/lib/__tests__/chain-address.test.ts index 6c3138a..55af322 100644 --- a/src/lib/__tests__/chain-address.test.ts +++ b/src/lib/__tests__/chain-address.test.ts @@ -15,19 +15,19 @@ describe('isValidAddressForChain', () => { it('accepts a valid Solana (base58) address on solana', () => { expect(isValidAddressForChain('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', 'solana')).toBe( - true + true, ) }) it('rejects an EVM 0x address on solana', () => { expect(isValidAddressForChain('0x1111111111111111111111111111111111111111', 'solana')).toBe( - false + false, ) }) it('accepts valid Bitcoin addresses (bech32 and legacy) on bitcoin', () => { expect(isValidAddressForChain('bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq', 'bitcoin')).toBe( - true + true, ) expect(isValidAddressForChain('1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', 'bitcoin')).toBe(true) expect(isValidAddressForChain('3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy', 'bitcoin')).toBe(true) @@ -35,7 +35,7 @@ describe('isValidAddressForChain', () => { it('rejects an EVM 0x address on bitcoin', () => { expect(isValidAddressForChain('0x1111111111111111111111111111111111111111', 'bitcoin')).toBe( - false + false, ) }) @@ -59,7 +59,7 @@ describe('validateQuoteRequest', () => { toChain: 8453, fromAddress: '0x1111111111111111111111111111111111111111', toAddress: '0x2222222222222222222222222222222222222222', - }) + }), ).toEqual({ ok: true }) }) From 5bf59a80c6994e79af2e2cb6e794f0392f18c52d Mon Sep 17 00:00:00 2001 From: Shreyas Patil Date: Sun, 31 May 2026 16:41:13 +0530 Subject: [PATCH 29/34] fix(symbiosis): use real fee/decimals from response and drop unexecutable BTC quotes --- src/services/__tests__/symbiosis.test.ts | 122 +++++++++++++++++++++++ src/services/providers/symbiosis.ts | 78 +++++++++++++-- 2 files changed, 189 insertions(+), 11 deletions(-) create mode 100644 src/services/__tests__/symbiosis.test.ts diff --git a/src/services/__tests__/symbiosis.test.ts b/src/services/__tests__/symbiosis.test.ts new file mode 100644 index 0000000..2cb5a10 --- /dev/null +++ b/src/services/__tests__/symbiosis.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { symbiosisProvider, computeSymbiosisFeeUSD } from '@/services/providers/symbiosis' +import { ChainTokenService } from '@/services/chain-token-service' + +/** + * Symbiosis Phase 5 tests (S2 / decimals / S3). + * + * Grounded in a REAL /v1/swap response (Arb-USDC -> Base-USDC, 10 USDC): + * tokenAmountOut: { amount:"9492719", decimals:18 (echoed hint!), priceUsd:0.999576 } + * fee: { amount:"250000", decimals:6, priceUsd:0.9996597 } // ~$0.25 + * The token is 6-decimal USDC; decimals:18 is the echoed request hint, NOT real. + */ + +type FetchResponse = { ok: boolean; status?: number; json: () => Promise; text: () => Promise } + +function mockResponse(body: unknown, ok = true, status = 200): FetchResponse { + return { + ok, + status, + json: async () => body, + text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), + } +} + +let fetchMock: ReturnType + +beforeEach(() => { + fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + // Default: registry resolves USDC-Base to 6 decimals. + vi.spyOn(ChainTokenService, 'getTokens').mockResolvedValue([ + { address: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', chainId: 8453, symbol: 'USDC', decimals: 6 }, + ] as never) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +const REAL_SWAP = { + id: 'sym-real-1', + tokenAmountIn: { symbol: 'USDC', address: '0xaf88', amount: '10000000', chainId: 42161, decimals: 6 }, + tokenAmountOut: { + symbol: 'tokenOut', + address: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + amount: '9492719', + chainId: 8453, + decimals: 18, // echoed hint — NOT the real token decimals + priceUsd: 0.999576, + }, + priceImpact: 0.28, + fee: { symbol: 'USDbC', address: '0xd9aAEc86', amount: '250000', chainId: 8453, decimals: 6, priceUsd: 0.9996597 }, + estimatedTime: 15, + tx: { to: '0xrouter', data: '0xdead', value: '0', chainId: 8453 }, +} + +const ARB_TO_BASE = { + fromChain: 42161, + toChain: 8453, + fromToken: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + toToken: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + fromAmount: '10000000', + fromAddress: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + fromTokenDecimals: 6, +} + +describe('computeSymbiosisFeeUSD', () => { + it('computes real USD fee from {amount, decimals, priceUsd}', () => { + // 250000 / 10^6 * 0.9996597 = 0.2499... -> "0.25" + expect(computeSymbiosisFeeUSD({ amount: '250000', decimals: 6, priceUsd: 0.9996597 })).toBe('0.25') + }) + it('defaults priceUsd to 1 when missing', () => { + expect(computeSymbiosisFeeUSD({ amount: '250000', decimals: 6 })).toBe('0.25') + }) + it('returns undefined when fee is absent or malformed', () => { + expect(computeSymbiosisFeeUSD(undefined)).toBeUndefined() + expect(computeSymbiosisFeeUSD({})).toBeUndefined() + expect(computeSymbiosisFeeUSD({ amount: '250000' })).toBeUndefined() + }) +}) + +describe('symbiosis getQuote — S2 (real fee) + decimals', () => { + it('reports the REAL fee (~$0.25), NOT the implied-fee heuristic, and not in estimatedGas', async () => { + fetchMock.mockResolvedValueOnce(mockResponse(REAL_SWAP)) + const [quote] = await symbiosisProvider.getQuote(ARB_TO_BASE as never) + expect(quote).toBeDefined() + // Real fee ~0.25 (NOT the implied heuristic which would be |10 - 9.49| = 0.51 at 6 dec). + expect(quote.fees?.totalFeeUSD).toBe('0.25') + expect(quote.fees?.bridgeFee).toBe('0.25') + // estimatedGas must NOT carry the fee anymore (no gas in response -> '0'). + expect(quote.estimatedGas).toBe('0') + // feeCosts amountUSD reflects the real fee. + const feeCost = quote.routes[0].estimate.feeCosts?.[0] + expect(feeCost?.amountUSD).toBe('0.25') + }) + + it('resolves REAL dest decimals (6) from request.toTokenDecimals, ignoring the echoed 18', async () => { + fetchMock.mockResolvedValueOnce(mockResponse(REAL_SWAP)) + const [quote] = await symbiosisProvider.getQuote(ARB_TO_BASE as never) + expect(quote.toTokenDecimals).toBe(6) + expect(quote.routes[0].action.toToken.decimals).toBe(6) + // CRITICAL: the raw amount itself is unchanged (only the decimals label is corrected). + expect(quote.toAmount).toBe('9492719') + }) + + it('falls back to the token registry for decimals when request omits toTokenDecimals', async () => { + fetchMock.mockResolvedValueOnce(mockResponse(REAL_SWAP)) + const { fromTokenDecimals, ...noDecimals } = ARB_TO_BASE + void fromTokenDecimals + const [quote] = await symbiosisProvider.getQuote({ ...noDecimals } as never) + // Registry mock returns USDC-Base at 6 decimals. + expect(quote.toTokenDecimals).toBe(6) + }) + + it('falls back to implied-fee heuristic only when data.fee is absent', async () => { + const noFee = { ...REAL_SWAP, fee: undefined } + fetchMock.mockResolvedValueOnce(mockResponse(noFee)) + const [quote] = await symbiosisProvider.getQuote(ARB_TO_BASE as never) + // input 10 USDC, output 9.492719 USDC (in=6dec, out resolved to 6dec) -> implied ~0.51 + expect(parseFloat(quote.fees?.totalFeeUSD || '0')).toBeCloseTo(0.51, 2) + }) +}) diff --git a/src/services/providers/symbiosis.ts b/src/services/providers/symbiosis.ts index 498f2a7..526356b 100644 --- a/src/services/providers/symbiosis.ts +++ b/src/services/providers/symbiosis.ts @@ -1,6 +1,44 @@ import { IProvider, QuoteRequest, QuoteResponse, StatusRequest, StatusResponse, TransactionStatus } from '@/types/provider' import { SYMBIOSIS_GATEWAY_MAP, SYMBIOSIS_CHAIN_IDS } from './symbiosis-config' import { SYMBIOSIS_CONFIG } from './symbiosis-data' +import { ChainTokenService } from '../chain-token-service' + +/** + * Resolve the REAL destination-token decimals. Symbiosis echoes back the `decimals` + * hint we send in `tokenOut` (we send 18), so `data.tokenAmountOut.decimals` is NOT + * authoritative for 6/8-decimal tokens like USDC/WBTC. Prefer the caller-supplied + * authoritative decimals, then the live token registry, then the API value. + */ +export async function resolveToTokenDecimals( + request: QuoteRequest, + toChain: number, + apiDecimals: number | undefined +): Promise { + if (typeof request.toTokenDecimals === 'number') return request.toTokenDecimals + try { + const tokens = await ChainTokenService.getTokens(String(toChain)) + const target = request.toToken.toLowerCase() + const match = tokens.find((t) => t.address.toLowerCase() === target) + if (match?.decimals !== undefined) return match.decimals + } catch { + // fall through to API value + } + return apiDecimals +} + +/** + * Compute the REAL fee in USD. Symbiosis returns the fee as + * `{ amount, decimals, priceUsd }` (NOT `fee.usd`). Convert it to a human USD + * value. Returns undefined when `data.fee` is absent so callers can fall back. + */ +export function computeSymbiosisFeeUSD(fee: unknown): string | undefined { + if (!fee || typeof fee !== 'object') return undefined + const f = fee as { amount?: string | number; decimals?: number; priceUsd?: number } + if (f.amount === undefined || f.decimals === undefined) return undefined + const human = Number(f.amount) / Math.pow(10, f.decimals) + if (!isFinite(human)) return undefined + return (human * (f.priceUsd ?? 1)).toFixed(2) +} const SYMBIOSIS_API_BASE = 'https://api.symbiosis.finance/crosschain/v1' const SYMBIOSIS_BTC_CHAIN_ID = 3652501241 @@ -81,22 +119,33 @@ export class SymbiosisProvider implements IProvider { if (!data.tokenAmountOut) return [] - // Symbiosis embeds fees in the output + // Resolve the REAL dest-token decimals (do NOT trust the echoed 18 hint). + const resolvedToDecimals = + (await resolveToTokenDecimals(request, toChainId, data.tokenAmountOut?.decimals)) ?? 18 + + // Symbiosis embeds fees in the output. The implied-fee heuristic must use the + // RESOLVED output decimals (not the echoed 18 hint) or it computes a garbage + // fee (output reads as ~0 → implied fee ≈ full input). const inputAmountRaw = request.fromAmount const outputAmountRaw = data.tokenAmountOut.amount - + const inputDecimals = data.tokenAmountIn?.decimals || 18 - const outputDecimals = data.tokenAmountOut?.decimals || 18 - + const outputDecimals = resolvedToDecimals + const inputHuman = parseFloat(inputAmountRaw) / Math.pow(10, inputDecimals) const outputHuman = parseFloat(outputAmountRaw) / Math.pow(10, outputDecimals) - + const impliedFeeUSD = Math.max(0, inputHuman - outputHuman).toFixed(2) - - const apiFeeUSD = data.fee?.usd?.toString() || '0' - const bridgeFeeUSD = parseFloat(apiFeeUSD) > 0 ? apiFeeUSD : impliedFeeUSD + + // REAL fee from data.fee = (amount / 10^decimals) * priceUsd. The implied + // heuristic is only a last-resort fallback when data.fee is absent. + const realFeeUSD = computeSymbiosisFeeUSD(data.fee) + const bridgeFeeUSD = realFeeUSD ?? impliedFeeUSD const priceImpact = data.priceImpact?.toString() + // Real gas if the response carries one, else '0'. The fee no longer goes here. + const gasUSD = data.estimatedGas?.toString() || data.tx?.gasPrice?.toString() || '0' + const approvalAddress = SYMBIOSIS_GATEWAY_MAP[fromChainId as number] let chainType: 'evm' | 'bitcoin' = 'evm' @@ -128,6 +177,13 @@ export class SymbiosisProvider implements IProvider { } catch (e) { console.warn('Symbiosis Forwarder API call failed:', e) } + + // Fail loudly: a BTC quote with no deposit address is unexecutable — + // surfacing it would let the user "pay" with nowhere to send funds. + if (!depositAddress) { + console.warn('Symbiosis: BTC source but no deposit address resolved, dropping quote') + return [] + } } return [{ @@ -136,8 +192,8 @@ export class SymbiosisProvider implements IProvider { fromAmount: request.fromAmount, toAmount: data.tokenAmountOut.amount, toAmountMin: data.tokenAmountOutMin?.amount || data.tokenAmountOut.amount, - toTokenDecimals: data.tokenAmountOut?.decimals, - estimatedGas: bridgeFeeUSD, + toTokenDecimals: resolvedToDecimals, + estimatedGas: gasUSD, estimatedDuration: data.estimatedTime || 65, transactionRequest: data.tx, metadata: { @@ -167,7 +223,7 @@ export class SymbiosisProvider implements IProvider { address: request.toToken, chainId: request.toChain, symbol: (data.tokenAmountOut?.symbol && data.tokenAmountOut.symbol !== 'tokenOut') ? data.tokenAmountOut.symbol : 'UNKNOWN', - decimals: data.tokenAmountOut?.decimals || 18 + decimals: resolvedToDecimals }, fromAmount: request.fromAmount, toAmount: data.tokenAmountOut.amount From 1dcdc8dfc5769a1eee115c0ef8fdce45664b8b4d Mon Sep 17 00:00:00 2001 From: Shreyas Patil Date: Sun, 31 May 2026 16:52:44 +0530 Subject: [PATCH 30/34] fix(executor): keep cross-chain atomic swaps at submitted and drop CCTP false-success hash --- src/components/PaymentInterface.tsx | 224 +++++-- src/hooks/useTransactionExecutor.ts | 811 ++++++++++++----------- src/lib/__tests__/execution-path.test.ts | 44 +- src/lib/execution-path.ts | 15 + 4 files changed, 651 insertions(+), 443 deletions(-) diff --git a/src/components/PaymentInterface.tsx b/src/components/PaymentInterface.tsx index 6083b74..8763e4a 100644 --- a/src/components/PaymentInterface.tsx +++ b/src/components/PaymentInterface.tsx @@ -58,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([]) @@ -69,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 @@ -83,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) @@ -104,7 +119,9 @@ 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. @@ -125,9 +142,7 @@ 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) @@ -146,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(() => { @@ -195,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) @@ -232,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) @@ -304,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 } @@ -339,7 +359,7 @@ export default function PaymentInterface({ link, onSuccess }: PaymentInterfacePr throw new Error( data.reason || data.error || - 'No routes found for this swap. Try a different token or chain.' + 'No routes found for this swap. Try a different token or chain.', ) } } catch (e) { @@ -353,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) @@ -394,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 @@ -402,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) @@ -411,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' @@ -427,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 (
@@ -484,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} ) : (
)} @@ -563,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) => ( + ))} ))} @@ -598,8 +643,21 @@ export default function PaymentInterface({ link, onSuccess }: PaymentInterfacePr )}
- - + +
@@ -607,7 +665,9 @@ export default function PaymentInterface({ link, onSuccess }: PaymentInterfacePr {/* Asset Selection */}
- +
{tokensLoading ? (
@@ -620,16 +680,30 @@ export default function PaymentInterface({ link, onSuccess }: PaymentInterfacePr value={fromTokenAddress} onChange={(e) => handleTokenChange(e.target.value)} > - {displayTokens.map(t => ( + {displayTokens.map((t) => ( ))} )}
- - + +
@@ -640,7 +714,9 @@ export default function PaymentInterface({ link, onSuccess }: PaymentInterfacePr