diff --git a/.gitignore b/.gitignore index ab0378b..db9e3f2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ node_modules .turbo dist +.env +.elizadb +.elizadb-test diff --git a/README.md b/README.md index 5d16b65..e7b8929 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# @elizaos-plugins/plugin-solana +# @elizaos/plugin-solana Core Solana blockchain plugin for Eliza OS that provides essential services and actions for token operations, trading, and DeFi integrations. @@ -55,7 +55,7 @@ The Solana plugin serves as a foundational component of Eliza OS, bridging Solan ## Installation ```bash -npm install @elizaos-plugins/plugin-solana +npm install @elizaos/plugin-solana ``` ## Configuration @@ -80,7 +80,7 @@ const solanaEnvSchema = { ### Basic Setup ```typescript -import { solanaPlugin } from '@elizaos-plugins/plugin-solana'; +import { solanaPlugin } from '@elizaos/plugin-solana'; // Initialize the plugin const runtime = await initializeRuntime({ diff --git a/build.ts b/build.ts new file mode 100644 index 0000000..94fbe72 --- /dev/null +++ b/build.ts @@ -0,0 +1,38 @@ +#!/usr/bin/env bun +/** + * Build script for @elizaos/plugin-solana using standardized build utilities + */ + +import { createBuildRunner } from '../../build-utils'; + +// Create and run the standardized build runner +const run = createBuildRunner({ + packageName: '@elizaos/plugin-solana', + buildOptions: { + entrypoints: ['src/index.ts'], + outdir: 'dist', + target: 'bun', // instead of 'node' + format: 'esm', + strict: true, + clean: true, + external: [ + // keep third-party externals + 'dotenv','@reflink/reflink','@node-llama-cpp', + 'agentkeepalive','safe-buffer','base-x','bs58','borsh', + '@solana/buffer-layout','querystring', + '@elizaos/core','@elizaos/service-interfaces','zod', + 'node:stream/web', // optional if you reference it; bun-types has it + 'fs','path','https','http','stream','buffer' + ], + sourcemap: true, + minify: false, + generateDts: false, + }, +}); + + +// Execute the build +run().catch((error) => { + //console.error('Build script error:', error); + process.exit(1); +}); diff --git a/env.example b/env.example new file mode 100644 index 0000000..e6fe4ea --- /dev/null +++ b/env.example @@ -0,0 +1,32 @@ +# Solana Plugin Environment Variables +# Copy this file to .env and fill in your actual values + +# Solana RPC URL (required) +# You can use public endpoints or get a dedicated one from providers like Helius, QuickNode, etc. +SOLANA_RPC_URL=https://api.mainnet-beta.solana.com + +# Birdeye API Key (required for price data and portfolio valuation) +# Get your free API key from https://birdeye.so/ +BIRDEYE_API_KEY=YourBirdeyeApiKeyHere + +# Optional: Helius API Key (for enhanced RPC features) +# Get your API key from https://helius.xyz/ +HELIUS_API_KEY=YourHeliusApiKeyHere + +# Solana Private Key (base58 encoded) +# Required for sending transactions (like transfers, swaps) and for account subscriptions. +# If not provided, the plugin will operate in a read-only mode for basic portfolio viewing (if public key was previously used/cached or if functionality is adapted to not require it for reads). +# WARNING: Keep this secret and never commit to version control if you put a real one here for testing. +# For development, if this is not set, a new key may be generated by some test environments. +SOLANA_PRIVATE_KEY=YourPrivateKeyHere_Base58Encoded_e_g_5jZ2p... + +# Optional: Slippage tolerance for swaps (default: 0.5%) +SLIPPAGE=0.5 + +# Optional: SOL token address (usually the wrapped SOL address for DeFi interactions) +SOL_ADDRESS=So11111111111111111111111111111111111111112 + +# Example values for testing (replace with your actual values if needed): +# SOLANA_RPC_URL=https://api.mainnet-beta.solana.com +# BIRDEYE_API_KEY=your-birdeye-api-key-here +# SOLANA_PRIVATE_KEY= # Fill with a testnet/devnet private key for transaction tests diff --git a/package.json b/package.json index 73f268a..15f1f72 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "@elizaos/plugin-solana", "version": "1.2.0", + "engines": { "bun": ">=1.1.0" }, "type": "module", "main": "dist/index.js", "module": "dist/index.js", - "types": "dist/index.d.ts", "repository": { "type": "git", "url": "git+https://github.com/elizaos-plugins/plugin-solana.git" @@ -21,22 +21,8 @@ "files": [ "dist" ], - "dependencies": { - "@elizaos/core": "^1.0.0", - "@solana/spl-token": "0.4.13", - "@solana/web3.js": "^1.98.0", - "bignumber.js": "9.3.0", - "bs58": "6.0.0", - "tweetnacl": "^1.0.3", - "vitest": "3.1.3" - }, - "devDependencies": { - "prettier": "3.5.3", - "tsup": "8.4.0", - "typescript": "^5.8.2" - }, "scripts": { - "build": "tsup", + "build": "bun run build.ts", "dev": "tsup --watch", "test": "vitest run", "lint": "prettier --write ./src", @@ -44,10 +30,32 @@ "format": "prettier --write ./src", "format:check": "prettier --check ./src" }, + "dependencies": { + "@solana/spl-token": "0.4.14", + "@solana/spl-token-metadata": "^0.1.6", + "@solana/web3.js": "^1.98.0", + "bignumber.js": "9.3.0", + "bs58": "6.0.0", + "tweetnacl": "^1.0.3" + }, + "devDependencies": { + "@elizaos/core": "latest", + "@elizaos/service-interfaces": "latest", + "bun-types": "^1.3.0", + "prettier": "3.5.3", + "tsup": "8.5.0", + "typescript": "^5.9.3", + "vitest": "3.1.3" + }, "peerDependencies": { + "@elizaos/core": "latest", + "@elizaos/service-interfaces": "latest", "form-data": "4.0.2", "whatwg-url": "7.1.0" }, + "tsup": { + "external": ["@elizaos/core", "@elizaos/service-interfaces"] + }, "gitHead": "646c632924826e2b75c2304a75ee56959fe4a460", "agentConfig": { "pluginType": "elizaos:plugin:1.0.0", diff --git a/src/actions/swap.ts b/src/actions/swap.ts index 76b30f0..f69d1e4 100644 --- a/src/actions/swap.ts +++ b/src/actions/swap.ts @@ -1,6 +1,7 @@ import { type Action, type ActionExample, + type ActionResult, type HandlerCallback, type IAgentRuntime, type Memory, @@ -72,11 +73,11 @@ async function swapToken( const amountBN = new BigNumber(amount); const adjustedAmount = amountBN.multipliedBy(new BigNumber(10).pow(decimals)); - logger.log('Fetching quote with params:', { + logger.log({ inputMint: inputTokenCA, outputMint: outputTokenCA, amount: adjustedAmount, - }); + }, 'Fetching quote with params:'); const quoteResponse = await fetch( `https://quote-api.jup.ag/v6/quote?inputMint=${inputTokenCA}&outputMint=${outputTokenCA}&amount=${adjustedAmount}&dynamicSlippage=true&maxAccounts=64` @@ -86,7 +87,7 @@ async function swapToken( }; if (!quoteData || quoteData.error) { - logger.error('Quote error:', quoteData); + logger.error({ quoteData },'Quote error'); throw new Error(`Failed to get quote: ${quoteData?.error || 'Unknown error'}`); } @@ -113,7 +114,7 @@ async function swapToken( }; if (!swapData || !swapData.swapTransaction) { - logger.error('Swap error:', swapData); + logger.error({ swapData }, 'Swap error'); throw new Error( `Failed to get swap transaction: ${swapData?.error || 'No swap transaction returned'}` ); @@ -121,7 +122,7 @@ async function swapToken( return swapData; } catch (error) { - logger.error('Error in swapToken:', error); + logger.error({ error }, 'Error in swapToken:'); throw error; } } @@ -155,7 +156,7 @@ async function getTokenFromWallet( return token ? token.address : null; } catch (error) { - logger.error('Error checking token in wallet:', error); + logger.error({ error }, 'Error checking token in wallet'); return null; } } @@ -250,7 +251,7 @@ export const executeSwap: Action = { state: State | undefined, _options: { [key: string]: unknown } | undefined, callback?: HandlerCallback - ): Promise => { + ): Promise => { state = await runtime.composeState(message, ['RECENT_MESSAGES']); try { @@ -293,7 +294,7 @@ export const executeSwap: Action = { (await getTokenFromWallet(runtime, response.inputTokenSymbol)) || undefined; if (!response.inputTokenCA) { callback?.({ text: 'Could not find the input token in your wallet' }); - return false; + return; } } @@ -304,13 +305,13 @@ export const executeSwap: Action = { callback?.({ text: 'Could not find the output token in your wallet', }); - return false; + return; } } if (!response.amount) { callback?.({ text: 'Please specify the amount you want to swap' }); - return false; + return; } const connection = new Connection( @@ -365,15 +366,15 @@ export const executeSwap: Action = { content: { success: true, txid }, }); - return true; + return; } catch (error) { if (error instanceof Error) { - logger.error('Error during token swap:', error); + logger.error({ error }, 'Error during token swap'); callback?.({ text: `Swap failed: ${error.message}`, content: { error: error.message }, }); - return false; + return; } throw error; } diff --git a/src/actions/transfer.ts b/src/actions/transfer.ts index f95f918..0a0115f 100644 --- a/src/actions/transfer.ts +++ b/src/actions/transfer.ts @@ -1,6 +1,7 @@ import { type Action, type ActionExample, + type ActionResult, type Content, type HandlerCallback, type IAgentRuntime, @@ -45,19 +46,18 @@ interface TransferContent extends Content { * @param {TransferContent} content - The content to be validated for transfer. * @returns {boolean} Returns true if the content is valid for transfer, and false otherwise. */ -function isTransferContent(content: TransferContent): boolean { - logger.log('Content for transfer', content); +function isTransferContent(content: unknown): content is TransferContent { + if (!content || typeof content !== 'object') return false; + const c = content as Partial>; // Base validation - if (!content.recipient || typeof content.recipient !== 'string' || !content.amount) { - return false; - } + if (typeof c.recipient !== 'string') return false; + if (!(typeof c.amount === 'string' || typeof c.amount === 'number')) return false; - if (content.tokenAddress === 'null') { - content.tokenAddress = null; - } + // Don’t mutate here; just validate. Treat 'null' as valid string; normalize later. + if (c.tokenAddress !== null && typeof c.tokenAddress !== 'string') return false; - return typeof content.amount === 'string' || typeof content.amount === 'number'; + return true; } /** @@ -134,8 +134,8 @@ export default { 'PAY_TOKENS_SOLANA', 'PAY_SOLANA', ], - validate: async (_runtime: IAgentRuntime, message: Memory) => { - logger.log('Validating transfer from entity:', message.entityId); + validate: async (runtime: IAgentRuntime, message: Memory) => { + runtime.logger.log('Validating transfer from entity:', message.entityId); return true; }, description: 'Transfer SOL or SPL tokens to another address on Solana.', @@ -145,7 +145,7 @@ export default { state: State, _options: { [key: string]: unknown }, callback?: HandlerCallback - ): Promise => { + ): Promise => { logger.log('Starting TRANSFER handler...'); const transferPrompt = composePromptFromState({ @@ -159,6 +159,16 @@ export default { const content = parseJSONObjectFromText(result); + if (!content) { + if (callback) { + callback({ + text: 'Need a valid recipient address and amount to transfer.', + content: { error: 'Invalid transfer content' }, + }); + } + return; + } + if (!isTransferContent(content)) { if (callback) { callback({ @@ -166,11 +176,20 @@ export default { content: { error: 'Invalid transfer content' }, }); } - return false; + return; } try { const { keypair: senderKeypair } = await getWalletKey(runtime, true); + if (!senderKeypair) { + if (callback) { + callback({ + text: 'Need a valid agent address.', + content: { error: 'Invalid transfer content' }, + }); + } + return; + } const connection = new Connection( runtime.getSetting('SOLANA_RPC_URL') || 'https://api.mainnet-beta.solana.com' ); @@ -270,16 +289,22 @@ export default { } } - return true; + return; } catch (error) { - logger.error('Error during transfer:', error); + logger.error({ error },'Error during transfer'); if (callback) { + const message = + error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : JSON.stringify(error); callback({ - text: `Transfer failed: ${error.message}`, - content: { error: error.message }, + text: `Transfer failed: ${message}`, + content: { error: message }, }); } - return false; + return; } }, diff --git a/src/environment.ts b/src/environment.ts index c40b484..8222f0d 100644 --- a/src/environment.ts +++ b/src/environment.ts @@ -24,26 +24,26 @@ import { z } from 'zod'; */ export const solanaEnvSchema = z .object({ - WALLET_SECRET_SALT: z.string().optional(), + SOLANA_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'), + SOLANA_PRIVATE_KEY: z.string().min(1).optional(), + SOLANA_PUBLIC_KEY: z.string().min(1, 'Solana public key is required'), }), z.object({ - WALLET_SECRET_SALT: z.string().min(1, 'Wallet secret salt is required'), + SOLANA_SECRET_SALT: z.string().min(1).optional(), }), ]) ) .and( z.object({ - SOL_ADDRESS: z.string().min(1, 'SOL address is required'), + //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'), + //HELIUS_API_KEY: z.string().min(1, 'Helius API key is required'), + //BIRDEYE_API_KEY: z.string().min(1, 'Birdeye API key is required'), }) ); @@ -63,24 +63,21 @@ 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, + SOLANA_SECRET_SALT: runtime.getSetting('SOLANA_SECRET_SALT'),// wtf is this? + //SOL_ADDRESS: runtime.getSetting('SOL_ADDRESS'), + SLIPPAGE: runtime.getSetting('SLIPPAGE'), + SOLANA_RPC_URL: runtime.getSetting('SOLANA_RPC_URL'), + //HELIUS_API_KEY: runtime.getSetting('HELIUS_API_KEY'), + //BIRDEYE_API_KEY: runtime.getSetting('BIRDEYE_API_KEY'), + // optional: + SOLANA_PRIVATE_KEY: runtime.getSetting('SOLANA_PRIVATE_KEY'), + SOLANA_PUBLIC_KEY: runtime.getSetting('SOLANA_PUBLIC_KEY') }; return solanaEnvSchema.parse(config); } catch (error) { if (error instanceof z.ZodError) { - const errorMessages = error.errors + const errorMessages = error.issues .map((err) => `${err.path.join('.')}: ${err.message}`) .join('\n'); throw new Error(`Solana configuration validation failed:\n${errorMessages}`); diff --git a/src/index.ts b/src/index.ts index 1374470..768195a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,52 +1,59 @@ -import type { IAgentRuntime, Plugin } from '@elizaos/core'; -import { logger } from '@elizaos/core'; +import type { IAgentRuntime, Plugin, ServiceTypeName } from '@elizaos/core'; +import { parseBooleanFromText } from '@elizaos/core'; + +// actions import { executeSwap } from './actions/swap'; import transferToken from './actions/transfer'; -import { SOLANA_SERVICE_NAME } from './constants'; + +// providers import { walletProvider } from './providers/wallet'; -import { SolanaService } from './service'; + +// service +import { SolanaService, SolanaWalletService } from './service'; + +import { SOLANA_SERVICE_NAME } from './constants'; export const solanaPlugin: Plugin = { name: SOLANA_SERVICE_NAME, - description: 'Solana Plugin for Eliza', - actions: [transferToken, executeSwap], - evaluators: [], - providers: [walletProvider], - services: [SolanaService], + description: 'Solana blockchain plugin', + services: [SolanaService, SolanaWalletService], init: async (_, runtime: IAgentRuntime) => { - logger.debug('solana init'); - - new Promise(async (resolve) => { - resolve(); - const asking = 'solana'; - const serviceType = 'TRADER_CHAIN'; - const maxRetries = 10; - let retries = 0; - - let traderChainService = runtime.getService(serviceType) as any; - while (!traderChainService && retries < maxRetries) { - logger.debug(`${asking} waiting for ${serviceType} service... (${retries + 1}/${maxRetries})`); - traderChainService = runtime.getService(serviceType) as any; - if (!traderChainService) { - await new Promise((waitResolve) => setTimeout(waitResolve, 1000)); - retries++; - } else { - logger.debug(`${asking} Acquired ${serviceType} service...`); - } - } - - if (traderChainService) { - const me = { - name: 'Solana services', - chain: 'solana', - service: SOLANA_SERVICE_NAME, - }; - traderChainService.registerChain(me); - logger.debug('solana init done'); - } else { - logger.debug('solana init done (standalone mode - TRADER_CHAIN service not available)'); - } + + // Validation + if (!runtime.getSetting('SOLANA_RPC_URL')) { + runtime.logger.log('no SOLANA_RPC_URL, skipping plugin-solana init') + return + } + + const noActions = parseBooleanFromText(runtime.getSetting("SOLANA_NO_ACTIONS")); + if (!noActions) { + runtime.registerAction(transferToken) + runtime.registerAction(executeSwap) + } else { + runtime.logger.log('SOLANA_NO_ACTIONS is set, skipping solana actions') + } + + runtime.registerProvider(walletProvider) + + // extensions + runtime.getServiceLoadPromise('INTEL_CHAIN' as ServiceTypeName).then( () => { + //runtime.logger.log('solana INTEL_CHAIN LOADED') + const traderChainService = runtime.getService('INTEL_CHAIN') as any; + const me = { + name: 'Solana services', + chain: 'solana', + service: SOLANA_SERVICE_NAME, + }; + traderChainService.registerChain(me); + }).catch(error => { + runtime.logger.error({ error },'Failed to register with INTEL_CHAIN'); }); + }, }; export default solanaPlugin; + +// Export additional items for use by other plugins +export { SOLANA_SERVICE_NAME } from './constants'; +export { SolanaService, SolanaWalletService } from './service'; +export type { SolanaService as ISolanaService } from './service'; \ No newline at end of file diff --git a/src/keypairUtils.ts b/src/keypairUtils.ts index fa93ae2..5c93041 100644 --- a/src/keypairUtils.ts +++ b/src/keypairUtils.ts @@ -44,14 +44,14 @@ export async function getWalletKey( const secretKey = bs58.decode(privateKeyString); return { keypair: Keypair.fromSecretKey(secretKey) }; } catch (e) { - logger.log('Error decoding base58 private key:', e); + logger.log({ e }, 'Error decoding base58 private key:'); try { // Then try base64 logger.log('Try decoding base64 instead'); const secretKey = Uint8Array.from(Buffer.from(privateKeyString, 'base64')); return { keypair: Keypair.fromSecretKey(secretKey) }; } catch (e2) { - logger.error('Error decoding private key: ', e2); + logger.error({ e: e2 }, 'Error decoding private key: '); throw new Error('Invalid private key format'); } } diff --git a/src/service.ts b/src/service.ts index 6f904c1..abe4a91 100644 --- a/src/service.ts +++ b/src/service.ts @@ -1,4 +1,5 @@ -import { type IAgentRuntime, Service, logger } from '@elizaos/core'; +import { type IAgentRuntime, ServiceTypeName, Service, ServiceType, logger } from '@elizaos/core'; +import { IWalletService, WalletPortfolio as siWalletPortfolio } from '@elizaos/service-interfaces'; import { Connection, Keypair, @@ -6,13 +7,20 @@ import { VersionedTransaction, SendTransactionError, LAMPORTS_PER_SOL, - AccountInfo + type AccountInfo, + SystemProgram, + Transaction, + TransactionMessage, + type RpcResponseAndContext, + type ParsedAccountData, } from '@solana/web3.js'; import { MintLayout, getMint, TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, unpackAccount, getAssociatedTokenAddressSync, ExtensionType, getExtensionData, getExtensionTypes, - unpackMint, + unpackMint, AccountLayout } from "@solana/spl-token"; +// parses the raw Token-2022 metadata struct +import { unpack as unpackToken2022Metadata } from '@solana/spl-token-metadata'; import BigNumber from 'bignumber.js'; import { SOLANA_SERVICE_NAME, SOLANA_WALLET_DATA_CACHE_KEY } from './constants'; import { getWalletKey, KeypairResult } from './keypairUtils'; @@ -32,6 +40,27 @@ const PROVIDER_CONFIG = { }, }; +export type MintBalance = { + amount: string; + decimals: number; + uiAmount: number; +}; + +type KeyedParsedTokenAccount = { + pubkey: PublicKey; + account: AccountInfo; +}; + +type ParsedTokenAccountsResponse = Awaited>; +/* +type ParsedTokenAccountsResponse = Promise; + }> + >> +*/ + const METADATA_PROGRAM_ID = new PublicKey( 'metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s' // Metaplex Token Metadata Program ID ); @@ -57,21 +86,218 @@ async function setCacheExp(runtime: IAgentRuntime, key: string, val: any, ttlInS }); } +export interface ISolanaPluginServiceAPI extends Service { + executeSwap: ( + wallets: Array<{ keypair: any; amount: number }>, signal: any + ) => Promise>; + /* + executeSwap: (params: { + inputMint: string; + outputMint: string; + amount: string; // Amount in base units of input token + slippageBps: number; + payerAddress: string; // Public key of the payer (must match service's configured wallet) + priorityFeeMicroLamports?: number; + }) => Promise<{ + success: boolean; + signature?: string; + error?: string; + outAmount?: string; + inAmount?: string; + swapUsdValue?: string; + }>; + */ + //getSolBalance: (publicKey: string) => Promise; // Returns SOL balance (not lamports) + /* + getTokenBalance: ( + publicKey: string, + mintAddress: string + ) => Promise<{ amount: string; decimals: number; uiAmount: number } | null>; + */ + getPublicKey: () => PublicKey | null; // Returns base58 public key +} + +// split out off to keep this wrapper simple, so we can move it out of here +// it's a single unit focused on one thing (reduce scope of main service) +export class SolanaWalletService extends IWalletService { + private solanaService: SolanaService; + + constructor(runtime?: IAgentRuntime) { + if (!runtime) throw new Error('runtime is required for solana service') + super(runtime); + // start / stop? + // link to main service... + this.solanaService = runtime.getService('chain_solana') as SolanaService; + if (!this.solanaService) throw new Error('Solana Service is required for Solana Wallet Service') + } + + /** + * Retrieves the entire portfolio of assets held by the wallet. + * @param owner - Optional: The specific wallet address/owner to query. + * @returns A promise that resolves to the wallet's portfolio. + */ + public async getPortfolio(owner?: string): Promise { + if (owner && owner !== this.solanaService.getPublicKey()?.toBase58()) { + throw new Error( + `This SolanaService instance can only get the portfolio for its configured wallet: ${this.solanaService.getPublicKey()?.toBase58()}` + ); + } + const wp: WalletPortfolio = await this.solanaService.updateWalletData(true) + const out: siWalletPortfolio = { + totalValueUsd: parseFloat(wp.totalUsd), + assets: wp.items.map(i => ({ + address: i.address, + symbol: i.symbol, + balance: Number(i.uiAmount ?? 0).toString(), + decimals: i.decimals, + valueUsd: Number(i.valueUsd ?? 0), + })), + } + return out; + } + + /** + * Retrieves the balance of a specific asset in the wallet. + * @param assetAddress - The mint address or native identifier ('SOL') of the asset. + * @param owner - Optional: The specific wallet address/owner to query. + * @returns A promise that resolves to the user-friendly (decimal-adjusted) balance of the asset held. + */ + public async getBalance(assetAddress: string, owner?: string): Promise { + const ownerAddress: string | undefined = owner || (this.solanaService.getPublicKey()?.toBase58()); + if (!ownerAddress) { + return -1 + } + if ( + assetAddress.toUpperCase() === 'SOL' || + assetAddress === PROVIDER_CONFIG.TOKEN_ADDRESSES.SOL + ) { + //return this.getSolBalance(ownerAddress); + const balances = await this.solanaService.getBalancesByAddrs([ownerAddress]) + const balance = balances[ownerAddress] ?? 0 + return balance + } + //const tokenBalance = await this.getTokenBalance(ownerAddress, assetAddress); + //return tokenBalance?.uiAmount || 0; + const tokensBalances: Record = await this.solanaService.getTokenAccountsByKeypairs([ownerAddress]) + const heldTokens = tokensBalances[ownerAddress] || [] + for(const t of heldTokens) { + //const decimals = t.account.data.parsed.info.tokenAmount.decimals; + //const balance = Number(amountRaw) / (10 ** decimals); + //const ca = new PublicKey(t.account.data.parsed.info.mint); + if (t.account.data.parsed.info.mint === assetAddress) { + return t.account.data.parsed.info.tokenAmount.uiAmount; + } + } + this.runtime.logger.log('could not find', assetAddress, 'in', heldTokens) + return -1 + } + + /** + * Transfers SOL from a specified keypair to a public key. + * The service's own wallet is used to pay transaction fees. + * @param {Keypair} from - The keypair of the account to send SOL from. + * @param {PublicKey} to - The public key of the account to send SOL to. + * @param {number} lamports - The amount of SOL to send, in lamports. + * @returns {Promise} The transaction signature. + * @throws {Error} If the transfer fails. + */ + public async transferSol(from: Keypair, to: PublicKey, lamports: number): Promise { + try { + const payerKey = this.solanaService.getPublicKey() + if (!payerKey || payerKey === null) { + throw new Error( + 'SolanaService is not initialized with a fee payer key, cannot send transaction.' + ); + } + const connection = this.solanaService.getConnection() + + const transaction = new TransactionMessage({ + payerKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [ + SystemProgram.transfer({ + fromPubkey: from.publicKey, + toPubkey: to, + lamports: lamports, + }), + ], + }).compileToV0Message(); + + const versionedTransaction = new VersionedTransaction(transaction); + + const serviceKeypair = await this.solanaService.getWalletKeypair() + versionedTransaction.sign([from, serviceKeypair]); + + const signature = await connection.sendTransaction(versionedTransaction, { + skipPreflight: false, + }); + + const confirmation = await connection.confirmTransaction(signature, 'confirmed'); + if (confirmation.value.err) { + throw new Error( + `Transaction confirmation failed: ${JSON.stringify(confirmation.value.err)}` + ); + } + + return signature; + } catch (error: unknown) { + this.runtime.logger.error({ error },'SolanaService: transferSol failed'); + throw error; + } + } + + /** + * Starts the Solana wallet service with the given agent runtime. + * + * @param {IAgentRuntime} runtime - The agent runtime to use for the Solana service. + * @returns {Promise} The initialized Solana service. + */ + static async start(runtime: IAgentRuntime): Promise { + runtime.logger.log(`SolanaWalletService start for ${runtime.character.name}`); + + const solanaWalletService = new SolanaWalletService(runtime); + return solanaWalletService; + } + + /** + * Stops the Solana wallet service. + * + * @param {IAgentRuntime} runtime - The agent runtime. + * @returns {Promise} - A promise that resolves once the Solana service has stopped. + */ + static async stop(runtime: IAgentRuntime): Promise { + const client = runtime.getService(ServiceType.WALLET) as SolanaService | null; + if (!client) { + logger.error('SolanaWalletService not found during static stop'); + return; + } + await client.stop(); + } + + /** + * @returns {Promise} A Promise that resolves when the update interval is stopped. + */ + async stop(): Promise { + } +} + /** * Service class for interacting with the Solana blockchain and accessing wallet data. * @extends Service */ +// implements ISolanaPluginServiceAPI export class SolanaService extends Service { - static serviceType: string = SOLANA_SERVICE_NAME; - capabilityDescription = - 'The agent is able to interact with the Solana blockchain, and has access to the wallet data'; + static override readonly serviceType: string = SOLANA_SERVICE_NAME; + public readonly capabilityDescription = + ('The agent is able to interact with the Solana blockchain, and has access to the wallet data' as unknown as typeof IWalletService.prototype.capabilityDescription); - private updateInterval: NodeJS.Timer | null = null; private lastUpdate = 0; private readonly UPDATE_INTERVAL = 2 * 60_000; // 2 minutes private connection: Connection; private publicKey: PublicKey | null = null; + private keypair: Keypair | null = null; private exchangeRegistry: Record = {}; + // probably should be an array of numbers? private subscriptions: Map = new Map(); jupiterService: any; @@ -91,40 +317,33 @@ export class SolanaService extends Service { * Constructor for creating an instance of the class. * @param {IAgentRuntime} runtime - The runtime object that provides access to agent-specific functionality. */ - constructor(protected runtime: IAgentRuntime) { - super(); + constructor(runtime?: IAgentRuntime) { + if (!runtime) throw new Error('runtime is required for solana service') + super(runtime); this.exchangeRegistry = {}; - const connection = new Connection( + this.connection = new Connection( runtime.getSetting('SOLANA_RPC_URL') || PROVIDER_CONFIG.DEFAULT_RPC ); - this.connection = connection; - - const asking = 'Solana service' - const serviceType = 'JUPITER_SERVICE' - - const getJup = async () => { - const maxRetries = 10; - let retries = 0; - - this.jupiterService = this.runtime.getService(serviceType) as any; - while (!this.jupiterService && retries < maxRetries) { - runtime.logger.debug(`${asking} waiting for ${serviceType} service... (${retries + 1}/${maxRetries})`); - this.jupiterService = this.runtime.getService(serviceType) as any; - if (!this.jupiterService) { - await new Promise((waitResolve) => setTimeout(waitResolve, 1000)); - retries++; - } else { - runtime.logger.debug(`${asking} Acquired ${serviceType} service...`); - } - } - - if (!this.jupiterService) { - runtime.logger.info(`${asking} - JUPITER_SERVICE not available (swap functionality will be limited)`); - } - } - getJup() // no wait + // jupiter support detection + // shouldn't even be here... + runtime.getServiceLoadPromise('JUPITER_SERVICE' as ServiceTypeName).then(async s => { + // now we have jupiter lets register our services + this.jupiterService = runtime.getService('JUPITER_SERVICE' as ServiceTypeName) as any; + }) + + getWalletKey(runtime, true) + .then(({ keypair }) => { + if (keypair) { + this.keypair = keypair + } + }).catch(e => { + // no private key + // not the end of the world, just somethings should be disabled... + runtime.logger.log('no useable solana private key') + }) // Initialize publicKey using getWalletKey + // FIXME: promise for this to be ready? getWalletKey(runtime, false) .then(({ publicKey }) => { if (!publicKey) { @@ -135,7 +354,8 @@ export class SolanaService extends Service { // get initial read this.updateWalletData(); // only need to update wallet if it changes - this.subscribeToAccount(this.publicKey.toBase58(), async (accountAddress, accountInfo, context) => { + // FIXME store this subscriptions somewhere... + this.subscribeToAccount(this.publicKey.toBase58(), async (accountAddress: string, accountInfo: unknown, context: unknown) => { runtime.logger.log('Updating wallet data'); await this.updateWalletData(); // non-forced (respect: UPDATE_INTERVAL) }).catch((error) => { @@ -164,7 +384,7 @@ export class SolanaService extends Service { */ async registerExchange(provider: any) { const id = Object.values(this.exchangeRegistry).length + 1; - logger.log(`Registered ${provider.name} as Solana provider #${id}`); + this.runtime.logger.success(`Registered ${provider.name} as Solana provider #${id}`); this.exchangeRegistry[id] = provider; return id; } @@ -198,6 +418,7 @@ export class SolanaService extends Service { return await response.json(); } catch (error) { logger.error(`Attempt ${i + 1} failed: ${error}`); + logger.error({ error }, `Attempt ${i + 1} failed`); lastError = error as Error; if (i < PROVIDER_CONFIG.MAX_RETRIES - 1) { await new Promise((resolve) => setTimeout(resolve, PROVIDER_CONFIG.RETRY_DELAY * 2 ** i)); @@ -220,8 +441,8 @@ export class SolanaService extends Service { return results; } - verifySolanaSignature({ - message, signatureBase64, publicKeyBase58 + verifySignature({ + publicKeyBase58, message, signatureBase64 }: { message: string; signatureBase64: string; publicKeyBase58: string; }): boolean { @@ -232,11 +453,22 @@ export class SolanaService extends Service { return nacl.sign.detached.verify(messageUint8, signature, publicKeyBytes); } + // Solana should be here, it's already in the class/service name + // deprecate + verifySolanaSignature({ + message, signatureBase64, publicKeyBase58 + }: { + message: string; signatureBase64: string; publicKeyBase58: string; + }): boolean { + this.runtime.logger.warn('verifySolanaSignature is deprecated, use verifySignature') + return this.verifySignature({ message, signatureBase64, publicKeyBase58 }) + } + // // MARK: Addresses // - public isValidSolanaAddress(address: string, onCurveOnly = false): boolean { + public isValidAddress(address: string, onCurveOnly = false): boolean { try { const pubkey = new PublicKey(address); if (onCurveOnly) { @@ -248,6 +480,13 @@ export class SolanaService extends Service { } } + // Solana should be here, it's already in the class/service name + // deprecate + public isValidSolanaAddress(address: string, onCurveOnly = false): boolean { + this.runtime.logger.warn('isValidSolanaAddress is deprecated, use isValidAddress') + return this.isValidAddress(address, onCurveOnly) + } + /** * Validates a Solana address. * @param {string | undefined} address - The address to validate. @@ -258,7 +497,7 @@ export class SolanaService extends Service { try { // Handle Solana addresses if (!/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(address)) { - logger.warn(`Invalid Solana address format: ${address}`); + this.runtime.logger.warn(`Invalid Solana address format: ${address}`); return false; } @@ -267,7 +506,8 @@ export class SolanaService extends Service { //logger.log(`Solana address validation: ${address}`, { isValid }); return isValid; } catch (error) { - logger.error(`Address validation error: ${address} - ${error}`); + //logger.error(`Address validation error: ${address} - ${error}`); + this.runtime.logger.error({ error }, `Address validation error: ${address}`); return false; } } @@ -276,52 +516,33 @@ export class SolanaService extends Service { private static readonly TOKEN_ACCOUNT_DATA_LENGTH = 165; private static readonly TOKEN_MINT_DATA_LENGTH = 82; - // could use batchGetMultipleAccountsInfo to get multiple - // FIXME: getAddressesTypes(addresses: string[]) + // deprecate async getAddressType(address: string): Promise { - let dataLength = -1 - try { - const key = 'solana_' + address + '_addressType' - const check = await this.runtime.getCache(key) - if (check) { - console.log('getAddressType - HIT') - return check - } - - const pubkey = new PublicKey(address); - console.log('getAddressType - getAccountInfo') - const accountInfo = await this.connection.getAccountInfo(pubkey); - - if (!accountInfo) { - return 'Account does not exist'; - } - - //console.log('accountInfo', accountInfo) - - dataLength = accountInfo.data.length; - - if (dataLength === 0) { - await this.runtime.setCache(key, 'Wallet') - return 'Wallet'; - } + const types = await this.getAddressesTypes([address]) + return types[address] + } - // SPL Token accounts are always 165 bytes - // User's balance of a specified token - if (dataLength === SolanaService.TOKEN_ACCOUNT_DATA_LENGTH) { - await this.runtime.setCache(key, 'Token Account') - return 'Token Account'; - } + async getAddressesTypes(addresses: string[]): Promise> { + const pubkeys = addresses.map(a => new PublicKey(a)); + const infos = await this.batchGetMultipleAccountsInfo(pubkeys, 'getAddressesTypes'); + + const resultList: string[] = addresses.map((addr, i) => { + const info = infos[i]; + if (!info) return 'Account does not exist'; + const dataLength = info.data.length; + if (dataLength === 0) return 'Wallet'; + if (dataLength === SolanaService.TOKEN_ACCOUNT_DATA_LENGTH) return 'Token Account'; + if (dataLength === SolanaService.TOKEN_MINT_DATA_LENGTH) return 'Token'; + return `Unknown (Data length: ${dataLength})`; + }); - // Token mint account - if (dataLength === SolanaService.TOKEN_MINT_DATA_LENGTH) { - await this.runtime.setCache(key, 'Token') - return 'Token'; - } - } catch(e) { - // likely bad address - console.error('solsrv:getAddressType - err', e) + const out: Record = {} + for(const i in addresses) { + const addr = addresses[i] + out[addr] = resultList[i] } - return `Unknown (Data length: ${dataLength})`; + + return out } /** @@ -417,6 +638,7 @@ export class SolanaService extends Service { // MARK: tokens // + // deprecate async getCirculatingSupply(mint: string) { //const mintPublicKey = new PublicKey(mint); @@ -455,16 +677,22 @@ export class SolanaService extends Service { return circulating; } + async getCirculatingSupplies(mints: string[]) { + // FIXME: use batchGetMultipleAccountsInfo? to efficiently check multiple + return Promise.all(mints.map(m => this.getCirculatingSupply(m))) + } + /** * Asynchronously fetches the prices of SOL, BTC, and ETH tokens. * Uses cache to store and retrieve prices if available. * @returns A Promise that resolves to an object containing the prices of SOL, BTC, and ETH tokens. */ private async fetchPrices(): Promise { - const cacheKey = 'prices'; + const cacheKey = 'prices_sol_btc_eth'; const cachedValue = await this.runtime.getCache(cacheKey); // if cachedValue is JSON, parse it + // FIXME: how long do we cache this for?!? if (cachedValue) { logger.log('Cache hit for fetchPrices'); return cachedValue; @@ -520,7 +748,8 @@ export class SolanaService extends Service { undefined, // optional commitment TOKEN_2022_PROGRAM_ID // specify the extensions token program ); - console.log('getDecimal - mintInfo2022', mintInfo) + // address, mintAuthority, supply, decimals, isInitialized, freezeAuthority, tlvData + //console.log('getDecimal - mintInfo2022', mintInfo) this.decimalsCache.set(key, mintInfo.decimals); return mintInfo.decimals; } @@ -534,6 +763,11 @@ export class SolanaService extends Service { } } + public async getDecimals(mints: string[]): Promise { + const mintPublicKeys = mints.map(a => new PublicKey(a)) + return Promise.all(mintPublicKeys.map(a => this.getDecimal(a))) + } + public async getMetadataAddress(mint: PublicKey): Promise { // not an rpc call const [metadataPDA] = await PublicKey.findProgramAddress( @@ -576,53 +810,156 @@ export class SolanaService extends Service { return symbol; } + // this is all local + private parseToken2022SymbolFromMintOrPtr = (mintData: Buffer): { symbol: string | null, ptr?: PublicKey } => { + // Try inline TokenMetadata extension first + const inline = getExtensionData(ExtensionType.TokenMetadata, mintData); + if (inline) { + try { + const md = unpackToken2022Metadata(inline); + const symbol = md?.symbol?.replace(/\0/g, '').trim() || null; + return { symbol }; + } catch { + // fall through to pointer + } + } + + // Try MetadataPointer extension + const ptrExt = getExtensionData(ExtensionType.MetadataPointer, mintData) as + | { authority: Uint8Array; metadataAddress: Uint8Array } + | null; + + if (ptrExt?.metadataAddress) { + return { symbol: null, ptr: new PublicKey(ptrExt.metadataAddress) }; + } + + return { symbol: null }; + }; + // cache me public async getTokensSymbols( mints: string[] ): Promise> { + console.log('getTokensSymbols'); const mintKeys: PublicKey[] = mints.map(k => new PublicKey(k)); + + // Phase 1: Metaplex PDAs (your existing flow) const metadataAddresses: PublicKey[] = await Promise.all( mintKeys.map(mk => this.getMetadataAddress(mk)) ); const accountInfos = await this.batchGetMultipleAccountsInfo( metadataAddresses, - 'getTokensSymbols' + 'getTokensSymbols/Metaplex' ); const out: Record = {}; + const needs2022: PublicKey[] = []; + mintKeys.forEach((token, i) => { - const accountInfo = accountInfos[i]; // raw AccountInfo | null + const accountInfo = accountInfos[i]; // AccountInfo | null if (!accountInfo || !accountInfo.data) { out[token.toBase58()] = null; + console.log('getTokensSymbols - adding', token.toBase58(), 'to token2022 list') + needs2022.push(token); return; } - const data = accountInfo.data; + try { + const data = accountInfo.data as Buffer; - // Skip the 1-byte key and 32+32+4+len name fields (you can parse these if needed) - let offset = 1 + 32 + 32; + // @metaplex-foundation/mpl-token-metadata + // Minimal Metaplex parse: + // key(1) + updateAuth(32) + mint(32) + let offset = 1 + 32 + 32; - // Name (length-prefixed string) - const nameLen = data.readUInt32LE(offset); - offset += 4 + nameLen; + // name + const nameLen = data.readUInt32LE(offset); + offset += 4 + nameLen; - // Symbol (length-prefixed string) - const symbolLen = data.readUInt32LE(offset); - offset += 4; - const symbol = - data - .slice(offset, offset + symbolLen) - .toString('utf8') - .replace(/\0/g, '') || null; - out[token.toBase58()] = symbol; + // symbol + const symbolLen = data.readUInt32LE(offset); + offset += 4; + const symbol = + data.slice(offset, offset + symbolLen).toString('utf8').replace(/\0/g, '').trim() || null; + + out[token.toBase58()] = symbol; + if (!symbol) needs2022.push(token); + } catch (e) { + console.log('Metaplex parse failed; will try Token-2022:', e); + out[token.toBase58()] = null; + needs2022.push(token); + } }); + // Phase 2: Batch fetch *mint accounts* via your batch helper, then parse Token-2022 TLV + if (needs2022.length) { + const mintInfos = await this.batchGetMultipleAccountsInfo( + needs2022, + 'getTokensSymbols/Token2022' + ); + + // First pass: parse inline metadata or collect pointer addresses + const ptrsToFetch: PublicKey[] = []; + const ptrOwnerByKey = new Map(); // mint base58 -> owner key (for logging) + + needs2022.forEach((mint, idx) => { + const info = mintInfos[idx] as AccountInfo | null; + if (!info || !info.data) { + console.log('getTokensSymbols - token2022 failed', mint.toBase58()); + return; + } + if (!info.owner.equals(TOKEN_2022_PROGRAM_ID)) { + console.log('getTokensSymbols - not a token2022', mint.toBase58()); + return; + } + + const { symbol, ptr } = this.parseToken2022SymbolFromMintOrPtr(info.data); + if (symbol) { + out[mint.toBase58()] = symbol; + } else if (ptr) { + ptrsToFetch.push(ptr); + ptrOwnerByKey.set(ptr.toBase58(), mint.toBase58()); + } else { + console.log('getTokensSymbols - no TokenMetadata or pointer', mint.toBase58()); + } + }); + + // Second pass: fetch and parse pointer accounts (batch) + if (ptrsToFetch.length) { + const pointerInfos = await this.batchGetMultipleAccountsInfo( + ptrsToFetch, + 'getTokensSymbols/Token2022Pointer' + ); + + ptrsToFetch.forEach((ptrPk, idx) => { + const pinfo = pointerInfos[idx] as AccountInfo | null; + const mintB58 = ptrOwnerByKey.get(ptrPk.toBase58())!; + if (!pinfo?.data) { + console.log('getTokensSymbols - pointer account missing', ptrPk.toBase58(), 'for mint', mintB58); + return; + } + try { + const md = unpackToken2022Metadata(pinfo.data); + const symbol = md?.symbol?.replace(/\0/g, '').trim() || null; + if (symbol) { + out[mintB58] = symbol; + } else { + console.log('getTokensSymbols - pointer metadata has no symbol', ptrPk.toBase58(), 'for mint', mintB58); + } + } catch (e) { + console.log('getTokensSymbols - failed to unpack pointer metadata', ptrPk.toBase58(), e); + } + }); + } + } + return out; } - + public async getSupply(CAs: string[]) { - const mintKeys: PublicKey[] = CAs.map((ca: string) => new PublicKey(ca)); + //console.log('getSupply CAs', CAs.length) + const mintKeys: PublicKey[] = CAs.map((ca: string) => new PublicKey(ca)); const mintInfos = await this.batchGetMultipleAccountsInfo(mintKeys, 'getSupply') const results = mintInfos.map((accountInfo, idx) => { @@ -630,21 +967,26 @@ export class SolanaService extends Service { return { address: CAs[idx], error: 'Account not found' }; } - const data = Uint8Array.from(Buffer.from(accountInfo.data)); - const mint = MintLayout.decode(data); - // mintAuthority, supply, decimals, isInitialized, freezeAuthorityOption, freezeAuthority - //console.log('mint', mint) + // accountInfo.data is a Node Buffer; make a Uint8Array *view* (no copy) + const buf = accountInfo.data as Buffer; + const u8 = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); - // Convert Buffer (little endian) to BigNumber - const supply = mint.supply; - const decimals = mint.decimals; + // MintLayout.decode accepts Uint8Array (and Buffer). Use u8 to avoid type fuss. + const mint = MintLayout.decode(u8); + + // Normalize types + const decimals: number = mint.decimals; + const supply: bigint = BigInt(mint.supply.toString()); // ensure bigint + + // bigint-safe 10^decimals + let denom = 1n; + for (let i = 0; i < decimals; i++) denom *= 10n; return { address: CAs[idx], - biSupply: supply, // or divide by 10**decimals if you want human-readable - // BigNumber is good for price for MCAP - human: new BigNumber((supply / BigInt(10 ** decimals)).toString()), - // maybe it should be a string... and they w/e use can cast it as such + biSupply: supply, // keep as bigint for exactness + // Human-readable (use BigNumber to avoid float issues for large values) + human: new BigNumber(supply.toString()).dividedBy(10 ** decimals), decimals, }; }); @@ -659,7 +1001,7 @@ export class SolanaService extends Service { return out } - public async parseTokenAccounts(heldTokens: any [], options = {}) { + public async parseTokenAccounts(heldTokens: any [], options: { notOlderThan?: number } = {}) { // decimalsCache means we don't need all I think // we need structure token cache // stil need them for symbol @@ -693,7 +1035,7 @@ export class SolanaService extends Service { let misses = 0 const fetchTokens = [] - const goodCache = {} + const goodCache: Record & { balanceUi: number }> = {} for(const i in heldTokens) { const t = heldTokens[i] if (cache[i]) { @@ -757,7 +1099,7 @@ export class SolanaService extends Service { const allMintKeys: PublicKey[] = Array.from(new Set( fetchTokens.map((t: any) => t.account.data.parsed.info.mint as string) )).map(s => new PublicKey(s)) - + // const mintInfos = await this.batchGetMultipleAccountsInfo(allMintKeys, "t22-mints") @@ -837,7 +1179,7 @@ export class SolanaService extends Service { // Parse the Token-2022 TokenMetadata TLV (just the Value slice) function parseToken2022MetadataTLV(ext: Buffer): { - isMutable: boolean; updateAuthority?: string; name: string; symbol: string; uri: string; + isMutable: boolean; updateAuthority?: string; mint: string; name: string; symbol: string; uri: string; additional?: unknown; } { const o = { off: 0 }; // 32B updateAuthority (all-zero = None) @@ -941,8 +1283,11 @@ export class SolanaService extends Service { } } else { // spl token - const data = info?.data; - const header = data.subarray(0, MintLayout.span); + const buf = info!.data as Buffer; + const u8 = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); + + // slice the header as a Uint8Array, not Buffer + const header = u8.subarray(0, MintLayout.span); const mintData = MintLayout.decode(header); //console.log('spl mintData', mintData) const uiSupply = formatSupplyUiAmount(mintData.supply, mintData.decimals) @@ -1105,26 +1450,26 @@ export class SolanaService extends Service { const t22Set = new Set(t22MintKeys.map(k => k.toBase58())); const results = heldTokens.map((t) => { - const mintStr = t.account.data.parsed.info.mint as string; - const mintKey = new PublicKey(mintStr); - const is2022 = t22Set.has(mintStr); + const mintStr: string = t.account.data.parsed.info.mint as string; + const mintKey: PublicKey = new PublicKey(mintStr); + const is2022: boolean = t22Set.has(mintStr); // decimals / balance (unchanged) const { amount: raw, decimals } = t.account.data.parsed.info.tokenAmount; - const balanceUi = Number(raw) / 10 ** decimals; + const balanceUi: number = Number(raw) / 10 ** decimals; // pick the right source for isMutable - const isMutable = + const isMutable: boolean | null = (is2022 && hasT22Meta.has(mintStr)) ? (t22IsMutable.get(mintStr) ?? null) : (mpIsMutable.get(mintStr) ?? null); - const symbol = + const symbol: string | null = (is2022 && hasT22Meta.has(mintStr)) ? (t22Symbols.get(mintStr) ?? null) : (mpSymbols.get(mintStr) ?? null); - let supply = mpSupply.get(mintStr) ?? null + let supply: string | number | null = mpSupply.get(mintStr) ?? null if (supply) supply = parseFloat(supply) return { @@ -1259,7 +1604,7 @@ export class SolanaService extends Service { (async () => { console.time('saveCache') for(const t of results) { - const copy = {...t} + const copy: any = {...t} delete copy.balanceUi delete copy.mint const key = 'solana_token_meta_' + t.mint @@ -1348,20 +1693,29 @@ export class SolanaService extends Service { * @returns {Promise} The wallet keypair * @throws {Error} If private key is not available */ - private async getWalletKeypair(): Promise { - const { keypair } = await getWalletKey(this.runtime, true); + public async getWalletKeypair(): Promise { + const keypair = this.keypair; if (!keypair) { throw new Error('Failed to get wallet keypair'); } return keypair; } + /** + * Retrieves the public key of the instance. + * + * @returns {PublicKey} The public key of the instance. + */ + public getPublicKey(): PublicKey | null { + return this.publicKey; + } + /** * Update wallet data including fetching wallet portfolio information, prices, and caching the data. * @param {boolean} [force=false] - Whether to force update the wallet data even if the update interval has not passed * @returns {Promise} The updated wallet portfolio information */ - private async updateWalletData(force = false): Promise { + public async updateWalletData(force = false): Promise { //console.log('updateWalletData - start') const now = Date.now(); @@ -1399,11 +1753,11 @@ export class SolanaService extends Service { const solPriceInUSD = new BigNumber(prices.solana.usd); - const missingSymbols = data.items.filter(i => !i.symbol) + const missingSymbols = data.items.filter((i: any) => !i.symbol) //console.log('data.items', data.items) if (missingSymbols.length) { - const symbols = await this.getTokensSymbols(missingSymbols.map(i => i.address)) + const symbols: Record = await this.getTokensSymbols(missingSymbols.map((i: any) => i.address)) let missing = false for(const i in data.items) { const item = data.items[i] @@ -1520,15 +1874,6 @@ export class SolanaService extends Service { return await this.updateWalletData(true); } - /** - * Retrieves the public key of the instance. - * - * @returns {PublicKey} The public key of the instance. - */ - public getPublicKey(): PublicKey | null { - return this.publicKey; - } - // // MARK: any wallet // @@ -1567,7 +1912,7 @@ export class SolanaService extends Service { const balance = Number(amountRaw) / (10 ** decimals); const symbol = await solanaService.getTokenSymbol(ca); */ - public async getTokenAccountsByKeypair(walletAddress: PublicKey, options = {}) { + public async getTokenAccountsByKeypair(walletAddress: PublicKey, options: { notOlderThan?: number; includeZeroBalances?: boolean; } = {}): Promise { //console.log('getTokenAccountsByKeypair', walletAddress.toString()) //console.log('publicKey', this.publicKey, 'vs', walletAddress) const key = 'solana_' + walletAddress.toString() + '_tokens' @@ -1583,7 +1928,7 @@ export class SolanaService extends Service { const diff = now - (check as any).fetchedAt // 1s - 5min cache? // FIXME: options driven... - const acceptableInMs = options.notOlderThan ?? 60_000 // default + const acceptableInMs: number = options.notOlderThan ?? 60_000 // default if (diff < acceptableInMs) { console.log('getTokenAccountsByKeypair cache HIT, its', diff.toLocaleString() + 'ms old') return (check as any).data @@ -1593,7 +1938,7 @@ export class SolanaService extends Service { } console.log('getTokenAccountsByKeypair - getParsedTokenAccountsByOwner', walletAddress.toString()) - const [accounts, token2022s]: [any, any] = await Promise.all([ + const [accounts, token2022s]: [ParsedTokenAccountsResponse, ParsedTokenAccountsResponse] = await Promise.all([ this.connection.getParsedTokenAccountsByOwner(walletAddress, { programId: TOKEN_PROGRAM_ID, // original SPL }), @@ -1606,10 +1951,10 @@ export class SolanaService extends Service { //console.log('haveToken22s', haveToken22s) //for(const t of token2022s.value) { console.log('t2022 account.data', t.account.data) } //const haveTokens = accounts.value.filter(account => account.account.data.parsed.info.tokenAmount.amount !== '0') - const allTokens = [...token2022s.value, ...accounts.value] + const allTokens: KeyedParsedTokenAccount[] = [...token2022s.value, ...accounts.value] // update decimalCache - const haveAllTokens = [] + const haveAllTokens: KeyedParsedTokenAccount[] = [] for(const t of allTokens) { const { amount, decimals } = t.account.data.parsed.info.tokenAmount; this.decimalsCache.set(t.account.data.parsed.info.mint, decimals); @@ -1635,6 +1980,15 @@ export class SolanaService extends Service { } } + public async getTokenAccountsByKeypairs(walletAddresses: string[], options = {}): Promise> { + const res = await Promise.all(walletAddresses.map(a => this.getTokenAccountsByKeypair(new PublicKey(a), options))) + const out: Record = {} + for(const i in walletAddresses) { + out[walletAddresses[i]] = res[i] + } + return out + } + // deprecated /* public async getBalanceByAddr(walletAddressStr: string): Promise { @@ -1656,7 +2010,6 @@ export class SolanaService extends Service { //console.log('walletAddressArr', walletAddressArr) const publicKeyObjs = walletAddressArr.map(k => new PublicKey(k)); //console.log('getBalancesByAddrs - getMultipleAccountsInfo') - //const accounts = await this.connection.getMultipleAccountsInfo(publicKeyObjs); const accounts = await this.batchGetMultipleAccountsInfo(publicKeyObjs, 'getBalancesByAddrs'); //console.log('getBalancesByAddrs - accounts', accounts) @@ -1740,6 +2093,84 @@ export class SolanaService extends Service { // MARK: wallet Associated Token Account (ATA) // + // single wallet, list of tokens + public async getWalletBalances(publicKeyStr: string, mintAddresses: string[]): Promise> { + + const owner = new PublicKey(publicKeyStr); + const mints = mintAddresses.map(m => new PublicKey(m)); + + // 1) Derive ATAs for both programs + const ataPairs = mints.map(mint => { + const ataLegacy = getAssociatedTokenAddressSync( + mint, owner, false, TOKEN_PROGRAM_ID + ); + const ata2022 = getAssociatedTokenAddressSync( + mint, owner, false, TOKEN_2022_PROGRAM_ID + ); + return { mint, ataLegacy, ata2022 }; + }); + + // 2) Batch fetch token accounts (both program ATAs) + const allAtaAddrs = ataPairs.flatMap(p => [p.ataLegacy, p.ata2022]); + const ataInfos = await this.batchGetMultipleAccountsInfo(allAtaAddrs, 'getWalletBalances'); + + // 3) Batch fetch mint accounts (for decimals) + //const mintInfos = await getMultiple(connection, mints, opts?.commitment); + const mintInfos = await this.batchGetMultipleAccountsInfo(mints, 'getWalletBalances'); + + // 4) Build quick lookups + const mintDecimals = new Map(); + mints.forEach((mintPk, i) => { + const acc = mintInfos[i]; + if (!acc) return; + // MintLayout.decode expects acc.data to be a Buffer of correct length + const mintData = MintLayout.decode(acc.data); + mintDecimals.set(mintPk.toBase58(), mintData.decimals); + }); + + const byAddress = new Map | null>(); + allAtaAddrs.forEach((ata, i) => { + const info = ataInfos[i]; + if (!info) { + byAddress.set(ata.toBase58(), null); + return; + } + byAddress.set(ata.toBase58(), AccountLayout.decode(info.data)); + }); + + // 5) Assemble balances; prefer legacy program over 2022 if both exist + const out: Record = {}; + + for (const { mint, ataLegacy, ata2022 } of ataPairs) { + const mintStr = mint.toBase58(); + const decimals = mintDecimals.get(mintStr); + // If we don’t know decimals (mint account not found), we can’t compute uiAmount + if (decimals === undefined) { + out[mintStr] = null; + continue; + } + + const legacy = byAddress.get(ataLegacy.toBase58()); + const tok2022 = byAddress.get(ata2022.toBase58()); + + // Choose which token account to use: + const chosen = legacy ?? tok2022; + if (!chosen) { + out[mintStr] = null; // ATA doesn’t exist → zero balance + continue; + } + + // AccountLayout amount is a u64 in little-endian buffer + const rawAmount = BigInt(chosen.amount.toString()); // AccountLayout already gives a BN-like + const amountStr = rawAmount.toString(); + const uiAmount = Number(rawAmount) / 10 ** decimals; + + out[mintStr] = { amount: amountStr, decimals, uiAmount }; + } + + return out; + } + // 5 calls to get a balance for 500 wallets public async getTokenBalanceForWallets(mint: PublicKey, walletAddresses: string[]): Promise> { const walletPubkeys = walletAddresses.map(a => new PublicKey(a)); @@ -1846,20 +2277,10 @@ export class SolanaService extends Service { return false; } - //await connection.removeAccountChangeListener(subscriptionId); - - /* - const ws = (this.connection as any).connection._rpcWebSocket; - const success = await ws.call('accountUnsubscribe', [subscriptionId]); + await this.connection.removeAccountChangeListener(subscriptionId); + this.subscriptions.delete(accountAddress); - if (success) { - this.subscriptions.delete(accountAddress); - logger.log(`Unsubscribed from account ${accountAddress}`); - } - - return success; - */ - // + return true; } catch (error) { logger.error(`Error unsubscribing from account: ${error}`); throw error; @@ -1952,7 +2373,7 @@ export class SolanaService extends Service { * @param {any} signal - Trading signal information * @returns {Promise>} */ - public async executeSwap(wallets: Array<{ keypair: any; amount: number }>, signal: any) { + public async executeSwap(wallets: Array<{ keypair: any; amount: number }>, signal: any): Promise> { // do it in serial to avoid hitting rate limits const swapResponses = {} for(const wallet of wallets) { @@ -2044,7 +2465,7 @@ export class SolanaService extends Service { */ // outAmount, minOutAmount, priceImpactPct - const impliedSlippageBps = ((initialQuote.outAmount - initialQuote.otherAmountThreshold) / initialQuote.outAmount) * 10_000; + const impliedSlippageBps: number = ((initialQuote.outAmount - initialQuote.otherAmountThreshold) / initialQuote.outAmount) * 10_000; console.log('impliedSlippageBps', impliedSlippageBps, 'jupSlip', initialQuote.slippageBps) // Calculate optimal buy amount using the input mint from quote @@ -2281,10 +2702,8 @@ export class SolanaService extends Service { if (inBal && outBal) { // in will be high than out in this scenario? - //const lamDiff = (outBal.uiTokenAmount?.uiAmount || 0) - (inBal.uiTokenAmount?.uiAmount || 0) - const lamDiff = inBal.uiTokenAmount.uiAmount - outBal.uiTokenAmount.uiAmount - //const diff = Number(outBal.uiTokenAmount?.amount || 0) - Number(inBal.uiTokenAmount?.amount || 0) - const diff = Number(inBal.uiTokenAmount.amount) - Number(outBal.uiTokenAmount.amount) + const lamDiff = (inBal.uiTokenAmount.uiAmount ?? 0) - (outBal.uiTokenAmount.uiAmount ?? 0) + const diff = Number(inBal.uiTokenAmount.amount ?? 0) - Number(outBal.uiTokenAmount.amount ?? 0) // we definitely didn't swap for nothing if (diff) { outAmount = diff @@ -2307,8 +2726,8 @@ export class SolanaService extends Service { } else { if (inBal && outBal) { - const lamDiff = outBal.uiTokenAmount.uiAmount - inBal.uiTokenAmount.uiAmount - const diff = Number(outBal.uiTokenAmount.amount) - Number(inBal.uiTokenAmount.amount) + const lamDiff = (outBal.uiTokenAmount.uiAmount ?? 0) - (inBal.uiTokenAmount.uiAmount ?? 0) + const diff = Number(outBal.uiTokenAmount.amount ?? 0) - Number(inBal.uiTokenAmount.amount ?? 0) // we definitely didn't swap for nothing if (diff) { outAmount = diff @@ -2355,7 +2774,7 @@ export class SolanaService extends Service { (swapResponses as any)[pubKey] = { success: true, outAmount, - outDecimal: await this.getDecimal(signal.targetTokenCA), + outDecimal: await this.getDecimal(new PublicKey(signal.targetTokenCA)), signature: txid, fees, swapResponse, @@ -2375,8 +2794,8 @@ export class SolanaService extends Service { * @param {IAgentRuntime} runtime - The agent runtime to use for the Solana service. * @returns {Promise} The initialized Solana service. */ - static async start(runtime: IAgentRuntime): Promise { - logger.log(`SolanaService start for ${runtime.character.name}`); + static async start(runtime: IAgentRuntime): Promise { + runtime.logger.log(`SolanaService start for ${runtime.character.name}`); const solanaService = new SolanaService(runtime); return solanaService; @@ -2388,28 +2807,30 @@ export class SolanaService extends Service { * @param {IAgentRuntime} runtime - The agent runtime. * @returns {Promise} - A promise that resolves once the Solana service has stopped. */ - static async stop(runtime: IAgentRuntime) { - const client = runtime.getService(SOLANA_SERVICE_NAME); + static async stop(runtime: IAgentRuntime): Promise { + const client = runtime.getService(SOLANA_SERVICE_NAME) as SolanaService | null; if (!client) { - logger.error('SolanaService not found'); + runtime.logger.error('SolanaService not found during static stop'); return; } await client.stop(); } /** - * Stops the update interval if it is currently running. + * Cleans up subscriptions * @returns {Promise} A Promise that resolves when the update interval is stopped. */ async stop(): Promise { + this.runtime.logger.info('SolanaService: Stopping instance...'); // Unsubscribe from all accounts for (const [address] of this.subscriptions) { - await this.unsubscribeFromAccount(address); - } - - if (this.updateInterval) { - clearInterval(this.updateInterval); - this.updateInterval = null; + await this.unsubscribeFromAccount(address).catch((e) => + this.runtime.logger.error( + `Error unsubscribing from ${address} during stop:`, + e instanceof Error ? e.message : String(e) + ) + ); } + this.subscriptions.clear(); } } diff --git a/tsconfig.build.json b/tsconfig.build.json index 9a7896f..cc135b7 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -3,11 +3,12 @@ "compilerOptions": { "rootDir": "./src", "outDir": "./dist", + "declarationDir": "./dist", "strict": true, "sourceMap": true, "inlineSources": true, - "declaration": true, - "emitDeclarationOnly": true + "emitDeclarationOnly": true, + "types": ["bun"], }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] diff --git a/tsconfig.json b/tsconfig.json index f156f86..2e8c616 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,23 +2,28 @@ "compilerOptions": { "outDir": "dist", "rootDir": "src", - "baseUrl": "../..", - "lib": ["ESNext"], + + // Bun runtime ≈ modern JS + Web APIs "target": "ESNext", "module": "Preserve", "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, "esModuleInterop": true, - "allowImportingTsExtensions": true, + "resolveJsonModule": true, "declaration": true, + "declarationDir": "dist", "emitDeclarationOnly": true, - "resolveJsonModule": true, + + // *** Bun types only *** + "types": ["bun"], + + // Nice-to-haves for Bun + bundlers + "allowImportingTsExtensions": true, "moduleDetection": "force", - "allowArbitraryExtensions": true, - "paths": { - "@elizaos/core": ["../core/src"], - "@elizaos/core/*": ["../core/src/*"] - } + "allowArbitraryExtensions": true }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] } diff --git a/tsup.config.ts b/tsup.config.ts index 3449114..d8cc1b9 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ sourcemap: true, clean: true, strict: true, - format: ['esm'], // Ensure you're targeting CommonJS + format: ['esm'], // ESM build (Bun-friendly) dts: true, external: [ 'dotenv', // Externalize dotenv to prevent bundling