diff --git a/typescript/.changeset/empty-baths-send.md b/typescript/.changeset/empty-baths-send.md new file mode 100644 index 000000000..a125484d9 --- /dev/null +++ b/typescript/.changeset/empty-baths-send.md @@ -0,0 +1,5 @@ +--- +"@coinbase/agentkit": patch +--- + +Added svm support for x402 action diff --git a/typescript/agentkit/package.json b/typescript/agentkit/package.json index 537907c06..dd8ca4459 100644 --- a/typescript/agentkit/package.json +++ b/typescript/agentkit/package.json @@ -48,6 +48,7 @@ "@jup-ag/api": "^6.0.39", "@privy-io/public-api": "2.18.5", "@privy-io/server-auth": "1.18.4", + "@solana/kit": "^2.1.1", "@solana/spl-token": "^0.4.12", "@solana/web3.js": "^1.98.1", "@zerodev/ecdsa-validator": "^5.4.5", diff --git a/typescript/agentkit/src/action-providers/x402/README.md b/typescript/agentkit/src/action-providers/x402/README.md index a478754bd..27ffec4a7 100644 --- a/typescript/agentkit/src/action-providers/x402/README.md +++ b/typescript/agentkit/src/action-providers/x402/README.md @@ -135,6 +135,8 @@ Successful responses include payment proof when payment was made: The x402 provider currently supports the following networks: - `base-mainnet` - `base-sepolia` +- `solana-mainnet` +- `solana-devnet` The provider requires EVM-compatible networks where the wallet can sign payment transactions. diff --git a/typescript/agentkit/src/action-providers/x402/utils.ts b/typescript/agentkit/src/action-providers/x402/utils.ts index 1001a68dc..33ee9c0fe 100644 --- a/typescript/agentkit/src/action-providers/x402/utils.ts +++ b/typescript/agentkit/src/action-providers/x402/utils.ts @@ -3,13 +3,21 @@ import { AxiosError } from "axios"; import { getTokenDetails } from "../erc20/utils"; import { TOKEN_ADDRESSES_BY_SYMBOLS } from "../erc20/constants"; import { formatUnits, parseUnits } from "viem"; -import { EvmWalletProvider } from "../../wallet-providers"; +import { EvmWalletProvider, SvmWalletProvider, WalletProvider } from "../../wallet-providers"; /** * Supported network types for x402 protocol */ export type X402Network = "base" | "base-sepolia" | "solana" | "solana-devnet"; +/** + * USDC token addresses for Solana networks + */ +const SOLANA_USDC_ADDRESSES = { + "solana-devnet": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "solana-mainnet": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", +} as const; + /** * Converts the internal network ID to the format expected by the x402 protocol. * @@ -90,15 +98,16 @@ export function handleHttpError(error: AxiosError, url: string): string { */ export async function formatPaymentOption( option: { asset: string; maxAmountRequired: string; network: string }, - walletProvider: EvmWalletProvider, + walletProvider: WalletProvider, ): Promise { const { asset, maxAmountRequired, network } = option; // Check if this is an EVM network and we can use ERC20 helpers const walletNetwork = walletProvider.getNetwork(); const isEvmNetwork = walletNetwork.protocolFamily === "evm"; + const isSvmNetwork = walletNetwork.protocolFamily === "svm"; - if (isEvmNetwork) { + if (isEvmNetwork && walletProvider instanceof EvmWalletProvider) { const networkId = walletNetwork.networkId as keyof typeof TOKEN_ADDRESSES_BY_SYMBOLS; const tokenSymbols = TOKEN_ADDRESSES_BY_SYMBOLS[networkId]; @@ -124,7 +133,19 @@ export async function formatPaymentOption( } } - // Fallback to original format for non-EVM networks or when token details can't be fetched + if (isSvmNetwork && walletProvider instanceof SvmWalletProvider) { + // Check if the asset is USDC on Solana networks + const networkId = walletNetwork.networkId as keyof typeof SOLANA_USDC_ADDRESSES; + const usdcAddress = SOLANA_USDC_ADDRESSES[networkId]; + + if (usdcAddress && asset === usdcAddress) { + // USDC has 6 decimals on Solana + const formattedAmount = formatUnits(BigInt(maxAmountRequired), 6); + return `${formattedAmount} USDC on ${network} network`; + } + } + + // Fallback to original format for non-EVM/SVM networks or when token details can't be fetched return `${asset} ${maxAmountRequired} on ${network} network`; } @@ -135,11 +156,12 @@ export async function formatPaymentOption( * @param walletProvider - The wallet provider for network context * @returns True if the asset is USDC, false otherwise */ -export function isUsdcAsset(asset: string, walletProvider: EvmWalletProvider): boolean { +export function isUsdcAsset(asset: string, walletProvider: WalletProvider): boolean { const walletNetwork = walletProvider.getNetwork(); const isEvmNetwork = walletNetwork.protocolFamily === "evm"; + const isSvmNetwork = walletNetwork.protocolFamily === "svm"; - if (isEvmNetwork) { + if (isEvmNetwork && walletProvider instanceof EvmWalletProvider) { const networkId = walletNetwork.networkId as keyof typeof TOKEN_ADDRESSES_BY_SYMBOLS; const tokenSymbols = TOKEN_ADDRESSES_BY_SYMBOLS[networkId]; @@ -148,6 +170,15 @@ export function isUsdcAsset(asset: string, walletProvider: EvmWalletProvider): b } } + if (isSvmNetwork && walletProvider instanceof SvmWalletProvider) { + const networkId = walletNetwork.networkId as keyof typeof SOLANA_USDC_ADDRESSES; + const usdcAddress = SOLANA_USDC_ADDRESSES[networkId]; + + if (usdcAddress) { + return asset === usdcAddress; + } + } + return false; } @@ -162,13 +193,14 @@ export function isUsdcAsset(asset: string, walletProvider: EvmWalletProvider): b export async function convertWholeUnitsToAtomic( wholeUnits: number, asset: string, - walletProvider: EvmWalletProvider, + walletProvider: WalletProvider, ): Promise { // Check if this is an EVM network and we can use ERC20 helpers const walletNetwork = walletProvider.getNetwork(); const isEvmNetwork = walletNetwork.protocolFamily === "evm"; + const isSvmNetwork = walletNetwork.protocolFamily === "svm"; - if (isEvmNetwork) { + if (isEvmNetwork && walletProvider instanceof EvmWalletProvider) { const networkId = walletNetwork.networkId as keyof typeof TOKEN_ADDRESSES_BY_SYMBOLS; const tokenSymbols = TOKEN_ADDRESSES_BY_SYMBOLS[networkId]; @@ -192,6 +224,17 @@ export async function convertWholeUnitsToAtomic( } } - // Fallback to 18 decimals for unknown tokens or non-EVM networks + if (isSvmNetwork && walletProvider instanceof SvmWalletProvider) { + // Check if the asset is USDC on Solana networks + const networkId = walletNetwork.networkId as keyof typeof SOLANA_USDC_ADDRESSES; + const usdcAddress = SOLANA_USDC_ADDRESSES[networkId]; + + if (usdcAddress && asset === usdcAddress) { + // USDC has 6 decimals on Solana + return parseUnits(wholeUnits.toString(), 6).toString(); + } + } + + // Fallback to 18 decimals for unknown tokens or non-EVM/SVM networks return parseUnits(wholeUnits.toString(), 18).toString(); } diff --git a/typescript/agentkit/src/action-providers/x402/x402ActionProvider.test.ts b/typescript/agentkit/src/action-providers/x402/x402ActionProvider.test.ts index bf7bf2f1a..e3a8a4ec5 100644 --- a/typescript/agentkit/src/action-providers/x402/x402ActionProvider.test.ts +++ b/typescript/agentkit/src/action-providers/x402/x402ActionProvider.test.ts @@ -5,6 +5,7 @@ import { AxiosError, AxiosResponse, AxiosRequestConfig, AxiosInstance } from "ax import axios from "axios"; import * as x402axios from "x402-axios"; import * as x402Verify from "x402/verify"; +import * as utils from "./utils"; // Mock external facilitator dependency jest.mock("@coinbase/x402", () => ({ @@ -15,6 +16,7 @@ jest.mock("@coinbase/x402", () => ({ jest.mock("axios"); jest.mock("x402-axios"); jest.mock("x402/verify"); +jest.mock("./utils"); // Create mock functions const mockRequest = jest.fn(); @@ -61,6 +63,10 @@ const mockAxios = { const mockWithPaymentInterceptor = jest.fn().mockReturnValue(mockAxiosInstance); const mockDecodeXPaymentResponse = jest.fn(); const mockUseFacilitator = jest.fn(); +const mockIsUsdcAsset = jest.fn(); +const mockConvertWholeUnitsToAtomic = jest.fn(); +const mockFormatPaymentOption = jest.fn(); +const mockGetX402Network = jest.fn(); // Override the mocked modules (axios as jest.Mocked).create = mockAxios.create; @@ -72,12 +78,26 @@ jest.mocked(x402axios.withPaymentInterceptor).mockImplementation(mockWithPayment jest.mocked(x402axios.decodeXPaymentResponse).mockImplementation(mockDecodeXPaymentResponse); jest.mocked(x402Verify.useFacilitator).mockImplementation(mockUseFacilitator); +// Mock utils functions +jest.mocked(utils.isUsdcAsset).mockImplementation(mockIsUsdcAsset); +jest.mocked(utils.convertWholeUnitsToAtomic).mockImplementation(mockConvertWholeUnitsToAtomic); +jest.mocked(utils.formatPaymentOption).mockImplementation(mockFormatPaymentOption); +jest.mocked(utils.getX402Network).mockImplementation(mockGetX402Network); +jest.mocked(utils.handleHttpError).mockImplementation((error, url) => { + return JSON.stringify({ + error: true, + message: error instanceof Error ? error.message : "Network error", + url: url, + }); +}); + // Mock wallet provider -const makeMockWalletProvider = (networkId: string) => - ({ - toSigner: jest.fn().mockReturnValue("mock-signer"), - getNetwork: jest.fn().mockReturnValue({ protocolFamily: "evm", networkId }), - }) as unknown as EvmWalletProvider; +const makeMockWalletProvider = (networkId: string) => { + const mockProvider = Object.create(EvmWalletProvider.prototype); + mockProvider.toSigner = jest.fn().mockReturnValue("mock-signer"); + mockProvider.getNetwork = jest.fn().mockReturnValue({ protocolFamily: "evm", networkId }); + return mockProvider as EvmWalletProvider; +}; // Sample responses based on real examples const MOCK_PAYMENT_INFO_RESPONSE = { @@ -136,6 +156,12 @@ describe("X402ActionProvider", () => { ("isAxiosError" in error || "response" in error || "request" in error), ), ); + + // Reset all utility mocks to default behavior + mockGetX402Network.mockImplementation(network => network.networkId); + mockIsUsdcAsset.mockReturnValue(false); + mockConvertWholeUnitsToAtomic.mockResolvedValue("100000"); + mockFormatPaymentOption.mockResolvedValue("mocked payment option"); }); afterEach(() => { @@ -158,8 +184,13 @@ describe("X402ActionProvider", () => { expect(provider.supportsNetwork(network)).toBe(false); }); - it("should not support non-EVM networks", () => { - const network: Network = { protocolFamily: "solana", networkId: "mainnet" }; + it("should support SVM networks", () => { + const network: Network = { protocolFamily: "svm", networkId: "solana-mainnet" }; + expect(provider.supportsNetwork(network)).toBe(true); + }); + + it("should not support non-EVM/SVM networks", () => { + const network: Network = { protocolFamily: "bitcoin", networkId: "mainnet" }; expect(provider.supportsNetwork(network)).toBe(false); }); }); @@ -185,6 +216,9 @@ describe("X402ActionProvider", () => { }); it("should handle 402 responses with payment options", async () => { + mockGetX402Network.mockReturnValue("base-sepolia"); + mockFormatPaymentOption.mockResolvedValue("10000 USDC on base-sepolia network"); + mockRequest.mockResolvedValue({ status: 402, data: MOCK_PAYMENT_INFO_RESPONSE.data, @@ -246,6 +280,7 @@ describe("X402ActionProvider", () => { }); mockUseFacilitator.mockReturnValue({ list: mockList }); + mockGetX402Network.mockReturnValue("base-sepolia"); const result = await provider.discoverX402Services( makeMockWalletProvider("base-sepolia"), @@ -311,6 +346,15 @@ describe("X402ActionProvider", () => { mockUseFacilitator.mockReturnValue({ list: mockList }); + // Mock the utility functions for this test + mockGetX402Network.mockReturnValue("base-sepolia"); + mockIsUsdcAsset.mockReturnValue(true); // All assets are USDC + mockConvertWholeUnitsToAtomic + .mockResolvedValueOnce("100000") // 0.1 USDC in atomic units + .mockResolvedValueOnce("100000") + .mockResolvedValueOnce("100000"); + mockFormatPaymentOption.mockResolvedValue("formatted payment option"); + const result = await provider.discoverX402Services(makeMockWalletProvider("base-sepolia"), { maxUsdcPrice: 0.1, }); @@ -339,6 +383,7 @@ describe("X402ActionProvider", () => { describe("retryHttpRequestWithX402", () => { it("should successfully retry with payment", async () => { mockDecodeXPaymentResponse.mockReturnValue(MOCK_PAYMENT_RESPONSE); + mockGetX402Network.mockReturnValue("base-sepolia"); mockRequest.mockResolvedValue({ status: 200, @@ -383,6 +428,7 @@ describe("X402ActionProvider", () => { error.request = {}; mockRequest.mockRejectedValue(error); + mockGetX402Network.mockReturnValue("base-sepolia"); const result = await provider.retryWithX402(makeMockWalletProvider("base-sepolia"), { url: "https://www.x402.org/protected", @@ -436,6 +482,8 @@ describe("X402ActionProvider", () => { }); it("should handle successful non-payment requests", async () => { + mockDecodeXPaymentResponse.mockReturnValue(null); // No payment made + mockRequest.mockResolvedValue({ status: 200, statusText: "OK", diff --git a/typescript/agentkit/src/action-providers/x402/x402ActionProvider.ts b/typescript/agentkit/src/action-providers/x402/x402ActionProvider.ts index be680b73b..3d9aaeaed 100644 --- a/typescript/agentkit/src/action-providers/x402/x402ActionProvider.ts +++ b/typescript/agentkit/src/action-providers/x402/x402ActionProvider.ts @@ -8,7 +8,7 @@ import { DirectX402RequestSchema, ListX402ServicesSchema, } from "./schemas"; -import { EvmWalletProvider } from "../../wallet-providers"; +import { EvmWalletProvider, WalletProvider, SvmWalletProvider } from "../../wallet-providers"; import axios, { AxiosError } from "axios"; import { withPaymentInterceptor, decodeXPaymentResponse } from "x402-axios"; import { PaymentRequirements } from "x402/types"; @@ -27,7 +27,7 @@ const SUPPORTED_NETWORKS = ["base-mainnet", "base-sepolia", "solana-mainnet", "s /** * X402ActionProvider provides actions for making HTTP requests, with optional x402 payment handling. */ -export class X402ActionProvider extends ActionProvider { +export class X402ActionProvider extends ActionProvider { /** * Creates a new instance of X402ActionProvider. * Initializes the provider with x402 capabilities. @@ -46,11 +46,11 @@ export class X402ActionProvider extends ActionProvider { @CreateAction({ name: "discover_x402_services", description: - "Discover available x402 services. Optionally filter by a maximum price in whole units of USDC (only USDC payment options will be considered when filter is applied).", + "Discover available x402 services. Only services available on the current network will be returned. Optionally filter by a maximum price in whole units of USDC (only USDC payment options will be considered when filter is applied).", schema: ListX402ServicesSchema, }) async discoverX402Services( - walletProvider: EvmWalletProvider, + walletProvider: WalletProvider, args: z.infer, ): Promise { try { @@ -209,7 +209,7 @@ If you receive a 402 Payment Required response, use retry_http_request_with_x402 schema: HttpRequestSchema, }) async makeHttpRequest( - walletProvider: EvmWalletProvider, + walletProvider: WalletProvider, args: z.infer, ): Promise { try { @@ -291,7 +291,7 @@ DO NOT use this action directly without first trying make_http_request!`, schema: RetryWithX402Schema, }) async retryWithX402( - walletProvider: EvmWalletProvider, + walletProvider: WalletProvider, args: z.infer, ): Promise { try { @@ -311,8 +311,25 @@ DO NOT use this action directly without first trying make_http_request!`, ); } + // Check if wallet provider is supported + if ( + !( + walletProvider instanceof SvmWalletProvider || walletProvider instanceof EvmWalletProvider + ) + ) { + return JSON.stringify( + { + error: true, + message: "Unsupported wallet provider", + details: "Only SvmWalletProvider and EvmWalletProvider are supported", + }, + null, + 2, + ); + } + // Make the request with payment handling - const account = walletProvider.toSigner(); + const account = await walletProvider.toSigner(); const paymentSelector = (accepts: PaymentRequirements[]) => { const { scheme, network, maxAmountRequired, asset } = args.selectedPaymentOption; @@ -418,11 +435,27 @@ Unless specifically instructed otherwise, prefer the two-step approach with make schema: DirectX402RequestSchema, }) async makeHttpRequestWithX402( - walletProvider: EvmWalletProvider, + walletProvider: WalletProvider, args: z.infer, ): Promise { try { - const account = walletProvider.toSigner(); + if ( + !( + walletProvider instanceof SvmWalletProvider || walletProvider instanceof EvmWalletProvider + ) + ) { + return JSON.stringify( + { + error: true, + message: "Unsupported wallet provider", + details: "Only SvmWalletProvider and EvmWalletProvider are supported", + }, + null, + 2, + ); + } + const account = await walletProvider.toSigner(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any const api = withPaymentInterceptor(axios.create({}), account as any); @@ -468,8 +501,7 @@ Unless specifically instructed otherwise, prefer the two-step approach with make * @param network - The network to check support for * @returns True if the network is supported, false otherwise */ - supportsNetwork = (network: Network) => - network.protocolFamily === "evm" && SUPPORTED_NETWORKS.includes(network.networkId!); + supportsNetwork = (network: Network) => SUPPORTED_NETWORKS.includes(network.networkId!); } export const x402ActionProvider = () => new X402ActionProvider(); diff --git a/typescript/agentkit/src/wallet-providers/cdpSolanaWalletProvider.test.ts b/typescript/agentkit/src/wallet-providers/cdpSolanaWalletProvider.test.ts index 0adbfe27e..d10601bac 100644 --- a/typescript/agentkit/src/wallet-providers/cdpSolanaWalletProvider.test.ts +++ b/typescript/agentkit/src/wallet-providers/cdpSolanaWalletProvider.test.ts @@ -401,4 +401,25 @@ describe("CdpSolanaWalletProvider", () => { await expect(provider.nativeTransfer(invalidAddress, amount)).rejects.toThrow(); }); }); + + // ========================================================= + // KeyPairSigner tests + // ========================================================= + + describe("KeyPairSigner", () => { + it("should handle errors when getting KeyPairSigner", async () => { + // Mock exportAccount to throw an error + mockCdpClient.solana.exportAccount = jest.fn().mockRejectedValue(new Error("Export failed")); + + await expect(provider.getKeyPairSigner()).rejects.toThrow("Export failed"); + }); + + it("should handle errors gracefully in isKeyPairSigner", async () => { + // Mock exportAccount to throw an error + mockCdpClient.solana.exportAccount = jest.fn().mockRejectedValue(new Error("Export failed")); + + const isValid = await provider.isKeyPairSigner(); + expect(isValid).toBe(false); + }); + }); }); diff --git a/typescript/agentkit/src/wallet-providers/cdpSolanaWalletProvider.ts b/typescript/agentkit/src/wallet-providers/cdpSolanaWalletProvider.ts index dfce07249..257ae1a3d 100644 --- a/typescript/agentkit/src/wallet-providers/cdpSolanaWalletProvider.ts +++ b/typescript/agentkit/src/wallet-providers/cdpSolanaWalletProvider.ts @@ -1,4 +1,5 @@ import { CdpClient } from "@coinbase/cdp-sdk"; +import type { KeyPairSigner } from "@solana/kit"; import { clusterApiUrl, ComputeBudgetProgram, @@ -12,6 +13,7 @@ import { SystemProgram, VersionedTransaction, } from "@solana/web3.js"; +import bs58 from "bs58"; import { Network } from "../network"; import { SOLANA_DEVNET_NETWORK, @@ -22,7 +24,7 @@ import { SOLANA_TESTNET_NETWORK_ID, } from "../network/svm"; import { WalletProviderWithClient, CdpWalletProviderConfig } from "./cdpShared"; -import { SvmWalletProvider } from "./svmWalletProvider"; +import { SvmWalletProvider, createSignerFromBytes } from "./svmWalletProvider"; interface ConfigureCdpSolanaWalletProviderWithWalletOptions { /** @@ -51,9 +53,9 @@ interface ConfigureCdpSolanaWalletProviderWithWalletOptions { */ export class CdpSolanaWalletProvider extends SvmWalletProvider implements WalletProviderWithClient { #connection: Connection; - #serverAccount: Awaited>; #cdp: CdpClient; #network: Network; + #serverAccount: Awaited>; /** * Constructs a new CdpSolanaWalletProvider. @@ -282,6 +284,26 @@ export class CdpSolanaWalletProvider extends SvmWalletProvider implements Wallet return this.#cdp; } + /** + * Sign a message. + * + * @param message - The message to sign as a Uint8Array + * @returns The signature as a Uint8Array + */ + async signMessage(message: Uint8Array): Promise { + // Convert Uint8Array to string for CDP SDK + const messageString = Buffer.from(message).toString("utf8"); + + const { signature } = await this.#cdp.solana.signMessage({ + address: this.#serverAccount.address, + message: messageString, + }); + + // Convert signature string back to Uint8Array + // CDP returns signature as a hex string, convert to bytes + return new Uint8Array(Buffer.from(signature, "hex")); + } + /** * Transfer SOL from the wallet to another address * @@ -330,4 +352,22 @@ export class CdpSolanaWalletProvider extends SvmWalletProvider implements Wallet return signature; } + + /** + * Get the keypair signer for this wallet. + * + * @returns The KeyPairSigner + */ + async getKeyPairSigner(): Promise { + // Export the private key from CDP + const exportedPrivateKey = await this.#cdp.solana.exportAccount({ + address: this.#serverAccount.address, + }); + + // Decode the base58 encoded private key to get the full 64-byte key + const fullKeyBytes = bs58.decode(exportedPrivateKey); + + // Create and return the KeyPairSigner using the full key bytes + return createSignerFromBytes(fullKeyBytes); + } } diff --git a/typescript/agentkit/src/wallet-providers/privySvmWalletProvider.test.ts b/typescript/agentkit/src/wallet-providers/privySvmWalletProvider.test.ts index d1c930877..c046aaf6b 100644 --- a/typescript/agentkit/src/wallet-providers/privySvmWalletProvider.test.ts +++ b/typescript/agentkit/src/wallet-providers/privySvmWalletProvider.test.ts @@ -342,5 +342,22 @@ describe("PrivySvmWalletProvider", () => { await expect(provider.getBalance()).rejects.toThrow("RPC endpoint error"); }); + + it("should throw error when trying to get KeyPairSigner", async () => { + await expect(provider.getKeyPairSigner()).rejects.toThrow( + "getKeyPairSigner is not supported for PrivySvmWalletProvider", + ); + }); + + it("should throw error when trying to convert to signer", async () => { + await expect(provider.toSigner()).rejects.toThrow( + "getKeyPairSigner is not supported for PrivySvmWalletProvider", + ); + }); + + it("should return false for isKeyPairSigner", async () => { + const isValid = await provider.isKeyPairSigner(); + expect(isValid).toBe(false); + }); }); }); diff --git a/typescript/agentkit/src/wallet-providers/privySvmWalletProvider.ts b/typescript/agentkit/src/wallet-providers/privySvmWalletProvider.ts index e33d7dfea..d394d581f 100644 --- a/typescript/agentkit/src/wallet-providers/privySvmWalletProvider.ts +++ b/typescript/agentkit/src/wallet-providers/privySvmWalletProvider.ts @@ -1,4 +1,5 @@ import { PrivyClient, SolanaCaip2ChainId } from "@privy-io/server-auth"; +import type { KeyPairSigner } from "@solana/kit"; import { SvmWalletProvider } from "./svmWalletProvider"; import { RpcResponseAndContext, @@ -250,4 +251,23 @@ export class PrivySvmWalletProvider extends SvmWalletProvider { getPublicKey(): PublicKey { return new PublicKey(this.#address); } + + /** + * Sign a message. + * + * @param _ - The message to sign as a Uint8Array (unused) + * @returns Never - throws an error as message signing is not supported yet + */ + async signMessage(_: Uint8Array): Promise { + throw new Error("Message signing is not supported yet for PrivySvmWalletProvider"); + } + + /** + * Get the keypair signer for this wallet. + * + * @returns The KeyPairSigner + */ + async getKeyPairSigner(): Promise { + throw new Error("getKeyPairSigner is not supported for PrivySvmWalletProvider"); + } } diff --git a/typescript/agentkit/src/wallet-providers/solanaKeypairWalletProvider.ts b/typescript/agentkit/src/wallet-providers/solanaKeypairWalletProvider.ts index 2fff35687..f488e72d1 100644 --- a/typescript/agentkit/src/wallet-providers/solanaKeypairWalletProvider.ts +++ b/typescript/agentkit/src/wallet-providers/solanaKeypairWalletProvider.ts @@ -1,4 +1,4 @@ -import { SvmWalletProvider } from "./svmWalletProvider"; +import { SvmWalletProvider, createSignerFromBytes } from "./svmWalletProvider"; import { Network } from "../network"; import { Connection, @@ -26,6 +26,7 @@ import { SOLANA_TESTNET_GENESIS_BLOCK_HASH, SOLANA_TESTNET_NETWORK_ID, } from "../network/svm"; +import { KeyPairSigner } from "@solana/kit"; /** * SolanaKeypairWalletProvider is a wallet provider that uses a local Solana keypair. @@ -322,6 +323,16 @@ export class SolanaKeypairWalletProvider extends SvmWalletProvider { return signature; } + /** + * Sign a message. + * + * @param _ - The message to sign as a Uint8Array (unused) + * @returns Never - throws an error as message signing is not supported yet + */ + async signMessage(_: Uint8Array): Promise { + throw new Error("Message signing is not supported yet for SolanaKeypairWalletProvider"); + } + /** * Request SOL tokens from the Solana faucet. This method only works on devnet and testnet networks. * @@ -331,4 +342,13 @@ export class SolanaKeypairWalletProvider extends SvmWalletProvider { async requestAirdrop(lamports: number): Promise { return await this.#connection.requestAirdrop(this.#keypair.publicKey, lamports); } + + /** + * Get the keypair signer for this wallet. + * + * @returns The KeyPairSigner + */ + async getKeyPairSigner(): Promise { + return createSignerFromBytes(this.#keypair.secretKey); + } } diff --git a/typescript/agentkit/src/wallet-providers/svmWalletProvider.test.ts b/typescript/agentkit/src/wallet-providers/svmWalletProvider.test.ts index e98ab0de0..50740a2d2 100644 --- a/typescript/agentkit/src/wallet-providers/svmWalletProvider.test.ts +++ b/typescript/agentkit/src/wallet-providers/svmWalletProvider.test.ts @@ -87,4 +87,22 @@ describe("SvmWalletProvider", () => { expect(getConnectionDescriptor).toBeDefined(); expect(typeof getConnectionDescriptor!.value).toBe("function"); }); + + it("should have toSigner method", () => { + const toSignerDescriptor = Object.getOwnPropertyDescriptor( + SvmWalletProvider.prototype, + "toSigner", + ); + expect(toSignerDescriptor).toBeDefined(); + expect(typeof toSignerDescriptor!.value).toBe("function"); + }); + + it("should have isKeyPairSigner method", () => { + const isKeyPairSignerDescriptor = Object.getOwnPropertyDescriptor( + SvmWalletProvider.prototype, + "isKeyPairSigner", + ); + expect(isKeyPairSignerDescriptor).toBeDefined(); + expect(typeof isKeyPairSignerDescriptor!.value).toBe("function"); + }); }); diff --git a/typescript/agentkit/src/wallet-providers/svmWalletProvider.ts b/typescript/agentkit/src/wallet-providers/svmWalletProvider.ts index 009de9694..9531052ce 100644 --- a/typescript/agentkit/src/wallet-providers/svmWalletProvider.ts +++ b/typescript/agentkit/src/wallet-providers/svmWalletProvider.ts @@ -10,6 +10,12 @@ import { VersionedTransaction, SignatureResult, } from "@solana/web3.js"; +import { + isKeyPairSigner, + KeyPairSigner, + createKeyPairSignerFromBytes, + createKeyPairSignerFromPrivateKeyBytes, +} from "@solana/kit"; /** * SvmWalletProvider is the abstract base class for all Solana wallet providers (non browsers). @@ -17,6 +23,29 @@ import { * @abstract */ export abstract class SvmWalletProvider extends WalletProvider { + /** + * Convert the wallet provider to a KeyPairSigner. + * + * @returns The KeyPairSigner instance + */ + async toSigner(): Promise { + return this.getKeyPairSigner(); + } + + /** + * Check if this wallet's signer is a valid KeyPairSigner. + * + * @returns True if the signer is a valid KeyPairSigner, false otherwise + */ + async isKeyPairSigner(): Promise { + try { + const signer = await this.toSigner(); + return isKeyPairSigner(signer); + } catch { + return false; + } + } + /** * Get the connection instance. * @@ -75,4 +104,39 @@ export abstract class SvmWalletProvider extends WalletProvider { abstract waitForSignatureResult( signature: string, ): Promise>; + + /** + * Sign a message. + * + * @param message - The message to sign as a Uint8Array + * @returns The signature as a Uint8Array + */ + abstract signMessage(message: Uint8Array): Promise; + + /** + * Get the keypair for this wallet. + * + * @returns The CryptoKeyPair for KeyPairSigner compatibility + */ + abstract getKeyPairSigner(): Promise; +} + +/** + * Create a KeyPairSigner from raw bytes. + * + * @param bytes - The raw key bytes (32 bytes for private key only, 64 bytes for private + public key) + * @returns A KeyPairSigner instance + * @throws Error if the byte length is not 32 or 64 + */ +export async function createSignerFromBytes(bytes: Uint8Array): Promise { + // generate a keypair signer from the bytes based on the byte-length + // 64 bytes represents concatenated private + public key + if (bytes.length === 64) { + return await createKeyPairSignerFromBytes(bytes); + } + // 32 bytes represents only the private key + if (bytes.length === 32) { + return await createKeyPairSignerFromPrivateKeyBytes(bytes); + } + throw new Error(`Unexpected key length: ${bytes.length}. Expected 32 or 64 bytes.`); } diff --git a/typescript/examples/langchain-cdp-chatbot/chatbot.ts b/typescript/examples/langchain-cdp-chatbot/chatbot.ts index ac27cd942..0ea5fb408 100644 --- a/typescript/examples/langchain-cdp-chatbot/chatbot.ts +++ b/typescript/examples/langchain-cdp-chatbot/chatbot.ts @@ -114,6 +114,7 @@ async function initializeAgent() { const walletProvider = isSolana ? await CdpSolanaWalletProvider.configureWithWallet(cdpWalletConfig) : await CdpEvmWalletProvider.configureWithWallet(cdpWalletConfig); + const actionProviders = [ walletActionProvider(), cdpApiActionProvider(), @@ -126,7 +127,7 @@ async function initializeAgent() { x402ActionProvider(), ] : isSolanaWalletProvider(walletProvider) - ? [splActionProvider()] + ? [splActionProvider(), x402ActionProvider()] : []), ]; diff --git a/typescript/examples/langchain-solana-chatbot/chatbot.ts b/typescript/examples/langchain-solana-chatbot/chatbot.ts index 7d25c74e5..05b9c5cf9 100644 --- a/typescript/examples/langchain-solana-chatbot/chatbot.ts +++ b/typescript/examples/langchain-solana-chatbot/chatbot.ts @@ -5,6 +5,7 @@ import { splActionProvider, walletActionProvider, cdpApiActionProvider, + x402ActionProvider, } from "@coinbase/agentkit"; import { getLangChainTools } from "@coinbase/agentkit-langchain"; import { HumanMessage } from "@langchain/core/messages"; @@ -94,7 +95,12 @@ async function initializeAgent() { cdpApiKeyId: process.env.CDP_API_KEY_ID, cdpApiKeySecret: process.env.CDP_API_KEY_SECRET, walletProvider, - actionProviders: [splActionProvider(), walletActionProvider(), cdpApiActionProvider()], + actionProviders: [ + splActionProvider(), + walletActionProvider(), + cdpApiActionProvider(), + x402ActionProvider(), + ], }); const tools = await getLangChainTools(agentkit); diff --git a/typescript/pnpm-lock.yaml b/typescript/pnpm-lock.yaml index 8972eac54..8533e8e1f 100644 --- a/typescript/pnpm-lock.yaml +++ b/typescript/pnpm-lock.yaml @@ -89,6 +89,9 @@ importers: '@privy-io/server-auth': specifier: 1.18.4 version: 1.18.4(bufferutil@4.0.9)(typescript@5.8.2)(utf-8-validate@5.0.10)(viem@2.23.15(bufferutil@4.0.9)(typescript@5.8.2)(utf-8-validate@5.0.10)(zod@3.24.2)) + '@solana/kit': + specifier: ^2.1.1 + version: 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.2)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/spl-token': specifier: ^0.4.12 version: 0.4.13(@solana/web3.js@1.98.1(bufferutil@4.0.9)(typescript@5.8.2)(utf-8-validate@5.0.10))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.2)(utf-8-validate@5.0.10) @@ -6385,7 +6388,7 @@ snapshots: '@babel/traverse': 7.27.0 '@babel/types': 7.27.0 convert-source-map: 2.0.0 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -6543,7 +6546,7 @@ snapshots: '@babel/parser': 7.27.0 '@babel/template': 7.27.0 '@babel/types': 7.27.0 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -7827,7 +7830,7 @@ snapshots: bufferutil: 4.0.9 cross-fetch: 4.1.0 date-fns: 2.30.0 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0 eciesjs: 0.4.15 eventemitter2: 6.4.9 readable-stream: 3.6.2 @@ -7851,7 +7854,7 @@ snapshots: '@paulmillr/qr': 0.2.1 bowser: 2.12.1 cross-fetch: 4.1.0 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0 eciesjs: 0.4.15 eth-rpc-errors: 4.0.3 eventemitter2: 6.4.9 @@ -7878,7 +7881,7 @@ snapshots: '@scure/base': 1.2.6 '@types/debug': 4.1.12 '@types/lodash': 4.17.20 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0 lodash: 4.17.21 pony-cause: 2.1.11 semver: 7.7.1 @@ -7890,7 +7893,7 @@ snapshots: dependencies: '@ethereumjs/tx': 4.2.0 '@types/debug': 4.1.12 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0 semver: 7.7.1 superstruct: 1.0.4 transitivePeerDependencies: @@ -7903,7 +7906,7 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 '@types/debug': 4.1.12 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0 pony-cause: 2.1.11 semver: 7.7.1 uuid: 9.0.1 @@ -7917,7 +7920,7 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 '@types/debug': 4.1.12 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0 pony-cause: 2.1.11 semver: 7.7.1 uuid: 9.0.1 @@ -10587,6 +10590,10 @@ snapshots: dependencies: ms: 2.1.2 + debug@4.4.0: + dependencies: + ms: 2.1.3 + debug@4.4.0(supports-color@5.5.0): dependencies: ms: 2.1.3 @@ -11872,7 +11879,7 @@ snapshots: istanbul-lib-source-maps@4.0.1: dependencies: - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0 istanbul-lib-coverage: 3.2.2 source-map: 0.6.1 transitivePeerDependencies: @@ -13062,7 +13069,7 @@ snapshots: portfinder@1.0.35: dependencies: async: 3.2.6 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0 transitivePeerDependencies: - supports-color