diff --git a/.env.example b/.env.example index 5fea60a937..d116f9a306 100644 --- a/.env.example +++ b/.env.example @@ -185,3 +185,11 @@ WHATSAPP_API_VERSION=v17.0 # WhatsApp API version (default: v17.0) # ICP INTERNET_COMPUTER_PRIVATE_KEY= INTERNET_COMPUTER_ADDRESS= + +# NEAR +NEAR_WALLET_SECRET_KEY= +NEAR_WALLET_PUBLIC_KEY= +NEAR_ADDRESS= +SLIPPAGE=1 +RPC_URL=https://rpc.testnet.near.org +NEAR_NETWORK=testnet # or mainnet \ No newline at end of file diff --git a/agent/package.json b/agent/package.json index 766e0a5e1d..6f32e68747 100644 --- a/agent/package.json +++ b/agent/package.json @@ -37,6 +37,7 @@ "@ai16z/plugin-icp": "workspace:*", "@ai16z/plugin-tee": "workspace:*", "@ai16z/plugin-coinbase": "workspace:*", + "@ai16z/plugin-near": "workspace:*", "readline": "1.3.0", "ws": "8.18.0", "@ai16z/plugin-evm": "workspace:*", diff --git a/agent/src/index.ts b/agent/src/index.ts index c2174f2903..3bb061c6a5 100644 --- a/agent/src/index.ts +++ b/agent/src/index.ts @@ -37,6 +37,7 @@ import { evmPlugin } from "@ai16z/plugin-evm"; import { createNodePlugin } from "@ai16z/plugin-node"; import { solanaPlugin } from "@ai16z/plugin-solana"; import { teePlugin } from "@ai16z/plugin-tee"; +import { nearPlugin } from "@ai16z/plugin-near"; import Database from "better-sqlite3"; import fs from "fs"; import path from "path"; @@ -372,7 +373,12 @@ export function createAgent( !getSecret(character, "WALLET_PUBLIC_KEY")?.startsWith("0x")) ? solanaPlugin : null, - getSecret(character, "EVM_PRIVATE_KEY") || + (getSecret(character, "NEAR_ADDRESS") || + getSecret(character, "NEAR_WALLET_PUBLIC_KEY")) && + getSecret(character, "NEAR_WALLET_SECRET_KEY") + ? nearPlugin + : null, + getSecret(character, "EVM_PUBLIC_KEY") || (getSecret(character, "WALLET_PUBLIC_KEY") && !getSecret(character, "WALLET_PUBLIC_KEY")?.startsWith("0x")) ? evmPlugin @@ -433,7 +439,6 @@ async function startAgent(character: Character, directClient) { const cache = intializeDbCache(character, db); const runtime = createAgent(character, db, cache, token); - await runtime.initialize(); const clients = await initializeClients(character, runtime); diff --git a/packages/plugin-near/package.json b/packages/plugin-near/package.json new file mode 100644 index 0000000000..3aaadaad35 --- /dev/null +++ b/packages/plugin-near/package.json @@ -0,0 +1,32 @@ +{ + "name": "@ai16z/plugin-near", + "version": "0.0.1", + "main": "dist/index.js", + "type": "module", + "types": "dist/index.d.ts", + "dependencies": { + "@ai16z/eliza": "workspace:*", + "@ai16z/plugin-trustdb": "workspace:*", + "@ref-finance/ref-sdk": "^1.4.6", + "tsup": "8.3.5", + "near-api-js": "5.0.1", + "bignumber.js": "9.1.2", + "node-cache": "5.1.2" + }, + "devDependencies": { + "eslint": "^9.15.0", + "eslint-config-prettier": "9.1.0", + "eslint-plugin-prettier": "5.2.1", + "eslint-plugin-vitest": "0.5.4" + }, + "scripts": { + "build": "tsup --format esm,cjs --dts", + "test": "vitest run", + "test:watch": "vitest", + "lint": "eslint . --fix" + }, + "peerDependencies": { + "whatwg-url": "7.1.0", + "form-data": "4.0.1" + } +} diff --git a/packages/plugin-near/src/actions/swap.ts b/packages/plugin-near/src/actions/swap.ts new file mode 100644 index 0000000000..2b42b3dd1d --- /dev/null +++ b/packages/plugin-near/src/actions/swap.ts @@ -0,0 +1,295 @@ +import { + ActionExample, + HandlerCallback, + IAgentRuntime, + Memory, + ModelClass, + State, + type Action, + composeContext, + generateObject, +} from "@ai16z/eliza"; +import { connect, keyStores, utils } from "near-api-js"; +import { init_env, ftGetTokenMetadata, estimateSwap, instantSwap, fetchAllPools, FT_MINIMUM_STORAGE_BALANCE_LARGE, ONE_YOCTO_NEAR } from '@ref-finance/ref-sdk'; +import { walletProvider } from "../providers/wallet"; +import { KeyPairString } from "near-api-js/lib/utils"; + + +async function checkStorageBalance(account: any, contractId: string): Promise { + try { + const balance = await account.viewFunction({ + contractId, + methodName: 'storage_balance_of', + args: { account_id: account.accountId } + }); + return balance !== null && balance.total !== '0'; + } catch (error) { + console.log(`Error checking storage balance: ${error}`); + return false; + } +} + +async function swapToken( + runtime: IAgentRuntime, + inputTokenId: string, + outputTokenId: string, + amount: string, + slippageTolerance: number = Number(runtime.getSetting("SLIPPAGE_TOLERANCE")) || 0.01 +): Promise { + try { + // Get token metadata + const tokenIn = await ftGetTokenMetadata(inputTokenId); + const tokenOut = await ftGetTokenMetadata(outputTokenId); + const networkId = runtime.getSetting("NEAR_NETWORK") || "testnet"; + const nodeUrl = runtime.getSetting("RPC_URL") || "https://rpc.testnet.near.org"; + + // Get all pools for estimation + const { ratedPools, unRatedPools, simplePools} = await fetchAllPools(); + const swapTodos = await estimateSwap({ + tokenIn, + tokenOut, + amountIn: amount, + simplePools, + options: { + enableSmartRouting: true, + } + }); + + if (!swapTodos || swapTodos.length === 0) { + throw new Error('No valid swap route found'); + } + + // Get account ID from runtime settings + const accountId = runtime.getSetting("NEAR_ADDRESS"); + if (!accountId) { + throw new Error("NEAR_ADDRESS not configured"); + } + + const secretKey = runtime.getSetting("NEAR_WALLET_SECRET_KEY"); + const keyStore = new keyStores.InMemoryKeyStore(); + const keyPair = utils.KeyPair.fromString(secretKey as KeyPairString); + await keyStore.setKey(networkId, accountId, keyPair); + + const nearConnection = await connect({ + networkId, + keyStore, + nodeUrl, + }); + + const account = await nearConnection.account(accountId); + + // Check storage balance for both tokens + const hasStorageIn = await checkStorageBalance(account, inputTokenId); + const hasStorageOut = await checkStorageBalance(account, outputTokenId); + + let transactions = await instantSwap({ + tokenIn, + tokenOut, + amountIn: amount, + swapTodos, + slippageTolerance, + AccountId: accountId + }); + + // If storage deposit is needed, add it to transactions + if (!hasStorageIn) { + transactions.unshift({ + receiverId: inputTokenId, + functionCalls: [{ + methodName: 'storage_deposit', + args: { account_id: accountId, registration_only: true }, + gas: '30000000000000', + amount: FT_MINIMUM_STORAGE_BALANCE_LARGE + }] + }); + } + + if (!hasStorageOut) { + transactions.unshift({ + receiverId: outputTokenId, + functionCalls: [{ + methodName: 'storage_deposit', + args: { account_id: accountId, registration_only: true }, + gas: '30000000000000', + amount: FT_MINIMUM_STORAGE_BALANCE_LARGE + }] + }); + } + + return transactions; + } catch (error) { + console.error("Error in swapToken:", error); + throw error; + } +} + +const swapTemplate = `Respond with a JSON markdown block containing only the extracted values. Use null for any values that cannot be determined. + +Example response: +\`\`\`json +{ + "inputTokenId": "wrap.testnet", + "outputTokenId": "ref.fakes.testnet", + "amount": "1.5" +} +\`\`\` + +{{recentMessages}} + +Given the recent messages and wallet information below: + +{{walletInfo}} + +Extract the following information about the requested token swap: +- Input token ID (the token being sold) +- Output token ID (the token being bought) +- Amount to swap + +Respond with a JSON markdown block containing only the extracted values. Use null for any values that cannot be determined. The result should be a valid JSON object with the following schema: +\`\`\`json +{ + "inputTokenId": string | null, + "outputTokenId": string | null, + "amount": string | null +} +\`\`\``; + +export const executeSwap: Action = { + name: "EXECUTE_SWAP_NEAR", + similes: ["SWAP_TOKENS_NEAR", "TOKEN_SWAP_NEAR", "TRADE_TOKENS_NEAR", "EXCHANGE_TOKENS_NEAR"], + validate: async (runtime: IAgentRuntime, message: Memory) => { + console.log("Message:", message); + return true; + }, + description: "Perform a token swap using Ref Finance.", + handler: async ( + runtime: IAgentRuntime, + message: Memory, + state: State, + _options: { [key: string]: unknown }, + callback?: HandlerCallback + ): Promise => { + // Initialize Ref SDK with testnet environment + init_env(runtime.getSetting("NEAR_NETWORK") || "testnet"); + // Compose state + if (!state) { + state = (await runtime.composeState(message)) as State; + } else { + state = await runtime.updateRecentMessageState(state); + } + + const walletInfo = await walletProvider.get(runtime, message, state); + state.walletInfo = walletInfo; + + const swapContext = composeContext({ + state, + template: swapTemplate, + }); + + const response = await generateObject({ + runtime, + context: swapContext, + modelClass: ModelClass.LARGE, + }); + + console.log("Response:", response); + + if (!response.inputTokenId || !response.outputTokenId || !response.amount) { + console.log("Missing required parameters, skipping swap"); + const responseMsg = { + text: "I need the input token ID, output token ID, and amount to perform the swap", + }; + callback?.(responseMsg); + return true; + } + + try { + // Get account credentials + const accountId = runtime.getSetting("NEAR_ADDRESS"); + const secretKey = runtime.getSetting("NEAR_WALLET_SECRET_KEY"); + + if (!accountId || !secretKey) { + throw new Error("NEAR wallet credentials not configured"); + } + + // Create keystore and connect to NEAR + const keyStore = new keyStores.InMemoryKeyStore(); + const keyPair = utils.KeyPair.fromString(secretKey as KeyPairString); + await keyStore.setKey("testnet", accountId, keyPair); + + const nearConnection = await connect({ + networkId: runtime.getSetting("NEAR_NETWORK") || "testnet", + keyStore, + nodeUrl: runtime.getSetting("RPC_URL") || "https://rpc.testnet.near.org", + }); + + // Execute swap + const swapResult = await swapToken( + runtime, + response.inputTokenId, + response.outputTokenId, + response.amount, + Number(runtime.getSetting("SLIPPAGE_TOLERANCE")) || 0.01 + ); + + // Sign and send transactions + const account = await nearConnection.account(accountId); + const results = []; + + for (const tx of swapResult) { + for (const functionCall of tx.functionCalls) { + const result = await account.functionCall({ + contractId: tx.receiverId, + methodName: functionCall.methodName, + args: functionCall.args, + gas: functionCall.gas, + attachedDeposit: BigInt(functionCall.amount === ONE_YOCTO_NEAR ? '1' : functionCall.amount), + }); + results.push(result); + } + } + + console.log("Swap completed successfully!"); + const txHashes = results.map(r => r.transaction.hash).join(", "); + + const responseMsg = { + text: `Swap completed successfully! Transaction hashes: ${txHashes}`, + }; + + callback?.(responseMsg); + return true; + } catch (error) { + console.error("Error during token swap:", error); + const responseMsg = { + text: `Error during swap: ${error instanceof Error ? error.message : String(error)}`, + }; + callback?.(responseMsg); + return false; + } + }, + examples: [ + [ + { + user: "{{user1}}", + content: { + inputTokenId: "wrap.testnet", + outputTokenId: "ref.fakes.testnet", + amount: "1.0", + }, + }, + { + user: "{{user2}}", + content: { + text: "Swapping 1.0 NEAR for REF...", + action: "TOKEN_SWAP", + }, + }, + { + user: "{{user2}}", + content: { + text: "Swap completed successfully! Transaction hash: ...", + }, + }, + ], + ] as ActionExample[][], +} as Action; diff --git a/packages/plugin-near/src/actions/transfer.ts b/packages/plugin-near/src/actions/transfer.ts new file mode 100644 index 0000000000..bcf0922509 --- /dev/null +++ b/packages/plugin-near/src/actions/transfer.ts @@ -0,0 +1,202 @@ +import { + ActionExample, + Content, + HandlerCallback, + IAgentRuntime, + Memory, + ModelClass, + State, + type Action, + composeContext, + generateObject, +} from "@ai16z/eliza"; +import { connect, keyStores, utils } from "near-api-js"; +import { KeyPairString } from "near-api-js/lib/utils"; +import { utils as nearUtils } from "near-api-js"; +import BigNumber from "bignumber.js"; + +export interface TransferContent extends Content { + recipient: string; + amount: string | number; + tokenAddress?: string; // Optional for native NEAR transfers +} + +function isTransferContent( + runtime: IAgentRuntime, + content: any +): content is TransferContent { + return ( + typeof content.recipient === "string" && + (typeof content.amount === "string" || typeof content.amount === "number") + ); +} + +const transferTemplate = `Respond with a JSON markdown block containing only the extracted values. Use null for any values that cannot be determined. + +Example response: +\`\`\`json +{ + "recipient": "bob.near", + "amount": "1.5", + "tokenAddress": null +} +\`\`\` + +{{recentMessages}} + +Given the recent messages and wallet information below: + +{{walletInfo}} + +Extract the following information about the requested token transfer: +- Recipient address (NEAR account) +- Amount to transfer +- Token contract address (null for native NEAR transfers) + +Respond with a JSON markdown block containing only the extracted values.`; + +async function transferNEAR( + runtime: IAgentRuntime, + recipient: string, + amount: string, +): Promise { + const networkId = runtime.getSetting("NEAR_NETWORK") || "testnet"; + const nodeUrl = runtime.getSetting("RPC_URL") || "https://rpc.testnet.near.org"; + const accountId = runtime.getSetting("NEAR_ADDRESS"); + const secretKey = runtime.getSetting("NEAR_WALLET_SECRET_KEY"); + + if (!accountId || !secretKey) { + throw new Error("NEAR wallet credentials not configured"); + } + + // Convert amount to yoctoNEAR (1 NEAR = 10^24 yoctoNEAR) + const yoctoAmount = new BigNumber(amount) + .multipliedBy(new BigNumber(10).pow(24)) + .toFixed(0); + + // Create keystore and connect to NEAR + const keyStore = new keyStores.InMemoryKeyStore(); + const keyPair = utils.KeyPair.fromString(secretKey as KeyPairString); + await keyStore.setKey(networkId, accountId, keyPair); + + const nearConnection = await connect({ + networkId, + keyStore, + nodeUrl, + }); + + const account = await nearConnection.account(accountId); + + // Execute transfer + const result = await account.sendMoney( + recipient, + BigInt(nearUtils.format.parseNearAmount(amount)!) + ); + + return result.transaction.hash; +} + +export const executeTransfer: Action = { + name: "SEND_NEAR", + similes: ["TRANSFER_NEAR", "SEND_TOKENS", "TRANSFER_TOKENS", "PAY_NEAR"], + validate: async (runtime: IAgentRuntime, message: Memory) => { + return true; // Add your validation logic here + }, + description: "Transfer NEAR tokens to another account", + handler: async ( + runtime: IAgentRuntime, + message: Memory, + state: State, + _options: { [key: string]: unknown }, + callback?: HandlerCallback + ): Promise => { + // Initialize or update state + if (!state) { + state = (await runtime.composeState(message)) as State; + } else { + state = await runtime.updateRecentMessageState(state); + } + + // Compose transfer context + const transferContext = composeContext({ + state, + template: transferTemplate, + }); + + // Generate transfer content + const content = await generateObject({ + runtime, + context: transferContext, + modelClass: ModelClass.SMALL, + }); + + // Validate transfer content + if (!isTransferContent(runtime, content)) { + console.error("Invalid content for TRANSFER_NEAR action."); + if (callback) { + callback({ + text: "Unable to process transfer request. Invalid content provided.", + content: { error: "Invalid transfer content" }, + }); + } + return false; + } + + try { + const txHash = await transferNEAR( + runtime, + content.recipient, + content.amount.toString() + ); + + if (callback) { + callback({ + text: `Successfully transferred ${content.amount} NEAR to ${content.recipient}\nTransaction: ${txHash}`, + content: { + success: true, + signature: txHash, + amount: content.amount, + recipient: content.recipient, + }, + }); + } + + return true; + } catch (error) { + console.error("Error during NEAR transfer:", error); + if (callback) { + callback({ + text: `Error transferring NEAR: ${error}`, + content: { error: error }, + }); + } + return false; + } + }, + + examples: [ + [ + { + user: "{{user1}}", + content: { + text: "Send 1.5 NEAR to bob.testnet", + }, + }, + { + user: "{{user2}}", + content: { + text: "I'll send 1.5 NEAR now...", + action: "SEND_NEAR", + }, + }, + { + user: "{{user2}}", + content: { + text: "Successfully sent 1.5 NEAR to bob.testnet\nTransaction: ABC123XYZ", + }, + }, + ], + ] as ActionExample[][], +} as Action; + + diff --git a/packages/plugin-near/src/environment.ts b/packages/plugin-near/src/environment.ts new file mode 100644 index 0000000000..cc51187027 --- /dev/null +++ b/packages/plugin-near/src/environment.ts @@ -0,0 +1,104 @@ +import { IAgentRuntime } from "@ai16z/eliza"; +import { z } from "zod"; + +// Add ENV variable at the top +let ENV: string = "testnet"; + +export const nearEnvSchema = z.object({ + NEAR_WALLET_SECRET_KEY: z.string().min(1, "Wallet secret key is required"), + NEAR_WALLET_PUBLIC_KEY: z.string().min(1, "Wallet public key is required"), + NEAR_ADDRESS: z.string().min(1, "Near address is required"), + SLIPPAGE: z.string().min(1, "Slippage is required"), + RPC_URL: z.string().min(1, "RPC URL is required"), + networkId: z.string(), + nodeUrl: z.string(), + walletUrl: z.string(), + WRAP_NEAR_CONTRACT_ID: z.string(), + REF_FI_CONTRACT_ID: z.string(), + REF_TOKEN_ID: z.string(), + indexerUrl: z.string(), + explorerUrl: z.string(), + REF_DCL_SWAP_CONTRACT_ID: z.string(), +}); + +export type NearConfig = z.infer; + +export function getConfig( + env: string | undefined | null = ENV || + process.env.NEAR_ENV || + process.env.REACT_APP_REF_SDK_ENV +) { + ENV = env || "testnet"; + switch (env) { + case 'mainnet': + return { + networkId: 'mainnet', + nodeUrl: 'https://rpc.mainnet.near.org', + walletUrl: 'https://wallet.near.org', + WRAP_NEAR_CONTRACT_ID: 'wrap.near', + REF_FI_CONTRACT_ID: 'v2.ref-finance.near', + REF_TOKEN_ID: 'token.v2.ref-finance.near', + indexerUrl: 'https://indexer.ref.finance', + explorerUrl: 'https://testnet.nearblocks.io', + REF_DCL_SWAP_CONTRACT_ID: 'dclv2.ref-labs.near', + }; + case 'testnet': + return { + networkId: 'testnet', + nodeUrl: 'https://rpc.testnet.near.org', + walletUrl: 'https://wallet.testnet.near.org', + indexerUrl: 'https://testnet-indexer.ref-finance.com', + WRAP_NEAR_CONTRACT_ID: 'wrap.testnet', + REF_FI_CONTRACT_ID: 'ref-finance-101.testnet', + REF_TOKEN_ID: 'ref.fakes.testnet', + explorerUrl: 'https://testnet.nearblocks.io', + REF_DCL_SWAP_CONTRACT_ID: 'dclv2.ref-dev.testnet', + }; + default: + return { + networkId: 'mainnet', + nodeUrl: 'https://rpc.mainnet.near.org', + walletUrl: 'https://wallet.near.org', + REF_FI_CONTRACT_ID: 'v2.ref-finance.near', + WRAP_NEAR_CONTRACT_ID: 'wrap.near', + REF_TOKEN_ID: 'token.v2.ref-finance.near', + indexerUrl: 'https://indexer.ref.finance', + explorerUrl: 'https://nearblocks.io', + REF_DCL_SWAP_CONTRACT_ID: 'dclv2.ref-labs.near', + }; + } +} + +export async function validateNearConfig( + runtime: IAgentRuntime +): Promise { + try { + const envConfig = getConfig(runtime.getSetting("NEAR_ENV") ?? undefined); + const config = { + NEAR_WALLET_SECRET_KEY: + runtime.getSetting("NEAR_WALLET_SECRET_KEY") || + process.env.NEAR_WALLET_SECRET_KEY, + NEAR_WALLET_PUBLIC_KEY: + runtime.getSetting("NEAR_PUBLIC_KEY") || + runtime.getSetting("NEAR_WALLET_PUBLIC_KEY") || + process.env.NEAR_WALLET_PUBLIC_KEY, + NEAR_ADDRESS: + runtime.getSetting("NEAR_ADDRESS") || process.env.NEAR_ADDRESS, + SLIPPAGE: runtime.getSetting("SLIPPAGE") || process.env.SLIPPAGE, + RPC_URL: runtime.getSetting("RPC_URL") || process.env.RPC_URL, + ...envConfig // Spread the environment-specific config + }; + + return nearEnvSchema.parse(config); + } catch (error) { + if (error instanceof z.ZodError) { + const errorMessages = error.errors + .map((err) => `${err.path.join(".")}: ${err.message}`) + .join("\n"); + throw new Error( + `Near configuration validation failed:\n${errorMessages}` + ); + } + throw error; + } +} diff --git a/packages/plugin-near/src/index.ts b/packages/plugin-near/src/index.ts new file mode 100644 index 0000000000..6c57aa5ad3 --- /dev/null +++ b/packages/plugin-near/src/index.ts @@ -0,0 +1,15 @@ +import { Plugin } from "@ai16z/eliza/src/types"; +import { walletProvider } from "./providers/wallet"; +// import { executeCreateToken } from "./actions/createToken"; +import { executeSwap } from "./actions/swap"; +import { executeTransfer } from './actions/transfer'; + +export const nearPlugin: Plugin = { + name: "NEAR", + description: "Near Protocol Plugin for Eliza", + providers: [walletProvider], + actions: [executeSwap, executeTransfer], + evaluators: [], +}; + +export default nearPlugin; diff --git a/packages/plugin-near/src/providers/wallet.ts b/packages/plugin-near/src/providers/wallet.ts new file mode 100644 index 0000000000..a5fdb149c2 --- /dev/null +++ b/packages/plugin-near/src/providers/wallet.ts @@ -0,0 +1,226 @@ +import { IAgentRuntime, Memory, Provider, State } from "@ai16z/eliza"; +import { KeyPair, keyStores, connect, Account, utils } from "near-api-js"; +import BigNumber from "bignumber.js"; +import { KeyPairString } from "near-api-js/lib/utils"; +import NodeCache from "node-cache"; + +const PROVIDER_CONFIG = { + networkId: process.env.NEAR_NETWORK || "testnet", + nodeUrl: process.env.RPC_URL || `https://rpc.${process.env.NEAR_NETWORK || "testnet"}.near.org`, + walletUrl: `https://${process.env.NEAR_NETWORK || "testnet"}.mynearwallet.com/`, + helperUrl: `https://helper.${process.env.NEAR_NETWORK || "testnet"}.near.org`, + explorerUrl: `https://${process.env.NEAR_NETWORK || "testnet"}.nearblocks.io`, + MAX_RETRIES: 3, + RETRY_DELAY: 2000, + SLIPPAGE: process.env.SLIPPAGE ? parseInt(process.env.SLIPPAGE) : 1, +}; + +export interface NearToken { + name: string; + symbol: string; + decimals: number; + balance: string; + uiAmount: string; + priceUsd: string; + valueUsd: string; + valueNear?: string; +} + +interface WalletPortfolio { + totalUsd: string; + totalNear?: string; + tokens: Array; +} + +export class WalletProvider implements Provider { + private cache: NodeCache; + private account: Account | null = null; + private keyStore: keyStores.InMemoryKeyStore; + constructor(private accountId: string) { + this.cache = new NodeCache({ stdTTL: 300 }); // Cache TTL set to 5 minutes + this.keyStore = new keyStores.InMemoryKeyStore(); + } + + async get( + runtime: IAgentRuntime, + _message: Memory, + _state?: State + ): Promise { + try { + return await this.getFormattedPortfolio(runtime); + } catch (error) { + console.error("Error in wallet provider:", error); + return null; + } + } + + public async connect(runtime: IAgentRuntime) { + if (this.account) return this.account; + + const secretKey = runtime.getSetting("NEAR_WALLET_SECRET_KEY"); + const publicKey = runtime.getSetting("NEAR_WALLET_PUBLIC_KEY"); + + if (!secretKey || !publicKey) { + throw new Error("NEAR wallet credentials not configured"); + } + + // Create KeyPair from secret key + const keyPair = KeyPair.fromString(secretKey as KeyPairString); + + // Set the key in the keystore + await this.keyStore.setKey(PROVIDER_CONFIG.networkId, this.accountId, keyPair); + + const nearConnection = await connect({ + networkId: PROVIDER_CONFIG.networkId, + keyStore: this.keyStore, + nodeUrl: PROVIDER_CONFIG.nodeUrl, + walletUrl: PROVIDER_CONFIG.walletUrl, + helperUrl: PROVIDER_CONFIG.helperUrl, + }); + + this.account = await nearConnection.account(this.accountId); + return this.account; + } + + private async fetchWithRetry( + url: string, + options: RequestInit = {} + ): Promise { + let lastError: Error; + + for (let i = 0; i < PROVIDER_CONFIG.MAX_RETRIES; i++) { + try { + const response = await fetch(url, options); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return await response.json(); + } catch (error) { + console.error(`Attempt ${i + 1} failed:`, error); + lastError = error as Error; + if (i < PROVIDER_CONFIG.MAX_RETRIES - 1) { + await new Promise(resolve => + setTimeout(resolve, PROVIDER_CONFIG.RETRY_DELAY * Math.pow(2, i)) + ); + } + } + } + throw lastError!; + } + + async fetchPortfolioValue(runtime: IAgentRuntime): Promise { + try { + const cacheKey = `portfolio-${this.accountId}`; + const cachedValue = this.cache.get(cacheKey); + + if (cachedValue) { + console.log("Cache hit for fetchPortfolioValue"); + return cachedValue; + } + + const account = await this.connect(runtime); + const balance = await account.getAccountBalance(); + + // Convert yoctoNEAR to NEAR + const nearBalance = utils.format.formatNearAmount(balance.available); + + // Fetch NEAR price in USD + const nearPrice = await this.fetchNearPrice(); + const valueUsd = new BigNumber(nearBalance).times(nearPrice); + + const portfolio: WalletPortfolio = { + totalUsd: valueUsd.toString(), + totalNear: nearBalance, + tokens: [{ + name: "NEAR Protocol", + symbol: "NEAR", + decimals: 24, + balance: balance.available, + uiAmount: nearBalance, + priceUsd: nearPrice.toString(), + valueUsd: valueUsd.toString(), + }] + }; + + this.cache.set(cacheKey, portfolio); + return portfolio; + } catch (error) { + console.error("Error fetching portfolio:", error); + throw error; + } + } + + private async fetchNearPrice(): Promise { + const cacheKey = "near-price"; + const cachedPrice = this.cache.get(cacheKey); + + if (cachedPrice) { + return cachedPrice; + } + + try { + const response = await this.fetchWithRetry( + "https://api.coingecko.com/api/v3/simple/price?ids=near&vs_currencies=usd" + ); + const price = response.near.usd; + this.cache.set(cacheKey, price); + return price; + } catch (error) { + console.error("Error fetching NEAR price:", error); + return 0; + } + } + + formatPortfolio(runtime: IAgentRuntime, portfolio: WalletPortfolio): string { + let output = `${runtime.character.system}\n`; + output += `Account ID: ${this.accountId}\n\n`; + + const totalUsdFormatted = new BigNumber(portfolio.totalUsd).toFixed(2); + const totalNearFormatted = portfolio.totalNear; + + output += `Total Value: $${totalUsdFormatted} (${totalNearFormatted} NEAR)\n\n`; + output += "Token Balances:\n"; + + for (const token of portfolio.tokens) { + output += `${token.name} (${token.symbol}): ${token.uiAmount} ($${new BigNumber(token.valueUsd).toFixed(2)})\n`; + } + + output += "\nMarket Prices:\n"; + output += `NEAR: $${new BigNumber(portfolio.tokens[0].priceUsd).toFixed(2)}\n`; + + return output; + } + + async getFormattedPortfolio(runtime: IAgentRuntime): Promise { + try { + const portfolio = await this.fetchPortfolioValue(runtime); + return this.formatPortfolio(runtime, portfolio); + } catch (error) { + console.error("Error generating portfolio report:", error); + return "Unable to fetch wallet information. Please try again later."; + } + } +} + +const walletProvider: Provider = { + get: async ( + runtime: IAgentRuntime, + _message: Memory, + _state?: State + ): Promise => { + try { + const accountId = runtime.getSetting("NEAR_ADDRESS"); + if (!accountId) { + throw new Error("NEAR_ADDRESS not configured"); + } + const provider = new WalletProvider(accountId); + return await provider.getFormattedPortfolio(runtime); + } catch (error) { + console.error("Error in wallet provider:", error); + return null; + } + }, +}; + + +export { walletProvider }; \ No newline at end of file diff --git a/packages/plugin-near/tsconfig.json b/packages/plugin-near/tsconfig.json new file mode 100644 index 0000000000..95cbe371ac --- /dev/null +++ b/packages/plugin-near/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "node", + "outDir": "dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "typeRoots": [ + "./node_modules/@types", + "./src/types" + ], + "declaration": true + }, + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/packages/plugin-near/tsup.config.ts b/packages/plugin-near/tsup.config.ts new file mode 100644 index 0000000000..96d9c23733 --- /dev/null +++ b/packages/plugin-near/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm", "cjs"], + dts: true, + splitting: false, + sourcemap: true, + clean: true, + shims: true, + treeshake: true +});