From a794ae2b5c3ccbd8e6c8bb5e4f1f886dde93bb06 Mon Sep 17 00:00:00 2001 From: Rafael Date: Sat, 9 Aug 2025 10:36:22 +0200 Subject: [PATCH 1/4] Swapper sdk --- packages/swapper-sdk/.env.example | 14 + packages/swapper-sdk/.eslintignore | 6 + packages/swapper-sdk/README.md | 453 ++++++++++++++++++ packages/swapper-sdk/jest.config.js | 16 + packages/swapper-sdk/package.json | 49 ++ packages/swapper-sdk/src/api/bridge.ts | 46 ++ packages/swapper-sdk/src/api/client.ts | 141 ++++++ packages/swapper-sdk/src/api/index.ts | 6 + packages/swapper-sdk/src/api/initialize.ts | 10 + packages/swapper-sdk/src/api/permissions.ts | 10 + packages/swapper-sdk/src/api/quotes.ts | 10 + packages/swapper-sdk/src/api/streaming.ts | 72 +++ packages/swapper-sdk/src/index.ts | 5 + packages/swapper-sdk/src/swapper-sdk.ts | 185 +++++++ packages/swapper-sdk/src/types/bridge.ts | 129 +++++ packages/swapper-sdk/src/types/chains.ts | 57 +++ packages/swapper-sdk/src/types/common.ts | 54 +++ packages/swapper-sdk/src/types/index.ts | 5 + packages/swapper-sdk/src/types/initialize.ts | 51 ++ packages/swapper-sdk/src/types/quotes.ts | 155 ++++++ packages/swapper-sdk/tests/client.test.ts | 182 +++++++ packages/swapper-sdk/tests/setup.ts | 12 + .../swapper-sdk/tests/swapper-sdk.test.ts | 204 ++++++++ packages/swapper-sdk/tsconfig.json | 15 + packages/swapper-sdk/tsup.config.ts | 11 + 25 files changed, 1898 insertions(+) create mode 100644 packages/swapper-sdk/.env.example create mode 100644 packages/swapper-sdk/.eslintignore create mode 100644 packages/swapper-sdk/README.md create mode 100644 packages/swapper-sdk/jest.config.js create mode 100644 packages/swapper-sdk/package.json create mode 100644 packages/swapper-sdk/src/api/bridge.ts create mode 100644 packages/swapper-sdk/src/api/client.ts create mode 100644 packages/swapper-sdk/src/api/index.ts create mode 100644 packages/swapper-sdk/src/api/initialize.ts create mode 100644 packages/swapper-sdk/src/api/permissions.ts create mode 100644 packages/swapper-sdk/src/api/quotes.ts create mode 100644 packages/swapper-sdk/src/api/streaming.ts create mode 100644 packages/swapper-sdk/src/index.ts create mode 100644 packages/swapper-sdk/src/swapper-sdk.ts create mode 100644 packages/swapper-sdk/src/types/bridge.ts create mode 100644 packages/swapper-sdk/src/types/chains.ts create mode 100644 packages/swapper-sdk/src/types/common.ts create mode 100644 packages/swapper-sdk/src/types/index.ts create mode 100644 packages/swapper-sdk/src/types/initialize.ts create mode 100644 packages/swapper-sdk/src/types/quotes.ts create mode 100644 packages/swapper-sdk/tests/client.test.ts create mode 100644 packages/swapper-sdk/tests/setup.ts create mode 100644 packages/swapper-sdk/tests/swapper-sdk.test.ts create mode 100644 packages/swapper-sdk/tsconfig.json create mode 100644 packages/swapper-sdk/tsup.config.ts diff --git a/packages/swapper-sdk/.env.example b/packages/swapper-sdk/.env.example new file mode 100644 index 00000000..1c532a17 --- /dev/null +++ b/packages/swapper-sdk/.env.example @@ -0,0 +1,14 @@ +# Swapper SDK Configuration + +# API Base URL (default: https://api.phantom.app) +PHANTOM_SWAPPER_API_URL=https://api.phantom.app + +# Optional: Service authentication token for fee removal +PHANTOM_SERVICE_AUTH_TOKEN= + +# Optional: Client identification +PHANTOM_CLIENT_VERSION= +PHANTOM_CLIENT_PLATFORM= + +# Optional: Country code for geo-blocking +PHANTOM_COUNTRY_CODE= \ No newline at end of file diff --git a/packages/swapper-sdk/.eslintignore b/packages/swapper-sdk/.eslintignore new file mode 100644 index 00000000..9262bf75 --- /dev/null +++ b/packages/swapper-sdk/.eslintignore @@ -0,0 +1,6 @@ +dist/ +node_modules/ +coverage/ +*.js +*.d.ts +tsup.config.ts \ No newline at end of file diff --git a/packages/swapper-sdk/README.md b/packages/swapper-sdk/README.md new file mode 100644 index 00000000..30150630 --- /dev/null +++ b/packages/swapper-sdk/README.md @@ -0,0 +1,453 @@ +# @phantom/swapper-sdk + +SDK for Phantom swap and bridge functionality. This SDK provides a TypeScript interface to the Phantom Swap API, enabling token swaps and cross-chain bridges. + +## Installation + +```bash +npm install @phantom/swapper-sdk +# or +yarn add @phantom/swapper-sdk +``` + +## Quick Start + +```typescript +import { SwapperSDK, ChainID } from '@phantom/swapper-sdk'; + +// Initialize the SDK +const swapper = new SwapperSDK({ + apiUrl: 'https://api.phantom.app', // optional, this is the default + headers: { + 'X-Phantom-Version': '1.0.0', + 'X-Phantom-Platform': 'web', + }, + debug: true, // optional, enables debug logging +}); + +// Get swap quotes +const quotes = await swapper.getQuotes({ + taker: { + chainId: ChainID.SolanaMainnet, + address: '8B3fBhkpHFMTdaQSfKVREB5HFKnZkT8rL6zKJfmmWDeZ', + resourceType: 'address', + }, + sellToken: { + chainId: ChainID.SolanaMainnet, + slip44: '501', + resourceType: 'nativeToken', + }, + buyToken: { + chainId: ChainID.SolanaMainnet, + address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + resourceType: 'address', + }, + sellAmount: '1000000000', + slippageTolerance: 0.5, +}); +``` + +## Configuration + +### Environment Variables + +Create a `.env` file based on `.env.example`: + +```env +# API Base URL (default: https://api.phantom.app) +PHANTOM_SWAPPER_API_URL=https://api.phantom.app + +# Optional: Service authentication token for fee removal +PHANTOM_SERVICE_AUTH_TOKEN= + +# Optional: Client identification +PHANTOM_CLIENT_VERSION= +PHANTOM_CLIENT_PLATFORM= + +# Optional: Country code for geo-blocking +PHANTOM_COUNTRY_CODE= +``` + +### SDK Configuration + +```typescript +interface SwapperSDKConfig { + apiUrl?: string; // API base URL + headers?: { // Optional custom headers + 'X-Phantom-Version'?: string; + 'X-Phantom-Platform'?: string; + 'X-Phantom-AnonymousId'?: string; + 'cf-ipcountry'?: string; + 'cloudfront-viewer-country'?: string; + Authorization?: string; + }; + timeout?: number; // Request timeout in ms (default: 30000) + debug?: boolean; // Enable debug logging +} +``` + +## API Methods + +### Swap Operations + +#### Get Quotes + +Get quotes for token swaps (same-chain) or bridges (cross-chain). + +```typescript +const quotes = await swapper.getQuotes({ + taker: SwapperCaip19, + buyToken: SwapperCaip19, + sellToken: SwapperCaip19, + sellAmount: string, + + // Optional parameters + takerDestination?: SwapperCaip19, // For bridges + exactOut?: boolean, + base64EncodedTx?: boolean, + autoSlippage?: boolean, + slippageTolerance?: number, + priorityFee?: number, + tipAmount?: number, +}); +``` + +#### Initialize Swap + +Initialize a swap session and get token metadata. + +```typescript +const result = await swapper.initializeSwap({ + type: 'buy' | 'sell' | 'swap', + + // Conditional fields based on type + network?: ChainID, + buyCaip19?: string, + sellCaip19?: string, + + // Optional + address?: string, + settings?: { + priorityFee?: number, + tip?: number, + }, +}); +``` + +#### Stream Quotes (Real-time) + +Get real-time quote updates via Server-Sent Events. + +```typescript +const stopStreaming = swapper.streamQuotes({ + taker: string, + buyToken: string, + sellToken: string, + sellAmount: string, + + // Event handlers + onQuote: (quote) => console.log('New quote:', quote), + onError: (error) => console.error('Error:', error), + onFinish: () => console.log('Stream finished'), +}); + +// Stop streaming when done +stopStreaming(); +``` + +### Bridge Operations + +#### Get Bridgeable Tokens + +```typescript +const { tokens } = await swapper.getBridgeableTokens(); +``` + +#### Get Bridge Providers + +```typescript +const { providers } = await swapper.getPreferredBridges(); +``` + +#### Initialize Bridge (Hyperunit) + +Generate a deposit address for bridging. + +```typescript +const result = await swapper.initializeBridge({ + sellToken: string, // CAIP-19 format + buyToken?: string, // CAIP-19 format + takerDestination: string, // CAIP-19 format +}); +``` + +#### Check Bridge Status (Relay) + +```typescript +const status = await swapper.getIntentsStatus({ + requestId: string, +}); +``` + +#### Get Bridge Operations (Hyperunit) + +```typescript +const operations = await swapper.getBridgeOperations({ + taker: string, // CAIP-19 format + opCreatedAtOrAfter?: string, // ISO timestamp +}); +``` + +### Utility Methods + +#### Get Permissions + +Check user permissions based on location. + +```typescript +const permissions = await swapper.getPermissions(); +``` + +#### Get Withdrawal Queue + +```typescript +const queue = await swapper.getWithdrawalQueue(); +``` + +#### Update Headers + +Dynamically update request headers. + +```typescript +swapper.updateHeaders({ + 'X-Phantom-Version': '2.0.0', + Authorization: 'Bearer new-token', +}); +``` + +## Types + +### ChainID + +Supported blockchain networks: + +```typescript +enum ChainID { + // Solana + SolanaMainnet = "solana:101", + SolanaTestnet = "solana:102", + SolanaDevnet = "solana:103", + + // Ethereum + EthereumMainnet = "eip155:1", + EthereumSepolia = "eip155:11155111", + + // Polygon + PolygonMainnet = "eip155:137", + + // Base + BaseMainnet = "eip155:8453", + + // Arbitrum + ArbitrumMainnet = "eip155:42161", + + // Sui + SuiMainnet = "sui:mainnet", + + // Bitcoin + BitcoinMainnet = "bip122:000000000019d6689c085ae165831e93", + + // ... and more +} +``` + +### SwapperCaip19 + +Universal token/address format: + +```typescript +interface SwapperCaip19 { + chainId: ChainID; + resourceType: "address" | "nativeToken"; + address?: string; // Required if resourceType = "address" + slip44?: string; // Required if resourceType = "nativeToken" +} +``` + +### SwapType + +```typescript +enum SwapType { + Solana = "solana", // Same-chain Solana swap + EVM = "eip155", // Same-chain EVM swap + XChain = "xchain", // Cross-chain swap/bridge + Sui = "sui" // Same-chain Sui swap +} +``` + +## Integration with Other Phantom SDKs + +The Swapper SDK can be integrated with other Phantom SDKs for a complete DApp experience. + +### With Server SDK + +```typescript +import { PhantomServer } from '@phantom/server-sdk'; +import { SwapperSDK } from '@phantom/swapper-sdk'; + +const server = new PhantomServer({ apiKey: 'your-api-key' }); +const swapper = new SwapperSDK(); + +// Get quotes +const quotes = await swapper.getQuotes({...}); + +// Sign and send transaction using Server SDK +const transaction = quotes.quotes[0].transactionData[0]; +// Use server SDK to sign and send... +``` + +### With Browser SDK + +```typescript +import { createPhantom } from '@phantom/browser-sdk'; +import { SwapperSDK } from '@phantom/swapper-sdk'; + +const phantom = await createPhantom(); +const swapper = new SwapperSDK(); + +// Get quotes +const quotes = await swapper.getQuotes({...}); + +// Sign and send using Browser SDK +const transaction = quotes.quotes[0].transactionData[0]; +// Use browser SDK to sign and send... +``` + +## Error Handling + +The SDK throws typed errors for various failure scenarios: + +```typescript +try { + const quotes = await swapper.getQuotes({...}); +} catch (error) { + if (error.code === 'INVALID_TOKEN_PAIR') { + console.error('These tokens cannot be swapped'); + } else if (error.code === 'INSUFFICIENT_LIQUIDITY') { + console.error('Not enough liquidity for this swap'); + } else if (error.code === 'PRICE_IMPACT_TOO_HIGH') { + console.error('Price impact exceeds 30%'); + } +} +``` + +Common error codes: +- `UnsupportedCountry` - Country blocked (400) +- `InvalidTokenPair` - Tokens not swappable +- `InsufficientLiquidity` - Not enough liquidity +- `PriceImpactTooHigh` - Price impact > 30% +- `InvalidCaip19Format` - Invalid token format +- `InvalidAmount` - Amount validation failed + +## Development + +### Running Tests + +```bash +# Run all tests +yarn test + +# Run tests in watch mode +yarn test:watch +``` + +### Building + +```bash +# Build the SDK +yarn build + +# Development mode +yarn dev +``` + +### Linting + +```bash +# Run ESLint +yarn lint + +# Check types +yarn check-types + +# Format code +yarn prettier +``` + +## Examples + +### Same-Chain Swap (Solana) + +```typescript +const quotes = await swapper.getQuotes({ + taker: { + chainId: ChainID.SolanaMainnet, + address: '8B3fBhkpHFMTdaQSfKVREB5HFKnZkT8rL6zKJfmmWDeZ', + resourceType: 'address', + }, + sellToken: { + chainId: ChainID.SolanaMainnet, + slip44: '501', + resourceType: 'nativeToken', + }, + buyToken: { + chainId: ChainID.SolanaMainnet, + address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + resourceType: 'address', + }, + sellAmount: '1000000000', + slippageTolerance: 0.5, +}); + +// Get the best quote +const bestQuote = quotes.quotes[0]; +const transaction = bestQuote.transactionData[0]; +// Sign and send transaction... +``` + +### Cross-Chain Bridge (Ethereum to Solana) + +```typescript +const quotes = await swapper.getQuotes({ + taker: { + chainId: ChainID.EthereumMainnet, + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f6D123', + resourceType: 'address', + }, + takerDestination: { + chainId: ChainID.SolanaMainnet, + address: '8B3fBhkpHFMTdaQSfKVREB5HFKnZkT8rL6zKJfmmWDeZ', + resourceType: 'address', + }, + sellToken: { + chainId: ChainID.EthereumMainnet, + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + resourceType: 'address', + }, + buyToken: { + chainId: ChainID.SolanaMainnet, + address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + resourceType: 'address', + }, + sellAmount: '1000000000', + slippageTolerance: 1.0, +}); +``` + +## Support + +For issues and questions, please visit [GitHub Issues](https://github.com/phantom/wallet-sdk/issues). + +## License + +See LICENSE file in the root of the repository. \ No newline at end of file diff --git a/packages/swapper-sdk/jest.config.js b/packages/swapper-sdk/jest.config.js new file mode 100644 index 00000000..c5d843f7 --- /dev/null +++ b/packages/swapper-sdk/jest.config.js @@ -0,0 +1,16 @@ +const sharedConfig = require("../../sharedJestConfig"); + +module.exports = { + ...sharedConfig, + preset: "ts-jest", + testEnvironment: "node", + roots: ["/src", "/tests"], + testMatch: ["**/*.test.ts"], + moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], + collectCoverageFrom: [ + "src/**/*.{ts,tsx}", + "!src/**/*.d.ts", + "!src/**/*.test.ts", + "!src/**/index.ts", + ], +}; \ No newline at end of file diff --git a/packages/swapper-sdk/package.json b/packages/swapper-sdk/package.json new file mode 100644 index 00000000..06d165d4 --- /dev/null +++ b/packages/swapper-sdk/package.json @@ -0,0 +1,49 @@ +{ + "name": "@phantom/swapper-sdk", + "version": "0.0.1", + "description": "SDK for Phantom swap and bridge functionality", + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "?pack-release": "When https://github.com/changesets/changesets/issues/432 has a solution we can remove this trick", + "pack-release": "rimraf ./_release && yarn pack && mkdir ./_release && tar zxvf ./package.tgz --directory ./_release && rm ./package.tgz", + "build": "rimraf ./dist && tsup src/index.ts --format cjs,esm --dts", + "dev": "tsc --watch", + "clean": "rm -rf dist", + "test": "jest", + "test:watch": "jest --watch", + "lint": "eslint src --ext .ts,.tsx", + "check-types": "yarn tsc --noEmit", + "prettier": "prettier --write \"src/**/*.{ts,tsx}\"" + }, + "devDependencies": { + "@types/jest": "^29.5.12", + "@types/node": "^20.11.0", + "dotenv": "^16.4.1", + "eslint": "8.53.0", + "jest": "^29.7.0", + "prettier": "^3.5.2", + "rimraf": "^6.0.1", + "ts-jest": "^29.1.2", + "tsup": "^6.7.0", + "typescript": "^5.0.4" + }, + "dependencies": { + "@phantom/parsers": "workspace:^", + "eventsource": "^2.0.2" + }, + "files": [ + "dist" + ], + "publishConfig": { + "directory": "_release/package" + } +} \ No newline at end of file diff --git a/packages/swapper-sdk/src/api/bridge.ts b/packages/swapper-sdk/src/api/bridge.ts new file mode 100644 index 00000000..ceacd87f --- /dev/null +++ b/packages/swapper-sdk/src/api/bridge.ts @@ -0,0 +1,46 @@ +import type { + GenerateAndVerifyAddressParams, + GenerateAndVerifyAddressResponse, + GetBridgeProvidersResponse, + GetBridgeableTokensResponse, + GetIntentsStatusParams, + GetIntentsStatusResponse, + InitializeFundingParams, + InitializeFundingResponse, + OperationsParams, + OperationsResponse, + WithdrawalQueueResponse, +} from "../types"; +import type { SwapperAPIClient } from "./client"; + +export class BridgeAPI { + constructor(private client: SwapperAPIClient) {} + + async getBridgeableTokens(): Promise { + return this.client.get("/spot/bridgeable-tokens"); + } + + async getPreferredBridges(): Promise { + return this.client.get("/spot/preferred-bridges"); + } + + async getIntentsStatus(params: GetIntentsStatusParams): Promise { + return this.client.get("/spot/get-intents-status", params); + } + + async bridgeInitialize(params: GenerateAndVerifyAddressParams): Promise { + return this.client.get("/spot/bridge-initialize", params); + } + + async getBridgeOperations(params: OperationsParams): Promise { + return this.client.get("/spot/bridge-operations", params); + } + + async initializeFunding(params: InitializeFundingParams): Promise { + return this.client.post("/spot/funding", params); + } + + async getWithdrawalQueue(): Promise { + return this.client.get("/spot/withdrawal-queue"); + } +} \ No newline at end of file diff --git a/packages/swapper-sdk/src/api/client.ts b/packages/swapper-sdk/src/api/client.ts new file mode 100644 index 00000000..43a61663 --- /dev/null +++ b/packages/swapper-sdk/src/api/client.ts @@ -0,0 +1,141 @@ +import type { ErrorResponse, Headers, OptionalHeaders } from "../types"; + +export interface SwapperClientConfig { + apiUrl?: string; + headers?: OptionalHeaders; + timeout?: number; +} + +export class SwapperAPIClient { + private readonly baseUrl: string; + private readonly headers: Headers; + private readonly timeout: number; + + constructor(config: SwapperClientConfig = {}) { + this.baseUrl = (config.apiUrl || process.env.PHANTOM_SWAPPER_API_URL || "https://api.phantom.app") + "/swap/v2"; + this.timeout = config.timeout || 30000; + + this.headers = { + "Content-Type": "application/json", + ...this.buildOptionalHeaders(config.headers), + }; + } + + private buildOptionalHeaders(customHeaders?: OptionalHeaders): OptionalHeaders { + const headers: OptionalHeaders = {}; + + if (process.env.PHANTOM_SERVICE_AUTH_TOKEN) { + headers.Authorization = `Bearer ${process.env.PHANTOM_SERVICE_AUTH_TOKEN}`; + } + + if (process.env.PHANTOM_CLIENT_VERSION) { + headers["X-Phantom-Version"] = process.env.PHANTOM_CLIENT_VERSION; + } + + if (process.env.PHANTOM_CLIENT_PLATFORM) { + headers["X-Phantom-Platform"] = process.env.PHANTOM_CLIENT_PLATFORM; + } + + if (process.env.PHANTOM_COUNTRY_CODE) { + headers["cf-ipcountry"] = process.env.PHANTOM_COUNTRY_CODE; + headers["cloudfront-viewer-country"] = process.env.PHANTOM_COUNTRY_CODE; + } + + return { + ...headers, + ...customHeaders, + }; + } + + async request( + endpoint: string, + options: { + method?: "GET" | "POST"; + body?: any; + headers?: Partial; + queryParams?: Record; + } = {} + ): Promise { + const { method = "GET", body, headers = {}, queryParams } = options; + + const url = new URL(`${this.baseUrl}${endpoint}`); + + if (queryParams) { + Object.entries(queryParams).forEach(([key, value]) => { + if (value !== undefined) { + url.searchParams.append(key, String(value)); + } + }); + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + try { + const response = await fetch(url.toString(), { + method, + headers: { + ...this.headers, + ...headers, + }, + body: body ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + const error: ErrorResponse = { + code: errorData.code || "UNKNOWN_ERROR", + message: errorData.message || `Request failed with status ${response.status}`, + statusCode: response.status, + details: errorData.details, + }; + throw error; + } + + return await response.json(); + } catch (error: any) { + clearTimeout(timeoutId); + + if (error.name === "AbortError") { + throw { + code: "TIMEOUT", + message: "Request timed out", + statusCode: 408, + } as ErrorResponse; + } + + throw error; + } + } + + async get( + endpoint: string, + queryParams?: Record, + headers?: Partial + ): Promise { + return this.request(endpoint, { + method: "GET", + queryParams, + headers, + }); + } + + async post(endpoint: string, body?: any, headers?: Partial): Promise { + return this.request(endpoint, { + method: "POST", + body, + headers, + }); + } + + updateHeaders(headers: OptionalHeaders): void { + Object.assign(this.headers, headers); + } + + getBaseUrl(): string { + return this.baseUrl; + } +} \ No newline at end of file diff --git a/packages/swapper-sdk/src/api/index.ts b/packages/swapper-sdk/src/api/index.ts new file mode 100644 index 00000000..4555978e --- /dev/null +++ b/packages/swapper-sdk/src/api/index.ts @@ -0,0 +1,6 @@ +export * from "./client"; +export * from "./quotes"; +export * from "./initialize"; +export * from "./streaming"; +export * from "./bridge"; +export * from "./permissions"; \ No newline at end of file diff --git a/packages/swapper-sdk/src/api/initialize.ts b/packages/swapper-sdk/src/api/initialize.ts new file mode 100644 index 00000000..330dbd5c --- /dev/null +++ b/packages/swapper-sdk/src/api/initialize.ts @@ -0,0 +1,10 @@ +import type { SwapperInitializeRequestParams, SwapperInitializeResults } from "../types"; +import type { SwapperAPIClient } from "./client"; + +export class InitializeAPI { + constructor(private client: SwapperAPIClient) {} + + async initialize(params: SwapperInitializeRequestParams): Promise { + return this.client.post("/initialize", params); + } +} \ No newline at end of file diff --git a/packages/swapper-sdk/src/api/permissions.ts b/packages/swapper-sdk/src/api/permissions.ts new file mode 100644 index 00000000..9b2983fb --- /dev/null +++ b/packages/swapper-sdk/src/api/permissions.ts @@ -0,0 +1,10 @@ +import type { PermissionsResponse } from "../types"; +import type { SwapperAPIClient } from "./client"; + +export class PermissionsAPI { + constructor(private client: SwapperAPIClient) {} + + async getPermissions(): Promise { + return this.client.get("/permissions"); + } +} \ No newline at end of file diff --git a/packages/swapper-sdk/src/api/quotes.ts b/packages/swapper-sdk/src/api/quotes.ts new file mode 100644 index 00000000..03c17cea --- /dev/null +++ b/packages/swapper-sdk/src/api/quotes.ts @@ -0,0 +1,10 @@ +import type { SwapperQuotesBody, SwapperQuotesDataRepresentation } from "../types"; +import type { SwapperAPIClient } from "./client"; + +export class QuotesAPI { + constructor(private client: SwapperAPIClient) {} + + async getQuotes(params: SwapperQuotesBody): Promise { + return this.client.post("/quotes", params); + } +} \ No newline at end of file diff --git a/packages/swapper-sdk/src/api/streaming.ts b/packages/swapper-sdk/src/api/streaming.ts new file mode 100644 index 00000000..4baa6617 --- /dev/null +++ b/packages/swapper-sdk/src/api/streaming.ts @@ -0,0 +1,72 @@ +import type { EventType, SSEEvent, SwapperQuery, SwapperQuotesDataRepresentation } from "../types"; +import type { SwapperAPIClient } from "./client"; + +export interface StreamQuotesOptions extends SwapperQuery { + onQuote?: (quote: SwapperQuotesDataRepresentation) => void; + onError?: (error: any) => void; + onFinish?: () => void; +} + +export class StreamingAPI { + private eventSource?: EventSource; + + constructor(private client: SwapperAPIClient) {} + + streamQuotes(options: StreamQuotesOptions): () => void { + const { onQuote, onError, onFinish, ...queryParams } = options; + + const params = new URLSearchParams(); + Object.entries(queryParams).forEach(([key, value]) => { + if (value !== undefined) { + params.append(key, String(value)); + } + }); + + const url = `${this.client.getBaseUrl()}/stream/quotes?${params.toString()}`; + + if (typeof EventSource === "undefined") { + const error = new Error("EventSource is not supported in this environment"); + onError?.(error); + throw error; + } + + this.eventSource = new EventSource(url); + + this.eventSource.addEventListener("new-quote-response", (event: MessageEvent) => { + try { + const data = JSON.parse(event.data) as SwapperQuotesDataRepresentation; + onQuote?.(data); + } catch (error) { + onError?.(error); + } + }); + + this.eventSource.addEventListener("error-quote-response", (event: MessageEvent) => { + try { + const error = JSON.parse(event.data); + onError?.(error); + } catch (parseError) { + onError?.(parseError); + } + }); + + this.eventSource.addEventListener("quote-stream-finished", () => { + onFinish?.(); + this.close(); + }); + + this.eventSource.onerror = (error) => { + onError?.(error); + this.close(); + }; + + return () => this.close(); + } + + private close(): void { + if (this.eventSource) { + this.eventSource.close(); + this.eventSource = undefined; + } + } +} \ No newline at end of file diff --git a/packages/swapper-sdk/src/index.ts b/packages/swapper-sdk/src/index.ts new file mode 100644 index 00000000..5adff27f --- /dev/null +++ b/packages/swapper-sdk/src/index.ts @@ -0,0 +1,5 @@ +export * from "./swapper-sdk"; +export * from "./types"; +export * from "./api"; + +export { SwapperSDK as default } from "./swapper-sdk"; \ No newline at end of file diff --git a/packages/swapper-sdk/src/swapper-sdk.ts b/packages/swapper-sdk/src/swapper-sdk.ts new file mode 100644 index 00000000..90b74622 --- /dev/null +++ b/packages/swapper-sdk/src/swapper-sdk.ts @@ -0,0 +1,185 @@ +import { + BridgeAPI, + InitializeAPI, + PermissionsAPI, + QuotesAPI, + StreamingAPI, + SwapperAPIClient, + type SwapperClientConfig, +} from "./api"; +import type { + GenerateAndVerifyAddressParams, + GenerateAndVerifyAddressResponse, + GetBridgeProvidersResponse, + GetBridgeableTokensResponse, + GetIntentsStatusParams, + GetIntentsStatusResponse, + InitializeFundingParams, + InitializeFundingResponse, + OperationsParams, + OperationsResponse, + OptionalHeaders, + PermissionsResponse, + SwapperInitializeRequestParams, + SwapperInitializeResults, + SwapperQuotesBody, + SwapperQuotesDataRepresentation, + WithdrawalQueueResponse, +} from "./types"; +import type { StreamQuotesOptions } from "./api/streaming"; + +export interface SwapperSDKConfig extends SwapperClientConfig { + debug?: boolean; +} + +export class SwapperSDK { + private readonly client: SwapperAPIClient; + private readonly quotes: QuotesAPI; + private readonly initialize: InitializeAPI; + private readonly streaming: StreamingAPI; + private readonly bridge: BridgeAPI; + private readonly permissions: PermissionsAPI; + private readonly debug: boolean; + + constructor(config: SwapperSDKConfig = {}) { + this.debug = config.debug || false; + this.client = new SwapperAPIClient(config); + + this.quotes = new QuotesAPI(this.client); + this.initialize = new InitializeAPI(this.client); + this.streaming = new StreamingAPI(this.client); + this.bridge = new BridgeAPI(this.client); + this.permissions = new PermissionsAPI(this.client); + + if (this.debug) { + console.error("[SwapperSDK] Initialized with config:", { + baseUrl: this.client.getBaseUrl(), + debug: this.debug, + }); + } + } + + async getQuotes(params: SwapperQuotesBody): Promise { + if (this.debug) { + console.error("[SwapperSDK] Getting quotes with params:", params); + } + const result = await this.quotes.getQuotes(params); + if (this.debug) { + console.error("[SwapperSDK] Received quotes:", result); + } + return result; + } + + async initializeSwap(params: SwapperInitializeRequestParams): Promise { + if (this.debug) { + console.error("[SwapperSDK] Initializing swap with params:", params); + } + const result = await this.initialize.initialize(params); + if (this.debug) { + console.error("[SwapperSDK] Initialize result:", result); + } + return result; + } + + streamQuotes(options: StreamQuotesOptions): () => void { + if (this.debug) { + console.error("[SwapperSDK] Starting quote stream with options:", options); + } + return this.streaming.streamQuotes(options); + } + + async getPermissions(): Promise { + if (this.debug) { + console.error("[SwapperSDK] Getting permissions"); + } + const result = await this.permissions.getPermissions(); + if (this.debug) { + console.error("[SwapperSDK] Permissions result:", result); + } + return result; + } + + async getBridgeableTokens(): Promise { + if (this.debug) { + console.error("[SwapperSDK] Getting bridgeable tokens"); + } + const result = await this.bridge.getBridgeableTokens(); + if (this.debug) { + console.error("[SwapperSDK] Bridgeable tokens:", result); + } + return result; + } + + async getPreferredBridges(): Promise { + if (this.debug) { + console.error("[SwapperSDK] Getting preferred bridges"); + } + const result = await this.bridge.getPreferredBridges(); + if (this.debug) { + console.error("[SwapperSDK] Preferred bridges:", result); + } + return result; + } + + async getIntentsStatus(params: GetIntentsStatusParams): Promise { + if (this.debug) { + console.error("[SwapperSDK] Getting intents status with params:", params); + } + const result = await this.bridge.getIntentsStatus(params); + if (this.debug) { + console.error("[SwapperSDK] Intents status:", result); + } + return result; + } + + async initializeBridge(params: GenerateAndVerifyAddressParams): Promise { + if (this.debug) { + console.error("[SwapperSDK] Initializing bridge with params:", params); + } + const result = await this.bridge.bridgeInitialize(params); + if (this.debug) { + console.error("[SwapperSDK] Bridge initialize result:", result); + } + return result; + } + + async getBridgeOperations(params: OperationsParams): Promise { + if (this.debug) { + console.error("[SwapperSDK] Getting bridge operations with params:", params); + } + const result = await this.bridge.getBridgeOperations(params); + if (this.debug) { + console.error("[SwapperSDK] Bridge operations:", result); + } + return result; + } + + async initializeFunding(params: InitializeFundingParams): Promise { + if (this.debug) { + console.error("[SwapperSDK] Initializing funding with params:", params); + } + const result = await this.bridge.initializeFunding(params); + if (this.debug) { + console.error("[SwapperSDK] Funding initialize result:", result); + } + return result; + } + + async getWithdrawalQueue(): Promise { + if (this.debug) { + console.error("[SwapperSDK] Getting withdrawal queue"); + } + const result = await this.bridge.getWithdrawalQueue(); + if (this.debug) { + console.error("[SwapperSDK] Withdrawal queue:", result); + } + return result; + } + + updateHeaders(headers: OptionalHeaders): void { + if (this.debug) { + console.error("[SwapperSDK] Updating headers:", headers); + } + this.client.updateHeaders(headers); + } +} \ No newline at end of file diff --git a/packages/swapper-sdk/src/types/bridge.ts b/packages/swapper-sdk/src/types/bridge.ts new file mode 100644 index 00000000..2ddccc60 --- /dev/null +++ b/packages/swapper-sdk/src/types/bridge.ts @@ -0,0 +1,129 @@ +import type { ChainID, SwapperCaip19 } from "./chains"; + +export interface GetBridgeableTokensResponse { + tokens: SwapperCaip19[]; +} + +export interface GetBridgeProvidersResponse { + providers: BridgeProviderInfo[]; +} + +export interface BridgeProviderInfo { + name: string; + max: number; +} + +export interface GetIntentsStatusParams { + requestId: string; +} + +export interface GetIntentsStatusResponse { + status: RelayExecutionStatus; + details: string; + inTxHashes: string[]; + txHashes: string[]; + time: number; + originChainId: number; + destinationChainId: number; +} + +export enum RelayExecutionStatus { + REFUND = "refund", + DELAYED = "delayed", + WAITING = "waiting", + FAILURE = "failure", + PENDING = "pending", + SUCCESS = "success", +} + +export interface GenerateAndVerifyAddressParams { + sellToken: string; + buyToken?: string; + takerDestination: string; +} + +export interface GenerateAndVerifyAddressResponse { + depositAddress: SwapperCaip19; + orderAssetId: number; + usdcPrice: string; +} + +export interface OperationsParams { + taker: string; + opCreatedAtOrAfter?: string; +} + +export interface OperationsResponse { + operations: ParsedOperation[]; + orderAssetId: number; + usdcPrice: string; +} + +export interface ParsedOperation { + operationId: string; + opCreatedAt: string; + protocolAddress: string; + sourceAddress: string; + destinationAddress: string; + sourceChain: HyperunitChain; + destinationChain: HyperunitChain; + sourceAmount: string; + destinationAmount?: string; + destinationUiAmount?: string; + destinationFeeAmount: string; + sweepFeeAmount: string; + state: OperationState; + sourceTxHash: string; + destinationTxHash: string; + positionInWithdrawQueue?: number; + asset: HyperunitAsset; + sourceTxConfirmations?: number; + destinationTxConfirmations?: number; + broadcastAt?: string; +} + +export interface HyperunitChain { + id: string; + name: string; +} + +export interface HyperunitAsset { + id: string; + symbol: string; + name: string; +} + +export enum OperationState { + PENDING = "pending", + CONFIRMED = "confirmed", + COMPLETED = "completed", + FAILED = "failed", +} + +export interface InitializeFundingParams { + type: "deposit" | "withdraw"; + taker: string; + originChain: ChainID; +} + +export interface InitializeFundingResponse { + fundingCaip19Address: string; + spotAssetId: number; + spotTokenId: string; + spotTokenName: string; + spotSzDecimals: number; + eta: string; + fee: string; + minimumAmount: string; +} + +export interface WithdrawalQueueResponse { + SOLANA: QueueStatus; + ETHEREUM: QueueStatus; + BITCOIN: QueueStatus; +} + +interface QueueStatus { + lastWithdrawQueueOperationTxID: string; + withdrawalQueueLength: number; +} \ No newline at end of file diff --git a/packages/swapper-sdk/src/types/chains.ts b/packages/swapper-sdk/src/types/chains.ts new file mode 100644 index 00000000..117a1d67 --- /dev/null +++ b/packages/swapper-sdk/src/types/chains.ts @@ -0,0 +1,57 @@ +export enum ChainID { + // Solana + SolanaMainnet = "solana:101", + SolanaTestnet = "solana:102", + SolanaDevnet = "solana:103", + + // Ethereum + EthereumMainnet = "eip155:1", + EthereumSepolia = "eip155:11155111", + + // Polygon + PolygonMainnet = "eip155:137", + PolygonAmoy = "eip155:80002", + + // Base + BaseMainnet = "eip155:8453", + BaseSepolia = "eip155:84532", + + // Arbitrum + ArbitrumMainnet = "eip155:42161", + ArbitrumSepolia = "eip155:421614", + + // Other EVM chains + BscMainnet = "eip155:56", + OptimismMainnet = "eip155:10", + AvalancheMainnet = "eip155:43114", + + // Sui + SuiMainnet = "sui:mainnet", + SuiTestnet = "sui:testnet", + SuiDevnet = "sui:devnet", + + // Bitcoin + BitcoinMainnet = "bip122:000000000019d6689c085ae165831e93", + BitcoinTestnet = "bip122:000000000933ea01ad0ee984209779ba", +} + +export interface SwapperCaip19 { + chainId: ChainID; + resourceType: "address" | "nativeToken"; + address?: string; + slip44?: string; +} + +export enum SwapType { + Solana = "solana", + EVM = "eip155", + XChain = "xchain", + Sui = "sui", +} + +export enum FeeType { + NETWORK = "NETWORK", + PROTOCOL = "PROTOCOL", + PHANTOM = "PHANTOM", + OTHER = "OTHER", +} \ No newline at end of file diff --git a/packages/swapper-sdk/src/types/common.ts b/packages/swapper-sdk/src/types/common.ts new file mode 100644 index 00000000..15c6390d --- /dev/null +++ b/packages/swapper-sdk/src/types/common.ts @@ -0,0 +1,54 @@ +export interface RequiredHeaders { + "Content-Type": "application/json"; +} + +export interface OptionalHeaders { + "X-Phantom-Version"?: string; + "X-Phantom-Platform"?: string; + "X-Phantom-AnonymousId"?: string; + "cf-ipcountry"?: string; + "cloudfront-viewer-country"?: string; + Authorization?: string; +} + +export type Headers = RequiredHeaders & OptionalHeaders; + +export interface PermissionsResponse { + perps: { + actions: boolean; + }; +} + +export interface ErrorResponse { + code: string; + message: string; + statusCode: number; + details?: any; +} + +export interface SwapperQuery { + taker: string; + buyToken: string; + sellToken: string; + sellAmount: string; + + sellAmountUsd?: string; + takerDestination?: string; + slippageTolerance?: string; + exactOut?: string; + base64EncodedTx?: string; + autoSlippage?: string; + country?: string; + priorityFee?: string; + tipAmount?: string; + isLedger?: string; + phantomCashAccount?: string; +} + +export type EventType = "new-quote-response" | "quote-stream-finished" | "error-quote-response"; + +export interface SSEEvent { + type: EventType; + data: any; + retry?: number; +} \ No newline at end of file diff --git a/packages/swapper-sdk/src/types/index.ts b/packages/swapper-sdk/src/types/index.ts new file mode 100644 index 00000000..75723627 --- /dev/null +++ b/packages/swapper-sdk/src/types/index.ts @@ -0,0 +1,5 @@ +export * from "./chains"; +export * from "./quotes"; +export * from "./initialize"; +export * from "./bridge"; +export * from "./common"; \ No newline at end of file diff --git a/packages/swapper-sdk/src/types/initialize.ts b/packages/swapper-sdk/src/types/initialize.ts new file mode 100644 index 00000000..41f67485 --- /dev/null +++ b/packages/swapper-sdk/src/types/initialize.ts @@ -0,0 +1,51 @@ +import type { ChainID } from "./chains"; + +export interface SwapperInitializeRequestParams { + type: "buy" | "sell" | "swap"; + + network?: ChainID; + buyCaip19?: string; + sellCaip19?: string; + + address?: string; + addresses?: Record; + cashAddress?: string; + cashAddresses?: Record; + takerCaip19?: string; + takerDestinationCaip19?: string; + settings?: SwapperSettings; +} + +export interface SwapperSettings { + priorityFee?: number; + tip?: number; +} + +export interface SwapperInitializeResults { + buyToken?: FungibleMetadata; + sellToken?: FungibleMetadata; + buyTokenPrice?: SwapperPriceData; + sellTokenPrice?: SwapperPriceData; + maxSellAmount?: string; +} + +export interface FungibleMetadata { + address: string; + chainId: ChainID; + symbol: string; + name: string; + decimals: number; + logoUri?: string; + coingeckoId?: string; + isNative?: boolean; +} + +export interface SwapperPriceData { + usd?: number; + usd_24h_change?: number; + price?: number; + priceChange24h?: number; + currencyValue?: number; + currencyChange?: number; + lastUpdatedAt?: string; +} \ No newline at end of file diff --git a/packages/swapper-sdk/src/types/quotes.ts b/packages/swapper-sdk/src/types/quotes.ts new file mode 100644 index 00000000..a07523bb --- /dev/null +++ b/packages/swapper-sdk/src/types/quotes.ts @@ -0,0 +1,155 @@ +import type { ChainID, FeeType, SwapperCaip19, SwapType } from "./chains"; + +export interface SwapperQuotesBody { + taker: SwapperCaip19; + buyToken: SwapperCaip19; + sellToken: SwapperCaip19; + sellAmount: string; + + takerDestination?: SwapperCaip19; + exactOut?: boolean; + base64EncodedTx?: boolean; + autoSlippage?: boolean; + country?: string; + slippageTolerance?: number; + priorityFee?: number; + tipAmount?: number; + sellAmountUsd?: string; + sellTokenBalance?: string; + solBalanceInLamport?: string; + isLedger?: boolean; + phantomCashAccount?: boolean; +} + +export interface SwapperQuotesDataRepresentation { + type: SwapType; + quotes: SwapperQuote[]; + + taker: SwapperCaip19; + buyToken: SwapperCaip19; + sellToken: SwapperCaip19; + slippageTolerance: number; + simulationTolerance?: number; + gasBuffer?: number; + analyticsContext?: string; + includesAllProviders?: boolean; +} + +export interface SwapperSource { + name: string; + proportion: string; +} + +export interface SwapperFee { + name: string; + percentage: number; + token: SwapperCaip19; + amount: number; + type: FeeType; +} + +export interface SwapperProvider { + id: string; + name: string; + logoUri?: string; +} + +export interface RFQProps { + expireAt: number; + quoteId: string; +} + +interface BaseQuote { + sellAmount: string; + buyAmount: string; + slippageTolerance: number; + priceImpact: number; + sources: SwapperSource[]; + fees: SwapperFee[]; + baseProvider: SwapperProvider; + simulationFailed?: boolean; + analyticsContext?: string; + rfqProps?: RFQProps; +} + +export interface SwapperSolanaQuoteRepresentation extends BaseQuote { + transactionData: string[]; + gaslessSignature?: string; + gaslessSwapFeeResult?: CalculateGaslessSwapFeeResult; +} + +export interface SwapperEvmQuoteRepresentation extends BaseQuote { + allowanceTarget: string; + approvalExactAmount?: string; + exchangeAddress: string; + value: string; + transactionData: string; + gas: number; +} + +export interface SwapperXChainQuoteRepresentation { + sellAmount: string; + buyAmount: string; + slippageTolerance: number; + executionDuration: number; + tags?: string[]; + steps: SwapperXChainStep[]; + baseProvider: SwapperProvider; +} + +export interface SwapperXChainStep { + transactionData: string; + buyToken: SwapperCaip19; + sellToken: SwapperCaip19; + nonIncludedNonGasFees: string; + includedFees: string; + feeCosts: BridgeFee[]; + includedFeeCosts: BridgeFee[]; + chainId: ChainID; + tool: BridgeTool; + value?: string; + allowanceTarget?: string; + approvalExactAmount?: string; + approvalMetadata?: ApprovalMetadata; + exchangeAddress?: string; + gasCosts?: number[]; + id?: string; +} + +export interface SwapperSuiQuoteRepresentation extends BaseQuote { + transactionData: string[]; + tradeFee?: string[]; + estimateGasFee?: string[]; +} + +export type SwapperQuote = + | SwapperSolanaQuoteRepresentation + | SwapperEvmQuoteRepresentation + | SwapperXChainQuoteRepresentation + | SwapperSuiQuoteRepresentation; + +export interface BridgeTool { + key: string; + name: string; + logoURI: string; +} + +export interface BridgeFee { + amount: string; + amountUSD?: string; + description: string; + included: boolean; + name: string; + percentage: string; + token: SwapperCaip19; +} + +export interface ApprovalMetadata { + name: string; + symbol: string; +} + +export interface CalculateGaslessSwapFeeResult { + fee?: number; + error?: string; +} \ No newline at end of file diff --git a/packages/swapper-sdk/tests/client.test.ts b/packages/swapper-sdk/tests/client.test.ts new file mode 100644 index 00000000..38577d61 --- /dev/null +++ b/packages/swapper-sdk/tests/client.test.ts @@ -0,0 +1,182 @@ +import { SwapperAPIClient } from "../src/api/client"; + +describe("SwapperAPIClient", () => { + let client: SwapperAPIClient; + const mockFetch = global.fetch as jest.MockedFunction; + + beforeEach(() => { + client = new SwapperAPIClient({ + apiUrl: "https://api.test.com", + }); + }); + + describe("constructor", () => { + it("should initialize with default config", () => { + const defaultClient = new SwapperAPIClient(); + expect(defaultClient.getBaseUrl()).toBe("https://api.phantom.app/swap/v2"); + }); + + it("should initialize with custom config", () => { + const customClient = new SwapperAPIClient({ + apiUrl: "https://custom.api.com", + headers: { + "X-Phantom-Version": "1.0.0", + }, + }); + expect(customClient.getBaseUrl()).toBe("https://custom.api.com/swap/v2"); + }); + + it("should use environment variables for headers", () => { + process.env.PHANTOM_SERVICE_AUTH_TOKEN = "test-token"; + process.env.PHANTOM_CLIENT_VERSION = "1.0.0"; + process.env.PHANTOM_CLIENT_PLATFORM = "test-platform"; + process.env.PHANTOM_COUNTRY_CODE = "US"; + + const envClient = new SwapperAPIClient(); + expect(envClient).toBeDefined(); + + delete process.env.PHANTOM_SERVICE_AUTH_TOKEN; + delete process.env.PHANTOM_CLIENT_VERSION; + delete process.env.PHANTOM_CLIENT_PLATFORM; + delete process.env.PHANTOM_COUNTRY_CODE; + }); + }); + + describe("get", () => { + it("should make GET request successfully", async () => { + const mockData = { test: "data" }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockData, + } as Response); + + const result = await client.get("/test"); + + expect(result).toEqual(mockData); + expect(mockFetch).toHaveBeenCalledWith( + "https://api.test.com/swap/v2/test", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + "Content-Type": "application/json", + }), + }) + ); + }); + + it("should handle query parameters", async () => { + const mockData = { test: "data" }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockData, + } as Response); + + await client.get("/test", { + param1: "value1", + param2: 123, + param3: true, + param4: undefined, + }); + + const calledUrl = (mockFetch.mock.calls[0][0] as string); + expect(calledUrl).toContain("param1=value1"); + expect(calledUrl).toContain("param2=123"); + expect(calledUrl).toContain("param3=true"); + expect(calledUrl).not.toContain("param4"); + }); + }); + + describe("post", () => { + it("should make POST request successfully", async () => { + const mockData = { result: "success" }; + const requestBody = { test: "body" }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockData, + } as Response); + + const result = await client.post("/test", requestBody); + + expect(result).toEqual(mockData); + expect(mockFetch).toHaveBeenCalledWith( + "https://api.test.com/swap/v2/test", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + "Content-Type": "application/json", + }), + body: JSON.stringify(requestBody), + }) + ); + }); + }); + + describe("error handling", () => { + it("should handle HTTP errors", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + json: async () => ({ + code: "BAD_REQUEST", + message: "Invalid request", + }), + } as Response); + + await expect(client.get("/test")).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: "Invalid request", + statusCode: 400, + }); + }); + + it("should handle network errors", async () => { + mockFetch.mockRejectedValueOnce(new Error("Network error")); + + await expect(client.get("/test")).rejects.toThrow("Network error"); + }); + + it("should handle timeout", async () => { + const slowClient = new SwapperAPIClient({ + timeout: 100, + }); + + mockFetch.mockImplementationOnce( + () => new Promise((resolve) => setTimeout(resolve, 200)) + ); + + await expect(slowClient.get("/test")).rejects.toMatchObject({ + code: "TIMEOUT", + message: "Request timed out", + statusCode: 408, + }); + }); + + it("should handle JSON parse errors", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => { + throw new Error("Invalid JSON"); + }, + } as Response); + + await expect(client.get("/test")).rejects.toMatchObject({ + code: "UNKNOWN_ERROR", + message: "Request failed with status 500", + statusCode: 500, + }); + }); + }); + + describe("updateHeaders", () => { + it("should update headers", () => { + expect(() => { + client.updateHeaders({ + "X-Phantom-Version": "2.0.0", + Authorization: "Bearer new-token", + }); + }).not.toThrow(); + }); + }); +}); \ No newline at end of file diff --git a/packages/swapper-sdk/tests/setup.ts b/packages/swapper-sdk/tests/setup.ts new file mode 100644 index 00000000..da564dd9 --- /dev/null +++ b/packages/swapper-sdk/tests/setup.ts @@ -0,0 +1,12 @@ +import "dotenv/config"; + +global.fetch = jest.fn(); + +beforeEach(() => { + jest.clearAllMocks(); + jest.resetModules(); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); \ No newline at end of file diff --git a/packages/swapper-sdk/tests/swapper-sdk.test.ts b/packages/swapper-sdk/tests/swapper-sdk.test.ts new file mode 100644 index 00000000..0c9dc03c --- /dev/null +++ b/packages/swapper-sdk/tests/swapper-sdk.test.ts @@ -0,0 +1,204 @@ +import { SwapperSDK } from "../src/swapper-sdk"; +import { ChainID } from "../src/types"; + +describe("SwapperSDK", () => { + let sdk: SwapperSDK; + const mockFetch = global.fetch as jest.MockedFunction; + + beforeEach(() => { + sdk = new SwapperSDK({ + apiUrl: "https://api.test.com", + debug: false, + }); + }); + + describe("constructor", () => { + it("should initialize with default config", () => { + const defaultSdk = new SwapperSDK(); + expect(defaultSdk).toBeDefined(); + }); + + it("should initialize with custom config", () => { + const customSdk = new SwapperSDK({ + apiUrl: "https://custom.api.com", + headers: { + "X-Phantom-Version": "1.0.0", + }, + debug: true, + }); + expect(customSdk).toBeDefined(); + }); + }); + + describe("getQuotes", () => { + it("should fetch quotes successfully", async () => { + const mockResponse = { + type: "solana", + quotes: [], + taker: { + chainId: ChainID.SolanaMainnet, + resourceType: "address" as const, + address: "test-address", + }, + buyToken: { + chainId: ChainID.SolanaMainnet, + resourceType: "address" as const, + address: "buy-token", + }, + sellToken: { + chainId: ChainID.SolanaMainnet, + resourceType: "nativeToken" as const, + slip44: "501", + }, + slippageTolerance: 0.5, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + const result = await sdk.getQuotes({ + taker: { + chainId: ChainID.SolanaMainnet, + resourceType: "address", + address: "test-address", + }, + buyToken: { + chainId: ChainID.SolanaMainnet, + resourceType: "address", + address: "buy-token", + }, + sellToken: { + chainId: ChainID.SolanaMainnet, + resourceType: "nativeToken", + slip44: "501", + }, + sellAmount: "1000000000", + }); + + expect(result).toEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("should handle errors when fetching quotes", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + json: async () => ({ + code: "INVALID_TOKEN_PAIR", + message: "Tokens not swappable", + }), + } as Response); + + await expect( + sdk.getQuotes({ + taker: { + chainId: ChainID.SolanaMainnet, + resourceType: "address", + address: "test-address", + }, + buyToken: { + chainId: ChainID.SolanaMainnet, + resourceType: "address", + address: "buy-token", + }, + sellToken: { + chainId: ChainID.SolanaMainnet, + resourceType: "nativeToken", + slip44: "501", + }, + sellAmount: "1000000000", + }) + ).rejects.toMatchObject({ + code: "INVALID_TOKEN_PAIR", + message: "Tokens not swappable", + statusCode: 400, + }); + }); + }); + + describe("initializeSwap", () => { + it("should initialize swap successfully", async () => { + const mockResponse = { + buyToken: { + address: "token-address", + chainId: ChainID.SolanaMainnet, + symbol: "USDC", + name: "USD Coin", + decimals: 6, + }, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + const result = await sdk.initializeSwap({ + type: "swap", + network: ChainID.SolanaMainnet, + }); + + expect(result).toEqual(mockResponse); + }); + }); + + describe("getPermissions", () => { + it("should fetch permissions successfully", async () => { + const mockResponse = { + perps: { + actions: true, + }, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + const result = await sdk.getPermissions(); + + expect(result).toEqual(mockResponse); + }); + }); + + describe("getBridgeableTokens", () => { + it("should fetch bridgeable tokens successfully", async () => { + const mockResponse = { + tokens: [ + { + chainId: ChainID.SolanaMainnet, + resourceType: "address" as const, + address: "token1", + }, + { + chainId: ChainID.EthereumMainnet, + resourceType: "address" as const, + address: "token2", + }, + ], + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + const result = await sdk.getBridgeableTokens(); + + expect(result).toEqual(mockResponse); + }); + }); + + describe("updateHeaders", () => { + it("should update headers", () => { + expect(() => { + sdk.updateHeaders({ + "X-Phantom-Version": "2.0.0", + "X-Phantom-Platform": "test", + }); + }).not.toThrow(); + }); + }); +}); \ No newline at end of file diff --git a/packages/swapper-sdk/tsconfig.json b/packages/swapper-sdk/tsconfig.json new file mode 100644 index 00000000..eba96768 --- /dev/null +++ b/packages/swapper-sdk/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} \ No newline at end of file diff --git a/packages/swapper-sdk/tsup.config.ts b/packages/swapper-sdk/tsup.config.ts new file mode 100644 index 00000000..34658ec3 --- /dev/null +++ b/packages/swapper-sdk/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["cjs", "esm"], + dts: true, + clean: true, + sourcemap: true, + minify: false, + external: ["eventsource"], +}); \ No newline at end of file From 015219d9e9132cc84784caa6ce75e5edd213c5c3 Mon Sep 17 00:00:00 2001 From: Rafael Date: Sat, 9 Aug 2025 21:10:35 +0200 Subject: [PATCH 2/4] integration tests and swapper client --- packages/browser-sdk/README.md | 1 - packages/client/README.md | 1 - packages/client/package.json | 1 + packages/client/src/PhantomClient.ts | 3 +- packages/client/src/SwapperClient.ts | 363 ++++++++++++++ packages/client/src/caip2-mappings.ts | 15 +- packages/client/src/index.ts | 1 + packages/client/tests/SwapperClient.test.ts | 441 ++++++++++++++++++ packages/react-sdk/README.md | 1 - packages/server-sdk/README.md | 1 - packages/swapper-sdk/.env.example | 14 - packages/swapper-sdk/README.md | 276 +++++++---- packages/swapper-sdk/package.json | 5 +- packages/swapper-sdk/src/api/bridge.ts | 6 +- packages/swapper-sdk/src/api/client.ts | 60 ++- packages/swapper-sdk/src/api/initialize.ts | 4 +- packages/swapper-sdk/src/constants/tokens.ts | 201 ++++++++ packages/swapper-sdk/src/index.ts | 32 +- packages/swapper-sdk/src/swapper-sdk.ts | 38 +- packages/swapper-sdk/src/types/bridge.ts | 10 +- packages/swapper-sdk/src/types/chains.ts | 37 +- packages/swapper-sdk/src/types/common.ts | 2 + packages/swapper-sdk/src/types/index.ts | 3 +- packages/swapper-sdk/src/types/initialize.ts | 2 +- packages/swapper-sdk/src/types/networks.ts | 161 +++++++ packages/swapper-sdk/src/types/public-api.ts | 42 ++ packages/swapper-sdk/src/types/quotes.ts | 3 +- .../swapper-sdk/src/utils/transformers.ts | 120 +++++ packages/swapper-sdk/tests/client.test.ts | 30 +- .../swapper-sdk/tests/integration.test.ts | 266 +++++++++++ .../swapper-sdk/tests/swapper-sdk.test.ts | 70 ++- yarn.lock | 26 ++ 32 files changed, 1980 insertions(+), 256 deletions(-) create mode 100644 packages/client/src/SwapperClient.ts create mode 100644 packages/client/tests/SwapperClient.test.ts delete mode 100644 packages/swapper-sdk/.env.example create mode 100644 packages/swapper-sdk/src/constants/tokens.ts create mode 100644 packages/swapper-sdk/src/types/networks.ts create mode 100644 packages/swapper-sdk/src/types/public-api.ts create mode 100644 packages/swapper-sdk/src/utils/transformers.ts create mode 100644 packages/swapper-sdk/tests/integration.test.ts diff --git a/packages/browser-sdk/README.md b/packages/browser-sdk/README.md index e8e7634b..887e413f 100644 --- a/packages/browser-sdk/README.md +++ b/packages/browser-sdk/README.md @@ -529,7 +529,6 @@ import { NetworkId } from "@phantom/browser-sdk"; - `NetworkId.ETHEREUM_SEPOLIA` - Ethereum Sepolia Testnet - `NetworkId.POLYGON_MAINNET` - Polygon Mainnet - `NetworkId.ARBITRUM_ONE` - Arbitrum One -- `NetworkId.OPTIMISM_MAINNET` - Optimism Mainnet - `NetworkId.BASE_MAINNET` - Base Mainnet ### Bitcoin diff --git a/packages/client/README.md b/packages/client/README.md index 0a99a369..f45ba7cd 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -139,7 +139,6 @@ await client.signAndSendTransaction({ // Other supported networks NetworkId.POLYGON_MAINNET; -NetworkId.OPTIMISM_MAINNET; NetworkId.ARBITRUM_ONE; NetworkId.BASE_MAINNET; // ... and more diff --git a/packages/client/package.json b/packages/client/package.json index a77327d3..dcb2c468 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -40,6 +40,7 @@ "@phantom/base64url": "workspace:^", "@phantom/crypto": "workspace:^", "@phantom/openapi-wallet-service": "^0.1.7", + "@phantom/swapper-sdk": "workspace:^", "axios": "^1.10.0", "bs58": "^6.0.0" }, diff --git a/packages/client/src/PhantomClient.ts b/packages/client/src/PhantomClient.ts index 8c63cfb4..cdeb8a64 100644 --- a/packages/client/src/PhantomClient.ts +++ b/packages/client/src/PhantomClient.ts @@ -36,6 +36,7 @@ import { KmsUserRole, Algorithm, type ExternalKmsOrganization, + type DerivationInfoAddressFormatEnum as AddressType, } from "@phantom/openapi-wallet-service"; import { DerivationPath, getNetworkConfig } from "./constants"; import { deriveSubmissionConfig } from "./caip2-mappings"; @@ -219,7 +220,7 @@ export class PhantomClient { async getWalletAddresses( walletId: string, derivationPaths?: string[], - ): Promise<{ addressType: string; address: string }[]> { + ): Promise<{ addressType: AddressType; address: string }[]> { try { const paths = derivationPaths || [ DerivationPath.Solana, diff --git a/packages/client/src/SwapperClient.ts b/packages/client/src/SwapperClient.ts new file mode 100644 index 00000000..77f07ab9 --- /dev/null +++ b/packages/client/src/SwapperClient.ts @@ -0,0 +1,363 @@ +import type { PhantomClient } from "./PhantomClient"; +import SwapperSDK, { + type GetQuotesParams, + type SwapperSolanaQuoteRepresentation, + type SwapperEvmQuoteRepresentation, + type GenerateAndVerifyAddressResponse, + type GetBridgeableTokensResponse, + type GetIntentsStatusResponse, + type Token, + type SwapperNetworkId, +} from "@phantom/swapper-sdk"; +import { getNetworkConfig } from "./constants"; +import type { SignedTransaction } from "./types"; +import type { NetworkId } from "./caip2-mappings"; + +// Re-export types that users will need +export type { Token } from "@phantom/swapper-sdk"; + +interface SwapParams { + walletId: string; + sellToken: Token; + buyToken: Token; + sellAmount: string; + slippageTolerance?: number; + autoSlippage?: boolean; +} + +interface BridgeParams { + walletId: string; + sellToken: Token; + buyToken: Token; + sellAmount: string; + destinationNetworkId: NetworkId; + slippageTolerance?: number; + autoSlippage?: boolean; +} + +interface RetryOptions { + maxAttempts?: number; + initialDelay?: number; + maxDelay?: number; + backoffFactor?: number; +} + +export class SwapperClient { + private phantomClient: PhantomClient; + private swapperSDK: SwapperSDK; + private defaultRetryOptions: RetryOptions = { + maxAttempts: 3, + initialDelay: 1000, + maxDelay: 10000, + backoffFactor: 2, + }; + + constructor(phantomClient: PhantomClient, swapperConfig?: { apiUrl?: string; debug?: boolean }) { + this.phantomClient = phantomClient; + this.swapperSDK = new SwapperSDK({ + apiUrl: swapperConfig?.apiUrl, + options: { debug: swapperConfig?.debug }, + }); + } + + private async delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + private async retryWithBackoff( + fn: () => Promise, + options: RetryOptions = {}, + ): Promise { + const opts = { ...this.defaultRetryOptions, ...options }; + let lastError: Error | undefined; + let delay = opts.initialDelay!; + + for (let attempt = 1; attempt <= opts.maxAttempts!; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error as Error; + + if (attempt === opts.maxAttempts) { + break; + } + + // Log retry attempt + // console.log(`Attempt ${attempt} failed, retrying after ${delay}ms...`, error); + await this.delay(delay); + + delay = Math.min(delay * opts.backoffFactor!, opts.maxDelay!); + } + } + + throw lastError || new Error("Max retry attempts reached"); + } + + private isEvmNetwork(networkId: NetworkId): boolean { + return networkId.startsWith("eip155:"); + } + + private async approveTokenIfNeeded( + walletId: string, + quote: SwapperEvmQuoteRepresentation, + networkId: NetworkId, + ): Promise { + if (!quote.approvalExactAmount || quote.approvalExactAmount === "0") { + return; + } + + // Token approval required + // console.log(`Token approval required. Amount: ${quote.approvalExactAmount}`); + + // TODO: Implement ERC20 approval transaction + // This would involve: + // 1. Creating an approval transaction for the token contract + // 2. Signing and sending it via phantomClient.signAndSendTransaction + // For now, we'll throw an error indicating manual approval is needed + + throw new Error( + `Token approval required for ${quote.approvalExactAmount}. ` + + `Please approve the token at ${quote.allowanceTarget} before swapping.` + ); + } + + async swap(params: SwapParams): Promise { + try { + // Get all wallet addresses (uses default derivation paths) + const addresses = await this.phantomClient.getWalletAddresses(params.walletId); + + // Find the right address based on the network + const networkConfig = getNetworkConfig(params.sellToken.networkId as NetworkId); + if (!networkConfig) { + throw new Error(`Unsupported network: ${params.sellToken.networkId}`); + } + + const walletAddress = addresses.find( + addr => addr.addressType === networkConfig.addressFormat as any + ); + + if (!walletAddress) { + throw new Error( + `No ${networkConfig.addressFormat} address found for wallet ${params.walletId}` + ); + } + + // Prepare quote parameters + const quoteParams: GetQuotesParams = { + sellToken: params.sellToken, + buyToken: params.buyToken, + sellAmount: params.sellAmount, + from: { + address: walletAddress.address, + networkId: params.sellToken.networkId as SwapperNetworkId, + }, + slippageTolerance: params.slippageTolerance, + autoSlippage: params.autoSlippage, + }; + + // Get quotes with retry logic + const quotesResponse = await this.retryWithBackoff( + () => this.swapperSDK.getQuotes(quoteParams) + ); + + if (!quotesResponse.quotes || quotesResponse.quotes.length === 0) { + throw new Error("No swap quotes available"); + } + + // Get the first (best) quote + const quote = quotesResponse.quotes[0]; + + // Handle different quote types + let transactionData: string; + + if ("transactionData" in quote) { + if (Array.isArray(quote.transactionData)) { + // Solana quote + const solanaQuote = quote as SwapperSolanaQuoteRepresentation; + if (solanaQuote.transactionData.length === 0) { + throw new Error("No transaction data in Solana quote"); + } + transactionData = solanaQuote.transactionData[0]; + } else { + // EVM quote + const evmQuote = quote as SwapperEvmQuoteRepresentation; + + // Check if token approval is needed for EVM chains + if (this.isEvmNetwork(params.sellToken.networkId as NetworkId)) { + await this.approveTokenIfNeeded(params.walletId, evmQuote, params.sellToken.networkId as NetworkId); + } + + transactionData = evmQuote.transactionData; + } + } else { + throw new Error("Unsupported quote type - no transaction data found"); + } + + // Sign and send the transaction + const result = await this.phantomClient.signAndSendTransaction({ + walletId: params.walletId, + transaction: transactionData, + networkId: params.sellToken.networkId as NetworkId, + }); + + return result; + } catch (error) { + // Log swap failure + // console.error("Swap failed:", error); + throw error; + } + } + + async bridge(params: BridgeParams): Promise { + try { + // Get all wallet addresses + const addresses = await this.phantomClient.getWalletAddresses(params.walletId); + + // Find source address + const sourceNetworkConfig = getNetworkConfig(params.sellToken.networkId as NetworkId); + if (!sourceNetworkConfig) { + throw new Error(`Unsupported source network: ${params.sellToken.networkId}`); + } + + const sourceAddress = addresses.find( + addr => addr.addressType === sourceNetworkConfig.addressFormat as any + ); + + if (!sourceAddress) { + throw new Error( + `No ${sourceNetworkConfig.addressFormat} address found for wallet ${params.walletId}` + ); + } + + // Find destination address + const destNetworkConfig = getNetworkConfig(params.destinationNetworkId); + if (!destNetworkConfig) { + throw new Error(`Unsupported destination network: ${params.destinationNetworkId}`); + } + + const destAddress = addresses.find( + addr => addr.addressType === destNetworkConfig.addressFormat as any + ); + + if (!destAddress) { + throw new Error( + `No ${destNetworkConfig.addressFormat} address found for wallet ${params.walletId}` + ); + } + + // Prepare quote parameters for bridge (cross-chain swap) + const quoteParams: GetQuotesParams = { + sellToken: params.sellToken, + buyToken: params.buyToken, + sellAmount: params.sellAmount, + from: { + address: sourceAddress.address, + networkId: params.sellToken.networkId as SwapperNetworkId, + }, + to: { + address: destAddress.address, + networkId: params.destinationNetworkId as SwapperNetworkId, + }, + slippageTolerance: params.slippageTolerance, + autoSlippage: params.autoSlippage, + }; + + // Get bridge quotes with retry logic + const quotesResponse = await this.retryWithBackoff( + () => this.swapperSDK.getQuotes(quoteParams) + ); + + if (!quotesResponse.quotes || quotesResponse.quotes.length === 0) { + throw new Error("No bridge quotes available"); + } + + // Get the first (best) quote + const quote = quotesResponse.quotes[0]; + + // Handle different quote types for bridge + let transactionData: string; + + if ("transactionData" in quote) { + if (Array.isArray(quote.transactionData)) { + // Solana bridge quote + const solanaQuote = quote as SwapperSolanaQuoteRepresentation; + if (solanaQuote.transactionData.length === 0) { + throw new Error("No transaction data in Solana bridge quote"); + } + transactionData = solanaQuote.transactionData[0]; + } else { + // EVM bridge quote + const evmQuote = quote as SwapperEvmQuoteRepresentation; + + // Check if token approval is needed for EVM chains + if (this.isEvmNetwork(params.sellToken.networkId as NetworkId)) { + await this.approveTokenIfNeeded(params.walletId, evmQuote, params.sellToken.networkId as NetworkId); + } + + transactionData = evmQuote.transactionData; + } + } else if ("steps" in quote) { + // Cross-chain quote with steps + throw new Error( + "Multi-step bridge transactions are not yet supported. " + + "Please use the SwapperSDK directly for complex bridge operations." + ); + } else { + throw new Error("Unsupported bridge quote type"); + } + + // Sign and send the bridge transaction + const result = await this.phantomClient.signAndSendTransaction({ + walletId: params.walletId, + transaction: transactionData, + networkId: params.sellToken.networkId as NetworkId, + }); + + return result; + } catch (error) { + // Log bridge failure + // console.error("Bridge failed:", error); + throw error; + } + } + + async getBridgeableTokens(): Promise { + return this.swapperSDK.getBridgeableTokens(); + } + + async getBridgeStatus(requestId: string): Promise { + return this.swapperSDK.getIntentsStatus({ requestId }); + } + + async initializeBridge( + walletId: string, + sellToken: string, + buyToken: string, + destinationNetworkId: NetworkId, + ): Promise { + // Get all wallet addresses + const addresses = await this.phantomClient.getWalletAddresses(walletId); + + // Find destination address + const destNetworkConfig = getNetworkConfig(destinationNetworkId); + if (!destNetworkConfig) { + throw new Error(`Unsupported destination network: ${destinationNetworkId}`); + } + + const destAddress = addresses.find( + addr => addr.addressType === destNetworkConfig.addressFormat + ); + + if (!destAddress) { + throw new Error( + `No ${destNetworkConfig.addressFormat} address found for wallet ${walletId}` + ); + } + + return this.swapperSDK.initializeBridge({ + sellToken, + buyToken, + takerDestination: destAddress.address, + }); + } +} \ No newline at end of file diff --git a/packages/client/src/caip2-mappings.ts b/packages/client/src/caip2-mappings.ts index 611ab993..9a1def0b 100644 --- a/packages/client/src/caip2-mappings.ts +++ b/packages/client/src/caip2-mappings.ts @@ -23,10 +23,6 @@ export enum NetworkId { POLYGON_MAINNET = "eip155:137", POLYGON_MUMBAI = "eip155:80001", - // Optimism Networks - OPTIMISM_MAINNET = "eip155:10", - OPTIMISM_GOERLI = "eip155:420", - // Arbitrum Networks ARBITRUM_ONE = "eip155:42161", ARBITRUM_GOERLI = "eip155:421613", @@ -106,16 +102,7 @@ const CAIP2_NETWORK_MAPPINGS: Record = { network: "mumbai", description: "Polygon Mumbai Testnet", }, - [NetworkId.OPTIMISM_MAINNET]: { - chain: "optimism", - network: "mainnet", - description: "Optimism Mainnet", - }, - [NetworkId.OPTIMISM_GOERLI]: { - chain: "optimism", - network: "goerli", - description: "Optimism Goerli Testnet", - }, + [NetworkId.ARBITRUM_ONE]: { chain: "arbitrum", network: "mainnet", diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 40d774ca..301a9ad7 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,4 +1,5 @@ export { PhantomClient } from "./PhantomClient"; +export { SwapperClient } from "./SwapperClient"; export { generateKeyPair, type Keypair } from "@phantom/crypto"; export * from "./types"; export * from "./caip2-mappings"; diff --git a/packages/client/tests/SwapperClient.test.ts b/packages/client/tests/SwapperClient.test.ts new file mode 100644 index 00000000..e116067f --- /dev/null +++ b/packages/client/tests/SwapperClient.test.ts @@ -0,0 +1,441 @@ +import { describe, it, expect, jest, beforeEach } from "@jest/globals"; +import { SwapperClient } from "../src/SwapperClient"; +import { PhantomClient } from "../src/PhantomClient"; +import { NetworkId } from "../src/caip2-mappings"; +import { AddressType } from "../src"; +import type { SignedTransaction } from "../src/types"; + +jest.mock("../src/PhantomClient"); + +describe("SwapperClient", () => { + let swapperClient: SwapperClient; + let mockPhantomClient: jest.Mocked; + let mockSwapperSDK: any; + + beforeEach(() => { + mockPhantomClient = { + getWalletAddresses: jest.fn(), + signAndSendTransaction: jest.fn(), + } as any; + + mockSwapperSDK = { + getQuotes: jest.fn(), + getBridgeableTokens: jest.fn(), + getIntentsStatus: jest.fn(), + initializeBridge: jest.fn(), + }; + + swapperClient = new SwapperClient(mockPhantomClient); + // Override the swapperSDK instance with our mock + (swapperClient as any).swapperSDK = mockSwapperSDK; + }); + + describe("swap", () => { + it("should successfully execute a swap on Solana", async () => { + const walletId = "test-wallet-id"; + const transactionData = "base64EncodedTransaction"; + const signedTransaction: SignedTransaction = { + rawTransaction: "signedBase64Transaction", + }; + + // Mock wallet addresses retrieval (returns all addresses) + mockPhantomClient.getWalletAddresses.mockResolvedValue([ + { addressType: AddressType.solana, address: "SoLaNaAdDrEsS123" }, + { addressType: AddressType.ethereum, address: "0xEthAddress123" }, + { addressType: AddressType.bitcoinSegwit, address: "bc1qAddress123" }, + ]); + + // Mock quote response + mockSwapperSDK.getQuotes.mockResolvedValue({ + type: "swap", + quotes: [ + { + sellAmount: "1000000", + buyAmount: "2000000", + slippageTolerance: 0.01, + priceImpact: 0.005, + sources: [], + fees: [], + baseProvider: { id: "jupiter", name: "Jupiter" }, + transactionData: [transactionData], + }, + ], + }); + + // Mock sign and send + mockPhantomClient.signAndSendTransaction.mockResolvedValue(signedTransaction); + + const result = await swapperClient.swap({ + walletId, + sellToken: { + type: "native", + networkId: NetworkId.SOLANA_MAINNET, + }, + buyToken: { + type: "address", + address: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", // USDC + networkId: NetworkId.SOLANA_MAINNET, + }, + sellAmount: "1000000", + slippageTolerance: 0.01, + }); + + expect(result).toBe(signedTransaction); + expect(mockPhantomClient.getWalletAddresses).toHaveBeenCalledWith(walletId); + expect(mockSwapperSDK.getQuotes).toHaveBeenCalled(); + expect(mockPhantomClient.signAndSendTransaction).toHaveBeenCalledWith({ + walletId, + transaction: transactionData, + networkId: NetworkId.SOLANA_MAINNET, + }); + }); + + it("should successfully execute a swap on Ethereum", async () => { + const walletId = "test-wallet-id"; + const transactionData = "0xEvmTransactionData"; + const signedTransaction: SignedTransaction = { + rawTransaction: "0xSignedEvmTransaction", + }; + + // Mock wallet addresses retrieval + mockPhantomClient.getWalletAddresses.mockResolvedValue([ + { addressType: AddressType.solana, address: "SoLaNaAdDrEsS123" }, + { addressType: AddressType.ethereum, address: "0xEthereumAddress123" }, + ]); + + // Mock quote response with EVM structure + mockSwapperSDK.getQuotes.mockResolvedValue({ + type: "swap", + quotes: [ + { + sellAmount: "1000000000000000000", + buyAmount: "2000000000", + slippageTolerance: 0.01, + priceImpact: 0.005, + sources: [], + fees: [], + baseProvider: { id: "uniswap", name: "Uniswap" }, + allowanceTarget: "0xAllowanceTarget", + approvalExactAmount: "0", // No approval needed + exchangeAddress: "0xExchangeAddress", + value: "0", + transactionData: transactionData, + gas: 200000, + }, + ], + }); + + // Mock sign and send + mockPhantomClient.signAndSendTransaction.mockResolvedValue(signedTransaction); + + const result = await swapperClient.swap({ + walletId, + sellToken: { + type: "native", + networkId: NetworkId.ETHEREUM_MAINNET, + }, + buyToken: { + type: "address", + address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC + networkId: NetworkId.ETHEREUM_MAINNET, + }, + sellAmount: "1000000000000000000", + slippageTolerance: 0.01, + }); + + expect(result).toBe(signedTransaction); + expect(mockPhantomClient.getWalletAddresses).toHaveBeenCalledWith(walletId); + expect(mockPhantomClient.signAndSendTransaction).toHaveBeenCalledWith({ + walletId, + transaction: transactionData, + networkId: NetworkId.ETHEREUM_MAINNET, + }); + }); + + it("should handle retry logic when getting quotes fails", async () => { + const walletId = "test-wallet-id"; + + mockPhantomClient.getWalletAddresses.mockResolvedValue([ + { addressType: AddressType.solana, address: "SoLaNaAdDrEsS123" }, + ]); + + // Mock quote failures then success + mockSwapperSDK.getQuotes + .mockRejectedValueOnce(new Error("Network error")) + .mockRejectedValueOnce(new Error("Timeout")) + .mockResolvedValueOnce({ + type: "swap", + quotes: [ + { + sellAmount: "1000000", + buyAmount: "2000000", + slippageTolerance: 0.01, + priceImpact: 0.005, + sources: [], + fees: [], + baseProvider: { id: "jupiter", name: "Jupiter" }, + transactionData: ["base64Transaction"], + }, + ], + }); + + mockPhantomClient.signAndSendTransaction.mockResolvedValue({ + rawTransaction: "signed", + }); + + // Reduce delays for testing + (swapperClient as any).defaultRetryOptions = { + maxAttempts: 3, + initialDelay: 10, + maxDelay: 100, + backoffFactor: 2, + }; + + const result = await swapperClient.swap({ + walletId, + sellToken: { + type: "native", + networkId: NetworkId.SOLANA_MAINNET, + }, + buyToken: { + type: "address", + address: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + networkId: NetworkId.SOLANA_MAINNET, + }, + sellAmount: "1000000", + }); + + expect(result.rawTransaction).toBe("signed"); + expect(mockSwapperSDK.getQuotes).toHaveBeenCalledTimes(3); + }); + + it("should throw error when no quotes are available", async () => { + const walletId = "test-wallet-id"; + mockPhantomClient.getWalletAddresses.mockResolvedValue([ + { addressType: AddressType.solana, address: "address" }, + ]); + + mockSwapperSDK.getQuotes.mockResolvedValue({ + type: "swap", + quotes: [], + }); + + await expect( + swapperClient.swap({ + walletId, + sellToken: { + type: "native", + networkId: NetworkId.SOLANA_MAINNET, + }, + buyToken: { + type: "address", + address: "token", + networkId: NetworkId.SOLANA_MAINNET, + }, + sellAmount: "1000000", + }) + ).rejects.toThrow("No swap quotes available"); + }); + + it("should throw error when wallet has no address for the network", async () => { + const walletId = "test-wallet-id"; + + // Mock wallet addresses - only has Ethereum address + mockPhantomClient.getWalletAddresses.mockResolvedValue([ + { addressType: AddressType.ethereum, address: "0xEthAddress" }, + ]); + + await expect( + swapperClient.swap({ + walletId, + sellToken: { + type: "native", + networkId: NetworkId.SOLANA_MAINNET, + }, + buyToken: { + type: "address", + address: "token", + networkId: NetworkId.SOLANA_MAINNET, + }, + sellAmount: "1000000", + }) + ).rejects.toThrow("No Solana address found for wallet test-wallet-id"); + }); + }); + + describe("bridge", () => { + it("should successfully execute a bridge transaction", async () => { + const walletId = "test-wallet-id"; + const transactionData = "0xBridgeTransaction"; + const signedTransaction: SignedTransaction = { + rawTransaction: "0xSignedBridge", + }; + + // Mock wallet addresses for both networks + mockPhantomClient.getWalletAddresses.mockResolvedValue([ + { addressType: AddressType.ethereum, address: "0xEthAddress" }, + { addressType: AddressType.solana, address: "SolanaAddress" }, + ]); + + // Mock bridge quote + mockSwapperSDK.getQuotes.mockResolvedValue({ + type: "bridge", + quotes: [ + { + sellAmount: "1000000000", + buyAmount: "2000000", + slippageTolerance: 0.01, + priceImpact: 0.005, + sources: [], + fees: [], + baseProvider: { id: "wormhole", name: "Wormhole" }, + allowanceTarget: "0xBridgeAllowance", + approvalExactAmount: "0", + exchangeAddress: "0xBridgeExchange", + value: "0", + transactionData: transactionData, + gas: 300000, + }, + ], + }); + + mockPhantomClient.signAndSendTransaction.mockResolvedValue(signedTransaction); + + const result = await swapperClient.bridge({ + walletId, + sellToken: { + type: "address", + address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC on Ethereum + networkId: NetworkId.ETHEREUM_MAINNET, + }, + buyToken: { + type: "address", + address: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", // USDC on Solana + networkId: NetworkId.SOLANA_MAINNET, + }, + sellAmount: "1000000000", + destinationNetworkId: NetworkId.SOLANA_MAINNET, + slippageTolerance: 0.01, + }); + + expect(result).toBe(signedTransaction); + expect(mockPhantomClient.getWalletAddresses).toHaveBeenCalledWith(walletId); + expect(mockSwapperSDK.getQuotes).toHaveBeenCalledWith( + expect.objectContaining({ + from: { address: "0xEthAddress", networkId: NetworkId.ETHEREUM_MAINNET }, + to: { address: "SolanaAddress", networkId: NetworkId.SOLANA_MAINNET }, + }) + ); + }); + + it("should throw error for multi-step bridge transactions", async () => { + const walletId = "test-wallet-id"; + + mockPhantomClient.getWalletAddresses.mockResolvedValue([ + { addressType: AddressType.ethereum, address: "0xAddr1" }, + { addressType: AddressType.solana, address: "SolAddr" }, + ]); + + // Mock cross-chain quote with steps + mockSwapperSDK.getQuotes.mockResolvedValue({ + type: "bridge", + quotes: [ + { + sellAmount: "1000000000", + buyAmount: "2000000", + slippageTolerance: 0.01, + executionDuration: 600, + steps: [ + { type: "swap", provider: "uniswap" }, + { type: "bridge", provider: "wormhole" }, + ], + baseProvider: { id: "multi", name: "Multi-step" }, + }, + ], + }); + + await expect( + swapperClient.bridge({ + walletId, + sellToken: { + type: "address", + address: "0xToken", + networkId: NetworkId.ETHEREUM_MAINNET, + }, + buyToken: { + type: "address", + address: "SolToken", + networkId: NetworkId.SOLANA_MAINNET, + }, + sellAmount: "1000000000", + destinationNetworkId: NetworkId.SOLANA_MAINNET, + }) + ).rejects.toThrow("Multi-step bridge transactions are not yet supported"); + }); + }); + + describe("helper methods", () => { + it("should get bridgeable tokens", async () => { + const mockTokens = { + tokens: ["solana:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"], + }; + + mockSwapperSDK.getBridgeableTokens.mockResolvedValue(mockTokens); + + const result = await swapperClient.getBridgeableTokens(); + + expect(result).toBe(mockTokens); + expect(mockSwapperSDK.getBridgeableTokens).toHaveBeenCalled(); + }); + + it("should get bridge status", async () => { + const requestId = "bridge-request-123"; + const mockStatus = { + status: "pending", + details: "Processing bridge", + inTxHashes: ["0xHash1"], + txHashes: [], + time: 1234567890, + originChainId: 1, + destinationChainId: 101, + }; + + mockSwapperSDK.getIntentsStatus.mockResolvedValue(mockStatus); + + const result = await swapperClient.getBridgeStatus(requestId); + + expect(result).toBe(mockStatus); + expect(mockSwapperSDK.getIntentsStatus).toHaveBeenCalledWith({ requestId }); + }); + + it("should initialize bridge", async () => { + const walletId = "test-wallet"; + const mockResponse = { + depositAddress: "bridge:deposit:address", + orderAssetId: 123, + usdcPrice: "1.0", + }; + + mockPhantomClient.getWalletAddresses.mockResolvedValue([ + { addressType: AddressType.ethereum, address: "0xEthAddr" }, + { addressType: AddressType.solana, address: "SolanaDestAddr" }, + ]); + + mockSwapperSDK.initializeBridge.mockResolvedValue(mockResponse); + + const result = await swapperClient.initializeBridge( + walletId, + "ethereum:0xUSDC", + "solana:USDC", + NetworkId.SOLANA_MAINNET + ); + + expect(result).toBe(mockResponse); + expect(mockSwapperSDK.initializeBridge).toHaveBeenCalledWith({ + sellToken: "ethereum:0xUSDC", + buyToken: "solana:USDC", + takerDestination: "SolanaDestAddr", + }); + }); + }); +}); \ No newline at end of file diff --git a/packages/react-sdk/README.md b/packages/react-sdk/README.md index 18d6ae92..63d942cb 100644 --- a/packages/react-sdk/README.md +++ b/packages/react-sdk/README.md @@ -404,7 +404,6 @@ The SDK automatically determines the transaction type from the NetworkId: - `NetworkId.ETHEREUM_SEPOLIA` - `NetworkId.POLYGON_MAINNET` - `NetworkId.ARBITRUM_ONE` -- `NetworkId.OPTIMISM_MAINNET` - `NetworkId.BASE_MAINNET` #### Bitcoin diff --git a/packages/server-sdk/README.md b/packages/server-sdk/README.md index 13d30f76..46b5d723 100644 --- a/packages/server-sdk/README.md +++ b/packages/server-sdk/README.md @@ -263,7 +263,6 @@ The SDK supports multiple blockchain networks through the `NetworkId` enum: - `NetworkId.POLYGON_MAINNET` - Polygon Mainnet - `NetworkId.POLYGON_MUMBAI` - Mumbai Testnet -- `NetworkId.OPTIMISM_MAINNET` - Optimism Mainnet - `NetworkId.ARBITRUM_ONE` - Arbitrum One - `NetworkId.BASE_MAINNET` - Base Mainnet diff --git a/packages/swapper-sdk/.env.example b/packages/swapper-sdk/.env.example deleted file mode 100644 index 1c532a17..00000000 --- a/packages/swapper-sdk/.env.example +++ /dev/null @@ -1,14 +0,0 @@ -# Swapper SDK Configuration - -# API Base URL (default: https://api.phantom.app) -PHANTOM_SWAPPER_API_URL=https://api.phantom.app - -# Optional: Service authentication token for fee removal -PHANTOM_SERVICE_AUTH_TOKEN= - -# Optional: Client identification -PHANTOM_CLIENT_VERSION= -PHANTOM_CLIENT_PLATFORM= - -# Optional: Country code for geo-blocking -PHANTOM_COUNTRY_CODE= \ No newline at end of file diff --git a/packages/swapper-sdk/README.md b/packages/swapper-sdk/README.md index 30150630..458dce8f 100644 --- a/packages/swapper-sdk/README.md +++ b/packages/swapper-sdk/README.md @@ -13,79 +13,89 @@ yarn add @phantom/swapper-sdk ## Quick Start ```typescript -import { SwapperSDK, ChainID } from '@phantom/swapper-sdk'; +import { SwapperSDK, NetworkId } from '@phantom/swapper-sdk'; // Initialize the SDK const swapper = new SwapperSDK({ apiUrl: 'https://api.phantom.app', // optional, this is the default - headers: { - 'X-Phantom-Version': '1.0.0', - 'X-Phantom-Platform': 'web', + options: { + organizationId: 'your-org-id', // from Phantom Portal + countryCode: 'US', // optional, for geo-blocking + debug: true, // optional, enables debug logging }, - debug: true, // optional, enables debug logging }); // Get swap quotes const quotes = await swapper.getQuotes({ - taker: { - chainId: ChainID.SolanaMainnet, - address: '8B3fBhkpHFMTdaQSfKVREB5HFKnZkT8rL6zKJfmmWDeZ', - resourceType: 'address', - }, sellToken: { - chainId: ChainID.SolanaMainnet, - slip44: '501', - resourceType: 'nativeToken', + type: 'native', + networkId: NetworkId.SOLANA_MAINNET, }, buyToken: { - chainId: ChainID.SolanaMainnet, + type: 'address', address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', - resourceType: 'address', + networkId: NetworkId.SOLANA_MAINNET, }, sellAmount: '1000000000', + from: { + address: '8B3fBhkpHFMTdaQSfKVREB5HFKnZkT8rL6zKJfmmWDeZ', + networkId: NetworkId.SOLANA_MAINNET, + }, slippageTolerance: 0.5, }); ``` ## Configuration -### Environment Variables +### Default API URL -Create a `.env` file based on `.env.example`: - -```env -# API Base URL (default: https://api.phantom.app) -PHANTOM_SWAPPER_API_URL=https://api.phantom.app - -# Optional: Service authentication token for fee removal -PHANTOM_SERVICE_AUTH_TOKEN= - -# Optional: Client identification -PHANTOM_CLIENT_VERSION= -PHANTOM_CLIENT_PLATFORM= - -# Optional: Country code for geo-blocking -PHANTOM_COUNTRY_CODE= +``` +https://api.phantom.app/swap/v2 ``` ### SDK Configuration ```typescript interface SwapperSDKConfig { - apiUrl?: string; // API base URL - headers?: { // Optional custom headers - 'X-Phantom-Version'?: string; - 'X-Phantom-Platform'?: string; - 'X-Phantom-AnonymousId'?: string; - 'cf-ipcountry'?: string; - 'cloudfront-viewer-country'?: string; - Authorization?: string; - }; + apiUrl?: string; // API base URL (default: https://api.phantom.app) timeout?: number; // Request timeout in ms (default: 30000) - debug?: boolean; // Enable debug logging + options?: { + organizationId?: string; // Organization ID from Phantom Portal + countryCode?: string; // Country code for geo-blocking (e.g., 'US') + anonymousId?: string; // Anonymous user ID for analytics + version?: string; // Client version + debug?: boolean; // Enable debug logging + }; } ``` +**Note:** The `countryCode` option can be used to check if a user is allowed to swap tokens based on their location. + +## Token Constants + +The SDK provides predefined token constants for easy use. Instead of manually constructing token objects, use the `TOKENS` object: + +```typescript +import { SwapperSDK, TOKENS } from '@phantom/swapper-sdk'; + +// Use predefined tokens +console.log(TOKENS.ETHEREUM_MAINNET.ETH); // Native ETH on Ethereum +console.log(TOKENS.ETHEREUM_MAINNET.USDC); // USDC on Ethereum +console.log(TOKENS.SOLANA_MAINNET.SOL); // Native SOL on Solana +console.log(TOKENS.BASE_MAINNET.ETH); // Native ETH on Base +console.log(TOKENS.ARBITRUM_ONE.USDC); // USDC on Arbitrum +console.log(TOKENS.POLYGON_MAINNET.MATIC); // Native MATIC on Polygon +``` + +### Available Networks + +Each network in `TOKENS` contains major tokens: +- `TOKENS.ETHEREUM_MAINNET`: ETH, USDC, USDT, WETH +- `TOKENS.BASE_MAINNET`: ETH, USDC, WETH +- `TOKENS.POLYGON_MAINNET`: MATIC, USDC, USDT, WETH +- `TOKENS.ARBITRUM_ONE`: ETH, USDC, USDT, WETH +- `TOKENS.SOLANA_MAINNET`: SOL, USDC, USDT, WSOL + ## API Methods ### Swap Operations @@ -94,21 +104,69 @@ interface SwapperSDKConfig { Get quotes for token swaps (same-chain) or bridges (cross-chain). +**Using Token Constants (Recommended):** +```typescript +import { SwapperSDK, TOKENS, NetworkId } from '@phantom/swapper-sdk'; + +// ETH to USDC swap on Ethereum +const quotes = await swapper.getQuotes({ + sellToken: TOKENS.ETHEREUM_MAINNET.ETH, + buyToken: TOKENS.ETHEREUM_MAINNET.USDC, + sellAmount: '1000000000000000000', // 1 ETH in wei + from: { + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f6D123', + networkId: NetworkId.ETHEREUM_MAINNET, + }, + slippageTolerance: 0.5, // 0.5% +}); + +// Cross-chain bridge: USDC from Ethereum to Solana +const bridgeQuotes = await swapper.getQuotes({ + sellToken: TOKENS.ETHEREUM_MAINNET.USDC, + buyToken: TOKENS.SOLANA_MAINNET.USDC, + sellAmount: '1000000', // 1 USDC (6 decimals) + from: { + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f6D123', + networkId: NetworkId.ETHEREUM_MAINNET, + }, + to: { + address: '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM', + networkId: NetworkId.SOLANA_MAINNET, + }, + slippageTolerance: 1.0, +}); +``` + +**Manual Token Construction:** ```typescript const quotes = await swapper.getQuotes({ - taker: SwapperCaip19, - buyToken: SwapperCaip19, - sellToken: SwapperCaip19, + sellToken: { + type: 'native' | 'address', + address?: string, // Required if type is 'address' + networkId: NetworkId, + }, + buyToken: { + type: 'native' | 'address', + address?: string, // Required if type is 'address' + networkId: NetworkId, + }, sellAmount: string, + from: { + address: string, + networkId: NetworkId, + }, + to?: { // Optional, for bridges + address: string, + networkId: NetworkId, + }, // Optional parameters - takerDestination?: SwapperCaip19, // For bridges - exactOut?: boolean, - base64EncodedTx?: boolean, - autoSlippage?: boolean, slippageTolerance?: number, priorityFee?: number, tipAmount?: number, + exactOut?: boolean, + autoSlippage?: boolean, + isLedger?: boolean, }); ``` @@ -214,63 +272,91 @@ const permissions = await swapper.getPermissions(); const queue = await swapper.getWithdrawalQueue(); ``` -#### Update Headers +## Advanced Configuration + +### Custom Headers -Dynamically update request headers. +While not commonly needed, you can provide custom headers if required: + +```typescript +const swapper = new SwapperSDK({ + apiUrl: 'https://api.phantom.app', + options: { + organizationId: 'your-org-id', + }, + // Advanced: custom headers (not typically needed) + headers: { + 'Custom-Header': 'value', + }, +}); +``` + +### Update Headers + +Dynamically update request headers: ```typescript swapper.updateHeaders({ - 'X-Phantom-Version': '2.0.0', - Authorization: 'Bearer new-token', + 'X-Custom-Header': 'new-value', }); ``` ## Types -### ChainID +### NetworkId Supported blockchain networks: ```typescript -enum ChainID { +enum NetworkId { // Solana - SolanaMainnet = "solana:101", - SolanaTestnet = "solana:102", - SolanaDevnet = "solana:103", + SOLANA_MAINNET = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + SOLANA_DEVNET = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + SOLANA_TESTNET = "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z", // Ethereum - EthereumMainnet = "eip155:1", - EthereumSepolia = "eip155:11155111", + ETHEREUM_MAINNET = "eip155:1", + ETHEREUM_SEPOLIA = "eip155:11155111", // Polygon - PolygonMainnet = "eip155:137", + POLYGON_MAINNET = "eip155:137", // Base - BaseMainnet = "eip155:8453", + BASE_MAINNET = "eip155:8453", // Arbitrum - ArbitrumMainnet = "eip155:42161", + ARBITRUM_ONE = "eip155:42161", // Sui - SuiMainnet = "sui:mainnet", + SUI_MAINNET = "sui:35834a8a", // Bitcoin - BitcoinMainnet = "bip122:000000000019d6689c085ae165831e93", + BITCOIN_MAINNET = "bip122:000000000019d6689c085ae165831e93", // ... and more } ``` -### SwapperCaip19 +### Token -Universal token/address format: +Token specification: ```typescript -interface SwapperCaip19 { - chainId: ChainID; - resourceType: "address" | "nativeToken"; - address?: string; // Required if resourceType = "address" - slip44?: string; // Required if resourceType = "nativeToken" +interface Token { + type: 'native' | 'address'; + address?: string; // Required if type is 'address' + networkId: NetworkId; +} +``` + +### UserAddress + +User address with network: + +```typescript +interface UserAddress { + address: string; + networkId: NetworkId; } ``` @@ -390,22 +476,20 @@ yarn prettier ```typescript const quotes = await swapper.getQuotes({ - taker: { - chainId: ChainID.SolanaMainnet, - address: '8B3fBhkpHFMTdaQSfKVREB5HFKnZkT8rL6zKJfmmWDeZ', - resourceType: 'address', - }, sellToken: { - chainId: ChainID.SolanaMainnet, - slip44: '501', - resourceType: 'nativeToken', + type: 'native', + networkId: NetworkId.SOLANA_MAINNET, }, buyToken: { - chainId: ChainID.SolanaMainnet, + type: 'address', address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', - resourceType: 'address', + networkId: NetworkId.SOLANA_MAINNET, }, sellAmount: '1000000000', + from: { + address: '8B3fBhkpHFMTdaQSfKVREB5HFKnZkT8rL6zKJfmmWDeZ', + networkId: NetworkId.SOLANA_MAINNET, + }, slippageTolerance: 0.5, }); @@ -419,27 +503,25 @@ const transaction = bestQuote.transactionData[0]; ```typescript const quotes = await swapper.getQuotes({ - taker: { - chainId: ChainID.EthereumMainnet, - address: '0x742d35Cc6634C0532925a3b844Bc9e7595f6D123', - resourceType: 'address', - }, - takerDestination: { - chainId: ChainID.SolanaMainnet, - address: '8B3fBhkpHFMTdaQSfKVREB5HFKnZkT8rL6zKJfmmWDeZ', - resourceType: 'address', - }, sellToken: { - chainId: ChainID.EthereumMainnet, - address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', - resourceType: 'address', + type: 'address', + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC + networkId: NetworkId.ETHEREUM_MAINNET, }, buyToken: { - chainId: ChainID.SolanaMainnet, - address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', - resourceType: 'address', + type: 'address', + address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', // USDC on Solana + networkId: NetworkId.SOLANA_MAINNET, }, sellAmount: '1000000000', + from: { + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f6D123', + networkId: NetworkId.ETHEREUM_MAINNET, + }, + to: { + address: '8B3fBhkpHFMTdaQSfKVREB5HFKnZkT8rL6zKJfmmWDeZ', + networkId: NetworkId.SOLANA_MAINNET, + }, slippageTolerance: 1.0, }); ``` diff --git a/packages/swapper-sdk/package.json b/packages/swapper-sdk/package.json index 06d165d4..b419e572 100644 --- a/packages/swapper-sdk/package.json +++ b/packages/swapper-sdk/package.json @@ -20,6 +20,8 @@ "clean": "rm -rf dist", "test": "jest", "test:watch": "jest --watch", + "test:integration": "jest tests/integration.test.ts --verbose", + "test:unit": "jest --testPathIgnorePatterns='integration.test.ts'", "lint": "eslint src --ext .ts,.tsx", "check-types": "yarn tsc --noEmit", "prettier": "prettier --write \"src/**/*.{ts,tsx}\"" @@ -37,7 +39,6 @@ "typescript": "^5.0.4" }, "dependencies": { - "@phantom/parsers": "workspace:^", "eventsource": "^2.0.2" }, "files": [ @@ -46,4 +47,4 @@ "publishConfig": { "directory": "_release/package" } -} \ No newline at end of file +} diff --git a/packages/swapper-sdk/src/api/bridge.ts b/packages/swapper-sdk/src/api/bridge.ts index ceacd87f..a69452b9 100644 --- a/packages/swapper-sdk/src/api/bridge.ts +++ b/packages/swapper-sdk/src/api/bridge.ts @@ -25,15 +25,15 @@ export class BridgeAPI { } async getIntentsStatus(params: GetIntentsStatusParams): Promise { - return this.client.get("/spot/get-intents-status", params); + return this.client.get("/spot/get-intents-status", params as any); } async bridgeInitialize(params: GenerateAndVerifyAddressParams): Promise { - return this.client.get("/spot/bridge-initialize", params); + return this.client.get("/spot/bridge-initialize", params as any); } async getBridgeOperations(params: OperationsParams): Promise { - return this.client.get("/spot/bridge-operations", params); + return this.client.get("/spot/bridge-operations", params as any); } async initializeFunding(params: InitializeFundingParams): Promise { diff --git a/packages/swapper-sdk/src/api/client.ts b/packages/swapper-sdk/src/api/client.ts index 43a61663..c1681fd6 100644 --- a/packages/swapper-sdk/src/api/client.ts +++ b/packages/swapper-sdk/src/api/client.ts @@ -1,8 +1,16 @@ import type { ErrorResponse, Headers, OptionalHeaders } from "../types"; +export interface SwapperClientOptions { + organizationId?: string; + countryCode?: string; + anonymousId?: string; + version?: string; +} + export interface SwapperClientConfig { apiUrl?: string; - headers?: OptionalHeaders; + options?: SwapperClientOptions; + headers?: OptionalHeaders; // Still available but not documented prominently timeout?: number; } @@ -12,35 +20,40 @@ export class SwapperAPIClient { private readonly timeout: number; constructor(config: SwapperClientConfig = {}) { - this.baseUrl = (config.apiUrl || process.env.PHANTOM_SWAPPER_API_URL || "https://api.phantom.app") + "/swap/v2"; + this.baseUrl = (config.apiUrl || "https://api.phantom.app") + "/swap/v2"; this.timeout = config.timeout || 30000; this.headers = { "Content-Type": "application/json", - ...this.buildOptionalHeaders(config.headers), + ...this.buildHeaders(config.options, config.headers), }; } - private buildOptionalHeaders(customHeaders?: OptionalHeaders): OptionalHeaders { + private buildHeaders(options?: SwapperClientOptions, customHeaders?: OptionalHeaders): OptionalHeaders { const headers: OptionalHeaders = {}; - if (process.env.PHANTOM_SERVICE_AUTH_TOKEN) { - headers.Authorization = `Bearer ${process.env.PHANTOM_SERVICE_AUTH_TOKEN}`; + // Platform is hardcoded to "sdk" + headers["X-Phantom-Platform"] = "sdk"; + + // Set headers from options + if (options?.organizationId) { + headers["X-Organization"] = options.organizationId; } - if (process.env.PHANTOM_CLIENT_VERSION) { - headers["X-Phantom-Version"] = process.env.PHANTOM_CLIENT_VERSION; + if (options?.countryCode) { + headers["cf-ipcountry"] = options.countryCode; + headers["cloudfront-viewer-country"] = options.countryCode; } - if (process.env.PHANTOM_CLIENT_PLATFORM) { - headers["X-Phantom-Platform"] = process.env.PHANTOM_CLIENT_PLATFORM; + if (options?.anonymousId) { + headers["X-Phantom-AnonymousId"] = options.anonymousId; } - if (process.env.PHANTOM_COUNTRY_CODE) { - headers["cf-ipcountry"] = process.env.PHANTOM_COUNTRY_CODE; - headers["cloudfront-viewer-country"] = process.env.PHANTOM_COUNTRY_CODE; + if (options?.version) { + headers["X-Phantom-Version"] = options.version; } + // Allow custom headers to override (but not documented prominently) return { ...headers, ...customHeaders, @@ -71,6 +84,16 @@ export class SwapperAPIClient { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeout); + // Log the outgoing request + console.log("=== OUTGOING API REQUEST ==="); + console.log(`URL: ${url.toString()}`); + console.log(`Method: ${method}`); + console.log(`Headers:`, JSON.stringify({ ...this.headers, ...headers }, null, 2)); + if (body) { + console.log(`Body:`, JSON.stringify(body, null, 2)); + } + console.log("============================="); + try { const response = await fetch(url.toString(), { method, @@ -84,8 +107,14 @@ export class SwapperAPIClient { clearTimeout(timeoutId); + console.log("=== API RESPONSE ==="); + console.log(`Status: ${response.status} ${response.statusText}`); + console.log(`Response Headers:`, Object.fromEntries(response.headers.entries())); + if (!response.ok) { const errorData = await response.json().catch(() => ({})); + console.log(`Error Response Body:`, JSON.stringify(errorData, null, 2)); + console.log("===================="); const error: ErrorResponse = { code: errorData.code || "UNKNOWN_ERROR", message: errorData.message || `Request failed with status ${response.status}`, @@ -95,7 +124,10 @@ export class SwapperAPIClient { throw error; } - return await response.json(); + const responseData = await response.json(); + console.log(`Success Response Body:`, JSON.stringify(responseData, null, 2)); + console.log("===================="); + return responseData; } catch (error: any) { clearTimeout(timeoutId); diff --git a/packages/swapper-sdk/src/api/initialize.ts b/packages/swapper-sdk/src/api/initialize.ts index 330dbd5c..dff885eb 100644 --- a/packages/swapper-sdk/src/api/initialize.ts +++ b/packages/swapper-sdk/src/api/initialize.ts @@ -1,10 +1,12 @@ import type { SwapperInitializeRequestParams, SwapperInitializeResults } from "../types"; import type { SwapperAPIClient } from "./client"; +import { transformInitializeParams } from "../utils/transformers"; export class InitializeAPI { constructor(private client: SwapperAPIClient) {} async initialize(params: SwapperInitializeRequestParams): Promise { - return this.client.post("/initialize", params); + const transformedParams = transformInitializeParams(params); + return this.client.post("/initialize", transformedParams); } } \ No newline at end of file diff --git a/packages/swapper-sdk/src/constants/tokens.ts b/packages/swapper-sdk/src/constants/tokens.ts new file mode 100644 index 00000000..e7f25682 --- /dev/null +++ b/packages/swapper-sdk/src/constants/tokens.ts @@ -0,0 +1,201 @@ +import { NetworkId } from "../types/networks"; +import type { Token } from "../types/public-api"; + +/** + * Token constants for easy reference in swaps and bridges + * Use these predefined tokens instead of manually constructing token objects + */ + +// Ethereum Mainnet Tokens +export const ETHEREUM_MAINNET = { + ETH: { + type: "native" as const, + networkId: NetworkId.ETHEREUM_MAINNET, + symbol: "ETH", + name: "Ethereum", + decimals: 18, + } satisfies Token & { symbol: string; name: string; decimals: number }, + + USDC: { + type: "address" as const, + address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + networkId: NetworkId.ETHEREUM_MAINNET, + symbol: "USDC", + name: "USD Coin", + decimals: 6, + } satisfies Token & { symbol: string; name: string; decimals: number }, + + USDT: { + type: "address" as const, + address: "0xdAC17F958D2ee523a2206206994597C13D831ec7", + networkId: NetworkId.ETHEREUM_MAINNET, + symbol: "USDT", + name: "Tether USD", + decimals: 6, + } satisfies Token & { symbol: string; name: string; decimals: number }, + + WETH: { + type: "address" as const, + address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + networkId: NetworkId.ETHEREUM_MAINNET, + symbol: "WETH", + name: "Wrapped Ether", + decimals: 18, + } satisfies Token & { symbol: string; name: string; decimals: number }, +}; + +// Base Mainnet Tokens +export const BASE_MAINNET = { + ETH: { + type: "native" as const, + networkId: NetworkId.BASE_MAINNET, + symbol: "ETH", + name: "Ethereum", + decimals: 18, + } satisfies Token & { symbol: string; name: string; decimals: number }, + + USDC: { + type: "address" as const, + address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + networkId: NetworkId.BASE_MAINNET, + symbol: "USDC", + name: "USD Coin", + decimals: 6, + } satisfies Token & { symbol: string; name: string; decimals: number }, + + WETH: { + type: "address" as const, + address: "0x4200000000000000000000000000000000000006", + networkId: NetworkId.BASE_MAINNET, + symbol: "WETH", + name: "Wrapped Ether", + decimals: 18, + } satisfies Token & { symbol: string; name: string; decimals: number }, +}; + +// Polygon Mainnet Tokens +export const POLYGON_MAINNET = { + MATIC: { + type: "native" as const, + networkId: NetworkId.POLYGON_MAINNET, + symbol: "MATIC", + name: "Polygon", + decimals: 18, + } satisfies Token & { symbol: string; name: string; decimals: number }, + + USDC: { + type: "address" as const, + address: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", + networkId: NetworkId.POLYGON_MAINNET, + symbol: "USDC", + name: "USD Coin", + decimals: 6, + } satisfies Token & { symbol: string; name: string; decimals: number }, + + USDT: { + type: "address" as const, + address: "0xc2132D05D31c914a87C6611C10748AEb04B58e8F", + networkId: NetworkId.POLYGON_MAINNET, + symbol: "USDT", + name: "Tether USD", + decimals: 6, + } satisfies Token & { symbol: string; name: string; decimals: number }, + + WETH: { + type: "address" as const, + address: "0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619", + networkId: NetworkId.POLYGON_MAINNET, + symbol: "WETH", + name: "Wrapped Ether", + decimals: 18, + } satisfies Token & { symbol: string; name: string; decimals: number }, +}; + +// Arbitrum One Tokens +export const ARBITRUM_ONE = { + ETH: { + type: "native" as const, + networkId: NetworkId.ARBITRUM_ONE, + symbol: "ETH", + name: "Ethereum", + decimals: 18, + } satisfies Token & { symbol: string; name: string; decimals: number }, + + USDC: { + type: "address" as const, + address: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + networkId: NetworkId.ARBITRUM_ONE, + symbol: "USDC", + name: "USD Coin", + decimals: 6, + } satisfies Token & { symbol: string; name: string; decimals: number }, + + USDT: { + type: "address" as const, + address: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", + networkId: NetworkId.ARBITRUM_ONE, + symbol: "USDT", + name: "Tether USD", + decimals: 6, + } satisfies Token & { symbol: string; name: string; decimals: number }, + + WETH: { + type: "address" as const, + address: "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", + networkId: NetworkId.ARBITRUM_ONE, + symbol: "WETH", + name: "Wrapped Ether", + decimals: 18, + } satisfies Token & { symbol: string; name: string; decimals: number }, +}; + + +// Solana Mainnet Tokens +export const SOLANA_MAINNET = { + SOL: { + type: "native" as const, + networkId: NetworkId.SOLANA_MAINNET, + symbol: "SOL", + name: "Solana", + decimals: 9, + } satisfies Token & { symbol: string; name: string; decimals: number }, + + USDC: { + type: "address" as const, + address: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + networkId: NetworkId.SOLANA_MAINNET, + symbol: "USDC", + name: "USD Coin", + decimals: 6, + } satisfies Token & { symbol: string; name: string; decimals: number }, + + USDT: { + type: "address" as const, + address: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", + networkId: NetworkId.SOLANA_MAINNET, + symbol: "USDT", + name: "Tether USD", + decimals: 6, + } satisfies Token & { symbol: string; name: string; decimals: number }, + + WSOL: { + type: "address" as const, + address: "So11111111111111111111111111111111111111112", + networkId: NetworkId.SOLANA_MAINNET, + symbol: "WSOL", + name: "Wrapped SOL", + decimals: 9, + } satisfies Token & { symbol: string; name: string; decimals: number }, +}; + + +/** + * All supported tokens organized by network + */ +export const TOKENS = { + ETHEREUM_MAINNET, + BASE_MAINNET, + POLYGON_MAINNET, + ARBITRUM_ONE, + SOLANA_MAINNET, +}; diff --git a/packages/swapper-sdk/src/index.ts b/packages/swapper-sdk/src/index.ts index 5adff27f..b4d5d5d9 100644 --- a/packages/swapper-sdk/src/index.ts +++ b/packages/swapper-sdk/src/index.ts @@ -1,5 +1,33 @@ export * from "./swapper-sdk"; -export * from "./types"; -export * from "./api"; +export { NetworkId } from "./types/networks"; +export { TOKENS } from "./constants/tokens"; +export type { + // Public API types + TokenType, + Token, + UserAddress, + GetQuotesParams, +} from "./types/public-api"; +export type { + // Response types that users need + SwapperQuotesDataRepresentation, + SwapperQuote, + SwapperSolanaQuoteRepresentation, + SwapperEvmQuoteRepresentation, + SwapperXChainQuoteRepresentation, + SwapperSuiQuoteRepresentation, + SwapperInitializeResults, + PermissionsResponse, + GetBridgeableTokensResponse, + GetBridgeProvidersResponse, + GetIntentsStatusResponse, + GenerateAndVerifyAddressResponse, + OperationsResponse, + InitializeFundingResponse, + WithdrawalQueueResponse, + RelayExecutionStatus, + SwapType, + FeeType, +} from "./types"; export { SwapperSDK as default } from "./swapper-sdk"; \ No newline at end of file diff --git a/packages/swapper-sdk/src/swapper-sdk.ts b/packages/swapper-sdk/src/swapper-sdk.ts index 90b74622..84c7abc5 100644 --- a/packages/swapper-sdk/src/swapper-sdk.ts +++ b/packages/swapper-sdk/src/swapper-sdk.ts @@ -6,6 +6,7 @@ import { StreamingAPI, SwapperAPIClient, type SwapperClientConfig, + type SwapperClientOptions, } from "./api"; import type { GenerateAndVerifyAddressParams, @@ -22,16 +23,23 @@ import type { PermissionsResponse, SwapperInitializeRequestParams, SwapperInitializeResults, - SwapperQuotesBody, SwapperQuotesDataRepresentation, WithdrawalQueueResponse, } from "./types"; +import type { GetQuotesParams } from "./types/public-api"; import type { StreamQuotesOptions } from "./api/streaming"; +import { transformQuotesParams } from "./utils/transformers"; -export interface SwapperSDKConfig extends SwapperClientConfig { +export interface SwapperSDKOptions extends SwapperClientOptions { debug?: boolean; } +export interface SwapperSDKConfig { + apiUrl?: string; + options?: SwapperSDKOptions; + timeout?: number; +} + export class SwapperSDK { private readonly client: SwapperAPIClient; private readonly quotes: QuotesAPI; @@ -42,8 +50,15 @@ export class SwapperSDK { private readonly debug: boolean; constructor(config: SwapperSDKConfig = {}) { - this.debug = config.debug || false; - this.client = new SwapperAPIClient(config); + this.debug = config.options?.debug || false; + + const clientConfig: SwapperClientConfig = { + apiUrl: config.apiUrl, + options: config.options, + timeout: config.timeout, + }; + + this.client = new SwapperAPIClient(clientConfig); this.quotes = new QuotesAPI(this.client); this.initialize = new InitializeAPI(this.client); @@ -54,19 +69,30 @@ export class SwapperSDK { if (this.debug) { console.error("[SwapperSDK] Initialized with config:", { baseUrl: this.client.getBaseUrl(), + options: config.options, debug: this.debug, }); } } - async getQuotes(params: SwapperQuotesBody): Promise { + async getQuotes(params: GetQuotesParams): Promise { if (this.debug) { console.error("[SwapperSDK] Getting quotes with params:", params); } - const result = await this.quotes.getQuotes(params); + + // Transform public API params to internal format + const swapperParams = transformQuotesParams(params); + + if (this.debug) { + console.error("[SwapperSDK] Transformed to internal params:", swapperParams); + } + + const result = await this.quotes.getQuotes(swapperParams); + if (this.debug) { console.error("[SwapperSDK] Received quotes:", result); } + return result; } diff --git a/packages/swapper-sdk/src/types/bridge.ts b/packages/swapper-sdk/src/types/bridge.ts index 2ddccc60..3e29f566 100644 --- a/packages/swapper-sdk/src/types/bridge.ts +++ b/packages/swapper-sdk/src/types/bridge.ts @@ -1,16 +1,12 @@ -import type { ChainID, SwapperCaip19 } from "./chains"; +import type { SwapperCaip19 } from "./chains"; +import type { ChainID } from "./networks"; export interface GetBridgeableTokensResponse { tokens: SwapperCaip19[]; } export interface GetBridgeProvidersResponse { - providers: BridgeProviderInfo[]; -} - -export interface BridgeProviderInfo { - name: string; - max: number; + providers: string[]; } export interface GetIntentsStatusParams { diff --git a/packages/swapper-sdk/src/types/chains.ts b/packages/swapper-sdk/src/types/chains.ts index 117a1d67..907a6958 100644 --- a/packages/swapper-sdk/src/types/chains.ts +++ b/packages/swapper-sdk/src/types/chains.ts @@ -1,39 +1,4 @@ -export enum ChainID { - // Solana - SolanaMainnet = "solana:101", - SolanaTestnet = "solana:102", - SolanaDevnet = "solana:103", - - // Ethereum - EthereumMainnet = "eip155:1", - EthereumSepolia = "eip155:11155111", - - // Polygon - PolygonMainnet = "eip155:137", - PolygonAmoy = "eip155:80002", - - // Base - BaseMainnet = "eip155:8453", - BaseSepolia = "eip155:84532", - - // Arbitrum - ArbitrumMainnet = "eip155:42161", - ArbitrumSepolia = "eip155:421614", - - // Other EVM chains - BscMainnet = "eip155:56", - OptimismMainnet = "eip155:10", - AvalancheMainnet = "eip155:43114", - - // Sui - SuiMainnet = "sui:mainnet", - SuiTestnet = "sui:testnet", - SuiDevnet = "sui:devnet", - - // Bitcoin - BitcoinMainnet = "bip122:000000000019d6689c085ae165831e93", - BitcoinTestnet = "bip122:000000000933ea01ad0ee984209779ba", -} +import type { ChainID } from "./networks"; export interface SwapperCaip19 { chainId: ChainID; diff --git a/packages/swapper-sdk/src/types/common.ts b/packages/swapper-sdk/src/types/common.ts index 15c6390d..fc35f702 100644 --- a/packages/swapper-sdk/src/types/common.ts +++ b/packages/swapper-sdk/src/types/common.ts @@ -6,9 +6,11 @@ export interface OptionalHeaders { "X-Phantom-Version"?: string; "X-Phantom-Platform"?: string; "X-Phantom-AnonymousId"?: string; + "X-Organization"?: string; "cf-ipcountry"?: string; "cloudfront-viewer-country"?: string; Authorization?: string; + [key: string]: any; // Allow additional headers } export type Headers = RequiredHeaders & OptionalHeaders; diff --git a/packages/swapper-sdk/src/types/index.ts b/packages/swapper-sdk/src/types/index.ts index 75723627..e1655b22 100644 --- a/packages/swapper-sdk/src/types/index.ts +++ b/packages/swapper-sdk/src/types/index.ts @@ -2,4 +2,5 @@ export * from "./chains"; export * from "./quotes"; export * from "./initialize"; export * from "./bridge"; -export * from "./common"; \ No newline at end of file +export * from "./common"; +export { ChainID } from "./networks"; \ No newline at end of file diff --git a/packages/swapper-sdk/src/types/initialize.ts b/packages/swapper-sdk/src/types/initialize.ts index 41f67485..be975836 100644 --- a/packages/swapper-sdk/src/types/initialize.ts +++ b/packages/swapper-sdk/src/types/initialize.ts @@ -1,4 +1,4 @@ -import type { ChainID } from "./chains"; +import type { ChainID } from "./networks"; export interface SwapperInitializeRequestParams { type: "buy" | "sell" | "swap"; diff --git a/packages/swapper-sdk/src/types/networks.ts b/packages/swapper-sdk/src/types/networks.ts new file mode 100644 index 00000000..4f4ba0be --- /dev/null +++ b/packages/swapper-sdk/src/types/networks.ts @@ -0,0 +1,161 @@ +/** + * User-friendly enum for network identifiers + * Copied from @phantom/client for consistency + */ +export enum NetworkId { + // Solana Networks + SOLANA_MAINNET = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + SOLANA_DEVNET = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + SOLANA_TESTNET = "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z", + + // Ethereum Networks + ETHEREUM_MAINNET = "eip155:1", + ETHEREUM_GOERLI = "eip155:5", + ETHEREUM_SEPOLIA = "eip155:11155111", + + // Polygon Networks + POLYGON_MAINNET = "eip155:137", + POLYGON_MUMBAI = "eip155:80001", + POLYGON_AMOY = "eip155:80002", + + // Arbitrum Networks + ARBITRUM_ONE = "eip155:42161", + ARBITRUM_GOERLI = "eip155:421613", + ARBITRUM_SEPOLIA = "eip155:421614", + + // Base Networks + BASE_MAINNET = "eip155:8453", + BASE_GOERLI = "eip155:84531", + BASE_SEPOLIA = "eip155:84532", + + // Bitcoin Networks + BITCOIN_MAINNET = "bip122:000000000019d6689c085ae165831e93", + BITCOIN_TESTNET = "bip122:000000000933ea01ad0ee984209779ba", + + // Sui Networks + SUI_MAINNET = "sui:35834a8a", + SUI_TESTNET = "sui:4c78adac", + SUI_DEVNET = "sui:devnet", +} + +/** + * Internal ChainID enum used by the Swapper API + * This is not exposed to SDK users + */ +export enum ChainID { + // Solana + SolanaMainnet = "solana:101", + SolanaTestnet = "solana:102", + SolanaDevnet = "solana:103", + + // Ethereum + EthereumMainnet = "eip155:1", + EthereumSepolia = "eip155:11155111", + + // Polygon + PolygonMainnet = "eip155:137", + PolygonAmoy = "eip155:80002", + + // Base + BaseMainnet = "eip155:8453", + BaseSepolia = "eip155:84532", + + // Arbitrum + ArbitrumMainnet = "eip155:42161", + ArbitrumSepolia = "eip155:421614", + + // Sui + SuiMainnet = "sui:mainnet", + SuiTestnet = "sui:testnet", + SuiDevnet = "sui:devnet", + + // Bitcoin + BitcoinMainnet = "bip122:000000000019d6689c085ae165831e93", + BitcoinTestnet = "bip122:000000000933ea01ad0ee984209779ba", +} + +/** + * Maps NetworkId to internal ChainID format + */ +export const NETWORK_TO_CHAIN_MAP: Record = { + // Solana + [NetworkId.SOLANA_MAINNET]: ChainID.SolanaMainnet, + [NetworkId.SOLANA_TESTNET]: ChainID.SolanaTestnet, + [NetworkId.SOLANA_DEVNET]: ChainID.SolanaDevnet, + + // Ethereum + [NetworkId.ETHEREUM_MAINNET]: ChainID.EthereumMainnet, + [NetworkId.ETHEREUM_SEPOLIA]: ChainID.EthereumSepolia, + [NetworkId.ETHEREUM_GOERLI]: ChainID.EthereumSepolia, // Map Goerli to Sepolia + + // Polygon + [NetworkId.POLYGON_MAINNET]: ChainID.PolygonMainnet, + [NetworkId.POLYGON_AMOY]: ChainID.PolygonAmoy, + [NetworkId.POLYGON_MUMBAI]: ChainID.PolygonAmoy, // Map Mumbai to Amoy + + // Base + [NetworkId.BASE_MAINNET]: ChainID.BaseMainnet, + [NetworkId.BASE_SEPOLIA]: ChainID.BaseSepolia, + [NetworkId.BASE_GOERLI]: ChainID.BaseSepolia, // Map Goerli to Sepolia + + // Arbitrum + [NetworkId.ARBITRUM_ONE]: ChainID.ArbitrumMainnet, + [NetworkId.ARBITRUM_SEPOLIA]: ChainID.ArbitrumSepolia, + [NetworkId.ARBITRUM_GOERLI]: ChainID.ArbitrumSepolia, // Map Goerli to Sepolia + + // Sui + [NetworkId.SUI_MAINNET]: ChainID.SuiMainnet, + [NetworkId.SUI_TESTNET]: ChainID.SuiTestnet, + [NetworkId.SUI_DEVNET]: ChainID.SuiDevnet, + + // Bitcoin + [NetworkId.BITCOIN_MAINNET]: ChainID.BitcoinMainnet, + [NetworkId.BITCOIN_TESTNET]: ChainID.BitcoinTestnet, +}; + +/** + * Get SLIP-44 coin type for native tokens by chain ID + * Only includes supported networks from NetworkId enum + */ +export const NATIVE_TOKEN_SLIP44_BY_CHAIN: Record = { + // Solana + "solana:101": "501", + "solana:102": "501", + "solana:103": "501", + + // Ethereum + "eip155:1": "60", + "eip155:11155111": "60", // Sepolia + + // Polygon + "eip155:137": "137", + "eip155:80002": "137", // Amoy + + // Base - uses its own SLIP-44 + "eip155:8453": "8453", + "eip155:84532": "8453", // Sepolia + + // Arbitrum + "eip155:42161": "42161", + "eip155:421614": "42161", // Sepolia + + + // Sui + "sui:mainnet": "784", + "sui:testnet": "784", + "sui:devnet": "784", + + // Bitcoin + "bip122:000000000019d6689c085ae165831e93": "0", + "bip122:000000000933ea01ad0ee984209779ba": "0", +}; + +/** + * Fallback SLIP-44 values by namespace (for backward compatibility) + */ +export const NATIVE_TOKEN_SLIP44: Record = { + solana: "501", + eip155: "60", // Ethereum default + sui: "784", + bip122: "0", // Bitcoin +}; \ No newline at end of file diff --git a/packages/swapper-sdk/src/types/public-api.ts b/packages/swapper-sdk/src/types/public-api.ts new file mode 100644 index 00000000..0ac0b20f --- /dev/null +++ b/packages/swapper-sdk/src/types/public-api.ts @@ -0,0 +1,42 @@ +import type { NetworkId } from "./networks"; + +/** + * Token type for swaps + */ +export type TokenType = "native" | "address"; + +/** + * Token specification for swaps + */ +export interface Token { + type: TokenType; + address?: string; // Required only if type is "address" + networkId: NetworkId; +} + +/** + * User address with network + */ +export interface UserAddress { + address: string; + networkId: NetworkId; +} + +/** + * Simplified quote request parameters + */ +export interface GetQuotesParams { + sellToken: Token; + buyToken: Token; + sellAmount: string; + from: UserAddress; + to?: UserAddress; // Optional, for bridges + + // Optional parameters + slippageTolerance?: number; + priorityFee?: number; + tipAmount?: number; + exactOut?: boolean; + autoSlippage?: boolean; + isLedger?: boolean; +} \ No newline at end of file diff --git a/packages/swapper-sdk/src/types/quotes.ts b/packages/swapper-sdk/src/types/quotes.ts index a07523bb..0e0be93e 100644 --- a/packages/swapper-sdk/src/types/quotes.ts +++ b/packages/swapper-sdk/src/types/quotes.ts @@ -1,4 +1,5 @@ -import type { ChainID, FeeType, SwapperCaip19, SwapType } from "./chains"; +import type { FeeType, SwapperCaip19, SwapType } from "./chains"; +import type { ChainID } from "./networks"; export interface SwapperQuotesBody { taker: SwapperCaip19; diff --git a/packages/swapper-sdk/src/utils/transformers.ts b/packages/swapper-sdk/src/utils/transformers.ts new file mode 100644 index 00000000..1d56a0e6 --- /dev/null +++ b/packages/swapper-sdk/src/utils/transformers.ts @@ -0,0 +1,120 @@ +import type { GetQuotesParams, Token, UserAddress } from "../types/public-api"; +import type { SwapperCaip19, SwapperQuotesBody, SwapperInitializeRequestParams } from "../types"; +import { ChainID, NATIVE_TOKEN_SLIP44, NATIVE_TOKEN_SLIP44_BY_CHAIN, NETWORK_TO_CHAIN_MAP, NetworkId } from "../types/networks"; + +/** + * Convert a Token to SwapperCaip19 format + */ +export function tokenToSwapperCaip19(token: Token): SwapperCaip19 { + const chainId = NETWORK_TO_CHAIN_MAP[token.networkId]; + + if (!chainId) { + throw new Error(`Unsupported network: ${token.networkId}`); + } + + if (token.type === "native") { + // Try to get chain-specific SLIP-44 first, then fallback to namespace + let slip44 = NATIVE_TOKEN_SLIP44_BY_CHAIN[chainId]; + + if (!slip44) { + const namespace = chainId.split(":")[0]; + slip44 = NATIVE_TOKEN_SLIP44[namespace]; + } + + if (!slip44) { + throw new Error(`No SLIP-44 code found for native token on chain ${chainId}`); + } + + return { + chainId, + resourceType: "nativeToken", + slip44, + }; + } else { + if (!token.address) { + throw new Error("Token address is required when type is 'address'"); + } + + return { + chainId, + resourceType: "address", + address: chainId.startsWith("eip155:") ? token.address.toLowerCase() : token.address, + }; + } +} + +/** + * Convert a UserAddress to SwapperCaip19 format + */ +export function userAddressToSwapperCaip19(userAddress: UserAddress): SwapperCaip19 { + const chainId = NETWORK_TO_CHAIN_MAP[userAddress.networkId]; + + if (!chainId) { + throw new Error(`Unsupported network: ${userAddress.networkId}`); + } + + return { + chainId, + resourceType: "address", + address: chainId.startsWith("eip155:") ? userAddress.address.toLowerCase() : userAddress.address, + }; +} + +/** + * Transform public API params to internal SwapperQuotesBody + */ +export function transformQuotesParams(params: GetQuotesParams): SwapperQuotesBody { + const body: SwapperQuotesBody = { + taker: userAddressToSwapperCaip19(params.from), + buyToken: tokenToSwapperCaip19(params.buyToken), + sellToken: tokenToSwapperCaip19(params.sellToken), + sellAmount: params.sellAmount, + }; + + // Add optional destination for bridges + if (params.to) { + body.takerDestination = userAddressToSwapperCaip19(params.to); + } + + // Add other optional parameters + if (params.slippageTolerance !== undefined) { + body.slippageTolerance = params.slippageTolerance; + } + if (params.priorityFee !== undefined) { + body.priorityFee = params.priorityFee; + } + if (params.tipAmount !== undefined) { + body.tipAmount = params.tipAmount; + } + if (params.exactOut !== undefined) { + body.exactOut = params.exactOut; + } + if (params.autoSlippage !== undefined) { + body.autoSlippage = params.autoSlippage; + } + if (params.isLedger !== undefined) { + body.isLedger = params.isLedger; + } + + return body; +} + +/** + * Transform initialize params - convert NetworkId to ChainID + */ +export function transformInitializeParams(params: SwapperInitializeRequestParams): SwapperInitializeRequestParams { + const transformedParams = { ...params }; + + // Convert network NetworkId to ChainID if present + if (params.network) { + const networkId = params.network as string; + if (networkId in NETWORK_TO_CHAIN_MAP) { + const chainId = NETWORK_TO_CHAIN_MAP[networkId as NetworkId]; + if (chainId) { + transformedParams.network = chainId; + } + } + } + + return transformedParams; +} \ No newline at end of file diff --git a/packages/swapper-sdk/tests/client.test.ts b/packages/swapper-sdk/tests/client.test.ts index 38577d61..e2988985 100644 --- a/packages/swapper-sdk/tests/client.test.ts +++ b/packages/swapper-sdk/tests/client.test.ts @@ -19,26 +19,24 @@ describe("SwapperAPIClient", () => { it("should initialize with custom config", () => { const customClient = new SwapperAPIClient({ apiUrl: "https://custom.api.com", - headers: { - "X-Phantom-Version": "1.0.0", + options: { + organizationId: "test-org", + version: "1.0.0", }, }); expect(customClient.getBaseUrl()).toBe("https://custom.api.com/swap/v2"); }); - it("should use environment variables for headers", () => { - process.env.PHANTOM_SERVICE_AUTH_TOKEN = "test-token"; - process.env.PHANTOM_CLIENT_VERSION = "1.0.0"; - process.env.PHANTOM_CLIENT_PLATFORM = "test-platform"; - process.env.PHANTOM_COUNTRY_CODE = "US"; - - const envClient = new SwapperAPIClient(); - expect(envClient).toBeDefined(); - - delete process.env.PHANTOM_SERVICE_AUTH_TOKEN; - delete process.env.PHANTOM_CLIENT_VERSION; - delete process.env.PHANTOM_CLIENT_PLATFORM; - delete process.env.PHANTOM_COUNTRY_CODE; + it("should use options for headers", () => { + const optionsClient = new SwapperAPIClient({ + options: { + organizationId: "test-org", + countryCode: "US", + anonymousId: "anon-123", + version: "1.0.0", + }, + }); + expect(optionsClient).toBeDefined(); }); }); @@ -173,7 +171,7 @@ describe("SwapperAPIClient", () => { it("should update headers", () => { expect(() => { client.updateHeaders({ - "X-Phantom-Version": "2.0.0", + "X-Custom-Header": "value", Authorization: "Bearer new-token", }); }).not.toThrow(); diff --git a/packages/swapper-sdk/tests/integration.test.ts b/packages/swapper-sdk/tests/integration.test.ts new file mode 100644 index 00000000..24bda798 --- /dev/null +++ b/packages/swapper-sdk/tests/integration.test.ts @@ -0,0 +1,266 @@ +import { SwapperSDK, NetworkId, TOKENS } from "../src"; + +describe("SwapperSDK Integration Tests", () => { + let sdk: SwapperSDK; + + // Test addresses with funds + const EVM_ADDRESS = "0x97b9d2102a9a65a26e1ee82d59e42d1b73b68689"; + const SOLANA_ADDRESS = "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM"; // Binance + + beforeAll(() => { + sdk = new SwapperSDK({ + apiUrl: "https://api.phantom.app", + options: { + debug: true, + }, + }); + }); + + describe("Solana Swaps", () => { + it("should get quotes for SOL to USDC swap", async () => { + const quotes = await sdk.getQuotes({ + sellToken: TOKENS.SOLANA_MAINNET.SOL, + buyToken: TOKENS.SOLANA_MAINNET.USDC, + sellAmount: "100000000", // 0.1 SOL + from: { + address: SOLANA_ADDRESS, + networkId: NetworkId.SOLANA_MAINNET, + }, + slippageTolerance: 0.5, + }); + + expect(quotes).toBeDefined(); + expect(quotes.type).toBe("solana"); + expect(quotes.quotes).toBeDefined(); + expect(Array.isArray(quotes.quotes)).toBe(true); + expect(quotes.quotes.length).toBeGreaterThan(0); + + const firstQuote = quotes.quotes[0]; + expect(firstQuote.sellAmount).toBeDefined(); + expect(firstQuote.buyAmount).toBeDefined(); + expect(firstQuote.baseProvider).toBeDefined(); + }, 30000); + + it("should get quotes for USDC to SOL swap", async () => { + const quotes = await sdk.getQuotes({ + sellToken: TOKENS.SOLANA_MAINNET.USDC, + buyToken: TOKENS.SOLANA_MAINNET.SOL, + sellAmount: "1000000", // 1 USDC + from: { + address: SOLANA_ADDRESS, + networkId: NetworkId.SOLANA_MAINNET, + }, + slippageTolerance: 0.5, + }); + + expect(quotes).toBeDefined(); + expect(quotes.type).toBe("solana"); + expect(quotes.quotes).toBeDefined(); + expect(Array.isArray(quotes.quotes)).toBe(true); + }, 30000); + }); + + describe("Ethereum Swaps", () => { + it("should get quotes for ETH to USDC swap", async () => { + const quotes = await sdk.getQuotes({ + sellToken: TOKENS.ETHEREUM_MAINNET.ETH, + buyToken: TOKENS.ETHEREUM_MAINNET.USDC, + sellAmount: "1000000000000000000", // 1 ETH + from: { + address: EVM_ADDRESS, + networkId: NetworkId.ETHEREUM_MAINNET, + }, + slippageTolerance: 0.5, + }); + + expect(quotes).toBeDefined(); + expect(quotes.type).toBe("eip155"); + expect(quotes.quotes).toBeDefined(); + expect(Array.isArray(quotes.quotes)).toBe(true); + expect(quotes.quotes.length).toBeGreaterThan(0); + + const firstQuote = quotes.quotes[0] as any; + expect(firstQuote.gas).toBeDefined(); + expect(firstQuote.transactionData).toBeDefined(); + expect(firstQuote.exchangeAddress).toBeDefined(); + }, 30000); + + it("should get quotes for USDC to WETH swap", async () => { + const quotes = await sdk.getQuotes({ + sellToken: TOKENS.ETHEREUM_MAINNET.USDC, + buyToken: TOKENS.ETHEREUM_MAINNET.WETH, + sellAmount: "1000000000", // 1000 USDC + from: { + address: EVM_ADDRESS, + networkId: NetworkId.ETHEREUM_MAINNET, + }, + slippageTolerance: 0.5, + }); + + expect(quotes).toBeDefined(); + expect(quotes.type).toBe("eip155"); + expect(quotes.quotes.length).toBeGreaterThan(0); + }, 30000); + }); + + describe("Base Swaps", () => { + it("should get quotes for ETH to USDC swap on Base", async () => { + const quotes = await sdk.getQuotes({ + sellToken: TOKENS.BASE_MAINNET.ETH, + buyToken: TOKENS.BASE_MAINNET.USDC, + sellAmount: "100000000000000000", // 0.1 ETH + from: { + address: EVM_ADDRESS, + networkId: NetworkId.BASE_MAINNET, + }, + slippageTolerance: 0.5, + }); + + expect(quotes).toBeDefined(); + expect(quotes.type).toBe("eip155"); + expect(quotes.quotes.length).toBeGreaterThan(0); + + const firstQuote = quotes.quotes[0]; + expect(firstQuote.baseProvider).toBeDefined(); + expect(firstQuote.buyAmount).toBeDefined(); + }, 30000); + }); + + describe("Cross-Chain Bridges", () => { + it("should get bridge quotes from Ethereum to Solana (USDC)", async () => { + const quotes = await sdk.getQuotes({ + sellToken: TOKENS.ETHEREUM_MAINNET.USDC, + buyToken: TOKENS.SOLANA_MAINNET.USDC, + sellAmount: "10000000", // 10 USDC + from: { + address: EVM_ADDRESS, + networkId: NetworkId.ETHEREUM_MAINNET, + }, + to: { + address: SOLANA_ADDRESS, + networkId: NetworkId.SOLANA_MAINNET, + }, + slippageTolerance: 1.0, + }); + + expect(quotes).toBeDefined(); + expect(quotes.type).toBe("xchain"); + expect(quotes.quotes).toBeDefined(); + + if (quotes.quotes.length > 0) { + const firstQuote = quotes.quotes[0] as any; + expect(firstQuote.steps).toBeDefined(); + expect(Array.isArray(firstQuote.steps)).toBe(true); + } + }, 30000); + + it("should get bridge quotes from Base to Solana (USDC)", async () => { + const quotes = await sdk.getQuotes({ + sellToken: TOKENS.BASE_MAINNET.USDC, + buyToken: TOKENS.SOLANA_MAINNET.USDC, + sellAmount: "5000000", // 5 USDC + from: { + address: EVM_ADDRESS, + networkId: NetworkId.BASE_MAINNET, + }, + to: { + address: SOLANA_ADDRESS, + networkId: NetworkId.SOLANA_MAINNET, + }, + slippageTolerance: 1.0, + }); + + expect(quotes).toBeDefined(); + expect(quotes.type).toBe("xchain"); + }, 30000); + + it("should get bridge quotes from Solana to Ethereum (USDC)", async () => { + const quotes = await sdk.getQuotes({ + sellToken: TOKENS.SOLANA_MAINNET.USDC, + buyToken: TOKENS.ETHEREUM_MAINNET.USDC, + sellAmount: "10000000", // 10 USDC + from: { + address: SOLANA_ADDRESS, + networkId: NetworkId.SOLANA_MAINNET, + }, + to: { + address: EVM_ADDRESS, + networkId: NetworkId.ETHEREUM_MAINNET, + }, + slippageTolerance: 1.0, + }); + + expect(quotes).toBeDefined(); + expect(quotes.type).toBe("xchain"); + }, 30000); + }); + + describe("SDK Operations", () => { + it("should initialize a swap session", async () => { + const result = await sdk.initializeSwap({ + type: "swap", + network: NetworkId.SOLANA_MAINNET as any, // Will be transformed internally + address: SOLANA_ADDRESS, + }); + + expect(result).toBeDefined(); + }, 10000); + + it("should get user permissions", async () => { + const permissions = await sdk.getPermissions(); + + expect(permissions).toBeDefined(); + expect(permissions.perps).toBeDefined(); + expect(typeof permissions.perps.actions).toBe("boolean"); + }, 10000); + + it("should get list of bridgeable tokens", async () => { + const response = await sdk.getBridgeableTokens(); + + expect(response).toBeDefined(); + expect(response.tokens).toBeDefined(); + expect(Array.isArray(response.tokens)).toBe(true); + expect(response.tokens.length).toBeGreaterThan(0); + + const firstToken = response.tokens[0]; + expect(firstToken.chainId).toBeDefined(); + expect(firstToken.resourceType).toBeDefined(); + }, 10000); + + it("should get preferred bridge providers", async () => { + const response = await sdk.getPreferredBridges(); + + expect(response).toBeDefined(); + expect(response.providers).toBeDefined(); + expect(Array.isArray(response.providers)).toBe(true); + + if (response.providers.length > 0) { + response.providers.forEach(provider => { + expect(typeof provider).toBe('string'); + }); + } + }, 10000); + }); + + describe("Error Handling", () => { + it("should handle invalid token pairs gracefully", async () => { + try { + await sdk.getQuotes({ + sellToken: { + type: "address", + address: "0x0000000000000000000000000000000000000000", + networkId: NetworkId.ETHEREUM_MAINNET, + }, + buyToken: TOKENS.ETHEREUM_MAINNET.USDC, + sellAmount: "1000000", + from: { + address: EVM_ADDRESS, + networkId: NetworkId.ETHEREUM_MAINNET, + }, + }); + } catch (error: any) { + expect(error).toBeDefined(); + } + }, 10000); + }); +}); \ No newline at end of file diff --git a/packages/swapper-sdk/tests/swapper-sdk.test.ts b/packages/swapper-sdk/tests/swapper-sdk.test.ts index 0c9dc03c..4e985ef4 100644 --- a/packages/swapper-sdk/tests/swapper-sdk.test.ts +++ b/packages/swapper-sdk/tests/swapper-sdk.test.ts @@ -1,5 +1,5 @@ import { SwapperSDK } from "../src/swapper-sdk"; -import { ChainID } from "../src/types"; +import { NetworkId } from "../src/types/networks"; describe("SwapperSDK", () => { let sdk: SwapperSDK; @@ -8,7 +8,9 @@ describe("SwapperSDK", () => { beforeEach(() => { sdk = new SwapperSDK({ apiUrl: "https://api.test.com", - debug: false, + options: { + debug: false, + }, }); }); @@ -21,10 +23,11 @@ describe("SwapperSDK", () => { it("should initialize with custom config", () => { const customSdk = new SwapperSDK({ apiUrl: "https://custom.api.com", - headers: { - "X-Phantom-Version": "1.0.0", + options: { + organizationId: "test-org", + countryCode: "US", + debug: true, }, - debug: true, }); expect(customSdk).toBeDefined(); }); @@ -36,17 +39,17 @@ describe("SwapperSDK", () => { type: "solana", quotes: [], taker: { - chainId: ChainID.SolanaMainnet, + chainId: "solana:101", resourceType: "address" as const, address: "test-address", }, buyToken: { - chainId: ChainID.SolanaMainnet, + chainId: "solana:101", resourceType: "address" as const, address: "buy-token", }, sellToken: { - chainId: ChainID.SolanaMainnet, + chainId: "solana:101", resourceType: "nativeToken" as const, slip44: "501", }, @@ -59,22 +62,20 @@ describe("SwapperSDK", () => { } as Response); const result = await sdk.getQuotes({ - taker: { - chainId: ChainID.SolanaMainnet, - resourceType: "address", - address: "test-address", + sellToken: { + type: "native", + networkId: NetworkId.SOLANA_MAINNET, }, buyToken: { - chainId: ChainID.SolanaMainnet, - resourceType: "address", + type: "address", address: "buy-token", - }, - sellToken: { - chainId: ChainID.SolanaMainnet, - resourceType: "nativeToken", - slip44: "501", + networkId: NetworkId.SOLANA_MAINNET, }, sellAmount: "1000000000", + from: { + address: "test-address", + networkId: NetworkId.SOLANA_MAINNET, + }, }); expect(result).toEqual(mockResponse); @@ -93,22 +94,20 @@ describe("SwapperSDK", () => { await expect( sdk.getQuotes({ - taker: { - chainId: ChainID.SolanaMainnet, - resourceType: "address", - address: "test-address", + sellToken: { + type: "native", + networkId: NetworkId.SOLANA_MAINNET, }, buyToken: { - chainId: ChainID.SolanaMainnet, - resourceType: "address", + type: "address", address: "buy-token", - }, - sellToken: { - chainId: ChainID.SolanaMainnet, - resourceType: "nativeToken", - slip44: "501", + networkId: NetworkId.SOLANA_MAINNET, }, sellAmount: "1000000000", + from: { + address: "test-address", + networkId: NetworkId.SOLANA_MAINNET, + }, }) ).rejects.toMatchObject({ code: "INVALID_TOKEN_PAIR", @@ -123,7 +122,7 @@ describe("SwapperSDK", () => { const mockResponse = { buyToken: { address: "token-address", - chainId: ChainID.SolanaMainnet, + chainId: "solana:101", symbol: "USDC", name: "USD Coin", decimals: 6, @@ -137,7 +136,7 @@ describe("SwapperSDK", () => { const result = await sdk.initializeSwap({ type: "swap", - network: ChainID.SolanaMainnet, + network: "solana:101" as any, }); expect(result).toEqual(mockResponse); @@ -168,12 +167,12 @@ describe("SwapperSDK", () => { const mockResponse = { tokens: [ { - chainId: ChainID.SolanaMainnet, + chainId: "solana:101", resourceType: "address" as const, address: "token1", }, { - chainId: ChainID.EthereumMainnet, + chainId: "eip155:1", resourceType: "address" as const, address: "token2", }, @@ -195,8 +194,7 @@ describe("SwapperSDK", () => { it("should update headers", () => { expect(() => { sdk.updateHeaders({ - "X-Phantom-Version": "2.0.0", - "X-Phantom-Platform": "test", + "X-Custom-Header": "value", }); }).not.toThrow(); }); diff --git a/yarn.lock b/yarn.lock index 314c1b96..7aab0dcf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3249,6 +3249,7 @@ __metadata: "@phantom/base64url": "workspace:^" "@phantom/crypto": "workspace:^" "@phantom/openapi-wallet-service": "npm:^0.1.7" + "@phantom/swapper-sdk": "workspace:^" "@types/jest": "npm:^29.5.12" "@types/node": "npm:^20.11.0" axios: "npm:^1.10.0" @@ -3524,6 +3525,24 @@ __metadata: languageName: unknown linkType: soft +"@phantom/swapper-sdk@workspace:^, @phantom/swapper-sdk@workspace:packages/swapper-sdk": + version: 0.0.0-use.local + resolution: "@phantom/swapper-sdk@workspace:packages/swapper-sdk" + dependencies: + "@types/jest": "npm:^29.5.12" + "@types/node": "npm:^20.11.0" + dotenv: "npm:^16.4.1" + eslint: "npm:8.53.0" + eventsource: "npm:^2.0.2" + jest: "npm:^29.7.0" + prettier: "npm:^3.5.2" + rimraf: "npm:^6.0.1" + ts-jest: "npm:^29.1.2" + tsup: "npm:^6.7.0" + typescript: "npm:^5.0.4" + languageName: unknown + linkType: soft + "@phantom/wallet-sdk@workspace:^, @phantom/wallet-sdk@workspace:packages/browser-embedded-sdk": version: 0.0.0-use.local resolution: "@phantom/wallet-sdk@workspace:packages/browser-embedded-sdk" @@ -8356,6 +8375,13 @@ __metadata: languageName: node linkType: hard +"eventsource@npm:^2.0.2": + version: 2.0.2 + resolution: "eventsource@npm:2.0.2" + checksum: 10c0/0b8c70b35e45dd20f22ff64b001be9d530e33b92ca8bdbac9e004d0be00d957ab02ef33c917315f59bf2f20b178c56af85c52029bc8e6cc2d61c31d87d943573 + languageName: node + linkType: hard + "exec-async@npm:^2.2.0": version: 2.2.0 resolution: "exec-async@npm:2.2.0" From e5eac787f7a8686bf2d0be2889bfc2ebd6085a27 Mon Sep 17 00:00:00 2001 From: Rafael Date: Sat, 9 Aug 2025 21:16:18 +0200 Subject: [PATCH 3/4] fix lint --- packages/client/package.json | 1 + packages/client/src/SwapperClient.ts | 24 ++++----------- packages/client/tests/SwapperClient.test.ts | 2 +- packages/swapper-sdk/src/api/client.ts | 30 +++++++++---------- packages/swapper-sdk/src/api/streaming.ts | 2 +- .../swapper-sdk/src/utils/transformers.ts | 3 +- yarn.lock | 3 +- 7 files changed, 28 insertions(+), 37 deletions(-) diff --git a/packages/client/package.json b/packages/client/package.json index dcb2c468..e9f3a8b1 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -25,6 +25,7 @@ "prettier": "prettier --write \"src/**/*.{ts,tsx}\"" }, "devDependencies": { + "@jest/globals": "^30.0.5", "@types/jest": "^29.5.12", "@types/node": "^20.11.0", "eslint": "8.53.0", diff --git a/packages/client/src/SwapperClient.ts b/packages/client/src/SwapperClient.ts index 77f07ab9..d0303808 100644 --- a/packages/client/src/SwapperClient.ts +++ b/packages/client/src/SwapperClient.ts @@ -7,7 +7,7 @@ import SwapperSDK, { type GetBridgeableTokensResponse, type GetIntentsStatusResponse, type Token, - type SwapperNetworkId, + type NetworkId as SwapperNetworkId, } from "@phantom/swapper-sdk"; import { getNetworkConfig } from "./constants"; import type { SignedTransaction } from "./types"; @@ -97,11 +97,11 @@ export class SwapperClient { return networkId.startsWith("eip155:"); } - private async approveTokenIfNeeded( + private approveTokenIfNeeded( walletId: string, quote: SwapperEvmQuoteRepresentation, - networkId: NetworkId, - ): Promise { + _networkId: NetworkId, + ): void { if (!quote.approvalExactAmount || quote.approvalExactAmount === "0") { return; } @@ -122,7 +122,6 @@ export class SwapperClient { } async swap(params: SwapParams): Promise { - try { // Get all wallet addresses (uses default derivation paths) const addresses = await this.phantomClient.getWalletAddresses(params.walletId); @@ -201,15 +200,9 @@ export class SwapperClient { }); return result; - } catch (error) { - // Log swap failure - // console.error("Swap failed:", error); - throw error; - } } async bridge(params: BridgeParams): Promise { - try { // Get all wallet addresses const addresses = await this.phantomClient.getWalletAddresses(params.walletId); @@ -314,18 +307,13 @@ export class SwapperClient { }); return result; - } catch (error) { - // Log bridge failure - // console.error("Bridge failed:", error); - throw error; - } } - async getBridgeableTokens(): Promise { + getBridgeableTokens(): Promise { return this.swapperSDK.getBridgeableTokens(); } - async getBridgeStatus(requestId: string): Promise { + getBridgeStatus(requestId: string): Promise { return this.swapperSDK.getIntentsStatus({ requestId }); } diff --git a/packages/client/tests/SwapperClient.test.ts b/packages/client/tests/SwapperClient.test.ts index e116067f..633397de 100644 --- a/packages/client/tests/SwapperClient.test.ts +++ b/packages/client/tests/SwapperClient.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, jest, beforeEach } from "@jest/globals"; import { SwapperClient } from "../src/SwapperClient"; -import { PhantomClient } from "../src/PhantomClient"; +import type { PhantomClient } from "../src/PhantomClient"; import { NetworkId } from "../src/caip2-mappings"; import { AddressType } from "../src"; import type { SignedTransaction } from "../src/types"; diff --git a/packages/swapper-sdk/src/api/client.ts b/packages/swapper-sdk/src/api/client.ts index c1681fd6..1b1f20b6 100644 --- a/packages/swapper-sdk/src/api/client.ts +++ b/packages/swapper-sdk/src/api/client.ts @@ -85,14 +85,14 @@ export class SwapperAPIClient { const timeoutId = setTimeout(() => controller.abort(), this.timeout); // Log the outgoing request - console.log("=== OUTGOING API REQUEST ==="); - console.log(`URL: ${url.toString()}`); - console.log(`Method: ${method}`); - console.log(`Headers:`, JSON.stringify({ ...this.headers, ...headers }, null, 2)); - if (body) { - console.log(`Body:`, JSON.stringify(body, null, 2)); - } - console.log("============================="); + // console.log("=== OUTGOING API REQUEST ==="); + // console.log(`URL: ${url.toString()}`); + // console.log(`Method: ${method}`); + // console.log(`Headers:`, JSON.stringify({ ...this.headers, ...headers }, null, 2)); + // if (body) { + // console.log(`Body:`, JSON.stringify(body, null, 2)); + // } + // console.log("============================="); try { const response = await fetch(url.toString(), { @@ -107,14 +107,14 @@ export class SwapperAPIClient { clearTimeout(timeoutId); - console.log("=== API RESPONSE ==="); - console.log(`Status: ${response.status} ${response.statusText}`); - console.log(`Response Headers:`, Object.fromEntries(response.headers.entries())); + // console.log("=== API RESPONSE ==="); + // console.log(`Status: ${response.status} ${response.statusText}`); + // console.log(`Response Headers:`, Object.fromEntries(response.headers.entries())); if (!response.ok) { const errorData = await response.json().catch(() => ({})); - console.log(`Error Response Body:`, JSON.stringify(errorData, null, 2)); - console.log("===================="); + // console.log(`Error Response Body:`, JSON.stringify(errorData, null, 2)); + // console.log("===================="); const error: ErrorResponse = { code: errorData.code || "UNKNOWN_ERROR", message: errorData.message || `Request failed with status ${response.status}`, @@ -125,8 +125,8 @@ export class SwapperAPIClient { } const responseData = await response.json(); - console.log(`Success Response Body:`, JSON.stringify(responseData, null, 2)); - console.log("===================="); + // console.log(`Success Response Body:`, JSON.stringify(responseData, null, 2)); + // console.log("===================="); return responseData; } catch (error: any) { clearTimeout(timeoutId); diff --git a/packages/swapper-sdk/src/api/streaming.ts b/packages/swapper-sdk/src/api/streaming.ts index 4baa6617..1e2e9bc2 100644 --- a/packages/swapper-sdk/src/api/streaming.ts +++ b/packages/swapper-sdk/src/api/streaming.ts @@ -1,4 +1,4 @@ -import type { EventType, SSEEvent, SwapperQuery, SwapperQuotesDataRepresentation } from "../types"; +import type { SwapperQuery, SwapperQuotesDataRepresentation } from "../types"; import type { SwapperAPIClient } from "./client"; export interface StreamQuotesOptions extends SwapperQuery { diff --git a/packages/swapper-sdk/src/utils/transformers.ts b/packages/swapper-sdk/src/utils/transformers.ts index 1d56a0e6..1ac91aa5 100644 --- a/packages/swapper-sdk/src/utils/transformers.ts +++ b/packages/swapper-sdk/src/utils/transformers.ts @@ -1,6 +1,7 @@ import type { GetQuotesParams, Token, UserAddress } from "../types/public-api"; import type { SwapperCaip19, SwapperQuotesBody, SwapperInitializeRequestParams } from "../types"; -import { ChainID, NATIVE_TOKEN_SLIP44, NATIVE_TOKEN_SLIP44_BY_CHAIN, NETWORK_TO_CHAIN_MAP, NetworkId } from "../types/networks"; +import { NATIVE_TOKEN_SLIP44, NATIVE_TOKEN_SLIP44_BY_CHAIN, NETWORK_TO_CHAIN_MAP } from "../types/networks"; +import type { NetworkId } from "../types/networks"; /** * Convert a Token to SwapperCaip19 format diff --git a/yarn.lock b/yarn.lock index 7aab0dcf..f6f8ed9b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2663,7 +2663,7 @@ __metadata: languageName: node linkType: hard -"@jest/globals@npm:30.0.5": +"@jest/globals@npm:30.0.5, @jest/globals@npm:^30.0.5": version: 30.0.5 resolution: "@jest/globals@npm:30.0.5" dependencies: @@ -3245,6 +3245,7 @@ __metadata: version: 0.0.0-use.local resolution: "@phantom/client@workspace:packages/client" dependencies: + "@jest/globals": "npm:^30.0.5" "@phantom/api-key-stamper": "workspace:^" "@phantom/base64url": "workspace:^" "@phantom/crypto": "workspace:^" From eb6bd091f0e700cfc037feb9c15a973706dfbca9 Mon Sep 17 00:00:00 2001 From: rafael pedrola Date: Wed, 13 Aug 2025 16:31:18 +0200 Subject: [PATCH 4/4] updated --- packages/client/src/SwapperClient.ts | 2 +- packages/constants/src/network-ids.ts | 3 + packages/constants/src/networks.ts | 151 ++++++++++++++++ packages/swapper-sdk/jest.config.js | 1 + packages/swapper-sdk/package.json | 1 + packages/swapper-sdk/src/constants/tokens.ts | 2 +- packages/swapper-sdk/src/index.ts | 4 +- packages/swapper-sdk/src/types/bridge.ts | 4 +- packages/swapper-sdk/src/types/chains.ts | 18 +- packages/swapper-sdk/src/types/index.ts | 2 +- packages/swapper-sdk/src/types/initialize.ts | 10 +- packages/swapper-sdk/src/types/networks.ts | 161 ------------------ packages/swapper-sdk/src/types/public-api.ts | 2 +- packages/swapper-sdk/src/types/quotes.ts | 6 +- .../swapper-sdk/src/utils/transformers.ts | 20 ++- packages/swapper-sdk/tests/client.test.ts | 2 +- .../swapper-sdk/tests/swapper-sdk.test.ts | 2 +- yarn.lock | 1 + 18 files changed, 188 insertions(+), 204 deletions(-) delete mode 100644 packages/swapper-sdk/src/types/networks.ts diff --git a/packages/client/src/SwapperClient.ts b/packages/client/src/SwapperClient.ts index d0303808..d91f010b 100644 --- a/packages/client/src/SwapperClient.ts +++ b/packages/client/src/SwapperClient.ts @@ -11,7 +11,7 @@ import SwapperSDK, { } from "@phantom/swapper-sdk"; import { getNetworkConfig } from "./constants"; import type { SignedTransaction } from "./types"; -import type { NetworkId } from "./caip2-mappings"; +import type { NetworkId } from "@phantom/constants"; // Re-export types that users will need export type { Token } from "@phantom/swapper-sdk"; diff --git a/packages/constants/src/network-ids.ts b/packages/constants/src/network-ids.ts index 63904414..24b87f3a 100644 --- a/packages/constants/src/network-ids.ts +++ b/packages/constants/src/network-ids.ts @@ -16,6 +16,7 @@ export enum NetworkId { // Polygon Networks POLYGON_MAINNET = "eip155:137", POLYGON_MUMBAI = "eip155:80001", + POLYGON_AMOY = "eip155:80002", // Optimism Networks OPTIMISM_MAINNET = "eip155:10", @@ -24,6 +25,7 @@ export enum NetworkId { // Arbitrum Networks ARBITRUM_ONE = "eip155:42161", ARBITRUM_GOERLI = "eip155:421613", + ARBITRUM_SEPOLIA = "eip155:421614", // Base Networks BASE_MAINNET = "eip155:8453", @@ -37,4 +39,5 @@ export enum NetworkId { // Sui Networks (for future support) SUI_MAINNET = "sui:35834a8a", SUI_TESTNET = "sui:4c78adac", + SUI_DEVNET = "sui:devnet", } diff --git a/packages/constants/src/networks.ts b/packages/constants/src/networks.ts index a6ec76a4..dbe3313a 100644 --- a/packages/constants/src/networks.ts +++ b/packages/constants/src/networks.ts @@ -4,6 +4,8 @@ export interface NetworkConfig { name: string; chain: string; network: string; + chainId?: string; // Internal ChainID for swapper API + slip44?: string; // SLIP-44 coin type explorer?: { name: string; transactionUrl: string; // Template with {hash} placeholder @@ -17,6 +19,8 @@ export const NETWORK_CONFIGS: Record = { name: "Solana Mainnet", chain: "solana", network: "mainnet", + chainId: "solana:101", + slip44: "501", explorer: { name: "Solscan", transactionUrl: "https://solscan.io/tx/{hash}", @@ -27,6 +31,8 @@ export const NETWORK_CONFIGS: Record = { name: "Solana Devnet", chain: "solana", network: "devnet", + chainId: "solana:103", + slip44: "501", explorer: { name: "Solscan", transactionUrl: "https://solscan.io/tx/{hash}?cluster=devnet", @@ -37,6 +43,8 @@ export const NETWORK_CONFIGS: Record = { name: "Solana Testnet", chain: "solana", network: "testnet", + chainId: "solana:102", + slip44: "501", explorer: { name: "Solscan", transactionUrl: "https://solscan.io/tx/{hash}?cluster=testnet", @@ -49,6 +57,8 @@ export const NETWORK_CONFIGS: Record = { name: "Ethereum Mainnet", chain: "ethereum", network: "mainnet", + chainId: "eip155:1", + slip44: "60", explorer: { name: "Etherscan", transactionUrl: "https://etherscan.io/tx/{hash}", @@ -59,6 +69,8 @@ export const NETWORK_CONFIGS: Record = { name: "Ethereum Goerli", chain: "ethereum", network: "goerli", + chainId: "eip155:11155111", // Maps to Sepolia for swapper + slip44: "60", explorer: { name: "Etherscan", transactionUrl: "https://goerli.etherscan.io/tx/{hash}", @@ -69,6 +81,8 @@ export const NETWORK_CONFIGS: Record = { name: "Ethereum Sepolia", chain: "ethereum", network: "sepolia", + chainId: "eip155:11155111", + slip44: "60", explorer: { name: "Etherscan", transactionUrl: "https://sepolia.etherscan.io/tx/{hash}", @@ -81,6 +95,8 @@ export const NETWORK_CONFIGS: Record = { name: "Polygon Mainnet", chain: "polygon", network: "mainnet", + chainId: "eip155:137", + slip44: "137", explorer: { name: "Polygonscan", transactionUrl: "https://polygonscan.com/tx/{hash}", @@ -91,18 +107,34 @@ export const NETWORK_CONFIGS: Record = { name: "Polygon Mumbai", chain: "polygon", network: "mumbai", + chainId: "eip155:80002", // Maps to Amoy for swapper + slip44: "137", explorer: { name: "Polygonscan", transactionUrl: "https://mumbai.polygonscan.com/tx/{hash}", addressUrl: "https://mumbai.polygonscan.com/address/{address}", }, }, + [NetworkId.POLYGON_AMOY]: { + name: "Polygon Amoy", + chain: "polygon", + network: "amoy", + chainId: "eip155:80002", + slip44: "137", + explorer: { + name: "Polygonscan", + transactionUrl: "https://amoy.polygonscan.com/tx/{hash}", + addressUrl: "https://amoy.polygonscan.com/address/{address}", + }, + }, // Base Networks [NetworkId.BASE_MAINNET]: { name: "Base Mainnet", chain: "base", network: "mainnet", + chainId: "eip155:8453", + slip44: "8453", explorer: { name: "Basescan", transactionUrl: "https://basescan.org/tx/{hash}", @@ -113,6 +145,8 @@ export const NETWORK_CONFIGS: Record = { name: "Base Goerli", chain: "base", network: "goerli", + chainId: "eip155:84532", // Maps to Sepolia for swapper + slip44: "8453", explorer: { name: "Basescan", transactionUrl: "https://goerli.basescan.org/tx/{hash}", @@ -123,6 +157,8 @@ export const NETWORK_CONFIGS: Record = { name: "Base Sepolia", chain: "base", network: "sepolia", + chainId: "eip155:84532", + slip44: "8453", explorer: { name: "Basescan", transactionUrl: "https://sepolia.basescan.org/tx/{hash}", @@ -135,6 +171,8 @@ export const NETWORK_CONFIGS: Record = { name: "Arbitrum One", chain: "arbitrum", network: "mainnet", + chainId: "eip155:42161", + slip44: "42161", explorer: { name: "Arbiscan", transactionUrl: "https://arbiscan.io/tx/{hash}", @@ -145,18 +183,34 @@ export const NETWORK_CONFIGS: Record = { name: "Arbitrum Goerli", chain: "arbitrum", network: "goerli", + chainId: "eip155:421614", // Maps to Sepolia for swapper + slip44: "42161", explorer: { name: "Arbiscan", transactionUrl: "https://goerli.arbiscan.io/tx/{hash}", addressUrl: "https://goerli.arbiscan.io/address/{address}", }, }, + [NetworkId.ARBITRUM_SEPOLIA]: { + name: "Arbitrum Sepolia", + chain: "arbitrum", + network: "sepolia", + chainId: "eip155:421614", + slip44: "42161", + explorer: { + name: "Arbiscan", + transactionUrl: "https://sepolia.arbiscan.io/tx/{hash}", + addressUrl: "https://sepolia.arbiscan.io/address/{address}", + }, + }, // Optimism Networks [NetworkId.OPTIMISM_MAINNET]: { name: "Optimism Mainnet", chain: "optimism", network: "mainnet", + chainId: "eip155:10", + slip44: "60", // Uses Ethereum SLIP-44 explorer: { name: "Optimistic Etherscan", transactionUrl: "https://optimistic.etherscan.io/tx/{hash}", @@ -167,6 +221,8 @@ export const NETWORK_CONFIGS: Record = { name: "Optimism Goerli", chain: "optimism", network: "goerli", + chainId: "eip155:420", + slip44: "60", // Uses Ethereum SLIP-44 explorer: { name: "Optimistic Etherscan", transactionUrl: "https://goerli-optimism.etherscan.io/tx/{hash}", @@ -179,6 +235,8 @@ export const NETWORK_CONFIGS: Record = { name: "Bitcoin Mainnet", chain: "bitcoin", network: "mainnet", + chainId: "bip122:000000000019d6689c085ae165831e93", + slip44: "0", explorer: { name: "Blockstream", transactionUrl: "https://blockstream.info/tx/{hash}", @@ -189,6 +247,8 @@ export const NETWORK_CONFIGS: Record = { name: "Bitcoin Testnet", chain: "bitcoin", network: "testnet", + chainId: "bip122:000000000933ea01ad0ee984209779ba", + slip44: "0", explorer: { name: "Blockstream", transactionUrl: "https://blockstream.info/testnet/tx/{hash}", @@ -201,6 +261,8 @@ export const NETWORK_CONFIGS: Record = { name: "Sui Mainnet", chain: "sui", network: "mainnet", + chainId: "sui:mainnet", + slip44: "784", explorer: { name: "Sui Explorer", transactionUrl: "https://explorer.sui.io/txblock/{hash}?network=mainnet", @@ -211,12 +273,26 @@ export const NETWORK_CONFIGS: Record = { name: "Sui Testnet", chain: "sui", network: "testnet", + chainId: "sui:testnet", + slip44: "784", explorer: { name: "Sui Explorer", transactionUrl: "https://explorer.sui.io/txblock/{hash}?network=testnet", addressUrl: "https://explorer.sui.io/address/{address}?network=testnet", }, }, + [NetworkId.SUI_DEVNET]: { + name: "Sui Devnet", + chain: "sui", + network: "devnet", + chainId: "sui:devnet", + slip44: "784", + explorer: { + name: "Sui Explorer", + transactionUrl: "https://explorer.sui.io/txblock/{hash}?network=devnet", + addressUrl: "https://explorer.sui.io/address/{address}?network=devnet", + }, + }, }; export function getNetworkConfig(networkId: NetworkId): NetworkConfig | undefined { @@ -246,3 +322,78 @@ export function getNetworksByChain(chain: string): NetworkId[] { .filter(([_, config]) => config.chain === chain) .map(([networkId]) => networkId as NetworkId); } + +// ===== SWAPPER-SPECIFIC CONSTANTS ===== + +/** + * Swapper swap types based on chain namespace + */ +export enum SwapType { + Solana = "solana", + EVM = "eip155", + XChain = "xchain", + Sui = "sui", +} + +/** + * Fee types for swapper operations + */ +export enum FeeType { + NETWORK = "NETWORK", + PROTOCOL = "PROTOCOL", + PHANTOM = "PHANTOM", + OTHER = "OTHER", +} + +/** + * Get internal ChainID for swapper API from NetworkId + */ +export function getChainIdForSwapper(networkId: NetworkId): string | undefined { + return NETWORK_CONFIGS[networkId]?.chainId; +} + +/** + * Get SLIP-44 coin type from NetworkId + */ +export function getSlip44ForNetwork(networkId: NetworkId): string | undefined { + return NETWORK_CONFIGS[networkId]?.slip44; +} + +/** + * Maps NetworkId to ChainID format for backward compatibility with swapper-sdk + * @deprecated Use getChainIdForSwapper() instead + */ +export function getNetworkToChainMapping(): Record { + const mapping: Record = {}; + Object.entries(NETWORK_CONFIGS).forEach(([networkId, config]) => { + if (config.chainId) { + mapping[networkId] = config.chainId; + } + }); + return mapping as Record; +} + +/** + * Get SLIP-44 mapping by chainId for backward compatibility with swapper-sdk + * @deprecated Use getSlip44ForNetwork() instead + */ +export function getNativeTokenSlip44ByChain(): Record { + const mapping: Record = {}; + Object.values(NETWORK_CONFIGS).forEach(config => { + if (config.chainId && config.slip44) { + mapping[config.chainId] = config.slip44; + } + }); + return mapping; +} + +/** + * Fallback SLIP-44 values by namespace (for backward compatibility) + * @deprecated Use getSlip44ForNetwork() instead + */ +export const NATIVE_TOKEN_SLIP44_FALLBACK: Record = { + solana: "501", + eip155: "60", // Ethereum default + sui: "784", + bip122: "0", // Bitcoin +}; diff --git a/packages/swapper-sdk/jest.config.js b/packages/swapper-sdk/jest.config.js index c5d843f7..ceda5d6b 100644 --- a/packages/swapper-sdk/jest.config.js +++ b/packages/swapper-sdk/jest.config.js @@ -6,6 +6,7 @@ module.exports = { testEnvironment: "node", roots: ["/src", "/tests"], testMatch: ["**/*.test.ts"], + setupFilesAfterEnv: ["/tests/setup.ts"], moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], collectCoverageFrom: [ "src/**/*.{ts,tsx}", diff --git a/packages/swapper-sdk/package.json b/packages/swapper-sdk/package.json index b419e572..f4f82096 100644 --- a/packages/swapper-sdk/package.json +++ b/packages/swapper-sdk/package.json @@ -39,6 +39,7 @@ "typescript": "^5.0.4" }, "dependencies": { + "@phantom/constants": "workspace:^", "eventsource": "^2.0.2" }, "files": [ diff --git a/packages/swapper-sdk/src/constants/tokens.ts b/packages/swapper-sdk/src/constants/tokens.ts index e7f25682..017db0b5 100644 --- a/packages/swapper-sdk/src/constants/tokens.ts +++ b/packages/swapper-sdk/src/constants/tokens.ts @@ -1,4 +1,4 @@ -import { NetworkId } from "../types/networks"; +import { NetworkId } from "@phantom/constants"; import type { Token } from "../types/public-api"; /** diff --git a/packages/swapper-sdk/src/index.ts b/packages/swapper-sdk/src/index.ts index b4d5d5d9..6b6c6e01 100644 --- a/packages/swapper-sdk/src/index.ts +++ b/packages/swapper-sdk/src/index.ts @@ -1,5 +1,5 @@ export * from "./swapper-sdk"; -export { NetworkId } from "./types/networks"; +export { NetworkId, SwapType, FeeType } from "@phantom/constants"; export { TOKENS } from "./constants/tokens"; export type { // Public API types @@ -26,8 +26,6 @@ export type { InitializeFundingResponse, WithdrawalQueueResponse, RelayExecutionStatus, - SwapType, - FeeType, } from "./types"; export { SwapperSDK as default } from "./swapper-sdk"; \ No newline at end of file diff --git a/packages/swapper-sdk/src/types/bridge.ts b/packages/swapper-sdk/src/types/bridge.ts index 3e29f566..862df3eb 100644 --- a/packages/swapper-sdk/src/types/bridge.ts +++ b/packages/swapper-sdk/src/types/bridge.ts @@ -1,5 +1,5 @@ import type { SwapperCaip19 } from "./chains"; -import type { ChainID } from "./networks"; +// ChainID is now internal to constants package export interface GetBridgeableTokensResponse { tokens: SwapperCaip19[]; @@ -99,7 +99,7 @@ export enum OperationState { export interface InitializeFundingParams { type: "deposit" | "withdraw"; taker: string; - originChain: ChainID; + originChain: string; } export interface InitializeFundingResponse { diff --git a/packages/swapper-sdk/src/types/chains.ts b/packages/swapper-sdk/src/types/chains.ts index 907a6958..019af6d1 100644 --- a/packages/swapper-sdk/src/types/chains.ts +++ b/packages/swapper-sdk/src/types/chains.ts @@ -1,22 +1,8 @@ -import type { ChainID } from "./networks"; - export interface SwapperCaip19 { - chainId: ChainID; + chainId: string; // ChainID is now internal to constants package resourceType: "address" | "nativeToken"; address?: string; slip44?: string; } -export enum SwapType { - Solana = "solana", - EVM = "eip155", - XChain = "xchain", - Sui = "sui", -} - -export enum FeeType { - NETWORK = "NETWORK", - PROTOCOL = "PROTOCOL", - PHANTOM = "PHANTOM", - OTHER = "OTHER", -} \ No newline at end of file +// SwapType and FeeType are now exported from @phantom/constants \ No newline at end of file diff --git a/packages/swapper-sdk/src/types/index.ts b/packages/swapper-sdk/src/types/index.ts index e1655b22..5d6bc3a7 100644 --- a/packages/swapper-sdk/src/types/index.ts +++ b/packages/swapper-sdk/src/types/index.ts @@ -3,4 +3,4 @@ export * from "./quotes"; export * from "./initialize"; export * from "./bridge"; export * from "./common"; -export { ChainID } from "./networks"; \ No newline at end of file +// ChainID is now internal to constants package \ No newline at end of file diff --git a/packages/swapper-sdk/src/types/initialize.ts b/packages/swapper-sdk/src/types/initialize.ts index be975836..1d1b180d 100644 --- a/packages/swapper-sdk/src/types/initialize.ts +++ b/packages/swapper-sdk/src/types/initialize.ts @@ -1,16 +1,16 @@ -import type { ChainID } from "./networks"; +// ChainID is now internal to constants package (replaced with string) export interface SwapperInitializeRequestParams { type: "buy" | "sell" | "swap"; - network?: ChainID; + network?: string; // ChainID buyCaip19?: string; sellCaip19?: string; address?: string; - addresses?: Record; + addresses?: Record; // Record cashAddress?: string; - cashAddresses?: Record; + cashAddresses?: Record; // Record takerCaip19?: string; takerDestinationCaip19?: string; settings?: SwapperSettings; @@ -31,7 +31,7 @@ export interface SwapperInitializeResults { export interface FungibleMetadata { address: string; - chainId: ChainID; + chainId: string; // ChainID symbol: string; name: string; decimals: number; diff --git a/packages/swapper-sdk/src/types/networks.ts b/packages/swapper-sdk/src/types/networks.ts deleted file mode 100644 index 4f4ba0be..00000000 --- a/packages/swapper-sdk/src/types/networks.ts +++ /dev/null @@ -1,161 +0,0 @@ -/** - * User-friendly enum for network identifiers - * Copied from @phantom/client for consistency - */ -export enum NetworkId { - // Solana Networks - SOLANA_MAINNET = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", - SOLANA_DEVNET = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", - SOLANA_TESTNET = "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z", - - // Ethereum Networks - ETHEREUM_MAINNET = "eip155:1", - ETHEREUM_GOERLI = "eip155:5", - ETHEREUM_SEPOLIA = "eip155:11155111", - - // Polygon Networks - POLYGON_MAINNET = "eip155:137", - POLYGON_MUMBAI = "eip155:80001", - POLYGON_AMOY = "eip155:80002", - - // Arbitrum Networks - ARBITRUM_ONE = "eip155:42161", - ARBITRUM_GOERLI = "eip155:421613", - ARBITRUM_SEPOLIA = "eip155:421614", - - // Base Networks - BASE_MAINNET = "eip155:8453", - BASE_GOERLI = "eip155:84531", - BASE_SEPOLIA = "eip155:84532", - - // Bitcoin Networks - BITCOIN_MAINNET = "bip122:000000000019d6689c085ae165831e93", - BITCOIN_TESTNET = "bip122:000000000933ea01ad0ee984209779ba", - - // Sui Networks - SUI_MAINNET = "sui:35834a8a", - SUI_TESTNET = "sui:4c78adac", - SUI_DEVNET = "sui:devnet", -} - -/** - * Internal ChainID enum used by the Swapper API - * This is not exposed to SDK users - */ -export enum ChainID { - // Solana - SolanaMainnet = "solana:101", - SolanaTestnet = "solana:102", - SolanaDevnet = "solana:103", - - // Ethereum - EthereumMainnet = "eip155:1", - EthereumSepolia = "eip155:11155111", - - // Polygon - PolygonMainnet = "eip155:137", - PolygonAmoy = "eip155:80002", - - // Base - BaseMainnet = "eip155:8453", - BaseSepolia = "eip155:84532", - - // Arbitrum - ArbitrumMainnet = "eip155:42161", - ArbitrumSepolia = "eip155:421614", - - // Sui - SuiMainnet = "sui:mainnet", - SuiTestnet = "sui:testnet", - SuiDevnet = "sui:devnet", - - // Bitcoin - BitcoinMainnet = "bip122:000000000019d6689c085ae165831e93", - BitcoinTestnet = "bip122:000000000933ea01ad0ee984209779ba", -} - -/** - * Maps NetworkId to internal ChainID format - */ -export const NETWORK_TO_CHAIN_MAP: Record = { - // Solana - [NetworkId.SOLANA_MAINNET]: ChainID.SolanaMainnet, - [NetworkId.SOLANA_TESTNET]: ChainID.SolanaTestnet, - [NetworkId.SOLANA_DEVNET]: ChainID.SolanaDevnet, - - // Ethereum - [NetworkId.ETHEREUM_MAINNET]: ChainID.EthereumMainnet, - [NetworkId.ETHEREUM_SEPOLIA]: ChainID.EthereumSepolia, - [NetworkId.ETHEREUM_GOERLI]: ChainID.EthereumSepolia, // Map Goerli to Sepolia - - // Polygon - [NetworkId.POLYGON_MAINNET]: ChainID.PolygonMainnet, - [NetworkId.POLYGON_AMOY]: ChainID.PolygonAmoy, - [NetworkId.POLYGON_MUMBAI]: ChainID.PolygonAmoy, // Map Mumbai to Amoy - - // Base - [NetworkId.BASE_MAINNET]: ChainID.BaseMainnet, - [NetworkId.BASE_SEPOLIA]: ChainID.BaseSepolia, - [NetworkId.BASE_GOERLI]: ChainID.BaseSepolia, // Map Goerli to Sepolia - - // Arbitrum - [NetworkId.ARBITRUM_ONE]: ChainID.ArbitrumMainnet, - [NetworkId.ARBITRUM_SEPOLIA]: ChainID.ArbitrumSepolia, - [NetworkId.ARBITRUM_GOERLI]: ChainID.ArbitrumSepolia, // Map Goerli to Sepolia - - // Sui - [NetworkId.SUI_MAINNET]: ChainID.SuiMainnet, - [NetworkId.SUI_TESTNET]: ChainID.SuiTestnet, - [NetworkId.SUI_DEVNET]: ChainID.SuiDevnet, - - // Bitcoin - [NetworkId.BITCOIN_MAINNET]: ChainID.BitcoinMainnet, - [NetworkId.BITCOIN_TESTNET]: ChainID.BitcoinTestnet, -}; - -/** - * Get SLIP-44 coin type for native tokens by chain ID - * Only includes supported networks from NetworkId enum - */ -export const NATIVE_TOKEN_SLIP44_BY_CHAIN: Record = { - // Solana - "solana:101": "501", - "solana:102": "501", - "solana:103": "501", - - // Ethereum - "eip155:1": "60", - "eip155:11155111": "60", // Sepolia - - // Polygon - "eip155:137": "137", - "eip155:80002": "137", // Amoy - - // Base - uses its own SLIP-44 - "eip155:8453": "8453", - "eip155:84532": "8453", // Sepolia - - // Arbitrum - "eip155:42161": "42161", - "eip155:421614": "42161", // Sepolia - - - // Sui - "sui:mainnet": "784", - "sui:testnet": "784", - "sui:devnet": "784", - - // Bitcoin - "bip122:000000000019d6689c085ae165831e93": "0", - "bip122:000000000933ea01ad0ee984209779ba": "0", -}; - -/** - * Fallback SLIP-44 values by namespace (for backward compatibility) - */ -export const NATIVE_TOKEN_SLIP44: Record = { - solana: "501", - eip155: "60", // Ethereum default - sui: "784", - bip122: "0", // Bitcoin -}; \ No newline at end of file diff --git a/packages/swapper-sdk/src/types/public-api.ts b/packages/swapper-sdk/src/types/public-api.ts index 0ac0b20f..913790df 100644 --- a/packages/swapper-sdk/src/types/public-api.ts +++ b/packages/swapper-sdk/src/types/public-api.ts @@ -1,4 +1,4 @@ -import type { NetworkId } from "./networks"; +import type { NetworkId } from "@phantom/constants"; /** * Token type for swaps diff --git a/packages/swapper-sdk/src/types/quotes.ts b/packages/swapper-sdk/src/types/quotes.ts index 0e0be93e..87eec275 100644 --- a/packages/swapper-sdk/src/types/quotes.ts +++ b/packages/swapper-sdk/src/types/quotes.ts @@ -1,5 +1,5 @@ -import type { FeeType, SwapperCaip19, SwapType } from "./chains"; -import type { ChainID } from "./networks"; +import type { SwapperCaip19 } from "./chains"; +import type { FeeType, SwapType } from "@phantom/constants"; export interface SwapperQuotesBody { taker: SwapperCaip19; @@ -106,7 +106,7 @@ export interface SwapperXChainStep { includedFees: string; feeCosts: BridgeFee[]; includedFeeCosts: BridgeFee[]; - chainId: ChainID; + chainId: string; tool: BridgeTool; value?: string; allowanceTarget?: string; diff --git a/packages/swapper-sdk/src/utils/transformers.ts b/packages/swapper-sdk/src/utils/transformers.ts index 1ac91aa5..c76ee761 100644 --- a/packages/swapper-sdk/src/utils/transformers.ts +++ b/packages/swapper-sdk/src/utils/transformers.ts @@ -1,13 +1,14 @@ import type { GetQuotesParams, Token, UserAddress } from "../types/public-api"; import type { SwapperCaip19, SwapperQuotesBody, SwapperInitializeRequestParams } from "../types"; -import { NATIVE_TOKEN_SLIP44, NATIVE_TOKEN_SLIP44_BY_CHAIN, NETWORK_TO_CHAIN_MAP } from "../types/networks"; -import type { NetworkId } from "../types/networks"; +import { NATIVE_TOKEN_SLIP44_FALLBACK, getNativeTokenSlip44ByChain, getNetworkToChainMapping } from "@phantom/constants"; +import type { NetworkId } from "@phantom/constants"; /** * Convert a Token to SwapperCaip19 format */ export function tokenToSwapperCaip19(token: Token): SwapperCaip19 { - const chainId = NETWORK_TO_CHAIN_MAP[token.networkId]; + const chainIdMapping = getNetworkToChainMapping(); + const chainId = chainIdMapping[token.networkId]; if (!chainId) { throw new Error(`Unsupported network: ${token.networkId}`); @@ -15,11 +16,12 @@ export function tokenToSwapperCaip19(token: Token): SwapperCaip19 { if (token.type === "native") { // Try to get chain-specific SLIP-44 first, then fallback to namespace - let slip44 = NATIVE_TOKEN_SLIP44_BY_CHAIN[chainId]; + const slip44ByChain = getNativeTokenSlip44ByChain(); + let slip44 = slip44ByChain[chainId]; if (!slip44) { const namespace = chainId.split(":")[0]; - slip44 = NATIVE_TOKEN_SLIP44[namespace]; + slip44 = NATIVE_TOKEN_SLIP44_FALLBACK[namespace]; } if (!slip44) { @@ -48,7 +50,8 @@ export function tokenToSwapperCaip19(token: Token): SwapperCaip19 { * Convert a UserAddress to SwapperCaip19 format */ export function userAddressToSwapperCaip19(userAddress: UserAddress): SwapperCaip19 { - const chainId = NETWORK_TO_CHAIN_MAP[userAddress.networkId]; + const chainIdMapping = getNetworkToChainMapping(); + const chainId = chainIdMapping[userAddress.networkId]; if (!chainId) { throw new Error(`Unsupported network: ${userAddress.networkId}`); @@ -109,8 +112,9 @@ export function transformInitializeParams(params: SwapperInitializeRequestParams // Convert network NetworkId to ChainID if present if (params.network) { const networkId = params.network as string; - if (networkId in NETWORK_TO_CHAIN_MAP) { - const chainId = NETWORK_TO_CHAIN_MAP[networkId as NetworkId]; + const chainIdMapping = getNetworkToChainMapping(); + if (networkId in chainIdMapping) { + const chainId = chainIdMapping[networkId as NetworkId]; if (chainId) { transformedParams.network = chainId; } diff --git a/packages/swapper-sdk/tests/client.test.ts b/packages/swapper-sdk/tests/client.test.ts index e2988985..5f47f30e 100644 --- a/packages/swapper-sdk/tests/client.test.ts +++ b/packages/swapper-sdk/tests/client.test.ts @@ -157,7 +157,7 @@ describe("SwapperAPIClient", () => { json: async () => { throw new Error("Invalid JSON"); }, - } as Response); + } as unknown as Response); await expect(client.get("/test")).rejects.toMatchObject({ code: "UNKNOWN_ERROR", diff --git a/packages/swapper-sdk/tests/swapper-sdk.test.ts b/packages/swapper-sdk/tests/swapper-sdk.test.ts index 4e985ef4..ea5507f7 100644 --- a/packages/swapper-sdk/tests/swapper-sdk.test.ts +++ b/packages/swapper-sdk/tests/swapper-sdk.test.ts @@ -1,5 +1,5 @@ import { SwapperSDK } from "../src/swapper-sdk"; -import { NetworkId } from "../src/types/networks"; +import { NetworkId } from "@phantom/constants"; describe("SwapperSDK", () => { let sdk: SwapperSDK; diff --git a/yarn.lock b/yarn.lock index 69bd869b..36dd43b4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3734,6 +3734,7 @@ __metadata: version: 0.0.0-use.local resolution: "@phantom/swapper-sdk@workspace:packages/swapper-sdk" dependencies: + "@phantom/constants": "workspace:^" "@types/jest": "npm:^29.5.12" "@types/node": "npm:^20.11.0" dotenv: "npm:^16.4.1"