From bf0060f2ebbde51ed5c6901bb2a64af9d628221f Mon Sep 17 00:00:00 2001 From: Alex Cheng Date: Tue, 11 Feb 2025 13:21:54 -0600 Subject: [PATCH] jupiter dex aggregator integration --- README.md | 24 +-- package.json | 3 + src/chains/jupiter.ts | 277 +++++++++++++++++++++++++++++++ src/chains/solana.ts | 375 +++++++++++++++++++----------------------- 4 files changed, 462 insertions(+), 217 deletions(-) create mode 100644 src/chains/jupiter.ts diff --git a/README.md b/README.md index 70a0fd9..4f7a990 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,12 @@ A Model-Context-Protocol server for interacting with multiple blockchains includ ## Features Solana Operations: -- Get current slot number - Check SOL account balances - Get detailed account information -- Display keypair information -- Transfer SOL between accounts (using private key from .env) +- Transfer SOL between accounts - View SPL token balances -- Get detailed SPL token account information +- Get your wallet address from private key +- Swap tokens using Jupiter (Best price routing across all Solana DEXs) Ethereum & EVM Chain Operations: - Check native token balances across multiple networks @@ -82,17 +81,18 @@ npm run build Ask Claude: ### Solana Operations -- "What's the latest slot on Solana?" +- "What's my Solana address?" - Shows your address derived from private key in .env - "What's the balance of 62QXuWZ3WT6ws1ZFxJobVDVXn6bEsiYpLo5yG612U6u3?" - "Transfer 0.001 SOL to Cg6cVS4tjkxHthm3K9BHhmvqF7kSz8GnXqqYXnHBzGXd" -- "Show me my SPL token balances for address 62QXuWZ3WT6ws1ZFxJobVDVXn6bEsiYpLo5yG612U6u3" +- "Show me my SPL token balances" +- "Swap 0.1 SOL to USDC" (Uses Jupiter for best price routing) ### EVM Operations -- "What's the ETH balance of 0x6b96f801ae7f7112e659181a84d2e22ebd0bc72a?" -- "Check the USDC balance for 0x6b96f801ae7f7112e659181a84d2e22ebd0bc72a on Ethereum" -- "Send 0.001 ETH to 0x6b96f801ae7f7112e659181a84d2e22ebd0bc72a" +- "What's the ETH balance of 0x556437c4d22ceaeeebf82006b85bdcc0ae67d933?" +- "Check the USDC balance for 0x556437c4d22ceaeeebf82006b85bdcc0ae67d933 on Ethereum" +- "Send 0.1 ETH to 0x556437c4d22ceaeeebf82006b85bdcc0ae67d933" - "What's the current gas price on Arbitrum?" -- "Send 100 USDC to 0x6b96f801ae7f7112e659181a84d2e22ebd0bc72a on Polygon" +- "Send 100 USDC to 0x556437c4d22ceaeeebf82006b85bdcc0ae67d933 on Polygon" ## Security Notes @@ -114,7 +114,7 @@ For EVM operations, you can specify the network by name (ethereum, base, arbitru To modify or extend the tool: 1. Source code is in the `src` directory -2. Chain-specific code is in `src/chains` +2. Chain-specific code in `src/chains` 3. Run `npm run build` after making changes 4. Use TypeScript for all new code @@ -124,4 +124,4 @@ Contributions are welcome! Please submit pull requests with any improvements or ## License -Apache-2.0 License \ No newline at end of file +ISC License \ No newline at end of file diff --git a/package.json b/package.json index e07671e..a2eb01f 100644 --- a/package.json +++ b/package.json @@ -46,12 +46,15 @@ "registry": "https://registry.npmjs.org/" }, "dependencies": { + "@jup-ag/api": "^6.0.12", "@modelcontextprotocol/sdk": "^1.4.1", "@solana/spl-token": "^0.4.1", "@solana/web3.js": "^1.87.6", "bs58": "^5.0.0", + "cross-fetch": "^4.0.0", "dotenv": "^16.4.5", "ethers": "^6.11.1", + "fetch-retry": "^5.0.6", "zod": "^3.24.1" }, "devDependencies": { diff --git a/src/chains/jupiter.ts b/src/chains/jupiter.ts new file mode 100644 index 0000000..e81f7fe --- /dev/null +++ b/src/chains/jupiter.ts @@ -0,0 +1,277 @@ +import { Connection, PublicKey, Transaction, VersionedTransaction } from '@solana/web3.js'; +import { Keypair } from '@solana/web3.js'; +import fetch from 'cross-fetch'; +import bs58 from 'bs58'; + +const JUPITER_API_BASE = 'https://quote-api.jup.ag/v6'; + +// Common token addresses +const COMMON_TOKENS = { + SOL: 'So11111111111111111111111111111111111111112', + USDC: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + USDT: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', + BONK: 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263', + ORCA: 'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE', +} as const; + +interface TokenMetadata { + symbol: string; + decimals: number; + address: string; + name: string; + logoURI?: string; +} + +// Types for Jupiter API responses +interface JupiterQuoteResponse { + inputMint: string; + inAmount: string; + outputMint: string; + outAmount: string; + otherAmountThreshold: string; + swapMode: string; + slippageBps: number; + platformFee: { + amount?: string; + feeBps?: number; + } | null; + priceImpactPct: string; + routePlan: Array<{ + swapInfo: { + ammKey: string; + label?: string; + inputMint: string; + outputMint: string; + inAmount: string; + outAmount: string; + feeAmount: string; + feeMint: string; + }; + percent: number; + }>; + contextSlot?: number; + timeTaken?: number; +} + +interface JupiterSwapResponse { + swapTransaction: string; + lastValidBlockHeight: number; + prioritizationFeeLamports: number; + computeUnitLimit: number; +} + +// Cache for token metadata +const tokenMetadataCache = new Map(); + +// Initialize token list +async function initializeTokenList() { + if (tokenMetadataCache.size > 0) return; + + try { + const response = await fetch('https://token.jup.ag/strict'); + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + const data = await response.json(); + + for (const token of data) { + tokenMetadataCache.set(token.address, { + symbol: token.symbol, + decimals: token.decimals, + address: token.address, + name: token.name, + logoURI: token.logoURI + }); + } + + // Ensure we have the common tokens + ensureCommonTokens(); + + } catch (error) { + console.error('Failed to fetch token list:', error); + // Fallback to common tokens + ensureCommonTokens(); + } +} + +// Ensure we have common tokens in cache +function ensureCommonTokens() { + // SOL + if (!tokenMetadataCache.has(COMMON_TOKENS.SOL)) { + tokenMetadataCache.set(COMMON_TOKENS.SOL, { + symbol: 'SOL', + decimals: 9, + address: COMMON_TOKENS.SOL, + name: 'Solana' + }); + } + // USDC + if (!tokenMetadataCache.has(COMMON_TOKENS.USDC)) { + tokenMetadataCache.set(COMMON_TOKENS.USDC, { + symbol: 'USDC', + decimals: 6, + address: COMMON_TOKENS.USDC, + name: 'USD Coin' + }); + } + // Add other common tokens as needed +} + +// Get token metadata +async function getTokenMetadata(mint: string): Promise { + await initializeTokenList(); + + const tokenInfo = tokenMetadataCache.get(mint); + if (!tokenInfo) { + throw new Error(`Token metadata not found for ${mint}`); + } + + return tokenInfo; +} + +// Format amount based on decimals +function formatTokenAmount(amount: string, decimals: number): string { + const value = parseInt(amount) / Math.pow(10, decimals); + return value.toFixed(decimals).replace(/\.?0+$/, ''); +} + +// Get quote from Jupiter +export async function getJupiterQuote( + inputMint: string, + outputMint: string, + amount: string, + slippageBps: number = 50 +): Promise { + // Initialize token list before getting quote + await initializeTokenList(); + + // Clean input parameters + inputMint = inputMint.toString().trim(); + outputMint = outputMint.toString().trim(); + amount = amount.toString().trim(); + + // Validate addresses + try { + new PublicKey(inputMint); + new PublicKey(outputMint); + } catch (error: any) { + throw new Error(`Invalid token address: ${error?.message || 'Invalid format'}`); + } + + const url = new URL('/quote', JUPITER_API_BASE); + url.searchParams.set('inputMint', inputMint); + url.searchParams.set('outputMint', outputMint); + url.searchParams.set('amount', amount); + url.searchParams.set('slippageBps', slippageBps.toString()); + url.searchParams.set('onlyDirectRoutes', 'false'); + url.searchParams.set('restrictIntermediateTokens', 'true'); + + console.log('Fetching quote from:', url.toString()); + + const response = await fetch(url.toString()); + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to get Jupiter quote: ${error}`); + } + + return response.json(); +} + +// Build swap transaction +export async function buildJupiterSwapTransaction( + quoteResponse: JupiterQuoteResponse, + userPublicKey: string +): Promise { + const url = new URL('/swap', JUPITER_API_BASE); + + const body = { + quoteResponse, + userPublicKey, + dynamicComputeUnitLimit: true, + dynamicSlippage: true, + prioritizationFeeLamports: { + priorityLevelWithMaxLamports: { + maxLamports: 1000000, + priorityLevel: "veryHigh" + } + } + }; + + const response = await fetch(url.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body) + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to build swap transaction: ${error}`); + } + + const { swapTransaction } = await response.json(); + return swapTransaction; +} + +// Execute swap transaction +export async function executeJupiterSwap( + connection: Connection, + swapTransaction: string, + privateKey: Uint8Array +): Promise { + try { + const keypair = Keypair.fromSecretKey(privateKey); + + // Convert base64 transaction to Uint8Array + const transactionBinary = Buffer.from(swapTransaction, 'base64'); + + // Deserialize and sign the transaction + const transaction = VersionedTransaction.deserialize(transactionBinary); + transaction.sign([keypair]); + + // Send the transaction with optimized parameters + const signature = await connection.sendRawTransaction( + transaction.serialize(), + { + skipPreflight: true, + maxRetries: 2, + preflightCommitment: 'confirmed' + } + ); + + // Wait for confirmation + await connection.confirmTransaction(signature, 'confirmed'); + + return signature; + } catch (error) { + console.error('Error executing swap:', error); + throw error; + } +} + +// Format quote details for display +export async function formatQuoteDetails(quote: JupiterQuoteResponse): Promise { + const [inputToken, outputToken] = await Promise.all([ + getTokenMetadata(quote.inputMint), + getTokenMetadata(quote.outputMint) + ]); + + const inputAmount = formatTokenAmount(quote.inAmount, inputToken.decimals); + const outputAmount = formatTokenAmount(quote.outAmount, outputToken.decimals); + + const routeSteps = quote.routePlan + .map(r => r.swapInfo.label || 'Unknown AMM') + .join(' → '); + + // Calculate rate + const inAmountNum = parseFloat(quote.inAmount) / Math.pow(10, inputToken.decimals); + const outAmountNum = parseFloat(quote.outAmount) / Math.pow(10, outputToken.decimals); + const rate = outAmountNum / inAmountNum; + + return `Swap Quote Details: +Input: ${inputAmount} ${inputToken.symbol} +Output: ${outputAmount} ${outputToken.symbol} +Rate: 1 ${inputToken.symbol} = ${rate.toFixed(6)} ${outputToken.symbol} +Price Impact: ${quote.priceImpactPct}% +Slippage Tolerance: ${quote.slippageBps / 100}% +Route: ${routeSteps}`; +} diff --git a/src/chains/solana.ts b/src/chains/solana.ts index b47a0a7..986a759 100644 --- a/src/chains/solana.ts +++ b/src/chains/solana.ts @@ -17,6 +17,7 @@ import { import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import bs58 from 'bs58'; +import { getJupiterQuote, buildJupiterSwapTransaction, executeJupiterSwap, formatQuoteDetails } from './jupiter.js'; const SOLANA_RPC = process.env.SOLANA_RPC_URL || "https://api.mainnet-beta.solana.com"; @@ -49,6 +50,13 @@ async function getTokenAccounts(walletAddress: string) { } } +interface SwapParams { + inputMint: string; + outputMint: string; + amount: string; + slippageBps?: number; +} + export function registerSolanaTools(server: McpServer) { // Debug: Check environment variables console.error('Debug - ENV vars available:', Object.keys(process.env)); @@ -58,17 +66,35 @@ export function registerSolanaTools(server: McpServer) { } server.tool( - "getSlot", - "Get the current slot", + "getMyAddress", + "Get your Solana public address from private key in .env", {}, async () => { try { - const slot = await solanaConnection.getSlot(); + if (!process.env.SOLANA_PRIVATE_KEY) { + throw new Error('SOLANA_PRIVATE_KEY not found in environment variables'); + } + + const privateKeyBytes = bs58.decode(process.env.SOLANA_PRIVATE_KEY); + const keypair = Keypair.fromSecretKey(privateKeyBytes); + const publicKey = keypair.publicKey.toString(); + + // Get SOL balance + const balance = await solanaConnection.getBalance(keypair.publicKey); + const solBalance = balance / LAMPORTS_PER_SOL; + + // Get token balances + const tokenAccounts = await getTokenAccounts(publicKey); + const tokenBalances = tokenAccounts + .filter(account => account.amount > 0) + .map(account => `${account.amount} (Mint: ${account.mint.toString()})`) + .join('\n'); + return { content: [ { type: "text", - text: `Current slot: ${slot}`, + text: `Your Solana Address: ${publicKey}\nSOL Balance: ${solBalance} SOL\n\nToken Balances:\n${tokenBalances}`, }, ], }; @@ -78,7 +104,7 @@ export function registerSolanaTools(server: McpServer) { content: [ { type: "text", - text: `Failed to retrieve current slot: ${error.message}`, + text: `Failed to get address: ${error.message}`, }, ], }; @@ -120,54 +146,6 @@ export function registerSolanaTools(server: McpServer) { } ); - server.tool( - "getKeypairInfo", - "Get information about a keypair from its secret key", - { - secretKey: z.string().describe("Base58 encoded secret key or array of bytes"), - }, - async ({ secretKey }) => { - try { - let keypair: Keypair; - try { - const decoded = Uint8Array.from(secretKey.split(',').map(num => parseInt(num.trim()))); - keypair = Keypair.fromSecretKey(decoded); - } catch { - keypair = Keypair.fromSecretKey(Uint8Array.from(JSON.parse(secretKey))); - } - - const publicKey = keypair.publicKey; - const balance = await solanaConnection.getBalance(publicKey); - const accountInfo = await solanaConnection.getAccountInfo(publicKey); - - return { - content: [ - { - type: "text", - text: `Keypair Information: -Public Key: ${publicKey.toBase58()} -Balance: ${balance / LAMPORTS_PER_SOL} SOL -Account Program Owner: ${accountInfo?.owner?.toBase58() || 'N/A'} -Account Size: ${accountInfo?.data.length || 0} bytes -Is Executable: ${accountInfo?.executable || false} -Rent Epoch: ${accountInfo?.rentEpoch || 0}`, - }, - ], - }; - } catch (err) { - const error = err as Error; - return { - content: [ - { - type: "text", - text: `Failed to retrieve keypair information: ${error.message}`, - }, - ], - }; - } - } - ); - server.tool( "getAccountInfo", "Get detailed account information for a Solana address", @@ -231,120 +209,6 @@ Data (${encoding}): ${formattedData}`, } ); - server.tool( - "transfer", - "Transfer SOL from your keypair (using private key from .env) to another address", - { - toAddress: z.string().describe("Destination wallet address"), - amount: z.number().positive().describe("Amount of SOL to send"), - }, - async ({ toAddress, amount }) => { - try { - if (!process.env.SOLANA_PRIVATE_KEY) { - throw new Error('SOLANA_PRIVATE_KEY not found in environment variables'); - } - - let fromKeypair: Keypair; - try { - // Try to decode the private key from base58 - const privateKeyBytes = bs58.decode(process.env.SOLANA_PRIVATE_KEY); - fromKeypair = Keypair.fromSecretKey(new Uint8Array(privateKeyBytes)); - - // Verify the keypair is valid - if (!fromKeypair.publicKey) { - throw new Error('Invalid keypair generated'); - } - } catch (error) { - console.error('Error decoding private key:', error); - throw new Error('Failed to create keypair from private key. Please ensure the private key is a valid base58-encoded string.'); - } - - // Convert SOL to lamports - const lamports = amount * LAMPORTS_PER_SOL; - - // Validate destination address - let destinationPubkey: PublicKey; - try { - destinationPubkey = new PublicKey(toAddress); - } catch (error) { - throw new Error('Invalid destination address'); - } - - // Build transaction - const transaction = new Transaction(); - - // Get recent blockhash - const { blockhash } = await solanaConnection.getLatestBlockhash('finalized'); - transaction.recentBlockhash = blockhash; - transaction.feePayer = fromKeypair.publicKey; - - // Add transfer instruction - transaction.add( - SystemProgram.transfer({ - fromPubkey: fromKeypair.publicKey, - toPubkey: destinationPubkey, - lamports, - }) - ); - - // Sign and send transaction - try { - const signature = await sendAndConfirmTransaction( - solanaConnection, - transaction, - [fromKeypair], - { commitment: 'confirmed' } - ); - - // Verify the transaction was successful - const confirmedTransaction = await solanaConnection.getTransaction(signature, { - commitment: 'confirmed', - }); - - if (!confirmedTransaction?.meta?.err) { - return { - content: [ - { - type: "text", - text: `Transfer successful! -From: ${fromKeypair.publicKey.toBase58()} -To: ${toAddress} -Amount: ${amount} SOL -Transaction signature: ${signature} -Explorer URL: https://explorer.solana.com/tx/${signature}`, - }, - ], - }; - } else { - throw new Error('Transaction failed on-chain'); - } - } catch (err) { - console.error('Transaction error:', err); - const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'; - return { - content: [ - { - type: "text", - text: `Failed to transfer SOL: ${errorMessage}. Please check your wallet has sufficient SOL for the transfer and transaction fees.`, - }, - ], - }; - } - } catch (err) { - console.error('Setup error:', err); - const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'; - return { - content: [ - { - type: "text", - text: `Failed to setup transfer: ${errorMessage}`, - }, - ], - }; - } - } - ); - server.tool( "getSplTokenBalances", "Get SPL token balances for a Solana address", @@ -394,52 +258,153 @@ Explorer URL: https://explorer.solana.com/tx/${signature}`, ); server.tool( - "getSplTokenInfo", - "Get detailed information about a specific SPL token account", + "getSwapQuote", + "Get best swap quote from Jupiter DEX aggregator", { - tokenMint: z.string().describe("Token mint address"), - ownerAddress: z.string().describe("Token account owner address"), + inputMint: z.string().describe("Input token mint address"), + outputMint: z.string().describe("Output token mint address"), + amount: z.string().describe("Amount of input tokens (in smallest denomination)"), + slippageBps: z.number().optional().describe("Slippage tolerance in basis points (optional, default 50 = 0.5%)"), }, - async ({ tokenMint, ownerAddress }) => { + async ({ inputMint, outputMint, amount, slippageBps }: SwapParams) => { try { - const mint = new PublicKey(tokenMint); - const owner = new PublicKey(ownerAddress); + // Validate input parameters + inputMint = inputMint.trim(); + outputMint = outputMint.trim(); + amount = amount.toString().trim(); - const tokenAccounts = await solanaConnection.getParsedTokenAccountsByOwner( - owner, - { - mint: mint, - } - ); - - if (tokenAccounts.value.length === 0) { - return { - content: [ - { - type: "text", - text: `No token account found for mint ${tokenMint} owned by ${ownerAddress}`, - }, - ], - }; - } - - const accountInfo = tokenAccounts.value[0].account; - const parsedInfo = accountInfo.data.parsed.info; + const quote = await getJupiterQuote(inputMint, outputMint, amount, slippageBps); + const formattedDetails = await formatQuoteDetails(quote); return { content: [ { type: "text", - text: `Token Account Information: -Token Account: ${tokenAccounts.value[0].pubkey.toString()} -Mint: ${parsedInfo.mint} -Owner: ${parsedInfo.owner} -Balance: ${parsedInfo.tokenAmount.uiAmount} -Decimals: ${parsedInfo.tokenAmount.decimals} -State: ${parsedInfo.state} -Is Native: ${parsedInfo.isNative} -Delegation: ${parsedInfo.delegate || 'None'} -Close Authority: ${parsedInfo.closeAuthority || 'None'}`, + text: formattedDetails, + }, + ], + quote, // Store quote for subsequent swap + }; + } catch (err) { + console.error('Error getting swap quote:', err); + return { + content: [ + { + type: "text", + text: `Failed to get swap quote: ${err instanceof Error ? err.message : 'Unknown error'}`, + }, + ], + }; + } + } + ); + + server.tool( + "executeSwap", + "Execute a token swap using Jupiter DEX aggregator (using private key from .env)", + { + inputMint: z.string().describe("Input token mint address"), + outputMint: z.string().describe("Output token mint address"), + amount: z.string().describe("Amount of input tokens (in smallest denomination)"), + slippageBps: z.number().optional().describe("Slippage tolerance in basis points (optional, default 50 = 0.5%)"), + }, + async ({ inputMint, outputMint, amount, slippageBps }: SwapParams) => { + try { + // Validate input parameters + inputMint = inputMint.trim(); + outputMint = outputMint.trim(); + amount = amount.toString().trim(); + + // Check for private key + if (!process.env.SOLANA_PRIVATE_KEY) { + throw new Error('SOLANA_PRIVATE_KEY not found in environment variables'); + } + + // Decode private key + const privateKeyBytes = bs58.decode(process.env.SOLANA_PRIVATE_KEY); + const keypair = Keypair.fromSecretKey(privateKeyBytes); + + // Get quote + const quote = await getJupiterQuote(inputMint, outputMint, amount, slippageBps); + const formattedQuote = await formatQuoteDetails(quote); + + // Build transaction + const swapTransaction = await buildJupiterSwapTransaction( + quote, + keypair.publicKey.toString() + ); + + // Execute swap + const signature = await executeJupiterSwap( + solanaConnection, + swapTransaction, + privateKeyBytes + ); + + return { + content: [ + { + type: "text", + text: `${formattedQuote}\n\nSwap executed successfully!\nTransaction signature: ${signature}\nExplorer URL: https://explorer.solana.com/tx/${signature}`, + }, + ], + }; + } catch (err) { + console.error('Error executing swap:', err); + return { + content: [ + { + type: "text", + text: `Failed to execute swap: ${err instanceof Error ? err.message : 'Unknown error'}`, + }, + ], + }; + } + } + ); + + server.tool( + "transfer", + "Transfer SOL from your keypair (using private key from .env) to another address", + { + toAddress: z.string().describe("Destination wallet address"), + amount: z.number().positive().describe("Amount of SOL to send"), + }, + async ({ toAddress, amount }) => { + try { + if (!process.env.SOLANA_PRIVATE_KEY) { + throw new Error('SOLANA_PRIVATE_KEY not found in environment variables'); + } + + const privateKeyBytes = bs58.decode(process.env.SOLANA_PRIVATE_KEY); + const fromKeypair = Keypair.fromSecretKey(privateKeyBytes); + + const lamports = amount * LAMPORTS_PER_SOL; + + const transaction = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: fromKeypair.publicKey, + toPubkey: new PublicKey(toAddress), + lamports, + }) + ); + + const signature = await sendAndConfirmTransaction( + solanaConnection, + transaction, + [fromKeypair] + ); + + return { + content: [ + { + type: "text", + text: `Transfer successful! +From: ${fromKeypair.publicKey.toBase58()} +To: ${toAddress} +Amount: ${amount} SOL +Transaction signature: ${signature} +Explorer URL: https://explorer.solana.com/tx/${signature}`, }, ], }; @@ -449,7 +414,7 @@ Close Authority: ${parsedInfo.closeAuthority || 'None'}`, content: [ { type: "text", - text: `Failed to retrieve token information: ${error.message}`, + text: `Failed to transfer SOL: ${error.message}`, }, ], };