diff --git a/CHANGELOG.md b/CHANGELOG.md index 6776905..6d9e2c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ All notable changes to blockrun-llm will be documented in this file. +## 1.17.0 — 2026-09-16 + +### Added +- **Arc.** `LLMClient(api_url="https://arc.blockrun.ai/api")` pays on Circle's + Arc. The chain table knew Base and Base Sepolia and fell back to Base for + anything else, while `asset` and `extra` were taken from the 402 as given — + so against arc.blockrun.ai (`eip155:5042`, USDC at `0x3600…`, domain name + `USDC`) every payment was a signature over chainId 8453 with Arc's contract: + invalid, a 401 from the facilitator, after the SDK had reported a payment. + + `EVM_NETWORKS` in `blockrun_llm.x402` maps a 402's `network` to the SDK's + own chain id, USDC address and EIP-712 domain (Base, Arc, Base Sepolia; the + `base-sepolia` alias still resolves). The 402 selects the network and + supplies nothing else: its `extra` no longer reaches the domain, an unknown + network raises `ValueError` naming what is supported, and a 402 whose + `asset` is not that network's USDC raises before anything is signed. Every + EVM client passes the 402's `asset` through. `accepted.asset` and + `accepted.extra` in the payload now describe the network actually signed. + Mirrors `@blockrun/llm` 3.16.0. + + Verified against arc.blockrun.ai with an unfunded throwaway key: Circle's + `/verify` answers `insufficient_funds` and recovers the throwaway's own + address as `payer` — the signature verifies on Arc's domain; only the + balance is missing. + ## 1.16.0 — 2026-09-08 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 47089c3..45f43e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ mypy blockrun_llm/ # type check ``` blockrun_llm/ ├── __init__.py # Package exports -├── client.py # LLMClient (Base chain) +├── client.py # LLMClient (EVM: Base, Arc — the 402's network picks the chain) ├── solana_client.py # SolanaLLMClient ├── wallet.py # EVM wallet management ├── solana_wallet.py # Solana wallet management @@ -51,9 +51,12 @@ blockrun_llm/ ## Supported chains - Base Mainnet (primary) — USDC +- Arc (Circle, chain 5042) — USDC, via `api_url="https://arc.blockrun.ai/api"`; same `LLMClient` and key - Base Sepolia (testnet) — Testnet USDC - Solana Mainnet — USDC SPL +The EVM domain signed follows the 402's `network` through `EVM_NETWORKS` in `blockrun_llm/x402.py`; a 402's `extra` is never trusted for it, an unknown network and a non-USDC `asset` are refused. + ## Conventions - Python >= 3.9 diff --git a/README.md b/README.md index 27b5aab..428634c 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ | **API key** | none — `api.blockrun.ai` | prepaid credit, topped up with a card | ✅ | | **Solana** | Solana Mainnet | USDC (SPL), gasless — the facilitator pays the fee | ✅ Recommended for x402 | | **Base** | Base Mainnet (Chain ID: 8453) | USDC | ✅ | +| **Arc** | Circle Arc (Chain ID: 5042) — `arc.blockrun.ai` | USDC (Arc's native token), settled by Circle — no gas per call | ✅ | | **Base Testnet** | Base Sepolia (Chain ID: 84532) | Testnet USDC | ✅ Development | **Protocol:** x402 v2 on the wallet rails; plain bearer auth on the API-key rail. @@ -151,6 +152,24 @@ export SOLANA_WALLET_KEY="your-bs58-solana-key" > what to switch to instead of failing with a cryptic "must be 66 characters" > error. +## Arc Support + +The same `LLMClient` pays on [Circle's Arc](https://www.arc.network) via [arc.blockrun.ai](https://arc.blockrun.ai) — point `api_url` at it and hold USDC on Arc in the same EVM wallet: + +```python +from blockrun_llm import LLMClient + +client = LLMClient(api_url="https://arc.blockrun.ai/api") # BLOCKRUN_WALLET_KEY as usual +print(client.chat("openai/gpt-4o", "gm Arc")) +``` + +The 402 from that host names `eip155:5042`, and the SDK signs the EIP-3009 authorization against Arc's USDC (`0x3600…0000`, EIP-712 domain `USDC` v2) — never Base's. Circle's facilitator verifies and settles it on Arc; you pay no gas. Which networks the SDK can sign for is the `EVM_NETWORKS` table in `blockrun_llm.x402` (Base, Arc, Base Sepolia); a 402 naming any other network, or a non-USDC asset, is refused before anything is signed. + +**Setup:** +1. Same wallet key as Base: `export BLOCKRUN_WALLET_KEY="0x..."` +2. Fund it with USDC on Arc (Arc's native token, shown as the ERC-20 at `0x3600…0000`) +3. `api_url="https://arc.blockrun.ai/api"` — payments are automatic via x402 + ## Smart Routing (Router Core) Let the SDK automatically pick the cheapest capable model for each request: diff --git a/VERSION b/VERSION index 15b989e..092afa1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.16.0 +1.17.0 diff --git a/blockrun_llm/__init__.py b/blockrun_llm/__init__.py index 6265ebb..db25c43 100644 --- a/blockrun_llm/__init__.py +++ b/blockrun_llm/__init__.py @@ -196,7 +196,7 @@ create_wallet as generate_wallet, # User-friendly alias ) -__version__ = "1.16.0" +__version__ = "1.17.0" __all__ = [ "DEFAULT_API_KEY_URL", "ENV_API_KEY", diff --git a/blockrun_llm/image.py b/blockrun_llm/image.py index c97e4ad..60c14bf 100644 --- a/blockrun_llm/image.py +++ b/blockrun_llm/image.py @@ -369,6 +369,7 @@ def _handle_payment_and_retry( resource_description=resource.get("description", "BlockRun Image Generation"), max_timeout_seconds=details.get("maxTimeoutSeconds", 300), extra=details.get("extra"), + asset=details.get("asset"), extensions=extensions, ) diff --git a/blockrun_llm/music.py b/blockrun_llm/music.py index 17c767f..e94b869 100644 --- a/blockrun_llm/music.py +++ b/blockrun_llm/music.py @@ -257,6 +257,7 @@ def _handle_payment_and_retry( resource_description=resource.get("description", "BlockRun Music Generation"), max_timeout_seconds=details.get("maxTimeoutSeconds", 300), extra=details.get("extra"), + asset=details.get("asset"), extensions=extensions, ) diff --git a/blockrun_llm/phone.py b/blockrun_llm/phone.py index a75bfff..52db7dd 100644 --- a/blockrun_llm/phone.py +++ b/blockrun_llm/phone.py @@ -299,6 +299,7 @@ def _handle_payment_and_retry( resource_description=resource.get("description", "BlockRun Phone"), max_timeout_seconds=details.get("maxTimeoutSeconds", 300), extra=details.get("extra"), + asset=details.get("asset"), extensions=extensions, ) diff --git a/blockrun_llm/portrait.py b/blockrun_llm/portrait.py index 1dcb55f..d31f46b 100644 --- a/blockrun_llm/portrait.py +++ b/blockrun_llm/portrait.py @@ -289,6 +289,7 @@ def _handle_payment_and_retry( ), max_timeout_seconds=details.get("maxTimeoutSeconds", 300), extra=details.get("extra"), + asset=details.get("asset"), extensions=extensions, ) diff --git a/blockrun_llm/price.py b/blockrun_llm/price.py index 8e1daa8..ad24761 100644 --- a/blockrun_llm/price.py +++ b/blockrun_llm/price.py @@ -305,6 +305,7 @@ def _pay_and_retry( resource_description=resource.get("description", "BlockRun Price Data"), max_timeout_seconds=details.get("maxTimeoutSeconds", 300), extra=details.get("extra"), + asset=details.get("asset"), extensions=extensions, ) diff --git a/blockrun_llm/realface.py b/blockrun_llm/realface.py index 8d9d442..c9b9899 100644 --- a/blockrun_llm/realface.py +++ b/blockrun_llm/realface.py @@ -441,6 +441,7 @@ def _handle_payment_and_retry( resource_description=resource.get("description", "BlockRun RealFace Enrollment"), max_timeout_seconds=details.get("maxTimeoutSeconds", 300), extra=details.get("extra"), + asset=details.get("asset"), extensions=extensions, ) diff --git a/blockrun_llm/rpc.py b/blockrun_llm/rpc.py index 353f262..66d970e 100644 --- a/blockrun_llm/rpc.py +++ b/blockrun_llm/rpc.py @@ -385,6 +385,7 @@ def _handle_payment_and_retry( resource_description=resource.get("description", "BlockRun Multi-chain RPC"), max_timeout_seconds=details.get("maxTimeoutSeconds", 300), extra=details.get("extra"), + asset=details.get("asset"), extensions=extensions, ) diff --git a/blockrun_llm/search.py b/blockrun_llm/search.py index a00bda9..38df411 100644 --- a/blockrun_llm/search.py +++ b/blockrun_llm/search.py @@ -205,6 +205,7 @@ def _handle_payment_and_retry( resource_description=resource.get("description", "BlockRun Search"), max_timeout_seconds=details.get("maxTimeoutSeconds", 300), extra=details.get("extra"), + asset=details.get("asset"), extensions=extensions, ) diff --git a/blockrun_llm/speech.py b/blockrun_llm/speech.py index 7ae78a0..04bfd1b 100644 --- a/blockrun_llm/speech.py +++ b/blockrun_llm/speech.py @@ -336,6 +336,7 @@ def _handle_payment_and_retry( resource_description=resource.get("description", "BlockRun Voice"), max_timeout_seconds=details.get("maxTimeoutSeconds", 300), extra=details.get("extra"), + asset=details.get("asset"), extensions=extensions, ) diff --git a/blockrun_llm/surf.py b/blockrun_llm/surf.py index ad7ccc4..c0b5dc5 100644 --- a/blockrun_llm/surf.py +++ b/blockrun_llm/surf.py @@ -390,6 +390,7 @@ def _handle_payment_and_retry( resource_description=resource.get("description", "BlockRun Surf"), max_timeout_seconds=details.get("maxTimeoutSeconds", 300), extra=details.get("extra"), + asset=details.get("asset"), extensions=extensions, ) diff --git a/blockrun_llm/video.py b/blockrun_llm/video.py index 432c439..3ec36d5 100644 --- a/blockrun_llm/video.py +++ b/blockrun_llm/video.py @@ -522,6 +522,7 @@ def _sign_from_challenge(self, resp402: httpx.Response, fallback_url: str) -> st details.get("maxTimeoutSeconds", 0) or 0, self.MAX_TIMEOUT_SECONDS ), extra=details.get("extra"), + asset=details.get("asset"), extensions=extensions, ) diff --git a/blockrun_llm/voice.py b/blockrun_llm/voice.py index 0caffee..6ce2de0 100644 --- a/blockrun_llm/voice.py +++ b/blockrun_llm/voice.py @@ -348,6 +348,7 @@ def _handle_payment_and_retry( resource_description=resource.get("description", "BlockRun Voice Call"), max_timeout_seconds=details.get("maxTimeoutSeconds", 300), extra=details.get("extra"), + asset=details.get("asset"), extensions=extensions, ) diff --git a/blockrun_llm/x402.py b/blockrun_llm/x402.py index 491f75a..af10d78 100644 --- a/blockrun_llm/x402.py +++ b/blockrun_llm/x402.py @@ -24,6 +24,68 @@ BASE_SEPOLIA_CHAIN_ID = 84532 USDC_BASE_SEPOLIA = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" +# Circle's Arc (arc.blockrun.ai). USDC is the chain's native token, exposed as +# the ERC-20 at 0x3600…0000; its EIP-712 domain name is "USDC", not Base's +# "USD Coin". +ARC_CHAIN_ID = 5042 +USDC_ARC = "0x3600000000000000000000000000000000000000" + +# The EVM networks a BlockRun gateway settles on, keyed by the CAIP-2 `network` +# a 402 carries, each with the SDK's OWN chain id, USDC address and EIP-712 +# domain. The 402 SELECTS a network from this table; it never supplies the +# domain — until the Arc release the table fell back to Base for any network +# it did not know and took `asset` and `extra` from the 402 as given, which on +# arc.blockrun.ai signed chainId 8453 against Arc's contract: an invalid +# signature, a 401 from the facilitator, after the SDK had reported a payment. +EVM_NETWORKS: dict[str, dict] = { + "eip155:8453": { + "name": "Base", + "chain_id": BASE_CHAIN_ID, + "usdc": USDC_BASE, + "domain": { + "name": "USD Coin", + "version": "2", + "chainId": BASE_CHAIN_ID, + "verifyingContract": USDC_BASE, + }, + }, + "eip155:5042": { + "name": "Arc", + "chain_id": ARC_CHAIN_ID, + "usdc": USDC_ARC, + "domain": { + "name": "USDC", + "version": "2", + "chainId": ARC_CHAIN_ID, + "verifyingContract": USDC_ARC, + }, + }, + "eip155:84532": { + "name": "Base Sepolia", + "chain_id": BASE_SEPOLIA_CHAIN_ID, + "usdc": USDC_BASE_SEPOLIA, + "domain": { + "name": "USDC", + "version": "2", + "chainId": BASE_SEPOLIA_CHAIN_ID, + "verifyingContract": USDC_BASE_SEPOLIA, + }, + }, +} +# The pre-CAIP alias this SDK accepted for the testnet. +_NETWORK_ALIASES = {"base-sepolia": "eip155:84532"} + + +def evm_network(network: str) -> dict: + """The table entry for a 402's `network`, or a ValueError naming what IS supported.""" + net = EVM_NETWORKS.get(_NETWORK_ALIASES.get(network, network)) + if net is None: + raise ValueError( + f'Unsupported x402 network "{network}": this SDK signs USDC payments on ' + + ", ".join(EVM_NETWORKS) + ) + return net + # BlockRun's x402 builder code — the ERC-8021 Schema 2 service code (`s`) that # tags every payment this SDK signs as BlockRun-originated for on-chain @@ -50,36 +112,14 @@ def with_builder_code_service_code( def get_chain_config(network: str) -> tuple[int, str]: - """ - Get chain ID and USDC contract address for a given network. - - Args: - network: Network identifier in EIP-155 format (e.g., "eip155:8453" or "eip155:84532") - - Returns: - Tuple of (chain_id, usdc_address) - """ - if network == "eip155:84532" or network == "base-sepolia": - return BASE_SEPOLIA_CHAIN_ID, USDC_BASE_SEPOLIA - # Default to mainnet - return BASE_CHAIN_ID, USDC_BASE + """Chain ID and USDC contract for a network — see EVM_NETWORKS. Raises for an unknown one.""" + net = evm_network(network) + return net["chain_id"], net["usdc"] def get_usdc_domain_name(network: str) -> str: - """ - Get the EIP-712 domain name for USDC on a given network. - - Mainnet USDC uses "USD Coin", testnet USDC uses "USDC". - - Args: - network: Network identifier in EIP-155 format - - Returns: - The EIP-712 domain name for signing - """ - if network == "eip155:84532" or network == "base-sepolia": - return "USDC" - return "USD Coin" + """The EIP-712 domain name for USDC on a network — "USD Coin" on Base, "USDC" on Arc and Base Sepolia.""" + return evm_network(network)["domain"]["name"] def create_nonce() -> str: @@ -113,8 +153,8 @@ def create_payment_payload( resource_url: URL of the resource being accessed resource_description: Description of the resource max_timeout_seconds: Max timeout for the payment (default: 300) - extra: Extra info for USDC domain (name, version) - asset: USDC contract address (optional, derived from network if not provided) + extra: The 402's `extra`. Accepted for compatibility; the domain comes from EVM_NETWORKS. + asset: The 402's `asset`. Checked against the network's USDC; a mismatch raises ValueError. Returns: Base64-encoded signed payment payload @@ -127,20 +167,17 @@ def create_payment_payload( # Generate random nonce nonce = create_nonce() - # Get chain config based on network - chain_id, default_usdc = get_chain_config(network) - - # Use provided asset address or default for the network - usdc_address = asset or default_usdc - - # EIP-712 domain for USDC (mainnet or testnet based on network) - default_domain_name = get_usdc_domain_name(network) - domain = { - "name": extra.get("name", default_domain_name) if extra else default_domain_name, - "version": extra.get("version", "2") if extra else "2", - "chainId": chain_id, - "verifyingContract": usdc_address, - } + # The domain is the SDK's own value for the 402's network — never the 402's + # `extra` (see EVM_NETWORKS). A 402 naming a network the table lacks, or an + # asset that is not that network's USDC, is refused rather than signed. + net = evm_network(network) + usdc_address = net["usdc"] + if asset and asset.lower() != usdc_address.lower(): + raise ValueError( + f"x402 asset mismatch: the 402 asks for {asset} on {network}, " + f"but this SDK only pays USDC there ({usdc_address})" + ) + domain = dict(net["domain"]) # EIP-712 types for TransferWithAuthorization types = { @@ -183,7 +220,7 @@ def create_payment_payload( "asset": usdc_address, "payTo": recipient, "maxTimeoutSeconds": max_timeout_seconds, - "extra": extra or {"name": default_domain_name, "version": "2"}, + "extra": {"name": domain["name"], "version": domain["version"]}, }, "payload": { "signature": ( diff --git a/pyproject.toml b/pyproject.toml index 3cac2aa..5014282 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "blockrun-llm" -version = "1.16.0" +version = "1.17.0" description = "BlockRun SDK - Pay-per-request AI (LLM, Image, Video, Music, Voice) via x402 on Base and Solana" readme = "README.md" license = "MIT" diff --git a/tests/unit/test_x402_evm_networks.py b/tests/unit/test_x402_evm_networks.py new file mode 100644 index 0000000..31cf774 --- /dev/null +++ b/tests/unit/test_x402_evm_networks.py @@ -0,0 +1,113 @@ +"""The EIP-712 domain a payment is signed against follows the 402's network. + +Until 2.x's Arc release the chain table knew Base and Base Sepolia and fell +back to Base for anything else, while `asset` and `extra` were taken from the +402 as given. Against arc.blockrun.ai (eip155:5042, USDC at 0x3600…, domain +name "USDC") that produced a signature over chainId 8453 with Arc's contract +— invalid; the facilitator recovers a different signer and answers 401 after +the SDK has reported a payment. + +The 402 now SELECTS a network from the SDK's own table, which supplies the +chainId, the USDC address and the domain; a hostile 402's `extra` cannot +steer a signature onto another contract, an unknown network is refused +naming what is supported, and a 402 whose `asset` is not that network's USDC +is refused before anything is signed. Mirrors @blockrun/llm 3.16.0. +""" + +import base64 +import json + +import pytest +from eth_account import Account +from eth_account.messages import encode_typed_data + +from blockrun_llm.x402 import EVM_NETWORKS, create_payment_payload, evm_network + +from ..helpers import TEST_ACCOUNT, TEST_RECIPIENT + +TYPES = { + "TransferWithAuthorization": [ + {"name": "from", "type": "address"}, + {"name": "to", "type": "address"}, + {"name": "value", "type": "uint256"}, + {"name": "validAfter", "type": "uint256"}, + {"name": "validBefore", "type": "uint256"}, + {"name": "nonce", "type": "bytes32"}, + ], +} + + +def sign_and_decode(network: str, **kwargs) -> dict: + payload = create_payment_payload( + account=TEST_ACCOUNT, recipient=TEST_RECIPIENT, amount="2000", network=network, **kwargs + ) + return json.loads(base64.b64decode(payload)) + + +def recovered_signer(decoded: dict, domain: dict) -> str: + a = decoded["payload"]["authorization"] + message = { + "from": a["from"], + "to": a["to"], + "value": int(a["value"]), + "validAfter": int(a["validAfter"]), + "validBefore": int(a["validBefore"]), + "nonce": bytes.fromhex(a["nonce"][2:]), + } + signable = encode_typed_data(domain_data=domain, message_types=TYPES, message_data=message) + return Account.recover_message(signable, signature=decoded["payload"]["signature"]) + + +class TestSignedDomainFollowsNetwork: + def test_knows_arc_base_and_base_sepolia(self): + arc = evm_network("eip155:5042") + assert arc["chain_id"] == 5042 + assert arc["usdc"] == "0x3600000000000000000000000000000000000000" + assert arc["domain"]["name"] == "USDC" + base = evm_network("eip155:8453") + assert base["chain_id"] == 8453 + assert base["domain"]["name"] == "USD Coin" + assert evm_network("eip155:84532")["domain"]["name"] == "USDC" + # The old alias still resolves. + assert evm_network("base-sepolia")["chain_id"] == 84532 + + def test_arc_payment_signs_arc_domain_not_base(self): + decoded = sign_and_decode("eip155:5042") + assert ( + recovered_signer(decoded, EVM_NETWORKS["eip155:5042"]["domain"]) == TEST_ACCOUNT.address + ) + assert ( + recovered_signer(decoded, EVM_NETWORKS["eip155:8453"]["domain"]) != TEST_ACCOUNT.address + ) + assert decoded["accepted"]["network"] == "eip155:5042" + assert decoded["accepted"]["asset"] == "0x3600000000000000000000000000000000000000" + assert decoded["accepted"]["extra"] == {"name": "USDC", "version": "2"} + + def test_base_payment_unchanged(self): + decoded = sign_and_decode("eip155:8453") + assert ( + recovered_signer(decoded, EVM_NETWORKS["eip155:8453"]["domain"]) == TEST_ACCOUNT.address + ) + assert decoded["accepted"]["asset"] == "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + assert decoded["accepted"]["extra"] == {"name": "USD Coin", "version": "2"} + + def test_unknown_network_is_refused_not_signed_as_base(self): + with pytest.raises(ValueError, match="eip155:1") as e: + sign_and_decode("eip155:1") + assert "eip155:5042" in str(e.value) # names what it does know + + def test_asset_not_that_networks_usdc_is_refused(self): + with pytest.raises(ValueError, match="(?i)asset"): + sign_and_decode("eip155:5042", asset="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913") + # Case-insensitive on the address; the gateway checksums, wallets often do not. + ok = sign_and_decode( + "eip155:5042", asset="0x3600000000000000000000000000000000000000".lower() + ) + assert ok["accepted"]["asset"] == "0x3600000000000000000000000000000000000000" + + def test_402_extra_is_ignored_for_the_domain(self): + decoded = sign_and_decode("eip155:5042", extra={"name": "USD Coin", "version": "9"}) + assert ( + recovered_signer(decoded, EVM_NETWORKS["eip155:5042"]["domain"]) == TEST_ACCOUNT.address + ) + assert decoded["accepted"]["extra"] == {"name": "USDC", "version": "2"}