feat(escrow): prove one conservation predicate on reveal, void, and settle - #459
Merged
karagozemin merged 2 commits intoOct 1, 2026
Merged
Conversation
…ettle
Settle, void, and the clear-time void path each paid the bidder index
without proving they moved exactly the escrow the round held. A dropped
index entry stranded a bidder's USDC in the contract forever, and a
duplicated entry would have paid the same refund twice. Partial reveal
made both reachable, because the unrevealed majority is refunded through
the same loop as the winner's surplus.
Track one persistent ledger per round — committed, payout, refunds, and an
independently accumulated locked balance — and prove
committed == payout + refunds + locked
before and after the transfers in reveal, clear, void, and settle. The
locked balance must also equal the escrow that indexed, unsettled bids
actually hold, so a dropped, duplicated, or phantom bidder is rejected
before any token moves rather than after. A refusal reverts every transfer
the call had already made, so a bad settle cannot partially pay.
Because the check runs inside the call, the failure is a contract error
(EscrowNotConserved = 40) rather than an off-chain warning. The SDK gains
the matching preflight: proveEscrowConservation pages the bidder index and
re-derives the same accounting, and preflightSettleConservation /
preflightVoidConservation raise a typed SubRosaEscrowConservationError so a
keeper can halt before paying a fee.
Closes Sub-Rosa-Issue#374
|
@Hamda-gbade Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #374.
The bug
settle,void, and the clear-time void path each paid the bidder index without proving they moved exactly the escrow the round held:round.bidderswhile their escrow is still locked) stranded that USDC in the contract forever — nothing else ever refunds it;Partial reveal made both reachable, because on a partially revealed round the unrevealed majority is refunded through the same loop as the winner's surplus.
The fix
One persistent ledger per round (
committed,payout,refunds,locked) and one predicate:committedis cumulative and never decreases (an overwrite-before-close adds a refund, not a negative commit).lockedis an independently accumulated field rather than the residual of the other three, so an arithmetic slip in a transfer path cannot cancel out.lockedmust additionally equal the escrow that indexed, unsettled bids actually hold. That second check is what rejects an index that drifted from the escrowed set, and it is proved by a single scan of the index inside the call:commitRoundFull; the ledger is extended in lockstep with the transfer inrevealclear→ voidsettleA refusal reverts every transfer the call had already made, so a bad settle cannot partially pay.
EscrowNotConserved = 40is a real contract error, not an off-chain warning.SDK
proveEscrowConservation(roundId, phase)re-derives the same accounting off-chain — paging the bidder index, reading every bid state, and cross-checking the walk against the bidder list on the round record. It never throws; a drifted, duplicated, or unreadable index comes back as an issue on the report.preflightSettleConservation/preflightVoidConservationare the strict wrappers and raise the typedSubRosaEscrowConservationError(aSubRosaPreflightErrorwithkind: "escrow_not_conserved", carryingroundId,phase, and the report) so a keeper can branch onerror.kindinstead of parsing text. Issue codes cover page drift, index integrity, and the accounting itself.Tests
cargo test -p sub-rosa-round— 102 pass, including:partial_reveal_refunds_every_unrevealed_bidder_exactly_once,zero_revealed_bids_void_refunds_every_bidder_exactly_once,empty_round_conserves_escrow_on_void_and_settle_pathsmany_bidders_across_multiple_pages_settle_conserving_escrow(7 bidders across severalget_bidders_pagepages)settle_rejects_dropped_bidder_instead_of_stranding_escrow,settle_rejects_double_pay_from_a_duplicated_bidder_index,settle_rejects_mint_when_the_ledger_claims_more_escrow_than_bids_hold,settle_rejects_bid_already_marked_settledreveal_rejects_a_bidder_index_that_drifted_from_the_escrowed_set,reveal_rejects_a_phantom_bidder_in_the_index,void_rejects_a_bidder_index_that_drifted_from_the_escrowed_set,settle_works_again_after_the_index_is_restoredconservation_predicate_table_driven,escrow_ledger_tracks_commits_overwrites_and_settlementerror_path_escrow_not_conservedin the error-path registrySDK:
pnpm --filter @sub-rosa/sdk test— 236 pass, including a newconservation.test.ts(predicate table, empty/single/multi-page walks, page-total drift, cursor repeat/stall, page-count mismatch, missing bid state, already-settled bid, index mismatch, bidder cap) and new client-level cases inpreflight.test.ts.sdk:typecheck,bindings:test/bindings:typecheck, pluskeeper,receipt-cli,agent,web,tlock,time,drand-toolstests and typechecks all pass, as do theerrors:check,snapshot:check,logging:check,threat-model:check,docs:check,docs:check-links,time:guard, anderrors:normalize:checkguards.Trade-offs worth reviewing
settle_works_again_after_the_index_is_restored), and the keeper preflight reports exactly which bidder is missing.revealandsettlenow walk the index once (≤MAX_BIDDERS = 500reads, persistent entries) and the round carries one extra persistent ledger entry.stellarCLI is not available in this environment, sopackages/round-bindings/src/index.tswas updated by hand (newErrorsentry,EscrowLedgerinterface,DataKey::Escrowvariant) rather than regenerated. Please runpnpm bindings:generateand drop in the output — the type ordering in particular may differ.