feat(minter-guard): model denyMinter as the vote-quorum veto it is - #49
Conversation
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.
|
This branch went through twelve review passes on two lenses — conformity/completeness and logic/correctness — That count is high for a change this size, and the reason is worth stating for whoever reviews it: one failure Two defects found along the way were judged out of scope and filed separately rather than folded in: #50 (a |
Summary
MinterGuardServicetreateddenyMinteras an admin call. It is not: it is a shareholder veto gated on a2% Equity vote quorum (
Equity.checkQualified), inside the finite application period fromsuggestMinter.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
votesDelegated— the value the contract itself useserror.messageapplicationTimestamp + applicationPeriodfrom the indexed rowminters(address)read from the chaintx.wait()GUARD_PRIVATE_KEYaborts; anything else disables the guard, pages once, monitoring continuesGET /guard+ aGUARD DELEGATIONdashboard sectionBackend
denyMinter: build the helper set, readtotalVotes()andvotesDelegated(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 verifiesthe balance inside
eth_estimateGaswould otherwise turn a funding shortfall into a generic revertinstead of a gas page.
initialize()takes its verdict from the same code pathGET /guarduses, so the bootlog 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.computeHelpers(new pure moduleminter-guard.logic.ts) rebuilds the delegationgraph from the indexed
Delegation(from, to)events — latest-wins per delegator, transitive likeEquity._canVoteFor, cycle-safe, signer excluded, sorted strictly ascending by numeric address value as_checkDuplicatesAndSorteddemands.GUARD_HELPER_ADDRESSis now optional and acts as an explicitlynamed seed unioned into that set, for a fresh database or an in-progress backfill.
requires insideEquity.votesDelegatedare bare, so they revertwith no data and reach the client as
missing revert data— which reads like an RPC fault.classifyDenyErrornames that case explicitly (a helper is unsorted/duplicated, equals the signer, or doesnot delegate to the signer). A mined revert is separate: ethers reports
data: nullfor every revertedreceipt 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.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.
TooLateis permanent and stops immediately; anything elsestops after three attempts. The terminal alert is sent once — and, because the service documents that
sendCriticalAlertreturnsfalseon non-delivery, it is retained and retried until delivery isconfirmed 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.
minters(address)and sorted bydeadline 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
PROPOSEDon local wall-clock time beforethe guard could observe a closing window, so without it the "passed unchallenged" warning would never
fire.
from a single deadline below the cron interval, and
tx.waitis capped by whatever is left of it. Anunbounded 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.
been running every 5 minutes.
API + dashboard
GET /guardreturnsGuardResponse(signer, live voting power %, quorum threshold, qualified, helpercount, gas balance, modelled deny cost, gas verdict, Equity address, chain id).
qualifiedis thecontract's own verdict via
votesDelegated, with a seed-less retry, so the endpoint cannot report ahealthy 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.
GUARD DELEGATIONsection renders that status and, when a live signer exists, offers a wallet-backeddelegateVoteTo(signer)call so JUICE holders can lend the guard their votes. Delegation is non-custodialand 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.
VITE_RPC_URL/VITE_WAGMI_IDare readfail-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.tscovers the two security-critical pure functions:computeHelpers(ordering contract, latest-wins re-delegation, multi-hop, cycle safety, seed union, signerexclusion) 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.solkeeps private.One incidental fix was needed to make them runnable: jest had no
moduleNameMapperfor thesrc/…pathalias, so
provider.service's import could not be resolved and the whole suite failed to load — ondeveloptoo, 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:
tsc --noEmitjest(full suite)develop: 0 tests, the suite could not even loadprettier --checkon the files this diff adds or rewriteseslinton the files this diff adds or rewritesBuild and test Monitoring)Build Frontend), no wallet secretsPre-existing debt, measured on
developand deliberately left alone:prettier --checkflags six filesrepo-wide, and
eslintreports three unused-import errors in three files this branch never touches. One ofthe six prettier-flagged files,
monitoring.service.ts, is touched here — its violations and its sevenprettier/prettiereslint errors sit in pre-existing code (prettier --writerewrites only lines 105-138,outside every hunk of this diff), and the error count is identical on
develop.Dependency notes
@web3modal/wagmideclares@wagmi/coreand@wagmi/connectorsas peers without an upper bound, so afresh install pulls
@wagmi/core3.x — which requires a newer TypeScript than this project pins and does notmatch the wagmi 2.x the modal was built against. Both are therefore pinned through
overridesto the versionswagmiitself depends on, rather than papering over the conflict withlegacy-peer-deps. The frontend buildstage additionally installs python3/make/g++ because the walletconnect tree compiles ws' native addons
(
bufferutil,utf-8-validate).Notes for deployment
GUARD_HELPER_ADDRESSchanged from required to optional, so existingconfigurations keep working unchanged.
the change: the guard now says so instead of failing silently at deny time.
VITE_RPC_URLandVITE_WAGMI_IDbuild secrets for the frontendworkflows. Until then everything else on the page works.
Known limitations, deliberately
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
PROPOSEDon 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.
convenience page is missing at startup; the per-cycle pre-check pages as soon as a real candidate exists.
human action, and the marking has to precede the alert to prevent a double deny; a failed delivery is
logged at error level.
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
DENIEDforever, so the guard never sees the second attempt. That code predates this branch and isuntouched 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.tsinterpolates its error message into Markdown without escaping, thesame way the guard-init alert did before this branch.
escapeMarkdownTextandtruncateAlertBodyare nowexported from the telegram service, so that call site is a one-line change whenever someone touches it.