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",