diff --git a/docs/src/CHANGELOG.md b/docs/src/CHANGELOG.md index 87f9044..7538a47 100644 --- a/docs/src/CHANGELOG.md +++ b/docs/src/CHANGELOG.md @@ -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 diff --git a/docs/src/INTEGRATION.md b/docs/src/INTEGRATION.md index d5b5a43..efb9bb9 100644 --- a/docs/src/INTEGRATION.md +++ b/docs/src/INTEGRATION.md @@ -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 diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 6034f3b..fe91706 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -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) diff --git a/docs/src/V2_MIGRATION.md b/docs/src/V2_MIGRATION.md new file mode 100644 index 0000000..80a5dc0 --- /dev/null +++ b/docs/src/V2_MIGRATION.md @@ -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. diff --git a/docs/src/V2_RESOLUTION.md b/docs/src/V2_RESOLUTION.md index f0956f4..1685045 100644 --- a/docs/src/V2_RESOLUTION.md +++ b/docs/src/V2_RESOLUTION.md @@ -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