From 9a4331053abe4a60abb881a476c2547a4a4df4fa Mon Sep 17 00:00:00 2001 From: sundayonah Date: Thu, 20 Aug 2026 09:26:19 +0100 Subject: [PATCH 1/4] =?UTF-8?q?feat(bridge):=20add=20Textile=20FX=20for=20?= =?UTF-8?q?USDT=E2=86=94cNGN=20on=20BSC=20and=20Celo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route same-chain USDT↔cNGN through Textile when enabled, with LI.FI fallback. Accept partial fills from the book (fillableAmount) instead of requiring full fill, and proxy quote/swap/status via server routes. Co-authored-by: Cursor --- .env.example | 5 +- __tests__/textileRouting.test.ts | 161 ++++++++++++ app/api/bridge/textile/quote/route.ts | 70 +++++ app/api/bridge/textile/status/route.ts | 52 ++++ app/api/bridge/textile/submit/route.ts | 53 ++++ app/api/bridge/textile/swap/route.ts | 67 +++++ app/components/bridge/BridgeQuoteCard.tsx | 9 +- app/components/bridge/BridgeRouteSelector.tsx | 2 +- app/hooks/bridge.ts | 136 +++++++++- app/hooks/useBridgeStatusTracker.ts | 7 +- app/lib/bridge.ts | 243 +++++++++++++++++- app/lib/bridgeFeature.ts | 22 ++ app/lib/config.ts | 1 + app/lib/textileNetworks.ts | 21 ++ app/lib/textileServer.ts | 25 ++ app/types.ts | 2 + app/utils.ts | 7 + 17 files changed, 868 insertions(+), 15 deletions(-) create mode 100644 __tests__/textileRouting.test.ts create mode 100644 app/api/bridge/textile/quote/route.ts create mode 100644 app/api/bridge/textile/status/route.ts create mode 100644 app/api/bridge/textile/submit/route.ts create mode 100644 app/api/bridge/textile/swap/route.ts create mode 100644 app/lib/textileNetworks.ts create mode 100644 app/lib/textileServer.ts diff --git a/.env.example b/.env.example index c5bcda4f..4ffa75dd 100644 --- a/.env.example +++ b/.env.example @@ -124,8 +124,11 @@ NEXT_PUBLIC_TRON_ENABLED=false # Referral Program: show/hide all referral UI and API routes NEXT_PUBLIC_REFERRAL_ENABLED=true -# Bridge/Swap (Convert): in-wallet cross-chain convert via NEAR Intents + LI.FI +# Bridge/Swap (Convert): in-wallet cross-chain convert via NEAR Intents + LI.FI + Textile FX NEXT_PUBLIC_BRIDGE_ENABLED=false +# Textile FX: USDT↔cNGN on BSC + Celo (server key from contact@textilecredit.com) +NEXT_PUBLIC_TEXTILE_ENABLED=false +TEXTILE_API_KEY= # NEAR Intents 1Click API JWT (server-side) ONE_CLICK_JWT= # LI.FI API key (server-side, optional) diff --git a/__tests__/textileRouting.test.ts b/__tests__/textileRouting.test.ts new file mode 100644 index 00000000..580547f3 --- /dev/null +++ b/__tests__/textileRouting.test.ts @@ -0,0 +1,161 @@ +/// + +jest.mock("../app/hooks/useEIP7702Account", () => ({ + get7702AuthorizedImplementationForAddress: jest.fn(), +})); + +jest.mock("../app/lib/config", () => ({ + __esModule: true, + default: { + bridgeEnabled: true, + textileEnabled: true, + }, +})); + +import { normalizeTextileQuote, selectEngine, type BridgeLeg } from "../app/lib/bridge"; +import { isTextileRoute } from "../app/lib/bridgeFeature"; + +const leg = ( + network: string, + chainId: number, + token: string, + tokenAddress: string, +): BridgeLeg => ({ + network, + chainId, + token, + tokenAddress, + decimals: token === "USDT" && network === "BNB Smart Chain" ? 18 : 6, + amount: "100", + rawAmount: "100000000", +}); + +const BSC_USDT = leg( + "BNB Smart Chain", + 56, + "USDT", + "0x55d398326f99059ff775485246999027b3197955", +); +const BSC_CNGN = leg( + "BNB Smart Chain", + 56, + "cNGN", + "0xa8aea66b361a8d53e8865c62d142167af28af058", +); +const CELO_USDT = leg( + "Celo", + 42220, + "USDT", + "0x48065fbBE25f71C9282ddf5e1cD6D6A887483D5e", +); +const CELO_CNGN = leg( + "Celo", + 42220, + "cNGN", + "0xF6829D7393dAe24509eb1E52eE8e572e2E271a4f", +); +const BASE_USDC = leg( + "Base", + 8453, + "USDC", + "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", +); +const BSC_USDC = leg( + "BNB Smart Chain", + 56, + "USDC", + "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d", +); + +describe("textile routing", () => { + it("selects textile for same-chain USDT↔cNGN on BSC", () => { + expect(selectEngine(BSC_USDT, BSC_CNGN)).toBe("textile"); + expect(selectEngine(BSC_CNGN, BSC_USDT)).toBe("textile"); + }); + + it("selects textile for same-chain USDT↔cNGN on Celo", () => { + expect(selectEngine(CELO_USDT, CELO_CNGN)).toBe("textile"); + expect(selectEngine(CELO_CNGN, CELO_USDT)).toBe("textile"); + }); + + it("does not select textile for USDC↔cNGN on BSC", () => { + expect(isTextileRoute(BSC_USDC, BSC_CNGN)).toBe(false); + expect(selectEngine(BSC_USDC, BSC_CNGN)).toBe("lifi"); + }); + + it("does not select textile for cross-chain USDT↔cNGN", () => { + expect(isTextileRoute(BSC_USDT, CELO_CNGN)).toBe(false); + expect(selectEngine(BSC_USDT, CELO_CNGN)).toBe("lifi"); + }); + + it("does not select textile for non-cNGN pairs on BSC", () => { + expect(selectEngine(BSC_USDC, BASE_USDC)).toBe("near"); + }); +}); + +describe("normalizeTextileQuote", () => { + const baseParams = { + chainId: 56, + sellToken: "0x55d398326f99059ff775485246999027b3197955", + buyToken: "0xa8aea66b361a8d53e8865c62d142167af28af058", + sellAmount: "500000000000000000", + slippageBps: 200, + toDecimals: 6, + }; + + it("accepts partial fills when fillableAmount > 0", () => { + const result = normalizeTextileQuote( + { + data: { + hasLiquidity: true, + fullyFilled: false, + fillableAmount: "400000000000000000", + proceeds: "650000000", + effectiveRateRay: "1000000000000000000000000000", + }, + }, + baseParams, + ); + + expect(result).not.toBeNull(); + expect(result?.sellAmount).toBe("400000000000000000"); + expect(result?.requestedSellAmount).toBe("500000000000000000"); + expect(result?.fullyFilled).toBe(false); + expect(result?.amountOut).toBe("650"); + }); + + it("rejects when fillableAmount is zero", () => { + const result = normalizeTextileQuote( + { + data: { + hasLiquidity: true, + fullyFilled: false, + fillableAmount: "0", + proceeds: "0", + }, + }, + baseParams, + ); + + expect(result).toBeNull(); + }); + + it("accepts full fills", () => { + const result = normalizeTextileQuote( + { + data: { + hasLiquidity: true, + fullyFilled: true, + fillableAmount: "500000000000000000", + proceeds: "812500000", + effectiveRateRay: "1000000000000000000000000000", + }, + }, + baseParams, + ); + + expect(result).not.toBeNull(); + expect(result?.fullyFilled).toBe(true); + expect(result?.sellAmount).toBe("500000000000000000"); + }); +}); diff --git a/app/api/bridge/textile/quote/route.ts b/app/api/bridge/textile/quote/route.ts new file mode 100644 index 00000000..6162bbd6 --- /dev/null +++ b/app/api/bridge/textile/quote/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from "next/server"; +import axios from "axios"; +import { withRateLimit } from "@/app/lib/rate-limit"; +import { + trackApiRequest, + trackApiResponse, + trackApiError, +} from "@/app/lib/server-analytics"; +import { + TEXTILE_API_BASE, + TEXTILE_UPSTREAM_TIMEOUT_MS, + textileAuthHeaders, + minRateRayFromEffective, +} from "@/app/lib/textileServer"; + +export const GET = withRateLimit(async (request: NextRequest) => { + const startTime = Date.now(); + try { + const params = Object.fromEntries(request.nextUrl.searchParams.entries()); + const slippageBps = Math.max(Number(params.slippageBps) || 50, 200); + + trackApiRequest(request, "/api/bridge/textile/quote", "GET", { + chain_id: params.chainId, + sell_token: params.sellToken, + buy_token: params.buyToken, + }); + + const { data, status } = await axios.get(`${TEXTILE_API_BASE}/quote`, { + params: { + chainId: params.chainId, + sellToken: params.sellToken, + buyToken: params.buyToken, + sellAmount: params.sellAmount, + minRate: params.minRate ?? "0", + }, + headers: textileAuthHeaders(), + validateStatus: () => true, + timeout: TEXTILE_UPSTREAM_TIMEOUT_MS, + }); + + trackApiResponse( + "/api/bridge/textile/quote", + "GET", + status, + Date.now() - startTime, + ); + + if (status >= 400) { + return NextResponse.json(data, { status }); + } + + const quote = data?.data; + if (quote?.effectiveRateRay) { + quote.minRateRay = minRateRayFromEffective( + quote.effectiveRateRay, + slippageBps, + ); + } + + return NextResponse.json(data, { status }); + } catch (err) { + trackApiError(request, "/api/bridge/textile/quote", "GET", err as Error, 502, { + response_time_ms: Date.now() - startTime, + }); + return NextResponse.json( + { error: "Failed to fetch Textile quote" }, + { status: 502 }, + ); + } +}); diff --git a/app/api/bridge/textile/status/route.ts b/app/api/bridge/textile/status/route.ts new file mode 100644 index 00000000..3bdace61 --- /dev/null +++ b/app/api/bridge/textile/status/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from "next/server"; +import axios from "axios"; +import { withRateLimit } from "@/app/lib/rate-limit"; +import { + trackApiRequest, + trackApiResponse, + trackApiError, +} from "@/app/lib/server-analytics"; +import { + TEXTILE_API_BASE, + TEXTILE_UPSTREAM_TIMEOUT_MS, + textileAuthHeaders, +} from "@/app/lib/textileServer"; + +export const GET = withRateLimit(async (request: NextRequest) => { + const startTime = Date.now(); + try { + const swapId = request.nextUrl.searchParams.get("swapId"); + if (!swapId) { + return NextResponse.json({ error: "swapId required" }, { status: 400 }); + } + + trackApiRequest(request, "/api/bridge/textile/status", "GET", { + swap_id: swapId, + }); + + const { data, status } = await axios.get( + `${TEXTILE_API_BASE}/swaps/${encodeURIComponent(swapId)}`, + { + headers: textileAuthHeaders(), + validateStatus: () => true, + timeout: TEXTILE_UPSTREAM_TIMEOUT_MS, + }, + ); + + trackApiResponse( + "/api/bridge/textile/status", + "GET", + status, + Date.now() - startTime, + ); + return NextResponse.json(data, { status }); + } catch (err) { + trackApiError(request, "/api/bridge/textile/status", "GET", err as Error, 502, { + response_time_ms: Date.now() - startTime, + }); + return NextResponse.json( + { error: "Failed to fetch Textile swap status" }, + { status: 502 }, + ); + } +}); diff --git a/app/api/bridge/textile/submit/route.ts b/app/api/bridge/textile/submit/route.ts new file mode 100644 index 00000000..b55890db --- /dev/null +++ b/app/api/bridge/textile/submit/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; +import axios from "axios"; +import { withRateLimit } from "@/app/lib/rate-limit"; +import { + trackApiRequest, + trackApiResponse, + trackApiError, +} from "@/app/lib/server-analytics"; +import { + TEXTILE_API_BASE, + TEXTILE_UPSTREAM_TIMEOUT_MS, + textileAuthHeaders, +} from "@/app/lib/textileServer"; + +export const POST = withRateLimit(async (request: NextRequest) => { + const startTime = Date.now(); + try { + const body = await request.json(); + + trackApiRequest(request, "/api/bridge/textile/submit", "POST", { + swap_id: body.swapId, + }); + + const { data, status } = await axios.post( + `${TEXTILE_API_BASE}/swaps/${encodeURIComponent(body.swapId)}/submit`, + { txHash: body.txHash }, + { + headers: { + ...textileAuthHeaders(), + "Content-Type": "application/json", + }, + validateStatus: () => true, + timeout: TEXTILE_UPSTREAM_TIMEOUT_MS, + }, + ); + + trackApiResponse( + "/api/bridge/textile/submit", + "POST", + status, + Date.now() - startTime, + ); + return NextResponse.json(data, { status }); + } catch (err) { + trackApiError(request, "/api/bridge/textile/submit", "POST", err as Error, 502, { + response_time_ms: Date.now() - startTime, + }); + return NextResponse.json( + { error: "Failed to submit Textile swap" }, + { status: 502 }, + ); + } +}); diff --git a/app/api/bridge/textile/swap/route.ts b/app/api/bridge/textile/swap/route.ts new file mode 100644 index 00000000..6a236c00 --- /dev/null +++ b/app/api/bridge/textile/swap/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from "next/server"; +import axios from "axios"; +import { withRateLimit } from "@/app/lib/rate-limit"; +import { + trackApiRequest, + trackApiResponse, + trackApiError, +} from "@/app/lib/server-analytics"; +import { + TEXTILE_API_BASE, + TEXTILE_UPSTREAM_TIMEOUT_MS, + textileAuthHeaders, +} from "@/app/lib/textileServer"; + +export const POST = withRateLimit(async (request: NextRequest) => { + const startTime = Date.now(); + try { + const body = await request.json(); + const idempotencyKey = request.headers.get("Idempotency-Key") ?? undefined; + + trackApiRequest(request, "/api/bridge/textile/swap", "POST", { + chain_id: body.chainId, + sell_token: body.sellToken, + buy_token: body.buyToken, + }); + + const headers: Record = { + ...textileAuthHeaders(), + "Content-Type": "application/json", + }; + if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey; + + const { data, status } = await axios.post( + `${TEXTILE_API_BASE}/swaps`, + { + chainId: body.chainId, + sellToken: body.sellToken, + buyToken: body.buyToken, + sellAmount: body.sellAmount, + minRate: body.minRate, + taker: body.taker, + requireFullFill: body.requireFullFill ?? false, + }, + { + headers, + validateStatus: () => true, + timeout: TEXTILE_UPSTREAM_TIMEOUT_MS, + }, + ); + + trackApiResponse( + "/api/bridge/textile/swap", + "POST", + status, + Date.now() - startTime, + ); + return NextResponse.json(data, { status }); + } catch (err) { + trackApiError(request, "/api/bridge/textile/swap", "POST", err as Error, 502, { + response_time_ms: Date.now() - startTime, + }); + return NextResponse.json( + { error: "Failed to build Textile swap" }, + { status: 502 }, + ); + } +}); diff --git a/app/components/bridge/BridgeQuoteCard.tsx b/app/components/bridge/BridgeQuoteCard.tsx index 05e4e17a..689241b2 100644 --- a/app/components/bridge/BridgeQuoteCard.tsx +++ b/app/components/bridge/BridgeQuoteCard.tsx @@ -8,7 +8,7 @@ interface BridgeQuoteCardProps { quote: BridgeQuote | null; isLoading: boolean; error: Error | null; - engine: "near" | "lifi" | null; + engine: "near" | "lifi" | "textile" | null; toToken?: string; onExpire?: () => void; } @@ -63,7 +63,12 @@ export const BridgeQuoteCard: React.FC = ({ if (!quote) return null; - const engineLabel = engine === "near" ? "NEAR Intents" : "LI.FI"; + const engineLabel = + engine === "near" + ? "NEAR Intents" + : engine === "textile" + ? "Textile FX" + : "LI.FI"; const routeName = quote.kind === "lifi-tx" && quote.raw && diff --git a/app/components/bridge/BridgeRouteSelector.tsx b/app/components/bridge/BridgeRouteSelector.tsx index ec0bd4f8..019f9c58 100644 --- a/app/components/bridge/BridgeRouteSelector.tsx +++ b/app/components/bridge/BridgeRouteSelector.tsx @@ -27,7 +27,7 @@ interface BridgeRouteSelectorProps { onFromNetworkChange: (name: string) => void; onToNetworkChange: (name: string) => void; outputAmount?: string; - engine?: "near" | "lifi" | null; + engine?: "near" | "lifi" | "textile" | null; timeEstimate?: string; isQuoteLoading?: boolean; } diff --git a/app/hooks/bridge.ts b/app/hooks/bridge.ts index e0140ac8..f1c6949e 100644 --- a/app/hooks/bridge.ts +++ b/app/hooks/bridge.ts @@ -7,6 +7,7 @@ import { selectEngine, NearIntentsClient, LifiClient, + TextileClient, toLifiChainId, resolveNearAssetId, toRawAmount, @@ -15,6 +16,7 @@ import { authHeaders, } from "@/app/lib/bridge"; import type { BridgeLeg, BridgeQuote, BridgeStatusResult, BridgeEngine, NearIntentsToken, BridgeAuth } from "@/app/lib/bridge"; +import { textileChainId } from "@/app/lib/textileNetworks"; import { getRpcUrl } from "@/app/utils"; import { appendBaseBuilderCode } from "@/app/lib/baseBuilderCode"; import type { BatchCall } from "@/app/lib/providerBatch"; @@ -24,6 +26,7 @@ const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; const nearClient = new NearIntentsClient(); const lifiClient = new LifiClient(); +const textileClient = new TextileClient(); // ============================================================================ // useBridgeQuote @@ -69,6 +72,40 @@ async function fetchLifiQuote( }, auth); } +async function fetchTextileQuote( + from: BridgeLeg, + to: BridgeLeg, + rawAmount: string, + evmAddress: string, + slippageBps: number, + auth: BridgeAuth, +): Promise { + const chainId = textileChainId(from.network); + if (!chainId) return null; + + const isCngn = + from.token.toLowerCase() === "cngn" || to.token.toLowerCase() === "cngn"; + const textileSlippage = isCngn + ? Math.max(slippageBps, 200) + : slippageBps; + + const sellToken = from.tokenAddress; + const buyToken = to.tokenAddress; + if (!sellToken || !buyToken) return null; + + return textileClient.getQuote( + { + chainId, + sellToken, + buyToken, + sellAmount: rawAmount, + slippageBps: textileSlippage, + toDecimals: to.decimals, + }, + auth, + ); +} + interface UseBridgeQuoteParams { from: BridgeLeg | null; to: BridgeLeg | null; @@ -145,6 +182,19 @@ export function useBridgeQuote({ }, auth, { origin: from.decimals, destination: to.decimals }); } + if (engine === "textile") { + const textileQuote = await fetchTextileQuote( + from, + to, + rawAmount, + evmAddress, + slippageBps, + auth, + ); + if (textileQuote) return textileQuote; + return fetchLifiQuote(from, to, rawAmount, evmAddress, slippageBps, auth); + } + return fetchLifiQuote(from, to, rawAmount, evmAddress, slippageBps, auth); }, [from, to, amount, evmAddress, starknetAddress, slippageBps, getAccessToken, getInjectedToken]); @@ -168,9 +218,9 @@ export function useBridgeQuote({ refetchInterval: (q) => { const data = q?.state?.data as BridgeQuote | undefined; if (!data) return false; - // LI.FI quotes embed a slippage/validity window but no explicit deadline — refresh on a - // fixed interval so a stale transactionRequest is never executed (it would revert on-chain). - if (data.kind === "lifi-tx") return 30_000; + // LI.FI / Textile quotes embed a slippage/validity window — refresh on a fixed + // interval so stale transaction data is never executed. + if (data.kind === "lifi-tx" || data.kind === "textile-swap") return 30_000; // NEAR deposit quotes: stop refetching once the deposit deadline has nearly passed. return data.deadline - Date.now() > 30_000 ? 30_000 : false; }, @@ -227,7 +277,9 @@ export function useBridgeStatus({ engine, refId, enabled, getAccessToken, getInj const status = engine === "near" ? await nearClient.getStatus(refId, auth) - : await lifiClient.getStatus(refId, auth); + : engine === "textile" + ? await textileClient.getStatus(refId, auth) + : await lifiClient.getStatus(refId, auth); setResult(status); if (status.status === "SUCCESS" || status.status === "REFUNDED" || status.status === "FAILED") { @@ -565,6 +617,82 @@ export function useBridgeExecute({ onSuccess?.(evmHash); // LI.FI: poll status by txHash, not a separate deposit address return { txHash: evmHash, depositRefId: evmHash }; + } else if (quote.kind === "textile-swap") { + const textileQuote = quote; + const chain = selectedNetworkRef.current?.chain; + if (!chain) { + throw new Error("Selected network not found"); + } + + const injectedToken = isInjectedWallet + ? ((await getInjectedToken?.()) ?? null) + : null; + const proxyAuth: BridgeAuth = injectedToken + ? { injectedToken } + : { token: (await getAccessToken?.()) ?? null }; + + const taker = (isInjectedWallet ? injectedAddress : embeddedWallet?.address) as + | string + | undefined; + if (!taker) throw new Error("Wallet not connected"); + + const built = await textileClient.buildSwap( + { + chainId: textileQuote.chainId, + sellToken: textileQuote.sellToken, + buyToken: textileQuote.buyToken, + sellAmount: textileQuote.sellAmount, + minRate: textileQuote.minRateRay, + taker, + idempotencyKey: crypto.randomUUID(), + requireFullFill: textileQuote.fullyFilled, + }, + proxyAuth, + ); + if (!built) { + throw new Error("Textile swap unavailable. Please try again."); + } + + const calls: BatchCall[] = []; + if (built.approval?.data && built.approval.data !== "0x") { + calls.push({ + to: built.approval.to as `0x${string}`, + value: BigInt(built.approval.value || "0"), + data: built.approval.data as `0x${string}`, + }); + } + calls.push({ + to: built.swap.to as `0x${string}`, + value: BigInt(built.swap.value || "0"), + data: built.swap.data as `0x${string}`, + }); + + let evmHash: string; + if (isInjectedWallet) { + evmHash = await executeInjectedCalls( + Number(from.chainId), + calls.map((c) => ({ to: c.to, value: c.value, data: c.data })), + ); + } else { + if (!embeddedWallet || !signDelegationAuthorization || !getAccessToken) { + throw new Error("EVM wallet not configured for Textile execution"); + } + evmHash = await executeBatchCalls({ + chain: chain as any, + calls, + getAccessToken, + embeddedWallet, + signDelegationAuthorization, + gasLimit: 600_000, + }); + } + + await textileClient.submitSwap(built.swapId, evmHash, proxyAuth); + + setTxHash(evmHash); + setIsSuccess(true); + onSuccess?.(evmHash); + return { txHash: evmHash, depositRefId: built.swapId }; } throw new Error("Unsupported quote type"); } catch (err) { diff --git a/app/hooks/useBridgeStatusTracker.ts b/app/hooks/useBridgeStatusTracker.ts index 6bdcd8d7..61c5b3a0 100644 --- a/app/hooks/useBridgeStatusTracker.ts +++ b/app/hooks/useBridgeStatusTracker.ts @@ -5,11 +5,12 @@ import { usePrivy } from "@privy-io/react-auth"; import { useWalletAddress } from "@/app/hooks/useWalletAddress"; import { useInjectedWallet } from "@/app/context"; import { updateBridgeTransactionStatus } from "@/app/api/aggregator"; -import { NearIntentsClient, LifiClient } from "@/app/lib/bridge"; +import { NearIntentsClient, LifiClient, TextileClient } from "@/app/lib/bridge"; import type { BridgeEngine, BridgeAuth } from "@/app/lib/bridge"; const nearClient = new NearIntentsClient(); const lifiClient = new LifiClient(); +const textileClient = new TextileClient(); export interface BridgeSubmitInfo { savedTxId: string; @@ -95,7 +96,9 @@ export function useBridgeStatusTracker() { const status = bridge.engine === "near" ? await nearClient.getStatus(bridge.depositRefId, auth) - : await lifiClient.getStatus(bridge.depositRefId, auth); + : bridge.engine === "textile" + ? await textileClient.getStatus(bridge.depositRefId, auth) + : await lifiClient.getStatus(bridge.depositRefId, auth); if (status.status === "SUCCESS") { await updateBridgeTransactionStatus( diff --git a/app/lib/bridge.ts b/app/lib/bridge.ts index 899afdc5..cbcb42e3 100644 --- a/app/lib/bridge.ts +++ b/app/lib/bridge.ts @@ -13,6 +13,8 @@ import { buildBatchDigest, encodeExecuteBatch, readBatchNonce } from "./provider import { getRpcUrl } from "@/app/utils"; import { getDelegationContractAddress } from "./config"; import { get7702AuthorizedImplementationForAddress } from "../hooks/useEIP7702Account"; +import { isTextileRoute } from "./bridgeFeature"; +import { minRateRayFromEffective } from "./textileServer"; // ============================================================================ // TYPES @@ -69,7 +71,35 @@ export interface LifiTxQuote { raw: unknown; } -export type BridgeQuote = NearDepositQuote | LifiTxQuote; +/** Textile FX quote preview — txs built on confirm via POST /v1/swaps. */ +export interface TextileSwapQuote { + kind: "textile-swap"; + amountOut: string; + /** Fee in receiving token (Textile taker fee is taken in collateral; 0 when unknown at quote time). */ + feeReceivingToken: string; + chainId: number; + sellToken: string; + buyToken: string; + /** User-requested sell amount in atomic units (may exceed what the book fills). */ + requestedSellAmount: string; + /** Executable sell amount in atomic units — pass to POST /v1/swaps. */ + sellAmount: string; + fullyFilled: boolean; + toDecimals: number; + /** RAY-scaled min rate passed to POST /v1/swaps on execute. */ + minRateRay: string; + raw: unknown; +} + +/** Built Textile swap with unsigned approval + swap txs (short-lived). */ +export interface TextileBuiltSwap { + swapId: string; + requiredAllowance: string; + approval: { to: string; data: string; value: string; chainId: number }; + swap: { to: string; data: string; value: string; chainId: number }; +} + +export type BridgeQuote = NearDepositQuote | LifiTxQuote | TextileSwapQuote; export type BridgeStatus = | "PENDING_DEPOSIT" @@ -85,18 +115,19 @@ export interface BridgeStatusResult { destinationTxHash?: string; } -export type BridgeEngine = "near" | "lifi"; +export type BridgeEngine = "near" | "lifi" | "textile"; // ============================================================================ // ROUTING // ============================================================================ /** - * Routes to LI.FI for cNGN legs or any Starknet leg; NEAR Intents otherwise (EVM↔EVM stablecoins). - * Starknet uses LI.FI because NEAR Intents' token list only includes STRK/ZEC/XRP on Starknet — - * no USDC/stablecoins — so stablecoin routes via NEAR always fail asset resolution. + * Routes Textile for same-chain USDT↔cNGN on BSC/Celo; LI.FI for other cNGN legs + * or Starknet; NEAR Intents otherwise (EVM↔EVM stablecoins). */ export function selectEngine(from: BridgeLeg, to: BridgeLeg): BridgeEngine { + if (isTextileRoute(from, to)) return "textile"; + const cngn = "cngn"; const isStarknet = (n: string) => n.toLowerCase() === "starknet"; if (from.token.toLowerCase() === cngn || to.token.toLowerCase() === cngn) return "lifi"; @@ -224,6 +255,9 @@ export function bridgeFeeInReceivingToken(quote: BridgeQuote): number { if (quote.kind === "lifi-tx") { return parseFloat(quote.feeReceivingToken) || 0; } + if (quote.kind === "textile-swap") { + return parseFloat(quote.feeReceivingToken) || 0; + } return parseFloat(quote.fee) || 0; } @@ -463,6 +497,205 @@ export class LifiClient { } } +export function mapTextileStatus(status?: string): BridgeStatus { + switch ((status ?? "").toUpperCase()) { + case "FILLED": + return "SUCCESS"; + case "FAILED": + case "CANCELLED": + return "FAILED"; + case "QUOTED": + case "SUBMITTED": + default: + return "PROCESSING"; + } +} + +/** + * Accept Textile quotes when the book can fill something (partial fills OK). + * Falls back to LI.FI only when there is no fillable liquidity at all. + */ +export function normalizeTextileQuote( + data: unknown, + params: { + chainId: number; + sellToken: string; + buyToken: string; + sellAmount: string; + slippageBps: number; + toDecimals: number; + }, +): TextileSwapQuote | null { + const quote = (data as { data?: Record })?.data; + if (!quote?.hasLiquidity) return null; + + const fillableAmount = String(quote.fillableAmount ?? "0"); + try { + if (BigInt(fillableAmount) <= BigInt(0)) return null; + } catch { + return null; + } + + const proceeds = String(quote.proceeds ?? "0"); + let amountOut = "0"; + try { + if (BigInt(proceeds) > BigInt(0)) { + amountOut = formatUnits(BigInt(proceeds), params.toDecimals); + } + } catch { + return null; + } + if (parseFloat(amountOut) <= 0) return null; + + const effectiveRateRay = String(quote.effectiveRateRay ?? "0"); + const slippageBps = Math.max(params.slippageBps, 200); + const minRateRay = + effectiveRateRay !== "0" + ? minRateRayFromEffective(effectiveRateRay, slippageBps) + : "0"; + + return { + kind: "textile-swap", + amountOut, + feeReceivingToken: "0", + chainId: params.chainId, + sellToken: params.sellToken, + buyToken: params.buyToken, + requestedSellAmount: params.sellAmount, + sellAmount: fillableAmount, + fullyFilled: quote.fullyFilled === true, + toDecimals: params.toDecimals, + minRateRay, + raw: data, + }; +} + +export class TextileClient { + async getQuote( + params: { + chainId: number; + sellToken: string; + buyToken: string; + sellAmount: string; + slippageBps: number; + toDecimals: number; + }, + auth?: BridgeAuth | string | null, + ): Promise { + const { data, status } = await axios.get("/api/bridge/textile/quote", { + params: { + chainId: params.chainId, + sellToken: params.sellToken, + buyToken: params.buyToken, + sellAmount: params.sellAmount, + slippageBps: params.slippageBps, + }, + headers: authHeaders(auth), + validateStatus: () => true, + }); + + if (status === 404) return null; + if (status === 401 || status === 403) { + throw new Error("Authentication required for bridge quote"); + } + if (status === 429) { + throw new Error("Textile quote rate-limited. Please retry shortly."); + } + if (status >= 400 && status < 500) return null; + if (status >= 500) { + throw new Error(data?.message || `Textile service error (${status})`); + } + + return normalizeTextileQuote(data, { + chainId: params.chainId, + sellToken: params.sellToken, + buyToken: params.buyToken, + sellAmount: params.sellAmount, + slippageBps: params.slippageBps, + toDecimals: params.toDecimals, + }); + } + + async buildSwap( + params: { + chainId: number; + sellToken: string; + buyToken: string; + sellAmount: string; + minRate: string; + taker: string; + idempotencyKey: string; + requireFullFill?: boolean; + }, + auth?: BridgeAuth | string | null, + ): Promise { + const { data, status } = await axios.post( + "/api/bridge/textile/swap", + { + chainId: params.chainId, + sellToken: params.sellToken, + buyToken: params.buyToken, + sellAmount: params.sellAmount, + minRate: params.minRate, + taker: params.taker, + requireFullFill: params.requireFullFill ?? false, + }, + { + headers: { + ...authHeaders(auth), + "Idempotency-Key": params.idempotencyKey, + }, + validateStatus: () => true, + }, + ); + + if (status === 401 || status === 403) { + throw new Error("Authentication required for bridge swap"); + } + if (status >= 500) { + throw new Error(data?.message || `Textile service error (${status})`); + } + if (status >= 400) return null; + + const built = data?.data; + if (!built?.fillable || !built.transactions?.swap) return null; + + return { + swapId: built.id, + requiredAllowance: built.requiredAllowance ?? params.sellAmount, + approval: built.transactions.approval, + swap: built.transactions.swap, + }; + } + + async submitSwap( + swapId: string, + txHash: string, + auth?: BridgeAuth | string | null, + ): Promise { + await axios.post( + `/api/bridge/textile/submit`, + { swapId, txHash }, + { headers: authHeaders(auth) }, + ); + } + + async getStatus( + swapId: string, + auth?: BridgeAuth | string | null, + ): Promise { + const { data } = await axios.get("/api/bridge/textile/status", { + params: { swapId }, + headers: authHeaders(auth), + }); + const swap = data?.data; + return { + status: mapTextileStatus(swap?.status), + txHash: swap?.txHash, + }; + } +} + // ============================================================================ // EVM BATCH EXECUTION // ============================================================================ diff --git a/app/lib/bridgeFeature.ts b/app/lib/bridgeFeature.ts index 91f04b7a..4c17dddf 100644 --- a/app/lib/bridgeFeature.ts +++ b/app/lib/bridgeFeature.ts @@ -1,5 +1,27 @@ import config from "./config"; +import type { BridgeLeg } from "./bridge"; +import { TEXTILE_SUPPORTED_NETWORKS } from "./textileNetworks"; export function isBridgeUiVisible(): boolean { return config.bridgeEnabled; } + +export function isTextileSwapEnabled(): boolean { + return config.bridgeEnabled && config.textileEnabled; +} + +/** + * Same-chain USDT ↔ cNGN on BSC or Celo when Textile is enabled. + */ +export function isTextileRoute(from: BridgeLeg, to: BridgeLeg): boolean { + if (!isTextileSwapEnabled()) return false; + if (from.network !== to.network) return false; + if (!TEXTILE_SUPPORTED_NETWORKS.has(from.network)) return false; + + const fromSym = from.token.toLowerCase(); + const toSym = to.token.toLowerCase(); + return ( + (fromSym === "usdt" && toSym === "cngn") || + (fromSym === "cngn" && toSym === "usdt") + ); +} diff --git a/app/lib/config.ts b/app/lib/config.ts index 07e10bba..c77029ed 100644 --- a/app/lib/config.ts +++ b/app/lib/config.ts @@ -73,6 +73,7 @@ const config: Config = { tronEnabled: process.env.NEXT_PUBLIC_TRON_ENABLED === "true", referralEnabled: (process.env.NEXT_PUBLIC_REFERRAL_ENABLED || "").trim().toLowerCase() !== "false", bridgeEnabled: process.env.NEXT_PUBLIC_BRIDGE_ENABLED === "true", + textileEnabled: process.env.NEXT_PUBLIC_TEXTILE_ENABLED === "true", onrampChainedForwardingEnabled: process.env.NEXT_PUBLIC_ONRAMP_CHAINED_FORWARDING_ENABLED === "true", kesOnrampEnabled: process.env.NEXT_PUBLIC_KES_ONRAMP_ENABLED !== "false", diff --git a/app/lib/textileNetworks.ts b/app/lib/textileNetworks.ts new file mode 100644 index 00000000..4e05647d --- /dev/null +++ b/app/lib/textileNetworks.ts @@ -0,0 +1,21 @@ +/** + * Textile FX network config: same-chain USDT ↔ cNGN on BSC and Celo. + * @see https://docs.textilecredit.com/address-book.html + */ + +export const TEXTILE_NETWORK_CONFIG = { + "BNB Smart Chain": { chainId: 56 }, + Celo: { chainId: 42220 }, +} as const; + +export type TextileNetworkName = keyof typeof TEXTILE_NETWORK_CONFIG; + +export const TEXTILE_SUPPORTED_NETWORKS = new Set( + Object.keys(TEXTILE_NETWORK_CONFIG), +); + +export function textileChainId(networkName: string): number | null { + const cfg = + TEXTILE_NETWORK_CONFIG[networkName as TextileNetworkName]; + return cfg?.chainId ?? null; +} diff --git a/app/lib/textileServer.ts b/app/lib/textileServer.ts new file mode 100644 index 00000000..d0119674 --- /dev/null +++ b/app/lib/textileServer.ts @@ -0,0 +1,25 @@ +/** Shared Textile FX upstream helpers for server proxy routes. */ + +export const TEXTILE_API_BASE = "https://api.textilecredit.com/v1"; +export const TEXTILE_UPSTREAM_TIMEOUT_MS = 15_000; + +export function textileAuthHeaders(): Record { + const key = process.env.TEXTILE_API_KEY || ""; + const headers: Record = {}; + if (key) headers.Authorization = `Bearer ${key}`; + return headers; +} + +/** Debt-per-collateral floor (RAY) after applying slippage tolerance. */ +export function minRateRayFromEffective( + effectiveRateRay: string, + slippageBps: number, +): string { + try { + const rate = BigInt(effectiveRateRay); + const factor = BigInt(Math.max(0, 10_000 - slippageBps)); + return ((rate * factor) / BigInt(10_000)).toString(); + } catch { + return "0"; + } +} diff --git a/app/types.ts b/app/types.ts index 957ef40b..356e6a7d 100644 --- a/app/types.ts +++ b/app/types.ts @@ -493,6 +493,8 @@ export type Config = { referralEnabled: boolean; /** Bridge/Swap feature flag. Controls Convert button visibility + proxy routes. */ bridgeEnabled: boolean; + /** Textile FX for same-chain USDT↔cNGN on BSC and Celo. Requires TEXTILE_API_KEY server-side. */ + textileEnabled: boolean; onrampChainedForwardingEnabled: boolean; /** * KES fiat→crypto onramp. Default on (unset env); set diff --git a/app/utils.ts b/app/utils.ts index 1d9e5c46..d9a82986 100644 --- a/app/utils.ts +++ b/app/utils.ts @@ -784,6 +784,13 @@ export const FALLBACK_TOKENS: { [key: string]: Token[] } = { address: "0x765DE816845861e75A25fCA122bb6898B8B1282a", imageUrl: "/logos/cusd-logo.svg", }, + { + name: "Compliant Naira", + symbol: "cNGN", + decimals: 6, + address: "0xF6829D7393dAe24509eb1E52eE8e572e2E271a4f", + imageUrl: "/logos/cngn-logo.svg", + }, ], Lisk: [ { From f3ccd63d925f9a017c1872bf79fe3fc9351e501d Mon Sep 17 00:00:00 2001 From: sundayonah Date: Thu, 20 Aug 2026 10:38:02 +0100 Subject: [PATCH 2/4] feat(bridge): enhance Textile quote validation and error handling --- __tests__/textileRouting.test.ts | 34 ++++++++++++ app/api/bridge/textile/quote/route.ts | 10 ++-- app/api/bridge/textile/submit/route.ts | 18 ++++++- app/api/bridge/textile/swap/route.ts | 28 +++++++++- app/components/bridge/BridgeQuoteCard.tsx | 6 +-- app/hooks/bridge.ts | 45 ++++++++++++++-- app/lib/bridge.ts | 65 ++++++++++++++++++----- app/lib/textileServer.ts | 30 +++++++++++ 8 files changed, 210 insertions(+), 26 deletions(-) diff --git a/__tests__/textileRouting.test.ts b/__tests__/textileRouting.test.ts index 580547f3..704b8e47 100644 --- a/__tests__/textileRouting.test.ts +++ b/__tests__/textileRouting.test.ts @@ -158,4 +158,38 @@ describe("normalizeTextileQuote", () => { expect(result?.fullyFilled).toBe(true); expect(result?.sellAmount).toBe("500000000000000000"); }); + + it("rejects when fillableAmount exceeds requested sellAmount", () => { + const result = normalizeTextileQuote( + { + data: { + hasLiquidity: true, + fullyFilled: true, + fillableAmount: "600000000000000000", + proceeds: "812500000", + effectiveRateRay: "1000000000000000000000000000", + }, + }, + baseParams, + ); + + expect(result).toBeNull(); + }); + + it("rejects when effectiveRateRay is zero", () => { + const result = normalizeTextileQuote( + { + data: { + hasLiquidity: true, + fullyFilled: true, + fillableAmount: "500000000000000000", + proceeds: "812500000", + effectiveRateRay: "0", + }, + }, + baseParams, + ); + + expect(result).toBeNull(); + }); }); diff --git a/app/api/bridge/textile/quote/route.ts b/app/api/bridge/textile/quote/route.ts index 6162bbd6..1056d8a6 100644 --- a/app/api/bridge/textile/quote/route.ts +++ b/app/api/bridge/textile/quote/route.ts @@ -11,6 +11,7 @@ import { TEXTILE_UPSTREAM_TIMEOUT_MS, textileAuthHeaders, minRateRayFromEffective, + isPositiveRayRate, } from "@/app/lib/textileServer"; export const GET = withRateLimit(async (request: NextRequest) => { @@ -50,11 +51,14 @@ export const GET = withRateLimit(async (request: NextRequest) => { } const quote = data?.data; - if (quote?.effectiveRateRay) { - quote.minRateRay = minRateRayFromEffective( - quote.effectiveRateRay, + if (quote?.effectiveRateRay && isPositiveRayRate(String(quote.effectiveRateRay))) { + const minRateRay = minRateRayFromEffective( + String(quote.effectiveRateRay), slippageBps, ); + if (isPositiveRayRate(minRateRay)) { + quote.minRateRay = minRateRay; + } } return NextResponse.json(data, { status }); diff --git a/app/api/bridge/textile/submit/route.ts b/app/api/bridge/textile/submit/route.ts index b55890db..74821085 100644 --- a/app/api/bridge/textile/submit/route.ts +++ b/app/api/bridge/textile/submit/route.ts @@ -12,10 +12,26 @@ import { textileAuthHeaders, } from "@/app/lib/textileServer"; +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + export const POST = withRateLimit(async (request: NextRequest) => { const startTime = Date.now(); try { - const body = await request.json(); + let body: Record; + try { + body = (await request.json()) as Record; + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + if (!isNonEmptyString(body.swapId) || !isNonEmptyString(body.txHash)) { + return NextResponse.json( + { error: "swapId and txHash are required" }, + { status: 400 }, + ); + } trackApiRequest(request, "/api/bridge/textile/submit", "POST", { swap_id: body.swapId, diff --git a/app/api/bridge/textile/swap/route.ts b/app/api/bridge/textile/swap/route.ts index 6a236c00..d047d161 100644 --- a/app/api/bridge/textile/swap/route.ts +++ b/app/api/bridge/textile/swap/route.ts @@ -12,10 +12,36 @@ import { textileAuthHeaders, } from "@/app/lib/textileServer"; +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + export const POST = withRateLimit(async (request: NextRequest) => { const startTime = Date.now(); try { - const body = await request.json(); + let body: Record; + try { + body = (await request.json()) as Record; + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const missing = [ + !body.chainId && "chainId", + !isNonEmptyString(body.sellToken) && "sellToken", + !isNonEmptyString(body.buyToken) && "buyToken", + !isNonEmptyString(body.sellAmount) && "sellAmount", + !isNonEmptyString(body.minRate) && "minRate", + !isNonEmptyString(body.taker) && "taker", + ].filter(Boolean); + + if (missing.length > 0) { + return NextResponse.json( + { error: `Missing required fields: ${missing.join(", ")}` }, + { status: 400 }, + ); + } + const idempotencyKey = request.headers.get("Idempotency-Key") ?? undefined; trackApiRequest(request, "/api/bridge/textile/swap", "POST", { diff --git a/app/components/bridge/BridgeQuoteCard.tsx b/app/components/bridge/BridgeQuoteCard.tsx index 689241b2..16e856ea 100644 --- a/app/components/bridge/BridgeQuoteCard.tsx +++ b/app/components/bridge/BridgeQuoteCard.tsx @@ -17,7 +17,7 @@ export const BridgeQuoteCard: React.FC = ({ quote, isLoading, error, - engine, + engine: _engine, toToken, onExpire, }) => { @@ -64,9 +64,9 @@ export const BridgeQuoteCard: React.FC = ({ if (!quote) return null; const engineLabel = - engine === "near" + quote.kind === "near-deposit" ? "NEAR Intents" - : engine === "textile" + : quote.kind === "textile-swap" ? "Textile FX" : "LI.FI"; const routeName = diff --git a/app/hooks/bridge.ts b/app/hooks/bridge.ts index f1c6949e..55288b87 100644 --- a/app/hooks/bridge.ts +++ b/app/hooks/bridge.ts @@ -8,6 +8,7 @@ import { NearIntentsClient, LifiClient, TextileClient, + textileIdempotencyKey, toLifiChainId, resolveNearAssetId, toRawAmount, @@ -368,6 +369,10 @@ export function useBridgeExecute({ const selectedNetworkRef = useRef(selectedNetwork); useEffect(() => { selectedNetworkRef.current = selectedNetwork; }, [selectedNetwork]); + const textileIdempotencyRef = useRef<{ quoteKey: string; key: string } | null>( + null, + ); + // Injected wallets sign and pay for their own transactions directly through // their provider — no sponsored bundler, no EIP-7702 delegation. Calls are sent // sequentially, each confirmed before the next, so an approval lands before the @@ -636,6 +641,28 @@ export function useBridgeExecute({ | undefined; if (!taker) throw new Error("Wallet not connected"); + const quoteKey = [ + textileQuote.chainId, + textileQuote.sellToken, + textileQuote.buyToken, + textileQuote.sellAmount, + taker, + textileQuote.minRateRay, + ].join("|"); + + let idempotencyKey = textileIdempotencyRef.current?.key; + if (textileIdempotencyRef.current?.quoteKey !== quoteKey) { + idempotencyKey = textileIdempotencyKey({ + chainId: textileQuote.chainId, + sellToken: textileQuote.sellToken, + buyToken: textileQuote.buyToken, + sellAmount: textileQuote.sellAmount, + taker, + minRate: textileQuote.minRateRay, + }); + textileIdempotencyRef.current = { quoteKey, key: idempotencyKey }; + } + const built = await textileClient.buildSwap( { chainId: textileQuote.chainId, @@ -644,7 +671,7 @@ export function useBridgeExecute({ sellAmount: textileQuote.sellAmount, minRate: textileQuote.minRateRay, taker, - idempotencyKey: crypto.randomUUID(), + idempotencyKey: idempotencyKey!, requireFullFill: textileQuote.fullyFilled, }, proxyAuth, @@ -654,11 +681,19 @@ export function useBridgeExecute({ } const calls: BatchCall[] = []; - if (built.approval?.data && built.approval.data !== "0x") { + const allowance = BigInt(built.requiredAllowance || "0"); + if (allowance > BigInt(0)) { + if (!built.approvalSpender || built.approvalSpender === ZERO_ADDRESS) { + throw new Error("Textile approval details unavailable. Please try again."); + } calls.push({ - to: built.approval.to as `0x${string}`, - value: BigInt(built.approval.value || "0"), - data: built.approval.data as `0x${string}`, + to: from.tokenAddress as `0x${string}`, + value: BigInt(0), + data: encodeFunctionData({ + abi: erc20Abi, + functionName: "approve", + args: [built.approvalSpender, allowance], + }), }); } calls.push({ diff --git a/app/lib/bridge.ts b/app/lib/bridge.ts index cbcb42e3..f83f4286 100644 --- a/app/lib/bridge.ts +++ b/app/lib/bridge.ts @@ -4,7 +4,7 @@ */ import axios from "axios"; -import { encodeFunctionData, parseUnits, formatUnits, http, createPublicClient, erc20Abi } from "viem"; +import { encodeFunctionData, decodeFunctionData, parseUnits, formatUnits, http, createPublicClient, erc20Abi } from "viem"; import type { SignedAuthorization } from "viem"; import { networks } from "@/app/mocks"; import type { Token, Network } from "@/app/types"; @@ -14,7 +14,11 @@ import { getRpcUrl } from "@/app/utils"; import { getDelegationContractAddress } from "./config"; import { get7702AuthorizedImplementationForAddress } from "../hooks/useEIP7702Account"; import { isTextileRoute } from "./bridgeFeature"; -import { minRateRayFromEffective } from "./textileServer"; +import { + isPositiveRayRate, + minRateRayFromEffective, + textileIdempotencyKey, +} from "./textileServer"; // ============================================================================ // TYPES @@ -95,10 +99,14 @@ export interface TextileSwapQuote { export interface TextileBuiltSwap { swapId: string; requiredAllowance: string; + /** ERC-20 approve spender decoded from upstream approval calldata. */ + approvalSpender?: `0x${string}`; approval: { to: string; data: string; value: string; chainId: number }; swap: { to: string; data: string; value: string; chainId: number }; } +export { textileIdempotencyKey }; + export type BridgeQuote = NearDepositQuote | LifiTxQuote | TextileSwapQuote; export type BridgeStatus = @@ -531,7 +539,9 @@ export function normalizeTextileQuote( const fillableAmount = String(quote.fillableAmount ?? "0"); try { - if (BigInt(fillableAmount) <= BigInt(0)) return null; + const fillable = BigInt(fillableAmount); + const requested = BigInt(params.sellAmount); + if (fillable <= BigInt(0) || fillable > requested) return null; } catch { return null; } @@ -548,11 +558,11 @@ export function normalizeTextileQuote( if (parseFloat(amountOut) <= 0) return null; const effectiveRateRay = String(quote.effectiveRateRay ?? "0"); + if (!isPositiveRayRate(effectiveRateRay)) return null; + const slippageBps = Math.max(params.slippageBps, 200); - const minRateRay = - effectiveRateRay !== "0" - ? minRateRayFromEffective(effectiveRateRay, slippageBps) - : "0"; + const minRateRay = minRateRayFromEffective(effectiveRateRay, slippageBps); + if (!isPositiveRayRate(minRateRay)) return null; return { kind: "textile-swap", @@ -570,6 +580,24 @@ export function normalizeTextileQuote( }; } +function parseTextileApprovalSpender( + approvalData?: string, +): `0x${string}` | undefined { + if (!approvalData || approvalData === "0x") return undefined; + try { + const decoded = decodeFunctionData({ + abi: erc20Abi, + data: approvalData as `0x${string}`, + }); + if (decoded.functionName === "approve" && decoded.args[0]) { + return decoded.args[0] as `0x${string}`; + } + } catch { + // ignore malformed calldata + } + return undefined; +} + export class TextileClient { async getQuote( params: { @@ -660,9 +688,12 @@ export class TextileClient { const built = data?.data; if (!built?.fillable || !built.transactions?.swap) return null; + const approvalData = built.transactions.approval?.data as string | undefined; + return { swapId: built.id, requiredAllowance: built.requiredAllowance ?? params.sellAmount, + approvalSpender: parseTextileApprovalSpender(approvalData), approval: built.transactions.approval, swap: built.transactions.swap, }; @@ -672,12 +703,20 @@ export class TextileClient { swapId: string, txHash: string, auth?: BridgeAuth | string | null, - ): Promise { - await axios.post( - `/api/bridge/textile/submit`, - { swapId, txHash }, - { headers: authHeaders(auth) }, - ); + ): Promise { + try { + const { status } = await axios.post( + `/api/bridge/textile/submit`, + { swapId, txHash }, + { + headers: authHeaders(auth), + validateStatus: () => true, + }, + ); + return status >= 200 && status < 300; + } catch { + return false; + } } async getStatus( diff --git a/app/lib/textileServer.ts b/app/lib/textileServer.ts index d0119674..70c2ff1c 100644 --- a/app/lib/textileServer.ts +++ b/app/lib/textileServer.ts @@ -23,3 +23,33 @@ export function minRateRayFromEffective( return "0"; } } + +/** True when a RAY-scaled rate string is a positive integer. */ +export function isPositiveRayRate(value: string): boolean { + try { + return BigInt(value) > BigInt(0); + } catch { + return false; + } +} + +/** + * Stable idempotency key for the same swap intent (retries replay Textile's first response). + */ +export function textileIdempotencyKey(params: { + chainId: number; + sellToken: string; + buyToken: string; + sellAmount: string; + taker: string; + minRate: string; +}): string { + return [ + params.chainId, + params.sellToken.toLowerCase(), + params.buyToken.toLowerCase(), + params.sellAmount, + params.taker.toLowerCase(), + params.minRate, + ].join(":"); +} From ee80dba5b751252bd45433bc1a0ef8fb1339363d Mon Sep 17 00:00:00 2001 From: sundayonah Date: Thu, 20 Aug 2026 10:55:36 +0100 Subject: [PATCH 3/4] feat(bridge): implement validation for Textile request bodies and enhance error handling --- __tests__/textileRouting.test.ts | 36 ++++++++++++++++++ app/api/bridge/textile/submit/route.ts | 26 +++++++------ app/api/bridge/textile/swap/route.ts | 31 ++++++--------- app/hooks/bridge.ts | 12 +++++- app/lib/bridge.ts | 45 +++++++++++++++------- app/lib/textileServer.ts | 52 +++++++++++++++++++++++++- 6 files changed, 156 insertions(+), 46 deletions(-) diff --git a/__tests__/textileRouting.test.ts b/__tests__/textileRouting.test.ts index 704b8e47..0ec8490b 100644 --- a/__tests__/textileRouting.test.ts +++ b/__tests__/textileRouting.test.ts @@ -193,3 +193,39 @@ describe("normalizeTextileQuote", () => { expect(result).toBeNull(); }); }); + +describe("textileServer validation", () => { + it("parseJsonObjectBody rejects null and arrays", () => { + const { parseJsonObjectBody, validateTextileSwapBody } = require("../app/lib/textileServer"); + + expect(parseJsonObjectBody(null).ok).toBe(false); + expect(parseJsonObjectBody([]).ok).toBe(false); + expect(parseJsonObjectBody({ chainId: 56 }).ok).toBe(true); + }); + + it("validateTextileSwapBody rejects zero minRate", () => { + const { validateTextileSwapBody } = require("../app/lib/textileServer"); + + expect( + validateTextileSwapBody({ + chainId: 56, + sellToken: "0xabc", + buyToken: "0xdef", + sellAmount: "1", + minRate: "0", + taker: "0x123", + }).ok, + ).toBe(false); + + expect( + validateTextileSwapBody({ + chainId: 56, + sellToken: "0xabc", + buyToken: "0xdef", + sellAmount: "1", + minRate: "1000000000000000000000000000", + taker: "0x123", + }).ok, + ).toBe(true); + }); +}); diff --git a/app/api/bridge/textile/submit/route.ts b/app/api/bridge/textile/submit/route.ts index 74821085..fccad144 100644 --- a/app/api/bridge/textile/submit/route.ts +++ b/app/api/bridge/textile/submit/route.ts @@ -10,27 +10,29 @@ import { TEXTILE_API_BASE, TEXTILE_UPSTREAM_TIMEOUT_MS, textileAuthHeaders, + parseJsonObjectBody, + validateTextileSubmitBody, } from "@/app/lib/textileServer"; -function isNonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.trim().length > 0; -} - export const POST = withRateLimit(async (request: NextRequest) => { const startTime = Date.now(); try { - let body: Record; + let parsed: unknown; try { - body = (await request.json()) as Record; + parsed = await request.json(); } catch { return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); } - if (!isNonEmptyString(body.swapId) || !isNonEmptyString(body.txHash)) { - return NextResponse.json( - { error: "swapId and txHash are required" }, - { status: 400 }, - ); + const objectBody = parseJsonObjectBody(parsed); + if (!objectBody.ok) { + return NextResponse.json({ error: objectBody.error }, { status: 400 }); + } + + const body = objectBody.body; + const validation = validateTextileSubmitBody(body); + if (!validation.ok) { + return NextResponse.json({ error: validation.error }, { status: 400 }); } trackApiRequest(request, "/api/bridge/textile/submit", "POST", { @@ -38,7 +40,7 @@ export const POST = withRateLimit(async (request: NextRequest) => { }); const { data, status } = await axios.post( - `${TEXTILE_API_BASE}/swaps/${encodeURIComponent(body.swapId)}/submit`, + `${TEXTILE_API_BASE}/swaps/${encodeURIComponent(body.swapId as string)}/submit`, { txHash: body.txHash }, { headers: { diff --git a/app/api/bridge/textile/swap/route.ts b/app/api/bridge/textile/swap/route.ts index d047d161..f1c60197 100644 --- a/app/api/bridge/textile/swap/route.ts +++ b/app/api/bridge/textile/swap/route.ts @@ -10,36 +10,29 @@ import { TEXTILE_API_BASE, TEXTILE_UPSTREAM_TIMEOUT_MS, textileAuthHeaders, + parseJsonObjectBody, + validateTextileSwapBody, } from "@/app/lib/textileServer"; -function isNonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.trim().length > 0; -} - export const POST = withRateLimit(async (request: NextRequest) => { const startTime = Date.now(); try { - let body: Record; + let parsed: unknown; try { - body = (await request.json()) as Record; + parsed = await request.json(); } catch { return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); } - const missing = [ - !body.chainId && "chainId", - !isNonEmptyString(body.sellToken) && "sellToken", - !isNonEmptyString(body.buyToken) && "buyToken", - !isNonEmptyString(body.sellAmount) && "sellAmount", - !isNonEmptyString(body.minRate) && "minRate", - !isNonEmptyString(body.taker) && "taker", - ].filter(Boolean); + const objectBody = parseJsonObjectBody(parsed); + if (!objectBody.ok) { + return NextResponse.json({ error: objectBody.error }, { status: 400 }); + } - if (missing.length > 0) { - return NextResponse.json( - { error: `Missing required fields: ${missing.join(", ")}` }, - { status: 400 }, - ); + const body = objectBody.body; + const validation = validateTextileSwapBody(body); + if (!validation.ok) { + return NextResponse.json({ error: validation.error }, { status: 400 }); } const idempotencyKey = request.headers.get("Idempotency-Key") ?? undefined; diff --git a/app/hooks/bridge.ts b/app/hooks/bridge.ts index 55288b87..70e366bc 100644 --- a/app/hooks/bridge.ts +++ b/app/hooks/bridge.ts @@ -722,7 +722,17 @@ export function useBridgeExecute({ }); } - await textileClient.submitSwap(built.swapId, evmHash, proxyAuth); + try { + await textileClient.submitSwap(built.swapId, evmHash, proxyAuth); + } catch (submitErr) { + const detail = + submitErr instanceof Error + ? submitErr.message + : "Textile submit failed"; + throw new Error( + `On-chain swap sent (${evmHash}) but Textile registration failed: ${detail}`, + ); + } setTxHash(evmHash); setIsSuccess(true); diff --git a/app/lib/bridge.ts b/app/lib/bridge.ts index f83f4286..792e02b0 100644 --- a/app/lib/bridge.ts +++ b/app/lib/bridge.ts @@ -703,20 +703,39 @@ export class TextileClient { swapId: string, txHash: string, auth?: BridgeAuth | string | null, - ): Promise { - try { - const { status } = await axios.post( - `/api/bridge/textile/submit`, - { swapId, txHash }, - { - headers: authHeaders(auth), - validateStatus: () => true, - }, - ); - return status >= 200 && status < 300; - } catch { - return false; + ): Promise { + const maxAttempts = 3; + let lastError: Error | null = null; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + const { data, status } = await axios.post( + `/api/bridge/textile/submit`, + { swapId, txHash }, + { + headers: authHeaders(auth), + validateStatus: () => true, + }, + ); + if (status >= 200 && status < 300) return; + + const message = + (data as { error?: { message?: string }; message?: string })?.error + ?.message || + (data as { message?: string })?.message || + `Textile submit failed (${status})`; + lastError = new Error(message); + } catch (err) { + lastError = + err instanceof Error ? err : new Error("Textile submit request failed"); + } + + if (attempt < maxAttempts - 1) { + await new Promise((resolve) => setTimeout(resolve, 500 * (attempt + 1))); + } } + + throw lastError ?? new Error("Textile submit failed"); } async getStatus( diff --git a/app/lib/textileServer.ts b/app/lib/textileServer.ts index 70c2ff1c..d1a48f5d 100644 --- a/app/lib/textileServer.ts +++ b/app/lib/textileServer.ts @@ -25,7 +25,8 @@ export function minRateRayFromEffective( } /** True when a RAY-scaled rate string is a positive integer. */ -export function isPositiveRayRate(value: string): boolean { +export function isPositiveRayRate(value: unknown): boolean { + if (typeof value !== "string" || value.trim().length === 0) return false; try { return BigInt(value) > BigInt(0); } catch { @@ -33,6 +34,55 @@ export function isPositiveRayRate(value: string): boolean { } } +/** Parse request JSON body; rejects null, arrays, and non-objects with 400. */ +export function parseJsonObjectBody( + value: unknown, +): { ok: true; body: Record } | { ok: false; error: string } { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return { ok: false, error: "Request body must be a JSON object" }; + } + return { ok: true, body: value as Record }; +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +export function validateTextileSwapBody( + body: Record, +): { ok: true } | { ok: false; error: string } { + const missing = [ + body.chainId === undefined || body.chainId === null ? "chainId" : null, + !isNonEmptyString(body.sellToken) ? "sellToken" : null, + !isNonEmptyString(body.buyToken) ? "buyToken" : null, + !isNonEmptyString(body.sellAmount) ? "sellAmount" : null, + !isNonEmptyString(body.minRate) ? "minRate" : null, + !isNonEmptyString(body.taker) ? "taker" : null, + ].filter(Boolean); + + if (missing.length > 0) { + return { + ok: false, + error: `Missing required fields: ${missing.join(", ")}`, + }; + } + + if (!isPositiveRayRate(body.minRate)) { + return { ok: false, error: "minRate must be a positive RAY-scaled integer string" }; + } + + return { ok: true }; +} + +export function validateTextileSubmitBody( + body: Record, +): { ok: true } | { ok: false; error: string } { + if (!isNonEmptyString(body.swapId) || !isNonEmptyString(body.txHash)) { + return { ok: false, error: "swapId and txHash are required" }; + } + return { ok: true }; +} + /** * Stable idempotency key for the same swap intent (retries replay Textile's first response). */ From 15d0b35cc70e230c16b06d01fc158193dba32ba1 Mon Sep 17 00:00:00 2001 From: sundayonah Date: Thu, 20 Aug 2026 11:08:04 +0100 Subject: [PATCH 4/4] feat(bridge): enhance Textile swap validation with new checks and corridor support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduced validation for Textile swap bodies, ensuring supported chain IDs and valid EVM addresses. - Added checks for positive sell amounts and valid token pairs for USDT↔cNGN corridors on BSC and Celo. - Updated tests to cover new validation scenarios and ensure robust error handling. --- __tests__/textileRouting.test.ts | 68 ++++++++++++++++++++++++++------ app/lib/textileNetworks.ts | 33 ++++++++++++++++ app/lib/textileServer.ts | 58 +++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 11 deletions(-) diff --git a/__tests__/textileRouting.test.ts b/__tests__/textileRouting.test.ts index 0ec8490b..8a2777e1 100644 --- a/__tests__/textileRouting.test.ts +++ b/__tests__/textileRouting.test.ts @@ -195,8 +195,17 @@ describe("normalizeTextileQuote", () => { }); describe("textileServer validation", () => { + const validBscSwap = { + chainId: 56, + sellToken: "0x55d398326f99059ff775485246999027b3197955", + buyToken: "0xa8aea66b361a8d53e8865c62d142167af28af058", + sellAmount: "1000000000000000000", + minRate: "1000000000000000000000000000", + taker: "0x0000000000000000000000000000000000000001", + }; + it("parseJsonObjectBody rejects null and arrays", () => { - const { parseJsonObjectBody, validateTextileSwapBody } = require("../app/lib/textileServer"); + const { parseJsonObjectBody } = require("../app/lib/textileServer"); expect(parseJsonObjectBody(null).ok).toBe(false); expect(parseJsonObjectBody([]).ok).toBe(false); @@ -208,23 +217,60 @@ describe("textileServer validation", () => { expect( validateTextileSwapBody({ - chainId: 56, - sellToken: "0xabc", - buyToken: "0xdef", - sellAmount: "1", + ...validBscSwap, minRate: "0", - taker: "0x123", }).ok, ).toBe(false); + expect(validateTextileSwapBody(validBscSwap).ok).toBe(true); + }); + + it("validateTextileSwapBody rejects unsupported chainId", () => { + const { validateTextileSwapBody } = require("../app/lib/textileServer"); + + expect( + validateTextileSwapBody({ ...validBscSwap, chainId: 8453 }).ok, + ).toBe(false); + }); + + it("validateTextileSwapBody rejects non-positive sellAmount", () => { + const { validateTextileSwapBody } = require("../app/lib/textileServer"); + + expect( + validateTextileSwapBody({ ...validBscSwap, sellAmount: "0" }).ok, + ).toBe(false); + }); + + it("validateTextileSwapBody rejects invalid addresses", () => { + const { validateTextileSwapBody } = require("../app/lib/textileServer"); + + expect( + validateTextileSwapBody({ ...validBscSwap, taker: "not-an-address" }).ok, + ).toBe(false); + }); + + it("validateTextileSwapBody rejects unsupported token pairs", () => { + const { validateTextileSwapBody } = require("../app/lib/textileServer"); + + expect( + validateTextileSwapBody({ + ...validBscSwap, + buyToken: "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d", + }).ok, + ).toBe(false); + }); + + it("validateTextileSwapBody accepts Celo USDT to cNGN", () => { + const { validateTextileSwapBody } = require("../app/lib/textileServer"); + expect( validateTextileSwapBody({ - chainId: 56, - sellToken: "0xabc", - buyToken: "0xdef", - sellAmount: "1", + chainId: 42220, + sellToken: "0x48065fbBE25f71C9282ddf5e1cD6D6A887483D5e", + buyToken: "0xF6829D7393dAe24509eb1E52eE8e572e2E271a4f", + sellAmount: "1000000", minRate: "1000000000000000000000000000", - taker: "0x123", + taker: "0x0000000000000000000000000000000000000001", }).ok, ).toBe(true); }); diff --git a/app/lib/textileNetworks.ts b/app/lib/textileNetworks.ts index 4e05647d..2c6c8dfd 100644 --- a/app/lib/textileNetworks.ts +++ b/app/lib/textileNetworks.ts @@ -14,6 +14,39 @@ export const TEXTILE_SUPPORTED_NETWORKS = new Set( Object.keys(TEXTILE_NETWORK_CONFIG), ); +export const TEXTILE_SUPPORTED_CHAIN_IDS = new Set([56, 42220]); + +/** Live Textile USDT↔cNGN token addresses per chain. */ +export const TEXTILE_CORRIDOR_TOKENS: Record< + number, + { usdt: `0x${string}`; cngn: `0x${string}` } +> = { + 56: { + usdt: "0x55d398326f99059ff775485246999027b3197955", + cngn: "0xa8aea66b361a8d53e8865c62d142167af28af058", + }, + 42220: { + usdt: "0x48065fbBE25f71C9282ddf5e1cD6D6A887483D5e", + cngn: "0xF6829D7393dAe24509eb1E52eE8e572e2E271a4f", + }, +}; + +export function isTextileCorridorPair( + chainId: number, + sellToken: string, + buyToken: string, +): boolean { + const tokens = TEXTILE_CORRIDOR_TOKENS[chainId]; + if (!tokens) return false; + + const sell = sellToken.toLowerCase(); + const buy = buyToken.toLowerCase(); + const usdt = tokens.usdt.toLowerCase(); + const cngn = tokens.cngn.toLowerCase(); + + return (sell === usdt && buy === cngn) || (sell === cngn && buy === usdt); +} + export function textileChainId(networkName: string): number | null { const cfg = TEXTILE_NETWORK_CONFIG[networkName as TextileNetworkName]; diff --git a/app/lib/textileServer.ts b/app/lib/textileServer.ts index d1a48f5d..2725f409 100644 --- a/app/lib/textileServer.ts +++ b/app/lib/textileServer.ts @@ -1,5 +1,10 @@ /** Shared Textile FX upstream helpers for server proxy routes. */ +import { + isTextileCorridorPair, + TEXTILE_SUPPORTED_CHAIN_IDS, +} from "./textileNetworks"; + export const TEXTILE_API_BASE = "https://api.textilecredit.com/v1"; export const TEXTILE_UPSTREAM_TIMEOUT_MS = 15_000; @@ -48,6 +53,28 @@ function isNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } +function isEvmAddress(value: string): boolean { + return /^0x[0-9a-fA-F]{40}$/.test(value); +} + +function parseChainId(value: unknown): number | null { + if (typeof value === "number" && Number.isInteger(value)) return value; + if (typeof value === "string" && /^\d+$/.test(value.trim())) { + const parsed = Number(value); + return Number.isInteger(parsed) ? parsed : null; + } + return null; +} + +function isPositiveAtomicAmount(value: unknown): boolean { + if (typeof value !== "string" || value.trim().length === 0) return false; + try { + return BigInt(value) > BigInt(0); + } catch { + return false; + } +} + export function validateTextileSwapBody( body: Record, ): { ok: true } | { ok: false; error: string } { @@ -67,6 +94,37 @@ export function validateTextileSwapBody( }; } + const chainId = parseChainId(body.chainId); + if (chainId === null || !TEXTILE_SUPPORTED_CHAIN_IDS.has(chainId)) { + return { + ok: false, + error: "chainId must be a supported Textile corridor (56 or 42220)", + }; + } + + const sellToken = body.sellToken as string; + const buyToken = body.buyToken as string; + const taker = body.taker as string; + + if (!isEvmAddress(sellToken) || !isEvmAddress(buyToken) || !isEvmAddress(taker)) { + return { ok: false, error: "sellToken, buyToken, and taker must be valid EVM addresses" }; + } + + if (sellToken.toLowerCase() === buyToken.toLowerCase()) { + return { ok: false, error: "sellToken and buyToken must differ" }; + } + + if (!isTextileCorridorPair(chainId, sellToken, buyToken)) { + return { + ok: false, + error: "Token pair is not a supported Textile USDT↔cNGN corridor on this chain", + }; + } + + if (!isPositiveAtomicAmount(body.sellAmount)) { + return { ok: false, error: "sellAmount must be a positive atomic amount string" }; + } + if (!isPositiveRayRate(body.minRate)) { return { ok: false, error: "minRate must be a positive RAY-scaled integer string" }; }