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.
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.
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.
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.
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.
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.
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.
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.
The committee can replace one of its own by vote (propose_rotation /
vote_rotation / cancel_rotation), removing the admin as the only path to
committee membership. Design record: docs/src/ROTATION_DESIGN.md. Three decisions
the scheme rests on:
- Strict majority of the live committee, same formula as a dispute. The
threshold is
len / 2 + 1, the only majority rule in the contract. A colluding majority already decides every dispute, so rotation-by-majority adds no new attack surface beyond what that majority already has; it just routes the membership change through the contract instead of the admin key. - No interaction with the per-dispute snapshot, by construction. Self-rotation
writes the same
Resolversinstance-storage slotupdate_resolverswrites. Because a dispute snapshots the live committee atdisputetime (Assertion.resolvers), a rotation completing after a dispute is open has exactly the behaviorupdate_resolversalready has: no effect on that dispute. The new committee governs only disputes opened afterward. No change toAssertion,dispute, orresolvewas needed; the existing snapshot invariant carries the rotation for free. - Coexists with
update_resolvers, doesn't replace it. Self-rotation is the day-to-day path; adminupdate_resolversstays as the emergency override for a compromised or deadlocked committee (the one case self-rotation can't solve: a committee can't vote to heal itself when it's the problem). Both paths emitResolversUpdated, so the "committee changed" signal stays unified; rotation addsRotationProposed/RotationExecuted/RotationCancelledfor the governance trail.
Liveness: only one rotation may be open at a time, and it's resolved by execution
(majority reached), proposer cancel, or a deterministic deadlock guard (if yes-votes
cast plus every unvoted resolver still can't reach a majority, the proposal
auto-cancels) so a lost proposer key can't permanently block rotation. Pause-exempt,
like update_resolvers: rotation is internal governance, not new exposure.
Admin override wins any race: update_resolvers clears an open self-rotation
proposal. The only ways the committee changes are update_resolvers and
rotation-execution, and both clear the proposal, so a live proposal always matches the
committee it was validated against — no stale proposal can execute against a committee
it wasn't built for.
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
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)
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