Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Tholos

CI Docs License: MIT

Bonded assertion and dispute oracle for resolving real world outcomes. Resolution infra for prediction markets and anything else that needs a trustworthy yes/no.

Docs: drydocs.github.io/tholos

Status

The assertion and dispute contract (contracts/tholos) is implemented, tested, and has been deployed and exercised on Stellar testnet.

  • Core propose/dispute/resolve flow: done
  • Admin-controlled resolver committee updates: done
  • Pause / emergency-stop: done
  • Reentrancy hardening (state written before external token transfers): done
  • CI (fmt, clippy, tests, wasm build): done
  • Fee-funded reward for uncontested finalizes: not yet (no fee-generating market layer exists to fund it)

See CONTRACT.md for the full interface and known gaps, ARCHITECTURE.md for design rationale, or INTEGRATION.md if you’re building a contract that wants to call into Tholos. Deploying your own instance: see DEPLOYMENT.md. New to the terminology: see GLOSSARY.md.

Why

Prediction markets and similar products eventually need to answer a hard question: who decides what actually happened? Existing approaches either rely on token holder votes that can be captured by large holders with a stake in the outcome, or on a centralized, regulated party acting as sole resolver.

Tholos is a bonded assertion and dispute contract: anyone can propose an outcome by posting a bond, and a challenge window gives others the chance to dispute it before it finalizes. It is designed to be standalone and composable, so any contract that needs a trustworthy resolution of a real world event can plug into it rather than building its own oracle logic.

How it works

stateDiagram-v2
    [*] --> Pending: assert_outcome
    Pending --> Disputed: dispute
    Pending --> Resolved: finalize
    Disputed --> Resolved: resolve (majority)
    Resolved --> [*]

A bond gets posted, a window gives anyone the chance to dispute it, and if disputed, a resolver committee votes to decide who was right. See CONTRACT.md for the function reference and events, or ARCHITECTURE.md for sequence diagrams of each flow.

Tech stack

LayerTechnology
ContractRust, Soroban SDK 26
NetworkStellar (testnet today)
TokenAny SEP-41 / Stellar Asset Contract token, configured per deployment
CIGitHub Actions: cargo fmt, shellcheck, cargo clippy, cargo test, wasm build

Project layout

contracts/
  tholos/               The assertion and dispute contract
  demo-consumer/        Minimal example contract that calls into Tholos,
                         validating the pattern documented in INTEGRATION.md
scripts/
  testnet-smoke.sh      End-to-end check against real Stellar testnet infrastructure
.github/workflows/
  ci.yml                 Runs fmt, clippy, tests, and the wasm build on every push/PR

Development

Requires the Rust toolchain with the wasm32v1-none target, plus the Stellar CLI for building and deploying the contract.

# Build tholos's wasm first: demo-consumer imports it at compile time, so this
# has to exist before anything below touches the rest of the workspace.
cargo build -p tholos --target wasm32v1-none --release

# Run unit tests
cargo test

# Check formatting and lints (same checks CI runs)
cargo fmt --check
shellcheck scripts/*.sh
cargo clippy --workspace --all-targets -- -D warnings

# Build the optimized, deployable contract wasm
cd contracts/tholos && stellar contract build

To exercise a fresh deploy end-to-end against Stellar testnet (deploy, initialize, assert, dispute, resolve):

bash scripts/testnet-smoke.sh

See CONTRIBUTING.md for contribution guidelines.

License

MIT

Architecture

This covers why Tholos is built the way it is. For what each function does, see CONTRACT.md.

One instance, one configuration

A Tholos deployment is initialized once with a single token, bond amount, challenge window, and resolver committee. There’s no per-call override. This is a deliberate simplicity tradeoff for v1: it means every assertion posted to a given instance is directly comparable (same collateral, same window), and it keeps the storage model and auth model simple. The cost is that markets wanting different bond sizes need separate instances; see INTEGRATION.md for how callers are expected to handle that.

Odd-length resolver committee, simple majority

resolvers must have an odd, non-zero length, enforced in both initialize and update_resolvers. This is the whole tie-breaking mechanism: with an odd committee, a strict majority (len / 2 + 1) is always reachable and never ambiguous. No tie-handling logic exists because none is needed.

Resolver committee is snapshotted per dispute

dispute copies the current resolver committee onto the assertion (Assertion.resolvers); resolve checks membership and computes majority against that snapshot, not the live Resolvers value in contract storage. Earlier this wasn’t snapshotted: resolve re-read the live committee on every call. That meant an update_resolvers call in the middle of an open dispute could change who was entitled to decide it and what majority meant, mid-vote, which is a correctness problem independent of whether the update was legitimate or malicious. Snapshotting at dispute time makes a dispute’s rules fixed for its whole lifetime: whoever was on the committee when it opened decides it, regardless of what the committee looks like by the time it closes.

State before external calls

Every function that moves tokens (assert_outcome, dispute, finalize, resolve) writes its state change to storage before calling the token contract’s transfer. This wasn’t the original implementation; an internal security review found that writing state after the transfer left a reentrancy window. Because Soroban cross-contract calls are synchronous, a non-standard or malicious token could call back into Tholos mid-transfer and see stale state (an assertion still Pending when it was actually already being finalized), allowing a second payout drawn from bonds belonging to unrelated assertions in the same pooled contract balance. The fix, and a regression test that exercises it directly against a token built to attempt exactly that reentrant call, are in contracts/tholos/src/lib.rs and contracts/tholos/src/test.rs::test_finalize_is_not_reentrant. See the “Security notes” section of CONTRACT.md for the interface-level summary.

Pause is scoped, not absolute

set_paused blocks assert_outcome, dispute, and resolve, but deliberately not finalize or update_resolvers. The reasoning: a pause exists to stop new exposure (new bonds, new disputes, new votes) while an incident is investigated, not to freeze funds that are already committed. An assertion that was Pending before the pause and never gets disputed shouldn’t be stuck waiting on the incident to resolve; letting finalize run means its bond returns normally. Similarly, if the pause was triggered because the resolver committee is compromised, the admin needs update_resolvers to actually fix that while paused, not after unpausing.

Flows

Uncontested: assert, then finalize

sequenceDiagram
    actor Asserter
    participant Tholos
    participant Token

    Asserter->>Tholos: assert_outcome(outcome)
    Tholos->>Token: transfer(asserter -> contract, bond)
    Tholos-->>Asserter: assertion id
    Note over Tholos: challenge window elapses, no dispute
    actor Anyone
    Anyone->>Tholos: finalize(id)
    Tholos->>Token: transfer(contract -> asserter, bond)
    Tholos-->>Anyone: outcome

Contested: assert, dispute, resolve

sequenceDiagram
    actor Asserter
    actor Disputer
    actor Resolver1
    actor Resolver2
    participant Tholos
    participant Token

    Asserter->>Tholos: assert_outcome(outcome)
    Tholos->>Token: transfer(asserter -> contract, bond)
    Disputer->>Tholos: dispute(id)
    Tholos->>Token: transfer(disputer -> contract, bond)
    Resolver1->>Tholos: resolve(id, vote)
    Note over Tholos: 1 of 3 votes, no majority yet
    Resolver2->>Tholos: resolve(id, vote)
    Note over Tholos: majority reached
    Tholos->>Token: transfer(contract -> winner, bond * 2)

Paused: new assertions rejected, existing ones unaffected

sequenceDiagram
    actor Admin
    actor Asserter
    participant Tholos

    Admin->>Tholos: set_paused(true)
    Asserter->>Tholos: assert_outcome(outcome)
    Tholos-->>Asserter: Error: Paused
    Note over Tholos: assertions already Pending can still finalize

Contract interface

Reference for contracts/tholos. Source of truth is contracts/tholos/src/lib.rs; this document should be updated alongside any change to the public interface.

Lifecycle

stateDiagram-v2
    [*] --> Pending: assert_outcome
    Pending --> Disputed: dispute
    Pending --> Resolved: finalize<br/>(challenge window elapsed,<br/>bond returned)
    Disputed --> Resolved: resolve<br/>(majority reached,<br/>winner paid both bonds)
    Resolved --> [*]

Every assertion ends in Resolved, reached one of two ways: uncontested (finalize after the challenge window with no dispute) or contested (resolve once a majority of the resolver committee agrees on one side).

Types

Status

State of an assertion: Pending, Disputed, or Resolved.

Assertion

FieldTypeMeaning
asserterAddressWho posted the claim
outcomeboolThe claimed outcome
bondi128Bond amount posted (in the configured token)
opened_atu64Ledger timestamp the assertion was posted
statusStatusCurrent state
disputerOption<Address>Who disputed it, if disputed
votes_for_outcome / votes_against_outcomeu32Resolver vote tally
votedVec<Address>Resolvers who have already voted, to prevent double-voting
resolversVec<Address>The resolver committee snapshotted at dispute time; empty until dispute is called. See resolve below.

Error

VariantMeaning
AlreadyInitializedinitialize called on a contract that’s already set up
NotInitializedCalled before initialize (e.g. update_resolvers)
InvalidResolverCountResolver list is empty or has an even length
AssertionNotFoundNo assertion exists with the given id
NotPendingAction requires Status::Pending but the assertion isn’t
NotDisputedAction requires Status::Disputed but the assertion isn’t
ChallengeWindowClosedTried to dispute after the challenge window elapsed
ChallengeWindowOpenTried to finalize before the challenge window elapsed
NotAResolverCaller isn’t in the current resolver committee
AlreadyVotedResolver already voted on this assertion
PausedCalled assert_outcome, dispute, or resolve while paused
InvalidBondAmountbond_amount is zero or negative
InvalidChallengeWindowchallenge_window_secs is zero

Functions

initialize(admin, token, bond_amount, challenge_window_secs, resolvers)

One-time setup. resolvers must have an odd, non-zero length so a majority vote can never tie. bond_amount must be positive and challenge_window_secs must be non-zero. Requires admin’s signature. Fails with AlreadyInitialized if called twice.

update_resolvers(new_resolvers)

Replaces the resolver committee used for assertions disputed after this call. Requires the stored admin’s signature. Same odd-length requirement as initialize. Emits ResolversUpdated. Has no effect on assertions already Disputed: each dispute snapshots the committee at the moment dispute is called (see the resolvers field on Assertion), and voting for that dispute is decided against that snapshot for its whole lifetime, not the live committee. A resolver removed after a dispute was opened can still vote on it; a resolver added after can’t.

set_paused(paused)

Pauses or unpauses assert_outcome, dispute, and resolve. Requires the stored admin’s signature. finalize is deliberately exempt: assertions already Pending before a pause can still be finalized while paused, so an uncontested claim isn’t stuck waiting on an unpause. update_resolvers is also exempt, so a compromised committee can be replaced without unpausing first. Emits PauseUpdated.

assert_outcome(asserter, outcome) -> u64

Posts a bonded claim. Transfers bond_amount from asserter to the contract. Requires asserter’s signature. Fails with Paused if paused. Returns the new assertion id. Emits Asserted.

dispute(disputer, id)

Disputes a Pending assertion within the challenge window, matching its bond. Requires disputer’s signature. Fails with Paused if paused, NotPending if the assertion isn’t pending (including if it’s already disputed), or ChallengeWindowClosed if the window has elapsed. Emits Disputed.

finalize(id) -> bool

Callable by anyone once a Pending assertion’s challenge window has elapsed with no dispute. Returns the asserter’s bond and returns the asserted outcome. Fails with ChallengeWindowOpen if called too early. Emits Finalized.

resolve(resolver, id, agrees_with_asserter) -> Option<bool>

Casts one resolver’s vote on a Disputed assertion. Requires resolver’s signature and that they’re in the committee snapshotted when this assertion was disputed (Assertion.resolvers), not necessarily the live committee. Fails with Paused if paused, NotAResolver, NotDisputed, or AlreadyVoted as appropriate.

Returns None if no side has reached a strict majority yet. Once a majority agrees, the winning side (asserter if the majority agreed with them, disputer otherwise) receives both bonds, the assertion moves to Resolved, a Resolved event is emitted, and the function returns Some(final_outcome).

get_assertion_state(id) -> Assertion

Read-only lookup. Fails with AssertionNotFound if the id doesn’t exist.

Security notes

assert_outcome, dispute, finalize, and resolve each write their state change (new assertion, status transition, vote tally) to storage before calling the external token contract’s transfer. This follows checks-effects-interactions deliberately: cross-contract calls in Soroban are synchronous, so a non-standard or malicious token contract could otherwise call back into Tholos mid-transfer and observe stale state (e.g. an assertion still Pending when it’s actually already being finalized), enabling a double payout drawn from the pooled bonds of unrelated assertions. contracts/tholos/src/test.rs::test_finalize_is_not_reentrant exercises this directly against a token that attempts exactly that reentrant call.

Events

Each state-changing function emits a corresponding event, topic-indexed by assertion id where applicable, so off-chain indexers can follow an assertion’s history without polling get_assertion_state:

EventEmitted byFields
Assertedassert_outcomeid, asserter, outcome
Disputeddisputeid, disputer
Finalizedfinalizeid, outcome
Resolvedresolve, once a majority is reachedid, outcome
ResolversUpdatedupdate_resolversresolvers (the new committee)
PauseUpdatedset_pausedpaused

Example: calling it with the Stellar CLI

Deploy, initialize with a 3-member resolver committee, and post an assertion (the same flow scripts/testnet-smoke.sh automates):

CONTRACT=$(stellar contract deploy --wasm target/wasm32v1-none/release/tholos.wasm \
  --source deployer --network testnet)

stellar contract invoke --id "$CONTRACT" --source deployer --network testnet -- initialize \
  --admin "$DEPLOYER_ADDRESS" \
  --token "$TOKEN_CONTRACT_ID" \
  --bond_amount 1000000 \
  --challenge_window_secs 3600 \
  --resolvers "[\"$R1\",\"$R2\",\"$R3\"]"

stellar contract invoke --id "$CONTRACT" --source asserter --network testnet -- assert_outcome \
  --asserter "$ASSERTER_ADDRESS" \
  --outcome true

See scripts/testnet-smoke.sh for the full round trip including dispute and resolve.

Known gaps

  • No fee/reward mechanism for uncontested finalizes: the original design called for a small reward funded by market fees, but no fee-generating market layer exists yet, so finalize just returns the bond as-is.
  • set_paused and update_resolvers are both single-admin-key operations, which is a bigger centralization point than the resolver committee itself. A resolver self-rotation scheme (the committee votes to replace one of its own) was considered but not built for v1.

Deployment and operations

A practical guide for deploying a Tholos instance and operating it afterward. For what each function does, see CONTRACT.md. For design rationale, see ARCHITECTURE.md.

Before you deploy

This is testnet-only until audited. See SECURITY.md. Don’t point a Tholos instance at real value on mainnet without an independent security review first.

Decide these parameters up front; none of them (except the resolver committee) can be changed after initialize:

ParameterGuidance
tokenAny SEP-41 token your users already hold. No swap step exists, so picking a token nobody has is a dead deployment.
bond_amountHigh enough to make spam assertions and bad-faith disputes costly, low enough that legitimate use isn’t priced out. There’s no data-driven formula for this yet; start conservative and watch real usage.
challenge_window_secsLong enough that people who’d actually catch a bad assertion have a realistic chance to see it and act. Short windows finalize faster but catch less.
resolversOdd-length, non-zero. Pick people who’ll actually be reachable to vote within a reasonable time of a dispute; a slow resolver committee stalls every disputed assertion until it acts.

Deploying

# Build the optimized wasm
cd contracts/tholos && stellar contract build

# Deploy
CONTRACT=$(stellar contract deploy --wasm target/wasm32v1-none/release/tholos.wasm \
  --source deployer --network testnet)

# Initialize
stellar contract invoke --id "$CONTRACT" --source deployer --network testnet -- initialize \
  --admin "$ADMIN_ADDRESS" \
  --token "$TOKEN_CONTRACT_ID" \
  --bond_amount 1000000 \
  --challenge_window_secs 3600 \
  --resolvers "[\"$R1\",\"$R2\",\"$R3\"]"

scripts/testnet-smoke.sh automates this full sequence plus assert/dispute/resolve against real testnet infrastructure; run it to sanity-check a fresh deploy before handing the contract id to anyone.

Admin runbook

Pausing during an incident

If something looks wrong (a bug is found, a resolver key looks compromised, vote behavior looks off), pause first and investigate second:

stellar contract invoke --id "$CONTRACT" --source admin --network testnet -- set_paused --paused true

This stops new assert_outcome, dispute, and resolve calls immediately. Assertions already Pending can still finalize normally, so you aren’t freezing funds that were never at risk. Unpause the same way with --paused false once the issue is resolved.

Rotating the resolver committee

Works whether paused or not, so a compromised committee can be replaced without waiting to unpause:

stellar contract invoke --id "$CONTRACT" --source admin --network testnet -- update_resolvers \
  --new_resolvers "[\"$NEW_R1\",\"$NEW_R2\",\"$NEW_R3\"]"

The new committee must be odd-length. Resolvers removed mid-dispute simply lose the ability to cast further votes on assertions already in flight; resolvers added mid-dispute can vote on assertions that were disputed before they joined. See CONTRACT.md for the full detail.

Checking state

Read-only, no auth required:

stellar contract invoke --id "$CONTRACT" --source admin --network testnet -- get_assertion_state --id 0

Mainnet readiness checklist

Not a green light to deploy to mainnet on its own: a checklist of what’s true today, so you can judge what’s still missing for your use case:

  • Core propose/dispute/resolve flow implemented and unit tested
  • Reentrancy hardened, with a regression test proving it
  • Admin pause and resolver rotation available for incident response
  • Exercised end-to-end against real Stellar testnet infrastructure
  • Independent security audit
  • Real-world dispute volume tested (all testing so far is synthetic)
  • Bond sizing validated against real spam/griefing attempts, not just reasoned about
  • Fee/reward mechanism for uncontested finalizes (currently none; see CONTRACT.md)

Integrating with Tholos

For contracts that need a trustworthy resolution of a real world outcome and want to call into Tholos rather than build their own propose/dispute/resolve logic. If you’re looking for the function-by-function reference instead, see CONTRACT.md.

Should you deploy your own instance, or share one?

Each Tholos deployment is initialized once with a single token, bond amount, challenge window, and resolver committee (initialize in CONTRACT.md). There’s no per-call override. That means:

  • If your markets all want the same bond size, token, and challenge window, they can share one deployed instance and just track the assertion ids that belong to them.
  • If you need different bond sizes per market (a $10 market and a $10,000 market probably shouldn’t share a bond amount), deploy a separate instance per configuration, or wait for a future version that supports per-call bonds.

There is currently no built-in way for a calling contract to distinguish “its” assertions from anyone else’s within one instance beyond tracking the ids it received back from assert_outcome. Store that mapping on your side (e.g. market_id -> assertion_id).

Calling Tholos from another Soroban contract

contracts/demo-consumer is a working, tested example of this, not just a snippet: its create_assertion and get_status functions are the pattern below, and its test deploys Tholos’s actual compiled wasm and calls through it. If anything here goes stale, that crate’s cargo test -p demo-consumer is what would catch it.

Import the client from the deployed contract’s WASM and call it like any other cross-contract invocation:

#![allow(unused)]
fn main() {
use soroban_sdk::{contractimport, Address, Env};

mod tholos {
    soroban_sdk::contractimport!(
        file = "../../target/wasm32v1-none/release/tholos.wasm"
    );
}

fn create_assertion(env: Env, tholos_id: Address, asserter: Address, outcome: bool) -> u64 {
    let client = tholos::Client::new(&env, &tholos_id);
    client.assert_outcome(&asserter, &outcome)
}
}

contractimport! reads the wasm file at your crate’s compile time, so it has to already exist on disk before you build. In this repo that means running cargo build -p tholos --target wasm32v1-none --release before touching demo-consumer (see CONTRIBUTING.md); if Tholos is a separate repo for you, the same constraint applies to wherever its wasm gets built.

Who should be the asserter: your contract, or the end user?

This is the decision that has the most integration friction, and it’s worth getting right before you write the code.

End user as asserter (what demo-consumer does, and the default recommendation). Pass through an Address the caller provides, as above. The user’s own signature authorizes assert_outcome and the underlying bond transfer directly; your contract doesn’t need any special auth plumbing. The tradeoff: because that signature lives on an argument to your function rather than the top-level call, if you’re writing tests against this you need env.mock_all_auths_allowing_non_root_auth() rather than plain mock_all_auths() (see demo-consumer/src/test.rs), and on a real network the transaction needs an authorization entry for that address alongside whatever signs the outer call.

Your contract’s own address as asserter. Bonds pool under your contract’s control (e.g. to later distribute pro-rata to your own users) instead of going directly to an end user. This is meaningfully harder than it looks: Tholos’s assert_outcome calls the underlying token’s transfer, which itself calls require_auth() on the asserter. That’s two contract calls away from your contract (yours -> Tholos -> token), and Soroban only auto-grants a contract’s implicit self-authorization one call deep. The deeper call fails with Error(Auth, InvalidAction) unless you explicitly pre-authorize it with env.authorize_as_current_contract before invoking Tholos, specifying the exact token contract, transfer args, and amount Tholos will end up calling. That means you need to already know Tholos’s configured token and bond amount to construct the right authorization, since there’s no way to ask Tholos for the sub-invocation it’s about to make ahead of time. Only take this path if pooling bonds under your contract is a real requirement, not a default choice.

Lifecycle from an integrator’s perspective

finalize and resolve are both permissionless: anyone (a keeper, a bot, an end user, your own contract) can call them once the preconditions are met. Tholos does not push a callback to your contract when an assertion resolves. If you need to react automatically, two options:

  1. Poll get_assertion_state(id) after the challenge window you configured has elapsed, and act once status is Resolved.
  2. Watch events. Every state transition emits an event (see the table in CONTRACT.md); an off-chain indexer or keeper watching Finalized/Resolved for your tracked ids can call back into your contract once the outcome is final.

Either way, build your integration assuming resolution is not instant: it takes at least the full challenge window, and longer if disputed and resolver votes trickle in slowly.

Reading the outcome

#![allow(unused)]
fn main() {
let state = client.get_assertion_state(&id);
match state.status {
    tholos::Status::Resolved => {
        // state.outcome reflects the *original* asserted outcome, not necessarily
        // the final one if the assertion was disputed and overturned. Prefer the
        // Finalized/Resolved event payload (`outcome` field), which is always the
        // final decided outcome, over re-deriving it from Assertion.outcome.
    }
    _ => { /* not resolved yet */ }
}
}

This is a sharp edge worth calling out explicitly: Assertion.outcome is the claimed outcome at assertion time and is not flipped in storage if a dispute overturns it. The authoritative final outcome is what the Finalized or Resolved event carries, not get_assertion_state(id).outcome.

Parameters you’re choosing when you initialize

ParameterConsideration
tokenAny SEP-41 token. Must be a token your users already hold or can acquire; bonds are paid in it directly, there’s no swap step.
bond_amountHigh enough to deter spam/bad-faith assertions, low enough that legitimate use isn’t priced out. Fixed per instance, see above.
challenge_window_secsLonger windows give more time to catch bad assertions but delay uncontested finalization.
resolversMust be odd-length. See CONTRACT.md for what update_resolvers can and can’t change mid-dispute.

Known caveats for integrators

  • No reward beyond bond-return for uncontested finalizes: there’s currently no fee mechanism, so integrators who want to incentivize keepers to call finalize promptly need to handle that themselves (e.g. your own contract pays a small bounty to whoever triggers your callback).
  • The admin can pause assert_outcome, dispute, and resolve at any time via set_paused. Your integration should treat a Paused error as a distinct, expected failure mode (surface it to the user as “resolution temporarily unavailable”) rather than an unexpected error. finalize and update_resolvers stay callable while paused, so assertions already Pending before a pause can still resolve uncontested.

Glossary

Assertion A claim about an outcome, posted with a bond via assert_outcome. Identified by a u64 id. See the Assertion type in CONTRACT.md.

Asserter The address that posted an assertion. Receives the bond back if the assertion finalizes uncontested, or if a resolver majority agrees with them after a dispute.

Bond The amount of the configured token an asserter or disputer must post to make a claim. Fixed per contract instance at initialize. Exists to make bad-faith assertions and disputes costly.

Challenge window The time period (in seconds, from opened_at) during which a Pending assertion can be disputed. Fixed per contract instance at initialize.

Disputer The address that disputed a Pending assertion within its challenge window, matching its bond. Receives both bonds if a resolver majority disagrees with the original asserter.

Resolver An address in the resolver committee, entitled to vote on Disputed assertions via resolve.

Resolver committee The full set of resolvers for a contract instance, set at initialize and replaceable via update_resolvers. Must have an odd, non-zero length.

Majority resolvers.len() / 2 + 1. The number of matching votes needed to resolve a disputed assertion. Always achievable and never ambiguous because the committee is odd-length.

Finalize Closing out a Pending assertion after its challenge window has elapsed with no dispute. Callable by anyone. Returns the asserter’s bond.

Resolve Casting one resolver’s vote on a Disputed assertion. Once a majority agrees, the winning side receives both bonds and the assertion moves to Resolved.

Pause An admin-controlled switch (set_paused) that blocks new assertions, disputes, and resolver votes, without affecting finalize or update_resolvers. See ARCHITECTURE.md.

SEP-41 The Stellar Ecosystem Proposal defining the standard token interface Soroban contracts use (transfer, balance, etc.). Tholos’s token parameter must implement it.

SAC (Stellar Asset Contract) The built-in Soroban contract wrapping a classic Stellar asset (like native XLM or a Stellar-issued USDC) so it can be used as a SEP-41 token. What scripts/testnet-smoke.sh uses for its bond token.

Contributing

Setup

  • Rust toolchain (stable) with the wasm32v1-none target: rustup target add wasm32v1-none
  • Stellar CLI, for building and deploying the contract

Clone the repo, then build Tholos’s wasm once before anything else:

cargo build -p tholos --target wasm32v1-none --release
cargo test

The first command is required, not optional: demo-consumer imports Tholos’s compiled wasm at compile time (contractimport!), so cargo test, cargo clippy --workspace, and any IDE build of the workspace will fail on a fresh checkout until that file exists. Only re-run it after changing contracts/tholos; demo-consumer alone doesn’t need a rebuild between runs.

Project layout

contracts/
  tholos/               The assertion and dispute contract
    src/
      lib.rs             Contract logic
      test.rs            Unit tests (soroban-sdk testutils, mocked ledger and auth)
  demo-consumer/        Minimal example contract that calls into Tholos
    src/
      lib.rs             Cross-contract call pattern from docs/src/INTEGRATION.md
      test.rs            Validates that pattern against Tholos's real compiled wasm
scripts/
  testnet-smoke.sh      End-to-end check against real Stellar testnet infrastructure
.github/workflows/
  ci.yml                 Runs fmt, clippy, tests, and the wasm build on every push/PR

demo-consumer exists to keep INTEGRATION.md honest: it’s not a product, it’s a compiled check that the documented integration pattern actually works. If you change Tholos’s public interface, update demo-consumer too if it uses the changed function, and re-run its test.

If a second real contract is added later (e.g. a market factory), it should live as its own crate under contracts/, added to the [workspace] members list in the root Cargo.toml, following the same layout as contracts/tholos.

Testing philosophy

There are two layers, and they catch different things:

  • Unit tests (cargo test) run against a mocked ledger and mocked auth. Fast, deterministic, and where most new behavior should be covered, including every new Error variant you introduce: if you add a new failure path, add a test that triggers it.
  • The testnet smoke script (scripts/testnet-smoke.sh) deploys to a real network and exercises real auth, real storage TTLs, and a real SAC token. This is the only thing that can catch a class of bug unit tests structurally can’t (for example, an auth check that’s satisfied by mock_all_auths() in tests but fails against a real signature). Run it before opening a PR that changes contract behavior in a way that affects the deployed flow, not for every change.

Code standards

  • Naming: snake_case for functions and variables, PascalCase for types (Assertion, Status, Error), UPPER_SNAKE_CASE for constants (INSTANCE_BUMP_AMOUNT).
  • Error handling: contract entry points return Result<T, Error>; add a new Error variant rather than panicking for anything a caller could plausibly trigger (bad input, wrong state, missing auth). Reserve .unwrap() for values that are only unreachable because of a prior check in the same function (see Self::get, which unwraps instance storage that initialize is responsible for guaranteeing exists), and prefer propagating Error::NotInitialized where that precondition can’t be locally guaranteed instead, as update_resolvers does.
  • Doc comments: every public contract function gets a /// summary covering what it does, who must sign it, and which Errors it can return.
  • Security: validate all inputs and assume callers are adversarial. Never read a storage key without either handling the “missing” case explicitly or having a preceding check in the same function that guarantees it exists.

Docs site

docs/ is an mdBook that publishes this repo’s docs as a site, deployed automatically from main by .github/workflows/docs.yml. Where a given doc’s real content lives depends on whether GitHub treats it specially:

  • README.md, CONTRIBUTING.md (this file), and SECURITY.md stay at the repo root, because GitHub does something with them there (README renders on the repo homepage, CONTRIBUTING is linked when opening an issue/PR, SECURITY.md powers the Security tab). Their docs/src/ copies are one-line {{#include ../../X.md}} stubs; edit the root file, not the stub.
  • ARCHITECTURE.md, CHANGELOG.md, CONTRACT.md, DEPLOYMENT.md, GLOSSARY.md, and INTEGRATION.md get no special treatment from GitHub at root, so their real content lives directly under docs/src/, with no root duplicate. Edit them there; they’re still normal markdown files GitHub renders fine if you click into docs/src/CONTRACT.md directly, they just aren’t at the repo’s top level.

Preview locally with mdbook serve docs (requires cargo install mdbook).

Before opening a PR

Run the same checks CI runs, in this order (see the note above on why the wasm build has to come first):

cargo fmt --check
shellcheck scripts/*.sh
cargo build -p tholos --target wasm32v1-none --release
cargo clippy --workspace --all-targets -- -D warnings
cargo test

If you changed the contract’s public interface (functions, types, errors), update CONTRACT.md to match; it’s meant to stay in sync with lib.rs, not drift into a separate design doc.

Commit messages

One-line, imperative, conventional-commit style: feat:, fix:, docs:, test:, ci:, etc., followed by a concise summary. No comma-separated lists of unrelated changes in a single message; split them into separate commits instead.

Opening a PR

CI (fmt, clippy, tests, wasm build) must pass before merge. The PR template (.github/pull_request_template.md) is pre-filled when you open a PR; fill it out rather than deleting it. If the change affects bond amounts, resolver behavior, or anything with an economic consequence, say so explicitly in the summary so it’s easy to reason about from the PR alone.

Security policy

Status

Tholos has not had an external security audit. It has undergone one internal review pass, which found and fixed a real reentrancy vulnerability (see CHANGELOG.md and the “Security notes” section of CONTRACT.md). Treat it as pre-production software: appropriate for testnet use and further review, not for deployments securing meaningful value on mainnet until it has been audited.

Reporting a vulnerability

Do not open a public GitHub issue for a security vulnerability.

Report it privately via GitHub’s private vulnerability reporting on this repository. Include:

  • A description of the vulnerability and its impact
  • Steps to reproduce, or a proof of concept
  • The affected contract(s) and function(s)
  • A suggested fix, if you have one

You should expect an initial response within 7 days. Please allow time for the issue to be triaged and, where applicable, patched before any public disclosure.

Scope

In scope: the contracts under contracts/ in this repository. Out of scope: third-party dependencies (soroban-sdk, the Stellar network itself), and the contracts/demo-consumer example, which exists to validate integration patterns and is not intended for production use on its own.

Changelog

All notable changes to this project are documented here. Format follows Keep a Changelog.

[Unreleased]

[0.2.0] - 2026-07-10

Added

  • Validation for initialize: bond_amount must be positive (InvalidBondAmount) and challenge_window_secs must be non-zero (InvalidChallengeWindow).
  • shellcheck for scripts/*.sh in CI.
  • Documentation reorganized into docs/ (formerly book/), with GitHub-special files (README.md, CONTRIBUTING.md, SECURITY.md) staying at root and everything else (ARCHITECTURE.md, CHANGELOG.md, CONTRACT.md, DEPLOYMENT.md, GLOSSARY.md, INTEGRATION.md) living directly under docs/src/.

Fixed

  • Resolver committee is now snapshotted onto an assertion when it’s disputed (Assertion.resolvers), and voting/majority for that dispute are decided against the snapshot for its whole lifetime. Previously resolve re-read the live committee on every call, so an update_resolvers call mid-dispute could change who was entitled to decide it and what majority meant, partway through voting.
  • The internal Self::get storage helper no longer panics on missing storage; it returns Error::NotInitialized like the rest of the contract’s error paths.

Changed

  • Test suite refactored around a shared Fixture helper to cut the boilerplate repeated across nearly every test (env setup, token registration, contract registration, initialization).

[0.1.0] - 2026-07-09

Initial release: a working, tested, testnet-deployed assertion and dispute oracle.

Added

  • contracts/tholos: the core assertion and dispute contract, with initialize, assert_outcome, dispute, finalize, resolve, update_resolvers, and set_paused.
  • Admin-controlled resolver committee updates (update_resolvers), so a compromised or unresponsive resolver can be replaced without redeploying.
  • Admin-controlled pause (set_paused) for assert_outcome, dispute, and resolve. finalize and update_resolvers deliberately stay callable while paused.
  • contracts/demo-consumer: a minimal example contract calling into Tholos, validating the cross-contract integration pattern documented in INTEGRATION.md against Tholos’s real compiled wasm.
  • scripts/testnet-smoke.sh: an end-to-end check against real Stellar testnet infrastructure (deploy, initialize, assert, dispute, resolve).
  • CI (fmt, clippy, test, wasm build) on every push and pull request.
  • Documentation: README.md, CONTRACT.md, INTEGRATION.md, CONTRIBUTING.md, published as a site via mdBook and GitHub Pages.

Fixed

  • Reentrancy: assert_outcome, dispute, finalize, and resolve now write their state change before calling the external token contract’s transfer, closing a hole where a non-standard or malicious token could re-enter mid-call and drain bonds belonging to unrelated assertions. Covered by a regression test (test_finalize_is_not_reentrant) using a token that actively attempts the reentrant call.