From e85c999fc62f3c8c4cb291e7ff9d90de4da8fee8 Mon Sep 17 00:00:00 2001 From: Larry Klosowski Date: Tue, 27 Oct 2026 16:29:48 -0700 Subject: [PATCH 1/5] test(network): red, r1006 address book and a genesis guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests pin the r1006 book (genesis 0xa3651a27…, AA factory 0x6ee3b590…, member SBT 0x88316513…, no LiquidStakingPool or other contracts missing from r1006 block 0, no r1005 address), the r1006 predictAddress(0x4242…) vector, and a block-0 guard on CitrateClient sends, the node-signed manager writes (pinned_send) and verify_wallet_address_on_chain. r1005 and r1006 share chain id 40204, so the chain-id pins cannot tell them apart. Stubs (verify_network no-op, WrongNetworkError, genesis_hash() "", the expected_genesis_hash keyword) keep the old behaviour so the tests fail on assertions. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FPJzHLHkCexnt8jGuPzvMn Signed-off-by: Larry Klosowski --- citrate_sdk/_chain_guard.py | 7 + citrate_sdk/_generated/contract.py | 4 + citrate_sdk/client.py | 5 +- citrate_sdk/compute.py | 3 +- citrate_sdk/errors.py | 7 + citrate_sdk/farming.py | 3 +- citrate_sdk/learning.py | 5 +- citrate_sdk/treasury.py | 3 +- tests/test_cli.py | 2 +- tests/test_federation_contract.py | 16 ++- tests/test_identity_wallet.py | 18 ++- tests/test_r1006_network_guard.py | 208 +++++++++++++++++++++++++++++ 12 files changed, 265 insertions(+), 16 deletions(-) create mode 100644 tests/test_r1006_network_guard.py diff --git a/citrate_sdk/_chain_guard.py b/citrate_sdk/_chain_guard.py index edf0e53..7381d41 100644 --- a/citrate_sdk/_chain_guard.py +++ b/citrate_sdk/_chain_guard.py @@ -16,6 +16,13 @@ from .errors import CitrateError +class _Unset: + """Sentinel: no expected genesis was passed (use the vendored one).""" + + +UNSET: Any = _Unset() + + def expected_chain_id(chain_id: int | None) -> int: return int(chain_id) if chain_id is not None else _contract.chain_id() diff --git a/citrate_sdk/_generated/contract.py b/citrate_sdk/_generated/contract.py index 936caed..8338c2f 100644 --- a/citrate_sdk/_generated/contract.py +++ b/citrate_sdk/_generated/contract.py @@ -26,6 +26,10 @@ def chain_id() -> int: return int(federation_contract()["chain"]["chainId"]) +def genesis_hash() -> str: + return "" + + def rpc_url() -> str: return str(federation_contract()["chain"]["rpcUrl"]) diff --git a/citrate_sdk/client.py b/citrate_sdk/client.py index c22cec6..acc3cf0 100644 --- a/citrate_sdk/client.py +++ b/citrate_sdk/client.py @@ -55,7 +55,7 @@ class CitrateClient: def __init__(self, rpc_url: str = "http://localhost:8545", private_key: str | None = None, allow_insecure_http: bool = False, timeout: float = 30.0, - chain_id: int | None = None): + chain_id: int | None = None, expected_genesis_hash: Any = None): """ Initialize Citrate client. @@ -145,6 +145,9 @@ def _rpc_call(self, method: str, params: list[Any] | None = None) -> Any: except Exception as e: raise CitrateError(f"Network error: {str(e)}") + def verify_network(self) -> None: + return None + def get_chain_id(self) -> int: """Get blockchain chain ID. diff --git a/citrate_sdk/compute.py b/citrate_sdk/compute.py index 7f3d14c..73dbd03 100644 --- a/citrate_sdk/compute.py +++ b/citrate_sdk/compute.py @@ -23,7 +23,7 @@ from typing import Any, cast -from ._chain_guard import expected_chain_id, pinned_send +from ._chain_guard import UNSET, expected_chain_id, pinned_send from .abi import AbiInterface, enum_index, from_wei, to_wei from .errors import CitrateError, ConfigurationError, ValidationError from .types import ComputeJob, ComputePool, Dispute, ProviderInfo @@ -173,6 +173,7 @@ def __init__( contract_addresses: dict[str, str] | None = None, *, chain_id: int | None = None, + expected_genesis_hash: Any = UNSET, ) -> None: self._rpc_call = rpc_call # PBA-L6b-042: writes assert eth_chainId against this before sending. diff --git a/citrate_sdk/errors.py b/citrate_sdk/errors.py index 6da30e3..597e264 100644 --- a/citrate_sdk/errors.py +++ b/citrate_sdk/errors.py @@ -12,6 +12,13 @@ def __init__(self, message: str, error_code: str | None = None, details: dict | self.details = details or {} +class WrongNetworkError(CitrateError): + """The RPC is on another network than the vendored address book is for, or cannot say.""" + + def __init__(self, message: str, details: dict | None = None): + super().__init__(message, "WRONG_NETWORK", details) + + class NetworkError(CitrateError): """Network communication errors""" pass diff --git a/citrate_sdk/farming.py b/citrate_sdk/farming.py index 2c05ebc..c987eea 100644 --- a/citrate_sdk/farming.py +++ b/citrate_sdk/farming.py @@ -22,7 +22,7 @@ from typing import Any, cast -from ._chain_guard import expected_chain_id, pinned_send +from ._chain_guard import UNSET, expected_chain_id, pinned_send from .abi import AbiInterface from .errors import ConfigurationError @@ -70,6 +70,7 @@ def __init__( contract_addresses: dict[str, str] | None = None, *, chain_id: int | None = None, + expected_genesis_hash: Any = UNSET, ) -> None: self._rpc_call = rpc_call # PBA-L6b-042: writes assert eth_chainId against this before sending. diff --git a/citrate_sdk/learning.py b/citrate_sdk/learning.py index 4a92b89..bdc8137 100644 --- a/citrate_sdk/learning.py +++ b/citrate_sdk/learning.py @@ -27,7 +27,7 @@ from eth_account.signers.local import LocalAccount from eth_utils import keccak -from ._chain_guard import expected_chain_id, pinned_send +from ._chain_guard import UNSET, expected_chain_id, pinned_send from .abi import AbiInterface, enum_index, from_wei, to_wei from .errors import ConfigurationError from .types import ( @@ -240,6 +240,7 @@ def __init__( contract_addresses: dict[str, str] | None = None, *, chain_id: int | None = None, + expected_genesis_hash: Any = UNSET, ) -> None: self._rpc_call = rpc_call # PBA-L6b-042: writes assert eth_chainId against this before sending. @@ -528,6 +529,7 @@ def __init__( staking_address: str | None = None, *, chain_id: int | None = None, + expected_genesis_hash: Any = UNSET, ) -> None: self._rpc_call = rpc_call # PBA-L6b-042: writes assert eth_chainId against this before sending. @@ -700,6 +702,7 @@ def __init__( classroom_address: str | None = None, *, chain_id: int | None = None, + expected_genesis_hash: Any = UNSET, ) -> None: self._rpc_call = rpc_call # PBA-L6b-042: writes assert eth_chainId against this before sending. diff --git a/citrate_sdk/treasury.py b/citrate_sdk/treasury.py index 12994fd..4be18d1 100644 --- a/citrate_sdk/treasury.py +++ b/citrate_sdk/treasury.py @@ -21,7 +21,7 @@ from typing import Any, cast -from ._chain_guard import expected_chain_id, pinned_send +from ._chain_guard import UNSET, expected_chain_id, pinned_send from .abi import AbiInterface from .errors import ConfigurationError @@ -75,6 +75,7 @@ def __init__( contract_addresses: dict[str, str] | None = None, *, chain_id: int | None = None, + expected_genesis_hash: Any = UNSET, ) -> None: self._rpc_call = rpc_call # PBA-L6b-042: writes assert eth_chainId against this before sending. diff --git a/tests/test_cli.py b/tests/test_cli.py index a4330af..74db2db 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -23,7 +23,7 @@ def test_entry_point_resolves_and_help_returns(): def test_wallet_predict_matches_onchain_vector(): rc, out = _run(["wallet", "predict", "--user-id", "0x" + "42" * 32]) assert rc == 0 - assert "0x6Ef7617980a1fe91D3449315E8D48f99A68Bb5eA" in out + assert "0xEFf9CC174Cdcbe60FF9D8D2b469Fb94008C8336D" in out def test_entitlement_capabilities(): diff --git a/tests/test_federation_contract.py b/tests/test_federation_contract.py index 8dc57c9..76f5523 100644 --- a/tests/test_federation_contract.py +++ b/tests/test_federation_contract.py @@ -1,4 +1,8 @@ -"""DEVX-S0 / F1 — the vendored federation contract pins the on-chain-verified truth.""" +"""DEVX-S0 / F1 — the vendored federation contract pins the r1006 book (genesis 0xa3651a27…). + +citrate-chain contracts/addresses/r1006/40204.json (final since a857443e; every contract is in +block 0), via citrate-federation gen-contract. +""" from __future__ import annotations from citrate_sdk._generated import contract @@ -10,17 +14,17 @@ def test_chain_id_is_40204(): assert contract.ws_url() == "wss://rpc.citrate.ai/ws" -def test_aa_stack_pins_the_live_addresses(): +def test_aa_stack_pins_the_r1006_addresses(): aa = contract.aa_stack() assert aa["EntryPoint"].lower() == "0x97d5391a647429233e202f99231743c53a648f3c" - assert aa["CitrateWalletFactory"].lower() == "0x9ac05ad65c9e8e48fbb41dde029512afa5346c85" - assert aa["CitratePaymaster"].lower() == "0x804a8021cc1212798180bc9330f6467f883c392c" + assert aa["CitrateWalletFactory"].lower() == "0x6ee3b59099c9c812cdf1c9e7e6ff166b2558434c" + assert aa["CitratePaymaster"].lower() == "0x5a1e2d73f8154a01a78b56e551c977cb8327ff56" def test_membership_addresses_present(): m = contract.membership() - assert m["CitrateMemberSBT"].lower() == "0xf8ad11f6d3aefa605e2ebf7c81ed08a2b38b3a3c" - assert m["MembershipStakeVault"].lower() == "0xa93f7f688a8f212e0cad59edbd1f7d3592151751" + assert m["CitrateMemberSBT"].lower() == "0x883165132776171fbcfde2d3c789ef5bdb0d060d" + assert m["MembershipStakeVault"].lower() == "0xd6632ea2179d047d2460f1de90abcc519839555e" def test_entitlement_vocabulary(): diff --git a/tests/test_identity_wallet.py b/tests/test_identity_wallet.py index 65b82cc..e2032f2 100644 --- a/tests/test_identity_wallet.py +++ b/tests/test_identity_wallet.py @@ -1,8 +1,10 @@ """DEVX-S1 / F2 — embedded wallet prediction (Python parity with JS + on-chain factory). -Pinned to the on-chain CitrateWalletFactory.predictAddress(0x4242…)=0x6Ef76179… (2026-10-06, -live factory 0x9Ac05AD6… after the 2026-10-06 r1005 re-roll, genesis 0x1dcfc490…), -the SAME value the JS SDK produces — cross-language byte parity (risk R-2). +r1006 (genesis 0xa3651a27…): factory 0x6ee3b590…, implementation CitrateWallet 0x2d742b98… +(unchanged), predictAddress(0x4242…) = 0xEFf9CC17…, computed offline with the formula that +reproduces r1005's live factory answer 0x6Ef76179… (2026-10-06, factory 0x9Ac05AD6…, genesis +0x1dcfc490…) byte-for-byte; the JS SDK pins the same values (risk R-2). Re-read the r1006 value +from the live factory after the public cutover. """ from __future__ import annotations @@ -20,7 +22,15 @@ def _raises(exc, fn): def test_predict_matches_onchain_and_js(): - assert wallet.predict_wallet_address(USER_ID_4242) == "0x6Ef7617980a1fe91D3449315E8D48f99A68Bb5eA" + assert wallet.predict_wallet_address(USER_ID_4242) == "0xEFf9CC174Cdcbe60FF9D8D2b469Fb94008C8336D" + + +def test_formula_reproduces_the_r1005_live_factory_answer(): + assert wallet.predict_wallet_address( + USER_ID_4242, + factory="0x9Ac05AD65C9E8E48Fbb41dDE029512afA5346C85", + implementation="0x2D742B98D867Fc7363F530DD6d756622e4Eb768D", + ) == "0x6Ef7617980a1fe91D3449315E8D48f99A68Bb5eA" def test_predict_uses_artifact_factory_and_impl_by_default(): diff --git a/tests/test_r1006_network_guard.py b/tests/test_r1006_network_guard.py new file mode 100644 index 0000000..1698796 --- /dev/null +++ b/tests/test_r1006_network_guard.py @@ -0,0 +1,208 @@ +"""r1006 address book + genesis guard. + +r1005 and r1006 share chain id 40204, so the SPY-B-005 / PBA-L6b-042 chain-id pins cannot +tell them apart. The vendored artifact is the r1006 book (citrate-chain +``contracts/addresses/r1006/40204.json`` via citrate-federation gen-contract) and carries the +r1006 block-0 hash (``R1006_GENESIS`` in citrate-chain core/consensus/src/hardening.rs). Every +write path and the on-chain wallet verification read block 0 first and refuse another network. +""" +from __future__ import annotations + +import json +from typing import Any +from unittest import mock + +import pytest +import requests + +from citrate_sdk import CitrateClient +from citrate_sdk._generated import contract +from citrate_sdk.compute import ComputeManager +from citrate_sdk.errors import CitrateError, WrongNetworkError +from citrate_sdk.identity import wallet + +R1006 = "0xa3651a272fe411948cbb4cf7cca4cee44be4248d32ab4376cea7636821c6cdd4" +R1005 = "0x1dcfc4909dd9fdcb0f03c823d627e4b2032cc86bef5fc4561030d6c64b9775f1" +KEY = "0x" + "11" * 32 +ACCT = "0x" + "01" * 20 +ADDRS = dict.fromkeys(("computePool", "disputeResolution"), "0x" + "02" * 20) + + +# ── the vendored book ──────────────────────────────────────────────────────── + +def test_artifact_carries_the_r1006_genesis() -> None: + assert contract.genesis_hash() == R1006 + + +def test_book_points_at_r1006_contracts() -> None: + aa = contract.aa_stack() + assert aa["CitrateWalletFactory"].lower() == "0x6ee3b59099c9c812cdf1c9e7e6ff166b2558434c" + assert aa["CitratePaymaster"].lower() == "0x5a1e2d73f8154a01a78b56e551c977cb8327ff56" + assert contract.contracts()["ComputeMarketplace"].lower() == "0x2fec2ac1dde3a1a749c84d487738904cd2ed50c2" + + +def test_book_drops_contracts_not_in_r1006_block_0() -> None: + names = contract.contracts() + for gone in ("LiquidStakingPool", "CitAgentTimelock", "DefensePrimeComplianceRegistry", "TenantHierarchy"): + assert gone not in names + + +def test_no_r1005_address_leaks() -> None: + blob = json.dumps(contract.federation_contract()).lower() + for dead in ("0x9ac05ad6", "0x804a8021", "0xf8ad11f6", "0xa93f7f68", "0x73270e98", "0xa96a891b"): + assert dead not in blob, dead + + +# ── CitrateClient ──────────────────────────────────────────────────────────── + +def _client(genesis: Any, **kw: Any) -> tuple[CitrateClient, list[str]]: + c = CitrateClient("http://127.0.0.1:8545", private_key=KEY, **kw) + methods: list[str] = [] + + def rpc(method: str, params: Any = None) -> Any: + methods.append(method) + if method == "eth_getBlockByNumber": + if isinstance(genesis, Exception): + raise genesis + return None if genesis is None else {"number": "0x0", "hash": genesis} + if method == "eth_chainId": + return hex(40204) + if method == "eth_getTransactionCount": + return "0x0" + return "0x" + "ab" * 32 + + c._rpc_call = rpc # type: ignore[method-assign] + return c, methods + + +def test_client_refuses_an_r1005_node_naming_both_hashes() -> None: + c, _ = _client(R1005) + with pytest.raises(WrongNetworkError) as e: + c.verify_network() + assert R1005 in str(e.value) and R1006 in str(e.value) + assert e.value.error_code == "WRONG_NETWORK" + assert isinstance(e.value, CitrateError) + + +def test_client_accepts_r1006_in_any_case_and_checks_once() -> None: + c, methods = _client(R1006.upper().replace("0X", "0x")) + c.verify_network() + c.verify_network() + assert methods.count("eth_getBlockByNumber") == 1 + + +@pytest.mark.parametrize("genesis", [None, "", CitrateError("Network error: down")]) +def test_client_fails_closed_without_block_0(genesis: Any) -> None: + c, _ = _client(genesis) + with pytest.raises(WrongNetworkError): + c.verify_network() + + +def test_client_failure_is_not_cached() -> None: + c, methods = _client(R1005) + for _ in range(2): + with pytest.raises(WrongNetworkError): + c.verify_network() + assert methods.count("eth_getBlockByNumber") == 2 + + +def test_client_send_refuses_before_signing_on_another_network() -> None: + c, methods = _client(R1005) + with pytest.raises(WrongNetworkError): + c._send_transaction("0x" + "00" * 19 + "01", {"x": 1}) + assert "eth_sendRawTransaction" not in methods + + +def test_client_send_goes_through_on_r1006() -> None: + c, methods = _client(R1006) + c._send_transaction("0x" + "00" * 19 + "01", {"x": 1}) + assert "eth_sendRawTransaction" in methods + + +def test_client_override_and_opt_out() -> None: + off, methods = _client(R1005, expected_genesis_hash=None) + off.verify_network() + assert "eth_getBlockByNumber" not in methods + pinned, _ = _client(R1005, expected_genesis_hash=R1005) + pinned.verify_network() + dev, dev_methods = _client(R1005, chain_id=31337) + dev.verify_network() + assert "eth_getBlockByNumber" not in dev_methods + with pytest.raises(CitrateError, match="expected_genesis_hash"): + CitrateClient("http://127.0.0.1:8545", expected_genesis_hash="0x12") + + +# ── node-signed manager writes (pinned_send) ───────────────────────────────── + +def _manager_rpc(genesis: str) -> tuple[Any, list[str]]: + methods: list[str] = [] + + def rpc(method: str, params: Any) -> Any: + methods.append(method) + if method == "eth_chainId": + return hex(40204) + if method == "eth_getBlockByNumber": + return {"hash": genesis} + return "0xhash" + + return rpc, methods + + +def test_manager_write_refuses_an_r1005_node_before_sending() -> None: + rpc, methods = _manager_rpc(R1005) + with pytest.raises(WrongNetworkError): + ComputeManager(rpc, default_account=ACCT, contract_addresses=ADDRS).heartbeat() + assert "eth_sendTransaction" not in methods + + +def test_manager_write_sends_on_r1006_and_checks_once() -> None: + rpc, methods = _manager_rpc(R1006) + mgr = ComputeManager(rpc, default_account=ACCT, contract_addresses=ADDRS) + mgr.heartbeat() + mgr.heartbeat() + assert methods.count("eth_getBlockByNumber") == 1 and methods.count("eth_sendTransaction") == 2 + + +def test_manager_override_and_opt_out() -> None: + rpc, methods = _manager_rpc(R1005) + ComputeManager(rpc, default_account=ACCT, contract_addresses=ADDRS, expected_genesis_hash=None).heartbeat() + assert "eth_getBlockByNumber" not in methods + rpc2, _ = _manager_rpc(R1005) + ComputeManager(rpc2, default_account=ACCT, contract_addresses=ADDRS, expected_genesis_hash=R1005).heartbeat() + + +# ── verify_wallet_address_on_chain ─────────────────────────────────────────── + +UID = "0x" + "42" * 32 + + +def _wallet_post(genesis: str) -> Any: + local = wallet.predict_wallet_address(UID) + + def fake_post(url: str, data: str | None = None, headers: Any = None, timeout: Any = None) -> requests.Response: + body = json.loads(data or "{}") + m = body["method"] + result: Any = { + "eth_chainId": hex(40204), + "eth_getBlockByNumber": {"hash": genesis}, + "eth_getCode": "0x6080", + "eth_call": "0x" + "00" * 12 + local[2:].lower(), + }[m] + r = requests.Response() + r.status_code = 200 + r._content = json.dumps({"jsonrpc": "2.0", "id": body["id"], "result": result}).encode() + return r + + return fake_post + + +def test_wallet_verify_refuses_an_r1005_node() -> None: + with mock.patch.object(wallet.requests, "post", _wallet_post(R1005)): + with pytest.raises(WrongNetworkError): + wallet.verify_wallet_address_on_chain(UID, rpc_url="https://rpc.example") + + +def test_wallet_verify_passes_on_r1006() -> None: + with mock.patch.object(wallet.requests, "post", _wallet_post(R1006)): + assert wallet.verify_wallet_address_on_chain(UID, rpc_url="https://rpc.example") == \ + wallet.predict_wallet_address(UID) From a1d832bb7e6619b66be7fce94e074d343399c649 Mon Sep 17 00:00:00 2001 From: Larry Klosowski Date: Tue, 27 Oct 2026 16:36:26 -0700 Subject: [PATCH 2/5] =?UTF-8?q?fix(addresses):=20r1006=20address=20book=20?= =?UTF-8?q?(genesis=200xa3651a27=E2=80=A6)=20and=20a=20genesis=20guard;=20?= =?UTF-8?q?0.7.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vendored federation_contract.json re-synced verbatim with scripts/sync_contract.py from the citrate-federation gen-contract output for the r1006 book (citrate-chain contracts/addresses/r1006/40204.json, final since a857443e). Contracts not in r1006 block 0 (LiquidStakingPool and the other retired entries) leave contract.contracts(); AA factory / paymaster and membership move to their r1006 addresses. StakingManager says LiquidStakingPool is not in the r1006 book. r1005 and r1006 share chain id 40204, so the SDK now reads block 0 and raises WrongNetworkError (WRONG_NETWORK) on another genesis before CitrateClient signs, before a manager's first write (pinned_send), and before verify_wallet_address_on_chain trusts the book's factory. expected_genesis_hash= overrides; None skips (a local devnet). sync_contract.py / check-contract-drift.sh honour CITRATE_FEDERATION_DIR and the sync requires chain.genesisHash. Test RPC fakes that stand in for a 40204 node answer block 0. README: r1006 book, network guard, devnet opt-out, tokenomics line. Version 0.7.0. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FPJzHLHkCexnt8jGuPzvMn Signed-off-by: Larry Klosowski --- .github/workflows/ci.yml | 4 +- CHANGELOG.md | 49 +++++ README.md | 22 +- citrate_sdk/__init__.py | 2 +- citrate_sdk/_chain_guard.py | 70 ++++++- citrate_sdk/_generated/contract.py | 3 +- .../_generated/federation_contract.json | 189 ++++++++---------- citrate_sdk/client.py | 25 ++- citrate_sdk/compute.py | 9 +- citrate_sdk/farming.py | 9 +- citrate_sdk/identity/wallet.py | 15 ++ citrate_sdk/learning.py | 19 +- citrate_sdk/treasury.py | 9 +- pyproject.toml | 2 +- scripts/check-contract-drift.sh | 3 +- scripts/sync_contract.py | 16 +- tests/_chain_rpc.py | 18 +- tests/test_classroom_invite_key.py | 3 + tests/test_hardening_round4.py | 3 + tests/test_payload_guard.py | 4 +- tests/test_pba_r2_l6b_027_wallet_verify.py | 5 +- tests/test_pba_r2_l6b_028_040_classroom.py | 3 + tests/test_pba_r2_l6b_031_enums.py | 3 + tests/test_pba_r2_l6b_042_chain_pin.py | 5 + tests/test_pba_r2_l6b_keyshares.py | 3 + tests/test_pba_r2_mutation_hardening.py | 30 ++- tests/test_r1006_network_guard.py | 8 +- tests/test_r2_compute_compat.py | 2 + uv.lock | 2 +- 29 files changed, 394 insertions(+), 141 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f99bbc..82701a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,7 +69,9 @@ jobs: # python CI only runs ruff/mypy/pytest, so vendored-address drift shipped # silently. Requires a read-only token for CitrateNetwork/citrate-federation; # inert until GitHub Actions + CITRATE_FEDERATION_READ_TOKEN are available (the - # local mirror scripts/check-contract-drift.sh runs the identical check). + # local mirror scripts/check-contract-drift.sh runs the identical check). Since 0.7.0 the + # artifact is the r1006 book and carries chain.genesisHash (r1006 block 0), which the SDK's + # network guard enforces and sync_contract.py requires; the federation artifact PR merges first. contract-drift: runs-on: ubuntu-latest # Internal-only gate (checks the vendored artifact against the canonical intermediate in the diff --git a/CHANGELOG.md b/CHANGELOG.md index ef79f31..cfa3411 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,55 @@ All notable changes to `citrate-labs-sdk` are documented here. This project adheres to [Semantic Versioning](https://semver.org/). +## [0.7.0] - 2026-10-27 — r1006 address book and a genesis guard + +Held for the r1006 public cutover: publish only after rpc.citrate.ai serves r1006. + +### Changed + +- Address book follows the r1006 network of 40204 (genesis block + `0xa3651a272fe411948cbb4cf7cca4cee44be4248d32ab4376cea7636821c6cdd4`). The book is citrate-chain + `contracts/addresses/r1006/40204.json` (final since a857443e; every contract is in block 0). + `citrate_sdk/_generated/federation_contract.json` re-synced verbatim via + `scripts/sync_contract.py` from the citrate-federation gen-contract output (branch + `beta/fxsdk-r1006-contract`); no hand edits. AA stack: `CitrateWalletFactory 0x6ee3b590…`, + `CitratePaymaster 0x5a1e2d73…`. Membership: `CitrateMemberSBT 0x88316513…`, + `MembershipStakeVault 0xd6632ea2…`. Contracts not in r1006 block 0 (`LiquidStakingPool`, + `CitAgentTimelock`, the DPF and tenant/governance-kit registries) are no longer in + `contract.contracts()`. +- `StakingManager` without `staking_address` now says that `LiquidStakingPool` is not in the + r1006 address book. + +### Added + +- Network guard. r1005 and r1006 share chain id 40204, so the SPY-B-005 / PBA-L6b-042 chain-id + pins cannot tell them apart. `CitrateClient` reads block 0 before it signs a transaction, the + Learning/Staking/Classroom/Compute/Treasury/Farming managers before their first write + (`pinned_send`), and `verify_wallet_address_on_chain` before it trusts the book's factory; each + raises `WrongNetworkError` (`error_code="WRONG_NETWORK"`) unless the hash is the vendored + genesis. `client.verify_network()` runs the check up front. `expected_genesis_hash=` overrides + the hash; `None` skips the check (a local devnet). With a non-vendored `chain_id=` the check is + off unless a hash is given. +- `contract.genesis_hash()`. +- `scripts/sync_contract.py` and `scripts/check-contract-drift.sh` honour + `CITRATE_FEDERATION_DIR`, and the sync refuses an artifact without a 32-byte `genesisHash`. + +### Tests + +- `tests/test_r1006_network_guard.py` (19 tests): the r1006 book, no r1005 address, the + LiquidStakingPool message, and the guard on every path (wrong genesis, missing or unreadable + block 0, failure not cached, opt-out, override). +- Wallet-prediction vector `predictAddress(0x4242…)` = `0xEFf9CC17…` for the r1006 factory, + computed offline; the same formula reproduces r1005's live `0x6Ef76179…`, which stays pinned as + a formula check. Re-read it from the live factory after the cutover. +- RPC fakes that stand in for a 40204 node answer block 0 with the vendored genesis + (`tests/_chain_rpc.py` `node_answer`, and the per-file fakes). + +### Docs + +- README: the r1006 book, the network guard, the devnet opt-out, and "Tokenomics are not yet + determined; see docs.citrate.ai." + ## [0.6.4] - 2026-10-06 — Post-reroll address book (r1005) ### Fixed diff --git a/README.md b/README.md index 6f1d39d..28ca2f3 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,16 @@ crypto envelope for encrypted inference, an OpenAI-compatible `GatewayClient`, a artifact and enforced at signing time (a hostile RPC cannot make you sign for another chain). It mirrors the canonical TypeScript SDK (`@citratelabs/sdk`) and may lag it. +Tokenomics are not yet determined; see [docs.citrate.ai](https://docs.citrate.ai). + +This release carries the **r1006** address book (citrate-chain +`contracts/addresses/r1006/40204.json`, genesis block `0xa3651a27…6cdd4`). Two networks can share +chain id 40204 (r1005 and r1006 do), so before the client or a manager sends a transaction, and +before `verify_wallet_address_on_chain` trusts the factory, the SDK reads block 0 and raises +`WrongNetworkError` (`error_code="WRONG_NETWORK"`) unless its hash is that genesis. +`client.verify_network()` runs the check up front. `LiquidStakingPool` is not in r1006 block 0, +so `StakingManager` has no default address. + See the concepts in the docs: . Depends on a running chain node ([citrate-chain](https://github.com/CitrateNetwork/citrate-chain)) and, for inference, the gateway ([citrate-inference-gateway](https://github.com/CitrateNetwork/citrate-inference-gateway)). @@ -96,11 +106,14 @@ Point the SDK at a local Citrate stack on one machine instead of the public test client = CitrateClient( rpc_url="http://localhost:8545", private_key="0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", # anvil acct #0 + expected_genesis_hash=None, # a local devnet is not the r1006 genesis ) assert client.get_chain_id() == 40204 ``` The client refuses to sign if the RPC's `eth_chainId` does not match the expected 40204; - pass `chain_id=` to override for a custom local chain. + pass `chain_id=` to override for a custom local chain. A local devnet has its own genesis, + so also pass `expected_genesis_hash=None` (or your devnet's block-0 hash) when it uses + chain id 40204. 3. **Local inference gateway** (optional) — run [citrate-inference-gateway](https://github.com/CitrateNetwork/citrate-inference-gateway) and point the gateway client at it: @@ -122,9 +135,10 @@ LOCAL_STACK guide at . | `CITRATE_PRIVATE_KEY` | — | signer key (examples generate an ephemeral one if unset) | | `CITRATE_GATEWAY_API_KEY` | — | `cgk_` bearer key for the inference gateway | -`CitrateClient(rpc_url, private_key=None, timeout=..., allow_insecure_http=False, chain_id=None)`. -The expected chain id (40204) and gateway base URL come from the vendored federation -artifact; `citrate contract` prints the full table. +`CitrateClient(rpc_url, private_key=None, timeout=..., allow_insecure_http=False, chain_id=None, +expected_genesis_hash=)`. The expected chain id (40204), genesis hash (r1006) and +gateway base URL come from the vendored federation artifact; `citrate contract` prints the full +table. The managers take the same `chain_id=` / `expected_genesis_hash=` keywords. ## Links - Docs: diff --git a/citrate_sdk/__init__.py b/citrate_sdk/__init__.py index 9a582c2..8bda555 100644 --- a/citrate_sdk/__init__.py +++ b/citrate_sdk/__init__.py @@ -48,7 +48,7 @@ StakingInfo, ) -__version__ = "0.6.4" +__version__ = "0.7.0" __author__ = "Citrate Team" __all__ = [ diff --git a/citrate_sdk/_chain_guard.py b/citrate_sdk/_chain_guard.py index 7381d41..f14ea3c 100644 --- a/citrate_sdk/_chain_guard.py +++ b/citrate_sdk/_chain_guard.py @@ -1,4 +1,4 @@ -"""Chain-id pin for the node-signed manager write paths (PBA-L6b-042). +"""Chain-id and genesis pins for the write paths (PBA-L6b-042, r1006). SPY-B-005 pinned the chain id for ``CitrateClient`` (which signs locally), but the Learning/Staking/Classroom/Compute/Treasury/Farming managers send writes @@ -7,13 +7,22 @@ moved funds there. ``pinned_send`` asserts ``eth_chainId`` against the expected chain once per manager, then sends with ``chainId`` in the tx so an honest node also rejects a mismatch itself. + +r1005 and r1006 share chain id 40204, so the chain id alone cannot tell them +apart. ``assert_genesis`` reads block 0 (``eth_getBlockByNumber``) and compares +its hash with the genesis of the vendored address book; ``pinned_send`` and +``CitrateClient`` run it once before the first send. """ from __future__ import annotations +import re +from collections.abc import Callable from typing import Any, cast from ._generated import contract as _contract -from .errors import CitrateError +from .errors import CitrateError, WrongNetworkError + +_HASH_RE = re.compile(r"0x[0-9a-f]{64}") class _Unset: @@ -23,12 +32,62 @@ class _Unset: UNSET: Any = _Unset() +def is_unset(value: Any) -> bool: + return isinstance(value, _Unset) + + def expected_chain_id(chain_id: int | None) -> int: return int(chain_id) if chain_id is not None else _contract.chain_id() +def resolve_expected_genesis(value: Any, expected_chain: int) -> str | None: + """The block-0 hash a client/manager must see, or ``None`` for no check. + + ``UNSET`` (not passed): the vendored book's genesis when the pinned chain is the + vendored chain, else ``None`` (a caller-chosen dev chain). ``None``: no check. + A string: that 32-byte hash (any case). + """ + if value is None: + return None + if isinstance(value, _Unset): + return _contract.genesis_hash() if expected_chain == _contract.chain_id() else None + v = str(value).lower() + if not _HASH_RE.fullmatch(v): + raise CitrateError(f"expected_genesis_hash must be a 0x-prefixed 32-byte hex hash or None, got {value!r}") + return v + + +def assert_genesis(rpc_call: Callable[[str, list[Any]], Any], expected: str) -> None: + """Read block 0 and raise ``WrongNetworkError`` unless its hash is ``expected``. + + Fails closed: an unreadable or missing block 0 is a refusal, not a pass. + """ + try: + block = rpc_call("eth_getBlockByNumber", ["0x0", False]) + except Exception as e: # noqa: BLE001 - any transport/RPC failure is a refusal + raise WrongNetworkError( + f"cannot read block 0 from the RPC to verify the network ({e}); " + "refusing to use the vendored address book", + {"expected": expected}, + ) from e + actual = block.get("hash") if isinstance(block, dict) else None + if not isinstance(actual, str) or not actual: + raise WrongNetworkError( + "the RPC returned no block 0 hash; refusing to use the vendored address book", + {"expected": expected}, + ) + actual = actual.lower() + if actual != expected: + raise WrongNetworkError( + f"the RPC is on another network: its block 0 is {actual}, but this SDK's address book is " + f"for genesis {expected}. Refusing to send to contracts that do not exist there. Point " + "the client at a node of that network, or pass expected_genesis_hash to match yours.", + {"expected": expected, "actual": actual}, + ) + + def pinned_send(manager: Any, tx: dict[str, Any]) -> str: - """Assert the node's chain (cached per manager) and send ``tx``.""" + """Assert the node's chain and genesis (cached per manager) and send ``tx``.""" expected = cast(int, manager._expected_chain_id) if not getattr(manager, "_chain_verified", False): raw = manager._rpc_call("eth_chainId", []) @@ -42,5 +101,10 @@ def pinned_send(manager: Any, tx: dict[str, Any]) -> str: f"chain-id is {expected}. Refusing to send a transaction to another network " "(PBA-L6b-042)." ) + genesis = getattr(manager, "_expected_genesis", UNSET) + if isinstance(genesis, _Unset): + genesis = resolve_expected_genesis(UNSET, expected) + if genesis is not None: + assert_genesis(manager._rpc_call, genesis) manager._chain_verified = True return cast(str, manager._rpc_call("eth_sendTransaction", [dict(tx, chainId=hex(expected))])) diff --git a/citrate_sdk/_generated/contract.py b/citrate_sdk/_generated/contract.py index 8338c2f..2e247da 100644 --- a/citrate_sdk/_generated/contract.py +++ b/citrate_sdk/_generated/contract.py @@ -27,7 +27,8 @@ def chain_id() -> int: def genesis_hash() -> str: - return "" + """Block-0 hash of the network the vendored address book is for (r1006).""" + return str(federation_contract()["chain"]["genesisHash"]).lower() def rpc_url() -> str: diff --git a/citrate_sdk/_generated/federation_contract.json b/citrate_sdk/_generated/federation_contract.json index 8806f7f..2f053f2 100644 --- a/citrate_sdk/_generated/federation_contract.json +++ b/citrate_sdk/_generated/federation_contract.json @@ -5,113 +5,92 @@ "chainName": "Citrate Network", "rpcUrl": "https://rpc.citrate.ai", "explorerUrl": "https://explorer.citrate.ai", - "wsUrl": "wss://rpc.citrate.ai/ws" + "wsUrl": "wss://rpc.citrate.ai/ws", + "genesisHash": "0xa3651a272fe411948cbb4cf7cca4cee44be4248d32ab4376cea7636821c6cdd4" }, "contracts": { - "ValidatorRegistry": "0xde4F679632be7810D915F637Cc6a72bf076A3BD9", - "ModelRegistry": "0x086F13745C53b460512eA2A0367EcdB62A2875eF", - "WrappedSALT": "0xAa918302B94a4B0E75E01e019cc6b819B4F7c906", - "AgentDecisionRegistry": "0x40A34010347C75c85F1EC990B1607a51FFDBE79d", - "SpecRegistry": "0xA7d848e4104B5dCac0dE081e92Eb1E93e43D6a1a", - "IPFSIncentives": "0x003563c0EE71FA2C69c6EB6440EE3c0fC8e1a467", - "X402Facilitator": "0x0D36F6e2a186436f4E4Cc78Bbc7DA2a1aC37aadb", - "X402Paywall": "0x5e833f530Dc09CaA0631AB2A50532025C0c0C0F0", - "LiquidStakingPool": "0x73270e9818530d9C141596411Ef58730C1eB9f0D", - "ContributionAccounting": "0x97Affbd04813Ea9a63DA0Cb659EE9b06E6BE6CdF", - "NematocystSlashing": "0x594C4110F0B23752cad0B1622193132d223c3595", - "MarketMakerAllocation": "0x6E01671D2D3dbF719EE1C5d6929111027aEc316A", - "ModelMarketplace": "0x6A93B6e99BfaBfb4e655c6fC15DB4d98192D040f", - "InferenceRouter": "0x42Ae16Dae5D7Bf535b5cce9d5b6521c504500896", - "LoRAFactory": "0x9BF27858C9Ece2eC13fAEa3e6BD356A20E9C5185", - "LearningPool": "0xBA5C9c886d65a969d02e40d7FBbADe316AD81E66", - "LearningCycleManager": "0x594b6EDD65EE13262cEddE14f3DeA5d62527615b", - "ClassroomRegistry": "0xe2b56b2BFcaeB3c8d14400184eAb01BBC980cC05", - "MentorMatcher": "0x19fc5C7f005c8416C5b6bddbBA634b20d9Dd56c7", - "ComputeVerifier": "0x55C3febF3c57e7f679CC773b1Cd87A5eE8a2c679", - "ComputeMarketplace": "0xA96a891b9a060788FBc93997D61A8a98D02e621d", - "ComputePool": "0x3e5969bDb10dD62ce48E84ad5FBe9CFE6F7D8AD4", - "HeartbeatMonitor": "0xBEaC9b2955D17fe14b5514CCb61aB58ac9089F65", - "DisputeResolution": "0xac5004C3282A6715712061bE7A1037362E9171f8", - "ComputePricingOracle": "0xdE15b71a7bD85499CAe26DF82407ee0bd0c3445D", - "StablecoinTreasury": "0x131fc17A9Df7c9e7B08c51B122D320018356A232", - "BulkComputeGateway": "0x244474F2E5fA35592ef60A873D356F9FEB4cb8Fa", - "TestnetFarmingAccounting": "0x19AaDBb6EDC2B9a2AF4B7f437c4E4bBD3778e487", - "TreasuryGovernor": "0x3848b933691d6dcd535D1a704E50657Fdb8C4604", - "ModelAccessControl": "0xaE0fC1a3E21afC38e781d9fB12F194dD71F53AA0", - "TEEAttestationRegistry": "0xDAC8b840A2e98A4cBEAfC413fad7DCd447D79Fb1", - "ComputePoolTraining": "0x17F0b25b8d8893f77bc5b55375BCdBd66F4cB8f3", - "InstitutionalVault": "0x3ec6473CF73bd8d4f8bC8Fb22aC923C5e0A09771", - "ClassroomClusterV1": "0x6b94b96485f789c8dDc4102A171bDafab41CE0b0", - "EduForwarder": "0x840e011A30e9082998e2094E83BeC7dC0E019bE3", - "BudgetAllocation": "0x8dC786BE442FF0A36794DDD201e7D220A97F733f", - "CashoutRequest": "0xBd8b17E21132B2bEaFdCf6FC2CbA998B33430517", - "AIModelRegistryPortable": "0xdA30A0408b1690AfA739fB63901a6608547F4dA6", - "AIInferenceRouterPortable": "0x5375785324843e8eAF024A6D2cf8CEE63496f0f1", - "AILearningCycleCorePortable": "0x197A32842aFA3775055f6F04da0c5aCaa09E0E72", - "CitrateMemberSBT": "0xf8aD11f6d3AeFA605e2EBF7C81ED08A2b38B3A3c", - "MemberBond": "0x7D6B92757e928ab4207Be3B54166Ecd2C491Aa92", - "MembershipStakeVaultImpl": "0x72035977F3Ec295C70e2A734AcbDFfB0C98E6F0b", - "MembershipStakeVault": "0xA93F7f688A8F212E0caD59eDBd1F7D3592151751", - "InstitutionTreeV1": "0xac4694986FE8E593A48D3f427E08Ac12Cc463c8F", - "ComplianceRegistry": "0xe3635aAE0B03e3c751b9E6375226E35995aD9154", - "FacilitySBTImpl": "0x58ac5816c42a3d293552Fe368C4db53b89edB02c", - "FacilitySBT": "0x47a65E700fdFBb5ccBCd64e0592D2EE58f1a8808", - "NetworkSBTImpl": "0x01D34046343a171ec7cd4DB8978adbaB095EF955", - "NetworkSBT": "0x82A14fA278055BFf7A50A82Acd00486a091E2C17", - "TenantHierarchy": "0xdf841E8C5F57fB18DE127c0223E98cE179C02c0d", - "ClassificationRegistry": "0x0084e573Bb22c3D72dc018A4d7bAC669828Ffa53", - "RoleEscalation": "0xCCbC5B7E8E3fe9a7C4cD3fe657800c58C42a4D4d", - "MultiSigEnvelope": "0xA32d82817c38Cf9baA052a9670050fece06Ddea2", - "AgentDecisionRegistryV2": "0x5F82681f959C9F1417bf59A18052CD2FEE9787eD", - "ContradictionLedger": "0x026BFbd2b9c7040BA40FD61Bdf893a72Ae8edCDb", - "QuorumAnchorRegistry": "0x9b743B49b1f7FEE8896C7BD0D1A8cE49652fe1e3", - "MeetingRegistry": "0x9A6Cdc2482730593b9E08D386157E0d41f129d4f", - "GovernanceTemplateRegistry": "0xd83eEa8e9C82a5E0ADdDd5b1aF5f646a8fa1489D", - "GovernanceProtocolFactory": "0xF6D1bf22317a7C986338d393243A33855eF28fB8", - "PolicyBinding": "0xCfCc444e54Ea3C855bDE630f0063D736d8E0ff90", - "CapabilityGrant": "0x250Ec3D18D9F0a7d99541A0B9C7Ae51469D7150a", - "VoteAllowance": "0x9E47dC72F831E6eb7304C77B3a6b26e41671F6fc", - "Sortition": "0xcdef59da0f6364959a79134b9c7421d98bB59bdC", - "PartProvenanceRegistry": "0x418D258AC8725C2048Ef3707aD22292387E5a072", - "SupplierRegistry": "0x391F6909Bc3DDc5dE73D4aFcaA7eb8800ACd7F0C", - "MoqRegistry": "0x58353A3b463Fc7aA1fAD167D793cD1A00126c252", - "DefensePrimeFLScopeIndex": "0xdcDf39d46E42BEB0C83f8847733E57Eda472b6F0", - "AppRegistry": "0x520AfE4F405B7501FBCCa8d2F13fb29a0946f963", - "CrossOrgIndex": "0x899DDb0663477A3Cd2b5D863d1334e73BA82913c", - "AuditBundleRegistry": "0xd21f20C8616D173704d0eF50967d1C3e7Ae04542", - "DefensePrimeComplianceRegistry": "0x0a320720cB00CEC119260058d1DBAFF3c6AFdE37", - "RoleGrantTenantIndex": "0x399193F404EA49a1bFeeB420Ec35A5bE9A2c9955", - "EntityRegistry": "0x34098cF54a3a3EB5324B079f83D424F178402f77", - "TinaWorkpaperRegistry": "0xfe13A08593fc91EADdeDbD26b4f381fEA0386a9A", - "CrossOrgEnvelope": "0x641E2F1AD7a30DeeF51c9596B3c32177d603ab7B", - "TripwireRegistry": "0x1aC7a2e53758ECB5eCaA2584eBF0428dC3b57Ba8", - "SponsorEvidenceRegistry": "0xE77e584c91196B7f3DE1090FA57ff9cAAe56098E", - "ReleaseManifestRegistry": "0x2C36d78F30207431EC641Fcc4D32e01F29c573E3", - "KYCRegistry": "0xCBd3e3DF59Ac1A710d1637A5A718ba42d4d63125", - "IPFSIncentivesV2": "0xF753903DAB5f891D9593c9c713374A923E80f930", - "IPFSIncentivesV3": "0xF5c115E6d88A960CC4f48129524C01004f067135", - "AggregationChallenge": "0x115a144495bE3fB4f426352BfB784BAea274603A", - "ComputePoolPipeline": "0x9A1A558AaA4a392FA1809F2767A2F5E6cb099551", - "OrganizationSBT": "0x423E4552E918A0bBCAe907A4109cc2E497C0FB1e", - "AgentSBT": "0x7c95195cfDF1F5D9444E7Cfb8B9C9869d9Fdc559", - "CapsuleRegistry": "0x88010Cc0778b9F48FeE167C86918aec8d9Ab46B2", - "AnchorRegistry": "0xAB87534EF027B52bb6233E889F758767127Bf47A", - "BenchmarkRegistry": "0x1231B7629D7FEBD3Bf24B0ce8788B944B10242c5", - "SkillRegistry": "0xbeDaD7a474785eCB27fc0Aa02ca583bdb3761A49", - "CitAgentTimelock": "0xC93d648F9D01D6c4D63F20F75E4d3e6910AeeFA4" + "ModelRegistry": "0x4c367f7742059b5bf39f6fbe5837ce6164afd33f", + "WrappedSALT": "0xaa918302b94a4b0e75e01e019cc6b819b4f7c906", + "AgentDecisionRegistry": "0x74eb3393d3be1b732adf5002334383dbfbe28f8d", + "SpecRegistry": "0xba9aa1ca90de62d0857839420491899abb2826d4", + "IPFSIncentives": "0xce5e13bf7c9d7320b4cafd0ef326febfba95dd52", + "X402Facilitator": "0x0c5167e9b1db3423ad2c068edc51da84db03dc44", + "X402Paywall": "0x30e1dcca7a222fdaeef2e1410d2d49ee8fb9ab44", + "ContributionAccounting": "0x621395beb46a6f59c0e3a94ba5248db94af1896f", + "NematocystSlashing": "0xce8dc4f9bd74729f275a84184cf245f5aad7cbf1", + "MarketMakerAllocation": "0x63dd6c064db0c54513b6711887a2c9284b755348", + "ModelMarketplace": "0xb7ea46dc2ecaed8595149b415f25e5b7d806278a", + "InferenceRouter": "0x165b811cdf1edd330d3ed77b87da38e9e0c9bd0f", + "LoRAFactory": "0x62e9aabec26288e60db2503f518eacf9cdcc8890", + "LearningPool": "0xba5c9c886d65a969d02e40d7fbbade316ad81e66", + "LearningCycleManager": "0xff400ad31c9f203d4a0a0d744833a135ac951268", + "ClassroomRegistry": "0xe2b56b2bfcaeb3c8d14400184eab01bbc980cc05", + "MentorMatcher": "0xa29d2ad464c579de580ce72951f1d01f48d49879", + "ComputeVerifier": "0xbaa6cc72886a5958e3902470fca17d471e563449", + "ComputeMarketplace": "0x2fec2ac1dde3a1a749c84d487738904cd2ed50c2", + "ComputePool": "0x7fbe2cb7c983d7b732fb2b8cb91fbbb2ee841787", + "HeartbeatMonitor": "0x7add85321992306e106a82345bc7867a5c557747", + "DisputeResolution": "0xbc3f09bd846bdb04f70b7b905fc5d34801b1f14a", + "ComputePricingOracle": "0xc3b66ef29726dfd3bb27e7c841efa664634a5027", + "StablecoinTreasury": "0xb2c19472903ab7d9298ac1dabdfd37b2e3b25158", + "BulkComputeGateway": "0x37a9dec7ea9b7e7ef6fd9d094540d22933ddd8d0", + "TestnetFarmingAccounting": "0x866b40857af3105043c1c932f8728a3058173839", + "TreasuryGovernor": "0xf3591f9ec1738508f648dfc252d4a3493a1dfd6c", + "InstitutionalVault": "0x9fea5d6bcfa82fa5879a7061381b40647aa67938", + "ClassroomClusterV1": "0x6015f82f95762e3a31205a4f6c391410652301f9", + "BudgetAllocation": "0xcc98a91155e170bdc4d92b2d425dd13b44cdb4cf", + "CashoutRequest": "0x82775eadc5803861f99d90b43dc6ebc0390edaff", + "AIModelRegistryPortable": "0xda30a0408b1690afa739fb63901a6608547f4da6", + "AIInferenceRouterPortable": "0x02912ede26224b5ab217f30a2b66e285d914f03d", + "AILearningCycleCorePortable": "0x696e330dfca80a870dbcaf4d0b08155d0b828e2c", + "ModelAccessControl": "0xb562faf611c903de86976f519401c967b8f8f97b", + "TEEAttestationRegistry": "0xa6b7a6c1e6a7388a29352bd12a26cbc25c63c4da", + "ComputePoolTraining": "0x736034d7a01f1e9c1812c6404564e64cf22decbc", + "ValidatorRegistry": "0xf511327ef586908bc34990621e3624d5de4131cd", + "CitrateMemberSBT": "0x883165132776171fbcfde2d3c789ef5bdb0d060d", + "MemberBond": "0x25118a63b614901c1871fef91b447602e11e1e9b", + "MembershipStakeVaultImpl": "0x46eae8b70644f5d3e0c67faa16a9f2383246f211", + "MembershipStakeVault": "0xd6632ea2179d047d2460f1de90abcc519839555e", + "EduForwarder": "0x9fb93ee5607accb7ac3cff7987f2ed9528caacce", + "GovernanceTimelock": "0x038bc87f5c0a0204127e47f4f6cc5348094dd467", + "GovernanceMultisig": "0x3975c796e3ca6825de3c8ac7dae0820a1c0b2c5c", + "GuardianTimelock": "0x1f1883c468a145d3dd8771a72182e74aa4c70200", + "GuardianMultisig": "0xc6ed3ed2a3a302f1ed2249fb43e94808c9fbaf1d", + "GuardianCanceller": "0xf84cb95142c876d44df1ea0c817838b440a4256d", + "TeamVesting_team_1": "0x2a60d582019e002dce914c8a24f28435605a47be", + "FederatedRoundLedger": "0x25fc02fbfad9589d0dde84f858d0962bfa0253c5", + "PatronageLedger": "0x41f68b9e710585b064459a0ec706148fbf503659", + "AgentDecisionRegistryV2": "0xbd7996c4fde4116cbeaf0c796378f45828b135b2", + "AgentSBT": "0xa087094ffaaf774c22d594520b39e3b1a66e32f2", + "AggregationChallenge": "0x88128d3f666f46f8d6aa5aa2e72c07379d2c560e", + "AnchorRegistry": "0xab87534ef027b52bb6233e889f758767127bf47a", + "BenchmarkRegistry": "0x1231b7629d7febd3bf24b0ce8788b944b10242c5", + "CapsuleRegistry": "0x46a3a5be60d919f30de3cb3d545709c557e9ef02", + "ComplianceRegistry": "0xd504c9ac2a36b6ac13150d14315e27977971c145", + "ComputePoolPipeline": "0xafa0a4a75b4e135930eff30c856612904bf3eafd", + "FacilitySBT": "0x5980214bea79d4174df69d604f913bd469adca4c", + "FacilitySBTImpl": "0x58ac5816c42a3d293552fe368c4db53b89edb02c", + "IPFSIncentivesV2": "0x23ae921cc49ed1dd3435696a274608605335b2dd", + "IPFSIncentivesV3": "0xc596caab803219f0f5b7e8639c0cfd3b3248b268", + "InstitutionTreeV1": "0x539315c9f43580d67e572b9ec305f5aa167f52c1", + "KYCRegistry": "0x4270895ff826b1093976f892fba8ae02fb8fdf5d", + "NetworkSBT": "0x7e7a5a040efc5d7f1900ecc25e0e7f775b417345", + "NetworkSBTImpl": "0x01d34046343a171ec7cd4db8978adbab095ef955", + "OrganizationSBT": "0x960bc81a48f29cf1912ac29b2b15203ebd35dda6", + "SkillRegistry": "0xbedad7a474785ecb27fc0aa02ca583bdb3761a49" }, "aaStack": { - "EntryPoint": "0x97d5391a647429233E202f99231743C53a648f3c", - "WebAuthnP256Validator": "0x0f421a99A0b8F6138Dea12F45A523Cb896D09fc7", - "CitrateECDSAValidator": "0xD2d35421379Ae5b461e216BFcdD1B7e6a64BBC40", - "GuardianRecoveryModule": "0x0A909769160C1945401b8f37a9310d37DbB6a891", - "CitrateWallet": "0x2D742B98D867Fc7363F530DD6d756622e4Eb768D", - "CitrateWalletFactory": "0x9Ac05AD65C9E8E48Fbb41dDE029512afA5346C85", - "CitratePaymaster": "0x804a8021cc1212798180bC9330f6467f883C392c" + "EntryPoint": "0x97d5391a647429233e202f99231743c53a648f3c", + "CitrateWallet": "0x2d742b98d867fc7363f530dd6d756622e4eb768d", + "CitrateWalletFactory": "0x6ee3b59099c9c812cdf1c9e7e6ff166b2558434c", + "CitratePaymaster": "0x5a1e2d73f8154a01a78b56e551c977cb8327ff56", + "WebAuthnP256Validator": "0x0f421a99a0b8f6138dea12f45a523cb896d09fc7", + "CitrateECDSAValidator": "0xd2d35421379ae5b461e216bfcdd1b7e6a64bbc40", + "GuardianRecoveryModule": "0x0a909769160c1945401b8f37a9310d37dbb6a891" }, "membership": { - "CitrateMemberSBT": "0xf8aD11f6d3AeFA605e2EBF7C81ED08A2b38B3A3c", - "MembershipStakeVault": "0xA93F7f688A8F212E0caD59eDBd1F7D3592151751" + "CitrateMemberSBT": "0x883165132776171fbcfde2d3c789ef5bdb0d060d", + "MembershipStakeVault": "0xd6632ea2179d047d2460f1de90abcc519839555e" }, "precompiles": { "ModelDeploy": "0x0000000000000000000000000000000000000100", @@ -164,10 +143,12 @@ "keyPrefix": "cgk_" }, "provenance": { - "deployer": "0x7DAbC319867fCcA7dE8A20809EfBd39BbF17Acdf", - "deployedAt": "2026-10-06T05:43:04Z", + "deployer": "0xe40325ba1f4cf362b203d9fe2b8160312e926c11", + "deployedAt": "2026-01-01T00:00:00Z", + "book": "citrate-chain/contracts/addresses/r1006/40204.json", + "genesisSource": "citrate-chain/core/consensus/src/hardening.rs R1006_GENESIS", "sourceHashes": { - "chainAddresses": "4ae8828a85c23b5a0b0d3652948e20b0705f35c4c1cd8a684be7d2ea8f405419", + "chainAddresses": "724e3843f677588a6aac680554dd1f2db768d5c3ca2bea18bdc0c173c100b90b", "entitlements": "d7c1167466eafbc3d2f8233c2adc32d310b4c73026b998e46e1b4f0c1fa461a1" } } diff --git a/citrate_sdk/client.py b/citrate_sdk/client.py index acc3cf0..1e0bf99 100644 --- a/citrate_sdk/client.py +++ b/citrate_sdk/client.py @@ -11,6 +11,7 @@ import requests +from ._chain_guard import UNSET, assert_genesis, resolve_expected_genesis from ._generated import contract as _contract from ._url_security import enforce_transport_security from .crypto import ( @@ -55,7 +56,7 @@ class CitrateClient: def __init__(self, rpc_url: str = "http://localhost:8545", private_key: str | None = None, allow_insecure_http: bool = False, timeout: float = 30.0, - chain_id: int | None = None, expected_genesis_hash: Any = None): + chain_id: int | None = None, expected_genesis_hash: Any = UNSET): """ Initialize Citrate client. @@ -81,11 +82,19 @@ def __init__(self, rpc_url: str = "http://localhost:8545", private_key: str | No cannot make the SDK produce a signature valid on another network (e.g. Ethereum mainnet). Pass an explicit value only when genuinely targeting a different Citrate network. + expected_genesis_hash: the block-0 hash the RPC must report before the + client signs a transaction. r1005 and r1006 share chain id 40204, + so the chain id alone cannot tell them apart. Defaults to the + vendored address book's genesis (r1006) when ``chain_id`` is the + vendored chain, and to no check for another ``chain_id``. Pass a + hash for another network, or ``None`` to skip (a local dev chain). """ self.timeout = timeout # SPY-B-005: the chain-id that domain-separates every signature is PINNED to # the vendored artifact (or an explicit override), never adopted from the RPC. self._expected_chain_id = int(chain_id) if chain_id is not None else _contract.chain_id() + self._expected_genesis = resolve_expected_genesis(expected_genesis_hash, self._expected_chain_id) + self._network_verified = False # SECREM-01 WEB-4: warn when an RPC endpoint sends signed transactions / # private inputs to a remote host over plaintext http://. rpc_url = enforce_transport_security(rpc_url, allow_insecure_http=allow_insecure_http) @@ -146,7 +155,16 @@ def _rpc_call(self, method: str, params: list[Any] | None = None) -> Any: raise CitrateError(f"Network error: {str(e)}") def verify_network(self) -> None: - return None + """Check that the RPC's block 0 is the expected genesis (see ``expected_genesis_hash``). + + Runs once per client (a failure is not cached) and before every transaction the + client signs. Raises ``WrongNetworkError`` on another network. + Data source: ``eth_getBlockByNumber(0)`` on ``rpc_url``. + """ + if self._expected_genesis is None or self._network_verified: + return + assert_genesis(self._rpc_call, self._expected_genesis) + self._network_verified = True def get_chain_id(self) -> int: """Get blockchain chain ID. @@ -487,6 +505,9 @@ def _send_transaction( if not self.key_manager: raise CitrateError("Private key required for transactions") + # r1006: refuse another network (same chain id, different genesis) before signing. + self.verify_network() + # Get account info from_address = self.key_manager.get_address() nonce = self.get_nonce(from_address) diff --git a/citrate_sdk/compute.py b/citrate_sdk/compute.py index 73dbd03..863f7e7 100644 --- a/citrate_sdk/compute.py +++ b/citrate_sdk/compute.py @@ -23,7 +23,12 @@ from typing import Any, cast -from ._chain_guard import UNSET, expected_chain_id, pinned_send +from ._chain_guard import ( + UNSET, + expected_chain_id, + pinned_send, + resolve_expected_genesis, +) from .abi import AbiInterface, enum_index, from_wei, to_wei from .errors import CitrateError, ConfigurationError, ValidationError from .types import ComputeJob, ComputePool, Dispute, ProviderInfo @@ -178,6 +183,8 @@ def __init__( self._rpc_call = rpc_call # PBA-L6b-042: writes assert eth_chainId against this before sending. self._expected_chain_id = expected_chain_id(chain_id) + # r1006: and block 0 against the vendored genesis (None = no check; see _chain_guard). + self._expected_genesis = resolve_expected_genesis(expected_genesis_hash, self._expected_chain_id) self._chain_verified = False self._default_account = default_account self._gas_limit = gas_limit diff --git a/citrate_sdk/farming.py b/citrate_sdk/farming.py index c987eea..5879799 100644 --- a/citrate_sdk/farming.py +++ b/citrate_sdk/farming.py @@ -22,7 +22,12 @@ from typing import Any, cast -from ._chain_guard import UNSET, expected_chain_id, pinned_send +from ._chain_guard import ( + UNSET, + expected_chain_id, + pinned_send, + resolve_expected_genesis, +) from .abi import AbiInterface from .errors import ConfigurationError @@ -75,6 +80,8 @@ def __init__( self._rpc_call = rpc_call # PBA-L6b-042: writes assert eth_chainId against this before sending. self._expected_chain_id = expected_chain_id(chain_id) + # r1006: and block 0 against the vendored genesis (None = no check; see _chain_guard). + self._expected_genesis = resolve_expected_genesis(expected_genesis_hash, self._expected_chain_id) self._chain_verified = False self._default_account = default_account self._gas_limit = gas_limit diff --git a/citrate_sdk/identity/wallet.py b/citrate_sdk/identity/wallet.py index bf2df75..c5ce97f 100644 --- a/citrate_sdk/identity/wallet.py +++ b/citrate_sdk/identity/wallet.py @@ -16,6 +16,7 @@ import requests from eth_utils import keccak, to_checksum_address +from .._chain_guard import UNSET, assert_genesis, is_unset, resolve_expected_genesis from .._generated import contract as _contract from .._url_security import enforce_transport_security @@ -87,6 +88,7 @@ def verify_wallet_address_on_chain( *, chain_id: int | None = None, allow_insecure_http: bool = False, + expected_genesis_hash: Any = UNSET, ) -> str: """Verify the local prediction against the on-chain factory (ground truth). @@ -99,6 +101,11 @@ def verify_wallet_address_on_chain( the pinned federation chain), and the factory must have code there — so a MITM or a wrong-chain RPC cannot make "verified" pass by echoing the publicly computable prediction. + + r1006: the same chain id is not the same network (r1005 and r1006 are both 40204). + When neither ``factory`` nor ``chain_id`` is overridden (the vendored book is being + verified), block 0 must be the book's genesis; ``expected_genesis_hash`` overrides it and + ``None`` skips it. Raises ``WrongNetworkError`` otherwise. """ local = predict_wallet_address(user_id, factory=factory, implementation=implementation) aa = _contract.aa_stack() @@ -116,6 +123,14 @@ def verify_wallet_address_on_chain( f"RPC is on chain {reported_chain}, expected chain {expected_chain} — refusing to verify" ) + uses_book = factory is None and chain_id is None + if uses_book or not is_unset(expected_genesis_hash): + genesis = resolve_expected_genesis( + expected_genesis_hash, _contract.chain_id() if uses_book else expected_chain + ) + if genesis is not None: + assert_genesis(lambda method, params: _rpc(url, method, params, timeout), genesis) + code = _rpc(url, "eth_getCode", [f, "latest"], timeout) if not isinstance(code, str) or code.lower() in ("", "0x", "0x0"): raise WalletPredictionError(f"factory {f} has no code on chain {expected_chain} — refusing to verify") diff --git a/citrate_sdk/learning.py b/citrate_sdk/learning.py index bdc8137..19478eb 100644 --- a/citrate_sdk/learning.py +++ b/citrate_sdk/learning.py @@ -27,7 +27,12 @@ from eth_account.signers.local import LocalAccount from eth_utils import keccak -from ._chain_guard import UNSET, expected_chain_id, pinned_send +from ._chain_guard import ( + UNSET, + expected_chain_id, + pinned_send, + resolve_expected_genesis, +) from .abi import AbiInterface, enum_index, from_wei, to_wei from .errors import ConfigurationError from .types import ( @@ -245,6 +250,8 @@ def __init__( self._rpc_call = rpc_call # PBA-L6b-042: writes assert eth_chainId against this before sending. self._expected_chain_id = expected_chain_id(chain_id) + # r1006: and block 0 against the vendored genesis (None = no check; see _chain_guard). + self._expected_genesis = resolve_expected_genesis(expected_genesis_hash, self._expected_chain_id) self._chain_verified = False self._default_account = default_account self._gas_limit = gas_limit @@ -534,6 +541,8 @@ def __init__( self._rpc_call = rpc_call # PBA-L6b-042: writes assert eth_chainId against this before sending. self._expected_chain_id = expected_chain_id(chain_id) + # r1006: and block 0 against the vendored genesis (None = no check; see _chain_guard). + self._expected_genesis = resolve_expected_genesis(expected_genesis_hash, self._expected_chain_id) self._chain_verified = False self._default_account = default_account self._gas_limit = gas_limit @@ -543,7 +552,11 @@ def __init__( def _require_address(self) -> str: if not self._staking_address: - raise ConfigurationError("LiquidStakingPool contract address not configured.") + raise ConfigurationError( + "LiquidStakingPool contract address not configured. LiquidStakingPool is not in the " + "r1006 address book (it is not deployed in r1006 block 0), so there is no default; " + "pass staking_address only for a network that has it." + ) return self._staking_address def deposit(self, amount: str) -> str: @@ -707,6 +720,8 @@ def __init__( self._rpc_call = rpc_call # PBA-L6b-042: writes assert eth_chainId against this before sending. self._expected_chain_id = expected_chain_id(chain_id) + # r1006: and block 0 against the vendored genesis (None = no check; see _chain_guard). + self._expected_genesis = resolve_expected_genesis(expected_genesis_hash, self._expected_chain_id) self._chain_verified = False self._default_account = default_account self._gas_limit = gas_limit diff --git a/citrate_sdk/treasury.py b/citrate_sdk/treasury.py index 4be18d1..6bccdbb 100644 --- a/citrate_sdk/treasury.py +++ b/citrate_sdk/treasury.py @@ -21,7 +21,12 @@ from typing import Any, cast -from ._chain_guard import UNSET, expected_chain_id, pinned_send +from ._chain_guard import ( + UNSET, + expected_chain_id, + pinned_send, + resolve_expected_genesis, +) from .abi import AbiInterface from .errors import ConfigurationError @@ -80,6 +85,8 @@ def __init__( self._rpc_call = rpc_call # PBA-L6b-042: writes assert eth_chainId against this before sending. self._expected_chain_id = expected_chain_id(chain_id) + # r1006: and block 0 against the vendored genesis (None = no check; see _chain_guard). + self._expected_genesis = resolve_expected_genesis(expected_genesis_hash, self._expected_chain_id) self._chain_verified = False self._default_account = default_account self._gas_limit = gas_limit diff --git a/pyproject.toml b/pyproject.toml index 5e76946..04d3c14 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ build-backend = "setuptools.build_meta" [project] name = "citrate-labs-sdk" -version = "0.6.4" +version = "0.7.0" description = "Python SDK for the Citrate distributed AI network (chain 40204). The canonical TypeScript SDK is @citratelabs/sdk on npm; this Python client is opt-in and may lag it." readme = "README.md" requires-python = ">=3.10" diff --git a/scripts/check-contract-drift.sh b/scripts/check-contract-drift.sh index b6c35d0..0ba60fa 100755 --- a/scripts/check-contract-drift.sh +++ b/scripts/check-contract-drift.sh @@ -13,7 +13,8 @@ # .github/workflows/ci.yml (CL-C2 / SW-092). set -euo pipefail HERE="$(cd "$(dirname "$0")/.." && pwd)" -CANONICAL="$HERE/../citrate-federation/contract/federation-contract.json" +FED_DIR="${CITRATE_FEDERATION_DIR:-$HERE/../citrate-federation}" +CANONICAL="$FED_DIR/contract/federation-contract.json" if [ ! -f "$CANONICAL" ]; then echo "[contract-drift] federation sibling not found at $CANONICAL" diff --git a/scripts/sync_contract.py b/scripts/sync_contract.py index 0c0751f..4fd4c6d 100644 --- a/scripts/sync_contract.py +++ b/scripts/sync_contract.py @@ -9,17 +9,24 @@ python scripts/sync_contract.py # overwrite the vendored copy (verbatim) python scripts/sync_contract.py --check # CI gate: non-zero if the vendored copy drifted +CITRATE_FEDERATION_DIR points at another citrate-federation checkout (e.g. a branch worktree) +instead of the sibling. + The copy is byte-verbatim so the drift check is exact across languages. """ from __future__ import annotations import json +import os +import re import sys from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent LABS_ROOT = REPO_ROOT.parent -CANONICAL = LABS_ROOT / "citrate-federation" / "contract" / "federation-contract.json" +FEDERATION_DIR = Path(os.environ["CITRATE_FEDERATION_DIR"]).resolve() if os.environ.get( + "CITRATE_FEDERATION_DIR") else LABS_ROOT / "citrate-federation" +CANONICAL = FEDERATION_DIR / "contract" / "federation-contract.json" VENDORED = REPO_ROOT / "citrate_sdk" / "_generated" / "federation_contract.json" @@ -43,6 +50,10 @@ def main() -> int: if doc.get("chain", {}).get("chainId") != 40204: print(f"[sync_contract] chainId {doc.get('chain', {}).get('chainId')}, expected 40204", file=sys.stderr) return 1 + if not re.fullmatch(r"0x[0-9a-f]{64}", str(doc.get("chain", {}).get("genesisHash", ""))): + print(f"[sync_contract] chain.genesisHash {doc.get('chain', {}).get('genesisHash')!r} is not a " + "32-byte lowercase hex hash", file=sys.stderr) + return 1 if check: if not VENDORED.exists(): @@ -60,7 +71,8 @@ def main() -> int: VENDORED.parent.mkdir(parents=True, exist_ok=True) VENDORED.write_text(raw, encoding="utf-8") - print(f"[sync_contract] wrote {VENDORED} (EntryPoint {doc['aaStack']['EntryPoint']})") + print(f"[sync_contract] wrote {VENDORED} (EntryPoint {doc['aaStack']['EntryPoint']}, " + f"genesis {doc['chain']['genesisHash']})") return 0 diff --git a/tests/_chain_rpc.py b/tests/_chain_rpc.py index 5e315fd..92c742e 100644 --- a/tests/_chain_rpc.py +++ b/tests/_chain_rpc.py @@ -1,7 +1,8 @@ -"""Test helper: an rpc_call mock that answers eth_chainId with the pinned chain. +"""Test helper: an rpc_call mock that answers like a node of the vendored network. -PBA-L6b-042 made every manager write assert ``eth_chainId`` before sending, so -write-path mocks must answer it like a real 40204 node does. +PBA-L6b-042 made every manager write assert ``eth_chainId`` before sending, and the r1006 +genesis guard reads block 0 too (r1005 and r1006 share chain id 40204), so write-path mocks +must answer both like a real node of the network the vendored book is for. """ from __future__ import annotations @@ -11,5 +12,14 @@ from citrate_sdk._generated import contract as _contract +def node_answer(method: str) -> Any: + """The vendored network's answer for the identity RPCs, or ``None`` for anything else.""" + if method == "eth_chainId": + return hex(_contract.chain_id()) + if method == "eth_getBlockByNumber": + return {"number": "0x0", "hash": _contract.genesis_hash()} + return None + + def chain_rpc(result: Any) -> MagicMock: - return MagicMock(side_effect=lambda method, params: hex(_contract.chain_id()) if method == "eth_chainId" else result) + return MagicMock(side_effect=lambda method, params: node_answer(method) or result) diff --git a/tests/test_classroom_invite_key.py b/tests/test_classroom_invite_key.py index 2231018..6864206 100644 --- a/tests/test_classroom_invite_key.py +++ b/tests/test_classroom_invite_key.py @@ -21,6 +21,7 @@ from eth_account.messages import encode_defunct from eth_utils import keccak, to_checksum_address +from citrate_sdk._generated import contract as _contract from citrate_sdk.abi import keccak256 from citrate_sdk.learning import ClassroomManager @@ -38,6 +39,8 @@ def _mgr(account: str, code_to_teacher: dict[bytes, str] | None = None, chain: i sent: list[Any] = [] def rpc(method: str, params: Any) -> Any: + if method == "eth_getBlockByNumber": # r1006 genesis guard reads block 0 + return {"number": "0x0", "hash": _contract.genesis_hash()} if method == "eth_chainId": return hex(chain) if method == "eth_sendTransaction": diff --git a/tests/test_hardening_round4.py b/tests/test_hardening_round4.py index dcc70c3..6271ecd 100644 --- a/tests/test_hardening_round4.py +++ b/tests/test_hardening_round4.py @@ -20,6 +20,7 @@ import pytest from citrate_sdk import KeyManager, crypto +from citrate_sdk._generated import contract as _contract from citrate_sdk.crypto import assert_no_key_share_material from citrate_sdk.errors import CitrateError from citrate_sdk.learning import ClassroomManager @@ -33,6 +34,8 @@ def _mgr() -> tuple[ClassroomManager, list[Any]]: sent: list[Any] = [] def rpc(method: str, params: Any) -> Any: + if method == "eth_getBlockByNumber": # r1006 genesis guard reads block 0 + return {"number": "0x0", "hash": _contract.genesis_hash()} if method == "eth_chainId": return hex(40204) sent.append(params) diff --git a/tests/test_payload_guard.py b/tests/test_payload_guard.py index 5c9841a..449c7bd 100644 --- a/tests/test_payload_guard.py +++ b/tests/test_payload_guard.py @@ -13,6 +13,7 @@ import rlp # type: ignore[import-untyped] from citrate_sdk import CitrateClient +from citrate_sdk._generated import contract as _contract from citrate_sdk.errors import CitrateError from citrate_sdk.models import ModelConfig @@ -48,7 +49,8 @@ def rpc(method: str, params: Any = None) -> Any: if method == "eth_sendRawTransaction": box["raw"] = params[0] return "0x" + "ab" * 32 - return {"eth_getTransactionCount": "0x0", "eth_chainId": hex(40204), "eth_gasPrice": "0x1"}[method] + return {"eth_getTransactionCount": "0x0", "eth_chainId": hex(40204), "eth_gasPrice": "0x1", + "eth_getBlockByNumber": {"number": "0x0", "hash": _contract.genesis_hash()}}[method] stubs: dict[str, Any] = {"_rpc_call": rpc, "_upload_to_ipfs": lambda d: "bafy", "_wait_for_receipt": lambda h: {"logs": []}, diff --git a/tests/test_pba_r2_l6b_027_wallet_verify.py b/tests/test_pba_r2_l6b_027_wallet_verify.py index 58c5dc9..2facdc9 100644 --- a/tests/test_pba_r2_l6b_027_wallet_verify.py +++ b/tests/test_pba_r2_l6b_027_wallet_verify.py @@ -31,6 +31,8 @@ def fake_post(url: str, data: str | None = None, headers: Any = None, timeout: A methods.append(body["method"]) if body["method"] == "eth_chainId": result: Any = hex(chain_id) + elif body["method"] == "eth_getBlockByNumber": # r1006 genesis guard reads block 0 + result = {"number": "0x0", "hash": _contract.genesis_hash()} elif body["method"] == "eth_getCode": result = code elif body["method"] == "eth_call": @@ -81,7 +83,8 @@ def test_happy_path_checks_chain_then_code_then_prediction() -> None: with mock.patch.object(wallet.requests, "post", post): assert wallet.verify_wallet_address_on_chain(UID, rpc_url="https://rpc.example") == \ wallet.predict_wallet_address(UID) - assert methods == ["eth_chainId", "eth_getCode", "eth_call"] + # r1006: block 0 (the genesis guard) is read between the chain id and the factory code. + assert methods == ["eth_chainId", "eth_getBlockByNumber", "eth_getCode", "eth_call"] def test_expected_chain_defaults_to_the_artifact_and_is_overridable() -> None: diff --git a/tests/test_pba_r2_l6b_028_040_classroom.py b/tests/test_pba_r2_l6b_028_040_classroom.py index 521b85d..5f883a9 100644 --- a/tests/test_pba_r2_l6b_028_040_classroom.py +++ b/tests/test_pba_r2_l6b_028_040_classroom.py @@ -24,6 +24,7 @@ from eth_account import Account from eth_utils import keccak +from citrate_sdk._generated import contract as _contract from citrate_sdk.abi import AbiInterface, keccak256, keccak256_text from citrate_sdk.learning import CLASSROOM_REGISTRY_ABI, ClassroomManager @@ -56,6 +57,8 @@ def rpc(method: str, params: Any) -> Any: if method == "eth_sendTransaction": sent["tx"] = params[0] return "0xhash" + if method == "eth_getBlockByNumber": # r1006 genesis guard reads block 0 + return {"number": "0x0", "hash": _contract.genesis_hash()} if method == "eth_chainId": return hex(40204) if method == "eth_call": diff --git a/tests/test_pba_r2_l6b_031_enums.py b/tests/test_pba_r2_l6b_031_enums.py index a07b0f2..154e979 100644 --- a/tests/test_pba_r2_l6b_031_enums.py +++ b/tests/test_pba_r2_l6b_031_enums.py @@ -12,12 +12,15 @@ import pytest +from citrate_sdk._generated import contract as _contract from citrate_sdk.compute import ComputeManager from citrate_sdk.learning import LearningManager def _rpc(sent: list[Any]) -> Any: def rpc(method: str, params: Any) -> Any: + if method == "eth_getBlockByNumber": # r1006 genesis guard reads block 0 + return {"number": "0x0", "hash": _contract.genesis_hash()} if method == "eth_chainId": return hex(40204) sent.append((method, params)) diff --git a/tests/test_pba_r2_l6b_042_chain_pin.py b/tests/test_pba_r2_l6b_042_chain_pin.py index 54acce5..ae0c1a1 100644 --- a/tests/test_pba_r2_l6b_042_chain_pin.py +++ b/tests/test_pba_r2_l6b_042_chain_pin.py @@ -13,6 +13,7 @@ import pytest +from citrate_sdk._generated import contract as _contract from citrate_sdk.compute import ComputeManager from citrate_sdk.errors import CitrateError from citrate_sdk.farming import FarmingManager @@ -29,6 +30,8 @@ def _rpc(chain: int) -> tuple[Any, list[str]]: def rpc(method: str, params: Any) -> Any: methods.append(method) + if method == "eth_getBlockByNumber": # r1006 genesis guard reads block 0 + return {"number": "0x0", "hash": _contract.genesis_hash()} if method == "eth_chainId": return hex(chain) return "0xhash" @@ -58,6 +61,8 @@ def test_right_chain_sends_with_chain_id_field(name: str, write: Any) -> None: sent: list[Any] = [] def rpc(method: str, params: Any) -> Any: + if method == "eth_getBlockByNumber": # r1006 genesis guard reads block 0 + return {"number": "0x0", "hash": _contract.genesis_hash()} if method == "eth_chainId": return hex(40204) sent.append(params[0]) diff --git a/tests/test_pba_r2_l6b_keyshares.py b/tests/test_pba_r2_l6b_keyshares.py index 6c98998..2c46d1f 100644 --- a/tests/test_pba_r2_l6b_keyshares.py +++ b/tests/test_pba_r2_l6b_keyshares.py @@ -26,6 +26,7 @@ from cryptography.hazmat.primitives.ciphers.aead import AESGCM from citrate_sdk import CitrateClient, KeyManager +from citrate_sdk._generated import contract as _contract from citrate_sdk.crypto import EncryptionConfig from citrate_sdk.errors import CitrateError from citrate_sdk.finite_field import ( @@ -56,6 +57,8 @@ def _client(tmp_path: Path) -> tuple[CitrateClient, dict[str, Any], Path]: def fake_rpc(method: str, params: Any = None) -> Any: if method == "eth_getTransactionCount": return "0x0" + if method == "eth_getBlockByNumber": # r1006 genesis guard reads block 0 + return {"number": "0x0", "hash": _contract.genesis_hash()} if method == "eth_chainId": return hex(40204) if method == "eth_gasPrice": diff --git a/tests/test_pba_r2_mutation_hardening.py b/tests/test_pba_r2_mutation_hardening.py index 843e406..8871e82 100644 --- a/tests/test_pba_r2_mutation_hardening.py +++ b/tests/test_pba_r2_mutation_hardening.py @@ -26,6 +26,7 @@ from citrate_sdk import CitrateClient, KeyManager from citrate_sdk._chain_guard import pinned_send from citrate_sdk._generated import contract +from citrate_sdk._generated import contract as _contract from citrate_sdk._url_security import InsecureTransportError, enforce_transport_security from citrate_sdk.crypto import ( EncryptionConfig, @@ -180,7 +181,8 @@ def rpc(method: str, params: Any = None) -> Any: if method == "eth_sendRawTransaction": box["raw"] = params[0] return "0x" + "ab" * 32 - return {"eth_getTransactionCount": "0x0", "eth_chainId": hex(40204), "eth_gasPrice": "0x1"}[method] + return {"eth_getTransactionCount": "0x0", "eth_chainId": hex(40204), "eth_gasPrice": "0x1", + "eth_getBlockByNumber": {"number": "0x0", "hash": _contract.genesis_hash()}}[method] stubs: dict[str, Any] = {"_rpc_call": rpc, "_upload_to_ipfs": lambda d: "bafy", "_wait_for_receipt": lambda h: {"logs": []}, @@ -202,6 +204,8 @@ class M: _chain_verified = False def _rpc_call(self, method: str, params: Any) -> Any: + if method == "eth_getBlockByNumber": # r1006 genesis guard reads block 0 + return {"number": "0x0", "hash": _contract.genesis_hash()} if method == "eth_chainId": return 40204 sent.append(params[0]) @@ -216,6 +220,8 @@ def _rpc_capture(result: str = "0xhash") -> tuple[Any, list[Any]]: sent: list[Any] = [] def rpc(method: str, params: Any) -> Any: + if method == "eth_getBlockByNumber": # r1006 genesis guard reads block 0 + return {"number": "0x0", "hash": _contract.genesis_hash()} if method == "eth_chainId": return hex(40204) if method == "eth_sendTransaction": @@ -365,8 +371,10 @@ def post(url: str, data: str | None = None, headers: Any = None, timeout: Any = r._content = json.dumps({"jsonrpc": "2.0", "id": 1, "error": {"code": -1}}).encode() return r m = body["method"] - if m == "eth_chainId": - res: Any = hex(chain) + if m == "eth_getBlockByNumber": # r1006 genesis guard reads block 0 + res: Any = {"number": "0x0", "hash": _contract.genesis_hash()} + elif m == "eth_chainId": + res = hex(chain) elif m == "eth_getCode": res = code else: @@ -387,7 +395,8 @@ def test_request_shape(self) -> None: assert url == "https://rpc.example" and headers == {"content-type": "application/json"} and timeout == 10.0 from eth_utils import keccak assert body["params"] == [{"to": factory, "data": "0x" + keccak(b"predictAddress(bytes32)")[:4].hex() + "42" * 32}, "latest"] - assert calls[1][1]["params"] == [factory, "latest"] + assert calls[1][1]["params"] == ["0x0", False] # r1006 genesis guard: block 0 + assert calls[2][1]["params"] == [factory, "latest"] def test_rpc_error_object_is_refused(self) -> None: post, _ = _wallet_post(error=True) @@ -644,15 +653,19 @@ def test_rpc_call_shapes_and_custom_implementation(self) -> None: def post(url: str, data: str | None = None, headers: Any = None, timeout: Any = None) -> requests.Response: body = json.loads(data or "{}") calls.append((url, body, timeout)) - res = {"eth_chainId": hex(40204), "eth_getCode": "0x60"}.get(body["method"], "0x" + "00" * 12 + local[2:].lower()) + res = {"eth_chainId": hex(40204), "eth_getCode": "0x60", + "eth_getBlockByNumber": {"hash": _contract.genesis_hash()}}.get( + body["method"], "0x" + "00" * 12 + local[2:].lower()) r = requests.Response() r.status_code = 200 r._content = json.dumps({"jsonrpc": "2.0", "id": 1, "result": res}).encode() return r with mock.patch.object(wallet.requests, "post", post): assert wallet.verify_wallet_address_on_chain(UID, rpc_url="https://r.example", implementation=impl, timeout=3.0) == local - assert [(c[0], c[1]["method"], c[1]["params"], c[2]) for c in calls[:2]] == [ - ("https://r.example", "eth_chainId", [], 3.0), ("https://r.example", "eth_getCode", [f, "latest"], 3.0)] + assert [(c[0], c[1]["method"], c[1]["params"], c[2]) for c in calls[:3]] == [ + ("https://r.example", "eth_chainId", [], 3.0), + ("https://r.example", "eth_getBlockByNumber", ["0x0", False], 3.0), + ("https://r.example", "eth_getCode", [f, "latest"], 3.0)] assert all(c[1]["jsonrpc"] == "2.0" for c in calls) @@ -669,7 +682,8 @@ def rpc(method: str, params: Any = None) -> Any: if method == "eth_sendRawTransaction": box["raw"] = params[0] return "0x" + "cd" * 32 - return {"eth_getTransactionCount": "0x0", "eth_chainId": hex(40204), "eth_gasPrice": "0x1"}[method] + return {"eth_getTransactionCount": "0x0", "eth_chainId": hex(40204), "eth_gasPrice": "0x1", + "eth_getBlockByNumber": {"number": "0x0", "hash": _contract.genesis_hash()}}[method] def wait(h: str) -> Any: assert h == "0x" + "cd" * 32 diff --git a/tests/test_r1006_network_guard.py b/tests/test_r1006_network_guard.py index 1698796..ae11630 100644 --- a/tests/test_r1006_network_guard.py +++ b/tests/test_r1006_network_guard.py @@ -18,8 +18,9 @@ from citrate_sdk import CitrateClient from citrate_sdk._generated import contract from citrate_sdk.compute import ComputeManager -from citrate_sdk.errors import CitrateError, WrongNetworkError +from citrate_sdk.errors import CitrateError, ConfigurationError, WrongNetworkError from citrate_sdk.identity import wallet +from citrate_sdk.learning import StakingManager R1006 = "0xa3651a272fe411948cbb4cf7cca4cee44be4248d32ab4376cea7636821c6cdd4" R1005 = "0x1dcfc4909dd9fdcb0f03c823d627e4b2032cc86bef5fc4561030d6c64b9775f1" @@ -47,6 +48,11 @@ def test_book_drops_contracts_not_in_r1006_block_0() -> None: assert gone not in names +def test_staking_manager_says_liquid_staking_is_not_in_r1006() -> None: + with pytest.raises(ConfigurationError, match="not in the r1006 address book"): + StakingManager(_manager_rpc(R1006)[0], default_account=ACCT).deposit("1") + + def test_no_r1005_address_leaks() -> None: blob = json.dumps(contract.federation_contract()).lower() for dead in ("0x9ac05ad6", "0x804a8021", "0xf8ad11f6", "0xa93f7f68", "0x73270e98", "0xa96a891b"): diff --git a/tests/test_r2_compute_compat.py b/tests/test_r2_compute_compat.py index d3d87f2..3b63f0a 100644 --- a/tests/test_r2_compute_compat.py +++ b/tests/test_r2_compute_compat.py @@ -71,6 +71,8 @@ def __init__(self, calls: dict[str, int] | None = None, block: int = 1_000, def __call__(self, method: str, params: list[Any]) -> Any: assert isinstance(params, list), f"{method}: params must be a JSON array" + if method == "eth_getBlockByNumber": # r1006 genesis guard reads block 0 + return {"number": "0x0", "hash": _contract.genesis_hash()} if method == "eth_chainId": return hex(self.chain_id) if method == "eth_blockNumber": diff --git a/uv.lock b/uv.lock index 9966e4b..92ba110 100644 --- a/uv.lock +++ b/uv.lock @@ -590,7 +590,7 @@ wheels = [ [[package]] name = "citrate-labs-sdk" -version = "0.6.4" +version = "0.7.0" source = { editable = "." } dependencies = [ { name = "cryptography" }, From dd5be49d54389f1dfcb8b0c51bef5603ca05c087 Mon Sep 17 00:00:00 2001 From: Larry Klosowski Date: Tue, 27 Oct 2026 21:39:07 -0700 Subject: [PATCH 3/5] test(network): manager genesis failure is not cached; unreadable block 0 refuses - test_manager_failure_is_not_cached: two writes on a wrong-genesis node both refuse, block 0 is read twice, nothing is sent, the manager stays unverified. - test_manager_rechecks_after_a_failure_and_then_caches_success: fail, then the node reports r1006 and the next write sends; success is then cached. - test_manager_fails_closed_when_block_0_is_unreadable (RPC error, None, ""), twice in a row. Red against the mutant that marks the manager verified before the genesis check (5 of 5 fail); green at a1d832b, whose code is correct. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FPJzHLHkCexnt8jGuPzvMn Signed-off-by: Larry Klosowski --- tests/test_r1006_network_guard.py | 52 +++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/test_r1006_network_guard.py b/tests/test_r1006_network_guard.py index ae11630..6db0fee 100644 --- a/tests/test_r1006_network_guard.py +++ b/tests/test_r1006_network_guard.py @@ -169,6 +169,58 @@ def test_manager_write_sends_on_r1006_and_checks_once() -> None: assert methods.count("eth_getBlockByNumber") == 1 and methods.count("eth_sendTransaction") == 2 +def _switching_manager_rpc(answers: list[Any]) -> tuple[Any, list[str]]: + """A node whose block 0 answers come from ``answers`` in turn (an Exception is raised).""" + methods: list[str] = [] + + def rpc(method: str, params: Any) -> Any: + methods.append(method) + if method == "eth_chainId": + return hex(40204) + if method == "eth_getBlockByNumber": + a = answers[min(methods.count("eth_getBlockByNumber"), len(answers)) - 1] + if isinstance(a, Exception): + raise a + return {"hash": a} + return "0xhash" + + return rpc, methods + + +def test_manager_failure_is_not_cached() -> None: + """A failed genesis check must not mark the manager verified: a second write refuses too.""" + rpc, methods = _manager_rpc(R1005) + mgr = ComputeManager(rpc, default_account=ACCT, contract_addresses=ADDRS) + for _ in range(2): + with pytest.raises(WrongNetworkError): + mgr.heartbeat() + assert "eth_sendTransaction" not in methods + assert methods.count("eth_getBlockByNumber") == 2 + assert mgr._chain_verified is False + + +def test_manager_rechecks_after_a_failure_and_then_caches_success() -> None: + rpc, methods = _switching_manager_rpc([R1005, R1006]) + mgr = ComputeManager(rpc, default_account=ACCT, contract_addresses=ADDRS) + with pytest.raises(WrongNetworkError): + mgr.heartbeat() + assert "eth_sendTransaction" not in methods + mgr.heartbeat() + mgr.heartbeat() + assert methods.count("eth_getBlockByNumber") == 2 + assert methods.count("eth_sendTransaction") == 2 + + +@pytest.mark.parametrize("answer", [CitrateError("Network error: down"), None, ""]) +def test_manager_fails_closed_when_block_0_is_unreadable(answer: Any) -> None: + rpc, methods = _switching_manager_rpc([answer]) + mgr = ComputeManager(rpc, default_account=ACCT, contract_addresses=ADDRS) + for _ in range(2): + with pytest.raises(WrongNetworkError): + mgr.heartbeat() + assert "eth_sendTransaction" not in methods + + def test_manager_override_and_opt_out() -> None: rpc, methods = _manager_rpc(R1005) ComputeManager(rpc, default_account=ACCT, contract_addresses=ADDRS, expected_genesis_hash=None).heartbeat() From e3103d8ed1e02d8d6cf49dbcb1d4390210782d6c Mon Sep 17 00:00:00 2001 From: Larry Klosowski Date: Tue, 27 Oct 2026 21:39:08 -0700 Subject: [PATCH 4/5] fix(tripwire): built-wheel key-share check keeps the genesis guard on scripts/check_keyshare_wheel.py: the capturing RPC answers block 0 with the wheel's vendored genesis_hash(), so deploy_model passes the guard and the key-share assertions run again. It also requires a client on another genesis to refuse with WRONG_NETWORK before sending, requires the real deploy to have read block 0, and points the client at port 1 (never a node; every RPC is stubbed). CHANGELOG: test counts and the manager-read scope. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FPJzHLHkCexnt8jGuPzvMn Signed-off-by: Larry Klosowski --- CHANGELOG.md | 14 ++++-- scripts/check_keyshare_wheel.py | 78 +++++++++++++++++++++++---------- 2 files changed, 65 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfa3411..0289b64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,16 +31,22 @@ Held for the r1006 public cutover: publish only after rpc.citrate.ai serves r100 raises `WrongNetworkError` (`error_code="WRONG_NETWORK"`) unless the hash is the vendored genesis. `client.verify_network()` runs the check up front. `expected_genesis_hash=` overrides the hash; `None` skips the check (a local devnet). With a non-vendored `chain_id=` the check is - off unless a hash is given. + off unless a hash is given. Manager reads (`eth_call` views) are not gated: the managers take + their contract addresses from the caller, not from the vendored book. - `contract.genesis_hash()`. - `scripts/sync_contract.py` and `scripts/check-contract-drift.sh` honour `CITRATE_FEDERATION_DIR`, and the sync refuses an artifact without a 32-byte `genesisHash`. ### Tests -- `tests/test_r1006_network_guard.py` (19 tests): the r1006 book, no r1005 address, the - LiquidStakingPool message, and the guard on every path (wrong genesis, missing or unreadable - block 0, failure not cached, opt-out, override). +- `tests/test_r1006_network_guard.py` (24 tests): the r1006 book, no r1005 address, the + LiquidStakingPool message, and the guard on every path: wrong genesis, missing or unreadable + block 0 (client and managers, fails closed), a failed check not cached (client and managers: + a second write after a failure refuses too; fail then succeed reads block 0 twice and a + success is then cached), opt-out, override. +- `scripts/check_keyshare_wheel.py` (built wheel) answers block 0 with the wheel's vendored + `genesis_hash()`, so the key-share checks run with the guard on, and it also requires the + wheel's client to refuse another genesis before sending. - Wallet-prediction vector `predictAddress(0x4242…)` = `0xEFf9CC17…` for the r1006 factory, computed offline; the same formula reproduces r1005's live `0x6Ef76179…`, which stays pinned as a formula check. Re-read it from the live factory after the cutover. diff --git a/scripts/check_keyshare_wheel.py b/scripts/check_keyshare_wheel.py index 4bafe0c..2eb7108 100644 --- a/scripts/check_keyshare_wheel.py +++ b/scripts/check_keyshare_wheel.py @@ -10,6 +10,9 @@ the model key (scripts/keyshare_leak_tripwire.py); 3. the holders must still rebuild the key from their envelopes. +The wheel's genesis guard stays on: the capturing RPC answers block 0 with the wheel's +vendored genesis, and a client on another genesis must refuse before sending. + Usage: python scripts/check_keyshare_wheel.py [path/to/citrate_labs_sdk-*.whl] """ from __future__ import annotations @@ -62,33 +65,60 @@ def main() -> None: tw = importlib.util.module_from_spec(spec) spec.loader.exec_module(tw) + from citrate_sdk._generated import contract as wheel_contract # noqa: E402 + owner = "0x" + "11" * 32 holders = ["0x" + "22" * 32, "0x" + "33" * 32, "0x" + "44" * 32] model = work / "m.onnx" model.write_bytes(b"SECRET-MODEL-WEIGHTS" * 100) - box: dict[str, Any] = {} - - def rpc(method: str, params: Any = None) -> Any: - if method == "eth_getTransactionCount": - return "0x0" - if method == "eth_chainId": - return hex(40204) - if method == "eth_gasPrice": - return "0x1" - if method == "eth_sendRawTransaction": - box["raw"] = params[0] - return "0x" + "ab" * 32 - raise AssertionError(method) - - client = CitrateClient("http://localhost:8545", private_key=owner) - stubs: dict[str, Any] = { - "_rpc_call": rpc, - "_upload_to_ipfs": lambda data: "bafyFAKE", - "_wait_for_receipt": lambda h: {"logs": []}, - "_extract_model_id_from_receipt": lambda r: "0x01", - } - for name, fn in stubs.items(): # offline: RPC and IPFS are stubbed - setattr(client, name, fn) + # The wheel's network guard stays on: block 0 is answered with the wheel's own vendored + # genesis, so the guard runs and passes before the key-share checks below. + genesis = wheel_contract.genesis_hash() + if not isinstance(genesis, str) or len(genesis) != 66: + fail(f"wheel genesis_hash() is not a 32-byte hash: {genesis!r}") + + def capturing_client(block0_hash: str) -> tuple[Any, dict[str, Any]]: + box: dict[str, Any] = {"block0_reads": 0} + + def rpc(method: str, params: Any = None) -> Any: + if method == "eth_getBlockByNumber" and params and params[0] in ("0x0", 0): + box["block0_reads"] += 1 + return {"number": "0x0", "hash": block0_hash} + if method == "eth_getTransactionCount": + return "0x0" + if method == "eth_chainId": + return hex(40204) + if method == "eth_gasPrice": + return "0x1" + if method == "eth_sendRawTransaction": + box["raw"] = params[0] + return "0x" + "ab" * 32 + raise AssertionError(method) + + # Port 1 is never a node; every RPC below is answered by the stub. + c = CitrateClient("http://127.0.0.1:1", private_key=owner) + stubs: dict[str, Any] = { + "_rpc_call": rpc, + "_upload_to_ipfs": lambda data: "bafyFAKE", + "_wait_for_receipt": lambda h: {"logs": []}, + "_extract_model_id_from_receipt": lambda r: "0x01", + } + for name, fn in stubs.items(): # offline: RPC and IPFS are stubbed + setattr(c, name, fn) + return c, box + + # 0. the wheel's network guard is live: another block 0 is refused before any send + other, other_box = capturing_client("0x" + "0" * 63 + "1") + try: + other.deploy_model(model, ModelConfig(name="m")) + fail("the wheel's client did not refuse an RPC on another genesis") + except CitrateError as e: + if getattr(e, "error_code", None) != "WRONG_NETWORK": + fail(f"expected a WRONG_NETWORK refusal on another genesis, got {e!r}") + if "raw" in other_box: + fail("a transaction was sent to an RPC on another genesis") + + client, box = capturing_client(genesis) try: client.deploy_model(model, ModelConfig(name="m", encrypted=True, encryption_config=EncryptionConfig( @@ -102,6 +132,8 @@ def rpc(method: str, params: Any = None) -> Any: pubs = [KeyManager(k).get_public_key() for k in holders] dep = client.deploy_model(model, ModelConfig(name="m", encrypted=True, encryption_config=EncryptionConfig( threshold_shares=2, total_shares=3, share_holder_public_keys=pubs))) + if box["block0_reads"] < 1: + fail("the deploy was sent without the block-0 network check") calldata = bytes(rlp.decode(bytes.fromhex(box["raw"][2:]))[5]) meta = json.loads(calldata.decode())["encryption_metadata"] key = KeyManager(owner)._decrypt_key_from_owner(meta["encrypted_key"]) From 3ecb5859126dee93c0cc94e13456f63c728a9ba3 Mon Sep 17 00:00:00 2001 From: Larry Klosowski Date: Thu, 29 Oct 2026 19:42:06 -0700 Subject: [PATCH 5/5] chore(deps): refresh black, certifi, pillow, setuptools in uv.lock The locked-env pip-audit gate fails on black 26.3.1, certifi 2026.2.25, pillow 12.3.0 and setuptools 84.0.0. Lockfile-only bump to black 26.10.1, certifi 2026.10.23, setuptools 84.2.1 and pillow 13.0.0 (Python >= 3.11; pillow 13 drops 3.10, so the 3.10 marker keeps 12.3.0, dev-only via matplotlib). pyproject.toml is unchanged. Signed-off-by: Larry Klosowski Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FPJzHLHkCexnt8jGuPzvMn --- uv.lock | 169 ++++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 133 insertions(+), 36 deletions(-) diff --git a/uv.lock b/uv.lock index 92ba110..470f67d 100644 --- a/uv.lock +++ b/uv.lock @@ -350,7 +350,7 @@ wheels = [ [[package]] name = "black" -version = "26.3.1" +version = "26.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -362,43 +362,43 @@ dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/65/a9611a6ec0a8c88d86e59385da02d68d9533f7e86a05913d20c67be54029/black-26.10.1.tar.gz", hash = "sha256:5f9f83beae62437e060dafd53d7f1fc327e3d3494f74d72ee5c2b73eb90fc4e7", size = 741979, upload-time = "2026-10-10T04:13:40.776Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/32/a8/11170031095655d36ebc6664fe0897866f6023892396900eec0e8fdc4299/black-26.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:86a8b5035fce64f5dcd1b794cf8ec4d31fe458cf6ce3986a30deb434df82a1d2", size = 1866562, upload-time = "2026-03-12T03:39:58.639Z" }, - { url = "https://files.pythonhosted.org/packages/69/ce/9e7548d719c3248c6c2abfd555d11169457cbd584d98d179111338423790/black-26.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5602bdb96d52d2d0672f24f6ffe5218795736dd34807fd0fd55ccd6bf206168b", size = 1703623, upload-time = "2026-03-12T03:40:00.347Z" }, - { url = "https://files.pythonhosted.org/packages/7f/0a/8d17d1a9c06f88d3d030d0b1d4373c1551146e252afe4547ed601c0e697f/black-26.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c54a4a82e291a1fee5137371ab488866b7c86a3305af4026bdd4dc78642e1ac", size = 1768388, upload-time = "2026-03-12T03:40:01.765Z" }, - { url = "https://files.pythonhosted.org/packages/52/79/c1ee726e221c863cde5164f925bacf183dfdf0397d4e3f94889439b947b4/black-26.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:6e131579c243c98f35bce64a7e08e87fb2d610544754675d4a0e73a070a5aa3a", size = 1412969, upload-time = "2026-03-12T03:40:03.252Z" }, - { url = "https://files.pythonhosted.org/packages/73/a5/15c01d613f5756f68ed8f6d4ec0a1e24b82b18889fa71affd3d1f7fad058/black-26.3.1-cp310-cp310-win_arm64.whl", hash = "sha256:5ed0ca58586c8d9a487352a96b15272b7fa55d139fc8496b519e78023a8dab0a", size = 1220345, upload-time = "2026-03-12T03:40:04.892Z" }, - { url = "https://files.pythonhosted.org/packages/17/57/5f11c92861f9c92eb9dddf515530bc2d06db843e44bdcf1c83c1427824bc/black-26.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:28ef38aee69e4b12fda8dba75e21f9b4f979b490c8ac0baa7cb505369ac9e1ff", size = 1851987, upload-time = "2026-03-12T03:40:06.248Z" }, - { url = "https://files.pythonhosted.org/packages/54/aa/340a1463660bf6831f9e39646bf774086dbd8ca7fc3cded9d59bbdf4ad0a/black-26.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf9bf162ed91a26f1adba8efda0b573bc6924ec1408a52cc6f82cb73ec2b142c", size = 1689499, upload-time = "2026-03-12T03:40:07.642Z" }, - { url = "https://files.pythonhosted.org/packages/f3/01/b726c93d717d72733da031d2de10b92c9fa4c8d0c67e8a8a372076579279/black-26.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:474c27574d6d7037c1bc875a81d9be0a9a4f9ee95e62800dab3cfaadbf75acd5", size = 1754369, upload-time = "2026-03-12T03:40:09.279Z" }, - { url = "https://files.pythonhosted.org/packages/e3/09/61e91881ca291f150cfc9eb7ba19473c2e59df28859a11a88248b5cbbc4d/black-26.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:5e9d0d86df21f2e1677cc4bd090cd0e446278bcbbe49bf3659c308c3e402843e", size = 1413613, upload-time = "2026-03-12T03:40:10.943Z" }, - { url = "https://files.pythonhosted.org/packages/16/73/544f23891b22e7efe4d8f812371ab85b57f6a01b2fc45e3ba2e52ba985b8/black-26.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9a5e9f45e5d5e1c5b5c29b3bd4265dcc90e8b92cf4534520896ed77f791f4da5", size = 1219719, upload-time = "2026-03-12T03:40:12.597Z" }, - { url = "https://files.pythonhosted.org/packages/dc/f8/da5eae4fc75e78e6dceb60624e1b9662ab00d6b452996046dfa9b8a6025b/black-26.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e6f89631eb88a7302d416594a32faeee9fb8fb848290da9d0a5f2903519fc1", size = 1895920, upload-time = "2026-03-12T03:40:13.921Z" }, - { url = "https://files.pythonhosted.org/packages/2c/9f/04e6f26534da2e1629b2b48255c264cabf5eedc5141d04516d9d68a24111/black-26.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cd2012d35b47d589cb8a16faf8a32ef7a336f56356babd9fcf70939ad1897f", size = 1718499, upload-time = "2026-03-12T03:40:15.239Z" }, - { url = "https://files.pythonhosted.org/packages/04/91/a5935b2a63e31b331060c4a9fdb5a6c725840858c599032a6f3aac94055f/black-26.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f76ff19ec5297dd8e66eb64deda23631e642c9393ab592826fd4bdc97a4bce7", size = 1794994, upload-time = "2026-03-12T03:40:17.124Z" }, - { url = "https://files.pythonhosted.org/packages/e7/0a/86e462cdd311a3c2a8ece708d22aba17d0b2a0d5348ca34b40cdcbea512e/black-26.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ddb113db38838eb9f043623ba274cfaf7d51d5b0c22ecb30afe58b1bb8322983", size = 1420867, upload-time = "2026-03-12T03:40:18.83Z" }, - { url = "https://files.pythonhosted.org/packages/5b/e5/22515a19cb7eaee3440325a6b0d95d2c0e88dd180cb011b12ae488e031d1/black-26.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:dfdd51fc3e64ea4f35873d1b3fb25326773d55d2329ff8449139ebaad7357efb", size = 1230124, upload-time = "2026-03-12T03:40:20.425Z" }, - { url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034, upload-time = "2026-03-12T03:40:21.813Z" }, - { url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503, upload-time = "2026-03-12T03:40:23.666Z" }, - { url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557, upload-time = "2026-03-12T03:40:25.497Z" }, - { url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766, upload-time = "2026-03-12T03:40:27.14Z" }, - { url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140, upload-time = "2026-03-12T03:40:28.882Z" }, - { url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" }, - { url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" }, - { url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" }, - { url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" }, - { url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" }, - { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, + { url = "https://files.pythonhosted.org/packages/03/35/46e9ee9a1b0b5f06d7702fb484e73a5b944acece76898a7ac4d75123514a/black-26.10.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:51d5e417e700fe6ec0b0ecdc408c6f6cb5def80328f31f724993d82c6486b746", size = 2140312, upload-time = "2026-10-10T04:18:26.873Z" }, + { url = "https://files.pythonhosted.org/packages/64/9d/a8f1fdff294ece6cac5d465b9c2349d6c0ca8173977c8b617ea2e12f673e/black-26.10.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:70ccbd175b7f6be29d2b727ee7ca6b4c54053df59da653a6df80b175d20a94fa", size = 1982094, upload-time = "2026-10-10T04:18:29.124Z" }, + { url = "https://files.pythonhosted.org/packages/8e/57/84c7274ecf3732d85a1533ce93d0eb25b318981a26e5c98a37c8f91274a8/black-26.10.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5bd3518d8e97138fef295230b1e9804076d69fa4e3594071494a8c68abe6266", size = 2038822, upload-time = "2026-10-10T04:18:30.829Z" }, + { url = "https://files.pythonhosted.org/packages/14/40/ca69d7af0b8da635fe16eabca737b1e414d44aad967e18ae59f9cd5d00bf/black-26.10.1-cp310-cp310-win_amd64.whl", hash = "sha256:0ce08b367307b0fd91c9dd1d4084e62b05b3055475f951f0f34a46b6e2393b64", size = 1606468, upload-time = "2026-10-10T04:18:33.142Z" }, + { url = "https://files.pythonhosted.org/packages/d8/21/71a49a1f337a89aae496ca864896e499440f36608cbc64054119d71c7cbe/black-26.10.1-cp310-cp310-win_arm64.whl", hash = "sha256:19fa8f5beb5e77c54c9c7e21d00cc93ed6c8b6228ee385616906d6befe081143", size = 1459642, upload-time = "2026-10-10T04:18:34.898Z" }, + { url = "https://files.pythonhosted.org/packages/6f/1e/c1d4f71299d45f4ff54c9c42ef9214d6b228223f196bc1bb2ee196aeaeee/black-26.10.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9a0219b29cd70e49f920acb7081e6ce5025c719008447c521d0200dcad93206a", size = 2121821, upload-time = "2026-10-10T04:18:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/7f/e2/43944049c0945199bbd5c8b291b85306bfd0eeccbc908bf65538a69ff1b0/black-26.10.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7bdade400bfe24d78a7762896acc2f9a8e1a17fb0fd0536bf6b7c7097cf3eec7", size = 1961908, upload-time = "2026-10-10T04:18:38.942Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e9/9616d58f69f0e4bfa89ebe8686b6ef50f4b4f0f91237ae4c76f34dabf8aa/black-26.10.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ff57f63029aa1353fa8b1b0c8971fd88a6c92dc766608d2eee33ad2deb23270e", size = 2019042, upload-time = "2026-10-10T04:18:40.612Z" }, + { url = "https://files.pythonhosted.org/packages/c5/de/0415cce69e2402a5c186cdb5462a0edd3fa44d69d1a1630e68e58daaa6a4/black-26.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:3414a0c52901964dceabd98c7c56beac0f964115a116ecedcce7247359b14017", size = 1608383, upload-time = "2026-10-10T04:18:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/a9/61/af18e716432e932f166ce9dfa419ce3f05fd5ef8c52c0510d27ae41aaa51/black-26.10.1-cp311-cp311-win_arm64.whl", hash = "sha256:1935b32f5326028019856e18cb42b4da63db23765dc84464cec723e0de478a9b", size = 1466048, upload-time = "2026-10-10T04:18:44.271Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ca/357ccdd12e8f8429539f8f52edc153de58acba84f6670ac36c254a276691/black-26.10.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe85fc4019bee59bc495c0f2a8ee76c5cd02c7015508d94a967ba2376f39a52c", size = 2167369, upload-time = "2026-10-10T04:18:45.948Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a2/4709110a7ca326ba7b4f81a669fbebc20653c79a972b0f8de4ba5b5c5e68/black-26.10.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:182f6c32be38074b16d378498c498b32cb51928178ee611485344972c35ec9c6", size = 1990732, upload-time = "2026-10-10T04:18:48.252Z" }, + { url = "https://files.pythonhosted.org/packages/1e/96/9f8fa839c169d19c38ce6582d9449f2d7d70dabd313ac28f15a930260c1f/black-26.10.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b5347d760f0c02bb00dd249384cab71c3bf828b4f68d5b401eb116e0390f147d", size = 2065235, upload-time = "2026-10-10T04:18:50.064Z" }, + { url = "https://files.pythonhosted.org/packages/2b/ee/929eaf7de6f05a3141d6df72acf9248118f49a5e48f9375be3180e9babf7/black-26.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:4d9a90516db1d99c25dbb20cc0998e0e01531dd903466c7744e56d66f864220a", size = 1621579, upload-time = "2026-10-10T04:18:51.59Z" }, + { url = "https://files.pythonhosted.org/packages/90/3a/8b6a44abf9648b087311003b91298226c1763172cd8d223ece5b7cf8f795/black-26.10.1-cp312-cp312-win_arm64.whl", hash = "sha256:2ffbc023a12d0c729408823b8f10514490bd0baa301d0d4e21a7240249f9507f", size = 1469662, upload-time = "2026-10-10T04:18:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/99/6b/bc0d39990bd7a71457d669639fa06acf6191bec28bafe8b8103f597cce76/black-26.10.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b6272cfd7e1e8e271f5b0e0207259fe2834687e5cb9b5f620b34a44db9754993", size = 2153812, upload-time = "2026-10-10T04:18:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/85/0e/cc83b88a6b1a51fc051aa4e24663918f0bf4fa9fca98552ee93a125a6780/black-26.10.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:978113a40223a6aaefc17364176a809a320e6b288683841427fff04c6d7b4130", size = 1980352, upload-time = "2026-10-10T04:18:56.849Z" }, + { url = "https://files.pythonhosted.org/packages/7b/0f/4dd24ea0ebadbe05e293c3ea7fbebd4389f50508f714640c9b309a0849d2/black-26.10.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03c0ddd93bb392e71209903a691767eb366fe1a76deb9509ccbaae9e1f14bb52", size = 2055268, upload-time = "2026-10-10T04:18:58.974Z" }, + { url = "https://files.pythonhosted.org/packages/a2/06/221f8e81891ecf5e4df9c258a11037997f1f218533ffe4fa1dc4db37f19c/black-26.10.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6dba8138cdc99061ef07b958ac082d2aa057b6961d1936f9717c350f02bab5f", size = 1625938, upload-time = "2026-10-10T04:19:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/2b/3b/763d2dd073fc1e2cbf0fe5e584fe96913bf0d14cd725d1116f4b38453adf/black-26.10.1-cp313-cp313-win_arm64.whl", hash = "sha256:d42dd2fac7c342ae67e64ee99c9532e20b2a84e92c79ed3317fa2ef54c801d93", size = 1470752, upload-time = "2026-10-10T04:19:03.009Z" }, + { url = "https://files.pythonhosted.org/packages/76/7b/e8d275b23f3023881c0b0d8ce3bbda0d100c79bdef87fb6518bf9e0c5040/black-26.10.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:8375962579d537364cc0efa19b1474481915d3a793f9fc0774901814c5e5b5f4", size = 2147731, upload-time = "2026-10-10T04:19:04.643Z" }, + { url = "https://files.pythonhosted.org/packages/ee/62/e44f86ee5fc7b893ee0ee935a79c785932da7758d2c339749d90a20ef213/black-26.10.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d8b3a9074a680b3c5749633714e9ae3992a1e5a23343a97ad61cd9b119b444d2", size = 1981714, upload-time = "2026-10-10T04:19:06.287Z" }, + { url = "https://files.pythonhosted.org/packages/89/2f/e12ca76edfcd037fe9e7e7635d9468d1d51255cf25a01b2f8f97a38f147d/black-26.10.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:289282aa2e09d3162312a3be1788ff21b08e9ea9cc4a81e656024728b32428fb", size = 2047807, upload-time = "2026-10-10T04:19:08.193Z" }, + { url = "https://files.pythonhosted.org/packages/c3/af/676d3c5cbb2ab0f17c8459b3ae8ea15ebdcc5a2f1c1c586228ec85b8897f/black-26.10.1-cp314-cp314-win_amd64.whl", hash = "sha256:5cd88fd7b444ca51f3fc883b6f6657ea53a258b0b2eef6d9f2dfcfa17ce0e27b", size = 1652537, upload-time = "2026-10-10T04:19:09.817Z" }, + { url = "https://files.pythonhosted.org/packages/ad/7b/860022d369fdd5fe14280b1e7109f58753c371ff91ce2311d68a056e488a/black-26.10.1-cp314-cp314-win_arm64.whl", hash = "sha256:2520037aa62f8a1454d0811b8f5c88b444445b03a4bfba480d8d220893b64c34", size = 1504226, upload-time = "2026-10-10T04:19:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/b1/28/9dd29175c1db777e6189e2a0bb5f37101adf19c3e509b236c8920f778d0b/black-26.10.1-py3-none-any.whl", hash = "sha256:28842f9a8207cc1df6eb983a35a14c5a0dfcd603d214fe82d84bef552afd2e3a", size = 239267, upload-time = "2026-10-10T04:13:38.808Z" }, ] [[package]] name = "certifi" -version = "2026.2.25" +version = "2026.10.23" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, + { url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" }, ] [[package]] @@ -2153,7 +2153,7 @@ dependencies = [ { name = "kiwisolver" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, { name = "packaging" }, - { name = "pillow" }, + { name = "pillow", version = "12.3.0", source = { registry = "https://pypi.org/simple" } }, { name = "pyparsing" }, { name = "python-dateutil" }, ] @@ -2232,7 +2232,7 @@ dependencies = [ { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "packaging" }, - { name = "pillow" }, + { name = "pillow", version = "13.0.0", source = { registry = "https://pypi.org/simple" } }, { name = "pyparsing" }, { name = "python-dateutil" }, ] @@ -2861,6 +2861,9 @@ wheels = [ name = "pillow" version = "12.3.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/25/c2/669d88644cddb1485bd9534e63e8cf476c8e51cb3c3a1297677023505c0e/pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a", size = 5392418, upload-time = "2026-07-01T11:53:27.808Z" }, @@ -2951,6 +2954,100 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, ] +[[package]] +name = "pillow" +version = "13.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/a4/9cc30690a394a8305e8be4c9036aa16d0dfddadd09079860b582f562496d/pillow-13.0.0.tar.gz", hash = "sha256:cdcd1ba258178f7356d74170c9a81ba4eab6a5d55748bc402a5a2a6fe6be3510", size = 47058631, upload-time = "2026-10-16T22:18:09.718Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/d7/af6af1b8cc55e07cb0489d5b0950bf5a9bb1461eeba825700b6d8d0e26ea/pillow-13.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:6f38f3f885a09cafc93fd667b1173a92f379e43785d635e7983c893f0da319fb", size = 5592324, upload-time = "2026-10-16T22:15:24.672Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d2/2c12cb4fbb5d95f38d1001ff3d95fafcb830077018cba40e56a8f932dfbc/pillow-13.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37290fbef0c73e2d51d594eb78fded7dc7170d1c3ad0a890269e6fd8c8c4e061", size = 4856298, upload-time = "2026-10-16T22:15:27.669Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ee/229b27472483a4801ea94990815e710391836afdf987b4b722b62bd75db6/pillow-13.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c88c10dd432f3b39ccdafc9ed81d0c21b8fd634502574da76d1bfa4fe2c76d0", size = 6517304, upload-time = "2026-10-16T22:15:29.43Z" }, + { url = "https://files.pythonhosted.org/packages/36/46/f509b74787f40ce0627501089d630b2c741b63b244c1f40b640b3b01e181/pillow-13.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a625df9a2a5597fa5e5266e01aefc82604bb85d291e3547981e863560d75da6c", size = 7140667, upload-time = "2026-10-16T22:15:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/5a398b8ec2c37145397d6461c0b551c23ec03072aa64f357aed25e582b00/pillow-13.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:10b5019883c98f1dd819ca48ffc6f33a91cee04948fe637c605f4345c7cafd6f", size = 6610583, upload-time = "2026-10-16T22:15:34.025Z" }, + { url = "https://files.pythonhosted.org/packages/4f/7b/f2f1109b230fd4e2d57ab2305de31adc37ea9b4ff8719436c623cb484415/pillow-13.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f391dc92a015c478745192fe4f2e120c2af2aba18d9fe0616341cbf537599806", size = 7218241, upload-time = "2026-10-16T22:15:36.214Z" }, + { url = "https://files.pythonhosted.org/packages/ab/d3/069edb9083b267746baf59756282cacef904d3475c5288c7216fb5195a98/pillow-13.0.0-cp311-cp311-win32.whl", hash = "sha256:6a0920cb55df5a4d3f6862964908078c0bd59332bdd1bcd20d14e8c70ef27cc5", size = 6515278, upload-time = "2026-10-16T22:15:38.129Z" }, + { url = "https://files.pythonhosted.org/packages/7d/f6/6e2d96413e4c9dbec12ea584d9e646d602877d4cce3ea669099f959deac6/pillow-13.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:d7710eb676e9bb68965cb045be0d333b4c0d7f9dfbaa7fbf121385b1588683d3", size = 7278471, upload-time = "2026-10-16T22:15:40.286Z" }, + { url = "https://files.pythonhosted.org/packages/70/e7/baff21f8cb60496c8c250609501b88919eeedb4088d3da106f6c62f3f3c0/pillow-13.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:12fbb851cf323cc19aac3eea1df5ad17b57e91e9fe3cfd06100dba7bb8208c13", size = 2698981, upload-time = "2026-10-16T22:15:42.637Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b8/58d79332229482eb0d27c82973850808c19d37e5bdc14443d3417bd373f5/pillow-13.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:67c8d44ad89fa7eb88de5d6ef6199b1e03dd24974f85a67639fc8badb801fe79", size = 5568643, upload-time = "2026-10-16T22:15:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/39/4b/f2ed53d83aa44ac19599ac0a54897f867fafb95f406980d1e24083f8cbe3/pillow-13.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc481f742ccda20e9ed760b8f978dff50f2a4e9bc253ac167fb0a05b0783ef52", size = 4852329, upload-time = "2026-10-16T22:15:46.284Z" }, + { url = "https://files.pythonhosted.org/packages/3b/bb/9e30e8ed18c20800ee327a12107d5249ad8dc444867b3adfcc21654e3c71/pillow-13.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8bf295fe459343bc6093c8e5e0df60bfa189e0fcb775a22e2c2488fdeccc6c0d", size = 6519309, upload-time = "2026-10-16T22:15:48.14Z" }, + { url = "https://files.pythonhosted.org/packages/e6/99/fdf65cc539e266ab93546c261bdf98411fe165c6e4e13f9dbf597097192b/pillow-13.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42d63be3af4f9b951b199b0a9d23365d37ab14687e6d77b74940432c8515c4ca", size = 7144542, upload-time = "2026-10-16T22:15:50.093Z" }, + { url = "https://files.pythonhosted.org/packages/26/9b/f38b7c4d8f599537e2d1a6d546cec9beef7536ff8f5c49a3f9c78179d8ac/pillow-13.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4bd024d567eca931b668978d672dba914d4b8e2628e2c3073a2ad178de8a2620", size = 6618042, upload-time = "2026-10-16T22:15:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a8/7c/45ee28531f89ee4582967d8a17f7a26d71ca64cd902b93c269590cd60a00/pillow-13.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23edf4ec4b6f4f240e8df6e4985e9ee2ced86474632433e55fb1e2548744bf40", size = 7228137, upload-time = "2026-10-16T22:15:54.009Z" }, + { url = "https://files.pythonhosted.org/packages/01/bc/9c2a969cb1b70201d04fa17fd5aedf02a732870b8da86bceaaa30b0ca385/pillow-13.0.0-cp312-cp312-win32.whl", hash = "sha256:02464da86f561f410e13b061ec37c3068a9cb557a095494157742d07b3c24a27", size = 6515423, upload-time = "2026-10-16T22:15:56.25Z" }, + { url = "https://files.pythonhosted.org/packages/ff/cd/73a79ecdc822076002eb24b5e1e4a530b0bb5310fcb6d239abbec877110b/pillow-13.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:ff3fe9eb621fbb9db4c4be33ab946f6bac5f98ab50ca8d3260503653656c961b", size = 7272166, upload-time = "2026-10-16T22:15:58.597Z" }, + { url = "https://files.pythonhosted.org/packages/4a/a3/be5a1236efd085b3984e4305da8f179ae9dab80c991d2ad78f2bd5c4c843/pillow-13.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:c3bc8ee0271387e0b1e3ad0ff893645990a656e79bf9c42f4f734a3642b11d51", size = 2698943, upload-time = "2026-10-16T22:16:00.637Z" }, + { url = "https://files.pythonhosted.org/packages/82/e2/9132d38df320e8099e2642d9fa538f18a7dfebf68a875a31725dc2a115aa/pillow-13.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e98b4f91f99b24b1da72e10621ba181f7281922384a7403094debb4199de6c00", size = 4190620, upload-time = "2026-10-16T22:16:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/cb/be/436dac0996876f65ef1916ffb711c5ef4af2ebc57414b6f16642e07f6b47/pillow-13.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:34fb3db1149c879343861831728c243c432d4a5afc8f1878ca7612ef8de2a033", size = 4290286, upload-time = "2026-10-16T22:16:04.861Z" }, + { url = "https://files.pythonhosted.org/packages/60/16/7c1007162e4169ea0d5bec47e2e0b2174f56c50cf5b8c02a1dc23e41037e/pillow-13.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:2d50500a4a3bc6469adcdb319cf05d688b08ca3bf0f22a05339f3b25b433a599", size = 3723169, upload-time = "2026-10-16T22:16:06.956Z" }, + { url = "https://files.pythonhosted.org/packages/08/bc/578ab2eaa04705a8978bd9be0fe15c3d43a8fc95097c4f7e39540e79e4fa/pillow-13.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ef5fe6b7db3f501c56000ba8fe7c7e1a35ca1d45824fbeb95a67daa85cff4dbe", size = 5568469, upload-time = "2026-10-16T22:16:08.664Z" }, + { url = "https://files.pythonhosted.org/packages/98/c8/ccede3dcc4f274b80fb49199eed1ef34ba05566c8b71976b9d1f111da2e3/pillow-13.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f11a3c1358bb3c68012a7b1f41b223c3db9f7bfde90b481df028cfe1c3363313", size = 4852187, upload-time = "2026-10-16T22:16:10.693Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3c/cd0770e96d20bfd990db024bcbd43897ff3e88da1f4782cf90ea231b1d4f/pillow-13.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19b66ca0a4d25cc0200b381ce824c7ea090db61427315e73cfb05dbf1b35c369", size = 6515908, upload-time = "2026-10-16T22:16:12.896Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fe/f5cdaa2d1c4086e757430f368c271a58ee29cfe7378e7ae7b9d964015c25/pillow-13.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f56499c1a74d2b862beb0c33b8016f99d095858455df9d21583bbbc1c15b582", size = 7142544, upload-time = "2026-10-16T22:16:14.844Z" }, + { url = "https://files.pythonhosted.org/packages/fb/b5/a54f6d44adbcd23737c8c12953f42be107e4cca64e247c0ca3a5b563061c/pillow-13.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2a14cd73d5e6c927f644b0110ee81f59c4ef506259ec9183904747f2caacc81", size = 6613097, upload-time = "2026-10-16T22:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b4/9e6c1207c21b2e91db0d15c16d30f3701ce5add1bd4da0e787761e66cb6e/pillow-13.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08b826a9bf1265c76de04a42061d61ea6b109418b7826f28ff1ecdfaa26c5502", size = 7224846, upload-time = "2026-10-16T22:16:19.195Z" }, + { url = "https://files.pythonhosted.org/packages/78/9f/d3c3cb0518f3dd1eee0ef501535f53d15597a53c5c4118d178086143397a/pillow-13.0.0-cp313-cp313-win32.whl", hash = "sha256:12b0fa15879fe5a9d9eebe66e4e2d74128a773b1a4448010c9c714f45e04b9d8", size = 6516013, upload-time = "2026-10-16T22:16:21.389Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ed/1b16fb66a37e1b9e41370b93b2c614c7da761a5b2a41dc333f7af4639962/pillow-13.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:9dda0788a9932776a75d8bcc020076edc308ce2508b51f6739a45e3158b3fc42", size = 7284777, upload-time = "2026-10-16T22:16:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/7d/8d02eba9f7ecc87f8e90f731e032cc4773802f2a843763afd0c66a535ff4/pillow-13.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:40a266371ece581e5de13b6635ef78d7487824af80561b6ca27694a88c19bc0f", size = 2698826, upload-time = "2026-10-16T22:16:25.382Z" }, + { url = "https://files.pythonhosted.org/packages/8e/45/fbad9c7f5e2f1745c373a434d46072bc84eddfefaa28b852425ac957fda6/pillow-13.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:44629d4498c9cb6b2051806155f22141df0d8a929c56134b166655232dfb2f8c", size = 4190721, upload-time = "2026-10-16T22:16:27.524Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bf/2074902a259a90692c5dbf132885d21671644cbbd3f690047516243a55db/pillow-13.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:bf97ba1b5542050e9b0c74e632323b6d8136cc99d64e193abebee22fe6013c76", size = 4290236, upload-time = "2026-10-16T22:16:29.562Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a5/3d1bb93b8ea38d15d21356c1b8e9f50971ac592aaa248f7fb71739094c0e/pillow-13.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:aa694c49787040943570d61c9b8fc01271bd2a2cfdd55f0d896f8aecdce1a981", size = 3723093, upload-time = "2026-10-16T22:16:31.249Z" }, + { url = "https://files.pythonhosted.org/packages/84/a1/be060135d85187b25fa8b2573786c0e45c62ae27527fff5257675eb97f4a/pillow-13.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:49c12af515073e9bf511e4ef736743bf70d531a643668722279314b937742ba4", size = 5572253, upload-time = "2026-10-16T22:16:33.103Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f4/0bf6f1ac9ba7ee2ffe9914aeb410a1035a0c1a3c42f122388d7fc950bc4b/pillow-13.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d9b0a2bee3f3e03e9f29057aedb807dbf1e1d96ee1b9d0f422d9db6c8583271d", size = 4852269, upload-time = "2026-10-16T22:16:35.102Z" }, + { url = "https://files.pythonhosted.org/packages/83/16/b603274617b3e6728da3a0133d4fba1ea8d7fbe0c4ea5fef9cbffa398b8b/pillow-13.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5933fcea5a5ba64c686f55c886a115cbfd92f2900460784a1e79909fdbafd7b6", size = 6516058, upload-time = "2026-10-16T22:16:37.228Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f2/1371707ce438ba9a43f9d24089828494ec6f9396e28cd7903aa704dd6306/pillow-13.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e91c2b1cbeae768c98752e67e717e283d68dae45717b41f2d91eaee648e37b7d", size = 7142574, upload-time = "2026-10-16T22:16:39.172Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a9/62cf68587f910bc87c5951bcb6e9430c7643a65076adf5f18b4bf350e2bd/pillow-13.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f6299e494e46253d40c34653cac369412f44ca53b5f85d848d3f95b1ed0546c", size = 6614295, upload-time = "2026-10-16T22:16:41.117Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c9/9e9b48178649730299be0bafbf5f810a5b6977538f5a92d47e4ba5f5d608/pillow-13.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f9bd7d260d986f3553518c5d723544346fa48031f8aef3265d786aeb59845bc0", size = 7224841, upload-time = "2026-10-16T22:16:43.118Z" }, + { url = "https://files.pythonhosted.org/packages/8f/48/c44e91384b1b6c072a714a63d00608acc16a6a968735e478e4be4384e5ac/pillow-13.0.0-cp314-cp314-win32.whl", hash = "sha256:2e31c4e68cffd7b1f116127626813b82fcba19edf6b940a7b0c266d6484d0055", size = 6508694, upload-time = "2026-10-16T22:16:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/50/e2/7ad17747517e4c813dca78257d6bb593466f8231baf1384bc98d92745edb/pillow-13.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:f7cbb7a64e7ec664775195e4f81923bc8a93d9451b963f844e59118f63147a7a", size = 7281830, upload-time = "2026-10-16T22:16:47.725Z" }, + { url = "https://files.pythonhosted.org/packages/41/9f/e5b51f2ce8ef4563215334035403ce61a244f93a2e65c24a940131618f87/pillow-13.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:a6aaa7871aebca081eb5c45f83e845bbd9712ae99fa9286dbfdcee835e63f4b4", size = 2695468, upload-time = "2026-10-16T22:16:49.515Z" }, + { url = "https://files.pythonhosted.org/packages/97/73/a53502f731f6c84805a4ce87a8881e86d07a65aee64728b900d112dc703e/pillow-13.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7b999816b5850a35ada08443e6e03945f1f6488144a83afe2d37b240a552aafa", size = 5578196, upload-time = "2026-10-16T22:16:51.291Z" }, + { url = "https://files.pythonhosted.org/packages/21/50/7e949959a56b5559c78ef172b70fc6025706acb289f3f648a15329e4fa6f/pillow-13.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:67871a13cf47275201553b536c025da777f997fa562a19eb798af4ad4b97c18f", size = 4857276, upload-time = "2026-10-16T22:16:53.399Z" }, + { url = "https://files.pythonhosted.org/packages/c9/52/43ded0ac08e77e39d17bd6d894162572201b5e8fdc4a022b14c55a1960a4/pillow-13.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8ca8f69b7c77fa96dad99f8638c1fc4f1c0bed4acaa1baf7a3c31bdb80a0ab1", size = 6607008, upload-time = "2026-10-16T22:16:55.525Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2e/4a57ed216d5bf877387cba4d44c02aa87abe51ad2e06404aa6b597c4da39/pillow-13.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bce6a29c3898191f4c9998e4d4e8f69790caeabde46e54da4bc235ce5bde683", size = 7224281, upload-time = "2026-10-16T22:16:57.738Z" }, + { url = "https://files.pythonhosted.org/packages/5c/3d/2929b248e135752d63aaeabf53f6de797e905b7b6263f9c1e2adade20aba/pillow-13.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:816752a5ef76a80484c8c441c85593b7ea9304f6231fc5776ad1fbff7e773058", size = 6695600, upload-time = "2026-10-16T22:16:59.842Z" }, + { url = "https://files.pythonhosted.org/packages/bc/33/fb0da992cf084ed1ee6ee05e96b6191e35c351eb707ca2df874feb24ce47/pillow-13.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5bdb38ba220b61b4ec31dd1046dea6b016d3c6850a23f43ed8b58332b2fc668e", size = 7300589, upload-time = "2026-10-16T22:17:01.686Z" }, + { url = "https://files.pythonhosted.org/packages/bb/af/c30dd9ef6a3dce84ecb1b2393cac629047a32e66279056c6cb46c3ffd65c/pillow-13.0.0-cp314-cp314t-win32.whl", hash = "sha256:06903aac074d00c988e77a1fc435286dc498506d4ca8d6f69df9babddf4ecc15", size = 6517204, upload-time = "2026-10-16T22:17:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/85/d5/ef3a8ae7609c8ab26570abf18194882534b480d245a9148b895a2262c004/pillow-13.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9a635068d9793c7f0d929f39f18b58f1bd86c236e8e7b64167b86af9232ebca0", size = 7286695, upload-time = "2026-10-16T22:17:05.543Z" }, + { url = "https://files.pythonhosted.org/packages/84/3c/0861db0c69a03689ebe3e7599d35668a414deb5aaafd8255215e4e62e3d3/pillow-13.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0d1a9cf7bc6917538b20f8d06b9ab2147e542f68849e96fc58eb66bdf6acc692", size = 2698098, upload-time = "2026-10-16T22:17:07.674Z" }, + { url = "https://files.pythonhosted.org/packages/6e/eb/e86a41962a3a31ad495a3d6a912d9002b01bba4a6fa71c28703abdca7f69/pillow-13.0.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7d5e097eabd5d4915c27ac9f6e9325bb1e01a7c94d7348b5c4451d3fc30e8521", size = 4191213, upload-time = "2026-10-16T22:17:09.449Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/7e7445e706afec84ef06dc25b2c77f9cecff242c23730c62928ad1089d4a/pillow-13.0.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:51ebb529fc5e8abdd55302f6c3f8943037348ec2384329a3e55677f69ae2fdfc", size = 4290650, upload-time = "2026-10-16T22:17:11.196Z" }, + { url = "https://files.pythonhosted.org/packages/46/85/d534089068ac40c0f61f650389a296c812eab745a8a733f81feec39ae955/pillow-13.0.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:c87a04e7f152ee4db5dbe15f0bfa3fe628a1aeedbe66b81c43b87f52c813f9ac", size = 3723078, upload-time = "2026-10-16T22:17:12.772Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2b/00b5c3b401df538b5dbe311d3899730fae5ba1e21cb8b80a128887ac6747/pillow-13.0.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:20722cd5375533ad754053363a80b8fb30c79d37930a6f7228030e237ce3bd5d", size = 5572292, upload-time = "2026-10-16T22:17:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/72/f5/a6b0610f946e9b54ae888ba7ad29d6e2f8d9b11f69e212d70416b7186c09/pillow-13.0.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:04a63720f79e22fd54d897c233a68314c50ca0603360f54ce2bd5db8f0f43782", size = 4852222, upload-time = "2026-10-16T22:17:16.521Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d0/c0ff25bb0427ec70f32aac995453321fc3c4494a46c7292ff02d9d260b04/pillow-13.0.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f8db54177035a2b5f717f94475b74907e12a96dc74f6d487f3966b0a4694071", size = 6527349, upload-time = "2026-10-16T22:17:18.534Z" }, + { url = "https://files.pythonhosted.org/packages/85/11/e46fa0bcc0fc117f1eacc206b3d51f04d583fab328b4b740672d0438d83f/pillow-13.0.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f36aa77f74b14a2103622b196402c71330e4e7cf4122ee45c854e9a01e0eecf", size = 7152873, upload-time = "2026-10-16T22:17:20.415Z" }, + { url = "https://files.pythonhosted.org/packages/7c/53/2bdeca705f3a58679fa9201233193c86a30238340ca29b3a152c3fcc719b/pillow-13.0.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:149a1c323ba366f90b48e6f1a6db6582eb50512df374588de56fc909abc0f0f3", size = 6626871, upload-time = "2026-10-16T22:17:22.361Z" }, + { url = "https://files.pythonhosted.org/packages/14/de/3829030f209722ade3753bb456b805a0e7f885cf885b483407365c82e9ca/pillow-13.0.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e892354a424b1e82a78ba16785822bdd40de06f19446a3d4a28047df1a4f8932", size = 7236439, upload-time = "2026-10-16T22:17:24.352Z" }, + { url = "https://files.pythonhosted.org/packages/44/b1/67fc37388382bc036a74f04da5bb6fcbf90ec3ac1f45893e06176c373fd3/pillow-13.0.0-cp315-cp315-win32.whl", hash = "sha256:8e459b613cf31f8980f584387b4fe47bf08e076e288d61aa8eba1f7fd4ddbb81", size = 6508688, upload-time = "2026-10-16T22:17:26.314Z" }, + { url = "https://files.pythonhosted.org/packages/44/05/fdb6a79aee10861165526b41b7b440e36ee1ff58613b2fbefe3acbf06d4f/pillow-13.0.0-cp315-cp315-win_amd64.whl", hash = "sha256:d4737f50d23ddd11d6685faacbb9252887dde0bc7997127757247983d74399d6", size = 7281107, upload-time = "2026-10-16T22:17:28.198Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9c/6c6f7dc2a566467baf121394e83de25ac287cffe34cc030726beb4be0224/pillow-13.0.0-cp315-cp315-win_arm64.whl", hash = "sha256:2310e2adfc13c6d457d7d0f2ffdd5c56a74a317650cf765887df8d515a647ac0", size = 2695513, upload-time = "2026-10-16T22:17:30.013Z" }, + { url = "https://files.pythonhosted.org/packages/23/16/e9120defdb8bff693b582d035312acc6f530cdc2bc5238a87f410efccd82/pillow-13.0.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:21c32061a5512dc13f87cc7303e120e4d77c2f70df89a35af0e3ce246e5ac57f", size = 5578129, upload-time = "2026-10-16T22:17:31.642Z" }, + { url = "https://files.pythonhosted.org/packages/47/22/76faf2bb2448f48a84cf894c1e2bb1090d7d2b1aa521ece131cb2f05d631/pillow-13.0.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c774b9c0ba0d0b356f71530677b39028878a2cae5a93e0a13779cfd17120f88", size = 4857428, upload-time = "2026-10-16T22:17:33.43Z" }, + { url = "https://files.pythonhosted.org/packages/b3/64/0861ea4e03c50b14af9ed9dd8b9f388ab42f445c623a9fcbab19aef7aa55/pillow-13.0.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20822140c9a2ba9c5e42df342b0a892ab2e102fc0e3db967a133bb9ce3f898bb", size = 6612582, upload-time = "2026-10-16T22:17:35.415Z" }, + { url = "https://files.pythonhosted.org/packages/5c/20/cd1e9b3f60a9f3d5ebe46da36955c26004092a501f7f2c42f9fcc26611bc/pillow-13.0.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffea6b2986a797c84b6d1d27d4f43e0b83c73fc16281a930891cd237cf0d08fe", size = 7230176, upload-time = "2026-10-16T22:17:37.362Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ef/ffb0a2fc85690714fb7ac662956949645d57f15bf4426f520da361e7b6e3/pillow-13.0.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:05cd2cd39055d0b9650060e4513ef4a9a0aea870083950bd046a0d6490f93a22", size = 6703640, upload-time = "2026-10-16T22:17:39.313Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c2/6402ee3ccb3363aeed8fdb3f9fe56703ac5377a539f0b81b53999d7e121f/pillow-13.0.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c6fd031333dedcad7ce3e03d244093f43504f209e86925ebdb2b868fa4037630", size = 7309231, upload-time = "2026-10-16T22:17:41.021Z" }, + { url = "https://files.pythonhosted.org/packages/fc/96/d6b82e8b8c06ad143b66b12e0b31051e4bf489b4fabca1f390a14fe1ebde/pillow-13.0.0-cp315-cp315t-win32.whl", hash = "sha256:c50c80e3591e5cf9a8dfa6007d4f10732f02e32263850999286e56ccd650cfb2", size = 6517200, upload-time = "2026-10-16T22:17:42.886Z" }, + { url = "https://files.pythonhosted.org/packages/60/d4/0062e6005e9b270b165015aafd343364ac378ec5a685431ea42baad33357/pillow-13.0.0-cp315-cp315t-win_amd64.whl", hash = "sha256:adb3c2124c68e480f4632d06cb7caa3b48a51678475f483b4edb1c6dee29ea43", size = 7287488, upload-time = "2026-10-16T22:17:45.56Z" }, + { url = "https://files.pythonhosted.org/packages/e4/81/b8eb349746be7bce47dd95337c6634e84910af91b59aaa9159c034c614fa/pillow-13.0.0-cp315-cp315t-win_arm64.whl", hash = "sha256:4a25756244bdcce3b50580baadf320fa48297f0eeb8e114ba64139a40d4d0fc8", size = 2698073, upload-time = "2026-10-16T22:17:47.556Z" }, + { url = "https://files.pythonhosted.org/packages/68/c0/fb7b1caf25ed5ac5d46116f09b8b0706b9559833c65c667cecb1a53153f2/pillow-13.0.0-pp311-pypy311_pp80-macosx_10_15_x86_64.whl", hash = "sha256:405be6058e206a86fedce2f715ecc2343f94072d7925251632dffa2c05f70dca", size = 5521630, upload-time = "2026-10-16T22:17:50.008Z" }, + { url = "https://files.pythonhosted.org/packages/1a/e6/4d11d5b1137f4fc080605cce410cfaae4f303ad400bdec75d27d3083fa29/pillow-13.0.0-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:b84be41387fc00db28c13c02e30d42b02d8d8f6a4a741a1aee6012ca7f1f6039", size = 4806023, upload-time = "2026-10-16T22:17:51.932Z" }, + { url = "https://files.pythonhosted.org/packages/f1/45/0923202a658e8ff6aafd72c1a4395ebadcdebaa182c437d8595d3dd3db32/pillow-13.0.0-pp311-pypy311_pp80-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a192360d512305fe92d3053e4f7627bbad1f7708f1f4ad38cccd9336acabe9ad", size = 5464831, upload-time = "2026-10-16T22:17:53.742Z" }, + { url = "https://files.pythonhosted.org/packages/07/52/d3e38f0cad1dadea2a658b9a7d6ba8a04048f5ae14f011b806e5c3bd6bcb/pillow-13.0.0-pp311-pypy311_pp80-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83b37abd87b590acd8f47ca8a61f70893322b11834d36e07c2298dacad4d14d2", size = 6043133, upload-time = "2026-10-16T22:17:55.625Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f9/2ed7685980c1d64f8131a7d87a19c369424747577152622b73ddeff163b7/pillow-13.0.0-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:73ee25cf8cf8b75d5a5aeed7cb6588f5e5dbfbb0fde51707b630bbb23897645c", size = 7365208, upload-time = "2026-10-16T22:17:57.509Z" }, + { url = "https://files.pythonhosted.org/packages/60/da/f6d644e631c5b370c5d0b899f3ccadd2780aab070b29a110527cf4e1fd2c/pillow-13.0.0-pp312-pypy312_pp80-macosx_10_15_x86_64.whl", hash = "sha256:8619685cad8efc4c38c66d3b36aad854523297c43d748b3b6f0cc03c90ba3463", size = 5523188, upload-time = "2026-10-16T22:17:59.334Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b0/2594f202d743a35d4fe80682bfd96f277e5ca7aecb27cf64ef5bfc1d000b/pillow-13.0.0-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:7e91e97a929232cdb828889ee6ad0e48d7f40f4ec38818f4e4a8968b2b1af22e", size = 4806808, upload-time = "2026-10-16T22:18:01.188Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ed/d504165ef79af6fafe25c76726cf30f544cc236002f7ed054f1d51adc4ff/pillow-13.0.0-pp312-pypy312_pp80-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93ff9c9c3663ce23aaa5d0b6b6abac55359e2a0c06c7e30f3771829af816be63", size = 5464358, upload-time = "2026-10-16T22:18:02.779Z" }, + { url = "https://files.pythonhosted.org/packages/7b/fe/a10aba5503ca13e9e009b8bd0a1a4ce5584bdce246245fd6f3a83701369c/pillow-13.0.0-pp312-pypy312_pp80-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e65dd6e75de67b2362a4fa3e16aa627595f65054dc4ec73d64680c84d592fa61", size = 6043514, upload-time = "2026-10-16T22:18:04.538Z" }, + { url = "https://files.pythonhosted.org/packages/c0/37/66b12dc476041fe523bca29c9d58fbf1ea92a4eaf73acfb9e993948f1191/pillow-13.0.0-pp312-pypy312_pp80-win_amd64.whl", hash = "sha256:567f20f1c75897d7663830da6ea8af719e03d712b1d9ee7f813319f9ece42e5a", size = 7366030, upload-time = "2026-10-16T22:18:06.638Z" }, +] + [[package]] name = "platformdirs" version = "4.9.4" @@ -3653,11 +3750,11 @@ wheels = [ [[package]] name = "setuptools" -version = "84.0.0" +version = "84.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } +sdist = { url = "https://files.pythonhosted.org/packages/70/b3/c19426f783614d625699a4b247faf628ea9ab1db228674491767b417f9e1/setuptools-84.2.1.tar.gz", hash = "sha256:3ff8b3b5492a0aa66960f4419365a86b84e013070d4313e4d4a6894f5d64d6bc", size = 1230289, upload-time = "2026-10-20T19:01:37.742Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, + { url = "https://files.pythonhosted.org/packages/ef/55/6dc6d6bb953bdfc52519a0059592c52b6e3b8ad51cf8256dffceb55b0610/setuptools-84.2.1-py3-none-any.whl", hash = "sha256:ca140b45d5325a9478f880e351484f62fef15f336462e449ca0c03c873d589c2", size = 876263, upload-time = "2026-10-20T19:01:35.678Z" }, ] [[package]]