From 24a6ad0a29306ff7e7cb8a836c264483359d2532 Mon Sep 17 00:00:00 2001 From: Yoni Date: Fri, 20 Mar 2026 09:33:57 +0200 Subject: [PATCH 1/2] feat(python): add x402search ActionProvider --- .../action_providers/__init__.py | 3 + .../action_providers/x402search/README.md | 37 +++++ .../action_providers/x402search/__init__.py | 8 ++ .../action_providers/x402search/schemas.py | 29 ++++ .../x402search/x402search_action_provider.py | 134 ++++++++++++++++++ 5 files changed, 211 insertions(+) create mode 100644 python/coinbase-agentkit/coinbase_agentkit/action_providers/x402search/README.md create mode 100644 python/coinbase-agentkit/coinbase_agentkit/action_providers/x402search/__init__.py create mode 100644 python/coinbase-agentkit/coinbase_agentkit/action_providers/x402search/schemas.py create mode 100644 python/coinbase-agentkit/coinbase_agentkit/action_providers/x402search/x402search_action_provider.py diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/__init__.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/__init__.py index c24574ea4..d759f8e44 100644 --- a/python/coinbase-agentkit/coinbase_agentkit/action_providers/__init__.py +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/__init__.py @@ -38,6 +38,7 @@ from .wow.wow_action_provider import WowActionProvider, wow_action_provider from .x402.schemas import X402Config from .x402.x402_action_provider import x402_action_provider, x402ActionProvider +from .x402search.x402search_action_provider import X402SearchActionProvider, x402search_action_provider __all__ = [ "Action", @@ -84,4 +85,6 @@ "x402ActionProvider", "x402_action_provider", "X402Config", + "X402SearchActionProvider", + "x402search_action_provider", ] diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/x402search/README.md b/python/coinbase-agentkit/coinbase_agentkit/action_providers/x402search/README.md new file mode 100644 index 000000000..fb5b00db6 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/x402search/README.md @@ -0,0 +1,37 @@ +# x402search ActionProvider + +Natural language search across **14,000+ indexed API services** for Coinbase AgentKit agents. + +**Cost:** $0.01 USDC per query — paid automatically via x402 protocol on Base mainnet. + +## Why + +Coinbase Bazaar is `ls /apis`. x402search is `grep -r "token price"` across all of them. + +When an agent needs a data source, it should search by capability — not iterate every Bazaar endpoint. This provider gives AgentKit agents that search natively, paid automatically with no human in the loop. + +## Usage +```python +from coinbase_agentkit import AgentKit, AgentKitConfig +from coinbase_agentkit.wallet_providers import CdpWalletProvider +from coinbase_agentkit.action_providers.x402search import x402search_action_provider + +agentkit = AgentKit(AgentKitConfig( + wallet_provider=CdpWalletProvider(cdp_config), + action_providers=[x402search_action_provider()], +)) +``` + +## Best queries + +| Query | Results | +|-------|---------| +| `crypto` | 112 | +| `token price` | 88 | +| `crypto market data` | 10 | +| `btc price` | 8 | + +## Links +- Live API: https://x402search.xyz +- MCP package: `x402search-mcp` +- x402 protocol: https://x402.org diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/x402search/__init__.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/x402search/__init__.py new file mode 100644 index 000000000..1cb4afe35 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/x402search/__init__.py @@ -0,0 +1,8 @@ +"""x402search action provider.""" + +from .x402search_action_provider import X402SearchActionProvider, x402search_action_provider + +__all__ = [ + "X402SearchActionProvider", + "x402search_action_provider", +] diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/x402search/schemas.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/x402search/schemas.py new file mode 100644 index 000000000..c83e8482e --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/x402search/schemas.py @@ -0,0 +1,29 @@ +"""Schemas for x402search action provider.""" + +from pydantic import BaseModel, Field + + +class SearchApisSchema(BaseModel): + """Input schema for the search_apis action.""" + + query: str = Field( + ..., + description=( + "Natural language query to find API services by capability. " + "Examples: 'token price', 'crypto market data', 'NFT metadata', " + "'weather forecast', 'sentiment analysis', 'btc price'" + ), + min_length=2, + max_length=200, + ) + limit: int = Field( + default=5, + ge=1, + le=10, + description="Maximum number of results to return (1-10, default 5).", + ) + + class Config: + """Pydantic config.""" + + title = "Parameters for searching API services by capability" diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/x402search/x402search_action_provider.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/x402search/x402search_action_provider.py new file mode 100644 index 000000000..65960db5b --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/x402search/x402search_action_provider.py @@ -0,0 +1,134 @@ +"""x402search action provider.""" + +import json +from typing import Any + +from x402.http.clients.requests import x402_requests +from x402.mechanisms.evm import EthAccountSigner +from x402.mechanisms.evm.exact.register import register_exact_evm_client +from x402 import x402ClientSync + +from ...network import Network +from ...wallet_providers import WalletProvider +from ...wallet_providers.evm_wallet_provider import EvmWalletProvider +from ..action_decorator import create_action +from ..action_provider import ActionProvider +from .schemas import SearchApisSchema + +X402SEARCH_URL = "https://x402search.xyz/v1/search" + + +class X402SearchActionProvider(ActionProvider[WalletProvider]): + """Provides natural language search across 14,000+ indexed API services via x402search. + + Coinbase Bazaar lists services. x402search searches them by capability. + Cost: $0.01 USDC per query via x402 protocol on Base mainnet. + """ + + def __init__(self): + super().__init__("x402search", []) + + @create_action( + name="search_apis", + description="""Search for API services and data providers by natural language capability query. + +Searches 14,000+ indexed APIs across crypto, DeFi, NFT, weather, finance, AI, and more. +Returns matching services with names, descriptions, and endpoints. +Cost: $0.01 USDC per query via x402 protocol on Base mainnet — paid automatically. + +Use this when an agent needs to discover what APIs exist for a given capability. +Coinbase Bazaar lists services. x402search searches them by capability — they are complementary. + +Examples: +- search_apis("token price") -> 88 results including CoinGecko, CryptoCompare +- search_apis("crypto market data") -> 10 focused results +- search_apis("NFT metadata") -> NFT-related APIs +- search_apis("btc price") -> 8 targeted results +""", + schema=SearchApisSchema, + ) + def search_apis(self, wallet_provider: WalletProvider, args: dict[str, Any]) -> str: + """Search x402search for API services matching the natural language query. + + Args: + wallet_provider: The wallet provider used to authorize x402 payment. + args: Input arguments containing query and optional limit. + + Returns: + str: A JSON string containing matched API services or error details. + + """ + validated = SearchApisSchema(**args) + + if not isinstance(wallet_provider, EvmWalletProvider): + return json.dumps({ + "success": False, + "error": "x402search requires an EvmWalletProvider with USDC on Base mainnet (eip155:8453).", + }) + + try: + client = x402ClientSync() + signer = wallet_provider.to_signer() + register_exact_evm_client(client, EthAccountSigner(signer)) + session = x402_requests(client) + + response = session.get( + X402SEARCH_URL, + params={"q": validated.query}, + timeout=15, + ) + response.raise_for_status() + data = response.json() + + except Exception as e: + return json.dumps({ + "success": False, + "error": f"x402search request failed: {e}", + "hint": "Ensure the wallet has USDC on Base mainnet (eip155:8453).", + }) + + results = data.get("results", data) if isinstance(data, dict) else data + if not isinstance(results, list): + return json.dumps({"success": False, "error": "Unexpected response format."}) + + results = results[: validated.limit] + + if not results: + return json.dumps({ + "success": True, + "query": validated.query, + "results": [], + "message": ( + f"No APIs found for '{validated.query}'. " + "Try broader terms: 'crypto' returns 112 results, 'token price' returns 88." + ), + }) + + formatted = [] + for r in results: + formatted.append({ + "name": r.get("name") or r.get("api_name", ""), + "description": r.get("description") or r.get("accepts", ""), + "url": r.get("url") or r.get("base_url", ""), + }) + + return json.dumps({ + "success": True, + "query": validated.query, + "count": len(formatted), + "results": formatted, + }, indent=2) + + def supports_network(self, network: Network) -> bool: + """x402search works with any EVM wallet; payment settles on Base mainnet.""" + return True + + +def x402search_action_provider() -> X402SearchActionProvider: + """Create a new x402search action provider. + + Returns: + X402SearchActionProvider: A new x402search action provider instance. + + """ + return X402SearchActionProvider() From 3505be949127aaae8eddc754eb8ca916f714c42d Mon Sep 17 00:00:00 2001 From: x402-index Date: Sat, 21 Mar 2026 18:24:45 +0200 Subject: [PATCH 2/2] fix(x402search): response parsing, domain dedup, search consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix response parsing: map resource_url→url, rank→rank, accepts array Add client-side domain dedup: max 2 results per netloc before slice Pass limit=50 to server so dedup has sufficient candidates Fix test script: use blastapi RPC, add delays between queries The public GET /v1/search endpoint on the server was also fixed (separately) to match POST /v1/search-acp: identical query expansion, OR/AND hybrid logic, dev URL filters, and domain dedup across all three search handlers. Co-Authored-By: Claude Sonnet 4.6 --- .../x402search/x402search_action_provider.py | 30 ++- test_x402search.py | 103 ++++++++ test_x402search_e2e.py | 225 ++++++++++++++++++ 3 files changed, 351 insertions(+), 7 deletions(-) create mode 100644 test_x402search.py create mode 100644 test_x402search_e2e.py diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/x402search/x402search_action_provider.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/x402search/x402search_action_provider.py index 65960db5b..db8bdad24 100644 --- a/python/coinbase-agentkit/coinbase_agentkit/action_providers/x402search/x402search_action_provider.py +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/x402search/x402search_action_provider.py @@ -1,7 +1,9 @@ """x402search action provider.""" import json +from collections import defaultdict from typing import Any +from urllib.parse import urlparse from x402.http.clients.requests import x402_requests from x402.mechanisms.evm import EthAccountSigner @@ -74,7 +76,7 @@ def search_apis(self, wallet_provider: WalletProvider, args: dict[str, Any]) -> response = session.get( X402SEARCH_URL, - params={"q": validated.query}, + params={"q": validated.query, "limit": 50}, timeout=15, ) response.raise_for_status() @@ -91,7 +93,15 @@ def search_apis(self, wallet_provider: WalletProvider, args: dict[str, Any]) -> if not isinstance(results, list): return json.dumps({"success": False, "error": "Unexpected response format."}) - results = results[: validated.limit] + domain_counts: dict[str, int] = defaultdict(int) + deduped = [] + for r in results: + url = r.get("resource_url", "") + domain = urlparse(url).netloc + if domain_counts[domain] < 2: + deduped.append(r) + domain_counts[domain] += 1 + results = deduped[: validated.limit] if not results: return json.dumps({ @@ -106,11 +116,17 @@ def search_apis(self, wallet_provider: WalletProvider, args: dict[str, Any]) -> formatted = [] for r in results: - formatted.append({ - "name": r.get("name") or r.get("api_name", ""), - "description": r.get("description") or r.get("accepts", ""), - "url": r.get("url") or r.get("base_url", ""), - }) + accepts = r.get("accepts", []) + entry: dict[str, Any] = { + "url": r.get("resource_url", ""), + "rank": r.get("rank"), + } + if accepts: + entry["accepts"] = [ + {k: v for k, v in a.items() if k in ("network", "max_amount", "payTo")} + for a in accepts + ] + formatted.append(entry) return json.dumps({ "success": True, diff --git a/test_x402search.py b/test_x402search.py new file mode 100644 index 000000000..1a4a19402 --- /dev/null +++ b/test_x402search.py @@ -0,0 +1,103 @@ +"""Test script for the x402search action provider. + +This script demonstrates how to use the X402SearchActionProvider with a mock +wallet provider, following the same test patterns used in the repo. + +Usage: + cd python/coinbase-agentkit && pip install -e . + python ../../test_x402search.py +""" + +import json +import sys +from unittest.mock import Mock, patch + +# --------------------------------------------------------------------------- +# Mock wallet provider (mimics EvmWalletProvider) +# --------------------------------------------------------------------------- + +from coinbase_agentkit.wallet_providers.evm_wallet_provider import EvmWalletProvider + +MOCK_ADDRESS = "0x1234567890123456789012345678901234567890" + +mock_wallet = Mock(spec=EvmWalletProvider) +mock_wallet.get_address.return_value = MOCK_ADDRESS +mock_wallet.to_signer.return_value = Mock() # EthAccountSigner-compatible + +# --------------------------------------------------------------------------- +# Import and instantiate the provider +# --------------------------------------------------------------------------- + +from coinbase_agentkit.action_providers.x402search import ( + X402SearchActionProvider, + x402search_action_provider, +) + +provider = x402search_action_provider() +print(f"Provider name : {provider.name}") +print(f"Actions : {[a.name for a in provider._actions]}") +print() + +# --------------------------------------------------------------------------- +# Call search_apis with a mocked HTTP response (no real network needed) +# --------------------------------------------------------------------------- + +MOCK_SEARCH_RESPONSE = { + "results": [ + { + "name": "CoinGecko Price API", + "description": "Real-time crypto price data for 10,000+ tokens", + "url": "https://api.coingecko.com/api/v3", + }, + { + "name": "CryptoCompare", + "description": "Comprehensive cryptocurrency market data", + "url": "https://min-api.cryptocompare.com", + }, + { + "name": "Messari", + "description": "Crypto asset metrics and market data", + "url": "https://data.messari.io/api/v1", + }, + ] +} + + +def make_mock_response(data: dict, status_code: int = 200): + """Return a requests.Response-like mock.""" + resp = Mock() + resp.status_code = status_code + resp.json.return_value = data + resp.raise_for_status = Mock() # no-op for 200 + return resp + + +# Patch x402_requests so no real HTTP or payment happens +with patch("coinbase_agentkit.action_providers.x402search.x402search_action_provider.x402_requests") as mock_x402_requests, \ + patch("coinbase_agentkit.action_providers.x402search.x402search_action_provider.x402ClientSync"), \ + patch("coinbase_agentkit.action_providers.x402search.x402search_action_provider.EthAccountSigner"), \ + patch("coinbase_agentkit.action_providers.x402search.x402search_action_provider.register_exact_evm_client"): + + mock_session = Mock() + mock_session.get.return_value = make_mock_response(MOCK_SEARCH_RESPONSE) + mock_x402_requests.return_value = mock_session + + result = provider.search_apis(mock_wallet, {"query": "crypto price feed"}) + +print("search_apis(query='crypto price feed') result:") +print(result) +print() + +parsed = json.loads(result) +assert parsed["success"] is True, f"Expected success=True, got: {parsed}" +assert parsed["count"] == 3, f"Expected 3 results, got {parsed['count']}" +assert parsed["results"][0]["name"] == "CoinGecko Price API" + +print(f" success : {parsed['success']}") +print(f" query : {parsed['query']}") +print(f" count : {parsed['count']}") +for r in parsed["results"]: + print(f" - {r['name']}: {r['url']}") + +print() +print("All assertions passed.") diff --git a/test_x402search_e2e.py b/test_x402search_e2e.py new file mode 100644 index 000000000..407111c7d --- /dev/null +++ b/test_x402search_e2e.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3.11 +"""End-to-end test for X402SearchActionProvider with real wallet and live payment.""" + +import json +import os +import sys +import time +from urllib.parse import urlparse + +from eth_account import Account +from web3 import Web3 + +# ── config ────────────────────────────────────────────────────────────────── +PRIVATE_KEY = os.environ.get("TEST_WALLET_PRIVATE_KEY") +if not PRIVATE_KEY: + sys.exit("ERROR: TEST_WALLET_PRIVATE_KEY env var not set") + +WALLET_ADDRESS = "0xfb286b0Cc7F33F1fb8A977c5c798A00Ce19D42Aa" +BASE_RPC = "https://base-mainnet.public.blastapi.io" +USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" # USDC on Base mainnet + +QUERIES = [ + "crypto price feed", + "whale alerts", + "btc price", + "sentiment analysis", + "defi lending rates", +] + +# Keywords that signal an irrelevant result for financial/price queries +# (LLM wrappers, generic AI tools that aren't price data sources) +LLM_WRAPPER_DOMAINS = { + "openai.com", "anthropic.com", "huggingface.co", "langchain.com", + "replicate.com", "together.ai", "perplexity.ai", +} + +DEV_STAGING_INDICATORS = ["dev.", "staging.", "localhost", "127.0.0.1", ".local", "-staging.", "-dev."] + +# ERC-20 balanceOf ABI +BALANCE_ABI = [{"inputs":[{"name":"account","type":"address"}],"name":"balanceOf", + "outputs":[{"name":"","type":"uint256"}],"stateMutability":"view","type":"function"}] + +# ── helpers ────────────────────────────────────────────────────────────────── + +def get_usdc_balance(w3: Web3, address: str) -> float: + usdc = w3.eth.contract(address=Web3.to_checksum_address(USDC_ADDRESS), abi=BALANCE_ABI) + raw = usdc.functions.balanceOf(Web3.to_checksum_address(address)).call() + return raw / 1e6 # USDC has 6 decimals + + +def extract_results(result: dict) -> list[dict]: + """Return a flat list of result dicts with 'url' and 'rank' keys.""" + # Try common shapes the provider might return + if "results" in result: + return result["results"] + if "apis" in result: + return result["apis"] + if "data" in result: + return result["data"] + return [] + + +def domain_of(url: str) -> str: + try: + return urlparse(url).netloc.lower().lstrip("www.") + except Exception: + return url + + +def check_query(query: str, result: dict) -> tuple[bool, list[str]]: + """ + Run quality checks on a single query result. + Returns (passed: bool, issues: list[str]). + """ + issues = [] + results = extract_results(result) + count = len(results) + + print(f" Results count : {count}") + + # Show top-3 URLs with ranks + top3 = results[:3] + for i, r in enumerate(top3): + url = r.get("url") or r.get("endpoint") or r.get("api_url") or "" + rank = r.get("rank") or r.get("score") or r.get("relevance") or 0 + print(f" [{i+1}] rank={rank:.4f} {url}") + + if count == 0: + issues.append("no results returned") + return False, issues + + # --- Check 1: no dev/staging URLs --- + for r in results: + url = r.get("url") or r.get("endpoint") or r.get("api_url") or "" + for indicator in DEV_STAGING_INDICATORS: + if indicator in url.lower(): + issues.append(f"dev/staging URL found: {url}") + break + + # --- Check 2: no single domain takes more than 2 slots --- + from collections import Counter + domains = [domain_of(r.get("url") or r.get("endpoint") or r.get("api_url") or "") for r in results] + for dom, cnt in Counter(domains).items(): + if dom and cnt > 2: + issues.append(f"domain '{dom}' appears {cnt} times (max 2)") + + # --- Check 3: top result rank > 0.10 --- + top_rank = top3[0].get("rank") or top3[0].get("score") or top3[0].get("relevance") or 0 + if top_rank <= 0.10: + issues.append(f"top result rank {top_rank:.4f} ≤ 0.10") + + # --- Check 4: no LLM wrapper domains in results (for price/financial queries) --- + price_queries = {"crypto price feed", "btc price", "defi lending rates"} + if query in price_queries: + for r in results: + url = r.get("url") or r.get("endpoint") or r.get("api_url") or "" + dom = domain_of(url) + if dom in LLM_WRAPPER_DOMAINS: + issues.append(f"LLM wrapper domain in price query results: {url}") + + passed = len(issues) == 0 + return passed, issues + + +# ── main ───────────────────────────────────────────────────────────────────── + +def main(): + # Verify private key matches expected address + local_account = Account.from_key(PRIVATE_KEY) + if local_account.address.lower() != WALLET_ADDRESS.lower(): + sys.exit( + f"ERROR: private key yields {local_account.address}, " + f"expected {WALLET_ADDRESS}" + ) + print(f"Wallet: {local_account.address}") + + # Check USDC balance before + w3 = Web3(Web3.HTTPProvider(BASE_RPC)) + balance_before = get_usdc_balance(w3, WALLET_ADDRESS) + print(f"USDC balance before: ${balance_before:.6f}") + min_required = 0.01 * len(QUERIES) + if balance_before < min_required: + sys.exit( + f"ERROR: insufficient USDC balance " + f"(need ≥${min_required:.2f} for {len(QUERIES)} queries, have ${balance_before:.6f})" + ) + + # Build wallet provider and action provider + from coinbase_agentkit.wallet_providers.eth_account_wallet_provider import ( + EthAccountWalletProvider, + EthAccountWalletProviderConfig, + ) + from coinbase_agentkit.action_providers.x402search import x402search_action_provider + + wallet_provider = EthAccountWalletProvider( + EthAccountWalletProviderConfig( + account=local_account, + chain_id="8453", # Base mainnet + ) + ) + + provider = x402search_action_provider() + + verdicts: list[tuple[str, bool, list[str]]] = [] + + for i, query in enumerate(QUERIES): + if i > 0: + print(" (waiting 3s to avoid rate limiting...)") + time.sleep(3) + + print(f"\n{'='*60}") + print(f"QUERY: '{query}'") + print(f"{'='*60}") + print("(charging ~$0.01 USDC via x402 payment ...)") + + bal_before_q = get_usdc_balance(w3, WALLET_ADDRESS) + result_str = provider.search_apis(wallet_provider, {"query": query}) + result = json.loads(result_str) + bal_after_q = get_usdc_balance(w3, WALLET_ADDRESS) + deducted = bal_before_q - bal_after_q + + if not result.get("success"): + print(f" ERROR: search failed — {result.get('error')}") + verdicts.append((query, False, [f"search failed: {result.get('error')}"])) + continue + + print(f" Payment: ${deducted:.6f} USDC deducted") + + passed, issues = check_query(query, result) + + if issues: + for issue in issues: + print(f" ISSUE: {issue}") + + verdict = "PASS" if passed else "FAIL" + print(f" --> {verdict}") + verdicts.append((query, passed, issues)) + + # ── summary ────────────────────────────────────────────────────────────── + balance_after = get_usdc_balance(w3, WALLET_ADDRESS) + total_deducted = balance_before - balance_after + + print(f"\n{'='*60}") + print("SUMMARY") + print(f"{'='*60}") + print(f"Total USDC spent: ${total_deducted:.6f}") + print() + + all_passed = True + for query, passed, issues in verdicts: + mark = "PASS" if passed else "FAIL" + print(f" [{mark}] {query}") + if not passed: + all_passed = False + for issue in issues: + print(f" - {issue}") + + print() + print("OVERALL:", "PASS" if all_passed else "FAIL") + if not all_passed: + sys.exit(1) + + +if __name__ == "__main__": + main()