diff --git a/typescript/.changeset/lazy-hornets-kiss.md b/typescript/.changeset/lazy-hornets-kiss.md new file mode 100644 index 000000000..870fc618f --- /dev/null +++ b/typescript/.changeset/lazy-hornets-kiss.md @@ -0,0 +1,5 @@ +--- +"@coinbase/agentkit": patch +--- + +Added a new action provider to support Base SQL API queries diff --git a/typescript/agentkit/README.md b/typescript/agentkit/README.md index bd408cd20..934bd69ba 100644 --- a/typescript/agentkit/README.md +++ b/typescript/agentkit/README.md @@ -236,6 +236,15 @@ const agent = createReactAgent({ +Base SQL API + + + execute_base_sql_query + Queries the Base blockchain using SQL based on the defined tables and fields. + + + + Clanker diff --git a/typescript/agentkit/src/action-providers/cdp/README.md b/typescript/agentkit/src/action-providers/cdp/README.md index 5d628e7c7..39928b061 100644 --- a/typescript/agentkit/src/action-providers/cdp/README.md +++ b/typescript/agentkit/src/action-providers/cdp/README.md @@ -12,8 +12,12 @@ cdp/ ├── cdpSmartWalletActionProvider.ts # Provider for CDP Smart Wallet operations ├── cdpEvmWalletActionProvider.test.ts # Tests for CDP EVM Wallet provider ├── cdpSmartWalletActionProvider.test.ts # Tests for CDP Smart Wallet provider +├── cdpSqlApiActionProvider.ts # Main provider implementation +├── cdpSqlApiActionProvider.test.ts # Provider test suite +├── baseSqlApiDescription.ts # Variables describing the action and valid SQL Schemas ├── schemas.ts # Action schemas for CDP operations ├── index.ts # Main exports +├── constants.ts # Constant variables └── README.md # This file ``` @@ -22,8 +26,8 @@ cdp/ ### CDP API Actions - `request_faucet_funds`: Request testnet funds from CDP faucet - - Available only on Base Sepolia, Ethereum Sepolia or Solana Devnet +- `execute_cdp_sql_query`: Execute a SQL query for Base or Sepolia Base data ### CDP EVM Wallet Actions @@ -62,4 +66,6 @@ The CDP providers support all networks available on the Coinbase Developer Platf - Requires CDP API credentials (API Key ID and Secret). Visit the [CDP Portal](https://portal.cdp.coinbase.com/) to get your credentials. +- Requires a **CDP Client API Key** for authentication. Visit [CDP](https://portal.cdp.coinbase.com/projects/api-keys/client-key/) to get your key. + For more information on the **Coinbase Developer Platform**, visit [CDP Documentation](https://docs.cdp.coinbase.com/). diff --git a/typescript/agentkit/src/action-providers/cdp/cdpSqlApiActionProvider.test.ts b/typescript/agentkit/src/action-providers/cdp/cdpSqlApiActionProvider.test.ts new file mode 100644 index 000000000..6767effd3 --- /dev/null +++ b/typescript/agentkit/src/action-providers/cdp/cdpSqlApiActionProvider.test.ts @@ -0,0 +1,130 @@ +import { cdpSqlApiActionProvider } from "./cdpSqlApiActionProvider"; +import { CdpSqlApiSchema } from "./schemas"; +import { CDP_SQL_API_URL } from "./constants"; + +describe("CDP SQL API Action Provider", () => { + let originalFetch: typeof fetch | undefined; + let mockFetch: jest.MockedFunction; + + const mockApiKey = "test-token"; + + beforeEach(() => { + process.env.CDP_API_CLIENT_KEY = mockApiKey; + + originalFetch = globalThis.fetch; + mockFetch = jest.fn() as jest.MockedFunction; + globalThis.fetch = mockFetch; + }); + + afterEach(() => { + mockFetch.mockReset(); + if (originalFetch) { + globalThis.fetch = originalFetch; + } + delete process.env.CDP_API_CLIENT_KEY; + }); + + it("should throw if no API key is provided", () => { + delete process.env.CDP_API_CLIENT_KEY; + expect(() => cdpSqlApiActionProvider()).toThrow("CDP_API_CLIENT_KEY is not configured."); + }); + + it("should use provided API key from config", () => { + const provider = cdpSqlApiActionProvider({ cdpApiClientKey: "foo" }); + expect(provider).toBeDefined(); + }); + + const provider = cdpSqlApiActionProvider({ cdpApiClientKey: "test-token" }); + + describe("schema validation", () => { + it("validates a correct payload", () => { + const validInput = { sqlQuery: "SELECT 1" }; + const parsed = CdpSqlApiSchema.safeParse(validInput); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.sqlQuery).toBe("SELECT 1"); + } + }); + + it("rejects an incorrect payload", () => { + const invalidInput = { fieldName: "", amount: "invalid" }; + const parsed = CdpSqlApiSchema.safeParse(invalidInput); + expect(parsed.success).toBe(false); + }); + }); + + describe("executeCdpSqlQuery", () => { + it("POSTs to the CDP SQL API with headers/body and returns the text result", async () => { + const args = { sqlQuery: "SELECT 1" }; + const resultPayload = { columns: ["one"], rows: [[1]] }; + + mockFetch.mockResolvedValue( + new Response(JSON.stringify({ result: resultPayload }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + const result = await provider.executeCdpSqlQuery(args); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledWith( + CDP_SQL_API_URL, + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + Authorization: "Bearer test-token", + "Content-Type": "application/json", + Accept: "application/json", + }), + body: JSON.stringify({ sql: args.sqlQuery }), + }), + ); + + expect(result).toBe(JSON.stringify(resultPayload)); + }); + + it("returns a readable error string when response.ok is false", async () => { + const args = { sqlQuery: "SELECT * FROM nope" }; + const errorBody = { errorMessage: "Unauthorized" }; + + mockFetch.mockResolvedValue( + new Response(JSON.stringify(errorBody), { + status: 401, + headers: { "Content-Type": "application/json" }, + }), + ); + + const result = await provider.executeCdpSqlQuery(args); + + expect(result).toContain("Error 401 executing CDP SQL query:"); + expect(result).toContain("Unauthorized"); + }); + + it("returns a readable error string when fetch throws", async () => { + const args = { sqlQuery: "SELECT * FROM throws" }; + mockFetch.mockRejectedValue(new Error("boom")); + + const result = await provider.executeCdpSqlQuery(args); + expect(result).toBe("Error executing CDP SQL query: Error: boom"); + }); + }); + + describe("supportsNetwork", () => { + it("returns true for base network", () => { + expect( + provider.supportsNetwork({ + protocolFamily: "evm", + networkId: "base-mainnet", + }), + ).toBe(true); + + expect( + provider.supportsNetwork({ + protocolFamily: "evm", + networkId: "ethereum-mainnet", + }), + ).toBe(false); + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/cdp/cdpSqlApiActionProvider.ts b/typescript/agentkit/src/action-providers/cdp/cdpSqlApiActionProvider.ts new file mode 100644 index 000000000..eaba00df9 --- /dev/null +++ b/typescript/agentkit/src/action-providers/cdp/cdpSqlApiActionProvider.ts @@ -0,0 +1,100 @@ +import { z } from "zod"; +import { ActionProvider } from "../actionProvider"; +import { Network } from "../../network"; +import { CreateAction } from "../actionDecorator"; +import { EvmWalletProvider } from "../../wallet-providers"; +import { CdpSqlApiSchema } from "./schemas"; +import { description } from "./cdpSqlApiDescription"; +import { CDP_SQL_API_URL } from "./constants"; + +/** + * Configuration options for the CdpSqlApiActionProvider. + */ +export interface CdpSqlApiActionProviderConfig { + /** + * CDP Client API Key. Request new at https://portal.cdp.coinbase.com/projects/api-keys/client-key/ + */ + cdpApiClientKey?: string; +} + +/** + * CdpSqlApiActionProvider provides actions for cdpSqlApi operations. + * + * @description + * This provider supports SQL querying on the Base Sepolia Base network. + */ +export class CdpSqlApiActionProvider extends ActionProvider { + private readonly cdpApiClientKey: string; + + /** + * Constructor for the CdpSqlApiActionProvider. + * + * @param config - The configuration options for the CdpSqlApiActionProvider. + */ + constructor(config: CdpSqlApiActionProviderConfig = {}) { + super("cdpSqlApi", []); + + const cdpApiClientKey = config.cdpApiClientKey || process.env.CDP_API_CLIENT_KEY; + if (!cdpApiClientKey) { + throw new Error("CDP_API_CLIENT_KEY is not configured."); + } + this.cdpApiClientKey = cdpApiClientKey; + } + + /** + * CDP SQL API action provider + * + * @description + * This action queries the Coinbase SQL API endpoint to efficiently retrieve onchain data on Base or Base Sepolia. + * + * @param args - Arguments defined by CdpSqlApiSchema, i.e. the SQL query to execute + * @returns A promise that resolves to a string describing the query result + */ + @CreateAction({ + name: "execute_cdp_sql_query", + description, + schema: CdpSqlApiSchema, + }) + async executeCdpSqlQuery(args: z.infer): Promise { + try { + const response = await fetch(CDP_SQL_API_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${this.cdpApiClientKey}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ sql: args.sqlQuery }), + }); + + if (!response.ok) { + const errorData = await response.json(); + return `Error ${response.status} executing CDP SQL query: ${errorData.errorMessage || response.statusText}`; + } + + const data = await response.json(); + return JSON.stringify(data.result); + } catch (error) { + return `Error executing CDP SQL query: ${error}`; + } + } + + /** + * Checks if this provider supports the given network. + * + * @param network - The network to check support for + * @returns True if the network is supported + */ + supportsNetwork(network: Network): boolean { + return network.networkId === "base-mainnet" || network.networkId === "base-sepolia"; + } +} + +/** + * Factory function to create a new CdpSqlApiActionProvider instance. + * + * @param config - the config of the cdp sql api action provider, contains the cdp client api key + * @returns A new CdpSqlApiActionProvider instance + */ +export const cdpSqlApiActionProvider = (config?: CdpSqlApiActionProviderConfig) => + new CdpSqlApiActionProvider(config); diff --git a/typescript/agentkit/src/action-providers/cdp/cdpSqlApiDescription.ts b/typescript/agentkit/src/action-providers/cdp/cdpSqlApiDescription.ts new file mode 100644 index 000000000..c7fbffe7d --- /dev/null +++ b/typescript/agentkit/src/action-providers/cdp/cdpSqlApiDescription.ts @@ -0,0 +1,384 @@ +const apiSchema = [ + { + tableName: "base.blocks", + fields: [ + { fieldName: "block_number", type: "uint64", description: "The number of the block" }, + { + fieldName: "block_hash", + type: "String", + description: "The unique hash identifying this block", + }, + { fieldName: "parent_hash", type: "String", description: "The hash of the parent block" }, + { + fieldName: "timestamp", + type: "DateTime", + description: "The timestamp when this block was created", + }, + { + fieldName: "miner", + type: "String", + description: "The address of the miner/validator who created this block", + }, + { fieldName: "nonce", type: "uint64", description: "The proof-of-work nonce value" }, + { + fieldName: "sha3_uncles", + type: "String", + description: "The hash of the uncles list for this block", + }, + { + fieldName: "transactions_root", + type: "String", + description: "The root hash of the transactions trie", + }, + { fieldName: "state_root", type: "String", description: "The root hash of the state trie" }, + { + fieldName: "receipts_root", + type: "String", + description: "The root hash of the receipts trie", + }, + { + fieldName: "logs_bloom", + type: "String", + description: "The bloom filter for the logs of the block", + }, + { + fieldName: "gas_limit", + type: "uint64", + description: "The maximum gas allowed in this block", + }, + { + fieldName: "gas_used", + type: "uint64", + description: "The total gas used by all transactions in this block", + }, + { + fieldName: "base_fee_per_gas", + type: "uint64", + description: "The base fee per gas in this block (EIP-1559)", + }, + { + fieldName: "total_difficulty", + type: "String", + description: "The total difficulty of the chain up to this block", + }, + { fieldName: "size", type: "uint64", description: "The size of this block in bytes" }, + { fieldName: "extra_data", type: "String", description: "Extra data field for this block" }, + { fieldName: "mix_hash", type: "String", description: "The mix hash for this block" }, + { + fieldName: "withdrawals_root", + type: "String", + description: "The root hash of withdrawals (post-merge)", + }, + { + fieldName: "parent_beacon_block_root", + type: "String", + description: "The parent beacon block root (post-merge)", + }, + { + fieldName: "blob_gas_used", + type: "uint64", + description: "The amount of blob gas used in this block", + }, + { + fieldName: "excess_blob_gas", + type: "uint64", + description: "The excess blob gas in this block", + }, + { + fieldName: "transaction_count", + type: "uint64", + description: "The number of transactions in this block", + }, + { + fieldName: "action", + type: "Int8", + description: "Indicates if block was added (1) or removed (-1) due to chain reorganization", + }, + ], + }, + { + tableName: "base.events", + fields: [ + { fieldName: "block_number", type: "uint64", description: "The block number" }, + { + fieldName: "block_hash", + type: "String", + description: "Keccak-256 hash of the block header; verifies block contents", + }, + { + fieldName: "timestamp", + type: "DateTime64", + description: "Time at which the block was created", + }, + { + fieldName: "transaction_hash", + type: "String", + description: "Keccak-256 hash of the signed transaction; unique tx identifier", + }, + { + fieldName: "transaction_to", + type: "String", + description: "Address the transaction is acting against (EOA or contract)", + }, + { fieldName: "transaction_from", type: "String", description: "Originating address (EOA)" }, + { + fieldName: "transaction_index", + type: "uint64", + description: "Order of the transaction within the block", + }, + { + fieldName: "log_index", + type: "uint64", + description: "Index of the log within the transaction (0-based)", + }, + { + fieldName: "address", + type: "String", + description: "Contract address that created the log", + }, + { + fieldName: "topics", + type: "Array(String)", + description: "Indexed params and the keccak256 of the event signature", + }, + { fieldName: "event_name", type: "String", description: "Human-readable event name" }, + { + fieldName: "event_signature", + type: "String", + description: "Full canonical declaration (name + parameter types)", + }, + { + fieldName: "parameters", + type: "Map(String, Variant(Bool, Int256, String, uint256))", + description: "Parameter name -> value", + }, + { + fieldName: "parameter_types", + type: "Map(String, String)", + description: "Parameter name -> ABI type", + }, + { + fieldName: "action", + type: "Int8", + description: "1 if created; −1 if reorged out; sum > 0 means still active", + }, + ], + }, + { + tableName: "base.transactions", + fields: [ + { + fieldName: "block_number", + type: "uint64", + description: "The number of the block that contains this transaction", + }, + { + fieldName: "block_hash", + type: "String", + description: "The hash of the block that contains this transaction", + }, + { + fieldName: "transaction_hash", + type: "String", + description: "The unique hash identifying this transaction", + }, + { + fieldName: "transaction_index", + type: "uint64", + description: "Index position within the block", + }, + { fieldName: "from_address", type: "String", description: "Originating address" }, + { fieldName: "to_address", type: "String", description: "Destination address" }, + { fieldName: "value", type: "String", description: "Transferred value" }, + { fieldName: "gas", type: "uint64", description: "Gas limit" }, + { fieldName: "gas_price", type: "uint64", description: "Gas price (wei)" }, + { fieldName: "input", type: "String", description: "Data payload" }, + { + fieldName: "nonce", + type: "uint64", + description: "Count of prior transactions from the sender", + }, + { fieldName: "type", type: "uint64", description: "Transaction type" }, + { + fieldName: "max_fee_per_gas", + type: "uint64", + description: "Max fee per gas the sender will pay", + }, + { + fieldName: "max_priority_fee_per_gas", + type: "uint64", + description: "Max priority fee per gas", + }, + { fieldName: "chain_id", type: "uint64", description: "Chain ID" }, + { fieldName: "v", type: "String", description: "Signature v" }, + { fieldName: "r", type: "String", description: "Signature r" }, + { fieldName: "s", type: "String", description: "Signature s" }, + { + fieldName: "is_system_tx", + type: "Bool", + description: "Whether this is a system transaction", + }, + { fieldName: "max_fee_per_blob_gas", type: "String", description: "Max fee per blob gas" }, + { + fieldName: "blob_versioned_hashes", + type: "Array(String)", + description: "Versioned hashes for associated blobs", + }, + { + fieldName: "timestamp", + type: "DateTime64", + description: "When the tx was included in a block", + }, + { fieldName: "action", type: "Int8", description: "1 if added, −1 if removed due to reorg" }, + ], + }, + { + tableName: "base.encoded_logs", + fields: [ + { fieldName: "block_number", type: "uint64", description: "Block number containing the log" }, + { + fieldName: "block_hash", + type: "String", + description: "Hash of the block containing the log", + }, + { fieldName: "block_timestamp", type: "DateTime64", description: "Timestamp of that block" }, + { + fieldName: "transaction_hash", + type: "String", + description: "Hash of the transaction containing the log", + }, + { + fieldName: "transaction_to", + type: "String", + description: "Transaction recipient (EOA or contract)", + }, + { fieldName: "transaction_from", type: "String", description: "Transaction sender (EOA)" }, + { + fieldName: "log_index", + type: "uint32", + description: "Log index within the transaction (0-based)", + }, + { + fieldName: "address", + type: "String", + description: "Contract address that created the log", + }, + { + fieldName: "topics", + type: "Array(String)", + description: "Indexed params / signature hash", + }, + { + fieldName: "action", + type: "Enum8('removed' = -1, 'added' = 1)", + description: "1 = created; −1 = reorged out; sum > 0 means active", + }, + ], + }, + // not public yet + // { + // tableName: "base.transfers", + // fields: [ + // { + // fieldName: "block_number", + // type: "uint64", + // description: "Block number containing the transfer", + // }, + // { fieldName: "block_timestamp", type: "DateTime64", description: "Block timestamp" }, + // { fieldName: "transaction_to", type: "String", description: "Transaction recipient address" }, + // { fieldName: "transaction_from", type: "String", description: "Transaction sender address" }, + // { fieldName: "log_index", type: "uint32", description: "Log index within the transaction" }, + // { fieldName: "token_address", type: "String", description: "Token contract address" }, + // { + // fieldName: "from_address", + // type: "String", + // description: "Address tokens were transferred from", + // }, + // { + // fieldName: "to_address", + // type: "String", + // description: "Address tokens were transferred to", + // }, + // { fieldName: "value", type: "uint256", description: "Amount of tokens transferred" }, + // { fieldName: "action", type: "Enum8", description: "Action flag: 1 add, −1 reorg removal" }, + // ], + // }, +]; + +const schemaJson = JSON.stringify(apiSchema, null, 2); + +export const description = ` + This action executes read-only SQL queries against indexed blockchain data using the CDP SQL API. + + **Use Cases:** + - Query transaction history and patterns + - Analyze event logs and smart contract interactions + - Retrieve block information and metadata + - Examine token transfers and DeFi activity + + **IMPORTANT Query Requirements:** + - Must be SELECT statements only (ClickHouse SQL dialect) + - Casts use the :: syntax (not CAST(... AS ...)) + - Maximum query length: 10,000 characters + - Maximum result rows: 10,000 + - Query timeout: 30 seconds + - Maximum JOINs: 5 + - No cartesian products allowed + - No DDL/DML operations (INSERT, UPDATE, DELETE, etc.) + - Keep it simple and break down the task into several queries if appropriate. + + **Available Tables:** + - base.events: Decoded event logs with parameters and signatures + - base.transactions: Complete transaction data including gas and signatures + - base.blocks: Block information and metadata + - base.encoded_logs: Raw log data that couldn't be decoded + **Table Schema Details:** + ${schemaJson} + + **Example Queries:** + + 1. Get ERC-20 token transfers for USDC: + SELECT + parameters['from']::String AS sender, + parameters['to']::String AS to, + parameters['value']::UInt256 AS amount, + address AS token_address + FROM base.events + WHERE + event_signature = 'Transfer(address,address,uint256)' + AND address = '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913' + LIMIT 10; + + 2. Get swap events from Uniswap v2-style DEXes: + SELECT parameters ['to']::String AS to, + parameters ['amount0In']::UInt256 AS amount0In, + parameters ['amount0Out']::UInt256 AS amount0Out, + parameters ['amount1In']::UInt256 AS amount1In, + parameters ['amount1Out']::UInt256 AS amount1Out, + parameters ['sender']::String AS sender + FROM base.events + WHERE event_signature = 'Swap(address,uint256,uint256,uint256,uint256,address)' + LIMIT 10; + + 3. Show me 10 rows from the events table: + SELECT * FROM base.events LIMIT 10; + + 4. Aggregate ZORA content rewards by coin and currency for payout recipient 0x0bC5f409e4d9298B93E98920276128b89280d832: + SELECT + parameters ['coin']::String as coin, + parameters ['currency']::String as currency, + sum( + ( + replaceAll( + splitByChar(' ', parameters ['marketRewards']::String) [1], + '{', + '' + ) + )::UInt64 + ) as market_rewards + FROM base.events + WHERE + event_signature = 'CoinMarketRewardsV4(address,address,address,address,address,address,address,(uint256,uint256,uint256,uint256,uint256,uint256,uint256,uint256,uint256,uint256))' + AND parameters ['payoutRecipient']::String = lower('0x0bC5f409e4d9298B93E98920276128b89280d832') + GROUP BY coin, currency; +`; diff --git a/typescript/agentkit/src/action-providers/cdp/constants.ts b/typescript/agentkit/src/action-providers/cdp/constants.ts new file mode 100644 index 000000000..5f1f7ebba --- /dev/null +++ b/typescript/agentkit/src/action-providers/cdp/constants.ts @@ -0,0 +1 @@ +export const CDP_SQL_API_URL = "https://api.cdp.coinbase.com/platform/v2/data/query/run"; diff --git a/typescript/agentkit/src/action-providers/cdp/index.ts b/typescript/agentkit/src/action-providers/cdp/index.ts index aee526eea..d5ab18b15 100644 --- a/typescript/agentkit/src/action-providers/cdp/index.ts +++ b/typescript/agentkit/src/action-providers/cdp/index.ts @@ -3,3 +3,4 @@ export * from "./cdpApiActionProvider"; export * from "./cdpSmartWalletActionProvider"; export * from "./cdpEvmWalletActionProvider"; export * from "./spendPermissionUtils"; +export * from "./cdpSqlApiActionProvider"; diff --git a/typescript/agentkit/src/action-providers/cdp/schemas.ts b/typescript/agentkit/src/action-providers/cdp/schemas.ts index 9df2e65cc..1f0430b80 100644 --- a/typescript/agentkit/src/action-providers/cdp/schemas.ts +++ b/typescript/agentkit/src/action-providers/cdp/schemas.ts @@ -70,3 +70,17 @@ export const UseSpendPermissionSchema = z }) .strip() .describe("Instructions for using a spend permission"); + +/** + * Input schema for querying the CDP SQL API + */ +export const CdpSqlApiSchema = z.object({ + sqlQuery: z + .string() + .min(1, "SQL query cannot be empty") + .max(10000, "Query exceeds maximum length of 10,000 characters") + .describe( + `The SQL query to execute using ClickHouse syntax. Must be a read-only SELECT statement. ` + + `Limitations: max 10,000 characters, max 5 JOINs, no cartesian products, 30s timeout. API supports a max of 10,000 result rows but limit it to 10 unless otherwise specified.`, + ), +});
execute_base_sql_query