Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.16.0
1.17.0
2 changes: 1 addition & 1 deletion blockrun_llm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions blockrun_llm/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
1 change: 1 addition & 0 deletions blockrun_llm/music.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
1 change: 1 addition & 0 deletions blockrun_llm/phone.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
1 change: 1 addition & 0 deletions blockrun_llm/portrait.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
1 change: 1 addition & 0 deletions blockrun_llm/price.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
1 change: 1 addition & 0 deletions blockrun_llm/realface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
1 change: 1 addition & 0 deletions blockrun_llm/rpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
1 change: 1 addition & 0 deletions blockrun_llm/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
1 change: 1 addition & 0 deletions blockrun_llm/speech.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
1 change: 1 addition & 0 deletions blockrun_llm/surf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
1 change: 1 addition & 0 deletions blockrun_llm/video.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
1 change: 1 addition & 0 deletions blockrun_llm/voice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
125 changes: 81 additions & 44 deletions blockrun_llm/x402.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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 = {
Expand Down Expand Up @@ -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": (
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading