UNPKG

@agentek/tools

Version:

Blockchain tools for AI agents

216 lines 9.71 kB
import { z } from "zod"; import { createTool } from "../client.js"; import { arbitrum, base, mainnet, sepolia } from "viem/chains"; import { encodeFunctionData, erc20Abi, parseUnits, } from "viem"; import { addressSchema } from "../utils.js"; import { resolveENSTool } from "../ens/tools.js"; const intentTransferChains = [mainnet, arbitrum, base, sepolia]; const intentTransferParameters = z.object({ token: addressSchema.describe("The token contract address (0x...), or 0x0000000000000000000000000000000000000000 for native ETH"), amount: z.string().describe("Amount to transfer in human-readable units (e.g. '1.5' for 1.5 tokens). Decimals are resolved automatically."), to: z.string().describe("Recipient address (0x...) or ENS name (e.g. 'vitalik.eth'). ENS names are resolved automatically."), chainId: z.number().optional().describe("Chain ID to transfer on (e.g. 1, 42161, 8453). If omitted, automatically selects the cheapest chain where you have sufficient balance."), }); export const ETH_ADDRESS = "0x0000000000000000000000000000000000000000"; const getTokenDecimals = async (publicClient, token) => { if (token == ETH_ADDRESS) { return 18; } else { return await publicClient.readContract({ abi: erc20Abi, functionName: "decimals", address: token, }); } }; const getTokenBalance = async (publicClient, token, from) => { if (token == ETH_ADDRESS) { return publicClient.getBalance({ address: from, }); } else { return await publicClient.readContract({ abi: erc20Abi, functionName: "balanceOf", address: token, args: [from], }); } }; export const intentTransferTool = createTool({ name: "intentTransfer", description: "Transfer ERC20 tokens or native ETH to an address or ENS name. Supports ENS resolution, automatic decimal handling, and auto-selects the cheapest chain if chainId is omitted.", supportedChains: intentTransferChains, parameters: intentTransferParameters, execute: async (client, args) => { let { token, amount, to, chainId } = args; const chains = client.filterSupportedChains(intentTransferChains, chainId); const from = await client.getAddress(); // if `to` is an ENS name, resolve it to an address if (to.includes('.')) { to = await resolveENSTool.execute(client, { name: to, }).catch(() => { throw new Error(`Failed to resolve ENS name ${to}`); }); } const chainsWithBalance = (await Promise.all(chains.map(async (chain) => { const publicClient = client.getPublicClient(chain.id); const decimals = await getTokenDecimals(publicClient, token); const balance = await getTokenBalance(publicClient, token, from); const amountBigInt = parseUnits(amount, decimals); if (balance >= amountBigInt) { return { chain, balance, amount: amountBigInt, decimals, }; } return null; }))).filter((chain) => chain !== null); if (chainsWithBalance.length === 0) { throw new Error(`${from} doesn't have enough ${token} balance on any of the supported chains - ${chains.map((chain) => chain.name)}`); } const cheapestChain = await Promise.all(chainsWithBalance.map(async (chainInfo) => { const publicClient = client.getPublicClient(chainInfo.chain.id); const gasPrice = await publicClient.getGasPrice(); return { ...chainInfo, gasPrice, }; })).then((chains) => chains.reduce((cheapest, current) => current.gasPrice < cheapest.gasPrice ? current : cheapest)); const walletClient = client.getWalletClient(cheapestChain.chain.id); let ops = []; let tokenSymbol = ""; if (token === ETH_ADDRESS) { tokenSymbol = "ETH"; ops.push({ target: to, value: cheapestChain.amount.toString(), data: "0x", }); } else { tokenSymbol = await client.getPublicClient(cheapestChain.chain.id).readContract({ address: token, abi: erc20Abi, functionName: "symbol", }); ops.push({ target: token, value: "0", data: encodeFunctionData({ abi: erc20Abi, functionName: "transfer", args: [to, cheapestChain.amount], }), }); } if (!walletClient) { return { intent: `send ${amount.toString()} ${tokenSymbol} to ${to}`, ops, chain: cheapestChain.chain.id, }; } else { const hash = await client.executeOps(ops, cheapestChain.chain.id); return { intent: `send ${amount.toString()} ${tokenSymbol} to ${to}`, ops, chain: cheapestChain.chain.id, hash: hash, }; } }, }); const intentTransferFromParameters = z.object({ token: z.string().describe("The ERC20 token contract address (0x...). Does not support native ETH."), amount: z.string().describe("Amount to transfer in human-readable units (e.g. '1.5' for 1.5 tokens). Decimals are resolved automatically."), from: z.string().describe("The address to transfer tokens from (0x...). Must have approved your address via ERC20 approve first."), to: z.string().describe("Recipient address (0x...) or ENS name (e.g. 'vitalik.eth'). ENS names are resolved automatically."), chainId: z.number().optional().describe("Chain ID to transfer on (e.g. 1, 42161, 8453). If omitted, automatically selects the cheapest chain where the 'from' address has sufficient balance."), }); const intentTransferFromChains = [mainnet, arbitrum, base, sepolia]; export const intentTransferFromTool = createTool({ name: "intentTransferFrom", description: "Transfer ERC20 tokens from another address using transferFrom. Requires prior ERC20 approval from the 'from' address. Does not support native ETH. Supports ENS resolution and auto-selects the cheapest chain if chainId is omitted.", supportedChains: intentTransferFromChains, parameters: intentTransferFromParameters, execute: async (client, args) => { let { token, amount, to, from, chainId } = args; const chains = client.filterSupportedChains(intentTransferChains, chainId); // if `to` is an ENS name, resolve it to an address if (to.includes('.')) { to = await resolveENSTool.execute(client, { name: to, }).catch(() => { throw new Error(`Failed to resolve ENS name ${to}`); }); } const chainsWithBalance = (await Promise.all(chains.map(async (chain) => { const publicClient = client.getPublicClient(chain.id); const decimals = await getTokenDecimals(publicClient, token); const balance = await getTokenBalance(publicClient, token, from); const amountBigInt = parseUnits(amount, decimals); if (balance >= amountBigInt) { return { chain, balance, amount: amountBigInt, decimals, }; } return null; }))).filter((chain) => chain !== null); if (chainsWithBalance.length === 0) { throw new Error(`${from} doesn't have enough ${token} balance on any of the supported chains - ${chains.map((chain) => chain.name)}`); } const cheapestChain = await Promise.all(chainsWithBalance.map(async (chainInfo) => { const publicClient = client.getPublicClient(chainInfo.chain.id); const gasPrice = await publicClient.getGasPrice(); return { ...chainInfo, gasPrice, }; })).then((chains) => chains.reduce((cheapest, current) => current.gasPrice < cheapest.gasPrice ? current : cheapest)); const publicClient = client.getPublicClient(cheapestChain.chain.id); const walletClient = client.getWalletClient(cheapestChain.chain.id); let ops = []; if (token === ETH_ADDRESS) { // @TODO gracefully fallback to weth if eth mentioned throw new Error("ETH transferFrom not supported"); } else { ops.push({ target: token, value: "0", data: encodeFunctionData({ abi: erc20Abi, functionName: "transferFrom", args: [from, to, cheapestChain.amount], }), }); } if (!walletClient) { return { intent: `send ${amount.toString()} ${token} from ${from} to ${to}`, ops, chain: cheapestChain.chain.id, }; } else { const hash = await client.executeOps(ops, cheapestChain.chain.id); return { intent: `send ${amount.toString()} ${token} from ${from} to ${to}`, ops, chain: cheapestChain.chain.id, hash: hash, }; } }, }); //# sourceMappingURL=intents.js.map