diff --git a/README.md b/README.md index cc58a69d4..ddea43b92 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,7 @@ agentkit/ │ │ └── wallet-providers/ │ │ ├── cdp/ │ │ ├── privy/ +| | ├── dynamic/ │ │ └── viem/ │ │ └── scripts/generate-action-provider/ # use this to create new actions │ ├── create-onchain-agent/ @@ -156,6 +157,7 @@ agentkit/ │ ├── langchain-farcaster-chatbot/ │ ├── langchain-legacy-cdp-chatbot/ │ ├── langchain-privy-chatbot/ +| ├── langchain-dynamic-chatbot/ │ ├── langchain-solana-chatbot/ │ ├── langchain-twitter-chatbot/ │ ├── langchain-xmtp-chatbot/ @@ -273,6 +275,7 @@ AgentKit is proud to have support for the following protocols, frameworks, walle ### Wallets Coinbase +Dynamic Privy ViEM diff --git a/assets/wallets/dynamic.svg b/assets/wallets/dynamic.svg new file mode 100644 index 000000000..05a3b0399 --- /dev/null +++ b/assets/wallets/dynamic.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/typescript/.changeset/small-banks-reply.md b/typescript/.changeset/small-banks-reply.md new file mode 100644 index 000000000..8b7af6ff1 --- /dev/null +++ b/typescript/.changeset/small-banks-reply.md @@ -0,0 +1,6 @@ +--- +"langchain-dynamic-chatbot": minor +"@coinbase/agentkit": minor +--- + +Adds Dynamic as wallet provider diff --git a/typescript/agentkit/package.json b/typescript/agentkit/package.json index 9d41f9392..f2e5800fa 100644 --- a/typescript/agentkit/package.json +++ b/typescript/agentkit/package.json @@ -43,16 +43,19 @@ "@alloralabs/allora-sdk": "^0.1.0", "@coinbase/cdp-sdk": "^1.34.0", "@coinbase/coinbase-sdk": "^0.20.0", + "@dynamic-labs-wallet/node-evm": "0.0.0-preview.160.0", + "@dynamic-labs-wallet/node-svm": "0.0.0-preview.160.0", + "@dynamic-labs-wallet/node": "0.0.0-preview.160.0", "@jup-ag/api": "^6.0.39", "@privy-io/public-api": "2.18.5", "@privy-io/server-auth": "1.18.4", "@solana/spl-token": "^0.4.12", - "@solana/web3.js": "^1.98.1", "@zerodev/ecdsa-validator": "^5.4.5", "@zerodev/intent": "^0.0.24", "@zerodev/sdk": "^5.4.28", "@zoralabs/coins-sdk": "^0.2.8", "axios": "^1.9.0", + "@solana/web3.js": "^1.98.2", "bs58": "^4.0.1", "canonicalize": "^2.1.0", "decimal.js": "^10.5.0", diff --git a/typescript/agentkit/src/wallet-providers/dynamicEvmWalletProvider.test.ts b/typescript/agentkit/src/wallet-providers/dynamicEvmWalletProvider.test.ts new file mode 100644 index 000000000..e33cd50d4 --- /dev/null +++ b/typescript/agentkit/src/wallet-providers/dynamicEvmWalletProvider.test.ts @@ -0,0 +1,204 @@ +import { DynamicEvmWalletProvider } from "./dynamicEvmWalletProvider"; +import { DynamicEvmWalletClient } from "@dynamic-labs-wallet/node-evm"; +import { ThresholdSignatureScheme } from "@dynamic-labs-wallet/node"; +import type { Address, Hex } from "viem"; +import { createWalletClient, http } from "viem"; +import { getChain } from "../network/network"; +import { createDynamicWallet } from "./dynamicShared"; + +jest.mock("@dynamic-labs-wallet/node-evm"); +jest.mock("viem"); +jest.mock("../network/network"); +jest.mock("./dynamicShared"); + +const MOCK_ADDRESS = "0x742d35Cc6634C0532925a3b844Bc454e4438f44e"; +const MOCK_TRANSACTION_HASH = "0xef01"; +const MOCK_SIGNATURE_HASH = "0x1234"; + +describe("DynamicEvmWalletProvider", () => { + const MOCK_CONFIG = { + authToken: "test-auth-token", + environmentId: "test-environment-id", + baseApiUrl: "https://app.dynamicauth.com", + baseMPCRelayApiUrl: "relay.dynamicauth.com", + chainId: "84532", + chainType: "ethereum" as const, + thresholdSignatureScheme: ThresholdSignatureScheme.TWO_OF_TWO, + }; + + const mockWallet = { + accountAddress: MOCK_ADDRESS, + publicKeyHex: "0x123", + }; + + const mockDynamicClient = { + createViemPublicClient: jest.fn().mockReturnValue({ + getBalance: jest.fn(), + getTransactionCount: jest.fn(), + }), + signMessage: jest.fn().mockResolvedValue(MOCK_SIGNATURE_HASH), + exportPrivateKey: jest.fn().mockResolvedValue({ derivedPrivateKey: "0xprivate" }), + importPrivateKey: jest.fn().mockResolvedValue({ + accountAddress: MOCK_ADDRESS, + publicKeyHex: "0x123", + }), + }; + + const mockWalletClient = { + account: { + address: MOCK_ADDRESS, + type: "json-rpc", + }, + chain: { + id: 84532, + name: "Base Goerli", + rpcUrls: { + default: { http: ["https://goerli.base.org"] }, + }, + nativeCurrency: { + name: "Ether", + symbol: "ETH", + decimals: 18, + }, + }, + signMessage: jest.fn().mockResolvedValue(MOCK_SIGNATURE_HASH), + signTypedData: jest.fn().mockResolvedValue(MOCK_SIGNATURE_HASH), + signTransaction: jest.fn().mockResolvedValue(MOCK_SIGNATURE_HASH), + sendTransaction: jest.fn().mockResolvedValue(MOCK_TRANSACTION_HASH), + }; + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock DynamicEvmWalletClient + (DynamicEvmWalletClient as jest.Mock).mockImplementation(() => mockDynamicClient); + + // Mock getChain + (getChain as jest.Mock).mockReturnValue({ + id: 84532, + name: "Base Goerli", + rpcUrls: { + default: { http: ["https://goerli.base.org"] }, + }, + nativeCurrency: { + name: "Ether", + symbol: "ETH", + decimals: 18, + }, + }); + + // Mock createWalletClient + (createWalletClient as jest.Mock).mockReturnValue(mockWalletClient); + + // Mock createDynamicWallet + (createDynamicWallet as jest.Mock).mockResolvedValue({ + wallet: mockWallet, + dynamic: mockDynamicClient, + }); + }); + + describe("configureWithWallet", () => { + it("should create a new wallet with Dynamic client", async () => { + const provider = await DynamicEvmWalletProvider.configureWithWallet(MOCK_CONFIG); + + expect(createDynamicWallet).toHaveBeenCalledWith({ + ...MOCK_CONFIG, + chainType: "ethereum", + }); + + expect(mockDynamicClient.createViemPublicClient).toHaveBeenCalledWith({ + chain: expect.any(Object), + }); + + expect(getChain).toHaveBeenCalledWith(MOCK_CONFIG.chainId); + expect(createWalletClient).toHaveBeenCalled(); + }); + + it("should throw error when wallet creation fails", async () => { + (createDynamicWallet as jest.Mock).mockRejectedValue(new Error("Failed to create wallet")); + + await expect(DynamicEvmWalletProvider.configureWithWallet(MOCK_CONFIG)).rejects.toThrow( + "Failed to create wallet", + ); + }); + + it("should throw error when chain is not found", async () => { + (getChain as jest.Mock).mockReturnValue(null); + + await expect(DynamicEvmWalletProvider.configureWithWallet(MOCK_CONFIG)).rejects.toThrow( + `Chain with ID ${MOCK_CONFIG.chainId} not found`, + ); + }); + + it("should use default chain ID when not provided", async () => { + const { chainId, ...configWithoutChainId } = MOCK_CONFIG; + + await DynamicEvmWalletProvider.configureWithWallet(configWithoutChainId); + + expect(getChain).toHaveBeenCalledWith("84532"); + }); + }); + + describe("wallet methods", () => { + let provider: DynamicEvmWalletProvider; + + beforeEach(async () => { + provider = await DynamicEvmWalletProvider.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: MOCK_CONFIG.chainId, + networkId: "base-sepolia", + }); + }); + + it("should get the provider name", () => { + expect(provider.getName()).toBe("dynamic_evm_wallet_provider"); + }); + + it("should sign a message using Dynamic client", async () => { + const result = await provider.signMessage("Hello, world!"); + expect(result).toBe(MOCK_SIGNATURE_HASH); + expect(mockDynamicClient.signMessage).toHaveBeenCalledWith({ + message: "Hello, world!", + accountAddress: MOCK_ADDRESS, + }); + }); + + it("should export private key", async () => { + const result = await provider.exportPrivateKey(); + expect(result).toBe("0xprivate"); + expect(mockDynamicClient.exportPrivateKey).toHaveBeenCalledWith({ + accountAddress: MOCK_ADDRESS, + }); + }); + + it("should import private key", async () => { + const result = await provider.importPrivateKey("0xprivate"); + expect(result).toEqual({ + accountAddress: MOCK_ADDRESS, + publicKeyHex: "0x123", + }); + expect(mockDynamicClient.importPrivateKey).toHaveBeenCalledWith({ + privateKey: "0xprivate", + chainName: "EVM", + thresholdSignatureScheme: "TWO_OF_TWO", + }); + }); + + it("should export wallet information", async () => { + const result = await provider.exportWallet(); + expect(result).toEqual({ + walletId: MOCK_ADDRESS, + chainId: MOCK_CONFIG.chainId, + networkId: "base-sepolia", + }); + }); + }); +}); \ No newline at end of file diff --git a/typescript/agentkit/src/wallet-providers/dynamicEvmWalletProvider.ts b/typescript/agentkit/src/wallet-providers/dynamicEvmWalletProvider.ts new file mode 100644 index 000000000..44d4cf8b5 --- /dev/null +++ b/typescript/agentkit/src/wallet-providers/dynamicEvmWalletProvider.ts @@ -0,0 +1,179 @@ +import { ViemWalletProvider } from "./viemWalletProvider"; +import { createWalletClient, http, type WalletClient } from "viem"; +import { getChain } from "../network/network"; +import { + type DynamicWalletConfig, + type DynamicWalletExport, + createDynamicWallet, +} from "./dynamicShared"; +import type { DynamicEvmWalletClient } from "@dynamic-labs-wallet/node-evm"; +import { ThresholdSignatureScheme } from "@dynamic-labs-wallet/node"; +/** + * Configuration options for the Dynamic wallet provider. + * + * @interface + */ +export interface DynamicEvmWalletConfig extends DynamicWalletConfig { + /** Optional chain ID to connect to */ + chainId?: string; +} + +/** + * A wallet provider that uses Dynamic's wallet API. + * This provider extends the ViemWalletProvider to provide Dynamic-specific wallet functionality + * while maintaining compatibility with the base wallet provider interface. + */ +export class DynamicEvmWalletProvider extends ViemWalletProvider { + #accountAddress: string; + #dynamicClient: DynamicEvmWalletClient; + + /** + * Private constructor to enforce use of factory method. + * + * @param walletClient - The Viem wallet client instance + * @param config - The configuration options for the Dynamic wallet + */ + private constructor( + walletClient: WalletClient, + config: DynamicWalletConfig & { accountAddress: string }, + dynamicClient: DynamicEvmWalletClient, + ) { + super(walletClient); + this.#accountAddress = config.accountAddress; + this.#dynamicClient = dynamicClient; + } + + /** + * Creates and configures a new DynamicWalletProvider instance. + * + * @param config - The configuration options for the Dynamic wallet + * @returns A configured DynamicWalletProvider instance + * + * @example + * ```typescript + * const provider = await DynamicWalletProvider.configureWithWallet({ + * authToken: "your-auth-token", + * environmentId: "your-environment-id", + * baseApiUrl: "https://app.dynamicauth.com", + * baseMPCRelayApiUrl: "relay.dynamicauth.com", + * chainType: "ethereum", + * chainId: "84532", + * thresholdSignatureScheme: ThresholdSignatureScheme.TWO_OF_TWO + * }); + * ``` + */ + public static async configureWithWallet( + config: DynamicEvmWalletConfig, + ): Promise { + const { wallet, dynamic } = await createDynamicWallet({ + ...config, + chainType: "ethereum", + }); + + const chainId = config.chainId || "84532"; + const chain = getChain(chainId); + if (!chain) { + throw new Error(`Chain with ID ${chainId} not found`); + } + + const publicClient = (dynamic as DynamicEvmWalletClient).createViemPublicClient({ + chain, + }); + + const walletClient = createWalletClient({ + account: { + address: wallet.accountAddress as `0x${string}`, + type: "json-rpc", + }, + chain, + transport: http(), + }); + + return new DynamicEvmWalletProvider(walletClient, { + ...config, + accountAddress: wallet.accountAddress, + }, dynamic as DynamicEvmWalletClient); + } + + /** + * Signs a message using the wallet's private key. + * + * @param message - The message to sign + * @returns The signature as a hex string with 0x prefix + */ + public async signMessage(message: string): Promise<`0x${string}`> { + const signature = await this.#dynamicClient.signMessage({ + message, + accountAddress: this.#accountAddress, + }); + return signature as `0x${string}`; + } + + /** + * Exports the private key for the wallet. + * + * @param password - Optional password for encrypted backup shares + * @returns The private key + */ + public async exportPrivateKey(password?: string): Promise { + const result = await this.#dynamicClient.exportPrivateKey({ + accountAddress: this.getAddress(), + password, + }); + return result.derivedPrivateKey || ""; + } + + /** + * Imports a private key. + * + * @param privateKey - The private key to import + * @param password - Optional password for encrypted backup shares + * @returns The account address and public key + */ + public async importPrivateKey(privateKey: string, password?: string): Promise<{ + accountAddress: string; + publicKeyHex: string; + }> { + const result = await this.#dynamicClient.importPrivateKey({ + privateKey, + chainName: "EVM", + thresholdSignatureScheme: ThresholdSignatureScheme.TWO_OF_TWO, + password, + }); + return { + accountAddress: result.accountAddress, + publicKeyHex: result.publicKeyHex, + }; + } + + /** + * Exports the wallet information. + * + * @returns The wallet information + */ + public async exportWallet(): Promise { + return { + walletId: this.#accountAddress, + chainId: this.getNetwork().chainId, + networkId: this.getNetwork().networkId, + }; + } + + /** + * Gets the name of the provider. + * + * @returns The provider name + */ + public getName(): string { + return "dynamic_evm_wallet_provider"; + } + + /** + * Gets the address of the wallet. + * + * @returns The wallet address + */ + public getAddress(): string { + return this.#accountAddress; + } +} \ No newline at end of file diff --git a/typescript/agentkit/src/wallet-providers/dynamicShared.ts b/typescript/agentkit/src/wallet-providers/dynamicShared.ts new file mode 100644 index 000000000..eec17268d --- /dev/null +++ b/typescript/agentkit/src/wallet-providers/dynamicShared.ts @@ -0,0 +1,134 @@ +import { DynamicEvmWalletClient } from "@dynamic-labs-wallet/node-evm"; +import { DynamicSvmWalletClient } from "@dynamic-labs-wallet/node-svm"; +import type { ThresholdSignatureScheme } from "@dynamic-labs-wallet/node"; +/** + * Configuration options for the Dynamic wallet provider. + * + * @interface + */ +export interface DynamicWalletConfig { + /** The Dynamic authentication token */ + authToken: string; + /** The Dynamic environment ID */ + environmentId: string; + /** The base API URL for Dynamic */ + baseApiUrl: string; + /** The base MPC relay API URL for Dynamic */ + baseMPCRelayApiUrl: string; + /** The ID of the wallet to use, if not provided a new wallet will be created */ + walletId?: string; + /** The chain ID to use for the wallet (for EVM) */ + chainId?: string; + /** The network ID to use for the wallet (for SVM) */ + networkId?: string; + /** The type of wallet to create */ + chainType: "ethereum" | "solana"; + /** The threshold signature scheme to use for wallet creation */ + thresholdSignatureScheme?: ThresholdSignatureScheme; + /** Optional password for encrypted backup shares */ + password?: string; +} + +export type DynamicWalletExport = { + walletId: string; + chainId: string | undefined; + networkId: string | undefined; +}; + +type CreateDynamicWalletReturnType = { + wallet: { + accountAddress: string; + publicKeyHex?: string; // Only for EVM + rawPublicKey: Uint8Array; + externalServerKeyShares: unknown[]; // Specify a more appropriate type if known + }; + dynamic: DynamicEvmWalletClient | DynamicSvmWalletClient; +}; + +/** + * Create a Dynamic client based on the chain type + * + * @param config - The configuration options for the Dynamic client + * @returns The created Dynamic client + */ +export const createDynamicClient = async (config: DynamicWalletConfig) => { + const clientConfig = { + authToken: config.authToken, + environmentId: config.environmentId, + baseApiUrl: config.baseApiUrl, + baseMPCRelayApiUrl: config.baseMPCRelayApiUrl, + }; + + try { + const client = config.chainType === "ethereum" + ? new DynamicEvmWalletClient(clientConfig) + : new DynamicSvmWalletClient(clientConfig); + + console.log("[createDynamicClient] Client created successfully"); + await client.authenticateApiToken(config.authToken); + console.log("[createDynamicClient] Client authenticated successfully"); + return client; + } catch (error) { + console.error("[createDynamicClient] Error creating client:", error); + throw error; + } +}; + +/** + * Create a Dynamic wallet + * + * @param config - The configuration options for the Dynamic wallet + * @returns The created Dynamic wallet and client + */ +export const createDynamicWallet = async (config: DynamicWalletConfig): Promise => { + console.log("[createDynamicWallet] Starting wallet creation with config:", { + chainType: config.chainType, + networkId: config.networkId + }); + + if (!config.thresholdSignatureScheme) { + throw new Error("thresholdSignatureScheme is required for wallet creation"); + } + + const client = await createDynamicClient(config); + console.log("[createDynamicWallet] Dynamic client created"); + + let wallet: CreateDynamicWalletReturnType['wallet']; + if (config.walletId) { + console.log("[createDynamicWallet] Using existing wallet ID:", config.walletId); + if (config.chainType === "solana") { + const svmClient = client as DynamicSvmWalletClient; + const result = await svmClient.deriveAccountAddress(new TextEncoder().encode(config.walletId)); + wallet = { + accountAddress: result.accountAddress, + rawPublicKey: new Uint8Array(), + externalServerKeyShares: [] + }; + } else { + throw new Error("deriveAccountAddress is only supported for Solana wallets"); + } + } else { + console.log("[createDynamicWallet] Creating new wallet"); + console.log("[createDynamicWallet] createWalletAccount params:", { + thresholdSignatureScheme: config.thresholdSignatureScheme, + password: config.password ? "***" : undefined, + networkId: config.networkId, + chainType: config.chainType + }); + const result = await client.createWalletAccount({ + thresholdSignatureScheme: config.thresholdSignatureScheme, + password: config.password, + }); + wallet = { + accountAddress: result.accountAddress, + rawPublicKey: result.rawPublicKey, + externalServerKeyShares: result.externalServerKeyShares + }; + } + + console.log("[createDynamicWallet] Wallet created/retrieved:", { + accountAddress: wallet.accountAddress + }); + + return { wallet, dynamic: client }; +}; \ No newline at end of file diff --git a/typescript/agentkit/src/wallet-providers/dynamicSvmWalletProvider.test.ts b/typescript/agentkit/src/wallet-providers/dynamicSvmWalletProvider.test.ts new file mode 100644 index 000000000..eed8a558f --- /dev/null +++ b/typescript/agentkit/src/wallet-providers/dynamicSvmWalletProvider.test.ts @@ -0,0 +1,300 @@ +import { DynamicSvmWalletProvider } from "./dynamicSvmWalletProvider"; +import { DynamicSvmWalletClient } from "@dynamic-labs-wallet/node-svm"; +import { Connection, clusterApiUrl, PublicKey, VersionedTransaction, MessageV0 } from "@solana/web3.js"; +import { createDynamicWallet } from "./dynamicShared"; +import { ThresholdSignatureScheme } from "@dynamic-labs-wallet/node"; + +jest.mock("@dynamic-labs-wallet/node-svm"); +jest.mock("../network/svm", () => ({ + SOLANA_CLUSTER_ID_BY_NETWORK_ID: { + "": "mainnet-beta", + "mainnet-beta": "mainnet-beta", + "testnet": "testnet", + "devnet": "devnet", + }, +})); +jest.mock("@solana/web3.js", () => { + const actual = jest.requireActual("@solana/web3.js"); + const mockVersionedTransaction = jest.fn().mockImplementation(message => { + const tx = { + signatures: [], + message: message || { compiledMessage: Buffer.from([]) }, + }; + Object.setPrototypeOf(tx, actual.VersionedTransaction.prototype); + return tx; + }); + + return { + ...actual, + Connection: jest.fn().mockImplementation((endpoint, commitment = "confirmed") => { + // Store the commitment for verification + (Connection as jest.Mock).mock.lastCall = [endpoint, commitment]; + return { + getGenesisHash: jest.fn().mockResolvedValue("test-genesis-hash"), + commitment, + rpcEndpoint: endpoint, + getBalance: jest.fn(), + getBalanceAndContext: jest.fn(), + sendTransaction: jest.fn().mockResolvedValue(MOCK_TRANSACTION_HASH), + getSignatureStatus: jest.fn().mockResolvedValue({ + context: { slot: 123 }, + value: { slot: 123, confirmations: 10, err: null }, + }), + getLatestBlockhash: jest.fn().mockResolvedValue({ + blockhash: "test-blockhash", + lastValidBlockHeight: 123, + }), + }; + }), + PublicKey: jest.fn().mockImplementation(address => ({ + toBase58: jest.fn().mockReturnValue(address), + toString: jest.fn().mockReturnValue(address), + toBuffer: jest.fn().mockReturnValue(Buffer.from(address)), + toArrayLike: jest.fn().mockReturnValue(Buffer.from(address)), + })), + VersionedTransaction: mockVersionedTransaction, + MessageV0: { + compile: jest.fn().mockReturnValue({ + compiledMessage: Buffer.from([]), + }), + }, + clusterApiUrl: jest.fn().mockImplementation(network => { + // Always use mainnet-beta as default + const networkId = network || "mainnet-beta"; + return `https://api.${networkId}.solana.com`; + }), + }; +}); +jest.mock("./dynamicShared"); + +const MOCK_ADDRESS = "test-address"; +const MOCK_TRANSACTION_HASH = "test-tx-hash"; +const MOCK_SIGNATURE_HASH = "test-signature"; +const MOCK_NETWORK = { + protocolFamily: "svm", + chainId: undefined, + networkId: "mainnet-beta", +}; + +describe("DynamicSvmWalletProvider", () => { + const MOCK_CONFIG = { + authToken: "test-auth-token", + environmentId: "test-environment-id", + baseApiUrl: "https://app.dynamicauth.com", + baseMPCRelayApiUrl: "relay.dynamicauth.com", + networkId: "mainnet-beta", + chainType: "solana" as const, + thresholdSignatureScheme: ThresholdSignatureScheme.TWO_OF_TWO, + }; + + const mockWallet = { + accountAddress: MOCK_ADDRESS, + publicKeyHex: "0x123", + }; + + const mockDynamicClient = { + signMessage: jest.fn().mockResolvedValue(MOCK_SIGNATURE_HASH), + signTransaction: jest.fn().mockImplementation(({ transaction }) => { + // Return the transaction directly, not the whole object + if (!(transaction instanceof VersionedTransaction)) { + Object.setPrototypeOf(transaction, VersionedTransaction.prototype); + } + return transaction; + }), + createWalletAccount: jest.fn().mockResolvedValue({ + accountAddress: MOCK_ADDRESS, + rawPublicKey: new Uint8Array(), + externalServerKeyShares: [], + }), + deriveAccountAddress: jest.fn().mockResolvedValue({ + accountAddress: MOCK_ADDRESS, + }), + exportPrivateKey: jest.fn().mockResolvedValue({ + derivedPrivateKey: "test-private-key", + }), + importPrivateKey: jest.fn().mockResolvedValue({ + accountAddress: MOCK_ADDRESS, + rawPublicKey: new Uint8Array(), + externalServerKeyShares: [], + }), + getSvmWallets: jest.fn().mockResolvedValue([]), + }; + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock DynamicSvmWalletClient + (DynamicSvmWalletClient as jest.Mock).mockImplementation(() => mockDynamicClient); + + // Mock createDynamicWallet + (createDynamicWallet as jest.Mock).mockResolvedValue({ + wallet: mockWallet, + dynamic: mockDynamicClient, + }); + }); + + describe("configureWithWallet", () => { + it("should create a new wallet with Dynamic client", async () => { + const provider = await DynamicSvmWalletProvider.configureWithWallet(MOCK_CONFIG); + + expect(createDynamicWallet).toHaveBeenCalledWith({ + ...MOCK_CONFIG, + chainType: "solana", + }); + + expect(Connection).toHaveBeenCalledWith( + "https://api.mainnet-beta.solana.com" + ); + const connection = (Connection as jest.Mock).mock.results[0].value; + expect(connection.getGenesisHash).toHaveBeenCalled(); + }); + + it("should throw error when wallet creation fails", async () => { + (createDynamicWallet as jest.Mock).mockRejectedValue(new Error("Failed to create wallet")); + + await expect(DynamicSvmWalletProvider.configureWithWallet(MOCK_CONFIG)).rejects.toThrow( + "Failed to create wallet", + ); + }); + + it("should use provided connection when available", async () => { + const mockConnection = { + getGenesisHash: jest.fn().mockResolvedValue("test-genesis-hash"), + commitment: "confirmed", + rpcEndpoint: "https://custom-rpc.example.com", + getBalance: jest.fn(), + getBalanceAndContext: jest.fn(), + sendTransaction: jest.fn().mockResolvedValue(MOCK_TRANSACTION_HASH), + getSignatureStatus: jest.fn().mockResolvedValue({ + context: { slot: 123 }, + value: { slot: 123, confirmations: 10, err: null }, + }), + getLatestBlockhash: jest.fn().mockResolvedValue({ + blockhash: "test-blockhash", + lastValidBlockHeight: 123, + }), + }; + const config = { + ...MOCK_CONFIG, + connection: mockConnection as unknown as Connection, + }; + const provider = await DynamicSvmWalletProvider.configureWithWallet(config); + + expect(Connection).not.toHaveBeenCalled(); + expect(mockConnection.getGenesisHash).toHaveBeenCalled(); + }); + + it("should use default network ID when not provided", async () => { + const { networkId, ...configWithoutNetworkId } = MOCK_CONFIG; + + await DynamicSvmWalletProvider.configureWithWallet(configWithoutNetworkId); + + expect(clusterApiUrl).toHaveBeenCalledWith("mainnet-beta"); + }); + }); + + describe("wallet methods", () => { + let provider: DynamicSvmWalletProvider; + + beforeEach(async () => { + provider = await DynamicSvmWalletProvider.configureWithWallet(MOCK_CONFIG); + // Mock getNetwork to return our test network + jest.spyOn(provider, "getNetwork").mockReturnValue(MOCK_NETWORK); + }); + + it("should get the wallet address", () => { + expect(provider.getAddress()).toBe(MOCK_ADDRESS); + }); + + it("should get the network information", () => { + expect(provider.getNetwork()).toEqual(MOCK_NETWORK); + }); + + it("should get the provider name", () => { + expect(provider.getName()).toBe("dynamic_svm_wallet_provider"); + }); + + it("should sign a message using Dynamic client", async () => { + const result = await provider.signMessage("Hello, world!"); + expect(result).toBe(MOCK_SIGNATURE_HASH); + expect(mockDynamicClient.signMessage).toHaveBeenCalledWith({ + message: "Hello, world!", + accountAddress: MOCK_ADDRESS, + }); + }); + + it("should sign a transaction using Dynamic client", async () => { + const message = MessageV0.compile({ + payerKey: new PublicKey(MOCK_ADDRESS), + instructions: [], + recentBlockhash: "test-blockhash", + }); + const transaction = new VersionedTransaction(message); + const result = await provider.signTransaction(transaction); + expect(result).toBe(transaction); + expect(mockDynamicClient.signTransaction).toHaveBeenCalledWith({ + senderAddress: MOCK_ADDRESS, + transaction, + }); + }); + + it("should send a transaction", async () => { + const message = MessageV0.compile({ + payerKey: new PublicKey(MOCK_ADDRESS), + instructions: [], + recentBlockhash: "test-blockhash", + }); + const transaction = new VersionedTransaction(message); + const result = await provider.sendTransaction(transaction); + expect(result).toBe(MOCK_TRANSACTION_HASH); + const connection = (Connection as jest.Mock).mock.results[0].value; + expect(connection.sendTransaction).toHaveBeenCalledWith(transaction); + }); + + it("should sign and send a transaction", async () => { + const message = MessageV0.compile({ + payerKey: new PublicKey(MOCK_ADDRESS), + instructions: [], + recentBlockhash: "test-blockhash", + }); + const transaction = new VersionedTransaction(message); + const result = await provider.signAndSendTransaction(transaction); + expect(result).toBe(MOCK_TRANSACTION_HASH); + expect(mockDynamicClient.signTransaction).toHaveBeenCalledWith({ + senderAddress: MOCK_ADDRESS, + transaction, + }); + const connection = (Connection as jest.Mock).mock.results[0].value; + expect(connection.sendTransaction).toHaveBeenCalledWith(transaction); + }); + + it("should get signature status", async () => { + const result = await provider.getSignatureStatus(MOCK_TRANSACTION_HASH); + expect(result).toEqual({ + context: { slot: 123 }, + value: { slot: 123, confirmations: 10, err: null }, + }); + const connection = (Connection as jest.Mock).mock.results[0].value; + expect(connection.getSignatureStatus).toHaveBeenCalledWith(MOCK_TRANSACTION_HASH, undefined); + }); + + it("should wait for signature result", async () => { + const result = await provider.waitForSignatureResult(MOCK_TRANSACTION_HASH); + expect(result).toEqual({ + context: { slot: 123 }, + value: { slot: 123, confirmations: 10, err: null }, + }); + const connection = (Connection as jest.Mock).mock.results[0].value; + expect(connection.getSignatureStatus).toHaveBeenCalledWith(MOCK_TRANSACTION_HASH); + }); + + it("should export wallet information", async () => { + const result = await provider.exportWallet(); + expect(result).toEqual({ + walletId: MOCK_ADDRESS, + chainId: undefined, + networkId: "mainnet-beta", + }); + }); + }); +}); \ No newline at end of file diff --git a/typescript/agentkit/src/wallet-providers/dynamicSvmWalletProvider.ts b/typescript/agentkit/src/wallet-providers/dynamicSvmWalletProvider.ts new file mode 100644 index 000000000..262a3c70e --- /dev/null +++ b/typescript/agentkit/src/wallet-providers/dynamicSvmWalletProvider.ts @@ -0,0 +1,339 @@ +import { SvmWalletProvider } from "./svmWalletProvider"; +import { + clusterApiUrl, + LAMPORTS_PER_SOL, + SystemProgram, + ComputeBudgetProgram, + Connection, + PublicKey, + VersionedTransaction, + MessageV0, +} from "@solana/web3.js"; +import { SOLANA_CLUSTER_ID_BY_NETWORK_ID, SOLANA_NETWORKS } from "../network/svm"; +import { type DynamicWalletConfig, type DynamicWalletExport, createDynamicWallet } from "./dynamicShared"; +import type { DynamicSvmWalletClient } from "@dynamic-labs-wallet/node-svm"; +import type { + SignatureStatus, + SignatureStatusConfig, + RpcResponseAndContext, + SignatureResult, +} from "@solana/web3.js"; +import type { Network } from "../network"; + +/** + * Configuration options for the Dynamic Svm wallet provider. + */ +export interface DynamicSvmWalletConfig extends DynamicWalletConfig { + /** The network ID to use for the wallet */ + networkId?: string; + /** The connection to use for the wallet */ + connection?: Connection; +} + +/** + * A wallet provider that uses Dynamic's wallet API. + * This provider extends the SvmWalletProvider to provide Dynamic-specific wallet functionality + * while maintaining compatibility with the base wallet provider interface. + */ +export class DynamicSvmWalletProvider extends SvmWalletProvider { + #accountAddress: string; + #dynamicClient: DynamicSvmWalletClient; + #connection: Connection; + #genesisHash: string; + #publicKey: PublicKey; + + /** + * Private constructor to enforce use of factory method. + * + * @param config - The configuration options for the Dynamic wallet + */ + private constructor( + config: DynamicSvmWalletConfig & { + accountAddress: string; + dynamicClient: DynamicSvmWalletClient; + connection: Connection; + genesisHash: string; + }, + ) { + super(); + console.log("[DynamicSvmWalletProvider] Initializing provider with:", { + accountAddress: config.accountAddress, + genesisHash: config.genesisHash, + networkId: config.networkId + }); + + this.#accountAddress = config.accountAddress; + this.#dynamicClient = config.dynamicClient; + this.#connection = config.connection; + this.#genesisHash = config.genesisHash; + this.#publicKey = new PublicKey(config.accountAddress); + + console.log("[DynamicSvmWalletProvider] Provider initialization complete"); + } + + /** + * Creates and configures a new DynamicSvmWalletProvider instance. + * + * @param config - The configuration options for the Dynamic wallet + * @returns A configured DynamicSvmWalletProvider instance + * + * @example + * ```typescript + * const provider = await DynamicSvmWalletProvider.configureWithWallet({ + * authToken: "your-auth-token", + * environmentId: "your-environment-id", + * baseApiUrl: "https://app.dynamicauth.com", + * baseMPCRelayApiUrl: "relay.dynamicauth.com", + * chainType: "solana", + * networkId: "mainnet-beta", + * thresholdSignatureScheme: ThresholdSignatureScheme.TWO_OF_TWO + * }); + * ``` + */ + public static async configureWithWallet( + config: DynamicSvmWalletConfig, + ): Promise { + console.log("[DynamicSvmWalletProvider] Starting wallet configuration with config:", { + networkId: config.networkId, + chainType: config.chainType, + baseApiUrl: config.baseApiUrl, + environmentId: config.environmentId + }); + + try { + const { wallet, dynamic } = await createDynamicWallet({ + ...config, + chainType: "solana", + }); + + console.log("[DynamicSvmWalletProvider] Wallet created:", { + accountAddress: wallet.accountAddress + }); + + const connection = + config.connection ?? + new Connection(clusterApiUrl(SOLANA_CLUSTER_ID_BY_NETWORK_ID[config.networkId ?? ""])); + + console.log("[DynamicSvmWalletProvider] Connection established with endpoint:", connection.rpcEndpoint); + + const genesisHash = await connection.getGenesisHash(); + console.log("[DynamicSvmWalletProvider] Genesis hash retrieved:", genesisHash); + + const provider = new DynamicSvmWalletProvider({ + ...config, + accountAddress: wallet.accountAddress, + dynamicClient: dynamic as DynamicSvmWalletClient, + connection, + genesisHash, + }); + + console.log("[DynamicSvmWalletProvider] Provider initialized with:", { + address: provider.getAddress(), + network: provider.getNetwork(), + name: provider.getName() + }); + + return provider; + } catch (error) { + console.error("[DynamicSvmWalletProvider] Error during configuration:", error); + throw error; + } + } + + /** + * Signs a message. + * + * @param message - The message to sign + * @returns The signature + */ + public async signMessage(message: string): Promise { + return this.#dynamicClient.signMessage({ + message, + accountAddress: this.getAddress(), + }); + } + + /** + * Signs a transaction. + * + * @param transaction - The transaction to sign + * @returns The signed transaction + */ + public async signTransaction(transaction: VersionedTransaction): Promise { + const signedTransaction = await this.#dynamicClient.signTransaction({ + senderAddress: this.#accountAddress, + transaction, + }); + if (!(signedTransaction instanceof VersionedTransaction)) { + throw new Error("Expected VersionedTransaction from signTransaction"); + } + return signedTransaction; + } + + /** + * Sends a transaction. + * + * @param transaction - The transaction to send + * @returns The transaction signature + */ + public async sendTransaction(transaction: VersionedTransaction): Promise { + const result = await this.#connection.sendTransaction(transaction); + return result; + } + + /** + * Signs and sends a transaction. + * + * @param transaction - The transaction to sign and send + * @returns The transaction signature + */ + public async signAndSendTransaction(transaction: VersionedTransaction): Promise { + const signedTransaction = await this.signTransaction(transaction); + return this.sendTransaction(signedTransaction); + } + + /** + * Gets the status of a transaction. + * + * @param signature - The transaction signature + * @param options - Optional configuration for the status check + * @returns The transaction status + */ + public async getSignatureStatus( + signature: string, + options?: SignatureStatusConfig, + ): Promise> { + return this.#connection.getSignatureStatus(signature, options); + } + + /** + * Waits for a transaction signature result. + * + * @param signature - The transaction signature + * @returns The transaction result + */ + public async waitForSignatureResult( + signature: string, + ): Promise> { + const status = await this.#connection.getSignatureStatus(signature); + if (!status.value) { + throw new Error(`Transaction ${signature} not found`); + } + return status as RpcResponseAndContext; + } + + /** + * Gets the network of the wallet. + * + * @returns The network + */ + public getNetwork(): Network { + return SOLANA_NETWORKS[this.#genesisHash]; + } + + /** + * Gets the name of the wallet provider. + * + * @returns The wallet provider name + */ + public getName(): string { + return "dynamic_svm_wallet_provider"; + } + + /** + * Exports the wallet information. + * + * @returns The wallet information + */ + public async exportWallet(): Promise { + return { + walletId: this.#accountAddress, + chainId: undefined, + networkId: this.getNetwork().networkId, + }; + } + + /** + * Gets the Solana connection. + * + * @returns The Solana connection + */ + public getConnection(): Connection { + return this.#connection; + } + + /** + * Gets the public key of the wallet. + * + * @returns The public key + */ + public getPublicKey(): PublicKey { + return this.#publicKey; + } + + /** + * Gets the address of the wallet. + * + * @returns The wallet address + */ + public getAddress(): string { + return this.#accountAddress; + } + + /** + * Gets the balance of the wallet. + * + * @returns The wallet balance in lamports + */ + public async getBalance(): Promise { + const balance = await this.#connection.getBalance(this.#publicKey); + return BigInt(balance); + } + + /** + * Performs a native transfer. + * + * @param to - The recipient address + * @param value - The amount to transfer in SOL (as a decimal string, e.g. "0.0001") + * @returns The transaction signature + */ + public async nativeTransfer(to: string, value: string): Promise { + const initialBalance = await this.getBalance(); + const solAmount = Number.parseFloat(value); + const lamports = BigInt(Math.floor(solAmount * LAMPORTS_PER_SOL)); + + // Check if we have enough balance (including estimated fees) + if (initialBalance < lamports + BigInt(5000)) { + throw new Error( + `Insufficient balance. Have ${Number(initialBalance) / LAMPORTS_PER_SOL} SOL, need ${ + solAmount + 0.000005 + } SOL (including fees)`, + ); + } + + const toPubkey = new PublicKey(to); + const instructions = [ + ComputeBudgetProgram.setComputeUnitPrice({ + microLamports: 10000, + }), + ComputeBudgetProgram.setComputeUnitLimit({ + units: 2000, + }), + SystemProgram.transfer({ + fromPubkey: this.getPublicKey(), + toPubkey: toPubkey, + lamports: lamports, + }), + ]; + + const tx = new VersionedTransaction( + MessageV0.compile({ + payerKey: this.getPublicKey(), + instructions: instructions, + recentBlockhash: (await this.#connection.getLatestBlockhash()).blockhash, + }), + ); + + return this.signAndSendTransaction(tx); + } +} \ No newline at end of file diff --git a/typescript/agentkit/src/wallet-providers/dynamicWalletProvider.test.ts b/typescript/agentkit/src/wallet-providers/dynamicWalletProvider.test.ts new file mode 100644 index 000000000..e450df5e7 --- /dev/null +++ b/typescript/agentkit/src/wallet-providers/dynamicWalletProvider.test.ts @@ -0,0 +1,153 @@ +import { DynamicWalletProvider } from "./dynamicWalletProvider"; +import type { DynamicEvmWalletConfig } from "./dynamicEvmWalletProvider"; +import type { DynamicSvmWalletConfig } from "./dynamicSvmWalletProvider"; +import { DynamicEvmWalletProvider } from "./dynamicEvmWalletProvider"; +import { DynamicSvmWalletProvider } from "./dynamicSvmWalletProvider"; + +global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({}), + } as Response), +); + +jest.mock("../analytics", () => ({ + sendAnalyticsEvent: jest.fn().mockImplementation(() => Promise.resolve()), +})); + +jest.mock("./dynamicEvmWalletProvider", () => ({ + DynamicEvmWalletProvider: { + configureWithWallet: jest.fn().mockResolvedValue({ + getAddress: jest.fn().mockReturnValue("0x742d35Cc6634C0532925a3b844Bc454e4438f44e"), + getNetwork: jest.fn().mockReturnValue({ + protocolFamily: "evm", + chainId: "1", + networkId: "mainnet", + }), + }), + }, +})); + +jest.mock("./dynamicSvmWalletProvider", () => ({ + DynamicSvmWalletProvider: { + configureWithWallet: jest.fn().mockResolvedValue({ + getAddress: jest.fn().mockReturnValue("AQoKYV7tYpTrFZN6P5oUufbQKAUr9mNYGe1TTJC9wajM"), + getNetwork: jest.fn().mockReturnValue({ + protocolFamily: "solana", + chainId: "mainnet-beta", + networkId: "mainnet-beta", + }), + }), + }, +})); + +describe("DynamicWalletProvider", () => { + const MOCK_EVM_CONFIG: DynamicEvmWalletConfig = { + authToken: "test-auth-token", + environmentId: "test-environment-id", + baseApiUrl: "https://app.dynamicauth.com", + baseMPCRelayApiUrl: "relay.dynamicauth.com", + chainId: "1", + chainType: "ethereum", + }; + + const MOCK_SVM_CONFIG: DynamicSvmWalletConfig = { + authToken: "test-auth-token", + environmentId: "test-environment-id", + baseApiUrl: "https://app.dynamicauth.com", + baseMPCRelayApiUrl: "relay.dynamicauth.com", + chainType: "solana", + networkId: "mainnet-beta", + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should create an EVM wallet provider by default", async () => { + const provider = await DynamicWalletProvider.configureWithWallet(MOCK_EVM_CONFIG); + + expect(DynamicEvmWalletProvider.configureWithWallet).toHaveBeenCalledWith(MOCK_EVM_CONFIG); + expect(DynamicSvmWalletProvider.configureWithWallet).not.toHaveBeenCalled(); + + expect(provider.getAddress()).toBe("0x742d35Cc6634C0532925a3b844Bc454e4438f44e"); + expect(provider.getNetwork().protocolFamily).toBe("evm"); + }); + + it("should create an EVM wallet provider when explicitly requested", async () => { + const config: DynamicEvmWalletConfig = { + ...MOCK_EVM_CONFIG, + chainType: "ethereum", + }; + + const provider = await DynamicWalletProvider.configureWithWallet(config); + + expect(DynamicEvmWalletProvider.configureWithWallet).toHaveBeenCalledWith(config); + expect(DynamicSvmWalletProvider.configureWithWallet).not.toHaveBeenCalled(); + + expect(provider.getAddress()).toBe("0x742d35Cc6634C0532925a3b844Bc454e4438f44e"); + expect(provider.getNetwork().protocolFamily).toBe("evm"); + }); + + it("should create an SVM wallet provider when solana is specified", async () => { + const provider = await DynamicWalletProvider.configureWithWallet(MOCK_SVM_CONFIG); + + expect(DynamicSvmWalletProvider.configureWithWallet).toHaveBeenCalledWith(MOCK_SVM_CONFIG); + expect(DynamicEvmWalletProvider.configureWithWallet).not.toHaveBeenCalled(); + + expect(provider.getAddress()).toBe("AQoKYV7tYpTrFZN6P5oUufbQKAUr9mNYGe1TTJC9wajM"); + expect(provider.getNetwork().protocolFamily).toBe("solana"); + }); + + it("should pass through all config properties", async () => { + const fullConfig: DynamicEvmWalletConfig = { + ...MOCK_EVM_CONFIG, + chainId: "5", + }; + + await DynamicWalletProvider.configureWithWallet(fullConfig); + + expect(DynamicEvmWalletProvider.configureWithWallet).toHaveBeenCalledWith(fullConfig); + }); + + it("should handle initialization failures properly", async () => { + const mockEvmConfigureWithWallet = DynamicEvmWalletProvider.configureWithWallet as jest.Mock; + + const originalImplementation = mockEvmConfigureWithWallet.getMockImplementation(); + + mockEvmConfigureWithWallet.mockImplementation(() => { + throw new Error("Auth token not found"); + }); + + await expect( + DynamicWalletProvider.configureWithWallet(MOCK_EVM_CONFIG), + ).rejects.toThrow("Auth token not found"); + + mockEvmConfigureWithWallet.mockImplementation(originalImplementation); + }); + + it("should validate config properly", async () => { + const mockEvmConfigureWithWallet = DynamicEvmWalletProvider.configureWithWallet as jest.Mock; + const originalImplementation = mockEvmConfigureWithWallet.getMockImplementation(); + + mockEvmConfigureWithWallet.mockImplementation(config => { + if (!config.authToken) { + throw new Error("Missing required authToken"); + } + return Promise.resolve({}); + }); + + const testConfig: Partial = { + environmentId: "test-environment-id", + baseApiUrl: "https://app.dynamicauth.com", + baseMPCRelayApiUrl: "relay.dynamicauth.com", + chainType: "ethereum", + }; + + await expect(DynamicWalletProvider.configureWithWallet(testConfig as DynamicEvmWalletConfig)).rejects.toThrow( + "Missing required authToken", + ); + + mockEvmConfigureWithWallet.mockImplementation(originalImplementation); + }); +}); \ No newline at end of file diff --git a/typescript/agentkit/src/wallet-providers/dynamicWalletProvider.ts b/typescript/agentkit/src/wallet-providers/dynamicWalletProvider.ts new file mode 100644 index 000000000..3403d8cf6 --- /dev/null +++ b/typescript/agentkit/src/wallet-providers/dynamicWalletProvider.ts @@ -0,0 +1,56 @@ +import { DynamicEvmWalletProvider, DynamicEvmWalletConfig } from "./dynamicEvmWalletProvider"; +import { DynamicSvmWalletProvider, DynamicSvmWalletConfig } from "./dynamicSvmWalletProvider"; + +type DynamicWalletConfig = ( + | DynamicEvmWalletConfig + | DynamicSvmWalletConfig +) & { + chainType?: "ethereum" | "solana"; +}; + +/** + * Factory class for creating Dynamic wallet providers. + * This class provides a unified interface for creating both EVM and SVM wallet providers. + */ +export class DynamicWalletProvider { + /** + * Creates and configures a new Dynamic wallet provider instance. + * + * @param config - The configuration options for the Dynamic wallet + * @returns A configured Dynamic wallet provider instance + * + * @example + * ```typescript + * // Create an EVM wallet provider + * const evmProvider = await DynamicWalletProvider.configureWithWallet({ + * authToken: "your-auth-token", + * environmentId: "your-environment-id", + * baseApiUrl: "https://app.dynamicauth.com", + * baseMPCRelayApiUrl: "relay.dynamicauth.com", + * chainType: "ethereum", + * chainId: "84532" + * }); + * + * // Create an SVM wallet provider + * const svmProvider = await DynamicWalletProvider.configureWithWallet({ + * authToken: "your-auth-token", + * environmentId: "your-environment-id", + * baseApiUrl: "https://app.dynamicauth.com", + * baseMPCRelayApiUrl: "relay.dynamicauth.com", + * chainType: "solana", + * networkId: "mainnet-beta" + * }); + * ``` + */ + public static async configureWithWallet( + config: DynamicWalletConfig, + ): Promise { + const chainType = config.chainType || "ethereum"; + + if (chainType === "ethereum") { + return DynamicEvmWalletProvider.configureWithWallet(config as DynamicEvmWalletConfig); + } else { + return DynamicSvmWalletProvider.configureWithWallet(config as DynamicSvmWalletConfig); + } + } +} \ No newline at end of file diff --git a/typescript/agentkit/src/wallet-providers/index.ts b/typescript/agentkit/src/wallet-providers/index.ts index 0c727b42b..042e5e332 100644 --- a/typescript/agentkit/src/wallet-providers/index.ts +++ b/typescript/agentkit/src/wallet-providers/index.ts @@ -14,3 +14,6 @@ export * from "./privyEvmWalletProvider"; export * from "./privySvmWalletProvider"; export * from "./privyEvmDelegatedEmbeddedWalletProvider"; export * from "./zeroDevWalletProvider"; +export * from "./dynamicWalletProvider"; +export * from "./dynamicEvmWalletProvider"; +export * from "./dynamicSvmWalletProvider"; diff --git a/typescript/examples/langchain-dynamic-chatbot/.env-local b/typescript/examples/langchain-dynamic-chatbot/.env-local new file mode 100644 index 000000000..4b1498e25 --- /dev/null +++ b/typescript/examples/langchain-dynamic-chatbot/.env-local @@ -0,0 +1,16 @@ +OPENAI_API_KEY= + +# Dynamic Configuration - get these from your Dynamic dashboard +DYNAMIC_AUTH_TOKEN= +DYNAMIC_ENVIRONMENT_ID= +DYNAMIC_BASE_API_URL=https://app.dynamicauth.com +DYNAMIC_BASE_MPC_RELAY_API_URL=https://relay.dynamicauth.com + +# Optional Network ID. If you'd like to use a Dynamic Solana wallet, set to "solana-devnet". Otherwise, defaults to "base-sepolia" +NETWORK_ID= + +# Optional CDP API Key Name. 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_NAME= + +# Optional CDP API Key Private Key. 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 +CDP_API_KEY_PRIVATE_KEY= \ No newline at end of file diff --git a/typescript/examples/langchain-dynamic-chatbot/.eslintrc.json b/typescript/examples/langchain-dynamic-chatbot/.eslintrc.json new file mode 100644 index 000000000..91571ba7a --- /dev/null +++ b/typescript/examples/langchain-dynamic-chatbot/.eslintrc.json @@ -0,0 +1,4 @@ +{ + "parser": "@typescript-eslint/parser", + "extends": ["../../.eslintrc.base.json"] +} diff --git a/typescript/examples/langchain-dynamic-chatbot/.prettierrc b/typescript/examples/langchain-dynamic-chatbot/.prettierrc new file mode 100644 index 000000000..ffb416b74 --- /dev/null +++ b/typescript/examples/langchain-dynamic-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-dynamic-chatbot/README.md b/typescript/examples/langchain-dynamic-chatbot/README.md new file mode 100644 index 000000000..953f8fd43 --- /dev/null +++ b/typescript/examples/langchain-dynamic-chatbot/README.md @@ -0,0 +1,70 @@ +# Dynamic AgentKit LangChain Chatbot Example + +This example demonstrates how to use AgentKit with Dynamic wallet provider and LangChain to create a chatbot that can interact with the Solana blockchain. + +## Prerequisites + +- Node.js 18+ +- [Dynamic Account](https://www.dynamic.xyz/) and API credentials +- [OpenAI API Key](https://help.openai.com/en/articles/4936850-where-do-i-find-my-openai-api-key) + +## Setup + +1. Clone the repository and install dependencies: +```bash +npm install +``` + +2. Copy the example environment file and fill in your credentials: +```bash +cp .env-local .env +``` + +3. Update the `.env` file with your credentials: +``` +OPENAI_API_KEY=your_openai_api_key + +# Dynamic Configuration - get these from your Dynamic dashboard +DYNAMIC_AUTH_TOKEN=your_dynamic_auth_token +DYNAMIC_ENVIRONMENT_ID=your_dynamic_environment_id +DYNAMIC_BASE_API_URL=https://app.dynamicauth.com +DYNAMIC_BASE_MPC_RELAY_API_URL=relay.dynamicauth.com + +# Optional Network ID. If you'd like to use a Dynamic Solana wallet, set to "solana-devnet". Otherwise, defaults to "mainnet-beta" +NETWORK_ID=solana-devnet + +# Optional CDP API Key Name. 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_NAME=your_cdp_api_key_name + +# Optional CDP API Key Private Key. 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 +CDP_API_KEY_PRIVATE_KEY=your_cdp_api_key_private_key +``` + +## Running the Example + +Start the chatbot: +```bash +npm start +``` + +The chatbot will start in either chat mode or autonomous mode: + +- **Chat Mode**: Interact with the agent through a command-line interface +- **Autonomous Mode**: The agent will automatically perform actions on the blockchain at regular intervals + +## Features + +- Uses Dynamic's wallet API for Solana blockchain interactions +- Integrates with LangChain for natural language processing +- Supports both interactive chat and autonomous modes +- Can perform various blockchain actions like: + - Checking wallet balance + - Sending transactions + - Interacting with smart contracts + - And more! + +## Learn More + +- [AgentKit Documentation](https://docs.cdp.coinbase.com) +- [Dynamic Documentation](https://docs.dynamic.xyz) +- [LangChain Documentation](https://js.langchain.com/docs) diff --git a/typescript/examples/langchain-dynamic-chatbot/chatbot.ts b/typescript/examples/langchain-dynamic-chatbot/chatbot.ts new file mode 100644 index 000000000..4f59ad492 --- /dev/null +++ b/typescript/examples/langchain-dynamic-chatbot/chatbot.ts @@ -0,0 +1,261 @@ +import { + AgentKit, + DynamicSvmWalletProvider, + wethActionProvider, + walletActionProvider, + erc20ActionProvider, + pythActionProvider, + cdpApiActionProvider, + splActionProvider, +} from "@coinbase/agentkit"; +import { getLangChainTools } from "@coinbase/agentkit-langchain"; +import { HumanMessage } from "@langchain/core/messages"; +import { MemorySaver } from "@langchain/langgraph"; +import { createReactAgent } from "@langchain/langgraph/prebuilt"; +import { ChatOpenAI } from "@langchain/openai"; +import * as dotenv from "dotenv"; +import * as readline from "node:readline"; +import * as fs from "node:fs"; +import { ThresholdSignatureScheme } from "@dynamic-labs-wallet/node"; + +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[] = []; + + // Check required variables + const requiredVars = ["OPENAI_API_KEY", "DYNAMIC_AUTH_TOKEN", "DYNAMIC_ENVIRONMENT_ID"]; + for (const varName of requiredVars) { + if (!process.env[varName]) { + missingVars.push(varName); + } + } + + // Exit if any required variables are missing + if (missingVars.length > 0) { + console.error("Error: Required environment variables are not set"); + for (const varName of missingVars) { + console.error(`${varName}=your_${varName.toLowerCase()}_here`); + } + process.exit(1); + } +} + +// Add this right after imports and before any other code +validateEnvironment(); + +/** + * Initialize the agent with Dynamic Agentkit + * + * @returns Agent executor and config + */ +async function initializeAgent() { + try { + // Initialize LLM + const llm = new ChatOpenAI({ + modelName: "gpt-4o-mini", + }); + + const networkId = process.env.NETWORK_ID || "mainnet-beta"; + + // Configure Dynamic wallet provider + const baseApiUrl = process.env.DYNAMIC_BASE_API_URL || "https://app.dynamicauth.com"; + const baseMPCRelayApiUrl = process.env.DYNAMIC_BASE_MPC_RELAY_API_URL || "relay.dynamicauth.com"; + + const walletConfig = { + authToken: process.env.DYNAMIC_AUTH_TOKEN as string, + environmentId: process.env.DYNAMIC_ENVIRONMENT_ID as string, + baseApiUrl, + baseMPCRelayApiUrl, + networkId, + chainType: "solana" as const, + thresholdSignatureScheme: ThresholdSignatureScheme.TWO_OF_TWO, + }; + + const walletProvider = await DynamicSvmWalletProvider.configureWithWallet(walletConfig); + + // Initialize AgentKit + const agentkit = await AgentKit.from({ + walletProvider, + actionProviders: [ + wethActionProvider(), + pythActionProvider(), + walletActionProvider(), + erc20ActionProvider(), + cdpApiActionProvider({ + apiKeyName: process.env.CDP_API_KEY_NAME as string, + apiKeyPrivateKey: process.env.CDP_API_KEY_PRIVATE_KEY as string, + }), + splActionProvider(), + ], + }); + + const tools = await getLangChainTools(agentkit); + + // Store buffered conversation history in memory + const memory = new MemorySaver(); + const agentConfig = { configurable: { thread_id: "Dynamic AgentKit Chatbot Example!" } }; + + // Create React Agent using the LLM and Dynamic AgentKit tools + const agent = createReactAgent({ + llm, + tools, + checkpointSaver: memory, + messageModifier: ` + You are a helpful agent with a Dynamic wallet that can interact onchain using the Coinbase Developer + Platform AgentKit. 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 'solana-devnet'. 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; // Re-throw to be handled by caller + } +} + +/** + * Run the agent autonomously with specified intervals + * + * @param agent - The agent executor + * @param config - Agent configuration + * @param interval - Time interval between actions in seconds + */ +async function runAutonomousMode( + agent: ReturnType, + config: { configurable: { thread_id: string } }, + 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 ("agent" in chunk) { + console.log(chunk.agent); + } + } + + console.log(`Waiting ${interval} seconds before next action...`); + await new Promise(resolve => setTimeout(resolve, interval * 1000)); + } catch (error) { + console.error("Error in autonomous mode:", error); + await new Promise(resolve => setTimeout(resolve, interval * 1000)); + } + } +} + +/** + * Run the agent in chat mode + * + * @param agent - The agent executor + * @param config - Agent configuration + */ +async function runChatMode( + agent: ReturnType, + config: { configurable: { thread_id: string } } +) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const question = (prompt: string): Promise => + new Promise(resolve => rl.question(prompt, resolve)); + + console.log("Starting chat mode..."); + console.log("Type 'exit' to quit"); + + // eslint-disable-next-line no-constant-condition + while (true) { + try { + const userInput = await question("\nYou: "); + + if (userInput.toLowerCase() === "exit") { + rl.close(); + break; + } + + const stream = await agent.stream({ messages: [new HumanMessage(userInput)] }, config); + + for await (const chunk of stream) { + if ("agent" in chunk) { + console.log(chunk.agent); + } + } + } catch (error) { + console.error("Error in chat mode:", error); + } + } +} + +/** + * Choose between chat and autonomous modes + * + * @returns The chosen 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)); + + while (true) { + const mode = await question("Choose mode (chat/auto): "); + if (mode.toLowerCase() === "chat" || mode.toLowerCase() === "auto") { + rl.close(); + return mode.toLowerCase() as "chat" | "auto"; + } + console.log("Invalid mode. Please choose 'chat' or 'auto'"); + } +} + +/** + * Main function + */ +async function main() { + try { + const { agent, config } = await initializeAgent(); + const mode = await chooseMode(); + + if (mode === "auto") { + await runAutonomousMode(agent, config); + } else { + await runChatMode(agent, config); + } + } catch (error) { + console.error("Failed to start agent:", error); + process.exit(1); + } +} + +main(); diff --git a/typescript/examples/langchain-dynamic-chatbot/package.json b/typescript/examples/langchain-dynamic-chatbot/package.json new file mode 100644 index 000000000..4983ca73e --- /dev/null +++ b/typescript/examples/langchain-dynamic-chatbot/package.json @@ -0,0 +1,27 @@ +{ + "name": "langchain-dynamic-chatbot", + "version": "1.0.0", + "description": "A chatbot example using AgentKit with Dynamic wallet provider", + "main": "chatbot.ts", + "scripts": { + "start": "ts-node chatbot.ts", + "build": "tsc", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "dependencies": { + "@coinbase/agentkit": "workspace:*", + "@coinbase/agentkit-langchain": "^0.3.0", + "@dynamic-labs-wallet/core": "^0.0.71", + "@dynamic-labs-wallet/node": "^0.0.71", + "@dynamic-labs-wallet/node-svm": "^0.0.71", + "@langchain/core": "^0.3.40", + "@langchain/langgraph": "^0.2.72", + "@langchain/openai": "*", + "dotenv": "^16.4.5" + }, + "devDependencies": { + "@types/node": "^20.11.24", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" + } +} diff --git a/typescript/examples/langchain-dynamic-chatbot/tsconfig.json b/typescript/examples/langchain-dynamic-chatbot/tsconfig.json new file mode 100644 index 000000000..a37da3664 --- /dev/null +++ b/typescript/examples/langchain-dynamic-chatbot/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "preserveSymlinks": true, + "outDir": "./dist", + "rootDir": ".", + "module": "Node16" + }, + "include": ["*.ts"] +}