From 580cc98730822b39ea7df8f64eec6436ac8bf813 Mon Sep 17 00:00:00 2001 From: Larry Klosowski Date: Thu, 12 Nov 2026 09:16:45 -0800 Subject: [PATCH 1/2] test(network): red: the vendored book is r1007, and an r1006 node is the wrong network The guard suite expects R1007 as the book's genesis and treats an r1006 node the way it treated r1005. TenantHierarchy is the r1007 nonce-0 address, and no r1006 post-genesis address may leak. Red against the committed r1006 artifact (8 failing in the full run before the re-sync). Signed-off-by: Larry Klosowski Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019cgBjDQkjCEEZwG1zJNnQi --- tests/test_r1006_network_guard.py | 91 +++++++++++++++++-------------- 1 file changed, 51 insertions(+), 40 deletions(-) diff --git a/tests/test_r1006_network_guard.py b/tests/test_r1006_network_guard.py index 6db0fee..76492f7 100644 --- a/tests/test_r1006_network_guard.py +++ b/tests/test_r1006_network_guard.py @@ -1,10 +1,11 @@ -"""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. +"""r1007 address book + genesis guard (file name kept from the r1006 version). + +r1005, r1006 and r1007 share chain id 40204, so the SPY-B-005 / PBA-L6b-042 chain-id pins cannot +tell them apart. The vendored artifact is the r1007 root book (citrate-chain +``contracts/addresses/40204.json`` via citrate-federation gen-contract) and carries the r1007 +block-0 hash (``R1007_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, +including r1006, which the book just left. """ from __future__ import annotations @@ -22,6 +23,7 @@ from citrate_sdk.identity import wallet from citrate_sdk.learning import StakingManager +R1007 = "0xcf68634502b4fca1e977ac8dd35aa006bb40c04208d5cd55f1c2b508bba96631" R1006 = "0xa3651a272fe411948cbb4cf7cca4cee44be4248d32ab4376cea7636821c6cdd4" R1005 = "0x1dcfc4909dd9fdcb0f03c823d627e4b2032cc86bef5fc4561030d6c64b9775f1" KEY = "0x" + "11" * 32 @@ -31,26 +33,35 @@ # ── the vendored book ──────────────────────────────────────────────────────── -def test_artifact_carries_the_r1006_genesis() -> None: - assert contract.genesis_hash() == R1006 +def test_artifact_carries_the_r1007_genesis() -> None: + assert contract.genesis_hash() == R1007 -def test_book_points_at_r1006_contracts() -> None: +def test_book_points_at_r1007_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: +def test_book_drops_retired_contracts_and_carries_the_r1007_post_genesis_ones() -> None: names = contract.contracts() - for gone in ("LiquidStakingPool", "CitAgentTimelock", "DefensePrimeComplianceRegistry", "TenantHierarchy"): + for gone in ("LiquidStakingPool", "CitAgentTimelock"): assert gone not in names + # The root book folds the post-genesis contracts in: r1007 POSTGENESIS_DEPLOYER nonce 0. + assert names["TenantHierarchy"].lower() == "0x92f2bc7e1b33841de1b138fe28cf202222562cc3" + assert "DefensePrimeComplianceRegistry" in names + +def test_no_r1006_post_genesis_address_leaks() -> None: + blob = json.dumps(contract.federation_contract()).lower() + for dead in ("0xe1c16749", "0xb0772491", "0x82224833", "0xf2359912", "0x5924d1d3"): + assert dead not in blob, dead -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_staking_manager_says_liquid_staking_is_not_in_r1007() -> None: + with pytest.raises(ConfigurationError, match="not in the r1007 address book"): + StakingManager(_manager_rpc(R1007)[0], default_account=ACCT).deposit("1") def test_no_r1005_address_leaks() -> None: @@ -81,17 +92,17 @@ def rpc(method: str, params: Any = None) -> Any: return c, methods -def test_client_refuses_an_r1005_node_naming_both_hashes() -> None: - c, _ = _client(R1005) +def test_client_refuses_an_r1006_node_naming_both_hashes() -> None: + c, _ = _client(R1006) with pytest.raises(WrongNetworkError) as e: c.verify_network() - assert R1005 in str(e.value) and R1006 in str(e.value) + assert R1006 in str(e.value) and R1007 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")) +def test_client_accepts_r1007_in_any_case_and_checks_once() -> None: + c, methods = _client(R1007.upper().replace("0X", "0x")) c.verify_network() c.verify_network() assert methods.count("eth_getBlockByNumber") == 1 @@ -105,7 +116,7 @@ def test_client_fails_closed_without_block_0(genesis: Any) -> None: def test_client_failure_is_not_cached() -> None: - c, methods = _client(R1005) + c, methods = _client(R1006) for _ in range(2): with pytest.raises(WrongNetworkError): c.verify_network() @@ -113,25 +124,25 @@ def test_client_failure_is_not_cached() -> None: def test_client_send_refuses_before_signing_on_another_network() -> None: - c, methods = _client(R1005) + c, methods = _client(R1006) 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) +def test_client_send_goes_through_on_r1007() -> None: + c, methods = _client(R1007) 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, methods = _client(R1006, expected_genesis_hash=None) off.verify_network() assert "eth_getBlockByNumber" not in methods - pinned, _ = _client(R1005, expected_genesis_hash=R1005) + pinned, _ = _client(R1006, expected_genesis_hash=R1006) pinned.verify_network() - dev, dev_methods = _client(R1005, chain_id=31337) + dev, dev_methods = _client(R1006, chain_id=31337) dev.verify_network() assert "eth_getBlockByNumber" not in dev_methods with pytest.raises(CitrateError, match="expected_genesis_hash"): @@ -154,15 +165,15 @@ def rpc(method: str, params: Any) -> Any: return rpc, methods -def test_manager_write_refuses_an_r1005_node_before_sending() -> None: - rpc, methods = _manager_rpc(R1005) +def test_manager_write_refuses_an_r1006_node_before_sending() -> None: + rpc, methods = _manager_rpc(R1006) 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) +def test_manager_write_sends_on_r1007_and_checks_once() -> None: + rpc, methods = _manager_rpc(R1007) mgr = ComputeManager(rpc, default_account=ACCT, contract_addresses=ADDRS) mgr.heartbeat() mgr.heartbeat() @@ -189,7 +200,7 @@ def rpc(method: str, params: Any) -> Any: 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) + rpc, methods = _manager_rpc(R1006) mgr = ComputeManager(rpc, default_account=ACCT, contract_addresses=ADDRS) for _ in range(2): with pytest.raises(WrongNetworkError): @@ -200,7 +211,7 @@ def test_manager_failure_is_not_cached() -> None: def test_manager_rechecks_after_a_failure_and_then_caches_success() -> None: - rpc, methods = _switching_manager_rpc([R1005, R1006]) + rpc, methods = _switching_manager_rpc([R1006, R1007]) mgr = ComputeManager(rpc, default_account=ACCT, contract_addresses=ADDRS) with pytest.raises(WrongNetworkError): mgr.heartbeat() @@ -222,11 +233,11 @@ def test_manager_fails_closed_when_block_0_is_unreadable(answer: Any) -> None: def test_manager_override_and_opt_out() -> None: - rpc, methods = _manager_rpc(R1005) + rpc, methods = _manager_rpc(R1006) 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() + rpc2, _ = _manager_rpc(R1006) + ComputeManager(rpc2, default_account=ACCT, contract_addresses=ADDRS, expected_genesis_hash=R1006).heartbeat() # ── verify_wallet_address_on_chain ─────────────────────────────────────────── @@ -254,13 +265,13 @@ def fake_post(url: str, data: str | None = None, headers: Any = None, timeout: A return fake_post -def test_wallet_verify_refuses_an_r1005_node() -> None: - with mock.patch.object(wallet.requests, "post", _wallet_post(R1005)): +def test_wallet_verify_refuses_an_r1006_node() -> None: + with mock.patch.object(wallet.requests, "post", _wallet_post(R1006)): 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)): +def test_wallet_verify_passes_on_r1007() -> None: + with mock.patch.object(wallet.requests, "post", _wallet_post(R1007)): assert wallet.verify_wallet_address_on_chain(UID, rpc_url="https://rpc.example") == \ wallet.predict_wallet_address(UID) From 8f34bd7d79e21b4d0200a1cf8f71339f9716ebbc Mon Sep 17 00:00:00 2001 From: Larry Klosowski Date: Thu, 12 Nov 2026 09:16:45 -0800 Subject: [PATCH 2/2] chore(contract): re-sync federation_contract.json to the r1007 federation contract python3 scripts/sync_contract.py from citrate-federation's r1007 contract. The docstrings in client.py, _chain_guard.py and identity/wallet.py and the LiquidStakingPool message in learning.py name r1007. Green: pytest 966 passed, 8 skipped (965 tests before, 8 of them the red guard cases; one new r1006-leak test); check-contract- drift OK. Signed-off-by: Larry Klosowski Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019cgBjDQkjCEEZwG1zJNnQi --- citrate_sdk/_chain_guard.py | 2 +- .../_generated/federation_contract.json | 42 +++++++++++++++---- citrate_sdk/client.py | 4 +- citrate_sdk/identity/wallet.py | 2 +- citrate_sdk/learning.py | 2 +- 5 files changed, 40 insertions(+), 12 deletions(-) diff --git a/citrate_sdk/_chain_guard.py b/citrate_sdk/_chain_guard.py index f14ea3c..334bd96 100644 --- a/citrate_sdk/_chain_guard.py +++ b/citrate_sdk/_chain_guard.py @@ -8,7 +8,7 @@ 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 +r1005, r1006 and r1007 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. diff --git a/citrate_sdk/_generated/federation_contract.json b/citrate_sdk/_generated/federation_contract.json index 2f053f2..8bfe2c1 100644 --- a/citrate_sdk/_generated/federation_contract.json +++ b/citrate_sdk/_generated/federation_contract.json @@ -6,7 +6,7 @@ "rpcUrl": "https://rpc.citrate.ai", "explorerUrl": "https://explorer.citrate.ai", "wsUrl": "wss://rpc.citrate.ai/ws", - "genesisHash": "0xa3651a272fe411948cbb4cf7cca4cee44be4248d32ab4376cea7636821c6cdd4" + "genesisHash": "0xcf68634502b4fca1e977ac8dd35aa006bb40c04208d5cd55f1c2b508bba96631" }, "contracts": { "ModelRegistry": "0x4c367f7742059b5bf39f6fbe5837ce6164afd33f", @@ -77,7 +77,35 @@ "NetworkSBT": "0x7e7a5a040efc5d7f1900ecc25e0e7f775b417345", "NetworkSBTImpl": "0x01d34046343a171ec7cd4db8978adbab095ef955", "OrganizationSBT": "0x960bc81a48f29cf1912ac29b2b15203ebd35dda6", - "SkillRegistry": "0xbedad7a474785ecb27fc0aa02ca583bdb3761a49" + "SkillRegistry": "0xbedad7a474785ecb27fc0aa02ca583bdb3761a49", + "AppRegistry": "0xe6fa75fb5aca6b0824edd5cfd3efeaa5d82ea2e4", + "AuditBundleRegistry": "0x784a6fbc97766b000998b00e38ad1110461ca984", + "CapabilityGrant": "0xcc0e43ba3cee0de6a5d1942bce41d1d48584671e", + "ClassificationRegistry": "0xdbf07d10f27011d0a7bbb0e73804393854c4ff11", + "ContradictionLedger": "0xbe6d4e2ccb72181588c25e0689ec5a662f7fea95", + "CrossOrgEnvelope": "0x5685fc31158553d7f93f01c7ee0f5871eaeb2949", + "CrossOrgIndex": "0xb35adeca982129687aa07b03ad446f48bf0db010", + "DefensePrimeComplianceRegistry": "0xabfe0c9d358ce8af37dc4d50aa4eaebdb250c914", + "DefensePrimeFLScopeIndex": "0x9ee2bf0fac5b57ee83431ae8a76a3085d846212b", + "EntityRegistry": "0x7cac6481c4352a00a443da2d07bc558282bd5099", + "GovernanceProtocolFactory": "0xa37244e0097e02328fd5e32d1392e72f49edbda1", + "GovernanceTemplateRegistry": "0xbbc275117f157affdf7a50c067dfa1cf73baa5bf", + "MeetingRegistry": "0x3295ba290f983071fa6924a91ec6417a11fe4b4c", + "MoqRegistry": "0x019b077cb8188150fa1d563e01107d13e827a574", + "MultiSigEnvelope": "0x7e893a47434486f39bfa4cd157e5346315371f48", + "PartProvenanceRegistry": "0x5d8362d8ebdf4a6a20a7164d7865f6ed30d40142", + "PolicyBinding": "0x2e7b88f0c5ced218c9a825ef2510900aaa625923", + "Quorum.AnchorRegistry": "0x902d63e13fadd0669ad8ebfc03fbfe78f8fc4a21", + "ReleaseManifestRegistry": "0xd6a8b3134340ca1e78843db5eb6640b1dbbc8b20", + "RoleEscalation": "0x6c3f7903d12c28b976d8170e3a5639ec420cb65c", + "RoleGrantTenantIndex": "0x1309f2120e69fd9598ccf171b70d645033c358e9", + "Sortition": "0xab30a3db0b5904fefe62bd7ec7292bc32f36628e", + "SponsorEvidenceRegistry": "0x2d04435e49393d7816f4e35e5c8406e76ab99cf1", + "SupplierRegistry": "0x8cff9083202b19a8201d5cdeadbfca41fedc5efc", + "TenantHierarchy": "0x92f2bc7e1b33841de1b138fe28cf202222562cc3", + "TinaWorkpaperRegistry": "0x086f128ed3babdfde8b556bb9458216e6e720b52", + "TripwireRegistry": "0x6da8d9fe0647ef68930d917aebdfdd5f379af6e8", + "VoteAllowance": "0xc98df3ede1d913e5c304be9e1239db90f56343a0" }, "aaStack": { "EntryPoint": "0x97d5391a647429233e202f99231743c53a648f3c", @@ -143,12 +171,12 @@ "keyPrefix": "cgk_" }, "provenance": { - "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", + "deployer": "0xE92A75519e9915E57d88E4e207e4EbcB3664cf23", + "deployedAt": "2026-11-12T14:00:00Z", + "book": "citrate-chain/contracts/addresses/40204.json", + "genesisSource": "citrate-chain/core/consensus/src/hardening.rs R1007_GENESIS", "sourceHashes": { - "chainAddresses": "724e3843f677588a6aac680554dd1f2db768d5c3ca2bea18bdc0c173c100b90b", + "chainAddresses": "1b908e56f99ef14e0fe260d27e0ce837200ee4bb0884771b5a007ec66a93ca96", "entitlements": "d7c1167466eafbc3d2f8233c2adc32d310b4c73026b998e46e1b4f0c1fa461a1" } } diff --git a/citrate_sdk/client.py b/citrate_sdk/client.py index a3345b7..cc6196f 100644 --- a/citrate_sdk/client.py +++ b/citrate_sdk/client.py @@ -83,9 +83,9 @@ def __init__(self, rpc_url: str = "http://localhost:8545", private_key: str | No (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, + client signs a transaction. r1005, r1006 and r1007 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 address book's genesis (r1007 today) 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). """ diff --git a/citrate_sdk/identity/wallet.py b/citrate_sdk/identity/wallet.py index c5ce97f..e6ebf22 100644 --- a/citrate_sdk/identity/wallet.py +++ b/citrate_sdk/identity/wallet.py @@ -102,7 +102,7 @@ def verify_wallet_address_on_chain( 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). + r1006: the same chain id is not the same network (r1005, r1006 and r1007 are all 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. diff --git a/citrate_sdk/learning.py b/citrate_sdk/learning.py index 19478eb..cbcc985 100644 --- a/citrate_sdk/learning.py +++ b/citrate_sdk/learning.py @@ -554,7 +554,7 @@ def _require_address(self) -> str: if not self._staking_address: 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; " + "r1007 address book (it is not deployed on r1007, as on r1006), so there is no default; " "pass staking_address only for a network that has it." ) return self._staking_address