diff --git a/typescript/.changeset/tidy-cities-scream.md b/typescript/.changeset/tidy-cities-scream.md new file mode 100644 index 000000000..c90543f3a --- /dev/null +++ b/typescript/.changeset/tidy-cities-scream.md @@ -0,0 +1,5 @@ +--- +"@coinbase/agentkit": minor +--- + +Added a new wallet provider and action providers to interact with the Safe Protocol diff --git a/typescript/agentkit/package.json b/typescript/agentkit/package.json index 0189fc102..d187b2494 100644 --- a/typescript/agentkit/package.json +++ b/typescript/agentkit/package.json @@ -44,6 +44,8 @@ "@coinbase/coinbase-sdk": "^0.20.0", "@jup-ag/api": "^6.0.39", "@privy-io/server-auth": "^1.18.4", + "@safe-global/api-kit": "^2.5.11", + "@safe-global/protocol-kit": "^5.2.4", "@solana/spl-token": "^0.4.12", "@solana/web3.js": "^1.98.0", "md5": "^2.3.0", diff --git a/typescript/agentkit/src/action-providers/index.ts b/typescript/agentkit/src/action-providers/index.ts index a4e9453dd..8defc195c 100644 --- a/typescript/agentkit/src/action-providers/index.ts +++ b/typescript/agentkit/src/action-providers/index.ts @@ -15,6 +15,7 @@ export * from "./pyth"; export * from "./moonwell"; export * from "./morpho"; export * from "./opensea"; +export * from "./safe"; export * from "./spl"; export * from "./twitter"; export * from "./wallet"; diff --git a/typescript/agentkit/src/action-providers/safe/README.md b/typescript/agentkit/src/action-providers/safe/README.md new file mode 100644 index 000000000..49ab5505d --- /dev/null +++ b/typescript/agentkit/src/action-providers/safe/README.md @@ -0,0 +1,60 @@ +# Safe Action Provider + +This directory contains the **SafeActionProvider** implementation, which provides actions to interact with [Safe](https://safe.global/) multi-signature wallets on EVM-compatible blockchains. + +## Directory Structure + +``` +safe/ +├── safeApiActionProvider.ts # Provider for Safe API interactions +├── safeWalletActionProvider.ts # Provider for Safe Wallet operations +├── schemas.ts # Action schemas for Safe operations +├── index.ts # Main exports +└── README.md # This file +``` + +## Actions + +### Safe API Actions + +- `safeInfo`: Retrieve detailed information about a Safe wallet +- `getAllowanceInfo`: Get current allowance configurations +- `withdrawAllowance`: Withdraw funds from an allowance + +### Safe Wallet Actions + +- `addSigner`: Add a new signer to a Safe wallet +- `removeSigner`: Remove an existing signer from a Safe wallet +- `changeThreshold`: Modify the number of required signatures +- `approvePending`: Approve a pending transaction +- `enableAllowanceModule`: Activate the allowance module for a Safe +- `setAllowance`: Configure spending allowances for specific addresses + +## Adding New Actions + +To add new Safe actions: + +1. Define your action schema in `schemas.ts` +2. Implement the action in the appropriate provider file: + - Safe API actions in `safeApiActionProvider.ts` + - Safe Wallet actions in `safeWalletActionProvider.ts` +3. Add corresponding tests + +## Network Support + +The Safe providers support all EVM-compatible networks, including: + +- Ethereum (Mainnet & Testnets) +- Base (Mainnet & Testnets) +- Optimism +- Arbitrum +- Optimism +- And other EVM-compatible networks + +## Notes + +- safeWalletActionProvider requires a safeWalletProvider +- safeWalletProvider connects to an existing Safe account or automatically creates a new one with the provided private key as single signer +- Safe API actions can be used with other evmWalletProvider + +For more information on Safe multi-signature wallets visit [Safe Documentation](https://docs.safe.global/). \ No newline at end of file diff --git a/typescript/agentkit/src/action-providers/safe/index.ts b/typescript/agentkit/src/action-providers/safe/index.ts new file mode 100644 index 000000000..31ad3d543 --- /dev/null +++ b/typescript/agentkit/src/action-providers/safe/index.ts @@ -0,0 +1,3 @@ +export * from "./schemas"; +export * from "./safeWalletActionProvider"; +export * from "./safeApiActionProvider"; diff --git a/typescript/agentkit/src/action-providers/safe/safeApiActionProvider.test.ts b/typescript/agentkit/src/action-providers/safe/safeApiActionProvider.test.ts new file mode 100644 index 000000000..ab7dc283e --- /dev/null +++ b/typescript/agentkit/src/action-providers/safe/safeApiActionProvider.test.ts @@ -0,0 +1,440 @@ +import { safeApiActionProvider } from "./safeApiActionProvider"; +import { SafeInfoSchema } from "./schemas"; +import { EvmWalletProvider } from "../../wallet-providers"; +import SafeApiKit from "@safe-global/api-kit"; + +// Mock the Safe API Kit +jest.mock("@safe-global/api-kit"); + +describe("Safe API Action Provider Input Schemas", () => { + describe("Safe Info Schema", () => { + it("should successfully parse valid input", () => { + const validInput = { + safeAddress: "0xe6b2af36b3bb8d47206a129ff11d5a2de2a63c83", + }; + + const result = SafeInfoSchema.safeParse(validInput); + + expect(result.success).toBe(true); + expect(result.data).toEqual(validInput); + }); + + it("should fail parsing invalid address", () => { + const invalidInput = { + safeAddress: "invalid-address", + }; + const result = SafeInfoSchema.safeParse(invalidInput); + + expect(result.success).toBe(false); + }); + + it("should fail parsing empty input", () => { + const emptyInput = {}; + const result = SafeInfoSchema.safeParse(emptyInput); + + expect(result.success).toBe(false); + }); + }); +}); + +describe("Safe API Action Provider", () => { + let actionProvider: ReturnType; + let mockWallet: jest.Mocked; + let mockSafeApiKit: jest.Mocked; + + const MOCK_SAFE_ADDRESS = "0xe6b2af36b3bb8d47206a129ff11d5a2de2a63c83"; + const MOCK_DELEGATE_ADDRESS = "0x1234567890123456789012345678901234567890"; + const MOCK_TOKEN_ADDRESS = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"; + const MOCK_NETWORK = "ethereum-sepolia"; + const MOCK_BALANCE = BigInt(1000000000000000000); // 1 ETH in wei + + beforeEach(() => { + // Reset all mocks before each test + jest.clearAllMocks(); + + // Mock SafeApiKit methods first + mockSafeApiKit = { + getSafeInfo: jest.fn(), + getPendingTransactions: jest.fn(), + } as unknown as jest.Mocked; + + // Set up the mock implementation before creating actionProvider + (SafeApiKit as jest.Mock).mockImplementation(() => mockSafeApiKit); + + actionProvider = safeApiActionProvider({ networkId: MOCK_NETWORK }); + + // Mock wallet provider + mockWallet = { + getPublicClient: jest.fn().mockReturnValue({ + getBalance: jest.fn().mockResolvedValue(MOCK_BALANCE), + }), + } as unknown as jest.Mocked; + }); + + describe("safeInfo", () => { + it("should successfully get Safe info", async () => { + const mockSafeInfo = { + address: MOCK_SAFE_ADDRESS, + owners: ["0x123", "0x456"], + threshold: 2, + modules: ["0x789"], + nonce: "1", + singleton: "0x123", + fallbackHandler: "0x456", + guard: "0x789", + version: "1.0.0", + }; + + const mockPendingTransactions = { + results: [ + { + safeTxHash: "0xabc", + isExecuted: false, + confirmationsRequired: 2, + confirmations: [{ owner: "0x123" }, { owner: "0x456" }], + }, + ], + count: 1, + }; + + mockSafeApiKit.getSafeInfo.mockResolvedValue(mockSafeInfo); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockSafeApiKit.getPendingTransactions.mockResolvedValue(mockPendingTransactions as any); + + const args = { + safeAddress: MOCK_SAFE_ADDRESS, + }; + + const response = await actionProvider.safeInfo(mockWallet, args); + + // Verify response contains expected information + expect(response).toContain(`Safe at address: ${MOCK_SAFE_ADDRESS}`); + expect(response).toContain("2 owners: 0x123, 0x456"); + expect(response).toContain("Threshold: 2"); + expect(response).toContain("Nonce: 1"); + expect(response).toContain("Modules: 0x789"); + expect(response).toContain("Balance: 1 ETH"); + expect(response).toContain("Pending transactions: 1"); + expect(response).toContain( + "Transaction 0xabc (2/2 confirmations, confirmed by: 0x123, 0x456)", + ); + }); + + it("should handle errors when getting Safe info", async () => { + const error = new Error("Failed to get Safe info"); + mockSafeApiKit.getSafeInfo.mockRejectedValue(error); + + const args = { + safeAddress: MOCK_SAFE_ADDRESS, + }; + + const response = await actionProvider.safeInfo(mockWallet, args); + expect(response).toBe(`Safe info: Error connecting to Safe: ${error.message}`); + }); + }); + + describe("supportsNetwork", () => { + it("should return true for EVM networks", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = actionProvider.supportsNetwork({ protocolFamily: "evm" } as any); + expect(result).toBe(true); + }); + + it("should return false for non-EVM networks", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = actionProvider.supportsNetwork({ protocolFamily: "solana" } as any); + expect(result).toBe(false); + }); + }); + + describe("getAllowanceInfo", () => { + beforeEach(() => { + // Mock additional wallet methods needed for getAllowanceInfo + mockWallet.readContract = jest.fn(); + + // Mock the getAllowanceModuleDeployment function + jest.mock("@safe-global/safe-modules-deployments", () => ({ + getAllowanceModuleDeployment: jest.fn().mockReturnValue({ + networkAddresses: { "421614": "0xallowanceModuleAddress" }, + abi: [ + { + name: "getTokens", + type: "function", + inputs: [{ type: "address" }, { type: "address" }], + outputs: [{ type: "address[]" }], + }, + { + name: "getTokenAllowance", + type: "function", + inputs: [{ type: "address" }, { type: "address" }, { type: "address" }], + outputs: [ + { type: "uint256" }, // amount + { type: "uint256" }, // spent + { type: "uint256" }, // resetTimeMin + { type: "uint256" }, // lastResetMin + { type: "uint256" }, // nonce + ], + }, + ], + }), + })); + }); + + it("should successfully get allowance info", async () => { + // Mock token list response + (mockWallet.readContract as jest.Mock).mockImplementation(params => { + if (params.functionName === "getTokens") { + return [MOCK_TOKEN_ADDRESS]; + } else if (params.functionName === "getTokenAllowance") { + return [ + BigInt(1000000000000000000), // amount: 1 token + BigInt(300000000000000000), // spent: 0.3 token + BigInt(1440), // resetTimeMin: 24 hours + BigInt(Math.floor(Date.now() / (60 * 1000)) - 720), // lastResetMin: 12 hours ago + BigInt(1), // nonce + ]; + } else if (params.functionName === "symbol") { + return "TEST"; + } else if (params.functionName === "decimals") { + return 18; + } else if (params.functionName === "balanceOf") { + return BigInt(5000000000000000000); // 5 tokens + } + }); + + const args = { + safeAddress: MOCK_SAFE_ADDRESS, + delegateAddress: MOCK_DELEGATE_ADDRESS, + }; + + const response = await actionProvider.getAllowanceInfo(mockWallet, args); + + // Verify response contains expected information + expect(response).toContain(`Delegate ${MOCK_DELEGATE_ADDRESS} has the following allowances`); + expect(response).toContain(`TEST (${MOCK_TOKEN_ADDRESS})`); + expect(response).toContain("Current Safe balance: 5 TEST"); + expect(response).toContain("Allowance: 0.7 available of 1 total (0.3 spent)"); + expect(response).toContain("resets every 1440 minutes"); + }); + + it("should handle case with no allowances", async () => { + // Mock empty token list response + (mockWallet.readContract as jest.Mock).mockImplementation(params => { + if (params.functionName === "getTokens") { + return []; // No tokens with allowances + } + }); + + const args = { + safeAddress: MOCK_SAFE_ADDRESS, + delegateAddress: MOCK_DELEGATE_ADDRESS, + }; + + const response = await actionProvider.getAllowanceInfo(mockWallet, args); + + // Verify response indicates no allowances + expect(response).toBe( + `Get allowance: Delegate ${MOCK_DELEGATE_ADDRESS} has no token allowances from Safe ${MOCK_SAFE_ADDRESS}`, + ); + }); + + it("should handle errors when getting allowance info", async () => { + // Mock error when reading contract + const error = new Error("Failed to get allowance info"); + (mockWallet.readContract as jest.Mock).mockRejectedValue(error); + + const args = { + safeAddress: MOCK_SAFE_ADDRESS, + delegateAddress: MOCK_DELEGATE_ADDRESS, + }; + + const response = await actionProvider.getAllowanceInfo(mockWallet, args); + expect(response).toBe(`Get allowance: Error getting allowance: ${error.message}`); + }); + }); + + describe("withdrawAllowance", () => { + beforeEach(() => { + // Mock wallet methods needed for withdrawAllowance + mockWallet.readContract = jest.fn(); + mockWallet.signHash = jest.fn(); + mockWallet.sendTransaction = jest.fn(); + mockWallet.waitForTransactionReceipt = jest.fn(); + + // Mock the getAllowanceModuleDeployment function + jest.mock("@safe-global/safe-modules-deployments", () => ({ + getAllowanceModuleDeployment: jest.fn().mockReturnValue({ + networkAddresses: { "11155111": "0xallowanceModuleAddress" }, // Sepolia chain ID + abi: [ + { + name: "getTokenAllowance", + type: "function", + inputs: [{ type: "address" }, { type: "address" }, { type: "address" }], + outputs: [ + { type: "uint256" }, // amount + { type: "uint256" }, // spent + { type: "uint256" }, // resetTimeMin + { type: "uint256" }, // lastResetMin + { type: "uint256" }, // nonce + ], + }, + { + name: "generateTransferHash", + type: "function", + inputs: [ + { type: "address" }, // safe + { type: "address" }, // token + { type: "address" }, // to + { type: "uint256" }, // amount + { type: "address" }, // paymentToken + { type: "uint256" }, // payment + { type: "uint256" }, // nonce + ], + outputs: [{ type: "bytes32" }], + }, + { + name: "executeAllowanceTransfer", + type: "function", + inputs: [ + { type: "address" }, // safe + { type: "address" }, // token + { type: "address" }, // to + { type: "uint256" }, // amount + { type: "address" }, // paymentToken + { type: "uint256" }, // payment + { type: "address" }, // delegate + { type: "bytes" }, // signature + ], + outputs: [], + }, + ], + }), + })); + }); + + it("should successfully withdraw tokens using allowance", async () => { + // Mock contract read responses + (mockWallet.readContract as jest.Mock).mockImplementation(params => { + if (params.functionName === "getTokenAllowance") { + return [ + BigInt(5000000000000000000), // amount: 5 tokens + BigInt(1000000000000000000), // spent: 1 token + BigInt(0), // resetTimeMin: no reset + BigInt(0), // lastResetMin: no reset + BigInt(3), // nonce: 3 + ]; + } else if (params.functionName === "generateTransferHash") { + return "0xmockhash123456789"; + } else if (params.functionName === "decimals") { + return 18; + } else if (params.functionName === "symbol") { + return "TEST"; + } + }); + + // Mock signature + (mockWallet.signHash as jest.Mock).mockResolvedValue("0xmocksignature"); + + // Mock transaction sending + const mockTxHash = "0xmocktxhash123456789"; + (mockWallet.sendTransaction as jest.Mock).mockResolvedValue(mockTxHash); + + // Mock transaction receipt + (mockWallet.waitForTransactionReceipt as jest.Mock).mockResolvedValue({ + transactionHash: mockTxHash, + status: "success", + }); + + const args = { + safeAddress: MOCK_SAFE_ADDRESS, + delegateAddress: MOCK_DELEGATE_ADDRESS, + tokenAddress: MOCK_TOKEN_ADDRESS, + amount: "2.5", // 2.5 tokens + }; + + const response = await actionProvider.withdrawAllowance(mockWallet, args); + + // Verify the response contains expected information + expect(response).toContain(`Successfully withdrew 2.5 TEST from Safe ${MOCK_SAFE_ADDRESS}`); + expect(response).toContain(`Transaction hash: ${mockTxHash}`); + + // Verify the correct contract methods were called + expect(mockWallet.readContract).toHaveBeenCalledWith( + expect.objectContaining({ + functionName: "getTokenAllowance", + args: [MOCK_SAFE_ADDRESS, MOCK_DELEGATE_ADDRESS, MOCK_TOKEN_ADDRESS], + }), + ); + + expect(mockWallet.readContract).toHaveBeenCalledWith( + expect.objectContaining({ + functionName: "generateTransferHash", + }), + ); + + expect(mockWallet.signHash).toHaveBeenCalledWith("0xmockhash123456789"); + + expect(mockWallet.sendTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + to: expect.any(String), + data: expect.any(String), + value: BigInt(0), + }), + ); + }); + + it("should handle errors when withdrawing allowance", async () => { + // Mock error when reading contract + const error = new Error("Insufficient allowance"); + (mockWallet.readContract as jest.Mock).mockRejectedValue(error); + + const args = { + safeAddress: MOCK_SAFE_ADDRESS, + delegateAddress: MOCK_DELEGATE_ADDRESS, + tokenAddress: MOCK_TOKEN_ADDRESS, + amount: "10", // 10 tokens + }; + + const response = await actionProvider.withdrawAllowance(mockWallet, args); + expect(response).toBe(`Withdraw allowance: Error withdrawing allowance: ${error.message}`); + }); + + it("should handle transaction failure", async () => { + // Mock successful contract reads + (mockWallet.readContract as jest.Mock).mockImplementation(params => { + if (params.functionName === "getTokenAllowance") { + return [ + BigInt(5000000000000000000), // amount: 5 tokens + BigInt(1000000000000000000), // spent: 1 token + BigInt(0), // resetTimeMin: no reset + BigInt(0), // lastResetMin: no reset + BigInt(3), // nonce: 3 + ]; + } else if (params.functionName === "generateTransferHash") { + return "0xmockhash123456789"; + } else if (params.functionName === "decimals") { + return 18; + } else if (params.functionName === "symbol") { + return "TEST"; + } + }); + + // Mock signature + (mockWallet.signHash as jest.Mock).mockResolvedValue("0xmocksignature"); + + // Mock transaction sending failure + const txError = new Error("Transaction reverted"); + (mockWallet.sendTransaction as jest.Mock).mockRejectedValue(txError); + + const args = { + safeAddress: MOCK_SAFE_ADDRESS, + delegateAddress: MOCK_DELEGATE_ADDRESS, + tokenAddress: MOCK_TOKEN_ADDRESS, + amount: "2.5", // 2.5 tokens + }; + + const response = await actionProvider.withdrawAllowance(mockWallet, args); + expect(response).toBe(`Withdraw allowance: Error withdrawing allowance: ${txError.message}`); + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/safe/safeApiActionProvider.ts b/typescript/agentkit/src/action-providers/safe/safeApiActionProvider.ts new file mode 100644 index 000000000..702d81e66 --- /dev/null +++ b/typescript/agentkit/src/action-providers/safe/safeApiActionProvider.ts @@ -0,0 +1,396 @@ +import { z } from "zod"; +import { CreateAction } from "../actionDecorator"; +import { ActionProvider } from "../actionProvider"; +import { EvmWalletProvider } from "../../wallet-providers"; +import { SafeInfoSchema, GetAllowanceInfoSchema, WithdrawAllowanceSchema } from "./schemas"; +import { Network, NETWORK_ID_TO_VIEM_CHAIN } from "../../network"; +import { + Chain, + formatEther, + formatUnits, + Hex, + encodeFunctionData, + zeroAddress, + parseUnits, +} from "viem"; + +import { abi as ERC20_ABI } from "../erc20/constants"; + +import SafeApiKit from "@safe-global/api-kit"; +import { getAllowanceModuleDeployment } from "@safe-global/safe-modules-deployments"; + +/** + * Configuration options for the SafeActionProvider. + */ +export interface SafeApiActionProviderConfig { + /** + * The network ID to use for the SafeActionProvider. + */ + networkId?: string; +} + +/** + * SafeApiActionProvider is an action provider for Safe. + * + * This provider is used for any action that uses the Safe API, but does not require a connected Safe Wallet. + */ +export class SafeApiActionProvider extends ActionProvider { + #chain: Chain; + #apiKit: SafeApiKit; + + /** + * Constructor for the SafeActionProvider class. + * + * @param config - The configuration options for the SafeActionProvider. + */ + constructor(config: SafeApiActionProviderConfig = {}) { + super("safe", []); + + // Initialize chain + this.#chain = NETWORK_ID_TO_VIEM_CHAIN[config.networkId || "base-sepolia"]; + if (!this.#chain) throw new Error(`Unsupported network: ${config.networkId}`); + + // Initialize apiKit with chain ID from Viem chain + this.#apiKit = new SafeApiKit({ + chainId: BigInt(this.#chain.id), + }); + } + + /** + * Connects to an existing Safe smart account. + * + * @param walletProvider - The wallet provider to use for the action. + * @param args - The input arguments for connecting to a Safe. + * @returns A message containing the connection details. + */ + @CreateAction({ + name: "safe_info", + description: ` +Gets information about an existing Safe smart account. +Takes the following input: +- safeAddress: Address of the existing Safe to connect to + +Important notes: +- The Safe must already be deployed +`, + schema: SafeInfoSchema, + }) + async safeInfo( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + try { + // Get Safe info + const safeInfo = await this.#apiKit.getSafeInfo(args.safeAddress); + + const owners = safeInfo.owners; + const threshold = safeInfo.threshold; + const modules = safeInfo.modules; + const nonce = safeInfo.nonce; + + // Get balance + const ethBalance = formatEther( + await walletProvider.getPublicClient().getBalance({ address: args.safeAddress }), + ); + + // Get pending transactions + const pendingTransactions = await this.#apiKit.getPendingTransactions(args.safeAddress); + const pendingTxDetails = pendingTransactions.results + .filter(tx => !tx.isExecuted) + .map(tx => { + const confirmations = tx.confirmations?.length || 0; + const needed = tx.confirmationsRequired; + const confirmedBy = tx.confirmations?.map(c => c.owner).join(", ") || "none"; + return `\n- Transaction ${tx.safeTxHash} (${confirmations}/${needed} confirmations, confirmed by: ${confirmedBy})`; + }) + .join(""); + + return `Safe info: +- Safe at address: ${args.safeAddress} +- Chain: ${this.#chain.name} +- ${owners.length} owners: ${owners.join(", ")} +- Threshold: ${threshold} +- Nonce: ${nonce} +- Modules: ${modules.join(", ")} +- Balance: ${ethBalance} ETH +- Pending transactions: ${pendingTransactions.count}${pendingTxDetails}`; + } catch (error) { + return `Safe info: Error connecting to Safe: ${error instanceof Error ? error.message : String(error)}`; + } + } + + /** + * Gets the current allowance for a delegate to spend tokens from the Safe. + * + * @param walletProvider - The wallet provider to connect to the Safe. + * @param args - The Safe address and delegate address. + * @returns A message containing the current allowance details. + */ + @CreateAction({ + name: "get_allowance_info", + description: ` +Gets the current token spending allowance for a delegate address. +Takes the following inputs: +- safeAddress: Address of the Safe +- delegateAddress: Address of the delegate to check allowance for + +Important notes: +- Requires an existing Safe +- Allowance module must be enabled +- Returns all token allowances for the delegate +`, + schema: GetAllowanceInfoSchema, + }) + async getAllowanceInfo( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + try { + // Get allowance module for current chain + const chainId = this.#chain.id.toString(); + const allowanceModule = getAllowanceModuleDeployment({ network: chainId }); + if (!allowanceModule) { + throw new Error(`Allowance module not found for chainId [${chainId}]`); + } + + const moduleAddress = allowanceModule.networkAddresses[chainId]; + + // Get all tokens for which the delegate has allowances + const tokens = (await walletProvider.readContract({ + address: moduleAddress, + abi: allowanceModule.abi, + functionName: "getTokens", + args: [args.safeAddress, args.delegateAddress], + })) as Hex[]; + + if (tokens.length === 0) { + return `Get allowance: Delegate ${args.delegateAddress} has no token allowances from Safe ${args.safeAddress}`; + } + + // Get allowance details for each token + const allowanceDetails = await Promise.all( + tokens.map(async tokenAddress => { + // Get allowance + const allowance = await walletProvider.readContract({ + address: moduleAddress, + abi: allowanceModule.abi, + functionName: "getTokenAllowance", + args: [args.safeAddress, args.delegateAddress, tokenAddress], + }); + + // Get token details for better formatting + let tokenSymbol = "Unknown"; + let tokenDecimals = 18; + let safeBalance = BigInt(0); + + try { + tokenSymbol = (await walletProvider.readContract({ + address: tokenAddress, + abi: ERC20_ABI, + functionName: "symbol", + })) as string; + + tokenDecimals = (await walletProvider.readContract({ + address: tokenAddress, + abi: ERC20_ABI, + functionName: "decimals", + })) as number; + + // Get Safe balance for this token + safeBalance = (await walletProvider.readContract({ + address: tokenAddress, + abi: ERC20_ABI, + functionName: "balanceOf", + args: [args.safeAddress], + })) as bigint; + } catch (error) { + console.log(`Error getting token details for ${tokenAddress}:`, error); + } + + // Format allowance with token decimals + const amount = formatUnits(allowance[0], tokenDecimals); + const spent = formatUnits(allowance[1], tokenDecimals); + const remaining = parseFloat(amount) - parseFloat(spent); + const formattedBalance = formatUnits(safeBalance, tokenDecimals); + + return { + tokenAddress, + tokenSymbol, + amount, + spent, + remaining, + balance: formattedBalance, + resetTimeMin: allowance[2], + lastResetMin: allowance[3], + nonce: allowance[4], + }; + }), + ); + + // Format the response + const allowanceStrings = allowanceDetails + .map(details => { + let resetInfo = ""; + if (details.resetTimeMin > 0) { + const lastResetTimestamp = Number(details.lastResetMin) * 60 * 1000; // Convert minutes to milliseconds + const resetIntervalMs = Number(details.resetTimeMin) * 60 * 1000; // Convert minutes to milliseconds + const nextResetTimestamp = lastResetTimestamp + resetIntervalMs; + const now = Date.now(); + const minutesUntilNextReset = Math.max( + 0, + Math.floor((nextResetTimestamp - now) / (60 * 1000)), + ); + + resetInfo = ` (resets every ${details.resetTimeMin} minutes, next reset in ${minutesUntilNextReset} minutes)`; + } + + return `\n- ${details.tokenSymbol} (${details.tokenAddress}): + • Current Safe balance: ${details.balance} ${details.tokenSymbol} + • Allowance: ${details.remaining} available of ${details.amount} total (${details.spent} spent)${resetInfo}`; + }) + .join(""); + + return `Get allowance: Delegate ${args.delegateAddress} has the following allowances from Safe ${args.safeAddress}:${allowanceStrings}`; + } catch (error) { + return `Get allowance: Error getting allowance: ${error instanceof Error ? error.message : String(error)}`; + } + } + + /** + * Withdraws tokens using an allowance from a Safe. + * + * @param walletProvider - The wallet provider to connect to the Safe. + * @param args - The input arguments for withdrawing the allowance. + * @returns A message containing the withdrawal details. + */ + @CreateAction({ + name: "withdraw_allowance", + description: ` +Withdraws tokens using an allowance from a Safe. +Takes the following inputs: +- safeAddress: Address of the Safe +- delegateAddress: Address of the delegate to withdraw allowance for +- tokenAddress: Address of the ERC20 token +- amount: Amount of tokens to withdraw in whole units (e.g. 1.5 WETH, 10 USDC) +- recipientAddress: (Optional) Address to receive the tokens (defaults to delegate address) + +Important notes: +- Requires an existing Safe +- Allowance module must be enabled +- Must have sufficient allowance +- Amount must be within allowance limit +- Safe must have sufficient token balance +`, + schema: WithdrawAllowanceSchema, + }) + async withdrawAllowance( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + try { + // Get allowance module for current chain + const chainId = this.#chain.id.toString(); + const allowanceModule = getAllowanceModuleDeployment({ network: chainId }); + if (!allowanceModule) { + throw new Error(`Allowance module not found for chainId [${chainId}]`); + } + + const moduleAddress = allowanceModule.networkAddresses[chainId]; + + // Get current allowance to check nonce + const allowance = await walletProvider.readContract({ + address: moduleAddress, + abi: allowanceModule.abi, + functionName: "getTokenAllowance", + args: [args.safeAddress, args.delegateAddress, args.tokenAddress], + }); + + // Get token decimals and convert amount + const tokenDecimals = await walletProvider.readContract({ + address: args.tokenAddress as Hex, + abi: ERC20_ABI, + functionName: "decimals", + }); + + // Get token details for better formatting + const tokenSymbol = await walletProvider.readContract({ + address: args.tokenAddress as Hex, + abi: ERC20_ABI, + functionName: "symbol", + }); + + // Convert amount to token decimals + const amount = parseUnits(args.amount, Number(tokenDecimals)); + + // Check if Safe has sufficient token balance + const safeBalance = (await walletProvider.readContract({ + address: args.tokenAddress as Hex, + abi: ERC20_ABI, + functionName: "balanceOf", + args: [args.safeAddress], + })) as bigint; + + if (safeBalance < amount) { + throw new Error( + `Insufficient token balance. Safe has ${formatUnits(safeBalance, Number(tokenDecimals))} ${tokenSymbol}, but ${args.amount} ${tokenSymbol} was requested.`, + ); + } + + // Generate transfer hash + const hash = await walletProvider.readContract({ + address: moduleAddress, + abi: allowanceModule.abi, + functionName: "generateTransferHash", + args: [ + args.safeAddress, + args.tokenAddress, + args.delegateAddress, + amount, + zeroAddress, + 0, + allowance[4], // nonce + ], + }); + + // Sign the hash + const signature = await walletProvider.signHash(hash as unknown as Hex); + + // Send transaction + const tx = await walletProvider.sendTransaction({ + to: moduleAddress, + data: encodeFunctionData({ + abi: allowanceModule.abi, + functionName: "executeAllowanceTransfer", + args: [ + args.safeAddress, + args.tokenAddress, + args.delegateAddress, + amount, + zeroAddress, + 0, + args.delegateAddress, + signature, + ], + }), + value: BigInt(0), + }); + + const receipt = await walletProvider.waitForTransactionReceipt(tx as Hex); + + return `Withdraw allowance: Successfully withdrew ${args.amount} ${tokenSymbol} from Safe ${args.safeAddress} to ${args.delegateAddress}. Transaction hash: ${receipt.transactionHash}`; + } catch (error) { + return `Withdraw allowance: Error withdrawing allowance: ${error instanceof Error ? error.message : String(error)}`; + } + } + + /** + * Checks if the Safe action provider supports the given network. + * + * @param network - The network to check. + * @returns True if the Safe action provider supports the network, false otherwise. + */ + supportsNetwork = (network: Network) => network.protocolFamily === "evm"; +} + +export const safeApiActionProvider = (config: SafeApiActionProviderConfig = {}) => + new SafeApiActionProvider(config); diff --git a/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.test.ts b/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.test.ts new file mode 100644 index 000000000..8c1956c31 --- /dev/null +++ b/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.test.ts @@ -0,0 +1,357 @@ +import { SafeWalletActionProvider } from "./safeWalletActionProvider"; +import { SafeWalletProvider } from "../../wallet-providers"; +import { AddSignerSchema } from "./schemas"; + +// Mock Safe SDK modules +jest.mock("@safe-global/protocol-kit"); +jest.mock("@safe-global/api-kit"); + +describe("SafeWalletActionProvider", () => { + let actionProvider: SafeWalletActionProvider; + let mockWallet: jest.Mocked; + const MOCK_SAFE_ADDRESS = "0x1234567890123456789012345678901234567890"; + const MOCK_NEW_SIGNER = "0x9876543210987654321098765432109876543210"; + const MOCK_TRANSACTION_HASH = "0xtxhash123"; + + beforeEach(() => { + // Reset mocks + jest.clearAllMocks(); + + actionProvider = new SafeWalletActionProvider(); + + // Mock SafeWalletProvider + mockWallet = { + getAddress: jest.fn().mockReturnValue(MOCK_SAFE_ADDRESS), + getNetwork: jest.fn().mockReturnValue({ networkId: "base-sepolia" }), + waitForInitialization: jest.fn().mockResolvedValue(undefined), + addOwnerWithThreshold: jest + .fn() + .mockResolvedValue( + `Successfully proposed adding signer ${MOCK_NEW_SIGNER} to Safe ${MOCK_SAFE_ADDRESS}. Safe transaction hash: ${MOCK_TRANSACTION_HASH}. The other signers will need to confirm the transaction before it can be executed.`, + ), + removeOwnerWithThreshold: jest.fn(), + changeThreshold: jest.fn(), + getOwners: jest.fn().mockResolvedValue(["0xowner1", "0xowner2"]), + getThreshold: jest.fn().mockResolvedValue(2), + approvePendingTransaction: jest.fn(), + enableAllowanceModule: jest.fn(), + setAllowance: jest.fn(), + } as unknown as jest.Mocked; + }); + + describe("Input Schema Validation", () => { + it("should validate AddSignerSchema with valid input", () => { + const validInput = { + safeAddress: MOCK_SAFE_ADDRESS, + newSigner: MOCK_NEW_SIGNER, + }; + + const result = AddSignerSchema.safeParse(validInput); + expect(result.success).toBe(true); + }); + + it("should reject AddSignerSchema with invalid address", () => { + const invalidInput = { + safeAddress: "not-an-address", + newSigner: "not-an-address", + }; + + const result = AddSignerSchema.safeParse(invalidInput); + expect(result.success).toBe(false); + }); + }); + + describe("addSigner", () => { + it("should successfully add a new signer", async () => { + const args = { + safeAddress: MOCK_SAFE_ADDRESS, + newSigner: MOCK_NEW_SIGNER, + }; + + const response = await actionProvider.addSigner(mockWallet, args); + + expect(response).toContain(`Successfully proposed adding signer ${MOCK_NEW_SIGNER}`); + expect(response).toContain(`Safe transaction hash: ${MOCK_TRANSACTION_HASH}`); + }); + + it("should fail when adding an existing owner", async () => { + const args = { + safeAddress: MOCK_SAFE_ADDRESS, + newSigner: "0xowner1", // Using an address that's already an owner + }; + + const error = new Error("Address is already an owner of this Safe"); + mockWallet.addOwnerWithThreshold.mockRejectedValue(error); + + await expect(actionProvider.addSigner(mockWallet, args)).rejects.toThrow( + "Failed to add signer: Address is already an owner of this Safe", + ); + }); + + it("should fail when threshold is less than 1", async () => { + const args = { + safeAddress: MOCK_SAFE_ADDRESS, + newSigner: MOCK_NEW_SIGNER, + newThreshold: 0, + }; + + const error = new Error("Threshold must be at least 1"); + mockWallet.addOwnerWithThreshold.mockRejectedValue(error); + + await expect(actionProvider.addSigner(mockWallet, args)).rejects.toThrow( + "Failed to add signer: Threshold must be at least 1", + ); + }); + + it("should fail when threshold is greater than owner count", async () => { + const args = { + safeAddress: MOCK_SAFE_ADDRESS, + newSigner: MOCK_NEW_SIGNER, + newThreshold: 4, // Would be 3 owners after adding new signer + }; + + const error = new Error("Invalid threshold: 4 cannot be greater than number of owners (3)"); + mockWallet.addOwnerWithThreshold.mockRejectedValue(error); + + await expect(actionProvider.addSigner(mockWallet, args)).rejects.toThrow( + "Failed to add signer: Invalid threshold: 4 cannot be greater than number of owners (3)", + ); + }); + }); + + describe("removeSigner", () => { + it("should successfully remove a signer", async () => { + const args = { + safeAddress: MOCK_SAFE_ADDRESS, + signerToRemove: "0xowner2", + newThreshold: 1, + }; + + mockWallet.removeOwnerWithThreshold = jest + .fn() + .mockResolvedValue( + `Successfully proposed removing signer ${args.signerToRemove} from Safe ${MOCK_SAFE_ADDRESS}. Safe transaction hash: ${MOCK_TRANSACTION_HASH}. The other signers will need to confirm the transaction before it can be executed.`, + ); + + const response = await actionProvider.removeSigner(mockWallet, args); + + expect(response).toContain(`Successfully proposed removing signer ${args.signerToRemove}`); + expect(response).toContain(`Safe transaction hash: ${MOCK_TRANSACTION_HASH}`); + }); + + it("should fail when removing non-existent owner", async () => { + const args = { + safeAddress: MOCK_SAFE_ADDRESS, + signerToRemove: MOCK_NEW_SIGNER, + newThreshold: 1, + }; + + const error = new Error("Address is not an owner of this Safe"); + mockWallet.removeOwnerWithThreshold = jest.fn().mockRejectedValue(error); + + await expect(actionProvider.removeSigner(mockWallet, args)).rejects.toThrow( + "Address is not an owner of this Safe", + ); + }); + }); + + describe("changeThreshold", () => { + it("should successfully change threshold", async () => { + const args = { + safeAddress: MOCK_SAFE_ADDRESS, + newThreshold: 2, + }; + + mockWallet.changeThreshold = jest + .fn() + .mockResolvedValue( + `Successfully proposed changing threshold to ${args.newThreshold} for Safe ${MOCK_SAFE_ADDRESS}. Safe transaction hash: ${MOCK_TRANSACTION_HASH}. The other signers will need to confirm the transaction before it can be executed.`, + ); + + const response = await actionProvider.changeThreshold(mockWallet, args); + + expect(response).toContain( + `Successfully proposed changing threshold to ${args.newThreshold}`, + ); + expect(response).toContain(`Safe transaction hash: ${MOCK_TRANSACTION_HASH}`); + }); + + it("should fail when threshold is invalid", async () => { + const args = { + safeAddress: MOCK_SAFE_ADDRESS, + newThreshold: 3, + }; + + const error = new Error("Threshold cannot be greater than owners length"); + mockWallet.changeThreshold = jest.fn().mockRejectedValue(error); + + await expect(actionProvider.changeThreshold(mockWallet, args)).rejects.toThrow( + "Threshold cannot be greater than owners length", + ); + }); + }); + + describe("supportsNetwork", () => { + it("should return true for EVM networks", () => { + const evmNetwork = { protocolFamily: "evm", networkId: "base-sepolia", chainId: "1" }; + expect(actionProvider.supportsNetwork(evmNetwork)).toBe(true); + }); + + it("should return false for non-EVM networks", () => { + const nonEvmNetwork = { protocolFamily: "svm", networkId: "solana", chainId: "1" }; + expect(actionProvider.supportsNetwork(nonEvmNetwork)).toBe(false); + }); + }); + + describe("approvePending", () => { + it("should successfully approve a pending transaction", async () => { + const args = { + safeAddress: MOCK_SAFE_ADDRESS, + safeTxHash: MOCK_TRANSACTION_HASH, + executeImmediately: true, + }; + + mockWallet.approvePendingTransaction.mockResolvedValue( + `Successfully approved transaction ${MOCK_TRANSACTION_HASH}. Transaction will be executed automatically when threshold is reached.`, + ); + + const response = await actionProvider.approvePending(mockWallet, args); + + expect(mockWallet.approvePendingTransaction).toHaveBeenCalledWith( + MOCK_TRANSACTION_HASH, + true, + ); + expect(response).toContain(`Successfully approved transaction ${MOCK_TRANSACTION_HASH}`); + }); + + it("should handle approval without immediate execution", async () => { + const args = { + safeAddress: MOCK_SAFE_ADDRESS, + safeTxHash: MOCK_TRANSACTION_HASH, + executeImmediately: false, + }; + + mockWallet.approvePendingTransaction.mockResolvedValue( + `Successfully approved transaction ${MOCK_TRANSACTION_HASH}. Transaction will need to be executed manually.`, + ); + + const response = await actionProvider.approvePending(mockWallet, args); + + expect(mockWallet.approvePendingTransaction).toHaveBeenCalledWith( + MOCK_TRANSACTION_HASH, + false, + ); + expect(response).toContain(`Successfully approved transaction ${MOCK_TRANSACTION_HASH}`); + expect(response).toContain("will need to be executed manually"); + }); + + it("should fail when approving an invalid transaction", async () => { + const args = { + safeAddress: MOCK_SAFE_ADDRESS, + safeTxHash: "invalid_hash", + executeImmediately: true, + }; + + mockWallet.approvePendingTransaction.mockRejectedValue(new Error("Transaction not found")); + + await expect(actionProvider.approvePending(mockWallet, args)).rejects.toThrow( + "Transaction not found", + ); + }); + }); + + describe("enableAllowanceModule", () => { + it("should successfully enable the allowance module", async () => { + mockWallet.enableAllowanceModule.mockResolvedValue( + `Successfully enabled allowance module for Safe ${MOCK_SAFE_ADDRESS}. Transaction hash: ${MOCK_TRANSACTION_HASH}`, + ); + + const response = await actionProvider.enableAllowanceModule(mockWallet); + + expect(mockWallet.enableAllowanceModule).toHaveBeenCalled(); + expect(response).toContain(`Successfully enabled allowance module`); + expect(response).toContain(MOCK_TRANSACTION_HASH); + }); + + it("should fail when allowance module is already enabled", async () => { + mockWallet.enableAllowanceModule.mockRejectedValue( + new Error("Allowance module is already enabled for this Safe"), + ); + + await expect(actionProvider.enableAllowanceModule(mockWallet)).rejects.toThrow( + "Allowance module is already enabled for this Safe", + ); + }); + + it("should fail when transaction is rejected", async () => { + mockWallet.enableAllowanceModule.mockRejectedValue(new Error("Transaction rejected by user")); + + await expect(actionProvider.enableAllowanceModule(mockWallet)).rejects.toThrow( + "Transaction rejected by user", + ); + }); + }); + + describe("setAllowance", () => { + it("should successfully set an allowance for a delegate", async () => { + const args = { + safeAddress: MOCK_SAFE_ADDRESS, + delegateAddress: MOCK_NEW_SIGNER, + tokenAddress: "0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9", // WETH on Sepolia + amount: "1.5", + resetTimeInMinutes: 1440, // 24 hours + }; + + mockWallet.setAllowance = jest + .fn() + .mockResolvedValue( + `Successfully set allowance of ${args.amount} tokens for delegate ${args.delegateAddress} from Safe ${MOCK_SAFE_ADDRESS}. Safe transaction hash: ${MOCK_TRANSACTION_HASH}. The other signers will need to confirm the transaction before it can be executed.`, + ); + + const response = await actionProvider.setAllowance(mockWallet, args); + + expect(mockWallet.setAllowance).toHaveBeenCalledWith( + args.delegateAddress, + args.tokenAddress, + args.amount, + args.resetTimeInMinutes, + ); + expect(response).toContain(`Successfully set allowance of ${args.amount} tokens`); + expect(response).toContain(`for delegate ${args.delegateAddress}`); + expect(response).toContain(`Safe transaction hash: ${MOCK_TRANSACTION_HASH}`); + }); + + it("should fail when allowance module is not enabled", async () => { + const args = { + safeAddress: MOCK_SAFE_ADDRESS, + delegateAddress: MOCK_NEW_SIGNER, + tokenAddress: "0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9", // WETH on Sepolia + amount: "1.5", + resetTimeInMinutes: 0, + }; + + const error = new Error("Allowance module is not enabled for this Safe"); + mockWallet.setAllowance = jest.fn().mockRejectedValue(error); + + await expect(actionProvider.setAllowance(mockWallet, args)).rejects.toThrow( + "Allowance module is not enabled for this Safe", + ); + }); + + it("should fail with invalid amount format", async () => { + const args = { + delegateAddress: MOCK_NEW_SIGNER, + amount: "invalid-amount", + tokenAddress: "0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9", + resetTimeInMinutes: 0, + }; + + const error = new Error("Invalid amount format: invalid-amount"); + mockWallet.setAllowance = jest.fn().mockRejectedValue(error); + + await expect(actionProvider.setAllowance(mockWallet, args)).rejects.toThrow( + "Invalid amount format: invalid-amount", + ); + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.ts b/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.ts new file mode 100644 index 000000000..4d8e97e8a --- /dev/null +++ b/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.ts @@ -0,0 +1,236 @@ +import { z } from "zod"; +import { CreateAction } from "../actionDecorator"; +import { ActionProvider } from "../actionProvider"; +import { SafeWalletProvider } from "../../wallet-providers/safeWalletProvider"; +import { + AddSignerSchema, + RemoveSignerSchema, + ChangeThresholdSchema, + ApprovePendingTransactionSchema, + EnableAllowanceModuleSchema, + SetAllowanceSchema, +} from "./schemas"; +import { Network } from "../../network"; + +/** + * SafeWalletActionProvider provides actions for managing Safe multi-sig wallets. + */ +export class SafeWalletActionProvider extends ActionProvider { + /** + * Constructor for the SafeWalletActionProvider class. + * + */ + constructor() { + super("safe_wallet", []); + } + + /** + * Adds a new signer to the Safe wallet + * + * @param walletProvider - The Safe wallet provider instance + * @param args - Arguments containing safeAddress and newSigner + * @returns A promise that resolves to a success message + */ + @CreateAction({ + name: "add_signer", + description: ` +Add a new signer to the Safe multi-sig wallet + +Takes the following inputs: +- newSigner: Address of the new signer to add +- newThreshold: (Optional) New threshold after adding signer + +Important notes: +- Must be called by an existing signer +- Requires confirmation from other signers if current threshold > 1 +- New signer must not already be in the Safe +- New threshold cannot exceed number of signers +- If newThreshold not provided, keeps existing threshold if valid, otherwise reduces it + + `, + schema: AddSignerSchema, + }) + async addSigner( + walletProvider: SafeWalletProvider, + args: z.infer, + ): Promise { + try { + // Create and propose/execute the transaction + const addOwnerTx = await walletProvider.addOwnerWithThreshold( + args.newSigner, + args.newThreshold, + ); + + return addOwnerTx; + } catch (error) { + throw new Error( + `Failed to add signer: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Removes a signer from the Safe wallet + * + * @param walletProvider - The Safe wallet provider instance + * @param args - Arguments containing safeAddress, signerToRemove, and newThreshold + * @returns A message containing the transaction details + */ + @CreateAction({ + name: "remove_signer", + description: ` +Removes a signer from the Safe. +Takes the following inputs: +- signerToRemove: Address of the signer to remove +- newThreshold: (Optional) New threshold after removing signer + +Important notes: +- Cannot remove the last signer +- If newThreshold not provided, keeps existing threshold if valid, otherwise reduces it +- Requires confirmation from other signers if current threshold > 1 + `, + schema: RemoveSignerSchema, + }) + async removeSigner( + walletProvider: SafeWalletProvider, + args: z.infer, + ): Promise { + return await walletProvider.removeOwnerWithThreshold(args.signerToRemove, args.newThreshold); + } + + /** + * Changes the threshold of the Safe. + * + * @param walletProvider - The Safe wallet provider instance + * @param args - Arguments containing newThreshold + * @returns A message containing the transaction details + */ + @CreateAction({ + name: "change_threshold", + description: ` +Changes the confirmation threshold of the Safe. +Takes the following input: +- newThreshold: New threshold value (must be >= 1 and <= number of signers) + +Important notes: +- Requires confirmation from other signers if current threshold > 1 +- New threshold cannot exceed number of signers + `, + schema: ChangeThresholdSchema, + }) + async changeThreshold( + walletProvider: SafeWalletProvider, + args: z.infer, + ): Promise { + return await walletProvider.changeThreshold(args.newThreshold); + } + + /** + * Approves and optionally executes a pending transaction for a Safe. + * + * @param walletProvider - The Safe wallet provider instance + * @param args - Arguments containing safeAddress, safeTxHash, and optional executeImmediately flag + * @returns A message containing the approval/execution details + */ + @CreateAction({ + name: "approve_pending", + description: ` +Approves and optionally executes a pending transaction for connected Safe. +Takes the following inputs: +- safeTxHash: Transaction hash to approve/execute +- executeImmediately: (Optional) Whether to execute the transaction immediately if all signatures are collected (default: true) + +Important notes: +- Must be called by an existing signer +- Will approve the transaction if not already approved +- Will execute the transaction if all required signatures are collected and executeImmediately is true + `, + schema: ApprovePendingTransactionSchema, + }) + async approvePending( + walletProvider: SafeWalletProvider, + args: z.infer, + ): Promise { + return await walletProvider.approvePendingTransaction(args.safeTxHash, args.executeImmediately); + } + + /** + * Enables the allowance module for a Safe, allowing for token spending allowances. + * + * @param walletProvider - The wallet provider to connect to the Safe. + * @returns A message containing the allowance module enabling details. + */ + @CreateAction({ + name: "enable_allowance_module", + description: ` +Enables the allowance module for a Safe, allowing for token spending allowances. + +Takes the following inputs: +- delegateAddress: Address that will receive the allowance +- tokenAddress: Address of the ERC20 token +- amount: Amount of tokens to allow (e.g. '1.5' for 1.5 tokens) +- resetTimeInMinutes: Time in minutes after which the allowance resets, e.g 1440 for 24 hours (optional, defaults to 0 for one-time allowance) + +Important notes: +- Must be called by an existing signer +- Requires confirmation from other signers if threshold > 1 +- Module can only be enabled once +`, + schema: EnableAllowanceModuleSchema, + }) + async enableAllowanceModule(walletProvider: SafeWalletProvider): Promise { + return await walletProvider.enableAllowanceModule(); + } + + /** + * Sets an allowance for a delegate to spend tokens from the Safe. + * + * @param walletProvider - The wallet provider to connect to the Safe. + * @param args - The input arguments for setting the allowance. + * @returns A message containing the allowance setting details. + */ + @CreateAction({ + name: "set_allowance", + description: ` +Sets a token spending allowance for a delegate address. +Takes the following inputs: +- delegateAddress: Address that will receive the allowance +- tokenAddress: Address of the ERC20 token +- amount: Amount of tokens to allow (e.g. '1.5' for 1.5 tokens) +- resetTimeInMinutes: Time in minutes after which the allowance resets, e.g 1440 for 24 hours (optional, defaults to 0 for one-time allowance) + +Important notes: +- Must be called by an existing signer +- Allowance module must be enabled +- Amount is in human-readable format (e.g. '1.5' for 1.5 tokens) +- Requires confirmation from other signers if threshold > 1 +`, + schema: SetAllowanceSchema, + }) + async setAllowance( + walletProvider: SafeWalletProvider, + args: z.infer, + ): Promise { + return await walletProvider.setAllowance( + args.delegateAddress, + args.tokenAddress, + args.amount, + args.resetTimeInMinutes || 0, + ); + } + + /** + * Checks if the Safe action provider supports the given network. + * + * @param network - The network to check. + * @returns True if the Safe action provider supports the network, false otherwise. + */ + supportsNetwork = (network: Network) => network.protocolFamily === "evm"; +} + +/** + * Creates a new SafeWalletActionProvider instance. + * + * @returns A new SafeWalletActionProvider instance + */ +export const safeWalletActionProvider = () => new SafeWalletActionProvider(); diff --git a/typescript/agentkit/src/action-providers/safe/schemas.ts b/typescript/agentkit/src/action-providers/safe/schemas.ts new file mode 100644 index 000000000..b51d0db7e --- /dev/null +++ b/typescript/agentkit/src/action-providers/safe/schemas.ts @@ -0,0 +1,87 @@ +import { z } from "zod"; + +export const SafeInfoSchema = z.object({ + safeAddress: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format") + .describe("Address of the existing Safe to connect to"), +}); + +export const GetAllowanceInfoSchema = z.object({ + safeAddress: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format") + .describe("Address of the Safe"), + delegateAddress: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format") + .describe("Address of the delegate to check allowance for"), +}); + +export const WithdrawAllowanceSchema = z.object({ + safeAddress: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format") + .describe("Address of the Safe"), + delegateAddress: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format") + .describe("Address of the delegate to withdraw allowance for"), + tokenAddress: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format") + .describe("Address of the ERC20 token"), + amount: z + .string() + .describe("Amount of tokens to withdraw in whole units (e.g. 1.5 WETH, 10 USDC)"), +}); + +export const AddSignerSchema = z.object({ + newSigner: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format") + .describe("Address of the new signer to add"), + newThreshold: z.number().optional().describe("Optional new threshold after adding signer"), +}); + +export const RemoveSignerSchema = z.object({ + signerToRemove: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format") + .describe("Address of the signer to remove"), + newThreshold: z.number().optional().describe("Optional new threshold after removing signer"), +}); + +export const ChangeThresholdSchema = z.object({ + newThreshold: z.number().min(1).describe("New threshold value"), +}); + +export const ApprovePendingTransactionSchema = z.object({ + safeTxHash: z.string().describe("Transaction hash to approve/execute"), + executeImmediately: z + .boolean() + .optional() + .default(true) + .describe("Whether to execute the transaction immediately if all signatures are collected"), +}); + +export const EnableAllowanceModuleSchema = z.object({}); + +export const SetAllowanceSchema = z.object({ + delegateAddress: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format") + .describe("Address of the delegate who will receive the allowance"), + tokenAddress: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format") + .describe("Address of the ERC20 token"), + amount: z.string().describe("Amount of tokens to allow (e.g. '1.5' for 1.5 tokens)"), + resetTimeInMinutes: z + .number() + .optional() + .describe( + "One time allowance by default. If larger than zero, time in minutes after which the allowance resets", + ) + .default(0), +}); diff --git a/typescript/agentkit/src/wallet-providers/cdpWalletProvider.ts b/typescript/agentkit/src/wallet-providers/cdpWalletProvider.ts index bd704a13a..e74793138 100644 --- a/typescript/agentkit/src/wallet-providers/cdpWalletProvider.ts +++ b/typescript/agentkit/src/wallet-providers/cdpWalletProvider.ts @@ -192,6 +192,27 @@ export class CdpWalletProvider extends EvmWalletProvider { return cdpWalletProvider; } + /** + * Signs a hash. + * + * @param hash - The hash to sign. + * @returns The signed hash. + */ + async signHash(hash: `0x${string}`): Promise<`0x${string}`> { + if (!this.#cdpWallet) { + throw new Error("Wallet not initialized"); + } + + // Use the wallet's createPayloadSignature method with the raw hash + const payload = await this.#cdpWallet.createPayloadSignature(hash); + + if (payload.getStatus() === "pending" && payload?.wait) { + await payload.wait(); // needed for Server-Signers + } + + return payload.getSignature() as `0x${string}`; + } + /** * Signs a message. * @@ -275,7 +296,7 @@ export class CdpWalletProvider extends EvmWalletProvider { } const preparedTransaction = await this.prepareTransaction( - transaction.to!, + transaction.to! as `0x${string}`, transaction.value!, transaction.data!, ); @@ -556,6 +577,15 @@ export class CdpWalletProvider extends EvmWalletProvider { return this.#cdpWallet.export(); } + /** + * Gets the public client. + * + * @returns The public client. + */ + getPublicClient(): PublicClient { + return this.#publicClient; + } + /** * Gets the wallet. * diff --git a/typescript/agentkit/src/wallet-providers/evmWalletProvider.ts b/typescript/agentkit/src/wallet-providers/evmWalletProvider.ts index 216ec72b2..6c67deb28 100644 --- a/typescript/agentkit/src/wallet-providers/evmWalletProvider.ts +++ b/typescript/agentkit/src/wallet-providers/evmWalletProvider.ts @@ -9,6 +9,7 @@ import { ContractFunctionName, Abi, ContractFunctionArgs, + PublicClient, } from "viem"; /** @@ -17,6 +18,14 @@ import { * @abstract */ export abstract class EvmWalletProvider extends WalletProvider { + /** + * Sign a hash. + * + * @param hash - The hash to sign. + * @returns The signed hash. + */ + abstract signHash(hash: `0x${string}`): Promise<`0x${string}`>; + /** * Sign a message. * @@ -70,4 +79,11 @@ export abstract class EvmWalletProvider extends WalletProvider { >( params: ReadContractParameters, ): Promise>; + + /** + * Get the public client. + * + * @returns The public client. + */ + abstract getPublicClient(): PublicClient; } diff --git a/typescript/agentkit/src/wallet-providers/index.ts b/typescript/agentkit/src/wallet-providers/index.ts index 1b966b4ee..953291a04 100644 --- a/typescript/agentkit/src/wallet-providers/index.ts +++ b/typescript/agentkit/src/wallet-providers/index.ts @@ -8,3 +8,4 @@ export * from "./solanaKeypairWalletProvider"; export * from "./privyWalletProvider"; export * from "./privyEvmWalletProvider"; export * from "./privySvmWalletProvider"; +export * from "./safeWalletProvider"; diff --git a/typescript/agentkit/src/wallet-providers/safeWalletProvider.test.ts b/typescript/agentkit/src/wallet-providers/safeWalletProvider.test.ts new file mode 100644 index 000000000..9872493c4 --- /dev/null +++ b/typescript/agentkit/src/wallet-providers/safeWalletProvider.test.ts @@ -0,0 +1,132 @@ +import { SafeWalletProvider } from "./safeWalletProvider"; +import { Network } from "../network"; +import { NETWORK_ID_TO_VIEM_CHAIN } from "../network/network"; +import Safe from "@safe-global/protocol-kit"; +import SafeApiKit from "@safe-global/api-kit"; +import { createPublicClient } from "viem"; + +// Mock modules +jest.mock("@safe-global/protocol-kit"); +jest.mock("@safe-global/api-kit"); +jest.mock("viem", () => ({ + ...jest.requireActual("viem"), + createPublicClient: jest.fn(), + http: jest.fn(), +})); + +describe("SafeWalletProvider", () => { + const mockPrivateKey = "0x1234567890123456789012345678901234567890123456789012345678901234"; + const mockNetworkId = "base-sepolia"; + const mockSafeAddress = "0x1234567890123456789012345678901234567890"; + + beforeEach(() => { + // Reset mocks before each test + jest.clearAllMocks(); + + // Mock createPublicClient with default 1 ETH balance + (createPublicClient as jest.Mock).mockReturnValue({ + getBalance: jest.fn().mockResolvedValue(BigInt(1000000000000000000)), // 1 ETH + waitForTransactionReceipt: jest.fn().mockResolvedValue({ transactionHash: "0xtxhash" }), + }); + + // Mock Safe.init + (Safe.init as jest.Mock).mockResolvedValue({ + getAddress: jest.fn().mockResolvedValue(mockSafeAddress), + connect: jest.fn().mockResolvedValue({ + getAddress: jest.fn().mockResolvedValue(mockSafeAddress), + }), + createSafeDeploymentTransaction: jest.fn().mockResolvedValue({ + to: "0x123", + value: "0", + data: "0x", + }), + getSafeProvider: jest.fn().mockReturnValue({ + getExternalSigner: jest.fn().mockResolvedValue({ + sendTransaction: jest.fn().mockResolvedValue("0xtxhash"), + }), + }), + }); + + // Mock SafeApiKit constructor + (SafeApiKit as unknown as jest.Mock).mockImplementation(() => ({})); + }); + + it("should initialize correctly with private key and network", async () => { + const provider = new SafeWalletProvider({ + privateKey: mockPrivateKey, + networkId: mockNetworkId, + }); + + await provider.waitForInitialization(); + + expect(provider.getName()).toBe("safe_wallet_provider"); + + const network = provider.getNetwork(); + expect(network).toEqual({ + protocolFamily: "evm", + networkId: mockNetworkId, + chainId: NETWORK_ID_TO_VIEM_CHAIN[mockNetworkId].id.toString(), + } as Network); + }); + + it("should throw error when connecting to unsupported network", () => { + expect(() => { + new SafeWalletProvider({ + privateKey: mockPrivateKey, + networkId: "solana", + }); + }).toThrow("Unsupported network: solana"); + }); + + it("should throw error when accessing address before initialization", async () => { + const provider = new SafeWalletProvider({ + privateKey: mockPrivateKey, + networkId: mockNetworkId, + }); + + expect(() => { + provider.getAddress(); + }).toThrow("Safe not yet initialized"); + }); + + it("should connect to existing Safe if address provided", async () => { + // Mock successful initialization + (Safe.init as jest.Mock).mockResolvedValue({ + getAddress: jest.fn().mockResolvedValue(mockSafeAddress), + }); + + const provider = new SafeWalletProvider({ + privateKey: mockPrivateKey, + networkId: mockNetworkId, + safeAddress: mockSafeAddress, + }); + + await provider.waitForInitialization(); + + expect(provider.getAddress()).toBe(mockSafeAddress); + expect(Safe.init).toHaveBeenCalledWith( + expect.objectContaining({ + safeAddress: mockSafeAddress, + }), + ); + }); + + it("should fail if account has no ETH balance when creating new Safe", async () => { + // Mock zero balance + (createPublicClient as jest.Mock).mockReturnValue({ + getBalance: jest.fn().mockResolvedValue(BigInt(0)), // 0 ETH + waitForTransactionReceipt: jest.fn().mockResolvedValue({ transactionHash: "0xtxhash" }), + }); + + const provider = new SafeWalletProvider({ + privateKey: mockPrivateKey, + networkId: mockNetworkId, + // No safeAddress -> will try to create new Safe + }); + + // Wait for initialization to fail + await expect(provider.waitForInitialization()).rejects.toThrow( + "Creating Safe account requires gaas fees. Please ensure you have enough ETH in your wallet.", + ); + }); +}); diff --git a/typescript/agentkit/src/wallet-providers/safeWalletProvider.ts b/typescript/agentkit/src/wallet-providers/safeWalletProvider.ts new file mode 100644 index 000000000..77c54ace7 --- /dev/null +++ b/typescript/agentkit/src/wallet-providers/safeWalletProvider.ts @@ -0,0 +1,932 @@ +import { EvmWalletProvider } from "./evmWalletProvider"; +import { Network } from "../network"; +import { + Account, + Chain, + createPublicClient, + http, + parseEther, + parseUnits, + ReadContractParameters, + ReadContractReturnType, + encodeFunctionData, + Hex, + TransactionRequest, + SignableMessage, + ContractFunctionArgs, + ContractFunctionName, + Abi, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { CHAIN_ID_TO_NETWORK_ID, NETWORK_ID_TO_VIEM_CHAIN } from "../network/network"; +import { PublicClient } from "viem"; + +import { abi as ERC20_ABI } from "../action-providers/erc20/constants"; + +// Safe SDK imports +import Safe from "@safe-global/protocol-kit"; +import SafeApiKit from "@safe-global/api-kit"; +import { getAllowanceModuleDeployment } from "@safe-global/safe-modules-deployments"; +/** + * Configuration options for the SafeWalletProvider. + */ +export interface SafeWalletProviderConfig { + /** + * Private key of the signer that (co-)owns the Safe. + */ + privateKey: string; + + /** + * Network ID, for example "base-sepolia" or "ethereum-mainnet". + */ + networkId: string; + + /** + * Optional existing Safe address. If provided, will connect to that Safe; + * otherwise, this provider will deploy a new Safe with the private key as one the only owner. + */ + safeAddress?: string; +} + +/** + * SafeWalletProvider is a wallet provider implementation that uses Safe multi-signature accounts. + * When instantiated, this provider can either connect to an existing Safe or deploy a new one. + */ +export class SafeWalletProvider extends EvmWalletProvider { + #privateKey: string; + #account: Account; + #chain: Chain; + #safeAddress: string | null = null; + #isInitialized: boolean = false; + #publicClient: PublicClient; + #safeClient: Safe | null = null; + #apiKit: SafeApiKit; + #initializationPromise: Promise; + + /** + * Creates a new SafeWalletProvider instance. + * + * @param config - The configuration options for the SafeWalletProvider. + */ + constructor(config: SafeWalletProviderConfig) { + super(); + + // Get chain ID from network ID + this.#chain = NETWORK_ID_TO_VIEM_CHAIN[config.networkId || "base-sepolia"]; + if (!this.#chain) throw new Error(`Unsupported network: ${config.networkId}`); + + // Create default public viem client + this.#publicClient = createPublicClient({ + chain: this.#chain, + transport: http(), + }); + + // Initialize apiKit with chain ID from Viem chain + this.#apiKit = new SafeApiKit({ + chainId: BigInt(this.#chain.id), + }); + + // Connect to an existing Safe or deploy a new one with account of private key as single owner + this.#privateKey = config.privateKey; + this.#account = privateKeyToAccount(this.#privateKey as Hex); + + this.#initializationPromise = this.initializeSafe(config.safeAddress).then( + address => { + this.#safeAddress = address; + this.#isInitialized = true; + this.trackInitialization(); + }, + error => { + throw new Error("Error initializing Safe wallet: " + error); + }, + ); + } + + /** + * Returns a promise that resolves when the wallet is initialized + * + * @returns Promise that resolves when initialization is complete + */ + async waitForInitialization(): Promise { + return this.#initializationPromise; + } + + /** + * Returns the Safe address once it is initialized. + * If the Safe isn't yet deployed or connected, throws an error. + * + * @returns The Safe's address. + * @throws Error if Safe is not initialized. + */ + getAddress(): string { + if (!this.#safeAddress) { + throw new Error("Safe not yet initialized."); + } + return this.#safeAddress; + } + + /** + * Returns the Network object for this Safe. + * + * @returns Network configuration for this Safe. + */ + getNetwork(): Network { + return { + protocolFamily: "evm", + networkId: CHAIN_ID_TO_NETWORK_ID[this.#chain.id], + chainId: this.#chain.id.toString(), + }; + } + + /** + * Returns the name of this wallet provider. + * + * @returns The string "safe_wallet_provider". + */ + getName(): string { + return "safe_wallet_provider"; + } + + /** + * Queries the current Safe balance. + * + * @returns The balance in wei. + * @throws Error if Safe address is not set. + */ + async getBalance(): Promise { + if (!this.#safeAddress) throw new Error("Safe address is not set."); + const balance = await this.#publicClient.getBalance({ + address: this.#safeAddress as Hex, + }); + return balance; + } + + /** + * Transfers native tokens from the Safe to the specified address. + * If single-owner, executes immediately. + * If multi-sig, proposes the transaction. + * + * @param to - The destination address + * @param value - The amount in decimal form (e.g. "0.5" for 0.5 ETH) + * @returns Transaction hash if executed or Safe transaction hash if proposed + */ + async nativeTransfer(to: string, value: string): Promise { + if (!this.#safeClient) throw new Error("Safe client is not set."); + + try { + // Convert decimal ETH to wei + const ethAmountInWei = parseEther(value); + + // Create the transaction + const safeTx = await this.#safeClient.createTransaction({ + transactions: [ + { + to: to as Hex, + data: "0x", + value: ethAmountInWei.toString(), + }, + ], + }); + + // Get current threshold + const threshold = await this.#safeClient.getThreshold(); + + if (threshold > 1) { + // Multi-sig flow: propose transaction + const safeTxHash = await this.#safeClient.getTransactionHash(safeTx); + const signature = await this.#safeClient.signHash(safeTxHash); + + // Propose the transaction + await this.#apiKit.proposeTransaction({ + safeAddress: this.getAddress(), + safeTransactionData: safeTx.data, + safeTxHash, + senderSignature: signature.data, + senderAddress: this.#account.address, + }); + + return `Proposed transaction with Safe transaction hash: ${safeTxHash}. Other owners will need to confirm the transaction before it can be executed.`; + } else { + // Single-sig flow: execute immediately + const response = await this.#safeClient.executeTransaction(safeTx); + const receipt = await this.waitForTransactionReceipt(response.hash as Hex); + return `Successfully transferred ${value} ETH to ${to}. Transaction hash: ${receipt.transactionHash}`; + } + } catch (error) { + throw new Error( + `Failed to transfer: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Signs a hash using the private key of the account that is co-owner of the Safe. + * Note: This signs with the owner's key, not through the Safe itself. + * + * @param hash - The hash to sign. + * @returns The signature as a hex string. + */ + async signHash(hash: Hex): Promise { + if (!this.#account) { + throw new Error("Account not initialized"); + } + + return this.#account.sign!({ hash }); + } + + /** + * Signs a message using the private key of the account that is co-owner of the Safe. + * Note: This signs with the owner's key, not through the Safe itself. + * + * @param message - The message to sign. + * @returns The signature as a hex string. + */ + async signMessage(message: string | Uint8Array): Promise { + if (!this.#account) { + throw new Error("Account not initialized"); + } + + return this.#account.signMessage!({ message: message as SignableMessage }); + } + + /** + * Signs typed data using the private key of the account that is co-owner of the Safe. + * Note: This signs with the owner's key, not through the Safe itself. + * + * @param typedData - The typed data to sign. + * @returns The signature as a hex string. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async signTypedData(typedData: any): Promise { + if (!this.#account) { + throw new Error("Account not initialized"); + } + + return this.#account.signTypedData!({ + domain: typedData.domain, + types: typedData.types, + primaryType: typedData.primaryType, + message: typedData.message, + }); + } + + /** + * Signs a transaction using the Safe protocol. + * This creates a Safe transaction and returns the signature, but doesn't execute it. + * + * @param transaction - The transaction to sign. + * @returns The signature as a hex string. + */ + async signTransaction(transaction: TransactionRequest): Promise { + if (!this.#safeClient) { + throw new Error("Safe client is not set"); + } + + try { + // Create a Safe transaction object + const safeTx = await this.#safeClient.createTransaction({ + transactions: [ + { + to: transaction.to as Hex, + data: (transaction.data as Hex) || "0x", + value: transaction.value?.toString() || "0", + }, + ], + }); + + // Sign the transaction hash + const signature = await this.#safeClient.signTransaction(safeTx); + + // Return the signature + return signature as unknown as Hex; + } catch (error) { + throw new Error( + `Failed to sign transaction: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Sends a transaction through the Safe. + * For single-owner Safes, executes immediately. + * For multi-owner Safes, proposes the transaction for other owners to confirm. + * + * @param transaction - The transaction to send + * @returns The transaction hash if executed immediately, or the Safe transaction hash if proposed + */ + async sendTransaction(transaction: TransactionRequest): Promise { + if (!this.#safeClient) throw new Error("Safe client is not set."); + + try { + // Create the Safe transaction + const safeTx = await this.#safeClient.createTransaction({ + transactions: [ + { + to: transaction.to as Hex, + data: (transaction.data as Hex) || "0x", + value: transaction.value?.toString() || "0", + }, + ], + }); + + // Get current threshold + const threshold = await this.#safeClient.getThreshold(); + + if (threshold > 1) { + // Multi-sig flow: propose transaction + const safeTxHash = await this.#safeClient.getTransactionHash(safeTx); + const signature = await this.#safeClient.signHash(safeTxHash); + + // Propose the transaction + await this.#apiKit.proposeTransaction({ + safeAddress: this.getAddress(), + safeTransactionData: safeTx.data, + safeTxHash, + senderSignature: signature.data, + senderAddress: this.#account.address, + }); + + return safeTxHash as Hex; + } else { + // Single-sig flow: execute immediately + const response = await this.#safeClient.executeTransaction(safeTx); + + await this.waitForTransactionReceipt(response.hash as Hex); + return response.hash as Hex; + } + } catch (error) { + throw new Error( + `Failed to send transaction: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Waits for a transaction receipt. + * + * @param txHash - The hash of the transaction to wait for. + * @returns The transaction receipt from the network. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async waitForTransactionReceipt(txHash: Hex): Promise { + return await this.#publicClient.waitForTransactionReceipt({ hash: txHash }); + } + + /** + * Reads a contract. + * + * @param params - The parameters to read the contract. + * @returns The response from the contract. + */ + async readContract< + const abi extends Abi | readonly unknown[], + functionName extends ContractFunctionName, + const args extends ContractFunctionArgs, + >( + params: ReadContractParameters, + ): Promise> { + return this.#publicClient.readContract(params); + } + + /** + * Gets the public client instance. + * + * @returns The Viem PublicClient instance. + */ + getPublicClient(): PublicClient { + return this.#publicClient; + } + + /** + * Gets the current owners of the Safe. + * + * @returns Array of owner addresses. + * @throws Error if Safe client is not set. + */ + async getOwners(): Promise { + if (!this.#safeClient) throw new Error("Safe client is not set."); + return await this.#safeClient.getOwners(); + } + + /** + * Gets the current threshold of the Safe. + * + * @returns Current threshold number. + * @throws Error if Safe client is not set. + */ + async getThreshold(): Promise { + if (!this.#safeClient) throw new Error("Safe client is not set."); + return await this.#safeClient.getThreshold(); + } + + /** + * Adds a new owner to the Safe. + * + * @param newSigner - The address of the new owner. + * @param newThreshold - The threshold for the new owner. + * @returns Transaction hash + */ + async addOwnerWithThreshold( + newSigner: string, + newThreshold: number | undefined, + ): Promise { + if (!this.#safeClient) throw new Error("Safe client is not set."); + + // Get current Safe settings + const currentOwners = await this.getOwners(); + const currentThreshold = await this.getThreshold(); + + // Validate new signer isn't already an owner + if (currentOwners.includes(newSigner.toLowerCase())) + throw new Error("Address is already an owner of this Safe"); + + // Determine new threshold (keep current if not specified) + newThreshold = newThreshold || currentThreshold; + + // Validate threshold + const newOwnerCount = currentOwners.length + 1; + if (newThreshold > newOwnerCount) + throw new Error( + `Invalid threshold: ${newThreshold} cannot be greater than number of owners (${newOwnerCount})`, + ); + if (newThreshold < 1) throw new Error("Threshold must be at least 1"); + + // Add new signer + const safeTransaction = await this.#safeClient.createAddOwnerTx({ + ownerAddress: newSigner, + threshold: newThreshold, + }); + + if (currentThreshold > 1) { + // Multi-sig flow: propose transaction + const safeTxHash = await this.#safeClient.getTransactionHash(safeTransaction); + const signature = await this.#safeClient.signHash(safeTxHash); + + await this.#apiKit.proposeTransaction({ + safeAddress: this.getAddress(), + safeTransactionData: safeTransaction.data, + safeTxHash, + senderSignature: signature.data, + senderAddress: this.#account.address, + }); + return `Successfully proposed adding signer ${newSigner} to Safe ${this.#safeAddress}. Safe transaction hash: ${safeTxHash}. The other signers will need to confirm the transaction before it can be executed.`; + } else { + // Single-sig flow: execute immediately + const tx = await this.#safeClient.executeTransaction(safeTransaction); + return `Successfully added signer ${newSigner} to Safe ${this.#safeAddress}. Threshold: ${newThreshold}. Transaction hash: ${tx.hash}.`; + } + } + + /** + * Removes an owner from the Safe. + * + * @param signerToRemove - The address of the owner to remove. + * @param newThreshold - Optional new threshold after removing the owner. + * @returns Transaction hash + */ + async removeOwnerWithThreshold(signerToRemove: string, newThreshold?: number): Promise { + if (!this.#safeClient) throw new Error("Safe client is not set."); + + // Get current Safe settings + const currentOwners = await this.getOwners(); + const currentThreshold = await this.getThreshold(); + + // Validate we're not removing the last owner + if (currentOwners.length <= 1) { + throw new Error("Cannot remove the last owner"); + } + + // Determine new threshold (keep current if valid, otherwise reduce) + newThreshold = + newThreshold || + (currentThreshold > currentOwners.length - 1 ? currentOwners.length - 1 : currentThreshold); + + // Validate threshold + if (newThreshold > currentOwners.length - 1) { + throw new Error( + `Invalid threshold: ${newThreshold} cannot be greater than number of remaining owners (${currentOwners.length - 1})`, + ); + } + if (newThreshold < 1) throw new Error("Threshold must be at least 1"); + + // Create transaction to remove owner + const safeTransaction = await this.#safeClient.createRemoveOwnerTx({ + ownerAddress: signerToRemove, + threshold: newThreshold, + }); + + if (currentThreshold > 1) { + // Multi-sig flow: propose transaction + const safeTxHash = await this.#safeClient.getTransactionHash(safeTransaction); + const signature = await this.#safeClient.signHash(safeTxHash); + + await this.#apiKit.proposeTransaction({ + safeAddress: this.getAddress(), + safeTransactionData: safeTransaction.data, + safeTxHash, + senderSignature: signature.data, + senderAddress: this.#account.address, + }); + return `Successfully proposed removing signer ${signerToRemove} from Safe ${this.#safeAddress}. Safe transaction hash: ${safeTxHash}. The other signers will need to confirm the transaction before it can be executed.`; + } else { + // Single-sig flow: execute immediately + const tx = await this.#safeClient.executeTransaction(safeTransaction); + return `Successfully removed signer ${signerToRemove} from Safe ${this.#safeAddress}. Transaction hash: ${tx.hash}.`; + } + } + + /** + * Changes the threshold of the Safe. + * + * @param newThreshold - The new threshold value. + * @returns Transaction hash + */ + async changeThreshold(newThreshold: number): Promise { + if (!this.#safeClient) throw new Error("Safe client is not set."); + + // Get current Safe settings + const currentOwners = await this.getOwners(); + const currentThreshold = await this.getThreshold(); + + // Validate new threshold + if (newThreshold > currentOwners.length) { + throw new Error( + `Invalid threshold: ${newThreshold} cannot be greater than number of owners (${currentOwners.length})`, + ); + } + if (newThreshold < 1) throw new Error("Threshold must be at least 1"); + + // Create transaction to change threshold + const safeTransaction = await this.#safeClient.createChangeThresholdTx(newThreshold); + + if (currentThreshold > 1) { + // Multi-sig flow: propose transaction + const safeTxHash = await this.#safeClient.getTransactionHash(safeTransaction); + const signature = await this.#safeClient.signHash(safeTxHash); + + await this.#apiKit.proposeTransaction({ + safeAddress: this.getAddress(), + safeTransactionData: safeTransaction.data, + safeTxHash, + senderSignature: signature.data, + senderAddress: this.#account.address, + }); + return `Successfully proposed changing threshold to ${newThreshold} for Safe ${this.#safeAddress}. Safe transaction hash: ${safeTxHash}. The other signers will need to confirm the transaction before it can be executed.`; + } else { + // Single-sig flow: execute immediately + const tx = await this.#safeClient.executeTransaction(safeTransaction); + return `Successfully changed threshold to ${newThreshold} for Safe ${this.#safeAddress}. Transaction hash: ${tx.hash}.`; + } + } + + /** + * Approves and optionally executes a pending transaction for the Safe. + * + * @param safeTxHash - The transaction hash to approve/execute + * @param executeImmediately - Whether to execute the transaction if all signatures are collected (default: true) + * @returns A message containing the approval/execution details + */ + async approvePendingTransaction( + safeTxHash: string, + executeImmediately: boolean = true, + ): Promise { + if (!this.#safeClient) throw new Error("Safe client is not set."); + + try { + // Get pending transactions + const pendingTxs = await this.#apiKit.getPendingTransactions(this.getAddress()); + + // Find the specific transaction + const tx = pendingTxs.results.find(tx => tx.safeTxHash === safeTxHash); + + if (!tx) { + return `No pending transaction found with hash: ${safeTxHash}`; + } + + if (tx.isExecuted) { + return `Transaction ${safeTxHash} has already been executed`; + } + + // Check if agent has already signed + const agentAddress = this.#account.address; + const hasAgentSigned = tx.confirmations?.some( + c => c.owner.toLowerCase() === agentAddress.toLowerCase(), + ); + const confirmations = tx.confirmations?.length || 0; + + // If agent hasn't signed yet, sign the transaction + if (!hasAgentSigned) { + const signature = await this.#safeClient.signHash(safeTxHash); + await this.#apiKit.confirmTransaction(safeTxHash, signature.data); + + // If this was the last required signature and executeImmediately is true, execute + if (confirmations + 1 >= tx.confirmationsRequired && executeImmediately) { + const executedTx = await this.#safeClient.executeTransaction(tx); + return `Successfully approved and executed transaction. Safe transaction hash: ${safeTxHash}. Execution transaction hash: ${executedTx.hash}`; + } + + return `Successfully approved transaction ${safeTxHash}. Current confirmations: ${confirmations + 1}/${tx.confirmationsRequired}${ + confirmations + 1 >= tx.confirmationsRequired + ? ". Transaction can now be executed" + : ". Other owners will need to approve the transaction before it can be executed" + }`; + } + + // If agent has already signed and there are enough confirmations, execute if requested + if (confirmations >= tx.confirmationsRequired && executeImmediately) { + const executedTx = await this.#safeClient.executeTransaction(tx); + return `Successfully executed transaction. Safe transaction hash: ${safeTxHash}. Execution transaction hash: ${executedTx.hash}`; + } + + return `Transaction ${safeTxHash} already approved. Current confirmations: ${confirmations}/${tx.confirmationsRequired}${ + confirmations >= tx.confirmationsRequired + ? ". Transaction can now be executed" + : ". Other owners will need to approve the transaction before it can be executed" + }`; + } catch (error) { + throw new Error( + `Error approving/executing transaction: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Enables the allowance module for the Safe. + * + * @returns A message indicating success or failure + */ + async enableAllowanceModule(): Promise { + if (!this.#safeClient) throw new Error("Safe client is not set."); + + try { + // Get allowance module address for current chain + const chainId = this.#chain.id.toString(); + const allowanceModule = getAllowanceModuleDeployment({ network: chainId }); + if (!allowanceModule) { + throw new Error(`Allowance module not found for chainId [${chainId}]`); + } + + // Check if module is already enabled + const moduleAddress = allowanceModule.networkAddresses[chainId]; + const isAlreadyEnabled = await this.#safeClient.isModuleEnabled(moduleAddress); + if (isAlreadyEnabled) { + return "Allowance module is already enabled for this Safe"; + } + + // Create transaction to enable module + const safeTransaction = await this.#safeClient.createEnableModuleTx(moduleAddress); + const currentThreshold = await this.#safeClient.getThreshold(); + + if (currentThreshold > 1) { + // Multi-sig flow: propose transaction + const safeTxHash = await this.#safeClient.getTransactionHash(safeTransaction); + const signature = await this.#safeClient.signHash(safeTxHash); + + await this.#apiKit.proposeTransaction({ + safeAddress: this.getAddress(), + safeTransactionData: safeTransaction.data, + safeTxHash, + senderSignature: signature.data, + senderAddress: this.#account.address, + }); + + return `Successfully proposed enabling allowance module for Safe ${this.#safeAddress}. Safe transaction hash: ${safeTxHash}. The other signers will need to confirm the transaction before it can be executed.`; + } else { + // Single-sig flow: execute immediately + const tx = await this.#safeClient.executeTransaction(safeTransaction); + return `Successfully enabled allowance module for Safe ${this.#safeAddress}. Transaction hash: ${tx.hash}.`; + } + } catch (error) { + throw new Error( + `Failed to enable allowance module: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Sets an allowance for a delegate to spend tokens from the Safe. + * + * @param delegateAddress - Address that will receive the allowance + * @param tokenAddress - Address of the ERC20 token + * @param amount - Amount of tokens to allow (e.g. '1.5' for 1.5 tokens) + * @param resetTimeInMinutes - Time in minutes after which the allowance resets, e.g 1440 for 24 hours (optional, defaults to 0 for one-time allowance) + * @returns A message containing the allowance setting details + */ + async setAllowance( + delegateAddress: string, + tokenAddress: string | undefined, + amount: string, + resetTimeInMinutes: number | undefined, + ): Promise { + if (!this.#safeClient) throw new Error("Safe client is not set."); + + try { + // Get allowance module for current chain + const chainId = this.#chain.id.toString(); + const allowanceModule = getAllowanceModuleDeployment({ network: chainId }); + if (!allowanceModule) { + throw new Error(`Allowance module not found for chainId [${chainId}]`); + } + + const moduleAddress = allowanceModule.networkAddresses[chainId]; + + // Check if module is enabled + const isModuleEnabled = await this.#safeClient.isModuleEnabled(moduleAddress); + if (!isModuleEnabled) { + throw new Error("Allowance module is not enabled for this Safe. Enable it first."); + } + + // Get token symbol + const tokenSymbol = await this.readContract({ + address: tokenAddress as Hex, + abi: ERC20_ABI, + functionName: "symbol", + }); + + // Get token decimals and convert amount + const tokenDecimals = await this.readContract({ + address: tokenAddress as Hex, + abi: ERC20_ABI, + functionName: "decimals", + }); + + // Convert amount to token decimals + const amountBigInt = parseUnits(amount, Number(tokenDecimals)); + + // Check if the address is already a delegate + let isDelegate = false; + try { + // Use getDelegates function to get the list of delegates + const [delegates] = (await this.readContract({ + address: moduleAddress as Hex, + abi: allowanceModule.abi, + functionName: "getDelegates", + args: [this.getAddress(), 0, 100], // Start from 0, get up to 100 delegates + })) as [string[], bigint]; + + // Check if delegateAddress is in the list of delegates + isDelegate = delegates.some( + delegate => delegate.toLowerCase() === delegateAddress.toLowerCase(), + ); + } catch (error) { + console.log("Error checking delegates:", error); + // If the call fails, assume not a delegate + isDelegate = false; + } + + // Add delegate (if not already a delegate) + const addDelegateData = encodeFunctionData({ + abi: allowanceModule.abi, + functionName: "addDelegate", + args: [delegateAddress], + }); + + // Prepare the setAllowance transaction data + const setAllowanceData = encodeFunctionData({ + abi: allowanceModule.abi, + functionName: "setAllowance", + args: [ + delegateAddress, + tokenAddress, + amountBigInt, + BigInt(resetTimeInMinutes || 0), // Use 0 for one-time allowance if not specified + BigInt(0), // resetBaseMin (0 is fine as default) + ], + }); + + // Create transaction + const safeTransaction = await this.#safeClient.createTransaction({ + transactions: isDelegate + ? [ + // If already a delegate, only set allowance + { + to: moduleAddress, + value: "0", + data: setAllowanceData, + }, + ] + : [ + // If not a delegate, first add as delegate then set allowance + { + to: moduleAddress, + value: "0", + data: addDelegateData, + }, + { + to: moduleAddress, + value: "0", + data: setAllowanceData, + }, + ], + }); + + const currentThreshold = await this.#safeClient.getThreshold(); + + // Update success message to include reset time info + const resetTimeMsg = + resetTimeInMinutes && resetTimeInMinutes > 0 + ? ` (resets every ${resetTimeInMinutes} minutes)` + : ` (one-time allowance)`; + + const delegateMsg = !isDelegate ? "adding delegate and " : ""; + + if (currentThreshold > 1) { + // Multi-sig flow: propose transaction + const safeTxHash = await this.#safeClient.getTransactionHash(safeTransaction); + const signature = await this.#safeClient.signHash(safeTxHash); + + await this.#apiKit.proposeTransaction({ + safeAddress: this.getAddress(), + safeTransactionData: safeTransaction.data, + safeTxHash, + senderSignature: signature.data, + senderAddress: this.#account.address, + }); + + return `Successfully proposed ${delegateMsg}setting allowance of ${amount} ${tokenSymbol} (${tokenAddress})${resetTimeMsg} for delegate ${delegateAddress}. Safe transaction hash: ${safeTxHash}. The other signers will need to confirm the transaction before it can be executed.`; + } else { + // Single-sig flow: execute immediately + const tx = await this.#safeClient.executeTransaction(safeTransaction); + return `Successfully ${delegateMsg}set allowance of ${amount} ${tokenSymbol} (${tokenAddress})${resetTimeMsg} for delegate ${delegateAddress}. Transaction hash: ${tx.hash}.`; + } + } catch (error) { + throw new Error( + `Error setting allowance: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Override walletProvider's trackInitialization to prevent tracking before Safe is initialized. + * Only tracks analytics after the Safe is fully set up. + */ + protected trackInitialization(): void { + // Only track if fully initialized + if (!this.#isInitialized) return; + super.trackInitialization(); + } + + /** + * Creates or connects to a Safe, depending on whether safeAddr is defined. + * + * @param safeAddr - The existing Safe address (if not provided, a new Safe is deployed). + * @returns The address of the Safe. + */ + private async initializeSafe(safeAddr?: string): Promise { + if (!safeAddr) { + // Check if account has enough ETH for gas fees + const balance = await this.#publicClient.getBalance({ address: this.#account.address }); + if (balance === BigInt(0)) + throw new Error( + "Creating Safe account requires gaas fees. Please ensure you have enough ETH in your wallet.", + ); + + // Deploy a new Safe + const predictedSafe = { + safeAccountConfig: { + owners: [this.#account.address], + threshold: 1, + }, + safeDeploymentConfig: { + saltNonce: BigInt(Date.now()).toString(), + }, + }; + + const safeSdk = await Safe.init({ + provider: this.#publicClient.transport, + signer: this.#privateKey, + predictedSafe, + }); + + // Prepare and send deployment transaction + const deploymentTx = await safeSdk.createSafeDeploymentTransaction(); + const externalSigner = await safeSdk.getSafeProvider().getExternalSigner(); + const hash = await externalSigner?.sendTransaction({ + to: deploymentTx.to, + value: BigInt(deploymentTx.value), + data: deploymentTx.data as Hex, + chain: this.#publicClient.chain, + }); + await this.waitForTransactionReceipt(hash as Hex); + + // Reconnect to the deployed Safe + const safeAddress = await safeSdk.getAddress(); + const reconnected = await safeSdk.connect({ safeAddress }); + this.#safeClient = reconnected; + this.#safeAddress = safeAddress; + + return safeAddress; + } else { + // Connect to an existing Safe + const safeSdk = await Safe.init({ + provider: this.#publicClient.transport, + signer: this.#privateKey, + safeAddress: safeAddr, + }); + this.#safeClient = safeSdk; + const existingAddress = await safeSdk.getAddress(); + + return existingAddress; + } + } +} diff --git a/typescript/agentkit/src/wallet-providers/smartWalletProvider.ts b/typescript/agentkit/src/wallet-providers/smartWalletProvider.ts index 769b556fc..78df65a07 100644 --- a/typescript/agentkit/src/wallet-providers/smartWalletProvider.ts +++ b/typescript/agentkit/src/wallet-providers/smartWalletProvider.ts @@ -147,6 +147,18 @@ export class SmartWalletProvider extends EvmWalletProvider { return smartWalletProvider; } + /** + * Stub for hash signing + * + * @throws as signing hashes is not implemented for SmartWallets. + * + * @param _ - The hash to sign. + * @returns The signed hash. + */ + async signHash(_: Hex): Promise { + throw new Error("Not implemented"); + } + /** * Stub for message signing * @@ -359,4 +371,13 @@ export class SmartWalletProvider extends EvmWalletProvider { throw new Error(`Transfer failed with status ${result.status}`); } } + + /** + * Gets the public client instance. + * + * @returns The Viem PublicClient instance. + */ + getPublicClient(): ViemPublicClient { + return this.#publicClient; + } } diff --git a/typescript/agentkit/src/wallet-providers/viemWalletProvider.ts b/typescript/agentkit/src/wallet-providers/viemWalletProvider.ts index 20b7b4006..beff29c16 100644 --- a/typescript/agentkit/src/wallet-providers/viemWalletProvider.ts +++ b/typescript/agentkit/src/wallet-providers/viemWalletProvider.ts @@ -61,6 +61,20 @@ export class ViemWalletProvider extends EvmWalletProvider { this.#feePerGasMultiplier = Math.max(gasConfig?.feePerGasMultiplier ?? 1, 1); } + /** + * Sign a hash. + * + * @param hash - The hash to sign. + * @returns The signed hash. + */ + async signHash(hash: `0x${string}`): Promise<`0x${string}`> { + const account = this.#walletClient.account; + if (!account) { + throw new Error("Account not found"); + } + return account.sign!({ hash: hash }); + } + /** * Signs a message. * @@ -250,4 +264,13 @@ export class ViemWalletProvider extends EvmWalletProvider { return receipt.transactionHash; } + + /** + * Gets the public client. + * + * @returns The public client. + */ + getPublicClient(): ViemPublicClient { + return this.#publicClient; + } } diff --git a/typescript/agentkit/src/wallet-providers/walletProvider.ts b/typescript/agentkit/src/wallet-providers/walletProvider.ts index 4cc21f8e2..1ad43b2cd 100644 --- a/typescript/agentkit/src/wallet-providers/walletProvider.ts +++ b/typescript/agentkit/src/wallet-providers/walletProvider.ts @@ -20,7 +20,7 @@ export abstract class WalletProvider { /** * Tracks the initialization of the wallet provider. */ - private trackInitialization() { + protected trackInitialization() { try { sendAnalyticsEvent({ name: "agent_initialization", diff --git a/typescript/examples/langchain-cdp-chatbot/chatbot.ts b/typescript/examples/langchain-cdp-chatbot/chatbot.ts index 182c82517..245560eac 100644 --- a/typescript/examples/langchain-cdp-chatbot/chatbot.ts +++ b/typescript/examples/langchain-cdp-chatbot/chatbot.ts @@ -10,6 +10,7 @@ import { pythActionProvider, openseaActionProvider, alloraActionProvider, + safeApiActionProvider, } from "@coinbase/agentkit"; import { getLangChainTools } from "@coinbase/agentkit-langchain"; import { HumanMessage } from "@langchain/core/messages"; @@ -122,6 +123,7 @@ async function initializeAgent() { ] : []), alloraActionProvider(), + safeApiActionProvider({ networkId: process.env.NETWORK_ID || "base-sepolia" }), ], }); diff --git a/typescript/examples/langchain-safe-chatbot/.env-local b/typescript/examples/langchain-safe-chatbot/.env-local new file mode 100644 index 000000000..36d600398 --- /dev/null +++ b/typescript/examples/langchain-safe-chatbot/.env-local @@ -0,0 +1,7 @@ +# Fill in these environment variables +OPENAI_API_KEY= +SAFE_OWNER_PRIVATE_KEY= +NETWORK_ID=base-sepolia + +# If you already have a deployed Safe, set this. Otherwise, a new Safe is created +SAFE_ADDRESS= \ No newline at end of file diff --git a/typescript/examples/langchain-safe-chatbot/.eslintrc.json b/typescript/examples/langchain-safe-chatbot/.eslintrc.json new file mode 100644 index 000000000..91571ba7a --- /dev/null +++ b/typescript/examples/langchain-safe-chatbot/.eslintrc.json @@ -0,0 +1,4 @@ +{ + "parser": "@typescript-eslint/parser", + "extends": ["../../.eslintrc.base.json"] +} diff --git a/typescript/examples/langchain-safe-chatbot/.prettierrc b/typescript/examples/langchain-safe-chatbot/.prettierrc new file mode 100644 index 000000000..ffb416b74 --- /dev/null +++ b/typescript/examples/langchain-safe-chatbot/.prettierrc @@ -0,0 +1,11 @@ +{ + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "bracketSpacing": true, + "arrowParens": "avoid", + "printWidth": 100, + "proseWrap": "never" +} diff --git a/typescript/examples/langchain-safe-chatbot/README.md b/typescript/examples/langchain-safe-chatbot/README.md new file mode 100644 index 000000000..e8bd946c0 --- /dev/null +++ b/typescript/examples/langchain-safe-chatbot/README.md @@ -0,0 +1,32 @@ +# Safe AgentKit LangChain Extension Example - Chatbot + +This example demonstrates an agent using a Safe multi-signature wallet provider. +It can connect to a deployed Safe or create a new one on the specified network. + +## Environment Variables + +- **OPENAI_API_KEY**: OpenAI API key +- **SAFE_AGENT_PRIVATE_KEY**: The private key that is one owner of the Safe +- **NETWORK_ID**: The network ID, e.g., "base-sepolia", "ethereum-mainnet", etc. +- **SAFE_ADDRESS** (optional): If already deployed, specify your existing Safe address. Otherwise, a new Safe is deployed. + +## Usage + +1. Install dependencies from the monorepo root: + ```bash + npm install + npm run build + ``` +2. Navigate into this folder: + ```bash + cd typescript/examples/langchain-safe-chatbot + ``` +3. Copy `.env-local` to `.env` and fill the variables: + ```bash + cp .env-local .env + ``` +4. Run: + ```bash + npm start + ``` +5. Choose the mode: "chat" for user-driven commands, or "auto" for an autonomous demonstration. diff --git a/typescript/examples/langchain-safe-chatbot/chatbot.ts b/typescript/examples/langchain-safe-chatbot/chatbot.ts new file mode 100644 index 000000000..c67b1c2e5 --- /dev/null +++ b/typescript/examples/langchain-safe-chatbot/chatbot.ts @@ -0,0 +1,232 @@ +import { config as loadEnv } from "dotenv"; +import { ChatOpenAI } from "@langchain/openai"; +import { MemorySaver } from "@langchain/langgraph"; +import { createReactAgent } from "@langchain/langgraph/prebuilt"; +import { HumanMessage } from "@langchain/core/messages"; +import { + AgentKit, + walletActionProvider, + SafeWalletProvider, + safeWalletActionProvider, + safeApiActionProvider, + erc20ActionProvider, +} from "@coinbase/agentkit"; +import { getLangChainTools } from "@coinbase/agentkit-langchain"; + +import * as readline from "readline"; + +// Load environment variables +loadEnv(); + +/** + * Validate environment variables. If missing or invalid, exit. + */ +function validateEnv() { + const missing: string[] = []; + if (!process.env.OPENAI_API_KEY) { + missing.push("OPENAI_API_KEY"); + } + if (!process.env.SAFE_OWNER_PRIVATE_KEY) { + missing.push("SAFE_OWNER_PRIVATE_KEY"); + } + if (missing.length > 0) { + console.error("Missing required environment variables:", missing.join(", ")); + process.exit(1); + } +} + +validateEnv(); + +/** + * Choose whether to run in chat or auto mode + * + * @returns The selected mode + */ +async function chooseMode(): Promise<"chat" | "auto"> { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const question = (prompt: string) => new Promise(resolve => rl.question(prompt, resolve)); + + // eslint-disable-next-line no-constant-condition + while (true) { + console.log("\nAvailable modes:"); + console.log("1. chat - Interactive chat mode"); + console.log("2. auto - Autonomous action mode"); + + const choice = (await question("\nChoose a mode (enter number or name): ")) + .toLowerCase() + .trim(); + + if (choice === "1" || choice === "chat") { + rl.close(); + return "chat"; + } else if (choice === "2" || choice === "auto") { + rl.close(); + return "auto"; + } + console.log("Invalid choice. Please try again."); + } +} + +/** + * Initialize the Safe-based agent + * + * @returns Agent executor and config + */ +async function initializeAgent() { + // Initialize LLM + const llm = new ChatOpenAI({ + model: "gpt-4o-mini", // example model name + }); + + // Configure SafeWalletProvider + const privateKey = process.env.SAFE_OWNER_PRIVATE_KEY as string; + const networkId = process.env.NETWORK_ID || "base-sepolia"; + const safeAddress = process.env.SAFE_ADDRESS; + const safeWallet = new SafeWalletProvider({ + privateKey, + networkId, + safeAddress, + }); + await safeWallet.waitForInitialization(); + + // Initialize AgentKit with the Safe wallet and some typical action providers + const agentkit = await AgentKit.from({ + walletProvider: safeWallet, + actionProviders: [ + walletActionProvider(), + safeWalletActionProvider(), + safeApiActionProvider({ networkId: networkId }), + erc20ActionProvider(), + ], + }); + + const tools = await getLangChainTools(agentkit); + + // Store buffered conversation history in memory + const memory = new MemorySaver(); + const agentConfig = { configurable: { thread_id: "Safe AgentKit Chatbot Example!" } }; + + // Create the agent + const agent = createReactAgent({ + llm, + tools, + checkpointSaver: memory, + messageModifier: ` + You are a helpful agent with a Safe multi-sig wallet that can interact onchain using the Coinbase Developer + Platform AgentKit. You are empowered to interact onchain using your tools. + If there are multiple owners (signers) and threshold > 1, you can propose transactions that need confirmations from other signers before they can be executed. + If threshold=1, you can execute immediately. You can also approve (and eventually execute) pending transactions proposed by other owners. + If you ever need funds, you can request them from the faucet if you are on network ID 'base-sepolia'. If not, you can provide your wallet + details and request funds from the user. + Before executing your first action, get the wallet details to see what network you're on. If there is a 5XX (internal) HTTP error code, ask the user to try again later. + If someone asks you to do something you can't do with your currently available tools, you must say so, and + encourage them to implement it themselves using the CDP SDK + Agentkit, recommend they go to + docs.cdp.coinbase.com for more information. Be concise and helpful with your responses. Refrain from + restating your tools' descriptions unless it is explicitly requested. + `, + }); + + return { agent, config: agentConfig }; +} + +/** + * Run the agent in chat mode + * + * @param agent - The agent executor + * @param config - Agent configuration + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function runChatMode(agent: any, config: any) { + console.log("Starting chat mode... Type 'exit' or Ctrl+C to exit.\n"); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const question = (prompt: string) => new Promise(resolve => rl.question(prompt, resolve)); + + // eslint-disable-next-line no-constant-condition + while (true) { + const userInput = await question("Prompt: "); + + if (userInput.toLowerCase() === "exit") { + rl.close(); + break; + } + + const stream = await agent.stream({ messages: [new HumanMessage(userInput)] }, config); + + for await (const chunk of stream) { + if ("agent" in chunk) { + console.log(chunk.agent.messages[0].content); + } else if ("tools" in chunk) { + console.log(chunk.tools.messages[0].content); + } + console.log("------------------------------------------"); + } + } +} + +/** + * Demonstration of an autonomous loop + * + * @param agent - The agent executor + * @param config - Agent configuration + * @param interval - Time interval between actions in seconds + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function runAutoMode(agent: any, config: any, interval = 15) { + console.log("Starting autonomous mode. Press Ctrl+C to exit.\n"); + // eslint-disable-next-line no-constant-condition + while (true) { + try { + const thought = + "Pick a creative onchain action that demonstrates Safe usage. Execute or propose it. Summarize progress."; + + const stream = await agent.stream({ messages: [new HumanMessage(thought)] }, config); + + for await (const chunk of stream) { + if ("agent" in chunk) { + console.log(chunk.agent.messages[0].content); + } else if ("tools" in chunk) { + console.log(chunk.tools.messages[0].content); + } + console.log("------------------------------------------"); + } + + // Wait seconds between iterations + await new Promise(resolve => setTimeout(resolve, interval * 1000)); + } catch (err) { + console.error("Error in auto mode:", err); + process.exit(1); + } + } +} + +/** + * Main entrypoint + */ +async function main() { + try { + const { agent, config } = await initializeAgent(); + const mode = await chooseMode(); + + if (mode === "chat") { + await runChatMode(agent, config); + } else { + await runAutoMode(agent, config); + } + } catch (error) { + console.error("Fatal error:", error); + process.exit(1); + } +} + +if (require.main === module) { + main(); +} diff --git a/typescript/examples/langchain-safe-chatbot/package.json b/typescript/examples/langchain-safe-chatbot/package.json new file mode 100644 index 000000000..dc4745559 --- /dev/null +++ b/typescript/examples/langchain-safe-chatbot/package.json @@ -0,0 +1,27 @@ +{ + "name": "@coinbase/langchain-safe-chatbot-example", + "description": "Safe AgentKit LangChain Chatbot Example", + "version": "1.0.0", + "license": "Apache-2.0", + "scripts": { + "start": "NODE_OPTIONS='--no-warnings' ts-node ./chatbot.ts", + "dev": "nodemon ./chatbot.ts", + "lint": "eslint -c .eslintrc.json *.ts", + "lint:fix": "eslint -c .eslintrc.json *.ts --fix", + "format": "prettier -c .prettierrc --write \"**/*.{ts,js,cjs,json,md}\"", + "format:check": "prettier -c .prettierrc --check \"**/*.{ts,js,cjs,json,md}\"" + }, + "dependencies": { + "@coinbase/agentkit": "^0.2.0", + "@coinbase/agentkit-langchain": "^0.2.0", + "@langchain/core": "^0.3.19", + "@langchain/langgraph": "^0.2.21", + "@langchain/openai": "^0.3.14", + "dotenv": "^16.4.5", + "zod": "^3.22.4" + }, + "devDependencies": { + "nodemon": "^3.1.0", + "ts-node": "^10.9.2" + } +} diff --git a/typescript/examples/langchain-safe-chatbot/tsconfig.json b/typescript/examples/langchain-safe-chatbot/tsconfig.json new file mode 100644 index 000000000..a37da3664 --- /dev/null +++ b/typescript/examples/langchain-safe-chatbot/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "preserveSymlinks": true, + "outDir": "./dist", + "rootDir": ".", + "module": "Node16" + }, + "include": ["*.ts"] +} diff --git a/typescript/package-lock.json b/typescript/package-lock.json index 4125916ff..29418e6d1 100644 --- a/typescript/package-lock.json +++ b/typescript/package-lock.json @@ -49,6 +49,8 @@ "@coinbase/coinbase-sdk": "^0.20.0", "@jup-ag/api": "^6.0.39", "@privy-io/server-auth": "^1.18.4", + "@safe-global/api-kit": "^2.5.11", + "@safe-global/protocol-kit": "^5.2.4", "@solana/spl-token": "^0.4.12", "@solana/web3.js": "^1.98.0", "md5": "^2.3.0", @@ -3039,6 +3041,55 @@ "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", "dev": true }, + "node_modules/@safe-global/api-kit": { + "version": "2.5.11", + "resolved": "https://registry.npmjs.org/@safe-global/api-kit/-/api-kit-2.5.11.tgz", + "integrity": "sha512-gNrbGI/vHbOplPrytTEe5+CmwOowkEjDoTqGxz6q/rQSEJ7d7z8YzVy8Zdia7ICld1nIymQmkBdXkLr2XrDwfQ==", + "dependencies": { + "@safe-global/protocol-kit": "^5.2.4", + "@safe-global/types-kit": "^1.0.4", + "node-fetch": "^2.7.0", + "viem": "^2.21.8" + } + }, + "node_modules/@safe-global/protocol-kit": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@safe-global/protocol-kit/-/protocol-kit-5.2.4.tgz", + "integrity": "sha512-HqEIoclgeit1xsNyZfnscUA3q3uwr0VwoDAnLpVpOTY3y/oh8AEwsFFYs5UGZ05zfQb5t2yfvDSZt0ye+8y86g==", + "dependencies": { + "@safe-global/safe-deployments": "^1.37.28", + "@safe-global/safe-modules-deployments": "^2.2.5", + "@safe-global/types-kit": "^1.0.4", + "abitype": "^1.0.2", + "semver": "^7.6.3", + "viem": "^2.21.8" + }, + "optionalDependencies": { + "@noble/curves": "^1.6.0", + "@peculiar/asn1-schema": "^2.3.13" + } + }, + "node_modules/@safe-global/safe-deployments": { + "version": "1.37.30", + "resolved": "https://registry.npmjs.org/@safe-global/safe-deployments/-/safe-deployments-1.37.30.tgz", + "integrity": "sha512-fARm/2VkT4Om/EoaVG4G/TvxaXnVfJZQrsXi/3eDcIB0NwkjgTHoku7FfdY4Gl3EINCaUHnWT9t7CNMPJu/I5w==", + "dependencies": { + "semver": "^7.6.2" + } + }, + "node_modules/@safe-global/safe-modules-deployments": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@safe-global/safe-modules-deployments/-/safe-modules-deployments-2.2.5.tgz", + "integrity": "sha512-rkKqj6gGKokjBX2JshH1EwjPkzFD14oOk2vY3LSxKhpkQyjdqxcj+OlWkjzpi613HzCtxY0mHgMiyLID7nzueA==" + }, + "node_modules/@safe-global/types-kit": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@safe-global/types-kit/-/types-kit-1.0.4.tgz", + "integrity": "sha512-PTVgu+tNrC0km7J/vSZBDGyv0yAEc9IWgh6oWyBPsCysxuLUqyVA53K1qrzzGfJL2mzpC7Bj4bx+Bt6iP9m/yQ==", + "dependencies": { + "abitype": "^1.0.2" + } + }, "node_modules/@scure/base": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.4.tgz", @@ -13526,6 +13577,8 @@ "@coinbase/coinbase-sdk": "^0.19.0", "@jup-ag/api": "^6.0.39", "@privy-io/server-auth": "^1.18.4", + "@safe-global/api-kit": "^2.5.11", + "@safe-global/protocol-kit": "^5.2.4", "@solana/spl-token": "^0.4.12", "@solana/web3.js": "^1.98.0", "md5": "^2.3.0", @@ -14477,6 +14530,8 @@ "@coinbase/coinbase-sdk": "^0.20.0", "@jup-ag/api": "^6.0.39", "@privy-io/server-auth": "^1.18.4", + "@safe-global/api-kit": "^2.5.11", + "@safe-global/protocol-kit": "^5.2.4", "@solana/spl-token": "^0.4.12", "@solana/web3.js": "^1.98.0", "@types/jest": "^29.5.14", @@ -15714,6 +15769,53 @@ "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", "dev": true }, + "@safe-global/api-kit": { + "version": "2.5.11", + "resolved": "https://registry.npmjs.org/@safe-global/api-kit/-/api-kit-2.5.11.tgz", + "integrity": "sha512-gNrbGI/vHbOplPrytTEe5+CmwOowkEjDoTqGxz6q/rQSEJ7d7z8YzVy8Zdia7ICld1nIymQmkBdXkLr2XrDwfQ==", + "requires": { + "@safe-global/protocol-kit": "^5.2.4", + "@safe-global/types-kit": "^1.0.4", + "node-fetch": "^2.7.0", + "viem": "^2.21.8" + } + }, + "@safe-global/protocol-kit": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@safe-global/protocol-kit/-/protocol-kit-5.2.4.tgz", + "integrity": "sha512-HqEIoclgeit1xsNyZfnscUA3q3uwr0VwoDAnLpVpOTY3y/oh8AEwsFFYs5UGZ05zfQb5t2yfvDSZt0ye+8y86g==", + "requires": { + "@noble/curves": "^1.6.0", + "@peculiar/asn1-schema": "^2.3.13", + "@safe-global/safe-deployments": "^1.37.28", + "@safe-global/safe-modules-deployments": "^2.2.5", + "@safe-global/types-kit": "^1.0.4", + "abitype": "^1.0.2", + "semver": "^7.6.3", + "viem": "^2.21.8" + } + }, + "@safe-global/safe-deployments": { + "version": "1.37.30", + "resolved": "https://registry.npmjs.org/@safe-global/safe-deployments/-/safe-deployments-1.37.30.tgz", + "integrity": "sha512-fARm/2VkT4Om/EoaVG4G/TvxaXnVfJZQrsXi/3eDcIB0NwkjgTHoku7FfdY4Gl3EINCaUHnWT9t7CNMPJu/I5w==", + "requires": { + "semver": "^7.6.2" + } + }, + "@safe-global/safe-modules-deployments": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@safe-global/safe-modules-deployments/-/safe-modules-deployments-2.2.5.tgz", + "integrity": "sha512-rkKqj6gGKokjBX2JshH1EwjPkzFD14oOk2vY3LSxKhpkQyjdqxcj+OlWkjzpi613HzCtxY0mHgMiyLID7nzueA==" + }, + "@safe-global/types-kit": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@safe-global/types-kit/-/types-kit-1.0.4.tgz", + "integrity": "sha512-PTVgu+tNrC0km7J/vSZBDGyv0yAEc9IWgh6oWyBPsCysxuLUqyVA53K1qrzzGfJL2mzpC7Bj4bx+Bt6iP9m/yQ==", + "requires": { + "abitype": "^1.0.2" + } + }, "@scure/base": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.4.tgz",