Skip to content

fix(minter-guard): take the deny fee from the chain, not from the library default - #54

Merged
TaprootFreak merged 3 commits into
developfrom
fix/fee-overrides-cheap-chain
Jul 28, 2026
Merged

fix(minter-guard): take the deny fee from the chain, not from the library default#54
TaprootFreak merged 3 commits into
developfrom
fix/fee-overrides-cheap-chain

Conversation

@TaprootFreak

Copy link
Copy Markdown
Contributor

What this fixes

Measured against production after #52 shipped: GET /guard reports gasEnough: false and the dashboard shows
Low gas for a signer that actually holds enough for dozens of denies. With a real proposal pending, the
guard would have refused to act and paged hourly instead.

Cause

The pinned ethers version never asks the chain for a priority fee. In getFeeData()
(lib.commonjs/providers/abstract-provider.js, 6.7.1) it hardcodes one gwei whenever the latest block carries
a base fee:

maxPriorityFeePerGas = BigInt("1000000000");
maxFeePerGas = (block.baseFeePerGas * BN_2) + maxPriorityFeePerGas;

Measured on the live chain: base fee 1,000,000 wei (0.001 gwei), eth_maxPriorityFeePerGas 100 wei,
eth_gasPrice 1,000,100 wei. So the library produces ~1.002 gwei — about 1000× what the chain asks — and
the guard used that both to price a deny and to let the library compose the transaction.

Worth being precise about what was and was not broken: the affordability check was right. EIP-1559 makes
the sender reserve gasLimit × maxFeePerGas, so a transaction carrying an inflated maxFeePerGas genuinely
cannot be submitted by an account that cannot cover the reservation — refusing to send was correct. The defect
is composing the transaction with a fee nobody asked for.

with a 208,000-gas ceiling reservation gasEnough denies affordable
before — library default (1.002 gwei) 0.000208416 cBTC false 0
after — chain's own fee (0.002 gwei) 0.000000416 cBTC true 22

Signer balance at the time of measuring: 0.000009335514187595 cBTC.

The change

One new resolveDenyFee() reads the fee from the chain once and returns both the number used for the
arithmetic and the overrides attached to the transaction, so the fee verified as affordable is by construction
the fee that gets sent — the pre-check and the send can no longer disagree.

  • EIP-1559 chain: base fee × 2 + the chain's suggested tip from eth_maxPriorityFeePerGas.
  • That RPC unsupported or unusable: falls back to the library's tip, then to zero — logging at each step,
    because a zero tip can leave a deny unmined.
  • No base fee (non-1559 chain): the legacy gas price, for both pricing and sending.
  • Nothing usable: still throws, as before.

Gas amounts and denyGasCeiling are untouched; this is only about the price per gas. The read-only status
path uses the same helper, so estimatedDenyCost and gasEnough now describe the fee the guard would really
pay.

Verification

tsc clean · jest 39/39 in 2 suites · prettier and eslint clean on the touched file · backend image
builds. The table above was computed from values read off the live chain at the time of writing, not from
fixtures.

…rary default

Measured against production: the guard reported "Low gas" and would have refused
to deny for a signer that in fact holds enough for dozens of denies.

The pinned ethers version does not ask the chain for a priority fee. In
getFeeData() it hardcodes one gwei whenever the latest block carries a base fee:

    maxPriorityFeePerGas = BigInt("1000000000");
    maxFeePerGas = (block.baseFeePerGas * BN_2) + maxPriorityFeePerGas;

On this deployment the base fee is 0.001 gwei and the chain's own suggested tip,
via eth_maxPriorityFeePerGas, is 100 wei. The library therefore produced roughly
a thousand times the fee the chain asks for, and the guard used that number both
to price a deny and to let the library compose the transaction.

That is not a wrong check. EIP-1559 makes the sender reserve gasLimit multiplied
by maxFeePerGas, so a transaction carrying an inflated maxFeePerGas genuinely
cannot be submitted by an account that cannot cover the reservation — the
pre-check was right to refuse. What was wrong is composing the transaction with a
fee nobody asked for: with a 208,000 gas ceiling the reservation came to
0.000208 cBTC against a real cost of 0.000000208, while the signer holds
0.0000093. The guard was inert on a cheap chain unless its signer was funded
about a thousandfold, and the dashboard said "Low gas" about a funded signer.

A single new resolveDenyFee() now reads the fee from the chain once and returns
both the number used for the arithmetic and the overrides put on the
transaction, so the fee that was verified affordable is by construction the fee
that gets sent. It uses the base fee plus the chain's suggested tip; if that RPC
is unsupported it falls back to the library's tip and then to zero, logging at
each step, since a zero tip can leave a deny unmined; on a chain without a base
fee it uses the legacy gas price; and it still throws when nothing usable comes
back. Gas amounts and the ceiling are untouched — this is only about the price
per gas.

Effect with the values measured on the live chain: the reservation drops from
0.000208416 to 0.000000416 cBTC, gasEnough flips from false to true, and the
current balance covers 22 denies instead of none.
Review of the previous commit on this branch. The fee cap was resolved once per
cycle and reused for every send, but sends are spread across minutes — each one
waits for its confirmation — while this chain produces a block every couple of
seconds. A base fee that rose during the cycle therefore left later candidates
going out with a stale cap: rejected as underpriced, or unmined until the
confirmation timeout, burning an attempt while the veto window runs.

The split now follows what actually moves. The parts that do not move are
resolved once per cycle: the chain's suggested tip, or the legacy gas price on a
chain without a base fee. The cap itself is built per send from the base fee of
the block that send is priced against — and that block is already in hand,
because the just-in-time window check fetched it a few lines earlier. No extra
call, and the cap can no longer drift away from the chain state the send decision
was made on.

Two smaller corrections from the same review:
- The cycle-level resolver makes up to three sequential chain calls and had no
  deadline check between them, while every other sequential chain in this file
  checks before each call. It now does the same, and defers the cycle instead of
  overrunning it. The read-only status path calls it without a deadline, because
  a single request is not a cycle.
- Two comments claimed the pre-check and the send "can no longer disagree" and
  that the node "cannot reject as underfunded" afterwards. That holds for the
  first send of a cycle only: the floor prices one deny while a cycle can send
  several, so a signer funded for exactly one will have its second send rejected
  for funds, surfacing as an ordinary per-candidate failure. Multiplying the
  floor by the candidate count was deliberately not done — it would let one
  underfunded signer block denies that are individually affordable — and that
  trade-off is now written down where the choice is made.
The previous commit made each send price itself from the block fetched for its
own window check, but affordability was still verified in the pre-check, priced
from a different block read two chain calls earlier. The node enforces the
reservation against the fee the transaction actually carries, so a base fee that
rose in that window could make even the first send fail on funds after a green
pre-check — precisely what the comment there claimed could not happen.

The balance needed to close this was already in hand: the pre-check reads it. It
is now returned and checked in the send loop against the very fee that send will
carry, computed from the same block. No additional chain call. A shortfall defers
the candidate rather than failing it — unmarked, still a candidate next cycle —
and pages through the existing rate-limited gas kind.

The pre-check floor stays where it is, as the cheap cycle-level gate that avoids
per-candidate work for an obviously unfunded signer; the per-send check is now
the guarantee.

The comments say what is true and name what is not: a base-fee rise between the
pre-check and a send can no longer cause a funds rejection, for the first send or
any later one. What remains is inherent to any read-then-broadcast scheme — the
base fee can move between that final read and inclusion. The doubling cap absorbs
the ordinary case; beyond it the transaction is simply not mined at that cap and
is retried next cycle, which is not a funds rejection. The balance itself is the
pre-check's snapshot, which holds because within a cycle it only decreases
through this guard's own serial denies.
@TaprootFreak

Copy link
Copy Markdown
Contributor Author

Three review passes on this branch, two rounds of fixes, both against the head of the moment.

Round one flagged that the fee was resolved once per cycle and reused for sends spread across minutes; it is
now built per send from the block that send is priced against — the block the window check already fetched, so
no extra call. Round two then found that the affordability check had stayed behind in the pre-check, priced
from a different block two chain calls earlier, so even the first send could still be rejected for funds after
a green pre-check. That check now runs against the exact fee each send carries, using the balance the pre-check
already read.

What remains is stated in the code rather than papered over: the base fee can still move between that final
read and inclusion. The doubling cap absorbs the ordinary case, and beyond it the transaction is simply not
mined at that cap and is retried next cycle — which is not a funds rejection.

One suggestion was deliberately not taken. Multiplying the affordability floor by the candidate count would
have made a signer that cannot afford three denies also skip the one it can afford, and with a veto window
running out, one deny beats none. The trade-off is written down where the choice is made.

@TaprootFreak
TaprootFreak marked this pull request as ready for review July 28, 2026 08:52
@TaprootFreak
TaprootFreak merged commit 1375753 into develop Jul 28, 2026
3 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