From 5c2e7ae3cfb25fde49b4395c3e40856f7cc93ca0 Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Wed, 16 Sep 2026 14:38:59 -0400 Subject: [PATCH] =?UTF-8?q?feat(x402):=20the=20signed=20domain=20follows?= =?UTF-8?q?=20the=20402's=20network=20=E2=80=94=20Arc,=20Base,=20Base=20Se?= =?UTF-8?q?polia?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chain table knew Base and Base Sepolia and fell back to Base for any other network, while `asset` and `extra` were taken from the 402 as given. Against arc.blockrun.ai (eip155:5042, USDC at 0x3600…, domain name "USDC") that signed chainId 8453 against Arc's contract — an invalid signature, a 401 from the facilitator, after the SDK had reported a payment. EVM_NETWORKS maps a 402's `network` to the SDK's OWN chain id, USDC address and EIP-712 domain; create_payment_payload signs those. The 402 SELECTS the network and supplies nothing else: its `extra` no longer reaches the domain (a hostile 402 cannot steer a signature onto another contract), an unknown network raises naming what is supported, and an `asset` that is not that network's USDC raises before signing. The twelve EVM clients that did not pass the 402's asset now do, so the check protects every route. get_chain_config / get_usdc_domain_name read the same table; the `base-sepolia` alias still resolves. 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. Six new tests recover the signer against each domain (Arc passes, Base fails for an Arc payment), pin the refusals, and that a 402's `extra` is ignored. 983 unit tests, ruff and black clean. 1.17.0, mirroring @blockrun/llm 3.16.0. --- CHANGELOG.md | 25 ++++++ CLAUDE.md | 5 +- README.md | 19 ++++ VERSION | 2 +- blockrun_llm/__init__.py | 2 +- blockrun_llm/image.py | 1 + blockrun_llm/music.py | 1 + blockrun_llm/phone.py | 1 + blockrun_llm/portrait.py | 1 + blockrun_llm/price.py | 1 + blockrun_llm/realface.py | 1 + blockrun_llm/rpc.py | 1 + blockrun_llm/search.py | 1 + blockrun_llm/speech.py | 1 + blockrun_llm/surf.py | 1 + blockrun_llm/video.py | 1 + blockrun_llm/voice.py | 1 + blockrun_llm/x402.py | 125 +++++++++++++++++---------- pyproject.toml | 2 +- tests/unit/test_x402_evm_networks.py | 113 ++++++++++++++++++++++++ 20 files changed, 257 insertions(+), 48 deletions(-) create mode 100644 tests/unit/test_x402_evm_networks.py 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"}