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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion citrate_sdk/_chain_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
42 changes: 35 additions & 7 deletions citrate_sdk/_generated/federation_contract.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
}
Expand Down
4 changes: 2 additions & 2 deletions citrate_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
"""
Expand Down
2 changes: 1 addition & 1 deletion citrate_sdk/identity/wallet.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion citrate_sdk/learning.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
91 changes: 51 additions & 40 deletions tests/test_r1006_network_guard.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -105,33 +116,33 @@ 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()
assert methods.count("eth_getBlockByNumber") == 2


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"):
Expand All @@ -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()
Expand All @@ -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):
Expand All @@ -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()
Expand All @@ -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 ───────────────────────────────────────────
Expand Down Expand Up @@ -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)
Loading