From bc79ac8202c7a4c07692e96dfadad3dee0b874df Mon Sep 17 00:00:00 2001 From: Odilitime Date: Fri, 31 Oct 2025 02:38:36 +0000 Subject: [PATCH 1/2] new intel_chain registry format --- src/index.ts | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/index.ts b/src/index.ts index 20970b9..419e3b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,14 +43,25 @@ export const solanaPlugin: Plugin = { 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); + // solana:mainnet + const chainNets: Record = { + 'mainnet': 'https://api.mainnet.solana.com', + 'devnet': 'https://api.devnet.solana.com', + 'testnet': 'https://api.testnet.solana.com', + 'localnet': runtime.getSetting('SOLANA_LOCALNET_RPC_URL') ?? 'http://127.0.0.1:8899', + } + for (const net in chainNets) { + const me = { + name: 'Solana services', + chainType: 'solana', + chainNet: net, + rpcUrl: chainNets[net], + service: SOLANA_SERVICE_NAME, + }; + traderChainService.registerChain(me); + } }).catch(error => { - runtime.logger.error({ error },'Failed to register with INTEL_CHAIN'); + runtime.logger.error({ error }, 'Failed to register with INTEL_CHAIN'); }); }, From c438b704e3576391d2c7f5f61638c709ccc663da Mon Sep 17 00:00:00 2001 From: Odilitime Date: Fri, 31 Oct 2025 02:42:23 +0000 Subject: [PATCH 2/2] multichain/exchange api work, better token2022 (pump fun token) support, tx refactor, improve error handling, better init --- src/service.ts | 1590 ++++++++++++++++++++++++++++++------------------ 1 file changed, 1005 insertions(+), 585 deletions(-) diff --git a/src/service.ts b/src/service.ts index 5028501..e39e585 100644 --- a/src/service.ts +++ b/src/service.ts @@ -2,22 +2,27 @@ import { type IAgentRuntime, ServiceTypeName, Service, ServiceType, logger } fro import { IWalletService, WalletPortfolio as siWalletPortfolio } from '@elizaos/service-interfaces'; import { Connection, - Keypair, + Keypair, // has static methods PublicKey, VersionedTransaction, SendTransactionError, LAMPORTS_PER_SOL, - type AccountInfo, SystemProgram, Transaction, TransactionMessage, - type RpcResponseAndContext, - type ParsedAccountData, + TransactionInstruction, +} from '@solana/web3.js'; +import type { + AccountInfo, + RpcResponseAndContext, + ParsedAccountData, + VersionedMessage, + ParsedTransactionWithMeta, } from '@solana/web3.js'; import { MintLayout, getMint, TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, unpackAccount, getAssociatedTokenAddressSync, ExtensionType, getExtensionData, getExtensionTypes, - unpackMint, AccountLayout + unpackMint, AccountLayout, createAssociatedTokenAccountInstruction, createTransferInstruction } from "@solana/spl-token"; // parses the raw Token-2022 metadata struct import { unpack as unpackToken2022Metadata } from '@solana/spl-token-metadata'; @@ -26,13 +31,13 @@ import { SOLANA_SERVICE_NAME, SOLANA_WALLET_DATA_CACHE_KEY } from './constants'; import { getWalletKey, KeypairResult } from './keypairUtils'; import type { Item, Prices, WalletPortfolio } from './types'; import bs58 from 'bs58'; -import nacl from "tweetnacl"; +import nacl from 'tweetnacl'; const PROVIDER_CONFIG = { BIRDEYE_API: 'https://public-api.birdeye.so', MAX_RETRIES: 3, RETRY_DELAY: 2000, - DEFAULT_RPC: 'https://api.mainnet-beta.solana.com', + DEFAULT_RPC: 'https://api.mainnet-beta.solana.com', // this is mainnet TOKEN_ADDRESSES: { SOL: 'So11111111111111111111111111111111111111112', BTC: '3NZ9JMVBmGAqocybic2c7LQCJScmgsAZ6vQqTDzcqmJh', @@ -61,6 +66,49 @@ type ParsedTokenAccountsResponse = Promise> */ +/* + signal: { + //chain: string? + chainId: 0 // mainnet all day every day + inToken: (signal.sourceTokenCA) + outToken: (signal.targetTokenCA) + }, + wallets: [ + // + { + sourceWallet / keypair (taker) + inAmount: (wallet.amount) + slippage limits + } + ] +*/ + +type SwapWalletSet = { + signal: { + inToken: string; // :/: (signal.sourceTokenCA) + outToken: string; // (signal.targetTokenCA) + }; + wallets: Array<{ + inKeypair: string; // giver (private key string in base58) + outKeypair: string; // taker (private key string in base58) + inAmount: number; + slippage: number; + }>; +}; + +type SolanaSwapWalletSet = { + signal: { + chainId: number; + inToken: string; + outToken: string; + }; + wallets: Array<{ + keypair: Keypair; + inAmount: number; + slippage: number; + }>; +}; + const METADATA_PROGRAM_ID = new PublicKey( 'metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s' // Metaplex Token Metadata Program ID ); @@ -119,22 +167,19 @@ export interface ISolanaPluginServiceAPI extends Service { // 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) +// also the type export class SolanaWalletService extends IWalletService { - private _solanaService: SolanaService | null = null; + private solanaService: SolanaService | null = null; + private pSrvSolana: any; constructor(runtime?: IAgentRuntime) { if (!runtime) throw new Error('runtime is required for solana service') - super(runtime); - } + super(runtime); // link to main service... - private get solanaService(): SolanaService { - if (!this._solanaService) { - this._solanaService = this.runtime.getService('chain_solana') as SolanaService; - if (!this._solanaService) { - throw new Error('Solana Service is required for Solana Wallet Service'); - } - } - return this._solanaService; + this.pSrvSolana = runtime.getServiceLoadPromise('chain_solana' as ServiceTypeName).then(async s => { + runtime.logger.log('Activating SolanaWallet as IWalletService') + this.solanaService = runtime.getService('chain_solana') as SolanaService + }) } /** @@ -143,6 +188,7 @@ export class SolanaWalletService extends IWalletService { * @returns A promise that resolves to the wallet's portfolio. */ public async getPortfolio(owner?: string): Promise { + if (!this.solanaService) throw new Error('Solana Service is required for Solana Wallet Service: getPortfolio') const publicKey = await this.solanaService.getPublicKey(); if (owner && owner !== publicKey?.toBase58()) { throw new Error( @@ -155,7 +201,7 @@ export class SolanaWalletService extends IWalletService { assets: wp.items.map(i => ({ address: i.address, symbol: i.symbol, - balance: Number(i.uiAmount ?? 0).toString(), + balance: Number(i.uiAmount ?? 0).toString(), decimals: i.decimals, valueUsd: Number(i.valueUsd ?? 0), })), @@ -170,6 +216,7 @@ export class SolanaWalletService extends IWalletService { * @returns A promise that resolves to the user-friendly (decimal-adjusted) balance of the asset held. */ public async getBalance(assetAddress: string, owner?: string): Promise { + if (!this.solanaService) throw new Error('Solana Service is required for Solana Wallet Service: getBalance') const publicKey = await this.solanaService.getPublicKey(); const ownerAddress: string | undefined = owner || publicKey?.toBase58(); if (!ownerAddress) { @@ -186,12 +233,13 @@ export class SolanaWalletService extends IWalletService { } //const tokenBalance = await this.getTokenBalance(ownerAddress, assetAddress); //return tokenBalance?.uiAmount || 0; - const tokensBalances: Record = await this.solanaService.getTokenAccountsByKeypairs([ownerAddress]) + const tokensBalances: Record = await this.solanaService.getTokenAccountsByKeypairs([ownerAddress]) const heldTokens = tokensBalances[ownerAddress] || [] - for(const t of heldTokens) { + 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 === null) continue if (t.account.data.parsed.info.mint === assetAddress) { return t.account.data.parsed.info.tokenAmount.uiAmount; } @@ -210,48 +258,8 @@ export class SolanaWalletService extends IWalletService { * @throws {Error} If the transfer fails. */ public async transferSol(from: Keypair, to: PublicKey, lamports: number): Promise { - try { - const payerKey = await 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; - } + if (!this.solanaService) throw new Error('Solana Service is required for Solana Wallet Service: transferSol') + return this.solanaService.transferSol(from, to, lamports); } /** @@ -303,15 +311,11 @@ export class SolanaService extends Service { private readonly UPDATE_INTERVAL = 2 * 60_000; // 2 minutes private connection: Connection; - // Lazy load fields (renamed from publicKey/keypair) + // Lazy-loaded key properties private _publicKey: PublicKey | null = null; private _keypair: Keypair | null = null; - - // Promise cache for lazy loading (anti-thundering herd pattern) private _publicKeyPromise: Promise | null = null; private _keypairPromise: Promise | null = null; - - // Debug counters private _publicKeyLoadAttempts = 0; private _keypairLoadAttempts = 0; @@ -320,6 +324,7 @@ export class SolanaService extends Service { private subscriptions: Map = new Map(); jupiterService: any; + srvIntelChain: any; // always multiple these static readonly LAMPORTS2SOL = 1 / LAMPORTS_PER_SOL; @@ -350,12 +355,15 @@ export class SolanaService extends Service { // now we have jupiter lets register our services this.jupiterService = runtime.getService('JUPITER_SERVICE' as ServiceTypeName) as any; }) + runtime.getServiceLoadPromise('INTEL_CHAIN' as ServiceTypeName).then(async s => { + this.srvIntelChain = runtime.getService('INTEL_CHAIN') + }) this.subscriptions = new Map(); } /** * Lazy load public key with promise caching (anti-thundering herd pattern) - * Returns null if wallet key is not available yet (e.g., not created or not in settings) + * Returns null if wallet key is not available yet */ private async ensurePublicKey(): Promise { if (this._publicKey) return this._publicKey; @@ -444,11 +452,222 @@ export class SolanaService extends Service { */ async registerExchange(provider: any) { const id = Object.values(this.exchangeRegistry).length + 1; - this.runtime.logger.success(`Registered ${provider.name} as Solana provider #${id}`); + this.runtime.logger.success(`Registered ${provider.name} as Solana exchange #${id}`); this.exchangeRegistry[id] = provider; return id; } + /** + * Extracts actual swap amounts from a Solana transaction + * @param {Object} txDetails - Transaction details with meta.preTokenBalances and meta.postTokenBalances + * @param {string} pubKey - Wallet public key + * @param {string} sourceTokenCA - Source token contract address (what you're spending) + * @param {string} targetTokenCA - Target token contract address (what you're getting) + * @returns {Object} { inAmount, outAmount, sourceDecimals, targetDecimals } + */ + getSwapAmounts(txDetails: ParsedTransactionWithMeta, pubKey: string, sourceTokenCA: string, targetTokenCA: string) { + if (!txDetails?.meta?.preTokenBalances || !txDetails?.meta?.postTokenBalances) { + return { inAmount: 0, outAmount: 0, sourceDecimals: null, targetDecimals: null }; + } + + const preBalances = txDetails.meta.preTokenBalances; + const postBalances = txDetails.meta.postTokenBalances; + + // Get source token balance change (what we spent) + const sourcePre = preBalances.find(tb => tb.owner === pubKey && tb.mint === sourceTokenCA); + const sourcePost = postBalances.find(tb => tb.owner === pubKey && tb.mint === sourceTokenCA); + + // Get target token balance change (what we received) + const targetPre = preBalances.find(tb => tb.owner === pubKey && tb.mint === targetTokenCA); + const targetPost = postBalances.find(tb => tb.owner === pubKey && tb.mint === targetTokenCA); + + // Calculate input amount (amount spent from source token) + let inAmount = 0; + let sourceDecimals = null; + + if (sourcePre && sourcePost) { + // Had balance before, calculate difference (pre - post = amount spent) + inAmount = Number(sourcePre.uiTokenAmount.amount) - Number(sourcePost.uiTokenAmount.amount); + sourceDecimals = sourcePost.uiTokenAmount.decimals; + } else if (sourcePre && !sourcePost) { + // Spent entire balance (account closed) + inAmount = Number(sourcePre.uiTokenAmount.amount); + sourceDecimals = sourcePre.uiTokenAmount.decimals; + } + + // Calculate output amount (amount received of target token) + let outAmount = 0; + let targetDecimals = null; + + if (targetPre && targetPost) { + // Had balance before, calculate difference (post - pre = amount received) + outAmount = Number(targetPost.uiTokenAmount.amount) - Number(targetPre.uiTokenAmount.amount); + targetDecimals = targetPost.uiTokenAmount.decimals; + } else if (!targetPre && targetPost) { + // New token account, entire balance is what we received + outAmount = Number(targetPost.uiTokenAmount.amount); + targetDecimals = targetPost.uiTokenAmount.decimals; + } + + return { + inAmount, // Raw amount (not UI amount) of tokens spent + outAmount, // Raw amount (not UI amount) of tokens received + sourceDecimals, + targetDecimals + }; + } + + // swap + /* + quote + 0x: + sellToken, + buyToken, + sellAmount, + taker, + slippagePercentage, + + jup: + inputMint: swapWalletSet.signal.inToken, + outputMint: swapWalletSet.signal.outToken, + amount: 0, + slippageBps: 200, + */ + /* + executeSwaps( + [ + signal: { + //chain: string? + chainId: 0 // mainnet all day every day + inToken: (signal.sourceTokenCA) + outToken: (signal.targetTokenCA) + }, + wallets: [ + // + { + sourceWallet / keypair (taker) + inAmount: (wallet.amount) + slippage limits + } + ] + ] + ) + */ + + // only works if chain part matches on both coins... + // arguments for/against parallelization? should be but keying on output is an issue + async doSwapOnExchange(exchHndl: number, swapWalletSet: SwapWalletSet) { + const exch = this.exchangeRegistry[exchHndl] + if (!exch) { + console.log('bad exchange', exchHndl) + return false + } + const exService = this.runtime.getService(exch.service) as any; + + const src = this.srvIntelChain.parseChainAssetId(swapWalletSet.signal.inToken) + const trg = this.srvIntelChain.parseChainAssetId(swapWalletSet.signal.outToken) + + const swapResponses = {} + for (const w of swapWalletSet.wallets) { + const secretKey = bs58.decode(w.inKeypair as string); + const keypair = Keypair.fromSecretKey(secretKey); + const pubKey = keypair.publicKey.toBase58() + + // validate amount + const intAmount: number = Math.round(w.inAmount) + if (isNaN(intAmount) || intAmount <= 0) { + console.warn(`solana::doSwapOnExchange - Amount in ${w.inAmount} became ${intAmount}`); + (swapResponses as any)[pubKey] = { + success: false, + error: 'bad amount' + }; + continue + } + + // quote + const quoteResponse = await exService.getQuote({ + inputMint: src.assetRef, + outputMint: trg.assetRef, + amount: w.inAmount, + slippageBps: w.slippage, + userPublicKey: pubKey, + }) + // swap (getTx) + const swapResponse = await exService.executeSwap({ + quoteResponse, + userPublicKey: pubKey, + slippageBps: w.slippage, + }) + // convert hex PK into a solana Keypair + //const secretKey = bs58.decode(w.keypair.secretKey); + //const keypair = Keypair.fromSecretKey(secretKey); + + const txBuffer = Buffer.from(swapResponse.swapTransaction as string, 'base64'); + const versionedTx = VersionedTransaction.deserialize(Uint8Array.from(txBuffer)); + + const txid = await this.sendTx(versionedTx, [keypair]).catch(e => { + console.error('doSwapOnExchange tx err', e.slippage, e) + }) ?? '' + console.log('txid', txid) + // Get transaction details including fees + const txDetails = await this.connection.getParsedTransaction(txid, { + commitment: 'confirmed', + maxSupportedTransactionVersion: 0 + }); + if (txDetails === null) { + (swapResponses as any)[pubKey] = { + success: true, + outAmount: 0, // in ui format + outDecimal: await this.getDecimal(new PublicKey(trg.assetRef)), + signature: txid, + fees: null, + swapResponse, + }; + continue + } + const amounts = this.getSwapAmounts(txDetails, pubKey, src.assetRef, trg.assetRef) + console.log('amounts', amounts) + // inAmount, outamount, sourceDecimals, targetDecimals + /* + inAmount, // Raw amount (not UI amount) of tokens spent + outAmount, // Raw amount (not UI amount) of tokens received + sourceDecimals, + targetDecimals + */ + const fee = txDetails?.meta?.fee; + console.log(`Transaction fee: ${fee?.toLocaleString()} lamports`); + const fees = { + /* + quote: { + lamports: initialQuote.platformFee.amount, + bps: initialQuote.platformFee.feeBps, + }, + */ + lamports: fee, + sol: fee ? fee * SolanaService.LAMPORTS2SOL : 0 + }; + + // maybe inWallet + outWallet key? + (swapResponses as any)[pubKey] = { + success: true, + outAmount: amounts.outAmount, // in ui format + outDecimal: await this.getDecimal(new PublicKey(trg.assetRef)), + signature: txid, + fees, + // might need to be more structurally defined since we're now supporting multiple exchanges... + // that mapping should happen in the exchange plugins + // however we need a reference here, so anything building off of it can know what guarantees we have + swapResponse, + }; + } + return swapResponses + } + + async selectExchange() { + // random grab one for now + return 1 // starts count at 1 + } + /** * Fetches data from the provided URL with retry logic. * @param {string} url - The URL to fetch data from. @@ -574,12 +793,12 @@ export class SolanaService extends Service { // getParsedAccountInfo private static readonly TOKEN_ACCOUNT_DATA_LENGTH = 165; - private static readonly TOKEN_MINT_DATA_LENGTH = 82; + private static readonly TOKEN_MINT_DATA_LENGTH = 82; // deprecate async getAddressType(address: string): Promise { const types = await this.getAddressesTypes([address]) - return types[address] + return types[address] ?? 'Unknown: undefined' } async getAddressesTypes(addresses: string[]): Promise> { @@ -597,9 +816,15 @@ export class SolanaService extends Service { }); const out: Record = {} - for(const i in addresses) { - const addr = addresses[i] - out[addr] = resultList[i] + for (const i in addresses) { + if (i !== undefined) { + const addr = addresses[i] + if (addr) { + out[addr] = resultList[i] ?? 'Unknown: undefined' + } else { + this.runtime.logger.warn('getAddressesTypes: No index ' + i + ' for addresses') + } + } } return out @@ -701,7 +926,7 @@ export class SolanaService extends Service { // deprecate async getCirculatingSupply(mint: string) { - //const mintPublicKey = new PublicKey(mint); + //const mintPublicKey = new PublicKey(mint); // 1. Fetch all token accounts holding this token const accounts = await this.connection.getParsedProgramAccounts( TOKEN_PROGRAM_ID, @@ -782,8 +1007,8 @@ export class SolanaService extends Service { return prices; } - public async getDecimal(mintPublicKey: PublicKey): Promise { - try { + public async getDecimal(mintPublicKey: PublicKey): Promise { + try { const key = mintPublicKey.toString() if (this.decimalsCache.has(key)) { console.log('getDecimal - HIT', key) @@ -870,30 +1095,96 @@ 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 - } + // Parse TLV (Type-Length-Value) extension data from Token-2022 mint + private parseTLVExtensions(tlvData: Buffer): Array<{ type: number; length: number; value: Buffer }> { + const extensions: Array<{ type: number; length: number; value: Buffer }> = []; + let offset = 0; + + while (offset < tlvData.length) { + if (offset + 4 > tlvData.length) break; + + const type = tlvData.readUInt16LE(offset); + const length = tlvData.readUInt16LE(offset + 2); + offset += 4; + + if (type === 0 && length === 0) break; // End of extensions + + const value = tlvData.slice(offset, offset + length); + extensions.push({ type, length, value }); + offset += length; } - // Try MetadataPointer extension - const ptrExt = getExtensionData(ExtensionType.MetadataPointer, mintData) as - | { authority: Uint8Array; metadataAddress: Uint8Array } - | null; + return extensions; + } - if (ptrExt?.metadataAddress) { - return { symbol: null, ptr: new PublicKey(ptrExt.metadataAddress) }; + // Parse Token-2022 metadata extension data + private parseToken2022MetadataExtension(data: Buffer): { name: string; symbol: string; uri: string } | null { + try { + // Token-2022 metadata format: + // updateAuthority (32 bytes) + mint (32 bytes) + name (4 bytes len + string) + symbol (4 bytes len + string) + uri (4 bytes len + string) + let offset = 0; + + // Skip updateAuthority and mint (64 bytes total) + offset += 64; + + // Read name + const nameLen = data.readUInt32LE(offset); + offset += 4; + const name = data.slice(offset, offset + nameLen).toString('utf8').replace(/\0/g, '').trim(); + offset += nameLen; + + // Read symbol + const symbolLen = data.readUInt32LE(offset); + offset += 4; + const symbol = data.slice(offset, offset + symbolLen).toString('utf8').replace(/\0/g, '').trim(); + offset += symbolLen; + + // Read URI + const uriLen = data.readUInt32LE(offset); + offset += 4; + const uri = data.slice(offset, offset + uriLen).toString('utf8').replace(/\0/g, '').trim(); + + return { name, symbol, uri }; + } catch (e) { + console.log('Failed to parse Token-2022 metadata extension:', e); + return null; } + } + + // this is all local + private parseToken2022SymbolFromMintOrPtr = (mint: PublicKey, mintData: Buffer): { symbol: string | null, ptr?: PublicKey } => { + // Use unpackMint to properly extract the TLV data from Token-2022 mint + try { + const mintInfo = unpackMint(mint, { data: mintData, owner: TOKEN_2022_PROGRAM_ID } as any, TOKEN_2022_PROGRAM_ID); + + if (!mintInfo.tlvData || mintInfo.tlvData.length === 0) { + return { symbol: null }; + } + + const extensions = this.parseTLVExtensions(mintInfo.tlvData); - return { symbol: null }; + // Look for TokenMetadata extension (type 19) + const tokenMetadataExt = extensions.find(ext => ext.type === 19); + if (tokenMetadataExt) { + const metadata = this.parseToken2022MetadataExtension(tokenMetadataExt.value); + if (metadata?.symbol) { + return { symbol: metadata.symbol }; + } + } + + // Look for MetadataPointer extension (type 18) + const metadataPointerExt = extensions.find(ext => ext.type === 18); + if (metadataPointerExt && metadataPointerExt.value.length >= 64) { + // MetadataPointer structure: authority (32 bytes) + metadataAddress (32 bytes) + const metadataAddress = new PublicKey(metadataPointerExt.value.slice(32, 64)); + return { symbol: null, ptr: metadataAddress }; + } + + return { symbol: null }; + } catch (e) { + console.log('Failed to parse Token-2022 mint:', e); + return { symbol: null }; + } }; // cache me @@ -903,7 +1194,9 @@ export class SolanaService extends Service { console.log('getTokensSymbols'); const mintKeys: PublicKey[] = mints.map(k => new PublicKey(k)); - // Phase 1: Metaplex PDAs (your existing flow) + const out: Record = {}; + + // Phase 1: Metaplex PDAs const metadataAddresses: PublicKey[] = await Promise.all( mintKeys.map(mk => this.getMetadataAddress(mk)) ); @@ -912,16 +1205,15 @@ export class SolanaService extends Service { 'getTokensSymbols/Metaplex' ); - const out: Record = {}; - const needs2022: PublicKey[] = []; + const needsOnChain: PublicKey[] = []; mintKeys.forEach((token, i) => { 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); + //console.log('getTokensSymbols - adding', token.toBase58(), 'to on-chain check list') + needsOnChain.push(token); return; } @@ -944,44 +1236,59 @@ export class SolanaService extends Service { data.slice(offset, offset + symbolLen).toString('utf8').replace(/\0/g, '').trim() || null; out[token.toBase58()] = symbol; - if (!symbol) needs2022.push(token); + if (!symbol) needsOnChain.push(token); } catch (e) { - console.log('Metaplex parse failed; will try Token-2022:', e); + console.log('Metaplex parse failed; will try on-chain metadata:', e); out[token.toBase58()] = null; - needs2022.push(token); + needsOnChain.push(token); } }); - // Phase 2: Batch fetch *mint accounts* via your batch helper, then parse Token-2022 TLV - if (needs2022.length) { + // Phase 2: Batch fetch mint accounts and check for Token-2022 or SPL metadata + if (needsOnChain.length) { const mintInfos = await this.batchGetMultipleAccountsInfo( - needs2022, - 'getTokensSymbols/Token2022' + needsOnChain, + 'getTokensSymbols/OnChain' ); // 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) => { + needsOnChain.forEach((mint, idx) => { const info = mintInfos[idx] as AccountInfo | null; + const mintStr = mint.toBase58(); + 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()); + console.log('getTokensSymbols - no mint account found', mintStr); + // Keep the null value already set 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()); + // Check if it's Token-2022 + if (info.owner.equals(TOKEN_2022_PROGRAM_ID)) { + console.log('getTokensSymbols - checking Token-2022', mintStr); + const { symbol, ptr } = this.parseToken2022SymbolFromMintOrPtr(mint, info.data); + if (symbol) { + out[mintStr] = symbol; + } else if (ptr) { + ptrsToFetch.push(ptr); + ptrOwnerByKey.set(ptr.toBase58(), mintStr); + } else { + console.log('getTokensSymbols - no TokenMetadata or pointer for Token-2022', mintStr); + } + } + // Check if it's regular SPL Token + else if (info.owner.equals(TOKEN_PROGRAM_ID)) { + console.log('getTokensSymbols - regular SPL token (no on-chain metadata)', mintStr); + // Regular SPL tokens don't have on-chain metadata + // Could fall back to Birdeye API here if needed + // For now, keep null + } + // Unknown program - might be a program address or other account type + else { + console.log('getTokensSymbols - unknown program owner', mintStr, info.owner.toBase58()); + // Keep null } }); @@ -1014,6 +1321,7 @@ export class SolanaService extends Service { } } + //console.log('symbols', out); return out; } @@ -1061,7 +1369,7 @@ export class SolanaService extends Service { return out } - public async parseTokenAccounts(heldTokens: any [], options: { notOlderThan?: number } = {}) { + 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 @@ -1096,7 +1404,7 @@ export class SolanaService extends Service { let misses = 0 const fetchTokens = [] const goodCache: Record & { balanceUi: number }> = {} - for(const i in heldTokens) { + for (const i in heldTokens) { const t = heldTokens[i] if (cache[i]) { const c = cache[i] @@ -1105,16 +1413,16 @@ export class SolanaService extends Service { // immutable data is always good useCache = true } else //otherwise - if (acceptableInMs !== 0) { - const diff = nowInMs - c.setAt - //console.log('cache for', t.account.data.parsed.info.mint, 'is', diff.toLocaleString() + 'ms old') - // freshness check - if (diff < acceptableInMs) { - useCache = true - //} else { - //console.log('parseTokenAccounts - MISS', mint) + if (acceptableInMs !== 0) { + const diff = nowInMs - c.setAt + //console.log('cache for', t.account.data.parsed.info.mint, 'is', diff.toLocaleString() + 'ms old') + // freshness check + if (diff < acceptableInMs) { + useCache = true + //} else { + //console.log('parseTokenAccounts - MISS', mint) + } } - } //useCache = false if (useCache) { // HIT @@ -1138,7 +1446,7 @@ export class SolanaService extends Service { // --- build unique mint sets by program --- const toB58 = (pk: string | PublicKey) => typeof pk === "string" ? pk : pk.toBase58() - const TOKEN_ID_B58 = TOKEN_PROGRAM_ID.toBase58() + const TOKEN_ID_B58 = TOKEN_PROGRAM_ID.toBase58() const TOKEN2022_B58 = TOKEN_2022_PROGRAM_ID.toBase58() const t22MintKeys: PublicKey[] = Array.from(new Set( @@ -1188,7 +1496,7 @@ export class SolanaService extends Service { // top of the function (near other maps/sets) const t22Symbols = new Map(); // mint -> symbol (Token-2022 TLV) - const mpSymbols = new Map(); // mint -> symbol (Metaplex PDA) + const mpSymbols = new Map(); // mint -> symbol (Metaplex PDA) // metadata-pointer map (mint -> pointer address) const t22PtrAddrByMint = new Map(); const mpSupply = new Map(); // mint -> supply @@ -1235,7 +1543,7 @@ export class SolanaService extends Service { offObj.off += len; return s.trim(); } - function allZero32(b: Buffer) { for (let i=0;i<32;i++) if (b[i]!==0) return false; return true; } + function allZero32(b: Buffer) { for (let i = 0; i < 32; i++) if (b[i] !== 0) return false; return true; } // Parse the Token-2022 TokenMetadata TLV (just the Value slice) function parseToken2022MetadataTLV(ext: Buffer): { @@ -1245,7 +1553,7 @@ export class SolanaService extends Service { // 32B updateAuthority (all-zero = None) const uaBytes = ext.subarray(o.off, o.off + 32); o.off += 32; const isMutable = !allZero32(uaBytes); - const updateAuthority = isMutable ? new PublicKey(uaBytes).toBase58() : undefined; + const updateAuthority = isMutable ? new PublicKey(uaBytes).toBase58() : ''; // 32B mint const mint = new PublicKey(ext.subarray(o.off, o.off + 32)).toBase58(); o.off += 32; @@ -1257,7 +1565,7 @@ export class SolanaService extends Service { const uri = readVecU8AsString(ext, o); // Optional Vec<(String,String)> - const additional: Array<[string,string]> = []; + const additional: Array<[string, string]> = []; if (o.off + 4 <= ext.length) { const n = readU32LE(ext, o); for (let i = 0; i < n; i++) additional.push([readVecU8AsString(ext, o), readVecU8AsString(ext, o)]); @@ -1287,7 +1595,7 @@ export class SolanaService extends Service { // 1) Sanity: owner must be TOKEN_2022_PROGRAM_ID const isT22 = info.owner?.toBase58?.() === TOKEN_2022_PROGRAM_ID.toBase58() //if (!isT22) { - //console.warn("mint not owned by TOKEN_2022", mk.toBase58(), info.owner?.toBase58?.()); + //console.warn("mint not owned by TOKEN_2022", mk.toBase58(), info.owner?.toBase58?.()); //} // 2) List all extensions present @@ -1344,7 +1652,7 @@ export class SolanaService extends Service { } else { // spl token const buf = info!.data as Buffer; - const u8 = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); + 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); @@ -1455,7 +1763,7 @@ export class SolanaService extends Service { if (!need(off)) return; const offObj = { off }; - const name = readBorshStringSafe(data, offObj); + const name = readBorshStringSafe(data, offObj); const symbol = readBorshStringSafe(data, offObj); /* const uri = */ readBorshStringSafe(data, offObj); @@ -1663,8 +1971,8 @@ export class SolanaService extends Service { // background slow save (async () => { console.time('saveCache') - for(const t of results) { - const copy: any = {...t} + for (const t of results) { + const copy: any = { ...t } delete copy.balanceUi delete copy.mint const key = 'solana_token_meta_' + t.mint @@ -1722,7 +2030,7 @@ export class SolanaService extends Service { } } */ - for(const mint in goodCache) { + for (const mint in goodCache) { out[mint] = goodCache[mint] } @@ -1734,246 +2042,342 @@ export class SolanaService extends Service { // MARK: wallets // - // - // MARK: agent wallet - // + // + // MARK: agent wallet + // - /** - * Asynchronously fetches token accounts for a specific owner. - * - * @returns {Promise} A promise that resolves to an array of token accounts. - */ - private async getTokenAccounts() { - const publicKey = await this.ensurePublicKey(); - if (!publicKey) return null; - return this.getTokenAccountsByKeypair(publicKey); - } - - /** - * Gets the wallet keypair for operations requiring private key access - * @returns {Promise} The wallet keypair - * @throws {Error} If private key is not available - */ - public async getWalletKeypair(): Promise { - const keypair = await this.ensureKeypair(); - if (!keypair) { - throw new Error('Failed to get wallet keypair'); - } - return keypair; - } - - /** - * Retrieves the public key of the instance. - * - * @returns {Promise} The public key of the instance. - */ - public async getPublicKey(): Promise { - return await this.ensurePublicKey(); - } - - /** - * 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 - */ - public async updateWalletData(force = false): Promise { - //console.log('updateWalletData - start') - const now = Date.now(); - - const publicKey = await this.ensurePublicKey(); - if (!publicKey) { - // can't be warn if we fire every start up - // maybe we just get the pubkey here proper - // or fall back to SOLANA_PUBLIC_KEY - logger.log('solana::updateWalletData - no Public Key yet'); - return { totalUsd: '0', items: [] }; + /** + * Asynchronously fetches token accounts for a specific owner. + * + * @returns {Promise} A promise that resolves to an array of token accounts. + */ + private async getTokenAccounts() { + const publicKey = await this.ensurePublicKey(); + if (!publicKey) return null; + return this.getTokenAccountsByKeypair(publicKey); + } + + /** + * Gets the wallet keypair for operations requiring private key access + * @returns {Promise} The wallet keypair + * @throws {Error} If private key is not available + */ + public async getWalletKeypair(): Promise { + const keypair = await this.ensureKeypair(); + if (!keypair) { + throw new Error('Failed to get wallet keypair'); + } + return keypair; + } + + /** + * Retrieves the public key of the instance. + * + * @returns {Promise} The public key of the instance. + */ + public async getPublicKey(): Promise { + return await this.ensurePublicKey(); + } + + /** + * Retrieves cached wallet portfolio data from the database adapter. + * @returns A promise that resolves with the cached WalletPortfolio data if available, otherwise resolves with null. + */ + public async getCachedData(): Promise { + const cachedValue = await this.runtime.getCache(SOLANA_WALLET_DATA_CACHE_KEY); + if (cachedValue) { + return cachedValue; + } + return null; + } + + /** + * 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 + */ + public async updateWalletData(force = false): Promise { + //console.log('updateWalletData - start') + const now = Date.now(); + + const publicKey = await this.ensurePublicKey(); + if (!publicKey) { + // can't be warn if we fire every start up + // maybe we just get the pubkey here proper + // or fall back to SOLANA_PUBLIC_KEY + logger.log('solana::updateWalletData - no Public Key yet'); + return { totalUsd: '0', items: [] }; + } + + // Check cache if not forcing update + if (!force && now - this.lastUpdate < this.UPDATE_INTERVAL) { + const cached = await this.runtime.getCache(SOLANA_WALLET_DATA_CACHE_KEY); + if (cached) { + return cached; } + } - //console.log('updateWalletData - force', force, 'last', this.lastUpdate, 'UPDATE_INTERVAL', this.UPDATE_INTERVAL) - // 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 { + // Basic implementation - fetch token accounts and construct portfolio + const accounts = await this.getTokenAccounts(); + if (!accounts || accounts.length === 0) { + const emptyPortfolio: WalletPortfolio = { + totalUsd: '0', + items: [], + }; + await this.runtime.setCache(SOLANA_WALLET_DATA_CACHE_KEY, emptyPortfolio); + this.lastUpdate = now; + return emptyPortfolio; } - //console.log('updateWalletData - fetch') - try { - // Try Birdeye API first - const birdeyeApiKey = this.runtime.getSetting('BIRDEYE_API_KEY'); - if (birdeyeApiKey) { - try { - const walletData = await this.birdeyeFetchWithRetry( - `${PROVIDER_CONFIG.BIRDEYE_API}/v1/wallet/token_list?wallet=${publicKey.toBase58()}` - ) as any; - // only good for checking envelope - //console.log('walletData', walletData) - - 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 missingSymbols = data.items.filter((i: any) => !i.symbol) - - //console.log('data.items', data.items) - if (missingSymbols.length) { - 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] - if (symbols[item.address]) { - data.items[i].symbol = symbols[item.address] - } else { - console.log('solana::updateWalletData - no symbol for', item.address, symbols[item.address]) - missing = true - } - } - if (missing) { - console.log('symbols', symbols) + // Get token metadata + const tokenMetadata = await this.parseTokenAccounts(accounts); + + const items: Item[] = accounts.map((acc: any) => { + const mint = acc.account.data.parsed.info.mint; + const metadata = tokenMetadata[mint]; + + this.decimalsCache.set(mint, acc.account.data.parsed.info.tokenAmount.decimals); + + return { + name: metadata?.symbol || 'Unknown', + address: mint, + symbol: metadata?.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() || '0', + priceUsd: '0', + valueUsd: '0', + valueSol: '0', + }; + }); + + const portfolio: WalletPortfolio = { + totalUsd: '0', + items, + lastUpdated: now, + }; + + await this.runtime.setCache(SOLANA_WALLET_DATA_CACHE_KEY, portfolio); + this.lastUpdate = now; + return portfolio; + } catch (error) { + logger.error('Error updating wallet data:', error instanceof Error ? error.message : String(error)); + return { totalUsd: '0', items: [] }; + } + } + + // deprecated + /* + public async getBalanceByAddr(walletAddressStr: string): Promise { + try { + // Try Birdeye API first + const birdeyeApiKey = this.runtime.getSetting('BIRDEYE_API_KEY'); + if (birdeyeApiKey) { + try { + const walletData = await this.birdeyeFetchWithRetry( + `${PROVIDER_CONFIG.BIRDEYE_API}/v1/wallet/token_list?wallet=${publicKey.toBase58()}` + ) as any; + // only good for checking envelope + //console.log('walletData', walletData) + + // only get SOL balance + public async getBalancesByAddrs(walletAddressArr: string[]): Promise> { + try { + //console.log('walletAddressArr', walletAddressArr) + const publicKeyObjs = walletAddressArr.map(k => new PublicKey(k)); + //console.log('getBalancesByAddrs - getMultipleAccountsInfo') + const accounts = await this.batchGetMultipleAccountsInfo(publicKeyObjs, 'getBalancesByAddrs'); + + + const missingSymbols = data.items.filter((i: any) => !i.symbol) + + //console.log('data.items', data.items) + if (missingSymbols.length) { + 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] + if (symbols[item.address]) { + data.items[i].symbol = symbols[item.address] + } else { + console.log('solana::updateWalletData - no symbol for', item.address, symbols[item.address]) + missing = true } } - - const portfolio: WalletPortfolio = { - totalUsd: totalUsd.toString(), - totalSol: totalUsd.div(solPriceInUSD).toFixed(6), - prices, - lastUpdated: now, - items: data.items.map((item: Item) => ({ - ...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', - })), - }; - - //console.log('saving portfolio', portfolio.items.length, 'tokens') - - // maybe should be keyed by public key - await this.runtime.setCache(SOLANA_WALLET_DATA_CACHE_KEY, portfolio); - this.lastUpdate = now; - return portfolio; + if (missing) { + console.log('updateWalletData - has missing, symbols table:', symbols) + } } - } catch (e) { - console.log('solana::updateWalletData - exception err', e); - } - } - // Fallback to basic token account info (without Birdeye) - logger.log('Using RPC fallback for wallet data (no Birdeye)'); - const accounts = await this.getTokenAccounts(); - if (!accounts || accounts.length === 0) { - logger.log('No token accounts found'); - const emptyPortfolio: WalletPortfolio = { - totalUsd: '0', - totalSol: '0', - items: [], - }; - await this.runtime.setCache(SOLANA_WALLET_DATA_CACHE_KEY, emptyPortfolio); - this.lastUpdate = now; - return emptyPortfolio; + const portfolio: WalletPortfolio = { + totalUsd: totalUsd.toString(), + totalSol: totalUsd.div(solPriceInUSD).toFixed(6), + prices, + lastUpdated: now, + items: data.items.map((item: Item) => ({ + ...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', + })), + }; + + //console.log('saving portfolio', portfolio.items.length, 'tokens') + + // maybe should be keyed by public key + await this.runtime.setCache(SOLANA_WALLET_DATA_CACHE_KEY, portfolio); + this.lastUpdate = now; + return portfolio; + } + } catch (e) { + console.log('solana::updateWalletData - exception err', e); } + } - // Get token metadata (symbols) using parseTokenAccounts - const tokenMetadata = await this.parseTokenAccounts(accounts); - - const items: Item[] = accounts.map((acc: any) => { - const mint = acc.account.data.parsed.info.mint; - const metadata = tokenMetadata[mint]; - - this.decimalsCache.set(mint, acc.account.data.parsed.info.tokenAmount.decimals); - - return { - name: metadata?.symbol || 'Unknown', - address: mint, - symbol: metadata?.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', - }; - }); - - logger.log(`Fallback mode: Found ${items.length} tokens in wallet`); - - const portfolio: WalletPortfolio = { + // Fallback to basic token account info (without Birdeye) + logger.log('Using RPC fallback for wallet data (no Birdeye)'); + const accounts = await this.getTokenAccounts(); + if (!accounts || accounts.length === 0) { + logger.log('No token accounts found'); + const emptyPortfolio: WalletPortfolio = { totalUsd: '0', totalSol: '0', - items, + items: [], }; - - await this.runtime.setCache(SOLANA_WALLET_DATA_CACHE_KEY, portfolio); + await this.runtime.setCache(SOLANA_WALLET_DATA_CACHE_KEY, emptyPortfolio); this.lastUpdate = now; - return portfolio; - } catch (error) { - logger.error(`Error updating wallet data: ${error}`); - throw error; + return emptyPortfolio; } + + // Get token metadata (symbols) using parseTokenAccounts + const tokenMetadata = await this.parseTokenAccounts(accounts); + + const items: Item[] = accounts.map((acc: any) => { + const mint = acc.account.data.parsed.info.mint; + const metadata = tokenMetadata[mint]; + + this.decimalsCache.set(mint, acc.account.data.parsed.info.tokenAmount.decimals); + + return { + name: metadata?.symbol || 'Unknown', + address: mint, + symbol: metadata?.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', + }; + }); + + logger.log(`Fallback mode: Found ${items.length} tokens in wallet`); + + const portfolio: WalletPortfolio = { + totalUsd: '0', + totalSol: '0', + items, + }; + + await this.runtime.setCache(SOLANA_WALLET_DATA_CACHE_KEY, portfolio); + this.lastUpdate = now; + return portfolio; + } catch (error) { + logger.error(`Error updating wallet data: ${error}`); + throw error; } + } - /** - * Retrieves cached wallet portfolio data from the database adapter. - * @returns A promise that resolves with the cached WalletPortfolio data if available, otherwise resolves with null. - */ - public async getCachedData(): Promise { - const cachedValue = await this.runtime.getCache(SOLANA_WALLET_DATA_CACHE_KEY); - if (cachedValue) { - return cachedValue; - } - return null; + // we might want USD price and other info... + async walletAddressToHumanString(pubKey: string): Promise { + let balanceStr = '' + // get wallet contents + const pubKeyObj = new PublicKey(pubKey) + + const [balances, heldTokens] = await Promise.all([ + this.getBalancesByAddrs([pubKey]), + this.getTokenAccountsByKeypair(pubKeyObj), + ]); + const solBal = balances[pubKey] + + balanceStr += 'Wallet Address: ' + pubKey + '\n' + balanceStr += ' Token Address (Symbol)\n' + balanceStr += ' So11111111111111111111111111111111111111111 ($sol) balance: ' + (solBal ?? 'unknown') + '\n' + const tokens = await this.parseTokenAccounts(heldTokens) // options + for (const ca in tokens) { + const t = tokens[ca] + balanceStr += ' ' + ca + ' ($' + t.symbol + ') balance: ' + t.balanceUi + '\n' } + balanceStr += '\n' + return balanceStr + } - /** - * Forces an update of the wallet data and returns the updated WalletPortfolio object. - * @returns A promise that resolves with the updated WalletPortfolio object. - */ - public async forceUpdate(): Promise { - return await this.updateWalletData(true); + async walletAddressToLLMString(pubKey: string): Promise { + let balanceStr = '' + // get wallet contents + const pubKeyObj = new PublicKey(pubKey) + const [balances, heldTokens] = await Promise.all([ + this.getBalancesByAddrs([pubKey]), + this.getTokenAccountsByKeypair(pubKeyObj), + ]); + //console.log('balances', balances) + const solBal = balances[pubKey] + balanceStr += 'Wallet Address: ' + pubKey + '\n' + balanceStr += 'Current wallet contents in csv format:\n' + balanceStr += 'Token Address,Symbol,Balance\n' + balanceStr += 'So11111111111111111111111111111111111111111,sol,' + (solBal ?? 'unknown') + '\n' + const tokens = await this.parseTokenAccounts(heldTokens) // options + for (const ca in tokens) { + const t = tokens[ca] + balanceStr += ca + ',' + t.symbol + ',' + t.balanceUi + '\n' } + balanceStr += '\n' + return balanceStr + } - // - // MARK: any wallet - // + // + // MARK: any wallet + // - /** - * Creates a new Solana wallet by generating a keypair - * @returns {Promise<{publicKey: string, privateKey: string}>} Object containing base58-encoded public and private keys - */ - public async createWallet(): Promise<{ publicKey: string; privateKey: string }> { - try { - // Generate new keypair - const newKeypair = Keypair.generate(); + // 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)); + const atAs = walletPubkeys.map(w => getAssociatedTokenAddressSync(mint, w)); + const balances: Record = {}; - // Convert to base58 strings for secure storage - const publicKey = newKeypair.publicKey.toBase58(); - const privateKey = bs58.encode(newKeypair.secretKey); + // fetch mint decimals once + const decimals = await this.getDecimal(mint); - // Clear the keypair from memory - newKeypair.secretKey.fill(0); + // fetch ATAs in batches + const infos = await this.batchGetMultipleAccountsInfo(atAs, 'getTokenBalanceForWallets'); - return { - publicKey, - privateKey, - }; - } catch (error) { - logger.error(`Error creating wallet: ${error}`); - throw new Error('Failed to create new wallet'); - } + return { + publicKey, + privateKey, + }; + } catch (error) { + logger.error(`Error creating wallet: ${error}`); + throw new Error('Failed to create new wallet'); } + } -/* - for (const t of haveTokens) { - const amountRaw = t.account.data.parsed.info.tokenAmount.amount; - const ca = new PublicKey(t.account.data.parsed.info.mint); - const decimals = t.account.data.parsed.info.tokenAmount.decimals; - const balance = Number(amountRaw) / (10 ** decimals); - const symbol = await solanaService.getTokenSymbol(ca); -*/ + public getPubkeyFromSecret(privateKeyB58: string): string { + const secretKey = bs58.decode(privateKeyB58); + const keypair = Keypair.fromSecretKey(secretKey); + return keypair.publicKey.toBase58() + } + + /* + for (const t of haveTokens) { + const amountRaw = t.account.data.parsed.info.tokenAmount.amount; + const ca = new PublicKey(t.account.data.parsed.info.mint); + const decimals = t.account.data.parsed.info.tokenAmount.decimals; + const balance = Number(amountRaw) / (10 ** decimals); + const symbol = await solanaService.getTokenSymbol(ca); + */ public async getTokenAccountsByKeypair(walletAddress: PublicKey, options: { notOlderThan?: number; includeZeroBalances?: boolean; } = {}): Promise { //console.log('getTokenAccountsByKeypair', walletAddress.toString()) //console.log('publicKey', this.publicKey, 'vs', walletAddress) @@ -2017,7 +2421,7 @@ export class SolanaService extends Service { // update decimalCache const haveAllTokens: KeyedParsedTokenAccount[] = [] - for(const t of allTokens) { + for (const t of allTokens) { const { amount, decimals } = t.account.data.parsed.info.tokenAmount; this.decimalsCache.set(t.account.data.parsed.info.mint, decimals); // filter out zero balances (if not includeZeroBalances) @@ -2037,16 +2441,18 @@ export class SolanaService extends Service { }) return haveAllTokens } catch (error) { - logger.error(`Error fetching token accounts: ${error}`); + logger.error('Error fetching token accounts:', error instanceof Error ? error.message : String(error)); return []; } } - public async getTokenAccountsByKeypairs(walletAddresses: string[], options = {}): Promise> { + 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] + const out: Record = {} + for (const i in walletAddresses) { + if (walletAddresses[i]) { + out[walletAddresses[i]] = res[i] ?? null + } } return out } @@ -2076,11 +2482,15 @@ export class SolanaService extends Service { //console.log('getBalancesByAddrs - accounts', accounts) const out: Record = {} - for(const i in accounts) { + for (const i in accounts) { const a = accounts[i] // lamports, data, owner, executable, rentEpoch, space //console.log('a', a) const pk = walletAddressArr[i] + if (!pk) { + this.runtime.logger.warn('getBalancesByAddrs - no publicKey for index ' + i) + continue + } if (a?.lamports) { out[pk] = a.lamports * SolanaService.LAMPORTS2SOL } else { @@ -2104,172 +2514,13 @@ export class SolanaService extends Service { } } - // we might want USD price and other info... - async walletAddressToHumanString(pubKey: string): Promise { - let balanceStr = '' - // get wallet contents - const pubKeyObj = new PublicKey(pubKey) - - const [balances, heldTokens] = await Promise.all([ - this.getBalancesByAddrs([pubKey]), - this.getTokenAccountsByKeypair(pubKeyObj), - ]); - const solBal = balances[pubKey] - - balanceStr += 'Wallet Address: ' + pubKey + '\n' - balanceStr += ' Token Address (Symbol)\n' - balanceStr += ' So11111111111111111111111111111111111111111 ($sol) balance: ' + (solBal ?? 'unknown') + '\n' - const tokens = await this.parseTokenAccounts(heldTokens) // options - for (const ca in tokens) { - const t = tokens[ca] - balanceStr += ' ' + ca + ' ($' + t.symbol + ') balance: ' + t.balanceUi + '\n' - } - balanceStr += '\n' - return balanceStr - } - - async walletAddressToLLMString(pubKey: string): Promise { - let balanceStr = '' - // get wallet contents - const pubKeyObj = new PublicKey(pubKey) - const [balances, heldTokens] = await Promise.all([ - this.getBalancesByAddrs([pubKey]), - this.getTokenAccountsByKeypair(pubKeyObj), - ]); - //console.log('balances', balances) - const solBal = balances[pubKey] - balanceStr += 'Wallet Address: ' + pubKey + '\n' - balanceStr += 'Current wallet contents in csv format:\n' - balanceStr += 'Token Address,Symbol,Balance\n' - balanceStr += 'So11111111111111111111111111111111111111111,sol,' + (solBal ?? 'unknown') + '\n' - const tokens = await this.parseTokenAccounts(heldTokens) // options - for (const ca in tokens) { - const t = tokens[ca] - balanceStr += ca + ',' + t.symbol + ',' + t.balanceUi + '\n' - } - balanceStr += '\n' - return balanceStr - } - - // - // 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)); - const atAs = walletPubkeys.map(w => getAssociatedTokenAddressSync(mint, w)); - const balances: Record = {}; - - // fetch mint decimals once - const decimals = await this.getDecimal(mint); - - // fetch ATAs in batches - const infos = await this.batchGetMultipleAccountsInfo(atAs, 'getTokenBalanceForWallets'); - - infos.forEach((info, idx) => { - const walletKey = walletPubkeys[idx].toBase58(); - let uiAmount = 0; - - if (info?.data) { - const account = unpackAccount(atAs[idx], info); - // address, mint, owner, amount, delegate, delegatedAmount, isInitiailized, isFrozen, isNative - // rentExemptReserve, closeAuthority, tlvData - const raw = account.amount; // bigint - uiAmount = Number(raw) / 10 ** decimals; - } - - balances[walletKey] = uiAmount; - }); - - return balances; - } - /** * Subscribes to account changes for the given public key * @param {string} accountAddress - The account address to subscribe to + * @param {Function} callback - Callback function to call when account changes * @returns {Promise} Subscription ID */ - // needs to take a handler... - public async subscribeToAccount(accountAddress: string, handler: any): Promise { + public async subscribeToAccount(accountAddress: string, callback?: () => Promise): Promise { try { if (!this.validateAddress(accountAddress)) { throw new Error('Invalid account address'); @@ -2312,8 +2563,10 @@ export class SolanaService extends Service { }); */ const accountPubkeyObj = new PublicKey(accountAddress); - const subscriptionId = this.connection.onAccountChange(accountPubkeyObj, (accountInfo, context) => { - handler(accountAddress, accountInfo, context) + const subscriptionId = this.connection.onAccountChange(accountPubkeyObj, async (accountInfo, context) => { + if (callback) { + await callback(); + } }, 'finalized') @@ -2438,7 +2691,7 @@ export class SolanaService extends Service { 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) { + for (const wallet of wallets) { const pubKey = wallet.keypair.publicKey.toString() try { @@ -2457,7 +2710,7 @@ export class SolanaService extends Service { // balance check to protect quote rate limit const balances = await this.getBalancesByAddrs([pubKey]) - const bal = balances[pubKey] + const bal = balances[pubKey] ?? 0 //console.log('executeSwap -', wallet.keypair.publicKey, 'bal', bal) // 0.000748928 @@ -2602,8 +2855,6 @@ export class SolanaService extends Service { // Deserialize, sign, and send const txBuffer = Buffer.from(swapResponse.swapTransaction as string, 'base64'); const transaction = VersionedTransaction.deserialize(Uint8Array.from(txBuffer)); - transaction.sign([keypair]); - //transaction.sign(...keypairs); not [keypairs] // Getting recent blockhash too slow for Solana/Jupiter /* @@ -2627,60 +2878,10 @@ export class SolanaService extends Service { }); }); */ + const txid = await this.sendTx(transaction, [keypair]).catch(e => { + console.error('executeSwap tx err', e.slippage, e) + }) ?? '' - // Send and confirm - let txid = '' - try { - txid = await this.connection.sendRawTransaction(transaction.serialize()); - } catch (err) { - if (err instanceof SendTransactionError) { - // getLogs expects param? - const logs = err.logs || await err.getLogs(this.connection); - - let showLogs = true - - if (logs) { - if (logs.some(l => l.includes('custom program error: 0x1771'))) { - console.log(`Swap failed: slippage tolerance exceeded. ${impliedSlippageBps}`); - // handle slippage - // 🎯 You could retry with higher slippage or log for the user - - // increment the slippage? and try again? - if (signal.targetTokenCA === 'So11111111111111111111111111111111111111112') { - // sell parameters - if (impliedSlippageBps < 3000) { - // let jupiter swap api rest - await new Promise((resolve) => setTimeout(resolve, 1000)); - // double and try again - return executeSwap(impliedSlippageBps * 2) - } - // just fail - } else { - // buy parameters - // we don't need to pay more - // but we can retry - showLogs = false - } - } - - if (logs.some(l => l.includes('insufficient lamports'))) { - console.log('Transaction failed: insufficient lamports in the account.'); - // optionally prompt user to top up SOL - } - - if (logs.some(l => l.includes('Program X failed: custom program error'))) { - console.log('Custom program failure detected.'); - // further custom program handling - } - - if (showLogs) { - console.log('logs', logs) - } - } - - } - throw err; - } console.log(pubKey, signal.sourceTokenCA, signal.targetTokenCA, 'txid', txid) // should probably always log this // swapResponse is of value return txid @@ -2693,7 +2894,7 @@ export class SolanaService extends Service { //console.log('finalized') // Get transaction details including fees - const txDetails = await this.connection.getTransaction(txid, { + const txDetails = await this.connection.getParsedTransaction(txid, { commitment: 'confirmed', maxSupportedTransactionVersion: 0 }); @@ -2850,6 +3051,225 @@ export class SolanaService extends Service { return swapResponses; } + /* + // jupiter sends versionedTransaction making this a one line utilty + signTx(keypairs: Keypair[], transaction: VersionedMessage): VersionedTransaction { + const versionedTransaction = new VersionedTransaction(transaction); + versionedTransaction.sign(keypairs); + return versionedTransaction + } + */ + + /** + * Build and send a transaction with instructions + * @param instructions Transaction instructions + * @param signers Keypairs to sign with + * @param feePayer Optional fee payer (defaults to service wallet) + * @returns Transaction signature + */ + private async buildAndSendTransaction( + instructions: TransactionInstruction[], + signers: Keypair[], + feePayer?: PublicKey + ): Promise { + const payerKey = feePayer || await this.getPublicKey(); + if (!payerKey) { + throw new Error('No fee payer available for transaction'); + } + + const messageV0 = new TransactionMessage({ + payerKey, + recentBlockhash: (await this.connection.getLatestBlockhash()).blockhash, + instructions, + }).compileToV0Message(); + + const versionedTransaction = new VersionedTransaction(messageV0); + return this.sendTx(versionedTransaction, signers); + } + + /** + * Transfers SOL from a specified keypair to a public key. + * @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 { + const serviceKeypair = await this.getWalletKeypair(); + + const instruction = SystemProgram.transfer({ + fromPubkey: from.publicKey, + toPubkey: to, + lamports, + }); + + return this.buildAndSendTransaction([instruction], [from, serviceKeypair]); + } + + /** + * Transfers SPL tokens from a specified keypair to a public key. + * @param {Keypair} from - The keypair of the account to send tokens from. + * @param {PublicKey} to - The public key of the account to send tokens to. + * @param {PublicKey} mint - The mint address of the token to transfer. + * @param {number} amount - The amount of tokens to send (in token units, not lamports). + * @returns {Promise} The transaction signature. + * @throws {Error} If the transfer fails. + */ + public async transferSplToken(from: Keypair, to: PublicKey, mint: PublicKey, amount: number): Promise { + const serviceKeypair = await this.getWalletKeypair(); + + // Get token decimals + const decimals = await this.getDecimal(mint); + const adjustedAmount = BigInt(amount * 10 ** decimals); + + // Get associated token addresses + const senderATA = getAssociatedTokenAddressSync(mint, from.publicKey); + const recipientATA = getAssociatedTokenAddressSync(mint, to); + + const instructions: TransactionInstruction[] = []; + + // Check if recipient ATA exists, create if not + const recipientATAInfo = await this.connection.getAccountInfo(recipientATA); + if (!recipientATAInfo) { + instructions.push( + createAssociatedTokenAccountInstruction( + from.publicKey, + recipientATA, + to, + mint + ) + ); + } + + // Add transfer instruction + instructions.push( + createTransferInstruction( + senderATA, + recipientATA, + from.publicKey, + adjustedAmount + ) + ); + + return this.buildAndSendTransaction(instructions, [from, serviceKeypair]); + } + + /** + * Execute custom transaction with provided instructions (advanced usage) + * @param from Sender keypair + * @param instructions Array of transaction instructions + * @returns Transaction signature + */ + public async executeCustomTransaction( + from: Keypair, + instructions: TransactionInstruction[] + ): Promise { + const serviceKeypair = await this.getWalletKeypair(); + return this.buildAndSendTransaction(instructions, [from, serviceKeypair]); + } + + async sendTx( + versionedTransaction: VersionedTransaction, + signersOrSlippage?: Keypair[] | number, + slippageBps?: number + ): Promise { + // Handle overloaded parameters for backward compatibility + let signers: Keypair[] | undefined; + let slippage: number; + + if (Array.isArray(signersOrSlippage)) { + signers = signersOrSlippage; + slippage = slippageBps ?? 200; + } else { + slippage = signersOrSlippage ?? 200; + } + + // Sign if signers provided + if (signers && signers.length > 0) { + versionedTransaction.sign(signers); + } + /* + const signature = await this.connection.sendTransaction(versionedTransaction, { + skipPreflight: false, + }); + */ + + // Send and confirm + let signature = '' + try { + // transaction.serialize() + //signature = await this.connection.sendRawTransaction(versionedTransaction.serialize()); + // skipPreflight: skip simulate + signature = await this.connection.sendTransaction(versionedTransaction, { + skipPreflight: false, + }); + } catch (err) { + if (err instanceof SendTransactionError) { + // getLogs expects param? + const logs = err.logs || await err.getLogs(this.connection); + + let showLogs = true + + if (logs) { + if (logs.some(l => l.includes('custom program error: 0x1771'))) { + console.log(`Swap failed: slippage tolerance exceeded. ${slippage}`); + // handle slippage + // 🎯 You could retry with higher slippage or log for the user + + // increment the slippage? and try again? + err.cause = { + slippage: true + } + /* + if (signal.targetTokenCA === 'So11111111111111111111111111111111111111112') { + // sell parameters + if (slippageBps < 3000) { + // let jupiter swap api rest + await new Promise((resolve) => setTimeout(resolve, 1000)); + // double and try again + return executeSwap(slippageBps * 2) + } + // just fail + } else { + // buy parameters + // we don't need to pay more + // but we can retry + showLogs = false + } + */ + } + + if (logs.some(l => l.includes('insufficient lamports'))) { + console.log('Transaction failed: insufficient lamports in the account.'); + // optionally prompt user to top up SOL + } + + if (logs.some(l => l.includes('Program X failed: custom program error'))) { + console.log('Custom program failure detected.'); + // further custom program handling + } + + if (showLogs) { + console.log('logs', logs) + } + } + + } + throw err; + } + + + const confirmation = await this.connection.confirmTransaction(signature, 'confirmed'); + if (confirmation.value.err) { + throw new Error( + `Transaction confirmation failed: ${JSON.stringify(confirmation.value.err)}` + ); + } + + return signature + } + /** * Starts the Solana service with the given agent runtime. *