From d3c12f4481e6bf1b6b34418f5abf330e5a757ab3 Mon Sep 17 00:00:00 2001 From: allhandghost Date: Sun, 18 Oct 2026 12:21:58 +0000 Subject: [PATCH] fix: CI event checker, save_sub helper, vault-registry events test, changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #121, #122, #123, #125 ──────────────────────────────────────────────────────────────────── Issue #121 — CI check: contractevent topics vs docs/CONTRACTS.md ──────────────────────────────────────────────────────────────────── Problem The event tables in docs/CONTRACTS.md had drifted from the #[contractevent] definitions in the Rust source with no automated guard to catch the difference. Any PR that added or renamed an event could silently leave the docs stale, breaking downstream indexers that rely on the documented topic strings. Solution scripts/check_events.py — a pure-Python script (no third-party deps, no Rust toolchain required) that: 1. Scans each contract's src/lib.rs for every #[contractevent(topics = ["", ...])] annotation and collects the first (discriminator) topic string. 2. Parses the '### Events' table for each contract slug in docs/CONTRACTS.md and extracts the same discriminator strings from the backtick-quoted rows. 3. Computes a symmetric diff: topics in Rust but not in docs, and topics in docs but not in Rust. 4. Exits 0 when every topic matches, exits 1 with a clear error listing every mismatch. Running the script on the existing repo immediately surfaced real drift: - vault-registry: "delist" and "retire" were defined in source but absent from the Events table. - access-lease: "proposeadmin" and "acceptadmin" (added in a previous session for issue #42) were not reflected in docs. - subscription: source emits "plan" but docs said "createplan"; docs listed "setprice" and "transfer" with no corresponding #[contractevent] in the source. All of these were corrected in docs/CONTRACTS.md as part of this PR. .github/workflows/ci.yml — added a new 'check-events' job that runs before lint and test. It requires only Python 3 (pre-installed on ubuntu-latest) and no Rust build, so it fails fast and cheaply on every PR. Files changed + scripts/check_events.py ~ .github/workflows/ci.yml (new check-events job) ~ docs/CONTRACTS.md (event tables corrected for all 3 contracts) ──────────────────────────────────────────────────────────────────── Issue #122 — Factor save_sub helper in subscription/src/lib.rs ──────────────────────────────────────────────────────────────────── Problem In subscription/src/lib.rs the pattern: env.storage().persistent().set(&key, &sub); env.storage().persistent().extend_ttl(&key, BUMP_THRESH, BUMP); appeared verbatim three times — once each in subscribe(), renew(), and cancel(). A future change to the TTL policy would require updating all three sites consistently; missing one would silently create an inconsistent archival window for that path. Solution Added a private helper on SubscriptionManager: fn save_sub(env: &Env, key: &DataKey, sub: &Subscription) modelled on vault-registry's existing save() helper (same pattern, same rationale). The helper performs the set + extend_ttl pair in one call. All three public entrypoints now call Self::save_sub() instead of the inline pair, reducing duplication from 6 lines × 3 to 1 line × 3. No observable behaviour change — the storage write and TTL bump are identical; only the call site is consolidated. Files changed ~ contracts/subscription/src/lib.rs - added save_sub() to the private impl block - subscribe(): replaced set+extend_ttl with save_sub() - renew(): replaced set+extend_ttl with save_sub() - cancel(): replaced set+extend_ttl with save_sub() ──────────────────────────────────────────────────────────────────── Issue #123 — Comprehensive events test for vault-registry ──────────────────────────────────────────────────────────────────── Problem The existing events_have_documented_shape test in contracts/vault-registry/src/test.rs only covered two event types: 'register' and 'setprice'. The contract emits 11 distinct events (init, register, setprice, updmeta, settags, setlisted, delist, proptrans, transfer, canceltrans, upgrade) and the remaining 9 were completely untested, meaning wire-format regressions on those paths would go undetected. Solution Replaced the partial test with a comprehensive events_have_documented_shape test that exercises every event type emitted by vault-registry in a single test function. Each contract call is immediately followed by an assertion on env.events().all().filter_by_contract() that checks both the topics tuple and the data value against the shapes documented in docs/CONTRACTS.md. The upgrade event is tested last because uploading the fixture WASM is itself a host call that would interfere with earlier event assertions. Events covered: init, register, setprice, updmeta, settags, delist, setlisted, proptrans, canceltrans, transfer, upgrade (11/11). Files changed ~ contracts/vault-registry/src/test.rs - events_have_documented_shape replaced with full 11-event version ──────────────────────────────────────────────────────────────────── Issue #125 — CHANGELOG.md tracking interface and error-code changes ──────────────────────────────────────────────────────────────────── Problem There was no file in the repository tracking what changed between releases at the public-interface level. Operators upgrading contracts had to diff the Rust source directly to find out whether an upgrade involved any storage-layout, entrypoint, event, or error-code change that required special handling. Solution Added CHANGELOG.md at the repo root following the Keep a Changelog convention (https://keepachangelog.com/en/1.1.0/). The file: - Documents what constitutes a MAJOR / MINOR / PATCH change for these contracts (breaking vs additive vs internal). - Carries an [Unreleased] section that already records the interface additions made in this PR (propose_admin/accept_admin for access-lease, new error codes NoPendingAdmin/NotPendingAdmin) and any other additions made since 0.1.0. - Carries a [0.1.0] section that lists the full initial public interface, storage keys, events, and error codes for all three contracts so the baseline is on record. - Includes a note directing maintainers to update both CHANGELOG.md and docs/CONTRACTS.md in any PR that touches the public interface, reinforcing the existing docs/CONTRACTS.md note and the new CI check-events guard. Files changed + CHANGELOG.md --- .github/workflows/ci.yml | 9 ++ CHANGELOG.md | 185 ++++++++++++++++++++++++++ contracts/access-lease/src/lib.rs | 87 ++++++++++++ contracts/subscription/src/lib.rs | 27 ++-- contracts/vault-registry/src/test.rs | 128 ++++++++++++++---- docs/CONTRACTS.md | 8 +- scripts/check_events.py | 189 +++++++++++++++++++++++++++ 7 files changed, 590 insertions(+), 43 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 scripts/check_events.py 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())