diff --git a/cdp-agentkit-core/typescript/CHANGELOG.md b/cdp-agentkit-core/typescript/CHANGELOG.md index a1fabc27..bab6adb2 100644 --- a/cdp-agentkit-core/typescript/CHANGELOG.md +++ b/cdp-agentkit-core/typescript/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### Added + +- Added `morpho_deposit` action to deposit to Morpho Vault. +- Added `morpho_withdrawal` action to withdraw from Morpho Vault. + ## [0.0.12] - 2025-01-17 ### Added diff --git a/cdp-agentkit-core/typescript/src/actions/cdp/constants.ts b/cdp-agentkit-core/typescript/src/actions/cdp/constants.ts new file mode 100644 index 00000000..cd6c132c --- /dev/null +++ b/cdp-agentkit-core/typescript/src/actions/cdp/constants.ts @@ -0,0 +1,14 @@ +export const ERC20_APPROVE_ABI = [ + { + constant: false, + inputs: [ + { internalType: "address", name: "spender", type: "address" }, + { internalType: "uint256", name: "value", type: "uint256" }, + ], + name: "approve", + outputs: [{ internalType: "bool", name: "", type: "bool" }], + payable: false, + stateMutability: "nonpayable", + type: "function", + }, +]; diff --git a/cdp-agentkit-core/typescript/src/actions/cdp/defi/morpho/constants.ts b/cdp-agentkit-core/typescript/src/actions/cdp/defi/morpho/constants.ts new file mode 100644 index 00000000..0f60c736 --- /dev/null +++ b/cdp-agentkit-core/typescript/src/actions/cdp/defi/morpho/constants.ts @@ -0,0 +1,25 @@ +export const MORPHO_BASE_ADDRESS = "0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb"; + +export const METAMORPHO_ABI = [ + { + inputs: [ + { internalType: "uint256", name: "assets", type: "uint256" }, + { internalType: "address", name: "receiver", type: "address" }, + ], + name: "deposit", + outputs: [{ internalType: "uint256", name: "shares", type: "uint256" }], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { internalType: "uint256", name: "assets", type: "uint256" }, + { internalType: "address", name: "receiver", type: "address" }, + { internalType: "address", name: "owner", type: "address" }, + ], + name: "withdraw", + outputs: [{ internalType: "uint256", name: "shares", type: "uint256" }], + stateMutability: "nonpayable", + type: "function", + }, +]; diff --git a/cdp-agentkit-core/typescript/src/actions/cdp/defi/morpho/deposit.ts b/cdp-agentkit-core/typescript/src/actions/cdp/defi/morpho/deposit.ts new file mode 100644 index 00000000..3c4973e9 --- /dev/null +++ b/cdp-agentkit-core/typescript/src/actions/cdp/defi/morpho/deposit.ts @@ -0,0 +1,112 @@ +import { Asset, Wallet } from "@coinbase/coinbase-sdk"; +import { z } from "zod"; +import { Decimal } from "decimal.js"; + +import { CdpAction } from "../../cdp_action"; +import { approve } from "../../utils"; + +import { METAMORPHO_ABI } from "./constants"; + +const DEPOSIT_PROMPT = ` +This tool allows depositing assets into a Morpho Vault. + +It takes: +- vaultAddress: The address of the Morpho Vault to deposit to +- assets: The amount of assets to deposit in whole units + Examples for WETH: + - 1 WETH + - 0.1 WETH + - 0.01 WETH +- receiver: The address to receive the shares +- tokenAddress: The address of the token to approve + +Important notes: +- Make sure to use the exact amount provided. Do not convert units for assets for this action. +- Please use a token address (example 0x4200000000000000000000000000000000000006) for the tokenAddress field. If you are unsure of the token address, please clarify what the requested token address is before continuing. +`; + +/** + * Input schema for Morpho Vault deposit action. + */ +export const MorphoDepositInput = z + .object({ + assets: z + .string() + .regex(/^\d+(\.\d+)?$/, "Must be a valid integer or decimal value") + .describe("The quantity of assets to deposit, in whole units"), + receiver: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format") + .describe( + "The address that will own the position on the vault which will receive the shares", + ), + tokenAddress: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format") + .describe("The address of the assets token to approve for deposit"), + vaultAddress: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format") + .describe("The address of the Morpho Vault to deposit to"), + }) + .describe("Input schema for Morpho Vault deposit action"); + +/** + * Deposits assets into a Morpho Vault + * @param Wallet - The wallet instance to execute the transaction + * @param args - The input arguments for the action + * @returns A success message with transaction details or an error message + */ +export async function depositToMorpho( + wallet: Wallet, + args: z.infer, +): Promise { + const assets = new Decimal(args.assets); + + if (assets.comparedTo(new Decimal(0.0)) != 1) { + return "Error: Assets amount must be greater than 0"; + } + + try { + const tokenAsset = await Asset.fetch(wallet.getNetworkId(), args.tokenAddress); + const atomicAssets = tokenAsset.toAtomicAmount(assets); + + const approvalResult = await approve( + wallet, + args.tokenAddress, + args.vaultAddress, + atomicAssets, + ); + if (approvalResult.startsWith("Error")) { + return `Error approving Morpho Vault as spender: ${approvalResult}`; + } + + const contractArgs = { + assets: atomicAssets.toString(), + receiver: args.receiver, + }; + + const invocation = await wallet.invokeContract({ + contractAddress: args.vaultAddress, + method: "deposit", + abi: METAMORPHO_ABI, + args: contractArgs, + }); + + const result = await invocation.wait(); + + return `Deposited ${args.assets} to Morpho Vault ${args.vaultAddress} with transaction hash: ${result.getTransactionHash()} and transaction link: ${result.getTransactionLink()}`; + } catch (error) { + return `Error depositing to Morpho Vault: ${error}`; + } +} + +/** + * Morpho Vault deposit action. + */ +export class MorphoDepositAction implements CdpAction { + public name = "morpho_deposit"; + public description = DEPOSIT_PROMPT; + public argsSchema = MorphoDepositInput; + public func = depositToMorpho; +} diff --git a/cdp-agentkit-core/typescript/src/actions/cdp/defi/morpho/index.ts b/cdp-agentkit-core/typescript/src/actions/cdp/defi/morpho/index.ts new file mode 100644 index 00000000..70204461 --- /dev/null +++ b/cdp-agentkit-core/typescript/src/actions/cdp/defi/morpho/index.ts @@ -0,0 +1,18 @@ +import { CdpAction, CdpActionSchemaAny } from "../../cdp_action"; + +import { MorphoDepositAction } from "./deposit"; +import { MorphoWithdrawAction } from "./withdraw"; + +/** + * Retrieves all Morpho action instances. + * WARNING: All new Morpho action classes must be instantiated here to be discovered. + * + * @returns - Array of Morpho action instances + */ +export function getAllMorphoActions(): CdpAction[] { + return [new MorphoDepositAction(), new MorphoWithdrawAction()]; +} + +export const MORPHO_ACTIONS = getAllMorphoActions(); + +export { MorphoDepositAction, MorphoWithdrawAction }; diff --git a/cdp-agentkit-core/typescript/src/actions/cdp/defi/morpho/withdraw.ts b/cdp-agentkit-core/typescript/src/actions/cdp/defi/morpho/withdraw.ts new file mode 100644 index 00000000..9c5b51e5 --- /dev/null +++ b/cdp-agentkit-core/typescript/src/actions/cdp/defi/morpho/withdraw.ts @@ -0,0 +1,79 @@ +import { Asset, Wallet } from "@coinbase/coinbase-sdk"; +import { z } from "zod"; + +import { CdpAction } from "../../cdp_action"; +import { METAMORPHO_ABI } from "./constants"; + +const WITHDRAW_PROMPT = ` +This tool allows withdrawing assets from a Morpho Vault. It takes: + +- vaultAddress: The address of the Morpho Vault to withdraw from +- assets: The amount of assets to withdraw in atomic units +- receiver: The address to receive the shares +`; + +/** + * Input schema for Morpho Vault withdraw action. + */ +export const MorphoWithdrawInput = z + .object({ + vaultAddress: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format") + .describe("The address of the Morpho Vault to withdraw from"), + assets: z + .string() + .regex(/^\d+$/, "Must be a valid whole number") + .describe("The amount of assets to withdraw in atomic units e.g. 1"), + receiver: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format") + .describe("The address to receive the shares"), + }) + .strip() + .describe("Input schema for Morpho Vault withdraw action"); + +/** + * Withdraw assets from a Morpho Vault. + * + * @param wallet - The wallet to execute the withdrawal from + * @param args - The input arguments for the action + * @returns A success message with transaction details or error message + */ +export async function withdrawFromMorpho( + wallet: Wallet, + args: z.infer, +): Promise { + if (BigInt(args.assets) <= 0) { + return "Error: Assets amount must be greater than 0"; + } + + try { + const invocation = await wallet.invokeContract({ + contractAddress: args.vaultAddress, + method: "withdraw", + abi: METAMORPHO_ABI, + args: { + assets: args.assets, + receiver: args.receiver, + owner: args.receiver, + }, + }); + + const result = await invocation.wait(); + + return `Withdrawn ${args.assets} from Morpho Vault ${args.vaultAddress} with transaction hash: ${result.getTransaction().getTransactionHash()} and transaction link: ${result.getTransaction().getTransactionLink()}`; + } catch (error) { + return `Error withdrawing from Morpho Vault: ${error}`; + } +} + +/** + * Morpho Vault withdraw action. + */ +export class MorphoWithdrawAction implements CdpAction { + public name = "morpho_withdraw"; + public description = WITHDRAW_PROMPT; + public argsSchema = MorphoWithdrawInput; + public func = withdrawFromMorpho; +} diff --git a/cdp-agentkit-core/typescript/src/actions/cdp/index.ts b/cdp-agentkit-core/typescript/src/actions/cdp/index.ts index 2fa928a4..68b6282c 100644 --- a/cdp-agentkit-core/typescript/src/actions/cdp/index.ts +++ b/cdp-agentkit-core/typescript/src/actions/cdp/index.ts @@ -11,8 +11,10 @@ import { TradeAction } from "./trade"; import { TransferAction } from "./transfer"; import { TransferNftAction } from "./transfer_nft"; import { WrapEthAction } from "./wrap_eth"; -import { WOW_ACTIONS } from "./defi/wow"; + +import { MORPHO_ACTIONS } from "./defi/morpho"; import { PYTH_ACTIONS } from "./data/pyth"; +import { WOW_ACTIONS } from "./defi/wow"; /** * Retrieves all CDP action instances. @@ -37,7 +39,10 @@ export function getAllCdpActions(): CdpAction[] { ]; } -export const CDP_ACTIONS = getAllCdpActions().concat(WOW_ACTIONS).concat(PYTH_ACTIONS); +export const CDP_ACTIONS = getAllCdpActions() + .concat(MORPHO_ACTIONS) + .concat(PYTH_ACTIONS) + .concat(WOW_ACTIONS); export { CdpAction, diff --git a/cdp-agentkit-core/typescript/src/actions/cdp/utils.ts b/cdp-agentkit-core/typescript/src/actions/cdp/utils.ts new file mode 100644 index 00000000..94043825 --- /dev/null +++ b/cdp-agentkit-core/typescript/src/actions/cdp/utils.ts @@ -0,0 +1,36 @@ +import { Wallet } from "@coinbase/coinbase-sdk"; + +import { ERC20_APPROVE_ABI } from "./constants"; + +/** + * Approve a spender to spend a specified amount of tokens. + * @param wallet - The wallet to execute the approval from + * @param tokenAddress - The address of the token contract + * @param spender - The address of the spender + * @param amount - The amount of tokens to approve + * @returns A success message with transaction hash or error message + */ +export async function approve( + wallet: Wallet, + tokenAddress: string, + spender: string, + amount: bigint, +): Promise { + try { + const invocation = await wallet.invokeContract({ + contractAddress: tokenAddress, + method: "approve", + abi: ERC20_APPROVE_ABI, + args: { + spender: spender, + value: amount.toString(), + }, + }); + + const result = await invocation.wait(); + + return `Approved ${amount} tokens for ${spender} with transaction hash: ${result.getTransactionHash()}`; + } catch (error) { + return `Error approving tokens: ${error}`; + } +} diff --git a/cdp-agentkit-core/typescript/src/tests/defi_morpho_deposit_test.ts b/cdp-agentkit-core/typescript/src/tests/defi_morpho_deposit_test.ts new file mode 100644 index 00000000..bd1f2fd7 --- /dev/null +++ b/cdp-agentkit-core/typescript/src/tests/defi_morpho_deposit_test.ts @@ -0,0 +1,199 @@ +import { Coinbase, ContractInvocation, Wallet, Asset } from "@coinbase/coinbase-sdk"; + +import { approve } from "../actions/cdp/utils"; + +import { MorphoDepositAction } from "../actions/cdp/defi/morpho/deposit"; +import { METAMORPHO_ABI } from "../actions/cdp/defi/morpho/constants"; + +const MOCK_VAULT_ADDRESS = "0x1234567890123456789012345678901234567890"; +const MOCK_ATOMIC_ASSETS = "1000000000000000000"; +const MOCK_WHOLE_ASSETS = "0.0001"; +const MOCK_RECEIVER_ID = "0x9876543210987654321098765432109876543210"; +const MOCK_TOKEN_ADDRESS = "0x4200000000000000000000000000000000000006"; + +jest.mock("../actions/cdp/utils"); +const mockApprove = approve as jest.MockedFunction; + +describe("Morpho Deposit Input", () => { + const action = new MorphoDepositAction(); + + it("should successfully parse valid input", () => { + const validInput = { + vaultAddress: MOCK_VAULT_ADDRESS, + assets: MOCK_WHOLE_ASSETS, + receiver: MOCK_RECEIVER_ID, + tokenAddress: MOCK_TOKEN_ADDRESS, + }; + + const result = action.argsSchema.safeParse(validInput); + + expect(result.success).toBe(true); + expect(result.data).toEqual(validInput); + }); + + it("should fail parsing empty input", () => { + const emptyInput = {}; + const result = action.argsSchema.safeParse(emptyInput); + + expect(result.success).toBe(false); + }); + + it("should fail with invalid vault address", () => { + const invalidInput = { + vaultAddress: "not_an_address", + assets: MOCK_WHOLE_ASSETS, + receiver: MOCK_RECEIVER_ID, + tokenAddress: MOCK_TOKEN_ADDRESS, + }; + const result = action.argsSchema.safeParse(invalidInput); + + expect(result.success).toBe(false); + }); + + it("should handle valid asset string formats", () => { + const validInput = { + vaultAddress: MOCK_VAULT_ADDRESS, + assets: MOCK_WHOLE_ASSETS, + receiver: MOCK_RECEIVER_ID, + tokenAddress: MOCK_TOKEN_ADDRESS, + }; + + const validInputs = [ + { ...validInput, assets: "1000000000000000000" }, + { ...validInput, assets: "1.5" }, + { ...validInput, assets: "0.00001" }, + ]; + + validInputs.forEach(input => { + const result = action.argsSchema.safeParse(input); + expect(result.success).toBe(true); + }); + }); + + it("should reject invalid asset strings", () => { + const validInput = { + vaultAddress: MOCK_VAULT_ADDRESS, + assets: MOCK_WHOLE_ASSETS, + receiver: MOCK_RECEIVER_ID, + tokenAddress: MOCK_TOKEN_ADDRESS, + }; + + const invalidInputs = [ + { ...validInput, assets: "" }, + { ...validInput, assets: "1,000" }, + { ...validInput, assets: "1.2.3" }, + { ...validInput, assets: "abc" }, + ]; + + invalidInputs.forEach(input => { + const result = action.argsSchema.safeParse(input); + expect(result.success).toBe(false); + }); + }); +}); + +describe("Morpho Deposit Action", () => { + const NETWORK_ID = Coinbase.networks.BaseSepolia; + const TRANSACTION_HASH = "0xabcdef1234567890"; + const TRANSACTION_LINK = `https://etherscan.io/tx/${TRANSACTION_HASH}`; + + const action = new MorphoDepositAction(); + + let mockContractInvocation: jest.Mocked; + let mockWallet: jest.Mocked; + + beforeEach(() => { + mockContractInvocation = { + wait: jest.fn().mockResolvedValue({ + getTransactionHash: jest.fn().mockReturnValue(TRANSACTION_HASH), + getTransactionLink: jest.fn().mockReturnValue(TRANSACTION_LINK), + }), + } as unknown as jest.Mocked; + + mockWallet = { + invokeContract: jest.fn(), + getDefaultAddress: jest.fn().mockResolvedValue({ + getId: jest.fn().mockReturnValue(MOCK_RECEIVER_ID), + }), + getNetworkId: jest.fn().mockReturnValue(NETWORK_ID), + } as unknown as jest.Mocked; + + mockWallet.invokeContract.mockResolvedValue(mockContractInvocation); + + jest.spyOn(Asset, "fetch").mockResolvedValue({ + toAtomicAmount: jest.fn().mockReturnValue(BigInt(MOCK_ATOMIC_ASSETS)), + } as unknown as Asset); + + mockApprove.mockResolvedValue("Approval successful"); + }); + + it("should successfully deposit to Morpho vault", async () => { + const args = { + vaultAddress: MOCK_VAULT_ADDRESS, + assets: MOCK_WHOLE_ASSETS, + receiver: MOCK_RECEIVER_ID, + tokenAddress: MOCK_TOKEN_ADDRESS, + }; + + const atomicAssets = BigInt(MOCK_ATOMIC_ASSETS); + const response = await action.func(mockWallet, args); + + expect(mockApprove).toHaveBeenCalledWith( + mockWallet, + MOCK_TOKEN_ADDRESS, + MOCK_VAULT_ADDRESS, + atomicAssets, + ); + + expect(mockWallet.invokeContract).toHaveBeenCalledWith({ + contractAddress: MOCK_VAULT_ADDRESS, + method: "deposit", + abi: METAMORPHO_ABI, + args: { + assets: MOCK_ATOMIC_ASSETS, + receiver: MOCK_RECEIVER_ID, + }, + }); + + expect(mockContractInvocation.wait).toHaveBeenCalled(); + expect(response).toContain(`Deposited ${MOCK_WHOLE_ASSETS}`); + expect(response).toContain(`to Morpho Vault ${MOCK_VAULT_ADDRESS}`); + expect(response).toContain(`with transaction hash: ${TRANSACTION_HASH}`); + expect(response).toContain(`and transaction link: ${TRANSACTION_LINK}`); + }); + + it("should handle approval failure", async () => { + const args = { + vaultAddress: MOCK_VAULT_ADDRESS, + assets: MOCK_WHOLE_ASSETS, + receiver: MOCK_RECEIVER_ID, + tokenAddress: MOCK_TOKEN_ADDRESS, + }; + + mockApprove.mockResolvedValue("Error: Approval failed"); + + const response = await action.func(mockWallet, args); + + expect(mockApprove).toHaveBeenCalled(); + expect(response).toContain("Error approving Morpho Vault as spender: Error: Approval failed"); + expect(mockWallet.invokeContract).not.toHaveBeenCalled(); + }); + + it("should handle deposit errors", async () => { + const args = { + vaultAddress: MOCK_VAULT_ADDRESS, + assets: MOCK_WHOLE_ASSETS, + receiver: MOCK_RECEIVER_ID, + tokenAddress: MOCK_TOKEN_ADDRESS, + }; + + const error = new Error("Failed to deposit to Morpho vault"); + mockWallet.invokeContract.mockRejectedValue(error); + + const response = await action.func(mockWallet, args); + + expect(mockApprove).toHaveBeenCalled(); + expect(mockWallet.invokeContract).toHaveBeenCalled(); + expect(response).toContain(`Error depositing to Morpho Vault: ${error}`); + }); +}); diff --git a/cdp-agentkit-core/typescript/src/tests/defi_morpho_withdraw_test.ts b/cdp-agentkit-core/typescript/src/tests/defi_morpho_withdraw_test.ts new file mode 100644 index 00000000..a9ad7d7a --- /dev/null +++ b/cdp-agentkit-core/typescript/src/tests/defi_morpho_withdraw_test.ts @@ -0,0 +1,154 @@ +import { Coinbase, ContractInvocation, Wallet } from "@coinbase/coinbase-sdk"; +import { MorphoWithdrawAction } from "../actions/cdp/defi/morpho/withdraw"; +import { METAMORPHO_ABI } from "../actions/cdp/defi/morpho/constants"; + +const MOCK_VAULT_ADDRESS = "0x1234567890123456789012345678901234567890"; +const MOCK_ASSETS = "1000000000000000000"; // 1 token in wei +const MOCK_RECEIVER_ID = "0x9876543210987654321098765432109876543210"; + +describe("Morpho Withdraw Input", () => { + const action = new MorphoWithdrawAction(); + + it("should successfully parse valid input", () => { + const validInput = { + vaultAddress: MOCK_VAULT_ADDRESS, + assets: MOCK_ASSETS, + receiver: MOCK_RECEIVER_ID, + }; + + const result = action.argsSchema.safeParse(validInput); + + expect(result.success).toBe(true); + expect(result.data).toEqual(validInput); + }); + + it("should fail parsing empty input", () => { + const emptyInput = {}; + const result = action.argsSchema.safeParse(emptyInput); + + expect(result.success).toBe(false); + }); + + it("should fail with invalid Vault address", () => { + const invalidInput = { + vaultAddress: "not_an_address", + assets: MOCK_ASSETS, + receiver: MOCK_RECEIVER_ID, + }; + const result = action.argsSchema.safeParse(invalidInput); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].path[0]).toBe("vaultAddress"); + } + }); + + it("should handle valid asset string formats", () => { + const validInput = { + vaultAddress: MOCK_VAULT_ADDRESS, + assets: MOCK_ASSETS, + receiver: MOCK_RECEIVER_ID, + }; + + const validInputs = [{ ...validInput, assets: "1000000000000000000" }]; + + validInputs.forEach(input => { + const result = action.argsSchema.safeParse(input); + expect(result.success).toBe(true); + }); + }); + + it("should reject invalid asset strings", () => { + const validInput = { + vaultAddress: MOCK_VAULT_ADDRESS, + assets: MOCK_ASSETS, + receiver: MOCK_RECEIVER_ID, + }; + + const invalidInputs = [ + { ...validInput, assets: "" }, + { ...validInput, assets: "1,000" }, + { ...validInput, assets: "1.2.3" }, + { ...validInput, assets: "abc" }, + ]; + + invalidInputs.forEach(input => { + const result = action.argsSchema.safeParse(input); + expect(result.success).toBe(false); + }); + }); +}); + +describe("Morpho Withdraw Action", () => { + const NETWORK_ID = Coinbase.networks.BaseSepolia; + const TRANSACTION_HASH = "0xabcdef1234567890"; + const TRANSACTION_LINK = `https://etherscan.io/tx/${TRANSACTION_HASH}`; + + const action = new MorphoWithdrawAction(); + + let mockContractInvocation: jest.Mocked; + let mockWallet: jest.Mocked; + + beforeEach(() => { + mockContractInvocation = { + wait: jest.fn().mockResolvedValue({ + getTransaction: jest.fn().mockReturnValue({ + getTransactionHash: jest.fn().mockReturnValue(TRANSACTION_HASH), + getTransactionLink: jest.fn().mockReturnValue(TRANSACTION_LINK), + }), + }), + } as unknown as jest.Mocked; + + mockWallet = { + invokeContract: jest.fn(), + getDefaultAddress: jest.fn().mockResolvedValue({ + getId: jest.fn().mockReturnValue(MOCK_RECEIVER_ID), + }), + getNetworkId: jest.fn().mockReturnValue(NETWORK_ID), + } as unknown as jest.Mocked; + + mockWallet.invokeContract.mockResolvedValue(mockContractInvocation); + }); + + it("should successfully withdraw from Morpho Vault", async () => { + const args = { + vaultAddress: MOCK_VAULT_ADDRESS, + assets: MOCK_ASSETS, + receiver: MOCK_RECEIVER_ID, + }; + + const response = await action.func(mockWallet, args); + + expect(mockWallet.invokeContract).toHaveBeenCalledWith({ + contractAddress: MOCK_VAULT_ADDRESS, + method: "withdraw", + abi: METAMORPHO_ABI, + args: { + assets: MOCK_ASSETS, + receiver: MOCK_RECEIVER_ID, + owner: MOCK_RECEIVER_ID, + }, + }); + expect(mockContractInvocation.wait).toHaveBeenCalled(); + expect(response).toContain(`Withdrawn ${MOCK_ASSETS}`); + expect(response).toContain(`from Morpho Vault ${MOCK_VAULT_ADDRESS}`); + expect(response).toContain(`with transaction hash: ${TRANSACTION_HASH}`); + expect(response).toContain(`and transaction link: ${TRANSACTION_LINK}`); + }); + + it("should handle errors when withdrawing", async () => { + const args = { + vaultAddress: MOCK_VAULT_ADDRESS, + assets: MOCK_ASSETS, + receiver: MOCK_RECEIVER_ID, + }; + + const error = new Error("API Error"); + mockWallet.invokeContract.mockRejectedValue(error); + + const response = await action.func(mockWallet, args); + + expect(mockWallet.invokeContract).toHaveBeenCalled(); + expect(response).toContain(`Error withdrawing from Morpho Vault: ${error}`); + }); +}); diff --git a/cdp-agentkit-core/typescript/src/tests/utils_test.ts b/cdp-agentkit-core/typescript/src/tests/utils_test.ts new file mode 100644 index 00000000..105dcfd6 --- /dev/null +++ b/cdp-agentkit-core/typescript/src/tests/utils_test.ts @@ -0,0 +1,64 @@ +import { ContractInvocation, Wallet } from "@coinbase/coinbase-sdk"; +import { approve } from "../actions/cdp/utils"; + +const MOCK_TOKEN_ADDRESS = "0x123456789abcdef"; +const MOCK_SPENDER_ADDRESS = "0xabcdef123456789"; +const MOCK_AMOUNT = BigInt(1000000); +const TRANSACTION_HASH = "0xghijkl987654321"; + +describe("Utils - Approve", () => { + let mockContractInvocation: jest.Mocked; + let mockWallet: jest.Mocked; + + beforeEach(() => { + mockContractInvocation = { + wait: jest.fn().mockResolvedValue({ + getTransactionHash: jest.fn().mockReturnValue(TRANSACTION_HASH), + }), + } as unknown as jest.Mocked; + + mockWallet = { + invokeContract: jest.fn(), + } as unknown as jest.Mocked; + + mockWallet.invokeContract.mockResolvedValue(mockContractInvocation); + }); + + it("should successfully approve tokens", async () => { + const response = await approve( + mockWallet, + MOCK_TOKEN_ADDRESS, + MOCK_SPENDER_ADDRESS, + MOCK_AMOUNT, + ); + + expect(mockWallet.invokeContract).toHaveBeenCalledWith({ + contractAddress: MOCK_TOKEN_ADDRESS, + method: "approve", + abi: expect.any(Array), + args: { + spender: MOCK_SPENDER_ADDRESS, + value: MOCK_AMOUNT.toString(), + }, + }); + expect(mockContractInvocation.wait).toHaveBeenCalled(); + expect(response).toBe( + `Approved ${MOCK_AMOUNT} tokens for ${MOCK_SPENDER_ADDRESS} with transaction hash: ${TRANSACTION_HASH}`, + ); + }); + + it("should handle approval errors", async () => { + const error = new Error("Approval failed"); + mockWallet.invokeContract.mockRejectedValue(error); + + const response = await approve( + mockWallet, + MOCK_TOKEN_ADDRESS, + MOCK_SPENDER_ADDRESS, + MOCK_AMOUNT, + ); + + expect(mockWallet.invokeContract).toHaveBeenCalled(); + expect(response).toBe(`Error approving tokens: ${error}`); + }); +});