Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 21dfb7a

Browse files
committedFeb 24, 2025
add safeWalletProvider
1 parent 3fa1b10 commit 21dfb7a

File tree

13 files changed

+891
-1
lines changed

13 files changed

+891
-1
lines changed
 
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
export * from "./schemas";
22
export * from "./safeActionProvider";
3+
export * from "./safeWalletActionProvider";
4+
export * from "./safeApiActionProvider";
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { z } from "zod";
2+
import { CreateAction } from "../actionDecorator";
3+
import { ActionProvider } from "../actionProvider";
4+
import { EvmWalletProvider } from "../../wallet-providers";
5+
import { SafeInfoSchema } from "./schemas";
6+
import { Network, NETWORK_ID_TO_VIEM_CHAIN } from "../../network";
7+
8+
import { Chain, formatEther } from "viem";
9+
import SafeApiKit from "@safe-global/api-kit";
10+
11+
/**
12+
* Configuration options for the SafeActionProvider.
13+
*/
14+
export interface SafeApiActionProviderConfig {
15+
/**
16+
* The network ID to use for the SafeActionProvider.
17+
*/
18+
networkId?: string;
19+
}
20+
21+
/**
22+
* SafeApiActionProvider is an action provider for Safe.
23+
*
24+
* This provider is used for any action that uses the Safe API, but does not require a Safe Wallet.
25+
*/
26+
export class SafeApiActionProvider extends ActionProvider<EvmWalletProvider> {
27+
private readonly chain: Chain;
28+
private apiKit: SafeApiKit;
29+
30+
/**
31+
* Constructor for the SafeActionProvider class.
32+
*
33+
* @param config - The configuration options for the SafeActionProvider.
34+
*/
35+
constructor(config: SafeApiActionProviderConfig = {}) {
36+
super("safe", []);
37+
38+
// Initialize chain
39+
this.chain = NETWORK_ID_TO_VIEM_CHAIN[config.networkId || "base-sepolia"];
40+
if (!this.chain) throw new Error(`Unsupported network: ${config.networkId}`);
41+
42+
// Initialize apiKit with chain ID from Viem chain
43+
this.apiKit = new SafeApiKit({
44+
chainId: BigInt(this.chain.id),
45+
});
46+
}
47+
48+
/**
49+
* Connects to an existing Safe smart account.
50+
*
51+
* @param walletProvider - The wallet provider to use for the action.
52+
* @param args - The input arguments for connecting to a Safe.
53+
* @returns A message containing the connection details.
54+
*/
55+
@CreateAction({
56+
name: "safe_info",
57+
description: `
58+
Gets information about an existing Safe smart account.
59+
Takes the following input:
60+
- safeAddress: Address of the existing Safe to connect to
61+
62+
Important notes:
63+
- The Safe must already be deployed
64+
`,
65+
schema: SafeInfoSchema,
66+
})
67+
async safeInfo(
68+
walletProvider: EvmWalletProvider,
69+
args: z.infer<typeof SafeInfoSchema>,
70+
): Promise<string> {
71+
try {
72+
// Get Safe info
73+
const safeInfo = await this.apiKit.getSafeInfo(args.safeAddress);
74+
75+
const owners = safeInfo.owners;
76+
const threshold = safeInfo.threshold;
77+
const modules = safeInfo.modules;
78+
const nonce = safeInfo.nonce;
79+
80+
// Get balance
81+
const ethBalance = formatEther(
82+
await walletProvider.getPublicClient().getBalance({ address: args.safeAddress }),
83+
);
84+
85+
// Get pending transactions
86+
const pendingTransactions = await this.apiKit.getPendingTransactions(args.safeAddress);
87+
const pendingTxDetails = pendingTransactions.results
88+
.filter(tx => !tx.isExecuted)
89+
.map(tx => {
90+
const confirmations = tx.confirmations?.length || 0;
91+
const needed = tx.confirmationsRequired;
92+
const confirmedBy = tx.confirmations?.map(c => c.owner).join(", ") || "none";
93+
return `\n- Transaction ${tx.safeTxHash} (${confirmations}/${needed} confirmations, confirmed by: ${confirmedBy})`;
94+
})
95+
.join("");
96+
97+
return `Safe info:
98+
- Safe at address: ${args.safeAddress}
99+
- Chain: ${this.chain.name}
100+
- ${owners.length} owners: ${owners.join(", ")}
101+
- Threshold: ${threshold}
102+
- Nonce: ${nonce}
103+
- Modules: ${modules.join(", ")}
104+
- Balance: ${ethBalance} ETH
105+
- Pending transactions: ${pendingTransactions.count}${pendingTxDetails}`;
106+
} catch (error) {
107+
return `Safe info: Error connecting to Safe: ${error instanceof Error ? error.message : String(error)}`;
108+
}
109+
}
110+
111+
/**
112+
* Checks if the Safe action provider supports the given network.
113+
*
114+
* @param network - The network to check.
115+
* @returns True if the Safe action provider supports the network, false otherwise.
116+
*/
117+
supportsNetwork = (network: Network) => network.protocolFamily === "evm";
118+
}
119+
120+
export const safeApiActionProvider = (config: SafeApiActionProviderConfig = {}) =>
121+
new SafeApiActionProvider(config);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { z } from "zod";
2+
import { CreateAction } from "../actionDecorator";
3+
import { ActionProvider } from "../actionProvider";
4+
import { SafeWalletProvider } from "../../wallet-providers";
5+
import { AddSignerSchema } from "./schemas";
6+
import { Network } from "../../network";
7+
8+
/**
9+
* SafeWalletActionProvider provides actions for managing Safe multi-sig wallets.
10+
*/
11+
export class SafeWalletActionProvider extends ActionProvider<SafeWalletProvider> {
12+
/**
13+
* Constructor for the SafeWalletActionProvider class.
14+
*
15+
*/
16+
constructor() {
17+
super("safe_wallet", []);
18+
}
19+
20+
/**
21+
* Adds a new signer to the Safe multi-sig wallet.
22+
*
23+
* @param walletProvider - The SafeWalletProvider instance to use
24+
* @param args - The input arguments for creating a Safe.
25+
* @returns A Promise that resolves to the transaction hash.
26+
*/
27+
@CreateAction({
28+
name: "add_signer",
29+
description: "Add a new signer to the Safe multi-sig wallet",
30+
schema: AddSignerSchema,
31+
})
32+
async addSigner(
33+
walletProvider: SafeWalletProvider,
34+
args: z.infer<typeof AddSignerSchema>,
35+
): Promise<string> {
36+
try {
37+
// Create and propose/execute the transaction
38+
const addOwnerTx = await walletProvider.addOwnerWithThreshold(
39+
args.newSigner,
40+
args.newThreshold,
41+
);
42+
43+
return addOwnerTx;
44+
} catch (error) {
45+
throw new Error(
46+
`Failed to add signer: ${error instanceof Error ? error.message : String(error)}`,
47+
);
48+
}
49+
}
50+
51+
/**
52+
* Checks if the Safe action provider supports the given network.
53+
*
54+
* @param network - The network to check.
55+
* @returns True if the Safe action provider supports the network, false otherwise.
56+
*/
57+
supportsNetwork = (network: Network) => network.protocolFamily === "evm";
58+
}
59+
60+
/**
61+
* Creates a new SafeWalletActionProvider instance.
62+
*
63+
* @returns A new SafeWalletActionProvider instance
64+
*/
65+
export const safeWalletActionProvider = () => new SafeWalletActionProvider();

‎typescript/agentkit/src/wallet-providers/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ export * from "./solanaKeypairWalletProvider";
77
export * from "./privyWalletProvider";
88
export * from "./privyEvmWalletProvider";
99
export * from "./privySvmWalletProvider";
10+
export * from "./safeWalletProvider";
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,387 @@
1+
import { WalletProvider } from "./walletProvider";
2+
import { Network } from "../network";
3+
import {
4+
Account,
5+
Chain,
6+
createPublicClient,
7+
http,
8+
parseEther,
9+
ReadContractParameters,
10+
ReadContractReturnType,
11+
} from "viem";
12+
import { privateKeyToAccount } from "viem/accounts";
13+
import { CHAIN_ID_TO_NETWORK_ID, NETWORK_ID_TO_VIEM_CHAIN } from "../network/network";
14+
import { PublicClient } from "viem";
15+
16+
// Safe SDK imports
17+
import Safe from "@safe-global/protocol-kit";
18+
import SafeApiKit from "@safe-global/api-kit";
19+
20+
/**
21+
* Configuration options for the SafeWalletProvider.
22+
*/
23+
export interface SafeWalletProviderConfig {
24+
/**
25+
* Private key of the signer that controls (or co-controls) the Safe.
26+
*/
27+
privateKey: string;
28+
29+
/**
30+
* Network ID, for example "base-sepolia" or "ethereum-mainnet".
31+
*/
32+
networkId: string;
33+
34+
/**
35+
* Optional existing Safe address. If provided, will connect to that Safe;
36+
* otherwise, this provider will deploy a new Safe.
37+
*/
38+
safeAddress?: string;
39+
}
40+
41+
/**
42+
* SafeWalletProvider is a wallet provider implementation that uses Safe multi-signature accounts.
43+
* When instantiated, this provider can either connect to an existing Safe or deploy a new one.
44+
*/
45+
export class SafeWalletProvider extends WalletProvider {
46+
#privateKey: string;
47+
#account: Account;
48+
#chain: Chain;
49+
#safeAddress: string | null = null;
50+
#isInitialized: boolean = false;
51+
#publicClient: PublicClient;
52+
#safeClient: Safe | null = null;
53+
#apiKit: SafeApiKit;
54+
55+
/**
56+
* Creates a new SafeWalletProvider instance.
57+
*
58+
* @param config - The configuration options for the SafeWalletProvider.
59+
*/
60+
constructor(config: SafeWalletProviderConfig) {
61+
super();
62+
63+
// Get chain ID from network ID
64+
this.#chain = NETWORK_ID_TO_VIEM_CHAIN[config.networkId || "base-sepolia"];
65+
if (!this.#chain) throw new Error(`Unsupported network: ${config.networkId}`);
66+
67+
// Create default public viem client
68+
this.#publicClient = createPublicClient({
69+
chain: this.#chain,
70+
transport: http(),
71+
});
72+
73+
// Initialize apiKit with chain ID from Viem chain
74+
this.#apiKit = new SafeApiKit({
75+
chainId: BigInt(this.#chain.id),
76+
});
77+
78+
// Connect to an existing Safe or deploy a new one with account of private key as single owner
79+
this.#privateKey = config.privateKey;
80+
this.#account = privateKeyToAccount(this.#privateKey as `0x${string}`);
81+
82+
this.initializeSafe(config.safeAddress).then(
83+
address => {
84+
this.#safeAddress = address;
85+
this.#isInitialized = true;
86+
this.trackInitialization();
87+
},
88+
error => {
89+
console.error("Error initializing Safe wallet:", error);
90+
},
91+
);
92+
}
93+
94+
/**
95+
* Returns the Safe address once it is initialized.
96+
* If the Safe isn't yet deployed or connected, throws an error.
97+
*
98+
* @returns The Safe's address.
99+
* @throws Error if Safe is not initialized.
100+
*/
101+
getAddress(): string {
102+
if (!this.#safeAddress) {
103+
throw new Error("Safe not yet initialized.");
104+
}
105+
return this.#safeAddress;
106+
}
107+
108+
/**
109+
* Returns the Network object for this Safe.
110+
*
111+
* @returns Network configuration for this Safe.
112+
*/
113+
getNetwork(): Network {
114+
return {
115+
protocolFamily: "evm",
116+
networkId: CHAIN_ID_TO_NETWORK_ID[this.#chain.id],
117+
chainId: this.#chain.id.toString(),
118+
};
119+
}
120+
121+
/**
122+
* Returns the name of this wallet provider.
123+
*
124+
* @returns The string "safe_wallet_provider".
125+
*/
126+
getName(): string {
127+
return "safe_wallet_provider";
128+
}
129+
130+
/**
131+
* Waits for a transaction receipt.
132+
*
133+
* @param txHash - The hash of the transaction to wait for.
134+
* @returns The transaction receipt from the network.
135+
*/
136+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
137+
async waitForTransactionReceipt(txHash: `0x${string}`): Promise<any> {
138+
return await this.#publicClient.waitForTransactionReceipt({ hash: txHash });
139+
}
140+
141+
/**
142+
* Reads data from a contract.
143+
*
144+
* @param params - The parameters to read the contract.
145+
* @returns The response from the contract.
146+
*/
147+
async readContract(params: ReadContractParameters): Promise<ReadContractReturnType> {
148+
return this.#publicClient.readContract(params);
149+
}
150+
151+
/**
152+
* Queries the current Safe balance.
153+
*
154+
* @returns The balance in wei.
155+
* @throws Error if Safe address is not set.
156+
*/
157+
async getBalance(): Promise<bigint> {
158+
if (!this.#safeAddress) throw new Error("Safe address is not set.");
159+
const balance = await this.#publicClient.getBalance({
160+
address: this.#safeAddress as `0x${string}`,
161+
});
162+
return balance;
163+
}
164+
165+
/**
166+
* Transfers native tokens from the Safe to the specified address.
167+
* If single-owner, executes immediately.
168+
* If multi-sig, proposes the transaction.
169+
*
170+
* @param to - The destination address
171+
* @param value - The amount in decimal form (e.g. "0.5" for 0.5 ETH)
172+
* @returns Transaction hash if executed or Safe transaction hash if proposed
173+
*/
174+
async nativeTransfer(to: string, value: string): Promise<string> {
175+
if (!this.#safeClient) throw new Error("Safe client is not set.");
176+
177+
try {
178+
// Convert decimal ETH to wei
179+
const ethAmountInWei = parseEther(value);
180+
181+
// Create the transaction
182+
const safeTx = await this.#safeClient.createTransaction({
183+
transactions: [
184+
{
185+
to: to as `0x${string}`,
186+
data: "0x",
187+
value: ethAmountInWei.toString(),
188+
},
189+
],
190+
});
191+
192+
// Get current threshold
193+
const threshold = await this.#safeClient.getThreshold();
194+
195+
if (threshold > 1) {
196+
// Multi-sig flow: propose transaction
197+
const safeTxHash = await this.#safeClient.getTransactionHash(safeTx);
198+
const signature = await this.#safeClient.signHash(safeTxHash);
199+
200+
// Propose the transaction
201+
await this.#apiKit.proposeTransaction({
202+
safeAddress: this.getAddress(),
203+
safeTransactionData: safeTx.data,
204+
safeTxHash,
205+
senderSignature: signature.data,
206+
senderAddress: this.#account.address,
207+
});
208+
209+
return `Proposed transaction with Safe transaction hash: ${safeTxHash}. Other owners will need to confirm the transaction before it can be executed.`;
210+
} else {
211+
// Single-sig flow: execute immediately
212+
const response = await this.#safeClient.executeTransaction(safeTx);
213+
const receipt = await this.waitForTransactionReceipt(response.hash as `0x${string}`);
214+
return `Successfully transferred ${value} ETH to ${to}. Transaction hash: ${receipt.transactionHash}`;
215+
}
216+
} catch (error) {
217+
throw new Error(
218+
`Failed to transfer: ${error instanceof Error ? error.message : String(error)}`,
219+
);
220+
}
221+
}
222+
223+
/**
224+
* Gets the current owners of the Safe.
225+
*
226+
* @returns Array of owner addresses.
227+
* @throws Error if Safe client is not set.
228+
*/
229+
async getOwners(): Promise<string[]> {
230+
if (!this.#safeClient) throw new Error("Safe client is not set.");
231+
return await this.#safeClient.getOwners();
232+
}
233+
234+
/**
235+
* Gets the current threshold of the Safe.
236+
*
237+
* @returns Current threshold number.
238+
* @throws Error if Safe client is not set.
239+
*/
240+
async getThreshold(): Promise<number> {
241+
if (!this.#safeClient) throw new Error("Safe client is not set.");
242+
return await this.#safeClient.getThreshold();
243+
}
244+
245+
/**
246+
* Adds a new owner to the Safe.
247+
*
248+
* @param newSigner - The address of the new owner.
249+
* @param newThreshold - The threshold for the new owner.
250+
* @returns Transaction hash
251+
*/
252+
async addOwnerWithThreshold(
253+
newSigner: string,
254+
newThreshold: number | undefined,
255+
): Promise<string> {
256+
if (!this.#safeClient) throw new Error("Safe client is not set.");
257+
258+
// Get current Safe settings
259+
const currentOwners = await this.getOwners();
260+
const currentThreshold = await this.getThreshold();
261+
262+
// Validate new signer isn't already an owner
263+
if (currentOwners.includes(newSigner.toLowerCase()))
264+
throw new Error("Address is already an owner of this Safe");
265+
266+
// Determine new threshold (keep current if not specified)
267+
newThreshold = newThreshold || currentThreshold;
268+
269+
// Validate threshold
270+
const newOwnerCount = currentOwners.length + 1;
271+
if (newThreshold > newOwnerCount)
272+
throw new Error(
273+
`Invalid threshold: ${newThreshold} cannot be greater than number of owners (${newOwnerCount})`,
274+
);
275+
if (newThreshold < 1) throw new Error("Threshold must be at least 1");
276+
277+
// Add new signer
278+
const safeTransaction = await this.#safeClient.createAddOwnerTx({
279+
ownerAddress: newSigner,
280+
threshold: newThreshold,
281+
});
282+
283+
if (currentThreshold > 1) {
284+
// Multi-sig flow: propose transaction
285+
const safeTxHash = await this.#safeClient.getTransactionHash(safeTransaction);
286+
const signature = await this.#safeClient.signHash(safeTxHash);
287+
288+
await this.#apiKit.proposeTransaction({
289+
safeAddress: this.getAddress(),
290+
safeTransactionData: safeTransaction.data,
291+
safeTxHash,
292+
senderSignature: signature.data,
293+
senderAddress: this.#account.address,
294+
});
295+
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.`;
296+
} else {
297+
// Single-sig flow: execute immediately
298+
const tx = await this.#safeClient.executeTransaction(safeTransaction);
299+
return `Successfully added signer ${newSigner} to Safe ${this.#safeAddress}. Threshold: ${newThreshold}. Transaction hash: ${tx.hash}.`;
300+
}
301+
}
302+
303+
/**
304+
* Gets the public client instance.
305+
*
306+
* @returns The Viem PublicClient instance.
307+
*/
308+
getPublicClient(): PublicClient {
309+
return this.#publicClient;
310+
}
311+
312+
/**
313+
* Override walletProvider's trackInitialization to prevent tracking before Safe is initialized.
314+
* Only tracks analytics after the Safe is fully set up.
315+
*/
316+
protected trackInitialization(): void {
317+
// Only track if fully initialized
318+
if (!this.#isInitialized) return;
319+
super.trackInitialization();
320+
}
321+
322+
/**
323+
* Creates or connects to a Safe, depending on whether safeAddr is defined.
324+
*
325+
* @param safeAddr - The existing Safe address (if not provided, a new Safe is deployed).
326+
* @returns The address of the Safe.
327+
*/
328+
private async initializeSafe(safeAddr?: string): Promise<string> {
329+
if (!safeAddr) {
330+
// Check if account has enough ETH for gas fees
331+
const balance = await this.#publicClient.getBalance({ address: this.#account.address });
332+
if (balance === BigInt(0))
333+
throw new Error(
334+
"Creating Safe account requires gaas fees. Please ensure you have enough ETH in your wallet.",
335+
);
336+
337+
// Deploy a new Safe
338+
const predictedSafe = {
339+
safeAccountConfig: {
340+
owners: [this.#account.address],
341+
threshold: 1,
342+
},
343+
safeDeploymentConfig: {
344+
saltNonce: BigInt(Date.now()).toString(),
345+
},
346+
};
347+
348+
const safeSdk = await Safe.init({
349+
provider: this.#publicClient.transport,
350+
signer: this.#privateKey,
351+
predictedSafe,
352+
});
353+
354+
// Prepare and send deployment transaction
355+
const deploymentTx = await safeSdk.createSafeDeploymentTransaction();
356+
const externalSigner = await safeSdk.getSafeProvider().getExternalSigner();
357+
const hash = await externalSigner?.sendTransaction({
358+
to: deploymentTx.to,
359+
value: BigInt(deploymentTx.value),
360+
data: deploymentTx.data as `0x${string}`,
361+
chain: this.#publicClient.chain,
362+
});
363+
const receipt = await this.waitForTransactionReceipt(hash as `0x${string}`);
364+
365+
// Reconnect to the deployed Safe
366+
const safeAddress = await safeSdk.getAddress();
367+
const reconnected = await safeSdk.connect({ safeAddress });
368+
this.#safeClient = reconnected;
369+
this.#safeAddress = safeAddress;
370+
371+
console.log("Safe deployed at:", safeAddress, "Receipt:", receipt.transactionHash);
372+
373+
return safeAddress;
374+
} else {
375+
// Connect to an existing Safe
376+
const safeSdk = await Safe.init({
377+
provider: this.#publicClient.transport,
378+
signer: this.#privateKey,
379+
safeAddress: safeAddr,
380+
});
381+
this.#safeClient = safeSdk;
382+
const existingAddress = await safeSdk.getAddress();
383+
384+
return existingAddress;
385+
}
386+
}
387+
}

‎typescript/agentkit/src/wallet-providers/walletProvider.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export abstract class WalletProvider {
2020
/**
2121
* Tracks the initialization of the wallet provider.
2222
*/
23-
private trackInitialization() {
23+
protected trackInitialization() {
2424
try {
2525
sendAnalyticsEvent({
2626
name: "agent_initialization",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Fill in these environment variables
2+
OPENAI_API_KEY=
3+
SAFE_AGENT_PRIVATE_KEY=
4+
NETWORK_ID=base-sepolia
5+
6+
# If you already have a deployed Safe, set this. Otherwise, a new Safe is created
7+
SAFE_ADDRESS=
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"parser": "@typescript-eslint/parser",
3+
"extends": ["../../../.eslintrc.base.json"]
4+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"tabWidth": 2,
3+
"useTabs": false,
4+
"semi": true,
5+
"singleQuote": false,
6+
"trailingComma": "all",
7+
"bracketSpacing": true,
8+
"arrowParens": "avoid",
9+
"printWidth": 100,
10+
"proseWrap": "never"
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Safe AgentKit LangChain Extension Example - Chatbot
2+
3+
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.
4+
5+
## Environment Variables
6+
7+
- **OPENAI_API_KEY**: Your OpenAI API key
8+
- **SAFE_AGENT_PRIVATE_KEY**: The private key that controls the Safe
9+
- **NETWORK_ID**: The network ID, e.g., "base-sepolia", "ethereum-mainnet", etc.
10+
- **SAFE_ADDRESS** (optional): If already deployed, specify your existing Safe address. Otherwise, a new Safe is deployed.
11+
12+
## Usage
13+
14+
1. Install dependencies from the monorepo root:
15+
```bash
16+
npm install
17+
npm run build
18+
```
19+
2. Navigate into this folder:
20+
```bash
21+
cd typescript/examples/langchain-safe-chatbot
22+
```
23+
3. Copy `.env-local` to `.env` and fill the variables:
24+
```bash
25+
cp .env-local .env
26+
```
27+
4. Run:
28+
```bash
29+
npm start
30+
```
31+
5. Choose the mode: "chat" for user-driven commands, or "auto" for an autonomous demonstration.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import { config as loadEnv } from "dotenv";
2+
import { ChatOpenAI } from "@langchain/openai";
3+
import { MemorySaver } from "@langchain/langgraph";
4+
import { createReactAgent } from "@langchain/langgraph/prebuilt";
5+
import { HumanMessage } from "@langchain/core/messages";
6+
import {
7+
AgentKit,
8+
walletActionProvider,
9+
SafeWalletProvider,
10+
safeWalletActionProvider,
11+
safeApiActionProvider,
12+
} from "@coinbase/agentkit";
13+
import { getLangChainTools } from "@coinbase/agentkit-langchain";
14+
15+
import * as readline from "readline";
16+
17+
// Load environment variables
18+
loadEnv();
19+
20+
/**
21+
* Validate environment variables. If missing or invalid, exit.
22+
*/
23+
function validateEnv() {
24+
const missing: string[] = [];
25+
if (!process.env.OPENAI_API_KEY) {
26+
missing.push("OPENAI_API_KEY");
27+
}
28+
if (!process.env.SAFE_AGENT_PRIVATE_KEY) {
29+
missing.push("SAFE_AGENT_PRIVATE_KEY");
30+
}
31+
if (missing.length > 0) {
32+
console.error("Missing required environment variables:", missing.join(", "));
33+
process.exit(1);
34+
}
35+
}
36+
37+
validateEnv();
38+
39+
/**
40+
* Choose whether to run in chat or auto mode
41+
*
42+
* @returns The selected mode
43+
*/
44+
async function chooseMode(): Promise<"chat" | "auto"> {
45+
const rl = readline.createInterface({
46+
input: process.stdin,
47+
output: process.stdout,
48+
});
49+
50+
const question = (prompt: string) => new Promise<string>(resolve => rl.question(prompt, resolve));
51+
52+
// eslint-disable-next-line no-constant-condition
53+
while (true) {
54+
console.log("\nAvailable modes:");
55+
console.log("1. chat - Interactive chat mode");
56+
console.log("2. auto - Autonomous action mode");
57+
58+
const choice = (await question("\nChoose a mode (enter number or name): "))
59+
.toLowerCase()
60+
.trim();
61+
62+
if (choice === "1" || choice === "chat") {
63+
rl.close();
64+
return "chat";
65+
} else if (choice === "2" || choice === "auto") {
66+
rl.close();
67+
return "auto";
68+
}
69+
console.log("Invalid choice. Please try again.");
70+
}
71+
}
72+
73+
/**
74+
* Initialize the Safe-based agent
75+
*
76+
* @returns Agent executor and config
77+
*/
78+
async function initializeAgent() {
79+
// 1) Create an LLM
80+
const llm = new ChatOpenAI({
81+
model: "gpt-4o-mini", // example model name
82+
});
83+
84+
// 2) Configure SafeWalletProvider
85+
const privateKey = process.env.SAFE_AGENT_PRIVATE_KEY as string;
86+
const networkId = process.env.NETWORK_ID || "base-sepolia";
87+
const safeAddress = process.env.SAFE_ADDRESS;
88+
const safeWallet = new SafeWalletProvider({
89+
privateKey,
90+
networkId,
91+
safeAddress,
92+
});
93+
94+
// 3) Initialize AgentKit with the Safe wallet and some typical action providers
95+
const agentkit = await AgentKit.from({
96+
walletProvider: safeWallet,
97+
actionProviders: [
98+
walletActionProvider(),
99+
safeWalletActionProvider(),
100+
safeApiActionProvider({ networkId: networkId }),
101+
],
102+
});
103+
104+
// 4) Convert to LangChain tools
105+
const tools = await getLangChainTools(agentkit);
106+
107+
// 5) Wrap in a memory saver for conversation
108+
const memory = new MemorySaver();
109+
const agentConfig = { configurable: { thread_id: "Safe AgentKit Chatbot Example!" } };
110+
111+
// 6) Create the agent
112+
const agent = createReactAgent({
113+
llm,
114+
tools,
115+
checkpointSaver: memory,
116+
messageModifier: `
117+
You are an agent with a Safe-based wallet. You can propose or execute actions
118+
on the Safe. If threshold > 1, you may need confirmations from other signers
119+
or to propose transactions. If threshold=1, you can execute immediately.
120+
Be concise and helpful. If you cannot fulfill a request with your current tools,
121+
apologize and suggest the user implement it themselves.
122+
`,
123+
});
124+
125+
return { agent, config: agentConfig };
126+
}
127+
128+
/**
129+
* Run the agent in chat mode
130+
*
131+
* @param agent - The agent executor
132+
* @param config - Agent configuration
133+
*/
134+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
135+
async function runChatMode(agent: any, config: any) {
136+
console.log("Starting chat mode... Type 'exit' or Ctrl+C to exit.\n");
137+
138+
const rl = readline.createInterface({
139+
input: process.stdin,
140+
output: process.stdout,
141+
});
142+
143+
const question = (prompt: string) => new Promise<string>(resolve => rl.question(prompt, resolve));
144+
145+
// eslint-disable-next-line no-constant-condition
146+
while (true) {
147+
const userInput = await question("Prompt: ");
148+
149+
if (userInput.toLowerCase() === "exit") {
150+
rl.close();
151+
break;
152+
}
153+
154+
const stream = await agent.stream({ messages: [new HumanMessage(userInput)] }, config);
155+
156+
for await (const chunk of stream) {
157+
if ("agent" in chunk) {
158+
console.log(chunk.agent.messages[0].content);
159+
} else if ("tools" in chunk) {
160+
console.log(chunk.tools.messages[0].content);
161+
}
162+
console.log("------------------------------------------");
163+
}
164+
}
165+
}
166+
167+
/**
168+
* Demonstration of an autonomous loop
169+
*
170+
* @param agent - The agent executor
171+
* @param config - Agent configuration
172+
* @param interval - Time interval between actions in seconds
173+
*/
174+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
175+
async function runAutoMode(agent: any, config: any, interval = 15) {
176+
console.log("Starting autonomous mode. Press Ctrl+C to exit.\n");
177+
// eslint-disable-next-line no-constant-condition
178+
while (true) {
179+
try {
180+
const thought =
181+
"Pick a creative onchain action that demonstrates Safe usage. Execute or propose it. Summarize progress.";
182+
183+
const stream = await agent.stream({ messages: [new HumanMessage(thought)] }, config);
184+
185+
for await (const chunk of stream) {
186+
if ("agent" in chunk) {
187+
console.log(chunk.agent.messages[0].content);
188+
} else if ("tools" in chunk) {
189+
console.log(chunk.tools.messages[0].content);
190+
}
191+
console.log("------------------------------------------");
192+
}
193+
194+
// Wait <interval> seconds between iterations
195+
await new Promise(resolve => setTimeout(resolve, interval * 1000));
196+
} catch (err) {
197+
console.error("Error in auto mode:", err);
198+
process.exit(1);
199+
}
200+
}
201+
}
202+
203+
/**
204+
* Main entrypoint
205+
*/
206+
async function main() {
207+
try {
208+
const { agent, config } = await initializeAgent();
209+
const mode = await chooseMode();
210+
211+
if (mode === "chat") {
212+
await runChatMode(agent, config);
213+
} else {
214+
await runAutoMode(agent, config);
215+
}
216+
} catch (error) {
217+
console.error("Fatal error:", error);
218+
process.exit(1);
219+
}
220+
}
221+
222+
if (require.main === module) {
223+
main();
224+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"name": "@coinbase/langchain-safe-chatbot-example",
3+
"description": "Safe AgentKit LangChain Chatbot Example",
4+
"version": "1.0.0",
5+
"license": "Apache-2.0",
6+
"scripts": {
7+
"start": "NODE_OPTIONS='--no-warnings' ts-node ./chatbot.ts",
8+
"dev": "nodemon ./chatbot.ts",
9+
"lint": "eslint -c .eslintrc.json *.ts",
10+
"lint:fix": "eslint -c .eslintrc.json *.ts --fix",
11+
"format": "prettier -c .prettierrc --write \"**/*.{ts,js,cjs,json,md}\"",
12+
"format:check": "prettier -c .prettierrc --check \"**/*.{ts,js,cjs,json,md}\""
13+
},
14+
"dependencies": {
15+
"@coinbase/agentkit": "^0.2.0",
16+
"@coinbase/agentkit-langchain": "^0.2.0",
17+
"@langchain/core": "^0.3.19",
18+
"@langchain/langgraph": "^0.2.21",
19+
"@langchain/openai": "^0.3.14",
20+
"dotenv": "^16.4.5",
21+
"zod": "^3.22.4"
22+
},
23+
"devDependencies": {
24+
"nodemon": "^3.1.0",
25+
"ts-node": "^10.9.2"
26+
}
27+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"extends": "../../../tsconfig.base.json",
3+
"compilerOptions": {
4+
"preserveSymlinks": true,
5+
"outDir": "./dist",
6+
"rootDir": ".",
7+
"module": "Node16"
8+
},
9+
"include": ["*.ts"]
10+
}

0 commit comments

Comments
 (0)
Please sign in to comment.