Skip to content
Open
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
148 changes: 33 additions & 115 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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.
Expand All @@ -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 <EXPLORER_CONTRACT_ID> \
--source <funded-identity> \
--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 <in.wasm> -o <out.wasm> \
--disable-reference-types --disable-multivalue \
--enable-bulk-memory --enable-bulk-memory-opt \
--enable-sign-ext --enable-mutable-globals -Oz

stellar contract deploy --wasm <out.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 <target> -- -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 … */
29 changes: 26 additions & 3 deletions docs/INTERFACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
Expand All @@ -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<ContractMeta, Error>` | Read-only |
| `get_contract_version` | `contract_id: BytesN<32>, abi_version: u32` | `Option<ContractMeta>` | Read-only |
| `get_latest_contract` | `contract_id: BytesN<32>` | `Option<ContractMeta>` | Read-only; alias for `get_contract` returning `Option` instead of `Result` |
Expand Down