Skip to content

feat(escrow): prove one conservation predicate on reveal, void, and settle - #459

Merged
karagozemin merged 2 commits into
Sub-Rosa-Issue:mainfrom
Hamda-gbade:feat/prove-escrow-conservation
Oct 1, 2026
Merged

karagozemin merged 2 commits into
Sub-Rosa-Issue:mainfrom
Hamda-gbade:feat/prove-escrow-conservation

Conversation

@Hamda-gbade

Copy link
Copy Markdown

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:

  • a dropped index entry (bidder removed from round.bidders while their escrow is still locked) stranded that USDC in the contract forever — nothing else ever refunds it;
  • a duplicated entry paid the same refund twice, i.e. minted from escrow already returned;
  • a payout above the winner's escrow or a bid already marked settled paid out of escrow that was not the round's.

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:

committed == payout + refunds + locked

committed is cumulative and never decreases (an overwrite-before-close adds a refund, not a negative commit). locked is an independently accumulated field rather than the residual of the other three, so an arithmetic slip in a transfer path cannot cancel out.

locked must 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:

path what it now proves
commit capacity is checked first, so a full round still returns RoundFull; the ledger is extended in lockstep with the transfer in
reveal a bid is never recorded against a round the contract can no longer account for
clear → void the whole locked balance is refundable, nothing already paid, and the result leaves nothing locked
settle the planned payout + refunds drain exactly the locked balance before any transfer, and nothing stays locked afterwards

A refusal reverts every transfer the call had already made, so a bad settle cannot partially pay. EscrowNotConserved = 40 is 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 / preflightVoidConservation are the strict wrappers and raise the typed SubRosaEscrowConservationError (a SubRosaPreflightError with kind: "escrow_not_conserved", carrying roundId, phase, and the report) so a keeper can branch on error.kind instead 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_paths
  • many_bidders_across_multiple_pages_settle_conserving_escrow (7 bidders across several get_bidders_page pages)
  • 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_settled
  • reveal_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_restored
  • conservation_predicate_table_driven, escrow_ledger_tracks_commits_overwrites_and_settlement
  • error_path_escrow_not_conserved in the error-path registry

SDK: pnpm --filter @sub-rosa/sdk test — 236 pass, including a new conservation.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 in preflight.test.ts. sdk:typecheck, bindings:test/bindings:typecheck, plus keeper, receipt-cli, agent, web, tlock, time, drand-tools tests and typechecks all pass, as do the errors:check, snapshot:check, logging:check, threat-model:check, docs:check, docs:check-links, time:guard, and errors:normalize:check guards.

Trade-offs worth reviewing

  • Fail-closed is deliberate. A round whose index has drifted refuses to settle or void, so escrow is stranded rather than minted or double-paid. That is recoverable: restore the index and settle (settle_works_again_after_the_index_is_restored), and the keeper preflight reports exactly which bidder is missing.
  • Cost. reveal and settle now walk the index once (≤ MAX_BIDDERS = 500 reads, persistent entries) and the round carries one extra persistent ledger entry.
  • Bindings were hand-edited. The stellar CLI is not available in this environment, so packages/round-bindings/src/index.ts was updated by hand (new Errors entry, EscrowLedger interface, DataKey::Escrow variant) rather than regenerated. Please run pnpm bindings:generate and drop in the output — the type ordering in particular may differ.

…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
@drips-wave

drips-wave Bot commented Sep 30, 2026

Copy link
Copy Markdown

@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! 🚀

Learn more about application limits

@karagozemin
karagozemin merged commit 5a80914 into Sub-Rosa-Issue:main Oct 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Prove escrow conservation across partial reveal, void, and settle

3 participants