feat(keeper): make the settlement guard refuse a settle the contract would reject - #451
Merged
karagozemin merged 3 commits intoOct 1, 2026
Conversation
…would reject The guard only compared its local view with itself, so it recorded "allowed" for a settle whose refund set it could not read, a settle of a round the contract considers voided, and a void inside the grace window — all of which the round contract reverts. The guard now reproduces the contract's winner, refund and void rules and refuses the submission with a typed reason instead of burning a fee on a reverting transaction: - expectedWinner, settleRefunds and voidRefunds read the bidder page and bid states the keeper actually needs. An incomplete page, an unreadable refund, or a winner that disagrees with the on-chain one means no submit; - a void requires status Open and the 3600s grace window past the reveal deadline, matching VOID_GRACE in contracts/round; - closeRound and voidIfStale only submit when the guard allows it, and they carry a typed guardSkip (exposed as guardSkip / guardSkipIndicator on the status API) so the refusal is visible instead of silent; - the early void gate runs the same guard rather than only checking status. Settle and void cases now live in one table, contracts/round/fixtures/settlement-cases.txt, read by both the contract test (settlement_fixture_drives_the_contract and settlement_fixture_guard_reasons_match_contract_rules) and the keeper test. Every row the contract rejects must carry a guard reason, and the rows the guard refuses although the contract would accept are asserted as such, so the two sides cannot drift apart again. Closes Sub-Rosa-Issue#385
|
@classikdev 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 #385
Problem
The keeper's settlement guard only compared its local view of a round with
itself: if two consecutive reads agreed, it recorded the settle as allowed and
the keeper submitted it. That does not reproduce what
contracts/roundwillactually accept, so the guard happily submitted:
state) — the contract needs the surplus transfer and every non-winner refund
to succeed in one transaction;
Each of those transactions reverts on chain and burns a fee.
What changed
Guard rules (
services/keeper/src/settlement-guard.ts)expectedWinner— picks the winner exactly as the contract'scleardoes(strict
>/<over revealed bids in bidder order, ties go to the firstbidder) and refuses when the local winner disagrees with the on-chain one,
when the winner's escrow is unreadable, or when no valid bid exists.
settleRefunds/voidRefunds— build the refund set the contract wouldexecute (skip already-settled bidders, skip zero escrow, winner gets the
surplus only when positive). An incomplete bidder page or an unreadable bid
state means the set cannot be proven ⇒ no submit.
evaluateVoid— requires statusOpenandnow > reveal_deadline + 3600s(
VOID_GRACE_SECONDS), matchingVOID_GRACEincontracts/round/src/lib.rs.already_settled,round_voided,not_cleared,missing_winner,bidder_page_incomplete,refund_missing,winner_mismatch,void_not_open,void_grace_not_elapsed.Submission plumbing (
keeper.ts,watch-loop.ts)closeRoundandvoidIfStalenow ask the guard before submitting; a refusalis carried back as a typed
guardSkipinstead of being dropped.the keeper's "why didn't you act" answer is a contract rule, not a guess.
guardSkip({ action, reason, detail, at }) andguardSkipIndicator(
"<action> refused: <reason>"), so an operator can see why nothing wassubmitted —
services/keeper/README.mddocuments both fields.Shared fixture (
contracts/round/fixtures/settlement-cases.txt)One pipe-delimited table of settle and void cases (status, grace, bids,
escrows, revealed/read counts, winner, refunds, guard reason, contract
answer), read by both suites:
cargo test -p sub-rosa-roundsettlement_fixture_drives_the_contractbuilds a real round per row and asserts the exact payouts or the exact contract errorpnpm keeper:testsettlement-guard.test.tsevaluates every row through the guard, then the acceptance tests drivecloseRound/voidIfStalewith a fake SDKsettlement_fixture_guard_reasons_match_contract_rules/ the agreement block in the keeper suiteThe agreement both suites assert:
guard_reason != "-"⟹ the contract rejects that action with the mappederror (
already_settled→AlreadySettled,round_voided→RoundVoided,not_cleared→NotCleared,missing_winner→NoValidBids,void_not_open→NotVoidable,void_grace_not_elapsed→NotVoidable);refund_missing,bidder_page_incomplete,winner_mismatch) map tonull— the contract may accept the round, butthe guard refuses because its view cannot prove the refund set;
guard_reason == "-"⟹ the contract answersok.Rows the guard and the contract disagreed on before this change are in the
fixture header:
no-valid-bids-voided(settle →RoundVoided),void-of-revealed-roundandvoid-inside-grace(void →NotVoidable). Thereverse direction is covered by
missing-refund-stateandtruncated-bidder-page, rounds the contract accepts that the guard stillrefuses. Editing one side's numbers now fails the other suite.
Verification
pnpm keeper:typecheckpnpm keeper:testcargo test -p sub-rosa-roundpnpm coverage:testpnpm bindings:check(stellar 28.1.0)pnpm docs:check,docs:check-links,snapshot:check,logging:check,errors:check,errors:normalize:check,threat-model:checkcargo build --target wasm32v1-none --releasecfg(test)only)Notes
feat/keeper-round-lease, so the PR diff shows that commit too until feat(keeper): exclusive per-round lease across queue, store, and watch loop #435merges; after that only this change remains. The
watch-lease.test.tsupdates here (fake SDK now serves bidder page / bid state so the guard can
verify) belong with the lease tests that exercise them.
from the 10 new snapshots written by the fixture test.
pnpm time:guardfails on a clean checkout too (it lives inapps/webandis not part of any CI workflow).