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..8a2777e1
--- /dev/null
+++ b/__tests__/textileRouting.test.ts
@@ -0,0 +1,277 @@
+///
+
+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");
+ });
+
+ 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();
+ });
+});
+
+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 } = 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({
+ ...validBscSwap,
+ minRate: "0",
+ }).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: 42220,
+ sellToken: "0x48065fbBE25f71C9282ddf5e1cD6D6A887483D5e",
+ buyToken: "0xF6829D7393dAe24509eb1E52eE8e572e2E271a4f",
+ sellAmount: "1000000",
+ minRate: "1000000000000000000000000000",
+ taker: "0x0000000000000000000000000000000000000001",
+ }).ok,
+ ).toBe(true);
+ });
+});
diff --git a/app/api/bridge/textile/quote/route.ts b/app/api/bridge/textile/quote/route.ts
new file mode 100644
index 00000000..1056d8a6
--- /dev/null
+++ b/app/api/bridge/textile/quote/route.ts
@@ -0,0 +1,74 @@
+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,
+ isPositiveRayRate,
+} 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 && isPositiveRayRate(String(quote.effectiveRateRay))) {
+ const minRateRay = minRateRayFromEffective(
+ String(quote.effectiveRateRay),
+ slippageBps,
+ );
+ if (isPositiveRayRate(minRateRay)) {
+ quote.minRateRay = minRateRay;
+ }
+ }
+
+ 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..fccad144
--- /dev/null
+++ b/app/api/bridge/textile/submit/route.ts
@@ -0,0 +1,71 @@
+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,
+ parseJsonObjectBody,
+ validateTextileSubmitBody,
+} from "@/app/lib/textileServer";
+
+export const POST = withRateLimit(async (request: NextRequest) => {
+ const startTime = Date.now();
+ try {
+ let parsed: unknown;
+ try {
+ parsed = await request.json();
+ } catch {
+ return NextResponse.json({ error: "Invalid JSON body" }, { 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", {
+ swap_id: body.swapId,
+ });
+
+ const { data, status } = await axios.post(
+ `${TEXTILE_API_BASE}/swaps/${encodeURIComponent(body.swapId as string)}/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..f1c60197
--- /dev/null
+++ b/app/api/bridge/textile/swap/route.ts
@@ -0,0 +1,86 @@
+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,
+ parseJsonObjectBody,
+ validateTextileSwapBody,
+} from "@/app/lib/textileServer";
+
+export const POST = withRateLimit(async (request: NextRequest) => {
+ const startTime = Date.now();
+ try {
+ let parsed: unknown;
+ try {
+ parsed = await request.json();
+ } catch {
+ return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
+ }
+
+ const objectBody = parseJsonObjectBody(parsed);
+ if (!objectBody.ok) {
+ return NextResponse.json({ error: objectBody.error }, { 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;
+
+ 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..16e856ea 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;
}
@@ -17,7 +17,7 @@ export const BridgeQuoteCard: React.FC = ({
quote,
isLoading,
error,
- engine,
+ engine: _engine,
toToken,
onExpire,
}) => {
@@ -63,7 +63,12 @@ export const BridgeQuoteCard: React.FC = ({
if (!quote) return null;
- const engineLabel = engine === "near" ? "NEAR Intents" : "LI.FI";
+ const engineLabel =
+ quote.kind === "near-deposit"
+ ? "NEAR Intents"
+ : quote.kind === "textile-swap"
+ ? "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..70e366bc 100644
--- a/app/hooks/bridge.ts
+++ b/app/hooks/bridge.ts
@@ -7,6 +7,8 @@ import {
selectEngine,
NearIntentsClient,
LifiClient,
+ TextileClient,
+ textileIdempotencyKey,
toLifiChainId,
resolveNearAssetId,
toRawAmount,
@@ -15,6 +17,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 +27,7 @@ const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
const nearClient = new NearIntentsClient();
const lifiClient = new LifiClient();
+const textileClient = new TextileClient();
// ============================================================================
// useBridgeQuote
@@ -69,6 +73,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 +183,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 +219,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 +278,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") {
@@ -316,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
@@ -565,6 +622,122 @@ 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 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,
+ sellToken: textileQuote.sellToken,
+ buyToken: textileQuote.buyToken,
+ sellAmount: textileQuote.sellAmount,
+ minRate: textileQuote.minRateRay,
+ taker,
+ idempotencyKey: idempotencyKey!,
+ requireFullFill: textileQuote.fullyFilled,
+ },
+ proxyAuth,
+ );
+ if (!built) {
+ throw new Error("Textile swap unavailable. Please try again.");
+ }
+
+ const calls: BatchCall[] = [];
+ 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: from.tokenAddress as `0x${string}`,
+ value: BigInt(0),
+ data: encodeFunctionData({
+ abi: erc20Abi,
+ functionName: "approve",
+ args: [built.approvalSpender, allowance],
+ }),
+ });
+ }
+ 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,
+ });
+ }
+
+ 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);
+ 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..792e02b0 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";
@@ -13,6 +13,12 @@ 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 {
+ isPositiveRayRate,
+ minRateRayFromEffective,
+ textileIdempotencyKey,
+} from "./textileServer";
// ============================================================================
// TYPES
@@ -69,7 +75,39 @@ 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;
+ /** 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 =
| "PENDING_DEPOSIT"
@@ -85,18 +123,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 +263,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 +505,255 @@ 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 {
+ const fillable = BigInt(fillableAmount);
+ const requested = BigInt(params.sellAmount);
+ if (fillable <= BigInt(0) || fillable > requested) 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");
+ if (!isPositiveRayRate(effectiveRateRay)) return null;
+
+ const slippageBps = Math.max(params.slippageBps, 200);
+ const minRateRay = minRateRayFromEffective(effectiveRateRay, slippageBps);
+ if (!isPositiveRayRate(minRateRay)) return null;
+
+ 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,
+ };
+}
+
+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: {
+ 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;
+
+ 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,
+ };
+ }
+
+ async submitSwap(
+ swapId: string,
+ txHash: string,
+ auth?: BridgeAuth | string | null,
+ ): 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(
+ 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..2c6c8dfd
--- /dev/null
+++ b/app/lib/textileNetworks.ts
@@ -0,0 +1,54 @@
+/**
+ * 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 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];
+ return cfg?.chainId ?? null;
+}
diff --git a/app/lib/textileServer.ts b/app/lib/textileServer.ts
new file mode 100644
index 00000000..2725f409
--- /dev/null
+++ b/app/lib/textileServer.ts
@@ -0,0 +1,163 @@
+/** 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;
+
+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";
+ }
+}
+
+/** True when a RAY-scaled rate string is a positive integer. */
+export function isPositiveRayRate(value: unknown): boolean {
+ if (typeof value !== "string" || value.trim().length === 0) return false;
+ try {
+ return BigInt(value) > BigInt(0);
+ } catch {
+ return false;
+ }
+}
+
+/** 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;
+}
+
+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 } {
+ 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(", ")}`,
+ };
+ }
+
+ 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" };
+ }
+
+ 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).
+ */
+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(":");
+}
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: [
{