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
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 18 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <https://docs.citrate.ai>.
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)).
Expand Down Expand Up @@ -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:
Expand All @@ -122,9 +135,10 @@ LOCAL_STACK guide at <https://docs.citrate.ai>.
| `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=<vendored>)`. 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: <https://docs.citrate.ai>
Expand Down
2 changes: 1 addition & 1 deletion citrate_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
StakingInfo,
)

__version__ = "0.6.4"
__version__ = "0.7.0"
__author__ = "Citrate Team"

__all__ = [
Expand Down
77 changes: 74 additions & 3 deletions citrate_sdk/_chain_guard.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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", [])
Expand All @@ -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))]))
5 changes: 5 additions & 0 deletions citrate_sdk/_generated/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])

Expand Down
Loading
Loading