fix(v2): gate the optimistic timeout default on a reveal-quorum floor (#167) - #185
Conversation
…drydocs#167) Withheld-reveal registration could deny a genuine majority and steer a dispute into the optimistic timeout default (asserted outcome stands): an actor registers heavy weight on the side that helps the claim stand, never reveals it, and inflates the eligible total so no revealed weight can reach strict majority nor a legitimate quorum. Per the maintainer's confirmed direction: - The timeout default now only applies when revealed weight is a genuine majority of everything committed: strictly more than half of the frozen eligible_total, the same subtraction form the strict-majority check uses (side_weight > W - side_weight), so at-or-below-half never qualifies. The 50% floor is a starting hypothesis to stress-test. - If the quorum fails, the round voids instead: new TerminalCause:: RevealQuorumNotMet (appended last so already-persisted XDR variant encodings stay stable), final_outcome stays None, and settlement pays the same bonds-back, no-forfeiture pool a stalled v1 dispute gets (drydocs#166's shape): every funded position, revealed or not, recovers its exact principal. No reward for withholding. - RoundVoided event (revealed_weight + eligible_total carried) published instead of Resolved, a distinct event for the same reason RoundCancelled is distinct from Resolved. Tests: 7 unit/behavior tests (quorum-met default survives, the attack scenario voids, at-or-below-half boundary, exact economics of the register-heavy/reveal-light attack, conservation) plus a 96-case property test sweeping voter counts, stakes, reveal masks, and sides, asserting the invariant trichotomy (strict majority / quorum-met timeout / voided with bonds back) on every closed round.
collinsezedike
left a comment
There was a problem hiding this comment.
Two things outside this diff, so I can't anchor them inline. resolve_outcome's doc comment (contracts/tholos-v2/src/lib.rs:1700) still says closing locks the outcome as strict majority if reached, OptimisticTimeout otherwise, with no mention of the new RevealQuorumNotMet/RoundVoided path this PR adds, an integrator relying on that comment to reason about possible return values won't anticipate it. docs/src/V2_RESOLUTION.md's state diagram and terminal-cause table have the same gap, still describing the old unconditional default-to-asserted-outcome timeout with no reveal-quorum gate. Please update both.
Separately: this PR adds 27 proptest-generated snapshot files under contracts/tholos-v2/test_snapshots/test/withheld_reveal_quorum/proptest_quorum/, and that path isn't in .gitignore even though three sibling proptest paths already are, with the comment that proptest snapshots are non-deterministic and must not be committed. Please add this path to .gitignore and drop these files from the PR.
…or quorum void - resolve_outcome doc: mention RevealQuorumNotMet/RoundVoided path - V2_RESOLUTION.md state diagram: add voided-round transition - V2_RESOLUTION.md storage table: add RevealQuorumNotMet terminal cause - V2_RESOLUTION.md decision summary: update bonds and outcome rule
|
@collinsezedike Both doc gaps fixed in cb497e3:
|
|
@collinsezedike Both doc updates pushed (cb497e3), CI all green (test ✅, demo ✅, sdk ✅):
|
|
@ZacLou Two things.
|
… doc count - Add withheld_reveal_quorum/proptest_quorum/ to .gitignore - Remove 99 committed non-deterministic snapshot files - settlement_pool doc comment: four -> five terminal causes
|
@collinsezedike Both items addressed in the latest two commits (
Ready for another look — thanks! |
|
Hi @collinsezedike, the review items should now be addressed:
All CI checks pass. Re-requesting your review — thanks again. |
|
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! |
|
@collinsezedike The review feedback has been addressed in the latest commits:
Please re-review when you have a moment. Thanks! |
|
@collinsezedike Both review items from your last pass should now be addressed in the latest push:
CI is green across the board (build, demo, sdk, test). Could you take another look when you have a moment? |
|
@collinsezedike All three items from your review are addressed:
Ready for another pass — thanks! |
collinsezedike
left a comment
There was a problem hiding this comment.
Thanks for this, both prior findings are fixed cleanly and the new test coverage is thorough. Merging now.
|
@ZacLou If you have a moment, a star on the repo would be appreciated! |
Closes #167.
Problem
registerfreezes weight intoresolution.eligible_totalregardless of whether the position ever reveals. Only revealed weight entersagree_weight/disagree_weight, but non-revealed weight stays counted when the strict-majority threshold and the optimistic timeout default are checked. An actor can register heavy weight on the side that helps the asserter's claim stand, never reveal it, and thereby (a) make it harder for a genuine, fully-revealed opposing majority to exceed half of the eligible total, and (b) push an otherwise-clear dispute into the timeout default — the asserted outcome stands — instead of the outcome real participants revealed for. As the issue notes, the coalition's net cost can even be below the nominal bond if it recycles forfeiture through positions that did reveal.Change (per the confirmed design direction in the issue thread)
A 50% reveal-quorum floor gates the timeout default. At close,
revealed_weight * 2 > eligible_totalmust hold — the same subtraction form the strict-majority lock already uses (side_weight > W - side_weight), so at-or-below-half never qualifies and the threshold needs no new justification, it's the existing one. At or below half, revealed weight isn't a genuine majority of everything committed and the default is withheld. The 50% figure is a starting hypothesis: the property tests below sweep the boundary region, and the trade-off analysis is spelled out in the module docs for maintainer review, per the issue's scope note.If the quorum fails, the round is voided, not defaulted. New
TerminalCause::RevealQuorumNotMet, appended last so every already-persisted XDR variant encoding stays stable (theTimeoutDefaultRuledoc gained the gating note).final_outcomestaysNone. Settlement pays the same bonds-back, no-forfeiture poolAdminCancelleduses (seesettlement_pool): every funded position, revealed or not, recovers its exact principal through the ordinarysettle/withdrawpath. No reward for withholding: the round produces no outcome and the withheld strategy earns nothing it didn't already have.RoundVoidedinstead ofResolved. A distinct event (carryingrevealed_weight/eligible_totalso the void condition is auditable straight from the event), separate for the same reasonRoundCancelledis separate fromResolved: indexers can tell a real outcome from a round that produced none without inferring it from assertion state.Tests
118 pass (107 pre-existing + 11 affected/new-touched). New
withheld_reveal_quorummodule:OptimisticTimeout(revealed == eligible, quorum met): the gate can't strand a no-registration dispute forever.reveal_deadlineclock) asserting the invariant trichotomy on every closed round: strict majority (outcomeset) / quorum-met timeout (revealed*2 > eligible, asserted outcome stands) / voided (revealed*2 <= eligible,final_outcomeNone, every position settles to exactly principal).The proptest_settlement mirrors (
expected_pool,is_recipient, dust-recipient) were extended to the new cause so the existing conservation properties continue to hold unchanged across the voided case.Compatibility note
Resolution/Position/PolicySnapshotV2shapes are untouched; the only on-chain data-model change is one appended enum variant inTerminalCause(XDR variant tags unchanged for all pre-existing values) — no storage migration needed. Behavior change is deliberate and confined to closes withrevealed_weight * 2 <= eligible_total, which under the old code would have taken the asserted-outcome default: those rounds now void with bonds back.