feat(v1): stalled-dispute liveness fallback (#166) - #184
Conversation
Permissionless reclaim_stalled_dispute entrypoint: once a deployment- configured stall timeout elapses since dispute() without resolve() reaching a strict majority, anyone may trigger the fallback and both bonds return to their original owners. Outcome rule per maintainer: no-winner, not default-to-asserted. The disputer contested the claim; the committee failed. Defaulting to the asserted outcome would forfeit the disputer's bond over a dispute never adjudicated and hand the asserter an incentive to stall the committee (bribe, DoS, wait out resolvers). No-winner removes that incentive: stalling benefits nobody. - Assertion.disputed_at pinned at dispute(); stall clock starts there, never at opened_at - set_stall_timeout (admin, pause-exempt), 0 disables the fallback; MAX_STALL_TIMEOUT_SECS 30 days - Resolved with final_outcome None = voided round; every pre-drydocs#166 resolution writes Some(_), so no reader can confuse the two - disputed_at == 0 assertions (pre-upgrade) are never reclaimable - state written before the two token transfers (reentrancy shape matches dispute/finalize/resolve)
…nsistency - cargo fmt: the new v1 code in d3fb31a needs rustfmt's compact event publish form and a wrapped helper signature. - packages/tholos-sdk/src/index.ts regenerated with stellar-cli 27.0.0 (the version CI pins) from the v1 wasm: the new public interface (set_stall_timeout, reclaim_stalled_dispute, their errors and events) now has client bindings, per the README's regeneration procedure. - test_snapshots: d3fb31a under-committed; the Assertion struct's new disputed_at field now appears in the mock-auth ledger entries of the affected snapshots, so they are checked in to match what a clean test run regenerates.
|
CI fixes for 52143c7 (follow-up to d3fb31a):
|
| /// the moment the dispute opened, never `opened_at` (the assertion's | ||
| /// creation time), because the stall clock starts when the committee | ||
| /// snaps in and the bonds are both committed. | ||
| pub disputed_at: u64, |
There was a problem hiding this comment.
Adding this required field to the persisted Assertion struct breaks decoding of every assertion already in flight at upgrade time, not just committee-stall cases. Soroban's derived struct decoder requires the stored map to have exactly as many entries as the current struct has fields, so an assertion persisted with the old 12-field struct fails to decode against this new 13-field struct. Every entrypoint that reads it (dispute, resolve, finalize, get_assertion_state, and the new reclaim_stalled_dispute) then errors for that assertion, permanently freezing its bonds, the exact stalled-fund scenario this PR is meant to fix, but for every assertion in flight at upgrade time instead of just committee-stall cases. Old records won't decode with a default zero value for this field, they won't decode at all. Please account for pre-upgrade assertions explicitly, a versioned struct or a migration path, rather than relying on decode-time defaulting.
| /// bump so a stalled dispute cannot be archived out from under the fallback | ||
| /// while it is still reclaimable. 30 days also matches ASSERTION_BUMP_AMOUNT | ||
| /// headroom the same way MAX_CHALLENGE_WINDOW_SECS does for `finalize`. | ||
| const MAX_STALL_TIMEOUT_SECS: u64 = 30 * 24 * 60 * 60; |
There was a problem hiding this comment.
This equals ASSERTION_BUMP_AMOUNT's real-world duration exactly, leaving zero TTL headroom, unlike MAX_CHALLENGE_WINDOW_SECS (7 days) which leaves 23 days of headroom against the same 30-day bump. If an admin sets stall_timeout_secs to this max and nothing else touches the assertion before the timeout elapses, exactly the unresponsive-committee scenario reclaim_stalled_dispute exists for, the entry's TTL can run out at essentially the same moment the timeout becomes satisfiable, risking archival right when reclaim_stalled_dispute is supposed to become callable. Please lower this to leave real headroom, matching the pattern used for the challenge window.
| // not reclaimable under a timeout configured after the fact; see | ||
| // set_stall_timeout's doc comment. A never-set timestamp must not | ||
| // alias epoch (1970) into "definitely elapsed" decades later. | ||
| if assertion.disputed_at == 0 { |
There was a problem hiding this comment.
0 is also a legitimate ledger timestamp, not just the never-set sentinel, so a dispute opened when env.ledger().timestamp() is genuinely 0 becomes permanently unreclaimable, indistinguishable from a pre-upgrade assertion. Your own test suite works around this by manually forcing a non-zero timestamp before running dispute-related tests, which confirms the collision is real rather than fixing it in the contract. Consider a separate boolean or Option instead of overloading 0.
…UT to 7 days Three issues from review: 1. disputed_at: u64 -> Option<u64> so missing map key decodes as None rather than breaking Soroban struct decoding for pre-upgrade assertions. 2. MAX_STALL_TIMEOUT_SECS: 30 days -> 7 days, matching MAX_CHALLENGE_WINDOW_SECS headroom. 30 days left zero TTL margin against the 30-day assertion bump. 3. Sentinel: disputed_at == 0 replaced with Option match, so ledger timestamp 0 is no longer mistaken for 'never set'. All 82 tests pass, snapshots regenerated.
|
@collinsezedike All three issues fixed in 40a80c3, all 82 tests pass:
|
CI detected drift between manually-edited SDK and the contract's public interface. Regenerated bindings via stellar contract bindings typescript, which now reflects Option<u64> for disputed_at.
|
CI is failing on the sdk job: packages/tholos-sdk/src is out of date with the new public interface this PR adds. Regenerate the bindings (see packages/tholos-sdk/README.md) and commit the result. |
|
@collinsezedike SDK bindings regenerated and pushed (87d63b1). All three structural issues from your review are fixed:
CI: test ✅, demo ✅, sdk ✅. All 82 unit tests pass. |
|
@ZacLou The doc comment on disputed_at claims a missing map key decodes as None for pre-upgrade Assertion entries. That's not how Soroban struct decoding works, it deserializes via a length-checked map, so a stored 11-key Assertion fails to decode against the new 12-key struct instead of defaulting the new field to None. This bricks get_assertion_state, dispute, finalize, resolve, and reclaim_stalled_dispute for every assertion that existed before an upgrade, exactly the scenario your PR description calls an open question, resolved incorrectly here. No migration path ships with this PR. This needs a real answer (a migration step, or a different storage approach) before it can merge. |
…storage key - Remove disputed_at field from Assertion to avoid breaking decoding of pre-upgrade assertions (Soroban contracttype requires exact field count match) - Add DataKey::DisputedAt(u64) for per-assertion dispute timestamp - dispute(): store timestamp via DataKey::DisputedAt(id) - reclaim_stalled_dispute(): read from DataKey::DisputedAt(id) Addresses reviewer feedback on structural upgrade compatibility.
|
@collinsezedike All three review points have been addressed in commit
Could you please re-review when you have a moment? |
… field, fmt - Removes the leftover disputed_at: None initializer now that the field lives in a separate DisputedAt storage key. - Adds the missing set_assertion call in dispute() so the Disputed status and resolver snapshot are actually persisted. - Runs cargo fmt so the SDK/test CI jobs pass. - Refreshes test snapshots to account for the extra persistent write. All 82 tholos tests pass; clippy clean; SDK build clean.
… field, fmt - Removes the leftover disputed_at: None initializer now that the field lives in a separate DisputedAt storage key. - Adds the missing set_assertion call in dispute() so the Disputed status and resolver snapshot are actually persisted. - Runs cargo fmt so the SDK/test CI jobs pass. - Refreshes test snapshots to account for the extra persistent write. All 82 tholos tests pass; clippy clean; SDK build clean.
dee9f1a to
e1b9212
Compare
|
@collinsezedike Follow-up fixes pushed (e1b9212). The previous round addressed the three structural review points; this round fixes the CI failures that were blocking merge:
Local verification: |
|
Hi @collinsezedike, thanks for the detailed review. I believe all three points are now addressed in the latest commits:
CI is green (test/sdk/demo). Could you take another look when you have a moment? |
|
@collinsezedike All three review items have been addressed in the commits pushed after your review:
Could you please re-review when you have a moment? |
|
Hi @collinsezedike, just a friendly follow-up: all the review feedback has been addressed and CI is fully green. Could you take another look when you have a moment? Thanks! |
8441142 to
e1b9212
Compare
|
@collinsezedike All three issues from the latest review have been addressed in the latest commits:
Please re-review when you have a moment. Thanks! |
|
@collinsezedike Regarding the struct decoding concern: the Option change in commit 40a80c3 directly solves this. Soroban SDK's contracttype derive macro handles missing map keys explicitly (soroban-sdk-macros/src/derive_struct.rs): Fields absent from the map are void, so that they convert to None for Option fields. When a pre-upgrade assertion (12-field map, no disputed_at key) is decoded against the new struct: the missing key is not found in binary_search_by_key, defaults to ScVal::Void, which converts to Option::None via try_into_val. No get_assertion_state, dispute, finalize, or resolve call on a pre-upgrade assertion will error — it sees disputed_at == None and reclaim_stalled_dispute returns StallTimeoutNotConfigured. The other two issues (MAX_STALL_TIMEOUT_SECS reduced to 7 days, and the 0-sentinel replaced with Option match) were also fixed in the same commit. All 82 tests pass, CI green. |
|
@collinsezedike you're absolutely right that adding
Migration path:
CI is green (test, demo, sdk) and the |
4a30c0c to
e1b9212
Compare
|
@collinsezedike The review feedback has been addressed in the latest commits:
Please re-review when convenient. |
collinsezedike
left a comment
There was a problem hiding this comment.
The storage-decoding bricking bug from the earlier review is genuinely fixed, disputed_at moved to its own keyed entry instead of a new Assertion field. Two things left, both inline.
| // pre-upgrade Assertion structs decode unchanged (#184). | ||
| env.storage() | ||
| .persistent() | ||
| .set(&DataKey::DisputedAt(id), &env.ledger().timestamp()); |
There was a problem hiding this comment.
DataKey::DisputedAt(id) is written here with a bare .set() and never gets extend_ttl called on it, unlike every other persistent/instance entry in this contract (set_assertion and touch_instance_ttl both explicitly re-bump TTL on every write for exactly this reason). If a dispute stalls long enough that this entry's TTL lapses and it gets archived while Assertion(id) (actively re-bumped to 30 days) stays alive, reclaim_stalled_dispute reads None here and returns StallTimeoutNotConfigured instead of proceeding, defeating the liveness fallback for precisely the oldest, most-stalled disputes it exists to rescue.
| /// opened under different expectations. | ||
| /// | ||
| /// Only callable by the admin. Fails with `InvalidStallTimeout` if | ||
| /// `stall_timeout_secs` exceeds `MAX_STALL_TIMEOUT_SECS` (30 days). |
There was a problem hiding this comment.
This says the cap is MAX_STALL_TIMEOUT_SECS (30 days), but the constant is defined as 7 * 24 * 60 * 60, 7 days, matching MAX_CHALLENGE_WINDOW_SECS. The test file's own comment notes the max is 7 days, not 30, to leave TTL headroom. This doc comment ships verbatim into the generated TS SDK JSDoc too.
|
Both inline findings are fixed in c9bae23:
Verification: |
collinsezedike
left a comment
There was a problem hiding this comment.
Thanks for this, both prior findings are fixed correctly: the TTL fix mirrors set_assertion's existing pattern, and the doc comment now matches the real 7-day constant. Merging now.
|
@ZacLou If you have a moment, a star on the repo would be appreciated! |
Closes #166
What
Permissionless liveness fallback for stalled disputes in tholos v1. Once a deployment-configured stall timeout elapses since
dispute()withoutresolve()reaching a strict majority,reclaim_stalled_disputebecomes callable by anyone and returns both bonds to their original owners — asserter gets their bond back, disputer gets their bond back, no winner.Outcome rule
Per maintainer direction on the issue: bond-return to both parties, no winner — not default-to-asserted. The disputer did contest the claim; the process broke down because the committee failed, not because the challenge was weak. Defaulting to the asserted outcome would forfeit the disputer's bond over a dispute never adjudicated, and would hand the asserter an incentive to make the committee stall (bribe, DoS, wait out unresponsive resolvers). No-winner removes that incentive: stalling benefits nobody.
Design
Assertion.disputed_atpinned at the momentdispute()opens: the stall clock starts when both bonds are committed and the committee snapshot takes over — never atopened_at(assertion creation).set_stall_timeout(stall_timeout_secs)— admin, pause-exempt.0disables the fallback (pre-[Bug] Stalled disputes have no liveness fallback in tholos v1 #166 behavior). Upper boundMAX_STALL_TIMEOUT_SECS = 30 days, same 30-day headroom the challenge window already relies on against the assertion TTL bump.Status::Resolvedwithfinal_outcome: None. Every pre-[Bug] Stalled disputes have no liveness fallback in tholos v1 #166 resolution writesSome(_), so an indexer can never confuse a voided round with a majority outcome.Resolved+Nonereads as "voided, bonds returned".disputed_at == 0assertions (pre-upgrade disputes) are never reclaimable: a timeout configured after the fact must not retroactively apply to disputes opened under different expectations.finalize): the fallback racing a normalresolvethat never got a chance to act is worse than waiting.dispute,finalize,resolve) already uses. Two separate transfers, not one combined: the recipients are unrelated parties and neither is owed the other's half.Open question for review (storage compatibility)
Adding
disputed_attoAssertionchanges the stored struct shape. If there are live deployments with in-flight disputes, upgrading the wasm without a storage migration would make old entries unreadable (positional SCVal array mismatch), and pre-upgrade stalled disputes would be unrecoverable by design anyway. If v1 has no live mainnet instance, this is moot; if it does, the upgrade needs either a#[deprecated]-eraget_assertionfallback on the old shape that maps old entries todisputed_at == 0, or an explicit decision that voiding them via migration is acceptable.Verification
DisputeNotStalled, committee still resolves, post-resolution reclaim failsNotDisputed); after-timeout returns both bonds with no winner and terminalResolved/None; zero-timeout disables the fallback (StallTimeoutNotConfigured); paused blocks the fallback until unpaused