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.
[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.