Skip to content
Closed
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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ OKX_API_KEY=
OKX_SECRET_KEY=
OKX_API_PASSPHRASE=
OKX_PROJECT_ID=
SQUID_INTEGRATOR_ID=
2 changes: 2 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,15 @@ 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 \
-e OKX_API_KEY \
-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
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
OrcaWhirlpoolProvider,
PanoraProvider,
OkxProvider,
SquidProvider,
SwapperException,
} from "@gemwallet/swapper";
import { Quote, QuoteRequest, SwapQuoteData } from "@gemwallet/types";
Expand Down Expand Up @@ -43,6 +44,7 @@ const providers: Record<string, Protocol> = {
orca: new OrcaWhirlpoolProvider(solanaRpc),
panora: new PanoraProvider(),
okx: new OkxProvider(solanaRpc),
squid: new SquidProvider(),
};

app.get("/", (_, res) => {
Expand Down
29 changes: 29 additions & 0 deletions packages/swapper/src/cosmos_fee.ts
Original file line number Diff line number Diff line change
@@ -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<string, bigint> = {
[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();
}
1 change: 1 addition & 0 deletions packages/swapper/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ export * from "./referrer";
export * from "./orca";
export * from "./panora";
export * from "./okx";
export * from "./squid";
export * from "./error";
2 changes: 1 addition & 1 deletion packages/swapper/src/okx/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string>> = {
[Chain.Base]: "800000",
[Chain.Base]: "920000",
[Chain.Manta]: "600000",
[Chain.Mantle]: "2000000000",
[Chain.XLayer]: "800000",
Expand Down
17 changes: 17 additions & 0 deletions packages/swapper/src/protobuf.test.ts
Original file line number Diff line number Diff line change
@@ -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" });
});
});
25 changes: 25 additions & 0 deletions packages/swapper/src/protobuf.ts
Original file line number Diff line number Diff line change
@@ -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;
},
};
39 changes: 39 additions & 0 deletions packages/swapper/src/squid/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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<SquidRouteResponse> {
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),
});
const responseText = await response.text();

if (!response.ok) {
let detail: string;
try {
const errorBody = JSON.parse(responseText) as SquidErrorResponse;
detail = errorBody.errors?.[0]?.message || errorBody.message || response.statusText;
} catch {
detail = responseText || response.statusText;
}
throw new SwapperException({
type: "compute_quote_error",
message: `Squid API error ${response.status}: ${detail}`,
});
}

try {
return JSON.parse(responseText) as SquidRouteResponse;
} catch {
throw new SwapperException({
type: "compute_quote_error",
message: "Squid API returned an invalid response",
});
}
}
3 changes: 3 additions & 0 deletions packages/swapper/src/squid/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./model";
export * from "./client";
export * from "./provider";
65 changes: 65 additions & 0 deletions packages/swapper/src/squid/integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// eslint-disable-next-line @typescript-eslint/no-require-imports
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";

const runIntegration = process.env.INTEGRATION_TEST === "1";
const describeIntegration = runIntegration ? describe : describe.skip;

describeIntegration("Squid live integration", () => {
jest.setTimeout(60_000);

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(integratorId);
});

it("fetches a live quote for 10 OSMO -> ATOM", async () => {
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+$/);
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");
});

it("fetches quote_data for OSMO -> ATOM (MsgExecuteContract)", async () => {
const quote = await provider.get_quote(SQUID_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_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(SQUID_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));
});
});
41 changes: 41 additions & 0 deletions packages/swapper/src/squid/model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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 SquidRoute {
estimate: SquidEstimate;
transactionRequest?: SquidTransactionRequest;
}

export interface SquidRouteResponse {
route: SquidRoute;
}

export interface SquidErrorResponse {
errors?: { message: string }[];
message?: string;
}
Loading
Loading