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..0289b64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,61 @@ 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. 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` (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. +- 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 edf0e53..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,21 +7,87 @@ 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: + """Sentinel: no expected genesis was passed (use the vendored one).""" + + +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", []) @@ -35,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 936caed..2e247da 100644 --- a/citrate_sdk/_generated/contract.py +++ b/citrate_sdk/_generated/contract.py @@ -26,6 +26,11 @@ def chain_id() -> int: return int(federation_contract()["chain"]["chainId"]) +def genesis_hash() -> str: + """Block-0 hash of the network the vendored address book is for (r1006).""" + return str(federation_contract()["chain"]["genesisHash"]).lower() + + def rpc_url() -> str: return str(federation_contract()["chain"]["rpcUrl"]) 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 c22cec6..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): + 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) @@ -145,6 +154,18 @@ 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: + """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. @@ -484,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 7f3d14c..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 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 @@ -173,10 +178,13 @@ 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. 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/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..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 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 @@ -70,10 +75,13 @@ 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. 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 4a92b89..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 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 ( @@ -240,10 +245,13 @@ 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. 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 @@ -528,10 +536,13 @@ 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. 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 @@ -541,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: @@ -700,10 +715,13 @@ 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. 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 12994fd..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 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,10 +80,13 @@ 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. 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/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"]) 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_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_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_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_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 new file mode 100644 index 0000000..6db0fee --- /dev/null +++ b/tests/test_r1006_network_guard.py @@ -0,0 +1,266 @@ +"""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, ConfigurationError, WrongNetworkError +from citrate_sdk.identity import wallet +from citrate_sdk.learning import StakingManager + +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_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"): + 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 _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() + 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) 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..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]] @@ -590,7 +590,7 @@ wheels = [ [[package]] name = "citrate-labs-sdk" -version = "0.6.4" +version = "0.7.0" source = { editable = "." } dependencies = [ { name = "cryptography" }, @@ -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]]