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/RotationVoted/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 / RotationVoted /
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, emittingRotationVoted(resolver, their vote, and the current yes/no tally) so an indexer can see votes accumulate on an open proposal, 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: Implemented.
contracts/tholos-v2ships the mechanism this document proposes; seedocs/src/CONTRACT_V2.mdfor the authoritative interface reference. This document is retained as the design rationale and threat analysis behind that implementation, not as a still-open proposal. The “Questions for design review” section below records which of the original open questions the shipped code answers, and which remain genuinely open.Tracking: Issue #19, implemented across #64-#71 and following.
Versioning: “v1” and “v2” in this document name protocol designs. They are independent of the Rust crate’s current package version.
This document proposed 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. contracts/tholos-v2 implements that mechanism as
described below; where the shipped contract’s actual behavior differs from or
resolves an ambiguity in the original proposal, the “Questions for design
review” section at the end says so explicitly. Everything else in this document
still accurately describes the shipped design.
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 but revealed weight is a genuine majority (> half), the asserted outcome stands as the optimistic default. If revealed weight is at or below half (#167), the round is voided with bonds back and no forfeiture. 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. After a quorum-voided round (#167: revealed weight at or below half), every funded position recovers its exact principal with no forfeiture. 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. Both shipped exactly as proposed here; see questions 1 and 5 in “Questions for design review” below for how each was actually decided.
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; majority revealed, no side won (optimistic timeout)
Reveal --> Resolved: all weight revealed; tie; asserted outcome is default
Reveal --> Resolved: reveal deadline; revealed weight <= half (round voided, #167)
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, OptimisticTimeout, and RevealQuorumNotMet (#167: round voided when revealed weight is at or below half of eligible total). |
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 was approved: the shipped TimeoutDefaultRule has only the
boolean-preserving AssertedOutcomeStands variant (see question 1 below).
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.
The admin address itself can be rotated through set_admin(new_admin). The call
requires the currently stored admin’s signature, takes effect immediately, and
emits AdminUpdated with the old and new addresses. Rotation changes only who may
invoke the existing administrative controls; it cannot alter any assertion’s
pinned policy, weight, deadline, or outcome.
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 mechanism shipped as cancel_round (#71), a separate admin-only path
distinct from creation-only pause. See question 9 below for how the two
mechanisms now compose.
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
This list scoped the work that followed this proposal. All items below are now implemented or otherwise resolved:
- versioned v2 state, policy pinning, registration, and authorization (#64, #65, #66);
- commitment/reveal voting and weighted-majority property tests (#67, #68);
- liability accounting, permissionless credit accrual, owner-authorized withdrawals, rounding, and adversarial-token tests (#69, #70);
- TTL behavior for assertion, resolution, position, and credit records (#72);
- v1/v2 consumer bindings and a blue/green migration runbook (#73);
- testnet volume tests with many positions and concurrent disputes (#74, closed);
- economic simulation for window lengths, minimum bonds, whale capture, and
default frequency (#75, see
docs/src/V2_BOND_SIZING.md); and - an independent security audit before meaningful-value mainnet use — not
done. Tracked privately as a maintainer procurement decision rather than on
the public issue tracker (see
SECURITY.md’s Status section); this remains a hard blocker on mainnet use regardless of everything else in this list being complete.
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
Each question below is checked against the shipped contracts/tholos-v2/src/lib.rs
implementation. “Answered” means the shipped code makes a specific, checkable
choice; “still open” means the code is silent or leaves the question exactly
where this document left it.
- Should timeout preserve the optimistic boolean result, or should safety on
split votes take priority through an
Inconclusiveresult? — Answered.TimeoutDefaultRulehas exactly one variant,AssertedOutcomeStands(lib.rs:39-47), applied byclose_reveal_if_ready. The type stays an enum so a futureInconclusive-style rule could be pinned per-deployment without touching already-open assertions, but no such rule exists today: the optimistic default was chosen as proposed. - Should the minimum external position always equal the base assertion bond,
or be a separately pinned parameter? — Answered: always equal to
base_bond.min_resolution_bondremains a distinctPolicySnapshotV2field (lib.rs:391), butinitializetakes no parameter for it and hard-setsmin_resolution_bond: base_bond(lib.rs:717-720). It is structurally separate but behaviorally pinned, not independently configurable per deployment. - Which deposits qualify for the anti-sniping extension, and what hard
maximum prevents extension griefing? — Answered.
PolicySnapshotV2carriesanti_snipe_extension_secsandanti_snipe_hard_max_secs(lib.rs:393-396). Anyregistercall landing withinanti_snipe_extension_secsof the current soft deadline pushes it forward, capped atregistration_hard_deadline(lib.rs:1222-1232), itself bounded at initialization byMAX_ANTI_SNIPE_HARD_MAX_SECS(29 days,lib.rs:591). - Should all non-revealed stake be forfeited to the recipient set, and what
happens on a symmetric operational cancellation? — Answered. Non-revealed
weight is forfeited and redistributed pro rata via
settlement_pool(lib.rs:1664-1676) andsettle(lib.rs:1872-1880), as proposed. The symmetric cancellation this document flagged as a separate design surface is implemented ascancel_round(lib.rs:2069-2117, tracked as #71): it requires pause active first, and forfeits nothing (TerminalCause::AdminCancelledmakesrecipient_weightequal to the full eligible total, soforfeited_poolis always zero) — every position, on either side and in any phase, gets its principal back. - Is refund-every-revealed-principal timeout settlement sufficient, or is a
distinct non-refundable dispute fee required? — Still open. The shipped
rule is exactly the refund-all-revealed one this document proposed
(
lib.rs:1668,lib.rs:1858). No separate non-refundable dispute fee exists anywhere inPolicySnapshotV2orinitialize. Whether that’s sufficient deterrence against frivolous disputes hasn’t been separately economically validated beyond the frequency modeling indocs/src/V2_BOND_SIZING.md. - Is perpetual, restorable credit entitlement acceptable, and who funds
storage restoration after the initial grace? — Partially answered.
Perpetual, restorable
Creditentitlement is implemented, and #72 (seerecord_bump,lib.rs:783-802) sizes each record’s TTL bump dynamically from the pinned policy plus a 7-day settlement grace, rather than a flat constant. A permissionlessbump_ttl(id, address)exists (lib.rs:1737-1776) so any interested off-chain indexer or the position owner can keep a record live. But nothing in the protocol funds or requires those keeper calls — the “who funds restoration” half of the question is exactly as open as this document originally left it. - What canonical market/question identifier and evidence convention, if any,
is needed? — Still open, as expected. No market/question identifier or
evidence field exists on
AssertionV2orPolicySnapshotV2(onlyid: u64,outcome: bool, and the policy/hash fields). This document already framed this as “an adjacent design dependency, not a decision approved by this issue” (see the Threat analysis table above); issue #76 tracks it separately and remains open. - Should a later protocol tier allow appeals, or is one weighted round the
final tier? — Answered: one round is final, by explicit design.
const ROUND: u32 = 0;(lib.rs:630-635), withroundincluded in the vote commitment preimage purely as forward-compatibility scaffolding for a possible future multi-round tier that does not otherwise exist in the shipped contract. - Is a creation-only pause acceptable, or is a separate symmetric
freeze/cancel mechanism required? — Answered: both exist.
set_paused_v2remains creation-only, matching this document’s baseline recommendation. The additional symmetric mechanism this document flagged as needing separate design and audit iscancel_round(#71,lib.rs:2069-2117): admin-only, requires pause first, and refunds every position across every open phase with no forfeiture.
Three questions (5, 6, 7) remain genuinely open in the shipped implementation. None of them affect the correctness of what’s deployed, they are scope decisions the implementation made implicitly (no fee, no funded keeper incentive, no identifier convention) rather than ambiguities left unresolved in the code itself.
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 (in the configured token) as actually received by the contract, measured by balance delta across the transfer, so a fee-on-transfer token records less than the nominal amount. Pinned at the moment assert_outcome created the assertion; a later set_bond_amount call never changes it retroactively |
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. assert_outcome). Admin-only calls like update_resolvers don’t return this: admin is pinned by __constructor, not initialize, so they succeed as soon as the contract is deployed |
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 |
NoAdminRotationProposal | accept_admin called without a pending admin proposal |
SelfVote | Resolver is also the assertion’s asserter or disputer |
SelfDispute | The disputer is the same address as the asserter |
StallTimeoutNotConfigured | reclaim_stalled_dispute was called with no stall timeout configured (or on a pre-upgrade dispute with no DisputedAt entry) |
DisputeNotStalled | reclaim_stalled_dispute was called before the stall timeout elapsed since the dispute opened |
TokenTransferMismatch | An incoming token transfer didn’t increase the contract’s balance (non-positive or overflowing received amount), or an escrow/balance arithmetic overflow |
Functions
__constructor(admin)
The contract’s constructor: Soroban invokes it atomically as part of the
same operation that creates the contract instance, not as a separate,
later call. Requires admin’s signature and pins it as the admin for
this instance. This closes a front-running gap the
deploy-then-initialize(admin) shape used to have: since deploy and any
follow-up call are otherwise separate transactions, nothing used to stop a
third party from submitting their own initialize with their own admin
first and claiming the role on an instance someone else paid to deploy.
Because the host runs the constructor only during contract creation, no
later call, including initialize below, can invoke it again or hijack
the role at deploy time. From then on, only the current admin can hand the
role to a new address, via propose_admin/accept_admin (see below).
initialize(token, bond_amount, challenge_window_secs, resolvers, finalize_reward_bps)
One-time setup. Requires the signature of the admin __constructor fixed
at deploy time; this call takes no admin parameter of its own. 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. A size-1 committee is legal. If that sole resolver
later asserts or disputes, SelfVote plus the default stall timeout of 0
(fallback disabled) leaves the dispute unresolvable — a documented trade-off,
not a committee-size change. See resolve and reclaim_stalled_dispute. 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.
Fails with AlreadyInitialized if called twice.
propose_admin(new_admin)
Opens or replaces a deployment-admin rotation proposal. Requires the currently
stored admin’s signature; authority remains unchanged until the proposed address
accepts. Emits AdminRotationProposed.
accept_admin()
Completes the pending deployment-admin rotation. Requires the proposed new
admin’s signature, then replaces the stored admin and emits AdminUpdated with
both addresses. Fails with NoAdminRotationProposal when no proposal is open.
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.
set_bond_amount(new_bond_amount)
Updates the bond amount required for assertions created after this call. Requires
the stored admin’s signature. Same bounds as initialize: new_bond_amount must be
positive and no greater than MAX_BOND_AMOUNT. Pause-exempt, like update_resolvers
and set_paused. Emits BondAmountUpdated.
Has no effect on assertions already open: Assertion.bond pins the bond amount at
the moment assert_outcome created the assertion, and every payout path (dispute,
finalize, resolve) reads that field, never the live bond amount. Fails with
InvalidBondAmount if new_bond_amount is zero, negative, or above
MAX_BOND_AMOUNT, or NotInitialized if called before initialize.
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, RotationVoted is emitted (the resolver, their
vote, and the current yes/no tally), 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, AlreadyVoted, or SelfVote (the
resolver is also the assertion’s asserter or disputer) as appropriate.
On a size-1 committee, or any snapshot where excluding the asserter and
disputer leaves fewer voters than a strict majority, this means the dispute
cannot reach a majority. With the default stall timeout of 0,
reclaim_stalled_dispute is also disabled, so both bonds stay frozen.
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 |
BondAmountUpdated | set_bond_amount | bond_amount (the new value) |
AdminRotationProposed | propose_admin | new_admin, proposed_by |
AdminUpdated | accept_admin | old_admin, new_admin |
RotationProposed | propose_rotation | old_resolver, new_resolver, proposed_by |
RotationExecuted | vote_rotation, once a majority is reached | old_resolver, new_resolver |
RotationVoted | vote_rotation, on a vote that neither passes nor deadlocks the proposal | resolver, approve, yes_count, no_count |
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):
# Deploy, pinning admin as a constructor argument
CONTRACT=$(stellar contract deploy --wasm target/wasm32v1-none/release/tholos.wasm \
--source deployer --network testnet -- --admin "$DEPLOYER_ADDRESS")
# Initialize the rest of the deployment-wide policy; requires that same
# admin's signature
stellar contract invoke --id "$CONTRACT" --source deployer --network testnet -- initialize \
--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_pausedandupdate_resolversare still single-admin-key operations at any given moment, butpropose_admin/accept_adminlet the current admin rotate that key with explicit consent from the new admin.update_resolversis 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 size of any register deposit, first-time or a top-up, unless a position’s remaining headroom under max_position is smaller. Always equal to base_bond, so a third party can’t break a tie, or qualify for the anti-snipe extension, 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. Also bounded by MAX_TOTAL_WEIGHT_TO_POSITION_RATIO * max_position (ratio = 10) so a single actor must split across at least that many positions to approach the plutocratic threshold. |
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. |
InvalidWeightRatio | max_total_weight exceeds MAX_TOTAL_WEIGHT_TO_POSITION_RATIO * max_position (ratio = 10). Raises the minimum capital cost of Sybil-style address splitting. |
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 register deposit, first-time or a top-up, is below the effective minimum: policy.min_resolution_bond, or the position’s remaining headroom under policy.max_position if that’s smaller. |
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
__constructor(admin)
The contract’s constructor: Soroban invokes it atomically as part of the
same operation that creates the contract instance, not as a separate,
later call. Requires admin’s signature and pins it as the admin for
this instance. This closes a front-running gap the
deploy-then-initialize(admin) shape used to have: since deploy and any
follow-up call are otherwise separate transactions, nothing used to stop a
third party from submitting their own initialize with their own admin
first and claiming the role on an instance someone else paid to deploy.
Because the host runs the constructor only during contract creation, no
later call — including initialize below — can invoke it again or hijack
the role at deploy time. From then on, only the current admin can hand the
role to a new address, via set_admin (see below).
initialize(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 the signature of the
admin __constructor fixed at deploy time; this call takes no admin
parameter of its own (v1’s initialize uses the same __constructor
pattern; see CONTRACT.md).
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.
Residual plutocratic risk: initialize enforces
max_total_weight <= max_position * MAX_TOTAL_WEIGHT_TO_POSITION_RATIO
(currently 10), requiring an actor to control at least 10 distinct
positions to dominate the eligible total. This raises the capital cost of
Sybil-style address splitting but does not eliminate plutocracy: a
coalition controlling more than half of the eligible bonded capital can
still determine the result. Fully solving this requires an external
identity system outside this contract’s scope; see issue #168 and
V2_RESOLUTION.md’s “Non-goals and trust assumption” section.
get_policy() -> PolicySnapshotV2
Read-only lookup of the deployment-wide policy defaults new assertions are
currently pinned from. Fails with NotInitialized before initialize.
set_admin(new_admin)
Replaces the deployment admin. Requires the current admin’s signature —
the address __constructor originally pinned, or whoever it was last
rotated to. The old admin loses authority the instant this call succeeds;
there’s no grace period or two-step handoff. admin is pinned by
__constructor, not initialize, so this succeeds as soon as the
contract has been deployed — even before initialize is ever called.
Emits AdminUpdated { old_admin, new_admin }.
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.
bump_ttl(id, address)
Permissionlessly re-extends persistent storage TTL on an assertion’s
AssertionV2 and, if it exists yet, its Resolution, plus (if address
is given) that address’s Position and Credit entries. Every other write
path already bumps TTL as a side effect, but Position/Credit keys
aren’t enumerable on-chain, so a record nobody happens to touch again
before its own next write (a registered voter who never reveals, or
unclaimed credit) would otherwise only get renewed if that address’s own
owner eventually calls reveal/settle/withdraw. Any caller who knows
the relevant (id, address) key can call this, no signature required and
no funds move. A no-op, not an error, for any of the four entries that
doesn’t exist for this id/address. Fails only with AssertionNotFound
if id itself doesn’t exist.
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.
Every deposit, first-time or a top-up, must be at least
policy.min_resolution_bond, or the position’s remaining headroom under
policy.max_position if that’s smaller (BelowMinimumResolutionBond
otherwise), so a dust-sized top-up can’t qualify for the anti-snipe
extension below without representing a real change in position, while a
voter close to the cap can still top off the last of it. 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 current admin
(see set_admin), 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
to cover that specific assertion’s own worst-case active-phase horizon:
anti_snipe_hard_max_secs + reveal_duration_secs, plus a fixed 7-day
settlement/withdrawal grace period on top, converted from seconds to
ledgers and floored at 30 days (INSTANCE_BUMP_AMOUNT, the flat bump v1
uses). A deployment with a short anti_snipe_hard_max_secs gets the same
30-day floor v1-style deployments always had; a deployment configured with
a longer one gets a proportionally longer bump, computed fresh from that
assertion’s own pinned PolicySnapshotV2 on every write, so its records
can’t outlive their own TTL mid-round regardless of how the deployment is
tuned. test_dispute_sizes_resolution_and_position_ttl_from_policy_when_larger_than_instance_bump
in contracts/tholos-v2/src/test.rs verifies the sized case exceeds the
floor; test_assertion_storage_ttl_is_extended_on_finalize verifies the
floor case matches it exactly.
Because Position and Credit keys aren’t enumerable on-chain (the design
deliberately avoids an unbounded voter vector), a record nobody happens to
touch again before its own next write, most commonly a registered voter
who never reveals, or credit nobody withdraws promptly, would otherwise
only get its TTL renewed if that address’s own owner eventually calls
reveal/settle/withdraw. bump_ttl(id, address) (below) lets anyone
who knows the relevant (id, address) key, an off-chain indexer, the
address’s own owner, or anyone else with an interest in the record staying
live, renew it permissionlessly without needing to be that owner or move
any funds, closing the same gap v1 doesn’t have (v1 has exactly one
long-lived record type per assertion, kept alive by that same assertion’s
own frequent writes).
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 |
|---|---|
admin | Passed to stellar contract deploy as a constructor argument, not to initialize. Pinned atomically with contract creation, so nothing at deploy or initialize time can override it; the admin can hand the role to a new address later via propose_admin/accept_admin (see “Rotating the admin key” below). |
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. See V1_MAINNET_PARAMETERS.md for sizing guidance by monitoring tier. |
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. See V1_MAINNET_PARAMETERS.md for size and composition trade-offs, and RESOLVER_GOVERNANCE.md for the onboarding process behind picking these addresses in the first place. |
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, pinning admin as a constructor argument
CONTRACT=$(stellar contract deploy --wasm target/wasm32v1-none/release/tholos.wasm \
--source deployer --network testnet -- --admin "$ADMIN_ADDRESS")
# Initialize the rest of the deployment-wide policy; requires that same
# admin's signature
stellar contract invoke --id "$CONTRACT" --source deployer --network testnet -- initialize \
--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
Rotating the admin key
Rotation is two steps, so a typo’d or unreachable address can never permanently lock out admin control: the current admin proposes a target, and that target must separately accept before authority actually moves.
# Current admin opens the proposal.
stellar contract invoke --id "$CONTRACT" --source admin --network testnet -- propose_admin \
--new_admin "$NEW_ADMIN"
# The proposed address accepts, completing the rotation.
stellar contract invoke --id "$CONTRACT" --source new_admin --network testnet -- accept_admin
accept_admin emits AdminUpdated with both addresses. Afterward, use the
new signer for set_paused, update_resolvers, and future admin rotations.
Note this only ever protects a planned rotation: propose_admin itself
still requires the current admin’s signature, so if the current admin key
is genuinely lost or destroyed (not just being proactively rotated), there is
no on-chain recovery path — see MAINNET_RUNBOOK.md for
what that means for admin key custody on a real deployment.
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
This section covers the on-chain mechanics only: who is authorized to call what, and what each call does. For the off-chain side, who actually becomes a resolver, how their key is generated and secured, what they commit to, and which of the two paths below to use for a given real-world reason (resignation, inactivity, a compromised key, a conflict of interest), see RESOLVER_GOVERNANCE.md.
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. See MAINNET_RUNBOOK.md for the operational layer this checklist doesn’t cover on its own: admin key custody, a concrete go/no-go launch sequence, and incident escalation.
- 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
-
challenge_window_secsand resolver committee size/composition analyzed by monitoring tier and real measured per-operation cost; see V1_MAINNET_PARAMETERS.md. Wall-clock figures there are modeled, not yet confirmed against a live testnet run. See that document’s Part 5. - 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.
V1 mainnet parameter selection
This note covers the two initialize parameters that BOND_SIZING.md
does not: challenge_window_secs and the resolver committee (size and
composition). For bond_amount and finalize_reward_bps, use
BOND_SIZING.md directly; nothing here duplicates it.
A note on what “grounded in real data” means here
This analysis was written without live Stellar testnet access: the sandbox it
was produced in cannot reach Stellar’s testnet RPC or Friendbot (confirmed
with a direct stellar keys generate --fund, which failed with an HTTP 403
from the network egress layer, not a DNS or config problem). scripts/testnet-load.sh
could not be run end-to-end as a result.
What is real and measured in this document:
- Per-operation CPU/memory cost, measured directly against the actual
compiled
tholos.wasmusingsoroban-sdk’s local test environment (Env::cost_estimate().budget()), not estimated or looked up. See Part 1. - Network resource limits (100,000,000 CPU instructions per transaction), from Stellar’s published fees-and-metering documentation, a stable protocol-level constant rather than something that needs a live run to confirm.
What is not measured here and needs a live run to confirm precisely:
- Wall-clock transaction latency (ledger close time, RPC round-trip) for each
operation type under
scripts/testnet-load.sh’s own timing methodology. - Real-network behavior under concurrent/adversarial load rather than this document’s sequential local simulation.
Part 5 gives the exact commands
and a table to fill in once someone with testnet access runs
scripts/testnet-load.sh at the N/D scales below. Until that happens, treat
the wall-clock figures in this document as informed placeholders derived from
Stellar’s published ~5 second ledger close time, not measurements.
Part 1: per-operation cost (what testnet-load.sh would also show)
Measured by deploying the actual compiled contracts/tholos wasm into
soroban-sdk’s test Env, then calling each entry point and reading
Env::cost_estimate().budget() immediately after (this reads real cost from
executing the real wasm bytecode through the host VM, the same measurement
stellar-cli’s own simulation step would report against a live network,
minus the network round-trip itself). Methodology and exact numbers below are
reproducible with the snippet in the Appendix.
Baseline: N=20 assertions, D=8 disputed (a comparable scale to
scripts/testnet-load.sh’s own default of N=5, D=3, run larger to make any
scaling trend visible)
| Operation | Avg CPU instructions | Max CPU instructions | Avg memory (bytes) | Max memory (bytes) |
|---|---|---|---|---|
assert_outcome | 475,133 | 512,027 | 175,158 | 187,794 |
dispute | 560,170 | 577,441 | 206,758 | 211,117 |
resolve (per call; 2 calls needed for a 3-member committee) | 508,340 | 517,665 | 194,544 | 197,296 |
finalize | 527,810 | 536,391 | 195,503 | 198,121 |
Against the network’s 100,000,000 CPU instruction ceiling per transaction,
every operation here uses well under 1% of budget. Compute is not a binding
constraint on any of these four operations at realistic scale; the
bond_amount and challenge_window_secs choices should be driven by the
economic and human-response considerations in this document and
BOND_SIZING.md, not by resource exhaustion concerns.
An unexpected finding: cost grows with the contract’s total lifetime usage
Repeating assert_outcome against the same contract instance shows cost
climbing steadily, not staying flat:
| Assertions so far | Avg CPU instructions for the next assert_outcome |
|---|---|
| 1–25 | 475,283 |
| 26–50 | 557,672 |
| 51–75 | 633,736 |
| 76–100 | 709,434 |
| 101–125 | 782,106 |
| 126–150 | 856,139 |
The very first call in a fresh instance costs 429,829 instructions. The
150th costs 890,671, almost exactly double, and the growth is close to
linear (~3,100 extra instructions per prior assertion). This repeats a
pattern already flagged for bond_amount sizing purposes in
BOND_SIZING.md: usage-dependent
cost isn’t unique to this operation, but this document is the first place it’s
been directly measured.
Each assert_outcome call writes a new, separate persistent storage entry
(DataKey::Assertion(id), one per id, not a shared growing collection), so
this isn’t a case of one contract-level data structure growing unbounded. The
most likely explanation is that Soroban’s ledger read/write cost accounting
is sensitive to the total footprint the transaction’s ledger-entry
accesses touch, which can grow as a contract’s overall persistent storage
does, but this document does not have access to Soroban’s cost-model source
to confirm that specific mechanism with certainty. Regardless of the exact
cause, the growth itself is real and repeatable.
At the still-tiny fractions of the CPU ceiling involved (0.9% of budget even
at call 150), this remains far from a practical constraint for any
deployment lifetime measured in thousands of assertions. It is worth
re-measuring periodically (e.g. with the appendix snippet, at whatever N
approximates a deployment’s expected multi-year assertion count) rather than
assumed to stay negligible forever, and is flagged here mainly because
nothing in the existing documentation mentions it at all.
Part 2: challenge_window_secs
scripts/testnet-load.sh’s CHALLENGE_WINDOW_SECS=120 exists purely so an
automated CI-style run finishes in about two minutes rather than hours. It is
not, and was never intended to be, a production candidate. The script picks
it for the same reason a unit test doesn’t sleep for a real day. Running the
script, even with full testnet access, would not produce a “real-world
dispute-response time” figure, because the script has no human in the loop
deciding whether to dispute: nothing about its timing reflects how long an
actual interested party takes to notice a bad assertion and act.
What actually bounds challenge_window_secs
The window has to be long enough for the realistic chain of events between
“a bad assertion is posted” and “a disputer’s dispute transaction lands”:
t_notice Time for a monitoring party (the asserter's counterpart, an
indexer operator, a manual watcher) to become aware the
assertion exists at all.
t_verify Time to check the assertion against the real-world fact it
claims, which may require off-chain research, human judgement,
or waiting on an external data source.
t_decide Time to decide disputing is worthwhile, weighing the bond cost
against the expected value of correcting the assertion.
t_submit Time to actually construct, sign, and submit the dispute
transaction (seconds, from Part 1, not the bottleneck).
t_submit is the only one of these Part 1’s measurements bear on directly,
and it’s the smallest by a wide margin: dispute itself costs 560K
instructions, executes in well under a network ledger close cycle
(Stellar’s mainnet ledger close time is approximately 5 seconds), and
imposes no meaningful floor on the window on its own. The window is bounded
by t_notice + t_verify + t_decide, which are entirely human/operational
factors this document cannot measure and depend on what a given deployment
is actually securing.
Sizing guidance
There is no formula that converts “monitoring infrastructure exists” into a
specific number of seconds the way BOND_SIZING.md’s
bond_amount formula does, because t_notice/t_verify/t_decide are
governed by who is watching and how automated their process is, not by
anything the contract or network expose. What can be stated concretely:
- 120 seconds (the load-test value) is not viable for any real deployment. No realistic monitoring setup notices a new assertion, independently verifies its claim, and submits a dispute transaction within two minutes, even with dedicated automation. Treat this purely as a load-test artifact, exactly as the issue that requested this analysis flagged it.
- A deployment with dedicated, automated dispute monitoring (a keeper
bot watching
Assertedevents against a known-good data feed, empowered to dispute automatically) is bounded mainly byt_notice(event propagation and indexing lag, typically seconds to low minutes) andt_submit(seconds). A window in the 1–6 hour range gives comfortable margin over automated response without meaningfully delaying honest finalization. - A deployment relying on manual/human monitoring (an integrator’s team,
or the general public, watching for bad assertions without automation)
needs to budget for
t_noticemeasured in hours (someone has to actually look),t_verifythat may involve real research, andt_decidethat may wait on a human decision-maker’s availability (time zones, working hours, on-call rotations). A 12–48 hour window is a reasonable starting range; below 6 hours risks systematically favoring assertions that happen to post outside a monitor’s active hours. - Every hour added to
challenge_window_secsis an hour added to the uncontested-assertion finality time (Pending->finalize-eligible). Size the window against how time-sensitive a correct-but-slow resolution is for what’s actually being asserted; a deployment where finality speed matters more than dispute coverage should lean toward the shorter end of its monitoring tier, not stretch the window further “to be safe.”
The canonical testnet deployment’s own window is informative, not exemplary
DEPLOYMENT.md uses 21600
(6 hours) for the canonical testnet instance. That sits within the automated-
monitoring range above, but the canonical deployment’s resolvers are
resolver1/resolver2/resolver3 test identities with, per DEPLOYMENT.md’s
own words, “no real-world accountability behind them yet.” Its window
choice reflects testnet convenience, not a production recommendation either.
Don’t copy it directly into a mainnet initialize call without reasoning
through the monitoring tier above for your specific deployment.
Part 3: resolver committee size and composition
initialize enforces odd-length, non-zero, distinct, at most 21 addresses
(DuplicateResolvers otherwise); resolve requires a strict majority
(more agrees_with_asserter votes than the alternative) among that fixed,
per-dispute-snapshotted committee. Composition can only change afterward via
update_resolvers (admin override) or propose_rotation/vote_rotation
(self-rotation, one slot at a time, majority vote); see
CONTRACT.md for the exact mechanics of both paths.
Size trade-off
| Committee size | Majority needed | Trade-off |
|---|---|---|
| 1 | 1 | A single point of failure and a single point of trust; any dispute resolves at that one party’s sole discretion. Only appropriate when the “committee” is genuinely one trusted, accountable operator and the deployment accepts that concentration explicitly. |
| 3 | 2 | Smallest size with any redundancy: one resolver being slow, unavailable, or compromised still leaves 2 who can act. This is what the canonical testnet deployment and scripts/testnet-load.sh both use. |
| 5 | 3 | Tolerates 2 simultaneous unavailable/compromised resolvers rather than 1. Meaningfully more resilient than 3 for a modest coordination-cost increase (each dispute still needs only a bare majority to act, not unanimity). |
| 7+ (odd, up to 21) | (n/2)+1 | Diminishing returns per added seat: coordination cost (getting a majority of a larger group to actually vote in a reasonable time) grows roughly linearly while the marginal resilience gain per seat shrinks. Justified mainly when the resolver role itself needs broad, visible representation (e.g. a public curated list) rather than for redundancy alone. |
Recommendation: default to 3 for anything below “meaningful value at
stake,” move to 5 once bond sizes or assertion values reach a point where a
single compromised or colluding pair of resolvers becomes an attractive
target, and treat anything above 7 as a governance/legitimacy choice rather
than a security one. resolve’s gas/compute cost (Part 1) is not a
constraint on committee size in either direction; the practical ceiling is
how many people can realistically be reached and vote within
challenge_window_secs once a dispute opens.
Composition
- Reachability within the window matters more than credentials. A
resolver who cannot be reached to vote within
challenge_window_secsmight as well not be on the committee for that dispute; a slow committee stalls every disputed assertion until enough of it acts (per DEPLOYMENT.md’s own guidance). Pick people with a demonstrated pattern of being available on the timescale the window implies, not simply the most qualified reviewers in the abstract. - Independence reduces collusion and correlated-failure risk. Resolvers who share an employer, a wallet-custody provider, or a single point of off-chain coordination (the same group chat, the same on-call rotation) aren’t functioning as independent checks even if they’re distinct addresses; a single incident (that provider’s outage, that group’s compromise) can take out a majority at once.
- Self-rotation lets the committee evolve without an admin override,
but only by strict majority and one seat at a time. It cannot recover
a committee that’s lost the ability to reach majority at all (e.g. more
than
(n-1)/2resolvers simultaneously unresponsive).update_resolversis the explicit break-glass path for that scenario; a real deployment needs a plan (who holds the admin key, under what conditions they’d use it) for exercising it, not just the contract-level capability existing.
Part 4: explicit assumptions
The guidance above depends on what a given deployment is actually securing. This document does not know that for any specific deployer, so it states the assumption ranges the sizing tiers above are built around:
| Input | Assumed range | Where it matters |
|---|---|---|
| Expected assertion value | Low-value (informational/reputational) to moderate (meaningful but not life-changing per assertion) | Drives which challenge_window_secs monitoring tier and which committee-size tier apply. This document does not model a high-value tier (see below). |
| Expected dispute frequency | Occasional, not every assertion | The whale/spam-style attack models in BOND_SIZING.md already cover adversarial dispute volume; this document assumes disputes are the exception, not the norm, when reasoning about resolver reachability load. |
| Monitoring sophistication | Ranges from no dedicated monitoring to a single automated keeper; does not assume a professional, multi-operator monitoring service | If a deployment has stronger guarantees here (e.g. a paid monitoring SLA), the window can be sized shorter than Part 2’s manual-monitoring tier suggests. |
Not covered by this document: a “higher-value mainnet candidate” tier analogous to BOND_SIZING.md’s own top profile, where assertion values are large enough to justify professional, SLA-backed monitoring and a larger, more formally accountable resolver committee (real legal identity, insurance, or similar). That tier needs deployment-specific threat modeling this document can’t assume generically, and should be revisited once Tholos has a concrete deployment targeting that value range.
Part 5: filling in the live testnet numbers
Run these once against real testnet access to replace this document’s
modeled wall-clock figures with measured ones, and to get the phase timing
scripts/testnet-load.sh itself reports (setup, assertion/dispute/resolve/
finalize phase durations, and average invocation latency):
# Small scale, close to the script's own default
bash scripts/testnet-load.sh 5 3
# Matches this document's Part 1 baseline
bash scripts/testnet-load.sh 20 8
# Larger scale, to see whether Part 1's cost-growth finding is visible in
# real wall-clock timing too (e.g. later resolves/finalizes taking
# perceptibly longer than earlier ones due to increasing ledger footprint)
bash scripts/testnet-load.sh 50 20
Record, for each run:
| N | D | Setup (s) | Avg assert (s) | Avg dispute (s) | Avg resolve (s) | Avg finalize (s) | Total wall-clock (s) |
|---|---|---|---|---|---|---|---|
| 5 | 3 | ||||||
| 20 | 8 | ||||||
| 50 | 20 |
If the per-operation wall-clock times climb across runs the way Part 1’s CPU instruction counts do, that’s independent confirmation of the cost-growth finding via a completely different measurement method (real network timing instead of local instruction counting), and would be worth its own follow-up investigation into the root cause.
Appendix: reproducing these measurements
The exact test used to produce Part 1’s numbers (not committed to the test
suite, since it’s a one-off benchmark rather than a correctness test –
paste it into contracts/tholos/src/test.rs inside mod test { ... } to
rerun or extend it):
#![allow(unused)]
fn main() {
#[test]
fn measure_real_operation_costs_for_v1_bond_sizing_doc() {
extern crate std;
use std::println;
let f = Fixture::new();
const N: u32 = 20;
const D: u32 = 8;
let asserter = f.generate();
f.mint(&asserter, i128::from(N) * DEFAULT_BOND);
let disputer = f.generate();
f.mint(&disputer, i128::from(D) * DEFAULT_BOND);
let mut assert_cpu = std::vec::Vec::new();
let mut ids = std::vec::Vec::new();
for i in 0..N {
f.env.cost_estimate().budget().reset_default();
let id = f.client.assert_outcome(&asserter, &(i % 2 == 0));
assert_cpu.push(f.env.cost_estimate().budget().cpu_instruction_cost());
ids.push(id);
}
// ... dispute/resolve/finalize loops follow the same
// reset_default() -> call -> cpu_instruction_cost() pattern.
let avg = assert_cpu.iter().sum::<u64>() / assert_cpu.len() as u64;
println!("assert_outcome avg CPU: {avg}");
}
}
Run with:
cargo test -p tholos measure_real_operation_costs_for_v1_bond_sizing_doc -- --nocapture
Resolver committee governance: onboarding and offboarding
This is the human, off-chain half of running a resolver committee. See
DEPLOYMENT.md for the on-chain
mechanics (update_resolvers, propose_rotation, vote_rotation) and
ROTATION_DESIGN.md for why self-rotation works the way it
does. Neither of those documents says who should hold a resolver seat, how that
person secures their signing key, what they’re expected to do when a dispute
lands, or what actually happens the day a resolver needs to be replaced. This
document is that operational layer, for anyone running a real (non-toy)
deployment.
None of this is enforced by the contract. initialize, update_resolvers, and
propose_rotation accept any distinct addresses; nothing on-chain checks who
controls them, how the key was generated, or whether the person behind it agreed
to anything. The guarantees below come entirely from the deployment operator
choosing to follow this process and documenting that they have.
Onboarding a new resolver
Selection criteria
V1_MAINNET_PARAMETERS.md
already establishes the two properties that matter most for anyone on the
committee: reachability within challenge_window_secs, and independence from
the other resolvers (no shared employer, custody provider, or coordination
channel that turns several addresses into one correlated point of failure).
Onboarding is where those properties get checked before a seat is handed out,
not after:
- Domain competence. A resolver has to be able to actually judge the
claims this specific deployment adjudicates. What that means varies by
deployment: for a freelance-escrow use case (see
demos/freelance-escrow) it might mean familiarity with the kind of work being disputed; for an oracle-style deployment it might mean the ability to verify a claimed real-world outcome from public sources. Generic trustworthiness isn’t a substitute for being able to tell a good assertion from a bad one in the deployment’s actual domain. - Demonstrated reachability. Ask for evidence, not a promise: a track
record of availability on a timescale compatible with
challenge_window_secsfor this deployment. Someone who is highly qualified but unreachable within the window is worse for the committee than someone less qualified who reliably responds. - Independence from the existing committee and from likely parties. Screen for shared employer, shared custody provider, and shared off-chain coordination (the same group chat, the same on-call rotation) with current resolvers, per the composition guidance already linked above. Also screen for relationships to parties likely to assert or dispute on this deployment; see the conflict-of-interest section below for why this matters beyond what the contract itself checks.
- No conflicting role in the deployment. A resolver shouldn’t also be an
operator with an economic stake in outcomes (e.g. the deployer taking a cut
of disputed bonds) beyond the ordinary
finalize_reward_bpsmechanism, which applies uniformly and isn’t resolver-specific.
Key generation and custody
The resolver’s signing key is what resolve, propose_rotation, and
vote_rotation authenticate against, so its custody model should match what
the resolver seat is actually worth to an attacker: the value of the disputes
it’s likely to swing, not the deployment’s total value locked.
- Plain single key (
stellar keys generate). What the canonical testnet deployment uses today, and reasonable for a low-value or clearly-labeled testnet/staging deployment where the committee itself is a stopgap (see DEPLOYMENT.md’s own caveat aboutresolver1/resolver2/resolver3). Fastest to set up, weakest guarantee: a single lost or leaked key is a fully compromised resolver seat with no recourse except rotating them out. - Hardware wallet. Recommended default once a deployment has any real
value at stake. The resolver holds one address’s worth of signing power on
a device that never exposes the private key to a networked machine.
Meaningfully raises the bar against remote compromise without adding
operational complexity for the resolver’s day-to-day
resolvecalls, and doesn’t require coordinating with anyone else to vote. - Multisig behind the resolver’s address. Appropriate when a resolver seat represents an organization rather than an individual (e.g. a curated public list, or the “broad, visible representation” case V1_MAINNET_PARAMETERS.md describes for 7+-member committees), or when a single resolver’s vote carries enough weight that no one person should be able to cast it unilaterally. Adds coordination overhead to every single vote, which cuts against reachability within the challenge window; weigh that cost against the deployment’s actual value at stake before choosing this over a hardware wallet.
Whichever model is chosen, the resolver (not the deployment operator) should control the key material. The operator’s role is validating that a reasonable custody model is in place before proposing the address for the committee, not holding the key on the resolver’s behalf.
Commitments a resolver makes
Before an address is proposed for initialize, update_resolvers, or
propose_rotation, the person behind it should explicitly agree to:
- A response-time commitment compatible with
challenge_window_secs. State it in concrete terms (e.g. “will vote on an open dispute within N hours of being notified”) rather than “will be reachable,” so both sides can later judge objectively whether it was kept. This is the off-chain counterpart to the reachability criterion above, made auditable. - Conflict-of-interest disclosure, both at onboarding and ongoing.
resolverejects a vote where the resolver is also the assertion’sasserterordisputer(SelfVote, added in PR #203, closing #165). That check is necessarily narrow. It only catches the resolver being a direct party to the specific dispute in front of them. It cannot catch a resolver with an undisclosed financial or personal relationship to one of the parties, since the contract has no way to know about relationships that don’t show up as an on-chain address match. A resolver should disclose any such relationship to the rest of the committee (and recuse informally by voting neither way) the moment they become aware a specific dispute implicates it. This disclosure is not enforced by the contract and depends entirely on the resolver’s own good faith. - Notice before going dark. A resolver who knows they’ll be unreachable for an extended period (travel, planned unavailability) should say so ahead of time, so the committee can decide whether to route around it or start a replacement conversation before a dispute actually stalls.
None of these commitments are enforceable by the contract; they’re operating norms the deployment should document and hold resolvers to. Keeping a record of who agreed to what (and when) is what makes a later removal-for-cause defensible instead of an admin’s unilateral judgment call.
Offboarding and replacement
What triggers a replacement
- Inactivity. A resolver who repeatedly misses their response-time
commitment, or who has gone dark without notice. Note the distinction from
the contract’s own liveness fallback:
reclaim_stalled_dispute(when a deployment hasset_stall_timeoutconfigured) unwinds a single stalled dispute by returning both bonds with no winner — it resolves that one case, it does not remove the unresponsive resolver from the committee. Persistent inactivity is a reason to replace the resolver, not just a reason to reclaim the dispute they stalled. - Compromised key. Any signal that a resolver’s signing key may have leaked or been used without their authorization. Treat this as urgent: see the admin-override guidance below.
- Conflict of interest. A disclosed (or discovered) relationship to a party that makes continued service on the committee inappropriate, whether or not any specific vote was actually affected.
- Resignation. A resolver who no longer wants or is able to serve.
Who initiates it
Either the affected resolver themselves (resignation, disclosed conflict, planned unavailability) or another committee member who observes a problem (inactivity, a suspected compromise, an undisclosed conflict they’ve learned of). The deployment operator (the admin key holder) can also initiate a replacement, but for anything short of a compromised key or a stalled committee, initiating through the committee’s own self-rotation path is preferable; see the choice of path below.
Choosing self-rotation vs. the admin override
Both paths write the same committee; the choice is about who should be making the decision and how urgently it needs to happen, not a technical constraint (both are documented in full in DEPLOYMENT.md):
- Use self-rotation (
propose_rotation/vote_rotation) for the routine cases: resignation, a disclosed conflict of interest, planned unavailability, or inactivity the rest of the committee can still act on without the outgoing resolver’s cooperation. This keeps the decision inside the committee, requires no admin involvement, and is the expected path for anything that isn’t an emergency. A resolver proposes their own removal (or another resolver proposes it, naming the affected seat), and the rest vote. - Use the admin override (
update_resolvers) only for the emergency cases: a compromised key that needs to be cut off immediately, or a committee that has lost the ability to reach majority at all (more than(n-1)/2resolvers simultaneously unreachable, uncooperative, or otherwise unable to vote). This is exactly the deadlock scenariodocs/src/ROTATION_DESIGN.mdcalls out: a compromised or deadlocked committee cannot be expected to self-heal by vote, which is why the admin path exists at all and stays pause-exempt. Using it for a routine, uncontested resignation is a bad default even though it’s technically capable of doing the same thing — it reintroduces the single-admin-key trust path that self-rotation exists to avoid, for a case that didn’t need it.
A real deployment needs to decide, in advance, who holds the admin key that can exercise this override and under what conditions they’re expected to use it (per V1_MAINNET_PARAMETERS.md’s own framing) — not discover the answer for the first time during an incident.
Practical notes on executing a replacement
- A rotation only ever affects disputes opened after it executes; the
outgoing resolver keeps their vote on any dispute already snapshotted
against the old committee (see
update_resolversin CONTRACT.md anddocs/src/ROTATION_DESIGN.md’s section on the per-dispute snapshot). If a resolver is being removed for a compromised key or an active conflict, check whether they’re a snapshotted voter on any currentlyDisputedassertion; removing them from the live committee does not retroactively remove their vote there. - Confirm the incoming resolver’s key and custody model (per the onboarding
section above) before the rotation executes, not after — once
vote_rotationreaches majority, the swap is immediate. - Document the reason for every replacement (which of the four triggers
above applied) alongside the on-chain
RotationExecuted/ResolversUpdatedevent. The event proves that the committee changed; it doesn’t record why, and that reasoning is what makes the deployment’s dispute history auditable later.
Mainnet operational runbook
This is prep work, written ahead of the audit clearing, not a signal that mainnet deployment is currently authorized. SECURITY.md is unambiguous: Tholos has not had an external security audit, and isn’t for mainnet deployments securing meaningful value until it has one. Nothing in this document changes that. It exists so the operational half of a mainnet launch — who holds which keys, what order to do things in, who gets paged when — is worked out and reviewed now, rather than drafted under time pressure the day the audit clears. Treat every “should” below as a plan to review at that point, not a plan already put into action.
DEPLOYMENT.md already covers parameter selection (backed by V1_MAINNET_PARAMETERS.md), the deploy sequence, and the on-chain mechanics of the admin runbook. RESOLVER_GOVERNANCE.md already covers resolver key custody, onboarding, and offboarding in depth. This document doesn’t repeat either. It covers what’s still missing: admin key custody specifically (the one role neither existing document addresses), a concrete go/no-go launch sequence tying the existing readiness checklist into an actual procedure, and incident escalation specifics for a mainnet context.
Part 1: admin key custody
RESOLVER_GOVERNANCE.md
already lays out the custody spectrum (plain key, hardware wallet, multisig)
for resolver seats. The admin role needs the same kind of decision, but its
risk profile is different enough to warrant its own treatment: a single
address controls set_paused, set_bond_amount, set_stall_timeout,
update_resolvers, and propose_admin — every lever that doesn’t require
resolver-committee consensus. Unlike a resolver, the admin acts alone; there
is no majority-vote check on any admin call the way there is on resolve.
What’s actually at stake if the admin key is compromised
The admin’s on-chain powers are all operational, not custodial — nothing in this list lets the admin move a bond or an assertion’s funds to an arbitrary address. But an attacker with the admin key can still do real damage:
set_paused(true)freezes the entire deployment (no new assertions, disputes, resolves, or finalizes) for as long as the attacker holds the key or until detected and countered.update_resolversreplaces the entire committee with addresses the attacker controls, after which every future dispute resolves however the attacker wants — a materially worse outcome than pausing, since it’s not obviously visible as an incident until a dispute actually resolves wrong.set_bond_amountandset_stall_timeoutcan be set to values that grief future assertions (an unaffordable bond, a stall timeout so short legitimate disputes get force-reclaimed, or so long a stalled one is frozen indefinitely) without being an outright fund-theft mechanism.propose_adminto an attacker-controlled address, followed byaccept_adminfrom that address, completes a full admin takeover. The two-step design (see DEPLOYMENT.md) protects against a mistyped address, not a malicious one with its own signature ready.
There is no on-chain recovery from a lost or compromised admin key
This is the fact custody planning has to be built around: propose_admin
requires the current admin’s signature (see
CONTRACT.md). If the admin key is
destroyed, lost, or seized before a rotation is proposed, there is no
contract-level path to recover the role — the deployment is stuck with
whatever admin-gated state it was last in (paused or not, whatever the
resolver committee and bond amount were) permanently. This is a deliberate
trade-off, not an oversight: any recovery mechanism that didn’t require the
current admin’s signature would itself be a takeover path. The mitigation is
entirely in custody strength and rotation discipline, not in anything the
contract can do after the fact.
Custody recommendations
- Never a plain single key for a mainnet deployment with real value at stake. The admin’s blast radius (every lever above, exercisable unilaterally) is larger than any single resolver seat’s, so the weakest custody tier that’s acceptable for a low-value resolver seat isn’t acceptable here.
- Multisig is the reasonable default, not just an option for
organizational resolver seats the way
RESOLVER_GOVERNANCE.md
frames it for resolvers. The admin role has no per-action peer check the
way
resolvedoes, so a multisig is the only way to introduce one: no single compromised or rogue signer can unilaterally pause, rewrite the committee, or take over the role. - Decide the signer set and threshold before deploying, not after. Who
holds a key, how many signatures an admin action requires, and how a
signer is replaced if they leave or their key is compromised, are all
things
propose_admin/accept_admincan execute once decided, but the contract has no opinion on any of it. Document the decision the same way RESOLVER_GOVERNANCE.md asks resolver commitments to be documented, so it’s auditable rather than tribal knowledge. - Plan the rotation cadence and the compromise-response path together.
A periodic planned rotation (e.g. annually, or on signer turnover) uses
propose_admin/accept_adminnormally. A suspected compromise needs the same call made under time pressure, racing an attacker who may have the same key — decide in advance who’s authorized to initiate an emergency rotation and how fast the remaining signers can produce a quorum, rather than discovering the answer during the incident. See Part 3’s escalation guidance for the detection side of this.
Part 2: go/no-go launch sequence
DEPLOYMENT.md’s checklist states what should be true before launch. This section is the order to verify and execute it in, and what happens if something goes wrong in the first hours and days after.
Before the launch window
- Confirm every unchecked item on the readiness checklist is either checked or explicitly accepted as a known gap. In particular: independent security audit, and real-world dispute volume (all testing to date is synthetic). Launching against real value with either unaddressed is a decision someone with the authority to accept that risk needs to make explicitly, not an oversight.
- Finalize and sign off on parameters (
bond_amount,challenge_window_secs,finalize_reward_bps, resolver committee) per V1_MAINNET_PARAMETERS.md and BOND_SIZING.md.challenge_window_secsandfinalize_reward_bpsare written once, insideinitialize, and permanently fixed at deployment with no setter at all — get them right before deploying, not after.bond_amountand the resolver committee are not permanently fixed the same way:bond_amounthas its own admin setter (set_bond_amount), and the committee can change post-deployment via the admin’supdate_resolvers(see Part 1) or resolver self-rotation — both are correctable later, unlike the other two. - Confirm every resolver has completed onboarding per RESOLVER_GOVERNANCE.md: custody model in place, response-time commitment made, conflict-of-interest disclosure done. Do not launch with a placeholder or stopgap committee the way the canonical testnet deployment explicitly does — that caveat exists precisely to distinguish testnet convenience from a mainnet-ready committee.
- Confirm the admin custody model is in place per Part 1: signer set
assembled, threshold agreed, and — if using a multisig — the multisig
itself deployed and tested with a harmless call (e.g.
set_paused(false)when already unpaused, a genuine no-op) before it holds the real admin role. - Assemble the incident response roster per Part 3: who gets paged, in what order, and confirm they know it before launch, not after the first incident.
- Sign-off. Whoever has the authority to accept the residual risk in step 1 explicitly approves proceeding, on the record (a dated comment, a signed statement, whatever the deployment’s own governance requires) — not an implicit “nobody objected.”
Launch
- Deploy and
initializeper DEPLOYMENT.md, substituting the mainnet-specific values from steps 2–4 for the testnet example values shown there. - Verify the submitted
initializetransaction’s arguments match what was signed off on before announcing the contract id publicly or pointing any real value at it. This contract has no getters forbond_amount,challenge_window_secs,finalize_reward_bps, the resolver set, or the admin address (get_assertion_state(id)is the only public read entrypoint, and it returns per-assertion state, not configuration — see CONTRACT.md), andinitializeemits no event either, so there is no independent on-chain query to check these against after the fact. The only real verification available is theinitializetransaction itself: read back its submitted arguments from a block explorer (or from your own CLI output, before it’s even confirmed) and diff them against step 2–4’s signed-off values. A mismatch here (a typo in a CLI argument, the wrong token address) is far cheaper to catch before anyone has posted an assertion than after. - Run one low-stakes assertion through the full happy path (assert,
wait out the challenge window, finalize) before directing real users or
real value at the deployment, the same way
scripts/testnet-smoke.shdoes on testnet. This confirms the deployed bytecode and configuration actually behave as expected against real network conditions, not just in the test suite.
First hours and days after launch
- Heightened monitoring for a defined initial window (a period the launch sign-off should set explicitly, e.g. 72 hours or the first N assertions) — closer attention than steady-state operation, since this is when a misconfiguration or an attack targeting the newly-live deployment is most likely to surface and most damaging if missed.
- Rollback plan, scoped to what’s actually possible. There is no
“undo the deployment” primitive — funds already locked in open
assertions can’t be un-locked except through the contract’s own
dispute/resolve/finalize/
reclaim_stalled_disputepaths. What is available as a rollback lever if something looks wrong early:set_paused(true)immediately, stopping new activity while the issue is assessed (see Part 3).- Steering users away from the deployment (stop advertising the contract id, redirect integrators) if the issue is severe enough that continuing to onboard new assertions would compound it, even though pause alone doesn’t prevent that.
- There is no path to migrate already-open assertions to a fixed redeployment; a genuinely broken deployment’s open assertions have to be wound down through the existing dispute/finalize/reclaim mechanisms, however long that takes. Decide, before launch, who has the authority to trigger this pause unilaterally during the heightened-monitoring window without waiting for the normal incident escalation in Part 3 to run its course — early hours after launch is exactly when speed matters most and confidence in root cause is lowest.
Part 3: incident escalation
Who gets paged, how fast
Not prescribed as specific names or tools here, since that’s deployment-specific infrastructure, but the decision to make in advance:
- A tier for anything touching the admin key (a suspected compromise, an admin action nobody recalls authorizing) — page immediately, all admin-multisig signers, regardless of time of day. This is the single highest-severity category: Part 1 established there’s no on-chain recovery from a lost admin key, so speed in the window before an attacker acts is the only lever available.
- A tier for resolver-committee problems (a resolver reports a compromised key, a dispute has been open unusually long without votes) — page the remaining resolvers and whoever holds the admin key, on a shorter fuse than routine operations but not necessarily the same immediate all-hands as an admin-key incident.
- A tier for contract-behavior anomalies (a transaction failing in an
unexpected way, state that doesn’t match what the code should produce) —
page whoever can assess whether this is a real bug versus a
misunderstanding of expected behavior, since
set_pausedis cheap insurance while that assessment happens (see the decision tree below).
Decision tree: set_paused vs. resolver rotation vs. reclaim vs. wait
| Situation | Action | Why |
|---|---|---|
| A dispute resolves in a way that looks wrong, but the resolvers who voted did so within their normal process | Wait. Let the normal dispute flow run. | A resolution you disagree with isn’t necessarily a broken resolution; second-guessing individual votes after the fact undermines the entire point of having a committee. Revisit resolver composition (per V1_MAINNET_PARAMETERS.md) if this becomes a pattern, not a one-off override. |
A specific dispute has been open far longer than challenge_window_secs would suggest, with no votes landing | reclaim_stalled_dispute, if set_stall_timeout is configured (see its doc comment in contracts/tholos/src/lib.rs; not yet in CONTRACT.md) — otherwise consider update_resolvers if the cause is a genuinely dead committee. | This is exactly the scenario the stall-timeout fallback exists for: a specific stuck case, not a systemic problem. It resolves that one dispute (no-winner, both bonds returned) without touching the committee itself. |
| A resolver reports (or is suspected of) a compromised key | update_resolvers (admin override), immediately, per RESOLVER_GOVERNANCE.md | This is explicitly the emergency case self-rotation isn’t designed for: don’t wait for a majority vote that the compromised seat could itself help block or corrupt. |
| A resolver resigns, discloses a conflict, or is inactive, with no urgency | Self-rotation (propose_rotation/vote_rotation) | The routine case; keeps the decision inside the committee per RESOLVER_GOVERNANCE.md, no admin action needed. |
| A bug is suspected in contract behavior itself (not a specific dispute’s outcome), or vote/finalize/dispute behavior looks inconsistent with the documented spec | set_paused(true), immediately, investigate second | Per DEPLOYMENT.md: a Pending assertion whose window elapses during a pause simply waits rather than finalizing uncontested, so pausing costs little while buying time to assess whether this is a real bug. |
| Any signal the admin key itself may be compromised | Emergency propose_admin/accept_admin rotation, racing to complete it before an attacker acts, using whatever quorum the admin custody model requires | Per Part 1: there is no on-chain recovery once an attacker completes their own rotation first, so this is the one scenario where speed dominates every other consideration, including normal multisig-quorum-gathering timelines. |
The common thread: reach for set_paused when the contract’s behavior
itself is in question, reach for the resolver-rotation paths when the
committee is the problem, reach for reclaim_stalled_dispute when it’s
one specific stuck case rather than a systemic one, and do nothing when the
process worked as designed even if a specific outcome is unwelcome.
Treating every unwelcome outcome as an incident erodes the credibility of
the committee the same way never intervening when something is genuinely
broken erodes trust in the deployment.
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. admin is the exception worth calling out separately: it’s
pinned by the constructor at deploy time, not passed to initialize at
all, and can only change afterward if the current admin itself authorizes
a handoff via set_admin (see Admin runbook below) —
there’s no deploy-time or initialize-time input that can override it:
Core parameters
| Parameter | Guidance |
|---|---|
admin | Passed to stellar contract deploy as a constructor argument, not to initialize. Controls set_paused_v2, cancel_round, and its own rotation via set_admin only; a hostile or lost admin key can grief active assertions via those levers but has no direct profit path (no fee-taking, no fund-sweeping power). Pick an address whose key custody you trust: nothing at deploy or initialize time can override it, though the admin can hand the role to a new address later via set_admin if needed. |
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, at most max_total_weight, and at least ceil(max_total_weight / 10) (the contract strictly enforces max_total_weight <= 10 * max_position at initialize, reverting with InvalidWeightRatio if max_position is below 10% of max_total_weight). Setting it equal to max_total_weight removes the whale constraint; setting it to 10%–20% of max_total_weight provides strong Sybil resistance while staying within valid initialization bounds. |
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 strictly enforces max_total_weight <= 10 * max_position (anti-Sybil ratio bound) and max_total_weight <= MAX_SETTLEMENT_TOTAL_WEIGHT (10^19). Set it to a realistic bound on the total value you want to put at risk in any one dispute (e.g., 10–50x your base bond), ensuring max_position is sized to at least 10% of this total. |
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
admin is pinned by the contract’s constructor (__constructor), invoked
atomically as part of the deploy operation itself, not by a later call: pass
it to stellar contract deploy as a constructor argument, after the --.
This closes the front-running window a separate deploy-then-initialize(admin)
step used to leave open (see the __constructor entry in
CONTRACT_V2.md’s Functions section).
initialize no longer takes admin at all; it authenticates against the
admin already fixed at deploy.
# Build the optimized wasm
cd contracts/tholos-v2 && stellar contract build
# Deploy, pinning admin as a constructor argument
CONTRACT=$(stellar contract deploy --wasm target/wasm32v1-none/release/tholos_v2.wasm \
--source deployer --network testnet -- --admin "$ADMIN_ADDRESS")
# Initialize the rest of the deployment-wide policy; requires that same
# admin's signature
stellar contract invoke --id "$CONTRACT" --source deployer --network testnet -- initialize \
--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.
Rotating the admin
If the admin key needs to change (planned custody handoff, or a compromised key that’s still able to sign), the current admin can hand the role to a new address:
stellar contract invoke --id "$CONTRACT" --source admin --network testnet -- set_admin \
--new_admin "$NEW_ADMIN_ADDRESS"
This takes effect immediately: the old admin loses set_paused_v2/cancel_round/set_admin authority
the instant the call succeeds, with no grace period. If the current admin’s key is lost outright (not just
compromised), there’s no recovery path — set_admin requires the current admin’s own signature, so a lost
key means the role is stuck until redeployment.
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.
V2 bond sizing and window analysis
This note models the deployment-time choices unique to protocol v2: the
base_bond / min_resolution_bond floor, the registration and reveal window
lengths, anti-sniping headroom, and the per-address position cap. The models are
not a substitute for live telemetry, but they give deployers a repeatable way to
pick initial values and revisit them as usage changes.
For the v1 bond-sizing framework (spam floors, dispute floors, and finalize-reward
constraints that still apply to base_bond) see BOND_SIZING.md.
This document extends that analysis with the three problems that are new in v2.
Background: v2 outcome rule
Every disputed assertion produces a frozen eligible-weight total W at the
registration cutoff. W includes the asserter’s base_bond, the disputer’s
base_bond, and every third-party position registered before the cutoff.
A side wins by strict majority: revealed weight for that side must exceed
W / 2. If neither side crosses that threshold when the reveal window closes,
the optimistic timeout default applies and the originally asserted outcome
stands.
Settlement differs by terminal cause:
- Strict majority: winning positions recover principal and share the losing and non-revealed pool pro rata. Losing and non-revealed positions are forfeited.
- Optimistic timeout: all revealed positions (on either side) recover principal and share only the non-revealed pool. Non-revealed positions are forfeited.
Those two rules make non-revelation a double loss: the non-revealer forfeits its bond and in a strict-majority scenario, its forfeiture is paid to the winning side rather than returned to it. Under a timeout default, non-revealers still forfeit to revealed positions.
Part 1: default-frequency model
The core asymmetry
The optimistic timeout assigns the outcome to the asserted side unless a revealed
majority actively overturns it. Non-revealed weight sits in the denominator W
but contributes no votes. This means a large pool of registered-but-silent
positions can make an honest majority impossible without being a majority itself.
Formally, let:
W= frozen eligible totalF= revealed weight agreeing with the asserted outcomeA= revealed weight againstN= non-revealed weight (N = W - F - A)
The assertion stands whenever A <= W / 2. Because N > 0 inflates W, even a
large A may not cross the threshold.
Default-frequency formula
For a dispute where the true outcome is against the asserted side, the assertion nevertheless survives if:
A / W <= 0.5
Define t = (F + A) / W as the reveal turnout rate (fraction of eligible
weight that actually reveals). For a symmetric dispute where roughly half the
revealed weight sides with each party:
A ~= t x W / 2
Then the assertion survives if:
t x W / 2 <= W / 2 -> t <= 1.0
That is always true: turnout alone cannot prevent a timeout. The meaningful
question is what reveal turnout t is needed for the against side to reach a
strict majority when the split of revealed weight is skewed toward the losing
(assertion-supporting) side.
Let s be the fraction of revealed weight on the against side (honesty
share). A strict-majority against-the-assertion requires:
s x t > 0.5
-> t > 0.5 / s
Against-side honesty share s | Minimum turnout t for correction |
|---|---|
| 100% (all revealers disagree) | > 50% |
| 80% | > 62.5% |
| 66% | > 75.8% |
| 51% | > 98% |
| 50% | impossible (tie never crosses 50%) |
Key finding: when honest disagreers are 66% of revealers, three-quarters of all eligible weight must reveal for correction. When they are 51%, virtually the entire pool must reveal. Any non-reveal (absent staking, lost keys, sleeping coordinators) benefits the asserted outcome.
The issue-body example
The V2 design discussion raised this scenario explicitly:
1% reveals for the assertion, 49% reveals against, 50% does not reveal.
Here W = 100, F = 1, A = 49, N = 50. The against-side has 49 units,
but the threshold is W / 2 = 50. The assertion stands because 49 < 50.
More generally, any time N >= 1 unit makes the strict-majority threshold
unattainable for the against-side, the assertion stands regardless of the
revealed margin. This is the intended behavior: the optimistic default favors the
status quo and treats abstention as implicit delegation to it.
Implications for window sizing
The default-frequency risk is not eliminated by longer windows; it is controlled by ensuring that enough bonded capital can reach the reveal phase. The levers are:
registration_duration_secs: a longer window lets more third-party positions arrive, growingWwith counter-stake rather than diluting a smallWwith silence.reveal_duration_secs: a longer reveal window lets coordinators organize reveals. Too short a window and registered positions may silently miss it.min_resolution_bond=base_bond: a higher floor means fewer positions but each one is more economically committed. It does not by itself prevent default.
The practical guard against frequent timeout-by-silence is ensuring that the
disputer plus a realistic cohort of third-party registrants can represent a
strict majority of W. With only two equal fixed positions (2 x base_bond) and
no third parties, W = 2 x base_bond, A = base_bond, and A / W = 0.5; the
disputer’s weight never exceeds half. A lone dispute always times out unless at
least one additional third party registers on the against side. See Part 3 for the
window-length tradeoffs that govern third-party participation.
Part 2: whale-capture model
Definitions
A whale is a single address or coalition that can unilaterally reach strict
majority in one dispute. In v2 this means controlling more than W / 2 of
eligible weight at the registration cutoff.
Because weight is strictly proportional to bonded capital and splitting across addresses provides no extra weight, the analysis is purely about capital concentration.
Let:
B=base_bond(asserter’s and disputer’s positions, each)R_w= whale’s registration deposit (one or more top-ups summing to at mostmax_position)R_h= total honest counter-stake registered by non-whale addressesW = 2B + R_w + R_h
The whale controls strict majority when:
R_w > W / 2
-> R_w > (2B + R_w + R_h) / 2
-> 2R_w > 2B + R_w + R_h
-> R_w > 2B + R_h
Concentration threshold
Define c as the whale’s share of total eligible weight:
c = R_w / W
The whale reaches strict majority when c > 0.5. But the cost to reach that
threshold depends on how much honest counter-stake R_h is present:
Minimum R_w for capture = 2B + R_h + 1 unit
With zero counter-stake (R_h = 0):
R_w > 2B -> just over twice the base bond is enough to capture majority
With counter-stake equal to the base bond (R_h = B):
R_w > 3B -> just over three times the base bond required
With counter-stake equal to five times the base bond (R_h = 5B):
R_w > 7B -> just over seven times the base bond required
Each additional unit of honest counter-stake raises the whale’s capture cost
one-for-one, so growing W with genuine third-party positions is the primary
defense.
Role of max_position
Setting max_position < max_total_weight prevents a single address from
contributing all of W. If max_position = p x base_bond:
Maximum single-address weight share = p / (2 + p) when R_h = 0
max_position (multiples of base_bond) | Max share of W (no counter-stake) |
|---|---|
| 1x (equal to each fixed party) | 33% |
| 2x | 50% exactly; cannot reach majority |
| 3x | 60% |
| 5x | 71% |
| 10x | 83% |
unlimited (= max_total_weight) | 100% |
Key finding: setting max_position = 2 x base_bond means a single address
with no counter-stake can only reach exactly 50%, which is not a strict majority.
Setting it at 3 x base_bond allows capture with no counter-stake. This is the
most impactful single-parameter defense: even if no third party registers,
max_position <= 2 x base_bond prevents a whale from reaching majority on its own.
However, max_position <= 2 x base_bond also caps legitimate counter-stake, which
means a single large honest voter cannot provide the full majority on its own
either. Whether that is acceptable depends on the deployment’s expected electorate
size. For a deployment expecting 3+ participants in a contested dispute, tight
position caps are advisable. For a two-party deployment, the caps may be
irrelevant.
Self-hedging and the cost illusion
A coalition that controls both the asserter role and a whale-sized registration
deposit can attempt to control both the assertion and the majority vote. If the
coalition has asserter position B (agreeing) plus registration R_w > W / 2
(also agreeing), it controls majority at total cost B + R_w. If the honest
side wins instead, the StrictMajorityAgainst settlement rule pays only
positions where agrees_with_outcome == Some(false); the asserter’s
Fixed(true) position has agrees_with_outcome == Some(true) and is therefore
forfeited along with R_w. The coalition’s real net exposure when it loses is
B + R_w, not R_w alone. There is no self-hedging benefit: the asserter bond
does not reduce the coalition’s downside.
A deployer should therefore think of capture cost as B + R_w in both the
success and the failure case. The minimum capital at risk for a capture attempt
is B + R_w, where R_w > 2B + R_h. A max_position limit forces the whale
to assemble multiple addresses, but linear weight prevents splitting from
amplifying its combined vote; it only complicates logistics and raises capital
requirements slightly.
Part 3: window-length tradeoff model
Variables
| Symbol | Meaning |
|---|---|
T_reg | registration_duration_secs |
T_rev | reveal_duration_secs |
T_hard | anti_snipe_hard_max_secs |
T_ext | anti_snipe_extension_secs |
t_react | Typical time for a new voter to notice the dispute and decide to register |
t_coord | Time needed to coordinate reveals off-chain (signal, salt collection) |
t_atk | Time an attacker needs to move capital into a sniped deposit |
Registration window: participation vs. capture speed
A longer T_reg window increases expected third-party participation by giving
more potential voters time to notice the dispute and post bonds. Every unit of
honest counter-stake R_h added during registration raises the whale’s capture
cost one-for-one (Part 2). But a longer window also extends the period during
which a well-funded attacker can observe accumulated positions, compute the
remaining gap to majority, and deploy exactly enough capital to cross it at
the last moment.
The tradeoff:
Participation benefit peaks when: T_reg >= t_react (typical voter latency)
Capture risk is manageable when: T_reg < time attacker needs to move capital
For most deployments these are the same order of magnitude (minutes to hours), which is why anti-sniping exists. The practical guidance:
- Use
T_reg >= t_reactto capture genuine participation. - Use
T_ext(anti-snipe extension) to deter last-second position injections that arrive after honest voters cannot respond. - Cap extensions at
T_hardto prevent an adversary from grieving registration by repeatedly triggering extensions.
Anti-sniping parameters
The soft deadline extends by T_ext when a qualifying deposit arrives within
T_ext seconds of the current deadline, but can never exceed T_hard. The
extension fires repeatedly if deposits keep arriving near the moving edge.
Maximum extensions before hard cap: floor((T_hard - T_reg) / T_ext)
For example, T_reg = 3600, T_ext = 300, T_hard = 7200:
Maximum extensions: floor((7200 - 3600) / 300) = 12 extensions of 5 minutes each
An attacker who tries to snipe must arrive at least T_ext seconds before the
hard deadline or its deposit will push the deadline out, giving honest voters
time to respond. Each extension also exposes the attacker’s deposit size, letting
honest counter-stake calculate the correct response.
Setting T_ext too small (e.g., 1 second) makes sniping easy; a last-second
deposit arrives and the extension is trivially short. Setting T_ext too large
(equal to T_reg) means a single late deposit nearly doubles the registration
window, which may itself be a griefing vector. A range of 5%-15% of T_reg is
generally appropriate.
Reveal window: participation vs. delay
A longer T_rev gives registered voters more time to compute and submit their
reveal transaction. The risk of a short T_rev is that registered positions
silently miss the reveal deadline and become non-reveals, forfeiting their bond
and, more importantly, inflating N in a way that can prevent a legitimate
majority (Part 1).
However, every second of T_rev adds directly to the total time from dispute
to Resolved. The total dispute lifecycle is approximately:
Total dispute time ~= T_hard + T_rev
The tradeoff is:
T_rev choice | Effect on default frequency | Effect on finality latency |
|---|---|---|
Very short (< t_coord) | High: reveal coordination may fail | Low |
Moderate (>= t_coord) | Low: coordinators have time to act | Moderate |
| Very long | Negligible marginal gain after saturation | High |
Recommended: set T_rev >= t_coord with at least a 2x safety margin. For most
disputes, t_coord is dominated by off-chain response time (monitoring, human
decision, client submission), not raw transaction throughput. 6 hours to 24 hours
is typical; sub-hour windows risk high default rates unless the deployment
guarantees automated reveal infrastructure.
Window-length summary table
| Scenario | Longer window helps | Shorter window helps |
|---|---|---|
| High default frequency | T_rev (more reveal time) | n/a |
| Low third-party participation | T_reg (more registration time) | n/a |
| Last-second sniping | T_ext, T_hard | n/a |
| Slow resolution / time-sensitive outcomes | n/a | T_reg, T_rev |
| Griefing via repeated extensions | n/a | T_hard (set conservatively) |
Part 4: recommended starting profiles
These profiles follow the same three-tier structure as BOND_SIZING.md.
They assume min_resolution_bond = base_bond (the contract enforces this). All
time values are in seconds; convert to your deployment’s ledger approximation.
Inputs to collect
Use token units consistently. The v1 inputs (V_min, C_assert, C_dispute,
R_case, K_spam, K_dispute, etc.) still apply to base_bond. The additional
v2 inputs are:
| Symbol | Meaning |
|---|---|
t_react | Typical time for an interested third party to notice a dispute and register, in seconds. |
t_coord | Time needed for off-chain reveal coordination: collecting salts, computing commitments, submitting transactions. |
N_voters | Expected number of distinct third-party registrants in a contested dispute. |
max_honest_stake | Expected total third-party honest counter-stake in a typical dispute, in token units. |
V_dispute | Economic value that could be captured by falsely resolving one assertion. |
Base-bond formula (extending v1)
The v2 minimum bond satisfies all v1 constraints plus one additional check:
base_bond >= max(
R_case / max(1, K_spam),
R_case / max(1, K_dispute),
target_attacker_loss - min(C_assert, C_dispute),
min_finalizer_reward x 10_000 / max(1, reward_bps),
V_dispute / capture_cost_multiplier <- new in v2
)
capture_cost_multiplier is the minimum multiple of base_bond an attacker must
stake to reach majority with no counter-stake. From Part 2, with zero
counter-stake this is 2B + 1, meaning capture_cost_multiplier ~= 2. With
realistic counter-stake the multiplier grows. Use 2 as the conservative
(zero-counter-stake) floor.
Affordability cap (unchanged from v1):
base_bond <= V_min x max_acceptable_bond_share
max_position guidance
Set max_position as a multiple of base_bond:
max_position = k x base_bond, k in [2, 20]
k = 2: a single address cannot reach majority alone (even with no counter-stake). Maximally whale-resistant but restricts legitimate large-stake participants.k = 5-10: a single address needs at least 5-10x honest counter-stake before becoming a threat. Suitable for most deployments.k = max_total_weight / base_bond: no per-position constraint. Use only in private / trusted deployments.
max_total_weight guidance and ratio bound reconciliation
max_total_weight = m x base_bond, where:
max(10, N_voters x k_avg + 2) <= m <= 10 x k
The contract enforces the anti-Sybil ratio bound m <= 10 x k (max_total_weight <= 10 * max_position) at initialize.
Mathematical Reconciliation with Electorate Size (N_voters):
If every voter were assumed to deposit the full cap k = max_position / base_bond, the capacity formula m >= N_voters * k + 2 together with the ratio bound m <= 10 * k would require N_voters * k + 2 <= 10 * k, which mathematically forces N_voters <= 10 - 2/k (at most 9 full-cap voters).
In real deployments with larger committees (N_voters > 10), this is reconciled through:
- Average vs. Peak Sizing: When budgeting for larger committees, size
magainst the expected average stakek_avg x base_bond(wherek_avg < k), satisfyingN_voters x k_avg + 2 <= m <= 10 x k. - Ceiling vs. Whale Trade-Off:
- To raise the aggregate counter-stake ceiling
m, you must raisekproportionally (e.g. settingk = 10permitsm = 100, allowing up to 98xbase_bondof third-party stake; settingk = 20permitsm = 200). The trade-off is that a largerkallows a single well-capitalized address to hold a larger individual fraction of total weight. - To maximize single-address whale resistance (e.g.
k = 3),mmust be capped at30, which restricts total third-party counter-stake to roughly 28xbase_bond.
- To raise the aggregate counter-stake ceiling
Profile table
| Profile | base_bond | T_reg | T_ext | T_hard | T_rev | max_position | max_total_weight | Use when |
|---|---|---|---|---|---|---|---|---|
| Private beta | 1x-2x spam floor | 1-4 h | 5 min | 2x T_reg | 4-12 h | 10x base_bond | 50x base_bond | Known users, coordinated reveals, low bot pressure. |
| Public testnet / low value | 2x-5x larger spam floor | 4-12 h | 10 min | 3x T_reg | 12-24 h | 5x base_bond | 50x base_bond | Open participation, moderate value, expect uncoordinated voters. |
| Higher-value mainnet candidate | 5x+ spam floor, within 5%-20% of V_min | 12-24 h | 15-30 min | 2x-4x T_reg | 24-48 h | 3x base_bond | 30x base_bond | Meaningful value, monitored reveals, audited deployment. |
Narrative guidance per profile
Private beta
The electorate is known. Reveals can be coordinated quickly. Default frequency
is low because coordinators will not silently miss the reveal window. The main
risk is a single insider dominating a dispute, so max_position = 10 x base_bond
is an informational bound rather than a security control (all participants are
trusted). A 1-4 hour registration window is long enough for beta testers to
notice a dispute; a 4-12 hour reveal window is long enough for manual
coordination. Set anti_snipe_extension_secs conservatively at 5 minutes to
prevent last-second test disputes from stalling registration.
Public testnet / low value
The electorate is open but economic stakes are low. Default frequency is a
secondary concern because a wrong resolution has limited financial impact.
The primary concern is UX: long enough windows so new users can register and
reveal without requiring dedicated keeper infrastructure, short enough that
resolutions complete within a day or two. A 4-12 hour registration window with
10-minute anti-snipe extensions and a 12-24 hour reveal window is a reasonable
starting point. Set max_position = 5 x base_bond to require at least 5 units
of honest counter-stake before a single whale can threaten majority.
Higher-value mainnet candidate
Every parameter is tightened toward security. Registration is 12-24 hours to
allow global participation across timezones. The reveal window is 24-48 hours
to give coordinators a full working day to organize reveals. For the profile in the table,
max_position = 3 x base_bond with max_total_weight = 30 x base_bond forces a whale to recruit
multiple addresses and raises logistics cost, capping single-address weight at 10% of total round
capacity while bounding aggregate third-party counter-stake to roughly 28x base_bond. Deployments
prioritizing higher counter-stake ceilings (e.g. 100x–200x base_bond) can scale max_position
proportionally to 10x–20x base_bond to preserve the 10x ratio bound. The anti-snipe extension is longer (15-30 minutes) to give honest
participants meaningful response time to a late deposit. The hard cap is 2-4x
the base registration window to allow 4-8 meaningful extensions without
permitting indefinite delay. The bond floor is computed at 5x+ the v1 spam/dispute
floor, checked against V_dispute / 2, and capped at 5%-20% of V_min.
Part 5: scenario checks
Default frequency check
Before launch, verify that a realistic dispute can correct a false assertion. With your chosen parameters:
- Assume only the disputer (one
base_bondagainst) plusN_votersthird parties each postingbase_bond. - Compute
W = 2 x base_bond x (1 + N_voters / 2)under a symmetric scenario where half the third parties side with each party. - Against-side weight:
base_bond x (1 + N_voters / 2). - Required threshold: strictly more than
W / 2. The contract uses the comparisonside_weight > W - side_weightrather thanside_weight > W / 2: integer division rounds down, which would wrongly pass a side sitting exactly at the boundary on an oddW. For an integerW, this means against-side weight must be at leastfloor(W / 2) + 1.
If N_voters is small (e.g., 1), then W = 3 x base_bond, against-side weight
is 1.5 x base_bond, and the required threshold is floor(3B / 2) + 1; with
integer bonds, against-side needs at least floor(3B / 2) + 1 units. For
base_bond = 2 this is 4 against 3, requiring one more unit on the honest side.
A deployment expecting zero third parties cannot deterministically correct a false
assertion: the disputer’s lone bond reaches exactly W / 2 at W = 2 x base_bond,
which does not satisfy side_weight > W - side_weight.
Rule of thumb: budget N_voters >= 3 realistically achievable third-party
registrants for a deployment that intends to correct false assertions without
relying on a dominant single voter. Note that N_voters >= 3 is a floor for a
healthy electorate, not a sufficient condition for correction on its own. The
model above assumes a symmetric split of revealed weight, under which the
against-side weight equals exactly W / 2 regardless of N_voters: correction
always requires an honest-side imbalance (more revealed weight against than
for), not just a larger headcount. A deployment with three third-party voters
all revealing on the honest side will correct a false assertion; a deployment
where half reveal for the asserted outcome will not, regardless of how many
participants registered.
Whale-capture check
For your chosen base_bond, max_position, and expected max_honest_stake:
Minimum capture bond = 2 x base_bond + max_honest_stake + 1 unit
If minimum capture bond > max_position, capture is impossible for a single
address regardless of counter-stake. If minimum capture bond <= max_position,
the whale risk exists and should be controlled by expecting max_honest_stake
to be present in contested disputes.
Worked example (higher-value mainnet candidate profile):
base_bond = 100unitsmax_position = 300units (3x)- Expected honest counter-stake:
200units (two third parties atbase_bondeach)
Minimum capture: 2(100) + 200 + 1 = 401 units > max_position (300)
A single address cannot reach majority even with no honest counter-stake beyond the two fixed parties. The deployment is whale-resistant by construction under this example.
Window-length sanity check
Verify:
T_rev >= 2 x t_coord (2x safety margin on reveal coordination)
T_reg >= t_react (voters can notice and register)
T_ext >= t_atk / 10 (extension is nontrivial relative to attacker response time)
T_hard <= T_reg + 20 x T_ext (hard cap prevents indefinite extension griefing)
If T_rev < t_coord, shorten the coordination process, add keeper automation, or
increase T_rev. If T_hard would exceed your acceptable total dispute lifetime
(T_hard + T_rev), tighten the anti-snipe parameters or reduce T_ext.
Monitoring and adjustment
Review after every testnet campaign and before mainnet launch:
- Fraction of disputes that end in
OptimisticTimeoutvs.StrictMajority. A high timeout rate with many registered positions suggests the reveal window is too short or coordination is breaking down. A high timeout rate with few or no third-party registrants may indicate the registration window or bond floor is too high for the expected electorate. - Distribution of
Wacross disputes. IfW ~= 2 x base_bondconsistently, third-party participation is absent; revisit window lengths and bond sizing. - Non-reveal rate among registered third-party positions. High non-reveal rates
inflate
Nand drive default frequency. Investigate whetherT_revis too short, whether keepers are missing reveals, or whether registrants are strategically abstaining. max_positionhits. If deposits are being rejected for exceedingmax_position, either the cap is too tight for the dispute’s capital or a whale is probing. Examine addresses to distinguish the two.- Total dispute lifecycle time (from
disputetoResolved). If disputes are consistently completing well inside the maximum window, the windows may safely be shortened for future deployments; if they are consistently running to the deadline, lengthen the reveal window first.
Raise base_bond if capture attempts cluster around small numbers of addresses
or if false assertions survive repeated disputes. Lengthen T_rev if the
non-reveal rate is high. Tighten max_position if large single-address positions
appear in contested disputes. Revisit the full calculation whenever the token
price, assertion value distribution, or expected electorate changes materially.
V2 anti-snipe throttle analysis
This note analyses whether the anti-snipe extension mechanism in register()
needs a per-position throttle — a flag (or restriction to first-time deposits
only) that would prevent a single address, or a coordinated set of addresses,
from retriggering the extension more than once. It follows the same approach as
V1_MAINNET_PARAMETERS.md and
V2_BOND_SIZING.md: work from the contract as it actually
exists, model the cost a potential attacker faces, and only recommend adding
mechanism where the existing constraints leave a real gap.
Background: how the extension works today
register() in contracts/tholos-v2/src/lib.rs applies an anti-snipe
extension whenever a qualifying deposit lands within the last
anti_snipe_extension_secs of the current soft deadline:
#![allow(unused)]
fn main() {
if now >= resolution.registration_deadline
.saturating_sub(assertion.policy.anti_snipe_extension_secs)
{
let extended = now + assertion.policy.anti_snipe_extension_secs;
resolution.registration_deadline =
extended.min(resolution.registration_hard_deadline);
}
}
Two hard limits bound this:
-
registration_hard_deadline: set once atdispute()time asnow + anti_snipe_hard_max_secs. No sequence of extensions can pushregistration_deadlinepast this value.initialize()enforcesanti_snipe_hard_max_secs >= registration_duration_secs, so the hard cap is always at least as far out as the original window. -
#155effective minimum (effective_minimum = min_resolution_bond.min(max_position.saturating_sub(previous_amount))): every deposit — whether first-time or a top-up — must individually meet this floor. A deposit that is too small to meet the floor is rejected outright, so it cannot trigger the extension at all.
There is no per-position “already extended” flag and no restriction to
first-time deposits only. A single address can retrigger the extension on
each subsequent top-up, and a Sybil actor funding fresh addresses was never
affected by #155 (a first-time deposit was always required to clear
min_resolution_bond).
Part 1: the attack surface and its actual cost
Single-address repeated top-up
An address that has already registered can retrigger the extension by
top-up. The maximum number of extensions it can trigger is bounded by
max_position / min_resolution_bond: each qualifying top-up must be at
least min_resolution_bond (unless it’s filling the final headroom gap),
and the total position cannot exceed max_position. With typical parameters:
max_position = 5 × min_resolution_bond → at most 5 extensions per address
max_position = 10 × min_resolution_bond → at most 10 extensions per address
Each of those extensions costs exactly min_resolution_bond in real
token capital — capital that is now locked in the position and subject to
forfeiture if the address is on the losing side.
The number of extensions the entire registration window can absorb is
bounded by max_total_weight:
Maximum extensions from all addresses combined
≤ floor((max_total_weight - 2 × base_bond) / min_resolution_bond)
(The two base_bond fixed positions — asserter and disputer — are already
locked at dispute() time and cannot trigger further extensions.)
Sybil actor with fresh addresses
A Sybil attacker funding n fresh addresses can trigger up to n first-time
extensions, each costing min_resolution_bond and again capped by
max_total_weight. A Sybil attack is therefore not qualitatively different
from the single-address repeated top-up: both are bounded by
max_total_weight and both cost at least min_resolution_bond per extension
trigger.
The hard deadline: an unconditional ceiling on total exposure
The critical constraint that does not appear in either of the above counts
is registration_hard_deadline. Regardless of how many extensions fire, the
soft deadline can never be pushed past dispute_time + anti_snipe_hard_max_secs.
The total number of extensions the hard cap absorbs is:
Maximum extensions before hard cap =
floor((anti_snipe_hard_max_secs - registration_duration_secs)
/ anti_snipe_extension_secs)
For the profiles in V2_BOND_SIZING.md:
| Profile | T_reg | T_ext | T_hard | Max extensions |
|---|---|---|---|---|
| Private beta | 3 600 s (1 h) | 300 s (5 min) | 7 200 s (2 h) | 12 |
| Public testnet | 14 400 s (4 h) | 600 s (10 min) | 43 200 s (12 h) | 48 |
| Higher-value mainnet | 43 200 s (12 h) | 1 800 s (30 min) | 172 800 s (48 h) | 72 |
The hard cap does two things at once: it limits the wall-clock damage an
attacker can do (registration can only be extended so far), and it limits
the number of extension triggers that are even possible, because the
soft deadline stops moving once it reaches the hard cap. This means
max_total_weight and registration_hard_deadline together determine the
worst-case attack budget: the attacker needs at most
floor((T_hard - T_reg) / T_ext) extensions worth of capital, each costing
at least min_resolution_bond.
Part 2: is the current deterrent sufficient?
The question the issue raises is whether the existing constraints —
registration_hard_deadline as the absolute ceiling and #155’s
effective_minimum as the per-deposit floor — already make repeated
triggering sufficiently costly without any additional per-position throttle.
The attacker’s cost floor
An attacker who wants to push registration_deadline to registration_hard_deadline
must trigger enough extensions to span the gap T_hard - T_reg. The number
of extensions required is ceil((T_hard - T_reg) / T_ext), and each
extension costs at least min_resolution_bond. Total attack cost:
attack_cost_min = ceil((T_hard - T_reg) / T_ext) × min_resolution_bond
For the mainnet candidate profile above (T_hard - T_reg = 36 h, T_ext = 30 min,
min_resolution_bond = base_bond):
attack_cost_min = 72 × base_bond
A deployment with base_bond = 100 tokens would require at least 7 200 tokens
of real capital held at risk through the entire dispute, every unit of it
subject to forfeiture on the losing side. This is not a cheap or consequence-free
operation.
Comparing to max_total_weight
The attack cost floor above — 72 × base_bond — far exceeds the
max_total_weight = 30 × base_bond cap for the mainnet profile (and the
28 × base_bond of third-party headroom once the asserter and disputer
bonds are reserved). This is precisely why the attacker cannot actually
trigger all 72 extensions under that profile: max_total_weight is
exhausted long before the hard deadline is reached, making it the binding
constraint. The real maximum attacker spend is bounded by the lower of the
two limits:
practical_attack_ceiling
= min(attack_cost_min, max_total_weight - 2 × base_bond)
= min(72 × base_bond, 28 × base_bond)
= 28 × base_bond
For the mainnet candidate profile, the Sybil / top-up attacker cannot push
the deadline all the way to registration_hard_deadline — the
max_total_weight cap kicks in first and the extension simply stops firing
once the total is full.
This means the existing configuration already limits both the attacker’s
total capital cost and the total deadline extension to what max_total_weight
can absorb, which is less than what T_hard would theoretically permit.
The legitimate-participant mirror
Every constraint that binds an attacker also binds a genuine late-arriving
participant. A participant who wants to register multiple top-ups late in the
window faces the same min_resolution_bond floor and the same max_position
cap. A throttle that restricts the extension to the first deposit only, or
marks a per-position “has extended” flag, would protect extension triggers
from repeated use — but would also prevent a legitimate participant who
top-ups late from triggering the extension their follow-on capital deserves.
The contract currently cannot and intentionally does not distinguish “attacker paying to extend” from “legitimate participant arriving late”: both look identical on-chain (real bonded capital, correctly sized, landing late). Any mechanism that suppresses the extension for the second case suppresses it for both.
Part 3: two throttle candidates and their trade-offs
For completeness, the two mechanisms raised in the issue are analysed here.
Option A: first-deposit-only extension
The extension triggers only when existing == None (the voter has no prior
position). Top-ups by an already-registered address never re-extend the
deadline.
What it prevents: a single address triggering more than one extension.
What it costs: a legitimate voter who registered early and tops up in the final extension window does not extend the deadline for their new capital. If their top-up is the late arrival that the anti-snipe mechanism is supposed to protect, the protection simply does not fire.
Net effect: Sybil actors funding fresh addresses are unaffected entirely. Single-address repeated top-ups are throttled to one extension per address. The Sybil path is the dominant attack vector anyway, so Option A provides minimal security improvement while meaningfully reducing coverage for legitimate top-ups.
Option B: per-assertion extended flag
The assertion state carries a flag has_been_extended: bool. Once set,
no further qualifying deposit triggers an extension.
What it prevents: every extension after the first, from any address.
What it costs: the anti-snipe mechanism functionally degrades to a one-shot soft deadline extension. An attacker who wants to snipe only needs to wait until after the first legitimate extension fires (which they cannot control), then times their deposit for just before the new soft deadline (before the flag makes no difference). More importantly, genuine late-arriving participants who show up after the first extension get no further protection at all.
Net effect: this is the strongest throttle and the most damaging to the mechanism’s core purpose. The anti-snipe protection is designed precisely for repeated late arrivals; a per-assertion flag disables it after one use.
Why both options make the same core trade-off worse
The anti-snipe mechanism’s value is not primarily in deterring attackers — it is in ensuring that the registration deadline stays open long enough for every genuine late capital to be counted. The hard deadline already bounds the worst-case delay an attacker can impose. Both throttle options reduce the mechanism’s coverage for the legitimate case without meaningfully changing the attacker’s actual capital cost, because the Sybil path is unaffected by any per-position restriction.
Part 4: verdict
The existing combination of constraints is sufficient:
-
registration_hard_deadlineprovides an unconditional wall-clock ceiling on how far registration can be extended, regardless of how many times the soft deadline is pushed. -
max_total_weightbounds the total capital that can be deposited and therefore the total number of extension triggers that are even possible, independent of any per-position bookkeeping. -
#155’s
effective_minimumensures every deposit that triggers an extension costs at leastmin_resolution_bondin real, at-risk capital. -
The residual attack cost (demonstrated in Part 2) is a meaningful multiple of
base_bond, all of it at risk of forfeiture, making a pure griefing campaign unprofitable unless the attacker is also the losing party in the dispute.
A per-position throttle does not close a meaningful gap beyond these existing controls. It would reduce the mechanism’s utility for legitimate late-arriving participants (Part 3) while leaving the dominant Sybil attack path unchanged. No throttle mechanism is recommended.
The parameter that does most of the heavy lifting is anti_snipe_hard_max_secs:
it should be set based on how much additional delay an operator is willing to
tolerate beyond the baseline registration_duration_secs. The guidance in
V2_BOND_SIZING.md already covers that sizing; this
document confirms the mechanism requires no further changes to its trigger
logic.
Appendix: parameter combinations worth double-checking
For any deployment, verify these two conditions before launch:
1. max_total_weight absorbs fewer extensions than T_hard would allow
floor((max_total_weight - 2 × base_bond) / min_resolution_bond)
< floor((anti_snipe_hard_max_secs - registration_duration_secs)
/ anti_snipe_extension_secs)
If this holds, max_total_weight is the binding constraint and the hard
deadline is not reachable through capital alone. If it does not hold, the
attacker can push all the way to the hard deadline and the sizing of
anti_snipe_hard_max_secs relative to registration_duration_secs matters
more.
2. Total attack cost still exceeds base_bond by a meaningful margin
min(floor((max_total_weight - 2 × base_bond) / min_resolution_bond),
ceil((anti_snipe_hard_max_secs - registration_duration_secs)
/ anti_snipe_extension_secs))
× min_resolution_bond
>> base_bond
For most deployments following the profiles in V2_BOND_SIZING.md this
will hold comfortably. Flag it if min_resolution_bond is set to a value
much smaller than base_bond or if max_total_weight is very close to
2 × base_bond (leaving almost no third-party registration headroom, which
means the anti-snipe extension can only fire one or two times regardless).
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 and error type from the shared tholos-client crate (which
does the actual contractimport! once, so demo-consumer and
asserter-consumer don’t each carry their own copy of it or of the
Tholos-error-to-your-error mapping below) and call it like any other
cross-contract invocation:
#![allow(unused)]
fn main() {
use soroban_sdk::{Address, Env};
use tholos_client::{tholos, Error};
fn create_assertion(env: Env, tholos_id: Address, asserter: Address, outcome: bool) -> Result<u64, Error> {
let client = tholos::Client::new(&env, &tholos_id);
Error::from_tholos_call(client.try_assert_outcome(&asserter, &outcome))
}
}
contractimport!, inside tholos-client, reads the wasm file at compile
time, so it has to already exist on disk before anything depending on
tholos-client builds. In this repo that means running
cargo build -p tholos --target wasm32v1-none --release before touching
demo-consumer or asserter-consumer (see CONTRIBUTING.md);
if Tholos is a separate repo for you, the same constraint applies to
wherever its wasm gets built. Using the panicking client methods directly
instead of try_ ones, or writing your own error type from scratch, works
too if you don’t want the tholos-client dependency: it exists for
convenience, not because Soroban requires it.
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. Because your contract self-authorizes
the token transfer on its own behalf, any entrypoint triggering
authorize_as_current_contract MUST be authenticated (e.g. restricted to an
authorized admin via require_auth()), and trusted contract addresses
(tholos_id, token_id) should be pinned in instance storage rather than
accepted as untrusted caller inputs (#157). To prevent front-running
during contract deployment, the admin address is atomically pinned in
__constructor(admin) at instance creation time. A subsequent
initialize(tholos_id, token_id) call authenticates against the stored admin
to pin the trusted targets once and for all, while mutating operational calls
(create_assertion_as_self) maintain instance storage TTL (extend_ttl).
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
tholos-client/ Shared Tholos client import and error mapping used by both
consumer examples below, so neither carries its own copy
src/
lib.rs The contractimport!, plus the shared Error type and
from_tholos_call mapping documented in INTEGRATION.md
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: contracts/tholos, contracts/tholos-v2, and contracts/asserter-consumer.
contracts/asserter-consumer is, like contracts/demo-consumer, an integration
example rather than a production deployment — but it demonstrates a materially
different, more security-sensitive pattern. Where demo-consumer has the end
user sign and authorize the assertion directly, asserter-consumer uses the
authorize_as_current_contract pattern from
INTEGRATION.md, where the contract self-authorizes
a fund transfer on its own behalf with no human signer in the loop. That
self-authorization construction is exactly what a real integrator is expected
to copy into production, so a vulnerability in this reference implementation
carries direct downstream security impact even though the example itself
never holds real value. demo-consumer’s pattern has no comparable surface to
get wrong, which is why it stays out of scope below.
Out of scope: third-party dependencies (soroban-sdk, the Stellar network
itself), and contracts/demo-consumer.
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. -
tholos-v2: persistent storage TTL forAssertionV2,Resolution,Position, andCreditis now sized per assertion, from that assertion’s own pinnedPolicySnapshotV2(anti_snipe_hard_max_secs + reveal_duration_secs, plus a 7-day settlement/withdrawal grace period), rather than a flat 30-day bump on every write. Floored at the same 30-dayINSTANCE_BUMP_AMOUNTv1 uses, so this only ever matches or exceeds the previous margin, never shrinks it. Also adds a new permissionlessbump_ttl(id, address)function: sincePositionandCreditkeys aren’t enumerable on-chain, a record nobody happens to touch again before its own next write (a registered voter who never reveals, or unclaimed credit) previously could only get its TTL renewed by that address’s own owner callingreveal/settle/withdraw; anyone who knows the(id, address)key can now renew it directly, no signature required, no funds moved. Closes #72. -
contracts/tholos:vote_rotationnow emits aRotationVotedevent (resolver, their vote, and the running yes/no tally) on a vote that neither reaches majority nor triggers the deadlock guard. Previously only the two terminal outcomes (RotationExecuted,RotationCancelled) produced an event, so an indexer watching only events had no way to see individual votes accumulate on an open rotation proposal until it resolved one way or the other. Closes #130.
Fixed
-
contracts/tholos-v2:registernow requires every deposit, first-time or a top-up, to meetpolicy.min_resolution_bond. Previously only a first-time deposit was checked, so an address with an existing position could re-trigger the anti-snipe registration extension repeatedly with dust-sized top-ups, unilaterally prolonging registration up to the hard deadline regardless of whether a real last-moment deposit was happening. Closes #155. -
contracts/tholos-v2:initializenow validates thatmax_total_weightdoes not exceedmax_position * MAX_TOTAL_WEIGHT_TO_POSITION_RATIO(10), rejecting invalid configurations withError::InvalidWeightRatio = 37. Previously,max_total_weightcould be arbitrarily larger thanmax_position, allowing an attacker to reach the eligible total with multiple dust-partitioned positions at negligible Sybil cost. Requiring at least 10 distinct positions to reach the eligible total raises the capital and coordination cost of address splitting without requiring an external identity system. Closes #168. -
contracts/asserter-consumer: pin admin atomically in__constructorand authenticateinitializewith the stored admin to prevent front-running. Restrictcreate_assertion_as_selfto admin authorization, maintain instance storage TTL across mutating calls, and use instance storage for trusted contract addresses (tholos_id,token_id) rather than caller arguments to prevent unauthorized fund drain. Closes #157. -
contracts/tholos: bonds and dispute deposits are now recorded as the amount the contract actually received (measured by balance delta across the transfer) rather than the nominal requested amount, with a per-assertion escrow balance tracked separately and resolution payouts capped by both the escrow and the contract’s live token balance. Previously a fee-on-transfer token could leave the recorded bond larger than the tokens actually held, deadlocking a disputed assertion sinceresolverequired transferring a payout the contract could never cover. AddsError::TokenTransferMismatch = 28for transfers whose received amount is non-positive or overflows accounting. Closes #164. -
contracts/tholos:reclaim_stalled_disputenow refunds each side the amount they actually deposited (derived from the per-assertion escrow) and caps both payouts by the contract’s live token balance, mirroringfinalize/resolve. Previously it paid the raw nominal bond to each side, so a stalled dispute funded with a fee-on-transfer token either trapped on insufficient balance (bricking the liveness fallback for the disputes most in need of it) or overpaid from other assertions’ pooled funds. TheStalledDisputeReclaimedevent’srefundedfield now reports the asserter’s refund specifically. Closes #207.
[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.