diff --git a/packages/browser-sdk/README.md b/packages/browser-sdk/README.md index e75eef9e..fbfd2225 100644 --- a/packages/browser-sdk/README.md +++ b/packages/browser-sdk/README.md @@ -705,7 +705,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 0f0db0f5..77b46477 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -131,7 +131,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 bc600e45..ff434362 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", @@ -42,6 +43,7 @@ "@phantom/crypto": "workspace:^", "@phantom/openapi-wallet-service": "^0.1.9", "@phantom/sdk-types": "workspace:^", + "@phantom/swapper-sdk": "workspace:^", "axios": "^1.10.0", "bs58": "^6.0.0", "buffer": "^6.0.3" diff --git a/packages/client/src/PhantomClient.ts b/packages/client/src/PhantomClient.ts index 82dd9e11..93238c73 100644 --- a/packages/client/src/PhantomClient.ts +++ b/packages/client/src/PhantomClient.ts @@ -32,7 +32,7 @@ import { type ExternalDerivedAccount, KmsUserRole, type ExternalKmsOrganization, - type DerivationInfoAddressFormatEnum, + type DerivationInfoAddressFormatEnum as AddressType, type ExternalKmsAuthenticator } from "@phantom/openapi-wallet-service"; import { DerivationPath, getNetworkConfig } from "./constants"; @@ -226,7 +226,7 @@ export class PhantomClient { async getWalletAddresses( walletId: string, derivationPaths?: string[], - ): Promise<{ addressType: DerivationInfoAddressFormatEnum; address: string }[]> { + ): Promise<{ addressType: AddressType; address: string }[]> { try { const paths = derivationPaths || [ DerivationPath.Solana, @@ -415,7 +415,7 @@ export class PhantomClient { const params: CreateOrganizationRequest = { organizationName: name, users: users.map(userConfig => ({ - role: userConfig.role === "ADMIN" ? KmsUserRole.admin : KmsUserRole.user, + role: userConfig.role === "ADMIN" ? KmsUserRole.admin : KmsUserRole.user, username: userConfig.username || `user-${Date.now()}`, authenticators: userConfig.authenticators as any, })), diff --git a/packages/client/src/SwapperClient.ts b/packages/client/src/SwapperClient.ts new file mode 100644 index 00000000..d91f010b --- /dev/null +++ b/packages/client/src/SwapperClient.ts @@ -0,0 +1,351 @@ +import type { PhantomClient } from "./PhantomClient"; +import SwapperSDK, { + type GetQuotesParams, + type SwapperSolanaQuoteRepresentation, + type SwapperEvmQuoteRepresentation, + type GenerateAndVerifyAddressResponse, + type GetBridgeableTokensResponse, + type GetIntentsStatusResponse, + type Token, + type NetworkId as SwapperNetworkId, +} from "@phantom/swapper-sdk"; +import { getNetworkConfig } from "./constants"; +import type { SignedTransaction } from "./types"; +import type { NetworkId } from "@phantom/constants"; + +// 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 approveTokenIfNeeded( + walletId: string, + quote: SwapperEvmQuoteRepresentation, + _networkId: NetworkId, + ): void { + 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 { + // 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; + } + + async bridge(params: BridgeParams): Promise { + // 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; + } + + getBridgeableTokens(): Promise { + return this.swapperSDK.getBridgeableTokens(); + } + + 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 8697fbee..21c3cbae 100644 --- a/packages/client/src/caip2-mappings.ts +++ b/packages/client/src/caip2-mappings.ts @@ -67,16 +67,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 e6bb0fc8..21fbed20 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..633397de --- /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 type { 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/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/react-sdk/README.md b/packages/react-sdk/README.md index 55a58fd8..ed538505 100644 --- a/packages/react-sdk/README.md +++ b/packages/react-sdk/README.md @@ -422,7 +422,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 58ad0737..3b88985d 100644 --- a/packages/server-sdk/README.md +++ b/packages/server-sdk/README.md @@ -262,7 +262,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/.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..458dce8f --- /dev/null +++ b/packages/swapper-sdk/README.md @@ -0,0 +1,535 @@ +# @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, NetworkId } from '@phantom/swapper-sdk'; + +// Initialize the SDK +const swapper = new SwapperSDK({ + apiUrl: 'https://api.phantom.app', // optional, this is the default + options: { + organizationId: 'your-org-id', // from Phantom Portal + countryCode: 'US', // optional, for geo-blocking + debug: true, // optional, enables debug logging + }, +}); + +// Get swap quotes +const quotes = await swapper.getQuotes({ + sellToken: { + type: 'native', + networkId: NetworkId.SOLANA_MAINNET, + }, + buyToken: { + type: 'address', + address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + networkId: NetworkId.SOLANA_MAINNET, + }, + sellAmount: '1000000000', + from: { + address: '8B3fBhkpHFMTdaQSfKVREB5HFKnZkT8rL6zKJfmmWDeZ', + networkId: NetworkId.SOLANA_MAINNET, + }, + slippageTolerance: 0.5, +}); +``` + +## Configuration + +### Default API URL + +``` +https://api.phantom.app/swap/v2 +``` + +### SDK Configuration + +```typescript +interface SwapperSDKConfig { + apiUrl?: string; // API base URL (default: https://api.phantom.app) + timeout?: number; // Request timeout in ms (default: 30000) + 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 + +#### Get Quotes + +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({ + 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 + slippageTolerance?: number, + priorityFee?: number, + tipAmount?: number, + exactOut?: boolean, + autoSlippage?: boolean, + isLedger?: boolean, +}); +``` + +#### 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(); +``` + +## Advanced Configuration + +### Custom 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-Custom-Header': 'new-value', +}); +``` + +## Types + +### NetworkId + +Supported blockchain networks: + +```typescript +enum NetworkId { + // Solana + SOLANA_MAINNET = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + SOLANA_DEVNET = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + SOLANA_TESTNET = "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z", + + // Ethereum + ETHEREUM_MAINNET = "eip155:1", + ETHEREUM_SEPOLIA = "eip155:11155111", + + // Polygon + POLYGON_MAINNET = "eip155:137", + + // Base + BASE_MAINNET = "eip155:8453", + + // Arbitrum + ARBITRUM_ONE = "eip155:42161", + + // Sui + SUI_MAINNET = "sui:35834a8a", + + // Bitcoin + BITCOIN_MAINNET = "bip122:000000000019d6689c085ae165831e93", + + // ... and more +} +``` + +### Token + +Token specification: + +```typescript +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; +} +``` + +### 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({ + sellToken: { + type: 'native', + networkId: NetworkId.SOLANA_MAINNET, + }, + buyToken: { + type: 'address', + address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + networkId: NetworkId.SOLANA_MAINNET, + }, + sellAmount: '1000000000', + from: { + address: '8B3fBhkpHFMTdaQSfKVREB5HFKnZkT8rL6zKJfmmWDeZ', + networkId: NetworkId.SOLANA_MAINNET, + }, + 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({ + sellToken: { + type: 'address', + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC + networkId: NetworkId.ETHEREUM_MAINNET, + }, + buyToken: { + 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, +}); +``` + +## 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..ceda5d6b --- /dev/null +++ b/packages/swapper-sdk/jest.config.js @@ -0,0 +1,17 @@ +const sharedConfig = require("../../sharedJestConfig"); + +module.exports = { + ...sharedConfig, + preset: "ts-jest", + testEnvironment: "node", + roots: ["/src", "/tests"], + testMatch: ["**/*.test.ts"], + setupFilesAfterEnv: ["/tests/setup.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..f4f82096 --- /dev/null +++ b/packages/swapper-sdk/package.json @@ -0,0 +1,51 @@ +{ + "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", + "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}\"" + }, + "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/constants": "workspace:^", + "eventsource": "^2.0.2" + }, + "files": [ + "dist" + ], + "publishConfig": { + "directory": "_release/package" + } +} diff --git a/packages/swapper-sdk/src/api/bridge.ts b/packages/swapper-sdk/src/api/bridge.ts new file mode 100644 index 00000000..a69452b9 --- /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 as any); + } + + async bridgeInitialize(params: GenerateAndVerifyAddressParams): Promise { + return this.client.get("/spot/bridge-initialize", params as any); + } + + async getBridgeOperations(params: OperationsParams): Promise { + return this.client.get("/spot/bridge-operations", params as any); + } + + 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..1b1f20b6 --- /dev/null +++ b/packages/swapper-sdk/src/api/client.ts @@ -0,0 +1,173 @@ +import type { ErrorResponse, Headers, OptionalHeaders } from "../types"; + +export interface SwapperClientOptions { + organizationId?: string; + countryCode?: string; + anonymousId?: string; + version?: string; +} + +export interface SwapperClientConfig { + apiUrl?: string; + options?: SwapperClientOptions; + headers?: OptionalHeaders; // Still available but not documented prominently + timeout?: number; +} + +export class SwapperAPIClient { + private readonly baseUrl: string; + private readonly headers: Headers; + private readonly timeout: number; + + constructor(config: SwapperClientConfig = {}) { + this.baseUrl = (config.apiUrl || "https://api.phantom.app") + "/swap/v2"; + this.timeout = config.timeout || 30000; + + this.headers = { + "Content-Type": "application/json", + ...this.buildHeaders(config.options, config.headers), + }; + } + + private buildHeaders(options?: SwapperClientOptions, customHeaders?: OptionalHeaders): OptionalHeaders { + const headers: OptionalHeaders = {}; + + // Platform is hardcoded to "sdk" + headers["X-Phantom-Platform"] = "sdk"; + + // Set headers from options + if (options?.organizationId) { + headers["X-Organization"] = options.organizationId; + } + + if (options?.countryCode) { + headers["cf-ipcountry"] = options.countryCode; + headers["cloudfront-viewer-country"] = options.countryCode; + } + + if (options?.anonymousId) { + headers["X-Phantom-AnonymousId"] = options.anonymousId; + } + + if (options?.version) { + headers["X-Phantom-Version"] = options.version; + } + + // Allow custom headers to override (but not documented prominently) + 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); + + // 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, + headers: { + ...this.headers, + ...headers, + }, + body: body ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + + 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}`, + statusCode: response.status, + details: errorData.details, + }; + throw error; + } + + const responseData = await response.json(); + // console.log(`Success Response Body:`, JSON.stringify(responseData, null, 2)); + // console.log("===================="); + return responseData; + } 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..dff885eb --- /dev/null +++ b/packages/swapper-sdk/src/api/initialize.ts @@ -0,0 +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 { + const transformedParams = transformInitializeParams(params); + return this.client.post("/initialize", transformedParams); + } +} \ 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..1e2e9bc2 --- /dev/null +++ b/packages/swapper-sdk/src/api/streaming.ts @@ -0,0 +1,72 @@ +import type { 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/constants/tokens.ts b/packages/swapper-sdk/src/constants/tokens.ts new file mode 100644 index 00000000..017db0b5 --- /dev/null +++ b/packages/swapper-sdk/src/constants/tokens.ts @@ -0,0 +1,201 @@ +import { NetworkId } from "@phantom/constants"; +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 new file mode 100644 index 00000000..6b6c6e01 --- /dev/null +++ b/packages/swapper-sdk/src/index.ts @@ -0,0 +1,31 @@ +export * from "./swapper-sdk"; +export { NetworkId, SwapType, FeeType } from "@phantom/constants"; +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, +} 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 new file mode 100644 index 00000000..84c7abc5 --- /dev/null +++ b/packages/swapper-sdk/src/swapper-sdk.ts @@ -0,0 +1,211 @@ +import { + BridgeAPI, + InitializeAPI, + PermissionsAPI, + QuotesAPI, + StreamingAPI, + SwapperAPIClient, + type SwapperClientConfig, + type SwapperClientOptions, +} from "./api"; +import type { + GenerateAndVerifyAddressParams, + GenerateAndVerifyAddressResponse, + GetBridgeProvidersResponse, + GetBridgeableTokensResponse, + GetIntentsStatusParams, + GetIntentsStatusResponse, + InitializeFundingParams, + InitializeFundingResponse, + OperationsParams, + OperationsResponse, + OptionalHeaders, + PermissionsResponse, + SwapperInitializeRequestParams, + SwapperInitializeResults, + SwapperQuotesDataRepresentation, + WithdrawalQueueResponse, +} from "./types"; +import type { GetQuotesParams } from "./types/public-api"; +import type { StreamQuotesOptions } from "./api/streaming"; +import { transformQuotesParams } from "./utils/transformers"; + +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; + 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.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); + 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(), + options: config.options, + debug: this.debug, + }); + } + } + + async getQuotes(params: GetQuotesParams): Promise { + if (this.debug) { + console.error("[SwapperSDK] Getting quotes with params:", 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; + } + + 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..862df3eb --- /dev/null +++ b/packages/swapper-sdk/src/types/bridge.ts @@ -0,0 +1,125 @@ +import type { SwapperCaip19 } from "./chains"; +// ChainID is now internal to constants package + +export interface GetBridgeableTokensResponse { + tokens: SwapperCaip19[]; +} + +export interface GetBridgeProvidersResponse { + providers: string[]; +} + +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: string; +} + +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..019af6d1 --- /dev/null +++ b/packages/swapper-sdk/src/types/chains.ts @@ -0,0 +1,8 @@ +export interface SwapperCaip19 { + chainId: string; // ChainID is now internal to constants package + resourceType: "address" | "nativeToken"; + address?: string; + slip44?: string; +} + +// SwapType and FeeType are now exported from @phantom/constants \ 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..fc35f702 --- /dev/null +++ b/packages/swapper-sdk/src/types/common.ts @@ -0,0 +1,56 @@ +export interface RequiredHeaders { + "Content-Type": "application/json"; +} + +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; + +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..5d6bc3a7 --- /dev/null +++ b/packages/swapper-sdk/src/types/index.ts @@ -0,0 +1,6 @@ +export * from "./chains"; +export * from "./quotes"; +export * from "./initialize"; +export * from "./bridge"; +export * from "./common"; +// 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 new file mode 100644 index 00000000..1d1b180d --- /dev/null +++ b/packages/swapper-sdk/src/types/initialize.ts @@ -0,0 +1,51 @@ +// ChainID is now internal to constants package (replaced with string) + +export interface SwapperInitializeRequestParams { + type: "buy" | "sell" | "swap"; + + network?: string; // ChainID + buyCaip19?: string; + sellCaip19?: string; + + address?: string; + addresses?: Record; // Record + cashAddress?: string; + cashAddresses?: Record; // 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: string; // 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/public-api.ts b/packages/swapper-sdk/src/types/public-api.ts new file mode 100644 index 00000000..913790df --- /dev/null +++ b/packages/swapper-sdk/src/types/public-api.ts @@ -0,0 +1,42 @@ +import type { NetworkId } from "@phantom/constants"; + +/** + * 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 new file mode 100644 index 00000000..87eec275 --- /dev/null +++ b/packages/swapper-sdk/src/types/quotes.ts @@ -0,0 +1,156 @@ +import type { SwapperCaip19 } from "./chains"; +import type { FeeType, SwapType } from "@phantom/constants"; + +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: string; + 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/src/utils/transformers.ts b/packages/swapper-sdk/src/utils/transformers.ts new file mode 100644 index 00000000..c76ee761 --- /dev/null +++ b/packages/swapper-sdk/src/utils/transformers.ts @@ -0,0 +1,125 @@ +import type { GetQuotesParams, Token, UserAddress } from "../types/public-api"; +import type { SwapperCaip19, SwapperQuotesBody, SwapperInitializeRequestParams } from "../types"; +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 chainIdMapping = getNetworkToChainMapping(); + const chainId = chainIdMapping[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 + const slip44ByChain = getNativeTokenSlip44ByChain(); + let slip44 = slip44ByChain[chainId]; + + if (!slip44) { + const namespace = chainId.split(":")[0]; + slip44 = NATIVE_TOKEN_SLIP44_FALLBACK[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 chainIdMapping = getNetworkToChainMapping(); + const chainId = chainIdMapping[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; + const chainIdMapping = getNetworkToChainMapping(); + if (networkId in chainIdMapping) { + const chainId = chainIdMapping[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 new file mode 100644 index 00000000..5f47f30e --- /dev/null +++ b/packages/swapper-sdk/tests/client.test.ts @@ -0,0 +1,180 @@ +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", + options: { + organizationId: "test-org", + version: "1.0.0", + }, + }); + expect(customClient.getBaseUrl()).toBe("https://custom.api.com/swap/v2"); + }); + + 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(); + }); + }); + + 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 unknown 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-Custom-Header": "value", + Authorization: "Bearer new-token", + }); + }).not.toThrow(); + }); + }); +}); \ No newline at end of file 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/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..ea5507f7 --- /dev/null +++ b/packages/swapper-sdk/tests/swapper-sdk.test.ts @@ -0,0 +1,202 @@ +import { SwapperSDK } from "../src/swapper-sdk"; +import { NetworkId } from "@phantom/constants"; + +describe("SwapperSDK", () => { + let sdk: SwapperSDK; + const mockFetch = global.fetch as jest.MockedFunction; + + beforeEach(() => { + sdk = new SwapperSDK({ + apiUrl: "https://api.test.com", + options: { + 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", + options: { + organizationId: "test-org", + countryCode: "US", + debug: true, + }, + }); + expect(customSdk).toBeDefined(); + }); + }); + + describe("getQuotes", () => { + it("should fetch quotes successfully", async () => { + const mockResponse = { + type: "solana", + quotes: [], + taker: { + chainId: "solana:101", + resourceType: "address" as const, + address: "test-address", + }, + buyToken: { + chainId: "solana:101", + resourceType: "address" as const, + address: "buy-token", + }, + sellToken: { + chainId: "solana:101", + resourceType: "nativeToken" as const, + slip44: "501", + }, + slippageTolerance: 0.5, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + const result = await sdk.getQuotes({ + sellToken: { + type: "native", + networkId: NetworkId.SOLANA_MAINNET, + }, + buyToken: { + type: "address", + address: "buy-token", + networkId: NetworkId.SOLANA_MAINNET, + }, + sellAmount: "1000000000", + from: { + address: "test-address", + networkId: NetworkId.SOLANA_MAINNET, + }, + }); + + 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({ + sellToken: { + type: "native", + networkId: NetworkId.SOLANA_MAINNET, + }, + buyToken: { + type: "address", + address: "buy-token", + networkId: NetworkId.SOLANA_MAINNET, + }, + sellAmount: "1000000000", + from: { + address: "test-address", + networkId: NetworkId.SOLANA_MAINNET, + }, + }) + ).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: "solana:101", + symbol: "USDC", + name: "USD Coin", + decimals: 6, + }, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + const result = await sdk.initializeSwap({ + type: "swap", + network: "solana:101" as any, + }); + + 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: "solana:101", + resourceType: "address" as const, + address: "token1", + }, + { + chainId: "eip155:1", + 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-Custom-Header": "value", + }); + }).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 diff --git a/yarn.lock b/yarn.lock index c289e73b..59f680e8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2726,7 +2726,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: @@ -3356,12 +3356,14 @@ __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/constants": "workspace:^" "@phantom/crypto": "workspace:^" "@phantom/openapi-wallet-service": "npm:^0.1.9" "@phantom/sdk-types": "workspace:^" + "@phantom/swapper-sdk": "workspace:^" "@types/jest": "npm:^29.5.12" "@types/node": "npm:^20.11.0" axios: "npm:^1.10.0" @@ -3733,6 +3735,25 @@ __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: + "@phantom/constants": "workspace:^" + "@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" @@ -8661,6 +8682,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"