diff --git a/packages/plugin-ton/src/actions/auctionInteraction.ts b/packages/plugin-ton/src/actions/auctionInteraction.ts new file mode 100644 index 00000000000..2fd5d177d7c --- /dev/null +++ b/packages/plugin-ton/src/actions/auctionInteraction.ts @@ -0,0 +1,422 @@ +import { + elizaLogger, + composeContext, + generateObject, + ModelClass, + type IAgentRuntime, + type Memory, + type State, + type HandlerCallback, +} from "@elizaos/core"; +import { Address, internal, SendMode, toNano } from "@ton/core"; +import { Builder } from "@ton/ton"; +import { z } from "zod"; +import { initWalletProvider, WalletProvider } from "../providers/wallet"; +import { waitSeqno } from "../utils/util"; + +/** + * Schema for auction interaction input. + * + * - auctionAddress: The auction contract address. + * - auctionAction: One of "getAuctionData", "bid", "stop" or "cancel". + * - bidAmount: For a bid action, the bid value (e.g., "2" for 2 TON) as a string. + * - senderAddress: For actions that send an internal message (bid, stop, cancel); represents the caller's address. + */ +const auctionInteractionSchema = z + .object({ + auctionAddress: z.string().nonempty("Auction address is required"), + auctionAction: z.enum(["getAuctionData", "bid", "stop", "cancel"]), + bidAmount: z.string().optional(), + senderAddress: z.string().optional(), + }) + .refine( + (data) => + data.auctionAction !== "bid" || + (data.auctionAction === "bid" && data.bidAmount && data.senderAddress), + { + message: "For a bid action, bidAmount and senderAddress are required", + path: ["bidAmount", "senderAddress"], + } + ) + .refine( + (data) => + (data.auctionAction === "stop" || data.auctionAction === "cancel") === false || + (!!data.senderAddress), + { + message: "For stop or cancel actions, senderAddress is required", + path: ["senderAddress"], + } + ); + +/** + * Template guiding the extraction of auction interaction parameters. + * + * Example expected output: + * { + * "auctionAddress": "EQAuctionAddressExample", + * "auctionAction": "bid", + * "bidAmount": "2", + * "senderAddress": "EQBidderAddressExample" + * } + * + * {{recentMessages}} + * + * Extract and output only these values. + */ +const auctionInteractionTemplate = `Respond with a JSON markdown block containing the properties: +{ + "auctionAddress": "", + "auctionAction": "", + "bidAmount": "", + "senderAddress": "" +} +{{recentMessages}} + +Extract and output only these values.`; + +/** + * Helper function to build auction interaction parameters. + */ +const buildAuctionInteractionData = async ( + runtime: IAgentRuntime, + message: Memory, + state: State +): Promise<{ + auctionAddress: string; + auctionAction: "getAuctionData" | "bid" | "stop" | "cancel"; + bidAmount?: string; + senderAddress?: string; +}> => { + const context = composeContext({ + state, + template: auctionInteractionTemplate, + }); + const content = await generateObject({ + runtime, + context, + schema: auctionInteractionSchema, + modelClass: ModelClass.SMALL, + }); + return content.object as any; +}; + +/** + * AuctionInteractionAction encapsulates the core logic to interact with an auction contract. + */ +export class AuctionInteractionAction { + private walletProvider: WalletProvider; + constructor(walletProvider: WalletProvider) { + this.walletProvider = walletProvider; + } + + /** + * Retrieves auction sale data by calling the "get_auction_data" method on the auction contract. + * The decoding here is demonstrative; actual fields depend on your auction contract's ABI. + */ + async getAuctionData(auctionAddress: string): Promise { + const client = this.walletProvider.getWalletClient(); + const addr = Address.parse(auctionAddress); + const result = await client.runMethod(addr, "get_auction_data"); + + // console.log("getSaleData result:", result); + + try { + const activated = result.stack.readNumber(); + const end = result.stack.readNumber(); + const end_time = result.stack.readNumber(); + const mp_addr = result.stack.readAddress()?.toString() || ""; + const nft_addr = result.stack.readAddress()?.toString() || ""; + let nft_owner: string; + try { + nft_owner = result.stack.readAddress()?.toString() || ""; + } catch (e) { + nft_owner = ""; + } + const last_bid = result.stack.readNumber(); + const last_member = result.stack.readAddress()?.toString() || ""; + const min_step = result.stack.readNumber(); + const mp_fee_addr = result.stack.readAddress()?.toString() || ""; + const mp_fee_factor = result.stack.readNumber(); + const mp_fee_base = result.stack.readNumber(); + const royalty_fee_addr = result.stack.readAddress()?.toString() || ""; + const royalty_fee_factor = result.stack.readNumber(); + const royalty_fee_base = result.stack.readNumber(); + const max_bid = result.stack.readNumber(); + const min_bid = result.stack.readNumber(); + let created_at: number | null = null; + try { + created_at = result.stack.readNumber(); + } catch (e) { + created_at = null; + } + const last_bid_at = result.stack.readNumber(); + const is_canceled = result.stack.readNumber(); + const step_time = result.stack.readNumber(); + const last_query_id = result.stack.readNumber(); + + return { + auctionAddress, + activated, + end, + end_time, + mp_addr, + nft_addr, + nft_owner, + last_bid, + last_member, + min_step, + mp_fee_addr, + mp_fee_factor, + mp_fee_base, + royalty_fee_addr, + royalty_fee_factor, + royalty_fee_base, + max_bid, + min_bid, + created_at, + last_bid_at, + is_canceled, + step_time, + last_query_id, + message: "Auction sale data fetched successfully", + }; + } catch (parseError) { + elizaLogger.error("Error parsing sale data:", parseError); + return { error: "Failed to parse sale data" }; + } + } + + /** + * Sends a bid by creating and sending an internal message with an empty bid body. + */ + async bid(auctionAddress: string, bidAmount: string): Promise { + const auctionAddr = Address.parse(auctionAddress); + // Create an empty cell for the bid message body. + const bidMessage = internal({ + to: auctionAddr, + value: toNano(bidAmount), + bounce: true, + body: "" + }); + + const contract = this.walletProvider.getWalletClient().open(this.walletProvider.wallet); + + const seqno = await contract.getSeqno(); + // Send message using the TON client. + const transfer = await contract.createTransfer({ + seqno, + secretKey: this.walletProvider.keypair.secretKey, + messages: [bidMessage], + sendMode: SendMode.IGNORE_ERRORS + SendMode.PAY_GAS_SEPARATELY, + } + ); + + await contract.send(transfer); + await waitSeqno(seqno, this.walletProvider.wallet); + + return { + auctionAddress, + bidAmount, + message: "Bid placed successfully", + }; + } + + /** + * Sends a stop-auction message. + */ + async stop(auctionAddress: string): Promise { + const client = this.walletProvider.getWalletClient(); + const contract = client.open(this.walletProvider.wallet); + + const seqno = await contract.getSeqno(); + + const auctionAddr = Address.parse(auctionAddress); + // based on https://github.com/getgems-io/nft-contracts/blob/7654183fea73422808281c8336649b49ce9939a2/packages/contracts/nft-auction-v2/NftAuctionV2.data.ts#L86 + const stopBody = new Builder().storeUint(0, 32).storeBuffer(Buffer.from('stop')).endCell(); + const stopMessage = internal({ + to: auctionAddr, + value: toNano("0.05"), + bounce: true, + body: stopBody + }); + const transfer = await contract.createTransfer({ + seqno, + secretKey: this.walletProvider.keypair.secretKey, + messages: [stopMessage], + sendMode: SendMode.IGNORE_ERRORS + SendMode.PAY_GAS_SEPARATELY, + }); + await contract.send(transfer); + await waitSeqno(seqno, this.walletProvider.wallet); + return { + auctionAddress, + message: "Stop auction message sent successfully", + }; + } + + /** + * Sends a cancel auction message using a placeholder opcode (0xDEADBEEF). + */ + async cancel(auctionAddress: string): Promise { + const client = this.walletProvider.getWalletClient(); + const contract = client.open(this.walletProvider.wallet); + + const auctionAddr = Address.parse(auctionAddress); + // based on https://github.com/getgems-io/nft-contracts/blob/7654183fea73422808281c8336649b49ce9939a2/packages/contracts/nft-auction-v2/NftAuctionV2.data.ts#L90 + const cancelBody = new Builder().storeUint(0, 32).storeBuffer(Buffer.from('cancel')).endCell(); + const seqno = await contract.getSeqno(); + const cancelMessage = internal({ + to: auctionAddr, + value: toNano("0.05"), + bounce: true, + body: cancelBody + }); + const transfer = await contract.createTransfer({ + seqno, + secretKey: this.walletProvider.keypair.secretKey, + messages: [cancelMessage], + sendMode: SendMode.IGNORE_ERRORS + SendMode.PAY_GAS_SEPARATELY, + }); + await contract.send(transfer); + await waitSeqno(seqno, this.walletProvider.wallet); + return { + auctionAddress, + message: "Cancel auction message sent successfully", + }; + } +} + +export default { + name: "INTERACT_AUCTION", + similes: ["AUCTION_INTERACT", "AUCTION_ACTION"], + description: + "Interacts with an auction contract. Supports actions: getSaleData, bid, stop, and cancel.", + handler: async ( + runtime: IAgentRuntime, + message: Memory, + state: State, + options: any, + callback?: HandlerCallback, + ) => { + elizaLogger.log("Starting INTERACT_AUCTION handler..."); + try { + // Build interaction parameters using the helper. + const params = await buildAuctionInteractionData(runtime, message, state); + const walletProvider = await initWalletProvider(runtime); + const auctionAction = new AuctionInteractionAction(walletProvider); + let result: any; + switch (params.auctionAction) { + case "getAuctionData": + result = await auctionAction.getAuctionData(params.auctionAddress); + break; + case "bid": + result = await auctionAction.bid( + params.auctionAddress, + params.bidAmount! + ); + break; + case "stop": + result = await auctionAction.stop( + params.auctionAddress + ); + break; + case "cancel": + result = await auctionAction.cancel( + params.auctionAddress + ); + break; + default: + throw new Error("Invalid auction action"); + } + if (callback) { + callback({ + text: JSON.stringify(result, null, 2), + content: result, + }); + } + } catch (error: any) { + elizaLogger.error("Error in INTERACT_AUCTION handler:", error); + if (callback) { + callback({ + text: `Error in INTERACT_AUCTION: ${error.message}`, + content: { error: error.message }, + }); + } + } + return true; + }, + template: auctionInteractionTemplate, + // eslint-disable-next-line + validate: async (_runtime: IAgentRuntime) => { + return true; + }, + examples: [ + [ + { + user: "{{user1}}", + content: { + auctionAddress: "EQAuctionAddressExample", + auctionAction: "getAuctionData", + action: "INTERACT_AUCTION", + }, + }, + { + user: "{{user1}}", + content: { + text: "Auction sale data fetched successfully", + }, + }, + ], + [ + { + user: "{{user1}}", + content: { + auctionAddress: "EQAuctionAddressExample", + auctionAction: "bid", + bidAmount: "2", + senderAddress: "EQBidderAddressExample", + action: "INTERACT_AUCTION", + }, + }, + { + user: "{{user1}}", + content: { + text: "Bid placed successfully", + }, + }, + ], + [ + { + user: "{{user1}}", + content: { + auctionAddress: "EQAuctionAddressExample", + auctionAction: "stop", + senderAddress: "EQOwnerAddressExample", + action: "INTERACT_AUCTION", + }, + }, + { + user: "{{user1}}", + content: { + text: "Stop auction message sent successfully", + }, + }, + ], + [ + { + user: "{{user1}}", + content: { + auctionAddress: "EQAuctionAddressExample", + auctionAction: "cancel", + senderAddress: "EQOwnerAddressExample", + action: "INTERACT_AUCTION", + }, + }, + { + user: "{{user1}}", + content: { + text: "Cancel auction message sent successfully", + }, + }, + ], + ], +}; \ No newline at end of file diff --git a/packages/plugin-ton/src/index.ts b/packages/plugin-ton/src/index.ts index ef576da962e..0dd81417a45 100644 --- a/packages/plugin-ton/src/index.ts +++ b/packages/plugin-ton/src/index.ts @@ -1,15 +1,16 @@ -import type { Plugin } from "@elizaos/core"; +import type { Action, Plugin } from "@elizaos/core"; import transferAction from "./actions/transfer.ts"; import { WalletProvider, nativeWalletProvider } from "./providers/wallet.ts"; +import auctionAction from "./actions/auctionInteraction.ts"; -export { WalletProvider, transferAction as TransferTonToken }; +export { WalletProvider, transferAction as TransferTonToken, auctionAction as AuctionInteractionTon }; export const tonPlugin: Plugin = { name: "ton", description: "Ton Plugin for Eliza", - actions: [transferAction], + actions: [transferAction, auctionAction as Action], evaluators: [], providers: [nativeWalletProvider], -}; +} export default tonPlugin; diff --git a/packages/plugin-ton/src/tests/auctionInteraction.test.ts b/packages/plugin-ton/src/tests/auctionInteraction.test.ts new file mode 100644 index 00000000000..f1050f74914 --- /dev/null +++ b/packages/plugin-ton/src/tests/auctionInteraction.test.ts @@ -0,0 +1,113 @@ +import { describe, it, vi, beforeAll, beforeEach, afterEach } from "vitest"; +import { AuctionInteractionAction } from "../actions/auctionInteraction"; +import { defaultCharacter } from "@elizaos/core"; +import { type KeyPair, mnemonicNew, mnemonicToPrivateKey } from "@ton/crypto"; +import { WalletProvider } from "../providers/wallet"; +import { CONFIG_KEYS } from "../enviroment"; + + +// Mock NodeCache +vi.mock("node-cache", () => { + return { + default: vi.fn().mockImplementation(() => ({ + set: vi.fn(), + get: vi.fn().mockReturnValue(null), + })), + }; +}); + +// Mock path module +vi.mock("path", async () => { + const actual = await vi.importActual("path"); + return { + ...actual, + join: vi.fn().mockImplementation((...args) => args.join("/")), + }; +}); + +// Mock the ICacheManager +export const mockCacheManager = { + get: vi.fn().mockResolvedValue(null), + set: vi.fn(), + delete: vi.fn(), +}; + +export const testnet = "https://testnet.toncenter.com/api/v2/jsonRPC"; + +const NFT_AUCTION_CONTRACT_ADDRESS = "kQC_fD_gbAgXsuizLU-5usV4sIuRhotmM3DYIUSkBpFYXwAR"; + +describe("Auction Interaction Action", () => { + let auctionAction: AuctionInteractionAction; + let walletProvider: WalletProvider; + let keypair: KeyPair; + let mockedRuntime; + + beforeAll(async () => { + const password = ""; + const privateKey = process.env.TON_PRIVATE_KEY; + if (!privateKey) { + throw new Error(`TON_PRIVATE_KEY is missing`); + } + + const mnemonics = privateKey.split(" "); + if (mnemonics.length < 2) { + throw new Error(`TON_PRIVATE_KEY mnemonic seems invalid`); + } + keypair = await mnemonicToPrivateKey(mnemonics, password); + + walletProvider = new WalletProvider(keypair, testnet, mockCacheManager); + mockedRuntime = { + character: defaultCharacter, + }; + auctionAction = new AuctionInteractionAction(walletProvider); + }); + + beforeEach(() => { + vi.clearAllMocks(); + mockCacheManager.get.mockResolvedValue(null); + }); + + afterEach(() => { + vi.clearAllTimers(); + }); + + + it("should log result for getSaleData", async () => { + try { + const result = await auctionAction.getAuctionData(NFT_AUCTION_CONTRACT_ADDRESS); + console.log("Direct getSaleData result:", result); + } catch (error: any) { + console.log("Direct getSaleData error:", error.message); + } + }); + + it("should log result for bid", async () => { + try { + const result = await auctionAction.bid( + NFT_AUCTION_CONTRACT_ADDRESS, + "2" + ); + console.log("Direct bid result:", result); + } catch (error: any) { + console.log("Direct bid error:", error); + } + }); + + it("should log result for stop", async () => { + try { + const result = await auctionAction.stop(NFT_AUCTION_CONTRACT_ADDRESS); + console.log("Direct stop result:", result); + } catch (error: any) { + console.log("Direct stop error:", error); + } + }); + + it("should log result for cancel", async () => { + try { + const result = await auctionAction.cancel(NFT_AUCTION_CONTRACT_ADDRESS); + console.log("Direct cancel result:", result); + } catch (error: any) { + console.log("Direct cancel error:", error.message); + } + }); +}); \ No newline at end of file diff --git a/packages/plugin-ton/src/tests/wallet.test.ts b/packages/plugin-ton/src/tests/wallet.test.ts index 37c8fa3a287..5606c26470c 100644 --- a/packages/plugin-ton/src/tests/wallet.test.ts +++ b/packages/plugin-ton/src/tests/wallet.test.ts @@ -34,13 +34,13 @@ vi.mock("path", async () => { }); // Mock the ICacheManager -const mockCacheManager = { +export const mockCacheManager = { get: vi.fn().mockResolvedValue(null), set: vi.fn(), delete: vi.fn(), }; -const testnet = "https://testnet.toncenter.com/api/v2/jsonRPC"; +export const testnet = "https://testnet.toncenter.com/api/v2/jsonRPC"; describe("Wallet provider", () => { let walletProvider: WalletProvider;