Skip to content

Release: develop -> main - #55

Merged
TaprootFreak merged 1 commit into
mainfrom
develop
Jul 28, 2026
Merged

Release: develop -> main#55
TaprootFreak merged 1 commit 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

…rary default (#54)

* fix(minter-guard): take the deny fee from the chain, not from the library 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.

* fix(minter-guard): price each send against the block it goes out with

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.

* fix(minter-guard): check affordability against the fee the send carries

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
TaprootFreak merged commit 76f0f08 into main Jul 28, 2026
7 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