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: done (configurable
finalize_reward_bps, bond-funded) - Stake-weighted bond-poster resolution (protocol v2): design proposed, not implemented
See CONTRACT.md for the full interface and known gaps, ARCHITECTURE.md for design rationale, or V2_RESOLUTION.md for the proposed, design-only stake-weighted resolution mechanism. See 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: three jobs, test (address/workspace checks, fmt, shellcheck, wasm builds, clippy, tests), demo (lint and build demos/freelance-escrow), sdk (bindings-drift check and build for packages/tholos-sdk) |
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
demos/
freelance-escrow/ A real freelance milestone-payment app built on
Tholos as its settlement layer, not a UI of buttons
calling contract functions; see its own README
scripts/
testnet-smoke.sh End-to-end check against real Stellar testnet infrastructure
.github/workflows/
ci.yml Runs three jobs on every push/PR: `test` (blocks
committed contract addresses, verifies workspace
membership, fmt, shellcheck, builds tholos's
wasm, clippy, tests, then a second workspace-wide
lib wasm build), `demo` (lint and build
demos/freelance-escrow), and `sdk` (checks
packages/tholos-sdk's generated bindings for
drift, then builds it)
Development
Requires the Rust toolchain with the wasm32v1-none target, plus the Stellar CLI for building and deploying the contract.
You can use the Makefile shortcut:
# Build tholos's wasm first (required by workspace)
make build-wasm
# Run unit tests
make test
# Check formatting and lints
make check
# Build the optimized, deployable contract wasm
make build-optimized
Or run the raw commands directly:
# 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 -x scripts/*.sh scripts/lib/*.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.
The committee described here is the implemented protocol v1 mechanism. A stake-weighted, dispute-scoped replacement is under design for protocol v2; see V2_RESOLUTION.md. That proposal does not describe current contract behavior.
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 be non-empty, have an odd number of members, contain distinct
addresses, and have no more than MAX_RESOLVERS (21) members. Both initialize
and update_resolvers enforce these constraints; duplicate addresses fail with
DuplicateResolvers. An odd committee makes the strict-majority threshold
(len / 2 + 1) unambiguous and eliminates an arithmetic tie. No separate
tie-handling or timeout logic exists.
Challenge window is capped at 7 days
initialize rejects any challenge_window_secs over 7 days, not just zero. This
isn’t arbitrary: persistent Assertion storage gets a 30-day TTL bump on every
write (see “Persistent storage TTL” in CONTRACT.md), and a window
close to that 30-day ceiling would leave little to no time after the window closes
for finalize to actually be called, or for a dispute opened right before the
window closes to get resolved, before the entry risks archival. 7 days keeps a
wide margin. Bond amount deliberately has no economic upper bound: a sensible
ceiling depends on the configured token’s decimals and intended use, which the
contract cannot judge. There is still an arithmetic limit: initialize enforces
MAX_BOND_AMOUNT, the largest bond that cannot overflow finalize’s
reward-multiply arithmetic or the token balance held across a dispute.
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.
The v2 proposal preserves this immutability rather than the committee itself. It pins resolution policy when an assertion opens, then freezes dispute-specific bond positions and their aggregate weight before discretionary third-party choices are revealed.
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 is in contracts/tholos/src/lib.rs, with
a test_*_is_not_reentrant regression test per function in
contracts/tholos/src/test.rs, each built against a token that attempts exactly
that reentrant call. See the “Security notes” section of CONTRACT.md
for the interface-level summary. All four functions require auth (finalize
unconditionally), so Soroban’s auth model independently rejects a reentrant
token’s nested require_auth for all of them; the state-before-transfer ordering
is a second layer of defense in case a colluding, pre-authorized signer ever got
one through.
Pause is scoped, not absolute
set_paused blocks assert_outcome, dispute, resolve, and finalize, but
deliberately not update_resolvers. finalize is blocked alongside dispute
rather than exempted: a pending assertion may have had no real opportunity to be
disputed during a challenge window that overlapped a pause, so it must not be able
to finalize uncontested until unpaused. This is not funds-neutral. Open disputes
cannot progress while paused, and a pending assertion whose window elapses while
paused simply waits, it becomes finalizable again once unpaused, rather than
finalizing uncontested during the pause. Pause must therefore be short-lived and is
not a safe retirement switch. A future version may extend a pending assertion’s
challenge window deadline by however long a pause overlapped it, so a paused
incident doesn’t cost legitimate disputers their real window; v1 does not do this.
If the pause was triggered because the live resolver committee is compromised,
the admin can use update_resolvers while paused to protect disputes opened after
the update. It cannot repair an already open dispute: that assertion keeps its
snapshotted committee, including a compromised or unavailable member.
Finalize reward is bond-funded, not externally funded
The original design anticipated a reward for prompt finalization paid from market
fees. No fee-generating market layer exists yet, so the reward is instead taken
from the asserter’s bond: finalize_reward_bps (0–1000 basis points) is set at
initialize time and determines what fraction of the bond the caller of finalize
receives. This keeps the mechanism self-contained — no external funding source is
required — while still creating an economic incentive. The asserter implicitly
accepts the haircut when they post; they control which deployment they post to, and
deployments with higher reward bps expose more of the bond.
Setting finalize_reward_bps to 0 (the default) reproduces the original behavior
exactly: no reward is taken, the full bond is returned to the asserter. Auth is
still required unconditionally: without it, any address could be passed as caller
with no verification, and that address would be permanently written into
Assertion.finalizer and the Finalized event as the finalizer of record — a
spoofable audit trail, even though no funds are at risk. Requiring auth keeps that
record trustworthy regardless of reward configuration. A non-zero value additionally
means the caller receives a fraction of the bond as an incentive. Soroban’s auth
model independently rejects a reentrant token’s nested require_auth in both cases,
so the reentrancy threat model for finalize is the same as for the other three
functions regardless of the reward setting.
Resolver self-rotation
The committee can replace one of its own by vote (propose_rotation /
vote_rotation / cancel_rotation), removing the admin as the only path to
committee membership. Design record: docs/src/ROTATION_DESIGN.md. Three decisions
the scheme rests on:
- Strict majority of the live committee, same formula as a dispute. The
threshold is
len / 2 + 1, the only majority rule in the contract. A colluding majority already decides every dispute, so rotation-by-majority adds no new attack surface beyond what that majority already has; it just routes the membership change through the contract instead of the admin key. - No interaction with the per-dispute snapshot, by construction. Self-rotation
writes the same
Resolversinstance-storage slotupdate_resolverswrites. Because a dispute snapshots the live committee atdisputetime (Assertion.resolvers), a rotation completing after a dispute is open has exactly the behaviorupdate_resolversalready has: no effect on that dispute. The new committee governs only disputes opened afterward. No change toAssertion,dispute, orresolvewas needed; the existing snapshot invariant carries the rotation for free. - Coexists with
update_resolvers, doesn’t replace it. Self-rotation is the day-to-day path; adminupdate_resolversstays as the emergency override for a compromised or deadlocked committee (the one case self-rotation can’t solve: a committee can’t vote to heal itself when it’s the problem). Both paths emitResolversUpdated, so the “committee changed” signal stays unified; rotation addsRotationProposed/RotationExecuted/RotationCancelledfor the governance trail.
Liveness: only one rotation may be open at a time, and it’s resolved by execution
(majority reached), proposer cancel, or a deterministic deadlock guard (if yes-votes
cast plus every unvoted resolver still can’t reach a majority, the proposal
auto-cancels) so a lost proposer key can’t permanently block rotation. Pause-exempt,
like update_resolvers: rotation is internal governance, not new exposure.
Admin override wins any race: update_resolvers clears an open self-rotation
proposal. The only ways the committee changes are update_resolvers and
rotation-execution, and both clear the proposal, so a live proposal always matches the
committee it was validated against — no stale proposal can execute against a committee
it wasn’t built for.
Flows
Uncontested: assert, then finalize (with optional reward)
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 Finalizer
Note over Finalizer: must authorize unconditionally
Finalizer->>Tholos: finalize(caller, id)
alt finalize_reward_bps > 0
Tholos->>Token: transfer(contract -> caller, reward)
Tholos->>Token: transfer(contract -> asserter, bond - reward)
else finalize_reward_bps == 0
Tholos->>Token: transfer(contract -> asserter, bond)
end
Tholos-->>Finalizer: 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: selected state changes blocked
sequenceDiagram
actor Admin
actor Asserter
participant Tholos
Admin->>Tholos: set_paused(true)
Asserter->>Tholos: assert_outcome(outcome)
Tholos-->>Asserter: Error: Paused
Note over Tholos: Pending can finalize but cannot be disputed;<br/>Disputed cannot receive votes
Resolver self-rotation: design
Design proposal for issue: “Design a resolver self-rotation scheme.” The goal is to
remove the committee-membership change from the single-admin-key trust path. Today
only update_resolvers (admin signature) can change who sits on the committee, which
CONTRACT.md’s Known gaps flags as a larger centralization point than the committee
itself. This design lets the committee replace one of its own by vote.
Implementation lives in contracts/tholos/src/lib.rs; the public interface is
documented in CONTRACT.md and the rationale in
ARCHITECTURE.md. This file is the design
record and the source of the three decisions the issue asks for.
The three questions
1. What majority is required?
A strict majority of the live committee, using the exact same formula as a dispute
tie-break: majority = committee.len() / 2 + 1.
Reasoning, and why not something stricter:
- The contract already has one majority rule, derived from the odd-length invariant: a strict majority is always reachable and never ties. Introducing a different threshold for rotation would fork that invariant and require new reasoning about when ties are or aren’t possible. Keeping a single rule is simpler and easier to audit.
- A colluding majority already controls every disputed assertion (2 of 3 decide any dispute in their favor). Rotation-by-majority does not add attack surface beyond what a colluding majority already has; it just lets that same majority change its own membership through the contract instead of needing the admin key.
- A higher bar (e.g. 2/3 supermajority, or quorum + majority) is the more conservative choice for a “constitutional” change and would be reasonable in a system where membership changes are rarer and higher-stakes than verdicts. We considered it and rejected it for v1: it would mean disputes resolve at a 2/3-equivalent only when the committee is size 3 or 5 (where strict majority already equals 2/3 or 3/5), but diverge for larger committees, re-introducing two thresholds to reason about. If a deployment wants a stricter bar later, it does it by choosing a smaller committee, not by a separate code path.
Votes are recorded as yes/no. Only yes-votes move the count; a no-vote records dissent
and prevents re-voting but never blocks (a proposal fails only by becoming
mathematically impossible, see liveness below). Execution fires the moment yes-votes
reach majority.
2. How does it interact with the per-dispute snapshot?
It doesn’t need to interact at all, by construction. This is the key insight that keeps the change small.
Assertion.resolvers is a per-dispute snapshot of the committee taken at dispute
time, used only to decide who may vote on that dispute and what majority means for
that dispute. Rotation is about committee membership, a different concern with its
own storage and its own vote. The two are independent objects.
Self-rotation writes the same Resolvers instance-storage slot that admin
update_resolvers writes. Because the dispute snapshot is taken at dispute time
against the live committee, any rotation that completes after a dispute is already
open inherits the exact behavior update_resolvers already has: it has no effect on
that dispute. The new (post-rotation) committee is used only for disputes opened
after the rotation executes. No change to Assertion, dispute, or resolve is
required; the existing snapshot invariant carries the rotation for free.
So the interaction is: none at the dispute layer. Rotation is just a
committee-governed alternative path to the same Resolvers value. The one place the
two paths do touch is the race where the admin overrides (update_resolvers) while a
self-rotation vote is in flight; that is handled explicitly (see coexistence below) by
having update_resolvers cancel any open self-rotation proposal.
3. Does it replace update_resolvers or coexist with it?
Coexists. Self-rotation is the day-to-day path; admin update_resolvers stays as
the emergency override. Three reasons:
- Deadlock recovery. If the committee is itself the problem (members go dark,
or a majority colludes), the committee cannot self-heal — that is precisely the
scenario where an external override is needed. Removing
update_resolverswould trade one centralization point for a permanent deadlock risk. - The two paths are complementary, not redundant.
update_resolversis already pause-exempt precisely so a compromised committee can be replaced without unpausing. Self-rotation, being a committee action, is useless exactly when the committee is the thing that’s compromised. The admin key is the break-glass for that case. - The issue frames it as an alternative path, not a replacement.
Both paths emit ResolversUpdated so the “committee just changed” signal stays unified
for indexers; self-rotation adds RotationProposed / RotationExecuted /
RotationCancelled for the governance audit trail.
Mechanics
Three new functions, gated to one open rotation at a time:
propose_rotation(resolver, old_resolver, new_resolver)— caller must be a current resolver (auth + membership).old_resolvermust be on the committee;new_resolvermust not be (and not equalold). One proposal open at a time. EmitsRotationProposed.vote_rotation(resolver, approve)— caller must be a current resolver, not already voted. Yes-vote that reachesmajorityexecutes the swap (old → new in the live committee) and clears the proposal, emittingRotationExecutedandResolversUpdated. A no-vote that makes the proposal mathematically impossible to pass auto-cancels it (liveness guard), emittingRotationCancelled. Otherwise the vote is recorded and the proposal stays open. ReturnsSome(true)(executed),Some(false)(auto-cancelled as dead), orNone(still open).cancel_rotation(resolver)— the proposer may cancel any time; any current resolver may cancel once the proposal can no longer reach a majority. EmitsRotationCancelled.
Liveness / deadlock guard
At most one open proposal. A proposal is resolved in exactly one way that changes state:
- Execute — yes-votes hit
majority. - Cancel by proposer — at any time.
- Deadlock auto-cancel — if
yes + remaining_unvoted < majority, it can never pass;vote_rotationcancels it automatically, andcancel_rotationlets any resolver cancel it too. This prevents a lost proposer key from permanently blocking all rotation.
Pause
Proposal and voting are pause-exempt, matching update_resolvers. Rotation is
internal governance (no assertion, dispute, or dispute-vote is created), so the
“pause stops new exposure” rationale doesn’t apply. A paused, incident-hit deployment
can still self-heal by committee vote; the admin override remains the fallback for a
compromised committee.
Reentrancy
None. Rotation never moves tokens, so there is no external call to reenter through. State is still written before events are published, matching the contract’s checks-effects convention.
Admin override vs. in-flight rotation
update_resolvers clears any open self-rotation proposal (emitting RotationCancelled
when one was present). The only way the live committee changes is update_resolvers
and rotation-execution, and both clear the proposal — so whenever a proposal exists,
the live committee still matches the assumptions it was validated against. No stale
proposal can ever execute against a committee it wasn’t built for.
Stake-weighted resolution (protocol v2 proposal)
Status: Proposed; design-only and not implemented.
Tracking: Issue #19.
Versioning: “v1” and “v2” in this document name protocol designs. They are independent of the Rust crate’s current
0.2.0package version.
This document proposes replacing v1’s fixed, admin-selected resolver committee with a vote whose electorate and weight come entirely from token bonds locked on the dispute being decided. It records a recommendation for review; it does not change the current contract, public interface, or deployment behavior.
Decision summary
| Question | Proposed answer |
|---|---|
| Who may vote? | An address with a positive resolution position funded before that dispute’s registration cutoff. The asserter and disputer are included through their existing bonds. |
| How much weight does it have? | One unit of voting weight per smallest token unit locked in that address’s position: weight(address) = locked_bond(address). Multiple deposits by one address are aggregated. |
| When is weight fixed? | Resolution policy is pinned when the assertion opens. Positions and total eligible weight are frozen when the dispute’s registration phase closes, before any discretionary third-party choice is revealed. |
What replaces Assertion.resolvers? | A per-dispute policy snapshot, an aggregate eligible-weight snapshot, and per-address Position records. No voter vector is copied or iterated. |
| What decides the result? | A side becomes mathematically irreversible once it has strictly more than half of all snapshotted eligible weight. If neither side does by the reveal deadline, the asserted outcome stands as the explicit optimistic default. Settlement waits until reveals close. |
| What happens to bonds? | After a strict-majority result, winning positions recover principal and share losing plus non-revealed stake. After an optimistic timeout default, all revealed positions recover principal and share only non-revealed stake. Permissionless O(1) settlement accrues owner-withdrawable credits; it never loops over all voters. |
| How do v1 deployments migrate? | Blue/green deployment: send only new assertions to a new v2 contract and attempt to drain each resolvable v1 assertion under the exact rules of its deployed WASM. Do not reinterpret or transfer in-flight v1 bonds; v2 cannot rescue an unresolvable v1 dispute. |
The timeout default and its separate settlement rule are economically material and remain the two highest-priority review points before an implementation issue is opened.
Why v2 needs more than a weighted resolve
In v1, an asserter posts one fixed bond and a single disputer matches it. Those are the only two bond posters. Giving only those two addresses voting weight would create two equal positions with opposing incentives, so every honest dispute would begin in a structural tie.
V2 therefore needs a bounded registration tier between dispute and voting.
The two parties can increase their positions and additional addresses can lock
capital specifically against that dispute during this phase. Once registration
closes, neither deposits, configuration changes, nor an administrator can change
that round’s electorate or denominator.
This preserves the important invariant behind v1’s resolver snapshot while removing the fixed committee: the rules of an in-flight decision cannot change after voting begins.
Goals
- Scope both eligibility and influence to capital actually at risk on one dispute, rather than unrelated token holdings or an administrator’s list.
- Make address splitting economically neutral: splitting one bond across many addresses must not increase its combined weight.
- Freeze policy, voters, and weights before discretionary third-party choices are revealed. The asserter and disputer sides are public by construction.
- Reach a deterministic terminal result by a bounded deadline, then allow permissionless O(1) position settlement and owner-authorized credit withdrawal.
- Preserve exact escrow accounting and make every token interaction reentrancy-safe.
- Keep storage and every call bounded without iterating an open-ended electorate.
- Give existing v1 deployments a cutover path that preserves their exact rules and custody without pretending v2 can rescue already stranded bonds.
Non-goals and trust assumption
This mechanism does not prove an external fact, provide one-human-one-vote, prevent bribery, or remove the need for evidence and off-chain coordination. It also does not eliminate wealth concentration: an address or coalition controlling more than half of the eligible bonded capital can determine the result.
The outcome rule has an explicitly asymmetric security assumption:
A false assertion is corrected only if revealed weight against it becomes strictly greater than half of all eligible weight before the deadline. A true assertion remains unless incorrect revealed weight against it crosses that same threshold. For outcome purposes, every non-reveal favors the assertion.
This is narrower than a generic token-holder vote because every unit of influence must be transferred into the contract, locked for the dispute, and assigned to a position whose protocol-level payout depends on the result. It is not an unconditional truth guarantee. A winning majority recovers its capital, and a beneficial owner can self-hedge through multiple addresses, so nominal bonded weight is not the same as guaranteed economic loss. Integrators must model bonds relative to the external value that a false result could capture.
The practical cost of corruption is set by independent counter-stake present or able to enter before the hard cutoff, not by the base bond alone. Once a coalition has an irreversible majority, its winning positions are no longer economically at risk from this dispute.
The proposal also does not specify final Rust types or function names. Those belong in a later implementation issue after the economics are accepted.
Lifecycle and the single weighted round
This proposal has an optimistic stage followed by at most one weighted round:
- Optimistic assertion. The asserter posts the deployment’s base bond. If no
one disputes within the challenge window,
finalizebehaves as it does in v1. - Dispute-scoped resolution. A distinct disputer matches the base bond. A bounded registration period lets other addresses post resolution bonds, then the frozen set of bond posters decides the result by stake weight.
It does not specify recursive appeals or repeated stake rounds. A later, more-highly-bonded tier remains a review question; adding one would require separate round snapshots, deadlines, and a maximum round count.
stateDiagram-v2
[*] --> Pending: assert_outcome + asserter bond
Pending --> Resolved: challenge window ends uncontested
Pending --> Registration: dispute + matching bond
Registration --> Reveal: cutoff; freeze positions and total weight
Reveal --> OutcomeLocked: either side exceeds 50% of eligible weight
Reveal --> Resolved: reveal deadline; asserted outcome is default
Reveal --> Resolved: all weight revealed; tie; asserted outcome is default
OutcomeLocked --> Resolved: reveal deadline or all weight revealed
Resolved --> [*]: position settlement, then credit withdrawal
Every deadline is derived from the policy pinned to the assertion. A caller may advance an expired phase permissionlessly; progress cannot depend on an admin or on one designated account remaining online.
Policy is pinned when the assertion opens
V1 snapshots the live committee when dispute is called. That protects an open
vote, but an admin can still change the future decision-maker after the asserter
has committed its bond and before a dispute arrives. V2 should close that gap.
Each assertion stores a complete, immutable PolicySnapshot when it is created.
A version and canonical hash identify and authenticate that snapshot, but neither
is a substitute for storing the values the contract must execute. At minimum,
the snapshot covers:
- configured token and base bond;
- minimum third-party resolution bond;
- registration duration, any anti-sniping extension, and its hard maximum;
- reveal duration;
- weight-rule version (
LinearStakeV1in this proposal); - strict-majority threshold and optimistic timeout default;
- forfeiture and payout-rule version;
- maximum position and total eligible stake supported by checked tally and payout arithmetic; and
- maximum active resolution horizon and initial settlement/withdrawal grace used to size storage TTLs.
Admin changes, if future versions allow them at all, apply only to assertions
created after the change. No policy update may alter an already opened assertion.
Copying this small, bounded snapshot into the assertion avoids a mutable lookup
and avoids giving a separate policy record its own archival dependency.
policy_hash is the specified cryptographic hash of the versioned, canonical
encoding of those stored values.
This proposal retains v1’s one-token-per-deployment model: the token address is immutable for the lifetime of a v2 contract. The policy snapshot binds each assertion to that token explicitly. A future multi-token deployment would instead have to include the token in every credit key and liability aggregate.
Registration and voter eligibility
Calling dispute starts a registration period and creates two initial positions:
- the asserter’s existing base bond is fixed in support of the asserted outcome;
- the distinct disputer’s matching bond is fixed against the asserted outcome.
Treating those initiating actions as fixed ballots avoids requiring either party to come back online merely to restate the position its bond already expresses. V2 should reject an address disputing its own assertion. This cannot prevent the same owner from using another address, but it prevents one storage position from occupying both protocol roles.
During registration, the asserter and disputer may top up their fixed-side
positions, and any other address may create one dispute-specific position. Every
new position or top-up transfers at least minimum_resolution_bond into escrow.
The recommended default is the assertion’s base bond; allowing a one-unit top-up
or third position would make an equal asserter/disputer tie too cheap to break.
An external poster supplies a salted commitment to its eventual
agrees_with_asserter choice with the deposit. Its side remains hidden until the
reveal phase, while its amount is visible and economically committed. The
commitment must domain-separate at least:
H(canonical_encode(
"THOLOS_V2_VOTE", network_id, contract_address, policy_hash,
assertion_id, round, voter, choice, salt_32
))
H must be a specified cryptographic hash, the encoding must be canonical and
length-delimited, and the 32-byte salt must be generated with enough entropy to
prevent brute-forcing a commitment whose only secret payload is a boolean.
Registration follows these rules:
- A position is keyed by
(assertion_id, address)and is non-transferable. - Repeated deposits from one address aggregate into one amount and therefore one vote. Asserter/disputer top-ups stay on their fixed sides; an external top-up may not replace the position’s original commitment.
- Every amount is positive, denominated in the configured token’s smallest unit, authorized by its poster, and transferred into escrow before it can be used as active weight.
- A deposit is rejected atomically before acquiring weight if the new position, eligible total, or worst-case settlement arithmetic would exceed its pinned bound or numeric type. A failed transfer leaves neither position nor weight.
- A poster cannot withdraw or reduce its position after funding it. It exits only through settlement.
- New positions and top-ups stop at the registration cutoff. A bounded anti-sniping extension may move the soft cutoff when a qualifying bond arrives near the end, but never past the snapshotted hard deadline.
- The contract maintains the eligible total incrementally as deposits arrive; it never discovers participants by scanning storage.
The exact registration and extension durations are deployment parameters, not
universal constants. Economic simulation should set them before implementation.
Clients must read an on-chain transition to Reveal before transmitting a choice
and salt. Guessing that the soft cutoff has passed is unsafe: a rejected reveal
transaction still publishes its preimage while an extension may leave
registration open.
Voting weight
For address i with s_i token units locked at the cutoff:
w_i = s_i
W = sum(w_i) for all eligible positions
W is frozen before reveals begin. A position’s amount cannot change afterward,
and token balances held elsewhere are irrelevant.
Linear weight is deliberate:
- splitting stake
sacross addresses leaves combined weight equal tos; - combining deposits under one address produces the same result;
- weight is backed by an escrowed quantity the contract can verify; and
- the arithmetic and economic exposure are auditable.
One-address-one-vote, per-address caps, square-root weight, and other concave rules all increase combined influence when a participant splits capital across pseudonymous addresses. Without a separate Sybil-resistant identity system, those rules create the appearance of limiting whales while making address splitting profitable. Token-balance snapshots are also rejected: they are not dispute-scoped and can admit borrowed or otherwise uncommitted voting power.
Linear weight limits a large holder to proportional, not superlinear, influence; it does not stop that holder from dominating a small dispute. That residual risk must be stated plainly rather than hidden behind an identity-dependent formula.
Replacing Assertion.resolvers
V2 replaces the committee vector with two snapshots serving different purposes:
- Policy snapshot at assertion creation: fixes how a future dispute will be funded, voted, timed, and settled.
- Eligibility snapshot at registration close: freezes each funded position
and the aggregate
Wused as the denominator.
The eligibility snapshot is logical, not a copied vector. A conceptual storage layout is:
| Record | Purpose |
|---|---|
AssertionV2(id) | Claim, parties, lifecycle, complete PolicySnapshot, and authoritative final outcome. |
Resolution(id) | Phase, deadlines, frozen W, weighted tallies, immutable terminal cause, settlement class/aggregates, and rule version. Terminal cause distinguishes StrictMajorityFor, StrictMajorityAgainst, and OptimisticTimeout. |
Position(id, address) | Escrowed amount, position kind, revealed side, and settlement state for one address. The kind is either protocol-fixed with a side (asserter/disputer) or external with a commitment hash. |
Credit(id, address) | Dispute-scoped token liability accrued by permissionless position settlement and withdrawable by its owner to an authorized destination. Keeping the assertion ID in the key preserves per-dispute accounting when one owner participates in several disputes. |
The proposed design must not place an unbounded Vec<Address> inside the
assertion. The current cap of 21 resolvers exists precisely because v1 copies and
iterates that vector. Per-position keys plus aggregate totals keep registration,
reveal, finalization, position settlement, and credit withdrawal O(1) in the
number of posters.
Every persistent record needs a TTL policy covering the full active lifecycle and
an explicit archival/restoration path for later withdrawals. Updating the
assertion alone is not sufficient to keep separate Position entries live.
This proposal does not confiscate an entitlement after a withdrawal deadline. A position is created with TTL covering the maximum active-phase horizon plus a pinned settlement/withdrawal grace period; known positions can then be bumped or settled permissionlessly. If a persistent record is archived later, its assertion, resolution, position, and credit footprints must be restored before settlement or withdrawal. The liability remains until paid. Events and an off-chain index are therefore part of the recovery path. This makes the outcome deadline bounded, not the time at which every owner actually receives funds, and it deliberately avoids an admin sweep of unclaimed property.
Because these keys are intentionally not enumerable on-chain, v2 must emit
indexable events for position funding/top-ups, eligibility freeze and W, every
reveal, outcome lock/finalization, position settlement/credit accrual, and credit
withdrawal. These events are required for keepers and historical indexers, not an
optional observability enhancement.
Reveal, majority, and timeout
After registration closes, the asserter and disputer weights are already tallied
on their protocol-fixed sides and included in revealed_weight exactly once. An
external voter reveals choice and salt.
The contract authenticates that address, verifies its frozen positive position
and commitment, rejects a second reveal, and adds exactly the frozen position
amount to one tally.
Let F be weight agreeing with the asserter and A weight against it. A side has
an absolute majority when:
F > W / 2 or A > W / 2
Implementation should use checked integer arithmetic and an overflow-safe
comparison such as side_weight > W - side_weight, not unchecked doubling.
Because the denominator includes non-revealed positions, a small fraction of
turnout cannot actively overturn a much larger frozen stake pool. This is not a
symmetric quorum guarantee: under the timeout rule below, abstention ultimately
favors the asserted outcome.
The outcome is safe to lock as soon as either side crosses the threshold;
unrevealed weight can no longer reverse it. Reveals nevertheless remain open
until their deadline so positions committed to the winning side can prove their
entitlement and avoid being treated as non-reveals. Settlement opens only after
the deadline. It may open earlier if revealed_weight == W; when all weight is
revealed but the tallies are tied, the optimistic default is already irreversible
and can be applied immediately.
If neither side crosses the threshold before the reveal deadline, the asserted outcome stands. This optimistic default is recommended because it:
- gives every dispute a bounded terminal result;
- does not restore an admin or committee as a tie-breaker;
- makes a challenger bear the burden of assembling more bonded weight against an assertion; and
- is consistent with the existing rule that an uncontested assertion finalizes as stated.
This asymmetry is broader than tie-breaking. For example, if 1% of eligible
weight reveals for the assertion, 49% reveals against, and 50% does not reveal,
the assertion still stands because neither revealed side exceeded half of W.
Non-revealed weight is therefore functionally delegated to the status quo for the
outcome, even though its positions are penalized in settlement.
The cost is real: an evenly split dispute favors the asserter. The principal
alternative is a terminal Inconclusive state that delegates fallback to the
integrator. That avoids asserting truth on a tie, but breaks the promise that
Tholos returns a boolean and moves resolution complexity outside the protocol.
This choice must receive explicit maintainer approval before implementation.
Outcome and settlement are separate decisions. A timeout default has no bonded majority, so it must not label every position against the assertion as a losing vote. Under the recommended timeout settlement, all revealed positions on both sides recover principal and share only the non-revealed pool pro rata. With only the two equal initiating bonds, the assertion therefore stands but both bonds are returned. A lone challenger pays fees, capital lock, and bounded delay rather than donating its entire bond. If that is insufficient protection against frivolous disputes, v2 needs a separately modeled non-refundable dispute fee; it should not silently reuse majority slashing for a result that no majority chose.
Bond settlement
Voting weight with unconditional refunds would let a large holder dictate a strict-majority result while paying only temporary illiquidity. Settlement depends on the terminal cause.
For a result locked by strict majority:
- Positions on the decided side recover principal.
- Losing positions and positions that fail to reveal are forfeited.
- The forfeited pool is distributed among winning positions in proportion to their weight.
For an optimistic timeout default without a strict majority:
- Every revealed position, agreeing or disagreeing, recovers principal.
- Only non-revealed positions are forfeited.
- That non-revealed pool is distributed among all revealed positions pro rata.
In both cases, settlement separates permissionless accounting from the external
token transfer. Any caller may settle a known position: the contract marks that
position settled and accrues its entitlement to the stored owner’s Credit
without calling the token. The owner later authorizes withdrawal of that credit
to a chosen address. A receiver that rejects the token can therefore delay only
its own withdrawal, not another position’s accounting or dust.
This is a protocol-level forfeiture, not proof of a beneficial owner’s net loss. A coalition that also controls reward-recipient positions recovers part or all of value forfeited by its losing/non-revealed addresses, and a coalition already above the majority threshold knows its winning stake will not be slashed. The mechanism creates contestable exposure during registration; it does not make capture cost equal to the attacker’s nominal deposits.
For each reward-recipient position, the reward is fixed when settlement opens:
recipient_weightis final winning-side weight after a strict majority; orrecipient_weightis total revealed weight after a timeout default.
reward_i = floor((s_i * forfeited_pool) / recipient_weight)
payout_i = s_i + reward_i
Integer implementation must conserve the exact escrow and avoid overflow in the
conceptual multiplication above. Every position settlement uses the original
forfeited_pool and recipient_weight, so its result is independent of settlement
order. Each position can immediately accrue principal plus that fixed reward. The
contract tracks unsettled recipient weight and distributed rewards.
Once recipient weight reaches zero, a permissionless dust settlement accrues the
exact undistributed remainder to a deterministic initiating party: the winning
asserter/disputer after a strict-majority result, or the asserter after a timeout
default. Only this indivisible dust, not that party’s principal or pro-rata
reward, waits for other positions to settle. If forfeited_pool == 0, no dust
operation is needed.
This deterministic rule is O(1) per position, conserves the entire pool, and prevents call order from changing base entitlements. It does give one initiating party all indivisible remainder units; that explicit trade-off is preferable to caller-selected ordering and must be property-tested. The multiply/divide itself must be full-precision or operate under a validated bound that cannot overflow.
Credit withdrawal reduces that dispute’s stored credit and outstanding liability,
and increases its withdrawn total, before the outgoing token transfer; a failed
transfer atomically restores all three. Incoming deposits
need an explicit reentrancy guard: a position must not be usable by a
permissionless cutoff or settlement callback while its token transfer is still
executing. The guard is entered before the external call and rolled back with the
transaction on transfer failure; the position becomes active only as part of a
successfully funded operation. Total withdrawals and credits for one dispute must
never exceed its funded positions, even if a token contract calls back into
Tholos. The authoritative terminal_cause and final_outcome are stored in v2
state as well as emitted, avoiding v1’s sharp edge where Assertion.outcome
always remains the original claim.
Example
With a base bond of 100 units:
- asserter: 100 agreeing;
- disputer: 100 against;
- voter A: 60 against; and
- voter B: 40 agreeing.
The frozen total is W = 300. Weight against is 160, strictly greater than 150,
so the assertion is overturned. The 160 units on the winning side recover their
principal and share the 140-unit forfeited pool pro rata. Address splitting would
not change W or either side’s weight, though it can change smallest-unit dust
allocation under the deterministic rule above.
Administration and pause semantics
V2 has no global resolver membership and no update_resolvers equivalent for v2
assertions. The admin cannot insert a voter, remove one, alter weight, or change a
pinned policy.
An emergency pause must not selectively censor a time-critical step of an
already funded assertion. In particular, blocking dispute while allowing
finalize, or blocking reveal while its deadline continues, can choose a winner
administratively. The recommended v2 pause blocks new assertions only. Dispute,
registration, reveal, finalization, settlement, and withdrawals for already
accepted assertions remain available. A stronger emergency mechanism would need
to freeze every affected deadline symmetrically or cancel the round with deterministic refunds;
that is a separate design and audit surface. Creation-only pause preserves voting
neutrality but cannot contain an exploit in registration, reveal, settlement, or
withdrawal, so this trade-off is blocking security review rather than a settled
operational detail.
Required invariants
A future implementation and its tests must establish all of the following:
- A position contributes weight only after the same amount is escrowed; a failed deposit leaves no position or weight.
- At the cutoff,
Wequals the sum of all position amounts, with each initiating base bond represented exactly once. - For every dispute and after every settlement or withdrawal,
funded_total == withdrawn_total + accrued_credit + unsettled_entitlements;accrued_creditis the outstanding sum ofCredit(id, *), so a withdrawal moves value from it towithdrawn_total. Across disputes, the contract’s token balance is at least the sum of outstanding credit and unsettled entitlements; no cross-dispute or unsolicited balance is treated as available surplus. - Policy cannot change after assertion creation.
- Position amount, eligibility, and
Wcannot change after the cutoff. - Each address has one aggregated position and at most one revealed choice.
- Fixed asserter/disputer ballots enter both their tally and
revealed_weightexactly once; at all times,agree_weight + disagree_weight <= W. - Phase transitions are monotonic and idempotent. No post-cutoff transition accepts a position or top-up.
- No side locks an outcome early without strictly more than half of
W, and neitherterminal_causenorfinal_outcomechanges after it is locked. - After the deadline, the optimistic default and its distinct settlement class are deterministic; settlement and owner-authorized withdrawal need no admin.
- A position accrues credit at most once to its stored owner, and deterministic dust accrues at most once to its pinned recipient. A failed credit withdrawal rolls back without consuming the credit.
- Registration, reveal, result calculation, position settlement, and withdrawal are O(1); none loops over all positions.
- Every token-moving path is reentrancy-safe. Outgoing withdrawal uses effects-before-interactions so a callback cannot spend the same credit twice; incoming stake cannot become usable during its transfer and rejects callbacks that could observe or act on a partially funded position.
- Deposits that exceed a pinned or numeric bound are rejected. All additions, threshold comparisons, and pro-rata payouts use checked, conservation-preserving arithmetic.
- Assertion, resolution, position, and credit TTLs cover every active phase and initial settlement/withdrawal grace; archived entitlements remain restorable.
- Pausing new assertions does not alter a deadline or action available to an already accepted assertion.
Threat analysis
| Threat | Proposed control | Residual risk |
|---|---|---|
| Address splitting / Sybil voting | Linear weight and one aggregated position per address. | Splitting identities remains possible but provides no extra weight. |
| Committee/admin capture | No resolver list; both snapshots are dispute-local and immutable. | Admin still controls any powers retained outside resolution, such as deployment configuration. |
| Borrowed or flash voting power | Tokens are transferred and locked across registration and reveal, not read from a wallet balance. | Longer-duration borrowed capital remains possible and carries the same economic risk as owned capital. |
| Last-moment stake | Fixed cutoff plus a bounded anti-sniping extension and hard deadline. | A sufficiently funded late entrant can still dominate before the hard deadline. |
| Vote copying / tactical side selection | Salted third-party commitments are funded before those discretionary sides are revealed. | Initiating sides are public; off-chain disclosure and bribery cannot be prevented. |
| Double voting or staking both sides | One immutable commitment and one position per (id, address). | A participant can use multiple addresses on both sides. Linear weight gives no extra aggregate influence, but self-hedging can reduce its net economic exposure and must be modeled. |
| Abstention to block majority | Non-revealed weight remains in W, its position is forfeited, and the assertion wins at timeout; all revealed positions share the forfeiture. | Abstention is effectively delegated to the status quo. A coalition with revealed addresses can recycle part of the forfeiture, so its net cost may be below the nominal bond. |
| Storage DoS | Minimum bond, per-address records, incremental aggregates, no voter vector or loop. | Many fully funded positions still consume storage and must be load-tested. |
| Arithmetic, reentrancy, or payout drain | Checked sums, bounded totals, permissionless credit accrual, liability invariants, outgoing effects before withdrawals, and a deposit guard. | Requires property, adversarial-token, and high-boundary tests plus audit. |
| Ambiguous real-world claim | Bind each v2 assertion to an immutable market/question identifier or content hash. | Evidence availability and interpretation remain off-chain concerns. |
The final row identifies an adjacent design dependency, not a decision approved by this issue. V1 stores only a boolean and expects the integrator to map its market to an assertion ID. An open electorate needs an unambiguous immutable reference to the proposition and resolution rules before posting bonds. A follow-up design must decide whether v2 commits an on-chain identifier or relies on a versioned integrator registry; the evidence format itself can remain off-chain.
Alternatives considered
| Alternative | Why it is not recommended |
|---|---|
| Give the asserter and disputer one weighted vote each | Their required bonds are equal, producing a structural tie. |
| Count only revealed weight | A tiny turnout could decide a large eligible pool; strategic abstention changes the effective denominator. |
| One address, one vote | Pseudonymous address splitting creates voting power at negligible cost. |
| Cap or square-root each address’s weight | Without identity, splitting a bond across addresses bypasses the cap or increases total concave weight. |
| Weight current wallet/token balance | Influence is not dispute-scoped or necessarily at risk and may be borrowed at snapshot time. |
| Keep weights live during voting | Deposits can change the denominator and required majority after votes are known, recreating the mutable-snapshot bug. |
| Let the admin committee break ties | Restores the centralization v2 is intended to remove and makes the fallback the real authority. |
| Require a supermajority | Raises manipulation cost but materially increases defaults and capital-locking grief; it needs evidence before replacing strict majority. |
Return Inconclusive on timeout | Semantically safer on a tie, but pushes a second oracle/fallback into every integrator and no longer guarantees a boolean result. |
Migration from existing v1 deployments
Existing v1 contracts cannot be converted in place. They expose no WASM upgrade entry point, state importer, escrow transfer, or administrative withdrawal. Changing the Rust crate later does not add those capabilities to already deployed bytecode.
The migration is therefore blue/green. Cutover to v2 does not guarantee that v1 can be fully drained: v1 has no timeout or cancellation for an unresolvable dispute, so dual operation and trapped v1 escrow may be permanent.
1. Inventory v1
For each deployment, record the network, contract ID, exact WASM hash and verified
source semantics, token, bond, challenge window, admin, committee, last observed
assertion ID, and every on-chain Pending or Disputed assertion, including
direct submissions not recognized by an integrator. Reconstruct configuration and
IDs from deployment transactions and events where necessary; v1 has no public
config, version, or NextId getter.
For each open assertion, reconcile expected liability:
Pending: one assertion bond;Disputed: two bonds; andResolved: no remaining liability under normal v1 settlement.
Audit whether every relevant committee has distinct, available addresses and a
reachable majority. V1 does not reject duplicate resolver addresses, while one
address can vote only once; a duplicate-filled snapshot can make its numeric
majority unreachable. For deployed v1 WASM that predates MAX_BOND_AMOUNT, also
verify that its bond-derived arithmetic is representable. Current v1 enforces
MAX_BOND_AMOUNT at initialization, but that source change does not alter older
deployed bytecode.
Archived assertion entries, instance storage, or contract code may need ledger restoration before settlement, especially for deployments predating the current TTL fix. Archive deployment transactions, assertion state, and events off-chain; RPC event retention and live ledger TTL are not a historical archive.
2. Deploy v2 separately
Deploy and initialize a new contract at a new address. Do not copy v1 assertion
records, mint replacement positions, or manually move pooled token balances.
Every v1 bond remains a liability of v1. Its only normal exit is v1 finalize or
resolve; if the exact deployed rules cannot reach either path, v2 cannot rescue
or move that bond.
Record a cutover ledger/timestamp and route only newly accepted assertions to v2.
The complete identity of an assertion becomes (contract_id, assertion_id);
both deployments can legitimately issue ID 0.
3. Run both versions while v1 drains
Integrators and indexers keep v1 and v2 bindings side by side and execute each v1 assertion under the exact semantics of its deployed WASM:
- v1 assertions are read and completed under the v1 interface;
- in snapshot-capable v1 releases, an already
Disputedassertion keeps its capturedAssertion.resolvers, while aPendingassertion captures the live committee only if and when it is disputed; - older v1 bytecode that predates
Assertion.resolversreads the live committee during voting, so a committee update has different consequences; - v2 assertions use only v2 positions and weighted voting; and
- historical v1
Finalized/Resolvedevents remain authoritative for their outcomes.
Keep the v1 committee stable during drain unless an incident requires a change, and analyze that change against the verified WASM first. Rotation cannot repair an unavailable or malformed committee already captured by a snapshot.
Do not pause v1 during this drain. V1 pause blocks assert_outcome, dispute,
resolve, and finalize all together, so it cannot selectively reject only new
assertions while leaving already-open ones free to finalize or resolve; using it
here just stalls every in-flight assertion for as long as the pause lasts, with
no compensating protection since drain is a routine wind-down, not an incident.
It also cannot rescue an open dispute whose snapshotted committee is unavailable.
The v1 contract cannot reject only new assertions. The cutover is therefore an integrator policy, not a perfect on-chain gate: direct callers may still create unrecognized v1 assertions, which remain v1 liabilities and must not be silently treated as v2 work.
4. Retire v1 operationally
Operators can remove v1 from official submission interfaces after accepted
traffic has drained, but the conservative on-chain policy is to leave it unpaused
and clearly deprecated. There is no atomic operation that blocks a new assertion
while preserving dispute and resolve: an assertion can enter immediately
before a final pause, after which its short challenge window may expire before an
unpause can restore the right to dispute it.
An operator that nevertheless pauses v1 must first find no Pending or
Disputed assertion anywhere in the complete on-chain inventory, monitor through
the pause ledger, and be prepared to unpause and drain again if new activity
appears. This is best-effort, not a trustless safe-retirement guarantee; an
adversary can delay it indefinitely by continuing to submit assertions.
Keep the v1 address, exact ABI/WASM metadata, and an off-chain state/event archive for historical use. A residual token balance can be called dust or an unsolicited transfer only after liabilities, including archived or previously unindexed assertions, are reconciled. V1 cannot sweep it and it must never be represented as migrated escrow.
Rollback boundary
Before v2 accepts its first bond, an integrator can route new traffic back to v1. After either version has live bonds, there is no atomic rollback: each contract must continue under its own deployed and per-assertion rules.
Absent a separately designed compatibility shim, on-chain consumers compiled against v1 require v2 bindings and an upgrade or new deployment; changing only the target contract ID is insufficient because v2 changes the assertion shape and lifecycle calls. Migration planning must inventory consumers as well as Tholos instances.
Future implementation work
No item below is part of this design-only change. After this proposal is reviewed and accepted, implementation should be split into auditable issues for:
- versioned v2 state, policy pinning, registration, and authorization;
- commitment/reveal voting and weighted-majority property tests;
- liability accounting, permissionless credit accrual, owner-authorized withdrawals, rounding, and adversarial-token tests;
- TTL behavior for assertion, resolution, position, and credit records;
- v1/v2 consumer bindings and a blue/green migration runbook;
- testnet volume tests with many positions and concurrent disputes;
- economic simulation for window lengths, minimum bonds, whale capture, and default frequency; and
- an independent security audit before meaningful-value mainnet use.
At minimum, randomized tests must cover address splitting, deposit aggregation, exact half versus half-plus-one, abstention, timeout, maximum amounts, overflow, arbitrary settlement order, exact escrow conservation, reentrancy, and immutable snapshots under attempted admin updates.
Questions for design review
- Should timeout preserve the optimistic boolean result as proposed, or should
safety on split votes take priority through an
Inconclusiveresult? - Should the minimum external position always equal the base assertion bond, or be a separately pinned parameter?
- Which deposits qualify for a bounded anti-sniping extension, and what hard maximum prevents extension griefing?
- Should all non-revealed stake be forfeited and redistributed to the proposed recipient set, or should its destination be independent of the outcome? What happens during a symmetric operational cancellation?
- Is the separate timeout settlement (refund every revealed principal and share only non-revealed stake) sufficient to deter frivolous disputes, or is a distinct non-refundable fee required?
- Is perpetual, restorable credit entitlement acceptable, and who funds storage restoration or keeper calls after the initial settlement/withdrawal grace?
- What canonical market/question identifier and evidence convention, if any, must a separate dependency add for an open electorate?
- Should a later protocol tier allow a new, more highly bonded assertion after
an
Inconclusivealternative, or is one weighted round the final tier? - Is a creation-only pause acceptable despite its limited incident containment, or must a separate symmetric freeze/cancel mechanism be designed first?
Until those economic choices are approved and threat-modeled, this proposal must
remain Proposed and no implementation issue should treat its interface sketch
as final.
V2 canonical claim identifier and evidence convention
Status: Accepted; design-only, not implemented.
Tracking: Issue #76.
This document proposes an on-chain reference for what a v2 assertion actually claims. It does not change any already-merged v2 code. A follow-up implementation issue is opened separately, the same split V2_RESOLUTION.md and its own implementation issues (#64-#71) used.
Why this needs deciding at all
V1 stores only a boolean per assertion (Assertion.outcome) and leaves the
actual claim, what the assertion is about, entirely off-chain, tracked by
whichever integrator posted it. That works in v1 because the only people who
ever need to know what an assertion means are the asserter, the disputer,
and the integrator, all of whom coordinated off-chain before any bond was
posted. Nobody else ever has a reason to look at assertion #42 and form an
opinion about it.
V2 breaks that assumption on purpose: registration (#66) opens voting to an unbounded set of third parties who were never in contact with the integrator. For a stranger to rationally lock capital on one side of a dispute, they need to know what they’re actually evaluating, and they need assurance that what they’re shown is the same thing every other voter is shown, not a claim description that quietly changed after some voters already committed. V2_RESOLUTION.md’s own threat table already names this gap explicitly: an open electorate needs an unambiguous immutable reference to the proposition, and flagged it as a decision deferred to a later issue. This is that issue.
Decision summary
| Question | Proposed answer |
|---|---|
| On-chain identifier or off-chain registry only? | A mandatory on-chain content hash. An off-chain-only registry (v1’s model) doesn’t give third-party voters any way to verify they’re all looking at the same claim. |
| What does the hash commit to? | The canonical encoding of an off-chain claim document (format is the integrator’s choice: prose, structured JSON, whatever fits their domain). The contract never inspects the content, only stores and exposes the hash. |
| Format | BytesN<32>, same shape as PolicySnapshotV2.policy_hash already uses. Reuses an established, already-tested pattern in this crate rather than inventing a new one. |
| Discoverability | An optional URI string alongside the hash, best-effort only, not verified by the contract. The hash is authoritative; the URI is a convenience pointer to where the matching content currently lives. |
| Structured on-chain schema? | No. Forcing claims into a rigid on-chain schema doesn’t scale across integrator domains and turns Tholos from a generic primitive into an opinionated claims format. The off-chain document the hash commits to can be as structured as the integrator needs. |
Validation at assert_outcome time | Reject an all-zero hash (a sentinel for “no claim specified”); every assertion must commit to something. The contract cannot and does not verify the hash corresponds to real, fetchable content, that’s unverifiable on-chain by construction and is a client-side/voter-side concern. |
| Evidence convention | Stays event-only, not stored in persistent state. dispute and register (once retrofitted; see below) gain an optional evidence hint included in their events, so supporting arguments are indexable without contract storage bloat. Not required, never validated. |
Reasoning
Why a hash, not a plain URI. A URI is a pointer that can rot or be
silently edited after the fact: nothing stops content at a URL from
changing between when an early voter reads it and when a late voter does. A
content hash pins the exact bytes at assertion-creation time; anyone can
fetch the claim from anywhere, it doesn’t matter if it’s IPFS, a integrator’s
own server, or a GitHub gist, and confirm locally that it hashes to what’s
on-chain. This is the same content-addressing pattern IPFS CIDs use, and
it’s already precedented in this exact crate: PolicySnapshotV2.policy_hash
already does this for deployment parameters. Reusing that pattern here means
no new cryptographic primitive, no new audit surface beyond what’s already
been reviewed for policy_hash.
Why not a structured on-chain schema. A freelance milestone claim, an insurance claim, and a sports-result claim have nothing structurally in common beyond “here is a proposition, and a bond backs an assertion about it.” Encoding that variety on-chain would mean either a schema so generic it carries no real validation value, or a schema so specific it only fits one integrator’s domain. The hash approach pushes structure to where it belongs: the integrator’s own off-chain document, in whatever shape fits their use case, while the contract’s only job is pinning it immutably.
Why the contract can’t validate the hash’s content. This is a hard
limit, not a design choice: a Soroban contract has no network access and
cannot fetch arbitrary off-chain data. The only thing assert_outcome can
meaningfully check is that a hash was actually supplied (non-zero), not that
it corresponds to real, comprehensible content. A voter’s own client is
responsible for fetching the claimed content, hashing it, and confirming the
match before a human decides how to vote, the same way a browser
verifying a subresource-integrity hash doesn’t guarantee the content makes
sense, only that it’s the content the page author committed to.
Why evidence stays off-chain and event-only. Evidence (why a disputer thinks an assertion is wrong, why a voter revealed the way they did) is inherently unstructured, arbitrarily sized human argumentation. Storing it in persistent contract state would be expensive and unbounded in a way the claim hash isn’t (one hash per assertion is a fixed, small cost; evidence could be arbitrarily long and arbitrarily frequent). Events are the right fit: cheap, already indexed by the events every v2 write emits (#72’s TTL issue already requires this event discipline for a different reason), and naturally queryable by an off-chain indexer without bloating storage that has to carry a TTL.
What changes in already-merged code
AssertionV2 (#64) needs one new field: claim_hash: BytesN<32>, supplied
as a new assert_outcome (#65) parameter and validated non-zero. An
optional claim_uri: Option<...> could ride alongside it in the Asserted
event only (not stored, matching the evidence convention above), since a
String/Bytes type wrapped in Option is a built-in type, not a custom
enum, so it doesn’t hit the Option<EnumType> limitation #64 and #66 already
documented.
dispute and register (#66) each gain an optional evidence hint parameter,
included in their existing Disputed / PositionFunded events, not in the
Position or Resolution records themselves.
None of this is implemented by this issue. A follow-up implementation issue, opened once this design is reviewed and accepted, makes these changes against the already-merged #64/#65/#66 code, the same way #64’s own “Future implementation work” list became #64-#71 after V2_RESOLUTION.md was accepted.
Alternatives considered
| Alternative | Why it is not recommended |
|---|---|
| Off-chain registry only (v1’s model), no on-chain anchor | Doesn’t give third-party voters any way to confirm they’re all evaluating the same claim; directly contradicts the reason this issue exists. |
| Plain URI, no hash | A URI can change or disappear after voters have already committed capital based on what it showed at the time. |
| Full structured on-chain claim schema | Doesn’t generalize across integrator domains; turns a generic primitive into an opinionated format; meaningfully larger audit and storage surface for no integrity benefit over a hash. |
| Contract-side content validation | Not possible: Soroban contracts have no network access to fetch and check off-chain content. |
| Evidence stored in persistent state | Unbounded, arbitrarily-sized human text is a poor fit for storage that carries a TTL and a per-byte cost; events already solve the indexability need without that cost. |
Resolved questions
claim_uriis capped to a bounded length, so event payload size can’t grow unbounded even though the URI’s content is never verified. The exact byte limit is an implementation-issue detail, not locked in here.- The evidence hint on
dispute/registeris a plain URI, not a hash. Evidence is inherently supplementary and informal, not something a voter is trusting the way they trust the claim itself, so the stronger tamper-evidence guarantee a hash would give isn’t worth the added complexity here.
The implementation issue for this design can proceed against the “What changes in already-merged code” section above.
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 split between asserter and finalizer)
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 |
final_outcome | Option<bool> | The authoritative resolved outcome; None until the assertion reaches Resolved |
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. |
finalizer | Option<Address> | Who called finalize, if the assertion was finalized (not resolved via resolve). None until finalize is called; always Some(caller) after — the caller must authorize unconditionally, so this is always a verified address once set. |
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 committee snapshotted for this dispute |
AlreadyVoted | Resolver already voted on this assertion |
Paused | Called assert_outcome, dispute, resolve, or finalize while paused |
InvalidBondAmount | bond_amount is zero, negative, or greater than MAX_BOND_AMOUNT |
InvalidChallengeWindow | challenge_window_secs is zero or greater than 7 days |
TooManyResolvers | Resolver list has more than MAX_RESOLVERS (21) entries |
InvalidFinalizeReward | finalize_reward_bps is greater than MAX_FINALIZE_REWARD_BPS (1000) |
DuplicateResolvers | Resolver list contains the same address more than once |
RotationInProgress | A rotation proposal is already open; only one may be open at a time |
NoRotationProposal | No open rotation proposal to vote on or cancel |
ResolverNotInCommittee | The old_resolver named for removal isn’t a current resolver |
RotationTargetAlreadyResolver | The new_resolver named for addition is already on the committee (or equals old_resolver) |
NotProposer | Caller isn’t the proposer and the proposal can still reach a majority, so can’t cancel it |
Functions
initialize(admin, token, bond_amount, challenge_window_secs, resolvers, finalize_reward_bps)
One-time setup. resolvers must have an odd, non-zero length, and at most
MAX_RESOLVERS (21), with no duplicate addresses, so a majority vote can never
tie and no single dispute
snapshot grows unbounded. bond_amount must be positive and no greater than
MAX_BOND_AMOUNT — the largest bond that can’t overflow the token balance or
finalize’s reward-multiply arithmetic — and challenge_window_secs
must be non-zero and at most 7 days (see “Persistent storage TTL” below for why).
finalize_reward_bps sets the fraction of the bond (in basis points, 0–1000) paid
to whoever calls finalize as an incentive for prompt finalization; 0 disables the
reward entirely and the full bond is returned to the asserter.
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 and MAX_RESOLVERS cap 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.
This is the emergency override path. It supersedes any in-flight self-rotation vote:
an open RotationProposal is cleared (emitting RotationCancelled when one was
present), so a committee-driven rotation can never execute against a committee it
wasn’t built for. Day-to-day committee changes go through propose_rotation /
vote_rotation instead.
propose_rotation(resolver, old_resolver, new_resolver)
Proposes a single-slot committee rotation: remove old_resolver (must be a current
resolver) and add new_resolver (must not already be one). Only a current resolver
may propose, and only one rotation may be open at a time. old_resolver must be on
the committee; new_resolver must not be (and not equal old_resolver). Emits
RotationProposed. Pause-exempt, like update_resolvers.
The proposal is decided by a strict majority of the live committee (the same
len / 2 + 1 threshold used to resolve disputes) via vote_rotation. On execution
it writes the same Resolvers slot update_resolvers writes, so it has no effect on
disputes already open: their committee was snapshotted at dispute time. See
docs/src/ROTATION_DESIGN.md.
vote_rotation(resolver, approve) -> Option<bool>
A resolver votes on the open rotation proposal. approve records a yes or no (both
prevent re-voting). Once yes-votes reach a strict majority of the live committee, the
rotation executes immediately: old_resolver is swapped for new_resolver in the
live committee, the proposal is cleared, RotationExecuted and ResolversUpdated
are emitted, and the function returns Some(true). If the remaining unvoted
resolvers can no longer supply enough yes-votes to reach a majority, the proposal is
cancelled automatically (deadlock guard), RotationCancelled is emitted, and the
function returns Some(false). Otherwise the vote is recorded and the proposal stays
open, returning None. Fails with NoRotationProposal, NotAResolver, or
AlreadyVoted as appropriate. Pause-exempt.
cancel_rotation(resolver)
Cancels the open rotation proposal. The proposer may cancel at any time. Any current
resolver may also cancel once the proposal can no longer reach a majority (deadlock
guard), so a lost proposer key can’t permanently block rotation. Emits
RotationCancelled. Fails with NoRotationProposal, NotAResolver, or
NotProposer as appropriate.
set_paused(paused)
Pauses or unpauses assert_outcome, dispute, resolve, and finalize. Requires
the stored admin’s signature. finalize is blocked alongside dispute, not
exempted: a pending assertion may have had no real opportunity to be disputed
during a challenge window that overlapped a pause, so it must not finalize
uncontested until unpaused, it becomes callable again once the contract is
unpaused. update_resolvers is exempt, so a compromised live committee can be
replaced for future disputes without unpausing first; an already disputed
assertion keeps its snapshot. Emits PauseUpdated.
Pause is an incident-control tool, not an atomic retirement gate: it can delay a legitimate uncontested claim from finalizing for as long as the pause lasts.
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(caller, id) -> bool
Callable once a Pending assertion’s challenge window has elapsed with no dispute.
Fails with Paused if paused. caller must authorize the call unconditionally —
regardless of whether finalize_reward_bps is zero — so the address recorded in
Assertion.finalizer and the Finalized event is always a verified caller and
cannot be spoofed. This
applies even when no reward is being paid: without enforced auth, any address could
be passed as caller, permanently writing an unverifiable identity into the
on-chain record.
- When
finalize_reward_bpsis non-zero,calleralso receivesbond * finalize_reward_bps / 10_000tokens as an incentive for prompt finalization; the asserter receives the remainder. - When
finalize_reward_bpsis zero (the default), no reward is paid and the full bond is returned to the asserter. Auth is still required.
In both cases Assertion.finalizer is set to Some(caller).
Returns the asserted outcome. Fails with ChallengeWindowOpen if called too early. Emits Finalized with finalizer (Address) and reward fields.
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. All four functions have a regression test in
contracts/tholos/src/test.rs (test_*_is_not_reentrant) that exercises this
directly against a token built to attempt exactly that reentrant call.
finalize requires caller.require_auth() unconditionally — regardless of whether
finalize_reward_bps is zero. Without this, a zero-bps deployment would accept any
address as caller with no authorization, permanently writing an unverifiable
identity into Assertion.finalizer and the Finalized event as the “finalizer of
record.” No funds are at risk (the caller only ever receives its own reward), but the
audit trail would be spoofable. Requiring auth unconditionally ensures the recorded
finalizer is always a verified address. Soroban’s auth model also independently
rejects a reentrant token’s nested require_auth, giving finalize the same
first-layer reentrancy protection as assert_outcome, dispute, and resolve.
The state-before-transfer ordering is a second layer of defense in both cases.
Persistent storage TTL
Every write to an assertion’s persistent storage entry (in assert_outcome,
dispute, finalize, and resolve) extends its TTL by 30 days
(ASSERTION_BUMP_AMOUNT), via the shared set_assertion helper. This is why
challenge_window_secs is capped at 7 days: it leaves comfortable headroom within
that 30-day bump for the window to elapse and for finalize, dispute, or a
resolver’s resolve to actually be called afterward, without the ledger entry
being archived first. contracts/tholos/src/test.rs::test_assertion_storage_ttl_is_extended_on_every_write
verifies the TTL is actually extended on write, not just claimed in a comment.
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, finalizer (Address), reward |
Resolved | resolve, once a majority is reached | id, outcome |
ResolversUpdated | update_resolvers, vote_rotation (on execution) | resolvers (the new committee) |
PauseUpdated | set_paused | paused |
RotationProposed | propose_rotation | old_resolver, new_resolver, proposed_by |
RotationExecuted | vote_rotation, once a majority is reached | old_resolver, new_resolver |
RotationCancelled | vote_rotation (deadlock auto-cancel), cancel_rotation, update_resolvers (admin override) | old_resolver, new_resolver |
Finalized.finalizer is always the address that called finalize — auth is required unconditionally, so this value is always verified regardless of whether finalize_reward_bps is non-zero. Finalized.reward is the number of tokens paid to that address (0 when finalize_reward_bps is 0).
Example: calling it with the Stellar CLI
Deploy, initialize with a 3-member resolver committee and a 1 % finalize reward,
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\"]" \
--finalize_reward_bps 100
stellar contract invoke --id "$CONTRACT" --source asserter --network testnet -- assert_outcome \
--asserter "$ASSERTER_ADDRESS" \
--outcome true
# After the challenge window elapses.
# Auth is required unconditionally: pass the caller's address and sign.
stellar contract invoke --id "$CONTRACT" --source finalizer --network testnet -- finalize \
--caller "$FINALIZER_ADDRESS" \
--id 0
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_pausedis still a single-admin-key operation.update_resolversis too, but it’s now an emergency override: a resolver self-rotation scheme (propose_rotation/vote_rotation/cancel_rotation) lets the committee vote to replace one of its own by a strict majority, removing the admin as the only path to committee membership.update_resolversstays as the break-glass for a compromised or deadlocked committee. Seedocs/src/ROTATION_DESIGN.md.
Contract interface (v2)
Reference for contracts/tholos-v2. Source of truth is
contracts/tholos-v2/src/lib.rs; this document should be updated alongside
any change to the public interface. See CONTRACT.md for v1 (the
fixed-committee-vote contract) and docs/src/V2_RESOLUTION.md for the design
rationale behind the stake-weighted scheme documented here.
Lifecycle
Note
This section covers the internal state machine. For a higher-level view of how clients interact with this lifecycle, see Lifecycle at a glance in the integration guide.
stateDiagram-v2
[*] --> Pending: assert_outcome
Pending --> Resolved: finalize<br/>(challenge window elapsed,<br/>uncontested)
Pending --> Registration: dispute
Registration --> Reveal: register deadline passes<br/>(lazily, on next reveal/resolve_outcome)
Reveal --> Resolved: reveal or resolve_outcome<br/>(last outstanding weight revealed,<br/>or deadline reached)
Resolved --> [*]
Every assertion ends in Resolved, reached one of three ways: uncontested
(finalize after challenge_window_secs with no dispute), a strict majority
of revealed weight locking in favor of one side, or the optimistic default
(AssertedOutcomeStands) applying because neither side reached a strict
majority by the time reveal closes, whether that’s the reveal deadline or,
if every eligible position revealed early with a split tally, before it. A
Registration- or Reveal-phase
assertion can also be short-circuited to Resolved by cancel_round (admin,
emergency-only).
PhaseV2::Reveal covers two states that look identical in phase but differ
in terminal_cause: a majority can lock in (terminal_cause set) while
phase stays Reveal, so other positions can keep revealing to prove
entitlement for settlement. Always read terminal_cause, not phase, to
learn whether the outcome itself is decided; phase == Resolved only tells
you settlement can begin.
Types
PhaseV2
Pending, Registration, Reveal, or Resolved.
TerminalCause
Why an assertion reached its decided outcome. NotYetDecided stands in for
None — the contracttype derive used here doesn’t support Option of a
custom enum, only of built-in types like Address/bool.
| Variant | Meaning |
|---|---|
NotYetDecided | The outcome hasn’t been decided yet. |
UncontestedFinalize | Never disputed within challenge_window_secs; closed via finalize. The only terminal cause that never goes through registration/reveal. |
StrictMajorityFor | Revealed weight agreeing with the asserted outcome exceeded half of the frozen eligible total W. |
StrictMajorityAgainst | Revealed weight disagreeing exceeded half of W. |
OptimisticTimeout | Neither side reached a strict majority before reveal closed; the originally asserted outcome stands by default. |
AdminCancelled | Set only by cancel_round, on an assertion with no terminal cause yet (Pending, Registration, or Reveal phase). The asserter’s bond is refunded (if Pending) or every funded position recovers its exact principal, with no forfeiture or reward. |
WeightRuleVersion, TimeoutDefaultRule, PayoutRuleVersion
Version markers pinned into every PolicySnapshotV2, not formulas, so a
future rule can be introduced without reinterpreting already-open assertions
under new math. Today each has exactly one variant: LinearStakeV1
(weight(address) = locked_bond(address)), AssertedOutcomeStands, and
ProRataV1 respectively.
PositionKind
What kind of position an address holds, and (for a fixed one) which side it’s on:
| Variant | Meaning |
|---|---|
Fixed(bool) | The asserter’s or disputer’s position, created by dispute. true if it agrees with the asserted outcome; already public, never hidden. |
External(BytesN<32>) | A third party’s position, created by register. Holds the salted commitment hash to the eventual side, verified by reveal. |
Position
One address’s stake on one dispute ((assertion_id, address)-keyed).
Non-transferable; once funded, only exits through settlement.
| Field | Type | Meaning |
|---|---|---|
amount | i128 | Total bonded, including any top-ups via repeated register calls. |
kind | PositionKind | Fixed (asserter/disputer) or External (third party). |
revealed | bool | Whether this position’s weight has been counted into Resolution.agree_weight/disagree_weight. Set automatically for Fixed positions when reveal opens; set by reveal for External ones. |
agrees_with_outcome | Option<bool> | Which side this position landed on. None until revealed is true. |
settled | bool | Whether settle has already run for this position. |
Resolution
Registration- and reveal-phase bookkeeping for one disputed assertion,
separate from AssertionV2: AssertionV2 is claim/parties/policy,
Resolution is the mutable per-dispute state. Only exists once dispute
has been called (get_resolution returns AssertionNotFound before that).
| Field | Type | Meaning |
|---|---|---|
registration_opened_at | u64 | Ledger timestamp dispute was called. |
registration_deadline | u64 | The soft cutoff; pushed out by anti_snipe_extension_secs on a qualifying late deposit, capped at registration_hard_deadline. |
registration_hard_deadline | u64 | Fixed at dispute time (registration_opened_at + anti_snipe_hard_max_secs); no sequence of extensions can push registration_deadline past this. |
eligible_total | i128 | The frozen-at-reveal-cutoff eligible total W, maintained incrementally as deposits arrive. |
reveal_opened_at | u64 | 0 until the lazy Registration -> Reveal transition, then that transition’s timestamp. |
reveal_deadline | u64 | 0 until reveal opens, then reveal_opened_at + reveal_duration_secs. |
agree_weight | i128 | Weight revealed agreeing with the asserted outcome, including the asserter’s fixed position. |
disagree_weight | i128 | Weight revealed against it, including the disputer’s fixed position. |
settled_recipient_weight | i128 | Cumulative weight of recipient (reward-eligible) positions already settled; used to detect the last settlement. |
settled_reward_total | i128 | Cumulative reward (principal excluded) already distributed; used to compute leftover dust on the last settlement. |
outstanding_liability | i128 | Total credit accrued (via settle) but not yet withdrawn. outstanding_liability + withdrawn_total never exceeds eligible_total. |
withdrawn_total | i128 | Cumulative amount actually transferred out via withdraw. |
Resolution::revealed_weight() returns agree_weight + disagree_weight
(not stored separately, always derived).
VoteCommitmentPreimage
The exact preimage reveal hashes and compares against a position’s stored
commitment: H(canonical_encode("THOLOS_V2_VOTE", network_id, contract_address, policy_hash, assertion_id, round, voter, choice, salt_32)), encoded via ToXdr rather than hand-rolled concatenation so the
domain separation is unambiguous by construction. pub so
tools/compute-commitment can build one off-chain the same way reveal
verifies one; this is a Rust visibility detail, not part of the on-chain
interface.
PolicySnapshotV2
Pinned in full onto every AssertionV2 at creation, never mutated
afterward. A deployment-wide parameter change (were one ever added) would
only affect assertions created after the change.
| Field | Type | Meaning |
|---|---|---|
token | Address | The bonding token. |
base_bond | i128 | Bond every fixed party (asserter, disputer) posts. |
challenge_window_secs | u64 | How long a Pending assertion can be disputed before it’s eligible for uncontested finalize. |
finalize_reward_bps | u32 | Basis points (0–1000) of the bond paid to whoever calls finalize on an uncontested assertion. |
min_resolution_bond | i128 | Minimum first-time register deposit. Always equal to base_bond, so a third party can’t break a tie for less than the original parties risked. |
registration_duration_secs | u64 | Base length of the registration window. |
anti_snipe_extension_secs | u64 | How far a qualifying late deposit pushes the soft registration deadline out. |
anti_snipe_hard_max_secs | u64 | Absolute cap on the registration window, from registration_opened_at. |
reveal_duration_secs | u64 | Length of the reveal window once it opens. |
weight_rule | WeightRuleVersion | Always LinearStakeV1 today. |
timeout_default | TimeoutDefaultRule | Always AssertedOutcomeStands today. |
payout_rule | PayoutRuleVersion | Always ProRataV1 today. |
max_position | i128 | Upper bound on any single position’s size, so settlement arithmetic can’t overflow. |
max_total_weight | i128 | Upper bound on the frozen eligible total W, for the same reason. |
AssertionV2
| Field | Type | Meaning |
|---|---|---|
id | u64 | Assertion id, unique within this deployment only (see INTEGRATION.md’s “Assertion identity changes”). |
asserter | Address | Who posted the claim. |
opened_at | u64 | Ledger timestamp assert_outcome posted this assertion. |
disputer | Option<Address> | Set once dispute opens registration; None while Pending. |
outcome | bool | The originally claimed outcome. |
phase | PhaseV2 | Current lifecycle phase. |
policy | PolicySnapshotV2 | The policy this assertion is pinned to. |
policy_hash | BytesN<32> | Hash of policy’s canonical encoding, so a client can confirm which exact policy an assertion is bound to. |
terminal_cause | TerminalCause | NotYetDecided until locked. Can lock before phase reaches Resolved; read this field, not phase, to know whether the outcome is decided. |
final_outcome | Option<bool> | The authoritative resolved outcome. None until terminal_cause is decided. Stored directly (unlike v1’s Assertion.outcome, which always keeps the original claim even after a dispute overturns it). |
finalizer | Option<Address> | Who called finalize. None until finalized. |
Error
| Variant | Meaning |
|---|---|
AlreadyInitialized | initialize called on a contract that’s already set up. |
NotInitialized | Called before initialize. |
AssertionNotFound | No assertion (or, depending on call, resolution/position) exists for the given id/address. |
InvalidBondAmount | base_bond isn’t positive, or exceeds MAX_BOND_AMOUNT. |
InvalidRegistrationDuration | registration_duration_secs is zero or exceeds 7 days. |
InvalidRevealDuration | reveal_duration_secs is zero or exceeds 7 days. |
InvalidAntiSnipeParams | anti_snipe_extension_secs exceeds anti_snipe_hard_max_secs, anti_snipe_hard_max_secs is shorter than registration_duration_secs, or anti_snipe_hard_max_secs exceeds MAX_ANTI_SNIPE_HARD_MAX_SECS (29 days). |
InvalidMaxPosition | max_position isn’t positive, or exceeds max_total_weight. |
InvalidMaxTotalWeight | max_total_weight isn’t positive, or exceeds MAX_SETTLEMENT_TOTAL_WEIGHT. |
InvalidChallengeWindow | challenge_window_secs is zero or exceeds 7 days. |
InvalidFinalizeReward | finalize_reward_bps exceeds MAX_FINALIZE_REWARD_BPS (1000). |
NotPending | Action requires PhaseV2::Pending but the assertion isn’t. |
ChallengeWindowOpen | finalize called before challenge_window_secs has elapsed since opened_at. |
DisputerIsAsserter | The disputer address passed to dispute matches the assertion’s own asserter. |
NotRegistration | Action requires PhaseV2::Registration but the assertion isn’t. |
CannotRegisterAsFixedParty | register called by the assertion’s own asserter or disputer; they already have fixed positions from dispute. |
InvalidPositionAmount | register’s amount isn’t positive. |
BelowMinimumResolutionBond | A first-time register deposit is below policy.min_resolution_bond. |
PositionExceedsMax | A position’s total after this deposit would exceed policy.max_position. |
EligibleTotalExceedsMax | The eligible total W after this deposit would exceed policy.max_total_weight. |
CommitmentMismatch | A top-up’s commitment doesn’t match the one this position was created with. |
RegistrationClosed | register called after registration_deadline has passed. |
RegistrationNotClosed | reveal or resolve_outcome called while still Registration, before registration_deadline has passed. |
NotReveal | reveal or resolve_outcome called on an assertion that’s Pending (or, for reveal, Resolved); it must be Registration (past deadline) or Reveal. |
RevealClosed | reveal called after reveal_deadline has passed. |
AlreadyRevealed | This position’s weight is already counted — a prior reveal call, reveal opening for a Fixed position, or a Fixed voter calling reveal themselves (nothing to reveal). |
CommitmentVerificationFailed | The supplied (choice, salt) didn’t hash to the stored commitment. |
RevealNotClosed | resolve_outcome called while still Reveal, before reveal_deadline and before all eligible weight has revealed. |
NotResolved | settle called before phase == Resolved, or on an UncontestedFinalize assertion (which never had a Resolution/Position created). |
AlreadySettled | settle called on a position that’s already settled. |
SettlementArithmeticOverflow | A checked arithmetic operation in settle/withdraw/add_credit would have overflowed i128. Not expected to be reachable given initialize’s bounds, but checked since settlement moves real funds. |
NoCreditToWithdraw | withdraw called with a 0 credit balance (never settled anything here, or already withdrew it). |
ReentrancyGuardActive | A call that moves tokens (or otherwise acts on funds-adjacent state) was attempted while another was still mid-flight. |
Paused | assert_outcome called while set_paused_v2 has paused new assertions. |
NotPaused | cancel_round called while not paused. |
RoundAlreadyDecided | cancel_round called on an assertion whose terminal_cause is already set, by a real outcome or an earlier cancellation. |
Functions
initialize(admin, token, base_bond, challenge_window_secs, finalize_reward_bps, registration_duration_secs, anti_snipe_extension_secs, anti_snipe_hard_max_secs, reveal_duration_secs, max_position, max_total_weight)
One-time setup, pinning the deployment-wide defaults every future
assertion’s PolicySnapshotV2 is built from. Requires admin’s signature.
base_bond must be positive and no greater than MAX_BOND_AMOUNT (so
finalize’s reward-multiply can’t overflow). challenge_window_secs and
reveal_duration_secs/registration_duration_secs must each be non-zero
and at most 7 days. finalize_reward_bps must be at most 1000.
anti_snipe_extension_secs must not exceed anti_snipe_hard_max_secs, and
anti_snipe_hard_max_secs must be at least registration_duration_secs.
max_total_weight must be positive and no greater than
MAX_SETTLEMENT_TOTAL_WEIGHT (so settlement’s forfeiture-distribution
multiply can’t overflow); max_position must be positive and no greater
than max_total_weight. min_resolution_bond is always set equal to
base_bond. Fails with AlreadyInitialized if called twice, or the
matching Invalid* error for any out-of-range parameter.
get_policy() -> PolicySnapshotV2
Read-only lookup of the deployment-wide policy defaults new assertions are
currently pinned from. Fails with NotInitialized before initialize.
set_paused_v2(paused)
Blocks or unblocks new assert_outcome calls. Requires the stored admin’s
signature. Narrower than v1’s set_paused: an already-active round’s
registration, reveal, resolve_outcome, settle, and withdraw all
continue normally even while paused, since blocking them would strand
capital already locked into that round rather than protect it.
cancel_round is the mechanism for protecting an already-active round
instead. Emits PauseUpdated.
get_assertion(id) -> AssertionV2
Read-only lookup. Fails with AssertionNotFound if the id doesn’t exist.
get_resolution(id) -> Resolution
Read-only lookup of one assertion’s registration/reveal bookkeeping. Fails
with AssertionNotFound if it doesn’t exist — a Resolution is only
created by dispute, so an uncontested (UncontestedFinalize) or still-
Pending assertion never has one.
get_position(id, address) -> Position
Read-only lookup of one address’s position on one assertion. Fails with
AssertionNotFound if that address has no position there.
get_credit(id, address) -> i128
Read-only lookup of one address’s withdrawable credit balance on one
assertion, accrued so far by settle. Returns 0 for an address with no
credit record rather than failing — unlike get_position, “never settled
anything here” isn’t a caller error worth surfacing as one.
assert_outcome(asserter, outcome) -> u64
Posts a bonded claim, the optimistic first stage before any dispute exists.
Transfers policy.base_bond from asserter to the contract. Requires
asserter’s signature. Fails with Paused if set_paused_v2 has paused
new assertions. Returns the new assertion id. Emits Asserted.
finalize(caller, id) -> bool
Callable once a Pending assertion’s challenge_window_secs has elapsed
with no dispute. caller must authorize the call unconditionally, even
when finalize_reward_bps is 0, the same hardening v1 applies, so
AssertionV2.finalizer and the Finalized event can never be spoofed
regardless of whether a reward is paid.
- When
finalize_reward_bpsis non-zero,callerreceivesbase_bond * finalize_reward_bps / 10_000tokens and the asserter receives the remainder. - When
finalize_reward_bpsis zero, the full bond returns to the asserter. Auth is still required.
Sets phase = Resolved and terminal_cause = UncontestedFinalize. Returns
the asserted outcome. Fails with AssertionNotFound, NotPending if the
assertion isn’t Pending, or ChallengeWindowOpen if called too early.
Emits Finalized.
dispute(disputer, id)
Disputes a Pending assertion, opening the registration phase. Transfers
base_bond from disputer into escrow, matching the asserter’s existing
bond, and creates both parties’ Fixed positions plus the Resolution
record (eligible_total starts at 2 * base_bond). Requires disputer’s
signature. Fails with AssertionNotFound, NotPending if the assertion
isn’t Pending, or DisputerIsAsserter if disputer equals the
assertion’s own asserter. Emits Disputed with the initial
registration_deadline.
register(voter, id, amount, commitment)
Funds (or tops up) a third-party position on a Registration-phase
assertion, committing to a side without revealing it. Not callable by the
assertion’s own asserter or disputer (CannotRegisterAsFixedParty) — they
already have fixed positions from dispute.
A first-time deposit must be at least policy.min_resolution_bond
(BelowMinimumResolutionBond otherwise). A top-up (same voter, same
assertion) aggregates into the existing position and must reuse its
original commitment (CommitmentMismatch otherwise) — a position’s
committed side can never change after funding. Rejects atomically, with no
position or weight created, if the resulting position size would exceed
policy.max_position (PositionExceedsMax) or the eligible total would
exceed policy.max_total_weight (EligibleTotalExceedsMax).
A qualifying deposit (landing within anti_snipe_extension_secs of the
current deadline) pushes registration_deadline out by
anti_snipe_extension_secs, capped at registration_hard_deadline.
Requires voter’s signature. Fails with AssertionNotFound,
NotRegistration if the assertion isn’t in the registration phase,
RegistrationClosed if registration_deadline has passed, or
InvalidPositionAmount if amount isn’t positive. Emits PositionFunded.
reveal(voter, id, choice, salt)
Discloses the side an External position committed to during registration,
and verifies it against the stored commitment. Requires voter’s
signature.
Lazily transitions the assertion from Registration to Reveal if called
after registration_deadline has passed; fails with
RegistrationNotClosed if called too early instead. On success, adds this
position’s full weight to Resolution.agree_weight (if choice matches
the asserted outcome) or disagree_weight otherwise, and locks
terminal_cause/final_outcome if that tips either side past a strict
majority. The assertion stays Reveal even after locking so other
positions can keep revealing to prove entitlement for settlement — unless
this reveal was the last outstanding weight, in which case the assertion
closes to Resolved in this same call.
A client must read the on-chain phase before submitting a reveal: a
rejected reveal transaction still publishes its (choice, salt) preimage
on-chain even though it failed, and a qualifying late deposit may have
extended the deadline.
Fails with AssertionNotFound, RegistrationNotClosed if called too early,
NotReveal if the assertion is Pending or Resolved, RevealClosed if
reveal_deadline has passed, AlreadyRevealed if this position’s weight
is already counted (including a Fixed voter, who has nothing to reveal), or
CommitmentVerificationFailed if (choice, salt) doesn’t hash to the
stored commitment. Emits Revealed, and RevealOpened/Resolved if those
transitions happen in the same call.
resolve_outcome(id) -> TerminalCause
Permissionlessly closes a disputed assertion out to Resolved once its
outcome can no longer change — most importantly when reveal_deadline
passes without every eligible weight revealing, and the degenerate case
where a dispute drew no third-party registrations at all, so nobody ever
has a position to call reveal with. Requires no signature: it only
applies a deterministic rule to already-committed weights and elapsed
time, and moves no funds.
Lazily transitions Registration to Reveal first if
registration_deadline has passed; that step alone may already close the
assertion out. Otherwise requires Reveal phase; if reveal_deadline has
passed or revealed_weight has caught up with the frozen eligible total
W, locks the outcome (strict majority if reached, OptimisticTimeout
otherwise) and moves the assertion to Resolved. Idempotent: calling it
again on an already-Resolved assertion just returns the already-decided
terminal_cause.
Fails with AssertionNotFound, NotReveal if the assertion is Pending,
RegistrationNotClosed if still Registration before its deadline, or
RevealNotClosed if still Reveal before its deadline with unrevealed
weight remaining. Emits RevealOpened and/or Resolved as those
transitions actually happen.
settle(id, address) -> i128
Converts one position’s share of a decided outcome into withdrawable
credit. Permissionless: any caller may settle any known position; settling
doesn’t move tokens itself (withdraw is the separate step that transfers
tokens against the accrued balance).
Requires phase == Resolved (NotResolved otherwise — including for an
UncontestedFinalize assertion, which never had a Resolution/Position
created). Fails with AlreadySettled if address’s position has already
settled.
A recipient position (per the assertion’s terminal_cause: the winning side
for a strict majority, or any revealed position on either side for an
optimistic timeout) recovers its principal plus a pro-rata share of the
forfeited pool: reward = floor(amount * forfeited_pool / recipient_weight).
A losing position, or an unrevealed position under any terminal cause,
recovers nothing. Whichever settlement brings the
recipient side’s settled weight up to its full total (the last recipient
position left to settle) also routes any leftover floor-division dust to a
deterministic recipient (the winning asserter or disputer after a
strict-majority result, or the asserter after a timeout default), emitting
DustCredited alongside.
Returns this position’s own payout (principal plus reward, or 0 if
forfeited; never includes dust routed to a different address in the same
call). Fails with AssertionNotFound, NotResolved, AlreadySettled, or
SettlementArithmeticOverflow. Emits Settled, and DustCredited when
this call happens to close out the recipient side.
withdraw(owner, id, destination) -> i128
Transfers owner’s entire withdrawable credit balance on one assertion to
destination. Requires owner’s authorization. destination may be any
address, not necessarily owner itself — a token that rejects transfers to
owner directly can’t permanently strand funds there. Fails with
AssertionNotFound, NoCreditToWithdraw if the balance is 0 (never
settled anything here, or already withdrew it), or
SettlementArithmeticOverflow. Returns the amount
withdrawn. Emits Withdrawn.
cancel_round(id)
Cancels an active round before any terminal outcome has locked, refunding
every already-funded position its exact principal, no forfeiture, no
reward, as if the round never happened. Only callable by the admin set at
initialize, and only while paused (NotPaused otherwise) — cancellation
is an emergency measure, requiring a pause first so it can never happen as
a surprise mid-transaction.
Fails outright, rather than treating it as a no-op, with
RoundAlreadyDecided if terminal_cause is already set, whether by a real
outcome or an earlier cancellation. A still-Pending assertion has no
Resolution/Position records yet, so its single asserter bond is
refunded directly here; a Registration- or Reveal-phase assertion’s
positions instead recover their principal through the normal
settle/withdraw path afterward (cancel_round sets
terminal_cause = AdminCancelled, under which every funded position is a
recipient of a zero forfeited pool).
Fails with AssertionNotFound, NotInitialized, NotPaused, or
RoundAlreadyDecided. Emits RoundCancelled, distinct from Resolved (a
real outcome’s event), so indexers can always tell the two apart.
Security notes
assert_outcome, finalize, dispute, and register each write their
state change to storage before calling the external token contract’s
transfer, the same checks-effects-interactions ordering v1 uses:
cross-contract calls in Soroban are synchronous, so a non-standard or
malicious token contract could otherwise call back into the contract
mid-transfer and observe stale state.
Beyond that state-before-transfer ordering, every function that moves
tokens (assert_outcome, finalize, dispute, register, withdraw, and
cancel_round when it refunds a still-Pending assertion’s bond directly)
also holds a contract-wide reentrancy mutex (ReentrancyGuard) for the
duration of the transfer, via enter_reentrancy_guard/
exit_reentrancy_guard. reveal, resolve_outcome, and settle never
move tokens themselves, and cancel_round doesn’t either outside the
Pending case, but all three still check the guard at
entry (check_reentrancy_guard), since all four can act on a position’s
weight, credit, or terminal state — state the guard exists specifically to
keep provisional until its funding transfer actually completes. A call
attempted while the guard is already held fails with
ReentrancyGuardActive. test_reentrancy_guard_blocks_calls_while_held in
contracts/tholos-v2/src/test.rs exercises this directly.
finalize requires caller.require_auth() unconditionally, regardless of
whether finalize_reward_bps is zero — the same reasoning as v1’s
finalize: without it, a zero-bps deployment would accept any address as
caller with no authorization, permanently writing an unverifiable
identity into AssertionV2.finalizer and the Finalized event. No funds
are at risk in that case, but the audit trail would be spoofable.
Settlement arithmetic (settle, withdraw, add_credit) uses checked
i128 operations throughout rather than assuming initialize’s bounds
(MAX_BOND_AMOUNT, MAX_SETTLEMENT_TOTAL_WEIGHT) make overflow
unreachable, returning SettlementArithmeticOverflow rather than
wrapping or panicking, since settlement moves real funds.
Persistent storage TTL
Every write to an assertion’s, resolution’s, position’s, or credit
balance’s persistent storage entry (via the shared set_assertion,
set_resolution, set_position, and add_credit helpers) extends its TTL
by 30 days (INSTANCE_BUMP_AMOUNT), the same bump amount v1 uses. This is
why challenge_window_secs, registration_duration_secs, and
reveal_duration_secs are each capped at 7 days: it leaves comfortable
headroom within that 30-day bump for a phase’s deadline to elapse and for
the next call (finalize, dispute, register, reveal,
resolve_outcome) to actually happen afterward, without the ledger entry
being archived first. test_assertion_storage_ttl_is_extended_on_finalize
in contracts/tholos-v2/src/test.rs verifies the TTL is actually extended
on write, not just claimed in a comment.
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:
| Event | Emitted by | Fields |
|---|---|---|
Asserted | assert_outcome | id, asserter, outcome |
Disputed | dispute | id, disputer, registration_deadline |
PositionFunded | register | id, voter, amount (position’s new total), eligible_total (running W) |
RevealOpened | reveal, resolve_outcome (on the lazy Registration -> Reveal transition) | id, reveal_deadline |
Revealed | reveal | id, voter, choice |
Resolved | reveal, resolve_outcome (once the assertion closes to Resolved) | id, terminal_cause, final_outcome |
Settled | settle | id, address, payout (principal plus reward, or 0; excludes any dust routed in the same call) |
DustCredited | settle, at most once per assertion, when that call closes out the recipient side with nonzero leftover dust | id, address (the deterministic dust recipient), amount |
Withdrawn | withdraw | id, owner, destination, amount |
PauseUpdated | set_paused_v2 | paused |
RoundCancelled | cancel_round | id |
Finalized | finalize | id, outcome, finalizer (Address), reward |
Finalized.finalizer is always the address that called finalize — auth
is required unconditionally, so this value is always verified regardless
of whether finalize_reward_bps is non-zero, the same guarantee v1’s
Finalized event carries.
Known gaps
- No top-up path for fixed positions. The asserter’s and disputer’s
Fixedpositions are sized once, atdisputetime, tobase_bond; a way for them to add to those positions after the fact is tracked separately from the work this document covers. - No canonical v2 deployment, SDK bindings, or
demos/freelance-escrowintegration yet. Seedocs/src/INTEGRATION.md’s “Tholos v2 > Known gaps” for the current state of each.
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 | Size from the spam/griefing model in BOND_SIZING.md: start with the larger of the assertion-spam and bad-faith-dispute floors (R_case / tolerated spam per challenge window), add any target attacker-loss margin, check finalize reward economics, then keep the result within the affordability cap for the smallest assertion value you want to support. Also capped at MAX_BOND_AMOUNT, a contract-enforced ceiling well above any realistic bond size — it exists so the bond can never overflow finalize’s reward-multiply arithmetic (the binding constraint) or the token balance held across a dispute. |
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, distinct, and at most 21 addresses. initialize rejects duplicates with DuplicateResolvers. 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. |
finalize_reward_bps | Basis points (0–1000) of the bond paid to whoever calls finalize. caller must authorize the call unconditionally, even at 0. 0 means no reward: the full bond returns to the asserter. A non-zero value creates an economic incentive for prompt finalization at the cost of a small bond haircut the asserter accepts when posting. 100 bps (1 %) is a reasonable starting point; 1000 bps (10 %) is the maximum enforced by the contract. |
Canonical testnet deployment
Before deploying your own instance, check whether this one already fits: it’s meant to be the shared, long-lived Tholos deployment on testnet, so that trust in the resolver committee’s track record accumulates in one place instead of fragmenting across many one-off deployments. Deploy your own only if you genuinely need different parameters (a different bond size or token, for example); see INTEGRATION.md.
| Field | Value |
|---|---|
| Network | Stellar testnet |
| Contract id | CAOSNC2SKQPGT7WHXKQJQ2RL2J7RECXE5QKZFYIMEHYA3DZTOZG76YYI |
token | Native XLM SAC (CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC) |
bond_amount | 50000000 (5 XLM), per BOND_SIZING.md’s public-testnet/low-value profile |
challenge_window_secs | 21600 (6 hours) |
finalize_reward_bps | 100 (1%) |
resolvers | resolver1/resolver2/resolver3 test identities from this repo’s own testnet workflow, a stopgap: they have no real-world accountability behind them yet. Revisit before treating this instance’s dispute history as trustworthy long-term. |
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\"]" \
--finalize_reward_bps 0
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 assert_outcome, dispute, resolve, and finalize immediately. A
Pending assertion whose challenge window elapses while paused simply waits, it
becomes finalizable once you unpause, rather than finalizing uncontested during
the incident. Minimize pause duration, since this delays legitimate uncontested
claims for as long as the pause lasts, and unpause with --paused false as soon
as incident handling permits. Do not use pause as a safe migration or retirement
switch.
Rotating the resolver committee
There are two paths. update_resolvers is the admin emergency override; it 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\"]"
Day to day, the committee rotates itself by a strict majority vote, with no admin key involved. A resolver proposes a single-slot swap, and the rest vote:
# R1 (a current resolver) proposes replacing themselves with R4.
stellar contract invoke --id "$CONTRACT" --source resolver1 --network testnet -- \
propose_rotation --resolver "$R1" --old_resolver "$R1" --new_resolver "$R4"
# Two more resolvers vote yes; with a 3-member committee that's the majority,
# so the rotation executes as soon as the second yes lands.
stellar contract invoke --id "$CONTRACT" --source resolver2 --network testnet -- \
vote_rotation --resolver "$R2" --approve true
stellar contract invoke --id "$CONTRACT" --source resolver3 --network testnet -- \
vote_rotation --resolver "$R3" --approve true
Either path writes the same committee; both emit ResolversUpdated. A rotation has
no effect on disputes already open, because each dispute snapshots the committee at
dispute time. See CONTRACT.md and
docs/src/ROTATION_DESIGN.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 modeled spam/griefing attempts; see BOND_SIZING.md
- Fee/reward mechanism for uncontested finalizes (configurable
finalize_reward_bps; see CONTRACT.md)
Bond sizing analysis
This note turns the deployment-time bond_amount choice into an operational model
for spam, bad-faith disputes, resolver rotation, and finalize reward griefing. It is
not a substitute for an audit or live production telemetry, but it gives deployers a
repeatable way to pick an initial value and revisit it as usage changes.
Inputs to collect
Use token units consistently. For example, if the configured SEP-41 token has 7
decimals, 1_0000000 means one token.
| Symbol | Meaning |
|---|---|
V_min | Minimum economically meaningful assertion value that should remain affordable. |
C_assert | Attacker’s non-bond cost to post one assertion: transaction fee, opportunity cost, and integration overhead. |
C_dispute | Attacker’s non-bond cost to dispute one assertion. |
R_case | Resolver committee’s off-chain cost to review and vote on one disputed assertion. |
K_spam | Maximum unresolved spam assertions the deployment is willing to tolerate in one challenge window. |
K_dispute | Maximum bad-faith disputes the deployment is willing to tolerate in one challenge window. |
A_window | Number of legitimate assertions expected during one challenge window. |
p_dispute | Expected share of legitimate assertions that receive good-faith disputes. |
target_attacker_loss | Minimum token loss you want an attacker to bear for each successful spam or dispute attempt. |
min_finalizer_reward | Minimum reward, in token units, needed to make third-party finalization worthwhile. |
reward_bps | Configured finalize_reward_bps, from 0 to 1000. |
Formula
Pick a target bond that satisfies all four constraints, then round up to a simple operator-friendly value:
bond_amount >= max(
R_case / max(1, K_spam),
R_case / max(1, K_dispute),
target_attacker_loss - min(C_assert, C_dispute),
min_finalizer_reward * 10_000 / max(1, reward_bps)
)
Then cap it with the affordability bound:
bond_amount <= V_min * max_acceptable_bond_share
Use max_acceptable_bond_share between 5% and 20% for user-facing markets. If the
lower bound exceeds the affordability cap, the deployment is underpriced for its
threat model: raise the minimum assertion value, narrow access to assertion posting,
increase resolver capacity, lengthen monitoring coverage, or lower finalize_reward_bps
instead of quietly launching with an unaffordable bond.
The formula intentionally treats the asserter bond and disputer bond symmetrically:
each side must lock bond_amount, and the losing side forfeits it. A spammer can
still force resolver attention by accepting losses, but each extra unit of resolver
work burns a predictable amount of attacker capital.
Scenario checks
1. Low-value assertion spam
Attack: an account posts many cheap assertions whose value is lower than resolver review time, hoping resolvers ignore them or spend time triaging junk.
Sizing rule:
bond_amount + C_assert >= R_case / K_spam
If the committee can tolerate at most 10 unresolved spam assertions per window and a
full review costs about 20 tokens of resolver time, the bond should be at least 2
tokens before considering transaction fees. For public deployments, use a larger
multiple, such as 2x to 5x R_case / K_spam, because attackers may value disruption
more than the direct token loss.
2. Bad-faith dispute spam
Attack: a disputer challenges legitimate assertions to lock both sides into the resolver path, delay finality, and consume resolver attention.
Sizing rule:
bond_amount + C_dispute >= R_case / K_dispute
Worked example: if one disputed case costs 30 tokens of resolver time and the
deployment tolerates no more than 5 bad-faith disputes per challenge window, set the
dispute-facing floor at 6 tokens. If expected legitimate disputed volume is
A_window * p_dispute, make sure the committee can process that baseline plus
K_dispute; otherwise the correct fix is resolver capacity, not only a larger bond.
3. Resolver self-rotation griefing
Attack: a resolver opens rotation proposals to distract the committee, block another proposal, or churn membership during active disputes.
Bond sizing does not directly price this action because propose_rotation and
vote_rotation do not move tokens. The contract’s mitigations are procedural and
structural:
- Only current resolvers can propose or vote, so the attack is limited to a trusted committee member or compromised resolver key.
- Only one rotation can be open at a time, and no-votes can make an impossible proposal auto-cancel, so a stale proposal cannot permanently deadlock rotation.
- A rotation does not affect in-flight disputes; each dispute snapshots the resolver
committee at
disputetime. - The admin
update_resolverspath clears any open rotation and remains the break-glass recovery path for a compromised or unavailable committee.
Operational guidance: include expected rotation review time in R_case when the
same people handle disputes and governance. If rotation noise becomes frequent,
replace the noisy resolver through self-rotation or the admin override; increasing
bond_amount will not punish rotation spam.
4. Finalize reward griefing
Attack: a bot finalizes every uncontested assertion only to extract
finalize_reward_bps, reducing asserter returns or making assertion posting feel
taxed.
The reward is bounded by:
finalize_reward = floor(bond_amount * reward_bps / 10_000)
This is not a contract-balance drain: the reward is paid from the asserter’s own bond, and the remainder returns to the asserter. The risk is economic UX. Keep the reward large enough to cover finalizer transaction fees and monitoring overhead, but small enough that the haircut is acceptable:
min_finalizer_reward <= bond_amount * reward_bps / 10_000
reward_bps <= max_acceptable_haircut_bps
Worked example: if finalizers need at least 0.02 tokens to bother calling and
reward_bps = 100 (1%), the bond must be at least 2 tokens for the reward to meet
that target. If that bond is too high for low-value assertions, set
finalize_reward_bps to 0 and rely on the asserter or integrator to finalize their
own assertions.
Recommended starting profiles
| Profile | bond_amount guidance | finalize_reward_bps guidance | Use when |
|---|---|---|---|
| Private beta | 1x to 2x expected resolver review cost divided by tolerated spam per window | 0–50 bps | Known users, low bot pressure, integrator can finalize. |
| Public testnet / low value | 2x to 5x the larger of assertion-spam and dispute-spam floors | 50–100 bps | Open participation with low economic stakes. |
| Higher-value mainnet candidate | 5x+ the larger spam floor, still within 5%–20% of V_min | 0–100 bps | Meaningful value, monitored resolvers, audited deployment. |
Do not set bond_amount near the contract’s MAX_BOND_AMOUNT. That maximum exists
only to prevent arithmetic overflow in dispute balances and finalize rewards; it is
not an economic recommendation.
Monitoring and adjustment
Review these metrics after every testnet campaign and before any mainnet launch:
- Assertions opened per challenge window, split by source integration.
- Dispute rate, dispute win rate, and repeated losing disputers.
- Median and p95 time from
DisputedtoResolved. - Rotation proposals opened, cancelled, and executed.
- Finalize calls by account and total reward paid.
Raise the bond if losing assertions or losing disputes cluster around a small number
of accounts and resolver latency rises. Lower the bond, or lower
finalize_reward_bps, if legitimate assertions are priced out relative to V_min.
Revisit the calculation whenever the token price, resolver compensation, challenge
window, committee size, or expected assertion value changes materially.
Deployment and operations: Protocol v2
A practical guide for deploying a Tholos v2 instance and operating it afterward. For what each function does, see CONTRACT.md. For design rationale, see V2_RESOLUTION.md. For v1 deployment, see DEPLOYMENT.md.
Before you deploy
This is testnet-only until audited. See SECURITY.md. Don’t point a Tholos v2 instance at real value on mainnet without an independent security review first.
Decide these parameters up front; none of them can be changed after initialize:
Core parameters
| 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. Must match v1’s choice if accepting both v1 and v2 assertions in your integrations. |
base_bond | Size from the spam/griefing model in BOND_SIZING.md. Equal to v1’s bond_amount in principle, but v2 adds a third-party registration tier: a cheaper base bond attracts counter-stake faster, while a larger one deters frivolous disputes. Set it using the same analysis as v1 (start with the larger of the assertion-spam and bad-faith-dispute floors, add any target attacker-loss margin), then check that max_total_weight and max_position will accommodate realistic multi-party dispute scenarios. Also capped at MAX_BOND_AMOUNT, a contract-enforced ceiling well above any realistic bond size. It exists so the bond can never overflow finalize’s reward-multiply arithmetic (bond * finalize_reward_bps) or the token balance held across registration and settlement. |
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. In v2, this is the only deadline before the assertion is disputed; registration and reveal happen afterward, so budget time before this expires for dispute-scoped registration and reveal to complete. |
finalize_reward_bps | Basis points (0–1000) of the bond paid to whoever calls finalize. caller must authorize the call unconditionally, even at 0. 0 means no reward: the full bond returns to the asserter. A non-zero value creates an economic incentive for prompt finalization at the cost of a small bond haircut the asserter accepts when posting. 100 bps (1%) is a reasonable starting point; 1000 bps (10%) is the maximum enforced by the contract. |
Registration and voting windows
These v2-specific parameters control the dispute-scoped registration and reveal phases.
They are immutable per deployment and take effect when the assertion is created, not when
a dispute arrives. Every timeline runs from the moment dispute is called.
| Parameter | Guidance |
|---|---|
registration_duration_secs | How long a dispute stays in registration, during which the asserter, disputer, and any third party can lock capital and commit votes. Must be at least 1 second; the contract enforces a practical upper bound to keep lifetimes reasonable. Deposit this commitment time into your business model: typical Internet disputes might use 1 day; urgent or time-sensitive ones might use 1 hour. If your dispute is about a sports result, a stock price, or anything with a known announcement, set this shorter than the time until the external event resolves, so resolution bonds are visible in time. |
anti_snipe_extension_secs | How much longer the registration deadline moves if a position is funded within this many seconds of the ordinary cutoff. Prevents a late attacker from dominating an already-open dispute in the final second. Set it to 0 if you don’t need anti-sniping (a trusted environment with no arms-race incentive), or to a reasonable backstab window (e.g., 5 minutes) if you expect contested disputes. The contract enforces an upper bound relative to anti_snipe_hard_max_secs (see below). |
anti_snipe_hard_max_secs | The absolute maximum registration deadline, regardless of how many extensions occur. No deposit can extend registration past this time, even if extensions keep firing. Set it to at least registration_duration_secs (the contract enforces this) and at most MAX_ANTI_SNIPE_HARD_MAX_SECS (29 days), plus enough extension opportunities to feel fair (e.g., registration_duration_secs + 100 * anti_snipe_extension_secs). A very large hard max defeats anti-sniping; a very small one (barely above the base window) defeats extensions. |
reveal_duration_secs | How long a dispute stays in the reveal phase after registration closes, during which all third-party commitments from registration become binding votes by revealing their salted choice. Must be at least 1 second; the contract enforces a practical upper bound. Typical disputes might use 6 hours to 1 day here: long enough for off-chain coordinators to run their own resolution process, short enough to finalize quickly. After the reveal deadline, any position that did not reveal is counted as abstaining (forfeited in settlement). |
Arithmetic bounds
These v2-specific parameters limit the total stake and individual positions the contract will accept. They exist to guarantee the arithmetic in settlement calculations cannot overflow and that the contract remains responsive.
| Parameter | Guidance |
|---|---|
max_position | The largest single stake one address can lock in any dispute. Prevents a whale from unilaterally moving the total weight, forcing it to split stake across addresses if it wants to participate larger. Must be at least 1 and at most max_total_weight. Setting it equal to max_total_weight removes this constraint (a single address can be 100% of eligible weight); that’s reasonable for small deployments or if you trust your stakers. For larger or more adversarial scenarios, set it well below, perhaps 10–20% of max_total_weight. |
max_total_weight | The aggregate locked stake any single dispute can reach. Once max_total_weight is locked, registration stops accepting new positions or top-ups. Prevents unlimited storage growth and ensures settlement arithmetic stays bounded. The contract enforces a hard ceiling to prevent overflow. Set it to a realistic bound on the total value you want to put at risk in any one dispute: perhaps 10–100x your base bond if you expect vigorous counter-stakes, or just 2–3x if you expect assertions to finalize mostly uncontested. |
Canonical testnet deployment
Before deploying your own v2 instance, check if there’s a canonical one that already fits. Unlike v1
(which has a long-lived shared instance), v2 does not yet have an official canonical testnet deployment.
For now, deploy your own: scripts/testnet-load-v2.sh demonstrates the full sequence against real Stellar testnet infrastructure.
Note: No canonical v2 contract address will be added to this document until one is deployed and independently verified. See CONTRIBUTING.md’s reviewing-PRs section for why a committed contract address is never accepted without independent verification.
Deploying
# Build the optimized wasm
cd contracts/tholos-v2 && stellar contract build
# Deploy
CONTRACT=$(stellar contract deploy --wasm target/wasm32v1-none/release/tholos_v2.wasm \
--source deployer --network testnet)
# Initialize
stellar contract invoke --id "$CONTRACT" --source deployer --network testnet -- initialize \
--admin "$ADMIN_ADDRESS" \
--token "$TOKEN_CONTRACT_ID" \
--base_bond 1000000 \
--challenge_window_secs 3600 \
--finalize_reward_bps 0 \
--registration_duration_secs 3600 \
--anti_snipe_extension_secs 300 \
--anti_snipe_hard_max_secs 7200 \
--reveal_duration_secs 3600 \
--max_position 50000000 \
--max_total_weight 250000000
Parameter selection for the deploy example above
The example uses these choices for illustration; adapt them to your use case:
base_bond: 1,000,000 units (e.g., 0.1 XLM if using native SAC)challenge_window_secs: 3600 (1 hour)finalize_reward_bps: 0registration_duration_secs: 3600 (1 hour)anti_snipe_extension_secs: 300 (5 minutes)anti_snipe_hard_max_secs: 7200 (2 hours), twiceregistration_duration_secsso a handful of near-deadline extensions can’t stall registration indefinitelyreveal_duration_secs: 3600 (1 hour)max_position: 50,000,000 units, 50xbase_bond, enough headroom for a real multi-party dispute without approachingmax_total_weighton its ownmax_total_weight: 250,000,000 units, 5xmax_position, so no single position can dominate the vote outright
scripts/testnet-load-v2.sh automates a similar sequence plus assert/dispute/register/reveal/resolve
against real testnet infrastructure; run it to sanity-check a fresh deploy before handing the contract
id to anyone.
Admin runbook
V2 has a narrower admin surface than v1. Notably, v2 has no equivalent to v1’s update_resolvers; there
is no resolver committee to rotate.
Pausing new assertions during an incident
If something looks wrong (a bug is found, or vote behavior looks off), pause to prevent new assertions from opening while investigation proceeds:
stellar contract invoke --id "$CONTRACT" --source admin --network testnet -- set_paused_v2 --paused true
This stops assert_outcome immediately, preventing the creation of new assertions. Critically, it does
not affect already-open assertions: existing disputes remain in registration, reveal, or resolution
as if nothing changed, and no deadline is altered or extended. A paused-out assert_outcome is the only
v2 pause available; v2 cannot (and does not) pause disputes, reveals, settlement, or withdrawals mid-flight.
This narrow scope is intentional; see V2_RESOLUTION.md
for why. Minimize pause duration and unpause with --paused false as soon as incident handling permits.
If a deeper incident requires canceling an already-open round entirely, use cancel_round instead (see below).
Canceling a round
If an assertion must be unwound, whether it’s still Pending (never disputed) or already Disputed
(e.g., a bug is discovered mid-round, or the contract needs to be redeployed), cancel it:
stellar contract invoke --id "$CONTRACT" --source admin --network testnet -- cancel_round --id 0
cancel_round can only be called while the contract is paused (i.e., after set_paused_v2 --paused true).
It permanently finalizes the round: phase moves to Resolved and terminal_cause locks to
AdminCancelled, so the claim itself is not left open, cancellation is a real terminal outcome, not just a
fund restoration. What happens to locked funds depends on the phase it was cancelled from:
- Still
Pending(never disputed): the asserter’s bond is refunded directly, in the same call. Disputed/Registration/Reveal(third-party positions exist):cancel_rounddoes not itself move any tokens. Every funded position, including the asserter’s and disputer’s, recovers its exact principal (no forfeiture, no reward) through the normalsettle+withdrawpath afterward, the same as any other resolved round.
Use this path only in genuine emergency scenarios (e.g., a discovered bug in voting logic, or a forced redeployment). Canceling a round is visible to users and affects the integrity of the record, so document why and coordinate with your users beforehand if possible.
Checking state
Read-only, no auth required:
# Get a specific assertion
stellar contract invoke --id "$CONTRACT" --source admin --network testnet -- get_assertion --id 0
# Get the current policy
stellar contract invoke --id "$CONTRACT" --source admin --network testnet -- get_policy
# Get a position and its entitlements
stellar contract invoke --id "$CONTRACT" --source admin --network testnet -- get_position \
--id 0 --address "$ADDRESS"
# Get a resolution round (phase, deadlines, tallies, etc.)
stellar contract invoke --id "$CONTRACT" --source admin --network testnet -- get_resolution --id 0
# Get an owner's available credit from a dispute
stellar contract invoke --id "$CONTRACT" --source admin --network testnet -- get_credit \
--id 0 --address "$ADDRESS"
Integration notes
v1 and v2 coexistence
v1 and v2 are separate contracts with separate storage, separate assertion ID sequences, and separate token contracts. They do not share state. An integrator may run both simultaneously (e.g., use v1 for existing, low-stakes assertions and v2 for new, higher-stakes ones), but must treat them as independent deployments for the purposes of routing assertions, checking assertion status, and settling disputes.
No automatic migration or bridging between v1 and v2 exists in the contract. See V2_MIGRATION.md for application-level migration strategies.
Voter secrecy and commit-reveal
Third-party positions in v2 use a salted commitment scheme: they lock capital and commit to a secret vote,
then reveal it in a later phase. The commitment is sha256(canonical_encode(VoteCommitmentPreimage)), where:
VoteCommitmentPreimage = {
domain: "THOLOS_V2_VOTE",
network_id: <soroban network id>,
contract_address: <this v2 contract address>,
policy_hash: <sha256 of the PolicySnapshotV2>,
assertion_id: <id of the assertion>,
round: <registration round counter>,
voter: <address revealing this vote>,
choice: <boolean, agrees with the asserter or not>,
salt: <32 random bytes>
}
Compute the commitment off-chain, call register with it, and call reveal with the original preimage
when the reveal phase opens. The compute-commitment tool (tools/compute-commitment/) is provided
for this; see the usage comment at the top of tools/compute-commitment/src/main.rs. The commit-reveal
scheme prevents vote copying and keeps a voter’s choice private until the reveal phase, when timing and
anonymity properties shift.
TTLs and archival
Every position, credit, and assertion in v2 has an associated TTL (time-to-live). Once a TTL expires, the entry may be archived by the ledger, becoming invisible on-chain. However, archival does not destroy the liability: an archived position can be restored by reading its preimage from historical events or off-chain sources, and the entitlement remains valid for settlement and withdrawal.
The contract emits an indexable event for every phase transition, deposit, reveal, settlement, and withdrawal. Off-chain indexers should track these events to allow users to recover their state and claim entitlements after TTL archival.
This design prioritizes bounded on-chain storage (no unlimited per-dispute vectors) over unlimited on-chain availability. It is the integrator’s responsibility to preserve event history or provide a recovery mechanism.
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?
Default to sharing the canonical deployment. Tholos is only trustworthy as an oracle if its resolver committee’s track record accumulates somewhere: one committee, one dispute history, building a reputation over time. Fragmenting into a separate deployment per integrator throws that away, each new instance starts with zero history and a committee nobody’s evaluated yet, which is no better than each integrator building its own bespoke escrow logic instead of using Tholos at all.
Each deployment is initialized once with a single token, bond amount,
challenge window, and resolver committee (initialize in CONTRACT.md),
with no per-call override, so a separate deployment is only justified when your
parameters are genuinely incompatible with the canonical one: a materially
different bond size for a much higher- or lower-value market, or a token the
canonical instance doesn’t use. If that’s not your situation, share the
canonical instance and just track the assertion ids that belong to you.
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. contracts/asserter-consumer is a
working, tested example of this pattern, the same way demo-consumer is for the
simple one above: its create_assertion_as_self function is the pattern below,
and its test deploys Tholos’s actual compiled wasm and calls through it without
mocking the nested authorization it depends on. 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.
Calling Tholos from a browser or Node app
The Rust pattern above only helps if you’re writing another Soroban contract.
An application calling Tholos directly, from a browser or a Node backend,
needs the same building/simulating/signing/submitting/polling machinery
demo-consumer gets from contractimport!, but in TypeScript.
packages/tholos-sdk is a generated client for exactly this, produced with
the Stellar CLI’s stellar contract bindings typescript against Tholos’s
compiled wasm (not a live deployment, so generating it never needs network
access or a contract id). It’s committed in-repo, not yet published to npm;
see its own README for regeneration
instructions and current status.
import { Client } from "tholos-sdk";
const client = new Client({
contractId: "<the deployed contract id, see DEPLOYMENT.md>",
networkPassphrase: "Test SDF Network ; September 2015",
rpcUrl: "https://soroban-testnet.stellar.org",
});
const tx = await client.assert_outcome({ asserter: "<address>", outcome: true });
const { result } = await tx.signAndSend();
demos/freelance-escrow doesn’t use this yet, it currently hand-rolls its
own client (src/lib/tholos.ts) predating this package. Migrating it is a
separate, deliberate follow-up rather than bundled here, so the SDK’s
completion doesn’t depend on unrelated demo-app churn.
Lifecycle from an integrator’s perspective
finalize requires caller’s authorization unconditionally — even when
finalize_reward_bps is 0 (the default). This ensures the address written into
Assertion.finalizer and the Finalized event is always a verified caller, not an
arbitrary address someone passed in. No funds are at risk (the caller only ever
receives its own reward), but without enforced auth the on-chain finalizer of record
could be spoofed. Pass caller = some_address and authorize the call regardless of
whether a reward is configured. When finalize_reward_bps is non-zero the caller
additionally receives bond * bps / 10_000 tokens as an incentive and
Assertion.finalizer is set to that verified address. resolve requires
authorization from a member of the resolver committee snapshotted for the
dispute. 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 => {
// `final_outcome` is guaranteed to be set when status is Resolved.
let final_outcome = state.final_outcome.unwrap();
}
_ => { /* not resolved yet */ }
}
}
Assertion.outcome always remains the claim made at assertion time. Read
Assertion.final_outcome for the authoritative result once the assertion is
resolved; it is None while the assertion is still Pending or Disputed.
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, non-zero, distinct, and at most 21 addresses; v1 rejects duplicates with DuplicateResolvers. See CONTRACT.md for what update_resolvers can and can’t change mid-dispute. |
finalize_reward_bps | 0–1000 basis points of the bond paid to whoever calls finalize. Auth is always required from the caller, regardless of this value. 0 (default) returns the full bond to the asserter with no reward; non-zero values incentivize prompt finalization. |
Tholos v2
Everything above this section is v1: the fixed-committee-vote contract in
contracts/tholos, deployed and stable. contracts/tholos-v2 is a wholly
separate, stake-weighted contract (design in
V2_RESOLUTION.md), never an upgrade of v1 in place; the
two run side by side rather than one replacing the other. See
V2_MIGRATION.md for the coexistence period specifically:
how to inventory a v1 deployment, when to cut new traffic over, and how to
retire v1 operationally once its accepted assertions have drained.
Assertion identity changes
Because v1 and v2 are independent deployments, each with its own NextId
counter starting at 0, an assertion id is only unique within one
contract. Two different assertions, one on each deployment, can both be id
0 at the same time. Once you integrate with both, track (contract_id, assertion_id) as the pair that actually identifies an assertion, not the
bare id alone; market_id -> (contract_id, assertion_id) if you’re
already storing a mapping per the advice above.
Lifecycle at a glance
Note
This section provides a high-level overview. For full details on the state machine, terminal causes, and edge cases, see Contract interface (v2).
v2 splits what v1 does in one resolve call into a multi-phase flow: an
optimistic stage identical in shape to v1’s, followed by a bounded
registration window and a commit-reveal vote open to any address willing to
post a bond, not just a fixed committee.
assert_outcome -> [uncontested: finalize]
-> [disputed: dispute -> register* -> reveal* -> resolve_outcome]
-> settle* (once per funded position)
-> withdraw* (once per address with a credit balance)
(* marks calls made once per participant, not once per assertion.)
| Function | What it does |
|---|---|
assert_outcome(asserter, outcome) -> u64 | Posts a bonded claim. Same shape as v1’s. |
finalize(caller, id) -> bool | Closes an uncontested assertion out once challenge_window_secs has elapsed. Same caller-auth-always-required rule as v1. |
dispute(disputer, id) | Opens the registration window. disputer’s bond becomes the fixed disagreeing position; the asserter’s existing bond becomes the fixed agreeing one. |
register(voter, id, amount, commitment) | Any third-party address posts a bond and a salted commitment to its eventual side, without revealing it yet. Repeated calls from the same voter top up one position; the commitment can’t change after the first deposit. |
reveal(voter, id, choice, salt) | Discloses and verifies a registered position’s side. Lazily closes registration and opens reveal on the first call after registration_deadline, permissionlessly. |
resolve_outcome(id) -> TerminalCause | Permissionlessly closes reveal out once it’s decided: a strict majority locked, everyone eligible revealed, or the deadline passed. Needed specifically for the case nobody’s reveal call would otherwise trigger it (see “Known gaps” below). |
settle(id, address) -> i128 | Converts one position’s share of the decided outcome into withdrawable credit. Permissionless: anyone may settle anyone’s known position. Doesn’t move tokens. |
withdraw(owner, id, destination) -> i128 | Pays out owner’s full credit balance to destination (any address, not necessarily owner). |
get_credit(id, address) -> i128 | Read-only lookup of a withdrawable credit balance. |
set_paused_v2(paused) | Admin-only. Blocks new assert_outcome calls; unlike v1’s pause, an already-active round keeps running (registration, reveal, settlement, withdrawal) even while paused. |
cancel_round(id) | Admin-only, and only while paused. Cancels a round before any terminal outcome has locked, refunding every funded position its exact principal. See V2_RESOLUTION.md for why this exists and what it deliberately can’t do. |
Reading the outcome and reacting to state changes follows the same two
options as v1 (poll get_assertion(id) for phase == Resolved, or watch
events), just against AssertionV2’s fields (terminal_cause,
final_outcome) instead of v1’s Assertion.status.
Known gaps
- No canonical v2 deployment yet. Unlike v1 (see DEPLOYMENT.md), there’s no shared, long-lived v2 instance to point at yet. Deploy your own for now, following the same parameter guidance as v1’s deployment section, until a canonical one exists.
packages/tholos-sdktargets v1 only. The generated TypeScript client described above is built fromcontracts/tholos’s wasm, notcontracts/tholos-v2’s. A browser or Node app integrating with v2 today needs its owncontractimport!-equivalent tooling or hand-rolled calls until v2 gets its own generated bindings.demos/freelance-escrowstill talks to v1. Migrating it to v2 is a separate follow-up, not bundled with the rest of the v2 work.
Known caveats for integrators
- Finalize always requires caller’s authorization:
caller.require_auth()is called unconditionally, regardless offinalize_reward_bps. Pass a real address and sign the call. Whenfinalize_reward_bpsis non-zero (0–1000 basis points of the bond, set once atinitializetime), the caller also receivesbond * bps / 10_000tokens as an incentive for prompt finalization. When it is 0 (the default), no reward is paid and the full bond is returned to the asserter, but auth is still required to keep the recorded finalizer trustworthy. - The admin can pause
assert_outcome,dispute,resolve, andfinalizeat 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.update_resolversstays callable while paused. A pending assertion whose challenge window elapses while paused does not become finalizable until unpaused; do not assume a pause only affects new assertions and disputes.
v1/v2 coexistence and migration runbook
V1 (contracts/tholos) and v2 (contracts/tholos-v2) are two independent
contracts, not one contract with an upgrade path between them. V1 has no
WASM upgrade entry point and no state importer, and even if it did, there’s
no way to move a bond already locked in a v1 dispute into a v2 record
without changing who’s liable for it. The two deployments run side by side
for as long as v1 has any open activity; this is the runbook for that
period, from inventorying an existing v1 deployment through retiring it.
See INTEGRATION.md for the function-level differences between the two contracts. This doc is about the operational sequence of moving traffic from one to the other, not the interface itself.
V2_RESOLUTION.md’s “Migration from existing v1 deployments” already covers this same period from a design-time angle (why blue/green, what can and can’t be guaranteed, the rollback boundary). This doc restates those steps as a practical runbook rather than duplicating them independently; treat the two as one account split across two docs, not two separate opinions, and update both together if either changes.
Why there’s no automated migration
- No upgrade entry point. V1’s WASM is immutable once deployed; nothing in it can be replaced with v2’s logic in place.
- No state importer. Even if v2 wanted to adopt v1’s history, v1 exposes no way to export its full assertion/dispute state in one authoritative read (see the inventory section below for what that actually takes to reconstruct).
- Bonds can’t move contracts. A bond locked in an open v1 dispute is a
liability of the v1 deployment’s own token balance. There’s no operation
that transfers it into v2’s balance and reissues it as a v2 position;
doing so would require v2 to honor a liability it never received funds
for. Every v1 bond stays a v1 liability until v1’s own
finalize/resolvepays it out, full stop.
Given that, migration is a traffic decision, not a data migration: stop sending new assertions to v1, send them to v2 instead, and let v1’s already-open assertions run to completion on v1’s own terms.
1. Inventory the v1 deployment
V1 has no public config getter, no version marker, and no way to enumerate
its own NextId range or list of open assertions; none of this can be read
back from the contract in one call. Reconstruct it from deployment
transactions and events instead:
- Network, contract id, exact WASM hash. From the
deploytransaction itself (orstellar contract infoagainst the live contract id). The WASM hash matters for the same reason CONTRIBUTING.md’s review policy never accepts a bare contract address as proof of what code it runs: a contract id alone doesn’t tell you what’s actually deployed there. token,bond_amount,challenge_window_secs,resolvers,finalize_reward_bps. From theinitializeinvocation’s arguments, or fromget_assertion_stateon any known assertion id if the invocation itself isn’t handy (Assertiondoesn’t carry every policy field, but the ones it does are enough to cross-check).- Current
adminand resolver committee. From the latestResolversUpdatedevent if the committee has ever rotated, otherwise frominitialize’s original arguments. - Every open
Pending/Disputedassertion. There’s no enumeration call for this either. Walk the contract’s event history (Asserted,Disputed,Finalized,Resolved) from deployment to now, and take the set ofids that have anAssertedbut no matchingFinalized/Resolved. This set is exactly what still needs to drain before v1 can be retired; see step 4.
2. Deploy v2 fresh
Deploy contracts/tholos-v2 as its own new contract, following
DEPLOYMENT.md’s parameter guidance (it’s written for v1,
but the considerations for token/bond_amount/challenge_window_secs
apply the same way to v2’s initialize; see
CONTRACT.md and V2_RESOLUTION.md for the
parameters v2 adds beyond v1’s, like registration_duration_secs and
reveal_duration_secs).
Do not:
- Copy v1’s assertion records into v2. V2’s
AssertionV2/Resolution/Positionrecords are a different shape from v1’sAssertion, and even a faithful reconstruction would misrepresent history: those assertions were decided (or are still being decided) under v1’s fixed-committee rule, not v2’s stake-weighted one. Let them stay v1 history. - Move v1’s pooled token balance into v2. V1’s balance backs its own
open liabilities (bonds not yet returned via
finalize/resolve). Move it and v1 can no longer pay out assertions it’s already committed to.
v2 starts with genuinely zero history, the same tradeoff any fresh deployment accepts per INTEGRATION.md. That’s expected here: this is a new contract, not a continuation of v1’s track record.
3. Cut new traffic over
Pick and record a cutover point (a ledger sequence number or timestamp works well, since it’s independently verifiable later). From that point on, route new assertions to v2’s contract id instead of v1’s. This is purely a decision your integration makes about which contract id it calls; neither contract has a flag that enforces it for you.
Record the cutover point somewhere durable (a deploy note, a config entry, whatever your integration already uses for this), since step 4 needs to distinguish “opened before cutover, still draining on v1” from “opened after cutover, already on v2.”
4. Let v1 drain, without pausing it
Do not pause v1 during drain. set_paused blocks assert_outcome,
dispute, resolve, and finalize all together (see
DEPLOYMENT.md and
INTEGRATION.md); there’s no
way to pause only new direct-caller assertions while leaving every already-
open Pending/Disputed assertion free to finalize or resolve normally.
Pausing during drain doesn’t protect anything, since drain is a routine
wind-down, not an incident, it just stalls every assertion still in flight
(a Pending one past its challenge window can’t finalize, a Disputed one
can’t resolve) for as long as the pause lasts, working directly against the
point of this step. This is the same reason DEPLOYMENT.md’s admin runbook
already warns not to use pause as a migration or retirement switch.
Instead, simply stop sending new assert_outcome calls to v1 (step 3
already does this) and let the inventory from step 1 run its natural
course: every open assertion either finalizes uncontested after its
challenge window, or gets disputed and resolved by the committee, same as
it always would have. There is no way to force this faster without
touching assertions that haven’t had their full, promised window to be
contested; don’t try to accelerate it.
Track the inventory set from step 1 against Finalized/Resolved events
as they arrive. V1 is fully drained once every id in that set has one, but
this isn’t guaranteed to happen on any timeline: v1 has no timeout or
cancellation for a dispute whose snapshotted committee can no longer reach
a majority (a resolver gone unreachable, a duplicate-filled snapshot from
older v1 bytecode that never validated distinctness), so a stuck dispute
can leave drain, and full v1 retirement, permanently incomplete. See
V2_RESOLUTION.md’s “Migration from existing v1 deployments”
for the fuller design-time treatment of this and the rollback boundary;
this runbook is the practical step-by-step version of the same period, and
the two should be read together rather than as competing accounts.
5. Retire v1 operationally
Once drained, there’s nothing left for v1 to do: leave it deployed and unpaused rather than pausing it as a final step. A paused-forever contract with a genuine zero-liability balance is operationally identical to an unpaused one nobody calls, so there’s no safety benefit to pausing at this point, only a documentation cost (an operator seeing it paused might reasonably wonder why, and go looking for an incident that isn’t there). Update whatever integration-facing docs point at v1’s contract id to point at v2’s instead, and note the retirement date alongside the inventory this runbook started with, for anyone auditing the transition later.
Updating your own integration
If you’re a v1 integrator working through this runbook for your own
deployment: demos/freelance-escrow in this repo is in exactly this
position (it currently calls v1 directly, see its own src/lib/tholos.ts),
and migrating it is tracked as its own follow-up rather than bundled with
v2’s implementation issues. Use it as a worked example once that follow-up
lands, not as a template today.
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 (admin emergency override) or a self-rotation vote
(propose_rotation / vote_rotation, a strict majority of the live committee). It
must be non-empty, have an odd number of members, contain distinct addresses, and
have no more than MAX_RESOLVERS (21) members. Duplicate addresses are rejected
with DuplicateResolvers.
Rotation
A committee-driven, single-slot replacement of one resolver with another, decided by
a strict majority of the live committee. The day-to-day alternative to admin
update_resolvers; does not affect disputes already open (their committee was
snapshotted at dispute time). See docs/src/ROTATION_DESIGN.md.
Majority
resolvers.len() / 2 + 1. The number of matching votes needed to resolve a
disputed assertion, calculated against the resolver committee snapshotted when
the dispute opens. An odd-length committee makes the numeric threshold
unambiguous; reaching it still requires enough available resolver addresses.
Finalize
Closing out a Pending assertion after its challenge window has elapsed with no
dispute. caller must authorize the call. Returns the asserter’s bond, minus an
optional reward paid to caller if finalize_reward_bps is non-zero.
Finalizer
The address that called finalize on an assertion. Recorded in
Assertion.finalizer and the Finalized event. Auth is required unconditionally,
so this is always a verified address.
Finalize reward
The optional cut of the bond (finalize_reward_bps, 0–1000 basis points, set at
initialize) paid to whoever calls finalize, as an incentive for prompt
finalization. 0 disables it entirely.
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,
resolver votes, and finalization, without affecting 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. You can use the Makefile shortcut:
make build-wasm
make test
Or run the raw commands directly:
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 (v1, deployed and stable)
src/
lib.rs Contract logic
test.rs Unit tests (soroban-sdk testutils, mocked ledger and auth)
tholos-v2/ Stake-weighted resolution (v2), design in docs/src/V2_RESOLUTION.md;
a wholly separate contract from v1, never upgraded in place
src/
lib.rs Contract logic, built up issue by issue per V2_RESOLUTION.md's
"Future implementation work" list
test.rs Unit tests, same conventions as contracts/tholos
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
asserter-consumer/ Example contract using its own address as the asserter
src/
lib.rs The authorize_as_current_contract pattern from docs/src/INTEGRATION.md
test.rs Validates that pattern against Tholos's real compiled wasm
demos/
freelance-escrow/ A real freelance milestone-payment app built on Tholos;
a pnpm/Vite/React project, not part of the Cargo
workspace, see its own README for setup
packages/
tholos-sdk/ Generated TypeScript client for contracts/tholos, via
`stellar contract bindings typescript`; regenerate
whenever the contract's public interface changes
(CI checks for drift), see its own README
tools/
compute-commitment/ Off-chain helper computing register()/reveal()'s salted vote
commitment for v2, via a path dependency on tholos-v2's own
VoteCommitmentPreimage type; a plain host binary, not a
contract, so it has no [lib]/cdylib target and `cargo build
--workspace --lib --target wasm32v1-none` skips it deliberately
scripts/
testnet-smoke.sh End-to-end check against real Stellar testnet infrastructure
.github/workflows/
ci.yml Runs three jobs on every push/PR: `test` (blocks
committed contract addresses, verifies workspace
membership, fmt, shellcheck, builds tholos's
wasm, clippy, tests, then a second workspace-wide
lib wasm build), `demo` (lint and build
demos/freelance-escrow), and `sdk` (checks
packages/tholos-sdk's generated bindings for
drift, then builds it)
Additional demo apps should each live as their own directory under demos/,
following the same layout as demos/freelance-escrow.
demo-consumer and asserter-consumer exist to keep INTEGRATION.md
honest: they’re not products, they’re compiled checks that the documented
integration patterns actually work. If you change Tholos’s public interface,
update whichever of them 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.
Property-based testing, via the proptest crate, is
used within the unit-test layer where hand-picked boundary values aren’t enough to
be confident an invariant holds across a whole input space (e.g. numeric parameter
validation, or a vote-counting formula that must hold for every committee size).
cargo-fuzz isn’t used: it needs the wasm32 target and a libFuzzer-driven
executable, which doesn’t fit Soroban’s native, mocked-Env test profile that these
contracts’ unit tests run against; proptest runs as ordinary #[test]s in that
same profile. Proptest-based tests live in their own mod proptest_* inside
test.rs, next to the hand-written tests they complement, and set
fork = false in their ProptestConfig because Soroban’s Env isn’t Send.
Running cargo test writes a test_snapshots/test/<name>.1.json file per test,
a snapshot of the mocked ledger’s state at the end of that test. Whether to commit
one comes down to reproducibility, not what kind of test wrote it: commit it if
running the test again always produces the same file (every hand-written test so
far), since it’s then a stable, reviewable artifact tied to a specific named
scenario. Don’t commit it if the content changes on every run (any proptest_*
module, since the random seed isn’t fixed and nothing in the repo reads these
files back for comparison anyway); instead add that module’s
test_snapshots/test/<module>/ path to .gitignore.
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).
Opening issues
Use one of the two issue templates. Blank issues are disabled.
Every issue title uses the bracket prefix format [Type] Short imperative description:
| Prefix | When to use |
|---|---|
[Bug] | Something in a contract, script, or CI is broken or behaving incorrectly. Use the Bug Report template. |
[Feature] | A new capability or a test that exercises new behavior. Use the Feature Request template. |
[Chore] | Dependency bumps, CI/tooling tweaks, docs-only changes, or cleanup that isn’t a new capability. Also uses the Feature Request template. |
If you think you’ve found a security vulnerability rather than a functional bug, don’t open an issue at all; see SECURITY.md instead.
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). You can use the Makefile shortcut:
make check
Or run the raw commands directly:
cargo fmt --check
shellcheck -x scripts/*.sh scripts/lib/*.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.
Reviewing PRs
Never accept a contract address a contributor provides as evidence their change works, and never let one land in docs, examples, or code. A deployed address can’t be tied to a specific source commit without an independent rebuild: a PR’s source could be correct while the address offered alongside it points at different, maliciously altered bytecode. If a change needs testnet verification, rebuild and deploy it yourself (or have CI do it) from the PR’s actual source; a pasted address is never sufficient proof on its own. CI blocks any literal Stellar contract address from being committed at all, as a backstop.
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 must pass before merge: the test job (contract-address and workspace-membership checks, fmt, shellcheck, builds tholos’s wasm, clippy, tests, then a second workspace-wide lib wasm build), the demo job (lint and build demos/freelance-escrow), and the sdk job (bindings-drift check and build for packages/tholos-sdk). 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]
Added
-
contracts/tholos-v2: a new, wholly separate contract crate for protocol v2 (stake-weighted resolution, design indocs/src/V2_RESOLUTION.md), never upgraded in place from v1. This first issue (#64) implements the immutablePolicySnapshotV2pinned at assertion creation and theAssertionV2record it lives on, plusinitializeand read-only lookups. Registration, reveal, outcome resolution, settlement, and the freeze/cancel mechanism are separate issues (#65-#71) landing as the crate grows. Closes #64. -
tholos-v2: bonded assertion posting (assert_outcome) and the uncontested-finalizepath, the same two-stage shape v1 has for an assertion nobody disputes. Addschallenge_window_secsandfinalize_reward_bpstoPolicySnapshotV2(distinct fromregistration_duration_secs, which only governs the post-dispute third-party join window), andopened_at/finalizertoAssertionV2.finalize_reward_bpsis carried over from v1 unchanged: the problem it solves (incentivizing a third party to spend gas finalizing on the asserter’s behalf) is identical in both versions for this uncontested case. Closes #65. -
tholos-v2:disputeand the third-party registration phase. NewPosition(one address’s stake, keyed by(assertion_id, address)) andResolution(per-dispute deadlines and the running eligible totalW) records, per V2_RESOLUTION.md’s storage layout. Third-party deposits carry a salted commitment hiding their side until reveal (#67); repeated deposits from one address aggregate into one position and can’t change that position’s original commitment. A qualifying late deposit extends the registration deadline, capped at a hard deadline fixed atdisputetime. Also fixes a gap in #64’sinitializevalidation:anti_snipe_hard_max_secsis now required to be at leastregistration_duration_secs, since the hard deadline isregistration_opened_at + anti_snipe_hard_max_secs, an absolute duration that could otherwise fall before the ordinary soft deadline. Closes #66. -
tholos-v2: the reveal phase and commitment verification.revealverifies a third-party position’s(choice, salt)against its stored commitment (via aVoteCommitmentPreimagestruct hashed the same canonical-encoding waypolicy_hashalready is) and counts its weight intoResolution.agree_weight/disagree_weight. The Registration -> Reveal transition is lazy, triggered by the firstrevealcall afterregistration_deadline, which also auto-counts and auto-reveals the asserter’s and disputer’s fixed positions, since their sides are already public and they never callrevealthemselves. Closes #67. -
packages/tholos-sdk: a generated TypeScript client forcontracts/tholos, viastellar contract bindings typescriptagainst the compiled wasm (never a live deployment, so regenerating needs no network access or contract id). Committed in-repo, not yet published to npm, since it hasn’t been consumed by a real integration yet. CI regenerates it on every push/PR and fails if the committed package has drifted from the contract’s current interface.docs/src/INTEGRATION.mddocuments this as the JS/TS integration path, alongside the existing Rust contract-to-contract pattern. Migratingdemos/freelance-escrowoff its hand-rolled client to use this instead is a separate, deliberately out-of-scope follow-up. Closes #60. -
tholos-v2: weighted-majority outcome resolution. After every reveal, a side locks interminal_cause/final_outcome(StrictMajorityFor/StrictMajorityAgainst) the moment its revealed weight exceeds half of the frozen eligible totalW, checked via subtraction rather than division to stay exact on an oddW. The assertion staysRevealafter locking so other positions can still reveal to prove entitlement for settlement, closing toResolvedoncerevealed_weightcatches up withWorreveal_deadlinepasses, whichever comes first; if neither side ever reached a majority,terminal_causedefaults toOptimisticTimeoutand the originally asserted outcome stands. New permissionlessresolve_outcomeentrypoint closes aReveal-phase assertion out once its deadline has passed, and is the only way a dispute that drew zero third-party registrations can ever leaveRegistration, since nobody would otherwise have a position to callrevealwith. Closes #68. -
tholos-v2: settlement, converting aResolvedassertion’s decided outcome into per-position entitlements via a new permissionlesssettle(id, address). A winning position (perterminal_cause: the agreeing side forStrictMajorityFor, the disagreeing side forStrictMajorityAgainst, either side if revealed forOptimisticTimeout) recovers its principal plus a pro-rata share of the forfeited pool from losing/never-revealed positions; a losing or never-revealed position recovers nothing. Every position’s share is computed from the same(recipient_weight, forfeited_pool)pair, derived purely fromResolutionfields already frozen oncephase == Resolved, so settling positions in any order never changes any individual result.settledoesn’t move tokens itself, it accrues the payout to a newCredit(id, address)record (get_creditreads it); withdrawal is a separate, not yet implemented, issue. Leftover dust from floor division is credited to a deterministic party (the winning asserter/disputer, or the asserter for a timeout default) once the last recipient position settles. Tightensinitialize’smax_total_weightbound fromMAX_BOND_AMOUNT(~1.7 * 10^35) to a newMAX_SETTLEMENT_TOTAL_WEIGHT(10^19), since the old bound was nowhere near tight enough to keep settlement’samount * forfeited_poolmultiply insidei128. Closes #69. -
tholos-v2: credit withdrawal, via a newwithdraw(owner, id, destination). Transfersowner’s entire withdrawableCredit(id, owner)balance (accrued bysettle) todestination, which can be any address, not necessarilyowneritself, so a token that rejects transfers toownerdirectly can’t permanently strand funds there. Effects before interactions: the credit balance is zeroed andResolution’s newoutstanding_liability/withdrawn_totalfields updated before the outgoing transfer, so a failed transfer rolls back the whole call and never consumes the credit. Also adds a contract-wide reentrancy guard (enter_reentrancy_guard/check_reentrancy_guard), held for the duration of every external token transfer this contract initiates (assert_outcome,dispute,register,finalize,withdraw) and checked at the entry ofreveal/resolve_outcome/settleas well, so a non-standard token whosetransfercalls back into this contract mid- transfer can’t act on state that looks complete before the tokens backing it have actually moved. Closes #70. -
tholos-v2: the symmetric freeze/cancel emergency mechanism, via two new admin-only entrypoints.set_paused_v2(paused)blocks newassert_outcomecalls; unlike v1’s broader pause, it never affects an already-active round, whose registration, reveal, resolution, settlement, and withdrawal all continue normally while paused, since blocking them would strand capital already locked into that round rather than protect it.cancel_round(id), callable only while paused, cancels a round before any terminal outcome has locked (Pending, orRegistration/Revealwith no strict majority reached yet) and refunds every already-funded position its exact principal, no forfeiture, no reward, as if the round never happened; it fails outright, not as a no-op, withRoundAlreadyDecidedonceterminal_causeis set by any means, including an earlier cancellation, making it structurally impossible to alter an already-decided result. APendingcancellation refunds the asserter’s bond directly (noResolution/Positionexists yet at that phase); aRegistration/Revealcancellation instead sets a newTerminalCause::AdminCancelledand lets every position recover its principal through the normalsettle/withdrawpath, sincesettlement_pooltreats every funded position as a recipient of a zero forfeited pool for that cause. Emits a distinctRoundCancelledevent, separate fromResolved, so indexers can always tell a cancellation apart from a real outcome. Closes #71. -
docs/src/INTEGRATION.md: a new “Tholos v2” section covering the v2 function-by-function lifecycle (assert_outcomethroughwithdraw), why an assertion’s identity is now(contract_id, assertion_id)rather than a bareid(v1 and v2 each have their ownNextIdcounter, so both can independently issue id0), and the current gaps (no canonical v2 testnet deployment yet,packages/tholos-sdktargets v1 only,demos/freelance-escrowstill talks to v1). -
docs/src/V2_MIGRATION.md: a new runbook for the v1/v2 coexistence period, since v1 has no WASM upgrade entry point and no state importer, and a bond already locked in an open v1 dispute can’t move contracts without changing who’s liable for it. Covers inventorying an existing v1 deployment from its transactions and event history (v1 exposes no public config/version/NextIdgetter), deploying v2 fresh without copying v1’s assertion records or pooled token balance, cutting new traffic over from a recorded point, and draining v1’s remaining open assertions to completion without pausing it (pause blocksassert_outcome,dispute,resolve, andfinalizeall together, so it can’t selectively reject new assertions while leaving already-open ones free to finalize or resolve; using it during drain just stalls everything in flight for no compensating protection). Also fixes the same factual error, present since the original design doc, inV2_RESOLUTION.md’s existing “Migration from existing v1 deployments” section, which the new runbook is meant to be read alongside rather than duplicate independently. Closes #73. -
scripts/testnet-load-v2.sh: an E2E load test fortholos-v2against real Stellar testnet infrastructure, the v2 analog ofscripts/testnet-load.sh. Opens two disputes concurrently, funds several third-party positions on one of them (real, individually computedsha256(VoteCommitmentPreimage)commitments, not placeholders, sincerevealwould reject anything else), drives one dispute to a strict-majority result and the other to the optimistic timeout default, then settles and withdraws every position across both in an order deliberately different from registration order, to exercisesettle/withdraw’s order-independence invariant from #69. Records per-phase and per-invocation timing.Adds a new
tools/compute-commitmentcrate (a path dependency ontholos-v2, reusing itsVoteCommitmentPreimagetype directly rather than duplicating the hashing logic) soregister/reveal’s off-chain callers can compute a real commitment without hand-rolling the XDR encoding themselves; a separate crate rather than living insidetholos-v2/src/bin/, sincestellar contract buildneeds exactly one buildable target per package and errors on the ambiguity a second target would introduce. Sincewasm32v1-nonehas nostd, this also means.github/workflows/ci.yml’s “Build contract wasm” step now runscargo build --workspace --librather than every target: only each crate’s library needs to build for that target, not a host-side helper binary that was never meant to. Closes #74. -
demos/freelance-escrow: migratedsrc/lib/tholos.tsoff its hand-rolledTransactionBuilder/nativeToScVal/simulate-sign-submit-poll boilerplate topackages/tholos-sdk’s generatedClient. Same five exported functions and signatures as before (assertOutcome/disputeAssertion/resolveAssertion/finalizeAssertion/getAssertionState), sostate/JobsContext.tsxdidn’t need to change at all;getAssertionStatenow returns the SDK’s own decodedAssertiontype directly instead of a hand-mapped camelCase shape nothing in the app actually consumed. Addstholos-sdkas a localfile:dependency (not published to npm) in place of a direct@stellar/stellar-sdkdependency, now pulled in transitively.Also fixes two real packaging bugs in
packages/tholos-sdkthis surfaced, neither caught before because nothing had actually consumed the package as a real dependency yet: itsexportsfield was a bare string with notypescondition, whichmoduleResolution: "bundler"(and"node16"/"nodenext") silently ignore the top-leveltypingsfallback for onceexportsis present at all, so no consumer could ever resolve its type declarations; and pnpm applies npm’s pack-list filtering even to localfile:directory dependencies, which respects.gitignore, so the gitignoreddist/build output was silently excluded from the linked copy entirely until a newfilesfield explicitly allowlisted it..github/workflows/ci.yml’sdemojob now buildstholos-sdkfirst, the same ordering constraintdemo-consumeralready has withtholos’s wasm on the Rust side. Closes #63. -
packages/tholos-sdk: bumps@stellar/stellar-sdkfrom^14.5.0(the version the Stellar CLI’s codegen template defaulted to when the SDK was originally generated in #60) to^16.2.0. Verified against both this package’s owntscbuild anddemos/freelance-escrow’s full build/lint, from a clean install, with no changes needed on either side. Also shrinksdemos/freelance-escrow’stholoschunk from 1,735 kB to 369 kB (480 kB to 98 kB gzipped), apparently from newerstellar-sdktree-shaking more cleanly.
[0.3.0] - 2026-08-08
Added
-
Assertiongains afinal_outcome: Option<bool>field, set atfinalizeandresolve. Previously the authoritative resolved outcome only existed in theFinalized/Resolvedevent payload;Assertion.outcomealways stays the original claim even when a dispute overturns it, which was a sharp edge for integrators reading state after the fact. Closes #37. -
A second integration example,
contracts/asserter-consumer, demonstrating the “contract-as-asserter” pattern from INTEGRATION.md (env.authorize_as_current_contract), alongsidedemo-consumer’s existing end-user-as-asserter example. Closes #14. -
docs/src/BOND_SIZING.md: a bond-sizing analysis modeling spam, bad-faith disputes, resolver self-rotation griefing, andfinalize_reward_bpsgriefing, with worked formulas for choosingbond_amount.DEPLOYMENT.mdnow points to it instead of only qualitative guidance. Closes #50. -
scripts/testnet-load.sh: an end-to-end load and volume test scenario against real Stellar testnet infrastructure (sequential assert/dispute/resolve/finalize phases with timing and integrity checks), complementing the single-flowtestnet-smoke.sh. Closes #13. -
.github/workflows/docs-check.yml: builds the mdBook docs site on every PR that touchesdocs/**,README.md,CONTRIBUTING.md, orSECURITY.md, so a broken doc build is caught before merge instead of at deploy time. Closes #16. -
A
Makefilewrapping the common dev commands (make check,make test,make build-wasm, etc.) documented in CONTRIBUTING.md’s “Before opening a PR” section, so contributors don’t have to remember the rawcargo/stellarinvocations. Closes #15. -
CONTRIBUTING.md’s Testing philosophy section now states the test snapshot commit policy explicitly: commit a
test_snapshots/file if the test that wrote it is reproducible,.gitignoreit if it isn’t (anyproptest_*module). Closes #24. -
Configurable finalize reward (
finalize_reward_bps, 0–1000 basis points of the bond) paid to whoever callsfinalizeas an incentive for prompt finalization. The reward is funded by the asserter’s bond: the caller receivesbond * bps / 10_000tokens and the asserter receives the remainder. Settingfinalize_reward_bpsto 0 (the default) reproduces the original no-reward behavior: the full bond returns to the asserter.callermust authorize the call unconditionally, regardless of the reward value, so the address recorded inAssertion.finalizerand theFinalizedevent can never be spoofed.initializenow acceptsfinalize_reward_bpsas a new parameter (validated ≤ 1000, failing withInvalidFinalizeRewardotherwise).finalizesignature changed fromfinalize(id)tofinalize(caller, id). TheFinalizedevent gains two new fields:finalizer: Addressandreward: i128.Assertiongains a newfinalizer: Option<Address>field populated on finalize. Closes #17. -
Property-based tests for resolver vote counting and majority (
proptest_vote_counting), generating random odd committee sizes and vote sequences and checking the result against an independent reference implementation of the(size / 2) + 1majority formula. Closes #12. -
Property-based tests for
initialize’sbond_amountandchallenge_window_secsvalidation (proptest_initialize_bounds), fuzzing the fulli128/u64domains against a reference implementation of the same checks, plus a boundary-weighted pass aroundMAX_CHALLENGE_WINDOW_SECS. Documented in CONTRIBUTING.md whyproptestis used overcargo-fuzz(the latter needs thewasm32target and libFuzzer, which doesn’t fit Soroban’s native, mocked-Envtest profile). Closes #11. -
CI now verifies every
contracts/*/Cargo.tomlis registered in the rootCargo.toml’s[workspace] members. A crate that exists on disk but isn’t a workspace member is invisible tocargo build/test/clippy --workspace, so CI could previously pass without ever building, testing, or linting it. Closes #43. -
Resolver self-rotation: the committee can now replace one of its own by a strict majority vote (
propose_rotation,vote_rotation,cancel_rotation), removing the admin as the only path to committee membership.update_resolversremains as the emergency override; both paths emitResolversUpdated, and rotation addsRotationProposed/RotationExecuted/RotationCancelledfor the governance trail. One rotation may be open at a time, with a deterministic deadlock guard so a lost proposer key can’t block rotation. Writes the sameResolversslot asupdate_resolvers, so it has no effect on disputes already open (their committee is snapshotted atdisputetime). Design indocs/src/ROTATION_DESIGN.md. Closes the self-rotation item from CONTRACT.md’s Known gaps. -
A design-only protocol v2 proposal for stake-weighted voting by bond posters, including eligibility and weight snapshots, settlement, threat analysis, and a blue/green migration path for existing v1 deployments. No contract behavior or public interface changed. Refs #19.
-
Reentrancy regression tests for
assert_outcome,dispute, andresolve, extending the pattern already used forfinalize. Along the way, confirmed that Soroban’s auth model itself rejects a reentrant token’s dynamically-triggered nestedrequire_authcall, so these three aren’t actually reachable by a hostile token acting alone; documented in ARCHITECTURE.md and CONTRACT.md. (At the time this was writtenfinalizeneeded no signature; it now requirescallerto authorize unconditionally, see thefinalize_reward_bpsentry above.) Closes #3. -
initializeandupdate_resolversnow reject resolver committees larger thanMAX_RESOLVERS(21), since the full committee is copied onto every disputed assertion. Closes #4.
Changed
-
Removed
PR_DESCRIPTION.md, a contributor’s scratch file that was accidentally committed to the repo root instead of pasted into the PR body. Closes #48. -
The
evil_tokentest module (contracts/tholos/src/test.rs) now uses a typedDataKey-style enum for its own storage keys instead of ad hocsymbol_short!strings, matching the main contract’s convention. Test-only, no behavior change. Closes #6. -
The repeated
(committee_len / 2) + 1majority-threshold calculation invote_rotation,cancel_rotation, andresolveis now a singleSelf::majority_thresholdhelper. No behavior change;proptest_vote_countingalready exercises exactly this formula. -
CI now passes
--lockedto everycargo build/test/clippyinvocation, so aCargo.lockthat’s drifted from whatCargo.tomlwould currently resolve to fails the build loudly instead of Cargo silently re-resolving and using an unreviewed dependency graph. -
Added a
[workspace.lints.rust] warnings = "deny"table (with each crate opting in via[lints] workspace = true), so a localcargo buildenforces the same warnings-as-errors bar CI’s-D warningsflag does, instead of only CI catching it.
Fixed
-
finalizeis now blocked while paused, alongsideassert_outcome,dispute, andresolve. Previously a pending assertion could finalize uncontested even if its entire challenge window overlapped a pause, during whichdisputewas blocked, so it had no real opportunity to be challenged. Closes #36. -
initializenow rejects abond_amountaboveMAX_BOND_AMOUNT, the tighter of two independent overflow constraints: the asserter’s and disputer’s bonds summing pasti128::MAXin the token balance across a dispute, andfinalize’s reward-multiply (bond * finalize_reward_bps) overflowing before it divides. A compile-time guard fails the build if a future change to either constant reintroduces the overflow. Closes #34. -
Added
rust-toolchain.tomlpinning the exact Rust toolchain version, and fixed CI’s install step to actually respect it (dtolnay/rust-toolchain’stoolchaininput turned out to be hard-required with no file-reading fallback, so CI switched to plainrustupcommands, which auto-detect the pinned version). Previously CI floated onstable, so wasm codegen could silently drift between runs with no source change, the root cause of several confusing snapshot diffs this cycle. Closes #38. -
initializeandupdate_resolversnow reject a resolver committee containing duplicate addresses. A committee like[A, A, B]previously passed the odd-length check while being an effective electorate of two, silently breaking the “majority can never tie” guarantee, and could make the majority denominator unreachable in the worst case, stranding both bonds on a dispute nobody could resolve. Closes #35. -
Committed test snapshot JSONs no longer show up as spuriously modified on Windows checkouts. Added a
.gitattributesforcing LF line endings regardless of each contributor’s localcore.autocrlfsetting. Closes #39. -
Corrected stale documentation in DEPLOYMENT.md and GLOSSARY.md that still described
finalizeas callable without authorization;callerhas required auth unconditionally since thefinalize_reward_bpschange above. -
Persistent
Assertionstorage now has its TTL extended by 30 days on every write (assert_outcome,dispute,finalize,resolve), through a sharedset_assertionhelper. Previously only instance storage got a TTL bump, so a long-livedPendingorDisputedassertion could have its ledger entry archived before anyone acted on it. Closes #1. -
initializenow rejectschallenge_window_secsover 7 days, not just zero. A window close to the 30-day TTL bump left little margin forfinalizeorresolveto actually be called before the entry risked archival. Closes #2. -
The internal
NextIdread inassert_outcomenow goes through the sameNotInitialized-returning helper as every other storage read, instead of silently defaulting via.unwrap_or(0). No observable behavior change (the pause check already fails first on an uninitialized contract), but removes an inconsistent pattern. Closes #5. -
Added regression tests for
Error::NoRotationProposal, triggered via bothvote_rotationandcancel_rotationwhen no proposal is open. This closes the last CONTRIBUTING.md gap where a newErrorvariant introduced by the self-rotation feature lacked a triggering test; every newErrorvariant now has one.
[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.