From 17a1dce19880f0e3ef49c5e861925acd745e991d Mon Sep 17 00:00:00 2001 From: GTCC777 <255618185+GTCC777@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:23:53 +0000 Subject: [PATCH 1/4] Add pulsenetwork-data template: agent buys live data mid-task via x402 --- pulsenetwork_data_template.py | 144 ++++++++++++++++++++++++++++++++++ templates.json | 131 ++++++++++++++++++++++--------- 2 files changed, 240 insertions(+), 35 deletions(-) create mode 100644 pulsenetwork_data_template.py diff --git a/pulsenetwork_data_template.py b/pulsenetwork_data_template.py new file mode 100644 index 0000000..b80dd55 --- /dev/null +++ b/pulsenetwork_data_template.py @@ -0,0 +1,144 @@ +"""PulseNetwork data tools: your agent buys live data mid-task with x402. + +Adds three tools to any browser-use agent: + - pulse_catalog: free search across 950+ pay-per-call data endpoints + - pulse_price: free price check for any PulseNetwork endpoint (a bare 402 quote) + - pulse_buy: pay one endpoint with USDC on Base and return its JSON + +Payments use the official x402 Python SDK. No API keys, no accounts: the wallet +is the identity. The private key stays in an env var; the LLM never sees it. +Per-call and per-session budget caps are enforced in code, and the buy tool +refuses any host outside PulseNetwork, so the agent cannot be talked into +paying somewhere else. + +Setup: + pip install browser-use "x402[httpx,evm]" httpx + export PULSE_WALLET_KEY=0x... # a throwaway wallet holding a few USDC on Base + export OPENAI_API_KEY=sk-... # or use any browser-use supported model + +Catalog and docs: https://pulse.theaslangroupllc.com/llms.txt +""" + +import asyncio +import os + +import httpx +from browser_use import ActionResult, Agent, ChatOpenAI, Tools +from eth_account import Account +from x402.client import x402Client +from x402.http.clients.httpx import x402HttpxClient +from x402.mechanisms.evm.exact import ExactEvmScheme +from x402.mechanisms.evm.signers import EthAccountSigner + +CATALOG_URL = "https://pulse.theaslangroupllc.com/llms-full.txt" +ALLOWED_HOST_SUFFIX = ".theaslangroupllc.com" +MAX_PER_CALL_USD = float(os.getenv("PULSE_MAX_PER_CALL_USD", "0.50")) +SESSION_BUDGET_USD = float(os.getenv("PULSE_SESSION_BUDGET_USD", "2.00")) + +tools = Tools() +_spent = {"usd": 0.0} +_catalog_cache = {"text": None} + + +def _quote_usd(url: str) -> float | None: + """Ask the endpoint what it costs. A bare 402 quote is free and settles nothing.""" + r = httpx.get(url, timeout=30) + if r.status_code != 402: + return None + for accept in r.json().get("accepts", []): + if accept.get("network") == "eip155:8453": + return int(accept["amount"]) / 1e6 + return None + + +@tools.action( + description=( + "Search the PulseNetwork catalog of 950+ pay-per-call data endpoints: " + "crypto token safety, market scans, travel rights, sports, climate, " + "compliance and more. Free. Returns matching lines with URLs and prices." + ) +) +async def pulse_catalog(query: str) -> ActionResult: + if _catalog_cache["text"] is None: + async with httpx.AsyncClient(timeout=30) as c: + _catalog_cache["text"] = (await c.get(CATALOG_URL)).text + q = query.lower() + hits = [ln for ln in _catalog_cache["text"].splitlines() if q in ln.lower()][:20] + return ActionResult( + extracted_content="\n".join(hits) or "No matches. Try a broader term." + ) + + +@tools.action( + description="Check the exact USD price of a PulseNetwork endpoint before buying. Free." +) +async def pulse_price(url: str) -> ActionResult: + price = await asyncio.to_thread(_quote_usd, url) + if price is None: + return ActionResult(extracted_content="No Base x402 quote at that URL.") + return ActionResult(extracted_content=f"{url} costs ${price:.3f} per call.") + + +@tools.action( + description=( + "Buy one PulseNetwork data call with USDC on Base and return its JSON. " + "Use pulse_catalog first to find the endpoint URL and its query params." + ) +) +async def pulse_buy(url: str) -> ActionResult: + host = httpx.URL(url).host or "" + if not host.endswith(ALLOWED_HOST_SUFFIX): + return ActionResult( + extracted_content="Refused: this tool only pays PulseNetwork endpoints." + ) + price = await asyncio.to_thread(_quote_usd, url) + if price is None: + return ActionResult( + extracted_content="No Base x402 quote at that URL; nothing was paid." + ) + if price > MAX_PER_CALL_USD: + return ActionResult( + extracted_content=f"Refused: ${price:.2f} exceeds the ${MAX_PER_CALL_USD:.2f} per-call cap." + ) + if _spent["usd"] + price > SESSION_BUDGET_USD: + return ActionResult( + extracted_content=f"Refused: the ${SESSION_BUDGET_USD:.2f} session budget would be exceeded." + ) + payer = x402Client() + payer.register( + "eip155:*", + ExactEvmScheme( + signer=EthAccountSigner(Account.from_key(os.environ["PULSE_WALLET_KEY"])) + ), + ) + async with x402HttpxClient(payer, timeout=90) as http: + r = await http.get(url) + if r.status_code != 200: + return ActionResult( + extracted_content=( + f"Endpoint answered {r.status_code}, nothing useful was bought. " + f"Check params via pulse_price. Body: {r.text[:300]}" + ) + ) + _spent["usd"] += price + return ActionResult(extracted_content=r.text[:6000]) + + +async def main(): + agent = Agent( + task=( + "Someone on a forum is hyping the token CLAWSTR at address " + "0xdca76ddec78cEd6449A70BD5Df5180ACa4a55386 on Robinhood Chain. " + "Before anyone buys it: use pulse_catalog to find the token safety " + "scanner, check its price with pulse_price, buy one scan for that " + "address with chain=robinhood using pulse_buy, and summarize the " + "verdict with its red and green flags." + ), + llm=ChatOpenAI(model="gpt-4.1-mini"), + tools=tools, + ) + await agent.run() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/templates.json b/templates.json index 0828e12..6a2d768 100644 --- a/templates.json +++ b/templates.json @@ -55,7 +55,9 @@ "next_steps": [ { "title": "Navigate to project directory", - "commands": ["cd {template}"] + "commands": [ + "cd {template}" + ] }, { "title": "Set up your API key", @@ -75,14 +77,18 @@ }, { "title": "Install dependencies", - "commands": ["uv sync"] + "commands": [ + "uv sync" + ] }, { "title": "Run the script", - "commands": ["uv run {output}"] + "commands": [ + "uv run {output}" + ] }, { - "footer": "πŸ“– See README.md for cloud configuration options and troubleshooting" + "footer": "\ud83d\udcd6 See README.md for cloud configuration options and troubleshooting" } ], "author": { @@ -124,7 +130,9 @@ "next_steps": [ { "title": "Navigate to project directory", - "commands": ["cd {template}"] + "commands": [ + "cd {template}" + ] }, { "title": "Set up your API key", @@ -136,19 +144,25 @@ }, { "title": "Install dependencies", - "commands": ["uv sync"] + "commands": [ + "uv sync" + ] }, { "title": "Launch Chrome with debugging (in a separate terminal)", - "commands": ["python launch_chrome_debug.py"], + "commands": [ + "python launch_chrome_debug.py" + ], "note": "(Run with --help to see options like --profile)\n(Keep this terminal open!)" }, { "title": "Run your script (in a NEW terminal)", - "commands": ["cd {template} && uv run {output}"] + "commands": [ + "cd {template} && uv run {output}" + ] }, { - "footer": "πŸ“– See README.md for detailed instructions" + "footer": "\ud83d\udcd6 See README.md for detailed instructions" } ], "author": { @@ -194,7 +208,9 @@ "next_steps": [ { "title": "Navigate to project directory", - "commands": ["cd {template}"] + "commands": [ + "cd {template}" + ] }, { "title": "Set up your API key", @@ -206,7 +222,9 @@ }, { "title": "Install dependencies", - "commands": ["uv sync"] + "commands": [ + "uv sync" + ] }, { "title": "Customize your data", @@ -217,10 +235,12 @@ }, { "title": "Run the application", - "commands": ["uv run {output} --resume example_resume.pdf"] + "commands": [ + "uv run {output} --resume example_resume.pdf" + ] }, { - "footer": "πŸ“– See README.md for customization and troubleshooting" + "footer": "\ud83d\udcd6 See README.md for customization and troubleshooting" } ], "author": { @@ -261,7 +281,9 @@ "next_steps": [ { "title": "Navigate to project directory", - "commands": ["cd {template}"] + "commands": [ + "cd {template}" + ] }, { "title": "Set up your API keys", @@ -273,18 +295,22 @@ }, { "title": "Install dependencies", - "commands": ["uv sync"] + "commands": [ + "uv sync" + ] }, { "title": "Run the script", - "commands": ["uv run {output}"] + "commands": [ + "uv run {output}" + ] }, { - "footer": "πŸ“– See README.md for customization and advanced usage" + "footer": "\ud83d\udcd6 See README.md for customization and advanced usage" } ], "author": { - "name": "Magnus MΓΌller", + "name": "Magnus M\u00fcller", "github_profile": "https://github.com/MagMueller", "last_modified_date": "2025-11-11" } @@ -317,7 +343,9 @@ "next_steps": [ { "title": "Navigate to project directory", - "commands": ["cd {template}"] + "commands": [ + "cd {template}" + ] }, { "title": "Set up your API keys", @@ -329,14 +357,18 @@ }, { "title": "Install dependencies", - "commands": ["uv sync"] + "commands": [ + "uv sync" + ] }, { "title": "Run the comparison", - "commands": ["uv run {output}"] + "commands": [ + "uv run {output}" + ] }, { - "footer": "πŸ“– See README.md for customization and advanced usage\n\n⚑ Enter a task and watch LLMs race to complete it!" + "footer": "\ud83d\udcd6 See README.md for customization and advanced usage\n\n\u26a1 Enter a task and watch LLMs race to complete it!" } ], "author": { @@ -377,7 +409,9 @@ "next_steps": [ { "title": "Navigate to project directory", - "commands": ["cd {template}"] + "commands": [ + "cd {template}" + ] }, { "title": "Set up your API keys", @@ -389,7 +423,9 @@ }, { "title": "Install dependencies", - "commands": ["uv sync"] + "commands": [ + "uv sync" + ] }, { "title": "Create and configure Slack app", @@ -400,15 +436,19 @@ }, { "title": "Start ngrok tunnel (for local development)", - "commands": ["ngrok http 8000"], + "commands": [ + "ngrok http 8000" + ], "note": "(Keep this terminal open and copy the HTTPS URL)" }, { "title": "Run the application (in a NEW terminal)", - "commands": ["cd {template} && uv run app/main.py"] + "commands": [ + "cd {template} && uv run app/main.py" + ] }, { - "footer": "πŸ“– See README.md for detailed setup, Event Subscriptions configuration, and production deployment" + "footer": "\ud83d\udcd6 See README.md for detailed setup, Event Subscriptions configuration, and production deployment" } ], "author": { @@ -446,7 +486,9 @@ "next_steps": [ { "title": "Navigate to project directory", - "commands": ["cd {template}"] + "commands": [ + "cd {template}" + ] }, { "title": "Set up your API key", @@ -458,14 +500,18 @@ }, { "title": "Install dependencies", - "commands": ["uv sync"] + "commands": [ + "uv sync" + ] }, { "title": "Run the job scraper", - "commands": ["uv run {output}"] + "commands": [ + "uv run {output}" + ] }, { - "footer": "πŸ“– See README.md for customization and troubleshooting\n\nπŸ€– CodeAgent will generate extraction code and save it to script.ipynb" + "footer": "\ud83d\udcd6 See README.md for customization and troubleshooting\n\n\ud83e\udd16 CodeAgent will generate extraction code and save it to script.ipynb" } ], "author": { @@ -511,7 +557,9 @@ "next_steps": [ { "title": "Navigate to project directory", - "commands": ["cd {template}"] + "commands": [ + "cd {template}" + ] }, { "title": "Set up your API key and cloud settings", @@ -523,14 +571,18 @@ }, { "title": "Install dependencies", - "commands": ["uv sync"] + "commands": [ + "uv sync" + ] }, { "title": "Run the scheduler", - "commands": ["uv run {output}"] + "commands": [ + "uv run {output}" + ] }, { - "footer": "πŸ“– See README.md for adding custom agents, configuration, and troubleshooting\n\nπŸ”„ The scheduler will auto-discover all .py files in the agents/ directory\n\nπŸ“¦ For an always-on VPS with Telegram control, see Browser Use Box: https://browser-use.com/bux" + "footer": "\ud83d\udcd6 See README.md for adding custom agents, configuration, and troubleshooting\n\n\ud83d\udd04 The scheduler will auto-discover all .py files in the agents/ directory\n\n\ud83d\udce6 For an always-on VPS with Telegram control, see Browser Use Box: https://browser-use.com/bux" } ], "author": { @@ -538,5 +590,14 @@ "github_profile": "https://github.com/ShawnPana", "last_modified_date": "2025-11-14" } + }, + "pulsenetwork-data": { + "file": "pulsenetwork_data_template.py", + "description": "Agent buys live data mid-task with x402 micropayments (token safety, markets, 950+ endpoints) - USDC on Base, no API keys, code-enforced budget caps", + "author": { + "name": "PulseNetwork", + "github_profile": "https://github.com/GTCC777", + "last_modified_date": "2026-08-09" + } } } From 099e06c90f19d78869d314046d63a2eae0c7debc Mon Sep 17 00:00:00 2001 From: GTCC777 <255618185+GTCC777@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:36:43 +0000 Subject: [PATCH 2/4] pulsenetwork-data: complex template, catalog API, payment bound to the signed challenge Addresses all three cubic findings. templates.json (P2): promoted from a bare single-file template to a complex one. A single .py copy left users with no pyproject.toml (so x402 was never installed) and no .env example for the wallet key, so the first 'uv run' failed on ImportError. Now ships main.py, pyproject.toml.template pinning x402[httpx,evm], .env.example.template, README.md and the shared gitignore, plus next_steps that walk through wallet setup, 'uv sync' and the run. pulse_catalog (P1): was grepping llms-full.txt line by line, so the task's own query ('token safety') matched no line at all, and any line that did match carried a relative route with the base URL somewhere else in the file. It now queries the free /api/catalog JSON endpoint and returns each match as a complete URL with its price and its documented parameters, so pulse_buy can be called directly from the result. Catalog-side ranking was fixed to match on word boundaries with query-side aliases, so 'token safety' now returns the token scanners. No silent fallback: if the catalog is unreachable the tool says so rather than answering from a stale grep. pulse_buy (P1): the preflight quote was advisory only, since x402HttpxClient went on to sign whatever challenge the second request returned. The quote is gone from the buy path. The per-call cap and the remaining session budget are now an x402 payment policy, so they are checked against the challenge that is actually signed, and a payload is never created when it does not fit. Spend is recorded from the signed amount via an after-payment-creation hook, and accumulated rather than overwritten so a retry can never undercount. A lock around the buy path stops two concurrent calls from both spending the last of the budget, and an invalid or missing wallet key now refuses cleanly instead of raising. Verified against the live fleet: caps refuse without signing, and a real $0.015 USDC settle on Base returns the token verdict. --- pulsenetwork-data/.env.example.template | 12 + pulsenetwork-data/README.md | 117 ++++++++ .../__pycache__/main.cpython-312.pyc | Bin 0 -> 15019 bytes pulsenetwork-data/main.py | 266 ++++++++++++++++++ pulsenetwork-data/pyproject.toml.template | 12 + pulsenetwork_data_template.py | 144 ---------- templates.json | 55 +++- 7 files changed, 461 insertions(+), 145 deletions(-) create mode 100644 pulsenetwork-data/.env.example.template create mode 100644 pulsenetwork-data/README.md create mode 100644 pulsenetwork-data/__pycache__/main.cpython-312.pyc create mode 100644 pulsenetwork-data/main.py create mode 100644 pulsenetwork-data/pyproject.toml.template delete mode 100644 pulsenetwork_data_template.py diff --git a/pulsenetwork-data/.env.example.template b/pulsenetwork-data/.env.example.template new file mode 100644 index 0000000..dd380a1 --- /dev/null +++ b/pulsenetwork-data/.env.example.template @@ -0,0 +1,12 @@ +# Wallet the agent pays from. Use a THROWAWAY key holding a few USDC on Base, +# never a wallet you keep funds in. This is the only credential you need: +# PulseNetwork has no accounts, no signup and no API keys. +PULSE_WALLET_KEY=0xyour-throwaway-private-key + +# Model for the agent (any browser-use supported provider works) +OPENAI_API_KEY=sk-your-key-here + +# Optional spending limits, enforced in code on every paid call. +# Defaults: 0.50 per call, 2.00 per session. +PULSE_MAX_PER_CALL_USD=0.50 +PULSE_SESSION_BUDGET_USD=2.00 diff --git a/pulsenetwork-data/README.md b/pulsenetwork-data/README.md new file mode 100644 index 0000000..3e8e317 --- /dev/null +++ b/pulsenetwork-data/README.md @@ -0,0 +1,117 @@ +# Agent Buys Live Data Mid-Task (x402) + +A browser-use agent that pays for the data it needs, per call, while it works. + +Most agents are stuck with whatever the model already knows plus whatever a page +happens to show. This template gives the agent a wallet and three tools, so when +a task needs a fact the model cannot know (is this token a honeypot, is this +flight owed compensation, has this product been recalled) the agent finds the +right endpoint, checks the price, pays a few cents in USDC on Base, and keeps +going. + +There is no signup, no API key and no account. The wallet is the identity. + +## The Tools + +| Tool | Cost | What it does | +| --- | --- | --- | +| `pulse_catalog` | free | Searches 950+ pay-per-call endpoints. Returns each match as a complete URL with its price and its query parameters. | +| `pulse_price` | free | Reads the exact price of one endpoint from its 402 challenge. Settles nothing. | +| `pulse_buy` | the endpoint price | Pays one call with USDC on Base and returns the JSON. | + +Typical prices run from $0.005 to $0.35 per call. The demo task costs about +$0.015. + +## Spending Controls + +The limits are in the code, not in the prompt, so the agent cannot be talked +past them by a web page or by its own reasoning: + +- **Host allowlist.** `pulse_buy` refuses any URL outside PulseNetwork. +- **Per-call cap and session budget.** Enforced as an x402 payment policy, which + inspects the 402 challenge that is actually being signed. A price that changes + between the quote and the payment cannot slip through, because the quote is + not what authorizes the spend. +- **A lock around the buy path**, so two concurrent tool calls cannot both spend + the last of the budget. +- **The key never reaches the model.** It is read from the environment inside the + tool. The agent passes a URL and gets JSON back. + +## Setup + +### 1. Navigate to the project directory + +```bash +cd pulsenetwork-data +``` + +### 2. Create a wallet and fund it + +Generate a throwaway key and send it a few dollars of USDC on Base. Do not use a +wallet that holds anything you care about. A dollar or two is enough for +hundreds of calls. + +### 3. Configure the environment + +```bash +cp .env.example .env +``` + +Edit `.env` and set `PULSE_WALLET_KEY` and your model key. You can also lower +`PULSE_MAX_PER_CALL_USD` and `PULSE_SESSION_BUDGET_USD` from their defaults of +$0.50 and $2.00. + +### 4. Install dependencies + +```bash +uv sync +``` + +This installs `browser-use`, the official `x402` Python SDK with its httpx and +EVM extras, and `eth-account`. + +### 5. Run it + +```bash +uv run main.py +``` + +The demo task scans a real token on Robinhood Chain for rug and honeypot risk +before anyone buys it, and reports the verdict with its red and green flags. + +## Using It In Your Own Agent + +Import the `tools` object and hand it to any `Agent`: + +```python +from main import tools + +agent = Agent(task="...", llm=ChatOpenAI(model="gpt-4.1-mini"), tools=tools) +``` + +The tools compose with browser-use's own actions, so an agent can read a page, +buy a fact that the page does not contain, and act on both. + +## Troubleshooting + +| What you see | What it means | +| --- | --- | +| `Refused: PULSE_WALLET_KEY is not set` | No `.env`, or the key line is still the placeholder. | +| `Refused, nothing was paid: the endpoint asked for $X` | The price is above your remaining allowance. Raise the cap or pick a cheaper endpoint. | +| `Refused: the session budget is spent` | Restart the process, or raise `PULSE_SESSION_BUDGET_USD`. | +| `The payment did not complete` | Usually an unfunded wallet. Check the USDC balance on Base. | +| `Endpoint answered 400` | A missing or malformed parameter. `pulse_catalog` lists what each endpoint takes. | + +## Links + +- Catalog and docs: https://pulse.theaslangroupllc.com/llms.txt +- Machine-readable search: https://pulse.theaslangroupllc.com/api/catalog?q=token+safety +- x402 protocol: https://x402.org + +## Disclosure + +PulseNetwork is operated by The Aslan Group LLC, who wrote this template. The +tools only pay PulseNetwork endpoints, which is a deliberate safety limit rather +than a claim that nothing else is worth buying. The pattern itself is generic: +any x402 seller can be reached the same way by changing the host allowlist and +the catalog URL. diff --git a/pulsenetwork-data/__pycache__/main.cpython-312.pyc b/pulsenetwork-data/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ea67c9579085528e105043326ea025896f3cb85f GIT binary patch literal 15019 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 literal 0 HcmV?d00001 diff --git a/pulsenetwork-data/main.py b/pulsenetwork-data/main.py new file mode 100644 index 0000000..7f68f1d --- /dev/null +++ b/pulsenetwork-data/main.py @@ -0,0 +1,266 @@ +"""PulseNetwork data tools: your agent buys live data mid-task with x402. + +Adds three tools to any browser-use agent: + - pulse_catalog: free search across 950+ pay-per-call data endpoints + - pulse_price: free price check for one endpoint (a bare 402 quote) + - pulse_buy: pay one endpoint with USDC on Base and return its JSON + +Payments use the official x402 Python SDK. No API keys, no accounts: the wallet +is the identity. The private key stays in an env var; the LLM never sees it. + +Three controls live in code, not in the prompt, so the agent cannot talk its way +past them: + - host allowlist: pulse_buy refuses any URL outside PulseNetwork + - per-call cap and session budget: enforced as an x402 payment policy, so the + cap is checked against the 402 challenge that is actually signed + - a lock around the buy path, so two concurrent calls cannot both spend the + last of the budget + +Docs: https://pulse.theaslangroupllc.com/llms.txt +""" + +import asyncio +import os + +import httpx +from browser_use import ActionResult, Agent, ChatOpenAI, Tools +from dotenv import load_dotenv +from eth_account import Account +from x402.client import x402Client +from x402.http.clients.httpx import x402HttpxClient +from x402.mechanisms.evm.exact import ExactEvmScheme +from x402.mechanisms.evm.signers import EthAccountSigner + +load_dotenv() + +CATALOG_URL = "https://pulse.theaslangroupllc.com/api/catalog" +ALLOWED_HOST_SUFFIX = ".theaslangroupllc.com" +BASE_NETWORK = "eip155:8453" # USDC on Base, 6 decimals +USDC_UNITS = 10**6 + +MAX_PER_CALL_USD = float(os.getenv("PULSE_MAX_PER_CALL_USD", "0.50")) +SESSION_BUDGET_USD = float(os.getenv("PULSE_SESSION_BUDGET_USD", "2.00")) + +tools = Tools() +_spent = {"usd": 0.0} +_buy_lock = asyncio.Lock() + + +class _PaymentGuard: + """Binds the budget caps to the challenge the SDK actually signs. + + Quoting the price and then paying are two separate HTTP requests, so a quote + taken beforehand proves nothing about what the second request will be asked + to sign. This runs as an x402 payment policy instead: it sees the real 402 + challenge inside the paid request and drops anything that is not USDC on + Base within budget. If nothing survives, the SDK raises and no payload is + ever created, so nothing is signed and nothing settles. + """ + + def __init__(self, allowance_usd: float) -> None: + self.allowance_usd = allowance_usd + self._allowance_atomic = int(round(allowance_usd * USDC_UNITS)) + self.refusal: str | None = None + self.signed_usd = 0.0 + + def policy(self, _version: int, requirements: list) -> list: + keep = [] + for req in requirements: + if getattr(req, "network", None) != BASE_NETWORK: + continue + amount = int(req.get_amount()) + if amount > self._allowance_atomic: + self.refusal = ( + f"the endpoint asked for ${amount / USDC_UNITS:.3f}, more than the " + f"${self.allowance_usd:.3f} left under the per-call cap and session budget" + ) + continue + keep.append(req) + if not keep and self.refusal is None: + self.refusal = "the endpoint offered no USDC-on-Base payment option" + return keep + + def record(self, ctx) -> None: + # Fires once per created payload, before it is sent. Accumulates rather + # than overwrites: if the SDK retries with a fresh payload, count both. + # Over-counting only shrinks the budget; under-counting would overspend. + self.signed_usd += int(ctx.selected_requirements.get_amount()) / USDC_UNITS + + +MAX_PARAMS_SHOWN = 6 + + +def _describe(entry: dict) -> str: + """One endpoint as the agent needs it: full URL, price, and its parameters.""" + price = entry.get("price_usd") + tag = "FREE" if not price else f"${price:.3f}" + lines = [ + f"{tag} {entry.get('method', 'GET')} {entry['url']}", + f" {entry.get('description', '')}", + ] + params = list((entry.get("params") or {}).items()) + # Required parameters first: a call fails without them, and some endpoints + # carry a dozen optional ones that would swamp the model's context. + params.sort(key=lambda kv: not kv[1].get("required")) + for name, spec in params[:MAX_PARAMS_SHOWN]: + need = "required" if spec.get("required") else "optional" + example = spec.get("example") + hint = f", e.g. {example}" if example is not None else "" + lines.append(f" - {name} ({need}): {spec.get('description', '')}{hint}") + if len(params) > MAX_PARAMS_SHOWN: + lines.append(f" - plus {len(params) - MAX_PARAMS_SHOWN} more optional parameters") + return "\n".join(lines) + + +async def _quote_usd(url: str) -> float | None: + """Ask an endpoint what it costs. A bare 402 quote is free and settles nothing.""" + try: + async with httpx.AsyncClient(timeout=30) as client: + r = await client.get(url) + if r.status_code != 402: + return None + for accept in r.json().get("accepts", []): + if accept.get("network") == BASE_NETWORK: + return int(accept["amount"]) / USDC_UNITS + except Exception: + return None + return None + + +@tools.action( + description=( + "Search the PulseNetwork catalog of 950+ pay-per-call data endpoints: " + "crypto token safety, market scans, travel rights, sports, climate, " + "compliance and more. Free. Returns each match as a complete URL with " + "its price and its query parameters, ready to pass to pulse_buy." + ) +) +async def pulse_catalog(query: str) -> ActionResult: + try: + async with httpx.AsyncClient(timeout=30) as client: + r = await client.get(CATALOG_URL, params={"q": query, "limit": 6}) + r.raise_for_status() + results = r.json().get("results") or [] + except Exception as exc: + return ActionResult( + extracted_content=f"The PulseNetwork catalog is unreachable right now ({exc}); nothing was searched." + ) + if not results: + return ActionResult( + extracted_content=( + f'No PulseNetwork endpoint matches "{query}". Try one broad noun instead, ' + 'such as "token", "flight", "recall", "sanctions" or "weather".' + ) + ) + body = "\n".join(_describe(e) for e in results) + return ActionResult( + extracted_content=( + f"PulseNetwork endpoints matching '{query}' (append the parameters as a query string, " + f"then call pulse_buy with the full URL):\n{body}" + ) + ) + + +@tools.action( + description="Check the exact USD price of a PulseNetwork endpoint before buying. Free." +) +async def pulse_price(url: str) -> ActionResult: + price = await _quote_usd(url) + if price is None: + return ActionResult(extracted_content="No USDC-on-Base x402 quote at that URL.") + return ActionResult( + extracted_content=( + f"{url} costs ${price:.3f} per call. " + f"${SESSION_BUDGET_USD - _spent['usd']:.2f} of the session budget is left." + ) + ) + + +@tools.action( + description=( + "Buy one PulseNetwork data call with USDC on Base and return its JSON. " + "Use pulse_catalog first to get the full endpoint URL and its parameters." + ) +) +async def pulse_buy(url: str) -> ActionResult: + try: + host = httpx.URL(url).host or "" + except Exception: + return ActionResult(extracted_content=f"Refused: {url!r} is not a usable URL.") + if not host.endswith(ALLOWED_HOST_SUFFIX): + return ActionResult( + extracted_content="Refused: this tool only pays PulseNetwork endpoints." + ) + + key = os.getenv("PULSE_WALLET_KEY") + if not key: + return ActionResult( + extracted_content=( + "Refused: PULSE_WALLET_KEY is not set, so no payment is possible. " + "Copy .env.example to .env and add a funded throwaway wallet key." + ) + ) + try: + signer = EthAccountSigner(Account.from_key(key)) + except Exception: + return ActionResult( + extracted_content=( + "Refused: PULSE_WALLET_KEY is not a valid private key " + "(expected 0x followed by 64 hex characters)." + ) + ) + + async with _buy_lock: + allowance = min(MAX_PER_CALL_USD, SESSION_BUDGET_USD - _spent["usd"]) + if allowance <= 0: + return ActionResult( + extracted_content=f"Refused: the ${SESSION_BUDGET_USD:.2f} session budget is spent." + ) + + guard = _PaymentGuard(allowance) + payer = x402Client() + payer.register(BASE_NETWORK, ExactEvmScheme(signer=signer)) + payer.register_policy(guard.policy) + payer.on_after_payment_creation(guard.record) + + try: + async with x402HttpxClient(payer, timeout=90) as http: + r = await http.get(url) + except Exception as exc: + _spent["usd"] += guard.signed_usd + if guard.refusal and not guard.signed_usd: + return ActionResult( + extracted_content=f"Refused, nothing was paid: {guard.refusal}." + ) + return ActionResult(extracted_content=f"The payment did not complete: {exc}") + + _spent["usd"] += guard.signed_usd + + if r.status_code != 200: + return ActionResult( + extracted_content=( + f"Endpoint answered {r.status_code}, nothing useful was bought. " + f"Check the parameters against pulse_catalog. Body: {r.text[:300]}" + ) + ) + return ActionResult(extracted_content=r.text[:6000]) + + +async def main(): + agent = Agent( + task=( + "Someone on a forum is hyping the token CLAWSTR at address " + "0xdca76ddec78cEd6449A70BD5Df5180ACa4a55386 on Robinhood Chain. " + "Before anyone buys it: use pulse_catalog to find the PulseNetwork " + "endpoint that scans an EVM token for safety, then call pulse_buy with " + "that endpoint plus address and chain=robinhood, and summarize the " + "verdict with its red and green flags." + ), + llm=ChatOpenAI(model="gpt-4.1-mini"), + tools=tools, + ) + await agent.run() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pulsenetwork-data/pyproject.toml.template b/pulsenetwork-data/pyproject.toml.template new file mode 100644 index 0000000..1464349 --- /dev/null +++ b/pulsenetwork-data/pyproject.toml.template @@ -0,0 +1,12 @@ +[project] +name = "browser-use-pulsenetwork-data" +version = "0.1.0" +description = "Agent buys live data mid-task with x402 micropayments" +requires-python = ">=3.11" +dependencies = [ + "browser-use", + "x402[httpx,evm]>=2.15", + "httpx", + "eth-account", + "python-dotenv", +] diff --git a/pulsenetwork_data_template.py b/pulsenetwork_data_template.py deleted file mode 100644 index b80dd55..0000000 --- a/pulsenetwork_data_template.py +++ /dev/null @@ -1,144 +0,0 @@ -"""PulseNetwork data tools: your agent buys live data mid-task with x402. - -Adds three tools to any browser-use agent: - - pulse_catalog: free search across 950+ pay-per-call data endpoints - - pulse_price: free price check for any PulseNetwork endpoint (a bare 402 quote) - - pulse_buy: pay one endpoint with USDC on Base and return its JSON - -Payments use the official x402 Python SDK. No API keys, no accounts: the wallet -is the identity. The private key stays in an env var; the LLM never sees it. -Per-call and per-session budget caps are enforced in code, and the buy tool -refuses any host outside PulseNetwork, so the agent cannot be talked into -paying somewhere else. - -Setup: - pip install browser-use "x402[httpx,evm]" httpx - export PULSE_WALLET_KEY=0x... # a throwaway wallet holding a few USDC on Base - export OPENAI_API_KEY=sk-... # or use any browser-use supported model - -Catalog and docs: https://pulse.theaslangroupllc.com/llms.txt -""" - -import asyncio -import os - -import httpx -from browser_use import ActionResult, Agent, ChatOpenAI, Tools -from eth_account import Account -from x402.client import x402Client -from x402.http.clients.httpx import x402HttpxClient -from x402.mechanisms.evm.exact import ExactEvmScheme -from x402.mechanisms.evm.signers import EthAccountSigner - -CATALOG_URL = "https://pulse.theaslangroupllc.com/llms-full.txt" -ALLOWED_HOST_SUFFIX = ".theaslangroupllc.com" -MAX_PER_CALL_USD = float(os.getenv("PULSE_MAX_PER_CALL_USD", "0.50")) -SESSION_BUDGET_USD = float(os.getenv("PULSE_SESSION_BUDGET_USD", "2.00")) - -tools = Tools() -_spent = {"usd": 0.0} -_catalog_cache = {"text": None} - - -def _quote_usd(url: str) -> float | None: - """Ask the endpoint what it costs. A bare 402 quote is free and settles nothing.""" - r = httpx.get(url, timeout=30) - if r.status_code != 402: - return None - for accept in r.json().get("accepts", []): - if accept.get("network") == "eip155:8453": - return int(accept["amount"]) / 1e6 - return None - - -@tools.action( - description=( - "Search the PulseNetwork catalog of 950+ pay-per-call data endpoints: " - "crypto token safety, market scans, travel rights, sports, climate, " - "compliance and more. Free. Returns matching lines with URLs and prices." - ) -) -async def pulse_catalog(query: str) -> ActionResult: - if _catalog_cache["text"] is None: - async with httpx.AsyncClient(timeout=30) as c: - _catalog_cache["text"] = (await c.get(CATALOG_URL)).text - q = query.lower() - hits = [ln for ln in _catalog_cache["text"].splitlines() if q in ln.lower()][:20] - return ActionResult( - extracted_content="\n".join(hits) or "No matches. Try a broader term." - ) - - -@tools.action( - description="Check the exact USD price of a PulseNetwork endpoint before buying. Free." -) -async def pulse_price(url: str) -> ActionResult: - price = await asyncio.to_thread(_quote_usd, url) - if price is None: - return ActionResult(extracted_content="No Base x402 quote at that URL.") - return ActionResult(extracted_content=f"{url} costs ${price:.3f} per call.") - - -@tools.action( - description=( - "Buy one PulseNetwork data call with USDC on Base and return its JSON. " - "Use pulse_catalog first to find the endpoint URL and its query params." - ) -) -async def pulse_buy(url: str) -> ActionResult: - host = httpx.URL(url).host or "" - if not host.endswith(ALLOWED_HOST_SUFFIX): - return ActionResult( - extracted_content="Refused: this tool only pays PulseNetwork endpoints." - ) - price = await asyncio.to_thread(_quote_usd, url) - if price is None: - return ActionResult( - extracted_content="No Base x402 quote at that URL; nothing was paid." - ) - if price > MAX_PER_CALL_USD: - return ActionResult( - extracted_content=f"Refused: ${price:.2f} exceeds the ${MAX_PER_CALL_USD:.2f} per-call cap." - ) - if _spent["usd"] + price > SESSION_BUDGET_USD: - return ActionResult( - extracted_content=f"Refused: the ${SESSION_BUDGET_USD:.2f} session budget would be exceeded." - ) - payer = x402Client() - payer.register( - "eip155:*", - ExactEvmScheme( - signer=EthAccountSigner(Account.from_key(os.environ["PULSE_WALLET_KEY"])) - ), - ) - async with x402HttpxClient(payer, timeout=90) as http: - r = await http.get(url) - if r.status_code != 200: - return ActionResult( - extracted_content=( - f"Endpoint answered {r.status_code}, nothing useful was bought. " - f"Check params via pulse_price. Body: {r.text[:300]}" - ) - ) - _spent["usd"] += price - return ActionResult(extracted_content=r.text[:6000]) - - -async def main(): - agent = Agent( - task=( - "Someone on a forum is hyping the token CLAWSTR at address " - "0xdca76ddec78cEd6449A70BD5Df5180ACa4a55386 on Robinhood Chain. " - "Before anyone buys it: use pulse_catalog to find the token safety " - "scanner, check its price with pulse_price, buy one scan for that " - "address with chain=robinhood using pulse_buy, and summarize the " - "verdict with its red and green flags." - ), - llm=ChatOpenAI(model="gpt-4.1-mini"), - tools=tools, - ) - await agent.run() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/templates.json b/templates.json index 6a2d768..9b19b4d 100644 --- a/templates.json +++ b/templates.json @@ -592,8 +592,61 @@ } }, "pulsenetwork-data": { - "file": "pulsenetwork_data_template.py", + "file": "pulsenetwork-data/main.py", "description": "Agent buys live data mid-task with x402 micropayments (token safety, markets, 950+ endpoints) - USDC on Base, no API keys, code-enforced budget caps", + "files": [ + { + "source": "pulsenetwork-data/main.py", + "dest": "main.py" + }, + { + "source": "pulsenetwork-data/pyproject.toml.template", + "dest": "pyproject.toml" + }, + { + "source": "gitignore.template", + "dest": ".gitignore" + }, + { + "source": "pulsenetwork-data/.env.example.template", + "dest": ".env.example" + }, + { + "source": "pulsenetwork-data/README.md", + "dest": "README.md" + } + ], + "next_steps": [ + { + "title": "Navigate to project directory", + "commands": [ + "cd {template}" + ] + }, + { + "title": "Set up your payment wallet and model key", + "commands": [ + "cp .env.example .env", + "# Edit .env: add PULSE_WALLET_KEY and OPENAI_API_KEY" + ], + "note": "(PULSE_WALLET_KEY is a THROWAWAY key holding a few USDC on Base. PulseNetwork has no signup and no API key.)" + }, + { + "title": "Install dependencies", + "commands": [ + "uv sync" + ] + }, + { + "title": "Run the agent", + "commands": [ + "uv run {output}" + ] + }, + { + "footer": "\ud83d\udcb8 Spending is capped in code: $0.50 per call and $2.00 per session by default, tunable in .env\n\n\ud83d\udd0e Browse the catalog free at https://pulse.theaslangroupllc.com/llms.txt\n\n\ud83d\udcd6 See README.md for the safety controls and troubleshooting" + } + ], "author": { "name": "PulseNetwork", "github_profile": "https://github.com/GTCC777", From 1bee395cc12f47d4ba3f1eee51e93d110e1b2190 Mon Sep 17 00:00:00 2001 From: GTCC777 <255618185+GTCC777@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:54:51 +0000 Subject: [PATCH 3/4] pulsenetwork-data: pin the asset, extend the allowlist to the free tools, honour the placeholder Second review round, all three valid. Asset pinning (P1): the payment policy checked the network but not the asset, so a Base challenge denominated in some other token would have passed a cap counted in USDC's six decimals. The same atomic amount in an 8 or 18 decimal token is a completely different amount of money. The policy now requires Base USDC by address and refuses anything else. Allowlist on the free tools (P2): pulse_price fetched whatever URL the model handed it. The agent reads live web pages, so a page could have steered it into probing a link-local or internal address, and a free tool with no allowlist would have made that request. Both the host check and an https requirement now live in one helper that every outbound path runs, not just the paying one. pulse_catalog was never affected: it only ever sends the query as a parameter to a fixed catalog URL. Placeholder key (P3): the shipped .env placeholder is non-empty, so an unedited copy reached Account.from_key and reported a malformed key rather than the documented 'not set'. It is treated as unconfigured now, and the README troubleshooting table covers both cases plus the allowlist refusal. Verified: the metadata address, loopback, plain http and an off-domain host are all refused by both free and paying tools; a synthetic non-USDC Base challenge is dropped while real Base USDC is kept; and a live $0.015 settle still returns the token verdict. --- pulsenetwork-data/README.md | 13 +++- .../__pycache__/main.cpython-312.pyc | Bin 15019 -> 16500 bytes pulsenetwork-data/main.py | 65 ++++++++++++++---- 3 files changed, 61 insertions(+), 17 deletions(-) diff --git a/pulsenetwork-data/README.md b/pulsenetwork-data/README.md index 3e8e317..f591fad 100644 --- a/pulsenetwork-data/README.md +++ b/pulsenetwork-data/README.md @@ -27,11 +27,18 @@ $0.015. The limits are in the code, not in the prompt, so the agent cannot be talked past them by a web page or by its own reasoning: -- **Host allowlist.** `pulse_buy` refuses any URL outside PulseNetwork. +- **Host allowlist.** Every tool that makes an outbound request refuses any URL + that is not an https PulseNetwork endpoint, the free ones included. The agent + reads live web pages while it works, so a page must not be able to talk it + into fetching an internal address on the agent's behalf. - **Per-call cap and session budget.** Enforced as an x402 payment policy, which inspects the 402 challenge that is actually being signed. A price that changes between the quote and the payment cannot slip through, because the quote is not what authorizes the spend. +- **USDC on Base only.** The caps are counted in USDC's six decimals, so the + policy pins the asset as well as the network. The same number of atomic units + in a token with different decimals would be a different amount of money, and + an unpinned cap would not notice. - **A lock around the buy path**, so two concurrent tool calls cannot both spend the last of the budget. - **The key never reaches the model.** It is read from the environment inside the @@ -96,7 +103,9 @@ buy a fact that the page does not contain, and act on both. | What you see | What it means | | --- | --- | -| `Refused: PULSE_WALLET_KEY is not set` | No `.env`, or the key line is still the placeholder. | +| `Refused: PULSE_WALLET_KEY is not set` | No `.env`, or the key line is still the shipped placeholder. | +| `Refused: PULSE_WALLET_KEY is not a valid private key` | The key is set to something that is not a 0x private key. | +| `Refused: these tools only reach PulseNetwork endpoints` | The agent tried a URL outside PulseNetwork. Working as intended. | | `Refused, nothing was paid: the endpoint asked for $X` | The price is above your remaining allowance. Raise the cap or pick a cheaper endpoint. | | `Refused: the session budget is spent` | Restart the process, or raise `PULSE_SESSION_BUDGET_USD`. | | `The payment did not complete` | Usually an unfunded wallet. Check the USDC balance on Base. | diff --git a/pulsenetwork-data/__pycache__/main.cpython-312.pyc b/pulsenetwork-data/__pycache__/main.cpython-312.pyc index ea67c9579085528e105043326ea025896f3cb85f..64a54b86b5f9242c82e53862a212ae132b791732 100644 GIT binary patch delta 4884 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 W*xk#s0*4#0yJ^Z!?XKt1`2Pjnd9s86 delta 3635 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 list: for req in requirements: if getattr(req, "network", None) != BASE_NETWORK: continue + if str(getattr(req, "asset", "")).lower() != BASE_USDC: + self.refusal = ( + "the endpoint asked to be paid in a token other than USDC on Base, " + "which the budget caps cannot price" + ) + continue amount = int(req.get_amount()) if amount > self._allowance_atomic: self.refusal = ( @@ -112,6 +127,25 @@ def _describe(entry: dict) -> str: return "\n".join(lines) +def _reject_url(url: str) -> str | None: + """Refusal message if this URL is not a PulseNetwork endpoint, else None. + + Every tool that makes an outbound request runs this, not just the paying one. + The agent reads live web pages while it works, so a page can try to talk it + into fetching an internal address; a free tool with no allowlist would still + make that request on the agent's behalf. + """ + try: + parsed = httpx.URL(url) + except Exception: + return f"Refused: {url!r} is not a usable URL." + if parsed.scheme != "https": + return "Refused: only https PulseNetwork URLs are allowed." + if not (parsed.host or "").endswith(ALLOWED_HOST_SUFFIX): + return "Refused: these tools only reach PulseNetwork endpoints." + return None + + async def _quote_usd(url: str) -> float | None: """Ask an endpoint what it costs. A bare 402 quote is free and settles nothing.""" try: @@ -165,6 +199,9 @@ async def pulse_catalog(query: str) -> ActionResult: description="Check the exact USD price of a PulseNetwork endpoint before buying. Free." ) async def pulse_price(url: str) -> ActionResult: + refusal = _reject_url(url) + if refusal: + return ActionResult(extracted_content=refusal) price = await _quote_usd(url) if price is None: return ActionResult(extracted_content="No USDC-on-Base x402 quote at that URL.") @@ -183,21 +220,19 @@ async def pulse_price(url: str) -> ActionResult: ) ) async def pulse_buy(url: str) -> ActionResult: - try: - host = httpx.URL(url).host or "" - except Exception: - return ActionResult(extracted_content=f"Refused: {url!r} is not a usable URL.") - if not host.endswith(ALLOWED_HOST_SUFFIX): - return ActionResult( - extracted_content="Refused: this tool only pays PulseNetwork endpoints." - ) - - key = os.getenv("PULSE_WALLET_KEY") - if not key: + refusal = _reject_url(url) + if refusal: + return ActionResult(extracted_content=refusal) + + key = (os.getenv("PULSE_WALLET_KEY") or "").strip() + # The shipped .env.example carries a placeholder, so an unedited copy has to + # read as "not configured" rather than as a broken key. + if not key or key.lower().startswith(PLACEHOLDER_KEY_PREFIX): return ActionResult( extracted_content=( "Refused: PULSE_WALLET_KEY is not set, so no payment is possible. " - "Copy .env.example to .env and add a funded throwaway wallet key." + "Copy .env.example to .env and replace the placeholder with a funded " + "throwaway wallet key." ) ) try: From 010b412e68b54906af49137ac2f19873913af267 Mon Sep 17 00:00:00 2001 From: GTCC777 <255618185+GTCC777@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:48:06 +0000 Subject: [PATCH 4/4] pulse_price: quote only what pulse_buy would actually pay Valid, and it is a hole I opened myself last round. Pinning the asset in the payment policy left the quote path matching on network alone, so a Base challenge denominated in some other token would have had its amount divided by USDC's decimals and printed as a confident dollar figure, for a challenge the buy path then refuses. Wrong number, and an inconsistency between two tools that are supposed to describe the same thing. _quote_usd now requires the same Base USDC asset the policy requires, so an unsupported challenge returns no quote rather than a meaningless one. It also keeps scanning the accepts list instead of taking the first Base entry, so a challenge offering a non-USDC option ahead of a USDC one is priced from the option that can really be paid. Verified against crafted 402 responses covering Base USDC, a non-USDC Base asset, USDC on another network and a mixed list, plus a live quote against a real endpoint that still returns $0.015. --- .../__pycache__/main.cpython-312.pyc | Bin 16500 -> 16965 bytes pulsenetwork-data/main.py | 15 ++++++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/pulsenetwork-data/__pycache__/main.cpython-312.pyc b/pulsenetwork-data/__pycache__/main.cpython-312.pyc index 64a54b86b5f9242c82e53862a212ae132b791732..c13a80a91fde55347a9a817c5a5449f3a2c3a5b7 100644 GIT binary patch delta 917 zcmXw#O=uHA6vt<>X=1**!6bgQHlt}8W22_kwopZ-2_Ce9T0Dp#Op{5n?PfRZZcMjS zD$;|6f-Q?QCtE!gY7vQ`2M?{OdQi|d;0Ji|R8JB=4nliyCZP{z=J#ga|Nq|Z-`nu@ z1Z;fga@hfm{*95Ljp)-xeZQ&2WK8bgvfK*wu8|UC#g*u4^x9p6#14E@|6KlWm3O-F_K$0FW?2FaXTJ26V0m zOs&$jMAw|L*%EL(8R4#h0t^6cml5{+Ep~j1wlyyJ`^W8eMB^gX5*eZ*EklrsvjkyP zC0Z@bVLeME4drA}Na&~_h$6y?1j%U;B*_{Y%B%H4rmLArL@UV3@KJPy;G`g>5tb0n z%6V#%B$AvJB&?An5+tO`o1Qn%aHn=7ug{1OyFf8c>tRJn3jah%t-9~@D5@K5?-NH`el|Sle zXL*EeM~t8TOU~aEe}I3tLDlUv&ZFx$8f@Kn&wb&2_eLj2i{T32`q;V7pZMb5^Nrs* z-96ho(_2i>D;3Y-3g7;j?^xqID*W+h`Z|A>)p&MI-z;X25lpgtFVeUDM z1uZYklwLaM-HH{)Lgtkn9>z^fw9?$iipzeMlo4cnu*QKJ2O-s#Lp2UtBC(U!@-Z0e zbe5x5#ywUhIBkpDxmBx)TCCb2V>=gjl@D|;rG&w k?t%pdOP@|Hn|Ax4yBMefddvqGS|3LKFsw$6r895;1FWe8<^TWy delta 495 zcmX@w!uX|uk?%AwFBbz495_>v<)*)puTqw2598!c+28Dj3=Fl*HO!L}jGoS~-%i1Q}L$}4l%RWL^~lrt(b zln6}jmX~MdsPdV-LEdMwuA(ZN5m28d=j0?sgUJgOeRxg-xi1)gG%$Q%;hg+aF}j|K z)2hSm2ES;3RcF-<&CC2MYj`g6o7@qXnqjs;Iw)fe zWFL|c1Ce?lkwf~7AhscxZ3JeUFaixYWCmtiusL(FAF^X~X5=|!&kkfCW@87cKg`SN zEXjOWj0tGMVF^YK5J!^PS!Q#M@*ft)y_+jF)EOCsyqw lG0JRSZg+r5oQF|hhVlmnAhkesg~~#m&miUpxyf~o#{maCg~$K^ diff --git a/pulsenetwork-data/main.py b/pulsenetwork-data/main.py index 3d709c9..c6eee02 100644 --- a/pulsenetwork-data/main.py +++ b/pulsenetwork-data/main.py @@ -147,15 +147,24 @@ def _reject_url(url: str) -> str | None: async def _quote_usd(url: str) -> float | None: - """Ask an endpoint what it costs. A bare 402 quote is free and settles nothing.""" + """Ask an endpoint what it costs. A bare 402 quote is free and settles nothing. + + Matches the same asset the payment policy will accept, not just the same + network. Reading an amount denominated in some other Base token and dividing + it by USDC's decimals would print a confident and meaningless dollar figure, + for a challenge the buy path is going to refuse anyway. + """ try: async with httpx.AsyncClient(timeout=30) as client: r = await client.get(url) if r.status_code != 402: return None for accept in r.json().get("accepts", []): - if accept.get("network") == BASE_NETWORK: - return int(accept["amount"]) / USDC_UNITS + if accept.get("network") != BASE_NETWORK: + continue + if str(accept.get("asset", "")).lower() != BASE_USDC: + continue + return int(accept["amount"]) / USDC_UNITS except Exception: return None return None