diff --git a/.env.example b/.env.example index 7603a9f2c59..86aaa58f977 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,27 @@ +# OpenAI Configuration OPENAI_API_KEY= + +# Anthropic Configuration ANTHROPIC_API_KEY= + +# Fill these out if you want to use Discord +DISCORD_APPLICATION_ID= +DISCORD_API_TOKEN= + +# Fill these out if you want to use Telegram +TELEGRAM_BOT_TOKEN= + +# Fill these out if you want to use Twitter +TWITTER_USERNAME= +TWITTER_PASSWORD= +TWITTER_EMAIL= + +# Fill these out if you want to use EVM +EVM_PRIVATE_KEY= +EVM_CHAINS=mainnet,sepolia,base,arbitrum,polygon +EVM_PROVIDER_URL= + +# Fill these out if you want to use Solana +SOLANA_PUBLIC_KEY= +SOLANA_PRIVATE_KEY= +BIRDEYE_API_KEY= \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9febd13a35d..caf0f5024db 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,6 @@ node_modules .env_main concatenated-output.ts embedding-cache.json -packages/plugin-buttplug/intiface-engine node-compile-cache @@ -50,10 +49,7 @@ characters/**/*.env characters/**/*.key characters/**/private/ -packages/core/src/providers/cache -packages/core/src/providers/cache/* cache/* -packages/plugin-coinbase/src/plugins/transactions.csv tsup.config.bundled_*.mjs @@ -71,10 +67,6 @@ eliza.manifest eliza.manifest.sgx eliza.sig -packages/plugin-nvidia-nim/extra -packages/plugin-nvidia-nim/old_code -packages/plugin-nvidia-nim/docs - # Edriziai specific ignores characters/edriziai-info/secrets.json diff --git a/bun.lockb b/bun.lockb index e577e7ff2b4..d5576174e8a 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/agent/src/defaultCharacter.ts b/packages/agent/src/defaultCharacter.ts index 5f7a3269e59..31b51a5478d 100644 --- a/packages/agent/src/defaultCharacter.ts +++ b/packages/agent/src/defaultCharacter.ts @@ -11,7 +11,8 @@ export const defaultCharacter: Character = { "@elizaos/plugin-node", // "@elizaos/plugin-telegram", // "@elizaos/plugin-twitter", - "@elizaos/plugin-evm" + "@elizaos/plugin-evm", + "@elizaos/plugin-solana", ], settings: { secrets: {}, diff --git a/packages/plugin-solana/.npmignore b/packages/plugin-solana/.npmignore new file mode 100644 index 00000000000..078562eceab --- /dev/null +++ b/packages/plugin-solana/.npmignore @@ -0,0 +1,6 @@ +* + +!dist/** +!package.json +!readme.md +!tsup.config.ts \ No newline at end of file diff --git a/packages/plugin-solana/README.md b/packages/plugin-solana/README.md new file mode 100644 index 00000000000..c0c3e79d8c8 --- /dev/null +++ b/packages/plugin-solana/README.md @@ -0,0 +1,374 @@ +# @elizaos/plugin-solana + +Core Solana blockchain plugin for Eliza OS that provides essential services and actions for token operations, trading, and DeFi integrations. + +## Overview + +The Solana plugin serves as a foundational component of Eliza OS, bridging Solana blockchain capabilities with the Eliza ecosystem. It provides crucial services for token operations, trading, portfolio management, and DeFi integrations, enabling both automated and user-directed interactions with the Solana blockchain. + +## Features + +### Token Operations + +- **Token Creation**: Deploy new tokens with customizable metadata +- **Token Transfers**: Send and receive tokens securely +- **Balance Management**: Track and manage token balances +- **Portfolio Analytics**: Real-time portfolio valuation and tracking + +### Trading Operations + +- **Token Swaps**: Execute trades between tokens using Jupiter aggregator +- **Order Management**: Place and track token orders +- **Price Monitoring**: Real-time price feeds and historical data +- **Automated Trading**: Configurable trading strategies and automation + +### DeFi Integration + +- **Liquidity Analysis**: Monitor and analyze pool liquidity +- **Market Making**: Automated market making capabilities +- **Yield Optimization**: Smart routing for optimal yields +- **Risk Management**: Advanced risk scoring and monitoring + +### Trust & Security + +- **Trust Scoring**: Dynamic trust score calculation for tokens +- **Risk Assessment**: Real-time risk evaluation for trades +- **Performance Tracking**: Historical performance monitoring +- **Simulation Mode**: Test strategies without real transactions + +## Security Features + +### Access Control + +- **Wallet Management**: Secure wallet key derivation and storage +- **Permission Scoping**: Granular control over trading permissions +- **TEE Integration**: Trusted Execution Environment support +- **Key Protection**: Secure private key handling + +### Risk Management + +- **Trade Limits**: Configurable transaction limits +- **Slippage Protection**: Automatic slippage controls +- **Validation Checks**: Multi-level transaction validation +- **Simulation Support**: Pre-execution transaction simulation + +## Installation + +```bash +npm install @elizaos/plugin-solana +``` + +## Configuration + +Configure the plugin by setting the following environment variables: + +```typescript +const solanaEnvSchema = { + WALLET_SECRET_SALT: string(optional), + WALLET_SECRET_KEY: string, + WALLET_PUBLIC_KEY: string, + SOL_ADDRESS: string, + SLIPPAGE: string, + SOLANA_RPC_URL: string, + HELIUS_API_KEY: string, + BIRDEYE_API_KEY: string, +}; +``` + +## Usage + +### Basic Setup + +```typescript +import { solanaPlugin } from "@elizaos/plugin-solana"; + +// Initialize the plugin +const runtime = await initializeRuntime({ + plugins: [solanaPlugin], +}); +``` + +### Services + +#### TokenProvider + +Manages token operations and information retrieval. + +```typescript +const tokenProvider = new TokenProvider( + tokenAddress, + walletProvider, + cacheManager +); +await tokenProvider.getTokensInWallet(runtime); +``` + +#### WalletProvider + +Handles wallet operations and portfolio management. + +```typescript +const walletProvider = new WalletProvider(connection, publicKey); +await walletProvider.getFormattedPortfolio(runtime); +``` + +#### TrustScoreProvider + +Evaluates and manages trust scores for tokens and trading activities. + +```typescript +const trustScore = await runtime.getProvider("trustScore"); +``` + +## Actions + +### executeSwap + +Executes a token swap using Jupiter aggregator. + +```typescript +// Example usage +const result = await runtime.executeAction("EXECUTE_SWAP", { + inputTokenSymbol: "SOL", + outputTokenSymbol: "USDC", + amount: 0.1, +}); +``` + +### transferToken + +Transfers tokens between wallets. + +```typescript +// Example usage +const result = await runtime.executeAction("SEND_TOKEN", { + tokenAddress: "TokenAddressHere", + recipient: "RecipientAddressHere", + amount: "1000", +}); +``` + +### transferSol + +Transfers SOL between wallets. + +```typescript +// Example usage +const result = await runtime.executeAction("SEND_SOL", { + recipient: "RecipientAddressHere", + amount: "1000", +}); +``` + +### takeOrder + +Places a buy order based on conviction level. + +```typescript +// Example usage +const result = await runtime.executeAction("TAKE_ORDER", { + ticker: "SOL", + contractAddress: "ContractAddressHere", +}); +``` + +### pumpfun + +Creates and buys tokens on pump.fun. + +```typescript +// Example usage +const result = await runtime.executeAction("CREATE_AND_BUY_TOKEN", { + tokenMetadata: { + name: "TokenName", + symbol: "SYMBOL", + description: "Token description", + image_description: "Image description", + }, + buyAmountSol: 0.1, +}); +``` + +### fomo + +Creates and buys tokens on fomo.fund. + +```typescript +// Example usage +const result = await runtime.executeAction("CREATE_AND_BUY_TOKEN", { + tokenMetadata: { + name: "TokenName", + symbol: "SYMBOL", + description: "Token description", + image_description: "Image description", + }, + buyAmountSol: 0.1, + requiredLiquidity: 1000, +}); +``` + +### executeSwapForDAO + +Executes token swaps for DAO operations. + +```typescript +// Example usage +const result = await runtime.executeAction("EXECUTE_SWAP_DAO", { + inputTokenSymbol: "SOL", + outputTokenSymbol: "USDC", + amount: 0.1, +}); +``` + +## Performance Optimization + +1. **Cache Management** + + - Implement token data caching + - Configure cache TTL settings + - Monitor cache hit rates + +2. **RPC Optimization** + + - Use connection pooling + - Implement request batching + - Monitor RPC usage + +3. **Transaction Management** + - Optimize transaction bundling + - Implement retry strategies + - Monitor transaction success rates + +## System Requirements + +- Node.js 16.x or higher +- Solana CLI tools (optional) +- Minimum 4GB RAM recommended +- Stable internet connection +- Access to Solana RPC endpoint + +## Troubleshooting + +### Common Issues + +1. **Wallet Connection Failures** + +```bash +Error: Failed to connect to wallet +``` + +- Verify RPC endpoint is accessible +- Check wallet configuration settings +- Ensure proper network selection + +2. **Transaction Errors** + +```bash +Error: Transaction simulation failed +``` + +- Check account balances +- Verify transaction parameters +- Ensure proper fee configuration + +3. **Price Feed Issues** + +```bash +Error: Unable to fetch price data +``` + +- Verify API key configuration +- Check network connectivity +- Ensure price feed service status + +## Safety & Security + +### Best Practices + +1. **Environment Variables** + + - Store sensitive keys in environment variables + - Use .env.example for non-sensitive defaults + - Never commit real credentials to version control + +2. **Transaction Limits** + + - Set maximum transaction amounts + - Implement daily trading limits + - Configure per-token restrictions + +3. **Monitoring** + + - Track failed transaction attempts + - Monitor unusual trading patterns + - Log security-relevant events + +4. **Recovery** + - Implement transaction rollback mechanisms + - Maintain backup RPC endpoints + - Document recovery procedures + +## Performance Optimization + +1. **Cache Management** + + - Implement token data caching + - Configure cache TTL settings + - Monitor cache hit rates + +2. **RPC Optimization** + + - Use connection pooling + - Implement request batching + - Monitor RPC usage + +3. **Transaction Management** + - Optimize transaction bundling + - Implement retry strategies + - Monitor transaction success rates + +## Support + +For issues and feature requests, please: + +1. Check the troubleshooting guide above +2. Review existing GitHub issues +3. Submit a new issue with: + - System information + - Error logs + - Steps to reproduce + - Transaction IDs (if applicable) + +## Contributing + +Contributions are welcome! Please see the [CONTRIBUTING.md](CONTRIBUTING.md) file for more information. + +## Credits + +This plugin integrates with and builds upon several key technologies: + +- [Solana](https://solana.com/) - The core blockchain platform +- [Solana Web3.js](https://github.com/solana-labs/solana-web3.js) - Core Solana interactions +- [SPL Token](https://spl.solana.com/) - Token program interactions +- [Jupiter](https://jup.ag/) - Token swap aggregation +- [Birdeye](https://birdeye.so/) - Price feeds and analytics +- [Helius](https://helius.xyz/) - Enhanced RPC services +- [Anchor](https://project-serum.github.io/anchor/) - Smart contract framework +- [FOMO](https://fomo.fund/) - Token creation and trading +- [Pump.fun](https://pump.fun/) - Token creation and trading + +Special thanks to: + +- The Solana ecosystem and all the open-source contributors who make these integrations possible. +- The Eliza community for their contributions and feedback. + +For more information about Solana blockchain capabilities: + +- [Solana Documentation](https://docs.solana.com/) +- [Solana Developer Portal](https://solana.com/developers) +- [Solana Network Dashboard](https://solscan.io/) +- [Solana GitHub Repository](https://github.com/solana-labs/solana) + +## License + +This plugin is part of the Eliza project. See the main project repository for license information. diff --git a/packages/plugin-solana/__tests__/actions/swap.test.ts b/packages/plugin-solana/__tests__/actions/swap.test.ts new file mode 100644 index 00000000000..f38f806c4e8 --- /dev/null +++ b/packages/plugin-solana/__tests__/actions/swap.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect, vi } from 'vitest'; + +describe('Swap Action', () => { + describe('validate', () => { + it('should handle swap message validation', async () => { + const mockMessage = { + content: 'Swap 1 SOL to USDC', + metadata: { + fromToken: 'SOL', + toToken: 'USDC', + amount: '1' + } + }; + + // Basic test to ensure message structure + expect(mockMessage.metadata).toBeDefined(); + expect(mockMessage.metadata.fromToken).toBe('SOL'); + expect(mockMessage.metadata.toToken).toBe('USDC'); + expect(mockMessage.metadata.amount).toBe('1'); + }); + }); +}); diff --git a/packages/plugin-solana/biome.json b/packages/plugin-solana/biome.json new file mode 100644 index 00000000000..818716a6219 --- /dev/null +++ b/packages/plugin-solana/biome.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.5.3/schema.json", + "organizeImports": { + "enabled": false + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "correctness": { + "noUnusedVariables": "error" + }, + "suspicious": { + "noExplicitAny": "error" + }, + "style": { + "useConst": "error", + "useImportType": "off" + } + } + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 4, + "lineWidth": 100 + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "trailingCommas": "es5" + } + }, + "files": { + "ignore": [ + "dist/**/*", + "extra/**/*", + "node_modules/**/*" + ] + } +} \ No newline at end of file diff --git a/packages/plugin-solana/images/banner.jpg b/packages/plugin-solana/images/banner.jpg new file mode 100644 index 00000000000..b0da69f7644 Binary files /dev/null and b/packages/plugin-solana/images/banner.jpg differ diff --git a/packages/plugin-solana/images/logo.jpg b/packages/plugin-solana/images/logo.jpg new file mode 100644 index 00000000000..11238b75fd3 Binary files /dev/null and b/packages/plugin-solana/images/logo.jpg differ diff --git a/packages/plugin-solana/package.json b/packages/plugin-solana/package.json new file mode 100644 index 00000000000..f61b659a6a9 --- /dev/null +++ b/packages/plugin-solana/package.json @@ -0,0 +1,52 @@ +{ + "name": "@elizaos/plugin-solana", + "version": "0.1.9", + "type": "module", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "import": { + "@elizaos/source": "./src/index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + } + }, + "files": [ + "dist" + ], + "dependencies": { + "@coral-xyz/anchor": "0.30.1", + "@elizaos/core": "workspace:*", + "@solana/spl-token": "0.4.9", + "@solana/web3.js": "npm:@solana/web3.js@1.95.8", + "bignumber.js": "9.1.2", + "bs58": "6.0.0", + "fomo-sdk-solana": "1.3.2", + "node-cache": "5.1.2", + "pumpdotfun-sdk": "1.3.2", + "solana-agent-kit": "^1.4.0", + "tsup": "8.3.5", + "vitest": "2.1.9" + }, + "devDependencies": { + "@biomejs/biome": "1.5.3", + "tsup": "^8.3.5" + }, + "scripts": { + "build": "tsup --format esm --dts", + "dev": "tsup --format esm --dts --watch", + "lint": "biome check src/", + "lint:fix": "biome check --apply src/", + "format": "biome format src/", + "format:fix": "biome format --write src/", + "test": "vitest run" + }, + "peerDependencies": { + "form-data": "4.0.1", + "whatwg-url": "7.1.0" + } +} diff --git a/packages/plugin-solana/src/actions/swap.ts b/packages/plugin-solana/src/actions/swap.ts new file mode 100644 index 00000000000..78150c8adff --- /dev/null +++ b/packages/plugin-solana/src/actions/swap.ts @@ -0,0 +1,433 @@ +import { + type Action, + type ActionExample, + composeContext, + elizaLogger, + generateObject, + type HandlerCallback, + type IAgentRuntime, + type Memory, + ModelClass, + settings, + type State, +} from "@elizaos/core"; +import { Connection, PublicKey, VersionedTransaction } from "@solana/web3.js"; +import BigNumber from "bignumber.js"; +import { getWalletKey } from "../keypairUtils.ts"; +import { walletProvider, WalletProvider } from "../providers/wallet.ts"; + +async function getTokenDecimals( + connection: Connection, + mintAddress: string +): Promise { + const mintPublicKey = new PublicKey(mintAddress); + const tokenAccountInfo = + await connection.getParsedAccountInfo(mintPublicKey); + + // Check if the data is parsed and contains the expected structure + if ( + tokenAccountInfo.value && + typeof tokenAccountInfo.value.data === "object" && + "parsed" in tokenAccountInfo.value.data + ) { + const parsedInfo = tokenAccountInfo.value.data.parsed?.info; + if (parsedInfo && typeof parsedInfo.decimals === "number") { + return parsedInfo.decimals; + } + } + + throw new Error("Unable to fetch token decimals"); +} + +async function swapToken( + connection: Connection, + walletPublicKey: PublicKey, + inputTokenCA: string, + outputTokenCA: string, + amount: number +): Promise { + try { + // Get the decimals for the input token + const decimals = + inputTokenCA === settings.SOL_ADDRESS + ? new BigNumber(9) + : new BigNumber( + await getTokenDecimals(connection, inputTokenCA) + ); + + elizaLogger.log("Decimals:", decimals.toString()); + + // Use BigNumber for adjustedAmount: amount * (10 ** decimals) + const amountBN = new BigNumber(amount); + const adjustedAmount = amountBN.multipliedBy( + new BigNumber(10).pow(decimals) + ); + + elizaLogger.log("Fetching quote with params:", { + inputMint: inputTokenCA, + outputMint: outputTokenCA, + amount: adjustedAmount, + }); + + const quoteResponse = await fetch( + `https://quote-api.jup.ag/v6/quote?inputMint=${inputTokenCA}&outputMint=${outputTokenCA}&amount=${adjustedAmount}&dynamicSlippage=true&maxAccounts=64` + ); + const quoteData = await quoteResponse.json(); + + if (!quoteData || quoteData.error) { + elizaLogger.error("Quote error:", quoteData); + throw new Error( + `Failed to get quote: ${quoteData?.error || "Unknown error"}` + ); + } + + elizaLogger.log("Quote received:", quoteData); + + const swapRequestBody = { + quoteResponse: quoteData, + userPublicKey: walletPublicKey.toBase58(), + dynamicComputeUnitLimit: true, + dynamicSlippage: true, + priorityLevelWithMaxLamports: { + maxLamports: 4000000, + priorityLevel: "veryHigh", + }, + }; + + elizaLogger.log("Requesting swap with body:", swapRequestBody); + + const swapResponse = await fetch("https://quote-api.jup.ag/v6/swap", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(swapRequestBody), + }); + + const swapData = await swapResponse.json(); + + if (!swapData || !swapData.swapTransaction) { + elizaLogger.error("Swap error:", swapData); + throw new Error( + `Failed to get swap transaction: ${swapData?.error || "No swap transaction returned"}` + ); + } + + elizaLogger.log("Swap transaction received"); + return swapData; + } catch (error) { + elizaLogger.error("Error in swapToken:", error); + throw error; + } +} + +const swapTemplate = `Respond with a JSON markdown block containing only the extracted values. Use null for any values that cannot be determined. + +Example response: +\`\`\`json +{ + "inputTokenSymbol": "SOL", + "outputTokenSymbol": "USDC", + "inputTokenCA": "So11111111111111111111111111111111111111112", + "outputTokenCA": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "amount": 1.5 +} +\`\`\` + +{{recentMessages}} + +Given the recent messages and wallet information below: + +{{walletInfo}} + +Extract the following information about the requested token swap: +- Input token symbol (the token being sold) +- Output token symbol (the token being bought) +- Input token contract address if provided +- Output token contract address if provided +- Amount to swap + +Respond with a JSON markdown block containing only the extracted values. Use null for any values that cannot be determined. The result should be a valid JSON object with the following schema: +\`\`\`json +{ + "inputTokenSymbol": string | null, + "outputTokenSymbol": string | null, + "inputTokenCA": string | null, + "outputTokenCA": string | null, + "amount": number | string | null +} +\`\`\``; + +// if we get the token symbol but not the CA, check walet for matching token, and if we have, get the CA for it + +// get all the tokens in the wallet using the wallet provider +async function getTokensInWallet(runtime: IAgentRuntime) { + const { publicKey } = await getWalletKey(runtime, false); + const walletProvider = new WalletProvider( + new Connection("https://api.mainnet-beta.solana.com"), + publicKey + ); + + const walletInfo = await walletProvider.fetchPortfolioValue(runtime); + const items = walletInfo.items; + return items; +} + +// check if the token symbol is in the wallet +async function getTokenFromWallet(runtime: IAgentRuntime, tokenSymbol: string) { + try { + const items = await getTokensInWallet(runtime); + const token = items.find((item) => item.symbol === tokenSymbol); + + if (token) { + return token.address; + } else { + return null; + } + } catch (error) { + elizaLogger.error("Error checking token in wallet:", error); + return null; + } +} + +// swapToken should took CA, not symbol + +export const executeSwap: Action = { + name: "SWAP_SOLANA", + similes: ["SWAP_SOL", "SWAP_TOKENS_SOLANA", "TOKEN_SWAP_SOLANA", "TRADE_TOKENS_SOLANA", "EXCHANGE_TOKENS_SOLANA"], + validate: async (runtime: IAgentRuntime, message: Memory) => { + // Check if the necessary parameters are provided in the message + elizaLogger.log("Message:", message); + return true; + }, + description: "Perform a token swap from one token to another on Solana. Works with SOL and SPL tokens.", + handler: async ( + runtime: IAgentRuntime, + message: Memory, + state: State, + _options: { [key: string]: unknown }, + callback?: HandlerCallback + ): Promise => { + // composeState + if (!state) { + state = (await runtime.composeState(message)) as State; + } else { + state = await runtime.updateRecentMessageState(state); + } + + const walletInfo = await walletProvider.get(runtime, message, state); + + state.walletInfo = walletInfo; + + const swapContext = composeContext({ + state, + template: swapTemplate, + }); + + const response = await generateObject({ + runtime, + context: swapContext, + modelClass: ModelClass.LARGE, + }); + + elizaLogger.log("Response:", response); + // const type = response.inputTokenSymbol?.toUpperCase() === "SOL" ? "buy" : "sell"; + + // Add SOL handling logic + if (response.inputTokenSymbol?.toUpperCase() === "SOL") { + response.inputTokenCA = settings.SOL_ADDRESS; + } + if (response.outputTokenSymbol?.toUpperCase() === "SOL") { + response.outputTokenCA = settings.SOL_ADDRESS; + } + + // if both contract addresses are set, lets execute the swap + // TODO: try to resolve CA from symbol based on existing symbol in wallet + if (!response.inputTokenCA && response.inputTokenSymbol) { + elizaLogger.log( + `Attempting to resolve CA for input token symbol: ${response.inputTokenSymbol}` + ); + response.inputTokenCA = await getTokenFromWallet( + runtime, + response.inputTokenSymbol + ); + if (response.inputTokenCA) { + elizaLogger.log( + `Resolved inputTokenCA: ${response.inputTokenCA}` + ); + } else { + elizaLogger.log( + "No contract addresses provided, skipping swap" + ); + const responseMsg = { + text: "I need the contract addresses to perform the swap", + }; + callback?.(responseMsg); + return true; + } + } + + if (!response.outputTokenCA && response.outputTokenSymbol) { + elizaLogger.log( + `Attempting to resolve CA for output token symbol: ${response.outputTokenSymbol}` + ); + response.outputTokenCA = await getTokenFromWallet( + runtime, + response.outputTokenSymbol + ); + if (response.outputTokenCA) { + elizaLogger.log( + `Resolved outputTokenCA: ${response.outputTokenCA}` + ); + } else { + elizaLogger.log( + "No contract addresses provided, skipping swap" + ); + const responseMsg = { + text: "I need the contract addresses to perform the swap", + }; + callback?.(responseMsg); + return true; + } + } + + if (!response.amount) { + elizaLogger.log("No amount provided, skipping swap"); + const responseMsg = { + text: "I need the amount to perform the swap", + }; + callback?.(responseMsg); + return true; + } + + // TODO: if response amount is half, all, etc, semantically retrieve amount and return as number + if (!response.amount) { + elizaLogger.log("Amount is not a number, skipping swap"); + const responseMsg = { + text: "The amount must be a number", + }; + callback?.(responseMsg); + return true; + } + try { + const connection = new Connection( + "https://api.mainnet-beta.solana.com" + ); + const { publicKey: walletPublicKey } = await getWalletKey( + runtime, + false + ); + + // const provider = new WalletProvider(connection, walletPublicKey); + + elizaLogger.log("Wallet Public Key:", walletPublicKey); + elizaLogger.log("inputTokenSymbol:", response.inputTokenCA); + elizaLogger.log("outputTokenSymbol:", response.outputTokenCA); + elizaLogger.log("amount:", response.amount); + + const swapResult = await swapToken( + connection, + walletPublicKey, + response.inputTokenCA as string, + response.outputTokenCA as string, + response.amount as number + ); + + elizaLogger.log("Deserializing transaction..."); + const transactionBuf = Buffer.from( + swapResult.swapTransaction, + "base64" + ); + const transaction = + VersionedTransaction.deserialize(transactionBuf); + + elizaLogger.log("Preparing to sign transaction..."); + + elizaLogger.log("Creating keypair..."); + const { keypair } = await getWalletKey(runtime, true); + // Verify the public key matches what we expect + if (keypair.publicKey.toBase58() !== walletPublicKey.toBase58()) { + throw new Error( + "Generated public key doesn't match expected public key" + ); + } + + elizaLogger.log("Signing transaction..."); + transaction.sign([keypair]); + + elizaLogger.log("Sending transaction..."); + + const latestBlockhash = await connection.getLatestBlockhash(); + + const txid = await connection.sendTransaction(transaction, { + skipPreflight: false, + maxRetries: 3, + preflightCommitment: "confirmed", + }); + + elizaLogger.log("Transaction sent:", txid); + + // Confirm transaction using the blockhash + const confirmation = await connection.confirmTransaction( + { + signature: txid, + blockhash: latestBlockhash.blockhash, + lastValidBlockHeight: latestBlockhash.lastValidBlockHeight, + }, + "confirmed" + ); + + if (confirmation.value.err) { + throw new Error( + `Transaction failed: ${confirmation.value.err}` + ); + } + + if (confirmation.value.err) { + throw new Error( + `Transaction failed: ${confirmation.value.err}` + ); + } + + elizaLogger.log("Swap completed successfully!"); + elizaLogger.log(`Transaction ID: ${txid}`); + + const responseMsg = { + text: `Swap completed successfully! Transaction ID: ${txid}`, + }; + + callback?.(responseMsg); + + return true; + } catch (error) { + elizaLogger.error("Error during token swap:", error); + return false; + } + }, + examples: [ + [ + { + user: "{{user1}}", + content: { + inputTokenSymbol: "SOL", + outputTokenSymbol: "USDC", + amount: 0.1, + }, + }, + { + user: "{{user2}}", + content: { + text: "Swapping 0.1 SOL for USDC...", + action: "SWAP_SOLANA", + }, + }, + { + user: "{{user2}}", + content: { + text: "Swap completed successfully! Transaction ID: ...", + }, + }, + ], + // Add more examples as needed + ] as ActionExample[][], +} as Action; \ No newline at end of file diff --git a/packages/plugin-solana/src/actions/transfer.ts b/packages/plugin-solana/src/actions/transfer.ts new file mode 100644 index 00000000000..54c8e2cb7ab --- /dev/null +++ b/packages/plugin-solana/src/actions/transfer.ts @@ -0,0 +1,276 @@ +import { + type Action, + type ActionExample, + composeContext, + type Content, + elizaLogger, + generateObject, + type HandlerCallback, + type IAgentRuntime, + type Memory, + ModelClass, settings, type State +} from "@elizaos/core"; +import { + createAssociatedTokenAccountInstruction, + createTransferInstruction, + getAssociatedTokenAddressSync, +} from "@solana/spl-token"; +import { + Connection, + PublicKey, + SystemProgram, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import { getWalletKey } from "../keypairUtils"; + +interface TransferContent extends Content { + tokenAddress: string | null; // null for SOL transfers + recipient: string; + amount: string | number; +} + +function isTransferContent( + content: any +): content is TransferContent { + elizaLogger.log("Content for transfer", content); + + // Base validation + if (!content.recipient || typeof content.recipient !== "string") { + return false; + } + + // SOL transfer validation + if (content.tokenAddress === null) { + return typeof content.amount === "number"; + } + + // SPL token transfer validation + if (typeof content.tokenAddress === "string") { + return typeof content.amount === "string" || typeof content.amount === "number"; + } + + return false; +} + +const transferTemplate = `Respond with a JSON markdown block containing only the extracted values. Use null for any values that cannot be determined. + +Example responses: +For SPL tokens: +\`\`\`json +{ + "tokenAddress": "BieefG47jAHCGZBxi2q87RDuHyGZyYC3vAzxpyu8pump", + "recipient": "9jW8FPr6BSSsemWPV22UUCzSqkVdTp6HTyPqeqyuBbCa", + "amount": "1000" +} +\`\`\` + +For SOL: +\`\`\`json +{ + "tokenAddress": null, + "recipient": "9jW8FPr6BSSsemWPV22UUCzSqkVdTp6HTyPqeqyuBbCa", + "amount": 1.5 +} +\`\`\` + +{{recentMessages}} + +Extract the following information about the requested transfer: +- Token contract address (use null for SOL transfers) +- Recipient wallet address +- Amount to transfer +`; + +export default { + name: "TRANSFER_SOLANA", + similes: [ + "TRANSFER_SOL", + "SEND_TOKEN_SOLANA", "TRANSFER_TOKEN_SOLANA", "SEND_TOKENS_SOLANA", "TRANSFER_TOKENS_SOLANA", + "SEND_SOL", "SEND_TOKEN_SOL", "PAY_SOL", "PAY_TOKEN_SOL", "PAY_TOKENS_SOL", "PAY_TOKENS_SOLANA", + "PAY_SOLANA" + ], + validate: async (runtime: IAgentRuntime, message: Memory) => { + elizaLogger.log("Validating transfer from user:", message.userId); + return true; + }, + description: "Transfer SOL or SPL tokens to another address on Solana.", + handler: async ( + runtime: IAgentRuntime, + message: Memory, + state: State, + _options: { [key: string]: unknown }, + callback?: HandlerCallback + ): Promise => { + elizaLogger.log("Starting TRANSFER handler..."); + + if (!state) { + state = (await runtime.composeState(message)) as State; + } else { + state = await runtime.updateRecentMessageState(state); + } + + const transferContext = composeContext({ + state, + template: transferTemplate, + }); + + const content = await generateObject({ + runtime, + context: transferContext, + modelClass: ModelClass.LARGE, + }); + + if (!isTransferContent(content)) { + if (callback) { + callback({ + text: "Need a valid recipient address and amount to transfer.", + content: { error: "Invalid transfer content" }, + }); + } + return false; + } + + try { + const { keypair: senderKeypair } = await getWalletKey(runtime, true); + const connection = new Connection(settings.SOLANA_RPC_URL!); + const recipientPubkey = new PublicKey(content.recipient); + + let signature: string; + + // Handle SOL transfer + if (content.tokenAddress === null) { + const lamports = Number(content.amount) * 1e9; + + const instruction = SystemProgram.transfer({ + fromPubkey: senderKeypair.publicKey, + toPubkey: recipientPubkey, + lamports, + }); + + const messageV0 = new TransactionMessage({ + payerKey: senderKeypair.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [instruction], + }).compileToV0Message(); + + const transaction = new VersionedTransaction(messageV0); + transaction.sign([senderKeypair]); + + signature = await connection.sendTransaction(transaction); + + if (callback) { + callback({ + text: `Sent ${content.amount} SOL. Transaction hash: ${signature}`, + content: { + success: true, + signature, + amount: content.amount, + recipient: content.recipient, + }, + }); + } + } + // Handle SPL token transfer + else { + const mintPubkey = new PublicKey(content.tokenAddress); + const mintInfo = await connection.getParsedAccountInfo(mintPubkey); + const decimals = (mintInfo.value?.data as any)?.parsed?.info?.decimals ?? 9; + const adjustedAmount = BigInt(Number(content.amount) * Math.pow(10, decimals)); + + const senderATA = getAssociatedTokenAddressSync(mintPubkey, senderKeypair.publicKey); + const recipientATA = getAssociatedTokenAddressSync(mintPubkey, recipientPubkey); + + const instructions = []; + + const recipientATAInfo = await connection.getAccountInfo(recipientATA); + if (!recipientATAInfo) { + instructions.push( + createAssociatedTokenAccountInstruction( + senderKeypair.publicKey, + recipientATA, + recipientPubkey, + mintPubkey + ) + ); + } + + instructions.push( + createTransferInstruction( + senderATA, + recipientATA, + senderKeypair.publicKey, + adjustedAmount + ) + ); + + const messageV0 = new TransactionMessage({ + payerKey: senderKeypair.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions, + }).compileToV0Message(); + + const transaction = new VersionedTransaction(messageV0); + transaction.sign([senderKeypair]); + + signature = await connection.sendTransaction(transaction); + + if (callback) { + callback({ + text: `Sent ${content.amount} tokens to ${content.recipient}\nTransaction hash: ${signature}`, + content: { + success: true, + signature, + amount: content.amount, + recipient: content.recipient, + }, + }); + } + } + + return true; + } catch (error) { + elizaLogger.error("Error during transfer:", error); + if (callback) { + callback({ + text: `Transfer failed: ${error.message}`, + content: { error: error.message }, + }); + } + return false; + } + }, + + examples: [ + [ + { + user: "{{user1}}", + content: { + text: "Send 1.5 SOL to 9jW8FPr6BSSsemWPV22UUCzSqkVdTp6HTyPqeqyuBbCa", + }, + }, + { + user: "{{user2}}", + content: { + text: "Sending SOL now...", + action: "TRANSFER_SOLANA", + }, + }, + ], + [ + { + user: "{{user1}}", + content: { + text: "Send 69 $DEGENAI BieefG47jAHCGZBxi2q87RDuHyGZyYC3vAzxpyu8pump to 9jW8FPr6BSSsemWPV22UUCzSqkVdTp6HTyPqeqyuBbCa", + }, + }, + { + user: "{{user2}}", + content: { + text: "Sending the tokens now...", + action: "TRANSFER_SOLANA", + }, + }, + ], + ] as ActionExample[][], +} as Action; \ No newline at end of file diff --git a/packages/plugin-solana/src/bignumber.ts b/packages/plugin-solana/src/bignumber.ts new file mode 100644 index 00000000000..f320676a0fc --- /dev/null +++ b/packages/plugin-solana/src/bignumber.ts @@ -0,0 +1,9 @@ +import BigNumber from "bignumber.js"; + +// Re-export BigNumber constructor +export const BN = BigNumber; + +// Helper function to create new BigNumber instances +export function toBN(value: string | number | BigNumber): BigNumber { + return new BigNumber(value); +} diff --git a/packages/plugin-solana/src/client.ts b/packages/plugin-solana/src/client.ts new file mode 100644 index 00000000000..78548935495 --- /dev/null +++ b/packages/plugin-solana/src/client.ts @@ -0,0 +1,256 @@ +import { logger, type Client, type IAgentRuntime, type ICacheManager } from '@elizaos/core'; +import { Connection, PublicKey } from "@solana/web3.js"; +import BigNumber from "bignumber.js"; +import { getWalletKey } from "./keypairUtils"; +import type { Item, Prices, WalletPortfolio } from "./types"; + +const PROVIDER_CONFIG = { + BIRDEYE_API: "https://public-api.birdeye.so", + MAX_RETRIES: 3, + RETRY_DELAY: 2000, + DEFAULT_RPC: "https://api.mainnet-beta.solana.com", + TOKEN_ADDRESSES: { + SOL: "So11111111111111111111111111111111111111112", + BTC: "3NZ9JMVBmGAqocybic2c7LQCJScmgsAZ6vQqTDzcqmJh", + ETH: "7vfCXTUXx5WJV5JADk17DUJ4ksgau7utNKj4b963voxs", + }, +}; + +class SolanaClient { + private updateInterval: NodeJS.Timer | null = null; + private lastUpdate: number = 0; + private readonly UPDATE_INTERVAL = 120000; // 2 minutes + private readonly CACHE_KEY = 'solana/walletData'; + private connection: Connection; + private publicKey: PublicKey; + + constructor( + private runtime: IAgentRuntime, + private cacheManager: ICacheManager, + connection: Connection, + publicKey: PublicKey + ) { + this.connection = connection; + this.publicKey = publicKey; + this.start(); + } + + start() { + logger.log('SolanaClient start'); + if (this.updateInterval) { + clearInterval(this.updateInterval); + } + + this.updateInterval = setInterval(async () => { + logger.log('Updating wallet data'); + await this.updateWalletData(); + }, this.UPDATE_INTERVAL); + + // Initial update + this.updateWalletData().catch(console.error); + } + + public stop(runtime: IAgentRuntime) { + if (this.updateInterval) { + clearInterval(this.updateInterval); + this.updateInterval = null; + } + + const client = runtime.clients.find(client => client.name === 'SolanaClient'); + runtime.clients = runtime.clients.filter(c => c !== client); + + return Promise.resolve(); + } + + private async fetchWithRetry( + url: string, + options: RequestInit = {} + ): Promise { + let lastError: Error; + + for (let i = 0; i < PROVIDER_CONFIG.MAX_RETRIES; i++) { + try { + const response = await fetch(url, { + ...options, + headers: { + Accept: "application/json", + "x-chain": "solana", + "X-API-KEY": this.runtime.getSetting("BIRDEYE_API_KEY"), + ...options.headers, + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`); + } + + return await response.json(); + } catch (error) { + logger.error(`Attempt ${i + 1} failed:`, error); + lastError = error; + if (i < PROVIDER_CONFIG.MAX_RETRIES - 1) { + await new Promise(resolve => setTimeout(resolve, PROVIDER_CONFIG.RETRY_DELAY * Math.pow(2, i))); + continue; + } + } + } + + throw lastError; + } + + private async fetchPrices(): Promise { + const cacheKey = "prices"; + const cachedValue = await this.cacheManager.get(cacheKey); + + if (cachedValue) { + logger.log("Cache hit for fetchPrices"); + return cachedValue; + } + + logger.log("Cache miss for fetchPrices"); + const { SOL, BTC, ETH } = PROVIDER_CONFIG.TOKEN_ADDRESSES; + const tokens = [SOL, BTC, ETH]; + const prices: Prices = { + solana: { usd: "0" }, + bitcoin: { usd: "0" }, + ethereum: { usd: "0" }, + }; + + for (const token of tokens) { + const response = await this.fetchWithRetry( + `${PROVIDER_CONFIG.BIRDEYE_API}/defi/price?address=${token}` + ); + + if (response?.data?.value) { + const price = response.data.value.toString(); + prices[ + token === SOL ? "solana" : token === BTC ? "bitcoin" : "ethereum" + ].usd = price; + } + } + + await this.cacheManager.set(cacheKey, prices); + return prices; + } + + private async getTokenAccounts() { + try { + const accounts = await this.connection.getParsedTokenAccountsByOwner( + this.publicKey, + { + programId: new PublicKey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"), + } + ); + return accounts.value; + } catch (error) { + logger.error("Error fetching token accounts:", error); + return []; + } + } + + private async updateWalletData(force: boolean = false): Promise { + const now = Date.now(); + + // Don't update if less than interval has passed, unless forced + if (!force && now - this.lastUpdate < this.UPDATE_INTERVAL) { + const cached = await this.getCachedData(); + if (cached) return cached; + } + + try { + // Try Birdeye API first + const birdeyeApiKey = this.runtime.getSetting("BIRDEYE_API_KEY"); + if (birdeyeApiKey) { + const walletData = await this.fetchWithRetry( + `${PROVIDER_CONFIG.BIRDEYE_API}/v1/wallet/token_list?wallet=${this.publicKey.toBase58()}` + ); + + if (walletData?.success && walletData?.data) { + const data = walletData.data; + const totalUsd = new BigNumber(data.totalUsd.toString()); + const prices = await this.fetchPrices(); + const solPriceInUSD = new BigNumber(prices.solana.usd); + + const portfolio: WalletPortfolio = { + totalUsd: totalUsd.toString(), + totalSol: totalUsd.div(solPriceInUSD).toFixed(6), + items: data.items.map((item: any) => ({ + ...item, + valueSol: new BigNumber(item.valueUsd || 0) + .div(solPriceInUSD) + .toFixed(6), + name: item.name || "Unknown", + symbol: item.symbol || "Unknown", + priceUsd: item.priceUsd || "0", + valueUsd: item.valueUsd || "0", + })), + }; + + await this.cacheManager.set(this.CACHE_KEY, portfolio); + this.lastUpdate = now; + return portfolio; + } + } + + // Fallback to basic token account info + const accounts = await this.getTokenAccounts(); + const items: Item[] = accounts.map((acc) => ({ + name: "Unknown", + address: acc.account.data.parsed.info.mint, + symbol: "Unknown", + decimals: acc.account.data.parsed.info.tokenAmount.decimals, + balance: acc.account.data.parsed.info.tokenAmount.amount, + uiAmount: acc.account.data.parsed.info.tokenAmount.uiAmount.toString(), + priceUsd: "0", + valueUsd: "0", + valueSol: "0", + })); + + const portfolio: WalletPortfolio = { + totalUsd: "0", + totalSol: "0", + items, + }; + + await this.cacheManager.set(this.CACHE_KEY, portfolio); + this.lastUpdate = now; + return portfolio; + + } catch (error) { + logger.error("Error updating wallet data:", error); + throw error; + } + } + + public async getCachedData(): Promise { + return await this.cacheManager.get(this.CACHE_KEY); + } + + public async forceUpdate(): Promise { + return await this.updateWalletData(true); + } + + public getPublicKey(): PublicKey { + return this.publicKey; + } + + public getConnection(): Connection { + return this.connection; + } +} + +export const SolanaClientInterface: Client = { + name: 'SolanaClient', + start: async (runtime: IAgentRuntime) => { + logger.log('initSolanaClient'); + + const connection = new Connection( + runtime.getSetting("SOLANA_RPC_URL") || PROVIDER_CONFIG.DEFAULT_RPC + ); + + const { publicKey } = await getWalletKey(runtime, false); + + return new SolanaClient(runtime, runtime.cacheManager, connection, publicKey); + } +}; \ No newline at end of file diff --git a/packages/plugin-solana/src/environment.ts b/packages/plugin-solana/src/environment.ts new file mode 100644 index 00000000000..5a536ced6fb --- /dev/null +++ b/packages/plugin-solana/src/environment.ts @@ -0,0 +1,78 @@ +import type { IAgentRuntime } from "@elizaos/core"; +import { z } from "zod"; + +export const solanaEnvSchema = z + .object({ + WALLET_SECRET_SALT: z.string().optional(), + }) + .and( + z.union([ + z.object({ + WALLET_SECRET_KEY: z + .string() + .min(1, "Wallet secret key is required"), + WALLET_PUBLIC_KEY: z + .string() + .min(1, "Wallet public key is required"), + }), + z.object({ + WALLET_SECRET_SALT: z + .string() + .min(1, "Wallet secret salt is required"), + }), + ]) + ) + .and( + z.object({ + SOL_ADDRESS: z.string().min(1, "SOL address is required"), + SLIPPAGE: z.string().min(1, "Slippage is required"), + SOLANA_RPC_URL: z.string().min(1, "RPC URL is required"), + HELIUS_API_KEY: z.string().min(1, "Helius API key is required"), + BIRDEYE_API_KEY: z.string().min(1, "Birdeye API key is required"), + }) + ); + +export type SolanaConfig = z.infer; + +export async function validateSolanaConfig( + runtime: IAgentRuntime +): Promise { + try { + const config = { + WALLET_SECRET_SALT: + runtime.getSetting("WALLET_SECRET_SALT") || + process.env.WALLET_SECRET_SALT, + WALLET_SECRET_KEY: + runtime.getSetting("WALLET_SECRET_KEY") || + process.env.WALLET_SECRET_KEY, + WALLET_PUBLIC_KEY: + runtime.getSetting("SOLANA_PUBLIC_KEY") || + runtime.getSetting("WALLET_PUBLIC_KEY") || + process.env.WALLET_PUBLIC_KEY, + SOL_ADDRESS: + runtime.getSetting("SOL_ADDRESS") || process.env.SOL_ADDRESS, + SLIPPAGE: runtime.getSetting("SLIPPAGE") || process.env.SLIPPAGE, + SOLANA_RPC_URL: + runtime.getSetting("SOLANA_RPC_URL") || + process.env.SOLANA_RPC_URL, + HELIUS_API_KEY: + runtime.getSetting("HELIUS_API_KEY") || + process.env.HELIUS_API_KEY, + BIRDEYE_API_KEY: + runtime.getSetting("BIRDEYE_API_KEY") || + process.env.BIRDEYE_API_KEY, + }; + + return solanaEnvSchema.parse(config); + } catch (error) { + if (error instanceof z.ZodError) { + const errorMessages = error.errors + .map((err) => `${err.path.join(".")}: ${err.message}`) + .join("\n"); + throw new Error( + `Solana configuration validation failed:\n${errorMessages}` + ); + } + throw error; + } +} diff --git a/packages/plugin-solana/src/index.ts b/packages/plugin-solana/src/index.ts new file mode 100644 index 00000000000..a062298fad2 --- /dev/null +++ b/packages/plugin-solana/src/index.ts @@ -0,0 +1,18 @@ +import type { Plugin } from "@elizaos/core"; +import { executeSwap } from "./actions/swap.ts"; +import transferToken from "./actions/transfer.ts"; +import { walletProvider } from "./providers/wallet.ts"; +import { SolanaClientInterface } from "./client.ts"; + +export const solanaPlugin: Plugin = { + name: "solana", + description: "Solana Plugin for Eliza", + actions: [ + transferToken, + executeSwap, + ], + evaluators: [], + providers: [walletProvider], + clients: [SolanaClientInterface], +}; +export default solanaPlugin; diff --git a/packages/plugin-solana/src/keypairUtils.ts b/packages/plugin-solana/src/keypairUtils.ts new file mode 100644 index 00000000000..c4c37e5e90b --- /dev/null +++ b/packages/plugin-solana/src/keypairUtils.ts @@ -0,0 +1,59 @@ +import { Keypair, PublicKey } from "@solana/web3.js"; +import bs58 from "bs58"; +import { type IAgentRuntime, elizaLogger } from "@elizaos/core"; + +export interface KeypairResult { + keypair?: Keypair; + publicKey?: PublicKey; +} + +/** + * Gets either a keypair or public key based on TEE mode and runtime settings + * @param runtime The agent runtime + * @param requirePrivateKey Whether to return a full keypair (true) or just public key (false) + * @returns KeypairResult containing either keypair or public key + */ +export async function getWalletKey( + runtime: IAgentRuntime, + requirePrivateKey = true +): Promise { + // TEE mode is OFF + if (requirePrivateKey) { + const privateKeyString = + runtime.getSetting("SOLANA_PRIVATE_KEY") ?? + runtime.getSetting("WALLET_PRIVATE_KEY"); + + if (!privateKeyString) { + throw new Error("Private key not found in settings"); + } + + try { + // First try base58 + const secretKey = bs58.decode(privateKeyString); + return { keypair: Keypair.fromSecretKey(secretKey) }; + } catch (e) { + elizaLogger.log("Error decoding base58 private key:", e); + try { + // Then try base64 + elizaLogger.log("Try decoding base64 instead"); + const secretKey = Uint8Array.from( + Buffer.from(privateKeyString, "base64") + ); + return { keypair: Keypair.fromSecretKey(secretKey) }; + } catch (e2) { + elizaLogger.error("Error decoding private key: ", e2); + throw new Error("Invalid private key format"); + } + } + } else { + const publicKeyString = + runtime.getSetting("SOLANA_PUBLIC_KEY") ?? + runtime.getSetting("WALLET_PUBLIC_KEY"); + + if (!publicKeyString) { + throw new Error("Solana Public key not found in settings, but plugin was loaded, please set SOLANA_PUBLIC_KEY"); + } + + return { publicKey: new PublicKey(publicKeyString) }; + } +} diff --git a/packages/plugin-solana/src/providers/wallet.ts b/packages/plugin-solana/src/providers/wallet.ts new file mode 100644 index 00000000000..ceb9ba647d4 --- /dev/null +++ b/packages/plugin-solana/src/providers/wallet.ts @@ -0,0 +1,418 @@ +import { + type IAgentRuntime, + type Memory, + type Provider, + type State, + elizaLogger, +} from "@elizaos/core"; +import { Connection, PublicKey } from "@solana/web3.js"; +import BigNumber from "bignumber.js"; +import NodeCache from "node-cache"; +import { getWalletKey } from "../keypairUtils"; +import { Item, Prices, WalletPortfolio } from "../types"; + +// Provider configuration +const PROVIDER_CONFIG = { + BIRDEYE_API: "https://public-api.birdeye.so", + MAX_RETRIES: 3, + RETRY_DELAY: 2000, + DEFAULT_RPC: "https://api.mainnet-beta.solana.com", + GRAPHQL_ENDPOINT: "https://graph.codex.io/graphql", + TOKEN_ADDRESSES: { + SOL: "So11111111111111111111111111111111111111112", + BTC: "3NZ9JMVBmGAqocybic2c7LQCJScmgsAZ6vQqTDzcqmJh", + ETH: "7vfCXTUXx5WJV5JADk17DUJ4ksgau7utNKj4b963voxs", + }, +}; + +export class WalletProvider { + private cache: NodeCache; + + constructor( + private connection: Connection, + private walletPublicKey: PublicKey + ) { + this.cache = new NodeCache({ stdTTL: 300 }); // Cache TTL set to 5 minutes + } + + private async fetchWithRetry( + runtime, + url: string, + options: RequestInit = {} + ): Promise { + let lastError: Error; + + for (let i = 0; i < PROVIDER_CONFIG.MAX_RETRIES; i++) { + try { + const response = await fetch(url, { + ...options, + headers: { + Accept: "application/json", + "x-chain": "solana", + "X-API-KEY": + runtime.getSetting("BIRDEYE_API_KEY", "") || "", + ...options.headers, + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `HTTP error! status: ${response.status}, message: ${errorText}` + ); + } + + const data = await response.json(); + return data; + } catch (error) { + elizaLogger.error(`Attempt ${i + 1} failed:`, error); + lastError = error; + if (i < PROVIDER_CONFIG.MAX_RETRIES - 1) { + const delay = PROVIDER_CONFIG.RETRY_DELAY * Math.pow(2, i); + await new Promise((resolve) => setTimeout(resolve, delay)); + continue; + } + } + } + + elizaLogger.error( + "All attempts failed. You may need BIRDEYE_API_KEY to fetch portfolio. Throwing the last error:", + lastError + ); + throw lastError; + } + + async fetchPortfolioValue(runtime): Promise { + try { + const cacheKey = `portfolio-${this.walletPublicKey.toBase58()}`; + const cachedValue = this.cache.get(cacheKey); + + if (cachedValue) { + elizaLogger.log("Cache hit for fetchPortfolioValue"); + return cachedValue; + } + elizaLogger.log("Cache miss for fetchPortfolioValue"); + + // Check if Birdeye API key is available + const birdeyeApiKey = runtime.getSetting("BIRDEYE_API_KEY"); + + if (birdeyeApiKey) { + // Existing Birdeye API logic + const walletData = await this.fetchWithRetry( + runtime, + `${PROVIDER_CONFIG.BIRDEYE_API}/v1/wallet/token_list?wallet=${this.walletPublicKey.toBase58()}` + ); + + if (walletData?.success && walletData?.data) { + const data = walletData.data; + const totalUsd = new BigNumber(data.totalUsd.toString()); + const prices = await this.fetchPrices(runtime); + const solPriceInUSD = new BigNumber( + prices.solana.usd.toString() + ); + + const items = data.items.map((item: any) => ({ + ...item, + valueSol: new BigNumber(item.valueUsd || 0) + .div(solPriceInUSD) + .toFixed(6), + name: item.name || "Unknown", + symbol: item.symbol || "Unknown", + priceUsd: item.priceUsd || "0", + valueUsd: item.valueUsd || "0", + })); + + const portfolio = { + totalUsd: totalUsd.toString(), + totalSol: totalUsd.div(solPriceInUSD).toFixed(6), + items: items.sort((a, b) => + new BigNumber(b.valueUsd) + .minus(new BigNumber(a.valueUsd)) + .toNumber() + ), + }; + + this.cache.set(cacheKey, portfolio); + return portfolio; + } + } + + // Fallback to basic token account info if no Birdeye API key or API call fails + const accounts = await this.getTokenAccounts( + this.walletPublicKey.toBase58() + ); + + const items = accounts.map((acc) => ({ + name: "Unknown", + address: acc.account.data.parsed.info.mint, + symbol: "Unknown", + decimals: acc.account.data.parsed.info.tokenAmount.decimals, + balance: acc.account.data.parsed.info.tokenAmount.amount, + uiAmount: + acc.account.data.parsed.info.tokenAmount.uiAmount.toString(), + priceUsd: "0", + valueUsd: "0", + valueSol: "0", + })); + + const portfolio = { + totalUsd: "0", + totalSol: "0", + items, + }; + + this.cache.set(cacheKey, portfolio); + return portfolio; + } catch (error) { + elizaLogger.error("Error fetching portfolio:", error); + throw error; + } + } + + async fetchPortfolioValueCodex(runtime): Promise { + try { + const cacheKey = `portfolio-${this.walletPublicKey.toBase58()}`; + const cachedValue = await this.cache.get(cacheKey); + + if (cachedValue) { + elizaLogger.log("Cache hit for fetchPortfolioValue"); + return cachedValue; + } + elizaLogger.log("Cache miss for fetchPortfolioValue"); + + const query = ` + query Balances($walletId: String!, $cursor: String) { + balances(input: { walletId: $walletId, cursor: $cursor }) { + cursor + items { + walletId + tokenId + balance + shiftedBalance + } + } + } + `; + + const variables = { + walletId: `${this.walletPublicKey.toBase58()}:${1399811149}`, + cursor: null, + }; + + const response = await fetch(PROVIDER_CONFIG.GRAPHQL_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: + runtime.getSetting("CODEX_API_KEY", "") || "", + }, + body: JSON.stringify({ + query, + variables, + }), + }).then((res) => res.json()); + + const data = response.data?.data?.balances?.items; + + if (!data || data.length === 0) { + elizaLogger.error("No portfolio data available", data); + throw new Error("No portfolio data available"); + } + + // Fetch token prices + const prices = await this.fetchPrices(runtime); + const solPriceInUSD = new BigNumber(prices.solana.usd.toString()); + + // Reformat items + const items: Item[] = data.map((item: any) => { + return { + name: "Unknown", + address: item.tokenId.split(":")[0], + symbol: item.tokenId.split(":")[0], + decimals: 6, + balance: item.balance, + uiAmount: item.shiftedBalance.toString(), + priceUsd: "", + valueUsd: "", + valueSol: "", + }; + }); + + // Calculate total portfolio value + const totalUsd = items.reduce( + (sum, item) => sum.plus(new BigNumber(item.valueUsd)), + new BigNumber(0) + ); + + const totalSol = totalUsd.div(solPriceInUSD); + + const portfolio: WalletPortfolio = { + totalUsd: totalUsd.toFixed(6), + totalSol: totalSol.toFixed(6), + items: items.sort((a, b) => + new BigNumber(b.valueUsd) + .minus(new BigNumber(a.valueUsd)) + .toNumber() + ), + }; + + // Cache the portfolio for future requests + await this.cache.set(cacheKey, portfolio, 60 * 1000); // Cache for 1 minute + + return portfolio; + } catch (error) { + elizaLogger.error("Error fetching portfolio:", error); + throw error; + } + } + + async fetchPrices(runtime): Promise { + try { + const cacheKey = "prices"; + const cachedValue = this.cache.get(cacheKey); + + if (cachedValue) { + elizaLogger.log("Cache hit for fetchPrices"); + return cachedValue; + } + elizaLogger.log("Cache miss for fetchPrices"); + + const { SOL, BTC, ETH } = PROVIDER_CONFIG.TOKEN_ADDRESSES; + const tokens = [SOL, BTC, ETH]; + const prices: Prices = { + solana: { usd: "0" }, + bitcoin: { usd: "0" }, + ethereum: { usd: "0" }, + }; + + for (const token of tokens) { + const response = await this.fetchWithRetry( + runtime, + `${PROVIDER_CONFIG.BIRDEYE_API}/defi/price?address=${token}`, + { + headers: { + "x-chain": "solana", + }, + } + ); + + if (response?.data?.value) { + const price = response.data.value.toString(); + prices[ + token === SOL + ? "solana" + : token === BTC + ? "bitcoin" + : "ethereum" + ].usd = price; + } else { + elizaLogger.warn( + `No price data available for token: ${token}` + ); + } + } + + this.cache.set(cacheKey, prices); + return prices; + } catch (error) { + elizaLogger.error("Error fetching prices:", error); + throw error; + } + } + + formatPortfolio( + runtime, + portfolio: WalletPortfolio, + prices: Prices + ): string { + let output = `${runtime.character.description}\n`; + output += `Wallet Address: ${this.walletPublicKey.toBase58()}\n\n`; + + const totalUsdFormatted = new BigNumber(portfolio.totalUsd).toFixed(2); + const totalSolFormatted = portfolio.totalSol; + + output += `Total Value: $${totalUsdFormatted} (${totalSolFormatted} SOL)\n\n`; + output += "Token Balances:\n"; + + const nonZeroItems = portfolio.items.filter((item) => + new BigNumber(item.uiAmount).isGreaterThan(0) + ); + + if (nonZeroItems.length === 0) { + output += "No tokens found with non-zero balance\n"; + } else { + for (const item of nonZeroItems) { + const valueUsd = new BigNumber(item.valueUsd).toFixed(2); + output += `${item.name} (${item.symbol}): ${new BigNumber( + item.uiAmount + ).toFixed(6)} ($${valueUsd} | ${item.valueSol} SOL)\n`; + } + } + + output += "\nMarket Prices:\n"; + output += `SOL: $${new BigNumber(prices.solana.usd).toFixed(2)}\n`; + output += `BTC: $${new BigNumber(prices.bitcoin.usd).toFixed(2)}\n`; + output += `ETH: $${new BigNumber(prices.ethereum.usd).toFixed(2)}\n`; + + return output; + } + + async getFormattedPortfolio(runtime): Promise { + try { + const [portfolio, prices] = await Promise.all([ + this.fetchPortfolioValue(runtime), + this.fetchPrices(runtime), + ]); + + return this.formatPortfolio(runtime, portfolio, prices); + } catch (error) { + elizaLogger.error("Error generating portfolio report:", error); + return "Unable to fetch wallet information. Please try again later."; + } + } + + private async getTokenAccounts(walletAddress: string) { + try { + const accounts = + await this.connection.getParsedTokenAccountsByOwner( + new PublicKey(walletAddress), + { + programId: new PublicKey( + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + ), + } + ); + return accounts.value; + } catch (error) { + elizaLogger.error("Error fetching token accounts:", error); + return []; + } + } +} + +const walletProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + _message: Memory, + _state?: State + ): Promise => { + try { + const { publicKey } = await getWalletKey(runtime, false); + + const connection = new Connection( + runtime.getSetting("SOLANA_RPC_URL") || + PROVIDER_CONFIG.DEFAULT_RPC + ); + + const provider = new WalletProvider(connection, publicKey); + + return await provider.getFormattedPortfolio(runtime); + } catch (error) { + elizaLogger.error("Error in wallet provider:", error); + return null; + } + }, +}; + +// Module exports +export { walletProvider }; diff --git a/packages/plugin-solana/src/types.ts b/packages/plugin-solana/src/types.ts new file mode 100644 index 00000000000..1cd7e4d5d9c --- /dev/null +++ b/packages/plugin-solana/src/types.ts @@ -0,0 +1,32 @@ +export interface Item { + name: string; + address: string; + symbol: string; + decimals: number; + balance: string; + uiAmount: string; + priceUsd: string; + valueUsd: string; + valueSol?: string; +} + +export interface WalletPortfolio { + totalUsd: string; + totalSol?: string; + items: Array; +} + +export interface _BirdEyePriceData { + data: { + [key: string]: { + price: number; + priceChange24h: number; + }; + }; +} + +export interface Prices { + solana: { usd: string }; + bitcoin: { usd: string }; + ethereum: { usd: string }; +} \ No newline at end of file diff --git a/packages/plugin-solana/src/types/token.ts b/packages/plugin-solana/src/types/token.ts new file mode 100644 index 00000000000..1fca4c37c32 --- /dev/null +++ b/packages/plugin-solana/src/types/token.ts @@ -0,0 +1,302 @@ +export interface TokenSecurityData { + ownerBalance: string; + creatorBalance: string; + ownerPercentage: number; + creatorPercentage: number; + top10HolderBalance: string; + top10HolderPercent: number; +} + +export interface TokenCodex { + id: string; + address: string; + cmcId: number; + decimals: number; + name: string; + symbol: string; + totalSupply: string; + circulatingSupply: string; + imageThumbUrl: string; + blueCheckmark: boolean; + isScam: boolean; +} + +export interface TokenTradeData { + address: string; + holder: number; + market: number; + last_trade_unix_time: number; + last_trade_human_time: string; + price: number; + history_30m_price: number; + price_change_30m_percent: number; + history_1h_price: number; + price_change_1h_percent: number; + history_2h_price: number; + price_change_2h_percent: number; + history_4h_price: number; + price_change_4h_percent: number; + history_6h_price: number; + price_change_6h_percent: number; + history_8h_price: number; + price_change_8h_percent: number; + history_12h_price: number; + price_change_12h_percent: number; + history_24h_price: number; + price_change_24h_percent: number; + unique_wallet_30m: number; + unique_wallet_history_30m: number; + unique_wallet_30m_change_percent: number; + unique_wallet_1h: number; + unique_wallet_history_1h: number; + unique_wallet_1h_change_percent: number; + unique_wallet_2h: number; + unique_wallet_history_2h: number; + unique_wallet_2h_change_percent: number; + unique_wallet_4h: number; + unique_wallet_history_4h: number; + unique_wallet_4h_change_percent: number; + unique_wallet_8h: number; + unique_wallet_history_8h: number | null; + unique_wallet_8h_change_percent: number | null; + unique_wallet_24h: number; + unique_wallet_history_24h: number | null; + unique_wallet_24h_change_percent: number | null; + trade_30m: number; + trade_history_30m: number; + trade_30m_change_percent: number; + sell_30m: number; + sell_history_30m: number; + sell_30m_change_percent: number; + buy_30m: number; + buy_history_30m: number; + buy_30m_change_percent: number; + volume_30m: number; + volume_30m_usd: number; + volume_history_30m: number; + volume_history_30m_usd: number; + volume_30m_change_percent: number; + volume_buy_30m: number; + volume_buy_30m_usd: number; + volume_buy_history_30m: number; + volume_buy_history_30m_usd: number; + volume_buy_30m_change_percent: number; + volume_sell_30m: number; + volume_sell_30m_usd: number; + volume_sell_history_30m: number; + volume_sell_history_30m_usd: number; + volume_sell_30m_change_percent: number; + trade_1h: number; + trade_history_1h: number; + trade_1h_change_percent: number; + sell_1h: number; + sell_history_1h: number; + sell_1h_change_percent: number; + buy_1h: number; + buy_history_1h: number; + buy_1h_change_percent: number; + volume_1h: number; + volume_1h_usd: number; + volume_history_1h: number; + volume_history_1h_usd: number; + volume_1h_change_percent: number; + volume_buy_1h: number; + volume_buy_1h_usd: number; + volume_buy_history_1h: number; + volume_buy_history_1h_usd: number; + volume_buy_1h_change_percent: number; + volume_sell_1h: number; + volume_sell_1h_usd: number; + volume_sell_history_1h: number; + volume_sell_history_1h_usd: number; + volume_sell_1h_change_percent: number; + trade_2h: number; + trade_history_2h: number; + trade_2h_change_percent: number; + sell_2h: number; + sell_history_2h: number; + sell_2h_change_percent: number; + buy_2h: number; + buy_history_2h: number; + buy_2h_change_percent: number; + volume_2h: number; + volume_2h_usd: number; + volume_history_2h: number; + volume_history_2h_usd: number; + volume_2h_change_percent: number; + volume_buy_2h: number; + volume_buy_2h_usd: number; + volume_buy_history_2h: number; + volume_buy_history_2h_usd: number; + volume_buy_2h_change_percent: number; + volume_sell_2h: number; + volume_sell_2h_usd: number; + volume_sell_history_2h: number; + volume_sell_history_2h_usd: number; + volume_sell_2h_change_percent: number; + trade_4h: number; + trade_history_4h: number; + trade_4h_change_percent: number; + sell_4h: number; + sell_history_4h: number; + sell_4h_change_percent: number; + buy_4h: number; + buy_history_4h: number; + buy_4h_change_percent: number; + volume_4h: number; + volume_4h_usd: number; + volume_history_4h: number; + volume_history_4h_usd: number; + volume_4h_change_percent: number; + volume_buy_4h: number; + volume_buy_4h_usd: number; + volume_buy_history_4h: number; + volume_buy_history_4h_usd: number; + volume_buy_4h_change_percent: number; + volume_sell_4h: number; + volume_sell_4h_usd: number; + volume_sell_history_4h: number; + volume_sell_history_4h_usd: number; + volume_sell_4h_change_percent: number; + trade_8h: number; + trade_history_8h: number | null; + trade_8h_change_percent: number | null; + sell_8h: number; + sell_history_8h: number | null; + sell_8h_change_percent: number | null; + buy_8h: number; + buy_history_8h: number | null; + buy_8h_change_percent: number | null; + volume_8h: number; + volume_8h_usd: number; + volume_history_8h: number; + volume_history_8h_usd: number; + volume_8h_change_percent: number | null; + volume_buy_8h: number; + volume_buy_8h_usd: number; + volume_buy_history_8h: number; + volume_buy_history_8h_usd: number; + volume_buy_8h_change_percent: number | null; + volume_sell_8h: number; + volume_sell_8h_usd: number; + volume_sell_history_8h: number; + volume_sell_history_8h_usd: number; + volume_sell_8h_change_percent: number | null; + trade_24h: number; + trade_history_24h: number; + trade_24h_change_percent: number | null; + sell_24h: number; + sell_history_24h: number; + sell_24h_change_percent: number | null; + buy_24h: number; + buy_history_24h: number; + buy_24h_change_percent: number | null; + volume_24h: number; + volume_24h_usd: number; + volume_history_24h: number; + volume_history_24h_usd: number; + volume_24h_change_percent: number | null; + volume_buy_24h: number; + volume_buy_24h_usd: number; + volume_buy_history_24h: number; + volume_buy_history_24h_usd: number; + volume_buy_24h_change_percent: number | null; + volume_sell_24h: number; + volume_sell_24h_usd: number; + volume_sell_history_24h: number; + volume_sell_history_24h_usd: number; + volume_sell_24h_change_percent: number | null; +} + +export interface HolderData { + address: string; + balance: string; +} + +export interface ProcessedTokenData { + security: TokenSecurityData; + tradeData: TokenTradeData; + holderDistributionTrend: string; // 'increasing' | 'decreasing' | 'stable' + highValueHolders: Array<{ + holderAddress: string; + balanceUsd: string; + }>; + recentTrades: boolean; + highSupplyHoldersCount: number; + dexScreenerData: DexScreenerData; + + isDexScreenerListed: boolean; + isDexScreenerPaid: boolean; + tokenCodex: TokenCodex; +} + +export interface DexScreenerPair { + chainId: string; + dexId: string; + url: string; + pairAddress: string; + baseToken: { + address: string; + name: string; + symbol: string; + }; + quoteToken: { + address: string; + name: string; + symbol: string; + }; + priceNative: string; + priceUsd: string; + txns: { + m5: { buys: number; sells: number }; + h1: { buys: number; sells: number }; + h6: { buys: number; sells: number }; + h24: { buys: number; sells: number }; + }; + volume: { + h24: number; + h6: number; + h1: number; + m5: number; + }; + priceChange: { + m5: number; + h1: number; + h6: number; + h24: number; + }; + liquidity: { + usd: number; + base: number; + quote: number; + }; + fdv: number; + marketCap: number; + pairCreatedAt: number; + info: { + imageUrl: string; + websites: { label: string; url: string }[]; + socials: { type: string; url: string }[]; + }; + boosts: { + active: number; + }; +} + +export interface DexScreenerData { + schemaVersion: string; + pairs: DexScreenerPair[]; +} + +export interface Prices { + solana: { usd: string }; + bitcoin: { usd: string }; + ethereum: { usd: string }; +} + +export interface CalculatedBuyAmounts { + none: 0; + low: number; + medium: number; + high: number; +} diff --git a/packages/plugin-solana/tsconfig.json b/packages/plugin-solana/tsconfig.json new file mode 100644 index 00000000000..005fbac9d36 --- /dev/null +++ b/packages/plugin-solana/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../core/tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/plugin-solana/tsup.config.ts b/packages/plugin-solana/tsup.config.ts new file mode 100644 index 00000000000..dd25475bb63 --- /dev/null +++ b/packages/plugin-solana/tsup.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + outDir: "dist", + sourcemap: true, + clean: true, + format: ["esm"], // Ensure you're targeting CommonJS + external: [ + "dotenv", // Externalize dotenv to prevent bundling + "fs", // Externalize fs to use Node.js built-in module + "path", // Externalize other built-ins if necessary + "@reflink/reflink", + "@node-llama-cpp", + "https", + "http", + "agentkeepalive", + "safe-buffer", + "base-x", + "bs58", + "borsh", + "@solana/buffer-layout", + "stream", + "buffer", + "querystring", + "amqplib", + // Add other modules you want to externalize + ], +}); diff --git a/packages/plugin-tee/src/providers/walletProvider.ts b/packages/plugin-tee/src/providers/walletProvider.ts deleted file mode 100644 index 24ac7943b34..00000000000 --- a/packages/plugin-tee/src/providers/walletProvider.ts +++ /dev/null @@ -1,295 +0,0 @@ -/* This is an example of how WalletProvider can use DeriveKeyProvider to generate a Solana Keypair */ -import { type IAgentRuntime, type Memory, type Provider, type State, logger } from '@elizaos/core'; -import { Connection, type Keypair, type PublicKey } from '@solana/web3.js'; -import BigNumber from 'bignumber.js'; -import NodeCache from 'node-cache'; -import { DeriveKeyProvider } from './deriveKeyProvider'; -import type { RemoteAttestationQuote } from '@elizaos/core'; -// Provider configuration -const PROVIDER_CONFIG = { - BIRDEYE_API: 'https://public-api.birdeye.so', - MAX_RETRIES: 3, - RETRY_DELAY: 2000, - DEFAULT_RPC: 'https://api.mainnet-beta.solana.com', - TOKEN_ADDRESSES: { - SOL: 'So11111111111111111111111111111111111111112', - BTC: '3NZ9JMVBmGAqocybic2c7LQCJScmgsAZ6vQqTDzcqmJh', - ETH: '7vfCXTUXx5WJV5JADk17DUJ4ksgau7utNKj4b963voxs', - }, -}; - -export interface Item { - name: string; - address: string; - symbol: string; - decimals: number; - balance: string; - uiAmount: string; - priceUsd: string; - valueUsd: string; - valueSol?: string; -} - -interface WalletPortfolio { - totalUsd: string; - totalSol?: string; - items: Array; -} - -interface _BirdEyePriceData { - data: { - [key: string]: { - price: number; - priceChange24h: number; - }; - }; -} - -interface Prices { - solana: { usd: string }; - bitcoin: { usd: string }; - ethereum: { usd: string }; -} - -export class WalletProvider { - private cache: NodeCache; - - constructor( - private connection: Connection, - private walletPublicKey: PublicKey, - ) { - this.cache = new NodeCache({ stdTTL: 300 }); // Cache TTL set to 5 minutes - } - - private async fetchWithRetry(runtime, url: string, options: RequestInit = {}): Promise { - let lastError: Error; - - for (let i = 0; i < PROVIDER_CONFIG.MAX_RETRIES; i++) { - try { - const apiKey = runtime.getSetting('BIRDEYE_API_KEY'); - const response = await fetch(url, { - ...options, - headers: { - Accept: 'application/json', - 'x-chain': 'solana', - 'X-API-KEY': apiKey || '', - ...options.headers, - }, - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error( - `HTTP error! status: ${response.status}, message: ${errorText}`, - ); - } - - const data = await response.json(); - return data; - } catch (error) { - logger.error(`Attempt ${i + 1} failed:`, error); - lastError = error; - if (i < PROVIDER_CONFIG.MAX_RETRIES - 1) { - const delay = PROVIDER_CONFIG.RETRY_DELAY * 2 ** i; - await new Promise((resolve) => setTimeout(resolve, delay)); - } - } - } - - logger.error('All attempts failed. Throwing the last error:', lastError); - throw lastError; - } - - async fetchPortfolioValue(runtime): Promise { - try { - const cacheKey = `portfolio-${this.walletPublicKey.toBase58()}`; - const cachedValue = this.cache.get(cacheKey); - - if (cachedValue) { - logger.log('Cache hit for fetchPortfolioValue'); - return cachedValue; - } - logger.log('Cache miss for fetchPortfolioValue'); - - const walletData = await this.fetchWithRetry( - runtime, - `${ - PROVIDER_CONFIG.BIRDEYE_API - }/v1/wallet/token_list?wallet=${this.walletPublicKey.toBase58()}`, - ); - - if (!walletData?.success || !walletData?.data) { - logger.error('No portfolio data available', walletData); - throw new Error('No portfolio data available'); - } - - const data = walletData.data; - const totalUsd = new BigNumber(data.totalUsd.toString()); - const prices = await this.fetchPrices(runtime); - const solPriceInUSD = new BigNumber(prices.solana.usd.toString()); - - const items = data.items.map((item: any) => ({ - ...item, - valueSol: new BigNumber(item.valueUsd || 0).div(solPriceInUSD).toFixed(6), - name: item.name || 'Unknown', - symbol: item.symbol || 'Unknown', - priceUsd: item.priceUsd || '0', - valueUsd: item.valueUsd || '0', - })); - - const totalSol = totalUsd.div(solPriceInUSD); - const portfolio = { - totalUsd: totalUsd.toString(), - totalSol: totalSol.toFixed(6), - items: items.sort((a, b) => - new BigNumber(b.valueUsd).minus(new BigNumber(a.valueUsd)).toNumber(), - ), - }; - this.cache.set(cacheKey, portfolio); - return portfolio; - } catch (error) { - logger.error('Error fetching portfolio:', error); - throw error; - } - } - - async fetchPrices(runtime): Promise { - try { - const cacheKey = 'prices'; - const cachedValue = this.cache.get(cacheKey); - - if (cachedValue) { - logger.log('Cache hit for fetchPrices'); - return cachedValue; - } - logger.log('Cache miss for fetchPrices'); - - const { SOL, BTC, ETH } = PROVIDER_CONFIG.TOKEN_ADDRESSES; - const tokens = [SOL, BTC, ETH]; - const prices: Prices = { - solana: { usd: '0' }, - bitcoin: { usd: '0' }, - ethereum: { usd: '0' }, - }; - - for (const token of tokens) { - const response = await this.fetchWithRetry( - runtime, - `${PROVIDER_CONFIG.BIRDEYE_API}/defi/price?address=${token}`, - { - headers: { - 'x-chain': 'solana', - }, - }, - ); - - if (response?.data?.value) { - const price = response.data.value.toString(); - prices[token === SOL ? 'solana' : token === BTC ? 'bitcoin' : 'ethereum'].usd = - price; - } else { - logger.warn(`No price data available for token: ${token}`); - } - } - - this.cache.set(cacheKey, prices); - return prices; - } catch (error) { - logger.error('Error fetching prices:', error); - throw error; - } - } - - formatPortfolio(runtime, portfolio: WalletPortfolio, prices: Prices): string { - let output = `${runtime.character.description}\n`; - output += `Wallet Address: ${this.walletPublicKey.toBase58()}\n\n`; - - const totalUsdFormatted = new BigNumber(portfolio.totalUsd).toFixed(2); - const totalSolFormatted = portfolio.totalSol; - - output += `Total Value: $${totalUsdFormatted} (${totalSolFormatted} SOL)\n\n`; - output += 'Token Balances:\n'; - - const nonZeroItems = portfolio.items.filter((item) => - new BigNumber(item.uiAmount).isGreaterThan(0), - ); - - if (nonZeroItems.length === 0) { - output += 'No tokens found with non-zero balance\n'; - } else { - for (const item of nonZeroItems) { - const valueUsd = new BigNumber(item.valueUsd).toFixed(2); - output += `${item.name} (${item.symbol}): ${new BigNumber(item.uiAmount).toFixed( - 6, - )} ($${valueUsd} | ${item.valueSol} SOL)\n`; - } - } - - output += '\nMarket Prices:\n'; - output += `SOL: $${new BigNumber(prices.solana.usd).toFixed(2)}\n`; - output += `BTC: $${new BigNumber(prices.bitcoin.usd).toFixed(2)}\n`; - output += `ETH: $${new BigNumber(prices.ethereum.usd).toFixed(2)}\n`; - - return output; - } - - async getFormattedPortfolio(runtime): Promise { - try { - const [portfolio, prices] = await Promise.all([ - this.fetchPortfolioValue(runtime), - this.fetchPrices(runtime), - ]); - - return this.formatPortfolio(runtime, portfolio, prices); - } catch (error) { - logger.error('Error generating portfolio report:', error); - return 'Unable to fetch wallet information. Please try again later.'; - } - } -} - -const walletProvider: Provider = { - get: async (runtime: IAgentRuntime, _message: Memory, _state?: State): Promise => { - const agentId = runtime.agentId; - const teeMode = runtime.getSetting('TEE_MODE'); - const deriveKeyProvider = new DeriveKeyProvider(teeMode); - try { - // Validate wallet configuration - if (!runtime.getSetting('WALLET_SECRET_SALT')) { - logger.error('Wallet secret salt is not configured in settings'); - return ''; - } - - let publicKey: PublicKey; - try { - const derivedKeyPair: { - keypair: Keypair; - attestation: RemoteAttestationQuote; - } = await deriveKeyProvider.deriveEd25519Keypair( - runtime.getSetting('WALLET_SECRET_SALT'), - 'solana', - agentId, - ); - publicKey = derivedKeyPair.keypair.publicKey; - logger.log('Wallet Public Key: ', publicKey.toBase58()); - } catch (error) { - logger.error('Error creating PublicKey:', error); - return ''; - } - - const connection = new Connection(PROVIDER_CONFIG.DEFAULT_RPC); - const provider = new WalletProvider(connection, publicKey); - - const porfolio = await provider.getFormattedPortfolio(runtime); - return porfolio; - } catch (error) { - logger.error('Error in wallet provider:', error.message); - return `Failed to fetch wallet information: ${ - error instanceof Error ? error.message : 'Unknown error' - }`; - } - }, -}; - -// Module exports -export { walletProvider };