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