From 74ebdabae5fb065b28ad32bc9ed7b52a72695d3a Mon Sep 17 00:00:00 2001 From: "lebraat (work)" Date: Thu, 23 Apr 2026 21:30:32 +0000 Subject: [PATCH 1/2] feat: add WaaP wallet provider with 2PC split-custody key management Added WaaP (Wallet as a Protocol) as a new EVM wallet provider for AgentKit. WaaP uses two-party computation (2PC) for key management -- the private key is split between the user and a secure enclave, so neither party ever holds the complete key. Includes: - waapWalletProvider.ts: Full EvmWalletProvider implementation wrapping the @human.tech/waap-cli binary for all signing operations - Unit tests (waapWalletProvider.test.ts) - Integration tests (waapWalletProvider.integration.test.ts) - LangChain example chatbot (langchain-waap-chatbot) - Vercel AI SDK example chatbot (vercel-ai-sdk-waap-chatbot) - README updates and WaaP logo asset Co-authored-by: Soe --- README.md | 6 +- assets/wallets/waap.svg | 56 ++ typescript/agentkit/README.md | 19 + .../agentkit/src/wallet-providers/index.ts | 1 + .../waapWalletProvider.integration.test.ts | 230 +++++++ .../waapWalletProvider.test.ts | 613 ++++++++++++++++++ .../wallet-providers/waapWalletProvider.ts | 439 +++++++++++++ .../langchain-waap-chatbot/.env-local | 14 + .../langchain-waap-chatbot/.eslintrc.json | 4 + .../langchain-waap-chatbot/.prettierrc | 11 + .../examples/langchain-waap-chatbot/README.md | 75 +++ .../langchain-waap-chatbot/chatbot.ts | 292 +++++++++ .../langchain-waap-chatbot/package.json | 30 + .../langchain-waap-chatbot/tsconfig.json | 10 + .../vercel-ai-sdk-waap-chatbot/.env-local | 14 + .../vercel-ai-sdk-waap-chatbot/.eslintrc.json | 4 + .../.prettierignore | 7 + .../vercel-ai-sdk-waap-chatbot/.prettierrc | 11 + .../vercel-ai-sdk-waap-chatbot/README.md | 86 +++ .../vercel-ai-sdk-waap-chatbot/chatbot.ts | 375 +++++++++++ .../vercel-ai-sdk-waap-chatbot/package.json | 29 + .../vercel-ai-sdk-waap-chatbot/tsconfig.json | 10 + 22 files changed, 2335 insertions(+), 1 deletion(-) create mode 100644 assets/wallets/waap.svg create mode 100644 typescript/agentkit/src/wallet-providers/waapWalletProvider.integration.test.ts create mode 100644 typescript/agentkit/src/wallet-providers/waapWalletProvider.test.ts create mode 100644 typescript/agentkit/src/wallet-providers/waapWalletProvider.ts create mode 100644 typescript/examples/langchain-waap-chatbot/.env-local create mode 100644 typescript/examples/langchain-waap-chatbot/.eslintrc.json create mode 100644 typescript/examples/langchain-waap-chatbot/.prettierrc create mode 100644 typescript/examples/langchain-waap-chatbot/README.md create mode 100644 typescript/examples/langchain-waap-chatbot/chatbot.ts create mode 100644 typescript/examples/langchain-waap-chatbot/package.json create mode 100644 typescript/examples/langchain-waap-chatbot/tsconfig.json create mode 100644 typescript/examples/vercel-ai-sdk-waap-chatbot/.env-local create mode 100644 typescript/examples/vercel-ai-sdk-waap-chatbot/.eslintrc.json create mode 100644 typescript/examples/vercel-ai-sdk-waap-chatbot/.prettierignore create mode 100644 typescript/examples/vercel-ai-sdk-waap-chatbot/.prettierrc create mode 100644 typescript/examples/vercel-ai-sdk-waap-chatbot/README.md create mode 100644 typescript/examples/vercel-ai-sdk-waap-chatbot/chatbot.ts create mode 100644 typescript/examples/vercel-ai-sdk-waap-chatbot/package.json create mode 100644 typescript/examples/vercel-ai-sdk-waap-chatbot/tsconfig.json diff --git a/README.md b/README.md index 33703c4c1..6b8f986d1 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,8 @@ agentkit/ │ │ └── wallet-providers/ │ │ ├── cdp/ │ │ ├── privy/ -│ │ └── viem/ +│ │ ├── viem/ +│ │ └── waap/ │ ├── create-onchain-agent/ │ ├── framework-extensions/ │ │ ├── langchain/ @@ -158,10 +159,12 @@ agentkit/ │ ├── langchain-privy-chatbot/ │ ├── langchain-solana-chatbot/ │ ├── langchain-twitter-chatbot/ +│ ├── langchain-waap-chatbot/ │ ├── langchain-xmtp-chatbot/ │ ├── langchain-zerodev-chatbot/ │ ├── model-context-protocol-smart-wallet-server/ │ └── vercel-ai-sdk-smart-wallet-chatbot/ +│ └── vercel-ai-sdk-waap-chatbot/ ├── python/ │ ├── coinbase-agentkit/ │ │ └── coinbase_agentkit/ @@ -279,6 +282,7 @@ AgentKit is proud to have support for the following protocols, frameworks, walle Coinbase Privy ViEM +WaaP ### Protocols diff --git a/assets/wallets/waap.svg b/assets/wallets/waap.svg new file mode 100644 index 000000000..ff36c0191 --- /dev/null +++ b/assets/wallets/waap.svg @@ -0,0 +1,56 @@ + diff --git a/typescript/agentkit/README.md b/typescript/agentkit/README.md index 37b14207f..ea2c3dee3 100644 --- a/typescript/agentkit/README.md +++ b/typescript/agentkit/README.md @@ -50,6 +50,7 @@ AgentKit is a framework for easily enabling AI agents to take actions onchain. I - [Configuring from CdpWalletProvider](#configuring-from-cdpwalletprovider) - [Configuring from PrivyWalletProvider](#configuring-from-privywalletprovider) - [Configuring from ViemWalletProvider](#configuring-from-viemwalletprovider) + - [WaapWalletProvider](#waapwalletprovider) - [SVM Wallet Providers](#svm-wallet-providers) - [CdpV2SolanaWalletProvider](#cdpv2solanawalletprovider) - [Basic Configuration](#basic-configuration-2) @@ -981,6 +982,7 @@ EVM: - [ViemWalletProvider](https://github.com/coinbase/agentkit/blob/main/typescript/agentkit/src/wallet-providers/viemWalletProvider.ts) - [PrivyWalletProvider](https://github.com/coinbase/agentkit/blob/main/typescript/agentkit/src/wallet-providers/privyWalletProvider.ts) - [ZeroDevWalletProvider](https://github.com/coinbase/agentkit/blob/main/typescript/agentkit/src/wallet-providers/zeroDevWalletProvider.ts) +- [WaapWalletProvider](https://github.com/coinbase/agentkit/blob/main/typescript/agentkit/src/wallet-providers/waapWalletProvider.ts) ### CdpEvmWalletProvider @@ -1417,6 +1419,23 @@ const walletProvider = await ZeroDevWalletProvider.configureWithWallet({ }); ``` +### WaapWalletProvider + +The `WaapWalletProvider` is an EVM wallet provider that uses the `waap-cli` binary. WaaP (Wallet as a Protocol) manages your private keys securely using two-party computation on the server-side, meaning that raw private keys never hit your local environment. The provider shells out to the `waap-cli` executable for all signing operations. + +```typescript +import { WaapWalletProvider } from "@coinbase/agentkit"; + +// Configures the wallet synchronously and logs in the CLI. +// Requires waap-cli to be installed and available in the local PATH. +const walletProvider = WaapWalletProvider.configureWithWallet({ + email: "your_email@example.com", // Optional, for auto-login + password: "password", // Optional, for auto-login + chainId: "11155111", // e.g., Ethereum Sepolia (11155111) + rpcUrl: "https://ethereum-sepolia-rpc.publicnode.com", // Optional overridable RPC node URL +}); +``` + ## SVM Wallet Providers Wallet providers give an agent access to a wallet. AgentKit currently supports the following wallet providers: diff --git a/typescript/agentkit/src/wallet-providers/index.ts b/typescript/agentkit/src/wallet-providers/index.ts index a24b3aa77..9bc723609 100644 --- a/typescript/agentkit/src/wallet-providers/index.ts +++ b/typescript/agentkit/src/wallet-providers/index.ts @@ -12,3 +12,4 @@ export * from "./privyEvmWalletProvider"; export * from "./privySvmWalletProvider"; export * from "./privyEvmDelegatedEmbeddedWalletProvider"; export * from "./zeroDevWalletProvider"; +export * from "./waapWalletProvider"; diff --git a/typescript/agentkit/src/wallet-providers/waapWalletProvider.integration.test.ts b/typescript/agentkit/src/wallet-providers/waapWalletProvider.integration.test.ts new file mode 100644 index 000000000..12e78073f --- /dev/null +++ b/typescript/agentkit/src/wallet-providers/waapWalletProvider.integration.test.ts @@ -0,0 +1,230 @@ +/** + * Integration tests for WaapWalletProvider. + * + * These tests require a real waap-cli installation and valid credentials. + * They are skipped by default. To run them, set the following environment + * variables and remove the `.skip` from each `describe` block: + * + * WAAP_CLI_PATH - Path to the waap-cli binary (default: "waap-cli") + * WAAP_EMAIL - WaaP account email + * WAAP_PASSWORD - WaaP account password + * WAAP_CHAIN_ID - EVM chain ID (default: "84532" for Base Sepolia) + * WAAP_RPC_URL - RPC URL for the chain (optional) + * + * Run: + * WAAP_EMAIL=you@example.com WAAP_PASSWORD=secret + * npx jest --testMatch "**\/*.integration.test.ts" --no-cache + */ + +import { WaapWalletProvider, WaapWalletProviderConfig } from "./waapWalletProvider"; + +const WAAP_CLI_PATH = process.env.WAAP_CLI_PATH ?? "waap-cli"; +const WAAP_EMAIL = process.env.WAAP_EMAIL; +const WAAP_PASSWORD = process.env.WAAP_PASSWORD; +const WAAP_CHAIN_ID = process.env.WAAP_CHAIN_ID ?? "84532"; +const WAAP_RPC_URL = process.env.WAAP_RPC_URL; + +const canRun = Boolean(WAAP_EMAIL && WAAP_PASSWORD); + +const describeIntegration = canRun ? describe : describe.skip; + +describeIntegration("WaapWalletProvider integration", () => { + let provider: WaapWalletProvider; + + const config: WaapWalletProviderConfig = { + cliPath: WAAP_CLI_PATH, + chainId: WAAP_CHAIN_ID, + rpcUrl: WAAP_RPC_URL, + email: WAAP_EMAIL, + password: WAAP_PASSWORD, + }; + + // ========================================================= + // authentication & setup + // ========================================================= + + describe("authentication & setup", () => { + it("should log in and create a provider via configureWithWallet", () => { + provider = WaapWalletProvider.configureWithWallet(config); + expect(provider).toBeInstanceOf(WaapWalletProvider); + }); + }); + + // ========================================================= + // wallet identity + // ========================================================= + + describe("wallet identity", () => { + beforeAll(() => { + provider = WaapWalletProvider.configureWithWallet(config); + }); + + it("should return a valid EVM address from getAddress", () => { + const address = provider.getAddress(); + expect(address).toMatch(/^0x[0-9a-fA-F]{40}$/); + }); + + it("should return the correct network", () => { + const network = provider.getNetwork(); + expect(network.protocolFamily).toBe("evm"); + expect(network.chainId).toBe(WAAP_CHAIN_ID); + }); + + it("should return the provider name", () => { + expect(provider.getName()).toBe("waap_wallet_provider"); + }); + }); + + // ========================================================= + // balance + // ========================================================= + + describe("balance", () => { + beforeAll(() => { + provider = WaapWalletProvider.configureWithWallet(config); + }); + + it("should fetch balance (>= 0)", async () => { + const balance = await provider.getBalance(); + expect(balance).toBeGreaterThanOrEqual(BigInt(0)); + }); + }); + + // ========================================================= + // signing operations + // ========================================================= + + describe("signing", () => { + beforeAll(() => { + provider = WaapWalletProvider.configureWithWallet(config); + }); + + it("should sign a message and return a valid signature", async () => { + const signature = await provider.signMessage("Hello from AgentKit integration test"); + expect(signature).toMatch(/^0x[0-9a-fA-F]+$/); + // ECDSA signature is 65 bytes = 130 hex chars + "0x" prefix + expect(signature.length).toBe(132); + }); + + it("should sign typed data (EIP-712)", async () => { + const typedData = { + domain: { + name: "AgentKit Integration Test", + version: "1", + chainId: Number(WAAP_CHAIN_ID), + }, + types: { + Greeting: [{ name: "text", type: "string" }], + }, + primaryType: "Greeting", + message: { text: "Hello" }, + }; + + const signature = await provider.signTypedData(typedData); + expect(signature).toMatch(/^0x[0-9a-fA-F]+$/); + expect(signature.length).toBe(132); + }); + + it("should sign a raw hash", async () => { + const hash = + "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" as `0x${string}`; + const signature = await provider.sign(hash); + expect(signature).toMatch(/^0x[0-9a-fA-F]+$/); + }); + }); + + // ========================================================= + // transaction flow (testnet only) + // ========================================================= + + describe("transaction flow", () => { + beforeAll(() => { + provider = WaapWalletProvider.configureWithWallet(config); + }); + + it("should sign a transaction without broadcasting", async () => { + const address = provider.getAddress() as `0x${string}`; + const signedTx = await provider.signTransaction({ + to: address, // self-transfer + value: BigInt(0), + }); + expect(signedTx).toMatch(/^0x[0-9a-fA-F]+$/); + }); + + // This test sends a real 0-value transaction on testnet. + // It requires the wallet to have gas funds on the configured chain. + it( + "should send a 0-value self-transfer and get a receipt", + async () => { + const address = provider.getAddress() as `0x${string}`; + const balance = await provider.getBalance(); + + // Skip if wallet has no gas + if (balance === BigInt(0)) { + console.warn("Skipping send-tx test: wallet has no balance for gas."); + return; + } + + const txHash = await provider.sendTransaction({ + to: address, + value: BigInt(0), + }); + expect(txHash).toMatch(/^0x[0-9a-fA-F]{64}$/); + + const receipt = await provider.waitForTransactionReceipt(txHash); + expect(receipt).toBeDefined(); + expect(receipt.transactionHash).toBe(txHash); + }, + 60_000, + ); + }); + + // ========================================================= + // multi-agent isolation + // ========================================================= + + describe("multi-agent isolation via + email notation", () => { + it("should create a separate wallet for a + alias email", () => { + if (!WAAP_EMAIL) return; + const [localPart, domain] = WAAP_EMAIL.split("@"); + const agentEmail = `${localPart}+agent-integration-test@${domain}`; + + try { + const agentProvider = WaapWalletProvider.configureWithWallet({ + ...config, + email: agentEmail, + }); + + const mainAddress = provider.getAddress(); + const agentAddress = agentProvider.getAddress(); + + // Each + alias gets its own wallet, so addresses should differ + expect(agentAddress).toMatch(/^0x[0-9a-fA-F]{40}$/); + expect(agentAddress).not.toBe(mainAddress); + } catch (error) { + // Skip if the + alias account is not registered on the server + const msg = (error as Error).message || ""; + if (msg.includes("401") || msg.includes("Invalid email")) { + console.warn("Skipping: + alias account not registered on server"); + return; + } + throw error; + } + }); + }); + + // ========================================================= + // contract reads + // ========================================================= + + describe("contract reads", () => { + beforeAll(() => { + provider = WaapWalletProvider.configureWithWallet(config); + }); + + it("should return a PublicClient for read-only operations", () => { + const client = provider.getPublicClient(); + expect(client).toBeDefined(); + }); + }); +}); diff --git a/typescript/agentkit/src/wallet-providers/waapWalletProvider.test.ts b/typescript/agentkit/src/wallet-providers/waapWalletProvider.test.ts new file mode 100644 index 000000000..66bf183d5 --- /dev/null +++ b/typescript/agentkit/src/wallet-providers/waapWalletProvider.test.ts @@ -0,0 +1,613 @@ +import { WaapWalletProvider, WaapWalletProviderConfig, WaapWalletExport } from "./waapWalletProvider"; +import * as child_process from "child_process"; +import { ReadContractParameters, Abi } from "viem"; + +// ========================================================= +// global mocks +// ========================================================= + +global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({}), + } as Response), +); + +jest.mock("../analytics", () => ({ + sendAnalyticsEvent: jest.fn().mockImplementation(() => Promise.resolve()), +})); + +jest.mock("child_process", () => ({ + execFileSync: jest.fn(), + execFile: jest.fn(), +})); + +// Intercept promisify so execFileAsync uses our execFile mock with callback semantics. +jest.mock("util", () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const cp = require("child_process"); + return { + ...jest.requireActual("util"), + promisify: (fn: unknown) => { + if (fn === cp.execFile) { + return (...args: unknown[]) => + new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { + (cp.execFile as jest.Mock)( + ...args, + (err: Error | null, stdout: string, stderr: string) => { + if (err) reject(err); + else resolve({ stdout, stderr }); + }, + ); + }); + } + return jest.requireActual("util").promisify(fn); + }, + }; +}); + +const mockPublicClient = { + getBalance: jest.fn(), + waitForTransactionReceipt: jest.fn(), + readContract: jest.fn(), +}; + +jest.mock("viem", () => { + const actual = jest.requireActual("viem"); + return { + ...actual, + createPublicClient: jest.fn(() => mockPublicClient), + }; +}); + +jest.mock("../network/network", () => ({ + CHAIN_ID_TO_NETWORK_ID: { + 8453: "base-mainnet", + 84532: "base-sepolia", + 1: "ethereum-mainnet", + }, + getChain: jest.fn().mockImplementation((chainId: string) => { + if (chainId === "999999") return undefined; + return { + id: Number(chainId), + name: "Base", + nativeCurrency: { name: "ETH", symbol: "ETH", decimals: 18 }, + rpcUrls: { default: { http: ["https://mainnet.base.org"] } }, + }; + }), +})); + +// ========================================================= +// consts +// ========================================================= + +const MOCK_ADDRESS = "0x6186E6CeD896981DDe6Da33830E697be900c95f5"; +const MOCK_SIGNATURE = + "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab1c"; +const MOCK_TX_HASH = "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"; +const MOCK_SIGNED_TX = + "0x02f87083210580843b9aca00850684ee180082520894dead000000000000000000000000000000000000872386f26fc1000080c0"; + +const DEFAULT_CONFIG: WaapWalletProviderConfig = { + chainId: "8453", + rpcUrl: "https://mainnet.base.org", + cliPath: "/usr/local/bin/waap-cli", +}; + +const mockExecFileSync = child_process.execFileSync as jest.MockedFunction< + typeof child_process.execFileSync +>; +// Cast as jest.Mock (not MockedFunction) to avoid ChildProcess return-type errors in mockImplementation +const mockExecFile = child_process.execFile as unknown as jest.Mock; + +/** + * Sets the return value for both sync and async CLI mocks simultaneously. + * Use this in tests to control what waap-cli "outputs". + */ +function mockCliOutput(output: string) { + mockExecFileSync.mockReturnValue(output); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockExecFile.mockImplementation((...args: any[]) => { + const cb = args[args.length - 1]; + if (typeof cb === "function") cb(null, output, ""); + }); +} + +// ========================================================= +// tests +// ========================================================= + +describe("WaapWalletProvider", () => { + let provider: WaapWalletProvider; + + beforeEach(() => { + jest.clearAllMocks(); + const whoamiOutput = `🔑 Fetching encrypted keyshare ...\n 🔓 Decrypting keyshare ...\n ✅ Keyshare ready\nWallet address: ${MOCK_ADDRESS}`; + mockCliOutput(whoamiOutput); + mockPublicClient.getBalance.mockResolvedValue(BigInt("1000000000000000000")); + mockPublicClient.waitForTransactionReceipt.mockResolvedValue({ + transactionHash: MOCK_TX_HASH, + }); + mockPublicClient.readContract.mockResolvedValue("mock_result"); + provider = new WaapWalletProvider(DEFAULT_CONFIG); + }); + + // ========================================================= + // initialization tests + // ========================================================= + + describe("initialization", () => { + it("should create a provider with valid config", () => { + expect(provider).toBeInstanceOf(WaapWalletProvider); + }); + + it("should throw for unsupported chain ID", () => { + expect( + () => + new WaapWalletProvider({ + chainId: "999999", + }), + ).toThrow("Unsupported chain ID: 999999"); + }); + + it("should use default cliPath when not provided", () => { + const p = new WaapWalletProvider({ chainId: "8453" }); + p.getAddress(); + expect(mockExecFileSync).toHaveBeenCalledWith("waap-cli", ["whoami"], expect.any(Object)); + }); + }); + + // ========================================================= + // basic wallet method tests + // ========================================================= + + describe("basic wallet methods", () => { + it("should get the address", () => { + const address = provider.getAddress(); + expect(address).toBe(MOCK_ADDRESS); + expect(mockExecFileSync).toHaveBeenCalledWith( + DEFAULT_CONFIG.cliPath, + ["whoami"], + expect.any(Object), + ); + }); + + it("should cache the address after first call", () => { + provider.getAddress(); + provider.getAddress(); + const whoamiCalls = mockExecFileSync.mock.calls.filter( + call => Array.isArray(call[1]) && call[1].includes("whoami"), + ); + expect(whoamiCalls).toHaveLength(1); + }); + + it("should get the name", () => { + expect(provider.getName()).toBe("waap_wallet_provider"); + }); + + it("should get the network", () => { + expect(provider.getNetwork()).toEqual({ + protocolFamily: "evm", + chainId: "8453", + networkId: "base-mainnet", + }); + }); + + it("should get the balance", async () => { + const balance = await provider.getBalance(); + expect(balance).toBe(BigInt("1000000000000000000")); + expect(mockPublicClient.getBalance).toHaveBeenCalledWith({ + address: MOCK_ADDRESS, + }); + }); + + it("should handle connection errors during balance check", async () => { + mockPublicClient.getBalance.mockRejectedValueOnce(new Error("Network connection error")); + await expect(provider.getBalance()).rejects.toThrow("Network connection error"); + }); + + it("should return the PublicClient", () => { + const client = provider.getPublicClient(); + expect(client).toBe(mockPublicClient); + }); + }); + + // ========================================================= + // signing operation tests + // ========================================================= + + describe("signing operations", () => { + it("should sign a raw hash", async () => { + mockCliOutput(`Signature: ${MOCK_SIGNATURE}`); + const hash = "0xabcdef1234567890" as `0x${string}`; + const result = await provider.sign(hash); + expect(mockExecFile).toHaveBeenCalledWith( + DEFAULT_CONFIG.cliPath, + ["sign-message", "--message", hash], + expect.any(Object), + expect.any(Function), + ); + expect(result).toBe(MOCK_SIGNATURE); + }); + + it("should sign a text message", async () => { + mockCliOutput(`Signature: ${MOCK_SIGNATURE}`); + const result = await provider.signMessage("Hello, World!"); + expect(mockExecFile).toHaveBeenCalledWith( + DEFAULT_CONFIG.cliPath, + ["sign-message", "--message", "Hello, World!"], + expect.any(Object), + expect.any(Function), + ); + expect(result).toBe(MOCK_SIGNATURE); + }); + + it("should convert Uint8Array message to hex", async () => { + mockCliOutput(`Signature: ${MOCK_SIGNATURE}`); + const msg = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]); + await provider.signMessage(msg); + expect(mockExecFile).toHaveBeenCalledWith( + DEFAULT_CONFIG.cliPath, + ["sign-message", "--message", "0x48656c6c6f"], + expect.any(Object), + expect.any(Function), + ); + }); + + it("should sign typed data", async () => { + mockCliOutput(`Signature: ${MOCK_SIGNATURE}`); + const typedData = { + domain: { name: "Test", version: "1", chainId: 1 }, + types: { Person: [{ name: "name", type: "string" }] }, + primaryType: "Person", + message: { name: "Alice" }, + }; + const result = await provider.signTypedData(typedData); + expect(mockExecFile).toHaveBeenCalledWith( + DEFAULT_CONFIG.cliPath, + ["sign-typed-data", "--data", JSON.stringify(typedData)], + expect.any(Object), + expect.any(Function), + ); + expect(result).toBe(MOCK_SIGNATURE); + }); + + it("should handle CLI failure during signing", async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockExecFile.mockImplementation((...args: any[]) => { + const cb = args[args.length - 1]; + if (typeof cb === "function") cb(new Error("waap-cli: command not found"), "", ""); + }); + await expect(provider.signMessage("test")).rejects.toThrow("waap-cli: command not found"); + }); + + it("should throw when CLI output contains no hex value", async () => { + mockCliOutput("Error: authentication required"); + await expect(provider.signMessage("test")).rejects.toThrow( + "Could not extract hex value from waap-cli output", + ); + }); + }); + + // ========================================================= + // transaction operation tests + // ========================================================= + + describe("transaction operations", () => { + it("should sign a transaction", async () => { + mockCliOutput(`Signed tx: ${MOCK_SIGNED_TX}`); + const result = await provider.signTransaction({ + to: "0xdead000000000000000000000000000000000000" as `0x${string}`, + value: BigInt("10000000000000000"), + }); + expect(mockExecFile).toHaveBeenCalledWith( + DEFAULT_CONFIG.cliPath, + [ + "sign-tx", + "--to", + "0xdead000000000000000000000000000000000000", + "--value", + "0.01", + "--chain-id", + "8453", + "--rpc", + "https://mainnet.base.org", + ], + expect.any(Object), + expect.any(Function), + ); + expect(result).toBe(MOCK_SIGNED_TX); + }); + + it("should send a transaction and return tx hash", async () => { + mockCliOutput(`Transaction hash: ${MOCK_TX_HASH}`); + const result = await provider.sendTransaction({ + to: "0xdead000000000000000000000000000000000000" as `0x${string}`, + value: BigInt("10000000000000000"), + }); + expect(mockExecFile).toHaveBeenCalledWith( + DEFAULT_CONFIG.cliPath, + [ + "send-tx", + "--to", + "0xdead000000000000000000000000000000000000", + "--value", + "0.01", + "--chain-id", + "8453", + "--rpc", + "https://mainnet.base.org", + ], + expect.any(Object), + expect.any(Function), + ); + expect(result).toBe(MOCK_TX_HASH); + }); + + it("should include calldata when provided", async () => { + mockCliOutput(`Transaction hash: ${MOCK_TX_HASH}`); + await provider.sendTransaction({ + to: "0xdead000000000000000000000000000000000000" as `0x${string}`, + data: "0xabcdef" as `0x${string}`, + }); + expect(mockExecFile).toHaveBeenCalledWith( + DEFAULT_CONFIG.cliPath, + [ + "send-tx", + "--to", + "0xdead000000000000000000000000000000000000", + "--value", + "0", + "--chain-id", + "8453", + "--rpc", + "https://mainnet.base.org", + "--data", + "0xabcdef", + ], + expect.any(Object), + expect.any(Function), + ); + }); + + it("should omit --rpc when rpcUrl is not configured", async () => { + const providerNoRpc = new WaapWalletProvider({ + chainId: "8453", + cliPath: "/usr/local/bin/waap-cli", + }); + mockCliOutput(`Transaction hash: ${MOCK_TX_HASH}`); + await providerNoRpc.sendTransaction({ + to: "0xdead000000000000000000000000000000000000" as `0x${string}`, + value: BigInt("10000000000000000"), + }); + const callArgs = mockExecFile.mock.calls.find( + call => Array.isArray(call[1]) && call[1].includes("send-tx"), + ); + expect(callArgs![1]).not.toContain("--rpc"); + }); + + it("should default --value to 0 when value is not provided (required by waap-cli for contract calls)", async () => { + mockCliOutput(`Transaction hash: ${MOCK_TX_HASH}`); + await provider.sendTransaction({ + to: "0xdead000000000000000000000000000000000000" as `0x${string}`, + data: "0x095ea7b3" as `0x${string}`, + }); + const callArgs = mockExecFile.mock.calls.find( + call => Array.isArray(call[1]) && call[1].includes("send-tx"), + ); + expect(callArgs![1]).toContain("--value"); + const valueIdx = callArgs![1].indexOf("--value"); + expect(callArgs![1][valueIdx + 1]).toBe("0"); + }); + + it("should handle CLI failure during transaction send", async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockExecFile.mockImplementation((...args: any[]) => { + const cb = args[args.length - 1]; + if (typeof cb === "function") cb(new Error("Transaction rejected by policy engine"), "", ""); + }); + await expect( + provider.sendTransaction({ + to: "0xdead000000000000000000000000000000000000" as `0x${string}`, + value: BigInt("10000000000000000"), + }), + ).rejects.toThrow("Transaction rejected by policy engine"); + }); + + it("should wait for transaction receipt", async () => { + const receipt = await provider.waitForTransactionReceipt(MOCK_TX_HASH as `0x${string}`); + expect(receipt.transactionHash).toBe(MOCK_TX_HASH); + expect(mockPublicClient.waitForTransactionReceipt).toHaveBeenCalledWith({ + hash: MOCK_TX_HASH, + timeout: 120_000, + }); + }); + + it("should handle receipt timeout errors", async () => { + mockPublicClient.waitForTransactionReceipt.mockRejectedValueOnce(new Error("Timed out")); + await expect( + provider.waitForTransactionReceipt(MOCK_TX_HASH as `0x${string}`), + ).rejects.toThrow("Timed out"); + }); + }); + + // ========================================================= + // native transfer tests + // ========================================================= + + describe("nativeTransfer", () => { + it("should return the tx hash from send-tx", async () => { + mockCliOutput(`Transaction hash: ${MOCK_TX_HASH}`); + const result = await provider.nativeTransfer( + "0xdead000000000000000000000000000000000000", + "10000000000000000", + ); + expect(result).toBe(MOCK_TX_HASH); + }); + + it("should not call waitForTransactionReceipt (waap-cli confirms before returning)", async () => { + mockCliOutput(`Transaction hash: ${MOCK_TX_HASH}`); + await provider.nativeTransfer( + "0xdead000000000000000000000000000000000000", + "10000000000000000", + ); + expect(mockPublicClient.waitForTransactionReceipt).not.toHaveBeenCalled(); + }); + }); + + // ========================================================= + // contract interaction tests + // ========================================================= + + describe("contract interactions", () => { + it("should read contract data", async () => { + const abi = [ + { + name: "balanceOf", + type: "function", + inputs: [{ name: "account", type: "address" }], + outputs: [{ name: "balance", type: "uint256" }], + stateMutability: "view", + }, + ] as const; + + const result = await provider.readContract({ + address: "0x1234567890123456789012345678901234567890" as `0x${string}`, + abi, + functionName: "balanceOf", + args: [MOCK_ADDRESS as `0x${string}`], + } as unknown as ReadContractParameters); + + expect(result).toBe("mock_result"); + expect(mockPublicClient.readContract).toHaveBeenCalled(); + }); + + it("should handle contract read errors", async () => { + mockPublicClient.readContract.mockRejectedValueOnce(new Error("Contract read error")); + + const abi = [ + { + name: "balanceOf", + type: "function", + inputs: [{ name: "account", type: "address" }], + outputs: [{ name: "balance", type: "uint256" }], + stateMutability: "view", + }, + ] as const; + + await expect( + provider.readContract({ + address: "0x1234567890123456789012345678901234567890" as `0x${string}`, + abi, + functionName: "balanceOf", + args: [MOCK_ADDRESS as `0x${string}`], + } as unknown as ReadContractParameters), + ).rejects.toThrow("Contract read error"); + }); + }); + + // ========================================================= + // configureWithWallet tests + // ========================================================= + + describe("configureWithWallet", () => { + it("should log in when credentials are provided", () => { + mockCliOutput(`Wallet address: ${MOCK_ADDRESS}`); + const result = WaapWalletProvider.configureWithWallet({ + ...DEFAULT_CONFIG, + email: "agent@test.com", + password: "secret123", + }); + expect(mockExecFileSync).toHaveBeenCalledWith( + DEFAULT_CONFIG.cliPath, + ["login", "--email", "agent@test.com", "--password", "secret123"], + expect.any(Object), + ); + expect(result).toBeInstanceOf(WaapWalletProvider); + }); + + it("should skip login when no credentials provided", () => { + const result = WaapWalletProvider.configureWithWallet(DEFAULT_CONFIG); + const loginCalls = mockExecFileSync.mock.calls.filter( + call => Array.isArray(call[1]) && call[1].includes("login"), + ); + expect(loginCalls).toHaveLength(0); + expect(result).toBeInstanceOf(WaapWalletProvider); + }); + + it("should support multi-agent isolation via + email notation", () => { + mockCliOutput(`Wallet address: ${MOCK_ADDRESS}`); + WaapWalletProvider.configureWithWallet({ + ...DEFAULT_CONFIG, + email: "owner+agent007@example.com", + password: "secret123", + }); + expect(mockExecFileSync).toHaveBeenCalledWith( + DEFAULT_CONFIG.cliPath, + ["login", "--email", "owner+agent007@example.com", "--password", "secret123"], + expect.any(Object), + ); + }); + + it("should handle login failure", () => { + mockExecFileSync.mockImplementation(() => { + throw new Error("Invalid credentials"); + }); + expect(() => + WaapWalletProvider.configureWithWallet({ + ...DEFAULT_CONFIG, + email: "agent@test.com", + password: "wrong", + }), + ).toThrow("Invalid credentials"); + }); + }); + + // ========================================================= + // exportWallet tests + // ========================================================= + + describe("exportWallet", () => { + it("should export wallet data with email and chain info", () => { + const p = WaapWalletProvider.configureWithWallet({ + ...DEFAULT_CONFIG, + email: "agent@test.com", + password: "secret123", + }); + const exported: WaapWalletExport = p.exportWallet(); + expect(exported.email).toBe("agent@test.com"); + expect(exported.chainId).toBe("8453"); + expect(exported.networkId).toBe("base-mainnet"); + expect(exported.rpcUrl).toBe("https://mainnet.base.org"); + }); + + it("should export undefined email when not configured", () => { + const exported = provider.exportWallet(); + expect(exported.email).toBeUndefined(); + expect(exported.chainId).toBe("8453"); + }); + + it("should export undefined rpcUrl when not configured", () => { + const p = new WaapWalletProvider({ chainId: "8453" }); + const exported = p.exportWallet(); + expect(exported.rpcUrl).toBeUndefined(); + }); + + it("exported data is sufficient to reconstruct the provider", () => { + const p = WaapWalletProvider.configureWithWallet({ + ...DEFAULT_CONFIG, + email: "agent@test.com", + password: "secret123", + }); + const exported = p.exportWallet(); + + // Reconstruct — password must be supplied separately (not exported for security) + const reconstructed = new WaapWalletProvider({ + chainId: exported.chainId, + rpcUrl: exported.rpcUrl, + email: exported.email, + }); + expect(reconstructed.getNetwork().chainId).toBe(exported.chainId); + }); + }); +}); diff --git a/typescript/agentkit/src/wallet-providers/waapWalletProvider.ts b/typescript/agentkit/src/wallet-providers/waapWalletProvider.ts new file mode 100644 index 000000000..4024211fa --- /dev/null +++ b/typescript/agentkit/src/wallet-providers/waapWalletProvider.ts @@ -0,0 +1,439 @@ +// TODO: Improve type safety +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { execFile, execFileSync } from "child_process"; +import { promisify } from "util"; + +const execFileAsync = promisify(execFile); +import { + createPublicClient, + http, + formatEther, + parseEther, + TransactionRequest, + PublicClient as ViemPublicClient, + ReadContractParameters, + ReadContractReturnType, + Abi, + ContractFunctionName, + ContractFunctionArgs, +} from "viem"; +import { EvmWalletProvider } from "./evmWalletProvider"; +import { Network } from "../network"; +import { CHAIN_ID_TO_NETWORK_ID, getChain } from "../network/network"; + +/** + * Exported wallet data from the WaaP wallet provider. + * Can be used to reconstruct the provider across sessions. + */ +export type WaapWalletExport = { + /** The email address associated with the WaaP wallet. */ + email: string | undefined; + /** The EVM chain ID the provider is connected to. */ + chainId: string; + /** The AgentKit network ID corresponding to the chain. */ + networkId: string | undefined; + /** The RPC URL override, if any. */ + rpcUrl: string | undefined; +}; + +/** + * Configuration for the WaaP wallet provider. + */ +export interface WaapWalletProviderConfig { + /** Path to the waap-cli binary. Defaults to "waap-cli". */ + cliPath?: string; + + /** EVM chain ID (e.g. "8453" for Base). */ + chainId: string; + + /** RPC URL for the target network. */ + rpcUrl?: string; + + /** Email for waap-cli login. If provided with password, auto-login on configure. */ + email?: string; + + /** Password for waap-cli login. */ + password?: string; +} + +const WAAP_CLI_TIMEOUT = 300_000; // 5 min — send-tx may wait for on-chain confirmation + +/** + * Executes a waap-cli command synchronously and returns trimmed stdout. + * Only used for the login step in configureWithWallet (startup, pre-event-loop). + */ +function execWaapCliSync(cliPath: string, args: string[]): string { + const result = execFileSync(cliPath, args, { + encoding: "utf-8", + timeout: WAAP_CLI_TIMEOUT, + env: { ...process.env }, + }); + return result.trim(); +} + +/** + * Executes a waap-cli command asynchronously and returns trimmed stdout. + * Used for all signing and transaction operations to avoid blocking the event loop. + */ +async function execWaapCli(cliPath: string, args: string[]): Promise { + const { stdout } = await execFileAsync(cliPath, args, { + encoding: "utf-8", + timeout: WAAP_CLI_TIMEOUT, + env: { ...process.env }, + }); + return stdout.trim(); +} + +/** + * Extracts a hex value (0x...) from waap-cli output, which may contain + * emoji decorations and status lines. + */ +function extractHex(output: string): `0x${string}` { + const match = output.match(/(0x[0-9a-fA-F]+)/); + if (!match) { + throw new Error(`Could not extract hex value from waap-cli output: ${output}`); + } + return match[1] as `0x${string}`; +} + +/** + * Extracts a value after a label (e.g. "Wallet address: 0x...") from waap-cli output. + */ +function extractLabeled(output: string, label: string): string { + const regex = new RegExp(`${label}\\s*[:=]\\s*(.+)`, "i"); + const match = output.match(regex); + if (!match) { + throw new Error(`Could not find "${label}" in waap-cli output: ${output}`); + } + return match[1].trim(); +} + +/** + * Extracts the longest hex value from waap-cli output. Useful when the output + * contains multiple hex values (e.g. key IDs, addresses) and we need the + * longest one (signatures, signed transactions). + */ +function extractLongestHex(output: string): `0x${string}` { + const matches = output.match(/0x[0-9a-fA-F]+/g); + if (!matches || matches.length === 0) { + throw new Error(`Could not extract hex value from waap-cli output: ${output}`); + } + const longest = matches.reduce((a, b) => (a.length >= b.length ? a : b)); + return longest as `0x${string}`; +} + +/** + * A wallet provider that uses the waap-cli binary for signing operations. + * + * WaaP (Wallet as a Protocol) manages private keys server-side using + * two-party computation, so keys are never exposed locally. This provider + * shells out to the waap-cli for all signing operations and uses a Viem + * PublicClient for read-only blockchain queries. + */ +export class WaapWalletProvider extends EvmWalletProvider { + #cliPath: string; + #chainId: string; + #rpcUrl: string | undefined; + #email: string | undefined; + #publicClient: ViemPublicClient; + #address: string | undefined; + + /** + * Constructs a new WaapWalletProvider. + * + * @param config - The configuration for the wallet provider. + */ + constructor(config: WaapWalletProviderConfig) { + super(); + + this.#cliPath = config.cliPath || "waap-cli"; + this.#chainId = config.chainId; + this.#rpcUrl = config.rpcUrl; + this.#email = config.email; + + const chain = getChain(config.chainId); + if (!chain) { + throw new Error(`Unsupported chain ID: ${config.chainId}`); + } + + this.#publicClient = createPublicClient({ + chain, + transport: config.rpcUrl ? http(config.rpcUrl) : http(), + }); + } + + /** + * Creates and configures a WaapWalletProvider. If email and password + * are provided, automatically logs in. + * + * @param config - The configuration for the wallet provider. + * @returns A configured WaapWalletProvider instance. + */ + static configureWithWallet(config: WaapWalletProviderConfig): WaapWalletProvider { + const cliPath = config.cliPath || "waap-cli"; + + if (config.email && config.password) { + execWaapCliSync(cliPath, [ + "login", + "--email", + config.email, + "--password", + config.password, + ]); + } + + const provider = new WaapWalletProvider(config); + // Pre-warm address cache synchronously at startup so getAddress() never + // blocks the event loop during agent tool execution. + provider.getAddress(); + return provider; + } + + /** + * Executes a waap-cli command with the configured binary path. + */ + private exec(args: string[]): Promise { + return execWaapCli(this.#cliPath, args); + } + + /** + * Builds common transaction CLI args. + */ + private txArgs(transaction: TransactionRequest): string[] { + const args: string[] = []; + + if (transaction.to) { + args.push("--to", transaction.to as string); + } + + if (transaction.value !== undefined && transaction.value !== null) { + // waap-cli expects ETH, AgentKit passes Wei + const ethValue = formatEther(BigInt(transaction.value.toString())); + args.push("--value", ethValue); + } else { + // waap-cli requires --value even for contract calls (e.g. ERC20 approve) + args.push("--value", "0"); + } + + args.push("--chain-id", this.#chainId); + + if (this.#rpcUrl) { + args.push("--rpc", this.#rpcUrl); + } + + if (transaction.data) { + args.push("--data", transaction.data as string); + } + + return args; + } + + /** + * Gets the address of the wallet. + * + * @returns The wallet address. + */ + getAddress(): string { + if (!this.#address) { + // Fallback: fetch synchronously (only on first call if not pre-warmed). + // Prefer calling configureWithWallet() which pre-warms the address cache. + const output = execWaapCliSync(this.#cliPath, ["whoami"]); + this.#address = extractLabeled(output, "Wallet address"); + } + return this.#address; + } + + /** + * Gets the network of the wallet. + * + * @returns The network of the wallet. + */ + getNetwork(): Network { + return { + protocolFamily: "evm" as const, + chainId: this.#chainId, + networkId: CHAIN_ID_TO_NETWORK_ID[Number(this.#chainId)], + }; + } + + /** + * Gets the name of the wallet provider. + * + * @returns The name of the wallet provider. + */ + getName(): string { + return "waap_wallet_provider"; + } + + /** + * Exports the wallet data for session persistence. + * + * The exported data contains enough information to reconstruct the provider + * in a future session by passing it back to `configureWithWallet`. Note that + * the password is not included — store it separately and securely. + * + * @returns The wallet export data. + */ + exportWallet(): WaapWalletExport { + return { + email: this.#email, + chainId: this.#chainId, + networkId: this.getNetwork().networkId, + rpcUrl: this.#rpcUrl, + }; + } + + /** + * Gets the balance of the wallet. + * + * @returns The balance in Wei. + */ + async getBalance(): Promise { + return this.#publicClient.getBalance({ + address: this.getAddress() as `0x${string}`, + }); + } + + /** + * Signs a raw hash using waap-cli sign-message. + * + * @param hash - The hash to sign. + * @returns The signature. + */ + async sign(hash: `0x${string}`): Promise<`0x${string}`> { + const output = await this.exec(["sign-message", "--message", hash]); + return extractLongestHex(output); + } + + /** + * Signs a message using EIP-191 (personal_sign). + * + * @param message - The message to sign. + * @returns The signature. + */ + async signMessage(message: string | Uint8Array): Promise<`0x${string}`> { + let msgArg: string; + if (message instanceof Uint8Array) { + msgArg = "0x" + Buffer.from(message).toString("hex"); + } else { + msgArg = message; + } + const output = await this.exec(["sign-message", "--message", msgArg]); + return extractLongestHex(output); + } + + /** + * Signs EIP-712 typed data. + * + * @param typedData - The typed data to sign. + * @returns The signature. + */ + async signTypedData(typedData: any): Promise<`0x${string}`> { + const output = await this.exec(["sign-typed-data", "--data", JSON.stringify(typedData)]); + return extractLongestHex(output); + } + + /** + * Signs a transaction without broadcasting it. + * + * @param transaction - The transaction to sign. + * @returns The signed transaction hex. + */ + async signTransaction(transaction: TransactionRequest): Promise<`0x${string}`> { + const output = await this.exec(["sign-tx", ...this.txArgs(transaction)]); + // Try labeled extraction first ("Signed transaction:" or "Signed tx:"), then + // fall back to the longest hex value — the signed RLP blob is always longer + // than any address or hash that waap-cli may print alongside it. + for (const label of ["Signed transaction", "Signed tx"]) { + try { + return extractLabeled(output, label) as `0x${string}`; + } catch { + // try next label + } + } + // Fallback: longest hex value in output + const matches = output.match(/0x[0-9a-fA-F]+/g); + if (matches) { + const longest = matches.reduce((a, b) => (a.length >= b.length ? a : b)); + return longest as `0x${string}`; + } + return extractHex(output); + } + + /** + * Sends a transaction (sign + broadcast). + * + * @param transaction - The transaction to send. + * @returns The transaction hash. + */ + async sendTransaction(transaction: TransactionRequest): Promise<`0x${string}`> { + const output = await this.exec(["send-tx", ...this.txArgs(transaction)]); + // Use labeled extraction to avoid picking up the sender address that + // waap-cli prints before the transaction hash. + try { + return extractLabeled(output, "Transaction hash") as `0x${string}`; + } catch { + // Fall back to last hex value in output if label not found + const matches = output.match(/0x[0-9a-fA-F]{64}/g); + if (matches) return matches[matches.length - 1] as `0x${string}`; + return extractHex(output); + } + } + + /** + * Waits for a transaction receipt. + * + * @param txHash - The transaction hash. + * @returns The transaction receipt. + */ + async waitForTransactionReceipt(txHash: `0x${string}`): Promise { + return this.#publicClient.waitForTransactionReceipt({ + hash: txHash, + timeout: 120_000, // 2 min — avoids hanging indefinitely on slow networks + }); + } + + /** + * 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 Viem PublicClient for read-only operations. + * + * @returns The PublicClient instance. + */ + getPublicClient(): ViemPublicClient { + return this.#publicClient; + } + + /** + * Transfers native currency (ETH, etc.). + * + * @param to - The destination address. + * @param value - The amount in Wei. + * @returns The transaction hash. + */ + async nativeTransfer(to: string, value: string): Promise { + // waap-cli send-tx waits for on-chain confirmation before returning the hash, + // so we skip waitForTransactionReceipt to avoid a redundant second block wait. + const txHash = await this.sendTransaction({ + to: to as `0x${string}`, + value: BigInt(value), + }); + + return txHash; + } +} diff --git a/typescript/examples/langchain-waap-chatbot/.env-local b/typescript/examples/langchain-waap-chatbot/.env-local new file mode 100644 index 000000000..c1a50bcdc --- /dev/null +++ b/typescript/examples/langchain-waap-chatbot/.env-local @@ -0,0 +1,14 @@ +OPENAI_API_KEY= + +# WaaP Configuration +WAAP_EMAIL= +WAAP_PASSWORD= + +# Optional: Path to waap-cli binary (default: "waap-cli") +WAAP_CLI_PATH= + +# Optional: EVM chain ID (default: "84532" for Base Sepolia) +WAAP_CHAIN_ID= + +# Optional: Custom RPC URL +WAAP_RPC_URL= diff --git a/typescript/examples/langchain-waap-chatbot/.eslintrc.json b/typescript/examples/langchain-waap-chatbot/.eslintrc.json new file mode 100644 index 000000000..91571ba7a --- /dev/null +++ b/typescript/examples/langchain-waap-chatbot/.eslintrc.json @@ -0,0 +1,4 @@ +{ + "parser": "@typescript-eslint/parser", + "extends": ["../../.eslintrc.base.json"] +} diff --git a/typescript/examples/langchain-waap-chatbot/.prettierrc b/typescript/examples/langchain-waap-chatbot/.prettierrc new file mode 100644 index 000000000..ffb416b74 --- /dev/null +++ b/typescript/examples/langchain-waap-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-waap-chatbot/README.md b/typescript/examples/langchain-waap-chatbot/README.md new file mode 100644 index 000000000..0f9451030 --- /dev/null +++ b/typescript/examples/langchain-waap-chatbot/README.md @@ -0,0 +1,75 @@ +# WaaP AgentKit LangChain Extension Examples - Chatbot Typescript + +This example demonstrates an agent setup as a terminal-style chatbot with a [WaaP (Wallet as a Protocol)](https://waap.xyz) wallet. + +WaaP uses two-party computation (2PC) to split private keys between the client and server, so keys are never fully exposed in any single location. The AgentKit integration wraps the `waap-cli` binary behind the standard wallet provider interface. + +## Ask the chatbot to engage in the Web3 ecosystem! + +- "Transfer a portion of your ETH to a random address" +- "What is the price of BTC?" +- "What kind of wallet do you have?" +- "What is your wallet address?" + +## Requirements + +- [Node.js 18+](https://nodejs.org/en/download/current) +- [waap-cli](https://www.npmjs.com/package/@human.tech/waap-cli) installed globally or available on `$PATH` +- A WaaP account (sign up via `waap-cli signup --email you@example.com`) + +### Install waap-cli + +```bash +npm install -g @human.tech/waap-cli +``` + +### Checking Node Version + +```bash +node --version +npm --version +``` + +## Installation + +```bash +npm install +``` + +## Run the Chatbot + +### Set ENV Vars + +Create a `.env` file (or copy `.env-local`) with the following variables: + +```bash +# Required +OPENAI_API_KEY= # OpenAI API key for the LLM +WAAP_EMAIL= # WaaP account email +WAAP_PASSWORD= # WaaP account password + +# Optional +WAAP_CLI_PATH= # Path to waap-cli binary (default: "waap-cli") +WAAP_CHAIN_ID= # EVM chain ID (default: "84532" for Base Sepolia) +WAAP_RPC_URL= # Custom RPC URL for the chain +``` + +#### Multi-Agent Isolation + +WaaP supports creating isolated wallets for each agent using `+` email notation: + +```bash +WAAP_EMAIL=owner+agent007@example.com +``` + +Each `+` alias gets its own wallet, allowing multiple agents to operate independently under a single account. + +### Start the chatbot + +```bash +npm start +``` + +## License + +[Apache-2.0](../../../LICENSE.md) diff --git a/typescript/examples/langchain-waap-chatbot/chatbot.ts b/typescript/examples/langchain-waap-chatbot/chatbot.ts new file mode 100644 index 000000000..435437a02 --- /dev/null +++ b/typescript/examples/langchain-waap-chatbot/chatbot.ts @@ -0,0 +1,292 @@ +import { + AgentKit, + WaapWalletProvider, + wethActionProvider, + walletActionProvider, + erc20ActionProvider, + pythActionProvider, +} from "@coinbase/agentkit"; +import { getLangChainTools } from "@coinbase/agentkit-langchain"; +import { HumanMessage } from "@langchain/core/messages"; +import { MemorySaver } from "@langchain/langgraph"; +import { createAgent } from "langchain"; +import { ChatOpenAI } from "@langchain/openai"; +import * as dotenv from "dotenv"; +import * as readline from "readline"; +import fs from "fs"; + +dotenv.config(); + +const WALLET_DATA_FILE = "wallet_data.txt"; + +/** + * Validates that required environment variables are set + * + * @throws {Error} - If required environment variables are missing + * @returns {void} + */ +function validateEnvironment(): void { + const missingVars: string[] = []; + + const requiredVars = ["OPENAI_API_KEY", "WAAP_EMAIL", "WAAP_PASSWORD"]; + requiredVars.forEach(varName => { + if (!process.env[varName]) { + missingVars.push(varName); + } + }); + + if (missingVars.length > 0) { + console.error("Error: Required environment variables are not set"); + missingVars.forEach(varName => { + console.error(`${varName}=your_${varName.toLowerCase()}_here`); + }); + process.exit(1); + } + + if (!process.env.WAAP_CHAIN_ID) { + console.warn("Warning: WAAP_CHAIN_ID not set, defaulting to 84532 (Base Sepolia)"); + } +} + +// Add this right after imports and before any other code +validateEnvironment(); + +/** + * Initialize the agent with WaaP AgentKit + * + * @returns Agent executor and config + */ +async function initializeAgent() { + try { + // Initialize LLM + const llm = new ChatOpenAI({ + model: "gpt-4o-mini", + }); + + const chainId = process.env.WAAP_CHAIN_ID || "84532"; + + // Configure WaaP wallet provider with auto-login + const walletProvider = WaapWalletProvider.configureWithWallet({ + cliPath: process.env.WAAP_CLI_PATH, + chainId, + rpcUrl: process.env.WAAP_RPC_URL, + email: process.env.WAAP_EMAIL, + password: process.env.WAAP_PASSWORD, + }); + + console.log(`WaaP wallet address: ${walletProvider.getAddress()}`); + console.log(`Network: ${JSON.stringify(walletProvider.getNetwork())}`); + + // Initialize AgentKit + const agentkit = await AgentKit.from({ + walletProvider, + actionProviders: [ + wethActionProvider(), + pythActionProvider(), + walletActionProvider(), + erc20ActionProvider(), + ], + }); + + const tools = await getLangChainTools(agentkit); + + // Store buffered conversation history in memory + const memory = new MemorySaver(); + const agentConfig = { configurable: { thread_id: "WaaP AgentKit Chatbot Example!" } }; + + // Create React Agent using the LLM and WaaP AgentKit tools + const agent = createAgent({ + model: llm, + tools, + checkpointer: memory, + systemPrompt: ` + You are a helpful agent with a WaaP (Wallet as a Protocol) wallet that can interact onchain + using the Coinbase Developer Platform AgentKit. Your wallet uses two-party computation (2PC) + for key security - private keys are never fully exposed in any single location. + + You are empowered to interact onchain using your tools. 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. + `, + }); + + // Save wallet data + const exportedWallet = walletProvider.exportWallet(); + fs.writeFileSync(WALLET_DATA_FILE, JSON.stringify(exportedWallet)); + + return { agent, config: agentConfig }; + } catch (error) { + console.error("Failed to initialize agent:", error); + throw error; + } +} + +/** + * Run the agent autonomously with specified intervals + * + * @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 runAutonomousMode(agent: any, config: any, interval = 10) { + console.log("Starting autonomous mode..."); + + // eslint-disable-next-line no-constant-condition + while (true) { + try { + const thought = + "Be creative and do something interesting on the blockchain. " + + "Choose an action or set of actions and execute it that highlights your abilities."; + + const stream = await agent.stream({ messages: [new HumanMessage(thought)] }, config); + + for await (const chunk of stream) { + if ("model_request" in chunk) { + const response = chunk.model_request.messages[0].content; + if (response !== "") { + console.log("\n Response: " + response); + } + } + if ("tools" in chunk) { + for (const tool of chunk.tools.messages) { + console.log("Tool " + tool.name + ": " + tool.content); + } + } + } + console.log("-------------------"); + + await new Promise(resolve => setTimeout(resolve, interval * 1000)); + } catch (error) { + if (error instanceof Error) { + console.error("Error:", error.message); + } + process.exit(1); + } + } +} + +/** + * Run the agent interactively based on user input + * + * @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' to end."); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const question = (prompt: string): Promise => + new Promise(resolve => rl.question(prompt, resolve)); + + try { + // eslint-disable-next-line no-constant-condition + while (true) { + const userInput = await question("\nPrompt: "); + console.log("-------------------"); + + if (userInput.toLowerCase() === "exit") { + break; + } + + const stream = await agent.stream({ messages: [new HumanMessage(userInput)] }, config); + + for await (const chunk of stream) { + if ("model_request" in chunk) { + const response = chunk.model_request.messages[0].content; + if (response !== "") { + console.log("\n Response: " + response); + } + } + if ("tools" in chunk) { + for (const tool of chunk.tools.messages) { + console.log("Tool " + tool.name + ": " + tool.content); + } + } + } + console.log("-------------------"); + } + } catch (error) { + if (error instanceof Error) { + console.error("Error:", error.message); + } + process.exit(1); + } finally { + rl.close(); + } +} + +/** + * Choose whether to run in autonomous or chat mode based on user input + * + * @returns Selected mode + */ +async function chooseMode(): Promise<"chat" | "auto"> { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const question = (prompt: string): Promise => + 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."); + } +} + +/** + * Start the chatbot agent + */ +async function main() { + try { + const { agent, config } = await initializeAgent(); + const mode = await chooseMode(); + + if (mode === "chat") { + await runChatMode(agent, config); + } else { + await runAutonomousMode(agent, config); + } + } catch (error) { + if (error instanceof Error) { + console.error("Error:", error.message); + } + process.exit(1); + } +} + +if (require.main === module) { + console.log("Starting WaaP Agent..."); + main().catch(error => { + console.error("Fatal error:", error); + process.exit(1); + }); +} diff --git a/typescript/examples/langchain-waap-chatbot/package.json b/typescript/examples/langchain-waap-chatbot/package.json new file mode 100644 index 000000000..c125a12dc --- /dev/null +++ b/typescript/examples/langchain-waap-chatbot/package.json @@ -0,0 +1,30 @@ +{ + "name": "@coinbase/langchain-waap-chatbot-example", + "description": "WaaP AgentKit LangChain Extension Chatbot Example", + "version": "1.0.0", + "private": true, + "author": "Coinbase Inc.", + "license": "Apache-2.0", + "scripts": { + "start": "NODE_OPTIONS='--no-warnings' tsx ./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": "workspace:*", + "@coinbase/agentkit-langchain": "workspace:*", + "@langchain/core": "^1.1.0", + "@langchain/langgraph": "^1.2.0", + "@langchain/openai": "^1.2.0", + "dotenv": "^16.4.5", + "langchain": "^1.1.0", + "zod": "^4.0.0" + }, + "devDependencies": { + "nodemon": "^3.1.0", + "tsx": "^4.7.1" + } +} diff --git a/typescript/examples/langchain-waap-chatbot/tsconfig.json b/typescript/examples/langchain-waap-chatbot/tsconfig.json new file mode 100644 index 000000000..a37da3664 --- /dev/null +++ b/typescript/examples/langchain-waap-chatbot/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "preserveSymlinks": true, + "outDir": "./dist", + "rootDir": ".", + "module": "Node16" + }, + "include": ["*.ts"] +} diff --git a/typescript/examples/vercel-ai-sdk-waap-chatbot/.env-local b/typescript/examples/vercel-ai-sdk-waap-chatbot/.env-local new file mode 100644 index 000000000..c1a50bcdc --- /dev/null +++ b/typescript/examples/vercel-ai-sdk-waap-chatbot/.env-local @@ -0,0 +1,14 @@ +OPENAI_API_KEY= + +# WaaP Configuration +WAAP_EMAIL= +WAAP_PASSWORD= + +# Optional: Path to waap-cli binary (default: "waap-cli") +WAAP_CLI_PATH= + +# Optional: EVM chain ID (default: "84532" for Base Sepolia) +WAAP_CHAIN_ID= + +# Optional: Custom RPC URL +WAAP_RPC_URL= diff --git a/typescript/examples/vercel-ai-sdk-waap-chatbot/.eslintrc.json b/typescript/examples/vercel-ai-sdk-waap-chatbot/.eslintrc.json new file mode 100644 index 000000000..91571ba7a --- /dev/null +++ b/typescript/examples/vercel-ai-sdk-waap-chatbot/.eslintrc.json @@ -0,0 +1,4 @@ +{ + "parser": "@typescript-eslint/parser", + "extends": ["../../.eslintrc.base.json"] +} diff --git a/typescript/examples/vercel-ai-sdk-waap-chatbot/.prettierignore b/typescript/examples/vercel-ai-sdk-waap-chatbot/.prettierignore new file mode 100644 index 000000000..20de531f4 --- /dev/null +++ b/typescript/examples/vercel-ai-sdk-waap-chatbot/.prettierignore @@ -0,0 +1,7 @@ +docs/ +dist/ +coverage/ +.github/ +src/client +**/**/*.json +*.md diff --git a/typescript/examples/vercel-ai-sdk-waap-chatbot/.prettierrc b/typescript/examples/vercel-ai-sdk-waap-chatbot/.prettierrc new file mode 100644 index 000000000..ffb416b74 --- /dev/null +++ b/typescript/examples/vercel-ai-sdk-waap-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/vercel-ai-sdk-waap-chatbot/README.md b/typescript/examples/vercel-ai-sdk-waap-chatbot/README.md new file mode 100644 index 000000000..23804ee7f --- /dev/null +++ b/typescript/examples/vercel-ai-sdk-waap-chatbot/README.md @@ -0,0 +1,86 @@ +# WaaP AgentKit Vercel AI SDK Extension Examples - Chatbot Typescript + +This example demonstrates an agent setup as a terminal-style chatbot with a [WaaP (Wallet as a Protocol)](https://waap.xyz) wallet. + +WaaP uses two-party computation (2PC) to split private keys between the client and server, so keys are never fully exposed in any single location. The AgentKit integration wraps the `waap-cli` binary behind the standard wallet provider interface. + +## Ask the chatbot to engage in the Web3 ecosystem! + +- "Transfer a portion of your ETH to a random address" +- "What is the price of BTC?" +- "What kind of wallet do you have?" +- "What is your wallet address?" +- "Sign this message: AgentKit WAAP smoke test" +- "Sign EIP-712 typed data for this payload: ..." +- "Read `balanceOf` from this ERC-20 contract for my wallet address" + +## Requirements + +- [Node.js 18+](https://nodejs.org/en/download/current) +- [waap-cli](https://www.npmjs.com/package/@human.tech/waap-cli) installed globally or available on `$PATH` +- A WaaP account (sign up via `waap-cli signup --email you@example.com`) + +### Install waap-cli + +```bash +npm install -g @human.tech/waap-cli +``` + +### Checking Node Version + +```bash +node --version +npm --version +``` + +### API Keys + +You'll need: + +- [OpenAI API Key](https://platform.openai.com/docs/quickstart#create-and-export-an-api-key) +- WaaP account credentials (`WAAP_EMAIL` and `WAAP_PASSWORD`) + +Once you have them, rename `.env-local` to `.env` and set: + +```bash +# Required +OPENAI_API_KEY= +WAAP_EMAIL= +WAAP_PASSWORD= + +# Optional +WAAP_CLI_PATH= +WAAP_CHAIN_ID= +WAAP_RPC_URL= +``` + +## Running the example + +From the root directory, run: + +```bash +npm install +npm run build +``` + +This installs dependencies and builds packages locally. The chatbot uses local `@coinbase/agentkit-vercel-ai-sdk` and `@coinbase/agentkit`. + +Now from the `typescript/examples/vercel-ai-sdk-waap-chatbot` directory, run: + +```bash +npm start +``` + +Select "1. chat mode" and start telling your agent to do things onchain. + +## Prompts to test advanced WAAP actions + +After startup, try these prompts: + +- `Sign this message: AgentKit WAAP smoke test at 2026-04-13T10:00:00Z` +- `Sign EIP-712 typed data with domain {"name":"AgentKitTest","version":"1","chainId":11155111,"verifyingContract":"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC"}, types {"Test":[{"name":"contents","type":"string"},{"name":"value","type":"uint256"}]}, primaryType "Test", and message {"contents":"hello","value":"123","from":""}` +- `Read contract 0x... using ABI [{"name":"balanceOf","type":"function","stateMutability":"view","inputs":[{"name":"account","type":"address"}],"outputs":[{"name":"","type":"uint256"}]}], function balanceOf, args ["0x..."]` + +## License + +[Apache-2.0](../../../LICENSE.md) diff --git a/typescript/examples/vercel-ai-sdk-waap-chatbot/chatbot.ts b/typescript/examples/vercel-ai-sdk-waap-chatbot/chatbot.ts new file mode 100644 index 000000000..2e01eb9c4 --- /dev/null +++ b/typescript/examples/vercel-ai-sdk-waap-chatbot/chatbot.ts @@ -0,0 +1,375 @@ +import { + AgentKit, + WaapWalletProvider, + EvmWalletProvider, + customActionProvider, + erc20ActionProvider, + pythActionProvider, + walletActionProvider, + wethActionProvider, +} from "@coinbase/agentkit"; +import { getVercelAITools } from "@coinbase/agentkit-vercel-ai-sdk"; +import { openai } from "@ai-sdk/openai"; +import { streamText, ToolSet, stepCountIs } from "ai"; +import * as dotenv from "dotenv"; +import * as fs from "fs"; +import * as readline from "readline"; +import { z } from "zod"; + +dotenv.config(); + +const WALLET_DATA_FILE = "wallet_data.txt"; + +const SignMessageSchema = z.object({ + message: z.string().describe("The message to sign."), +}); + +const SignTypedDataSchema = z.object({ + typedData: z.record(z.string(), z.any()).optional().describe("Full EIP-712 typed data object."), + typedDataJson: z.string().optional().describe("Stringified JSON for EIP-712 typed data."), + domain: z.record(z.string(), z.any()).optional().describe("EIP-712 domain object."), + types: z + .record(z.string(), z.array(z.object({ name: z.string(), type: z.string() }))) + .optional() + .describe("EIP-712 type definitions."), + primaryType: z.string().optional().describe("Primary type name for typed data."), + message: z.record(z.string(), z.any()).optional().describe("EIP-712 message payload."), +}); + +const ReadContractSchema = z.object({ + contractAddress: z.string().describe("The contract address to query."), + abi: z.array(z.record(z.string(), z.any())).describe("Contract ABI array."), + functionName: z.string().describe("Name of the view/pure function to call."), + args: z.array(z.any()).optional().describe("Function arguments."), +}); + +const waapAdvancedActions = customActionProvider([ + { + name: "sign_message", + description: + "Sign an arbitrary message with the connected WaaP wallet using personal_sign semantics.", + schema: SignMessageSchema, + invoke: async (walletProvider, args) => { + const signature = await walletProvider.signMessage(args.message); + return `Message signature: ${signature}`; + }, + }, + { + name: "sign_typed_data", + description: + "Sign EIP-712 typed data with the connected WaaP wallet. Input must include domain, types, primaryType, and message.", + schema: SignTypedDataSchema, + invoke: async (walletProvider, args) => { + let typedDataPayload: Record | undefined = args.typedData; + + if (!typedDataPayload && args.typedDataJson) { + typedDataPayload = JSON.parse(args.typedDataJson) as Record; + } + + if (!typedDataPayload && args.domain && args.types && args.primaryType && args.message) { + typedDataPayload = { + domain: args.domain, + types: args.types, + primaryType: args.primaryType, + message: args.message, + }; + } + + if (!typedDataPayload) { + throw new Error( + "Missing typed data. Provide typedData, typedDataJson, or domain/types/primaryType/message.", + ); + } + + const signature = await walletProvider.signTypedData(typedDataPayload); + return `Typed data signature: ${signature}`; + }, + }, + { + name: "read_contract", + description: + "Read a pure/view function from a smart contract using contract address, ABI, function name, and args.", + schema: ReadContractSchema, + invoke: async (walletProvider, args) => { + const result = await walletProvider.readContract({ + address: args.contractAddress as `0x${string}`, + abi: args.abi, + functionName: args.functionName, + args: args.args ?? [], + }); + return `Contract read result: ${JSON.stringify(result, (_key, value) => + typeof value === "bigint" ? value.toString() : value, + )}`; + }, + }, +]); + +/** + * Validates required environment variables. + */ +function validateEnvironment(): void { + const missingVars: string[] = []; + + const requiredVars = ["OPENAI_API_KEY", "WAAP_EMAIL", "WAAP_PASSWORD"]; + requiredVars.forEach(varName => { + if (!process.env[varName]) { + missingVars.push(varName); + } + }); + + if (missingVars.length > 0) { + console.error("Error: Required environment variables are not set"); + missingVars.forEach(varName => { + console.error(`${varName}=your_${varName.toLowerCase()}_here`); + }); + process.exit(1); + } + + if (!process.env.WAAP_CHAIN_ID) { + console.warn("Warning: WAAP_CHAIN_ID not set, defaulting to 84532 (Base Sepolia)"); + } +} + +validateEnvironment(); + +const system = `You are a helpful agent with a WaaP (Wallet as a Protocol) wallet that can interact onchain +using the Coinbase Developer Platform AgentKit. Your wallet uses two-party computation (2PC) +for key security - private keys are never fully exposed in any single location. + +You are empowered to interact onchain using your tools. 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.`; + +const signingInstructions = `When the user asks to sign a message or sign EIP-712 typed data, you must call the appropriate signing tool directly. +Do not refuse unless a tool call actually fails. If a signing tool fails, return the exact error and ask for corrected input.`; + +/** + * Initializes AgentKit with WaaP wallet and actions. + * + * @returns Initialized Vercel AI SDK tools. + */ +async function initializeAgent() { + try { + const chainId = process.env.WAAP_CHAIN_ID || "84532"; + + const walletProvider = WaapWalletProvider.configureWithWallet({ + cliPath: process.env.WAAP_CLI_PATH, + chainId, + rpcUrl: process.env.WAAP_RPC_URL, + email: process.env.WAAP_EMAIL, + password: process.env.WAAP_PASSWORD, + }); + + console.log(`WaaP wallet address: ${walletProvider.getAddress()}`); + console.log(`Network: ${JSON.stringify(walletProvider.getNetwork())}`); + + const agentKit = await AgentKit.from({ + walletProvider, + actionProviders: [ + wethActionProvider(), + pythActionProvider(), + walletActionProvider(), + erc20ActionProvider(), + waapAdvancedActions, + ], + }); + + const tools = getVercelAITools(agentKit); + + const exportedWallet = walletProvider.exportWallet(); + fs.writeFileSync(WALLET_DATA_FILE, JSON.stringify(exportedWallet)); + + return { tools }; + } catch (error) { + console.error("Failed to initialize agent:", error); + throw error; + } +} + +/** + * Runs interactive chat mode. + * + * @param tools - Vercel AI SDK tools. + */ +async function runChatMode(tools: ToolSet) { + console.log("Starting chat mode... Type 'exit' to end."); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const question = (prompt: string): Promise => + new Promise(resolve => rl.question(prompt, resolve)); + + const messages: Parameters[0]["messages"] = []; + + try { + // eslint-disable-next-line no-constant-condition + while (true) { + const userInput = await question("\nPrompt: "); + console.log("-------------------"); + + if (userInput.toLowerCase() === "exit") { + break; + } + + messages.push({ role: "user", content: userInput }); + + const result = streamText({ + model: openai.chat("gpt-4o-mini"), + messages, + tools, + system: `${system}\n\n${signingInstructions}`, + stopWhen: stepCountIs(10), + onStepFinish: async ({ toolResults }) => { + for (const tr of toolResults) { + console.log(`Tool ${tr.toolName}: ${tr.output}`); + } + }, + }); + + let fullResponse = ""; + for await (const delta of result.textStream) { + fullResponse += delta; + } + + if (fullResponse) { + console.log("\n Response: " + fullResponse); + } + + messages.push({ role: "assistant", content: fullResponse }); + + console.log("-------------------"); + } + } catch (error) { + console.error("Error:", error); + } finally { + rl.close(); + } +} + +/** + * Runs autonomous mode on an interval. + * + * @param tools - Vercel AI SDK tools. + * @param interval - Seconds between autonomous steps. + */ +async function runAutonomousMode(tools: ToolSet, interval = 10) { + console.log("Starting autonomous mode..."); + + const messages: Parameters[0]["messages"] = []; + + // eslint-disable-next-line no-constant-condition + while (true) { + try { + const thought = + "Be creative and do something interesting on the blockchain. " + + "Choose an action or set of actions and execute it that highlights your abilities."; + + messages.push({ role: "user", content: thought }); + + const result = streamText({ + model: openai.chat("gpt-4o-mini"), + messages, + tools, + system: `${system}\n\n${signingInstructions}`, + stopWhen: stepCountIs(10), + onStepFinish: async ({ toolResults }) => { + for (const tr of toolResults) { + console.log(`Tool ${tr.toolName}: ${tr.output}`); + } + }, + }); + + let fullResponse = ""; + for await (const delta of result.textStream) { + fullResponse += delta; + } + + if (fullResponse) { + console.log("\n Response: " + fullResponse); + } + + messages.push({ role: "assistant", content: fullResponse }); + + console.log("-------------------"); + + await new Promise(resolve => setTimeout(resolve, interval * 1000)); + } catch (error) { + if (error instanceof Error) { + console.error("Error:", error.message); + } + process.exit(1); + } + } +} + +/** + * Prompts user to choose chat or autonomous mode. + * + * @returns Selected execution mode. + */ +async function chooseMode(): Promise<"chat" | "auto"> { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const question = (prompt: string): Promise => + 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."); + } +} + +/** + * Main entry point. + */ +async function main() { + try { + const { tools } = await initializeAgent(); + const mode = await chooseMode(); + + if (mode === "chat") { + await runChatMode(tools); + } else { + await runAutonomousMode(tools); + } + } catch (error) { + if (error instanceof Error) { + console.error("Error:", error.message); + } + process.exit(1); + } +} + +if (require.main === module) { + console.log("Starting WaaP Agent..."); + main().catch(error => { + console.error("Fatal error:", error); + process.exit(1); + }); +} diff --git a/typescript/examples/vercel-ai-sdk-waap-chatbot/package.json b/typescript/examples/vercel-ai-sdk-waap-chatbot/package.json new file mode 100644 index 000000000..dfb2ba0b6 --- /dev/null +++ b/typescript/examples/vercel-ai-sdk-waap-chatbot/package.json @@ -0,0 +1,29 @@ +{ + "name": "@coinbase/vercel-ai-sdk-waap-chatbot-example", + "description": "WaaP AgentKit Vercel AI SDK Chatbot Example", + "version": "1.0.0", + "private": true, + "author": "Coinbase Inc.", + "license": "Apache-2.0", + "scripts": { + "start": "NODE_OPTIONS='--no-warnings' tsx ./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": { + "@ai-sdk/openai": "^3.0.0", + "@coinbase/agentkit": "workspace:*", + "@coinbase/agentkit-vercel-ai-sdk": "workspace:*", + "ai": "^6.0.0", + "dotenv": "^16.4.5", + "tsx": "^4.7.1", + "zod": "^4.0.0" + }, + "devDependencies": { + "nodemon": "^3.1.0", + "tsx": "^4.7.1" + } +} diff --git a/typescript/examples/vercel-ai-sdk-waap-chatbot/tsconfig.json b/typescript/examples/vercel-ai-sdk-waap-chatbot/tsconfig.json new file mode 100644 index 000000000..a37da3664 --- /dev/null +++ b/typescript/examples/vercel-ai-sdk-waap-chatbot/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "preserveSymlinks": true, + "outDir": "./dist", + "rootDir": ".", + "module": "Node16" + }, + "include": ["*.ts"] +} From 99f1675630020a999834bbf67b9cca3a623a9fae Mon Sep 17 00:00:00 2001 From: "lebraat (work)" Date: Thu, 23 Apr 2026 21:30:58 +0000 Subject: [PATCH 2/2] chore: add changeset for WaaP wallet provider --- typescript/.changeset/add-waap-wallet-provider.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 typescript/.changeset/add-waap-wallet-provider.md diff --git a/typescript/.changeset/add-waap-wallet-provider.md b/typescript/.changeset/add-waap-wallet-provider.md new file mode 100644 index 000000000..48564fff7 --- /dev/null +++ b/typescript/.changeset/add-waap-wallet-provider.md @@ -0,0 +1,5 @@ +--- +"@coinbase/agentkit": patch +--- + +Added WaaP wallet provider with 2PC split-custody key management