diff --git a/typescript/agentkit/src/action-providers/clicks/README.md b/typescript/agentkit/src/action-providers/clicks/README.md new file mode 100644 index 000000000..c336a996e --- /dev/null +++ b/typescript/agentkit/src/action-providers/clicks/README.md @@ -0,0 +1,55 @@ +# Clicks Protocol Action Provider + +This directory contains the **ClicksActionProvider** implementation, which provides actions to interact with **Clicks Protocol** for AI agent yield generation on Base. + +## What is Clicks Protocol? + +Clicks Protocol is an on-chain revenue-sharing layer for AI agents on Base. It enables agents to earn yield on USDC deposits through an 80/20 split model: + +- **80%** of yield goes to the agent +- **20%** is retained as a protocol fee + +Deposited USDC is routed into battle-tested yield strategies including **Aave V3** and **Morpho** vaults on Base. + +## Directory Structure + +``` +clicks/ +├── clicksActionProvider.ts # Main provider with Clicks functionality +├── clicksActionProvider.test.ts # Test file for Clicks provider +├── constants.ts # Contract addresses and ABIs +├── schemas.ts # Action input schemas +├── index.ts # Main exports +└── README.md # This file +``` + +## Actions + +- `quick_start`: Register an agent and deposit USDC in a single transaction +- `deposit`: Deposit additional USDC into the yield router +- `withdraw`: Withdraw USDC from yield +- `get_info`: Get the agent's registration status, deposits, and earnings + +## Deployed Contracts (Base Mainnet) + +| Contract | Address | +|---|---| +| ClicksSplitterV3 | `0xF625e41D6e83Ca4FA890e0C73DAd65433a6ab5E3` | +| ClicksYieldRouter | `0x4DE206153c2C6888F394F8CEcCE15B818dFb51A8` | +| USDC | `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` | + +## Network Support + +The Clicks provider supports **Base mainnet** only. + +## Adding New Actions + +To add new Clicks Protocol actions: + +1. Define your action schema in `schemas.ts` +2. Implement the action in `clicksActionProvider.ts` +3. Add tests in `clicksActionProvider.test.ts` + +## Notes + +For more information on **Clicks Protocol**, visit [clicks.supply](https://clicks.supply). diff --git a/typescript/agentkit/src/action-providers/clicks/clicksActionProvider.test.ts b/typescript/agentkit/src/action-providers/clicks/clicksActionProvider.test.ts new file mode 100644 index 000000000..dd47c3872 --- /dev/null +++ b/typescript/agentkit/src/action-providers/clicks/clicksActionProvider.test.ts @@ -0,0 +1,212 @@ +import { encodeFunctionData, parseUnits } from "viem"; +import { EvmWalletProvider } from "../../wallet-providers"; +import { ClicksActionProvider } from "./clicksActionProvider"; +import { + CLICKS_SPLITTER_ADDRESS, + CLICKS_YIELD_ROUTER_ADDRESS, + USDC_BASE_ADDRESS, + CLICKS_SPLITTER_ABI, + CLICKS_YIELD_ROUTER_ABI, + ERC20_APPROVE_ABI, +} from "./constants"; + +const MOCK_AMOUNT = "100"; +const MOCK_AGENT_ADDRESS = "0x1234567890123456789012345678901234567890"; +const MOCK_SPLITTER_ADDRESS = "0x9876543210987654321098765432109876543210"; +const MOCK_TX_HASH = "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"; +const MOCK_RECEIPT = { status: 1, blockNumber: 1234567 }; + +describe("Clicks Action Provider", () => { + const actionProvider = new ClicksActionProvider(); + let mockWallet: jest.Mocked; + + beforeEach(() => { + mockWallet = { + getAddress: jest.fn().mockReturnValue(MOCK_AGENT_ADDRESS), + getNetwork: jest.fn().mockReturnValue({ protocolFamily: "evm", networkId: "base-mainnet" }), + sendTransaction: jest.fn().mockResolvedValue(MOCK_TX_HASH as `0x${string}`), + waitForTransactionReceipt: jest.fn().mockResolvedValue(MOCK_RECEIPT), + readContract: jest.fn().mockResolvedValue([ + MOCK_SPLITTER_ADDRESS, + true, + BigInt(100000000), // 100 USDC deposited + BigInt(5000000), // 5 USDC earned + ]), + } as unknown as jest.Mocked; + }); + + describe("quickStart", () => { + it("should successfully register and deposit via quick start", async () => { + const args = { amount: MOCK_AMOUNT }; + const atomicAmount = parseUnits(MOCK_AMOUNT, 6); + + const response = await actionProvider.quickStart(mockWallet, args); + + // Should approve USDC first + expect(mockWallet.sendTransaction).toHaveBeenCalledWith({ + to: USDC_BASE_ADDRESS as `0x${string}`, + data: encodeFunctionData({ + abi: ERC20_APPROVE_ABI, + functionName: "approve", + args: [CLICKS_SPLITTER_ADDRESS, atomicAmount], + }), + }); + + // Should call quickStart + expect(mockWallet.sendTransaction).toHaveBeenCalledWith({ + to: CLICKS_SPLITTER_ADDRESS as `0x${string}`, + data: encodeFunctionData({ + abi: CLICKS_SPLITTER_ABI, + functionName: "quickStart", + args: [MOCK_AGENT_ADDRESS, atomicAmount], + }), + }); + + expect(response).toContain("Successfully registered agent"); + expect(response).toContain(MOCK_AMOUNT); + expect(response).toContain(MOCK_TX_HASH); + }); + + it("should handle errors during quick start", async () => { + const args = { amount: MOCK_AMOUNT }; + + mockWallet.sendTransaction.mockRejectedValue(new Error("Insufficient USDC balance")); + + const response = await actionProvider.quickStart(mockWallet, args); + + expect(response).toContain("Error during Clicks Protocol quick start"); + }); + }); + + describe("deposit", () => { + it("should successfully deposit USDC into yield router", async () => { + const args = { amount: MOCK_AMOUNT }; + const atomicAmount = parseUnits(MOCK_AMOUNT, 6); + + const response = await actionProvider.deposit(mockWallet, args); + + // Should approve USDC for YieldRouter + expect(mockWallet.sendTransaction).toHaveBeenCalledWith({ + to: USDC_BASE_ADDRESS as `0x${string}`, + data: encodeFunctionData({ + abi: ERC20_APPROVE_ABI, + functionName: "approve", + args: [CLICKS_YIELD_ROUTER_ADDRESS, atomicAmount], + }), + }); + + // Should call deposit on YieldRouter + expect(mockWallet.sendTransaction).toHaveBeenCalledWith({ + to: CLICKS_YIELD_ROUTER_ADDRESS as `0x${string}`, + data: encodeFunctionData({ + abi: CLICKS_YIELD_ROUTER_ABI, + functionName: "deposit", + args: [atomicAmount], + }), + }); + + expect(response).toContain("Deposited"); + expect(response).toContain(MOCK_AMOUNT); + }); + + it("should handle errors when depositing", async () => { + const args = { amount: MOCK_AMOUNT }; + + mockWallet.sendTransaction.mockRejectedValue(new Error("Failed to deposit")); + + const response = await actionProvider.deposit(mockWallet, args); + + expect(response).toContain("Error depositing to Clicks Yield Router"); + }); + }); + + describe("withdraw", () => { + it("should successfully withdraw USDC from yield router", async () => { + const args = { amount: MOCK_AMOUNT }; + const atomicAmount = parseUnits(MOCK_AMOUNT, 6); + + const response = await actionProvider.withdraw(mockWallet, args); + + expect(mockWallet.sendTransaction).toHaveBeenCalledWith({ + to: CLICKS_YIELD_ROUTER_ADDRESS as `0x${string}`, + data: encodeFunctionData({ + abi: CLICKS_YIELD_ROUTER_ABI, + functionName: "withdraw", + args: [atomicAmount], + }), + }); + + expect(response).toContain("Withdrawn"); + expect(response).toContain(MOCK_AMOUNT); + }); + + it("should handle errors when withdrawing", async () => { + const args = { amount: MOCK_AMOUNT }; + + mockWallet.sendTransaction.mockRejectedValue(new Error("Failed to withdraw")); + + const response = await actionProvider.withdraw(mockWallet, args); + + expect(response).toContain("Error withdrawing from Clicks Yield Router"); + }); + }); + + describe("getInfo", () => { + it("should successfully retrieve agent info", async () => { + const response = await actionProvider.getInfo(mockWallet, {}); + + expect(mockWallet.readContract).toHaveBeenCalledWith({ + address: CLICKS_SPLITTER_ADDRESS, + abi: CLICKS_SPLITTER_ABI, + functionName: "getAgentInfo", + args: [MOCK_AGENT_ADDRESS], + }); + + expect(response).toContain("Registered: true"); + expect(response).toContain("100"); + expect(response).toContain("5"); + }); + + it("should handle errors when getting info", async () => { + mockWallet.readContract.mockRejectedValue(new Error("Contract read failed")); + + const response = await actionProvider.getInfo(mockWallet, {}); + + expect(response).toContain("Error getting Clicks Protocol agent info"); + }); + }); + + describe("supportsNetwork", () => { + it("should return true for Base Mainnet", () => { + const result = actionProvider.supportsNetwork({ + protocolFamily: "evm", + networkId: "base-mainnet", + }); + expect(result).toBe(true); + }); + + it("should return false for Base Sepolia", () => { + const result = actionProvider.supportsNetwork({ + protocolFamily: "evm", + networkId: "base-sepolia", + }); + expect(result).toBe(false); + }); + + it("should return false for other EVM networks", () => { + const result = actionProvider.supportsNetwork({ + protocolFamily: "evm", + networkId: "ethereum", + }); + expect(result).toBe(false); + }); + + it("should return false for non-EVM networks", () => { + const result = actionProvider.supportsNetwork({ + protocolFamily: "bitcoin", + networkId: "base-mainnet", + }); + expect(result).toBe(false); + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/clicks/clicksActionProvider.ts b/typescript/agentkit/src/action-providers/clicks/clicksActionProvider.ts new file mode 100644 index 000000000..b54b0549f --- /dev/null +++ b/typescript/agentkit/src/action-providers/clicks/clicksActionProvider.ts @@ -0,0 +1,291 @@ +import { z } from "zod"; +import { encodeFunctionData, Hex, parseUnits, formatUnits } from "viem"; +import { ActionProvider } from "../actionProvider"; +import { EvmWalletProvider } from "../../wallet-providers"; +import { CreateAction } from "../actionDecorator"; +import { + CLICKS_SPLITTER_ADDRESS, + CLICKS_YIELD_ROUTER_ADDRESS, + USDC_BASE_ADDRESS, + CLICKS_SPLITTER_ABI, + CLICKS_YIELD_ROUTER_ABI, + ERC20_APPROVE_ABI, +} from "./constants"; +import { + ClicksQuickStartSchema, + ClicksDepositSchema, + ClicksWithdrawSchema, + ClicksGetInfoSchema, +} from "./schemas"; +import { Network } from "../../network"; + +const USDC_DECIMALS = 6; + +export const SUPPORTED_NETWORKS = ["base-mainnet"]; + +/** + * ClicksActionProvider is an action provider for Clicks Protocol interactions. + * + * Clicks Protocol enables AI agents to earn yield on USDC through an 80/20 revenue + * split model, powered by Aave V3 and Morpho yield strategies on Base. + */ +export class ClicksActionProvider extends ActionProvider { + /** + * Constructor for the ClicksActionProvider class. + */ + constructor() { + super("clicks", []); + } + + /** + * Quick start: registers the agent and deposits USDC into Clicks Protocol. + * + * @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 + */ + @CreateAction({ + name: "quick_start", + description: ` +This tool registers an AI agent with Clicks Protocol and deposits USDC in a single transaction. +Clicks Protocol splits revenue 80/20 (80% to the agent, 20% protocol fee) and routes +deposited USDC into yield strategies (Aave V3 / Morpho) on Base. + +It takes: +- amount: The amount of USDC to deposit (e.g. "100" for 100 USDC) + +Important notes: +- Only supported on Base mainnet +- The wallet must hold sufficient USDC +- This will approve the ClicksSplitter contract to spend USDC before depositing +`, + schema: ClicksQuickStartSchema, + }) + async quickStart( + wallet: EvmWalletProvider, + args: z.infer, + ): Promise { + try { + const atomicAmount = parseUnits(args.amount, USDC_DECIMALS); + + if (atomicAmount <= 0n) { + return "Error: Amount must be greater than 0"; + } + + const agentAddress = await wallet.getAddress(); + + // Approve USDC spend + const approveData = encodeFunctionData({ + abi: ERC20_APPROVE_ABI, + functionName: "approve", + args: [CLICKS_SPLITTER_ADDRESS as Hex, atomicAmount], + }); + + const approveTxHash = await wallet.sendTransaction({ + to: USDC_BASE_ADDRESS as `0x${string}`, + data: approveData, + }); + + await wallet.waitForTransactionReceipt(approveTxHash); + + // Call quickStart + const data = encodeFunctionData({ + abi: CLICKS_SPLITTER_ABI, + functionName: "quickStart", + args: [agentAddress as Hex, atomicAmount], + }); + + const txHash = await wallet.sendTransaction({ + to: CLICKS_SPLITTER_ADDRESS as `0x${string}`, + data, + }); + + const receipt = await wallet.waitForTransactionReceipt(txHash); + + return `Successfully registered agent and deposited ${args.amount} USDC into Clicks Protocol.\nTransaction hash: ${txHash}\nTransaction receipt: ${JSON.stringify(receipt)}`; + } catch (error) { + return `Error during Clicks Protocol quick start: ${error}`; + } + } + + /** + * Deposits USDC into the Clicks Yield Router for yield generation. + * + * @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 + */ + @CreateAction({ + name: "deposit", + description: ` +This tool deposits additional USDC into the Clicks Yield Router, which routes funds +into active yield strategies (Aave V3 / Morpho) on Base. + +It takes: +- amount: The amount of USDC to deposit (e.g. "50" for 50 USDC) + +Important notes: +- Only supported on Base mainnet +- The wallet must hold sufficient USDC +- The agent should already be registered via quick_start +`, + schema: ClicksDepositSchema, + }) + async deposit( + wallet: EvmWalletProvider, + args: z.infer, + ): Promise { + try { + const atomicAmount = parseUnits(args.amount, USDC_DECIMALS); + + if (atomicAmount <= 0n) { + return "Error: Amount must be greater than 0"; + } + + // Approve USDC spend for YieldRouter + const approveData = encodeFunctionData({ + abi: ERC20_APPROVE_ABI, + functionName: "approve", + args: [CLICKS_YIELD_ROUTER_ADDRESS as Hex, atomicAmount], + }); + + const approveTxHash = await wallet.sendTransaction({ + to: USDC_BASE_ADDRESS as `0x${string}`, + data: approveData, + }); + + await wallet.waitForTransactionReceipt(approveTxHash); + + // Deposit into YieldRouter + const data = encodeFunctionData({ + abi: CLICKS_YIELD_ROUTER_ABI, + functionName: "deposit", + args: [atomicAmount], + }); + + const txHash = await wallet.sendTransaction({ + to: CLICKS_YIELD_ROUTER_ADDRESS as `0x${string}`, + data, + }); + + const receipt = await wallet.waitForTransactionReceipt(txHash); + + return `Deposited ${args.amount} USDC into Clicks Yield Router.\nTransaction hash: ${txHash}\nTransaction receipt: ${JSON.stringify(receipt)}`; + } catch (error) { + return `Error depositing to Clicks Yield Router: ${error}`; + } + } + + /** + * Withdraws USDC from the Clicks Yield Router. + * + * @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 + */ + @CreateAction({ + name: "withdraw", + description: ` +This tool withdraws USDC from the Clicks Yield Router, pulling funds back from the +active yield strategy. + +It takes: +- amount: The amount of USDC to withdraw (e.g. "50" for 50 USDC) + +Important notes: +- Only supported on Base mainnet +- Cannot withdraw more than the deposited amount +`, + schema: ClicksWithdrawSchema, + }) + async withdraw( + wallet: EvmWalletProvider, + args: z.infer, + ): Promise { + try { + const atomicAmount = parseUnits(args.amount, USDC_DECIMALS); + + if (atomicAmount <= 0n) { + return "Error: Amount must be greater than 0"; + } + + const data = encodeFunctionData({ + abi: CLICKS_YIELD_ROUTER_ABI, + functionName: "withdraw", + args: [atomicAmount], + }); + + const txHash = await wallet.sendTransaction({ + to: CLICKS_YIELD_ROUTER_ADDRESS as `0x${string}`, + data, + }); + + const receipt = await wallet.waitForTransactionReceipt(txHash); + + return `Withdrawn ${args.amount} USDC from Clicks Yield Router.\nTransaction hash: ${txHash}\nTransaction receipt: ${JSON.stringify(receipt)}`; + } catch (error) { + return `Error withdrawing from Clicks Yield Router: ${error}`; + } + } + + /** + * Gets the agent's current info from Clicks Protocol. + * + * @param wallet - The wallet instance to read contract state + * @param args - The input arguments for the action (none required) + * @returns Agent info including registration status, deposited amount, and earnings + */ + @CreateAction({ + name: "get_info", + description: ` +This tool retrieves the current agent's information from Clicks Protocol, including: +- Whether the agent is registered +- The agent's splitter contract address +- Total USDC deposited +- Total USDC earned through the 80/20 yield split + +No arguments are required - it uses the connected wallet's address. +`, + schema: ClicksGetInfoSchema, + }) + async getInfo( + wallet: EvmWalletProvider, + args: z.infer, + ): Promise { + try { + const agentAddress = await wallet.getAddress(); + + const result = await wallet.readContract({ + address: CLICKS_SPLITTER_ADDRESS as Hex, + abi: CLICKS_SPLITTER_ABI, + functionName: "getAgentInfo", + args: [agentAddress as Hex], + }); + + const [splitter, registered, deposited, earned] = result as [string, boolean, bigint, bigint]; + + const depositedUsdc = formatUnits(deposited, USDC_DECIMALS); + const earnedUsdc = formatUnits(earned, USDC_DECIMALS); + + return `Clicks Protocol Agent Info: +- Address: ${agentAddress} +- Registered: ${registered} +- Splitter: ${splitter} +- Deposited: ${depositedUsdc} USDC +- Earned: ${earnedUsdc} USDC`; + } catch (error) { + return `Error getting Clicks Protocol agent info: ${error}`; + } + } + + /** + * Checks if the Clicks action provider supports the given network. + * + * @param network - The network to check. + * @returns True if the Clicks action provider supports the network, false otherwise. + */ + supportsNetwork = (network: Network) => + network.protocolFamily === "evm" && SUPPORTED_NETWORKS.includes(network.networkId!); +} + +export const clicksActionProvider = () => new ClicksActionProvider(); diff --git a/typescript/agentkit/src/action-providers/clicks/constants.ts b/typescript/agentkit/src/action-providers/clicks/constants.ts new file mode 100644 index 000000000..c3cf47a98 --- /dev/null +++ b/typescript/agentkit/src/action-providers/clicks/constants.ts @@ -0,0 +1,58 @@ +export const CLICKS_SPLITTER_ADDRESS = "0xF625e41D6e83Ca4FA890e0C73DAd65433a6ab5E3"; +export const CLICKS_YIELD_ROUTER_ADDRESS = "0x4DE206153c2C6888F394F8CEcCE15B818dFb51A8"; +export const USDC_BASE_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; + +export const CLICKS_SPLITTER_ABI = [ + { + inputs: [ + { internalType: "address", name: "agent", type: "address" }, + { internalType: "uint256", name: "amount", type: "uint256" }, + ], + name: "quickStart", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [{ internalType: "address", name: "agent", type: "address" }], + name: "getAgentInfo", + outputs: [ + { internalType: "address", name: "splitter", type: "address" }, + { internalType: "bool", name: "registered", type: "bool" }, + { internalType: "uint256", name: "deposited", type: "uint256" }, + { internalType: "uint256", name: "earned", type: "uint256" }, + ], + stateMutability: "view", + type: "function", + }, +] as const; + +export const CLICKS_YIELD_ROUTER_ABI = [ + { + inputs: [{ internalType: "uint256", name: "amount", type: "uint256" }], + name: "deposit", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [{ internalType: "uint256", name: "amount", type: "uint256" }], + name: "withdraw", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, +] as const; + +export const ERC20_APPROVE_ABI = [ + { + inputs: [ + { internalType: "address", name: "spender", type: "address" }, + { internalType: "uint256", name: "amount", type: "uint256" }, + ], + name: "approve", + outputs: [{ internalType: "bool", name: "", type: "bool" }], + stateMutability: "nonpayable", + type: "function", + }, +] as const; diff --git a/typescript/agentkit/src/action-providers/clicks/index.ts b/typescript/agentkit/src/action-providers/clicks/index.ts new file mode 100644 index 000000000..0037783ff --- /dev/null +++ b/typescript/agentkit/src/action-providers/clicks/index.ts @@ -0,0 +1,2 @@ +export * from "./schemas"; +export * from "./clicksActionProvider"; diff --git a/typescript/agentkit/src/action-providers/clicks/schemas.ts b/typescript/agentkit/src/action-providers/clicks/schemas.ts new file mode 100644 index 000000000..ae7e6314f --- /dev/null +++ b/typescript/agentkit/src/action-providers/clicks/schemas.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; + +/** + * Input schema for Clicks Protocol quick start action. + */ +export const ClicksQuickStartSchema = z + .object({ + amount: z + .string() + .regex(/^\d+(\.\d+)?$/, "Must be a valid integer or decimal value") + .describe("The amount of USDC to deposit during quick start, in whole units (e.g. '100' for 100 USDC)"), + }) + .describe("Input schema for Clicks Protocol quick start action"); + +/** + * Input schema for Clicks Protocol deposit action. + */ +export const ClicksDepositSchema = z + .object({ + amount: z + .string() + .regex(/^\d+(\.\d+)?$/, "Must be a valid integer or decimal value") + .describe("The amount of USDC to deposit into yield, in whole units (e.g. '50' for 50 USDC)"), + }) + .describe("Input schema for Clicks Protocol deposit action"); + +/** + * Input schema for Clicks Protocol withdraw action. + */ +export const ClicksWithdrawSchema = z + .object({ + amount: z + .string() + .regex(/^\d+(\.\d+)?$/, "Must be a valid integer or decimal value") + .describe("The amount of USDC to withdraw from yield, in whole units (e.g. '50' for 50 USDC)"), + }) + .describe("Input schema for Clicks Protocol withdraw action"); + +/** + * Input schema for Clicks Protocol get info action. + */ +export const ClicksGetInfoSchema = z + .object({}) + .describe("Input schema for Clicks Protocol get agent info action"); diff --git a/typescript/agentkit/src/action-providers/index.ts b/typescript/agentkit/src/action-providers/index.ts index 9f7164086..56f09cbd4 100644 --- a/typescript/agentkit/src/action-providers/index.ts +++ b/typescript/agentkit/src/action-providers/index.ts @@ -9,6 +9,7 @@ export * from "./baseAccount"; export * from "./basename"; export * from "./cdp"; export * from "./clanker"; +export * from "./clicks"; export * from "./compound"; export * from "./defillama"; export * from "./dtelecom";