Tholos
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
| Layer | Technology |
|---|---|
| Contract | Rust, Soroban SDK 26 |
| Network | Stellar (testnet today) |
| Token | Any SEP-41 / Stellar Asset Contract token, configured per deployment |
| CI | GitHub 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
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
| Field | Type | Meaning |
|---|---|---|
asserter | Address | Who posted the claim |
outcome | bool | The claimed outcome |
bond | i128 | Bond amount posted (in the configured token) |
opened_at | u64 | Ledger timestamp the assertion was posted |
status | Status | Current state |
disputer | Option<Address> | Who disputed it, if disputed |
votes_for_outcome / votes_against_outcome | u32 | Resolver vote tally |
voted | Vec<Address> | Resolvers who have already voted, to prevent double-voting |
resolvers | Vec<Address> | The resolver committee snapshotted at dispute time; empty until dispute is called. See resolve below. |
Error
| Variant | Meaning |
|---|---|
AlreadyInitialized | initialize called on a contract that’s already set up |
NotInitialized | Called before initialize (e.g. update_resolvers) |
InvalidResolverCount | Resolver list is empty or has an even length |
AssertionNotFound | No assertion exists with the given id |
NotPending | Action requires Status::Pending but the assertion isn’t |
NotDisputed | Action requires Status::Disputed but the assertion isn’t |
ChallengeWindowClosed | Tried to dispute after the challenge window elapsed |
ChallengeWindowOpen | Tried to finalize before the challenge window elapsed |
NotAResolver | Caller isn’t in the current resolver committee |
AlreadyVoted | Resolver already voted on this assertion |
Paused | Called assert_outcome, dispute, or resolve while paused |
InvalidBondAmount | bond_amount is zero or negative |
InvalidChallengeWindow | challenge_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:
| Event | Emitted by | Fields |
|---|---|---|
Asserted | assert_outcome | id, asserter, outcome |
Disputed | dispute | id, disputer |
Finalized | finalize | id, outcome |
Resolved | resolve, once a majority is reached | id, outcome |
ResolversUpdated | update_resolvers | resolvers (the new committee) |
PauseUpdated | set_paused | paused |
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
finalizejust returns the bond as-is. set_pausedandupdate_resolversare 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:
| Parameter | Guidance |
|---|---|
token | Any SEP-41 token your users already hold. No swap step exists, so picking a token nobody has is a dead deployment. |
bond_amount | High 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_secs | Long 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. |
resolvers | Odd-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:
- Poll
get_assertion_state(id)after the challenge window you configured has elapsed, and act oncestatusisResolved. - Watch events. Every state transition emits an event (see the table in
CONTRACT.md); an off-chain indexer or keeper watching
Finalized/Resolvedfor your trackedids 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
| Parameter | Consideration |
|---|---|
token | Any 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_amount | High enough to deter spam/bad-faith assertions, low enough that legitimate use isn’t priced out. Fixed per instance, see above. |
challenge_window_secs | Longer windows give more time to catch bad assertions but delay uncontested finalization. |
resolvers | Must 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
finalizepromptly 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, andresolveat any time viaset_paused. Your integration should treat aPausederror as a distinct, expected failure mode (surface it to the user as “resolution temporarily unavailable”) rather than an unexpected error.finalizeandupdate_resolversstay callable while paused, so assertions alreadyPendingbefore 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-nonetarget: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 newErrorvariant 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 bymock_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_casefor functions and variables,PascalCasefor types (Assertion,Status,Error),UPPER_SNAKE_CASEfor constants (INSTANCE_BUMP_AMOUNT). - Error handling: contract entry points return
Result<T, Error>; add a newErrorvariant 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 (seeSelf::get, which unwraps instance storage thatinitializeis responsible for guaranteeing exists), and prefer propagatingError::NotInitializedwhere that precondition can’t be locally guaranteed instead, asupdate_resolversdoes. - Doc comments: every public contract function gets a
///summary covering what it does, who must sign it, and whichErrors 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), andSECURITY.mdstay 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). Theirdocs/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, andINTEGRATION.mdget no special treatment from GitHub at root, so their real content lives directly underdocs/src/, with no root duplicate. Edit them there; they’re still normal markdown files GitHub renders fine if you click intodocs/src/CONTRACT.mddirectly, 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_amountmust be positive (InvalidBondAmount) andchallenge_window_secsmust be non-zero (InvalidChallengeWindow). shellcheckforscripts/*.shin CI.- Documentation reorganized into
docs/(formerlybook/), 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 underdocs/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. Previouslyresolvere-read the live committee on every call, so anupdate_resolverscall mid-dispute could change who was entitled to decide it and what majority meant, partway through voting. - The internal
Self::getstorage helper no longer panics on missing storage; it returnsError::NotInitializedlike the rest of the contract’s error paths.
Changed
- Test suite refactored around a shared
Fixturehelper 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, withinitialize,assert_outcome,dispute,finalize,resolve,update_resolvers, andset_paused.- Admin-controlled resolver committee updates (
update_resolvers), so a compromised or unresponsive resolver can be replaced without redeploying. - Admin-controlled pause (
set_paused) forassert_outcome,dispute, andresolve.finalizeandupdate_resolversdeliberately 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, andresolvenow write their state change before calling the external token contract’stransfer, 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.