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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

115 changes: 115 additions & 0 deletions docs/claim-ttl.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# Prediction Market — Claimable-State TTL Lifecycle (issue #9)

## What the issue is

Markets, bet entries, bettor indexes, and resolution-time payout keys all live
in **persistent storage**, which Soroban deletes once an entry's live-until
ledger is reached unless the contract keeps re-arming it. Every user payment
that ends up in a market pool can only be recovered through `claim` /
`cancel_refund`, and both paths depend on those persistent entries still
existing. If they expire first, funds are **permanently stuck in the contract**.

Soroban cannot resurrect an already-expired (deleted) entry: once gone, reads
return the same not-found result as for an entry that never existed. The fix is
therefore a **read-time TTL refresh strategy**: every lifecycle operation that
may still be needed later re-arms the TTL of the entries it reads/depends on,
so the recovery window keeps sliding forward as long as anyone is interacting
with the contract.

## Storage keys that must stay alive for fund recovery

| Key | Written by | Read by (recovery) | Why it must stay alive |
|---|---|---|---|
| `Market(market_id)` | `create_market`, `place_bet`, `resolve_market`, `cancel_market` | `claim`, `cancel_refund`, `get_market` | `resolved`/`cancelled`/`outcome` gate the recovery paths; a missing market bricks the whole market's claims/refunds |
| `Bet(market_id, user)` | `place_bet`, updated by `claim`/`cancel_refund` | `claim`, `cancel_refund`, `get_bet` | determines stake, winning side, and `claimed`/refunded idempotency; a missing bet = `NoBetFound` |
| `Payout(market_id, user)` | `resolve_market` (winners only) | `claim`, `get_payout` | the exact XLM payout; a missing payout silently pays 0 to a winner |
| `BettorCount(market_id)` | `place_bet` (lazily, first bet) | `resolve_market`, `get_market_bettors_page` | drives winner enumeration at resolve; a missing index drops bettors from payout computation |
| `BettorAt(market_id, i)` | `place_bet` (lazily, first bet) | `resolve_market`, `get_market_bettors_page` | the index slots that enumerate bettors at resolve |

## Where TTL is extended

All bumps use the repository's existing constants `TTL_BUMP` / `TTL_HIGH`
(`prediction_market/src/lib.rs`) and the existing inline
`extend_ttl(key, TTL_BUMP, TTL_HIGH)` convention. A tiny helper
`PredictionMarketContract::bump_ttl(env, key)` applies the same call; callers
only invoke it on keys they have already confirmed to exist (the host errors
when extending a deleted/non-existent key).

### Read-time extension (the primary mechanism)

- **`claim`** — bumps `Bet` (already done), `Market` (already done), and now
also `Payout(market_id, user)` **if present** (winners). The three entries a
winner needs stay alive together for the whole claim path.
- **`cancel_refund`** — bumps `Bet` and `Market` (already done): the refund
path on a cancelled market stays open while the user still interacts.
- **`resolve_market`** — bumps `BettorCount`, every `BettorAt` slot, and every
`Bet` entry it walks while computing payouts, and each new `Payout` key it
writes. A long-lived market (bets placed near the start of a multi-year
window) otherwise risks its index expiring before resolution, silently
dropping bettors from the enumeration and locking their stake.
- **`get_market`** — bumps `Market`.
- **`get_bet`** — bumps `Bet`.
- **`get_payout`** — bumps `Payout` when present.
- **`get_market_bettors_page`** — bumps `BettorCount` and each `BettorAt` slot.

View functions are a deliberate part of the strategy: a user checking their bet,
payout, or the market is the cheapest possible "keep-alive" interaction, and it
works without a keeper.

## Design decisions

- **Read-time extension over a keeper/off-chain refresher.** The repository has
no keeper infrastructure, and a keeper would be an external trust dependency.
Read-time refresh needs no extra moving parts.
- **Both read-time AND resolution-time extension are used.** Resolution-time
bumps guarantee newly created `Payout` keys plus the already-written bet/index
entries start (and re-start) with a full `TTL_HIGH` window; read-time bumps on
claim/refund/views then keep sliding that window while the state is still
relevant.
- **`Market` and `BetEntry` are kept alive together.** The claim path bumps the
whole triple `Market` + `Bet` + `Payout`; the refund path bumps `Market` +
`Bet`. No key that a recovery path needs can outlive its partner on its own.
- **Missing/expired state semantics are preserved.** `extend_ttl` is only ever
called on keys proven to exist in the same call, so genuinely absent entries
(e.g. no bet placed) still produce the existing `MarketNotFound` /
`NoBetFound` errors, and a stale `Payout` read still yields `0`.
- **Terminal behavior is unchanged.** `claim` still marks `claimed` and pays
out via the settlement-time payout ledger; `cancel_refund` still zeroes
`gross`. The TTL changes touch only expiry windows, not money movement.
- **No TTL constants were increased.** The existing `TTL_BUMP` / `TTL_HIGH`
(~1yr/~2yr at mainnet ~5s ledgers) are reused; extending them would only
postpone, not solve, expiry, and the fix here already slides them on read.
- **Scope.** This change touches `prediction_market` only. Leaderboard
(`issue #21`) and token-balance (`issue #36`) TTL are separate issue tracks
and were not modified.

## Remaining limitation (must be stated explicitly)

Read-time TTL refresh **cannot resurrect an entry that has already expired**.
If no one interacts with the contract — no claim, no refund, no view call —
for the full TTL window after the last bump, the entries are deleted by the
ledger and the funds behind them are unrecoverable. This fix changes the
recovery guarantee from "the TTL from the last **write**" to "the TTL from the
**last interaction**", which is strictly stronger but still bounded.

Permanent recoverability would require either a persistent on-chain keeper
(repeatedly bumping keys on a schedule) or a storage-rental redesign — neither
exists in this repository, so the contract-level fix cannot honestly promise it.

## Tests

See `prediction_market/src/tests.rs` (SECURITY REGRESSION SUITE — issue #9):

- `test_claim_rebumps_ttl_entries` — claim bumps `Bet` + `Market` (existing).
- `test_claim_rebumps_payout_ttl` — **new**: claim bumps the winner's `Payout`.
- `test_cancel_refund_rebumps_ttl_entries` — refund bumps `Bet` + `Market`.
- `test_resolve_extends_claimable_state_ttl` — **new**: resolve bumps the
bettor index, `BettorCount`, both `Bet` entries, and creates fresh-`TTL`
`Payout` keys for multiple winners.
- `test_get_bet_extends_ttl`, `test_get_market_extends_ttl`,
`test_get_payout_extends_ttl`, `test_get_market_bettors_page_extends_index_ttl`
— **new**: view interactions keep claimable state alive.
- `test_missing_state_preserves_not_found_semantics` — **new**: no writer
resurrects genuinely absent keys; not-found errors and zero payouts preserved.
- Full existing suite (market lifecycle, claim/refund payout math, upgrade
coordination) unchanged and passing.
96 changes: 96 additions & 0 deletions docs/supply-cap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# PULSE Supply Cap (issue #34)

## Cap value — source

The repository contains **no authoritative tokenomics value**: no `MAX_SUPPLY`
constant existed, no deployment configs/scripts exist, the removed
`ipredict_token` contract had none, and neither `ISSUE_DRAFT.md` nor
`issues/*.md` specify a number. Per the issue's guidance ("if no authoritative
cap exists, implement the smallest safe configuration mechanism... clearly
document the chosen migration/default semantics"), the cap is therefore a
**deployer-supplied parameter** passed to `PULSETokenContract::initialize(...)`
rather than a invented compile-time constant.

- The deployer chooses `max_supply` (in base units, i.e. scaled by `decimals`)
at deployment time and that value becomes the monetary policy.
- Once declared it can only move **down** (admin one-way ratchet) — it can
never be raised through the token contract.
- There is deliberately **no hard-coded default number** in the source.

## Where the cap is enforced

Enforcement lives **inside `pulse_token::mint()`** (`pulse_token/src/lib.rs`),
the single choke point all minting goes through:

- Every authorized minter — `leaderboard.reward`, `leaderboard.reward_bonus`
(the two production mint paths, both mediated by the leaderboard), or any
future minter granted `set_minter` — calls `mint()` and is subject to the
same global ceiling. No caller-side enforcement is required.
- `mint()` computes `new_supply = total_supply.checked_add(amount)`,
**before** writing either balance or supply; if `new_supply > max_supply`
(or the addition would overflow), the whole mint rejects with
`TokenError::MaxSupplyExceeded` (#7) and **no storage is modified**.
- `checked_add` guarantees arithmetic overflow cannot wrap around and bypass
the ceiling.
- `burn()` decreases `total_supply`, so burned PULSE frees cap headroom.

Because reward/reward_bonus mint through the token, an over-cap reward fails
the entire cross-contract call atomically (the leaderboard's internal `mint`
invoke reverts the surrounding `reward`/`reward_bonus`).

## Behavior when a mint would exceed the cap

- The mint returns `TokenError::MaxSupplyExceeded` (#7).
- Recipient balance is unchanged; `total_supply` is unchanged.
- In the leaderboard paths this reverts `reward` / `reward_bonus` (and, via
`prediction_market.claim`, the whole claim), keeping the supply invariant.

## Governance

- `max_supply(env) -> i128` read-only getter.
- `set_max_supply(env, admin, new_cap)` — admin only:
- `new_cap < current total_supply` → `TokenError::CapBelowCurrentSupply` (#8).
- raising an already-declared cap → `TokenError::CapTooHigh` (#9).
- only lowering (or first-time establishing) is allowed.
- non-admin callers → `TokenError::NotAdmin` (#6) or host auth failure.

## Deployment / migration implications

- **New deployments:** pass the desired `max_supply` to `initialize`. It is
stored in instance storage (`DataKey::MaxSupply`) alongside `TotalSupply`.
- **Existing deployed instances** (pre-upgrade) have **no `MaxSupply` key**.
`max_supply()` reads it as `0`, and `mint()` fails closed with
`MaxSupplyExceeded` until the admin declares a cap. To migrate: call
`set_max_supply(admin, cap)` once (any value ≥ current total_supply); after
that the one-way ratchet applies. This is the explicit, documented migration
step — no data migration is needed, and unrelated reads
(`balance`/`total_supply`/`transfer`/`burn`) are unaffected.
- The token's cross-contract ABI used by the leaderboard (`mint`,
`interface_version`) is unchanged, so `TOKEN_MINT_INTERFACE_VERSION` remains
`1`; no redeploy/version bump of the leaderboard is required for the cap.

## All mint paths share the same cap

| Path | Mint caller on the token |
|---|---|
| `prediction_market.claim` | `leaderboard.reward` → `token.mint` |
| `referral_registry.register_referral` | `leaderboard.reward_bonus` → `token.mint` |
| `referral_registry.credit` | `leaderboard.add_bonus_pts` (no token mint) |
| any future authorized minter | direct or via leaderboard |

All route through `pulse_token::mint()`, so one global cap applies to every
path.

## Tests

See `pulse_token/src/tests.rs` and `leaderboard/src/tests.rs`:

- mint below / exactly at / above cap (typed `MaxSupplyExceeded`),
- over-cap mint leaves recipient balance and `total_supply` untouched,
- zero-cap (legacy/unset) fails closed until a cap is declared,
- multiple authorized minters share one global cap,
- `i128` overflow mint cannot bypass the cap (`checked_add`),
- burn frees cap headroom,
- cap ratchet: authorized lowering works; raising (`#9`), lowering below
current supply (`#8`), and non-admin configuration (`#6`) are rejected,
- `reward`- and `reward_bonus`-triggered mints are capped (leaderboard tests).
106 changes: 106 additions & 0 deletions docs/upgrade-coordination.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Cross-Contract Upgrade Coordination (issue #39)

Every contract in this workspace can be upgraded **independently** via its own
`upgrade()` (issue #5): `prediction_market`, `referral_registry`, `leaderboard`,
and `pulse_token`. They are tightly coupled through cross-contract calls, so an
uncoordinated upgrade that changes any called ABI breaks the whole system.

This document describes the interface-versioning scheme added to solve that.

## Dependency graph protected by this scheme

| Caller | Callee | Called function | Guard |
|---|---|---|---|
| `prediction_market.place_bet` | `referral_registry` | `credit` | `REFERRAL_CREDIT_INTERFACE_VERSION` |
| `prediction_market.claim` | `leaderboard` | `reward` | `LEADERBOARD_REWARD_INTERFACE_VERSION` |
| `referral_registry.register_referral` | `leaderboard` | `reward_bonus` | `LEADERBOARD_BONUS_INTERFACE_VERSION` |
| `referral_registry.credit` | `leaderboard` | `add_bonus_pts` | `LEADERBOARD_BONUS_INTERFACE_VERSION` |
| `leaderboard.reward` / `reward_bonus` | `pulse_token` | `mint` | `TOKEN_MINT_INTERFACE_VERSION` |

Every guard is executed **before any state change or cross-contract invoke** in
the caller, so a failed check reverts the whole transaction without partial
effects.

## Versioning model

- Each contract stores a single `u32` **interface version** in its own instance
storage (`DataKey::InterfaceVersion`), committed at `initialize()` time to the
crate-level `INTERFACE_VERSION` constant.
- Every contract exposes `interface_version() -> u32`, a read-only getter that
returns `0` when the key is absent (an un-migrated/legacy deployment).
- A caller that invokes a dependency first calls
`require_interface_version(env, dependency_address, required_version)`:
- `0` / missing getter (host error) → typed `InterfaceVersionMissing`.
- reported version `< required_version` → typed `IncompatibleInterface`.
- otherwise the call proceeds.
- Versions are **monotonically increasing integers**. Bump the relevant
`*_INTERFACE_VERSION` requirement constant only when the ABI *you call*
(signature, argument order, return type) changes on the dependency.
- Fail-closed semantics: an unverifiable dependency is treated as incompatible,
never as "probably fine".

## Upgrade procedure (operational)

A safe upgrade is **coordinated**, even though each contract can be upgraded
independently:

1. **Prepare**: write the new WASM, bump the `INTERFACE_VERSION` constant in the
upgraded contract's source to the next integer, and keep the *callee-facing*
ABI stable unless you are intentionally breaking it.
2. **Deploy the new WASM** via the upgraded contract's `upgrade()`.
`upgrade()` only swaps the code — it does **not** change storage or versions.
3. **Committing a changed ABI**: if the upgrade changed any function that other
contracts call (`credit`, `reward`, `reward_bonus`, `add_bonus_pts`, `mint`,
`interface_version` itself), the admin must call
`set_interface_version(admin, <new_version>)` on the upgraded contract.
4. **Upgrade callers** to require the new minimum where relevant (update the
appropriate `*_INTERFACE_VERSION` constant in their source), then deploy them
with `upgrade()`. Since the version lives in storage, existing callers keep
running against the old contract until their own upgrade lands.
5. **Deploy order that minimizes downtime** (each step is additive):
- `pulse_token` first (its `interface_version` getter is the leaf dependency).
- `leaderboard` second (it is *called by* market and referral, and *calls*
the token).
- `referral_registry` and `prediction_market` last (they only call others).
With all four at version `1` (the initial value), no setter call is needed at
all — `initialize()` already stored it.

## Migration note for existing deployments

Contracts deployed **before** this scheme have no `InterfaceVersion` key.
`interface_version()` returns `0` for them, so any caller requiring version `1`
will fail closed with `InterfaceVersionMissing`. To migrate:

- If the deployed contract was logged with the new code and you want it to serve
version `1`, call `set_interface_version(admin, 1)` on it — no redeploy needed.
- `pulse_token.set_interface_version` takes only `(env, version)` and
authorizes via its stored admin; the other contracts take
`(env, admin, version)`.

There is no on-chain migration task; this is a single admin call per contract.

## Failure mode and recovery

- **Symptom**: `Error(Contract, #27)`/`#26` (market), `#8`/`#7` (referral),
`#7`/`#6` (leaderboard) — `IncompatibleInterface` / `InterfaceVersionMissing`.
- **Cause**: a dependency's declared interface version is below the caller's
required minimum, or the dependency never exposed the version getter.
- **Effect**: the specific cross-contract call (and therefore the whole
transaction: bet / claim / registration / referral credit / reward) fails
closed. No partial state is written.
- **Recovery**: either
1. declare the correct version on the dependency via `set_interface_version`
(coordinate so the version matches the deploy), or
2. upgrade/roll back the contract to an ABI-consistent version, then
re-declare the version.
No data migration or redeployment of unaffected contracts is required.

## Tests

See `*/src/tests.rs`:

- compatible path (full call succeeds with the dependency at version `1`),
- `IncompatibleInterface` when the dependency is downgraded to `0`,
- `InterfaceVersionMissing` when the dependency has no version getter,
- upgrade-path tests proving a unilateral incompatible upgrade fails closed and
recovers after `set_interface_version` without touching user data.
1 change: 1 addition & 0 deletions leaderboard/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ soroban-sdk = { workspace = true }

[dev-dependencies]
soroban-sdk = { workspace = true, features = ["testutils"] }
pulse_token = { path = "../pulse_token", features = ["testutils"] }
Loading