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
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
185 changes: 185 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
87 changes: 87 additions & 0 deletions contracts/access-lease/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand All @@ -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")]
Expand All @@ -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 {
Expand Down Expand Up @@ -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<Address> {
env.storage().instance().get(&DataKey::PendingAdmin)
}

pub fn get_version(env: Env) -> String {
env.storage()
.instance()
Expand Down
27 changes: 15 additions & 12 deletions contracts/subscription/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)]
Expand Down
Loading