From b0f463b3858cc8c5012e5211d9d6752f9082e8f3 Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Thu, 12 Mar 2026 13:53:10 +0900 Subject: [PATCH 1/6] Add Squid provider and integrate into API Introduce a Squid integration: add provider, client, and model files to packages/swapper/src/squid, plus unit and optional live integration tests. Export the new squid module from the swapper package and wire SquidProvider into the apps/api provider list, using a new SQUID_INTEGRATOR_ID env var (added to .env.example). The SquidProvider maps Cosmos chains/assets to Squid identifiers and uses fetchRoute to obtain quotes and transaction data. --- .env.example | 1 + apps/api/src/index.ts | 2 + packages/swapper/src/index.ts | 1 + packages/swapper/src/squid/client.ts | 30 ++++ packages/swapper/src/squid/index.ts | 3 + .../swapper/src/squid/integration.test.ts | 59 +++++++ packages/swapper/src/squid/model.ts | 39 +++++ packages/swapper/src/squid/provider.test.ts | 151 ++++++++++++++++++ packages/swapper/src/squid/provider.ts | 101 ++++++++++++ 9 files changed, 387 insertions(+) create mode 100644 packages/swapper/src/squid/client.ts create mode 100644 packages/swapper/src/squid/index.ts create mode 100644 packages/swapper/src/squid/integration.test.ts create mode 100644 packages/swapper/src/squid/model.ts create mode 100644 packages/swapper/src/squid/provider.test.ts create mode 100644 packages/swapper/src/squid/provider.ts diff --git a/.env.example b/.env.example index ff536b3..4b71409 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,4 @@ OKX_API_KEY= OKX_SECRET_KEY= OKX_API_PASSPHRASE= OKX_PROJECT_ID= +SQUID_INTEGRATOR_ID= diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index a4e8bae..b469f0c 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -9,6 +9,7 @@ import { OrcaWhirlpoolProvider, PanoraProvider, OkxProvider, + SquidProvider, SwapperException, } from "@gemwallet/swapper"; import { Quote, QuoteRequest, SwapQuoteData } from "@gemwallet/types"; @@ -43,6 +44,7 @@ const providers: Record = { orca: new OrcaWhirlpoolProvider(solanaRpc), panora: new PanoraProvider(), okx: new OkxProvider(solanaRpc), + squid: new SquidProvider(process.env.SQUID_INTEGRATOR_ID || ""), }; app.get("/", (_, res) => { diff --git a/packages/swapper/src/index.ts b/packages/swapper/src/index.ts index e617e78..51f8a79 100644 --- a/packages/swapper/src/index.ts +++ b/packages/swapper/src/index.ts @@ -7,4 +7,5 @@ export * from "./referrer"; export * from "./orca"; export * from "./panora"; export * from "./okx"; +export * from "./squid"; export * from "./error"; diff --git a/packages/swapper/src/squid/client.ts b/packages/swapper/src/squid/client.ts new file mode 100644 index 0000000..6ec75dd --- /dev/null +++ b/packages/swapper/src/squid/client.ts @@ -0,0 +1,30 @@ +import type { SquidRouteRequest, SquidRouteResponse, SquidErrorResponse } from "./model"; + +const SQUID_API_BASE_URL = "https://v2.api.squidrouter.com"; + +export async function fetchRoute( + params: SquidRouteRequest, + integratorId: string, +): Promise { + const response = await fetch(`${SQUID_API_BASE_URL}/v2/route`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-integrator-id": integratorId, + }, + body: JSON.stringify(params), + }); + + if (!response.ok) { + let detail: string; + try { + const errorBody = (await response.json()) as SquidErrorResponse; + detail = errorBody.errors?.[0]?.message || errorBody.message || response.statusText; + } catch { + detail = await response.text(); + } + throw new Error(`Squid API error ${response.status}: ${detail}`); + } + + return (await response.json()) as SquidRouteResponse; +} diff --git a/packages/swapper/src/squid/index.ts b/packages/swapper/src/squid/index.ts new file mode 100644 index 0000000..eed8486 --- /dev/null +++ b/packages/swapper/src/squid/index.ts @@ -0,0 +1,3 @@ +export * from "./model"; +export * from "./client"; +export * from "./provider"; diff --git a/packages/swapper/src/squid/integration.test.ts b/packages/swapper/src/squid/integration.test.ts new file mode 100644 index 0000000..7e7b85c --- /dev/null +++ b/packages/swapper/src/squid/integration.test.ts @@ -0,0 +1,59 @@ +// eslint-disable-next-line @typescript-eslint/no-require-imports +require("dotenv").config({ path: "../../.env" }); + +import { Chain, QuoteRequest } from "@gemwallet/types"; + +import { BigIntMath } from "../bigint_math"; +import { timedPromise } from "../debug"; + +const runIntegration = process.env.INTEGRATION_TEST === "1"; +const describeIntegration = runIntegration ? describe : describe.skip; + +const OSMOSIS_ADDRESS = "osmo1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu"; +const COSMOS_ADDRESS = "cosmos1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5z054l"; + +const OSMO_TO_ATOM_REQUEST: QuoteRequest = { + from_address: OSMOSIS_ADDRESS, + to_address: COSMOS_ADDRESS, + from_asset: { + id: Chain.Osmosis, + symbol: "OSMO", + decimals: 6, + }, + to_asset: { + id: Chain.Cosmos, + symbol: "ATOM", + decimals: 6, + }, + from_value: "10000000", + referral_bps: 0, + slippage_bps: 100, +}; + +describeIntegration("Squid live integration", () => { + jest.setTimeout(60_000); + + let provider: import("./provider").SquidProvider; + + beforeAll(async () => { + const { SquidProvider } = await import("./provider"); + provider = new SquidProvider(process.env.SQUID_INTEGRATOR_ID || ""); + }); + + it("fetches a live quote for 10 OSMO -> ATOM", async () => { + const quote = await timedPromise("squid quote OSMO->ATOM", provider.get_quote(OSMO_TO_ATOM_REQUEST)); + + expect(BigInt(quote.output_value) > 0).toBe(true); + expect(quote.output_value).toMatch(/^\d+$/); + expect(quote.output_min_value).toMatch(/^\d+$/); + expect(quote.eta_in_seconds).toBeGreaterThan(0); + + const outputValue = BigIntMath.formatDecimals(quote.output_value, 6); + console.log("Squid 10 OSMO -> ATOM output:", outputValue); + console.log("Squid ETA:", quote.eta_in_seconds, "seconds"); + + const routeData = quote.route_data as { estimate: { gasCosts: object[]; actions: object[] } }; + expect(routeData.estimate.gasCosts.length).toBeGreaterThan(0); + expect(routeData.estimate.actions.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/swapper/src/squid/model.ts b/packages/swapper/src/squid/model.ts new file mode 100644 index 0000000..2f5048d --- /dev/null +++ b/packages/swapper/src/squid/model.ts @@ -0,0 +1,39 @@ +export interface SquidRouteRequest { + fromChain: string; + toChain: string; + fromToken: string; + toToken: string; + fromAmount: string; + fromAddress: string; + toAddress: string; + slippageConfig: { + autoMode: number; + }; + quoteOnly?: boolean; +} + +export interface SquidEstimate { + toAmount: string; + toAmountMin: string; + estimatedRouteDuration: number; +} + +export interface SquidTransactionRequest { + target: string; + data: string; + value: string; + gasLimit: string; + gasPrice: string; +} + +export interface SquidRouteResponse { + route: { + estimate: SquidEstimate; + transactionRequest: SquidTransactionRequest; + }; +} + +export interface SquidErrorResponse { + errors?: { message: string }[]; + message?: string; +} diff --git a/packages/swapper/src/squid/provider.test.ts b/packages/swapper/src/squid/provider.test.ts new file mode 100644 index 0000000..7215c08 --- /dev/null +++ b/packages/swapper/src/squid/provider.test.ts @@ -0,0 +1,151 @@ +import { Chain, QuoteRequest } from "@gemwallet/types"; + +import { createQuoteRequest } from "../testkit/mock"; +import { SquidProvider } from "./provider"; + +const COSMOS_TEST_ADDRESS = "cosmos1qwerty12345test"; +const OSMOSIS_TEST_ADDRESS = "osmo1qwerty12345test"; + +const SQUID_COSMOS_QUOTE_REQUEST: QuoteRequest = { + from_address: OSMOSIS_TEST_ADDRESS, + to_address: COSMOS_TEST_ADDRESS, + from_asset: { + id: Chain.Osmosis, + symbol: "OSMO", + decimals: 6, + }, + to_asset: { + id: Chain.Cosmos, + symbol: "ATOM", + decimals: 6, + }, + from_value: "1000000", + referral_bps: 0, + slippage_bps: 100, +}; + +describe("SquidProvider", () => { + const provider = new SquidProvider("test-integrator"); + + describe("mapChainToSquidChainId", () => { + it("maps supported Cosmos chains", () => { + expect(provider.mapChainToSquidChainId(Chain.Cosmos)).toBe("cosmoshub-4"); + expect(provider.mapChainToSquidChainId(Chain.Osmosis)).toBe("osmosis-1"); + expect(provider.mapChainToSquidChainId(Chain.Celestia)).toBe("celestia"); + expect(provider.mapChainToSquidChainId(Chain.Injective)).toBe("injective-1"); + expect(provider.mapChainToSquidChainId(Chain.Sei)).toBe("pacific-1"); + expect(provider.mapChainToSquidChainId(Chain.Noble)).toBe("noble-1"); + }); + + it("throws for unsupported chains", () => { + expect(() => provider.mapChainToSquidChainId(Chain.Solana)).toThrow(); + }); + }); + + describe("mapAssetToSquidToken", () => { + it("maps native Cosmos assets to denoms", () => { + const { AssetId } = require("@gemwallet/types"); + expect(provider.mapAssetToSquidToken(AssetId.fromString("cosmos"))).toBe("uatom"); + expect(provider.mapAssetToSquidToken(AssetId.fromString("osmosis"))).toBe("uosmo"); + expect(provider.mapAssetToSquidToken(AssetId.fromString("celestia"))).toBe("utia"); + expect(provider.mapAssetToSquidToken(AssetId.fromString("noble"))).toBe("uusdc"); + }); + + it("returns tokenId for non-native assets", () => { + const { AssetId } = require("@gemwallet/types"); + const ibc = "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2"; + expect(provider.mapAssetToSquidToken(AssetId.fromString(`osmosis_${ibc}`))).toBe(ibc); + }); + }); + + describe("get_quote", () => { + it("constructs a valid quote request", async () => { + const mockRoute = { + route: { + estimate: { + toAmount: "500000", + toAmountMin: "495000", + estimatedRouteDuration: 60, + }, + transactionRequest: {}, + params: {}, + }, + }; + + const fetchSpy = jest.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + json: async () => mockRoute, + } as Response); + + const request = createQuoteRequest(SQUID_COSMOS_QUOTE_REQUEST); + const quote = await provider.get_quote(request); + + expect(quote.output_value).toBe("500000"); + expect(quote.output_min_value).toBe("495000"); + expect(quote.eta_in_seconds).toBe(60); + + const body = JSON.parse(fetchSpy.mock.calls[0][1]!.body as string); + expect(body.fromChain).toBe("osmosis-1"); + expect(body.toChain).toBe("cosmoshub-4"); + expect(body.fromToken).toBe("uosmo"); + expect(body.toToken).toBe("uatom"); + expect(body.quoteOnly).toBe(true); + + fetchSpy.mockRestore(); + }); + }); + + describe("get_quote_data", () => { + it("returns cosmos transaction data", async () => { + const cosmosMsg = JSON.stringify({ + typeUrl: "/cosmwasm.wasm.v1.MsgExecuteContract", + value: { + sender: OSMOSIS_TEST_ADDRESS, + contract: "osmo1squid_multicall_contract", + msg: "{}", + funds: [{ denom: "uosmo", amount: "1000000" }], + }, + }); + + const mockRoute = { + route: { + estimate: { + toAmount: "500000", + toAmountMin: "495000", + estimatedRouteDuration: 60, + }, + transactionRequest: { + routeType: "COSMOS_ONLY", + target: "", + data: cosmosMsg, + value: "0", + gasLimit: "500000", + gasPrice: "0.025uosmo", + }, + params: {}, + }, + }; + + jest.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + json: async () => mockRoute, + } as Response); + + const quote = { + quote: createQuoteRequest(SQUID_COSMOS_QUOTE_REQUEST), + output_value: "500000", + output_min_value: "495000", + route_data: {}, + eta_in_seconds: 60, + }; + + const data = await provider.get_quote_data(quote); + + expect(data.data).toBe(cosmosMsg); + expect(data.gasLimit).toBe("500000"); + expect(data.dataType).toBe("contract"); + + jest.restoreAllMocks(); + }); + }); +}); diff --git a/packages/swapper/src/squid/provider.ts b/packages/swapper/src/squid/provider.ts new file mode 100644 index 0000000..587980f --- /dev/null +++ b/packages/swapper/src/squid/provider.ts @@ -0,0 +1,101 @@ +import { QuoteRequest, Quote, SwapQuoteData, AssetId, Chain, SwapQuoteDataType } from "@gemwallet/types"; + +import { SwapperException } from "../error"; +import { Protocol } from "../protocol"; +import { fetchRoute } from "./client"; +import { SquidRouteRequest } from "./model"; + +export class SquidProvider implements Protocol { + private integratorId: string; + + constructor(integratorId: string) { + this.integratorId = integratorId; + } + + mapChainToSquidChainId(chain: Chain): string { + switch (chain) { + case Chain.Cosmos: + return "cosmoshub-4"; + case Chain.Osmosis: + return "osmosis-1"; + case Chain.Celestia: + return "celestia"; + case Chain.Injective: + return "injective-1"; + case Chain.Sei: + return "pacific-1"; + case Chain.Noble: + return "noble-1"; + default: + throw new SwapperException({ type: "not_supported_chain" }); + } + } + + mapAssetToSquidToken(assetId: AssetId): string { + if (assetId.isNative()) { + switch (assetId.chain) { + case Chain.Cosmos: + return "uatom"; + case Chain.Osmosis: + return "uosmo"; + case Chain.Celestia: + return "utia"; + case Chain.Injective: + return "inj"; + case Chain.Sei: + return "usei"; + case Chain.Noble: + return "uusdc"; + default: + throw new SwapperException({ type: "not_supported_asset" }); + } + } + return assetId.getTokenId(); + } + + private buildRouteRequest(quoteRequest: QuoteRequest, quoteOnly: boolean): SquidRouteRequest { + const fromAsset = AssetId.fromString(quoteRequest.from_asset.id); + const toAsset = AssetId.fromString(quoteRequest.to_asset.id); + + return { + fromChain: this.mapChainToSquidChainId(fromAsset.chain), + toChain: this.mapChainToSquidChainId(toAsset.chain), + fromToken: this.mapAssetToSquidToken(fromAsset), + toToken: this.mapAssetToSquidToken(toAsset), + fromAmount: quoteRequest.from_value, + fromAddress: quoteRequest.from_address, + toAddress: quoteRequest.to_address, + slippageConfig: { + autoMode: 1, + }, + quoteOnly, + }; + } + + async get_quote(quoteRequest: QuoteRequest): Promise { + const params = this.buildRouteRequest(quoteRequest, true); + const { route } = await fetchRoute(params, this.integratorId); + + return { + quote: quoteRequest, + output_value: route.estimate.toAmount, + output_min_value: route.estimate.toAmountMin, + route_data: {}, + eta_in_seconds: route.estimate.estimatedRouteDuration, + }; + } + + async get_quote_data(quote: Quote): Promise { + const params = this.buildRouteRequest(quote.quote, false); + const { route } = await fetchRoute(params, this.integratorId); + const tx = route.transactionRequest; + + return { + to: tx.target, + value: tx.value, + data: tx.data, + dataType: SwapQuoteDataType.Contract, + gasLimit: tx.gasLimit, + }; + } +} From 556628cc8c48e4643583545c96c66397f0b7cc63 Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Sun, 15 Mar 2026 11:03:35 +0900 Subject: [PATCH 2/6] Squid: require integrator ID and robust client --- apps/api/src/index.ts | 2 +- packages/swapper/src/okx/constants.ts | 2 +- packages/swapper/src/squid/client.ts | 25 +++++++++++++------ .../swapper/src/squid/integration.test.ts | 13 +++++++--- packages/swapper/src/squid/model.ts | 10 +++++--- packages/swapper/src/squid/provider.test.ts | 24 +++++++++--------- packages/swapper/src/squid/provider.ts | 21 ++++++++++------ 7 files changed, 60 insertions(+), 37 deletions(-) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index b469f0c..9f94185 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -44,7 +44,7 @@ const providers: Record = { orca: new OrcaWhirlpoolProvider(solanaRpc), panora: new PanoraProvider(), okx: new OkxProvider(solanaRpc), - squid: new SquidProvider(process.env.SQUID_INTEGRATOR_ID || ""), + squid: new SquidProvider(), }; app.get("/", (_, res) => { diff --git a/packages/swapper/src/okx/constants.ts b/packages/swapper/src/okx/constants.ts index e2a4526..1535f80 100644 --- a/packages/swapper/src/okx/constants.ts +++ b/packages/swapper/src/okx/constants.ts @@ -32,7 +32,7 @@ export const SOLANA_NATIVE_TOKEN_ADDRESS = "11111111111111111111111111111111"; export const SOLANA_DEX_IDS_PARAM = SOLANA_DEX_IDS.join(","); export const DEFAULT_SLIPPAGE_PERCENT = "1"; const EVM_GAS_LIMITS: Partial> = { - [Chain.Base]: "800000", + [Chain.Base]: "920000", [Chain.Manta]: "600000", [Chain.Mantle]: "2000000000", [Chain.XLayer]: "800000", diff --git a/packages/swapper/src/squid/client.ts b/packages/swapper/src/squid/client.ts index 6ec75dd..447fd9e 100644 --- a/packages/swapper/src/squid/client.ts +++ b/packages/swapper/src/squid/client.ts @@ -1,11 +1,9 @@ +import { SwapperException } from "../error"; import type { SquidRouteRequest, SquidRouteResponse, SquidErrorResponse } from "./model"; const SQUID_API_BASE_URL = "https://v2.api.squidrouter.com"; -export async function fetchRoute( - params: SquidRouteRequest, - integratorId: string, -): Promise { +export async function fetchRoute(params: SquidRouteRequest, integratorId: string): Promise { const response = await fetch(`${SQUID_API_BASE_URL}/v2/route`, { method: "POST", headers: { @@ -14,17 +12,28 @@ export async function fetchRoute( }, body: JSON.stringify(params), }); + const responseText = await response.text(); if (!response.ok) { let detail: string; try { - const errorBody = (await response.json()) as SquidErrorResponse; + const errorBody = JSON.parse(responseText) as SquidErrorResponse; detail = errorBody.errors?.[0]?.message || errorBody.message || response.statusText; } catch { - detail = await response.text(); + detail = responseText || response.statusText; } - throw new Error(`Squid API error ${response.status}: ${detail}`); + throw new SwapperException({ + type: "compute_quote_error", + message: `Squid API error ${response.status}: ${detail}`, + }); } - return (await response.json()) as SquidRouteResponse; + try { + return JSON.parse(responseText) as SquidRouteResponse; + } catch { + throw new SwapperException({ + type: "compute_quote_error", + message: "Squid API returned an invalid response", + }); + } } diff --git a/packages/swapper/src/squid/integration.test.ts b/packages/swapper/src/squid/integration.test.ts index 7e7b85c..030c567 100644 --- a/packages/swapper/src/squid/integration.test.ts +++ b/packages/swapper/src/squid/integration.test.ts @@ -5,6 +5,7 @@ import { Chain, QuoteRequest } from "@gemwallet/types"; import { BigIntMath } from "../bigint_math"; import { timedPromise } from "../debug"; +import type { SquidRoute } from "./model"; const runIntegration = process.env.INTEGRATION_TEST === "1"; const describeIntegration = runIntegration ? describe : describe.skip; @@ -36,8 +37,12 @@ describeIntegration("Squid live integration", () => { let provider: import("./provider").SquidProvider; beforeAll(async () => { + const integratorId = process.env.SQUID_INTEGRATOR_ID; + if (!integratorId) { + throw new Error("Missing required environment variable: SQUID_INTEGRATOR_ID"); + } const { SquidProvider } = await import("./provider"); - provider = new SquidProvider(process.env.SQUID_INTEGRATOR_ID || ""); + provider = new SquidProvider(integratorId); }); it("fetches a live quote for 10 OSMO -> ATOM", async () => { @@ -52,8 +57,8 @@ describeIntegration("Squid live integration", () => { console.log("Squid 10 OSMO -> ATOM output:", outputValue); console.log("Squid ETA:", quote.eta_in_seconds, "seconds"); - const routeData = quote.route_data as { estimate: { gasCosts: object[]; actions: object[] } }; - expect(routeData.estimate.gasCosts.length).toBeGreaterThan(0); - expect(routeData.estimate.actions.length).toBeGreaterThan(0); + const routeData = quote.route_data as SquidRoute; + expect(routeData.estimate.toAmount).toBe(quote.output_value); + expect(routeData.estimate.toAmountMin).toBe(quote.output_min_value); }); }); diff --git a/packages/swapper/src/squid/model.ts b/packages/swapper/src/squid/model.ts index 2f5048d..e19baa6 100644 --- a/packages/swapper/src/squid/model.ts +++ b/packages/swapper/src/squid/model.ts @@ -26,11 +26,13 @@ export interface SquidTransactionRequest { gasPrice: string; } +export interface SquidRoute { + estimate: SquidEstimate; + transactionRequest?: SquidTransactionRequest; +} + export interface SquidRouteResponse { - route: { - estimate: SquidEstimate; - transactionRequest: SquidTransactionRequest; - }; + route: SquidRoute; } export interface SquidErrorResponse { diff --git a/packages/swapper/src/squid/provider.test.ts b/packages/swapper/src/squid/provider.test.ts index 7215c08..a573294 100644 --- a/packages/swapper/src/squid/provider.test.ts +++ b/packages/swapper/src/squid/provider.test.ts @@ -1,4 +1,4 @@ -import { Chain, QuoteRequest } from "@gemwallet/types"; +import { AssetId, Chain, QuoteRequest } from "@gemwallet/types"; import { createQuoteRequest } from "../testkit/mock"; import { SquidProvider } from "./provider"; @@ -27,6 +27,10 @@ const SQUID_COSMOS_QUOTE_REQUEST: QuoteRequest = { describe("SquidProvider", () => { const provider = new SquidProvider("test-integrator"); + afterEach(() => { + jest.restoreAllMocks(); + }); + describe("mapChainToSquidChainId", () => { it("maps supported Cosmos chains", () => { expect(provider.mapChainToSquidChainId(Chain.Cosmos)).toBe("cosmoshub-4"); @@ -44,7 +48,6 @@ describe("SquidProvider", () => { describe("mapAssetToSquidToken", () => { it("maps native Cosmos assets to denoms", () => { - const { AssetId } = require("@gemwallet/types"); expect(provider.mapAssetToSquidToken(AssetId.fromString("cosmos"))).toBe("uatom"); expect(provider.mapAssetToSquidToken(AssetId.fromString("osmosis"))).toBe("uosmo"); expect(provider.mapAssetToSquidToken(AssetId.fromString("celestia"))).toBe("utia"); @@ -52,7 +55,6 @@ describe("SquidProvider", () => { }); it("returns tokenId for non-native assets", () => { - const { AssetId } = require("@gemwallet/types"); const ibc = "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2"; expect(provider.mapAssetToSquidToken(AssetId.fromString(`osmosis_${ibc}`))).toBe(ibc); }); @@ -74,7 +76,7 @@ describe("SquidProvider", () => { const fetchSpy = jest.spyOn(global, "fetch").mockResolvedValueOnce({ ok: true, - json: async () => mockRoute, + text: async () => JSON.stringify(mockRoute), } as Response); const request = createQuoteRequest(SQUID_COSMOS_QUOTE_REQUEST); @@ -83,6 +85,7 @@ describe("SquidProvider", () => { expect(quote.output_value).toBe("500000"); expect(quote.output_min_value).toBe("495000"); expect(quote.eta_in_seconds).toBe(60); + expect(quote.route_data).toEqual(mockRoute.route); const body = JSON.parse(fetchSpy.mock.calls[0][1]!.body as string); expect(body.fromChain).toBe("osmosis-1"); @@ -90,13 +93,11 @@ describe("SquidProvider", () => { expect(body.fromToken).toBe("uosmo"); expect(body.toToken).toBe("uatom"); expect(body.quoteOnly).toBe(true); - - fetchSpy.mockRestore(); }); }); describe("get_quote_data", () => { - it("returns cosmos transaction data", async () => { + it("fetches and returns cosmos transaction data", async () => { const cosmosMsg = JSON.stringify({ typeUrl: "/cosmwasm.wasm.v1.MsgExecuteContract", value: { @@ -126,16 +127,16 @@ describe("SquidProvider", () => { }, }; - jest.spyOn(global, "fetch").mockResolvedValueOnce({ + const fetchSpy = jest.spyOn(global, "fetch").mockResolvedValueOnce({ ok: true, - json: async () => mockRoute, + text: async () => JSON.stringify(mockRoute), } as Response); const quote = { quote: createQuoteRequest(SQUID_COSMOS_QUOTE_REQUEST), output_value: "500000", output_min_value: "495000", - route_data: {}, + route_data: mockRoute.route, eta_in_seconds: 60, }; @@ -144,8 +145,7 @@ describe("SquidProvider", () => { expect(data.data).toBe(cosmosMsg); expect(data.gasLimit).toBe("500000"); expect(data.dataType).toBe("contract"); - - jest.restoreAllMocks(); + expect(fetchSpy).toHaveBeenCalledTimes(1); }); }); }); diff --git a/packages/swapper/src/squid/provider.ts b/packages/swapper/src/squid/provider.ts index 587980f..5b41b1e 100644 --- a/packages/swapper/src/squid/provider.ts +++ b/packages/swapper/src/squid/provider.ts @@ -3,13 +3,17 @@ import { QuoteRequest, Quote, SwapQuoteData, AssetId, Chain, SwapQuoteDataType } import { SwapperException } from "../error"; import { Protocol } from "../protocol"; import { fetchRoute } from "./client"; -import { SquidRouteRequest } from "./model"; +import type { SquidRouteRequest } from "./model"; export class SquidProvider implements Protocol { - private integratorId: string; + private readonly integratorId: string; - constructor(integratorId: string) { - this.integratorId = integratorId; + constructor(integratorId?: string) { + const resolvedIntegratorId = integratorId ?? process.env.SQUID_INTEGRATOR_ID; + if (!resolvedIntegratorId) { + throw new Error("Squid integrator ID is required"); + } + this.integratorId = resolvedIntegratorId; } mapChainToSquidChainId(chain: Chain): string { @@ -80,16 +84,19 @@ export class SquidProvider implements Protocol { quote: quoteRequest, output_value: route.estimate.toAmount, output_min_value: route.estimate.toAmountMin, - route_data: {}, + route_data: route, eta_in_seconds: route.estimate.estimatedRouteDuration, }; } async get_quote_data(quote: Quote): Promise { - const params = this.buildRouteRequest(quote.quote, false); - const { route } = await fetchRoute(params, this.integratorId); + const { route } = await fetchRoute(this.buildRouteRequest(quote.quote, false), this.integratorId); const tx = route.transactionRequest; + if (!tx) { + throw new SwapperException({ type: "invalid_route" }); + } + return { to: tx.target, value: tx.value, From 526adc6b1c3bd2e3fc3b914540046157f2ed6689 Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Sun, 15 Mar 2026 20:00:54 +0900 Subject: [PATCH 3/6] Update docker.yml --- .github/workflows/docker.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index ba61bcb..7464d1e 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -35,6 +35,7 @@ jobs: OKX_SECRET_KEY: "ci-dummy-secret" OKX_API_PASSPHRASE: "ci-dummy-passphrase" OKX_PROJECT_ID: "ci-dummy-project" + SQUID_INTEGRATOR_ID: "ci-dummy-squid-integrator" run: | docker run -d --rm --name test-container -p 3000:3000 \ -e PANORA_API_KEY \ @@ -42,6 +43,7 @@ jobs: -e OKX_SECRET_KEY \ -e OKX_API_PASSPHRASE \ -e OKX_PROJECT_ID \ + -e SQUID_INTEGRATOR_ID \ core-ts:test echo "Waiting for container to start..." sleep 4 # Adjust sleep time if needed From 84d0686b99529a31797720a6649d0338cb943536 Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Mon, 16 Mar 2026 12:14:26 +0900 Subject: [PATCH 4/6] convert protobuf long to u64 for rust --- packages/swapper/src/protobuf.ts | 25 ++++++++ .../swapper/src/squid/integration.test.ts | 50 +++++++++++++++- packages/swapper/src/squid/provider.test.ts | 59 ++++++++++++++++++- packages/swapper/src/squid/provider.ts | 5 +- 4 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 packages/swapper/src/protobuf.ts diff --git a/packages/swapper/src/protobuf.ts b/packages/swapper/src/protobuf.ts new file mode 100644 index 0000000..1d11d61 --- /dev/null +++ b/packages/swapper/src/protobuf.ts @@ -0,0 +1,25 @@ +interface LongValue { + low: number; + high: number; +} + +function isLong(v: unknown): v is LongValue { + return typeof v === "object" && v !== null && "low" in v && "high" in v; +} + +export const Long = { + toUint64(l: LongValue): string { + const low = l.low >>> 0; + const high = l.high >>> 0; + return (BigInt(high) * BigInt(0x100000000) + BigInt(low)).toString(); + }, + + deepConvert(obj: unknown): unknown { + if (isLong(obj)) return this.toUint64(obj); + if (Array.isArray(obj)) return obj.map((v) => this.deepConvert(v)); + if (typeof obj === "object" && obj !== null) { + return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, this.deepConvert(v)])); + } + return obj; + }, +}; diff --git a/packages/swapper/src/squid/integration.test.ts b/packages/swapper/src/squid/integration.test.ts index 030c567..b2a4913 100644 --- a/packages/swapper/src/squid/integration.test.ts +++ b/packages/swapper/src/squid/integration.test.ts @@ -10,8 +10,8 @@ import type { SquidRoute } from "./model"; const runIntegration = process.env.INTEGRATION_TEST === "1"; const describeIntegration = runIntegration ? describe : describe.skip; -const OSMOSIS_ADDRESS = "osmo1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu"; -const COSMOS_ADDRESS = "cosmos1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5z054l"; +const OSMOSIS_ADDRESS = "osmo1tkvyjqeq204rmrrz3w4hcrs336qahsfwn8m0ye"; +const COSMOS_ADDRESS = "cosmos1tkvyjqeq204rmrrz3w4hcrs336qahsfwmugljt"; const OSMO_TO_ATOM_REQUEST: QuoteRequest = { from_address: OSMOSIS_ADDRESS, @@ -31,6 +31,24 @@ const OSMO_TO_ATOM_REQUEST: QuoteRequest = { slippage_bps: 100, }; +const ATOM_TO_OSMO_REQUEST: QuoteRequest = { + from_address: COSMOS_ADDRESS, + to_address: OSMOSIS_ADDRESS, + from_asset: { + id: Chain.Cosmos, + symbol: "ATOM", + decimals: 6, + }, + to_asset: { + id: Chain.Osmosis, + symbol: "OSMO", + decimals: 6, + }, + from_value: "1000000", + referral_bps: 0, + slippage_bps: 100, +}; + describeIntegration("Squid live integration", () => { jest.setTimeout(60_000); @@ -61,4 +79,32 @@ describeIntegration("Squid live integration", () => { expect(routeData.estimate.toAmount).toBe(quote.output_value); expect(routeData.estimate.toAmountMin).toBe(quote.output_min_value); }); + + it("fetches quote_data for OSMO -> ATOM (MsgExecuteContract)", async () => { + const quote = await provider.get_quote(OSMO_TO_ATOM_REQUEST); + const data = await provider.get_quote_data(quote); + + expect(data.dataType).toBe("contract"); + expect(data.gasLimit).toBeTruthy(); + + const msg = JSON.parse(data.data); + expect(msg.typeUrl).toBe("/cosmwasm.wasm.v1.MsgExecuteContract"); + expect(msg.value.contract).toBeTruthy(); + expect(msg.value.sender).toBe(OSMOSIS_ADDRESS); + console.log("OSMO->ATOM data:", JSON.stringify(msg, null, 2)); + }); + + it("fetches quote_data for ATOM -> OSMO (MsgTransfer)", async () => { + const quote = await provider.get_quote(ATOM_TO_OSMO_REQUEST); + const data = await provider.get_quote_data(quote); + + expect(data.dataType).toBe("contract"); + expect(data.gasLimit).toBeTruthy(); + + const msg = JSON.parse(data.data); + expect(msg.typeUrl).toBe("/ibc.applications.transfer.v1.MsgTransfer"); + expect(msg.value.sourcePort).toBe("transfer"); + expect(typeof msg.value.timeoutTimestamp).toBe("string"); + console.log("ATOM->OSMO data:", JSON.stringify(msg, null, 2)); + }); }); diff --git a/packages/swapper/src/squid/provider.test.ts b/packages/swapper/src/squid/provider.test.ts index a573294..d09a6dc 100644 --- a/packages/swapper/src/squid/provider.test.ts +++ b/packages/swapper/src/squid/provider.test.ts @@ -1,6 +1,7 @@ import { AssetId, Chain, QuoteRequest } from "@gemwallet/types"; import { createQuoteRequest } from "../testkit/mock"; +import { Long } from "../protobuf"; import { SquidProvider } from "./provider"; const COSMOS_TEST_ADDRESS = "cosmos1qwerty12345test"; @@ -142,10 +143,66 @@ describe("SquidProvider", () => { const data = await provider.get_quote_data(quote); - expect(data.data).toBe(cosmosMsg); + expect(JSON.parse(data.data)).toEqual(JSON.parse(cosmosMsg)); expect(data.gasLimit).toBe("500000"); expect(data.dataType).toBe("contract"); expect(fetchSpy).toHaveBeenCalledTimes(1); }); + + it("normalizes Long.js timeoutTimestamp to string", async () => { + const dataWithLong = JSON.stringify({ + typeUrl: "/ibc.applications.transfer.v1.MsgTransfer", + value: { + sourcePort: "transfer", + sourceChannel: "channel-141", + token: { denom: "uatom", amount: "1000000" }, + sender: "cosmos1test", + receiver: "osmo1test", + timeoutTimestamp: { low: -72998656, high: 412955876, unsigned: false }, + memo: "", + }, + }); + + const mockRoute = { + route: { + estimate: { toAmount: "500000", toAmountMin: "495000", estimatedRouteDuration: 60 }, + transactionRequest: { target: "", data: dataWithLong, value: "0", gasLimit: "500000", gasPrice: "0.03uatom" }, + }, + }; + + jest.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + text: async () => JSON.stringify(mockRoute), + } as Response); + + const quote = { + quote: createQuoteRequest(SQUID_COSMOS_QUOTE_REQUEST), + output_value: "500000", + output_min_value: "495000", + route_data: mockRoute.route, + eta_in_seconds: 60, + }; + + const data = await provider.get_quote_data(quote); + const parsed = JSON.parse(data.data); + + expect(parsed.value.timeoutTimestamp).toBe("1773631986332999936"); + }); + }); + + describe("Long", () => { + it("converts Long.js objects to uint64 strings", () => { + expect(Long.toUint64({ low: -72998656, high: 412955876 })).toBe("1773631986332999936"); + }); + + it("leaves plain values unchanged", () => { + expect(Long.deepConvert(42)).toBe(42); + expect(Long.deepConvert("hello")).toBe("hello"); + }); + + it("recurses into nested objects", () => { + const input = { a: { low: 1, high: 0 }, b: "keep" }; + expect(Long.deepConvert(input)).toEqual({ a: "1", b: "keep" }); + }); }); }); diff --git a/packages/swapper/src/squid/provider.ts b/packages/swapper/src/squid/provider.ts index 5b41b1e..6cbae50 100644 --- a/packages/swapper/src/squid/provider.ts +++ b/packages/swapper/src/squid/provider.ts @@ -3,6 +3,7 @@ import { QuoteRequest, Quote, SwapQuoteData, AssetId, Chain, SwapQuoteDataType } import { SwapperException } from "../error"; import { Protocol } from "../protocol"; import { fetchRoute } from "./client"; +import { Long } from "../protobuf"; import type { SquidRouteRequest } from "./model"; export class SquidProvider implements Protocol { @@ -97,10 +98,12 @@ export class SquidProvider implements Protocol { throw new SwapperException({ type: "invalid_route" }); } + const data = JSON.stringify(Long.deepConvert(JSON.parse(tx.data))); + return { to: tx.target, value: tx.value, - data: tx.data, + data, dataType: SwapQuoteDataType.Contract, gasLimit: tx.gasLimit, }; From cdc803e6df698fcd38073c1d6fc93b040ece2c0c Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:30:43 +0900 Subject: [PATCH 5/6] cleanup --- AGENTS.md | 2 +- packages/swapper/src/protobuf.test.ts | 17 +++++++ .../swapper/src/squid/integration.test.ts | 50 ++---------------- packages/swapper/src/squid/provider.test.ts | 51 +++---------------- packages/swapper/src/testkit/mock.ts | 26 ++++++++++ 5 files changed, 55 insertions(+), 91 deletions(-) create mode 100644 packages/swapper/src/protobuf.test.ts diff --git a/AGENTS.md b/AGENTS.md index 1ca6e99..7451489 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ - Use shared quote fixtures from `packages/swapper/src/testkit/mock.ts`; for provider-specific scenarios, extend via overrides instead of adding new provider-local `testkit.ts` files. ## Architecture Overview -- Providers implemented today: `stonfi_v2`, `mayan`, `cetus`, `relay`, `orca`, `okx`. +- Providers implemented today: `stonfi_v2`, `mayan`, `cetus`, `relay`, `orca`, `okx`, `squid`. - API endpoints: `GET /` (providers, version), `POST /:providerId/quote`, `POST /:providerId/quote_data`. - Keep provider interface consistent for quotes and transaction building. - OKX Solana provider notes: [`packages/swapper/src/okx/README.md`](packages/swapper/src/okx/README.md). diff --git a/packages/swapper/src/protobuf.test.ts b/packages/swapper/src/protobuf.test.ts new file mode 100644 index 0000000..ba6834b --- /dev/null +++ b/packages/swapper/src/protobuf.test.ts @@ -0,0 +1,17 @@ +import { Long } from "./protobuf"; + +describe("Long", () => { + it("converts Long.js objects to uint64 strings", () => { + expect(Long.toUint64({ low: -72998656, high: 412955876 })).toBe("1773631986332999936"); + }); + + it("leaves plain values unchanged", () => { + expect(Long.deepConvert(42)).toBe(42); + expect(Long.deepConvert("hello")).toBe("hello"); + }); + + it("recurses into nested objects", () => { + const input = { a: { low: 1, high: 0 }, b: "keep" }; + expect(Long.deepConvert(input)).toEqual({ a: "1", b: "keep" }); + }); +}); diff --git a/packages/swapper/src/squid/integration.test.ts b/packages/swapper/src/squid/integration.test.ts index b2a4913..99736af 100644 --- a/packages/swapper/src/squid/integration.test.ts +++ b/packages/swapper/src/squid/integration.test.ts @@ -1,54 +1,14 @@ // eslint-disable-next-line @typescript-eslint/no-require-imports require("dotenv").config({ path: "../../.env" }); -import { Chain, QuoteRequest } from "@gemwallet/types"; - import { BigIntMath } from "../bigint_math"; import { timedPromise } from "../debug"; +import { SQUID_ATOM_TO_OSMO_REQUEST, SQUID_OSMO_TO_ATOM_REQUEST, OSMOSIS_TEST_ADDRESS } from "../testkit/mock"; import type { SquidRoute } from "./model"; const runIntegration = process.env.INTEGRATION_TEST === "1"; const describeIntegration = runIntegration ? describe : describe.skip; -const OSMOSIS_ADDRESS = "osmo1tkvyjqeq204rmrrz3w4hcrs336qahsfwn8m0ye"; -const COSMOS_ADDRESS = "cosmos1tkvyjqeq204rmrrz3w4hcrs336qahsfwmugljt"; - -const OSMO_TO_ATOM_REQUEST: QuoteRequest = { - from_address: OSMOSIS_ADDRESS, - to_address: COSMOS_ADDRESS, - from_asset: { - id: Chain.Osmosis, - symbol: "OSMO", - decimals: 6, - }, - to_asset: { - id: Chain.Cosmos, - symbol: "ATOM", - decimals: 6, - }, - from_value: "10000000", - referral_bps: 0, - slippage_bps: 100, -}; - -const ATOM_TO_OSMO_REQUEST: QuoteRequest = { - from_address: COSMOS_ADDRESS, - to_address: OSMOSIS_ADDRESS, - from_asset: { - id: Chain.Cosmos, - symbol: "ATOM", - decimals: 6, - }, - to_asset: { - id: Chain.Osmosis, - symbol: "OSMO", - decimals: 6, - }, - from_value: "1000000", - referral_bps: 0, - slippage_bps: 100, -}; - describeIntegration("Squid live integration", () => { jest.setTimeout(60_000); @@ -64,7 +24,7 @@ describeIntegration("Squid live integration", () => { }); it("fetches a live quote for 10 OSMO -> ATOM", async () => { - const quote = await timedPromise("squid quote OSMO->ATOM", provider.get_quote(OSMO_TO_ATOM_REQUEST)); + const quote = await timedPromise("squid quote OSMO->ATOM", provider.get_quote(SQUID_OSMO_TO_ATOM_REQUEST)); expect(BigInt(quote.output_value) > 0).toBe(true); expect(quote.output_value).toMatch(/^\d+$/); @@ -81,7 +41,7 @@ describeIntegration("Squid live integration", () => { }); it("fetches quote_data for OSMO -> ATOM (MsgExecuteContract)", async () => { - const quote = await provider.get_quote(OSMO_TO_ATOM_REQUEST); + const quote = await provider.get_quote(SQUID_OSMO_TO_ATOM_REQUEST); const data = await provider.get_quote_data(quote); expect(data.dataType).toBe("contract"); @@ -90,12 +50,12 @@ describeIntegration("Squid live integration", () => { const msg = JSON.parse(data.data); expect(msg.typeUrl).toBe("/cosmwasm.wasm.v1.MsgExecuteContract"); expect(msg.value.contract).toBeTruthy(); - expect(msg.value.sender).toBe(OSMOSIS_ADDRESS); + expect(msg.value.sender).toBe(OSMOSIS_TEST_ADDRESS); console.log("OSMO->ATOM data:", JSON.stringify(msg, null, 2)); }); it("fetches quote_data for ATOM -> OSMO (MsgTransfer)", async () => { - const quote = await provider.get_quote(ATOM_TO_OSMO_REQUEST); + const quote = await provider.get_quote(SQUID_ATOM_TO_OSMO_REQUEST); const data = await provider.get_quote_data(quote); expect(data.dataType).toBe("contract"); diff --git a/packages/swapper/src/squid/provider.test.ts b/packages/swapper/src/squid/provider.test.ts index d09a6dc..8a28700 100644 --- a/packages/swapper/src/squid/provider.test.ts +++ b/packages/swapper/src/squid/provider.test.ts @@ -1,30 +1,8 @@ -import { AssetId, Chain, QuoteRequest } from "@gemwallet/types"; +import { AssetId, Chain } from "@gemwallet/types"; -import { createQuoteRequest } from "../testkit/mock"; -import { Long } from "../protobuf"; +import { createQuoteRequest, OSMOSIS_TEST_ADDRESS, SQUID_OSMO_TO_ATOM_REQUEST } from "../testkit/mock"; import { SquidProvider } from "./provider"; -const COSMOS_TEST_ADDRESS = "cosmos1qwerty12345test"; -const OSMOSIS_TEST_ADDRESS = "osmo1qwerty12345test"; - -const SQUID_COSMOS_QUOTE_REQUEST: QuoteRequest = { - from_address: OSMOSIS_TEST_ADDRESS, - to_address: COSMOS_TEST_ADDRESS, - from_asset: { - id: Chain.Osmosis, - symbol: "OSMO", - decimals: 6, - }, - to_asset: { - id: Chain.Cosmos, - symbol: "ATOM", - decimals: 6, - }, - from_value: "1000000", - referral_bps: 0, - slippage_bps: 100, -}; - describe("SquidProvider", () => { const provider = new SquidProvider("test-integrator"); @@ -80,7 +58,7 @@ describe("SquidProvider", () => { text: async () => JSON.stringify(mockRoute), } as Response); - const request = createQuoteRequest(SQUID_COSMOS_QUOTE_REQUEST); + const request = createQuoteRequest(SQUID_OSMO_TO_ATOM_REQUEST); const quote = await provider.get_quote(request); expect(quote.output_value).toBe("500000"); @@ -128,13 +106,13 @@ describe("SquidProvider", () => { }, }; - const fetchSpy = jest.spyOn(global, "fetch").mockResolvedValueOnce({ + jest.spyOn(global, "fetch").mockResolvedValueOnce({ ok: true, text: async () => JSON.stringify(mockRoute), } as Response); const quote = { - quote: createQuoteRequest(SQUID_COSMOS_QUOTE_REQUEST), + quote: createQuoteRequest(SQUID_OSMO_TO_ATOM_REQUEST), output_value: "500000", output_min_value: "495000", route_data: mockRoute.route, @@ -146,7 +124,6 @@ describe("SquidProvider", () => { expect(JSON.parse(data.data)).toEqual(JSON.parse(cosmosMsg)); expect(data.gasLimit).toBe("500000"); expect(data.dataType).toBe("contract"); - expect(fetchSpy).toHaveBeenCalledTimes(1); }); it("normalizes Long.js timeoutTimestamp to string", async () => { @@ -176,7 +153,7 @@ describe("SquidProvider", () => { } as Response); const quote = { - quote: createQuoteRequest(SQUID_COSMOS_QUOTE_REQUEST), + quote: createQuoteRequest(SQUID_OSMO_TO_ATOM_REQUEST), output_value: "500000", output_min_value: "495000", route_data: mockRoute.route, @@ -189,20 +166,4 @@ describe("SquidProvider", () => { expect(parsed.value.timeoutTimestamp).toBe("1773631986332999936"); }); }); - - describe("Long", () => { - it("converts Long.js objects to uint64 strings", () => { - expect(Long.toUint64({ low: -72998656, high: 412955876 })).toBe("1773631986332999936"); - }); - - it("leaves plain values unchanged", () => { - expect(Long.deepConvert(42)).toBe(42); - expect(Long.deepConvert("hello")).toBe("hello"); - }); - - it("recurses into nested objects", () => { - const input = { a: { low: 1, high: 0 }, b: "keep" }; - expect(Long.deepConvert(input)).toEqual({ a: "1", b: "keep" }); - }); - }); }); diff --git a/packages/swapper/src/testkit/mock.ts b/packages/swapper/src/testkit/mock.ts index c5ae0db..bde052f 100644 --- a/packages/swapper/src/testkit/mock.ts +++ b/packages/swapper/src/testkit/mock.ts @@ -174,3 +174,29 @@ export function createSolanaUsdcQuoteRequest(overrides: Partial = export function createAptosUsdcQuoteRequest(overrides: Partial = {}): QuoteRequest { return createQuoteRequest(APTOS_USDC_REQUEST_TEMPLATE, overrides); } + +export const COSMOS_TEST_ADDRESS = "cosmos1tkvyjqeq204rmrrz3w4hcrs336qahsfwmugljt"; +export const OSMOSIS_TEST_ADDRESS = "osmo1tkvyjqeq204rmrrz3w4hcrs336qahsfwn8m0ye"; + +export const ATOM_ASSET = { id: Chain.Cosmos, symbol: "ATOM", decimals: 6 }; +export const OSMO_ASSET = { id: Chain.Osmosis, symbol: "OSMO", decimals: 6 }; + +export const SQUID_OSMO_TO_ATOM_REQUEST: QuoteRequest = { + from_address: OSMOSIS_TEST_ADDRESS, + to_address: COSMOS_TEST_ADDRESS, + from_asset: OSMO_ASSET, + to_asset: ATOM_ASSET, + from_value: "10000000", + referral_bps: 0, + slippage_bps: 100, +}; + +export const SQUID_ATOM_TO_OSMO_REQUEST: QuoteRequest = { + from_address: COSMOS_TEST_ADDRESS, + to_address: OSMOSIS_TEST_ADDRESS, + from_asset: ATOM_ASSET, + to_asset: OSMO_ASSET, + from_value: "1000000", + referral_bps: 0, + slippage_bps: 100, +}; From dcaa532b84a6c3ba536f151d3faa327e953b88bf Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Mon, 16 Mar 2026 17:19:51 +0900 Subject: [PATCH 6/6] handle max swap for cosmos/squid --- packages/swapper/src/cosmos_fee.ts | 29 +++++++++++++++++++ .../swapper/src/squid/integration.test.ts | 5 ---- packages/swapper/src/squid/provider.test.ts | 2 +- packages/swapper/src/squid/provider.ts | 7 +++-- 4 files changed, 34 insertions(+), 9 deletions(-) create mode 100644 packages/swapper/src/cosmos_fee.ts diff --git a/packages/swapper/src/cosmos_fee.ts b/packages/swapper/src/cosmos_fee.ts new file mode 100644 index 0000000..6dd282c --- /dev/null +++ b/packages/swapper/src/cosmos_fee.ts @@ -0,0 +1,29 @@ +import { Chain, QuoteRequest } from "@gemwallet/types"; + +import { SwapperException } from "./error"; + +// Swap tx fees: preload_gas_limit(2M) × 1.3 × base_fee / 200K +const COSMOS_SWAP_FEES: Record = { + [Chain.Cosmos]: BigInt("39000"), + [Chain.Osmosis]: BigInt("130000"), + [Chain.Celestia]: BigInt("39000"), + [Chain.Injective]: BigInt("1300000000000000"), + [Chain.Sei]: BigInt("1300000"), + [Chain.Noble]: BigInt("325000"), +}; + +export function resolveCosmosMaxAmount(quoteRequest: QuoteRequest): string { + if (!quoteRequest.use_max_amount) { + return quoteRequest.from_value; + } + const chain = quoteRequest.from_asset.id.split("_")[0]; + const reservedFee = COSMOS_SWAP_FEES[chain]; + if (!reservedFee) { + return quoteRequest.from_value; + } + const amount = BigInt(quoteRequest.from_value); + if (amount <= reservedFee) { + throw new SwapperException({ type: "input_amount_error", min_amount: reservedFee.toString() }); + } + return (amount - reservedFee).toString(); +} diff --git a/packages/swapper/src/squid/integration.test.ts b/packages/swapper/src/squid/integration.test.ts index 99736af..0f646f2 100644 --- a/packages/swapper/src/squid/integration.test.ts +++ b/packages/swapper/src/squid/integration.test.ts @@ -4,7 +4,6 @@ require("dotenv").config({ path: "../../.env" }); import { BigIntMath } from "../bigint_math"; import { timedPromise } from "../debug"; import { SQUID_ATOM_TO_OSMO_REQUEST, SQUID_OSMO_TO_ATOM_REQUEST, OSMOSIS_TEST_ADDRESS } from "../testkit/mock"; -import type { SquidRoute } from "./model"; const runIntegration = process.env.INTEGRATION_TEST === "1"; const describeIntegration = runIntegration ? describe : describe.skip; @@ -34,10 +33,6 @@ describeIntegration("Squid live integration", () => { const outputValue = BigIntMath.formatDecimals(quote.output_value, 6); console.log("Squid 10 OSMO -> ATOM output:", outputValue); console.log("Squid ETA:", quote.eta_in_seconds, "seconds"); - - const routeData = quote.route_data as SquidRoute; - expect(routeData.estimate.toAmount).toBe(quote.output_value); - expect(routeData.estimate.toAmountMin).toBe(quote.output_min_value); }); it("fetches quote_data for OSMO -> ATOM (MsgExecuteContract)", async () => { diff --git a/packages/swapper/src/squid/provider.test.ts b/packages/swapper/src/squid/provider.test.ts index 8a28700..b9ee179 100644 --- a/packages/swapper/src/squid/provider.test.ts +++ b/packages/swapper/src/squid/provider.test.ts @@ -64,7 +64,7 @@ describe("SquidProvider", () => { expect(quote.output_value).toBe("500000"); expect(quote.output_min_value).toBe("495000"); expect(quote.eta_in_seconds).toBe(60); - expect(quote.route_data).toEqual(mockRoute.route); + expect(quote.route_data).toEqual({}); const body = JSON.parse(fetchSpy.mock.calls[0][1]!.body as string); expect(body.fromChain).toBe("osmosis-1"); diff --git a/packages/swapper/src/squid/provider.ts b/packages/swapper/src/squid/provider.ts index 6cbae50..0b97a2b 100644 --- a/packages/swapper/src/squid/provider.ts +++ b/packages/swapper/src/squid/provider.ts @@ -1,9 +1,10 @@ import { QuoteRequest, Quote, SwapQuoteData, AssetId, Chain, SwapQuoteDataType } from "@gemwallet/types"; +import { resolveCosmosMaxAmount } from "../cosmos_fee"; import { SwapperException } from "../error"; import { Protocol } from "../protocol"; -import { fetchRoute } from "./client"; import { Long } from "../protobuf"; +import { fetchRoute } from "./client"; import type { SquidRouteRequest } from "./model"; export class SquidProvider implements Protocol { @@ -67,7 +68,7 @@ export class SquidProvider implements Protocol { toChain: this.mapChainToSquidChainId(toAsset.chain), fromToken: this.mapAssetToSquidToken(fromAsset), toToken: this.mapAssetToSquidToken(toAsset), - fromAmount: quoteRequest.from_value, + fromAmount: resolveCosmosMaxAmount(quoteRequest), fromAddress: quoteRequest.from_address, toAddress: quoteRequest.to_address, slippageConfig: { @@ -85,7 +86,7 @@ export class SquidProvider implements Protocol { quote: quoteRequest, output_value: route.estimate.toAmount, output_min_value: route.estimate.toAmountMin, - route_data: route, + route_data: {}, eta_in_seconds: route.estimate.estimatedRouteDuration, }; }