fix(minter-guard): take the deny fee from the chain, not from the library default - #54
Conversation
…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.
|
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 What remains is stated in the code rather than papered over: the base fee can still move between that final One suggestion was deliberately not taken. Multiplying the affordability floor by the candidate count would |
What this fixes
Measured against production after #52 shipped:
GET /guardreportsgasEnough: falseand the dashboard showsLow 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 carriesa base fee:
Measured on the live chain: base fee 1,000,000 wei (0.001 gwei),
eth_maxPriorityFeePerGas100 wei,eth_gasPrice1,000,100 wei. So the library produces ~1.002 gwei — about 1000× what the chain asks — andthe 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 inflatedmaxFeePerGasgenuinelycannot 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.
gasEnoughfalsetrueSigner 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 thearithmetic 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.
eth_maxPriorityFeePerGas.because a zero tip can leave a deny unmined.
Gas amounts and
denyGasCeilingare untouched; this is only about the price per gas. The read-only statuspath uses the same helper, so
estimatedDenyCostandgasEnoughnow describe the fee the guard would reallypay.
Verification
tscclean ·jest39/39 in 2 suites ·prettierandeslintclean on the touched file · backend imagebuilds. The table above was computed from values read off the live chain at the time of writing, not from
fixtures.