Skip to content

feat(keeper): make the settlement guard refuse a settle the contract would reject - #451

Merged
karagozemin merged 3 commits into
Sub-Rosa-Issue:mainfrom
classikdev:feat/issue-385-settlement-guard-contract-rules
Oct 1, 2026
Merged

karagozemin merged 3 commits into
Sub-Rosa-Issue:mainfrom
classikdev:feat/issue-385-settlement-guard-contract-rules

Conversation

@classikdev

Copy link
Copy Markdown
Contributor

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/round will
actually accept, so the guard happily submitted:

  • a settle whose refund set it could not prove (unreadable or partial bid
    state) — the contract needs the surplus transfer and every non-winner refund
    to succeed in one transaction;
  • a settle of a round the contract already considers voided;
  • a void of a fully revealed round, or a void inside the 3600s grace window.

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's clear does
    (strict >/< over revealed bids in bidder order, ties go to the first
    bidder) 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 would
    execute (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 status Open and now > reveal_deadline + 3600s
    (VOID_GRACE_SECONDS), matching VOID_GRACE in contracts/round/src/lib.rs.
  • Refusal reasons are typed: 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)

  • closeRound and voidIfStale now ask the guard before submitting; a refusal
    is carried back as a typed guardSkip instead of being dropped.
  • The early void gate (previously a bare status check) runs the same guard, so
    the keeper's "why didn't you act" answer is a contract rule, not a guess.
  • Refusals are logged by the watch loop and exposed on the status API as
    guardSkip ({ action, reason, detail, at }) and guardSkipIndicator
    ("<action> refused: <reason>"), so an operator can see why nothing was
    submitted — services/keeper/README.md documents 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:

Suite Test
cargo test -p sub-rosa-round settlement_fixture_drives_the_contract builds a real round per row and asserts the exact payouts or the exact contract error
pnpm keeper:test settlement-guard.test.ts evaluates every row through the guard, then the acceptance tests drive closeRound / voidIfStale with a fake SDK
both settlement_fixture_guard_reasons_match_contract_rules / the agreement block in the keeper suite

The agreement both suites assert:

  • guard_reason != "-" ⟹ the contract rejects that action with the mapped
    error (already_settled→AlreadySettled, round_voided→RoundVoided,
    not_cleared→NotCleared, missing_winner→NoValidBids,
    void_not_open→NotVoidable, void_grace_not_elapsed→NotVoidable);
  • local-view reasons (refund_missing, bidder_page_incomplete,
    winner_mismatch) map to null — the contract may accept the round, but
    the guard refuses because its view cannot prove the refund set;
  • guard_reason == "-" ⟹ the contract answers ok.

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-round and void-inside-grace (void → NotVoidable). The
reverse direction is covered by missing-refund-state and
truncated-bidder-page, rounds the contract accepts that the guard still
refuses. Editing one side's numbers now fails the other suite.

Verification

Check Result
pnpm keeper:typecheck clean
pnpm keeper:test 131 pass / 0 fail (24 suites)
cargo test -p sub-rosa-round 89 pass / 0 fail
pnpm coverage:test 81.95% aggregate, keeper 87.10% (gate ≥ 70%)
pnpm bindings:check (stellar 28.1.0) round bindings up to date — no public contract interface change
pnpm docs:check, docs:check-links, snapshot:check, logging:check, errors:check, errors:normalize:check, threat-model:check all pass
cargo build --target wasm32v1-none --release builds (fixture test is cfg(test) only)

Notes

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

drips-wave Bot commented Sep 30, 2026

Copy link
Copy Markdown

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

Learn more about application limits

@karagozemin
karagozemin merged commit d89bbd6 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.

Make the settlement guard refuse a settle the contract would reject

2 participants