Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs/src/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,32 @@ All notable changes to this project are documented here. Format follows
separate from `Resolved`, 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_outcome` through `withdraw`), why
an assertion's identity is now `(contract_id, assertion_id)` rather than
a bare `id` (v1 and v2 each have their own `NextId` counter, so both can
independently issue id `0`), and the current gaps (no canonical v2
testnet deployment yet, `packages/tholos-sdk` targets v1 only,
`demos/freelance-escrow` still 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/`NextId` getter), 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 blocks `assert_outcome`, `dispute`,
`resolve`, and `finalize` all 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, in `V2_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.

## [0.3.0] - 2026-08-08

### Added
Expand Down
71 changes: 71 additions & 0 deletions docs/src/INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,77 @@ resolved; it is `None` while the assertion is still `Pending` or `Disputed`.
| `resolvers` | Must be odd-length, non-zero, distinct, and at most 21 addresses; v1 rejects duplicates with `DuplicateResolvers`. See [CONTRACT.md](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](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](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

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.

```text
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](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](DEPLOYMENT.md#canonical-testnet-deployment)), 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-sdk` targets v1 only.** The generated TypeScript client
described above is built from `contracts/tholos`'s wasm, not
`contracts/tholos-v2`'s. A browser or Node app integrating with v2 today
needs its own `contractimport!`-equivalent tooling or hand-rolled calls
until v2 gets its own generated bindings.
- **`demos/freelance-escrow` still 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
Expand Down
1 change: 1 addition & 0 deletions docs/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
- [Deployment & Operations](DEPLOYMENT.md)
- [Bond Sizing Analysis](BOND_SIZING.md)
- [Integration Guide](INTEGRATION.md)
- [v1/v2 Migration Runbook](V2_MIGRATION.md)
- [Glossary](GLOSSARY.md)
- [Contributing](CONTRIBUTING.md)
- [Security](SECURITY.md)
Expand Down
162 changes: 162 additions & 0 deletions docs/src/V2_MIGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
# 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](INTEGRATION.md#tholos-v2) 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"](V2_RESOLUTION.md#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`/
`resolve` pays 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 `deploy` transaction
itself (or `stellar contract info` against 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 the `initialize` invocation's arguments, or
from `get_assertion_state` on any known assertion id if the invocation
itself isn't handy (`Assertion` doesn't carry every policy field, but the
ones it does are enough to cross-check).
- **Current `admin` and resolver committee.** From the latest
`ResolversUpdated` event if the committee has ever rotated, otherwise
from `initialize`'s original arguments.
- **Every open `Pending`/`Disputed` assertion.** 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 of `id`s that have an `Asserted` but no matching `Finalized`/
`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](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](CONTRACT.md) and [V2_RESOLUTION.md](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`/
`Position` records are a different shape from v1's `Assertion`, 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](INTEGRATION.md#should-you-deploy-your-own-instance-or-share-one).
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](DEPLOYMENT.md#pausing-during-an-incident) and
[INTEGRATION.md](INTEGRATION.md#known-caveats-for-integrators)); 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"](V2_RESOLUTION.md#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.
10 changes: 6 additions & 4 deletions docs/src/V2_RESOLUTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -613,10 +613,12 @@ 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 both `dispute` and `resolve`
while leaving `finalize` callable; pausing a pending assertion could remove its
chance to be challenged without preventing it from finalizing. It also cannot
rescue an open dispute whose snapshotted committee is unavailable.
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
Expand Down
Loading