Release: develop -> main - #52
Merged
Merged
Conversation
* feat(minter-guard): verify the 2% vote quorum before denying a minter 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. * feat(frontend): add GUARD DELEGATION section with delegate action 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. * test(minter-guard): cover helper-set and error classification 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. * fix(minter-guard): close alerting and diagnosis gaps found in review 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. * fix(minter-guard): make the slipped-through warning reachable, and harden 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. * fix(minter-guard): assert the tracking invariant, share the alert escaper 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. * fix(minter-guard): make registration RPC-free and bound the work per 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. * fix(minter-guard): one cycle deadline, per-page alert retention, a permissive 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. * fix(minter-guard): run the cheap checks before the budget gate 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. * fix(minter-guard): bound the per-candidate reads the reordering left 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. * fix(minter-guard): bound the pre-check, and take fresh readings where 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.
…oyment secrets (#53) * fix(frontend): keep the public wallet config in the repo, not in deployment secrets The guard delegation section needs a Citrea RPC URL and a WalletConnect project id. Both are public by nature: they are baked into the browser bundle and visible to anyone who opens the dashboard. Wiring them through repository secrets was therefore the wrong mechanism — it implied they were sensitive, and it made the delegate button depend on deployment configuration that a local or fork build cannot have. They are now Dockerfile defaults, exactly like VITE_API_BASE_URL above them, and both workflows stop passing them. A plain `docker build` now produces a working delegate button, and a deployment that wants a different endpoint or project can still override either with --build-arg. The RPC default is the same public endpoint the backend uses; it answers browser requests directly (access-control-allow-origin: *), so it needs no proxy. Verified by building the image without any build-arg and confirming both values are present in the emitted bundle. * docs(frontend): name the coupling between the backend network and the baked RPC URL The delegate action builds its chain from the chain id the backend reports, while the RPC URL is now a fixed default in this image. Repointing the backend at another network without overriding the build-arg in the same breath leaves the wallet transport pointed at Citrea while the declared chain id says otherwise, and that combination builds silently. Naming the coupling where the override lives is cheaper than a runtime cross-check and does not add a mechanism whose own failure modes would need reviewing.
TaprootFreak
approved these changes
Jul 28, 2026
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.
Automatic Release PR
This PR was automatically created after changes were pushed to develop.
Commits: 1 new commit(s)
Checklist