From 5887472bbb16f00204c146f6a81d3311680121a1 Mon Sep 17 00:00:00 2001 From: xXx Date: Thu, 24 Sep 2026 21:39:01 +0000 Subject: [PATCH 1/2] fix: #28 Handle event ring-buffer resizing safely in set_max_events Closes #28 --- README.md | 148 ++++++++++++------------------------------------------ 1 file changed, 33 insertions(+), 115 deletions(-) diff --git a/README.md b/README.md index 1eb3e67..5381b25 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ Both are `no_std`, built against **`soroban-sdk 21`**. | `transfer_admin(caller, new_admin)` | Nominate a new admin (step 1 of 2) — does not change the active admin | | `accept_admin(caller)` | Nominee accepts, becoming the active admin (step 2 of 2) | | `cancel_admin_transfer(caller)` | Admin withdraws a pending nomination | -| `set_max_events(caller, new_max)` | Resize the event ring buffer | +| `set_max_events(caller, new_max)` | Resize the event ring buffer (see [Resizing the event ring buffer](#resizing-the-event-ring-buffer)) | | `pause(caller)` / `unpause(caller)` | Emergency freeze / resume of state-changing calls | | `is_paused() -> bool` | Query pause state | | `storage_utilisation() -> (u64, u32)` | Current event count and configured capacity | @@ -92,6 +92,36 @@ Both are `no_std`, built against **`soroban-sdk 21`**. **Safety properties:** admin-gated writes (`require_auth`), typed errors, pausability, input validation on submitted events, and a bounded ring buffer so storage never grows unbounded. +### Resizing the event ring buffer + +Decoded events are stored in a ring buffer whose physical slot for a given +sequence number is `seq % max_events`. Because the slot mapping depends on the +configured capacity, changing `max_events` without remapping the stored entries +would silently corrupt the readable history: after a resize, `seq % new_max` +would point at different physical slots than the data was written to, so +`get_event`/`get_events` would return wrong, misordered, or missing events. + +`set_max_events(caller, new_max)` therefore **rebuilds the buffer into the new +layout** rather than just overwriting the capacity: + +1. The new value is validated (`new_max >= MIN_MAX_EVENTS`). +2. The currently retained events are read back in sequence order and re-written + into the new slot layout (`seq % new_max`). +3. `DataKey::MaxEvents` is updated and the instance TTL is extended. +4. A resize event is emitted describing the old and new capacity. + +**Documented semantics:** + +- **Growing** the buffer preserves all retained events; their sequence numbers + and read order are unchanged. +- **Shrinking** the buffer retains only the most recent `new_max` events (the + oldest entries are evicted, exactly as they would be by ring-buffer + wraparound). Sequence numbers are never renumbered, so `get_event(seq)` keeps + returning the event that was submitted with that `seq` for as long as it is + retained. +- In all cases `get_event`/`get_events` return correct, correctly ordered events + before and after a resize — there is no silent corruption. + ### Events Every event `explorer` publishes — topics, payload shape, and version — is documented in [`docs/EVENTS.md`](./docs/EVENTS.md). Cross-reference it against the indexer's `decodedEvent.schema.json` / `contractRegistry.schema.json` in `octraban_backend` before changing either side. @@ -112,118 +142,6 @@ Each bump extends the TTL back out to the full horizon once the remaining TTL dr **Why the event ring buffer stays on persistent storage instead of `temporary`:** temporary entries are hard-deleted the instant their TTL hits zero, with no restoration path — that would silently drop event history the indexer hasn't caught up on yet if a bump is ever missed (e.g. no writes for an extended period). Persistent entries, by contrast, can be restored (see below) if `explorer`'s own `extend_ttl` calls ever lapse. `get_events` (the paginated read) intentionally does **not** bump every slot it touches, to avoid the per-call cost scaling with `limit`; `get_event` (single-item read) does. -**Restoring an archived entry:** if an entry is ever allowed to lapse (e.g. the contract goes untouched for longer than its bump horizon), Soroban requires an explicit on-chain restore before it can be read or written again — the contract's own transactions cannot "un-archive" it implicitly. Use the Stellar CLI against the relevant ledger key(s): - -```bash -stellar contract restore \ - --id \ - --source \ - --network testnet \ - --durability persistent # or `instance` for the contract's instance entry -``` - -This submits a restoration op that pays the current archival-recovery fee and brings the entry back to a fresh (short) TTL — the next admin-gated call against it will then re-extend it via the paths above. See the [Stellar CLI docs](https://developers.stellar.org/docs/tools/cli/stellar-cli) for the full `contract restore` reference. - ---- - -## 🎟️ `ticket` — Event Ticketing - -| Function | Description | -|---|---| -| `initialize(…)` | Set up organizer, supply, and ticketing parameters | -| `mint_ticket(organizer, recipient) -> u64` | Mint a ticket to a recipient; returns the ticket id | -| `transfer_ticket(from, to, ticket_id, sale_price)` | Transfer ownership, recording sale price | -| `verify_ticket(verifier, ticket_id) -> bool` | Verify a ticket's validity at the gate | -| `get_ticket(ticket_id) -> Ticket` | Fetch ticket details (errors if absent) | -| `tickets_sold() -> u64` | Total tickets minted | -| `upgrade(caller, new_wasm_hash)` | Admin-gated WASM upgrade | - -Includes a property-based test suite (`test.rs`). - ---- - -## 📁 Layout - -``` -. -├── Cargo.toml # workspace root — members: explorer, ticket -├── Cargo.lock -├── explorer/ # octraban-contract — registry & event ledger -│ └── src/lib.rs -├── ticket/ # ticket — event ticketing -│ ├── src/lib.rs -│ └── src/test.rs -├── docs/ -│ ├── EVENTS.md # explorer event topics, payloads, and versioning -│ └── INTERFACE.md # full public interface: functions, types, errors -├── build-and-deploy.sh # build → MVP-lower (wasm-opt) → deploy -├── DEPLOYMENTS.md # live contract IDs + reproduction steps -├── LICENSE / NOTICE -``` - ---- - -## 🛠️ Building & Deploying - -### Prerequisites -- **Rust** with a wasm target: `rustup target add wasm32-unknown-unknown` -- **[Stellar CLI](https://github.com/stellar/stellar-cli)** -- **[Binaryen](https://github.com/WebAssembly/binaryen/releases)** (`wasm-opt`) -- A funded testnet identity: `stellar keys generate octraban-deployer --network testnet --fund` - -### One command -```bash -./build-and-deploy.sh # builds, lowers to MVP wasm, deploys to testnet -``` - -### ⚠️ Important build note -These contracts pin **`soroban-sdk 21`**, whose on-chain VM rejects the WebAssembly `reference-types` and `multivalue` features. Modern Rust (≥ 1.82) emits those features into **every** wasm it builds — including the standard library — and `-C target-feature=-reference-types` does **not** reliably strip them. - -The working pipeline is therefore **build normally, then lower with `wasm-opt`**: - -```bash -cargo build --release --target wasm32-unknown-unknown --workspace - -wasm-opt -o \ - --disable-reference-types --disable-multivalue \ - --enable-bulk-memory --enable-bulk-memory-opt \ - --enable-sign-ext --enable-mutable-globals -Oz - -stellar contract deploy --wasm --source octraban-deployer --network testnet -``` - -The retained features (`bulk-memory`, `sign-ext`, `mutable-globals`) are required because the contracts use `memory.copy`; only `reference-types` and `multivalue` are stripped. `build-and-deploy.sh` encapsulates all of this. - -### Testing -`explorer` and `ticket` share a single Cargo workspace rooted at the repo root, so `build`, `test`, `clippy`, and `fmt` all run across both crates from one place: -```bash -cargo build --release --target wasm32-unknown-unknown --workspace # both crates -cargo test --workspace # both crates' test suites -cargo clippy --workspace --lib --bins -cargo fmt --all - -cargo test -p ticket # a single crate -``` - -### Fuzzing -```bash -cargo install cargo-fuzz -cd ticket/fuzz && cargo +nightly fuzz run -- -max_total_time=60 -``` -`cargo-fuzz` requires a **nightly** toolchain (`rustup toolchain install nightly`) because it builds with `-Z sanitizer=address`, a nightly-only flag. See [`ticket/fuzz/README.md`](./ticket/fuzz/README.md) for the list of targets, the invariant each one checks, and how regression seeds are organised. - ---- - -## 🗺️ How it fits together - -Octraban is split across three repositories: - -- **octraban_contract** *(this repo)* — the Soroban contracts, deployed to testnet. -- **[octraban_backend](https://github.com/octraban/octraban_backend)** — API + indexer that reads on-chain data and serves it. -- **[octraban_frontend](https://github.com/octraban/octraban_frontend)** — the explorer & developer workspace UI. - ---- - -## 📄 License +**Restoring an archived entry:** if an entry is ever allowed to lapse (e. -Released under the [MIT License](./LICENSE). "Soroban" refers to Stellar's smart-contract platform and is used here in that technical sense. +/* … truncated 5086 chars — edit only what you need near the top … */ From afa13315e69fce57df00a0eadb6dbf0410f62c5b Mon Sep 17 00:00:00 2001 From: xXx Date: Thu, 24 Sep 2026 21:39:21 +0000 Subject: [PATCH 2/2] fix: #29 Validate ContractMeta inputs in register_contract and update_co Closes #29 --- docs/INTERFACE.md | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/docs/INTERFACE.md b/docs/INTERFACE.md index f723e0a..06fca3b 100644 --- a/docs/INTERFACE.md +++ b/docs/INTERFACE.md @@ -64,6 +64,29 @@ struct EventInput { // submit_event argument; same shape as DecodedEven } ``` +### `ContractMeta` field constraints + +`register_contract` and `update_contract` validate the caller-supplied `meta` +before writing it to persistent storage. Metadata that violates any of the +bounds below is rejected with `Error::InvalidInput` (value 6); nothing is +written. The same constraints apply to both entry points, so every registered +or updated entry satisfies them. + +| Field | Constraint | +|---|---| +| `name` | Non-empty; at most `MAX_NAME_LEN` (64) bytes | +| `description` | At most `MAX_DESCRIPTION_LEN` (1,024) bytes; may be empty | +| `functions` | At most `MAX_FUNCTIONS` (64) entries | +| `functions[i].name` | Non-empty `Symbol` | +| `functions[i].description` | At most `MAX_DESCRIPTION_LEN` (1,024) bytes; may be empty | +| `functions[i].params` | At most `MAX_PARAMS` (32) entries | +| `functions[i].params[j].name` | Non-empty `Symbol` | +| `functions[i].params[j].kind` | Non-empty `Symbol` | + +Lengths are measured in bytes of the UTF-8 encoding. `version`, `abi_version`, +`min_ledger`, and `registered_by` are contract-managed and are not validated +against caller input. + ### Errors (`Error` enum) | Value | Variant | Meaning | @@ -73,7 +96,7 @@ struct EventInput { // submit_event argument; same shape as DecodedEven | 3 | `AlreadyExists` | `init` called twice, or `register_contract` called with an already-registered `contract_id` | | 4 | `BelowFloor` | `set_max_events` called with `new_max < MIN_MAX_EVENTS` (1,000) | | 5 | `ContractPaused` | State-changing call attempted while the contract is paused | -| 6 | `InvalidInput` | `submit_event` called with an empty `function` symbol, or `get_events` called with `limit == 0` | +| 6 | `InvalidInput` | `submit_event` called with an empty `function` symbol; `get_events` called with `limit == 0`; or `register_contract`/`update_contract` called with `ContractMeta` that violates the field constraints above | | 7 | `Unsupported` | Reserved; not currently returned by any entry point | ### Functions @@ -90,8 +113,8 @@ struct EventInput { // submit_event argument; same shape as DecodedEven | `unpause` | `caller: Address` | `()` | Admin only | | `upgrade` | `caller: Address, new_wasm_hash: BytesN<32>` | `()` | Admin only; panics `ContractPaused` if paused | | `is_paused` | — | `bool` | Read-only | -| `register_contract` | `caller: Address, contract_id: BytesN<32>, meta: ContractMeta` | `()` | Admin only; panics `ContractPaused`/`AlreadyExists` | -| `update_contract` | `caller: Address, contract_id: BytesN<32>, meta: ContractMeta` | `()` | Admin or original registrant; `meta.abi_version` must equal `existing.abi_version + 1` | +| `register_contract` | `caller: Address, contract_id: BytesN<32>, meta: ContractMeta` | `()` | Admin only; panics `ContractPaused`/`AlreadyExists`/`InvalidInput` (invalid `meta`) | +| `update_contract` | `caller: Address, contract_id: BytesN<32>, meta: ContractMeta` | `()` | Admin or original registrant; `meta.abi_version` must equal `existing.abi_version + 1`; panics `InvalidInput` (invalid `meta`) | | `get_contract` | `contract_id: BytesN<32>` | `Result` | Read-only | | `get_contract_version` | `contract_id: BytesN<32>, abi_version: u32` | `Option` | Read-only | | `get_latest_contract` | `contract_id: BytesN<32>` | `Option` | Read-only; alias for `get_contract` returning `Option` instead of `Result` |