diff --git a/packages/plugin-sui/README.md b/packages/plugin-sui/README.md index 8a0b7564d73..52f86abc54d 100644 --- a/packages/plugin-sui/README.md +++ b/packages/plugin-sui/README.md @@ -149,7 +149,7 @@ Contributions are welcome! Please see the [CONTRIBUTING.md](CONTRIBUTING.md) fil This plugin integrates with and builds upon several key technologies: - [Sui Blockchain](https://sui.io/): Next-generation smart contract platform -- [@mysten/sui.js](https://www.npmjs.com/package/@mysten/sui.js): Official Sui SDK +- [@mysten/sui](https://www.npmjs.com/package/@mysten/sui): Official new version Sui SDK - [bignumber.js](https://github.com/MikeMcl/bignumber.js/): Precise number handling - [node-cache](https://www.npmjs.com/package/node-cache): Caching implementation diff --git a/packages/plugin-sui/package.json b/packages/plugin-sui/package.json index 552f3729167..d21c1f1f97c 100644 --- a/packages/plugin-sui/package.json +++ b/packages/plugin-sui/package.json @@ -21,6 +21,10 @@ "dependencies": { "@elizaos/core": "workspace:*", "@mysten/sui": "^1.16.0", + "@mysten/sui.js": "^0.54.1", + "@mysten/suins": "^0.4.2", + "aftermath-ts-sdk": "^1.2.45", + "bignumber": "1.1.0", "bignumber.js": "9.1.2", "node-cache": "5.1.2", "tsup": "8.3.5", diff --git a/packages/plugin-sui/src/actions/convertNameToAddress.ts b/packages/plugin-sui/src/actions/convertNameToAddress.ts new file mode 100644 index 00000000000..37a1d00d12f --- /dev/null +++ b/packages/plugin-sui/src/actions/convertNameToAddress.ts @@ -0,0 +1,221 @@ +import { + ActionExample, + Content, + HandlerCallback, + IAgentRuntime, + Memory, + ModelClass, + State, + composeContext, + elizaLogger, + generateObject, + type Action, +} from "@elizaos/core"; +import { z } from "zod"; + +import { SuiClient, getFullnodeUrl } from "@mysten/sui/client"; +import { SuinsClient } from "@mysten/suins"; + +import { walletProvider } from "../providers/wallet"; +import { SuiNetwork } from "../types"; + +export interface NameToAddressContent extends Content { + recipientName: string; +} + +function isNameToAddressContent( + content: Content +): content is NameToAddressContent { + console.log("Content for show address", content); + return typeof content.recipientName === "string"; +} + +const nameToAddressTemplate = `Extract the SUI domain name from the recent messages and return it in a JSON format. + +Example input: "Convert adeniyi.sui to address" or "What's the address for adeniyi.sui" +Example output: +\`\`\`json +{ + "recipientName": "adeniyi.sui" +} +\`\`\` + +{{recentMessages}} + +Extract the SUI domain name (ending in .sui) that needs to be converted to an address. +If no valid .sui domain is found, return null.`; + +export default { + name: "CONVERT_NAME_TO_ADDRESS", + similes: [ + "CONVERT_SUI_NAME_TO_ADDRESS", + "CONVERT_DOMAIN_TO_ADDRESS", + "SHOW_ADDRESS_BY_NAME", + "SHOW_ADDRESS_BY_DOMAIN", + ], + validate: async (runtime: IAgentRuntime, message: Memory) => { + console.log( + "Validating sui name to address from user:", + message.userId + ); + //add custom validate logic here + /* + const adminIds = runtime.getSetting("ADMIN_USER_IDS")?.split(",") || []; + //console.log("Admin IDs from settings:", adminIds); + + const isAdmin = adminIds.includes(message.userId); + + if (isAdmin) { + //console.log(`Authorized transfer from user: ${message.userId}`); + return true; + } + else + { + //console.log(`Unauthorized transfer attempt from user: ${message.userId}`); + return false; + } + */ + return true; + }, + description: "Convert a name service domain to an sui address", + handler: async ( + runtime: IAgentRuntime, + message: Memory, + state: State, + _options: { [key: string]: unknown }, + callback?: HandlerCallback + ): Promise => { + elizaLogger.log("Starting CONVERT_NAME_TO_ADDRESS handler..."); + const walletInfo = await walletProvider.get(runtime, message, state); + state.walletInfo = walletInfo; + + // Initialize or update state + if (!state) { + state = (await runtime.composeState(message)) as State; + } else { + state = await runtime.updateRecentMessageState(state); + } + + // Define the schema for the expected output + const nameToAddressSchema = z.object({ + recipientName: z.string(), + }); + + // Compose transfer context + const nameToAddressContext = composeContext({ + state, + template: nameToAddressTemplate, + }); + + // Generate transfer content with the schema + const content = await generateObject({ + runtime, + context: nameToAddressContext, + schema: nameToAddressSchema, + modelClass: ModelClass.SMALL, + }); + + const nameToAddressContent = content.object as NameToAddressContent; + + // Validate transfer content + if (!isNameToAddressContent(nameToAddressContent)) { + console.error( + "Invalid content for CONVERT_NAME_TO_ADDRESS action." + ); + if (callback) { + callback({ + text: "Unable to process name to address request. Invalid content provided.", + content: { error: "Invalid name to address content" }, + }); + } + return false; + } + + try { + const network = runtime.getSetting("SUI_NETWORK"); + const suiClient = new SuiClient({ + url: getFullnodeUrl(network as SuiNetwork), + }); + const suinsClient = new SuinsClient({ + client: suiClient, + network: network as Exclude, + }); + + console.log( + "Getting address for name:", + nameToAddressContent.recipientName + ); + + const address = await suinsClient.getNameRecord( + nameToAddressContent.recipientName + ); + console.log("Address:", address); + + if (callback) { + callback({ + text: `Successfully convert ${nameToAddressContent.recipientName} to ${address.targetAddress}`, + content: { + success: true, + address: address.targetAddress, + }, + }); + } + + return true; + } catch (error) { + console.error("Error during name to address conversion:", error); + if (callback) { + callback({ + text: "An error occurred during name to address conversion.", + content: { error: "ConversionError" }, + }); + } + return false; + } + }, + + examples: [ + [ + { + user: "{{user1}}", + content: { + text: "Convert adeniyi.sui to address", + }, + }, + { + user: "{{user2}}", + content: { + text: "Converting adeniyi.sui to address...", + action: "CONVERT_NAME_TO_ADDRESS", + }, + }, + { + user: "{{user2}}", + content: { + text: "Successfully convert adeniyi.sui to 0x1eb7c57e3f2bd0fc6cb9dcffd143ea957e4d98f805c358733f76dee0667fe0b1", + }, + }, + ], + [ + { + user: "{{user1}}", + content: { + text: "Convert @adeniyi to address", + }, + }, + { + user: "{{user2}}", + content: { + text: "Convert @adeniyi to address", + action: "CONVERT_NAME_TO_ADDRESS", + }, + }, + { + user: "{{user2}}", + content: { + text: "Successfully convert @adeniyi to 0x1eb7c57e3f2bd0fc6cb9dcffd143ea957e4d98f805c358733f76dee0667fe0b1", + }, + }, + ], + ] as ActionExample[][], +} as Action; diff --git a/packages/plugin-sui/src/actions/swap.ts b/packages/plugin-sui/src/actions/swap.ts new file mode 100644 index 00000000000..d253778d9d0 --- /dev/null +++ b/packages/plugin-sui/src/actions/swap.ts @@ -0,0 +1,225 @@ +import { + ActionExample, + Content, + HandlerCallback, + IAgentRuntime, + Memory, + ModelClass, + State, + composeContext, + elizaLogger, + generateObject, + type Action, +} from "@elizaos/core"; +import { z } from "zod"; + +import { SuiClient, getFullnodeUrl } from "@mysten/sui/client"; +import { SUI_DECIMALS } from "@mysten/sui/utils"; +import { Aftermath } from "aftermath-ts-sdk"; + +import { walletProvider } from "../providers/wallet"; +import { parseAccount } from "../utils"; +import { SuiNetwork } from "../types"; + +export interface SwapContent extends Content { + recipient: string; + amount: string | number; + fromCoinType: string; + toCoinType: string; +} + +function isSwapContent(content: Content): content is SwapContent { + console.log("Content for swap", content); + return ( + typeof content.recipient === "string" && + typeof content.fromCoinType === "string" && + typeof content.toCoinType === "string" && + (typeof content.amount === "string" || + typeof content.amount === "number") + ); +} + +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 +{ + "recipient": "0xaa000b3651bd1e57554ebd7308ca70df7c8c0e8e09d67123cc15c8a8a79342b3", + "amount": "1", + "fromCoinType": "0x2::sui::SUI", + "toCoinType": "0xdeeb7a4662eec9f2f3def03fb937a663dddaa2e215b8078a284d026b7946c270::deep::DEEP" +} +\`\`\` + +{{recentMessages}} + +Given the recent messages, extract the following information about the requested token swap: +- Recipient wallet address to receive swapped tokens +- Amount of tokens to swap +- Source token type to swap from +- Destination token type to swap to + +Respond with a JSON markdown block containing only the extracted values.`; + +export default { + name: "SWAP_TOKEN", + similes: ["SWAP_TOKEN", "SWAP_TOKENS", "SWAP_SUI", "SWAP"], + validate: async (runtime: IAgentRuntime, message: Memory) => { + console.log("Validating sui swap from user:", message.userId); + //add custom validate logic here + /* + const adminIds = runtime.getSetting("ADMIN_USER_IDS")?.split(",") || []; + //console.log("Admin IDs from settings:", adminIds); + + const isAdmin = adminIds.includes(message.userId); + + if (isAdmin) { + //console.log(`Authorized transfer from user: ${message.userId}`); + return true; + } + else + { + //console.log(`Unauthorized transfer attempt from user: ${message.userId}`); + return false; + } + */ + return true; + }, + description: "Swap tokens from the agent's wallet to another address", + handler: async ( + runtime: IAgentRuntime, + message: Memory, + state: State, + _options: { [key: string]: unknown }, + callback?: HandlerCallback + ): Promise => { + elizaLogger.log("Starting SWAP_TOKEN handler..."); + + const walletInfo = await walletProvider.get(runtime, message, state); + state.walletInfo = walletInfo; + + // Initialize or update state + if (!state) { + state = (await runtime.composeState(message)) as State; + } else { + state = await runtime.updateRecentMessageState(state); + } + + // Define the schema for the expected output + const swapSchema = z.object({ + recipient: z.string(), + amount: z.union([z.string(), z.number()]), + fromCoinType: z.string(), + toCoinType: z.string(), + }); + + // Compose swap context + const swapContext = composeContext({ + state, + template: swapTemplate, + }); + + // Generate swap content with the schema + const content = await generateObject({ + runtime, + context: swapContext, + schema: swapSchema, + modelClass: ModelClass.SMALL, + }); + + const swapContent = content.object as SwapContent; + + // Validate swap content + if (!isSwapContent(swapContent)) { + console.error("Invalid content for SWAP_TOKEN action."); + if (callback) { + callback({ + text: "Unable to process swap request. Invalid content provided.", + content: { error: "Invalid swap content" }, + }); + } + return false; + } + + try { + const suiAccount = parseAccount(runtime); + const network = runtime.getSetting("SUI_NETWORK"); + const suiClient = new SuiClient({ + url: getFullnodeUrl(network as SuiNetwork), + }); + const router = new Aftermath("MAINNET").Router(); + const adjustedAmount = BigInt( + Number(swapContent.amount) * Math.pow(10, SUI_DECIMALS) + ); + console.log( + `Swapping: ${swapContent.amount} ${swapContent.fromCoinType} to ${swapContent.toCoinType} (${adjustedAmount} base units)` + ); + const route = await router.getCompleteTradeRouteGivenAmountIn({ + coinInType: swapContent.fromCoinType, + coinOutType: swapContent.toCoinType, + coinInAmount: adjustedAmount, + }); + console.log("Route:", route); + const tx = await router.getTransactionForCompleteTradeRoute({ + walletAddress: swapContent.recipient, + completeRoute: route, + slippage: 0.01, // 1% max slippage + }); + console.log("Transaction:", tx); + const executedTransaction = + await suiClient.signAndExecuteTransaction({ + signer: suiAccount, + transaction: tx, + }); + + console.log("Swap successful:", executedTransaction.digest); + + if (callback) { + callback({ + text: `Successfully swapped ${swapContent.amount} ${swapContent.fromCoinType} to ${swapContent.toCoinType} to ${swapContent.recipient}, Transaction: ${executedTransaction.digest}`, + content: { + success: true, + hash: executedTransaction.digest, + amount: swapContent.amount, + recipient: swapContent.recipient, + }, + }); + } + + return true; + } catch (error) { + console.error("Error during token swap:", error); + if (callback) { + callback({ + text: `Error swapping tokens: ${error.message}`, + content: { error: error.message }, + }); + } + return false; + } + }, + + examples: [ + [ + { + user: "{{user1}}", + content: { + text: "Swap 1 SUI(0x2::sui::SUI) to DEEP(0xdeeb7a4662eec9f2f3def03fb937a663dddaa2e215b8078a284d026b7946c270::deep::DEEP) to 0x4f2e63be8e7fe287836e29cde6f3d5cbc96eefd0c0e3f3747668faa2ae7324b0", + }, + }, + { + user: "{{user2}}", + content: { + text: "Swap 1 SUI(0x2::sui::SUI) to DEEP(0xdeeb7a4662eec9f2f3def03fb937a663dddaa2e215b8078a284d026b7946c270::deep::DEEP) to 0x4f2e63be8e7fe287836e29cde6f3d5cbc96eefd0c0e3f3747668faa2ae7324b0", + action: "SWAP_TOKEN", + }, + }, + { + user: "{{user2}}", + content: { + text: "Successfully swapped 1 SUI to DEEP to 0x4f2e63be8e7fe287836e29cde6f3d5cbc96eefd0c0e3f3747668faa2ae7324b0, Transaction: 0x39a8c432d9bdad993a33cc1faf2e9b58fb7dd940c0425f1d6db3997e4b4b05c0", + }, + }, + ], + ] as ActionExample[][], +} as Action; diff --git a/packages/plugin-sui/src/actions/transfer.ts b/packages/plugin-sui/src/actions/transfer.ts index d9471aaa5f2..d5e88217058 100644 --- a/packages/plugin-sui/src/actions/transfer.ts +++ b/packages/plugin-sui/src/actions/transfer.ts @@ -15,12 +15,11 @@ import { z } from "zod"; import { SuiClient, getFullnodeUrl } from "@mysten/sui/client"; import { Transaction } from "@mysten/sui/transactions"; -import { SUI_DECIMALS } from "@mysten/sui/utils"; +import { isValidSuiNSName, SUI_DECIMALS } from "@mysten/sui/utils"; import { walletProvider } from "../providers/wallet"; -import { parseAccount } from "../utils"; - -type SuiNetwork = "mainnet" | "testnet" | "devnet" | "localnet"; +import { parseAccount, getSuiAddress } from "../utils"; +import { SuiNetwork } from "../types"; export interface TransferContent extends Content { recipient: string; @@ -145,6 +144,22 @@ export default { url: getFullnodeUrl(network as SuiNetwork), }); + const recipientAddress = await getSuiAddress( + transferContent.recipient, + suiClient + ); + if (!recipientAddress) { + console.error( + "Invalid recipient address for TRANSFER_TOKEN action." + ); + if (callback) { + callback({ + text: "Invalid recipient address provided.", + content: { error: "Invalid recipient address" }, + }); + } + return false; + } const adjustedAmount = BigInt( Number(transferContent.amount) * Math.pow(10, SUI_DECIMALS) ); @@ -153,7 +168,8 @@ export default { ); const tx = new Transaction(); const [coin] = tx.splitCoins(tx.gas, [adjustedAmount]); - tx.transferObjects([coin], transferContent.recipient); + + tx.transferObjects([coin], recipientAddress); const executedTransaction = await suiClient.signAndExecuteTransaction({ signer: suiAccount, @@ -163,13 +179,16 @@ export default { console.log("Transfer successful:", executedTransaction.digest); if (callback) { + const recipient = isValidSuiNSName(transferContent.recipient) + ? `${transferContent.recipient}(${recipientAddress})` + : recipientAddress; callback({ - text: `Successfully transferred ${transferContent.amount} SUI to ${transferContent.recipient}, Transaction: ${executedTransaction.digest}`, + text: `Successfully transferred ${transferContent.amount} SUI to ${recipient}, Transaction: ${executedTransaction.digest}`, content: { success: true, hash: executedTransaction.digest, amount: transferContent.amount, - recipient: transferContent.recipient, + recipient: recipientAddress, }, }); } @@ -209,5 +228,26 @@ export default { }, }, ], + [ + { + user: "{{user1}}", + content: { + text: "Send 1 SUI tokens to eliza.sui", + }, + }, + { + user: "{{user2}}", + content: { + text: "Starting transfer to eliza.sui...", + action: "SEND_TOKEN", + }, + }, + { + user: "{{user2}}", + content: { + text: "Successfully sent 1 SUI tokens to eliza.sui, Transaction: 0x39a8c432d9bdad993a33cc1faf2e9b58fb7dd940c0425f1d6db3997e4b4b05c0", + }, + }, + ], ] as ActionExample[][], } as Action; diff --git a/packages/plugin-sui/src/index.ts b/packages/plugin-sui/src/index.ts index 5f69381fda0..1626ba15403 100644 --- a/packages/plugin-sui/src/index.ts +++ b/packages/plugin-sui/src/index.ts @@ -1,5 +1,7 @@ import { Plugin } from "@elizaos/core"; import transferToken from "./actions/transfer.ts"; +import convertNameToAddress from "./actions/convertNameToAddress.ts"; +import swapToken from "./actions/swap.ts"; import { WalletProvider, walletProvider } from "./providers/wallet.ts"; export { WalletProvider, transferToken as TransferSuiToken }; @@ -7,7 +9,7 @@ export { WalletProvider, transferToken as TransferSuiToken }; export const suiPlugin: Plugin = { name: "sui", description: "Sui Plugin for Eliza", - actions: [transferToken], + actions: [transferToken, convertNameToAddress, swapToken], evaluators: [], providers: [walletProvider], }; diff --git a/packages/plugin-sui/src/providers/wallet.ts b/packages/plugin-sui/src/providers/wallet.ts index 5c12093566f..8b22a96780d 100644 --- a/packages/plugin-sui/src/providers/wallet.ts +++ b/packages/plugin-sui/src/providers/wallet.ts @@ -13,6 +13,7 @@ import BigNumber from "bignumber.js"; import NodeCache from "node-cache"; import * as path from "path"; import { parseAccount } from "../utils"; +import { SuiNetwork } from "../types"; // Provider configuration const PROVIDER_CONFIG = { @@ -29,8 +30,6 @@ interface Prices { sui: { usd: string }; } -type SuiNetwork = "mainnet" | "testnet" | "devnet" | "localnet"; - export class WalletProvider { private cache: NodeCache; private cacheKey: string = "sui/wallet"; @@ -181,7 +180,11 @@ export class WalletProvider { } ); const prices: Prices = { - sui: { usd: (1 / suiPriceData.pair.priceNative).toString() }, + sui: { + usd: new BigNumber(1) + .div(suiPriceData.pair.priceNative) + .toString(), + }, }; this.setCachedData(cacheKey, prices); return prices; diff --git a/packages/plugin-sui/src/types.ts b/packages/plugin-sui/src/types.ts new file mode 100644 index 00000000000..83209689bf3 --- /dev/null +++ b/packages/plugin-sui/src/types.ts @@ -0,0 +1 @@ +export type SuiNetwork = "mainnet" | "testnet" | "devnet" | "localnet"; diff --git a/packages/plugin-sui/src/utils.ts b/packages/plugin-sui/src/utils.ts index 4db8623eb1f..5c6a0e90582 100644 --- a/packages/plugin-sui/src/utils.ts +++ b/packages/plugin-sui/src/utils.ts @@ -1,5 +1,7 @@ import { IAgentRuntime } from "@elizaos/core"; import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519"; +import { isValidSuiNSName, isValidSuiAddress } from "@mysten/sui/utils"; +import type { SuiClient } from "@mysten/sui/client"; const parseAccount = (runtime: IAgentRuntime): Ed25519Keypair => { const privateKey = runtime.getSetting("SUI_PRIVATE_KEY"); @@ -12,4 +14,42 @@ const parseAccount = (runtime: IAgentRuntime): Ed25519Keypair => { } }; -export { parseAccount }; +// to make sure the coin type is valid for swap +const isCoinType = (coinType: string): boolean => { + if ( + coinType.includes("-") || + coinType === "" || + coinType === null || + coinType === undefined + ) { + return false; + } + return true; +}; + +async function getSuiAddress( + value: string, + suiClient: SuiClient +): Promise { + if (isValidSuiNSName(value)) { + const address = await suiClient + .resolveNameServiceAddress({ + name: value, + }) + .then((address) => { + console.log("Resolved address:", address); + return address; + }) + .catch((err) => { + console.error("Error resolving address:", err); + return null; + }); + return address; + } else if (isValidSuiAddress(value)) { + return value; + } else { + return null; + } +} + +export { parseAccount, isCoinType, getSuiAddress };