diff --git a/typescript/.changeset/privy-evm-embedded-wallet.md b/typescript/.changeset/privy-evm-embedded-wallet.md new file mode 100644 index 000000000..c049152d2 --- /dev/null +++ b/typescript/.changeset/privy-evm-embedded-wallet.md @@ -0,0 +1,11 @@ +--- +"@coinbase/agentkit": minor +--- + +Added support for Privy Evm embedded wallets with delegation. (Thanks @njokuScript!) + +This change introduces a new wallet provider, `PrivyEvmDelegatedEmbeddedWalletProvider`, which allows AgentKit to use Privy's embedded wallets that have been delegated to a server. This enables autonomous agents to perform onchain actions on behalf of users who have delegated transaction signing authority to the agent. + +Key changes: +- Add `PrivyEvmDelegatedEmbeddedWalletProvider` class extending the `EvmWalletProvider` base class +- Update the `PrivyWalletProvider` factory to support embedded wallets via a new `walletType` option \ No newline at end of file diff --git a/typescript/agentkit/README.md b/typescript/agentkit/README.md index b717c4049..749007986 100644 --- a/typescript/agentkit/README.md +++ b/typescript/agentkit/README.md @@ -646,24 +646,61 @@ const walletProvider = new ViemWalletProvider(client, { ### PrivyWalletProvider -The `PrivyWalletProvider` is a wallet provider that uses [Privy Server Wallets](https://docs.privy.io/guide/server-wallets/). This implementation extends the `ViemWalletProvider`. +The `PrivyWalletProvider` is a wallet provider that uses [Privy Server Wallets](https://docs.privy.io/guide/server-wallets/) or [Privy Embedded Wallets](https://docs.privy.io/guide/embedded-wallets/). This implementation extends the `EvmWalletProvider`. + +#### Server Wallet Configuration ```typescript -import { PrivyWalletProvider, PrivyWalletConfig } from "@coinbase/agentkit"; +import { PrivyWalletProvider } from "@coinbase/agentkit"; -// Configure Wallet Provider -const config: PrivyWalletConfig = { +// Configure Server Wallet Provider +const config = { appId: "PRIVY_APP_ID", appSecret: "PRIVY_APP_SECRET", chainId: "84532", // base-sepolia walletId: "PRIVY_WALLET_ID", // optional, otherwise a new wallet will be created - authorizationPrivateKey: PRIVY_WALLET_AUTHORIZATION_PRIVATE_KEY, // optional, required if your account is using authorization keys - authorizationKeyId: PRIVY_WALLET_AUTHORIZATION_KEY_ID, // optional, only required to create a new wallet if walletId is not provided + authorizationPrivateKey: "PRIVY_WALLET_AUTHORIZATION_PRIVATE_KEY", // optional, required if your account is using authorization keys + authorizationKeyId: "PRIVY_WALLET_AUTHORIZATION_KEY_ID", // optional, only required to create a new wallet if walletId is not provided }; const walletProvider = await PrivyWalletProvider.configureWithWallet(config); ``` +#### Delegated Embedded Wallet Configuration + +You can also use Privy's embedded wallets with delegation for agent actions. This allows your agent to use wallets that have been delegated transaction signing authority by users. + +```typescript +import { PrivyWalletProvider } from "@coinbase/agentkit"; + +// Configure Embedded Wallet Provider +const config = { + appId: "PRIVY_APP_ID", + appSecret: "PRIVY_APP_SECRET", + authorizationPrivateKey: "PRIVY_WALLET_AUTHORIZATION_PRIVATE_KEY", + walletId: "PRIVY_DELEGATED_WALLET_ID", // The ID of the wallet that was delegated to your server + networkId: "base-mainnet", // or any supported network + walletType: "embedded" // Specify "embedded" to use the embedded wallet provider +}; + +const walletProvider = await PrivyWalletProvider.configureWithWallet(config); +``` + +### Prerequisites + +Before using this wallet provider, you need to: + +1. Set up Privy in your application +2. Enable server delegated actions +3. Have users delegate permissions to your server +4. Obtain the delegated wallet ID + +For more information on setting up Privy and enabling delegated actions, see [Privy's documentation](https://docs.privy.io/guide/embedded/server-delegated-actions). + +### Supported Operations + +The `PrivyEvmDelegatedEmbeddedWalletProvider` supports all standard wallet operations including transaction signing, message signing, and native transfers, using the wallet that was delegated to your server. + #### Authorization Keys Privy offers the option to use authorization keys to secure your server wallets. @@ -679,12 +716,19 @@ The `PrivyWalletProvider` can export wallet information by calling the `exportWa ```typescript const walletData = await walletProvider.exportWallet(); -// walletData will be in the following format: +// For server wallets, walletData will be in the following format: { walletId: string; authorizationKey: string | undefined; chainId: string | undefined; } + +// For embedded wallets, walletData will be in the following format: +{ + walletId: string; + networkId: string; + chainId: string | undefined; +} ``` ### SmartWalletProvider diff --git a/typescript/agentkit/package.json b/typescript/agentkit/package.json index a0657206a..cd1d72619 100644 --- a/typescript/agentkit/package.json +++ b/typescript/agentkit/package.json @@ -47,6 +47,7 @@ "@solana/spl-token": "^0.4.12", "@solana/web3.js": "^1.98.0", "bs58": "^4.0.1", + "canonicalize": "^2.1.0", "decimal.js": "^10.5.0", "ethers": "^6.13.5", "md5": "^2.3.0", diff --git a/typescript/agentkit/src/wallet-providers/index.ts b/typescript/agentkit/src/wallet-providers/index.ts index 1b966b4ee..ad79f9fe4 100644 --- a/typescript/agentkit/src/wallet-providers/index.ts +++ b/typescript/agentkit/src/wallet-providers/index.ts @@ -8,3 +8,4 @@ export * from "./solanaKeypairWalletProvider"; export * from "./privyWalletProvider"; export * from "./privyEvmWalletProvider"; export * from "./privySvmWalletProvider"; +export * from "./privyEvmDelegatedEmbeddedWalletProvider"; diff --git a/typescript/agentkit/src/wallet-providers/privyEvmDelegatedEmbeddedWalletProvider.test.ts b/typescript/agentkit/src/wallet-providers/privyEvmDelegatedEmbeddedWalletProvider.test.ts new file mode 100644 index 000000000..81a868a66 --- /dev/null +++ b/typescript/agentkit/src/wallet-providers/privyEvmDelegatedEmbeddedWalletProvider.test.ts @@ -0,0 +1,351 @@ +import { + PrivyEvmDelegatedEmbeddedWalletConfig, + PrivyEvmDelegatedEmbeddedWalletProvider, +} from "./privyEvmDelegatedEmbeddedWalletProvider"; +import { Address, Hex } from "viem"; + +global.fetch = jest.fn().mockImplementation(async (url, init) => { + if (!init?.headers?.["privy-authorization-signature"]) { + throw new Error("Missing privy-authorization-signature header"); + } + if (!init?.headers?.["privy-app-id"]) { + throw new Error("Missing privy-app-id header"); + } + + const body = JSON.parse(init.body as string); + + if (url.includes("wallets/rpc")) { + if (body.method === "personal_sign") { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ data: { signature: "0x1234" } }), + } as Response); + } + if (body.method === "eth_signTypedData_v4") { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ signature: "0x1234" }), + } as Response); + } + if (body.method === "eth_signTransaction") { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ data: { signed_transaction: "0x1234" } }), + } as Response); + } + if (body.method === "eth_sendTransaction") { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ data: { hash: "0xef01" } }), + } as Response); + } + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({}), + } as Response); +}); + +jest.mock("../analytics", () => ({ + sendAnalyticsEvent: jest.fn().mockImplementation(() => Promise.resolve()), +})); + +const MOCK_ADDRESS = "0x742d35Cc6634C0532925a3b844Bc454e4438f44e"; +const MOCK_WALLET_ID = "test-wallet-id"; +const MOCK_TRANSACTION_HASH = "0xef01"; +const MOCK_SIGNATURE = "0x1234"; + +jest.mock("../network", () => { + const chain = { + id: 84532, + name: "Base Sepolia", + rpcUrls: { + default: { http: ["https://sepolia.base.org"] }, + }, + nativeCurrency: { + name: "Ether", + symbol: "ETH", + decimals: 18, + }, + }; + + return { + getNetwork: jest.fn().mockReturnValue({ + protocolFamily: "evm", + chainId: "84532", + networkId: "base-sepolia", + }), + getChain: jest.fn().mockReturnValue(chain), + NETWORK_ID_TO_CHAIN_ID: { + "base-sepolia": "84532", + }, + }; +}); + +jest.mock("viem", () => { + const originalModule = jest.requireActual("viem"); + return { + ...originalModule, + createPublicClient: jest.fn().mockReturnValue({ + getBalance: jest.fn().mockResolvedValue(BigInt(1000000000000000000)), + waitForTransactionReceipt: jest.fn().mockResolvedValue({ + transactionHash: "0xef01", + status: "success", + }), + readContract: jest.fn().mockResolvedValue("mock_result"), + }), + parseEther: jest.fn().mockReturnValue(BigInt(1000000000000000000)), + }; +}); + +jest.mock("./privyShared", () => ({ + createPrivyClient: jest.fn().mockReturnValue({ + getUser: jest.fn().mockResolvedValue({ + linkedAccounts: [ + { + type: "wallet", + walletClientType: "privy", + address: "0x742d35Cc6634C0532925a3b844Bc454e4438f44e", + }, + ], + }), + }), +})); + +jest.mock("canonicalize", () => { + const mockFn = jest.fn().mockImplementation((obj: unknown) => { + const replacer = (key: string, value: unknown) => { + if (typeof value === "bigint") { + return value.toString(); + } + return value; + }; + return JSON.stringify(obj, replacer); + }); + return mockFn; +}); + +jest.mock("crypto", () => ({ + createPrivateKey: jest.fn().mockImplementation(() => ({})), + sign: jest.fn().mockImplementation(() => Buffer.from("mock-signature")), +})); + +describe("PrivyEvmDelegatedEmbeddedWalletProvider", () => { + const MOCK_CONFIG = { + appId: "test-app-id", + appSecret: "test-app-secret", + authorizationPrivateKey: "wallet-auth:test-auth-key", + walletId: MOCK_WALLET_ID, + networkId: "base-sepolia", + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("configureWithWallet", () => { + it("should configure with required configuration", async () => { + const provider = + await PrivyEvmDelegatedEmbeddedWalletProvider.configureWithWallet(MOCK_CONFIG); + + expect(provider).toBeInstanceOf(PrivyEvmDelegatedEmbeddedWalletProvider); + expect(provider.getNetwork()).toEqual({ + protocolFamily: "evm", + chainId: "84532", + networkId: "base-sepolia", + }); + }); + + it("should throw error if walletId is missing", async () => { + const { walletId: _walletId, ...configWithoutWalletId } = MOCK_CONFIG; + await expect( + PrivyEvmDelegatedEmbeddedWalletProvider.configureWithWallet( + configWithoutWalletId as PrivyEvmDelegatedEmbeddedWalletConfig, + ), + ).rejects.toThrow("walletId is required"); + }); + + it("should throw error if appId or appSecret is missing", async () => { + const { appId: _appId, ...configWithoutAppId } = MOCK_CONFIG; + await expect( + PrivyEvmDelegatedEmbeddedWalletProvider.configureWithWallet( + configWithoutAppId as PrivyEvmDelegatedEmbeddedWalletConfig, + ), + ).rejects.toThrow("appId and appSecret are required"); + }); + + it("should throw error if authorizationPrivateKey is missing", async () => { + const { authorizationPrivateKey: _authKey, ...configWithoutAuthKey } = MOCK_CONFIG; + await expect( + PrivyEvmDelegatedEmbeddedWalletProvider.configureWithWallet(configWithoutAuthKey), + ).rejects.toThrow("authorizationPrivateKey is required"); + }); + }); + + describe("wallet methods", () => { + let provider: PrivyEvmDelegatedEmbeddedWalletProvider; + + beforeEach(async () => { + provider = await PrivyEvmDelegatedEmbeddedWalletProvider.configureWithWallet(MOCK_CONFIG); + }); + + it("should get the wallet address", () => { + expect(provider.getAddress()).toBe(MOCK_ADDRESS); + }); + + it("should get the network information", () => { + expect(provider.getNetwork()).toEqual({ + protocolFamily: "evm", + chainId: "84532", + networkId: "base-sepolia", + }); + }); + + it("should get the provider name", () => { + expect(provider.getName()).toBe("privy_evm_embedded_wallet_provider"); + }); + + it("should get the wallet balance", async () => { + const balance = await provider.getBalance(); + expect(balance).toBe(BigInt(1000000000000000000)); + }); + + it("should sign a message", async () => { + const result = await provider.signMessage("Hello, world!"); + expect(result).toBe(MOCK_SIGNATURE); + }); + + it("should sign typed data", async () => { + const typedData = { + domain: { name: "Test" }, + types: { Test: [{ name: "test", type: "string" }] }, + primaryType: "Test", + message: { test: "test" }, + }; + + const result = await provider.signTypedData(typedData); + expect(result).toBe(MOCK_SIGNATURE); + }); + + it("should sign a transaction", async () => { + const transaction = { + to: "0x1234567890123456789012345678901234567890" as Address, + value: BigInt(1000000000000000000), + }; + + const result = await provider.signTransaction(transaction); + expect(result).toBe(MOCK_SIGNATURE); + }); + + it("should send a transaction", async () => { + const transaction = { + to: "0x1234567890123456789012345678901234567890" as Address, + value: BigInt(1000000000000000000), + }; + + const result = await provider.sendTransaction(transaction); + expect(result).toBe(MOCK_TRANSACTION_HASH); + }); + + it("should wait for transaction receipt", async () => { + const receipt = await provider.waitForTransactionReceipt(MOCK_TRANSACTION_HASH as Hex); + expect(receipt).toEqual({ + transactionHash: MOCK_TRANSACTION_HASH, + status: "success", + }); + }); + + it("should transfer native tokens", async () => { + const result = await provider.nativeTransfer( + "0x1234567890123456789012345678901234567890", + "1.0", + ); + expect(result).toBe(MOCK_TRANSACTION_HASH); + }); + + it("should export wallet data", () => { + const exportData = provider.exportWallet(); + expect(exportData).toEqual({ + walletId: MOCK_WALLET_ID, + authorizationPrivateKey: MOCK_CONFIG.authorizationPrivateKey, + networkId: "base-sepolia", + chainId: "84532", + }); + }); + + 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 Address, + abi, + functionName: "balanceOf", + args: [MOCK_ADDRESS as Address], + }); + + expect(result).toBe("mock_result"); + }); + + describe("request signing", () => { + it("should include required headers in requests", async () => { + const transaction = { + to: "0x1234567890123456789012345678901234567890" as Address, + value: BigInt(1000000000000000000), + }; + + await provider.sendTransaction(transaction); + + expect(global.fetch).toHaveBeenCalled(); + const lastCall = (global.fetch as jest.Mock).mock.calls[ + (global.fetch as jest.Mock).mock.calls.length - 1 + ]; + const [_url, init] = lastCall; + + expect(init.headers).toBeDefined(); + expect(init.headers["privy-authorization-signature"]).toBeDefined(); + expect(init.headers["privy-app-id"]).toBe("test-app-id"); + expect(init.headers["Authorization"]).toBeDefined(); + }); + + it("should handle signature generation errors", async () => { + const canonicalize = jest.requireMock("canonicalize"); + canonicalize.mockImplementationOnce(() => null); // Force canonicalization failure + + const transaction = { + to: "0x1234567890123456789012345678901234567890" as Address, + value: BigInt(1000000000000000000), + }; + + await expect(provider.sendTransaction(transaction)).rejects.toThrow( + "Error generating Privy authorization signature", + ); + }); + + it("should handle HTTP errors", async () => { + (global.fetch as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ + ok: false, + status: 400, + } as Response), + ); + + const transaction = { + to: "0x1234567890123456789012345678901234567890" as Address, + value: BigInt(1000000000000000000), + }; + + await expect(provider.sendTransaction(transaction)).rejects.toThrow( + "Privy request failed: HTTP error! status: 400", + ); + }); + }); + }); +}); diff --git a/typescript/agentkit/src/wallet-providers/privyEvmDelegatedEmbeddedWalletProvider.ts b/typescript/agentkit/src/wallet-providers/privyEvmDelegatedEmbeddedWalletProvider.ts new file mode 100644 index 000000000..c4b9f3d8f --- /dev/null +++ b/typescript/agentkit/src/wallet-providers/privyEvmDelegatedEmbeddedWalletProvider.ts @@ -0,0 +1,514 @@ +import { WalletWithMetadata } from "@privy-io/server-auth"; +import canonicalize from "canonicalize"; +import crypto from "crypto"; +import { + Abi, + Address, + ContractFunctionArgs, + ContractFunctionName, + Hex, + PublicClient, + ReadContractParameters, + ReadContractReturnType, + TransactionReceipt, + TransactionRequest, + createPublicClient, + http, + parseEther, +} from "viem"; +import { Network } from "../network"; +import { NETWORK_ID_TO_CHAIN_ID, getChain } from "../network/network"; +import { PrivyWalletConfig, PrivyWalletExport, createPrivyClient } from "./privyShared"; +import { WalletProvider } from "./walletProvider"; + +interface PrivyResponse { + data: T; +} + +/** + * Configuration options for the Privy Embedded Wallet provider. + */ +export interface PrivyEvmDelegatedEmbeddedWalletConfig extends PrivyWalletConfig { + /** The ID of the delegated wallet */ + walletId: string; + + /** The network ID to connect to (e.g., "base-mainnet") */ + networkId?: string; + + /** The chain ID to connect to */ + chainId?: string; +} + +/** + * A wallet provider that uses Privy's embedded wallets with delegation. + * This provider extends the EvmWalletProvider to provide Privy-specific wallet functionality + * while maintaining compatibility with the base wallet provider interface. + */ +export class PrivyEvmDelegatedEmbeddedWalletProvider extends WalletProvider { + #walletId: string; + #address: string; + #appId: string; + #appSecret: string; + #authKey: string; + #network: Network; + #publicClient: PublicClient; + + /** + * Private constructor to enforce use of factory method. + * + * @param config - The configuration options for the wallet provider + */ + private constructor(config: PrivyEvmDelegatedEmbeddedWalletConfig & { address: string }) { + super(); + + this.#walletId = config.walletId; + this.#address = config.address; + this.#appId = config.appId; + this.#appSecret = config.appSecret; + this.#authKey = config.authorizationPrivateKey || ""; + + const networkId = config.networkId || "base-sepolia"; + const chainId = config.chainId || NETWORK_ID_TO_CHAIN_ID[networkId]; + + this.#network = { + protocolFamily: "evm", + networkId: networkId, + chainId: chainId, + }; + + // Create a public client for read operations + const chain = getChain(chainId); + if (!chain) { + throw new Error(`Chain with ID ${chainId} not found`); + } + + this.#publicClient = createPublicClient({ + chain, + transport: http(), + }); + } + + /** + * Creates and configures a new PrivyEvmDelegatedEmbeddedWalletProvider instance. + * + * @param config - The configuration options for the Privy wallet + * @returns A configured PrivyEvmDelegatedEmbeddedWalletProvider instance + * + * @example + * ```typescript + * const provider = await PrivyEvmDelegatedEmbeddedWalletProvider.configureWithWallet({ + * appId: "your-app-id", + * appSecret: "your-app-secret", + * authorizationPrivateKey: "your-auth-key", + * walletId: "privy-wallet-id", + * networkId: "base-mainnet" + * }); + * ``` + */ + public static async configureWithWallet( + config: PrivyEvmDelegatedEmbeddedWalletConfig, + ): Promise { + try { + if (!config.walletId) { + throw new Error("walletId is required for PrivyEvmDelegatedEmbeddedWalletProvider"); + } + + if (!config.appId || !config.appSecret) { + throw new Error( + "appId and appSecret are required for PrivyEvmDelegatedEmbeddedWalletProvider", + ); + } + + if (!config.authorizationPrivateKey) { + throw new Error( + "authorizationPrivateKey is required for PrivyEvmDelegatedEmbeddedWalletProvider", + ); + } + + const privyClient = createPrivyClient(config); + const user = await privyClient.getUser(config.walletId); + + const embeddedWallets = user.linkedAccounts.filter( + (account): account is WalletWithMetadata => + account.type === "wallet" && account.walletClientType === "privy", + ); + + if (embeddedWallets.length === 0) { + throw new Error(`Could not find wallet address for wallet ID ${config.walletId}`); + } + + const walletAddress = embeddedWallets[0].address; + + // Verify the network/chain ID if provided + if (config.chainId) { + const chain = getChain(config.chainId); + if (!chain) { + throw new Error(`Chain with ID ${config.chainId} not found`); + } + } + + return new PrivyEvmDelegatedEmbeddedWalletProvider({ + ...config, + address: walletAddress as string, + }); + } catch (error) { + if (error instanceof Error) { + throw new Error(`Failed to configure Privy embedded wallet provider: ${error.message}`); + } + throw new Error("Failed to configure Privy embedded wallet provider"); + } + } + + /** + * Gets the address of the wallet. + * + * @returns The address of the wallet. + */ + getAddress(): string { + return this.#address; + } + + /** + * Gets the network of the wallet. + * + * @returns The network of the wallet. + */ + getNetwork(): Network { + return this.#network; + } + + /** + * Gets the name of the wallet provider. + * + * @returns The name of the wallet provider. + */ + getName(): string { + return "privy_evm_embedded_wallet_provider"; + } + + /** + * Gets the balance of the wallet. + * + * @returns The balance of the wallet in wei + */ + async getBalance(): Promise { + try { + const balance = await this.#publicClient.getBalance({ + address: this.#address as Address, + }); + + return balance; + } catch (error) { + if (error instanceof Error) { + throw new Error(`Error getting balance: ${error.message}`); + } + throw new Error("Error getting balance"); + } + } + + /** + * Signs a message. + * + * @param message - The message to sign. + * @returns The signed message. + */ + async signMessage(message: string): Promise { + const body = { + address: this.#address, + chain_type: "ethereum", + method: "personal_sign", + params: { + message, + encoding: "utf-8", + }, + }; + + try { + const response = await this.executePrivyRequest>(body); + return response.data?.signature; + } catch (error) { + if (error instanceof Error) { + throw new Error(`Message signing failed: ${error.message}`); + } + throw new Error("Message signing failed"); + } + } + + /** + * Signs typed data according to EIP-712. + * + * @param typedData - The typed data object to sign + * @param typedData.domain - The domain object containing contract and chain information + * @param typedData.types - The type definitions for the structured data + * @param typedData.primaryType - The primary type being signed + * @param typedData.message - The actual data to sign + * @returns A Address that resolves to the signed typed data as a hex string + */ + async signTypedData(typedData: { + domain: Record; + types: Record>; + primaryType: string; + message: Record; + }): Promise { + const body = { + address: this.#address, + chain_type: "ethereum", + chain_id: this.#network.chainId, + ...typedData, + }; + + try { + const response = await this.executePrivyRequest<{ signature: Hex }>({ + method: "eth_signTypedData_v4", + params: body, + }); + return response.signature; + } catch (error) { + if (error instanceof Error) { + throw new Error("Typed data signing failed: " + error.message); + } + throw new Error("Typed data signing failed with unknown error"); + } + } + + /** + * Signs a transaction. + * + * @param transaction - The transaction to sign. + * @returns The signed transaction. + */ + async signTransaction(transaction: TransactionRequest): Promise { + const body = { + address: this.#address, + chain_type: "ethereum", + method: "eth_signTransaction", + params: { + transaction: { + ...transaction, + from: this.#address, + }, + }, + }; + + try { + const response = + await this.executePrivyRequest>(body); + return response.data?.signed_transaction; + } catch (error) { + if (error instanceof Error) { + throw new Error(`Transaction signing failed: ${error.message}`); + } + throw new Error("Transaction signing failed"); + } + } + + /** + * Sends a transaction. + * + * @param transaction - The transaction to send. + * @returns The hash of the transaction. + */ + async sendTransaction(transaction: TransactionRequest): Promise { + const body = { + address: this.#address, + chain_type: "ethereum", + method: "eth_sendTransaction", + caip2: `eip155:${this.#network.chainId!}`, + params: { + transaction: { + ...transaction, + from: this.#address, + }, + }, + }; + + try { + const response = await this.executePrivyRequest>(body); + return response.data?.hash; + } catch (error) { + if (error instanceof Error) { + throw new Error(`Transaction sending failed: ${error.message}`); + } + throw new Error("Transaction sending failed"); + } + } + + /** + * Waits for a transaction receipt. + * + * @param txHash - The hash of the transaction to wait for. + * @returns The transaction receipt. + */ + async waitForTransactionReceipt(txHash: Hex): Promise { + return await this.#publicClient.waitForTransactionReceipt({ + hash: txHash, + }); + } + + /** + * Reads data from a smart contract. + * + * @param params - Parameters for reading the contract + * @param params.address - The address of the contract + * @param params.abi - The ABI of the contract + * @param params.functionName - The name of the function to call + * @param params.args - The arguments to pass to the function + * @returns A Address that resolves to the contract function's return value + */ + async readContract< + const abi extends Abi | readonly unknown[], + functionName extends ContractFunctionName, + const args extends ContractFunctionArgs, + >( + params: ReadContractParameters, + ): Promise> { + return this.#publicClient.readContract(params); + } + + /** + * Transfer the native asset of the network. + * + * @param to - The destination address. + * @param value - The amount to transfer in Wei. + * @returns The transaction hash. + */ + async nativeTransfer(to: string, value: string): Promise { + const valueInWei = parseEther(value); + const valueHex = `0x${valueInWei.toString(16)}`; + + const body = { + address: this.#address, + chain_type: "ethereum", + method: "eth_sendTransaction", + caip2: `eip155:${this.#network.chainId!}`, + params: { + transaction: { + to, + value: valueHex, + }, + }, + }; + + try { + const response = await this.executePrivyRequest>(body); + + const receipt = await this.waitForTransactionReceipt(response.data.hash); + + if (!receipt) { + throw new Error("Transaction failed"); + } + + return receipt.transactionHash; + } catch (error) { + if (error instanceof Error) { + throw new Error(`Native transfer failed: ${error.message}`); + } + throw new Error("Native transfer failed"); + } + } + + /** + * Exports the wallet information. + * + * @returns The wallet data + */ + exportWallet(): PrivyWalletExport { + return { + walletId: this.#walletId, + authorizationPrivateKey: this.#authKey, + networkId: this.#network.networkId!, + chainId: this.#network.chainId, + }; + } + + /** + * Generate Privy authorization signature for API requests + * + * @param url - The URL for the request + * @param body - The request body + * @returns The generated signature + */ + private generatePrivySignature(url: string, body: object): string { + try { + const payload = { + version: 1, + method: "POST", + url, + body, + headers: { + "privy-app-id": this.#appId, + }, + }; + + const serializedPayload = canonicalize(payload); + if (!serializedPayload) throw new Error("Failed to canonicalize payload"); + + const serializedPayloadBuffer = Buffer.from(serializedPayload); + + const privateKeyAsString = this.#authKey.replace("wallet-auth:", ""); + const privateKeyAsPem = `-----BEGIN PRIVATE KEY-----\n${privateKeyAsString}\n-----END PRIVATE KEY-----`; + + const privateKey = crypto.createPrivateKey({ + key: privateKeyAsPem, + format: "pem", + }); + + const signatureBuffer = crypto.sign("sha256", serializedPayloadBuffer, privateKey); + return signatureBuffer.toString("base64"); + } catch (error) { + if (error instanceof Error) { + throw new Error(`Error generating Privy authorization signature: ${error.message}`); + } + throw new Error("Error generating Privy authorization signature"); + } + } + + /** + * Get Privy headers for API requests + * + * @param url - The URL for the request + * @param body - The request body + * @returns The headers for the request + */ + private getPrivyHeaders(url: string, body: object) { + return { + "Content-Type": "application/json", + Authorization: `Basic ${Buffer.from(`${this.#appId}:${this.#appSecret}`).toString("base64")}`, + "privy-app-id": this.#appId, + "privy-authorization-signature": this.generatePrivySignature(url, body), + }; + } + + /** + * Execute a Privy API request. + * + * @param body - The request body to send to the Privy API + * @returns A promise that resolves to the response data + * @throws Error if the request fails + */ + private async executePrivyRequest(body: Record): Promise { + const url = `https://api.privy.io/v1/wallets/rpc`; + const headers = this.getPrivyHeaders(url, body); + + try { + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(body, (_key, value) => + typeof value === "bigint" ? value.toString() : value, + ), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); + } catch (error) { + if (error instanceof Error) { + throw new Error("Privy request failed: " + error.message); + } + throw new Error("Privy request failed with unknown error"); + } + } +} diff --git a/typescript/agentkit/src/wallet-providers/privyShared.ts b/typescript/agentkit/src/wallet-providers/privyShared.ts index f6b16c746..19eb4356a 100644 --- a/typescript/agentkit/src/wallet-providers/privyShared.ts +++ b/typescript/agentkit/src/wallet-providers/privyShared.ts @@ -18,6 +18,8 @@ export interface PrivyWalletConfig { authorizationKeyId?: string; /** The chain type to create the wallet on */ chainType?: "ethereum" | "solana"; + /** The type of wallet to use */ + walletType?: "server" | "embedded"; } export type PrivyWalletExport = { @@ -33,21 +35,31 @@ type CreatePrivyWalletReturnType = { }; /** - * Create a Privy wallet + * Create a Privy client * - * @param config - The configuration options for the Privy wallet - * @returns The created Privy wallet + * @param config - The configuration options for the Privy client + * @returns The created Privy client */ -export async function createPrivyWallet( - config: PrivyWalletConfig, -): Promise { - const privy = new PrivyClient(config.appId, config.appSecret, { +export const createPrivyClient = (config: PrivyWalletConfig) => { + return new PrivyClient(config.appId, config.appSecret, { walletApi: config.authorizationPrivateKey ? { authorizationPrivateKey: config.authorizationPrivateKey, } : undefined, }); +}; + +/** + * Create a Privy wallet + * + * @param config - The configuration options for the Privy wallet + * @returns The created Privy wallet + */ +export async function createPrivyWallet( + config: PrivyWalletConfig, +): Promise { + const privy = createPrivyClient(config); if (config.walletId) { const wallet = await privy.walletApi.getWallet({ id: config.walletId }); diff --git a/typescript/agentkit/src/wallet-providers/privyWalletProvider.ts b/typescript/agentkit/src/wallet-providers/privyWalletProvider.ts index 01e1c8a56..6d5a803cd 100644 --- a/typescript/agentkit/src/wallet-providers/privyWalletProvider.ts +++ b/typescript/agentkit/src/wallet-providers/privyWalletProvider.ts @@ -1,44 +1,94 @@ import { PrivyEvmWalletProvider, PrivyEvmWalletConfig } from "./privyEvmWalletProvider"; import { PrivySvmWalletProvider, PrivySvmWalletConfig } from "./privySvmWalletProvider"; +import { + PrivyEvmDelegatedEmbeddedWalletProvider, + PrivyEvmDelegatedEmbeddedWalletConfig, +} from "./privyEvmDelegatedEmbeddedWalletProvider"; -export type PrivyWalletConfig = PrivyEvmWalletConfig | PrivySvmWalletConfig; +export type PrivyWalletConfig = + | PrivyEvmWalletConfig + | PrivySvmWalletConfig + | PrivyEvmDelegatedEmbeddedWalletConfig; + +export type PrivyWalletProviderVariant = T extends { walletType: "embedded" } + ? PrivyEvmDelegatedEmbeddedWalletProvider + : T extends { chainType: "solana" } + ? PrivySvmWalletProvider + : PrivyEvmWalletProvider; /** * Factory class for creating chain-specific Privy wallet providers */ export class PrivyWalletProvider { /** - * Creates and configures a new wallet provider instance based on the chain type. + * Creates and configures a new wallet provider instance based on the chain type and wallet type. * * @param config - The configuration options for the Privy wallet - * @returns A configured WalletProvider instance for the specified chain + * @returns A configured WalletProvider instance for the specified chain and wallet type * * @example * ```typescript - * // For EVM (default) + * // For EVM server wallets (default) * const evmWallet = await PrivyWalletProvider.configureWithWallet({ * appId: "your-app-id", * appSecret: "your-app-secret" * }); * - * // For Solana + * // For Solana server wallets * const solanaWallet = await PrivyWalletProvider.configureWithWallet({ * appId: "your-app-id", * appSecret: "your-app-secret", * chainType: "solana" * }); + * + * // For Ethereum embedded wallets + * const embeddedWallet = await PrivyWalletProvider.configureWithWallet({ + * appId: "your-app-id", + * appSecret: "your-app-secret", + * walletId: "delegated-wallet-id", + * walletType: "embedded" + * }); * ``` */ static async configureWithWallet( - config: T & { chainType?: "ethereum" | "solana" }, - ): Promise { - if (config.chainType === "solana") { - return (await PrivySvmWalletProvider.configureWithWallet( - config as PrivySvmWalletConfig, - )) as T extends { chainType: "solana" } ? PrivySvmWalletProvider : PrivyEvmWalletProvider; + config: T & { + chainType?: "ethereum" | "solana"; + walletType?: "server" | "embedded"; + }, + ): Promise> { + const chainType = config.chainType || "ethereum"; + const walletType = config.walletType || "server"; + + switch (chainType) { + case "ethereum": { + switch (walletType) { + case "server": + return (await PrivyEvmWalletProvider.configureWithWallet( + config as PrivyEvmWalletConfig, + )) as PrivyWalletProviderVariant; + case "embedded": + return (await PrivyEvmDelegatedEmbeddedWalletProvider.configureWithWallet( + config as PrivyEvmDelegatedEmbeddedWalletConfig, + )) as PrivyWalletProviderVariant; + default: + throw new Error("Invalid wallet type"); + } + } + case "solana": { + switch (walletType) { + case "server": + return (await PrivySvmWalletProvider.configureWithWallet( + config as PrivySvmWalletConfig, + )) as PrivyWalletProviderVariant; + case "embedded": + throw new Error("Embedded wallets are not supported for Solana"); + default: + throw new Error("Invalid wallet type"); + } + } + default: { + throw new Error("Invalid chain type"); + } } - return (await PrivyEvmWalletProvider.configureWithWallet( - config as PrivyEvmWalletConfig, - )) as T extends { chainType: "solana" } ? PrivySvmWalletProvider : PrivyEvmWalletProvider; } } diff --git a/typescript/examples/langchain-privy-chatbot/README.md b/typescript/examples/langchain-privy-chatbot/README.md index c2574a1cb..d0ee4aaf0 100644 --- a/typescript/examples/langchain-privy-chatbot/README.md +++ b/typescript/examples/langchain-privy-chatbot/README.md @@ -1,9 +1,11 @@ # Privy AgentKit LangChain Extension Examples - Chatbot Typescript -This example demonstrates an agent setup as a self-aware terminal style chatbot with a [Privy server wallet](https://docs.privy.io/guide/server-wallets/). +This example demonstrates an agent setup as a self-aware terminal style chatbot with a [Privy server wallet](https://docs.privy.io/guide/server-wallets/) or [Privy delegated embedded wallet](https://docs.privy.io/wallets/overview/index#embedded-wallets). Privy's server wallets enable you to securely provision and manage cross-chain wallets via a flexible API - learn more at https://docs.privy.io/guide/server-wallets/. The Agentkit integration assumes you have a Privy server wallet ID which you want to use for your agent - creation and management of Privy wallets can be done via the Privy dashboard or API. +Privy's embedded wallets, when delegated, also enable you to securely provision and manage cross-chain wallets via a flexible API - learn more at https://docs.privy.io/wallets/overview/index#embedded-wallets and https://docs.privy.io/wallets/using-wallets/server-sessions/usage#delegating-access. The Agentkit integration assumes you have a Privy delegated embedded wallet ID which you want to use for your agent - creation and management of Privy wallets can be done via the Privy dashboard or API. + ## Ask the chatbot to engage in the Web3 ecosystem! - "Transfer a portion of your ETH to a random address" @@ -37,9 +39,9 @@ npm install - Ensure the following ENV Vars from your Privy dashboard are set in `.env`: - PRIVY_APP_ID= - PRIVY_APP_SECRET= - - PRIVY_WALLET_ID=[optional, otherwise a new wallet will be created] - - PRIVY_WALLET_AUTHORIZATION_PRIVATE_KEY=[optional, only if you are using authorization keys for your server wallets] - - PRIVY_WALLET_AUTHORIZATION_KEY_ID=[optional, only if walletId is not provided in order to create a new wallet, this can be found in your Privy Dashboard] + - PRIVY_WALLET_ID=[For server wallets, optional, otherwise a new wallet will be created. For delegated embedded wallets, required and refers to the delegated wallet id] + - PRIVY_WALLET_AUTHORIZATION_PRIVATE_KEY=[For server wallets, optional, only if you are using authorization keys. For delegated embedded wallets, required] + - PRIVY_WALLET_AUTHORIZATION_KEY_ID=[For server wallets, optional, only if walletId is not provided in order to create a new wallet, this can be found in your Privy Dashboard. For delegated embedded wallets, required] - NETWORK_ID=[optional. If you'd like to use a Privy Solana wallet, set to "solana-devnet". Otherwise, defaults to "base-sepolia" and will use a Privy EVM wallet] - CDP_API_KEY_NAME=[optional. If you'd like to use the CDP API, for example to faucet funds, set this to the name of the CDP API key] - CDP_API_KEY_PRIVATE_KEY=[optional. If you'd like to use the CDP API, for example to faucet funds, set this to the private key of the CDP API key] diff --git a/typescript/examples/langchain-privy-chatbot/chatbot.ts b/typescript/examples/langchain-privy-chatbot/chatbot.ts index 2d1dbee82..9ac417731 100644 --- a/typescript/examples/langchain-privy-chatbot/chatbot.ts +++ b/typescript/examples/langchain-privy-chatbot/chatbot.ts @@ -10,6 +10,7 @@ import { PrivySvmWalletProvider, cdpApiActionProvider, splActionProvider, + PrivyEvmDelegatedEmbeddedWalletProvider, } from "@coinbase/agentkit"; import { getLangChainTools } from "@coinbase/agentkit-langchain"; import { HumanMessage } from "@langchain/core/messages"; @@ -66,7 +67,10 @@ async function initializeAgent() { model: "gpt-4o-mini", }); - let walletProvider: PrivyEvmWalletProvider | PrivySvmWalletProvider; + let walletProvider: + | PrivyEvmWalletProvider + | PrivySvmWalletProvider + | PrivyEvmDelegatedEmbeddedWalletProvider; const networkId = process.env.NETWORK_ID; @@ -90,7 +94,6 @@ async function initializeAgent() { } walletProvider = await PrivyWalletProvider.configureWithWallet(config); - walletProvider = await PrivyWalletProvider.configureWithWallet(config); } else { const config: PrivyWalletConfig = { appId: process.env.PRIVY_APP_ID as string, @@ -99,7 +102,7 @@ async function initializeAgent() { walletId: process.env.PRIVY_WALLET_ID as string, authorizationPrivateKey: process.env.PRIVY_WALLET_AUTHORIZATION_PRIVATE_KEY, authorizationKeyId: process.env.PRIVY_WALLET_AUTHORIZATION_KEY_ID, - chainType: "ethereum", + // walletType: "embedded", // Uncomment to use delegated embedded wallets (makes walletId required) }; // Try to load saved wallet data diff --git a/typescript/pnpm-lock.yaml b/typescript/pnpm-lock.yaml index 27acf5428..b5a31dad6 100644 --- a/typescript/pnpm-lock.yaml +++ b/typescript/pnpm-lock.yaml @@ -86,6 +86,9 @@ importers: bs58: specifier: ^4.0.1 version: 4.0.1 + canonicalize: + specifier: ^2.1.0 + version: 2.1.0 decimal.js: specifier: ^10.5.0 version: 10.5.0