Skip to content

Release: develop -> main - #52

Merged
TaprootFreak merged 2 commits into
mainfrom
develop
Jul 28, 2026
Merged

Release: develop -> main#52
TaprootFreak merged 2 commits into
mainfrom
develop

Conversation

@github-actions

Copy link
Copy Markdown

Automatic Release PR

This PR was automatically created after changes were pushed to develop.

Commits: 1 new commit(s)

Checklist

  • Review all changes
  • Verify CI passes
  • Approve and merge when ready for production

* 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
TaprootFreak merged commit f4841c8 into main Jul 28, 2026
11 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.

1 participant