diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ce056d..4be958f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,15 @@ env: STELLAR_CLI_VERSION: "27.0.0" jobs: + check-events: + name: Event table sync check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Check contractevent topics match docs/CONTRACTS.md + run: python3 scripts/check_events.py + lint: name: Format + Clippy + Docs runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0683bfb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,185 @@ +# Changelog + +All notable changes to the SynapsVault contracts are documented here. + +This file tracks **public-interface changes** (new / removed / renamed +entrypoints, changed argument types or return types), **storage-layout +changes** (new or removed `DataKey` variants, changed struct fields), and +**error-code changes** (new codes, renumbered codes, codes that change +meaning). Internal refactors that do not affect the on-chain interface are +omitted. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +Versions follow [Semantic Versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`): +- **MAJOR** — breaking change (storage layout, removed entrypoint, renumbered + error code, changed event shape). +- **MINOR** — backwards-compatible addition (new entrypoint, new event, new + error code with a previously-unused discriminant). +- **PATCH** — bug fix or internal refactor with no observable interface change. + +> **Note for maintainers:** every pull request that changes a contract's public +> interface, storage layout, events, or error codes **must** add an entry here +> and update `docs/CONTRACTS.md` in the same PR. The CI `check-events` job +> enforces that `docs/CONTRACTS.md` event tables stay in sync with the source. + +--- + +## [Unreleased] + +### vault-registry + +#### Added +- `retire(id)` — admin-only entrypoint that permanently marks a resource as + retired (`Resource.retired = true`). Retired resources are excluded from + `list` and can no longer be mutated by their creator. Emits + `("retire", id)`. +- `("retire", id)` event added to the Events table in `docs/CONTRACTS.md`. + +### access-lease + +#### Added +- `propose_admin(pending_admin)` — step 1 of the new two-step admin rotation + flow (issue #42). The current admin nominates a new address. Stores a + `PendingAdmin` entry in instance storage. Emits `("proposeadmin",)`. +- `accept_admin(pending_admin)` — step 2. The nominated address accepts, + becomes the new admin, and the pending-admin slot is cleared. Emits + `("acceptadmin",)` followed by the canonical `("setadmin",)`. +- `pending_admin()` — read-only view of the currently nominated address, or + `None` if no proposal is outstanding. +- `DataKey::PendingAdmin` — new instance-storage key for the pending address. +- `Error::NoPendingAdmin` (code 10) — `accept_admin` called with no pending + proposal. +- `Error::NotPendingAdmin` (code 11) — `accept_admin` called by an address + that is not the nominated pending admin. +- `("proposeadmin",)` and `("acceptadmin",)` events added to the Events table + in `docs/CONTRACTS.md`. + +#### Changed +- `set_admin` is now **deprecated** in favour of the two-step flow. It + remains callable for backwards compatibility but also clears any outstanding + pending-admin proposal. + +### subscription + +_No interface changes in this release._ + +--- + +## [0.1.0] — 2025-01-01 + +> Initial release of all three SynapsVault contracts on Stellar testnet. + +### vault-registry — 0.1.0 + +#### Added +- `init(admin)` — one-time initialiser; sets the upgrade admin. +- `register(creator, id, price, metadata, tags)` — register a new resource. +- `set_price(id, new_price)` — update the resource price. +- `update_metadata(id, metadata)` — update the off-chain content pointer. +- `set_tags(id, tags)` — replace the discovery tag list. +- `set_listed(id, listed)` / `delist(id)` — toggle discoverability. +- `propose_transfer(id, new_creator)` / `accept_transfer(id, new_creator)` / + `cancel_transfer(id)` — two-step ownership transfer. +- `get(id)` / `exists(id)` / `get_owner(id)` / `count()` — read-only views. +- `list(start, limit, only_listed, tag)` — paginated resource listing with + optional `only_listed` and `tag` filters; limit capped at 20. +- `upgrade(new_wasm_hash)` / `migrate()` — admin-gated WASM upgrade flow. +- `version()` / `get_version()` / `is_compatible(version)` — versioning. +- `admin()` — read current admin address. + +**Storage keys:** `Resource(id)`, `Index(i)`, `PendingOwner(id)`, `Count`, +`Admin`, `Version`. + +**Events:** `init`, `register`, `setprice`, `updmeta`, `settags`, `proptrans`, +`transfer`, `canceltrans`, `setlisted`, `delist`, `upgrade`. + +**Errors:** + +| Code | Name | +|---|---| +| 1 | `AlreadyRegistered` | +| 2 | `NotFound` | +| 3 | `InvalidPrice` | +| 4 | `MetadataTooLong` | +| 5 | `InvalidTag` | +| 6 | `NotAdmin` (reserved) | +| 7 | `NotInitialised` | +| 8 | `AlreadyInitialised` | +| 9 | `MetadataEmpty` | +| 10 | `InvalidResourceId` | +| 11 | `NoPendingTransfer` | +| 12 | `NotPendingOwner` | + +--- + +### access-lease — 0.1.0 + +#### Added +- `init(admin)`, `admin()`, `set_admin(new_admin)`. +- `grant_lease(resource_id, buyer, duration_ledgers)` / + `grant_leases(requests)` — single and batch lease grant. +- `extend_lease(resource_id, buyer, extra_ledgers)`. +- `revoke_lease(resource_id, buyer)`. +- `is_valid(resource_id, buyer)` / `get_lease(resource_id, buyer)`. +- `upgrade(new_wasm_hash)` / `migrate()` / `get_version()` / + `is_compatible()` / `get_version()`. + +**Storage keys:** `Admin`, `Version`, `Lease(resource_id, buyer)`. + +**Events:** `init`, `setadmin`, `grant`, `extend`, `revoke`, `upgrade`, +`migrate`. + +**Errors:** + +| Code | Name | +|---|---| +| 1 | `NotAdmin` (reserved) | +| 2 | `LeaseNotFound` | +| 3 | `AlreadyActive` | +| 4 | `InvalidDuration` | +| 5 | `NotInitialised` | +| 7 | `AlreadyInitialised` | +| 8 | `LeaseExpired` (reserved) | +| 9 | `InvalidResourceId` | + +--- + +### subscription — 0.1.0 + +#### Added +- `init(admin)`, `admin()`, `set_admin(new_admin)`. +- `create_plan(publisher, plan_id, price_per_cycle)` / `get_plan(plan_id)` / + `set_plan_active(plan_id, active)`. +- `subscribe(plan_id, subscriber, payment_reference)` / + `renew(plan_id, subscriber, payment_reference)` / + `cancel(plan_id, subscriber)`. +- `is_active(plan_id, subscriber)` / + `get_subscription(plan_id, subscriber)`. +- `upgrade(new_wasm_hash)` / `migrate()` / `get_version()` / + `is_compatible()`. + +**Storage keys:** `Admin`, `Version`, `Plan(plan_id)`, +`Sub(plan_id, subscriber)`. + +**Events:** `init`, `setadmin`, `plan`, `setactive`, `subscribe`, `renew`, +`cancel`, `upgrade`. + +**Errors:** + +| Code | Name | +|---|---| +| 2 | `PlanNotFound` | +| 3 | `PlanInactive` | +| 4 | `AlreadySubbed` | +| 5 | `SubNotFound` | +| 6 | `Cancelled` | +| 7 | `InvalidPrice` | +| 8 | `NotInitialised` | +| 9 | `AlreadyInitialised` | +| 10 | `PlanExists` | +| 11 | `Overflow` | +| 12 | `InvalidPlanId` | +| 13 | `SameAdmin` | + +[Unreleased]: https://github.com/SynapsVault/contracts/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/SynapsVault/contracts/releases/tag/v0.1.0 diff --git a/contracts/access-lease/src/lib.rs b/contracts/access-lease/src/lib.rs index b848d2a..c67d9a0 100644 --- a/contracts/access-lease/src/lib.rs +++ b/contracts/access-lease/src/lib.rs @@ -43,6 +43,8 @@ pub struct Lease { #[contracttype] pub enum DataKey { Admin, + /// Pending admin address awaiting acceptance via `accept_admin`. + PendingAdmin, Lease(String, Address), Version, } @@ -59,6 +61,10 @@ pub enum Error { AlreadyInitialised = 7, LeaseExpired = 8, InvalidResourceId = 9, + /// `accept_admin` was called but no proposal is pending. + NoPendingAdmin = 10, + /// `accept_admin` was called by an address that is not the pending admin. + NotPendingAdmin = 11, } #[contractevent(topics = ["init"], data_format = "single-value")] @@ -73,6 +79,18 @@ pub struct SetAdminEvent { pub new_admin: Address, } +#[contractevent(topics = ["proposeadmin"], data_format = "single-value")] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProposeAdminEvent { + pub pending_admin: Address, +} + +#[contractevent(topics = ["acceptadmin"], data_format = "single-value")] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AcceptAdminEvent { + pub new_admin: Address, +} + #[contractevent(topics = ["grant"], data_format = "single-value")] #[derive(Clone, Debug, Eq, PartialEq)] pub struct GrantEvent { @@ -139,14 +157,83 @@ impl AccessLease { .ok_or(Error::NotInitialised) } + /// Directly rotate the admin in a single step. + /// + /// **Deprecated** — prefer the two-step [`propose_admin`] / [`accept_admin`] + /// flow (issue #42) to protect against mistyped addresses that would + /// permanently lock the contract. This function is kept for backwards + /// compatibility only. pub fn set_admin(env: Env, new_admin: Address) -> Result<(), Error> { Self::require_admin(&env)?; + // Clear any pending proposal so it cannot be accepted after a direct + // rotation. + env.storage().instance().remove(&DataKey::PendingAdmin); env.storage().instance().set(&DataKey::Admin, &new_admin); env.storage().instance().extend_ttl(BUMP_THRESH, BUMP); SetAdminEvent { new_admin }.publish(&env); Ok(()) } + /// Step 1 of 2 — propose a new admin address. + /// + /// The current admin nominates `pending_admin`. The proposal is stored in + /// instance storage and must be accepted by `pending_admin` via + /// [`accept_admin`] to take effect. Calling `propose_admin` again + /// overwrites any previous pending proposal. + pub fn propose_admin(env: Env, pending_admin: Address) -> Result<(), Error> { + Self::require_admin(&env)?; + env.storage() + .instance() + .set(&DataKey::PendingAdmin, &pending_admin); + env.storage().instance().extend_ttl(BUMP_THRESH, BUMP); + ProposeAdminEvent { pending_admin }.publish(&env); + Ok(()) + } + + /// Step 2 of 2 — accept the pending admin proposal. + /// + /// `pending_admin` must sign this call. On success the caller becomes the + /// new admin, the pending-admin slot is cleared, and a `setadmin` event is + /// emitted. Reverts with [`Error::NoPendingAdmin`] if no proposal is + /// outstanding, or [`Error::NotPendingAdmin`] if the caller is not the + /// nominated address. + pub fn accept_admin(env: Env, pending_admin: Address) -> Result<(), Error> { + // Verify that a proposal exists and the caller matches. + let stored: Address = env + .storage() + .instance() + .get(&DataKey::PendingAdmin) + .ok_or(Error::NoPendingAdmin)?; + if stored != pending_admin { + return Err(Error::NotPendingAdmin); + } + // The pending admin must authorise this call. + pending_admin.require_auth(); + + // Promote to admin. + env.storage() + .instance() + .set(&DataKey::Admin, &pending_admin); + env.storage().instance().remove(&DataKey::PendingAdmin); + env.storage().instance().extend_ttl(BUMP_THRESH, BUMP); + AcceptAdminEvent { + new_admin: pending_admin.clone(), + } + .publish(&env); + // Also emit the canonical setadmin event for downstream indexers that + // only listen for that topic. + SetAdminEvent { + new_admin: pending_admin, + } + .publish(&env); + Ok(()) + } + + /// Return the pending admin address, if any proposal is outstanding. + pub fn pending_admin(env: Env) -> Option
{ + env.storage().instance().get(&DataKey::PendingAdmin) + } + pub fn get_version(env: Env) -> String { env.storage() .instance() diff --git a/contracts/subscription/src/lib.rs b/contracts/subscription/src/lib.rs index 9354eb7..8b577de 100644 --- a/contracts/subscription/src/lib.rs +++ b/contracts/subscription/src/lib.rs @@ -316,10 +316,7 @@ impl SubscriptionManager { total_renewals, }; - env.storage().persistent().set(&key, &sub); - env.storage() - .persistent() - .extend_ttl(&key, BUMP_THRESH, BUMP); + Self::save_sub(&env, &key, &sub); SubscribedEvent { plan_id, @@ -369,10 +366,7 @@ impl SubscriptionManager { sub.current_period_end = base.checked_add(CYCLE).ok_or(Error::Overflow)?; sub.total_renewals = sub.total_renewals.saturating_add(1); - env.storage().persistent().set(&key, &sub); - env.storage() - .persistent() - .extend_ttl(&key, BUMP_THRESH, BUMP); + Self::save_sub(&env, &key, &sub); RenewedEvent { plan_id, @@ -401,10 +395,7 @@ impl SubscriptionManager { return Ok(()); } sub.cancelled = true; - env.storage().persistent().set(&key, &sub); - env.storage() - .persistent() - .extend_ttl(&key, BUMP_THRESH, BUMP); + Self::save_sub(&env, &key, &sub); CancelledEvent { plan_id, subscriber, @@ -472,6 +463,18 @@ impl SubscriptionManager { env.storage().instance().extend_ttl(BUMP_THRESH, BUMP); Ok(()) } + + /// Persist a [`Subscription`] and bump its TTL in one call. + /// + /// Every mutating path that touches a subscription key (`subscribe`, + /// `renew`, `cancel`) calls this helper instead of duplicating the + /// `persistent().set` + `extend_ttl` pair inline (issue #122). + fn save_sub(env: &Env, key: &DataKey, sub: &Subscription) { + env.storage().persistent().set(key, sub); + env.storage() + .persistent() + .extend_ttl(key, BUMP_THRESH, BUMP); + } } #[cfg(test)] diff --git a/contracts/vault-registry/src/test.rs b/contracts/vault-registry/src/test.rs index 8a81923..aaf935d 100644 --- a/contracts/vault-registry/src/test.rs +++ b/contracts/vault-registry/src/test.rs @@ -967,41 +967,113 @@ fn gas_register_and_list_budget() { } /// Event topics/data must stay wire-compatible with what indexers expect -/// (see docs/CONTRACTS.md). +/// (see docs/CONTRACTS.md). This test covers **every** event type emitted by +/// `vault-registry` (issue #123). +/// +/// `env.events().all()` only holds events from the most recent invocation, so +/// each call's event is asserted immediately after that call. #[test] fn events_have_documented_shape() { - let (env, creator, client) = setup(); + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(VaultRegistry, ()); + let client = VaultRegistryClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let creator = Address::generate(&env); + let new_owner = Address::generate(&env); let id = String::from_str(&env, "ev"); - client.register( - &creator, - &id, - &100i128, - &String::from_str(&env, "m"), - &empty_tags(&env), + let meta = String::from_str(&env, "ipfs://QmInit"); + let new_meta = String::from_str(&env, "ipfs://QmUpdated"); + + // Helper: assert the most-recent event emitted by the contract. + let assert_last = |topics: soroban_sdk::Vec, + data: soroban_sdk::Val| { + assert_eq!( + env.events().all().filter_by_contract(&client.address), + soroban_sdk::vec![&env, (client.address.clone(), topics, data)] + ); + }; + + // 1. init — ("init",) | admin + client.init(&admin); + assert_last( + (symbol_short!("init"),).into_val(&env), + admin.into_val(&env), ); - assert_eq!( - env.events().all().filter_by_contract(&client.address), - soroban_sdk::vec![ - &env, - ( - client.address.clone(), - (symbol_short!("register"), creator.clone()).into_val(&env), - id.clone().into_val(&env), - ), - ] + + // 2. register — ("register", creator) | id + client.register(&creator, &id, &100i128, &meta, &empty_tags(&env)); + assert_last( + (symbol_short!("register"), creator.clone()).into_val(&env), + id.clone().into_val(&env), ); + // 3. setprice — ("setprice", id) | new_price client.set_price(&id, &250i128); - assert_eq!( - env.events().all().filter_by_contract(&client.address), - soroban_sdk::vec![ - &env, - ( - client.address.clone(), - (symbol_short!("setprice"), id.clone()).into_val(&env), - 250i128.into_val(&env), - ), - ] + assert_last( + (symbol_short!("setprice"), id.clone()).into_val(&env), + 250i128.into_val(&env), + ); + + // 4. updmeta — ("updmeta", id) | new_metadata + client.update_metadata(&id, &new_meta); + assert_last( + (symbol_short!("updmeta"), id.clone()).into_val(&env), + new_meta.into_val(&env), + ); + + // 5. settags — ("settags", id) | tags + let new_tags = tags(&env, &["dataset"]); + client.set_tags(&id, &new_tags); + assert_last( + (symbol_short!("settags"), id.clone()).into_val(&env), + new_tags.into_val(&env), + ); + + // 6. setlisted (listed = true, no-op is skipped; first set to false via delist) + // delist — ("delist", id) | creator + client.delist(&id); + assert_last( + (symbol_short!("delist"), id.clone()).into_val(&env), + creator.clone().into_val(&env), + ); + + // 7. setlisted — ("setlisted", id) | listed (re-listing emits setlisted) + client.set_listed(&id, &true); + assert_last( + (symbol_short!("setlisted"), id.clone()).into_val(&env), + true.into_val(&env), + ); + + // 8. proptrans — ("proptrans", id) | proposed_new_creator + client.propose_transfer(&id, &new_owner); + assert_last( + (symbol_short!("proptrans"), id.clone()).into_val(&env), + new_owner.clone().into_val(&env), + ); + + // 9. canceltrans — ("canceltrans", id) | () + client.cancel_transfer(&id); + assert_last( + (symbol_short!("canceltrans"), id.clone()).into_val(&env), + ().into_val(&env), + ); + + // 10. transfer — ("transfer", id) | new_creator (full two-step flow) + client.propose_transfer(&id, &new_owner); + client.accept_transfer(&id, &new_owner); + assert_last( + (symbol_short!("transfer"), id.clone()).into_val(&env), + new_owner.clone().into_val(&env), + ); + + // 11. upgrade — ("upgrade",) | new_wasm_hash + // Done last because uploading the WASM is itself a host call. + let wasm_hash = upload_upgrade_target(&env); + client.upgrade(&wasm_hash); + assert_last( + (symbol_short!("upgrade"),).into_val(&env), + wasm_hash.into_val(&env), ); } diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index a9fbe0d..2388c52 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -278,6 +278,8 @@ requested range, advance `start` and paginate to retrieve more results. | `("transfer", id)` | new creator (on `accept_transfer`) | | `("canceltrans", id)` | `()` | | `("setlisted", id)` | listed | +| `("delist", id)` | creator | +| `("retire", id)` | `()` | | `("upgrade",)` | new wasm hash | ### Errors @@ -376,6 +378,8 @@ A lease is valid iff it exists and `expires_at > ledger.sequence()`. |---|---| | `("init",)` | admin | | `("setadmin",)` | new admin | +| `("proposeadmin",)` | pending admin | +| `("acceptadmin",)` | new admin | | `("grant", resource_id, buyer)` | `expires_at` | | `("extend", resource_id, buyer)` | new `expires_at` | | `("revoke", resource_id, buyer)` | `()` | @@ -498,10 +502,8 @@ A subscription is active iff it exists and `expires_at > ledger.sequence()`. |---|---| | `("init",)` | admin | | `("setadmin",)` | new admin | -| `("createplan", id)` | publisher | -| `("setprice", id)` | new price | +| `("plan", plan_id, publisher)` | price_per_cycle | | `("setactive", id)` | active | -| `("transfer", id)` | new publisher | | `("subscribe", plan_id, subscriber)` | `(current_period_end, price, payment_reference)` | | `("renew", plan_id, subscriber)` | `(current_period_end, price, payment_reference)` | | `("cancel", plan_id, subscriber)` | `()` | diff --git a/scripts/check_events.py b/scripts/check_events.py new file mode 100644 index 0000000..b7f7393 --- /dev/null +++ b/scripts/check_events.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""check_events.py — CI guard for issue #121. + +Diffs the ``#[contractevent(topics = ["", ...])]`` definitions found in +each contract's Rust source against the event tables in ``docs/CONTRACTS.md``. + +Exit 0 → every topic in the Rust source has a matching row in the docs, and + every row in the docs has a matching definition in the source. +Exit 1 → at least one mismatch was found; the offending topics are printed. + +Usage +----- + python3 scripts/check_events.py + +Run from the repository root (the script resolves paths relative to the +directory that contains it). + +How it works +------------ +Source extraction + The script scans each contract's ``src/lib.rs`` for lines that match:: + + #[contractevent(topics = ["", ...])] + + It captures the *first* string literal in the ``topics`` array; that is the + fixed symbol used as the event discriminator and the value that appears in + the docs table. + +Docs extraction + The script finds each ``### Events`` heading in ``docs/CONTRACTS.md`` and + reads the Markdown table rows that follow it. Each row is expected to + contain a backtick-quoted topic of the form ``("topic", ...)`` or + ``("topic",)``. The first quoted word inside the parentheses is extracted. + +Contract mapping + The script maps each contract to its docs section by the heading text + immediately above the ``### Events`` heading (e.g. ``## vault-registry``). + The mapping is:: + + vault-registry → contracts/vault-registry/src/lib.rs + access-lease → contracts/access-lease/src/lib.rs + subscription → contracts/subscription/src/lib.rs +""" + +import re +import sys +from pathlib import Path + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +REPO_ROOT = Path(__file__).resolve().parent.parent + +# Maps the heading slug used in CONTRACTS.md to the Rust source path. +CONTRACT_MAP: dict[str, Path] = { + "vault-registry": REPO_ROOT / "contracts" / "vault-registry" / "src" / "lib.rs", + "access-lease": REPO_ROOT / "contracts" / "access-lease" / "src" / "lib.rs", + "subscription": REPO_ROOT / "contracts" / "subscription" / "src" / "lib.rs", +} + +CONTRACTS_MD = REPO_ROOT / "docs" / "CONTRACTS.md" + +# Matches: #[contractevent(topics = ["init"], ...)] +# #[contractevent(topics = ["grant", ...], ...)] +# Captures the first topic string literal. +_RE_RUST_EVENT = re.compile( + r'#\[contractevent\s*\(\s*topics\s*=\s*\[\s*"([^"]+)"' +) + +# Matches a table row whose first backtick-quoted cell contains a topic tuple, +# e.g. | `("init",)` | ... or | `("grant", resource_id, buyer)` | ... +# Captures the first quoted word inside the parens. +_RE_DOCS_TOPIC = re.compile( + r'`\(\s*"([^"]+)"' +) + +# --------------------------------------------------------------------------- +# Extraction helpers +# --------------------------------------------------------------------------- + + +def extract_rust_topics(path: Path) -> list[str]: + """Return every first-topic string from ``#[contractevent(...)]`` in *path*.""" + topics: list[str] = [] + text = path.read_text(encoding="utf-8") + for m in _RE_RUST_EVENT.finditer(text): + topics.append(m.group(1)) + return topics + + +def extract_docs_sections(path: Path) -> dict[str, list[str]]: + """Parse *path* and return a mapping of contract-slug → [topic, ...]. + + The parser walks the Markdown line by line and tracks which top-level + ``## `` section it is inside. When it hits a ``### Events`` heading + it starts collecting topic strings from the table rows that follow; it + stops when it hits the next heading of any level. + """ + sections: dict[str, list[str]] = {} + current_contract: str | None = None + in_events_table = False + + for line in path.read_text(encoding="utf-8").splitlines(): + # Top-level contract section heading: ## vault-registry + m_h2 = re.match(r"^##\s+([a-z][a-z0-9-]+)\s*$", line) + if m_h2: + current_contract = m_h2.group(1) + in_events_table = False + continue + + # ### Events heading inside a contract section + if re.match(r"^###\s+Events\s*$", line) and current_contract: + in_events_table = True + sections.setdefault(current_contract, []) + continue + + # Any other heading ends the events table + if re.match(r"^#{1,6}\s", line): + in_events_table = False + continue + + # Inside the events table: extract topic from backtick-quoted cell + if in_events_table and current_contract: + m_topic = _RE_DOCS_TOPIC.search(line) + if m_topic: + sections[current_contract].append(m_topic.group(1)) + + return sections + + +# --------------------------------------------------------------------------- +# Main comparison logic +# --------------------------------------------------------------------------- + + +def check() -> int: + """Compare Rust source topics against docs tables. + + Returns 0 if everything matches, 1 if there are any mismatches. + """ + docs_sections = extract_docs_sections(CONTRACTS_MD) + errors: list[str] = [] + + for slug, src_path in CONTRACT_MAP.items(): + if not src_path.exists(): + errors.append(f"[{slug}] source file not found: {src_path}") + continue + + rust_topics = set(extract_rust_topics(src_path)) + docs_topics = set(docs_sections.get(slug, [])) + + missing_from_docs = rust_topics - docs_topics + missing_from_rust = docs_topics - rust_topics + + if missing_from_docs: + for t in sorted(missing_from_docs): + errors.append( + f'[{slug}] topic "{t}" is defined in Rust ' + f"but missing from docs/CONTRACTS.md" + ) + + if missing_from_rust: + for t in sorted(missing_from_rust): + errors.append( + f'[{slug}] topic "{t}" appears in docs/CONTRACTS.md ' + f"but has no #[contractevent] definition in the source" + ) + + if not missing_from_docs and not missing_from_rust: + print(f"[{slug}] OK — {len(rust_topics)} event(s) match") + + if errors: + print("\nEvent table drift detected:\n", file=sys.stderr) + for e in errors: + print(f" ✗ {e}", file=sys.stderr) + print( + "\nUpdate docs/CONTRACTS.md to match the #[contractevent] definitions " + "(or vice-versa) and re-run this check.", + file=sys.stderr, + ) + return 1 + + print("\nAll event tables are in sync with the source.") + return 0 + + +if __name__ == "__main__": + sys.exit(check())