From 45ecc71ca455d617ce44a2025fcbd6359ff47261 Mon Sep 17 00:00:00 2001 From: Philippe d'Argent Date: Sat, 8 Feb 2025 22:37:41 +0900 Subject: [PATCH 1/9] add safe action provider --- typescript/agentkit/package.json | 3 + .../agentkit/src/action-providers/index.ts | 1 + .../src/action-providers/safe/README.md | 1 + .../src/action-providers/safe/index.ts | 2 + .../safe/safeActionProvider.test.ts | 0 .../safe/safeActionProvider.ts | 958 ++++++++++++++++++ .../src/action-providers/safe/schemas.ts | 47 + .../src/action-providers/safe/utils.ts | 0 .../src/wallet-providers/cdpWalletProvider.ts | 11 +- .../examples/langchain-cdp-chatbot/chatbot.ts | 34 +- typescript/package-lock.json | 152 +++ 11 files changed, 1202 insertions(+), 7 deletions(-) create mode 100644 typescript/agentkit/src/action-providers/safe/README.md create mode 100644 typescript/agentkit/src/action-providers/safe/index.ts create mode 100644 typescript/agentkit/src/action-providers/safe/safeActionProvider.test.ts create mode 100644 typescript/agentkit/src/action-providers/safe/safeActionProvider.ts create mode 100644 typescript/agentkit/src/action-providers/safe/schemas.ts create mode 100644 typescript/agentkit/src/action-providers/safe/utils.ts diff --git a/typescript/agentkit/package.json b/typescript/agentkit/package.json index 0189fc102..cb9bc350b 100644 --- a/typescript/agentkit/package.json +++ b/typescript/agentkit/package.json @@ -46,6 +46,9 @@ "@privy-io/server-auth": "^1.18.4", "@solana/spl-token": "^0.4.12", "@solana/web3.js": "^1.98.0", + "@safe-global/api-kit": "^2.5.8", + "@safe-global/protocol-kit": "^5.2.1", + "@safe-global/safe-core-sdk-types": "^5.1.0", "md5": "^2.3.0", "opensea-js": "^7.1.18", "reflect-metadata": "^0.2.2", 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..1de9449a5 --- /dev/null +++ b/typescript/agentkit/src/action-providers/safe/README.md @@ -0,0 +1 @@ +# Safe Action Provider \ 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..7e1a8ed29 --- /dev/null +++ b/typescript/agentkit/src/action-providers/safe/index.ts @@ -0,0 +1,2 @@ +export * from "./schemas"; +export * from "./safeActionProvider"; diff --git a/typescript/agentkit/src/action-providers/safe/safeActionProvider.test.ts b/typescript/agentkit/src/action-providers/safe/safeActionProvider.test.ts new file mode 100644 index 000000000..e69de29bb diff --git a/typescript/agentkit/src/action-providers/safe/safeActionProvider.ts b/typescript/agentkit/src/action-providers/safe/safeActionProvider.ts new file mode 100644 index 000000000..b8308dfe3 --- /dev/null +++ b/typescript/agentkit/src/action-providers/safe/safeActionProvider.ts @@ -0,0 +1,958 @@ +import { z } from "zod"; +import { ActionProvider } from "../actionProvider"; +import { CreateAction } from "../actionDecorator"; +import { InitializeSafeSchema, SafeInfoSchema, AddSignerSchema, RemoveSignerSchema, ChangeThresholdSchema, ExecutePendingSchema, WithdrawFromSafeSchema, EnableAllowanceModuleSchema, AnalyzeTransactionSchema } from "./schemas"; +import { Wallet, JsonRpcProvider } from "ethers"; +import { Network } from "../../network"; +import { base, baseSepolia, sepolia } from "viem/chains"; +import Safe, { PredictedSafeProps } from '@safe-global/protocol-kit' +import SafeApiKit from '@safe-global/api-kit' +import { getAllowanceModuleDeployment } from '@safe-global/safe-modules-deployments' +import { EvmWalletProvider } from "../../wallet-providers/evmWalletProvider"; +import { NETWORK_ID_TO_VIEM_CHAIN } from "../../network/network"; +import { waitForTransactionReceipt } from "viem/actions"; + +/** + * Configuration options for the SafeActionProvider. + */ +export interface SafeActionProviderConfig { + /** + * The private key to use for the SafeActionProvider. + */ + privateKey?: string; + + /** + * The network ID to use for the SafeActionProvider. + */ + networkId?: string; +} + +/** + * NetworkConfig is the configuration for network-specific settings. + */ +interface NetworkConfig { + rpcUrl: string; + chain: string; +} + +interface TenderlySimulationResponse { + success: boolean; + error?: string; + gasUsed?: string; + logs?: Array<{ + name: string; + topics: string[]; + }>; +} + +async function simulateWithTenderly(tx: any, network: string): Promise { + const TENDERLY_USER = process.env.TENDERLY_USER; + const TENDERLY_PROJECT = process.env.TENDERLY_PROJECT; + const TENDERLY_ACCESS_KEY = process.env.TENDERLY_ACCESS_KEY; + + if (!TENDERLY_USER || !TENDERLY_PROJECT || !TENDERLY_ACCESS_KEY) { + return { success: false, error: "Tenderly credentials not configured" }; + } + + try { + const response = await fetch( + `https://api.tenderly.co/api/v1/account/${TENDERLY_USER}/project/${TENDERLY_PROJECT}/simulate`, + { + method: "POST", + headers: { + "X-Access-Key": TENDERLY_ACCESS_KEY, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + network_id: network, + from: tx.proposer, + to: tx.to, + input: tx.data, + value: tx.value, + save: true, + }), + } + ); + + const result = await response.json(); + return { + success: result.simulation.status, + gasUsed: result.simulation.gas_used, + logs: result.simulation.logs, + error: result.simulation.error_message + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Simulation failed" + }; + } +} + +/** + * SafeActionProvider is an action provider for Safe smart account interactions. + */ +export class SafeActionProvider extends ActionProvider { + private walletWithProvider: Wallet; + private safeClient: Safe | null = null; + private readonly networkConfig: NetworkConfig; + private readonly privateKey: string; + private apiKit: SafeApiKit; + + /** + * Constructor for the SafeActionProvider class. + * + * @param config - The configuration options for the SafeActionProvider. + */ + constructor(config: SafeActionProviderConfig = {}) { + super("safe", []); + + // Get Viem chain object for the network + const viemChain = NETWORK_ID_TO_VIEM_CHAIN[config.networkId || "base-sepolia"]; + if (!viemChain) { + throw new Error(`Unsupported network: ${config.networkId}`); + } + console.log("viemChain: ", viemChain); + + // Use Viem chain's RPC URL and name + this.networkConfig = { + rpcUrl: viemChain.rpcUrls.default.http[0], + chain: config.networkId || "base-sepolia" + }; + console.log("networkConfig: ", this.networkConfig); + // if (!this.supportsNetwork({ networkId: this.networkConfig.chain })) { + // throw new Error("Unsupported network. Only base-sepolia, ethereum-sepolia, and base-mainnet are supported."); + // } + + const privateKey = config.privateKey || process.env.WALLET_PRIVATE_KEY; + if (!privateKey) { + throw new Error("Private key is not configured"); + } + + const provider = new JsonRpcProvider(this.networkConfig.rpcUrl); + const walletWithProvider = new Wallet(privateKey, provider); + this.walletWithProvider = walletWithProvider; + this.privateKey = privateKey; + + // Initialize apiKit with chain ID from Viem chain + this.apiKit = new SafeApiKit({ + chainId: BigInt(viemChain.id) + }); + } + + /** + * Initializes a new Safe smart account. + * + * @param args - The input arguments for creating a Safe. + * @returns A message containing the Safe creation details. + */ + @CreateAction({ + name: "create_safe", + description: ` +Creates a new Safe smart account. +Takes the following inputs: +- signers: Array of additional signer addresses (optional, default: []) +- threshold: Number of required confirmations (optional, default: 1) + +Important notes: +- Requires gas to deploy the Safe contract +- The deployer (private key owner) will be automatically added as a signer +- Threshold must be <= number of signers + `, + schema: InitializeSafeSchema, + }) + async initializeSafe(args: z.infer): Promise { + + try { + const predictedSafe: PredictedSafeProps = { + safeAccountConfig: { + owners: [this.walletWithProvider.address], + threshold: 1 + }, + safeDeploymentConfig: { + saltNonce: BigInt(Date.now()).toString() + } + } + + let protocolKit = await Safe.init({ + provider: this.networkConfig.rpcUrl, + signer: this.privateKey, + predictedSafe + }); + + const safeAddress = await protocolKit.getAddress(); + console.log("safeAddress", safeAddress); + + const deploymentTransaction = await protocolKit.createSafeDeploymentTransaction() + + // Execute transaction + const client = await protocolKit.getSafeProvider().getExternalSigner() + const txHash = await client?.sendTransaction({ + to: deploymentTransaction.to, + value: BigInt(deploymentTransaction.value), + data: deploymentTransaction.data as `0x${string}`, + chain: this.networkConfig.chain === "base-sepolia" ? baseSepolia : this.networkConfig.chain === "ethereum-sepolia" ? sepolia : base + }) + console.log("txHash: ", txHash); + + const address = client!.account.address + const txReceipt = await waitForTransactionReceipt(client!, { hash: txHash! }); + console.log("txReceipt: ", txReceipt); + + // Reconnect to newly deployed Safe + protocolKit = await protocolKit.connect({ safeAddress }) + this.safeClient = protocolKit; + + console.log('Is Safe deployed:', await protocolKit.isSafeDeployed()) + console.log('Safe Address:', await protocolKit.getAddress()) + console.log('Safe Owners:', await protocolKit.getOwners()) + console.log('Safe Threshold:', await protocolKit.getThreshold()) + + return `Successfully created Safe at address ${safeAddress} with signers ${args.signers} and threshold of ${args.threshold}.`; + } catch (error) { + return `Error creating Safe: ${error instanceof Error ? error.message : String(error)}`; + } + } + + /** + * Connects to an existing Safe smart account. + * + * @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(args: z.infer): Promise { + try { + const protocolKit = await Safe.init({ + provider: this.networkConfig.rpcUrl, + signer: this.privateKey, + safeAddress: args.safeAddress + }); + + const owners = await protocolKit.getOwners(); + const threshold = await protocolKit.getThreshold(); + const pendingTransactions = await this.apiKit.getPendingTransactions(args.safeAddress); + const balance = await this.walletWithProvider.provider?.getBalance(args.safeAddress); + const ethBalance = balance ? parseFloat(balance.toString()) / 1e18 : 0; + + console.log("pendingTransactions: ", pendingTransactions); + + 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 at address ${args.safeAddress}: +- Balance: ${ethBalance.toFixed(5)} ETH +- ${owners.length} owners: ${owners.join(', ')} +- Threshold: ${threshold} +- Pending transactions: ${pendingTransactions.count}${pendingTxDetails}`; + } catch (error) { + return `Error connecting to Safe: ${error instanceof Error ? error.message : String(error)}`; + } + } + + @CreateAction({ + name: "add_signer", + description: ` +Adds a new signer to an existing Safe. +Takes the following inputs: +- safeAddress: Address of the Safe to modify +- newSigner: Address of the new signer to add +- newThreshold: (Optional) New threshold after adding signer + +Important notes: +- Requires an existing Safe +- Must be called by an existing signer +- Requires confirmation from other signers if threshold > 1 +- If newThreshold not provided, keeps existing threshold +`, + schema: AddSignerSchema, + }) + async addSigner(args: z.infer): Promise { + try { + // Connect to existing safe + const protocolKit = await Safe.init({ + provider: this.networkConfig.rpcUrl, + signer: this.privateKey, + safeAddress: args.safeAddress + }); + + // Get current signers and threshold + const currentSigners = await protocolKit.getOwners(); + const currentThreshold = await protocolKit.getThreshold(); + + // Add new signer + const safeTransaction = await protocolKit.createAddOwnerTx({ + ownerAddress: args.newSigner, + threshold: args.newThreshold || currentThreshold + }); + + if (currentThreshold > 1) { + // Multi-sig flow: propose transaction + const safeTxHash = await protocolKit.getTransactionHash(safeTransaction); + const signature = await protocolKit.signHash(safeTxHash); + + const txResponse = await this.apiKit.proposeTransaction({ + safeAddress: args.safeAddress, + safeTransactionData: safeTransaction.data, + safeTxHash, + senderSignature: signature.data, + senderAddress: this.walletWithProvider.address + }); + + return `Successfully proposed adding signer ${args.newSigner} to Safe ${args.safeAddress}. Transaction hash: ${safeTxHash}. The other signers will need to confirm the transaction before it can be executed.`; + } + + else { + // Single-sig flow: execute immediately + const txResponse = await protocolKit.executeTransaction(safeTransaction); + console.log("txResponse: ", txResponse); + return `Successfully added signer ${args.newSigner} to Safe ${args.safeAddress}.`; + } + } catch (error) { + return `Error adding signer: ${error instanceof Error ? error.message : String(error)}`; + } + } + + @CreateAction({ + name: "remove_signer", + description: ` +Removes a signer from an existing Safe. +Takes the following inputs: +- safeAddress: Address of the Safe to modify +- signerToRemove: Address of the signer to remove +- newThreshold: (Optional) New threshold after removing signer + +Important notes: +- Requires an existing Safe +- Must be called by an existing signer +- Cannot remove the last signer +- If newThreshold not provided, keeps existing threshold if valid +`, + schema: RemoveSignerSchema, + }) + async removeSigner(args: z.infer): Promise { + try { + const protocolKit = await Safe.init({ + provider: this.networkConfig.rpcUrl, + signer: this.privateKey, + safeAddress: args.safeAddress + }); + + const currentSigners = await protocolKit.getOwners(); + const currentThreshold = await protocolKit.getThreshold(); + + if (currentSigners.length <= 1) { + throw new Error("Cannot remove the last signer"); + } + + const safeTransaction = await protocolKit.createRemoveOwnerTx({ + ownerAddress: args.signerToRemove, + threshold: args.newThreshold || (currentThreshold > currentSigners.length - 1 ? currentSigners.length - 1 : currentThreshold) + }); + + if (currentThreshold > 1) { + // Multi-sig flow: propose transaction + const safeTxHash = await protocolKit.getTransactionHash(safeTransaction); + const signature = await protocolKit.signHash(safeTxHash); + + await this.apiKit.proposeTransaction({ + safeAddress: args.safeAddress, + safeTransactionData: safeTransaction.data, + safeTxHash, + senderSignature: signature.data, + senderAddress: this.walletWithProvider.address + }); + + return `Successfully proposed removing signer ${args.signerToRemove} from Safe ${args.safeAddress}. Transaction hash: ${safeTxHash}. The other signers will need to confirm the transaction before it can be executed.`; + } else { + // Single-sig flow: execute immediately + const txResponse = await protocolKit.executeTransaction(safeTransaction); + console.log("txResponse: ", txResponse); + return `Successfully removed signer ${args.signerToRemove} from Safe ${args.safeAddress}`; + } + } catch (error) { + return `Error removing signer: ${error instanceof Error ? error.message : String(error)}`; + } + } + + @CreateAction({ + name: "change_threshold", + description: ` +Changes the confirmation threshold of an existing Safe. +Takes the following inputs: +- safeAddress: Address of the Safe to modify +- newThreshold: New threshold value (must be >= 1 and <= number of signers) + +Important notes: +- Requires an existing Safe +- Must be called by an existing signer +- New threshold must not exceed number of signers +`, + schema: ChangeThresholdSchema, + }) + async changeThreshold(args: z.infer): Promise { + try { + const protocolKit = await Safe.init({ + provider: this.networkConfig.rpcUrl, + signer: this.privateKey, + safeAddress: args.safeAddress + }); + + const currentSigners = await protocolKit.getOwners(); + const currentThreshold = await protocolKit.getThreshold(); + + if (args.newThreshold > currentSigners.length) { + throw new Error("New threshold cannot exceed number of signers"); + } + + const safeTransaction = await protocolKit.createChangeThresholdTx(args.newThreshold); + + if (currentThreshold > 1) { + // Multi-sig flow: propose transaction + const safeTxHash = await protocolKit.getTransactionHash(safeTransaction); + const signature = await protocolKit.signHash(safeTxHash); + + await this.apiKit.proposeTransaction({ + safeAddress: args.safeAddress, + safeTransactionData: safeTransaction.data, + safeTxHash, + senderSignature: signature.data, + senderAddress: this.walletWithProvider.address + }); + + return `Successfully proposed changing threshold to ${args.newThreshold} for Safe ${args.safeAddress}. Transaction hash: ${safeTxHash}. The other signers will need to confirm the transaction before it can be executed.`; + } else { + // Single-sig flow: execute immediately + const txResponse = await protocolKit.executeTransaction(safeTransaction); + console.log("txResponse: ", txResponse); + return `Successfully changed threshold to ${args.newThreshold} for Safe ${args.safeAddress}`; + } + } catch (error) { + return `Error changing threshold: ${error instanceof Error ? error.message : String(error)}`; + } + } + + @CreateAction({ + name: "execute_pending", + description: ` +Executes pending transactions for a Safe if enough signatures are collected. +Takes the following inputs: +- safeAddress: Address of the Safe +- safeTxHash: (Optional) Specific transaction hash to execute. If not provided, will try to execute all pending transactions + +Important notes: +- Requires an existing Safe +- Must be called by an existing signer +- Transaction must have enough signatures to meet threshold +- Will fail if threshold is not met +`, + schema: ExecutePendingSchema, + }) + async executePending(args: z.infer): Promise { + try { + const protocolKit = await Safe.init({ + provider: this.networkConfig.rpcUrl, + signer: this.privateKey, + safeAddress: args.safeAddress + }); + + const pendingTxs = await this.apiKit.getPendingTransactions(args.safeAddress); + + if (pendingTxs.results.length === 0) { + return "No pending transactions found."; + } + + let executedTxs = 0; + let skippedTxs = 0; + const txsToProcess = args.safeTxHash + ? pendingTxs.results.filter(tx => tx.safeTxHash === args.safeTxHash && tx.isExecuted === false) + : pendingTxs.results.filter(tx => tx.isExecuted === false); + + for (const tx of txsToProcess) { + try { + // Skip if not enough confirmations + console.log("tx.confirmations: ", tx.confirmations); + if (tx.confirmations && tx.confirmations.length < tx.confirmationsRequired) { + console.log(`Transaction ${tx.safeTxHash} needs ${tx.confirmationsRequired} confirmations but only has ${tx.confirmations?.length}.`); + skippedTxs++; + continue; + } + const txResponse = await protocolKit.executeTransaction(tx); + console.log(`Executed transaction ${tx.safeTxHash}:`, txResponse); + executedTxs++; + } catch (error) { + console.error(`Failed to execute ${tx.safeTxHash}:`, error); + skippedTxs++; + } + } + + if (executedTxs === 0 && skippedTxs > 0) { + return `No transactions executed. ${skippedTxs} transaction(s) have insufficient confirmations.`; + } + + return `Execution complete. Successfully executed ${executedTxs} transaction(s)${skippedTxs > 0 ? `, ${skippedTxs} still pending and need more confirmations` : ''}.${ + args.safeTxHash ? ` Transaction hash: ${args.safeTxHash}` : '' + }`; + } catch (error) { + return `Error executing transactions: ${error instanceof Error ? error.message : String(error)}`; + } + } + + @CreateAction({ + name: "withdraw_eth", + description: ` +Withdraws ETH from the Safe. +Takes the following inputs: +- safeAddress: Address of the Safe +- recipientAddress: Address to receive the ETH +- amount: (Optional) Amount of ETH to withdraw. If not provided, withdraws entire balance + +Important notes: +- Requires an existing Safe +- Must be called by an existing signer +- Requires confirmation from other signers if threshold > 1 +`, + schema: WithdrawFromSafeSchema, + }) + async withdrawEth(args: z.infer): Promise { + try { + const protocolKit = await Safe.init({ + provider: this.networkConfig.rpcUrl, + signer: this.privateKey, + safeAddress: args.safeAddress + }); + + const currentThreshold = await protocolKit.getThreshold(); + const balance = await this.walletWithProvider.provider?.getBalance(args.safeAddress); + if (!balance) throw new Error("Could not get Safe balance"); + + // Calculate amount to withdraw + let withdrawAmount: bigint; + if (args.amount) { + withdrawAmount = BigInt(Math.floor(parseFloat(args.amount) * 1e18)); + if (withdrawAmount > balance) { + throw new Error(`Insufficient balance. Safe has ${parseFloat(balance.toString()) / 1e18} ETH`); + } + } else { + withdrawAmount = balance; + } + + const safeTransaction = await protocolKit.createTransaction({ + transactions: [{ + to: args.recipientAddress, + data: "0x", + value: withdrawAmount.toString() + }] + }); + + if (currentThreshold > 1) { + // Multi-sig flow: propose transaction + const safeTxHash = await protocolKit.getTransactionHash(safeTransaction); + const signature = await protocolKit.signHash(safeTxHash); + + await this.apiKit.proposeTransaction({ + safeAddress: args.safeAddress, + safeTransactionData: safeTransaction.data, + safeTxHash, + senderSignature: signature.data, + senderAddress: this.walletWithProvider.address + }); + + return `Successfully proposed withdrawing ${parseFloat(withdrawAmount.toString()) / 1e18} ETH to ${args.recipientAddress}. Transaction hash: ${safeTxHash}. The other signers will need to confirm the transaction before it can be executed.`; + } else { + // Single-sig flow: execute immediately + const txResponse = await protocolKit.executeTransaction(safeTransaction); + console.log("txResponse: ", txResponse); + return `Successfully withdrew ${parseFloat(withdrawAmount.toString()) / 1e18} ETH to ${args.recipientAddress}`; + } + } catch (error) { + return `Error withdrawing ETH: ${error instanceof Error ? error.message : String(error)}`; + } + } + + @CreateAction({ + name: "enable_allowance_module", + description: ` +Enables the allowance module for a Safe, allowing for token spending allowances. +Takes the following input: +- safeAddress: Address of the Safe + +Important notes: +- Requires an existing Safe +- 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(args: z.infer): Promise { + try { + const protocolKit = await Safe.init({ + provider: this.networkConfig.rpcUrl, + signer: this.privateKey, + safeAddress: args.safeAddress + }); + + const isSafeDeployed = await protocolKit.isSafeDeployed(); + if (!isSafeDeployed) { + throw new Error("Safe not deployed"); + } + + // Get allowance module address for current chain + // const chainId = baseSepolia.id.toString(); + const chainId = sepolia.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 protocolKit.isModuleEnabled(moduleAddress); + if (isAlreadyEnabled) { + return "Allowance module is already enabled for this Safe"; + } + + // Create and execute/propose transaction + const safeTransaction = await protocolKit.createEnableModuleTx(moduleAddress); + const currentThreshold = await protocolKit.getThreshold(); + + if (currentThreshold > 1) { + // Multi-sig flow: propose transaction + const safeTxHash = await protocolKit.getTransactionHash(safeTransaction); + const signature = await protocolKit.signHash(safeTxHash); + + await this.apiKit.proposeTransaction({ + safeAddress: args.safeAddress, + safeTransactionData: safeTransaction.data, + safeTxHash, + senderSignature: signature.data, + senderAddress: this.walletWithProvider.address + }); + + return `Successfully proposed enabling allowance module for Safe ${args.safeAddress}. Transaction hash: ${safeTxHash}. The other signers will need to confirm the transaction before it can be executed.`; + } else { + // Single-sig flow: execute immediately + const txResponse = await protocolKit.executeTransaction(safeTransaction); + console.log("txResponse: ", txResponse); + return `Successfully enabled allowance module for Safe ${args.safeAddress}`; + } + } catch (error) { + return `Error enabling allowance module: ${error instanceof Error ? error.message : String(error)}`; + } + } + + @CreateAction({ + name: "analyze_transaction", + description: ` +Analyzes a pending Safe transaction to explain what it does. +Takes the following inputs: +- safeAddress: Address of the Safe +- safeTxHash: Hash of the transaction to analyze + +Returns a detailed analysis including: +- Who proposed it and when +- Current confirmation status +- What the transaction will do if executed +- Any risk considerations +`, + schema: AnalyzeTransactionSchema, + }) + async analyzeTransaction(args: z.infer): Promise { + try { + // Get transaction details + const pendingTxs = await this.apiKit.getPendingTransactions(args.safeAddress); + const tx = pendingTxs.results.find(tx => tx.safeTxHash === args.safeTxHash); + console.log("tx: ", tx); + + if (!tx) { + return `Transaction ${args.safeTxHash} not found in pending transactions.`; + } + + // Analyze the transaction + const proposedAt = new Date(tx.submissionDate).toLocaleString(); + const confirmations = tx.confirmations?.length || 0; + const confirmationStatus = `${confirmations}/${tx.confirmationsRequired} confirmations`; + + // Get known addresses from delegates and common contracts + const knownAddresses = new Map(); + + // Add well-known contracts + const commonContracts: Record = { + "0x3E5c63644E683549055b9Be8653de26E0B4CD36E": "Safe Singleton v1.3.0", + "0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552": "Safe Singleton v1.3.0", + "0x69f4D1788e39c87893C980c06EdF4b7f686e2938": "Safe Allowance Module", + // Add more known contracts here + }; + + // Add common contracts to known addresses + Object.entries(commonContracts).forEach(([address, name]) => { + knownAddresses.set(address.toLowerCase(), name); + }); + + // Get delegates if any + try { + const delegates = await this.apiKit.getSafeDelegates({ + safeAddress: args.safeAddress, + limit: 100 + }); + + // Add delegates to known addresses + delegates.results.forEach(delegate => { + knownAddresses.set(delegate.delegate.toLowerCase(), `Delegate (added by ${delegate.delegator})`); + }); + } catch (error) { + console.error("Error fetching delegates:", error); + } + + // Helper function to get readable address + const getAddressName = (address: string) => knownAddresses.get(address.toLowerCase()) || address; + + // Update proposer and confirmations with known names + const proposerName = tx.proposer ? getAddressName(tx.proposer) : 'unknown'; + const confirmedBy = tx.confirmations?.map(c => getAddressName(c.owner)).join(', ') || 'none'; + + // Analyze transaction type and data + let actionDescription = "Unknown transaction type"; + if (tx.dataDecoded) { + console.log("tx.dataDecoded: ", tx.dataDecoded); + + const method = tx.dataDecoded.method; + const params = tx.dataDecoded.parameters; + + // Decode common transaction types + switch (method) { + case "addOwnerWithThreshold": + actionDescription = `Add new owner ${params?.[0].value} with threshold ${params?.[1].value}`; + break; + case "removeOwner": + actionDescription = `Remove owner ${params?.[1].value} and change threshold to ${params?.[2].value}`; + break; + case "changeThreshold": + actionDescription = `Change confirmation threshold to ${params?.[0].value}`; + break; + case "enableModule": + actionDescription = `Enable module at address ${params?.[0].value}`; + break; + default: + if (tx.value !== "0") { + actionDescription = `Transfer ${parseFloat(tx.value) / 1e18} ETH to ${tx.to}`; + } else { + actionDescription = `Call method '${method}' on contract ${tx.to}`; + } + } + } else if (tx.value !== "0") { + actionDescription = `Transfer ${parseFloat(tx.value) / 1e18} ETH to ${tx.to}`; + } + + // Add risk analysis section + const riskFactors: string[] = []; + + // 1. Check if this changes Safe configuration + if (tx.dataDecoded?.method.includes("Owner") || tx.dataDecoded?.method === "changeThreshold") { + riskFactors.push("⚠️ This transaction modifies Safe ownership/configuration"); + } + + // 2. Check value transfer risks + if (tx.value !== "0") { + const ethValue = parseFloat(tx.value) / 1e18; + const balance = await this.walletWithProvider.provider?.getBalance(args.safeAddress); + const safeBalance = balance ? parseFloat(balance.toString()) / 1e18 : 0; + + if (ethValue > safeBalance * 0.5) { + riskFactors.push(`⚠️ High-value transfer: ${ethValue} ETH (>${Math.round(ethValue/safeBalance*100)}% of Safe balance)`); + } + } + + // 3. Check destination address + if (tx.to) { + // Check if destination is a contract + const code = await this.walletWithProvider.provider?.getCode(tx.to); + const isContract = code && code !== "0x"; + + // Check if it's a known contract (could expand this list) + const knownContracts: Record = { + "0x...": "Uniswap V3", + // Add more known contracts + }; + + if (isContract && !knownContracts[tx.to.toLowerCase()]) { + riskFactors.push("⚠️ Interaction with unverified contract"); + } + } + + // 4. Check for unusual patterns + // if (tx.nonce > 0) { + // // Get previous transactions + // const allTxs = await this.apiKit.getAllTransactions(args.safeAddress); + // const previousTx = allTxs.results.find(t => t.nonce === tx.nonce - 1); + + // // Check for rapid sequence of transactions + // if (previousTx && + // new Date(tx.submissionDate).getTime() - new Date(previousTx.submissionDate).getTime() < 300000) { + // riskFactors.push("⚠️ Quick sequence of transactions (< 5 min apart)"); + // } + // } + + // 5. Check for complex contract interactions + if (tx.dataDecoded?.parameters) { + const paramCount = tx.dataDecoded.parameters.length; + if (paramCount > 3) { + riskFactors.push(`ℹ️ Complex transaction with ${paramCount} parameters`); + } + } + + // Add Etherscan contract verification check + const ETHERSCAN_API_KEY = process.env.ETHERSCAN_API_KEY; + if (tx.to && ETHERSCAN_API_KEY) { + try { + const baseUrl = this.networkConfig.chain === "ethereum-sepolia" + ? "https://api-sepolia.etherscan.io/api" + : this.networkConfig.chain === "base-sepolia" + ? "https://api-sepolia.basescan.org/api" + : "https://api.basescan.org/api"; + + const response = await fetch( + `${baseUrl}?module=contract&action=getsourcecode&address=${tx.to}&apikey=${ETHERSCAN_API_KEY}` + ); + const data = await response.json(); + + if (data.status === "1" && data.result[0]) { + if (!data.result[0].ContractName) { + riskFactors.push("⚠️ Contract not verified on Etherscan/Basescan"); + } else { + actionDescription += `\nContract Name: ${data.result[0].ContractName}`; + if (data.result[0].Implementation) { // Check if proxy + actionDescription += `\nImplementation: ${data.result[0].Implementation}`; + } + } + } + } catch (error) { + console.error("Etherscan API error:", error); + } + } + + // Add Tenderly simulation + try { + const networkId = this.networkConfig.chain === "ethereum-sepolia" + ? "11155111" + : this.networkConfig.chain === "base-sepolia" + ? "84532" + : "8453"; + + const simulationResult = await simulateWithTenderly(tx, networkId); + + if (!simulationResult.success) { + riskFactors.push(`⚠️ Transaction simulation failed: ${simulationResult.error || "Unknown error"}`); + } else { + const gasUsed = simulationResult.gasUsed + ? `\nEstimated gas usage: ${simulationResult.gasUsed}` + : ''; + actionDescription += `\nTransaction simulation successful.${gasUsed}`; + } + } catch (error) { + console.error("Tenderly simulation error:", error); + } + + // Get historical context + const allTxs = await this.apiKit.getAllTransactions(args.safeAddress); + console.log("allTxs: ", allTxs); + + // const similarTxs = allTxs.results.filter(pastTx => + // (pastTx.safeTxHash !== tx.safeTxHash) && // Don't include current tx + // (pastTx.to === tx.to || + // (pastTx.dataDecoded?.method === tx.dataDecoded?.method) || + // (pastTx.value === tx.value && tx.value !== "0")) + // ); + + // // Add historical analysis to risk factors + // if (similarTxs.length > 0) { + // const recentSimilarTxs = similarTxs.slice(0, 3); + // actionDescription += "\n\nSimilar Past Transactions:"; + // recentSimilarTxs.forEach(pastTx => { + // const pastTxDate = new Date(pastTx.submissionDate).toLocaleDateString(); + // actionDescription += `\n- ${pastTx.safeTxHash} (${pastTxDate})`; + // if (pastTx.isExecuted) { + // actionDescription += " ✓ Executed successfully"; + // } else if (pastTx.isSuccessful === false) { + // riskFactors.push("⚠️ Similar transaction has failed in the past"); + // } + // }); + // } else if (tx.value !== "0" || tx.dataDecoded) { + // // If this is a non-trivial transaction with no history + // riskFactors.push("ℹ️ No similar transactions found in Safe history"); + // } + + // Update destination analysis with known addresses + if (tx.to) { + const toName = getAddressName(tx.to); + if (toName !== tx.to) { + actionDescription = actionDescription.replace(tx.to, `${toName} (${tx.to})`); + } else if (!knownAddresses.has(tx.to.toLowerCase())) { + riskFactors.push("⚠️ Destination address not in Safe's address book"); + } + } + + // Build analysis report + return `Transaction Analysis for ${args.safeTxHash}: + +OVERVIEW +- Proposed by: ${proposerName} (${tx.proposer}) +- Proposed at: ${proposedAt} +- Status: ${tx.isExecuted ? 'Executed' : 'Pending'} (${confirmationStatus}) +- Confirmed by: ${confirmedBy} + +ACTION +${actionDescription} + +DETAILS +- To: ${getAddressName(tx.to)} (${tx.to}) +- Value: ${parseFloat(tx.value) / 1e18} ETH +- Nonce: ${tx.nonce} +${tx.dataDecoded ? `- Method: ${tx.dataDecoded.method}` : ''} + +RISK ANALYSIS +${riskFactors.length > 0 + ? riskFactors.join('\n') + : '✅ No significant risk factors identified'} + +CONFIRMATIONS NEEDED +${confirmations < tx.confirmationsRequired + ? `Needs ${tx.confirmationsRequired - confirmations} more confirmation(s) before it can be executed` + : 'Has enough confirmations and can be executed'}`; + + } catch (error) { + return `Error analyzing transaction: ${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 safeActionProvider = (config?: SafeActionProviderConfig) => + new SafeActionProvider(config); 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..06a7fa90d --- /dev/null +++ b/typescript/agentkit/src/action-providers/safe/schemas.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; + +export const InitializeSafeSchema = z.object({ + signers: z.array(z.string()).describe("Array of additional signer addresses for the Safe").default([]), + threshold: z.number().min(1).describe("Number of required confirmations").default(1) +}); + +export const SafeInfoSchema = z.object({ + safeAddress: z.string().describe("Address of the existing Safe to connect to") +}); + +export const AddSignerSchema = z.object({ + safeAddress: z.string().describe("Address of the Safe to modify"), + newSigner: z.string().describe("Address of the new signer to add"), + newThreshold: z.number().optional().describe("Optional new threshold after adding signer") +}); + +export const RemoveSignerSchema = z.object({ + safeAddress: z.string().describe("Address of the Safe to modify"), + signerToRemove: z.string().describe("Address of the signer to remove"), + newThreshold: z.number().optional().describe("Optional new threshold after removing signer") +}); + +export const ChangeThresholdSchema = z.object({ + safeAddress: z.string().describe("Address of the Safe to modify"), + newThreshold: z.number().min(1).describe("New threshold value") +}); + +export const ExecutePendingSchema = z.object({ + safeAddress: z.string().describe("Address of the Safe"), + safeTxHash: z.string().optional().describe("Optional specific transaction hash to execute. If not provided, will try to execute all pending transactions") +}); + +export const WithdrawFromSafeSchema = z.object({ + safeAddress: z.string().describe("Address of the Safe"), + recipientAddress: z.string().describe("Address to receive the ETH"), + amount: z.string().optional().describe("Amount of ETH to withdraw (e.g. '0.1'). If not provided, withdraws entire balance") +}); + +export const EnableAllowanceModuleSchema = z.object({ + safeAddress: z.string().describe("Address of the Safe to enable allowance module for") +}); + +export const AnalyzeTransactionSchema = z.object({ + safeAddress: z.string().describe("Address of the Safe"), + safeTxHash: z.string().describe("Hash of the transaction to analyze") +}); diff --git a/typescript/agentkit/src/action-providers/safe/utils.ts b/typescript/agentkit/src/action-providers/safe/utils.ts new file mode 100644 index 000000000..e69de29bb diff --git a/typescript/agentkit/src/wallet-providers/cdpWalletProvider.ts b/typescript/agentkit/src/wallet-providers/cdpWalletProvider.ts index bd704a13a..8f5542b3d 100644 --- a/typescript/agentkit/src/wallet-providers/cdpWalletProvider.ts +++ b/typescript/agentkit/src/wallet-providers/cdpWalletProvider.ts @@ -275,7 +275,7 @@ export class CdpWalletProvider extends EvmWalletProvider { } const preparedTransaction = await this.prepareTransaction( - transaction.to!, + transaction.to! as `0x${string}`, transaction.value!, transaction.data!, ); @@ -556,6 +556,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/examples/langchain-cdp-chatbot/chatbot.ts b/typescript/examples/langchain-cdp-chatbot/chatbot.ts index 182c82517..15c6b0b63 100644 --- a/typescript/examples/langchain-cdp-chatbot/chatbot.ts +++ b/typescript/examples/langchain-cdp-chatbot/chatbot.ts @@ -10,6 +10,8 @@ import { pythActionProvider, openseaActionProvider, alloraActionProvider, + safeActionProvider, + ViemWalletProvider, } from "@coinbase/agentkit"; import { getLangChainTools } from "@coinbase/agentkit-langchain"; import { HumanMessage } from "@langchain/core/messages"; @@ -20,6 +22,12 @@ import * as dotenv from "dotenv"; import * as fs from "fs"; import * as readline from "readline"; +import { + WalletClient as ViemWalletClient, +} from "viem"; +import { createWalletClient, http } from 'viem'; +import { sepolia } from 'viem/chains'; + dotenv.config(); /** @@ -93,10 +101,18 @@ async function initializeAgent() { }; const walletProvider = await CdpWalletProvider.configureWithWallet(config); + // console.log("getPublicClient: ", walletProvider.getPublicClient()); + + // const walletClient = createWalletClient({ + // account: await (await walletProvider.getWallet().getDefaultAddress()).export() as `0x${string}`, + // chain: sepolia, + // transport: http(), + // }); + // const viemWalletProvider = new ViemWalletProvider(walletClient); // Initialize AgentKit const agentkit = await AgentKit.from({ - walletProvider, + walletProvider: walletProvider, actionProviders: [ wethActionProvider(), pythActionProvider(), @@ -122,6 +138,12 @@ async function initializeAgent() { ] : []), alloraActionProvider(), + safeActionProvider( + { + networkId: walletProvider.getNetwork().networkId, + privateKey: await (await walletProvider.getWallet().getDefaultAddress()).export(), + } + ), ], }); @@ -288,13 +310,13 @@ async function chooseMode(): Promise<"chat" | "auto"> { async function main() { try { const { agent, config } = await initializeAgent(); - const mode = await chooseMode(); + // const mode = await chooseMode(); - if (mode === "chat") { + // if (mode === "chat") { await runChatMode(agent, config); - } else { - await runAutonomousMode(agent, config); - } + // } else { + // await runAutonomousMode(agent, config); + // } } catch (error) { if (error instanceof Error) { console.error("Error:", error.message); diff --git a/typescript/package-lock.json b/typescript/package-lock.json index 4125916ff..c7f8c9918 100644 --- a/typescript/package-lock.json +++ b/typescript/package-lock.json @@ -3039,6 +3039,64 @@ "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", "dev": true }, + "node_modules/@safe-global/api-kit": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/@safe-global/api-kit/-/api-kit-2.5.8.tgz", + "integrity": "sha512-+509+k/QAkqbCdR3XpD46b2Qy7gxKTeY+buNGzd35KgS+LaFbgj4b4fDH9xYgEXEFRlDAxmCmwAi8bc+9+ByOA==", + "dependencies": { + "@safe-global/protocol-kit": "^5.2.1", + "@safe-global/types-kit": "^1.0.2", + "node-fetch": "^2.7.0", + "viem": "^2.21.8" + } + }, + "node_modules/@safe-global/protocol-kit": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@safe-global/protocol-kit/-/protocol-kit-5.2.1.tgz", + "integrity": "sha512-7jY+p2+GQU9FPKMMgHDr32gPC/1BKtYVzvQHniZuPSJVcb26E0CBwZb7IUyRiUd+kJN8OF13zRFbhQdH99Akhw==", + "dependencies": { + "@safe-global/safe-deployments": "^1.37.26", + "@safe-global/safe-modules-deployments": "^2.2.5", + "@safe-global/types-kit": "^1.0.2", + "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-core-sdk-types": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@safe-global/safe-core-sdk-types/-/safe-core-sdk-types-5.1.0.tgz", + "integrity": "sha512-UzXR4zWmVzux25FcIm4H049QhZZpVpIBL5HE+V0p5gHpArZROL+t24fZmsKUf403CtBxIJM5zZSVQL0nFJi+IQ==", + "deprecated": "WARNING: This project has been renamed to @safe-global/types-kit. Please, migrate from @safe-global/safe-core-sdk-types@5.1.0 to @safe-global/types-kit@1.0.0.", + "dependencies": { + "abitype": "^1.0.2" + } + }, + "node_modules/@safe-global/safe-deployments": { + "version": "1.37.27", + "resolved": "https://registry.npmjs.org/@safe-global/safe-deployments/-/safe-deployments-1.37.27.tgz", + "integrity": "sha512-0r/BzntT/XUPlca11obF1Zog1m8jLSGVbb97FqxnjaEzqWf+N7dBwAYJ9Voq3e7hlqpcVLMh3O6VDAf4y+5ndg==", + "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.2", + "resolved": "https://registry.npmjs.org/@safe-global/types-kit/-/types-kit-1.0.2.tgz", + "integrity": "sha512-KiRlU1nlWuj4Qr+nLxgO/yRpJamVUOonnAkLrSMrzfGwXcfLWXJJtoD9sv/pvTIHMWSOlzgkrc1KXqY3K68SGg==", + "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", @@ -10914,6 +10972,24 @@ } ] }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "optional": true, + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz", + "integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==", + "optional": true, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -13528,6 +13604,9 @@ "@privy-io/server-auth": "^1.18.4", "@solana/spl-token": "^0.4.12", "@solana/web3.js": "^1.98.0", + "@safe-global/api-kit": "^2.5.8", + "@safe-global/protocol-kit": "^5.2.1", + "@safe-global/safe-core-sdk-types": "^5.1.0", "md5": "^2.3.0", "opensea-js": "^7.1.16", "reflect-metadata": "^0.2.2", @@ -14479,6 +14558,9 @@ "@privy-io/server-auth": "^1.18.4", "@solana/spl-token": "^0.4.12", "@solana/web3.js": "^1.98.0", + "@safe-global/api-kit": "^2.5.8", + "@safe-global/protocol-kit": "^5.2.1", + "@safe-global/safe-core-sdk-types": "^5.1.0", "@types/jest": "^29.5.14", "@types/nunjucks": "^3.2.6", "@types/ora": "^3.2.0", @@ -15714,6 +15796,61 @@ "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", "dev": true }, + "@safe-global/api-kit": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/@safe-global/api-kit/-/api-kit-2.5.8.tgz", + "integrity": "sha512-+509+k/QAkqbCdR3XpD46b2Qy7gxKTeY+buNGzd35KgS+LaFbgj4b4fDH9xYgEXEFRlDAxmCmwAi8bc+9+ByOA==", + "requires": { + "@safe-global/protocol-kit": "^5.2.1", + "@safe-global/types-kit": "^1.0.2", + "node-fetch": "^2.7.0", + "viem": "^2.21.8" + } + }, + "@safe-global/protocol-kit": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@safe-global/protocol-kit/-/protocol-kit-5.2.1.tgz", + "integrity": "sha512-7jY+p2+GQU9FPKMMgHDr32gPC/1BKtYVzvQHniZuPSJVcb26E0CBwZb7IUyRiUd+kJN8OF13zRFbhQdH99Akhw==", + "requires": { + "@noble/curves": "^1.6.0", + "@peculiar/asn1-schema": "^2.3.13", + "@safe-global/safe-deployments": "^1.37.26", + "@safe-global/safe-modules-deployments": "^2.2.5", + "@safe-global/types-kit": "^1.0.2", + "abitype": "^1.0.2", + "semver": "^7.6.3", + "viem": "^2.21.8" + } + }, + "@safe-global/safe-core-sdk-types": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@safe-global/safe-core-sdk-types/-/safe-core-sdk-types-5.1.0.tgz", + "integrity": "sha512-UzXR4zWmVzux25FcIm4H049QhZZpVpIBL5HE+V0p5gHpArZROL+t24fZmsKUf403CtBxIJM5zZSVQL0nFJi+IQ==", + "requires": { + "abitype": "^1.0.2" + } + }, + "@safe-global/safe-deployments": { + "version": "1.37.27", + "resolved": "https://registry.npmjs.org/@safe-global/safe-deployments/-/safe-deployments-1.37.27.tgz", + "integrity": "sha512-0r/BzntT/XUPlca11obF1Zog1m8jLSGVbb97FqxnjaEzqWf+N7dBwAYJ9Voq3e7hlqpcVLMh3O6VDAf4y+5ndg==", + "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.2", + "resolved": "https://registry.npmjs.org/@safe-global/types-kit/-/types-kit-1.0.2.tgz", + "integrity": "sha512-KiRlU1nlWuj4Qr+nLxgO/yRpJamVUOonnAkLrSMrzfGwXcfLWXJJtoD9sv/pvTIHMWSOlzgkrc1KXqY3K68SGg==", + "requires": { + "abitype": "^1.0.2" + } + }, "@scure/base": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.4.tgz", @@ -21259,6 +21396,21 @@ "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", "dev": true }, + "pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "optional": true, + "requires": { + "tslib": "^2.8.1" + } + }, + "pvutils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz", + "integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==", + "optional": true + }, "qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", From 240c4fdedeb91de772a24f527a67b67bceedf9d3 Mon Sep 17 00:00:00 2001 From: Philippe d'Argent Date: Mon, 10 Feb 2025 13:49:19 +0900 Subject: [PATCH 2/9] improved wallet provider integration --- .../safe/safeActionProvider.test.ts | 209 +++++ .../safe/safeActionProvider.ts | 860 ++++++------------ .../src/action-providers/safe/schemas.ts | 34 +- .../src/action-providers/safe/utils.ts | 40 + .../src/wallet-providers/evmWalletProvider.ts | 9 + .../wallet-providers/viemWalletProvider.ts | 9 + .../examples/langchain-cdp-chatbot/chatbot.ts | 25 +- 7 files changed, 583 insertions(+), 603 deletions(-) diff --git a/typescript/agentkit/src/action-providers/safe/safeActionProvider.test.ts b/typescript/agentkit/src/action-providers/safe/safeActionProvider.test.ts index e69de29bb..0e0728eb4 100644 --- a/typescript/agentkit/src/action-providers/safe/safeActionProvider.test.ts +++ b/typescript/agentkit/src/action-providers/safe/safeActionProvider.test.ts @@ -0,0 +1,209 @@ +import { SafeActionProvider } from "./safeActionProvider"; +import { EvmWalletProvider } from "../../wallet-providers"; +import Safe from "@safe-global/protocol-kit"; +import SafeApiKit from "@safe-global/api-kit"; +import { waitForTransactionReceipt } from "viem/actions"; +import { SafeTransaction } from "@safe-global/safe-core-sdk-types"; + +jest.mock("@safe-global/protocol-kit"); +jest.mock("@safe-global/api-kit"); +jest.mock("viem/actions"); + +describe("Safe Action Provider", () => { + const MOCK_PRIVATE_KEY = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; + const MOCK_NETWORK_ID = "base-sepolia"; + const MOCK_SAFE_ADDRESS = "0x1234567890123456789012345678901234567890"; + const MOCK_SIGNER_ADDRESS = "0x2345678901234567890123456789012345678901"; + const MOCK_TX_HASH = "0xabcdef1234567890abcdef1234567890"; + + const MOCK_TRANSACTION = { + data: { + to: MOCK_SAFE_ADDRESS, + value: "0", + data: "0x", + }, + signatures: new Map(), + getSignature: jest.fn(), + addSignature: jest.fn(), + encodedSignatures: jest.fn(), + } as unknown as SafeTransaction; + + const MOCK_TX_RESULT = { + hash: MOCK_TX_HASH, + isExecuted: true, + transactionResponse: { hash: MOCK_TX_HASH }, + }; + + let actionProvider: SafeActionProvider; + let mockWallet: jest.Mocked; + let mockSafeSDK: jest.Mocked; + let mockApiKit: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + + mockWallet = { + getAddress: jest.fn().mockReturnValue(MOCK_SIGNER_ADDRESS), + getNetwork: jest.fn().mockReturnValue({ protocolFamily: "evm", networkId: MOCK_NETWORK_ID }), + getPublicClient: jest.fn().mockReturnValue({ + transport: {}, + chain: { + blockExplorers: { + default: { url: "https://sepolia.basescan.org" }, + }, + }, + getBalance: jest.fn().mockResolvedValue(BigInt(1000000000000000000)), // 1 ETH + }), + } as unknown as jest.Mocked; + + const mockExternalSigner = { + sendTransaction: jest.fn().mockResolvedValue(MOCK_TX_HASH), + }; + + const mockReceipt = { + transactionHash: MOCK_TX_HASH, + status: 1, + blockNumber: 123456, + }; + + (waitForTransactionReceipt as jest.Mock).mockResolvedValue(mockReceipt); + + mockSafeSDK = { + getAddress: jest.fn().mockResolvedValue(MOCK_SAFE_ADDRESS), + getOwners: jest.fn().mockResolvedValue([MOCK_SIGNER_ADDRESS]), + getThreshold: jest.fn().mockResolvedValue(1), + createTransaction: jest.fn().mockResolvedValue(MOCK_TRANSACTION), + getTransactionHash: jest.fn(), + signHash: jest.fn().mockResolvedValue({ + data: "0x", + signer: MOCK_SIGNER_ADDRESS, + isContractSignature: false, + staticPart: () => "0x", + dynamicPart: () => "0x", + }), + executeTransaction: jest.fn().mockResolvedValue(MOCK_TX_RESULT), + connect: jest.fn().mockReturnThis(), + isSafeDeployed: jest.fn().mockResolvedValue(true), + createSafeDeploymentTransaction: jest.fn().mockResolvedValue({ + to: MOCK_SAFE_ADDRESS, + value: "0", + data: "0x", + }), + getSafeProvider: jest.fn().mockReturnValue({ + getExternalSigner: jest.fn().mockResolvedValue(mockExternalSigner), + }), + } as unknown as jest.Mocked; + + (Safe.init as jest.Mock).mockResolvedValue(mockSafeSDK); + + mockApiKit = { + getPendingTransactions: jest.fn().mockResolvedValue({ results: [], count: 0 }), + proposeTransaction: jest.fn(), + getTransaction: jest.fn().mockResolvedValue({ + safe: MOCK_SAFE_ADDRESS, + to: MOCK_SAFE_ADDRESS, + data: "0x", + value: "0", + operation: 0, + nonce: 0, + safeTxGas: 0, + baseGas: 0, + gasPrice: "0", + gasToken: "0x0000000000000000000000000000000000000000", + refundReceiver: "0x0000000000000000000000000000000000000000", + submissionDate: new Date().toISOString(), + executionDate: new Date().toISOString(), + modified: new Date().toISOString(), + transactionHash: MOCK_TX_HASH, + isExecuted: true, + isSuccessful: true, + safeTxHash: MOCK_TX_HASH, + confirmationsRequired: 1, + confirmations: [ + { + owner: MOCK_SIGNER_ADDRESS, + signature: "0x", + signatureType: "EOA", + submissionDate: new Date().toISOString(), + }, + ], + }), + } as unknown as jest.Mocked; + + (SafeApiKit as unknown as jest.Mock).mockImplementation(() => mockApiKit); + + actionProvider = new SafeActionProvider({ + privateKey: MOCK_PRIVATE_KEY, + networkId: MOCK_NETWORK_ID, + }); + }); + + describe("initializeSafe", () => { + it("should successfully create a new Safe", async () => { + const args = { + signers: ["0x3456789012345678901234567890123456789012"], + threshold: 2, + }; + + const response = await actionProvider.initializeSafe(mockWallet, args); + + expect(Safe.init).toHaveBeenCalled(); + expect(response).toContain("Successfully created Safe"); + expect(response).toContain(MOCK_SAFE_ADDRESS); + }); + + it("should handle Safe creation errors", async () => { + const args = { + signers: ["0x3456789012345678901234567890123456789012"], + threshold: 2, + }; + + (Safe.init as jest.Mock).mockRejectedValue(new Error("Failed to create Safe")); + + const response = await actionProvider.initializeSafe(mockWallet, args); + + expect(response).toContain("Error creating Safe"); + }); + }); + + describe("safeInfo", () => { + it("should successfully get Safe info", async () => { + const args = { + safeAddress: MOCK_SAFE_ADDRESS, + }; + + const response = await actionProvider.safeInfo(mockWallet, args); + + expect(response).toContain(MOCK_SAFE_ADDRESS); + expect(response).toContain("owners:"); + expect(response).toContain("Threshold:"); + }); + + it("should handle Safe info errors", async () => { + const args = { + safeAddress: MOCK_SAFE_ADDRESS, + }; + + mockSafeSDK.getOwners.mockRejectedValue(new Error("Failed to get owners")); + + const response = await actionProvider.safeInfo(mockWallet, args); + + expect(response).toContain("Error connecting to Safe"); + }); + }); + + describe("supportsNetwork", () => { + it("should return true for EVM networks", () => { + const result = actionProvider.supportsNetwork({ protocolFamily: "evm", networkId: "any" }); + expect(result).toBe(true); + }); + + it("should return false for non-EVM networks", () => { + const result = actionProvider.supportsNetwork({ + protocolFamily: "bitcoin", + networkId: "any", + }); + expect(result).toBe(false); + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/safe/safeActionProvider.ts b/typescript/agentkit/src/action-providers/safe/safeActionProvider.ts index b8308dfe3..cddd0f2de 100644 --- a/typescript/agentkit/src/action-providers/safe/safeActionProvider.ts +++ b/typescript/agentkit/src/action-providers/safe/safeActionProvider.ts @@ -1,17 +1,28 @@ import { z } from "zod"; import { ActionProvider } from "../actionProvider"; import { CreateAction } from "../actionDecorator"; -import { InitializeSafeSchema, SafeInfoSchema, AddSignerSchema, RemoveSignerSchema, ChangeThresholdSchema, ExecutePendingSchema, WithdrawFromSafeSchema, EnableAllowanceModuleSchema, AnalyzeTransactionSchema } from "./schemas"; -import { Wallet, JsonRpcProvider } from "ethers"; +import { + InitializeSafeSchema, + SafeInfoSchema, + AddSignerSchema, + RemoveSignerSchema, + ChangeThresholdSchema, + ExecutePendingSchema, + WithdrawFromSafeSchema, + EnableAllowanceModuleSchema, +} from "./schemas"; import { Network } from "../../network"; -import { base, baseSepolia, sepolia } from "viem/chains"; -import Safe, { PredictedSafeProps } from '@safe-global/protocol-kit' -import SafeApiKit from '@safe-global/api-kit' -import { getAllowanceModuleDeployment } from '@safe-global/safe-modules-deployments' -import { EvmWalletProvider } from "../../wallet-providers/evmWalletProvider"; import { NETWORK_ID_TO_VIEM_CHAIN } from "../../network/network"; +import { EvmWalletProvider } from "../../wallet-providers/evmWalletProvider"; + +import { Chain } from "viem/chains"; import { waitForTransactionReceipt } from "viem/actions"; +import Safe, { PredictedSafeProps } from "@safe-global/protocol-kit"; +import SafeApiKit from "@safe-global/api-kit"; +import { getAllowanceModuleDeployment } from "@safe-global/safe-modules-deployments"; +import { initializeClientIfNeeded } from "./utils"; + /** * Configuration options for the SafeActionProvider. */ @@ -27,77 +38,15 @@ export interface SafeActionProviderConfig { networkId?: string; } -/** - * NetworkConfig is the configuration for network-specific settings. - */ -interface NetworkConfig { - rpcUrl: string; - chain: string; -} - -interface TenderlySimulationResponse { - success: boolean; - error?: string; - gasUsed?: string; - logs?: Array<{ - name: string; - topics: string[]; - }>; -} - -async function simulateWithTenderly(tx: any, network: string): Promise { - const TENDERLY_USER = process.env.TENDERLY_USER; - const TENDERLY_PROJECT = process.env.TENDERLY_PROJECT; - const TENDERLY_ACCESS_KEY = process.env.TENDERLY_ACCESS_KEY; - - if (!TENDERLY_USER || !TENDERLY_PROJECT || !TENDERLY_ACCESS_KEY) { - return { success: false, error: "Tenderly credentials not configured" }; - } - - try { - const response = await fetch( - `https://api.tenderly.co/api/v1/account/${TENDERLY_USER}/project/${TENDERLY_PROJECT}/simulate`, - { - method: "POST", - headers: { - "X-Access-Key": TENDERLY_ACCESS_KEY, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - network_id: network, - from: tx.proposer, - to: tx.to, - input: tx.data, - value: tx.value, - save: true, - }), - } - ); - - const result = await response.json(); - return { - success: result.simulation.status, - gasUsed: result.simulation.gas_used, - logs: result.simulation.logs, - error: result.simulation.error_message - }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : "Simulation failed" - }; - } -} - /** * SafeActionProvider is an action provider for Safe smart account interactions. */ export class SafeActionProvider extends ActionProvider { - private walletWithProvider: Wallet; - private safeClient: Safe | null = null; - private readonly networkConfig: NetworkConfig; private readonly privateKey: string; + private readonly chain: Chain; private apiKit: SafeApiKit; + private safeClient: Safe | null = null; + private safeBaseUrl: string; /** * Constructor for the SafeActionProvider class. @@ -107,42 +56,26 @@ export class SafeActionProvider extends ActionProvider { constructor(config: SafeActionProviderConfig = {}) { super("safe", []); - // Get Viem chain object for the network - const viemChain = NETWORK_ID_TO_VIEM_CHAIN[config.networkId || "base-sepolia"]; - if (!viemChain) { - throw new Error(`Unsupported network: ${config.networkId}`); - } - console.log("viemChain: ", viemChain); - - // Use Viem chain's RPC URL and name - this.networkConfig = { - rpcUrl: viemChain.rpcUrls.default.http[0], - chain: config.networkId || "base-sepolia" - }; - console.log("networkConfig: ", this.networkConfig); - // if (!this.supportsNetwork({ networkId: this.networkConfig.chain })) { - // throw new Error("Unsupported network. Only base-sepolia, ethereum-sepolia, and base-mainnet are supported."); - // } - - const privateKey = config.privateKey || process.env.WALLET_PRIVATE_KEY; - if (!privateKey) { - throw new Error("Private key is not configured"); - } + // Initialize chain + this.chain = NETWORK_ID_TO_VIEM_CHAIN[config.networkId || "base-sepolia"]; + if (!this.chain) throw new Error(`Unsupported network: ${config.networkId}`); - const provider = new JsonRpcProvider(this.networkConfig.rpcUrl); - const walletWithProvider = new Wallet(privateKey, provider); - this.walletWithProvider = walletWithProvider; + // Initialize private key + const privateKey = config.privateKey; + if (!privateKey) throw new Error("Private key is not configured"); this.privateKey = privateKey; // Initialize apiKit with chain ID from Viem chain this.apiKit = new SafeApiKit({ - chainId: BigInt(viemChain.id) + chainId: BigInt(this.chain.id), }); + this.safeBaseUrl = "https://app.safe.global/"; } /** * Initializes a new Safe smart account. * + * @param walletProvider - The wallet provider to create the Safe. * @param args - The input arguments for creating a Safe. * @returns A message containing the Safe creation details. */ @@ -161,62 +94,64 @@ Important notes: `, schema: InitializeSafeSchema, }) - async initializeSafe(args: z.infer): Promise { - + async initializeSafe( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { try { - const predictedSafe: PredictedSafeProps = { - safeAccountConfig: { - owners: [this.walletWithProvider.address], - threshold: 1 - }, - safeDeploymentConfig: { - saltNonce: BigInt(Date.now()).toString() - } - } - - let protocolKit = await Safe.init({ - provider: this.networkConfig.rpcUrl, - signer: this.privateKey, - predictedSafe - }); + const predictedSafe: PredictedSafeProps = { + safeAccountConfig: { + owners: [walletProvider.getAddress(), ...args.signers], + threshold: args.threshold, + }, + safeDeploymentConfig: { + saltNonce: BigInt(Date.now()).toString(), + }, + }; - const safeAddress = await protocolKit.getAddress(); - console.log("safeAddress", safeAddress); + let safeClient = await Safe.init({ + provider: walletProvider.getPublicClient().transport, + signer: this.privateKey, + predictedSafe, + }); - const deploymentTransaction = await protocolKit.createSafeDeploymentTransaction() + const safeAddress = await safeClient.getAddress(); + const deploymentTransaction = await safeClient.createSafeDeploymentTransaction(); - // Execute transaction - const client = await protocolKit.getSafeProvider().getExternalSigner() - const txHash = await client?.sendTransaction({ - to: deploymentTransaction.to, - value: BigInt(deploymentTransaction.value), - data: deploymentTransaction.data as `0x${string}`, - chain: this.networkConfig.chain === "base-sepolia" ? baseSepolia : this.networkConfig.chain === "ethereum-sepolia" ? sepolia : base - }) - console.log("txHash: ", txHash); - - const address = client!.account.address - const txReceipt = await waitForTransactionReceipt(client!, { hash: txHash! }); - console.log("txReceipt: ", txReceipt); - - // Reconnect to newly deployed Safe - protocolKit = await protocolKit.connect({ safeAddress }) - this.safeClient = protocolKit; - - console.log('Is Safe deployed:', await protocolKit.isSafeDeployed()) - console.log('Safe Address:', await protocolKit.getAddress()) - console.log('Safe Owners:', await protocolKit.getOwners()) - console.log('Safe Threshold:', await protocolKit.getThreshold()) - - return `Successfully created Safe at address ${safeAddress} with signers ${args.signers} and threshold of ${args.threshold}.`; + // Execute transaction + const client = await safeClient.getSafeProvider().getExternalSigner(); + const txHash = await client?.sendTransaction({ + to: deploymentTransaction.to, + value: BigInt(deploymentTransaction.value), + data: deploymentTransaction.data as `0x${string}`, + chain: this.chain, + }); + const txReceipt = await waitForTransactionReceipt(client!, { hash: txHash! }); + const txLink = `${walletProvider.getPublicClient().chain?.blockExplorers?.default.url}/tx/${txReceipt.transactionHash}`; + + // Reconnect to newly deployed Safe + safeClient = await safeClient.connect({ safeAddress }); + + if (await safeClient.isSafeDeployed()) { + this.safeClient = safeClient; + + const safeAddress = await safeClient.getAddress(); + const safeOwners = await safeClient.getOwners(); + const safeThreshold = await safeClient.getThreshold(); + + return `Successfully created Safe at address ${safeAddress} with signers ${safeOwners} and threshold of ${safeThreshold}. Transaction link: ${txLink}. Safe dashboard link: ${this.safeBaseUrl}/home?safe=${safeAddress}`; + } else { + return `Error creating Safe`; + } } catch (error) { - return `Error creating Safe: ${error instanceof Error ? error.message : String(error)}`; + return `Error creating Safe: ${error instanceof Error ? error.message : String(error)}`; } } /** * Connects to an existing Safe smart account. * + * @param walletProvider - The wallet provider to connect to the Safe. * @param args - The input arguments for connecting to a Safe. * @returns A message containing the connection details. */ @@ -232,35 +167,42 @@ Important notes: `, schema: SafeInfoSchema, }) - async safeInfo(args: z.infer): Promise { + async safeInfo( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { try { - const protocolKit = await Safe.init({ - provider: this.networkConfig.rpcUrl, - signer: this.privateKey, - safeAddress: args.safeAddress - }); - - const owners = await protocolKit.getOwners(); - const threshold = await protocolKit.getThreshold(); + // Connect to Safe client + this.safeClient = await initializeClientIfNeeded( + this.safeClient, + args.safeAddress, + walletProvider.getPublicClient().transport, + this.privateKey, + ); + + // Get Safe info + const owners = await this.safeClient.getOwners(); + const threshold = await this.safeClient.getThreshold(); const pendingTransactions = await this.apiKit.getPendingTransactions(args.safeAddress); - const balance = await this.walletWithProvider.provider?.getBalance(args.safeAddress); - const ethBalance = balance ? parseFloat(balance.toString()) / 1e18 : 0; - - console.log("pendingTransactions: ", pendingTransactions); + const balance = await walletProvider + .getPublicClient() + .getBalance({ address: args.safeAddress }); + const ethBalance = balance ? parseFloat(balance.toString()) / 1e18 : 0; + // Get pending transactions 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'; + const confirmedBy = tx.confirmations?.map(c => c.owner).join(", ") || "none"; return `\n- Transaction ${tx.safeTxHash} (${confirmations}/${needed} confirmations, confirmed by: ${confirmedBy})`; }) - .join(''); + .join(""); return `Safe at address ${args.safeAddress}: - Balance: ${ethBalance.toFixed(5)} ETH -- ${owners.length} owners: ${owners.join(', ')} +- ${owners.length} owners: ${owners.join(", ")} - Threshold: ${threshold} - Pending transactions: ${pendingTransactions.count}${pendingTxDetails}`; } catch (error) { @@ -268,6 +210,13 @@ Important notes: } } + /** + * Adds a new signer to an existing Safe. + * + * @param walletProvider - The wallet provider to connect to the Safe. + * @param args - The input arguments for adding a signer. + * @returns A message containing the signer addition details. + */ @CreateAction({ name: "add_signer", description: ` @@ -285,45 +234,45 @@ Important notes: `, schema: AddSignerSchema, }) - async addSigner(args: z.infer): Promise { + async addSigner( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { try { - // Connect to existing safe - const protocolKit = await Safe.init({ - provider: this.networkConfig.rpcUrl, - signer: this.privateKey, - safeAddress: args.safeAddress - }); + // Connect to Safe client + this.safeClient = await initializeClientIfNeeded( + this.safeClient, + args.safeAddress, + walletProvider.getPublicClient().transport, + this.privateKey, + ); + + // Get current threshold + const currentThreshold = await this.safeClient.getThreshold(); - // Get current signers and threshold - const currentSigners = await protocolKit.getOwners(); - const currentThreshold = await protocolKit.getThreshold(); - // Add new signer - const safeTransaction = await protocolKit.createAddOwnerTx({ + const safeTransaction = await this.safeClient.createAddOwnerTx({ ownerAddress: args.newSigner, - threshold: args.newThreshold || currentThreshold + threshold: args.newThreshold || currentThreshold, }); if (currentThreshold > 1) { // Multi-sig flow: propose transaction - const safeTxHash = await protocolKit.getTransactionHash(safeTransaction); - const signature = await protocolKit.signHash(safeTxHash); + const safeTxHash = await this.safeClient.getTransactionHash(safeTransaction); + const signature = await this.safeClient.signHash(safeTxHash); - const txResponse = await this.apiKit.proposeTransaction({ + await this.apiKit.proposeTransaction({ safeAddress: args.safeAddress, safeTransactionData: safeTransaction.data, safeTxHash, senderSignature: signature.data, - senderAddress: this.walletWithProvider.address + senderAddress: walletProvider.getAddress(), }); - return `Successfully proposed adding signer ${args.newSigner} to Safe ${args.safeAddress}. Transaction hash: ${safeTxHash}. The other signers will need to confirm the transaction before it can be executed.`; - } - - else { + return `Successfully proposed adding signer ${args.newSigner} to Safe ${args.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 txResponse = await protocolKit.executeTransaction(safeTransaction); - console.log("txResponse: ", txResponse); + await this.safeClient.executeTransaction(safeTransaction); return `Successfully added signer ${args.newSigner} to Safe ${args.safeAddress}.`; } } catch (error) { @@ -331,6 +280,13 @@ Important notes: } } + /** + * Removes a signer from an existing Safe. + * + * @param walletProvider - The wallet provider to connect to the Safe. + * @param args - The input arguments for removing a signer. + * @returns A message containing the signer removal details. + */ @CreateAction({ name: "remove_signer", description: ` @@ -348,44 +304,52 @@ Important notes: `, schema: RemoveSignerSchema, }) - async removeSigner(args: z.infer): Promise { + async removeSigner( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { try { - const protocolKit = await Safe.init({ - provider: this.networkConfig.rpcUrl, - signer: this.privateKey, - safeAddress: args.safeAddress - }); + // Connect to Safe client + this.safeClient = await initializeClientIfNeeded( + this.safeClient, + args.safeAddress, + walletProvider.getPublicClient().transport, + this.privateKey, + ); + + const currentSigners = await this.safeClient.getOwners(); + const currentThreshold = await this.safeClient.getThreshold(); - const currentSigners = await protocolKit.getOwners(); - const currentThreshold = await protocolKit.getThreshold(); - if (currentSigners.length <= 1) { throw new Error("Cannot remove the last signer"); } - const safeTransaction = await protocolKit.createRemoveOwnerTx({ + const safeTransaction = await this.safeClient.createRemoveOwnerTx({ ownerAddress: args.signerToRemove, - threshold: args.newThreshold || (currentThreshold > currentSigners.length - 1 ? currentSigners.length - 1 : currentThreshold) + threshold: + args.newThreshold || + (currentThreshold > currentSigners.length - 1 + ? currentSigners.length - 1 + : currentThreshold), }); if (currentThreshold > 1) { // Multi-sig flow: propose transaction - const safeTxHash = await protocolKit.getTransactionHash(safeTransaction); - const signature = await protocolKit.signHash(safeTxHash); - + const safeTxHash = await this.safeClient.getTransactionHash(safeTransaction); + const signature = await this.safeClient.signHash(safeTxHash); + await this.apiKit.proposeTransaction({ safeAddress: args.safeAddress, safeTransactionData: safeTransaction.data, safeTxHash, senderSignature: signature.data, - senderAddress: this.walletWithProvider.address + senderAddress: walletProvider.getAddress(), }); - return `Successfully proposed removing signer ${args.signerToRemove} from Safe ${args.safeAddress}. Transaction hash: ${safeTxHash}. The other signers will need to confirm the transaction before it can be executed.`; + return `Successfully proposed removing signer ${args.signerToRemove} from Safe ${args.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 txResponse = await protocolKit.executeTransaction(safeTransaction); - console.log("txResponse: ", txResponse); + await this.safeClient.executeTransaction(safeTransaction); return `Successfully removed signer ${args.signerToRemove} from Safe ${args.safeAddress}`; } } catch (error) { @@ -393,6 +357,13 @@ Important notes: } } + /** + * Changes the confirmation threshold of an existing Safe. + * + * @param walletProvider - The wallet provider to connect to the Safe. + * @param args - The input arguments for changing the threshold. + * @returns A message containing the threshold change details. + */ @CreateAction({ name: "change_threshold", description: ` @@ -408,41 +379,45 @@ Important notes: `, schema: ChangeThresholdSchema, }) - async changeThreshold(args: z.infer): Promise { + async changeThreshold( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { try { - const protocolKit = await Safe.init({ - provider: this.networkConfig.rpcUrl, - signer: this.privateKey, - safeAddress: args.safeAddress - }); + // Connect to Safe client + this.safeClient = await initializeClientIfNeeded( + this.safeClient, + args.safeAddress, + walletProvider.getPublicClient().transport, + this.privateKey, + ); + + const currentSigners = await this.safeClient.getOwners(); + const currentThreshold = await this.safeClient.getThreshold(); - const currentSigners = await protocolKit.getOwners(); - const currentThreshold = await protocolKit.getThreshold(); - if (args.newThreshold > currentSigners.length) { throw new Error("New threshold cannot exceed number of signers"); } - const safeTransaction = await protocolKit.createChangeThresholdTx(args.newThreshold); + const safeTransaction = await this.safeClient.createChangeThresholdTx(args.newThreshold); if (currentThreshold > 1) { // Multi-sig flow: propose transaction - const safeTxHash = await protocolKit.getTransactionHash(safeTransaction); - const signature = await protocolKit.signHash(safeTxHash); - + const safeTxHash = await this.safeClient.getTransactionHash(safeTransaction); + const signature = await this.safeClient.signHash(safeTxHash); + await this.apiKit.proposeTransaction({ safeAddress: args.safeAddress, safeTransactionData: safeTransaction.data, safeTxHash, senderSignature: signature.data, - senderAddress: this.walletWithProvider.address + senderAddress: walletProvider.getAddress(), }); - return `Successfully proposed changing threshold to ${args.newThreshold} for Safe ${args.safeAddress}. Transaction hash: ${safeTxHash}. The other signers will need to confirm the transaction before it can be executed.`; + return `Successfully proposed changing threshold to ${args.newThreshold} for Safe ${args.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 txResponse = await protocolKit.executeTransaction(safeTransaction); - console.log("txResponse: ", txResponse); + await this.safeClient.executeTransaction(safeTransaction); return `Successfully changed threshold to ${args.newThreshold} for Safe ${args.safeAddress}`; } } catch (error) { @@ -450,6 +425,13 @@ Important notes: } } + /** + * Executes pending transactions for a Safe if enough signatures are collected. + * + * @param walletProvider - The wallet provider to connect to the Safe. + * @param args - The input arguments for executing pending transactions. + * @returns A message containing the execution details. + */ @CreateAction({ name: "execute_pending", description: ` @@ -466,56 +448,62 @@ Important notes: `, schema: ExecutePendingSchema, }) - async executePending(args: z.infer): Promise { + async executePending( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { try { - const protocolKit = await Safe.init({ - provider: this.networkConfig.rpcUrl, - signer: this.privateKey, - safeAddress: args.safeAddress - }); + // Connect to Safe client + this.safeClient = await initializeClientIfNeeded( + this.safeClient, + args.safeAddress, + walletProvider.getPublicClient().transport, + this.privateKey, + ); const pendingTxs = await this.apiKit.getPendingTransactions(args.safeAddress); - + if (pendingTxs.results.length === 0) { return "No pending transactions found."; } let executedTxs = 0; let skippedTxs = 0; - const txsToProcess = args.safeTxHash - ? pendingTxs.results.filter(tx => tx.safeTxHash === args.safeTxHash && tx.isExecuted === false) + const txsToProcess = args.safeTxHash + ? pendingTxs.results.filter( + tx => tx.safeTxHash === args.safeTxHash && tx.isExecuted === false, + ) : pendingTxs.results.filter(tx => tx.isExecuted === false); for (const tx of txsToProcess) { - try { - // Skip if not enough confirmations - console.log("tx.confirmations: ", tx.confirmations); - if (tx.confirmations && tx.confirmations.length < tx.confirmationsRequired) { - console.log(`Transaction ${tx.safeTxHash} needs ${tx.confirmationsRequired} confirmations but only has ${tx.confirmations?.length}.`); - skippedTxs++; - continue; - } - const txResponse = await protocolKit.executeTransaction(tx); - console.log(`Executed transaction ${tx.safeTxHash}:`, txResponse); - executedTxs++; - } catch (error) { - console.error(`Failed to execute ${tx.safeTxHash}:`, error); + // Skip if not enough confirmations + if (tx.confirmations && tx.confirmations.length < tx.confirmationsRequired) { skippedTxs++; + continue; } + await this.safeClient.executeTransaction(tx); + executedTxs++; } if (executedTxs === 0 && skippedTxs > 0) { return `No transactions executed. ${skippedTxs} transaction(s) have insufficient confirmations.`; } - return `Execution complete. Successfully executed ${executedTxs} transaction(s)${skippedTxs > 0 ? `, ${skippedTxs} still pending and need more confirmations` : ''}.${ - args.safeTxHash ? ` Transaction hash: ${args.safeTxHash}` : '' + return `Execution complete. Successfully executed ${executedTxs} transaction(s)${skippedTxs > 0 ? `, ${skippedTxs} still pending and need more confirmations` : ""}.${ + args.safeTxHash ? ` Safe transaction hash: ${args.safeTxHash}` : "" }`; } catch (error) { return `Error executing transactions: ${error instanceof Error ? error.message : String(error)}`; } } + /** + * Withdraws ETH from the Safe. + * + * @param walletProvider - The wallet provider to connect to the Safe. + * @param args - The input arguments for withdrawing ETH. + * @returns A message containing the withdrawal details. + */ @CreateAction({ name: "withdraw_eth", description: ` @@ -532,55 +520,64 @@ Important notes: `, schema: WithdrawFromSafeSchema, }) - async withdrawEth(args: z.infer): Promise { + async withdrawEth( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { try { - const protocolKit = await Safe.init({ - provider: this.networkConfig.rpcUrl, - signer: this.privateKey, - safeAddress: args.safeAddress - }); - - const currentThreshold = await protocolKit.getThreshold(); - const balance = await this.walletWithProvider.provider?.getBalance(args.safeAddress); - if (!balance) throw new Error("Could not get Safe balance"); + // Connect to Safe client + this.safeClient = await initializeClientIfNeeded( + this.safeClient, + args.safeAddress, + walletProvider.getPublicClient().transport, + this.privateKey, + ); + + const currentThreshold = await this.safeClient.getThreshold(); + const balance = await walletProvider + .getPublicClient() + .getBalance({ address: args.safeAddress }); // Calculate amount to withdraw let withdrawAmount: bigint; if (args.amount) { withdrawAmount = BigInt(Math.floor(parseFloat(args.amount) * 1e18)); if (withdrawAmount > balance) { - throw new Error(`Insufficient balance. Safe has ${parseFloat(balance.toString()) / 1e18} ETH`); + throw new Error( + `Insufficient balance. Safe has ${parseFloat(balance.toString()) / 1e18} ETH`, + ); } } else { withdrawAmount = balance; } - const safeTransaction = await protocolKit.createTransaction({ - transactions: [{ - to: args.recipientAddress, - data: "0x", - value: withdrawAmount.toString() - }] + const safeTransaction = await this.safeClient.createTransaction({ + transactions: [ + { + to: args.recipientAddress, + data: "0x", + value: withdrawAmount.toString(), + }, + ], }); if (currentThreshold > 1) { // Multi-sig flow: propose transaction - const safeTxHash = await protocolKit.getTransactionHash(safeTransaction); - const signature = await protocolKit.signHash(safeTxHash); - + const safeTxHash = await this.safeClient.getTransactionHash(safeTransaction); + const signature = await this.safeClient.signHash(safeTxHash); + await this.apiKit.proposeTransaction({ safeAddress: args.safeAddress, safeTransactionData: safeTransaction.data, safeTxHash, senderSignature: signature.data, - senderAddress: this.walletWithProvider.address + senderAddress: walletProvider.getAddress(), }); - return `Successfully proposed withdrawing ${parseFloat(withdrawAmount.toString()) / 1e18} ETH to ${args.recipientAddress}. Transaction hash: ${safeTxHash}. The other signers will need to confirm the transaction before it can be executed.`; + return `Successfully proposed withdrawing ${parseFloat(withdrawAmount.toString()) / 1e18} ETH to ${args.recipientAddress}. 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 txResponse = await protocolKit.executeTransaction(safeTransaction); - console.log("txResponse: ", txResponse); + await this.safeClient.executeTransaction(safeTransaction); return `Successfully withdrew ${parseFloat(withdrawAmount.toString()) / 1e18} ETH to ${args.recipientAddress}`; } } catch (error) { @@ -588,6 +585,13 @@ Important notes: } } + /** + * Enables the allowance module for a Safe, allowing for token spending allowances. + * + * @param walletProvider - The wallet provider to connect to the Safe. + * @param args - The input arguments for enabling the allowance module. + * @returns A message containing the allowance module enabling details. + */ @CreateAction({ name: "enable_allowance_module", description: ` @@ -603,22 +607,26 @@ Important notes: `, schema: EnableAllowanceModuleSchema, }) - async enableAllowanceModule(args: z.infer): Promise { + async enableAllowanceModule( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { try { - const protocolKit = await Safe.init({ - provider: this.networkConfig.rpcUrl, - signer: this.privateKey, - safeAddress: args.safeAddress - }); - - const isSafeDeployed = await protocolKit.isSafeDeployed(); + // Connect to Safe client + this.safeClient = await initializeClientIfNeeded( + this.safeClient, + args.safeAddress, + walletProvider.getPublicClient().transport, + this.privateKey, + ); + + const isSafeDeployed = await this.safeClient.isSafeDeployed(); if (!isSafeDeployed) { throw new Error("Safe not deployed"); } // Get allowance module address for current chain - // const chainId = baseSepolia.id.toString(); - const chainId = sepolia.id.toString(); + const chainId = this.chain.id.toString(); const allowanceModule = getAllowanceModuleDeployment({ network: chainId }); if (!allowanceModule) { throw new Error(`Allowance module not found for chainId [${chainId}]`); @@ -626,33 +634,32 @@ Important notes: // Check if module is already enabled const moduleAddress = allowanceModule.networkAddresses[chainId]; - const isAlreadyEnabled = await protocolKit.isModuleEnabled(moduleAddress); + const isAlreadyEnabled = await this.safeClient.isModuleEnabled(moduleAddress); if (isAlreadyEnabled) { return "Allowance module is already enabled for this Safe"; } // Create and execute/propose transaction - const safeTransaction = await protocolKit.createEnableModuleTx(moduleAddress); - const currentThreshold = await protocolKit.getThreshold(); + const safeTransaction = await this.safeClient.createEnableModuleTx(moduleAddress); + const currentThreshold = await this.safeClient.getThreshold(); if (currentThreshold > 1) { // Multi-sig flow: propose transaction - const safeTxHash = await protocolKit.getTransactionHash(safeTransaction); - const signature = await protocolKit.signHash(safeTxHash); - + const safeTxHash = await this.safeClient.getTransactionHash(safeTransaction); + const signature = await this.safeClient.signHash(safeTxHash); + await this.apiKit.proposeTransaction({ safeAddress: args.safeAddress, safeTransactionData: safeTransaction.data, safeTxHash, senderSignature: signature.data, - senderAddress: this.walletWithProvider.address + senderAddress: walletProvider.getAddress(), }); - return `Successfully proposed enabling allowance module for Safe ${args.safeAddress}. Transaction hash: ${safeTxHash}. The other signers will need to confirm the transaction before it can be executed.`; + return `Successfully proposed enabling allowance module for Safe ${args.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 txResponse = await protocolKit.executeTransaction(safeTransaction); - console.log("txResponse: ", txResponse); + await this.safeClient.executeTransaction(safeTransaction); return `Successfully enabled allowance module for Safe ${args.safeAddress}`; } } catch (error) { @@ -660,290 +667,6 @@ Important notes: } } - @CreateAction({ - name: "analyze_transaction", - description: ` -Analyzes a pending Safe transaction to explain what it does. -Takes the following inputs: -- safeAddress: Address of the Safe -- safeTxHash: Hash of the transaction to analyze - -Returns a detailed analysis including: -- Who proposed it and when -- Current confirmation status -- What the transaction will do if executed -- Any risk considerations -`, - schema: AnalyzeTransactionSchema, - }) - async analyzeTransaction(args: z.infer): Promise { - try { - // Get transaction details - const pendingTxs = await this.apiKit.getPendingTransactions(args.safeAddress); - const tx = pendingTxs.results.find(tx => tx.safeTxHash === args.safeTxHash); - console.log("tx: ", tx); - - if (!tx) { - return `Transaction ${args.safeTxHash} not found in pending transactions.`; - } - - // Analyze the transaction - const proposedAt = new Date(tx.submissionDate).toLocaleString(); - const confirmations = tx.confirmations?.length || 0; - const confirmationStatus = `${confirmations}/${tx.confirmationsRequired} confirmations`; - - // Get known addresses from delegates and common contracts - const knownAddresses = new Map(); - - // Add well-known contracts - const commonContracts: Record = { - "0x3E5c63644E683549055b9Be8653de26E0B4CD36E": "Safe Singleton v1.3.0", - "0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552": "Safe Singleton v1.3.0", - "0x69f4D1788e39c87893C980c06EdF4b7f686e2938": "Safe Allowance Module", - // Add more known contracts here - }; - - // Add common contracts to known addresses - Object.entries(commonContracts).forEach(([address, name]) => { - knownAddresses.set(address.toLowerCase(), name); - }); - - // Get delegates if any - try { - const delegates = await this.apiKit.getSafeDelegates({ - safeAddress: args.safeAddress, - limit: 100 - }); - - // Add delegates to known addresses - delegates.results.forEach(delegate => { - knownAddresses.set(delegate.delegate.toLowerCase(), `Delegate (added by ${delegate.delegator})`); - }); - } catch (error) { - console.error("Error fetching delegates:", error); - } - - // Helper function to get readable address - const getAddressName = (address: string) => knownAddresses.get(address.toLowerCase()) || address; - - // Update proposer and confirmations with known names - const proposerName = tx.proposer ? getAddressName(tx.proposer) : 'unknown'; - const confirmedBy = tx.confirmations?.map(c => getAddressName(c.owner)).join(', ') || 'none'; - - // Analyze transaction type and data - let actionDescription = "Unknown transaction type"; - if (tx.dataDecoded) { - console.log("tx.dataDecoded: ", tx.dataDecoded); - - const method = tx.dataDecoded.method; - const params = tx.dataDecoded.parameters; - - // Decode common transaction types - switch (method) { - case "addOwnerWithThreshold": - actionDescription = `Add new owner ${params?.[0].value} with threshold ${params?.[1].value}`; - break; - case "removeOwner": - actionDescription = `Remove owner ${params?.[1].value} and change threshold to ${params?.[2].value}`; - break; - case "changeThreshold": - actionDescription = `Change confirmation threshold to ${params?.[0].value}`; - break; - case "enableModule": - actionDescription = `Enable module at address ${params?.[0].value}`; - break; - default: - if (tx.value !== "0") { - actionDescription = `Transfer ${parseFloat(tx.value) / 1e18} ETH to ${tx.to}`; - } else { - actionDescription = `Call method '${method}' on contract ${tx.to}`; - } - } - } else if (tx.value !== "0") { - actionDescription = `Transfer ${parseFloat(tx.value) / 1e18} ETH to ${tx.to}`; - } - - // Add risk analysis section - const riskFactors: string[] = []; - - // 1. Check if this changes Safe configuration - if (tx.dataDecoded?.method.includes("Owner") || tx.dataDecoded?.method === "changeThreshold") { - riskFactors.push("⚠️ This transaction modifies Safe ownership/configuration"); - } - - // 2. Check value transfer risks - if (tx.value !== "0") { - const ethValue = parseFloat(tx.value) / 1e18; - const balance = await this.walletWithProvider.provider?.getBalance(args.safeAddress); - const safeBalance = balance ? parseFloat(balance.toString()) / 1e18 : 0; - - if (ethValue > safeBalance * 0.5) { - riskFactors.push(`⚠️ High-value transfer: ${ethValue} ETH (>${Math.round(ethValue/safeBalance*100)}% of Safe balance)`); - } - } - - // 3. Check destination address - if (tx.to) { - // Check if destination is a contract - const code = await this.walletWithProvider.provider?.getCode(tx.to); - const isContract = code && code !== "0x"; - - // Check if it's a known contract (could expand this list) - const knownContracts: Record = { - "0x...": "Uniswap V3", - // Add more known contracts - }; - - if (isContract && !knownContracts[tx.to.toLowerCase()]) { - riskFactors.push("⚠️ Interaction with unverified contract"); - } - } - - // 4. Check for unusual patterns - // if (tx.nonce > 0) { - // // Get previous transactions - // const allTxs = await this.apiKit.getAllTransactions(args.safeAddress); - // const previousTx = allTxs.results.find(t => t.nonce === tx.nonce - 1); - - // // Check for rapid sequence of transactions - // if (previousTx && - // new Date(tx.submissionDate).getTime() - new Date(previousTx.submissionDate).getTime() < 300000) { - // riskFactors.push("⚠️ Quick sequence of transactions (< 5 min apart)"); - // } - // } - - // 5. Check for complex contract interactions - if (tx.dataDecoded?.parameters) { - const paramCount = tx.dataDecoded.parameters.length; - if (paramCount > 3) { - riskFactors.push(`ℹ️ Complex transaction with ${paramCount} parameters`); - } - } - - // Add Etherscan contract verification check - const ETHERSCAN_API_KEY = process.env.ETHERSCAN_API_KEY; - if (tx.to && ETHERSCAN_API_KEY) { - try { - const baseUrl = this.networkConfig.chain === "ethereum-sepolia" - ? "https://api-sepolia.etherscan.io/api" - : this.networkConfig.chain === "base-sepolia" - ? "https://api-sepolia.basescan.org/api" - : "https://api.basescan.org/api"; - - const response = await fetch( - `${baseUrl}?module=contract&action=getsourcecode&address=${tx.to}&apikey=${ETHERSCAN_API_KEY}` - ); - const data = await response.json(); - - if (data.status === "1" && data.result[0]) { - if (!data.result[0].ContractName) { - riskFactors.push("⚠️ Contract not verified on Etherscan/Basescan"); - } else { - actionDescription += `\nContract Name: ${data.result[0].ContractName}`; - if (data.result[0].Implementation) { // Check if proxy - actionDescription += `\nImplementation: ${data.result[0].Implementation}`; - } - } - } - } catch (error) { - console.error("Etherscan API error:", error); - } - } - - // Add Tenderly simulation - try { - const networkId = this.networkConfig.chain === "ethereum-sepolia" - ? "11155111" - : this.networkConfig.chain === "base-sepolia" - ? "84532" - : "8453"; - - const simulationResult = await simulateWithTenderly(tx, networkId); - - if (!simulationResult.success) { - riskFactors.push(`⚠️ Transaction simulation failed: ${simulationResult.error || "Unknown error"}`); - } else { - const gasUsed = simulationResult.gasUsed - ? `\nEstimated gas usage: ${simulationResult.gasUsed}` - : ''; - actionDescription += `\nTransaction simulation successful.${gasUsed}`; - } - } catch (error) { - console.error("Tenderly simulation error:", error); - } - - // Get historical context - const allTxs = await this.apiKit.getAllTransactions(args.safeAddress); - console.log("allTxs: ", allTxs); - - // const similarTxs = allTxs.results.filter(pastTx => - // (pastTx.safeTxHash !== tx.safeTxHash) && // Don't include current tx - // (pastTx.to === tx.to || - // (pastTx.dataDecoded?.method === tx.dataDecoded?.method) || - // (pastTx.value === tx.value && tx.value !== "0")) - // ); - - // // Add historical analysis to risk factors - // if (similarTxs.length > 0) { - // const recentSimilarTxs = similarTxs.slice(0, 3); - // actionDescription += "\n\nSimilar Past Transactions:"; - // recentSimilarTxs.forEach(pastTx => { - // const pastTxDate = new Date(pastTx.submissionDate).toLocaleDateString(); - // actionDescription += `\n- ${pastTx.safeTxHash} (${pastTxDate})`; - // if (pastTx.isExecuted) { - // actionDescription += " ✓ Executed successfully"; - // } else if (pastTx.isSuccessful === false) { - // riskFactors.push("⚠️ Similar transaction has failed in the past"); - // } - // }); - // } else if (tx.value !== "0" || tx.dataDecoded) { - // // If this is a non-trivial transaction with no history - // riskFactors.push("ℹ️ No similar transactions found in Safe history"); - // } - - // Update destination analysis with known addresses - if (tx.to) { - const toName = getAddressName(tx.to); - if (toName !== tx.to) { - actionDescription = actionDescription.replace(tx.to, `${toName} (${tx.to})`); - } else if (!knownAddresses.has(tx.to.toLowerCase())) { - riskFactors.push("⚠️ Destination address not in Safe's address book"); - } - } - - // Build analysis report - return `Transaction Analysis for ${args.safeTxHash}: - -OVERVIEW -- Proposed by: ${proposerName} (${tx.proposer}) -- Proposed at: ${proposedAt} -- Status: ${tx.isExecuted ? 'Executed' : 'Pending'} (${confirmationStatus}) -- Confirmed by: ${confirmedBy} - -ACTION -${actionDescription} - -DETAILS -- To: ${getAddressName(tx.to)} (${tx.to}) -- Value: ${parseFloat(tx.value) / 1e18} ETH -- Nonce: ${tx.nonce} -${tx.dataDecoded ? `- Method: ${tx.dataDecoded.method}` : ''} - -RISK ANALYSIS -${riskFactors.length > 0 - ? riskFactors.join('\n') - : '✅ No significant risk factors identified'} - -CONFIRMATIONS NEEDED -${confirmations < tx.confirmationsRequired - ? `Needs ${tx.confirmationsRequired - confirmations} more confirmation(s) before it can be executed` - : 'Has enough confirmations and can be executed'}`; - - } catch (error) { - return `Error analyzing transaction: ${error instanceof Error ? error.message : String(error)}`; - } - } - /** * Checks if the Safe action provider supports the given network. * @@ -951,7 +674,6 @@ ${confirmations < tx.confirmationsRequired * @returns True if the Safe action provider supports the network, false otherwise. */ supportsNetwork = (network: Network) => network.protocolFamily === "evm"; - } export const safeActionProvider = (config?: SafeActionProviderConfig) => diff --git a/typescript/agentkit/src/action-providers/safe/schemas.ts b/typescript/agentkit/src/action-providers/safe/schemas.ts index 06a7fa90d..327f5d462 100644 --- a/typescript/agentkit/src/action-providers/safe/schemas.ts +++ b/typescript/agentkit/src/action-providers/safe/schemas.ts @@ -1,47 +1,53 @@ import { z } from "zod"; export const InitializeSafeSchema = z.object({ - signers: z.array(z.string()).describe("Array of additional signer addresses for the Safe").default([]), - threshold: z.number().min(1).describe("Number of required confirmations").default(1) + signers: z + .array(z.string()) + .describe("Array of additional signer addresses for the Safe") + .default([]), + threshold: z.number().min(1).describe("Number of required confirmations").default(1), }); export const SafeInfoSchema = z.object({ - safeAddress: z.string().describe("Address of the existing Safe to connect to") + safeAddress: z.string().describe("Address of the existing Safe to connect to"), }); export const AddSignerSchema = z.object({ safeAddress: z.string().describe("Address of the Safe to modify"), newSigner: z.string().describe("Address of the new signer to add"), - newThreshold: z.number().optional().describe("Optional new threshold after adding signer") + newThreshold: z.number().optional().describe("Optional new threshold after adding signer"), }); export const RemoveSignerSchema = z.object({ safeAddress: z.string().describe("Address of the Safe to modify"), signerToRemove: z.string().describe("Address of the signer to remove"), - newThreshold: z.number().optional().describe("Optional new threshold after removing signer") + newThreshold: z.number().optional().describe("Optional new threshold after removing signer"), }); export const ChangeThresholdSchema = z.object({ safeAddress: z.string().describe("Address of the Safe to modify"), - newThreshold: z.number().min(1).describe("New threshold value") + newThreshold: z.number().min(1).describe("New threshold value"), }); export const ExecutePendingSchema = z.object({ safeAddress: z.string().describe("Address of the Safe"), - safeTxHash: z.string().optional().describe("Optional specific transaction hash to execute. If not provided, will try to execute all pending transactions") + safeTxHash: z + .string() + .optional() + .describe( + "Optional specific transaction hash to execute. If not provided, will try to execute all pending transactions", + ), }); export const WithdrawFromSafeSchema = z.object({ safeAddress: z.string().describe("Address of the Safe"), recipientAddress: z.string().describe("Address to receive the ETH"), - amount: z.string().optional().describe("Amount of ETH to withdraw (e.g. '0.1'). If not provided, withdraws entire balance") + amount: z + .string() + .optional() + .describe("Amount of ETH to withdraw (e.g. '0.1'). If not provided, withdraws entire balance"), }); export const EnableAllowanceModuleSchema = z.object({ - safeAddress: z.string().describe("Address of the Safe to enable allowance module for") -}); - -export const AnalyzeTransactionSchema = z.object({ - safeAddress: z.string().describe("Address of the Safe"), - safeTxHash: z.string().describe("Hash of the transaction to analyze") + safeAddress: z.string().describe("Address of the Safe to enable allowance module for"), }); diff --git a/typescript/agentkit/src/action-providers/safe/utils.ts b/typescript/agentkit/src/action-providers/safe/utils.ts index e69de29bb..b404b2b88 100644 --- a/typescript/agentkit/src/action-providers/safe/utils.ts +++ b/typescript/agentkit/src/action-providers/safe/utils.ts @@ -0,0 +1,40 @@ +import Safe from "@safe-global/protocol-kit"; +import { PublicClient } from "viem"; + +/** + * Initializes or reinitializes a Safe client if needed + * + * @param currentClient - The current Safe client instance + * @param safeAddress - The target Safe address + * @param provider - The provider for initializing the client + * @param signer - The signer for initializing the client + * @returns The initialized Safe client + */ +export const initializeClientIfNeeded = async ( + currentClient: Safe | null, + safeAddress: string, + provider: PublicClient["transport"], + signer: string, +): Promise => { + // If no client exists, initialize new one + if (!currentClient) { + return await Safe.init({ + provider, + signer, + safeAddress, + }); + } + + // If client exists but for different Safe address, reinitialize + const currentAddress = await currentClient.getAddress(); + if (currentAddress.toLowerCase() !== safeAddress.toLowerCase()) { + return await Safe.init({ + provider, + signer, + safeAddress, + }); + } + + // Return existing client if it's for the same Safe + return currentClient; +}; diff --git a/typescript/agentkit/src/wallet-providers/evmWalletProvider.ts b/typescript/agentkit/src/wallet-providers/evmWalletProvider.ts index 216ec72b2..af1d70600 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"; /** @@ -70,4 +71,12 @@ export abstract class EvmWalletProvider extends WalletProvider { >( params: ReadContractParameters, ): Promise>; + abstract readContract(params: ReadContractParameters): Promise; + + /** + * Get the public client. + * + * @returns The public client. + */ + abstract getPublicClient(): PublicClient; } diff --git a/typescript/agentkit/src/wallet-providers/viemWalletProvider.ts b/typescript/agentkit/src/wallet-providers/viemWalletProvider.ts index 20b7b4006..2c313b263 100644 --- a/typescript/agentkit/src/wallet-providers/viemWalletProvider.ts +++ b/typescript/agentkit/src/wallet-providers/viemWalletProvider.ts @@ -250,4 +250,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/examples/langchain-cdp-chatbot/chatbot.ts b/typescript/examples/langchain-cdp-chatbot/chatbot.ts index 15c6b0b63..2b9058679 100644 --- a/typescript/examples/langchain-cdp-chatbot/chatbot.ts +++ b/typescript/examples/langchain-cdp-chatbot/chatbot.ts @@ -11,7 +11,6 @@ import { openseaActionProvider, alloraActionProvider, safeActionProvider, - ViemWalletProvider, } from "@coinbase/agentkit"; import { getLangChainTools } from "@coinbase/agentkit-langchain"; import { HumanMessage } from "@langchain/core/messages"; @@ -22,12 +21,6 @@ import * as dotenv from "dotenv"; import * as fs from "fs"; import * as readline from "readline"; -import { - WalletClient as ViemWalletClient, -} from "viem"; -import { createWalletClient, http } from 'viem'; -import { sepolia } from 'viem/chains'; - dotenv.config(); /** @@ -101,14 +94,6 @@ async function initializeAgent() { }; const walletProvider = await CdpWalletProvider.configureWithWallet(config); - // console.log("getPublicClient: ", walletProvider.getPublicClient()); - - // const walletClient = createWalletClient({ - // account: await (await walletProvider.getWallet().getDefaultAddress()).export() as `0x${string}`, - // chain: sepolia, - // transport: http(), - // }); - // const viemWalletProvider = new ViemWalletProvider(walletClient); // Initialize AgentKit const agentkit = await AgentKit.from({ @@ -310,13 +295,13 @@ async function chooseMode(): Promise<"chat" | "auto"> { async function main() { try { const { agent, config } = await initializeAgent(); - // const mode = await chooseMode(); + const mode = await chooseMode(); - // if (mode === "chat") { + if (mode === "chat") { await runChatMode(agent, config); - // } else { - // await runAutonomousMode(agent, config); - // } + } else { + await runAutonomousMode(agent, config); + } } catch (error) { if (error instanceof Error) { console.error("Error:", error.message); From a361e6b719ceeb57daeea496aad3de8ff2171bce Mon Sep 17 00:00:00 2001 From: Philippe d'Argent Date: Tue, 18 Feb 2025 23:13:27 +0900 Subject: [PATCH 3/9] add safeWalletProvider --- .../src/action-providers/safe/index.ts | 2 + .../safe/safeApiActionProvider.ts | 121 ++++++ .../safe/safeWalletActionProvider.ts | 65 +++ .../agentkit/src/wallet-providers/index.ts | 1 + .../wallet-providers/safeWalletProvider.ts | 387 ++++++++++++++++++ .../src/wallet-providers/walletProvider.ts | 2 +- .../langchain-safe-chatbot/.env-local | 7 + .../langchain-safe-chatbot/.eslintrc.json | 4 + .../langchain-safe-chatbot/.prettierrc | 11 + .../examples/langchain-safe-chatbot/README.md | 31 ++ .../langchain-safe-chatbot/chatbot.ts | 224 ++++++++++ .../langchain-safe-chatbot/package.json | 27 ++ .../langchain-safe-chatbot/tsconfig.json | 10 + 13 files changed, 891 insertions(+), 1 deletion(-) create mode 100644 typescript/agentkit/src/action-providers/safe/safeApiActionProvider.ts create mode 100644 typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.ts create mode 100644 typescript/agentkit/src/wallet-providers/safeWalletProvider.ts create mode 100644 typescript/examples/langchain-safe-chatbot/.env-local create mode 100644 typescript/examples/langchain-safe-chatbot/.eslintrc.json create mode 100644 typescript/examples/langchain-safe-chatbot/.prettierrc create mode 100644 typescript/examples/langchain-safe-chatbot/README.md create mode 100644 typescript/examples/langchain-safe-chatbot/chatbot.ts create mode 100644 typescript/examples/langchain-safe-chatbot/package.json create mode 100644 typescript/examples/langchain-safe-chatbot/tsconfig.json diff --git a/typescript/agentkit/src/action-providers/safe/index.ts b/typescript/agentkit/src/action-providers/safe/index.ts index 7e1a8ed29..f644c5b3a 100644 --- a/typescript/agentkit/src/action-providers/safe/index.ts +++ b/typescript/agentkit/src/action-providers/safe/index.ts @@ -1,2 +1,4 @@ export * from "./schemas"; export * from "./safeActionProvider"; +export * from "./safeWalletActionProvider"; +export * from "./safeApiActionProvider"; 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..b5b5fa3ab --- /dev/null +++ b/typescript/agentkit/src/action-providers/safe/safeApiActionProvider.ts @@ -0,0 +1,121 @@ +import { z } from "zod"; +import { CreateAction } from "../actionDecorator"; +import { ActionProvider } from "../actionProvider"; +import { EvmWalletProvider } from "../../wallet-providers"; +import { SafeInfoSchema } from "./schemas"; +import { Network, NETWORK_ID_TO_VIEM_CHAIN } from "../../network"; + +import { Chain, formatEther } from "viem"; +import SafeApiKit from "@safe-global/api-kit"; + +/** + * 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 Safe Wallet. + */ +export class SafeApiActionProvider extends ActionProvider { + private readonly chain: Chain; + private 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)}`; + } + } + + /** + * 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.ts b/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.ts new file mode 100644 index 000000000..0d8b7ef61 --- /dev/null +++ b/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.ts @@ -0,0 +1,65 @@ +import { z } from "zod"; +import { CreateAction } from "../actionDecorator"; +import { ActionProvider } from "../actionProvider"; +import { SafeWalletProvider } from "../../wallet-providers"; +import { AddSignerSchema } 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 multi-sig wallet. + * + * @param walletProvider - The SafeWalletProvider instance to use + * @param args - The input arguments for creating a Safe. + * @returns A Promise that resolves to the transaction hash. + */ + @CreateAction({ + name: "add_signer", + description: "Add a new signer to the Safe multi-sig wallet", + 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)}`, + ); + } + } + + /** + * 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/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.ts b/typescript/agentkit/src/wallet-providers/safeWalletProvider.ts new file mode 100644 index 000000000..546c87383 --- /dev/null +++ b/typescript/agentkit/src/wallet-providers/safeWalletProvider.ts @@ -0,0 +1,387 @@ +import { WalletProvider } from "./walletProvider"; +import { Network } from "../network"; +import { + Account, + Chain, + createPublicClient, + http, + parseEther, + ReadContractParameters, + ReadContractReturnType, +} 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"; + +// Safe SDK imports +import Safe from "@safe-global/protocol-kit"; +import SafeApiKit from "@safe-global/api-kit"; + +/** + * Configuration options for the SafeWalletProvider. + */ +export interface SafeWalletProviderConfig { + /** + * Private key of the signer that controls (or co-controls) 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. + */ + 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 WalletProvider { + #privateKey: string; + #account: Account; + #chain: Chain; + #safeAddress: string | null = null; + #isInitialized: boolean = false; + #publicClient: PublicClient; + #safeClient: Safe | null = null; + #apiKit: SafeApiKit; + + /** + * 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 `0x${string}`); + + this.initializeSafe(config.safeAddress).then( + address => { + this.#safeAddress = address; + this.#isInitialized = true; + this.trackInitialization(); + }, + error => { + console.error("Error initializing Safe wallet:", error); + }, + ); + } + + /** + * 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"; + } + + /** + * 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: `0x${string}`): Promise { + return await this.#publicClient.waitForTransactionReceipt({ hash: txHash }); + } + + /** + * Reads data from a contract. + * + * @param params - The parameters to read the contract. + * @returns The response from the contract. + */ + async readContract(params: ReadContractParameters): Promise { + return this.#publicClient.readContract(params); + } + + /** + * 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 `0x${string}`, + }); + 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 `0x${string}`, + 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 `0x${string}`); + 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)}`, + ); + } + } + + /** + * 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}.`; + } + } + + /** + * Gets the public client instance. + * + * @returns The Viem PublicClient instance. + */ + getPublicClient(): PublicClient { + return this.#publicClient; + } + + /** + * 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 `0x${string}`, + chain: this.#publicClient.chain, + }); + const receipt = await this.waitForTransactionReceipt(hash as `0x${string}`); + + // Reconnect to the deployed Safe + const safeAddress = await safeSdk.getAddress(); + const reconnected = await safeSdk.connect({ safeAddress }); + this.#safeClient = reconnected; + this.#safeAddress = safeAddress; + + console.log("Safe deployed at:", safeAddress, "Receipt:", receipt.transactionHash); + + 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/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-safe-chatbot/.env-local b/typescript/examples/langchain-safe-chatbot/.env-local new file mode 100644 index 000000000..57232a737 --- /dev/null +++ b/typescript/examples/langchain-safe-chatbot/.env-local @@ -0,0 +1,7 @@ +# Fill in these environment variables +OPENAI_API_KEY= +SAFE_AGENT_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..fc9385e78 --- /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..c643caeba --- /dev/null +++ b/typescript/examples/langchain-safe-chatbot/README.md @@ -0,0 +1,31 @@ +# Safe AgentKit LangChain Extension Example - Chatbot + +This example demonstrates an agent using a Safe-based wallet provider, which allows interactions onchain from a multi-sig Safe. By default, it can create or connect to a Safe on the specified network via a private key. + +## Environment Variables + +- **OPENAI_API_KEY**: Your OpenAI API key +- **SAFE_AGENT_PRIVATE_KEY**: The private key that controls 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..f70195d69 --- /dev/null +++ b/typescript/examples/langchain-safe-chatbot/chatbot.ts @@ -0,0 +1,224 @@ +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, +} 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_AGENT_PRIVATE_KEY) { + missing.push("SAFE_AGENT_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() { + // 1) Create an LLM + const llm = new ChatOpenAI({ + model: "gpt-4o-mini", // example model name + }); + + // 2) Configure SafeWalletProvider + const privateKey = process.env.SAFE_AGENT_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, + }); + + // 3) Initialize AgentKit with the Safe wallet and some typical action providers + const agentkit = await AgentKit.from({ + walletProvider: safeWallet, + actionProviders: [ + walletActionProvider(), + safeWalletActionProvider(), + safeApiActionProvider({ networkId: networkId }), + ], + }); + + // 4) Convert to LangChain tools + const tools = await getLangChainTools(agentkit); + + // 5) Wrap in a memory saver for conversation + const memory = new MemorySaver(); + const agentConfig = { configurable: { thread_id: "Safe AgentKit Chatbot Example!" } }; + + // 6) Create the agent + const agent = createReactAgent({ + llm, + tools, + checkpointSaver: memory, + messageModifier: ` + You are an agent with a Safe-based wallet. You can propose or execute actions + on the Safe. If threshold > 1, you may need confirmations from other signers + or to propose transactions. If threshold=1, you can execute immediately. + Be concise and helpful. If you cannot fulfill a request with your current tools, + apologize and suggest the user implement it themselves. + `, + }); + + 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..cd4edb798 --- /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"] +} From 20394411753ab3c9cc4d71c9a381573a981f09f7 Mon Sep 17 00:00:00 2001 From: Philippe d'Argent Date: Wed, 19 Feb 2025 11:39:35 +0900 Subject: [PATCH 4/9] add change_threshold/remove_signer actions and tests --- .../safe/safeApiActionProvider.test.ts | 145 ++++++++++++ .../safe/safeWalletActionProvider.test.ts | 208 ++++++++++++++++++ .../safe/safeWalletActionProvider.ts | 52 ++++- .../src/action-providers/safe/schemas.ts | 22 +- .../safeWalletProvider.test.ts | 132 +++++++++++ .../wallet-providers/safeWalletProvider.ts | 127 ++++++++++- .../langchain-safe-chatbot/chatbot.ts | 3 +- typescript/package-lock.json | 8 +- 8 files changed, 677 insertions(+), 20 deletions(-) create mode 100644 typescript/agentkit/src/action-providers/safe/safeApiActionProvider.test.ts create mode 100644 typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.test.ts create mode 100644 typescript/agentkit/src/wallet-providers/safeWalletProvider.test.ts 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..5c43adce7 --- /dev/null +++ b/typescript/agentkit/src/action-providers/safe/safeApiActionProvider.test.ts @@ -0,0 +1,145 @@ +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_NETWORK = "base-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); + 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", () => { + const result = actionProvider.supportsNetwork({ protocolFamily: "evm" } as any); + expect(result).toBe(true); + }); + + it("should return false for non-EVM networks", () => { + const result = actionProvider.supportsNetwork({ protocolFamily: "solana" } as any); + expect(result).toBe(false); + }); + }); +}); \ No newline at end of file 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..46ead0d68 --- /dev/null +++ b/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.test.ts @@ -0,0 +1,208 @@ +import { SafeWalletActionProvider } from "./safeWalletActionProvider"; +import { SafeWalletProvider } from "../../wallet-providers"; +import { AddSignerSchema } from "./schemas"; +import Safe from "@safe-global/protocol-kit"; + +// 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" }), + getSafeClient: jest.fn(), + 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(), + } as unknown as jest.Mocked; + + // Mock Safe client methods + const mockSafeClient = { + getOwners: jest.fn().mockResolvedValue(["0xowner1", "0xowner2"]), + getThreshold: jest.fn().mockResolvedValue(2), + createTransaction: jest.fn().mockResolvedValue({ + data: { safeTxHash: MOCK_TRANSACTION_HASH }, + }), + getPendingTransactions: jest.fn().mockResolvedValue({ + results: [], + }), + } as unknown as Safe; + + mockWallet.getSafeClient.mockReturnValue(mockSafeClient); + }); + + 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); + }); + }); +}); \ No newline at end of file diff --git a/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.ts b/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.ts index 0d8b7ef61..b0d9e7e3e 100644 --- a/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.ts +++ b/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.ts @@ -1,8 +1,8 @@ import { z } from "zod"; import { CreateAction } from "../actionDecorator"; import { ActionProvider } from "../actionProvider"; -import { SafeWalletProvider } from "../../wallet-providers"; -import { AddSignerSchema } from "./schemas"; +import { SafeWalletProvider } from "../../wallet-providers/safeWalletProvider"; +import { AddSignerSchema, RemoveSignerSchema, ChangeThresholdSchema } from "./schemas"; import { Network } from "../../network"; /** @@ -48,6 +48,54 @@ export class SafeWalletActionProvider extends ActionProvider } } + /** + * Removes a signer from the Safe. + */ + @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. + */ + @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); + } + /** * Checks if the Safe action provider supports the given network. * diff --git a/typescript/agentkit/src/action-providers/safe/schemas.ts b/typescript/agentkit/src/action-providers/safe/schemas.ts index 327f5d462..9dbf30d29 100644 --- a/typescript/agentkit/src/action-providers/safe/schemas.ts +++ b/typescript/agentkit/src/action-providers/safe/schemas.ts @@ -2,35 +2,35 @@ import { z } from "zod"; export const InitializeSafeSchema = z.object({ signers: z - .array(z.string()) + .array(z.string().regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format")) .describe("Array of additional signer addresses for the Safe") .default([]), threshold: z.number().min(1).describe("Number of required confirmations").default(1), }); export const SafeInfoSchema = z.object({ - safeAddress: z.string().describe("Address of the existing Safe to connect to"), + 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 AddSignerSchema = z.object({ - safeAddress: z.string().describe("Address of the Safe to modify"), - newSigner: z.string().describe("Address of the new signer to add"), + safeAddress: z.string().regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format").describe("Address of the Safe to modify"), + 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({ - safeAddress: z.string().describe("Address of the Safe to modify"), - signerToRemove: z.string().describe("Address of the signer to remove"), + safeAddress: z.string().regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format").describe("Address of the Safe to modify"), + 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({ - safeAddress: z.string().describe("Address of the Safe to modify"), + safeAddress: z.string().regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format").describe("Address of the Safe to modify"), newThreshold: z.number().min(1).describe("New threshold value"), }); export const ExecutePendingSchema = z.object({ - safeAddress: z.string().describe("Address of the Safe"), + safeAddress: z.string().regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format").describe("Address of the Safe"), safeTxHash: z .string() .optional() @@ -40,8 +40,8 @@ export const ExecutePendingSchema = z.object({ }); export const WithdrawFromSafeSchema = z.object({ - safeAddress: z.string().describe("Address of the Safe"), - recipientAddress: z.string().describe("Address to receive the ETH"), + safeAddress: z.string().regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format").describe("Address of the Safe"), + recipientAddress: z.string().regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format").describe("Address to receive the ETH"), amount: z .string() .optional() @@ -49,5 +49,5 @@ export const WithdrawFromSafeSchema = z.object({ }); export const EnableAllowanceModuleSchema = z.object({ - safeAddress: z.string().describe("Address of the Safe to enable allowance module for"), + safeAddress: z.string().regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format").describe("Address of the Safe to enable allowance module for"), }); 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..3361f1888 --- /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." + ); + +}); + +}); \ No newline at end of file diff --git a/typescript/agentkit/src/wallet-providers/safeWalletProvider.ts b/typescript/agentkit/src/wallet-providers/safeWalletProvider.ts index 546c87383..86772cd6e 100644 --- a/typescript/agentkit/src/wallet-providers/safeWalletProvider.ts +++ b/typescript/agentkit/src/wallet-providers/safeWalletProvider.ts @@ -51,6 +51,7 @@ export class SafeWalletProvider extends WalletProvider { #publicClient: PublicClient; #safeClient: Safe | null = null; #apiKit: SafeApiKit; + #initializationPromise: Promise; /** * Creates a new SafeWalletProvider instance. @@ -79,18 +80,27 @@ export class SafeWalletProvider extends WalletProvider { this.#privateKey = config.privateKey; this.#account = privateKeyToAccount(this.#privateKey as `0x${string}`); - this.initializeSafe(config.safeAddress).then( + this.#initializationPromise = this.initializeSafe(config.safeAddress).then( address => { this.#safeAddress = address; this.#isInitialized = true; this.trackInitialization(); }, error => { - console.error("Error initializing Safe wallet:", 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. @@ -300,6 +310,107 @@ export class SafeWalletProvider extends WalletProvider { } } + /** + * 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}.`; + } + } + /** * Gets the public client instance. * @@ -384,4 +495,16 @@ export class SafeWalletProvider extends WalletProvider { return existingAddress; } } + + /** + * Returns the Safe client instance + * + * @returns The Safe client + */ + getSafeClient(): Safe { + if (!this.#safeClient) { + throw new Error("Safe client not initialized"); + } + return this.#safeClient; + } } diff --git a/typescript/examples/langchain-safe-chatbot/chatbot.ts b/typescript/examples/langchain-safe-chatbot/chatbot.ts index f70195d69..c96d6a3b8 100644 --- a/typescript/examples/langchain-safe-chatbot/chatbot.ts +++ b/typescript/examples/langchain-safe-chatbot/chatbot.ts @@ -90,7 +90,8 @@ async function initializeAgent() { networkId, safeAddress, }); - + await safeWallet.waitForInitialization(); + // 3) Initialize AgentKit with the Safe wallet and some typical action providers const agentkit = await AgentKit.from({ walletProvider: safeWallet, diff --git a/typescript/package-lock.json b/typescript/package-lock.json index c7f8c9918..661937381 100644 --- a/typescript/package-lock.json +++ b/typescript/package-lock.json @@ -13602,11 +13602,11 @@ "@coinbase/coinbase-sdk": "^0.19.0", "@jup-ag/api": "^6.0.39", "@privy-io/server-auth": "^1.18.4", - "@solana/spl-token": "^0.4.12", - "@solana/web3.js": "^1.98.0", "@safe-global/api-kit": "^2.5.8", "@safe-global/protocol-kit": "^5.2.1", "@safe-global/safe-core-sdk-types": "^5.1.0", + "@solana/spl-token": "^0.4.12", + "@solana/web3.js": "^1.98.0", "md5": "^2.3.0", "opensea-js": "^7.1.16", "reflect-metadata": "^0.2.2", @@ -14556,11 +14556,11 @@ "@coinbase/coinbase-sdk": "^0.20.0", "@jup-ag/api": "^6.0.39", "@privy-io/server-auth": "^1.18.4", - "@solana/spl-token": "^0.4.12", - "@solana/web3.js": "^1.98.0", "@safe-global/api-kit": "^2.5.8", "@safe-global/protocol-kit": "^5.2.1", "@safe-global/safe-core-sdk-types": "^5.1.0", + "@solana/spl-token": "^0.4.12", + "@solana/web3.js": "^1.98.0", "@types/jest": "^29.5.14", "@types/nunjucks": "^3.2.6", "@types/ora": "^3.2.0", From a25fa7fa3c28dd5ec0a734741eabae074f2439ed Mon Sep 17 00:00:00 2001 From: Philippe d'Argent Date: Mon, 24 Feb 2025 20:10:29 +0100 Subject: [PATCH 5/9] remove obsolete safeActionProvider --- .../src/action-providers/safe/index.ts | 1 - .../safe/safeActionProvider.test.ts | 209 ------ .../safe/safeActionProvider.ts | 680 ------------------ .../safe/safeApiActionProvider.test.ts | 16 +- .../safe/safeWalletActionProvider.test.ts | 56 +- .../safe/safeWalletActionProvider.ts | 18 +- .../src/action-providers/safe/schemas.ts | 57 +- .../safeWalletProvider.test.ts | 18 +- .../wallet-providers/safeWalletProvider.ts | 21 +- .../examples/langchain-cdp-chatbot/chatbot.ts | 11 +- .../langchain-safe-chatbot/chatbot.ts | 2 +- 11 files changed, 97 insertions(+), 992 deletions(-) delete mode 100644 typescript/agentkit/src/action-providers/safe/safeActionProvider.test.ts delete mode 100644 typescript/agentkit/src/action-providers/safe/safeActionProvider.ts diff --git a/typescript/agentkit/src/action-providers/safe/index.ts b/typescript/agentkit/src/action-providers/safe/index.ts index f644c5b3a..31ad3d543 100644 --- a/typescript/agentkit/src/action-providers/safe/index.ts +++ b/typescript/agentkit/src/action-providers/safe/index.ts @@ -1,4 +1,3 @@ export * from "./schemas"; -export * from "./safeActionProvider"; export * from "./safeWalletActionProvider"; export * from "./safeApiActionProvider"; diff --git a/typescript/agentkit/src/action-providers/safe/safeActionProvider.test.ts b/typescript/agentkit/src/action-providers/safe/safeActionProvider.test.ts deleted file mode 100644 index 0e0728eb4..000000000 --- a/typescript/agentkit/src/action-providers/safe/safeActionProvider.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { SafeActionProvider } from "./safeActionProvider"; -import { EvmWalletProvider } from "../../wallet-providers"; -import Safe from "@safe-global/protocol-kit"; -import SafeApiKit from "@safe-global/api-kit"; -import { waitForTransactionReceipt } from "viem/actions"; -import { SafeTransaction } from "@safe-global/safe-core-sdk-types"; - -jest.mock("@safe-global/protocol-kit"); -jest.mock("@safe-global/api-kit"); -jest.mock("viem/actions"); - -describe("Safe Action Provider", () => { - const MOCK_PRIVATE_KEY = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; - const MOCK_NETWORK_ID = "base-sepolia"; - const MOCK_SAFE_ADDRESS = "0x1234567890123456789012345678901234567890"; - const MOCK_SIGNER_ADDRESS = "0x2345678901234567890123456789012345678901"; - const MOCK_TX_HASH = "0xabcdef1234567890abcdef1234567890"; - - const MOCK_TRANSACTION = { - data: { - to: MOCK_SAFE_ADDRESS, - value: "0", - data: "0x", - }, - signatures: new Map(), - getSignature: jest.fn(), - addSignature: jest.fn(), - encodedSignatures: jest.fn(), - } as unknown as SafeTransaction; - - const MOCK_TX_RESULT = { - hash: MOCK_TX_HASH, - isExecuted: true, - transactionResponse: { hash: MOCK_TX_HASH }, - }; - - let actionProvider: SafeActionProvider; - let mockWallet: jest.Mocked; - let mockSafeSDK: jest.Mocked; - let mockApiKit: jest.Mocked; - - beforeEach(() => { - jest.clearAllMocks(); - - mockWallet = { - getAddress: jest.fn().mockReturnValue(MOCK_SIGNER_ADDRESS), - getNetwork: jest.fn().mockReturnValue({ protocolFamily: "evm", networkId: MOCK_NETWORK_ID }), - getPublicClient: jest.fn().mockReturnValue({ - transport: {}, - chain: { - blockExplorers: { - default: { url: "https://sepolia.basescan.org" }, - }, - }, - getBalance: jest.fn().mockResolvedValue(BigInt(1000000000000000000)), // 1 ETH - }), - } as unknown as jest.Mocked; - - const mockExternalSigner = { - sendTransaction: jest.fn().mockResolvedValue(MOCK_TX_HASH), - }; - - const mockReceipt = { - transactionHash: MOCK_TX_HASH, - status: 1, - blockNumber: 123456, - }; - - (waitForTransactionReceipt as jest.Mock).mockResolvedValue(mockReceipt); - - mockSafeSDK = { - getAddress: jest.fn().mockResolvedValue(MOCK_SAFE_ADDRESS), - getOwners: jest.fn().mockResolvedValue([MOCK_SIGNER_ADDRESS]), - getThreshold: jest.fn().mockResolvedValue(1), - createTransaction: jest.fn().mockResolvedValue(MOCK_TRANSACTION), - getTransactionHash: jest.fn(), - signHash: jest.fn().mockResolvedValue({ - data: "0x", - signer: MOCK_SIGNER_ADDRESS, - isContractSignature: false, - staticPart: () => "0x", - dynamicPart: () => "0x", - }), - executeTransaction: jest.fn().mockResolvedValue(MOCK_TX_RESULT), - connect: jest.fn().mockReturnThis(), - isSafeDeployed: jest.fn().mockResolvedValue(true), - createSafeDeploymentTransaction: jest.fn().mockResolvedValue({ - to: MOCK_SAFE_ADDRESS, - value: "0", - data: "0x", - }), - getSafeProvider: jest.fn().mockReturnValue({ - getExternalSigner: jest.fn().mockResolvedValue(mockExternalSigner), - }), - } as unknown as jest.Mocked; - - (Safe.init as jest.Mock).mockResolvedValue(mockSafeSDK); - - mockApiKit = { - getPendingTransactions: jest.fn().mockResolvedValue({ results: [], count: 0 }), - proposeTransaction: jest.fn(), - getTransaction: jest.fn().mockResolvedValue({ - safe: MOCK_SAFE_ADDRESS, - to: MOCK_SAFE_ADDRESS, - data: "0x", - value: "0", - operation: 0, - nonce: 0, - safeTxGas: 0, - baseGas: 0, - gasPrice: "0", - gasToken: "0x0000000000000000000000000000000000000000", - refundReceiver: "0x0000000000000000000000000000000000000000", - submissionDate: new Date().toISOString(), - executionDate: new Date().toISOString(), - modified: new Date().toISOString(), - transactionHash: MOCK_TX_HASH, - isExecuted: true, - isSuccessful: true, - safeTxHash: MOCK_TX_HASH, - confirmationsRequired: 1, - confirmations: [ - { - owner: MOCK_SIGNER_ADDRESS, - signature: "0x", - signatureType: "EOA", - submissionDate: new Date().toISOString(), - }, - ], - }), - } as unknown as jest.Mocked; - - (SafeApiKit as unknown as jest.Mock).mockImplementation(() => mockApiKit); - - actionProvider = new SafeActionProvider({ - privateKey: MOCK_PRIVATE_KEY, - networkId: MOCK_NETWORK_ID, - }); - }); - - describe("initializeSafe", () => { - it("should successfully create a new Safe", async () => { - const args = { - signers: ["0x3456789012345678901234567890123456789012"], - threshold: 2, - }; - - const response = await actionProvider.initializeSafe(mockWallet, args); - - expect(Safe.init).toHaveBeenCalled(); - expect(response).toContain("Successfully created Safe"); - expect(response).toContain(MOCK_SAFE_ADDRESS); - }); - - it("should handle Safe creation errors", async () => { - const args = { - signers: ["0x3456789012345678901234567890123456789012"], - threshold: 2, - }; - - (Safe.init as jest.Mock).mockRejectedValue(new Error("Failed to create Safe")); - - const response = await actionProvider.initializeSafe(mockWallet, args); - - expect(response).toContain("Error creating Safe"); - }); - }); - - describe("safeInfo", () => { - it("should successfully get Safe info", async () => { - const args = { - safeAddress: MOCK_SAFE_ADDRESS, - }; - - const response = await actionProvider.safeInfo(mockWallet, args); - - expect(response).toContain(MOCK_SAFE_ADDRESS); - expect(response).toContain("owners:"); - expect(response).toContain("Threshold:"); - }); - - it("should handle Safe info errors", async () => { - const args = { - safeAddress: MOCK_SAFE_ADDRESS, - }; - - mockSafeSDK.getOwners.mockRejectedValue(new Error("Failed to get owners")); - - const response = await actionProvider.safeInfo(mockWallet, args); - - expect(response).toContain("Error connecting to Safe"); - }); - }); - - describe("supportsNetwork", () => { - it("should return true for EVM networks", () => { - const result = actionProvider.supportsNetwork({ protocolFamily: "evm", networkId: "any" }); - expect(result).toBe(true); - }); - - it("should return false for non-EVM networks", () => { - const result = actionProvider.supportsNetwork({ - protocolFamily: "bitcoin", - networkId: "any", - }); - expect(result).toBe(false); - }); - }); -}); diff --git a/typescript/agentkit/src/action-providers/safe/safeActionProvider.ts b/typescript/agentkit/src/action-providers/safe/safeActionProvider.ts deleted file mode 100644 index cddd0f2de..000000000 --- a/typescript/agentkit/src/action-providers/safe/safeActionProvider.ts +++ /dev/null @@ -1,680 +0,0 @@ -import { z } from "zod"; -import { ActionProvider } from "../actionProvider"; -import { CreateAction } from "../actionDecorator"; -import { - InitializeSafeSchema, - SafeInfoSchema, - AddSignerSchema, - RemoveSignerSchema, - ChangeThresholdSchema, - ExecutePendingSchema, - WithdrawFromSafeSchema, - EnableAllowanceModuleSchema, -} from "./schemas"; -import { Network } from "../../network"; -import { NETWORK_ID_TO_VIEM_CHAIN } from "../../network/network"; -import { EvmWalletProvider } from "../../wallet-providers/evmWalletProvider"; - -import { Chain } from "viem/chains"; -import { waitForTransactionReceipt } from "viem/actions"; - -import Safe, { PredictedSafeProps } from "@safe-global/protocol-kit"; -import SafeApiKit from "@safe-global/api-kit"; -import { getAllowanceModuleDeployment } from "@safe-global/safe-modules-deployments"; -import { initializeClientIfNeeded } from "./utils"; - -/** - * Configuration options for the SafeActionProvider. - */ -export interface SafeActionProviderConfig { - /** - * The private key to use for the SafeActionProvider. - */ - privateKey?: string; - - /** - * The network ID to use for the SafeActionProvider. - */ - networkId?: string; -} - -/** - * SafeActionProvider is an action provider for Safe smart account interactions. - */ -export class SafeActionProvider extends ActionProvider { - private readonly privateKey: string; - private readonly chain: Chain; - private apiKit: SafeApiKit; - private safeClient: Safe | null = null; - private safeBaseUrl: string; - - /** - * Constructor for the SafeActionProvider class. - * - * @param config - The configuration options for the SafeActionProvider. - */ - constructor(config: SafeActionProviderConfig = {}) { - 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 private key - const privateKey = config.privateKey; - if (!privateKey) throw new Error("Private key is not configured"); - this.privateKey = privateKey; - - // Initialize apiKit with chain ID from Viem chain - this.apiKit = new SafeApiKit({ - chainId: BigInt(this.chain.id), - }); - this.safeBaseUrl = "https://app.safe.global/"; - } - - /** - * Initializes a new Safe smart account. - * - * @param walletProvider - The wallet provider to create the Safe. - * @param args - The input arguments for creating a Safe. - * @returns A message containing the Safe creation details. - */ - @CreateAction({ - name: "create_safe", - description: ` -Creates a new Safe smart account. -Takes the following inputs: -- signers: Array of additional signer addresses (optional, default: []) -- threshold: Number of required confirmations (optional, default: 1) - -Important notes: -- Requires gas to deploy the Safe contract -- The deployer (private key owner) will be automatically added as a signer -- Threshold must be <= number of signers - `, - schema: InitializeSafeSchema, - }) - async initializeSafe( - walletProvider: EvmWalletProvider, - args: z.infer, - ): Promise { - try { - const predictedSafe: PredictedSafeProps = { - safeAccountConfig: { - owners: [walletProvider.getAddress(), ...args.signers], - threshold: args.threshold, - }, - safeDeploymentConfig: { - saltNonce: BigInt(Date.now()).toString(), - }, - }; - - let safeClient = await Safe.init({ - provider: walletProvider.getPublicClient().transport, - signer: this.privateKey, - predictedSafe, - }); - - const safeAddress = await safeClient.getAddress(); - const deploymentTransaction = await safeClient.createSafeDeploymentTransaction(); - - // Execute transaction - const client = await safeClient.getSafeProvider().getExternalSigner(); - const txHash = await client?.sendTransaction({ - to: deploymentTransaction.to, - value: BigInt(deploymentTransaction.value), - data: deploymentTransaction.data as `0x${string}`, - chain: this.chain, - }); - const txReceipt = await waitForTransactionReceipt(client!, { hash: txHash! }); - const txLink = `${walletProvider.getPublicClient().chain?.blockExplorers?.default.url}/tx/${txReceipt.transactionHash}`; - - // Reconnect to newly deployed Safe - safeClient = await safeClient.connect({ safeAddress }); - - if (await safeClient.isSafeDeployed()) { - this.safeClient = safeClient; - - const safeAddress = await safeClient.getAddress(); - const safeOwners = await safeClient.getOwners(); - const safeThreshold = await safeClient.getThreshold(); - - return `Successfully created Safe at address ${safeAddress} with signers ${safeOwners} and threshold of ${safeThreshold}. Transaction link: ${txLink}. Safe dashboard link: ${this.safeBaseUrl}/home?safe=${safeAddress}`; - } else { - return `Error creating Safe`; - } - } catch (error) { - return `Error creating Safe: ${error instanceof Error ? error.message : String(error)}`; - } - } - - /** - * Connects to an existing Safe smart account. - * - * @param walletProvider - The wallet provider to connect to the Safe. - * @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 { - // Connect to Safe client - this.safeClient = await initializeClientIfNeeded( - this.safeClient, - args.safeAddress, - walletProvider.getPublicClient().transport, - this.privateKey, - ); - - // Get Safe info - const owners = await this.safeClient.getOwners(); - const threshold = await this.safeClient.getThreshold(); - const pendingTransactions = await this.apiKit.getPendingTransactions(args.safeAddress); - const balance = await walletProvider - .getPublicClient() - .getBalance({ address: args.safeAddress }); - const ethBalance = balance ? parseFloat(balance.toString()) / 1e18 : 0; - - // Get pending transactions - 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 at address ${args.safeAddress}: -- Balance: ${ethBalance.toFixed(5)} ETH -- ${owners.length} owners: ${owners.join(", ")} -- Threshold: ${threshold} -- Pending transactions: ${pendingTransactions.count}${pendingTxDetails}`; - } catch (error) { - return `Error connecting to Safe: ${error instanceof Error ? error.message : String(error)}`; - } - } - - /** - * Adds a new signer to an existing Safe. - * - * @param walletProvider - The wallet provider to connect to the Safe. - * @param args - The input arguments for adding a signer. - * @returns A message containing the signer addition details. - */ - @CreateAction({ - name: "add_signer", - description: ` -Adds a new signer to an existing Safe. -Takes the following inputs: -- safeAddress: Address of the Safe to modify -- newSigner: Address of the new signer to add -- newThreshold: (Optional) New threshold after adding signer - -Important notes: -- Requires an existing Safe -- Must be called by an existing signer -- Requires confirmation from other signers if threshold > 1 -- If newThreshold not provided, keeps existing threshold -`, - schema: AddSignerSchema, - }) - async addSigner( - walletProvider: EvmWalletProvider, - args: z.infer, - ): Promise { - try { - // Connect to Safe client - this.safeClient = await initializeClientIfNeeded( - this.safeClient, - args.safeAddress, - walletProvider.getPublicClient().transport, - this.privateKey, - ); - - // Get current threshold - const currentThreshold = await this.safeClient.getThreshold(); - - // Add new signer - const safeTransaction = await this.safeClient.createAddOwnerTx({ - ownerAddress: args.newSigner, - threshold: args.newThreshold || currentThreshold, - }); - - 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: args.safeAddress, - safeTransactionData: safeTransaction.data, - safeTxHash, - senderSignature: signature.data, - senderAddress: walletProvider.getAddress(), - }); - - return `Successfully proposed adding signer ${args.newSigner} to Safe ${args.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 - await this.safeClient.executeTransaction(safeTransaction); - return `Successfully added signer ${args.newSigner} to Safe ${args.safeAddress}.`; - } - } catch (error) { - return `Error adding signer: ${error instanceof Error ? error.message : String(error)}`; - } - } - - /** - * Removes a signer from an existing Safe. - * - * @param walletProvider - The wallet provider to connect to the Safe. - * @param args - The input arguments for removing a signer. - * @returns A message containing the signer removal details. - */ - @CreateAction({ - name: "remove_signer", - description: ` -Removes a signer from an existing Safe. -Takes the following inputs: -- safeAddress: Address of the Safe to modify -- signerToRemove: Address of the signer to remove -- newThreshold: (Optional) New threshold after removing signer - -Important notes: -- Requires an existing Safe -- Must be called by an existing signer -- Cannot remove the last signer -- If newThreshold not provided, keeps existing threshold if valid -`, - schema: RemoveSignerSchema, - }) - async removeSigner( - walletProvider: EvmWalletProvider, - args: z.infer, - ): Promise { - try { - // Connect to Safe client - this.safeClient = await initializeClientIfNeeded( - this.safeClient, - args.safeAddress, - walletProvider.getPublicClient().transport, - this.privateKey, - ); - - const currentSigners = await this.safeClient.getOwners(); - const currentThreshold = await this.safeClient.getThreshold(); - - if (currentSigners.length <= 1) { - throw new Error("Cannot remove the last signer"); - } - - const safeTransaction = await this.safeClient.createRemoveOwnerTx({ - ownerAddress: args.signerToRemove, - threshold: - args.newThreshold || - (currentThreshold > currentSigners.length - 1 - ? currentSigners.length - 1 - : currentThreshold), - }); - - 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: args.safeAddress, - safeTransactionData: safeTransaction.data, - safeTxHash, - senderSignature: signature.data, - senderAddress: walletProvider.getAddress(), - }); - - return `Successfully proposed removing signer ${args.signerToRemove} from Safe ${args.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 - await this.safeClient.executeTransaction(safeTransaction); - return `Successfully removed signer ${args.signerToRemove} from Safe ${args.safeAddress}`; - } - } catch (error) { - return `Error removing signer: ${error instanceof Error ? error.message : String(error)}`; - } - } - - /** - * Changes the confirmation threshold of an existing Safe. - * - * @param walletProvider - The wallet provider to connect to the Safe. - * @param args - The input arguments for changing the threshold. - * @returns A message containing the threshold change details. - */ - @CreateAction({ - name: "change_threshold", - description: ` -Changes the confirmation threshold of an existing Safe. -Takes the following inputs: -- safeAddress: Address of the Safe to modify -- newThreshold: New threshold value (must be >= 1 and <= number of signers) - -Important notes: -- Requires an existing Safe -- Must be called by an existing signer -- New threshold must not exceed number of signers -`, - schema: ChangeThresholdSchema, - }) - async changeThreshold( - walletProvider: EvmWalletProvider, - args: z.infer, - ): Promise { - try { - // Connect to Safe client - this.safeClient = await initializeClientIfNeeded( - this.safeClient, - args.safeAddress, - walletProvider.getPublicClient().transport, - this.privateKey, - ); - - const currentSigners = await this.safeClient.getOwners(); - const currentThreshold = await this.safeClient.getThreshold(); - - if (args.newThreshold > currentSigners.length) { - throw new Error("New threshold cannot exceed number of signers"); - } - - const safeTransaction = await this.safeClient.createChangeThresholdTx(args.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: args.safeAddress, - safeTransactionData: safeTransaction.data, - safeTxHash, - senderSignature: signature.data, - senderAddress: walletProvider.getAddress(), - }); - - return `Successfully proposed changing threshold to ${args.newThreshold} for Safe ${args.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 - await this.safeClient.executeTransaction(safeTransaction); - return `Successfully changed threshold to ${args.newThreshold} for Safe ${args.safeAddress}`; - } - } catch (error) { - return `Error changing threshold: ${error instanceof Error ? error.message : String(error)}`; - } - } - - /** - * Executes pending transactions for a Safe if enough signatures are collected. - * - * @param walletProvider - The wallet provider to connect to the Safe. - * @param args - The input arguments for executing pending transactions. - * @returns A message containing the execution details. - */ - @CreateAction({ - name: "execute_pending", - description: ` -Executes pending transactions for a Safe if enough signatures are collected. -Takes the following inputs: -- safeAddress: Address of the Safe -- safeTxHash: (Optional) Specific transaction hash to execute. If not provided, will try to execute all pending transactions - -Important notes: -- Requires an existing Safe -- Must be called by an existing signer -- Transaction must have enough signatures to meet threshold -- Will fail if threshold is not met -`, - schema: ExecutePendingSchema, - }) - async executePending( - walletProvider: EvmWalletProvider, - args: z.infer, - ): Promise { - try { - // Connect to Safe client - this.safeClient = await initializeClientIfNeeded( - this.safeClient, - args.safeAddress, - walletProvider.getPublicClient().transport, - this.privateKey, - ); - - const pendingTxs = await this.apiKit.getPendingTransactions(args.safeAddress); - - if (pendingTxs.results.length === 0) { - return "No pending transactions found."; - } - - let executedTxs = 0; - let skippedTxs = 0; - const txsToProcess = args.safeTxHash - ? pendingTxs.results.filter( - tx => tx.safeTxHash === args.safeTxHash && tx.isExecuted === false, - ) - : pendingTxs.results.filter(tx => tx.isExecuted === false); - - for (const tx of txsToProcess) { - // Skip if not enough confirmations - if (tx.confirmations && tx.confirmations.length < tx.confirmationsRequired) { - skippedTxs++; - continue; - } - await this.safeClient.executeTransaction(tx); - executedTxs++; - } - - if (executedTxs === 0 && skippedTxs > 0) { - return `No transactions executed. ${skippedTxs} transaction(s) have insufficient confirmations.`; - } - - return `Execution complete. Successfully executed ${executedTxs} transaction(s)${skippedTxs > 0 ? `, ${skippedTxs} still pending and need more confirmations` : ""}.${ - args.safeTxHash ? ` Safe transaction hash: ${args.safeTxHash}` : "" - }`; - } catch (error) { - return `Error executing transactions: ${error instanceof Error ? error.message : String(error)}`; - } - } - - /** - * Withdraws ETH from the Safe. - * - * @param walletProvider - The wallet provider to connect to the Safe. - * @param args - The input arguments for withdrawing ETH. - * @returns A message containing the withdrawal details. - */ - @CreateAction({ - name: "withdraw_eth", - description: ` -Withdraws ETH from the Safe. -Takes the following inputs: -- safeAddress: Address of the Safe -- recipientAddress: Address to receive the ETH -- amount: (Optional) Amount of ETH to withdraw. If not provided, withdraws entire balance - -Important notes: -- Requires an existing Safe -- Must be called by an existing signer -- Requires confirmation from other signers if threshold > 1 -`, - schema: WithdrawFromSafeSchema, - }) - async withdrawEth( - walletProvider: EvmWalletProvider, - args: z.infer, - ): Promise { - try { - // Connect to Safe client - this.safeClient = await initializeClientIfNeeded( - this.safeClient, - args.safeAddress, - walletProvider.getPublicClient().transport, - this.privateKey, - ); - - const currentThreshold = await this.safeClient.getThreshold(); - const balance = await walletProvider - .getPublicClient() - .getBalance({ address: args.safeAddress }); - - // Calculate amount to withdraw - let withdrawAmount: bigint; - if (args.amount) { - withdrawAmount = BigInt(Math.floor(parseFloat(args.amount) * 1e18)); - if (withdrawAmount > balance) { - throw new Error( - `Insufficient balance. Safe has ${parseFloat(balance.toString()) / 1e18} ETH`, - ); - } - } else { - withdrawAmount = balance; - } - - const safeTransaction = await this.safeClient.createTransaction({ - transactions: [ - { - to: args.recipientAddress, - data: "0x", - value: withdrawAmount.toString(), - }, - ], - }); - - 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: args.safeAddress, - safeTransactionData: safeTransaction.data, - safeTxHash, - senderSignature: signature.data, - senderAddress: walletProvider.getAddress(), - }); - - return `Successfully proposed withdrawing ${parseFloat(withdrawAmount.toString()) / 1e18} ETH to ${args.recipientAddress}. Safe transaction hash: ${safeTxHash}. The other signers will need to confirm the transaction before it can be executed.`; - } else { - // Single-sig flow: execute immediately - await this.safeClient.executeTransaction(safeTransaction); - return `Successfully withdrew ${parseFloat(withdrawAmount.toString()) / 1e18} ETH to ${args.recipientAddress}`; - } - } catch (error) { - return `Error withdrawing ETH: ${error instanceof Error ? error.message : String(error)}`; - } - } - - /** - * Enables the allowance module for a Safe, allowing for token spending allowances. - * - * @param walletProvider - The wallet provider to connect to the Safe. - * @param args - The input arguments for enabling the allowance module. - * @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 input: -- safeAddress: Address of the Safe - -Important notes: -- Requires an existing Safe -- 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: EvmWalletProvider, - args: z.infer, - ): Promise { - try { - // Connect to Safe client - this.safeClient = await initializeClientIfNeeded( - this.safeClient, - args.safeAddress, - walletProvider.getPublicClient().transport, - this.privateKey, - ); - - const isSafeDeployed = await this.safeClient.isSafeDeployed(); - if (!isSafeDeployed) { - throw new Error("Safe not deployed"); - } - - // 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 and execute/propose transaction - 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: args.safeAddress, - safeTransactionData: safeTransaction.data, - safeTxHash, - senderSignature: signature.data, - senderAddress: walletProvider.getAddress(), - }); - - return `Successfully proposed enabling allowance module for Safe ${args.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 - await this.safeClient.executeTransaction(safeTransaction); - return `Successfully enabled allowance module for Safe ${args.safeAddress}`; - } - } catch (error) { - return `Error enabling allowance module: ${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 safeActionProvider = (config?: SafeActionProviderConfig) => - new SafeActionProvider(config); diff --git a/typescript/agentkit/src/action-providers/safe/safeApiActionProvider.test.ts b/typescript/agentkit/src/action-providers/safe/safeApiActionProvider.test.ts index 5c43adce7..fdc2a6bed 100644 --- a/typescript/agentkit/src/action-providers/safe/safeApiActionProvider.test.ts +++ b/typescript/agentkit/src/action-providers/safe/safeApiActionProvider.test.ts @@ -80,7 +80,7 @@ describe("Safe API Action Provider", () => { singleton: "0x123", fallbackHandler: "0x456", guard: "0x789", - version: "1.0.0" + version: "1.0.0", }; const mockPendingTransactions = { @@ -89,16 +89,14 @@ describe("Safe API Action Provider", () => { safeTxHash: "0xabc", isExecuted: false, confirmationsRequired: 2, - confirmations: [ - { owner: "0x123" }, - { owner: "0x456" }, - ], + 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 = { @@ -115,7 +113,9 @@ describe("Safe API Action Provider", () => { 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)"); + expect(response).toContain( + "Transaction 0xabc (2/2 confirmations, confirmed by: 0x123, 0x456)", + ); }); it("should handle errors when getting Safe info", async () => { @@ -133,13 +133,15 @@ describe("Safe API Action Provider", () => { 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); }); }); -}); \ No newline at end of file +}); diff --git a/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.test.ts b/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.test.ts index 46ead0d68..1313902d5 100644 --- a/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.test.ts +++ b/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.test.ts @@ -1,7 +1,6 @@ import { SafeWalletActionProvider } from "./safeWalletActionProvider"; import { SafeWalletProvider } from "../../wallet-providers"; import { AddSignerSchema } from "./schemas"; -import Safe from "@safe-global/protocol-kit"; // Mock Safe SDK modules jest.mock("@safe-global/protocol-kit"); @@ -24,28 +23,17 @@ describe("SafeWalletActionProvider", () => { mockWallet = { getAddress: jest.fn().mockReturnValue(MOCK_SAFE_ADDRESS), getNetwork: jest.fn().mockReturnValue({ networkId: "base-sepolia" }), - getSafeClient: jest.fn(), 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.` - ), + 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(), - } as unknown as jest.Mocked; - - // Mock Safe client methods - const mockSafeClient = { getOwners: jest.fn().mockResolvedValue(["0xowner1", "0xowner2"]), getThreshold: jest.fn().mockResolvedValue(2), - createTransaction: jest.fn().mockResolvedValue({ - data: { safeTxHash: MOCK_TRANSACTION_HASH }, - }), - getPendingTransactions: jest.fn().mockResolvedValue({ - results: [], - }), - } as unknown as Safe; - - mockWallet.getSafeClient.mockReturnValue(mockSafeClient); + } as unknown as jest.Mocked; }); describe("Input Schema Validation", () => { @@ -93,7 +81,7 @@ describe("SafeWalletActionProvider", () => { mockWallet.addOwnerWithThreshold.mockRejectedValue(error); await expect(actionProvider.addSigner(mockWallet, args)).rejects.toThrow( - "Failed to add signer: Address is already an owner of this Safe" + "Failed to add signer: Address is already an owner of this Safe", ); }); @@ -108,7 +96,7 @@ describe("SafeWalletActionProvider", () => { mockWallet.addOwnerWithThreshold.mockRejectedValue(error); await expect(actionProvider.addSigner(mockWallet, args)).rejects.toThrow( - "Failed to add signer: Threshold must be at least 1" + "Failed to add signer: Threshold must be at least 1", ); }); @@ -123,7 +111,7 @@ describe("SafeWalletActionProvider", () => { 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)" + "Failed to add signer: Invalid threshold: 4 cannot be greater than number of owners (3)", ); }); }); @@ -136,9 +124,11 @@ describe("SafeWalletActionProvider", () => { 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.` - ); + 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); @@ -157,7 +147,7 @@ describe("SafeWalletActionProvider", () => { mockWallet.removeOwnerWithThreshold = jest.fn().mockRejectedValue(error); await expect(actionProvider.removeSigner(mockWallet, args)).rejects.toThrow( - "Address is not an owner of this Safe" + "Address is not an owner of this Safe", ); }); }); @@ -169,13 +159,17 @@ describe("SafeWalletActionProvider", () => { 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.` - ); + 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( + `Successfully proposed changing threshold to ${args.newThreshold}`, + ); expect(response).toContain(`Safe transaction hash: ${MOCK_TRANSACTION_HASH}`); }); @@ -189,7 +183,7 @@ describe("SafeWalletActionProvider", () => { mockWallet.changeThreshold = jest.fn().mockRejectedValue(error); await expect(actionProvider.changeThreshold(mockWallet, args)).rejects.toThrow( - "Threshold cannot be greater than owners length" + "Threshold cannot be greater than owners length", ); }); }); @@ -205,4 +199,4 @@ describe("SafeWalletActionProvider", () => { expect(actionProvider.supportsNetwork(nonEvmNetwork)).toBe(false); }); }); -}); \ No newline at end of file +}); diff --git a/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.ts b/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.ts index b0d9e7e3e..1909f47f8 100644 --- a/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.ts +++ b/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.ts @@ -18,11 +18,11 @@ export class SafeWalletActionProvider extends ActionProvider } /** - * Adds a new signer to the Safe multi-sig wallet. + * Adds a new signer to the Safe wallet * - * @param walletProvider - The SafeWalletProvider instance to use - * @param args - The input arguments for creating a Safe. - * @returns A Promise that resolves to the transaction hash. + * @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", @@ -49,7 +49,11 @@ export class SafeWalletActionProvider extends ActionProvider } /** - * Removes a signer from the Safe. + * 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", @@ -75,6 +79,10 @@ Important notes: /** * 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", diff --git a/typescript/agentkit/src/action-providers/safe/schemas.ts b/typescript/agentkit/src/action-providers/safe/schemas.ts index 9dbf30d29..58b6dbe91 100644 --- a/typescript/agentkit/src/action-providers/safe/schemas.ts +++ b/typescript/agentkit/src/action-providers/safe/schemas.ts @@ -1,36 +1,49 @@ import { z } from "zod"; -export const InitializeSafeSchema = z.object({ - signers: z - .array(z.string().regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format")) - .describe("Array of additional signer addresses for the Safe") - .default([]), - threshold: z.number().min(1).describe("Number of required confirmations").default(1), -}); - 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"), + 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 AddSignerSchema = z.object({ - safeAddress: z.string().regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format").describe("Address of the Safe to modify"), - newSigner: z.string().regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format").describe("Address of the new signer to add"), + safeAddress: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format") + .describe("Address of the Safe to modify"), + 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({ - safeAddress: z.string().regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format").describe("Address of the Safe to modify"), - signerToRemove: z.string().regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format").describe("Address of the signer to remove"), + safeAddress: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format") + .describe("Address of the Safe to modify"), + 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({ - safeAddress: z.string().regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format").describe("Address of the Safe to modify"), + safeAddress: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format") + .describe("Address of the Safe to modify"), newThreshold: z.number().min(1).describe("New threshold value"), }); export const ExecutePendingSchema = z.object({ - safeAddress: z.string().regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format").describe("Address of the Safe"), + safeAddress: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format") + .describe("Address of the Safe"), safeTxHash: z .string() .optional() @@ -39,15 +52,9 @@ export const ExecutePendingSchema = z.object({ ), }); -export const WithdrawFromSafeSchema = z.object({ - safeAddress: z.string().regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format").describe("Address of the Safe"), - recipientAddress: z.string().regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format").describe("Address to receive the ETH"), - amount: z - .string() - .optional() - .describe("Amount of ETH to withdraw (e.g. '0.1'). If not provided, withdraws entire balance"), -}); - export const EnableAllowanceModuleSchema = z.object({ - safeAddress: z.string().regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format").describe("Address of the Safe to enable allowance module for"), + safeAddress: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format") + .describe("Address of the Safe to enable allowance module for"), }); diff --git a/typescript/agentkit/src/wallet-providers/safeWalletProvider.test.ts b/typescript/agentkit/src/wallet-providers/safeWalletProvider.test.ts index 3361f1888..9872493c4 100644 --- a/typescript/agentkit/src/wallet-providers/safeWalletProvider.test.ts +++ b/typescript/agentkit/src/wallet-providers/safeWalletProvider.test.ts @@ -60,7 +60,7 @@ describe("SafeWalletProvider", () => { await provider.waitForInitialization(); expect(provider.getName()).toBe("safe_wallet_provider"); - + const network = provider.getNetwork(); expect(network).toEqual({ protocolFamily: "evm", @@ -94,7 +94,7 @@ describe("SafeWalletProvider", () => { (Safe.init as jest.Mock).mockResolvedValue({ getAddress: jest.fn().mockResolvedValue(mockSafeAddress), }); - + const provider = new SafeWalletProvider({ privateKey: mockPrivateKey, networkId: mockNetworkId, @@ -104,9 +104,11 @@ describe("SafeWalletProvider", () => { await provider.waitForInitialization(); expect(provider.getAddress()).toBe(mockSafeAddress); - expect(Safe.init).toHaveBeenCalledWith(expect.objectContaining({ - safeAddress: mockSafeAddress, - })); + expect(Safe.init).toHaveBeenCalledWith( + expect.objectContaining({ + safeAddress: mockSafeAddress, + }), + ); }); it("should fail if account has no ETH balance when creating new Safe", async () => { @@ -124,9 +126,7 @@ describe("SafeWalletProvider", () => { // 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." + "Creating Safe account requires gaas fees. Please ensure you have enough ETH in your wallet.", ); - + }); }); - -}); \ No newline at end of file diff --git a/typescript/agentkit/src/wallet-providers/safeWalletProvider.ts b/typescript/agentkit/src/wallet-providers/safeWalletProvider.ts index 86772cd6e..d5e20b6ed 100644 --- a/typescript/agentkit/src/wallet-providers/safeWalletProvider.ts +++ b/typescript/agentkit/src/wallet-providers/safeWalletProvider.ts @@ -94,7 +94,7 @@ export class SafeWalletProvider extends WalletProvider { /** * Returns a promise that resolves when the wallet is initialized - * + * * @returns Promise that resolves when initialization is complete */ async waitForInitialization(): Promise { @@ -330,13 +330,14 @@ export class SafeWalletProvider extends WalletProvider { } // Determine new threshold (keep current if valid, otherwise reduce) - newThreshold = newThreshold || + 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})` + `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"); @@ -383,7 +384,7 @@ export class SafeWalletProvider extends WalletProvider { // Validate new threshold if (newThreshold > currentOwners.length) { throw new Error( - `Invalid threshold: ${newThreshold} cannot be greater than number of owners (${currentOwners.length})` + `Invalid threshold: ${newThreshold} cannot be greater than number of owners (${currentOwners.length})`, ); } if (newThreshold < 1) throw new Error("Threshold must be at least 1"); @@ -495,16 +496,4 @@ export class SafeWalletProvider extends WalletProvider { return existingAddress; } } - - /** - * Returns the Safe client instance - * - * @returns The Safe client - */ - getSafeClient(): Safe { - if (!this.#safeClient) { - throw new Error("Safe client not initialized"); - } - return this.#safeClient; - } } diff --git a/typescript/examples/langchain-cdp-chatbot/chatbot.ts b/typescript/examples/langchain-cdp-chatbot/chatbot.ts index 2b9058679..245560eac 100644 --- a/typescript/examples/langchain-cdp-chatbot/chatbot.ts +++ b/typescript/examples/langchain-cdp-chatbot/chatbot.ts @@ -10,7 +10,7 @@ import { pythActionProvider, openseaActionProvider, alloraActionProvider, - safeActionProvider, + safeApiActionProvider, } from "@coinbase/agentkit"; import { getLangChainTools } from "@coinbase/agentkit-langchain"; import { HumanMessage } from "@langchain/core/messages"; @@ -97,7 +97,7 @@ async function initializeAgent() { // Initialize AgentKit const agentkit = await AgentKit.from({ - walletProvider: walletProvider, + walletProvider, actionProviders: [ wethActionProvider(), pythActionProvider(), @@ -123,12 +123,7 @@ async function initializeAgent() { ] : []), alloraActionProvider(), - safeActionProvider( - { - networkId: walletProvider.getNetwork().networkId, - privateKey: await (await walletProvider.getWallet().getDefaultAddress()).export(), - } - ), + safeApiActionProvider({ networkId: process.env.NETWORK_ID || "base-sepolia" }), ], }); diff --git a/typescript/examples/langchain-safe-chatbot/chatbot.ts b/typescript/examples/langchain-safe-chatbot/chatbot.ts index c96d6a3b8..88ef49b50 100644 --- a/typescript/examples/langchain-safe-chatbot/chatbot.ts +++ b/typescript/examples/langchain-safe-chatbot/chatbot.ts @@ -91,7 +91,7 @@ async function initializeAgent() { safeAddress, }); await safeWallet.waitForInitialization(); - + // 3) Initialize AgentKit with the Safe wallet and some typical action providers const agentkit = await AgentKit.from({ walletProvider: safeWallet, From 88e59a669dbee936466ad46e5c1626b1ee50cf7e Mon Sep 17 00:00:00 2001 From: Philippe d'Argent Date: Mon, 24 Feb 2025 23:31:53 +0100 Subject: [PATCH 6/9] add approvePendingTransaction and enableAllowanceModule actions --- typescript/agentkit/package.json | 6 +- .../safe/safeApiActionProvider.ts | 2 + .../safe/safeWalletActionProvider.test.ts | 91 +++++++++++++ .../safe/safeWalletActionProvider.ts | 64 ++++++++- .../src/action-providers/safe/schemas.ts | 19 +-- .../src/action-providers/safe/utils.ts | 40 ------ .../wallet-providers/safeWalletProvider.ts | 125 ++++++++++++++++++ typescript/package-lock.json | 72 +++++----- 8 files changed, 327 insertions(+), 92 deletions(-) delete mode 100644 typescript/agentkit/src/action-providers/safe/utils.ts diff --git a/typescript/agentkit/package.json b/typescript/agentkit/package.json index cb9bc350b..039effcaf 100644 --- a/typescript/agentkit/package.json +++ b/typescript/agentkit/package.json @@ -44,11 +44,11 @@ "@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", + "@safe-global/safe-core-sdk-types": "^5.1.0", "@solana/spl-token": "^0.4.12", "@solana/web3.js": "^1.98.0", - "@safe-global/api-kit": "^2.5.8", - "@safe-global/protocol-kit": "^5.2.1", - "@safe-global/safe-core-sdk-types": "^5.1.0", "md5": "^2.3.0", "opensea-js": "^7.1.18", "reflect-metadata": "^0.2.2", diff --git a/typescript/agentkit/src/action-providers/safe/safeApiActionProvider.ts b/typescript/agentkit/src/action-providers/safe/safeApiActionProvider.ts index b5b5fa3ab..baf1066cf 100644 --- a/typescript/agentkit/src/action-providers/safe/safeApiActionProvider.ts +++ b/typescript/agentkit/src/action-providers/safe/safeApiActionProvider.ts @@ -70,7 +70,9 @@ Important notes: ): Promise { try { // Get Safe info + console.log("Getting Safe info for address:", args.safeAddress); const safeInfo = await this.apiKit.getSafeInfo(args.safeAddress); + console.log("Safe info:", safeInfo); const owners = safeInfo.owners; const threshold = safeInfo.threshold; diff --git a/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.test.ts b/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.test.ts index 1313902d5..bddccacca 100644 --- a/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.test.ts +++ b/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.test.ts @@ -33,6 +33,8 @@ describe("SafeWalletActionProvider", () => { changeThreshold: jest.fn(), getOwners: jest.fn().mockResolvedValue(["0xowner1", "0xowner2"]), getThreshold: jest.fn().mockResolvedValue(2), + approvePendingTransaction: jest.fn(), + enableAllowanceModule: jest.fn(), } as unknown as jest.Mocked; }); @@ -199,4 +201,93 @@ describe("SafeWalletActionProvider", () => { 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", + ); + }); + }); }); diff --git a/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.ts b/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.ts index 1909f47f8..b8844945c 100644 --- a/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.ts +++ b/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.ts @@ -2,7 +2,13 @@ import { z } from "zod"; import { CreateAction } from "../actionDecorator"; import { ActionProvider } from "../actionProvider"; import { SafeWalletProvider } from "../../wallet-providers/safeWalletProvider"; -import { AddSignerSchema, RemoveSignerSchema, ChangeThresholdSchema } from "./schemas"; +import { + AddSignerSchema, + RemoveSignerSchema, + ChangeThresholdSchema, + ApprovePendingTransactionSchema, + EnableAllowanceModuleSchema, +} from "./schemas"; import { Network } from "../../network"; /** @@ -104,6 +110,62 @@ Important notes: 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 a Safe. +Takes the following inputs: +- safeAddress: Address of the Safe +- safeTxHash: Transaction hash to approve/execute +- executeImmediately: (Optional) Whether to execute the transaction immediately if all signatures are collected (default: true) + +Important notes: +- Requires an existing Safe +- 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 input: +- safeAddress: Address of the Safe + +Important notes: +- Requires an existing Safe +- 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(); + } + /** * Checks if the Safe action provider supports the given network. * diff --git a/typescript/agentkit/src/action-providers/safe/schemas.ts b/typescript/agentkit/src/action-providers/safe/schemas.ts index 58b6dbe91..ac8977006 100644 --- a/typescript/agentkit/src/action-providers/safe/schemas.ts +++ b/typescript/agentkit/src/action-providers/safe/schemas.ts @@ -39,22 +39,17 @@ export const ChangeThresholdSchema = z.object({ newThreshold: z.number().min(1).describe("New threshold value"), }); -export const ExecutePendingSchema = z.object({ +export const ApprovePendingTransactionSchema = z.object({ safeAddress: z .string() .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format") .describe("Address of the Safe"), - safeTxHash: z - .string() + safeTxHash: z.string().describe("Transaction hash to approve/execute"), + executeImmediately: z + .boolean() .optional() - .describe( - "Optional specific transaction hash to execute. If not provided, will try to execute all pending transactions", - ), + .default(true) + .describe("Whether to execute the transaction immediately if all signatures are collected"), }); -export const EnableAllowanceModuleSchema = z.object({ - safeAddress: z - .string() - .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format") - .describe("Address of the Safe to enable allowance module for"), -}); +export const EnableAllowanceModuleSchema = z.object({}); diff --git a/typescript/agentkit/src/action-providers/safe/utils.ts b/typescript/agentkit/src/action-providers/safe/utils.ts deleted file mode 100644 index b404b2b88..000000000 --- a/typescript/agentkit/src/action-providers/safe/utils.ts +++ /dev/null @@ -1,40 +0,0 @@ -import Safe from "@safe-global/protocol-kit"; -import { PublicClient } from "viem"; - -/** - * Initializes or reinitializes a Safe client if needed - * - * @param currentClient - The current Safe client instance - * @param safeAddress - The target Safe address - * @param provider - The provider for initializing the client - * @param signer - The signer for initializing the client - * @returns The initialized Safe client - */ -export const initializeClientIfNeeded = async ( - currentClient: Safe | null, - safeAddress: string, - provider: PublicClient["transport"], - signer: string, -): Promise => { - // If no client exists, initialize new one - if (!currentClient) { - return await Safe.init({ - provider, - signer, - safeAddress, - }); - } - - // If client exists but for different Safe address, reinitialize - const currentAddress = await currentClient.getAddress(); - if (currentAddress.toLowerCase() !== safeAddress.toLowerCase()) { - return await Safe.init({ - provider, - signer, - safeAddress, - }); - } - - // Return existing client if it's for the same Safe - return currentClient; -}; diff --git a/typescript/agentkit/src/wallet-providers/safeWalletProvider.ts b/typescript/agentkit/src/wallet-providers/safeWalletProvider.ts index d5e20b6ed..a07cea978 100644 --- a/typescript/agentkit/src/wallet-providers/safeWalletProvider.ts +++ b/typescript/agentkit/src/wallet-providers/safeWalletProvider.ts @@ -16,6 +16,7 @@ import { PublicClient } from "viem"; // 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. @@ -412,6 +413,130 @@ export class SafeWalletProvider extends WalletProvider { } } + /** + * 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 we have 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)}`, + ); + } + } + /** * Gets the public client instance. * diff --git a/typescript/package-lock.json b/typescript/package-lock.json index 661937381..fd88c0bad 100644 --- a/typescript/package-lock.json +++ b/typescript/package-lock.json @@ -3040,24 +3040,24 @@ "dev": true }, "node_modules/@safe-global/api-kit": { - "version": "2.5.8", - "resolved": "https://registry.npmjs.org/@safe-global/api-kit/-/api-kit-2.5.8.tgz", - "integrity": "sha512-+509+k/QAkqbCdR3XpD46b2Qy7gxKTeY+buNGzd35KgS+LaFbgj4b4fDH9xYgEXEFRlDAxmCmwAi8bc+9+ByOA==", + "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.1", - "@safe-global/types-kit": "^1.0.2", + "@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.1", - "resolved": "https://registry.npmjs.org/@safe-global/protocol-kit/-/protocol-kit-5.2.1.tgz", - "integrity": "sha512-7jY+p2+GQU9FPKMMgHDr32gPC/1BKtYVzvQHniZuPSJVcb26E0CBwZb7IUyRiUd+kJN8OF13zRFbhQdH99Akhw==", + "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.26", + "@safe-global/safe-deployments": "^1.37.28", "@safe-global/safe-modules-deployments": "^2.2.5", - "@safe-global/types-kit": "^1.0.2", + "@safe-global/types-kit": "^1.0.4", "abitype": "^1.0.2", "semver": "^7.6.3", "viem": "^2.21.8" @@ -3077,9 +3077,9 @@ } }, "node_modules/@safe-global/safe-deployments": { - "version": "1.37.27", - "resolved": "https://registry.npmjs.org/@safe-global/safe-deployments/-/safe-deployments-1.37.27.tgz", - "integrity": "sha512-0r/BzntT/XUPlca11obF1Zog1m8jLSGVbb97FqxnjaEzqWf+N7dBwAYJ9Voq3e7hlqpcVLMh3O6VDAf4y+5ndg==", + "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" } @@ -3090,9 +3090,9 @@ "integrity": "sha512-rkKqj6gGKokjBX2JshH1EwjPkzFD14oOk2vY3LSxKhpkQyjdqxcj+OlWkjzpi613HzCtxY0mHgMiyLID7nzueA==" }, "node_modules/@safe-global/types-kit": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@safe-global/types-kit/-/types-kit-1.0.2.tgz", - "integrity": "sha512-KiRlU1nlWuj4Qr+nLxgO/yRpJamVUOonnAkLrSMrzfGwXcfLWXJJtoD9sv/pvTIHMWSOlzgkrc1KXqY3K68SGg==", + "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" } @@ -13602,8 +13602,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.8", - "@safe-global/protocol-kit": "^5.2.1", + "@safe-global/api-kit": "^2.5.11", + "@safe-global/protocol-kit": "^5.2.4", "@safe-global/safe-core-sdk-types": "^5.1.0", "@solana/spl-token": "^0.4.12", "@solana/web3.js": "^1.98.0", @@ -14556,8 +14556,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.8", - "@safe-global/protocol-kit": "^5.2.1", + "@safe-global/api-kit": "^2.5.11", + "@safe-global/protocol-kit": "^5.2.4", "@safe-global/safe-core-sdk-types": "^5.1.0", "@solana/spl-token": "^0.4.12", "@solana/web3.js": "^1.98.0", @@ -15797,26 +15797,26 @@ "dev": true }, "@safe-global/api-kit": { - "version": "2.5.8", - "resolved": "https://registry.npmjs.org/@safe-global/api-kit/-/api-kit-2.5.8.tgz", - "integrity": "sha512-+509+k/QAkqbCdR3XpD46b2Qy7gxKTeY+buNGzd35KgS+LaFbgj4b4fDH9xYgEXEFRlDAxmCmwAi8bc+9+ByOA==", + "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.1", - "@safe-global/types-kit": "^1.0.2", + "@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.1", - "resolved": "https://registry.npmjs.org/@safe-global/protocol-kit/-/protocol-kit-5.2.1.tgz", - "integrity": "sha512-7jY+p2+GQU9FPKMMgHDr32gPC/1BKtYVzvQHniZuPSJVcb26E0CBwZb7IUyRiUd+kJN8OF13zRFbhQdH99Akhw==", + "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.26", + "@safe-global/safe-deployments": "^1.37.28", "@safe-global/safe-modules-deployments": "^2.2.5", - "@safe-global/types-kit": "^1.0.2", + "@safe-global/types-kit": "^1.0.4", "abitype": "^1.0.2", "semver": "^7.6.3", "viem": "^2.21.8" @@ -15831,9 +15831,9 @@ } }, "@safe-global/safe-deployments": { - "version": "1.37.27", - "resolved": "https://registry.npmjs.org/@safe-global/safe-deployments/-/safe-deployments-1.37.27.tgz", - "integrity": "sha512-0r/BzntT/XUPlca11obF1Zog1m8jLSGVbb97FqxnjaEzqWf+N7dBwAYJ9Voq3e7hlqpcVLMh3O6VDAf4y+5ndg==", + "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" } @@ -15844,9 +15844,9 @@ "integrity": "sha512-rkKqj6gGKokjBX2JshH1EwjPkzFD14oOk2vY3LSxKhpkQyjdqxcj+OlWkjzpi613HzCtxY0mHgMiyLID7nzueA==" }, "@safe-global/types-kit": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@safe-global/types-kit/-/types-kit-1.0.2.tgz", - "integrity": "sha512-KiRlU1nlWuj4Qr+nLxgO/yRpJamVUOonnAkLrSMrzfGwXcfLWXJJtoD9sv/pvTIHMWSOlzgkrc1KXqY3K68SGg==", + "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" } From c125a01443827f1184e246cc3610afb82e7070ea Mon Sep 17 00:00:00 2001 From: Philippe d'Argent Date: Wed, 26 Feb 2025 15:26:36 +0100 Subject: [PATCH 7/9] add set_allowance, get_allowance_info, withdraw_allowance actions --- typescript/agentkit/package.json | 1 - .../safe/safeApiActionProvider.ts | 266 +++++++++++- .../safe/safeWalletActionProvider.ts | 39 ++ .../src/action-providers/safe/schemas.ts | 54 +++ .../src/wallet-providers/cdpWalletProvider.ts | 21 + .../src/wallet-providers/evmWalletProvider.ts | 8 + .../wallet-providers/safeWalletProvider.ts | 397 ++++++++++++++++-- .../wallet-providers/viemWalletProvider.ts | 14 + .../langchain-safe-chatbot/chatbot.ts | 2 + typescript/package-lock.json | 19 - 10 files changed, 763 insertions(+), 58 deletions(-) diff --git a/typescript/agentkit/package.json b/typescript/agentkit/package.json index 039effcaf..d187b2494 100644 --- a/typescript/agentkit/package.json +++ b/typescript/agentkit/package.json @@ -46,7 +46,6 @@ "@privy-io/server-auth": "^1.18.4", "@safe-global/api-kit": "^2.5.11", "@safe-global/protocol-kit": "^5.2.4", - "@safe-global/safe-core-sdk-types": "^5.1.0", "@solana/spl-token": "^0.4.12", "@solana/web3.js": "^1.98.0", "md5": "^2.3.0", diff --git a/typescript/agentkit/src/action-providers/safe/safeApiActionProvider.ts b/typescript/agentkit/src/action-providers/safe/safeApiActionProvider.ts index baf1066cf..092d0b979 100644 --- a/typescript/agentkit/src/action-providers/safe/safeApiActionProvider.ts +++ b/typescript/agentkit/src/action-providers/safe/safeApiActionProvider.ts @@ -2,11 +2,22 @@ import { z } from "zod"; import { CreateAction } from "../actionDecorator"; import { ActionProvider } from "../actionProvider"; import { EvmWalletProvider } from "../../wallet-providers"; -import { SafeInfoSchema } from "./schemas"; +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 { Chain, formatEther } from "viem"; import SafeApiKit from "@safe-global/api-kit"; +import { getAllowanceModuleDeployment } from "@safe-global/safe-modules-deployments"; /** * Configuration options for the SafeActionProvider. @@ -21,7 +32,7 @@ export interface SafeApiActionProviderConfig { /** * SafeApiActionProvider is an action provider for Safe. * - * This provider is used for any action that uses the Safe API, but does not require a Safe Wallet. + * 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 { private readonly chain: Chain; @@ -110,6 +121,255 @@ Important notes: } } + /** + * 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 the Safe's current balance of 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 +`, + 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)); + + // 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. * diff --git a/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.ts b/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.ts index b8844945c..d0808ad3e 100644 --- a/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.ts +++ b/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.ts @@ -8,6 +8,7 @@ import { ChangeThresholdSchema, ApprovePendingTransactionSchema, EnableAllowanceModuleSchema, + SetAllowanceSchema, } from "./schemas"; import { Network } from "../../network"; @@ -166,6 +167,44 @@ Important notes: 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: (Optional) Address of the ERC20 token (defaults to Sepolia WETH) +- amount: Amount of tokens to allow (e.g. '1.5' for 1.5 tokens) +- resetTimeInMinutes: Time in minutes after which the allowance resets + +Important notes: +- Requires an existing Safe +- Must be called by an existing signer +- Allowance module must be enabled first +- 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, + ); + } + /** * Checks if the Safe action provider supports the given network. * diff --git a/typescript/agentkit/src/action-providers/safe/schemas.ts b/typescript/agentkit/src/action-providers/safe/schemas.ts index ac8977006..602262e71 100644 --- a/typescript/agentkit/src/action-providers/safe/schemas.ts +++ b/typescript/agentkit/src/action-providers/safe/schemas.ts @@ -53,3 +53,57 @@ export const ApprovePendingTransactionSchema = z.object({ }); export const EnableAllowanceModuleSchema = z.object({}); + +export const SetAllowanceSchema = 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 who will receive the allowance"), + tokenAddress: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format") + .optional() + .describe("Address of the ERC20 token (defaults to Sepolia WETH)") + .default("0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9"), + 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), +}); + +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)"), +}); diff --git a/typescript/agentkit/src/wallet-providers/cdpWalletProvider.ts b/typescript/agentkit/src/wallet-providers/cdpWalletProvider.ts index 8f5542b3d..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. * diff --git a/typescript/agentkit/src/wallet-providers/evmWalletProvider.ts b/typescript/agentkit/src/wallet-providers/evmWalletProvider.ts index af1d70600..9fabbd675 100644 --- a/typescript/agentkit/src/wallet-providers/evmWalletProvider.ts +++ b/typescript/agentkit/src/wallet-providers/evmWalletProvider.ts @@ -18,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. * diff --git a/typescript/agentkit/src/wallet-providers/safeWalletProvider.ts b/typescript/agentkit/src/wallet-providers/safeWalletProvider.ts index a07cea978..d9dacbaaf 100644 --- a/typescript/agentkit/src/wallet-providers/safeWalletProvider.ts +++ b/typescript/agentkit/src/wallet-providers/safeWalletProvider.ts @@ -1,4 +1,4 @@ -import { WalletProvider } from "./walletProvider"; +import { EvmWalletProvider } from "./evmWalletProvider"; import { Network } from "../network"; import { Account, @@ -6,18 +6,28 @@ import { 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 Safe, { EthSafeSignature } from "@safe-global/protocol-kit"; import SafeApiKit from "@safe-global/api-kit"; import { getAllowanceModuleDeployment } from "@safe-global/safe-modules-deployments"; - +import SafeTransaction from "@safe-global/protocol-kit/dist/src/utils/transactions/SafeTransaction"; /** * Configuration options for the SafeWalletProvider. */ @@ -43,7 +53,7 @@ export interface SafeWalletProviderConfig { * 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 WalletProvider { +export class SafeWalletProvider extends EvmWalletProvider { #privateKey: string; #account: Account; #chain: Chain; @@ -79,7 +89,7 @@ export class SafeWalletProvider extends WalletProvider { // 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 `0x${string}`); + this.#account = privateKeyToAccount(this.#privateKey as Hex); this.#initializationPromise = this.initializeSafe(config.safeAddress).then( address => { @@ -138,27 +148,6 @@ export class SafeWalletProvider extends WalletProvider { return "safe_wallet_provider"; } - /** - * 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: `0x${string}`): Promise { - return await this.#publicClient.waitForTransactionReceipt({ hash: txHash }); - } - - /** - * Reads data from a contract. - * - * @param params - The parameters to read the contract. - * @returns The response from the contract. - */ - async readContract(params: ReadContractParameters): Promise { - return this.#publicClient.readContract(params); - } - /** * Queries the current Safe balance. * @@ -168,7 +157,7 @@ export class SafeWalletProvider extends WalletProvider { async getBalance(): Promise { if (!this.#safeAddress) throw new Error("Safe address is not set."); const balance = await this.#publicClient.getBalance({ - address: this.#safeAddress as `0x${string}`, + address: this.#safeAddress as Hex, }); return balance; } @@ -193,7 +182,7 @@ export class SafeWalletProvider extends WalletProvider { const safeTx = await this.#safeClient.createTransaction({ transactions: [ { - to: to as `0x${string}`, + to: to as Hex, data: "0x", value: ethAmountInWei.toString(), }, @@ -221,7 +210,7 @@ export class SafeWalletProvider extends WalletProvider { } else { // Single-sig flow: execute immediately const response = await this.#safeClient.executeTransaction(safeTx); - const receipt = await this.waitForTransactionReceipt(response.hash as `0x${string}`); + const receipt = await this.waitForTransactionReceipt(response.hash as Hex); return `Successfully transferred ${value} ETH to ${to}. Transaction hash: ${receipt.transactionHash}`; } } catch (error) { @@ -231,6 +220,195 @@ export class SafeWalletProvider extends WalletProvider { } } + /** + * Signs a hash using the private key of the account that controls the Safe. + * + * @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 controls the Safe. + * + * @param message - The message to sign. + * @returns The signature as a hex string. + */ + async signMessage(message: string | Uint8Array): Promise<`0x${string}`> { + 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 controls 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<`0x${string}`> { + 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<`0x${string}`> { + 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); + console.log("signature", signature); + + // Return the signature + return signature as unknown as `0x${string}`; + // return signature.data.data as `0x${string}`; + } 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<`0x${string}`> { + 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", + }, + ], + }); + + // signTransaction + const safeTransaction = (await this.signTransaction( + transaction, + )) as unknown as SafeTransaction; + console.log("signature from signTransaction", safeTransaction); + const signatureOwner1 = safeTransaction.getSignature( + this.#account.address, + ) as EthSafeSignature; + console.log("signatureOwner1", signatureOwner1); + // 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); + console.log("signature from signHash", signature); + + // Propose the transaction + await this.#apiKit.proposeTransaction({ + safeAddress: this.getAddress(), + safeTransactionData: safeTx.data, + safeTxHash, + senderSignature: signature.data, + senderAddress: this.#account.address, + }); + + console.log(`Transaction proposed with Safe transaction hash: ${safeTxHash}`); + return safeTxHash as `0x${string}`; + } else { + // Single-sig flow: execute immediately + const response = await this.#safeClient.executeTransaction(safeTx); + + await this.waitForTransactionReceipt(response.hash as Hex); + return response.hash as `0x${string}`; + } + } 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. * @@ -510,6 +688,7 @@ export class SafeWalletProvider extends WalletProvider { // Create transaction to enable module const safeTransaction = await this.#safeClient.createEnableModuleTx(moduleAddress); const currentThreshold = await this.#safeClient.getThreshold(); + console.log("currentThreshold", currentThreshold); if (currentThreshold > 1) { // Multi-sig flow: propose transaction @@ -538,12 +717,160 @@ export class SafeWalletProvider extends WalletProvider { } /** - * Gets the public client instance. + * Sets an allowance for a delegate to spend tokens from the Safe. * - * @returns The Viem PublicClient instance. + * @param delegateAddress - Address that will receive the allowance + * @param tokenAddress - Address of the ERC20 token (optional, defaults to Sepolia WETH) + * @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 */ - getPublicClient(): PublicClient { - return this.#publicClient; + 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."); + } + + // Default to WETH if no token address provided + const tokenAddress_ = tokenAddress || "0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9"; // Sepolia WETH + + // 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; + } + console.log("isDelegate", isDelegate); + + // 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)}`, + ); + } } /** @@ -594,10 +921,10 @@ export class SafeWalletProvider extends WalletProvider { const hash = await externalSigner?.sendTransaction({ to: deploymentTx.to, value: BigInt(deploymentTx.value), - data: deploymentTx.data as `0x${string}`, + data: deploymentTx.data as Hex, chain: this.#publicClient.chain, }); - const receipt = await this.waitForTransactionReceipt(hash as `0x${string}`); + const receipt = await this.waitForTransactionReceipt(hash as Hex); // Reconnect to the deployed Safe const safeAddress = await safeSdk.getAddress(); diff --git a/typescript/agentkit/src/wallet-providers/viemWalletProvider.ts b/typescript/agentkit/src/wallet-providers/viemWalletProvider.ts index 2c313b263..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. * diff --git a/typescript/examples/langchain-safe-chatbot/chatbot.ts b/typescript/examples/langchain-safe-chatbot/chatbot.ts index 88ef49b50..95b844a39 100644 --- a/typescript/examples/langchain-safe-chatbot/chatbot.ts +++ b/typescript/examples/langchain-safe-chatbot/chatbot.ts @@ -9,6 +9,7 @@ import { SafeWalletProvider, safeWalletActionProvider, safeApiActionProvider, + erc20ActionProvider, } from "@coinbase/agentkit"; import { getLangChainTools } from "@coinbase/agentkit-langchain"; @@ -99,6 +100,7 @@ async function initializeAgent() { walletActionProvider(), safeWalletActionProvider(), safeApiActionProvider({ networkId: networkId }), + erc20ActionProvider(), ], }); diff --git a/typescript/package-lock.json b/typescript/package-lock.json index fd88c0bad..e0afd51d8 100644 --- a/typescript/package-lock.json +++ b/typescript/package-lock.json @@ -3067,15 +3067,6 @@ "@peculiar/asn1-schema": "^2.3.13" } }, - "node_modules/@safe-global/safe-core-sdk-types": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@safe-global/safe-core-sdk-types/-/safe-core-sdk-types-5.1.0.tgz", - "integrity": "sha512-UzXR4zWmVzux25FcIm4H049QhZZpVpIBL5HE+V0p5gHpArZROL+t24fZmsKUf403CtBxIJM5zZSVQL0nFJi+IQ==", - "deprecated": "WARNING: This project has been renamed to @safe-global/types-kit. Please, migrate from @safe-global/safe-core-sdk-types@5.1.0 to @safe-global/types-kit@1.0.0.", - "dependencies": { - "abitype": "^1.0.2" - } - }, "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", @@ -13604,7 +13595,6 @@ "@privy-io/server-auth": "^1.18.4", "@safe-global/api-kit": "^2.5.11", "@safe-global/protocol-kit": "^5.2.4", - "@safe-global/safe-core-sdk-types": "^5.1.0", "@solana/spl-token": "^0.4.12", "@solana/web3.js": "^1.98.0", "md5": "^2.3.0", @@ -14558,7 +14548,6 @@ "@privy-io/server-auth": "^1.18.4", "@safe-global/api-kit": "^2.5.11", "@safe-global/protocol-kit": "^5.2.4", - "@safe-global/safe-core-sdk-types": "^5.1.0", "@solana/spl-token": "^0.4.12", "@solana/web3.js": "^1.98.0", "@types/jest": "^29.5.14", @@ -15822,14 +15811,6 @@ "viem": "^2.21.8" } }, - "@safe-global/safe-core-sdk-types": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@safe-global/safe-core-sdk-types/-/safe-core-sdk-types-5.1.0.tgz", - "integrity": "sha512-UzXR4zWmVzux25FcIm4H049QhZZpVpIBL5HE+V0p5gHpArZROL+t24fZmsKUf403CtBxIJM5zZSVQL0nFJi+IQ==", - "requires": { - "abitype": "^1.0.2" - } - }, "@safe-global/safe-deployments": { "version": "1.37.30", "resolved": "https://registry.npmjs.org/@safe-global/safe-deployments/-/safe-deployments-1.37.30.tgz", From 03214f079d7e2985f90602ac16dec399c13fe7e8 Mon Sep 17 00:00:00 2001 From: Philippe d'Argent Date: Wed, 26 Feb 2025 17:33:24 +0100 Subject: [PATCH 8/9] add missing tests --- .github/workflows/unit_tests.yml | 3 +- .../safe/safeApiActionProvider.test.ts | 295 +++++++++++++++++- .../safe/safeApiActionProvider.ts | 41 ++- .../safe/safeWalletActionProvider.test.ts | 64 ++++ .../safe/safeWalletActionProvider.ts | 16 +- .../src/action-providers/safe/schemas.ts | 78 ++--- .../src/wallet-providers/evmWalletProvider.ts | 1 - .../wallet-providers/safeWalletProvider.ts | 65 ++-- .../langchain-safe-chatbot/.env-local | 2 +- .../langchain-safe-chatbot/.eslintrc.json | 2 +- .../examples/langchain-safe-chatbot/README.md | 7 +- .../langchain-safe-chatbot/chatbot.ts | 33 +- .../langchain-safe-chatbot/tsconfig.json | 2 +- typescript/package-lock.json | 2 + 14 files changed, 471 insertions(+), 140 deletions(-) diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index e563e989f..a29963f40 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -42,6 +42,7 @@ jobs: test-agentkit-typescript: runs-on: ubuntu-latest + timeout-minutes: 15 strategy: matrix: node-version: ["18", "20"] @@ -56,4 +57,4 @@ jobs: working-directory: ./typescript run: | npm ci - npm run test + npm run test -- -- --testTimeout=300000 diff --git a/typescript/agentkit/src/action-providers/safe/safeApiActionProvider.test.ts b/typescript/agentkit/src/action-providers/safe/safeApiActionProvider.test.ts index fdc2a6bed..ab7dc283e 100644 --- a/typescript/agentkit/src/action-providers/safe/safeApiActionProvider.test.ts +++ b/typescript/agentkit/src/action-providers/safe/safeApiActionProvider.test.ts @@ -43,7 +43,9 @@ describe("Safe API Action Provider", () => { let mockSafeApiKit: jest.Mocked; const MOCK_SAFE_ADDRESS = "0xe6b2af36b3bb8d47206a129ff11d5a2de2a63c83"; - const MOCK_NETWORK = "base-sepolia"; + 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(() => { @@ -144,4 +146,295 @@ describe("Safe API Action Provider", () => { 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 index 092d0b979..702d81e66 100644 --- a/typescript/agentkit/src/action-providers/safe/safeApiActionProvider.ts +++ b/typescript/agentkit/src/action-providers/safe/safeApiActionProvider.ts @@ -35,8 +35,8 @@ export interface SafeApiActionProviderConfig { * 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 { - private readonly chain: Chain; - private apiKit: SafeApiKit; + #chain: Chain; + #apiKit: SafeApiKit; /** * Constructor for the SafeActionProvider class. @@ -47,12 +47,12 @@ export class SafeApiActionProvider extends ActionProvider { 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}`); + 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), + this.#apiKit = new SafeApiKit({ + chainId: BigInt(this.#chain.id), }); } @@ -81,9 +81,7 @@ Important notes: ): Promise { try { // Get Safe info - console.log("Getting Safe info for address:", args.safeAddress); - const safeInfo = await this.apiKit.getSafeInfo(args.safeAddress); - console.log("Safe info:", safeInfo); + const safeInfo = await this.#apiKit.getSafeInfo(args.safeAddress); const owners = safeInfo.owners; const threshold = safeInfo.threshold; @@ -96,7 +94,7 @@ Important notes: ); // Get pending transactions - const pendingTransactions = await this.apiKit.getPendingTransactions(args.safeAddress); + const pendingTransactions = await this.#apiKit.getPendingTransactions(args.safeAddress); const pendingTxDetails = pendingTransactions.results .filter(tx => !tx.isExecuted) .map(tx => { @@ -109,7 +107,7 @@ Important notes: return `Safe info: - Safe at address: ${args.safeAddress} -- Chain: ${this.chain.name} +- Chain: ${this.#chain.name} - ${owners.length} owners: ${owners.join(", ")} - Threshold: ${threshold} - Nonce: ${nonce} @@ -149,7 +147,7 @@ Important notes: ): Promise { try { // Get allowance module for current chain - const chainId = this.chain.id.toString(); + const chainId = this.#chain.id.toString(); const allowanceModule = getAllowanceModuleDeployment({ network: chainId }); if (!allowanceModule) { throw new Error(`Allowance module not found for chainId [${chainId}]`); @@ -198,7 +196,7 @@ Important notes: functionName: "decimals", })) as number; - // Get the Safe's current balance of this token + // Get Safe balance for this token safeBalance = (await walletProvider.readContract({ address: tokenAddress, abi: ERC20_ABI, @@ -281,6 +279,7 @@ Important notes: - Allowance module must be enabled - Must have sufficient allowance - Amount must be within allowance limit +- Safe must have sufficient token balance `, schema: WithdrawAllowanceSchema, }) @@ -290,7 +289,7 @@ Important notes: ): Promise { try { // Get allowance module for current chain - const chainId = this.chain.id.toString(); + const chainId = this.#chain.id.toString(); const allowanceModule = getAllowanceModuleDeployment({ network: chainId }); if (!allowanceModule) { throw new Error(`Allowance module not found for chainId [${chainId}]`); @@ -323,6 +322,20 @@ Important notes: // 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, diff --git a/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.test.ts b/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.test.ts index bddccacca..8c1956c31 100644 --- a/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.test.ts +++ b/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.test.ts @@ -35,6 +35,7 @@ describe("SafeWalletActionProvider", () => { getThreshold: jest.fn().mockResolvedValue(2), approvePendingTransaction: jest.fn(), enableAllowanceModule: jest.fn(), + setAllowance: jest.fn(), } as unknown as jest.Mocked; }); @@ -290,4 +291,67 @@ describe("SafeWalletActionProvider", () => { ); }); }); + + 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 index d0808ad3e..67aa06d9b 100644 --- a/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.ts +++ b/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.ts @@ -121,14 +121,12 @@ Important notes: @CreateAction({ name: "approve_pending", description: ` -Approves and optionally executes a pending transaction for a Safe. +Approves and optionally executes a pending transaction for connected Safe. Takes the following inputs: -- safeAddress: Address of the Safe - safeTxHash: Transaction hash to approve/execute - executeImmediately: (Optional) Whether to execute the transaction immediately if all signatures are collected (default: true) Important notes: -- Requires an existing Safe - 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 @@ -152,11 +150,8 @@ Important notes: name: "enable_allowance_module", description: ` Enables the allowance module for a Safe, allowing for token spending allowances. -Takes the following input: -- safeAddress: Address of the Safe Important notes: -- Requires an existing Safe - Must be called by an existing signer - Requires confirmation from other signers if threshold > 1 - Module can only be enabled once @@ -180,14 +175,13 @@ Important notes: Sets a token spending allowance for a delegate address. Takes the following inputs: - delegateAddress: Address that will receive the allowance -- tokenAddress: (Optional) Address of the ERC20 token (defaults to Sepolia WETH) +- 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 +- 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: -- Requires an existing Safe - Must be called by an existing signer -- Allowance module must be enabled first +- 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 `, @@ -201,7 +195,7 @@ Important notes: args.delegateAddress, args.tokenAddress, args.amount, - args.resetTimeInMinutes, + args.resetTimeInMinutes || 0, ); } diff --git a/typescript/agentkit/src/action-providers/safe/schemas.ts b/typescript/agentkit/src/action-providers/safe/schemas.ts index 602262e71..b51d0db7e 100644 --- a/typescript/agentkit/src/action-providers/safe/schemas.ts +++ b/typescript/agentkit/src/action-providers/safe/schemas.ts @@ -7,11 +7,36 @@ export const SafeInfoSchema = z.object({ .describe("Address of the existing Safe to connect to"), }); -export const AddSignerSchema = z.object({ +export const GetAllowanceInfoSchema = z.object({ safeAddress: z .string() .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format") - .describe("Address of the Safe to modify"), + .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") @@ -20,10 +45,6 @@ export const AddSignerSchema = z.object({ }); export const RemoveSignerSchema = z.object({ - safeAddress: z - .string() - .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format") - .describe("Address of the Safe to modify"), signerToRemove: z .string() .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format") @@ -32,18 +53,10 @@ export const RemoveSignerSchema = z.object({ }); export const ChangeThresholdSchema = z.object({ - safeAddress: z - .string() - .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format") - .describe("Address of the Safe to modify"), newThreshold: z.number().min(1).describe("New threshold value"), }); export const ApprovePendingTransactionSchema = z.object({ - safeAddress: z - .string() - .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format") - .describe("Address of the Safe"), safeTxHash: z.string().describe("Transaction hash to approve/execute"), executeImmediately: z .boolean() @@ -55,10 +68,6 @@ export const ApprovePendingTransactionSchema = z.object({ export const EnableAllowanceModuleSchema = z.object({}); export const SetAllowanceSchema = 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") @@ -66,9 +75,7 @@ export const SetAllowanceSchema = z.object({ tokenAddress: z .string() .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format") - .optional() - .describe("Address of the ERC20 token (defaults to Sepolia WETH)") - .default("0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9"), + .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() @@ -78,32 +85,3 @@ export const SetAllowanceSchema = z.object({ ) .default(0), }); - -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)"), -}); diff --git a/typescript/agentkit/src/wallet-providers/evmWalletProvider.ts b/typescript/agentkit/src/wallet-providers/evmWalletProvider.ts index 9fabbd675..6c67deb28 100644 --- a/typescript/agentkit/src/wallet-providers/evmWalletProvider.ts +++ b/typescript/agentkit/src/wallet-providers/evmWalletProvider.ts @@ -79,7 +79,6 @@ export abstract class EvmWalletProvider extends WalletProvider { >( params: ReadContractParameters, ): Promise>; - abstract readContract(params: ReadContractParameters): Promise; /** * Get the public client. diff --git a/typescript/agentkit/src/wallet-providers/safeWalletProvider.ts b/typescript/agentkit/src/wallet-providers/safeWalletProvider.ts index d9dacbaaf..77c54ace7 100644 --- a/typescript/agentkit/src/wallet-providers/safeWalletProvider.ts +++ b/typescript/agentkit/src/wallet-providers/safeWalletProvider.ts @@ -24,16 +24,15 @@ import { PublicClient } from "viem"; import { abi as ERC20_ABI } from "../action-providers/erc20/constants"; // Safe SDK imports -import Safe, { EthSafeSignature } from "@safe-global/protocol-kit"; +import Safe from "@safe-global/protocol-kit"; import SafeApiKit from "@safe-global/api-kit"; import { getAllowanceModuleDeployment } from "@safe-global/safe-modules-deployments"; -import SafeTransaction from "@safe-global/protocol-kit/dist/src/utils/transactions/SafeTransaction"; /** * Configuration options for the SafeWalletProvider. */ export interface SafeWalletProviderConfig { /** - * Private key of the signer that controls (or co-controls) the Safe. + * Private key of the signer that (co-)owns the Safe. */ privateKey: string; @@ -44,7 +43,7 @@ export interface SafeWalletProviderConfig { /** * Optional existing Safe address. If provided, will connect to that Safe; - * otherwise, this provider will deploy a new Safe. + * otherwise, this provider will deploy a new Safe with the private key as one the only owner. */ safeAddress?: string; } @@ -221,7 +220,8 @@ export class SafeWalletProvider extends EvmWalletProvider { } /** - * Signs a hash using the private key of the account that controls the Safe. + * 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. @@ -235,12 +235,13 @@ export class SafeWalletProvider extends EvmWalletProvider { } /** - * Signs a message using the private key of the account that controls the Safe. + * 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<`0x${string}`> { + async signMessage(message: string | Uint8Array): Promise { if (!this.#account) { throw new Error("Account not initialized"); } @@ -249,14 +250,14 @@ export class SafeWalletProvider extends EvmWalletProvider { } /** - * Signs typed data using the private key of the account that controls the Safe. + * 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<`0x${string}`> { + async signTypedData(typedData: any): Promise { if (!this.#account) { throw new Error("Account not initialized"); } @@ -276,7 +277,7 @@ export class SafeWalletProvider extends EvmWalletProvider { * @param transaction - The transaction to sign. * @returns The signature as a hex string. */ - async signTransaction(transaction: TransactionRequest): Promise<`0x${string}`> { + async signTransaction(transaction: TransactionRequest): Promise { if (!this.#safeClient) { throw new Error("Safe client is not set"); } @@ -295,11 +296,9 @@ export class SafeWalletProvider extends EvmWalletProvider { // Sign the transaction hash const signature = await this.#safeClient.signTransaction(safeTx); - console.log("signature", signature); // Return the signature - return signature as unknown as `0x${string}`; - // return signature.data.data as `0x${string}`; + return signature as unknown as Hex; } catch (error) { throw new Error( `Failed to sign transaction: ${error instanceof Error ? error.message : String(error)}`, @@ -315,7 +314,7 @@ export class SafeWalletProvider extends EvmWalletProvider { * @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<`0x${string}`> { + async sendTransaction(transaction: TransactionRequest): Promise { if (!this.#safeClient) throw new Error("Safe client is not set."); try { @@ -330,15 +329,6 @@ export class SafeWalletProvider extends EvmWalletProvider { ], }); - // signTransaction - const safeTransaction = (await this.signTransaction( - transaction, - )) as unknown as SafeTransaction; - console.log("signature from signTransaction", safeTransaction); - const signatureOwner1 = safeTransaction.getSignature( - this.#account.address, - ) as EthSafeSignature; - console.log("signatureOwner1", signatureOwner1); // Get current threshold const threshold = await this.#safeClient.getThreshold(); @@ -346,7 +336,6 @@ export class SafeWalletProvider extends EvmWalletProvider { // Multi-sig flow: propose transaction const safeTxHash = await this.#safeClient.getTransactionHash(safeTx); const signature = await this.#safeClient.signHash(safeTxHash); - console.log("signature from signHash", signature); // Propose the transaction await this.#apiKit.proposeTransaction({ @@ -357,14 +346,13 @@ export class SafeWalletProvider extends EvmWalletProvider { senderAddress: this.#account.address, }); - console.log(`Transaction proposed with Safe transaction hash: ${safeTxHash}`); - return safeTxHash as `0x${string}`; + 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 `0x${string}`; + return response.hash as Hex; } } catch (error) { throw new Error( @@ -644,7 +632,7 @@ export class SafeWalletProvider extends EvmWalletProvider { }`; } - // If agent has already signed and we have enough confirmations, execute if requested + // 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}`; @@ -688,7 +676,6 @@ export class SafeWalletProvider extends EvmWalletProvider { // Create transaction to enable module const safeTransaction = await this.#safeClient.createEnableModuleTx(moduleAddress); const currentThreshold = await this.#safeClient.getThreshold(); - console.log("currentThreshold", currentThreshold); if (currentThreshold > 1) { // Multi-sig flow: propose transaction @@ -720,7 +707,7 @@ export class SafeWalletProvider extends EvmWalletProvider { * 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 (optional, defaults to Sepolia WETH) + * @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 @@ -749,19 +736,16 @@ export class SafeWalletProvider extends EvmWalletProvider { throw new Error("Allowance module is not enabled for this Safe. Enable it first."); } - // Default to WETH if no token address provided - const tokenAddress_ = tokenAddress || "0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9"; // Sepolia WETH - // Get token symbol const tokenSymbol = await this.readContract({ - address: tokenAddress_ as Hex, + address: tokenAddress as Hex, abi: ERC20_ABI, functionName: "symbol", }); // Get token decimals and convert amount const tokenDecimals = await this.readContract({ - address: tokenAddress_ as Hex, + address: tokenAddress as Hex, abi: ERC20_ABI, functionName: "decimals", }); @@ -789,7 +773,6 @@ export class SafeWalletProvider extends EvmWalletProvider { // If the call fails, assume not a delegate isDelegate = false; } - console.log("isDelegate", isDelegate); // Add delegate (if not already a delegate) const addDelegateData = encodeFunctionData({ @@ -804,7 +787,7 @@ export class SafeWalletProvider extends EvmWalletProvider { functionName: "setAllowance", args: [ delegateAddress, - tokenAddress_, + tokenAddress, amountBigInt, BigInt(resetTimeInMinutes || 0), // Use 0 for one-time allowance if not specified BigInt(0), // resetBaseMin (0 is fine as default) @@ -860,11 +843,11 @@ export class SafeWalletProvider extends EvmWalletProvider { 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.`; + 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}.`; + return `Successfully ${delegateMsg}set allowance of ${amount} ${tokenSymbol} (${tokenAddress})${resetTimeMsg} for delegate ${delegateAddress}. Transaction hash: ${tx.hash}.`; } } catch (error) { throw new Error( @@ -924,7 +907,7 @@ export class SafeWalletProvider extends EvmWalletProvider { data: deploymentTx.data as Hex, chain: this.#publicClient.chain, }); - const receipt = await this.waitForTransactionReceipt(hash as Hex); + await this.waitForTransactionReceipt(hash as Hex); // Reconnect to the deployed Safe const safeAddress = await safeSdk.getAddress(); @@ -932,8 +915,6 @@ export class SafeWalletProvider extends EvmWalletProvider { this.#safeClient = reconnected; this.#safeAddress = safeAddress; - console.log("Safe deployed at:", safeAddress, "Receipt:", receipt.transactionHash); - return safeAddress; } else { // Connect to an existing Safe diff --git a/typescript/examples/langchain-safe-chatbot/.env-local b/typescript/examples/langchain-safe-chatbot/.env-local index 57232a737..36d600398 100644 --- a/typescript/examples/langchain-safe-chatbot/.env-local +++ b/typescript/examples/langchain-safe-chatbot/.env-local @@ -1,6 +1,6 @@ # Fill in these environment variables OPENAI_API_KEY= -SAFE_AGENT_PRIVATE_KEY= +SAFE_OWNER_PRIVATE_KEY= NETWORK_ID=base-sepolia # If you already have a deployed Safe, set this. Otherwise, a new Safe is created diff --git a/typescript/examples/langchain-safe-chatbot/.eslintrc.json b/typescript/examples/langchain-safe-chatbot/.eslintrc.json index fc9385e78..91571ba7a 100644 --- a/typescript/examples/langchain-safe-chatbot/.eslintrc.json +++ b/typescript/examples/langchain-safe-chatbot/.eslintrc.json @@ -1,4 +1,4 @@ { "parser": "@typescript-eslint/parser", - "extends": ["../../../.eslintrc.base.json"] + "extends": ["../../.eslintrc.base.json"] } diff --git a/typescript/examples/langchain-safe-chatbot/README.md b/typescript/examples/langchain-safe-chatbot/README.md index c643caeba..e8bd946c0 100644 --- a/typescript/examples/langchain-safe-chatbot/README.md +++ b/typescript/examples/langchain-safe-chatbot/README.md @@ -1,11 +1,12 @@ # Safe AgentKit LangChain Extension Example - Chatbot -This example demonstrates an agent using a Safe-based wallet provider, which allows interactions onchain from a multi-sig Safe. By default, it can create or connect to a Safe on the specified network via a private key. +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**: Your OpenAI API key -- **SAFE_AGENT_PRIVATE_KEY**: The private key that controls the Safe +- **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. diff --git a/typescript/examples/langchain-safe-chatbot/chatbot.ts b/typescript/examples/langchain-safe-chatbot/chatbot.ts index 95b844a39..c67b1c2e5 100644 --- a/typescript/examples/langchain-safe-chatbot/chatbot.ts +++ b/typescript/examples/langchain-safe-chatbot/chatbot.ts @@ -26,8 +26,8 @@ function validateEnv() { if (!process.env.OPENAI_API_KEY) { missing.push("OPENAI_API_KEY"); } - if (!process.env.SAFE_AGENT_PRIVATE_KEY) { - missing.push("SAFE_AGENT_PRIVATE_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(", ")); @@ -77,13 +77,13 @@ async function chooseMode(): Promise<"chat" | "auto"> { * @returns Agent executor and config */ async function initializeAgent() { - // 1) Create an LLM + // Initialize LLM const llm = new ChatOpenAI({ model: "gpt-4o-mini", // example model name }); - // 2) Configure SafeWalletProvider - const privateKey = process.env.SAFE_AGENT_PRIVATE_KEY as string; + // 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({ @@ -93,7 +93,7 @@ async function initializeAgent() { }); await safeWallet.waitForInitialization(); - // 3) Initialize AgentKit with the Safe wallet and some typical action providers + // Initialize AgentKit with the Safe wallet and some typical action providers const agentkit = await AgentKit.from({ walletProvider: safeWallet, actionProviders: [ @@ -104,24 +104,29 @@ async function initializeAgent() { ], }); - // 4) Convert to LangChain tools const tools = await getLangChainTools(agentkit); - // 5) Wrap in a memory saver for conversation + // Store buffered conversation history in memory const memory = new MemorySaver(); const agentConfig = { configurable: { thread_id: "Safe AgentKit Chatbot Example!" } }; - // 6) Create the agent + // Create the agent const agent = createReactAgent({ llm, tools, checkpointSaver: memory, messageModifier: ` - You are an agent with a Safe-based wallet. You can propose or execute actions - on the Safe. If threshold > 1, you may need confirmations from other signers - or to propose transactions. If threshold=1, you can execute immediately. - Be concise and helpful. If you cannot fulfill a request with your current tools, - apologize and suggest the user implement it themselves. + 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. `, }); diff --git a/typescript/examples/langchain-safe-chatbot/tsconfig.json b/typescript/examples/langchain-safe-chatbot/tsconfig.json index cd4edb798..a37da3664 100644 --- a/typescript/examples/langchain-safe-chatbot/tsconfig.json +++ b/typescript/examples/langchain-safe-chatbot/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../tsconfig.base.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "preserveSymlinks": true, "outDir": "./dist", diff --git a/typescript/package-lock.json b/typescript/package-lock.json index e0afd51d8..0661000c5 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", From 8236bde679ceff70d52c5f9dc3892e9595802096 Mon Sep 17 00:00:00 2001 From: Philippe d'Argent Date: Sat, 8 Mar 2025 12:06:57 +0100 Subject: [PATCH 9/9] add changelog --- .github/workflows/unit_tests.yml | 3 +- typescript/.changeset/tidy-cities-scream.md | 5 ++ .../src/action-providers/safe/README.md | 61 ++++++++++++++++++- .../safe/safeWalletActionProvider.ts | 22 ++++++- .../wallet-providers/smartWalletProvider.ts | 21 +++++++ typescript/package-lock.json | 33 ---------- 6 files changed, 108 insertions(+), 37 deletions(-) create mode 100644 typescript/.changeset/tidy-cities-scream.md diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index a29963f40..e563e989f 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -42,7 +42,6 @@ jobs: test-agentkit-typescript: runs-on: ubuntu-latest - timeout-minutes: 15 strategy: matrix: node-version: ["18", "20"] @@ -57,4 +56,4 @@ jobs: working-directory: ./typescript run: | npm ci - npm run test -- -- --testTimeout=300000 + npm run test 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/src/action-providers/safe/README.md b/typescript/agentkit/src/action-providers/safe/README.md index 1de9449a5..49ab5505d 100644 --- a/typescript/agentkit/src/action-providers/safe/README.md +++ b/typescript/agentkit/src/action-providers/safe/README.md @@ -1 +1,60 @@ -# Safe Action Provider \ No newline at end of file +# 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/safeWalletActionProvider.ts b/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.ts index 67aa06d9b..4d8e97e8a 100644 --- a/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.ts +++ b/typescript/agentkit/src/action-providers/safe/safeWalletActionProvider.ts @@ -33,7 +33,21 @@ export class SafeWalletActionProvider extends ActionProvider */ @CreateAction({ name: "add_signer", - description: "Add a new signer to the Safe multi-sig wallet", + 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( @@ -151,6 +165,12 @@ Important notes: 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 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/package-lock.json b/typescript/package-lock.json index 0661000c5..29418e6d1 100644 --- a/typescript/package-lock.json +++ b/typescript/package-lock.json @@ -10965,24 +10965,6 @@ } ] }, - "node_modules/pvtsutils": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", - "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", - "optional": true, - "dependencies": { - "tslib": "^2.8.1" - } - }, - "node_modules/pvutils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz", - "integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==", - "optional": true, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -21379,21 +21361,6 @@ "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", "dev": true }, - "pvtsutils": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", - "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", - "optional": true, - "requires": { - "tslib": "^2.8.1" - } - }, - "pvutils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz", - "integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==", - "optional": true - }, "qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",