diff --git a/package.json b/package.json index 59ae261..55ba85d 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,11 @@ ], "dependencies": { "@coral-xyz/anchor": "0.30.1", - "@elizaos/plugin-tee": "workspace:*", - "@elizaos/plugin-trustdb": "workspace:*", + "@elizaos/core": "0.25.8", + "@elizaos/plugin-tee": "^0.25.6-alpha.1", + "@elizaos/plugin-trustdb": "^0.25.6-alpha.1", + "@marinade.finance/marinade-ts-sdk": "^4.0.2", + "@solana/spl-stake-pool": "1.1.8", "@solana/spl-token": "0.4.9", "@solana/web3.js": "npm:@solana/web3.js@1.95.8", "bignumber.js": "9.1.2", @@ -31,7 +34,8 @@ "pumpdotfun-sdk": "1.3.2", "solana-agent-kit": "^1.4.0", "tsup": "8.3.5", - "vitest": "2.1.9" + "vitest": "2.1.9", + "zod": "^3.24.2" }, "devDependencies": { "@biomejs/biome": "1.5.3", diff --git a/src/actions/liquidStake.ts b/src/actions/liquidStake.ts new file mode 100644 index 0000000..ced392c --- /dev/null +++ b/src/actions/liquidStake.ts @@ -0,0 +1,485 @@ +// Import required dependencies from Solana web3 and other libraries +import { + Connection, + PublicKey, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import { depositSol } from "@solana/spl-stake-pool"; +import { + ActionExample, + composeContext, + elizaLogger, + generateObject, + HandlerCallback, + IAgentRuntime, + Memory, + ModelClass, + State, +} from "@elizaos/core"; +import bs58 from "bs58"; +import { + Marinade, + MarinadeConfig, + MarinadeUtils, +} from "@marinade.finance/marinade-ts-sdk"; +import { + SelectedPool, + SelectedPoolSchema, + StakeConfig, + StakeParams, + StakeParamsSchema, + StakePool, + StakePoolsType, +} from "../types/stake"; +import { StakeProtocolProvider } from "../providers/stakeProtocol"; + +// Initialize Solana connection to mainnet +const connection = new Connection("https://api.mainnet-beta.solana.com"); + +// Initialize stake protocol provider with connection +const provider = new StakeProtocolProvider(); + +// Define available stake pools with their addresses and protocol names +const DEFAULT_POOL_LIST: StakePoolsType = { + jito: { + address: "Jito4APyf642JPZPx3hGc6WWJ8zPKtRbRs4P815Awbb", + protocolName: "Jito", + }, + blaze: { + address: "stk9ApL5HeVAwPLr3TLhDXdZS8ptVu7zp6ov8HFDuMi", + protocolName: "Blaze", + }, + marginfi: { + address: "DqhH94PjkZsjAqEze2BEkWhFQJ6EyU6MdtMphMgnXqeK", + protocolName: "Marginfi", + }, + jpool: { + address: "CtMyWsrUtAwXWiGr9WjHT5fC3p3fgV8cyGpLTo2LJzG1", + protocolName: "JPool", + }, + marinade: { + address: "MckGXZC1GbLqTK1vaSWsjRvWg5G3tj8hpXfaHYBqqKy", + protocolName: "Marinade", + }, +}; + +// Configuration for stake action including name and alternative command phrases +const config: StakeConfig = { + name: "STAKE_SOL", + similes: [ + "STAKE_LIQUID_SOL", + "STAKE_SOL_IN_JITO", + "STAKE_SOL_IN_BLAZE", + "STAKE_SOL_IN_MARGINFI", + "STAKE_SOL_IN_JPOOL", + "STAKE_SOL_IN_MARINADE", + "STAKE_BLAZE_SOL", + "STAKE_MARGINFI_SOL", + "STAKE_JITO_SOL", + "STAKE_JPOOL_SOL", + "STAKE_MARINADE_SOL", + ], +}; + +// Type guard to validate stake parameters +function isStakeParams(content: any): content is StakeParams { + elizaLogger.log("Content for stake sol", content); + return ( + typeof content.userAddress === "string" && + (typeof content.amountSol === "number" || + typeof content.amountSol === "string") + ); +} + +/** + * Selects and validates a staking pool based on user input or automatic selection + * @param runtime Agent runtime environment + * @param state Current state + * @param poolName Optional pool name specified by user + * @param callback Optional callback for status updates + * @returns Selected pool info or error + */ +async function selectAndValidatePool( + runtime: IAgentRuntime, + state: State, + poolName: string | null, + poolList: StakePoolsType, + callback?: HandlerCallback +): Promise<{ pool: StakePool | null; error?: string }> { + let finalPoolName = poolName; + + // If no pool specified, select best pool based on metrics + if (finalPoolName === "" || finalPoolName === null) { + elizaLogger.log("Selecting best pool..."); + + state.poolData = await provider.getStakePoolInfo(runtime); + const selectionPoolContext = composeContext({ + state, + template: selectionPoolTemplate, + }); + + elizaLogger.info("Generating selection pool content..."); + const selectedPoolResp = await generateObject({ + runtime, + context: selectionPoolContext, + modelClass: ModelClass.MEDIUM, + schema: SelectedPoolSchema, + }); + + const { selectedPool, reason } = + selectedPoolResp.object as SelectedPool; + + elizaLogger.log(`Selected pool reason: ${reason}`); + + if (!selectedPool) { + elizaLogger.error("Invalid selected pool."); + if (callback) { + callback({ + text: "Unable to process stake request. Invalid selected pool.", + content: { + error: "Invalid selected pool", + }, + }); + } + return { pool: null, error: "Invalid selected pool" }; + } + finalPoolName = selectedPool; + } + + // Validate pool exists in config + const pool = poolList[finalPoolName.toLowerCase()]; + + if (!pool) { + elizaLogger.error(`Invalid pool name: ${finalPoolName}`); + if (callback) { + callback({ + text: `Invalid pool name: ${finalPoolName}. Please choose either "jito" or "blaze".`, + content: { + error: "Invalid pool name", + }, + }); + } + return { pool: null, error: "Invalid pool name" }; + } + + elizaLogger.log(`selected pool: ${pool.address} ${pool.protocolName}`); + return { pool }; +} + +// Main action handler for staking SOL +export default { + name: config.name, + similes: config.similes, + validate: async (_runtime: IAgentRuntime, _message: Memory) => { + // Basic validation - could be extended for more checks + return true; + }, + description: `Stake SOL in liquid Protocol. This action requires the user to provide the user address, amount of SOL to stake.`, + handler: async ( + runtime: IAgentRuntime, + message: Memory, + state: State, + _options: { [key: string]: unknown }, + callback?: HandlerCallback + ): Promise => { + elizaLogger.log(`Starting ${config.name} handler...`); + + let poolList; + const newPoolList = await provider.getStakePoolList(runtime); + + if (Object.keys(newPoolList).length !== 0) { + poolList = newPoolList; + } else { + poolList = DEFAULT_POOL_LIST; + } + + // Extract all the pool names and put them in a string array, then use them in the subsequent templates. + const poolNames = Object.keys(poolList).map( + (key) => poolList[key].protocolName + ); + + // Initialize or update state + if (!state) { + state = await runtime.composeState(message); + } else { + state = await runtime.updateRecentMessageState(state); + } + state.poolNames = poolNames; + + // Generate stake parameters from user input + const stakeContext = composeContext({ + state, + template: createStakeTemplate, + }); + + elizaLogger.info("Generating stake content..."); + + const contentFromSchema = await generateObject({ + runtime, + context: stakeContext, + modelClass: ModelClass.SMALL, + schema: StakeParamsSchema, + }); + + const content = contentFromSchema.object as StakeParams; + + // Validate stake parameters + if (!isStakeParams(content)) { + elizaLogger.error("Invalid content for stake action."); + if (callback) { + callback({ + text: "Unable to process stake request. Invalid content provided.", + content: { + error: "Invalid stake content", + }, + }); + } + return false; + } + + const userAddress = content.userAddress; + const amountSol = parseFloat(content.amountSol as string); + + // Select and validate staking pool + const poolResult = await selectAndValidatePool( + runtime, + state, + content.poolName, + poolList, + callback + ); + if (!poolResult.pool) { + return false; + } + + const userPublicKey = new PublicKey(userAddress); + const STAKE_POOL = new PublicKey(poolResult.pool.address); + let depositResult: { + transaction: VersionedTransaction; + rentFee: number; + }; + + try { + // Handle deposit based on protocol type + if (poolResult.pool.protocolName.toLowerCase() == "marinade") { + depositResult = await depositSolToMarinade( + connection, + userPublicKey, + amountSol + ); + } else { + depositResult = await depositSolToPool( + connection, + STAKE_POOL, + userPublicKey, + amountSol + ); + } + + elizaLogger.log("Preparing to sign transaction..."); + + // Serialize and encode transaction for transmission + const serializedTx = depositResult.transaction.serialize(); + const base58Tx = bs58.encode(serializedTx); + + const responseMsg = { + text: JSON.stringify({ + action: config.name, + amountSol, + userAddress, + protocol: poolResult.pool.protocolName, + transaction: base58Tx, + }), + }; + + callback?.(responseMsg); + + return true; + } catch (error) { + elizaLogger.error("Error during sol stake:", error); + if (callback) { + callback({ + text: `Error during staking: ${error.message}`, + }); + } + return false; + } + }, + examples: createStakeExamples(config), +}; + +/** + * Deposits SOL to Marinade Finance protocol + * @param connection Solana connection + * @param from User's public key + * @param amountSol Amount of SOL to stake + * @returns Transaction and rent fee + */ +export async function depositSolToMarinade( + connection: Connection, + from: PublicKey, + amountSol: number +): Promise<{ transaction: VersionedTransaction; rentFee: number }> { + const config = new MarinadeConfig({ + connection, + publicKey: from, + }); + const marinade = new Marinade(config); + + // Create deposit transaction + const { transaction } = await marinade.deposit( + MarinadeUtils.solToLamports(amountSol) + ); + + const instructions = transaction.instructions; + + const latestBlockhash = await connection.getLatestBlockhash(); + + // Compile transaction message + const msg = new TransactionMessage({ + payerKey: from, + recentBlockhash: latestBlockhash.blockhash, + instructions, + }).compileToLegacyMessage(); + + const txMsg = new VersionedTransaction(msg); + + return { + transaction: txMsg, + rentFee: 0, + }; +} + +/** + * Deposits SOL to a specified stake pool + * @param connection Solana connection + * @param stakePoolAddress Pool address + * @param from User's public key + * @param amountSol Amount of SOL to stake + * @returns Transaction and rent fee + */ +export async function depositSolToPool( + connection: Connection, + stakePoolAddress: PublicKey, + from: PublicKey, + amountSol: number +): Promise<{ transaction: VersionedTransaction; rentFee: number }> { + const lamports = MarinadeUtils.solToLamports(amountSol); + + // Create deposit instructions + const { instructions, signers } = await depositSol( + connection, + stakePoolAddress, + from, + lamports.toNumber() + ); + + const latestBlockhash = await connection.getLatestBlockhash(); + + // Compile transaction message + const msg = new TransactionMessage({ + payerKey: from, + recentBlockhash: latestBlockhash.blockhash, + instructions, + }).compileToLegacyMessage(); + + const transaction = new VersionedTransaction(msg); + + transaction.sign(signers); + + return { + transaction, + rentFee: 0, + }; +} + +// Create example interactions for documentation and testing +export function createStakeExamples(config: StakeConfig): ActionExample[][] { + const examples: ActionExample[][] = []; + + // Example with specific protocol + examples.push([ + { + user: "{{user1}}", + content: { + text: "Stake 2 SOL to Jito, my wallet address is: EugPwuZ8oUMWsYHeBGERWvELfLGFmA1taDtmY8uMeX6r", + action: config.name, + }, + }, + { + user: "{{user2}}", + content: { + text: "Successfully constructed the transaction from stake 2SOL to Jito, with the transaction base58 encoded as 5KtPn3DXXzHkb7VAVHZGwXJQww39ASnrf7YkyJoF2qAGEpBEEGvRHHLnnTG8ZVwKqNHMqScWVGnsQAgfH5pbxEb", + action: config.name, + }, + }, + ]); + + // Example without specific protocol (auto-selection) + examples.push([ + { + user: "{{user1}}", + content: { + text: "Stake 3.5 SOL, my wallet address is: EugPwuZ8oUMWsYHeBGERWvELfLGFmA1taDtmY8uMeX6r", + action: config.name, + }, + }, + { + user: "{{user2}}", + content: { + text: "The most suitable stake protocol blaze has been selected for you at present, and the base58 code for building the transaction is 5KtPn3DXXzHkb7VAVHZGwXJQww39ASnrf7YkyJoF2qAGEpBEEGvRHHLnnTG8ZVwKqNHMqScWVGnsQAgfH5pbxEb", + action: config.name, + }, + }, + ]); + + return examples; +} + +// Template for creating stake parameters from user input +const createStakeTemplate = `Respond with a JSON markdown block containing only the extracted values. Use null for any values that cannot be determined. + +Example response: +\\\`json +{ + "userAddress": "HqvTPqS2FaB2fQ7mxqZHQHz6H28o1u2Z9fRH5No8JN2W", + "amountSol": "1.5", + "poolName": "" +} +\\\` + +{{recentMessages}} + +Given the recent messages, extract or come up with (if not explicitly stated) the following information about the requested SOL staking: +- User Address for stake +- Amount of SOL to stake +- Pool name in {{poolNames}}. It may also be empty + +Respond with a JSON markdown block containing only the extracted values.`; + +// Template for selecting the best staking pool based on metrics +const selectionPoolTemplate = `Respond with a JSON markdown block containing only the extracted values. Use null for any values that cannot be determined. + +Example response: +\\\`json +{ + "selectedPool": "jpool", + "reason": "" +} +\\\` + +Given the pool data: +{{poolData}} + +Analyze the following factors to select the most suitable SOL staking protocol: +1. APY (Annual Percentage Yield) - Higher is better. +2. TVL (Total Value Locked) - Higher indicates more stability and liquidity. +3. If there is other data such as miningApy or airdropExpectation, analysis should also be conducted in combination with this data. +4. Protocol reliability and features. + +Select the best pool from these options: {{poolNames}}. + +State the reason for the selection. + +Respond with a JSON markdown block containing only the extracted values.`; diff --git a/src/index.ts b/src/index.ts index 0398a94..2b1c6bd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,21 +1,22 @@ -export * from "./providers/token.ts"; -export * from "./providers/wallet.ts"; -export * from "./providers/trustScoreProvider.ts"; -export * from "./evaluators/trust.ts"; +export * from "./providers/token"; +export * from "./providers/wallet"; +export * from "./providers/trustScoreProvider"; +export * from "./evaluators/trust"; import type { Plugin } from "@elizaos/core"; -import transferToken from "./actions/transfer.ts"; -import transferSol from "./actions/transfer_sol.ts"; -import { TokenProvider } from "./providers/token.ts"; -import { WalletProvider } from "./providers/wallet.ts"; -import { getTokenBalance, getTokenBalances } from "./providers/tokenUtils.ts"; -import { walletProvider } from "./providers/wallet.ts"; -import { trustScoreProvider } from "./providers/trustScoreProvider.ts"; -import { trustEvaluator } from "./evaluators/trust.ts"; -import { executeSwap } from "./actions/swap.ts"; +import transferToken from "./actions/transfer"; +import transferSol from "./actions/transfer_sol"; +import { TokenProvider } from "./providers/token"; +import { WalletProvider } from "./providers/wallet"; +import { getTokenBalance, getTokenBalances } from "./providers/tokenUtils"; +import { walletProvider } from "./providers/wallet"; +import { trustScoreProvider } from "./providers/trustScoreProvider"; +import { trustEvaluator } from "./evaluators/trust"; +import { executeSwap } from "./actions/swap"; import take_order from "./actions/takeOrder"; -import pumpfun from "./actions/pumpfun.ts"; -import fomo from "./actions/fomo.ts"; +import pumpfun from "./actions/pumpfun"; +import fomo from "./actions/fomo"; import { executeSwapForDAO } from "./actions/swapDao"; +import liquidStake from "./actions/liquidStake.js"; export { TokenProvider, WalletProvider, getTokenBalance, getTokenBalances }; export const solanaPlugin: Plugin = { name: "solana", @@ -28,8 +29,9 @@ export const solanaPlugin: Plugin = { fomo, executeSwapForDAO, take_order, + liquidStake, ], evaluators: [trustEvaluator], providers: [walletProvider, trustScoreProvider], }; -export default solanaPlugin; \ No newline at end of file +export default solanaPlugin; diff --git a/src/providers/stakeProtocol.ts b/src/providers/stakeProtocol.ts new file mode 100644 index 0000000..de1e9fa --- /dev/null +++ b/src/providers/stakeProtocol.ts @@ -0,0 +1,113 @@ +import { elizaLogger, IAgentRuntime, Provider } from "@elizaos/core"; +import { StakePoolsType, StakeProtocolData } from "../types/stake"; + +// Provider configuration for retry mechanism and caching +const PROVIDER_CONFIG = { + MAX_RETRIES: 3, // Maximum number of retry attempts for failed requests + RETRY_DELAY: 2000, // Delay between retries in milliseconds + CACHE_TTL: 300, // Cache time-to-live in seconds (5 minutes) +}; + +/** + * Class responsible for fetching and managing stake pool information + * Implements caching and retry mechanisms for reliable data fetching + */ +export class StakeProtocolProvider { + /** + * Fetches data from a URL with retry mechanism + * @param url - The URL to fetch data from + * @param options - Optional fetch configuration + * @returns Promise resolving to the parsed JSON response + */ + private async fetchWithRetry( + url: string, + options: RequestInit = {} + ): Promise { + let lastError: unknown; + for (let i = 0; i < PROVIDER_CONFIG.MAX_RETRIES; i++) { + try { + const response = await fetch(url, options); + if (!response.ok) { + elizaLogger.error("HTTP error:", response); + throw new Error(`HTTP error! status: ${response.status}`); + } + return await response.json(); + } catch (error) { + lastError = error; + elizaLogger.error("Error fetching data:", error); + await new Promise((resolve) => + setTimeout(resolve, PROVIDER_CONFIG.RETRY_DELAY) + ); + } + } + throw lastError; + } + + private async fetchPoolList( + runtime: IAgentRuntime + ): Promise { + const requestBaseUrl = + runtime.getSetting("STAKE_POOL_REQUEST_BASE_URL") ?? + "https://eliza-provider-api.stafi.io"; + const data = (await this.fetchWithRetry( + `${requestBaseUrl}/api/stake-pool-list` + )) as StakePoolsType; + + elizaLogger.log("Pool List:", JSON.stringify(data, null, 2)); + + return data; + } + + /** + * Fetches comprehensive pool data from all supported protocols + * Implements caching to avoid frequent API calls + * @returns Promise containing pool information and timestamp + */ + private async fetchPoolData( + runtime: IAgentRuntime + ): Promise { + const requestBaseUrl = + runtime.getSetting("STAKE_POOL_REQUEST_BASE_URL") ?? + "https://eliza-provider-api.stafi.io"; + + const data = (await this.fetchWithRetry( + `${requestBaseUrl}/api/stake-pool-info` + )) as StakeProtocolData; + + elizaLogger.log("Stake Pool Info:", JSON.stringify(data, null, 2)); + + return data; + } + + /** + * Public method to retrieve stake pool information + * @returns Promise with current pool data + */ + async getStakePoolInfo(runtime: IAgentRuntime): Promise { + return await this.fetchPoolData(runtime); + } + + async getStakePoolList(runtime: IAgentRuntime): Promise { + return await this.fetchPoolList(runtime); + } +} + +/** + * Provider implementation for stake protocol + * Exports a singleton instance that can be used to fetch stake pool data + */ +const stakeProtocolProvider: Provider = { + async get( + runtime: IAgentRuntime + ): Promise { + const provider = new StakeProtocolProvider(); + try { + return await provider.getStakePoolInfo(runtime); + } catch (error) { + elizaLogger.error("Error in stake protocol provider:", error); + return "Sorry, I couldn't fetch the stake protocol data at the moment."; + } + }, +}; + +export { stakeProtocolProvider }; diff --git a/src/types/stake.ts b/src/types/stake.ts new file mode 100644 index 0000000..0141864 --- /dev/null +++ b/src/types/stake.ts @@ -0,0 +1,52 @@ +import { Content } from "@elizaos/core"; +import { z } from "zod"; + +export const StakeParamsSchema = z.object({ + poolName: z.string().or(z.null()), + userAddress: z.string(), + amountSol: z.number().or(z.string()), +}); + +export interface StakeParams extends Content { + poolName: string; + userAddress: string; + amountSol: number | string; +} + +export const SelectedPoolSchema = z.object({ + selectedPool: z.string(), + reason: z.string(), +}); + +export interface SelectedPool extends Content { + selectedPool: string; + reason: string; +} + +export interface StakePool { + readonly address: string; + readonly protocolName: string; +} + +export type StakePoolsType = { + readonly [key: string]: StakePool; +}; + +export interface StakeConfig { + name: string; + similes: string[]; +} + +export interface StakePoolInfo { + totalApy: number; + tvl: number; + miningApy: number; + airdropExpectation: string; + protocolName: string; + extra?: Record; +} + +export interface StakeProtocolData { + pools: Record; + timestamp: number; +} \ No newline at end of file