Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
277 changes: 277 additions & 0 deletions __tests__/textileRouting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
/// <reference types="jest" />

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);
});
});
74 changes: 74 additions & 0 deletions app/api/bridge/textile/quote/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
);
}
});
Loading
Loading