Skip to content

feat(minter-guard): model denyMinter as the vote-quorum veto it is - #49

Merged
TaprootFreak merged 11 commits into
developfrom
feat/minter-guard-quorum-preflight
Jul 28, 2026
Merged

feat(minter-guard): model denyMinter as the vote-quorum veto it is#49
TaprootFreak merged 11 commits into
developfrom
feat/minter-guard-quorum-preflight

Conversation

@TaprootFreak

@TaprootFreak TaprootFreak commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

MinterGuardService treated denyMinter as an admin call. It is not: it is a shareholder veto gated on a
2% Equity vote quorum (Equity.checkQualified), inside the finite application period from suggestMinter.
This PR models that precondition, verifies it before acting, and makes the guard's readiness visible
instead of discoverable only through a failed deny against a live proposal.

Closes #48.

Behaviour before / after

before after
Qualification never checked checked at startup and once per cycle before any send, against votesDelegated — the value the contract itself uses
Rejected deny retried every 5 min forever permanent rejection stops immediately; anything else stops after 3 attempts
Alerting one critical alert per failed attempt one alert per minter per terminal state, retried until delivery is confirmed; skip pages rate-limited per class
Revert reason raw error.message custom errors decoded; empty revert data, a mined revert and a transport failure are three separate diagnoses
Deny deadline applicationTimestamp + applicationPeriod from the indexed row minters(address) read from the chain
tx.wait() unbounded capped per transaction and per cycle, below the 5-minute cadence
Guard init failure aborted the whole monitoring process only a broken GUARD_PRIVATE_KEY aborts; anything else disables the guard, pages once, monitoring continues
Empty whitelist logged like a populated one logged as a warning naming deny-by-default
Visibility none GET /guard + a GUARD DELEGATION dashboard section

Backend

  • Qualification preflight. Once per cycle, before any denyMinter: build the helper set, read
    totalVotes() and votesDelegated(signer, helpers), and skip loudly when under quorum or short on gas.
    The gas floor is checked against a worst-case ceiling before estimateGas, because a node that verifies
    the balance inside eth_estimateGas would otherwise turn a funding shortfall into a generic revert
    instead of a gas page.
  • Startup probe. initialize() takes its verdict from the same code path GET /guard uses, so the boot
    log and the dashboard cannot disagree, and pages once when the signer is not qualified. It deliberately
    does not abort bootstrap: a governance state that a single delegation fixes at runtime must not take all
    monitoring down, and the state is exposed continuously at GET /guard.
  • Dynamic helper set. computeHelpers (new pure module minter-guard.logic.ts) rebuilds the delegation
    graph from the indexed Delegation(from, to) events — latest-wins per delegator, transitive like
    Equity._canVoteFor, cycle-safe, signer excluded, sorted strictly ascending by numeric address value as
    _checkDuplicatesAndSorted demands. GUARD_HELPER_ADDRESS is now optional and acts as an explicitly
    named seed unioned into that set, for a fresh database or an in-progress backfill.
  • Three distinct revert diagnoses. The requires inside Equity.votesDelegated are bare, so they revert
    with no data and reach the client as missing revert data — which reads like an RPC fault.
    classifyDenyError names that case explicitly (a helper is unsorted/duplicated, equals the signer, or does
    not delegate to the signer). A mined revert is separate: ethers reports data: null for every reverted
    receipt regardless of the real reason, so blaming the helper list there would be wrong; that class names
    the realistic causes and the transaction hash. An error with no revert data and no revert marker at all is
    a nonce, funds, timeout or network failure and says so, so a network blip cannot send an operator after
    GUARD_HELPER_ADDRESS.
  • Stale-seed protection. When the quorum read is rejected with empty revert data and a seed is
    configured, the pre-check retries once without the seed, continues on the graph-only set, and pages so the
    environment value gets fixed. A seed equal to the signer is rejected at startup, since the contract
    forbids it outright.
  • Retry policy. Per-minter attempt state: TooLate is permanent and stops immediately; anything else
    stops after three attempts. The terminal alert is sent once — and, because the service documents that
    sendCriticalAlert returns false on non-delivery, it is retained and retried until delivery is
    confirmed instead of being marked sent. Skip pages have one independent cooldown per class, armed only on
    confirmed delivery, so a reassuring page can never suppress a critical one.
  • Authoritative window handling. Candidates are resolved against minters(address) and sorted by
    deadline before the pre-check runs, so the most urgent veto window is served first and an already-resolved
    minter neither inflates a page nor triggers a false one. A bounded sweep additionally covers minters the
    guard was already tracking: the indexed status flips away from PROPOSED on local wall-clock time before
    the guard could observe a closing window, so without it the "passed unchallenged" warning would never
    fire.
  • One cycle deadline. Every pass — resolve, send loop, sweep, alert retries — derives its remaining time
    from a single deadline below the cron interval, and tx.wait is capped by whatever is left of it. An
    unbounded wait would leave the cycle flag set, so no later cycle and no sibling watcher would run — and
    because nothing throws, no stuck-alert would fire either. The deadline is meaningful rather than exact: an
    in-flight RPC call cannot be cancelled, so a cycle can overshoot by one outstanding read.
  • Cadence docstring corrected: the class claimed an hourly cadence was sufficient while the watcher has
    been running every 5 minutes.

API + dashboard

  • GET /guard returns GuardResponse (signer, live voting power %, quorum threshold, qualified, helper
    count, gas balance, modelled deny cost, gas verdict, Equity address, chain id). qualified is the
    contract's own verdict via votesDelegated, with a seed-less retry, so the endpoint cannot report a
    healthy guard while a real deny would revert; the percentage is a display estimate over the same helper
    set. Fail-loud: a genuine read failure 5xxes rather than reporting a fabricated zero. The private key never
    leaves the backend.
  • New GUARD DELEGATION section renders that status and, when a live signer exists, offers a wallet-backed
    delegateVoteTo(signer) call so JUICE holders can lend the guard their votes. Delegation is non-custodial
    and additive: the delegator keeps their JUICE and their own voting power, and the guard may only count the
    votes toward the quorum in addition to the signer's own.
  • The wallet stack mounts lazily inside that section only. VITE_RPC_URL / VITE_WAGMI_ID are read
    fail-loud, but the failure is contained: without them the read-only panel still renders and the button
    shows a visible "wallet delegation unavailable" notice instead of white-screening the dashboard. Unset
    build secrets therefore do not fail the build — the delegate action simply stays inactive until they are
    configured.

Tests

src/monitoringV2/minter-guard.logic.spec.ts covers the two security-critical pure functions:
computeHelpers (ordering contract, latest-wins re-delegation, multi-hop, cycle safety, seed union, signer
exclusion) and classifyDenyError (permanent vs transient, every revert-data nesting shape, empty data,
mined receipt, no data at all, extraction precedence), plus a regression test pinning the quorum constant
Equity.sol keeps private.

One incidental fix was needed to make them runnable: jest had no moduleNameMapper for the src/… path
alias, so provider.service's import could not be resolved and the whole suite failed to load — on
develop too, where zero tests ran. With the three-line mapper the suite loads and both files pass.

Verification

Run on an arm64 host, matching the CI runners:

gate result
tsc --noEmit clean
jest (full suite) 39 passed / 2 suites — develop: 0 tests, the suite could not even load
prettier --check on the files this diff adds or rewrites clean
eslint on the files this diff adds or rewrites clean
backend image (Build and test Monitoring) builds
frontend image (Build Frontend), no wallet secrets builds

Pre-existing debt, measured on develop and deliberately left alone: prettier --check flags six files
repo-wide, and eslint reports three unused-import errors in three files this branch never touches. One of
the six prettier-flagged files, monitoring.service.ts, is touched here — its violations and its seven
prettier/prettier eslint errors sit in pre-existing code (prettier --write rewrites only lines 105-138,
outside every hunk of this diff), and the error count is identical on develop.

Dependency notes

@web3modal/wagmi declares @wagmi/core and @wagmi/connectors as peers without an upper bound, so a
fresh install pulls @wagmi/core 3.x — which requires a newer TypeScript than this project pins and does not
match the wagmi 2.x the modal was built against. Both are therefore pinned through overrides to the versions
wagmi itself depends on, rather than papering over the conflict with legacy-peer-deps. The frontend build
stage additionally installs python3/make/g++ because the walletconnect tree compiles ws' native addons
(bufferutil, utf-8-validate).

Notes for deployment

  • No new required environment variable. GUARD_HELPER_ADDRESS changed from required to optional, so existing
    configurations keep working unchanged.
  • Expect one additional startup alert per boot while the signer is under the 2% quorum — that is the point of
    the change: the guard now says so instead of failing silently at deny time.
  • To arm the delegate button, set the VITE_RPC_URL and VITE_WAGMI_ID build secrets for the frontend
    workflows. Until then everything else on the page works.

Known limitations, deliberately

  • Per-minter tracking is in memory. Attempt counts, terminal outcomes and an undelivered page live for
    the process lifetime. A restart inside a proposal's application period loses the knowledge that the address
    was once a deniable candidate, and since the indexed status leaves PROPOSED on wall-clock time, the
    "passed unchallenged" page would not fire for that one minter. No wrong action is taken — the failure mode
    is silence. Filed as Minter guard: a restart inside the application period loses the tracking that produces the slipped-through warning #51 with two possible directions; closing it needs new machinery in exactly the place
    where this branch's review rounds kept finding defects, so it is not bolted on late here.
  • The boot probe cannot tell an unbackfilled delegation graph from a genuinely empty one. At worst a
    convenience page is missing at startup; the per-cycle pre-check pages as soon as a real candidate exists.
  • The confirmed-deny success page is not retained for retry. The deny is already on-chain and needs no
    human action, and the marking has to precede the alert to prevent a double deny; a failed delivery is
    logged at error level.
  • An end-of-cycle assertion guards the invariant that produced three rounds of the same defect — every
    candidate the guard observed must be tracked, so the sweep can see it later. A violation pages instead of
    silently reopening the hole.

Out of scope

Reviewing this change surfaced a separate defect in how minter status is derived — a re-suggested minter
stays DENIED forever, so the guard never sees the second attempt. That code predates this branch and is
untouched here; it is filed as #50.

One smaller pre-existing item, noted rather than fixed so this diff stays scoped: the "monitoring system
stuck" alert in monitoring.service.ts interpolates its error message into Markdown without escaping, the
same way the guard-init alert did before this branch. escapeMarkdownText and truncateAlertBody are now
exported from the telegram service, so that call site is a one-line change whenever someone touches it.

denyMinter is a shareholder veto gated on Equity.checkQualified inside the
finite application period from suggestMinter, not an admin call. The guard
never modelled that precondition: it retried a permanently rejected deny on
every cycle, raised a critical alert per attempt, and logged the raw error
message instead of the actual rejection reason.

- Pre-check per cycle before any send: build the helper set, read totalVotes
  and votesDelegated (the values the contract itself uses) and skip loudly
  when under quorum or short on gas. The gas floor is checked against a
  worst-case ceiling before estimateGas, because a node that verifies the
  balance inside eth_estimateGas would otherwise turn a funding shortfall
  into a generic revert instead of a gas page.
- Startup probe reads additive voting power (votes(signer) + sum votes(helper),
  which cannot revert on a stale graph) and pages once when not qualified.
  It does not abort bootstrap: a governance state that a delegation fixes at
  runtime must not take all monitoring down, and GET /guard exposes it
  continuously.
- New pure module minter-guard.logic.ts: computeHelpers rebuilds the
  delegation graph from indexed Delegation events (latest-wins, transitive,
  cycle-safe, signer excluded, sorted strictly ascending by numeric address
  value as _checkDuplicatesAndSorted demands); GUARD_HELPER_ADDRESS becomes
  an optional seed unioned into that set.
- classifyDenyError separates a permanent TooLate from transient causes and
  names the empty-revert-data case: the bare requires in votesDelegated carry
  no data and reach the client as "missing revert data", which reads like an
  RPC fault. A data-less error without any revert marker is classified
  separately so a nonce or network failure is never blamed on the helper list.
- Retry policy per minter: TooLate stops immediately, anything else stops
  after three attempts, and the FAILED alert is sent once on the terminal
  state. Skip pages are rate-limited to one per hour per kind.
- Just-in-time window check against applicationTimestamp + applicationPeriod
  with a 60s buffer, so a closed window is recorded instead of burning gas on
  a guaranteed revert.
- tx.wait is bounded at 180s: an unbounded wait leaves the cycle flag set, so
  no later cycle and no sibling watcher runs, and nothing throws to alert on.
- A guard init failure no longer aborts the whole monitoring process; only a
  missing or invalid GUARD_PRIVATE_KEY does. An empty whitelist is logged as a
  warning naming deny-by-default, so a truncated or unmounted file is no
  longer indistinguishable from the intended configuration.
- GET /guard returns the live guard status (signer, voting power, quorum,
  qualification, helper count, gas), fail-loud on a read error rather than
  reporting a fabricated zero.
The dashboard gave no way to tell whether the minter guard is armed. It now
renders the /guard status — signer, live voting power, the Qualified (>= 2%)
verdict, helper count and gas readiness — and, when a live signer exists,
offers a wallet-backed delegateVoteTo(signer) call so JUICE holders can lend
the guard their votes. Delegation is non-custodial and additive: the delegator
keeps their JUICE and their own voting power, and the guard may only count the
votes toward the quorum in addition.

The wallet stack mounts lazily inside that section only. VITE_RPC_URL and
VITE_WAGMI_ID are read fail-loud, but the failure is contained: without them
the read-only panel still renders and the button shows an inline "wallet
delegation unavailable" notice instead of white-screening the dashboard.
Unset build secrets therefore do not break the build.

Citrea mainnet has no chain definition in viem, so the chain is built locally
from the chain id the backend reports, which keeps the frontend from drifting
away from the network Equity actually lives on.

@wagmi/core and @wagmi/connectors are pinned via overrides: @web3modal/wagmi
declares them as peers without an upper bound, so a fresh install otherwise
pulls @wagmi/core 3.x, which needs a newer TypeScript than this project pins
and does not match the wagmi 2.x the modal was built against. The frontend
image additionally needs python3/make/g++ in the build stage, because the
walletconnect tree compiles ws' native addons.
Unit-tests the two security-critical pure functions: computeHelpers (ordering
contract, latest-wins re-delegation, multi-hop, cycle safety, seed union,
signer exclusion) and classifyDenyError (permanent vs transient, every revert
data nesting shape, empty data, no data at all, extraction precedence), plus a
regression test pinning the quorum constant that Equity keeps private.

Also registers a jest moduleNameMapper for the src/... path alias. Without it
the runner cannot resolve provider.service's import, so the whole suite failed
to load and the new tests would not have been runnable.

README and .env.example now describe the guard as it behaves: the 2% quorum,
the pre-send verification, the optional GUARD_HELPER_ADDRESS seed, bounded
retries with a single escalation, empty-whitelist deny-by-default, and the
GET /guard endpoint.
Ten defects, each of which could either lose a page that a human must act on
or point that human at the wrong cause.

Alerting integrity:
- A terminal escalation marked itself delivered without checking the boolean
  sendCriticalAlert returns, which the service documents explicitly. With
  Telegram down the page was lost for good, because `done` also removes the
  minter from the candidate set. The message is now retained and retried on
  the next cycle; when alerting is disabled outright there is nothing to
  retry and that is recorded instead.
- The reassuring "helper seed rejected, cycle continues" page shared its
  cooldown with the critical "under quorum, nothing denied" page, so the
  former could silence the latter for an hour while a finite veto window ran
  out. Each page class now has its own timer.
- A pre-check that cannot evaluate qualification at all (RPC failure, or a
  helper list rejected with no seed configured) only wrote a log line while
  every candidate went unchallenged. It now pages, rate-limited.
- On a first boot or a reset database the probe ran before the first backfill
  and reported a quorum shortfall that was really an empty delegation graph.
  It now says it cannot assess yet.

Diagnosis truthfulness:
- ethers reports a mined, reverted transaction as CALL_EXCEPTION with
  data:null regardless of the real reason, so the empty-revert branch blamed
  the helper list for every failed confirmation. A mined revert is now its own
  class that names the realistic causes and the transaction hash.
- The deny deadline came from the indexed row, which lags a confirmed deny.
  After a restart in that gap the guard re-sent, the contract reverted because
  denyMinter deletes the mapping entry, and the guard paged that an
  unwhitelisted minter was passing unchallenged — about a minter it had
  successfully denied. The deadline is now read from the chain, and a
  no-longer-pending minter is recorded without an alert.
- GET /guard summed raw votes() over the helper set including the unvalidated
  seed. Since votes() is balance times time and says nothing about delegation,
  a funded seed pointing elsewhere made the dashboard report qualified while a
  real deny would revert. `qualified` now comes from votesDelegated, the value
  the contract itself checks, with a seed-less retry; the percentage stays a
  display estimate and says so.

Robustness:
- Confirmation waits ran sequentially with a per-transaction timeout only, so
  two slow denies could overrun the monitoring cadence. A per-cycle budget now
  bounds the total, and candidates that no longer fit are deferred rather than
  failed.
- An invalid optional GUARD_HELPER_ADDRESS threw the config error class and
  therefore aborted the whole monitoring process, contradicting the stated
  contract that only the private key does that. It now disables the guard.
- A seed equal to the signer was dropped silently, although the contract
  rejects it outright; it is reported at startup now.

Also corrects three statements that were simply untrue: the dashboard claimed
the signer holds no JUICE (nothing enforces that, and its own votes count),
the event config claimed the guard depends on its alert flag for indexing, and
the environment documentation overstated where the stale-seed protection
applies.
…rden alerting further

Second review round. The heaviest finding was that the terminal page warning
that an unwhitelisted minter slipped through could never fire in normal
operation: syncMinters() runs before the guard each cycle and relabels the row
from PROPOSED to APPROVED on local wall-clock time, while the guard only looks
at PROPOSED rows and only pages inside a 60-second buffer. With a 5-minute
cadence no cycle ever sees both conditions at once, so the minter dropped out
of the candidate set in silence.

A bounded sweep now covers exactly the minters this process already tracked:
for each one the on-chain mapping decides — cleared means it was denied and is
marked done without an alert, a future deadline means it stays a candidate, and
a past deadline means the application period ended without a deny and pages
once. The candidate query is deliberately NOT widened to APPROVED: the
whitelist ships empty, so that would page for every legitimately approved
minter on the first run.

Further fixes from the same round:
- The startup probe still summed raw votes over an unvalidated seed, so a
  funded seed delegating elsewhere reported "qualified" at boot. It now takes
  its verdict from the same contract-truthful path the /guard endpoint uses, so
  the boot log and the dashboard cannot disagree.
- Candidates are resolved against the chain and sorted by deadline before the
  pre-check runs. Previously a stale row for an already-denied minter inflated
  the candidate count of a critical page, and the per-cycle confirmation budget
  could be spent on a distant deadline while a closing window was deferred.
- A skip page armed its hour-long cooldown before delivery was attempted, so a
  failed page silenced its whole class for an hour. The cooldown is now armed
  only on confirmed delivery.
- Dynamic provider text (error messages, codes such as NONCE_EXPIRED) is
  escaped before it enters a Markdown alert. Unescaped underscores made those
  messages undeliverable, and the retry machinery then resent the same
  unsendable text every cycle.
- The pending-alert retry pass moved behind the deny work and is bounded per
  cycle, with the remaining backlog named in the log. Notifications are not
  time-critical; a veto window is.
- The reported voting-power percentage is recomputed over the helper set
  actually used, so it can no longer contradict the qualification verdict
  beside it.

Two limitations are deliberate and now documented in place: the boot probe
cannot distinguish an unbackfilled delegation graph from a genuinely empty one
(at worst a missing convenience page, since the per-cycle pre-check pages when
it matters), and the confirmed-deny success page is not retained for retry
because the deny is already on-chain and needs no human action.
…aper

Third review round. The recurring defect of this branch had one root: a minter
became tracked as a side effect of an outcome — a deny attempt, a closed
window, an already-resolved mapping — rather than as a consequence of having
been observed. Every early exit therefore left a candidate untracked and
invisible to the sweep that is supposed to notice it slipping through: a read
failure, the confirmation-budget deferral, and a pre-check that bailed for the
whole cycle. That is why the same silence reappeared three times, one level
deeper each round.

The candidate is now registered the moment it is confirmed deniable, before any
pre-check runs, and the end of each cycle asserts the invariant: every observed
candidate has tracking state. A violation logs the addresses and pages, so a
future early exit cannot quietly reopen the hole instead of being caught.

Also from this round:
- The guard-init failure alert interpolated raw error text into a Markdown
  message. Its real messages contain underscores — GUARD_HELPER_ADDRESS is
  invalid, GUARD_WHITELIST_FILE is missing — so Telegram could reject the whole
  page at the one moment it matters: the guard is off and nobody is told. The
  escaper moved to the telegram service as a shared function and is used there
  too, and that alert now records a failed delivery instead of discarding the
  result. It deliberately gets no retry machinery: a one-shot bootstrap path is
  not a cycle.
- The on-chain deadline is re-read immediately before each send. Reading it once
  per cycle meant that a minter another actor denied while the guard waited for
  a previous confirmation was still sent to, reverted, marked permanently
  failed, and paged as needing a manual deny.
- The two RPC passes are bounded per cycle and the sweep rotates its starting
  point, so a large tracking map can neither outgrow the cadence nor starve its
  own tail.
- Alert bodies are truncated below Telegram's message limit, and a failed retry
  rotates to the back of the queue. An oversized provider error could otherwise
  make a page permanently unsendable and monopolise every retry pass.
- The startup probe no longer depends on the gas reads it does not need, so a
  fee-data failure cannot suppress an under-quorum page.
- The reported voting power and the qualification verdict now come from the same
  contract call, so they cannot contradict each other.
- A rejected page retries after a bounded backoff instead of on every cycle.
- Two overstatements corrected: a page claimed a deny was impossible while the
  contract would still have accepted one, and the backlog counter stayed silent
  when every delivery attempt failed.
…cycle

Fourth review round. Round 3's invariant only half held: a candidate was
registered AFTER its on-chain deadline read, and that pass is capped — so a
candidate whose read failed, or which sat beyond the cap, was still never
tracked. Worse, the end-of-cycle assertion checked the uncapped candidate set,
so ordinary truncation was reported as a code defect: it would have taught the
operator to ignore the one page that says the guard is broken.

Registration is bookkeeping, so nothing that can fail may come before it. Every
candidate is now registered before any network call, and the candidate list is
sorted by the deadline derivable from the indexed row before the cap applies, so
a window with minutes left can no longer sit behind twenty-five with days left.
The chain read stays authoritative for the decision; the row only decides who is
examined first.

Bounding the work:
- The two RPC passes now have a wall-clock budget as well as a call count. A
  count cap does not stop twenty-five sequential reads from consuming the
  configured 60-second RPC timeout each, which would run a cycle ~25 minutes
  past its cadence and suppress several scheduled ticks while veto windows keep
  moving.
- The gas ceiling that guarantees a shortfall pages before a doomed send now
  scales with the helper count. It modelled a call that loops over an unbounded
  helper list with a flat constant, so the balance check could pass while the
  transaction ran out of gas.

Alerting and diagnosis:
- A skip page whose delivery failed is retained and retried like a terminal page
  and counted in the backlog. Previously it was only stamped with a backoff, so
  it was delivered solely if its condition happened to recur — and when the
  candidate had meanwhile been approved, it never did.
- A replaced or repriced transaction is no longer reported as a mined revert.
  Those errors carry a receipt describing the replacement, so the old check
  (receipt present) could state the opposite of the truth: the replacement may
  have succeeded. The revert branch now also requires a failed receipt status.
- Before claiming manual intervention is required, the mapping is re-read. If
  another actor denied the minter in between, the guard records that and stays
  quiet instead of paging about a minter that is already denied.
- The window-closed remedy uses the chain timestamp and the contract's strict
  comparison rather than local time, so the text cannot contradict what the
  contract would do.
- The startup page honours its delivery result, like its three siblings.
- Truncation moved next to the escaper in the telegram service so every alert
  site can reach it, including the bootstrap page whose body embeds a configured
  path of unbounded length.

Two comments corrected to match the code: a failed page is retried at the end of
the same cycle, not only the next one, and the qualification values come from two
sequential contract reads rather than one atomic snapshot — this deployment has
no Multicall3.
…rmissive gas floor

Fifth review round, and this one removes machinery rather than adding it. All
three defects were introduced by the previous round's own fixes.

- Three independent time meters could not express the invariant that matters:
  the whole cycle must fit inside the cadence, because the caller guards it with
  a single flag and an overrun costs the next tick entirely. A 60-second budget
  was applied separately to two passes, a 240-second one metered only
  transaction waits, and the per-candidate reads in the send loop had no limit at
  all — worst case well past five minutes. There is now one cycle deadline that
  every pass derives its remaining time from, including the previously unbounded
  path, and the two obsolete constants and their accumulator are gone.
- The retained-skip-page map was keyed by alert kind, but some of those pages
  name a single minter. A later page of the same kind for a different minter
  therefore destroyed an earlier undelivered one: the retention built to stop
  pages being lost was losing them. Retention is now keyed per page; the cooldown
  stays per kind, since its job is to limit how often a class of page fires.
- The helper-scaled gas ceiling was checked before the precise estimate and, on
  a shortfall, skipped every candidate for the cycle — at 30 000 gas per helper,
  far above the real cost of a votes() read and a delegation walk. It therefore
  invented shortfalls and stopped the guard from denying anything, which is a
  worse outcome than the out-of-gas risk it was raised to cover. The per-helper
  term is now realistic and the ceiling is capped absolutely; the floor stays
  ahead of the estimate, because an underfunded signer must page even when
  estimateGas itself reverts for lack of funds, but it is deliberately permissive
  and the estimate remains the accurate check.

Also corrects a comment that still claimed candidates are registered in the
resolve pass; registration moved ahead of it, RPC-free, in the previous commit.
Verification pass on the previous commit. Three of its guards collided with
each other.

- The send loop checked the remaining cycle budget before the two reads that
  detect an already-resolved minter and a closing veto window. Since the working
  set is sorted soonest-deadline-first, that deferred exactly the most urgent
  candidate instead of recording it and paging that it is passing unchallenged.
  Those reads also spent budget after the usefulness check, so the per-transaction
  wait timeout could go non-positive — and a non-positive timeout does not
  disable the timer, it fires almost immediately, so the transaction was
  submitted and its nonce consumed while the guard booked it as a failed attempt.
  The reads now run first and the budget gates only the send, which makes the
  timeout positive by construction.
- The skip-page retry loop did not rotate a failed entry to the back of the
  queue, unlike its terminal-page sibling. That was harmless while retention was
  keyed by alert kind — at most six entries — but the previous commit keyed it
  per page, so the deadline kind can now hold one entry per minter and five
  persistently failing ones would starve every later page indefinitely.
- An insufficient-funds revert from the precise gas estimate was swallowed by the
  catch that exists to keep a candidate-specific revert from dropping the cycle.
  The guard then sent a transaction that could not succeed and reported it as an
  ordinary failure instead of the gas page the floor-before-estimate ordering is
  meant to guarantee. That one reason is now recognised and pages; every other
  estimate failure still proceeds on the floor.

Also closes the two remaining spots that performed chain reads without consulting
the cycle deadline, so the invariant the previous commit introduced is now
literally true rather than nearly true.
…unbounded

The previous commit moved the budget gate after the two just-in-time reads so a
candidate whose veto window is closing is recorded and paged instead of silently
deferred. That ordering is right and stays — but the gate was also the only thing
bounding those reads. Every candidate in the working set then performed both
reads unconditionally, each bounded only by the configured RPC timeout, so a
degraded endpoint could keep one cycle running for tens of minutes against a
240-second deadline, holding the caller's running flag and starving the sweep and
the alert retries that follow in the same cycle.

The loop now checks the cycle deadline where its sibling resolve pass already
does: once before each candidate's reads, and once between the two reads so a
single slow call cannot be compounded by a second. Deferred candidates keep their
existing semantics — not marked, not paged, still candidates next cycle — and the
send gate keeps its own floor, so the wait timeout stays positive by
construction.

The bound is stated honestly in the code: an in-flight call cannot be cancelled,
so the cycle can still overshoot by at most one read. That is the same bound the
resolve pass has, and it makes the deadline meaningful rather than exact.
… freshness matters

Final review round. Two independent lenses converged on the first item.

- The pre-check consulted the cycle deadline only at entry and then made up to
  seven sequential chain calls. At the configured 60-second RPC timeout that is
  several minutes past a 240-second budget, and during it the guard takes no
  action at all — not even the cheap reads that would record and report a
  candidate whose veto window is closing — while the caller's running flag can
  swallow whole scheduled ticks. Every sibling pass already checks before each
  read; this was the one place where the previous commit's claim of a one-read
  bound was untrue. It now checks before each of the seven.
- The wait timeout was computed before the transaction was submitted, and that
  submission is itself several round trips, so the value was stale before the
  wait began. It is now computed immediately before the wait — with a floor,
  because a transaction that is already broadcast must never be abandoned
  instantly. That distinction is the point: the deadline governs whether a send
  is started, not how long an in-flight transaction is awaited.
- The terminal remedy re-read the deadline to avoid claiming manual action for a
  minter someone else had denied, but compared it against a block timestamp
  captured before the submission and a wait that can last minutes. A fresh
  deadline against a stale clock is not a fresh decision, so both sides are now
  read together, and the note says so when either read fails.
@TaprootFreak

Copy link
Copy Markdown
Contributor Author

This branch went through twelve review passes on two lenses — conformity/completeness and logic/correctness —
and needed eight rounds of fixes before both came back clean. Every round ran against the then-current head, so
each fix was itself reviewed rather than assumed good; the gates (type-check, tests, formatting, lint, and both
CI images) were re-run after every round.

That count is high for a change this size, and the reason is worth stating for whoever reviews it: one failure
class kept reappearing one level deeper. A minter could leave the guard's attention with nobody notified — first
because the indexed status flips off PROPOSED on wall-clock time before the guard could observe a closing
window, then because tracking membership was a side effect of an outcome rather than of observation, then
because registration sat behind an on-chain read that could fail or be capped. It is now established before any
network call and asserted at the end of every cycle, so a future early exit fails loudly instead of quietly
reopening the hole.

Two defects found along the way were judged out of scope and filed separately rather than folded in: #50 (a
re-suggested minter stays DENIED forever, so the guard never sees the second attempt) and #51 (a restart
inside an application period loses the tracking that produces the slipped-through warning). Four deliberate
limitations are documented in the description.

@TaprootFreak
TaprootFreak marked this pull request as ready for review July 28, 2026 04:40
@TaprootFreak
TaprootFreak merged commit 9d3bcd0 into develop Jul 28, 2026
5 checks passed
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.

MinterGuard: denyMinter is a vote-quorum veto the service never models or verifies

1 participant