From 2ad8d4763503b694a5866877e46277682c6c931a Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:57:36 +0200 Subject: [PATCH 1/3] fix(minter-guard): take the deny fee from the chain, not from the library default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/monitoringV2/minter-guard.service.ts | 126 ++++++++++++++++++----- 1 file changed, 100 insertions(+), 26 deletions(-) diff --git a/src/monitoringV2/minter-guard.service.ts b/src/monitoringV2/minter-guard.service.ts index cd56c5a..6a0feea 100644 --- a/src/monitoringV2/minter-guard.service.ts +++ b/src/monitoringV2/minter-guard.service.ts @@ -683,6 +683,9 @@ export class MinterGuardService { const precheck = await this.runDenyPrecheck(signerAddress, wallet, workingSet, cycleStartedAt); if (precheck.ok) { const helpers = precheck.helpers; + // Same fee overrides the pre-check priced the balance floor / estimate against — must match the + // deny send so the node cannot reject as underfunded after a green pre-check. + const overrides = precheck.overrides; let deferDeniesLogged = false; let candidatesStarted = 0; @@ -802,7 +805,13 @@ export class MinterGuardService { let confirmed = false; let txHash: string | undefined; try { - const tx = await juiceDollar.denyMinter(address, helpers, message); + // Fee overrides are exactly those resolveDenyFee produced and the pre-check priced + // against (feePerGas / maxFeePerGas+tip or gasPrice), so a shortfall can no longer be + // discovered by the node after the pre-check passed. The failure this prevents: pinned + // ethers 6.7.1 hardcodes a 1 gwei priority fee while Citrea base fee is ~0.001 gwei, so + // an unchecked getFeeData path inflated the EIP-1559 reservation ~1000× and made a + // well-funded signer (e.g. 0.0000093 cBTC) look broke. + const tx = await juiceDollar.denyMinter(address, helpers, message, overrides); txHash = tx.hash; this.logger.warn(`Submitted denyMinter for ${address}: tx=${tx.hash}`); // Bounded wait: on timeout this throws and the minter is left unmarked to retry next @@ -813,15 +822,15 @@ export class MinterGuardService { // // INVARIANT: the cycle deadline governs whether a send is STARTED, not how long an // in-flight transaction is awaited. Compute waitTimeoutMs HERE (after broadcast), not - // before denyMinter: with no gas/fee overrides that call populates the tx first (gas - // estimation, fee data, nonce), then signs and broadcasts — so a pre-send remaining-budget - // value is stale by the whole submission duration and can starve the wait. Once broadcast, - // the guard waits at least DENY_CONFIRM_MIN_WAIT_MS so the timeout is positive by - // construction and the cycle may overshoot by that floor at most. A non-positive timeout - // must never be submitted: ethers passes it to setTimeout, Node clamps it to ~1 ms, and - // tx.wait rejects almost immediately while the transaction is still in flight — burning a - // MAX_DENY_ATTEMPTS slot and risking a redundant fresh-nonce resend for a deny that may - // confirm on its own. + // before denyMinter: even with fee overrides from resolveDenyFee, that call still + // populates gas estimate and nonce, then signs and broadcasts — so a pre-send + // remaining-budget value is stale by the whole submission duration and can starve the + // wait. Once broadcast, the guard waits at least DENY_CONFIRM_MIN_WAIT_MS so the timeout + // is positive by construction and the cycle may overshoot by that floor at most. A + // non-positive timeout must never be submitted: ethers passes it to setTimeout, Node + // clamps it to ~1 ms, and tx.wait rejects almost immediately while the transaction is + // still in flight — burning a MAX_DENY_ATTEMPTS slot and risking a redundant fresh-nonce + // resend for a deny that may confirm on its own. const waitTimeoutMs = Math.min( DENY_CONFIRM_TIMEOUT_MS, Math.max(this.cycleRemainingMs(cycleStartedAt), DENY_CONFIRM_MIN_WAIT_MS) @@ -1141,8 +1150,71 @@ export class MinterGuardService { } /** - * Signer-global deny pre-check, run once per cycle before any denyMinter(). Returns { ok, helpers }: - * - ok=true => helpers are ready; proceed to per-candidate deny loop. + * Resolve the per-gas fee the guard will actually put on a deny, once, so the balance check and the + * send path cannot disagree on reservation size. + * + * WHY not provider.getFeeData() for the tip: pinned ethers 6.7.1 hardcodes maxPriorityFeePerGas to + * 1 gwei whenever the latest block has a baseFeePerGas (lib.commonjs/providers/abstract-provider.js + * getFeeData), then sets maxFeePerGas = 2 * baseFeePerGas + 1 gwei. On Citrea mainnet the base fee is + * ~0.001 gwei (1_000_000 wei) and eth_maxPriorityFeePerGas returns ~100 wei, so ethers inflates the + * EIP-1559 reservation about 1000×. With a ~208_000-gas ceiling that makes the node demand ~0.000208 + * cBTC reserved while the real cost is ~0.000000208 cBTC — a signer holding 0.0000093 cBTC (enough for + * dozens of real denies) fails the pre-check floor and would be rejected as underfunded. This helper + * asks the chain for its own priority fee and prices / sends against that number. + */ + private async resolveDenyFee(): Promise<{ feePerGas: bigint; overrides: ethers.Overrides }> { + const provider = this.providerService.provider; + const latestBlock = await provider.getBlock('latest'); + if (!latestBlock) { + throw new Error("provider.getBlock('latest') returned null while resolving deny fee"); + } + + const baseFeePerGas = latestBlock.baseFeePerGas; + if (baseFeePerGas !== null && baseFeePerGas !== undefined) { + let maxPriorityFeePerGas: bigint; + try { + const result: unknown = await provider.send('eth_maxPriorityFeePerGas', []); + if (result === null || result === undefined || result === '') { + throw new Error(`eth_maxPriorityFeePerGas returned unusable value: ${String(result)}`); + } + maxPriorityFeePerGas = BigInt(result as string | number | bigint); + } catch (error) { + // Do not silently swallow: log the RPC / parse failure, then fall back with a further log. + const errorMsg = typeof error?.message === 'string' && error.message ? error.message : String(error); + this.logger.warn(`MinterGuard: eth_maxPriorityFeePerGas failed or unusable (${errorMsg}); falling back for priority fee`); + const feeData = await provider.getFeeData(); + if (feeData.maxPriorityFeePerGas !== null && feeData.maxPriorityFeePerGas !== undefined) { + maxPriorityFeePerGas = feeData.maxPriorityFeePerGas; + this.logger.warn( + `MinterGuard: using getFeeData().maxPriorityFeePerGas=${maxPriorityFeePerGas.toString()} after eth_maxPriorityFeePerGas failure` + ); + } else { + // Zero tip can leave the transaction unmined on a busy chain — warn, do not hide. + maxPriorityFeePerGas = 0n; + this.logger.warn( + 'MinterGuard: falling back to maxPriorityFeePerGas=0 after eth_maxPriorityFeePerGas failure and no getFeeData tip; ' + + 'a zero tip can leave the deny unmined on a busy chain' + ); + } + } + const maxFeePerGas = baseFeePerGas * 2n + maxPriorityFeePerGas; + return { feePerGas: maxFeePerGas, overrides: { maxFeePerGas, maxPriorityFeePerGas } }; + } + + // Non-EIP-1559 chain: price and send with legacy gasPrice only. + const feeData = await provider.getFeeData(); + const gasPrice = feeData.gasPrice; + if (gasPrice === null || gasPrice === undefined) { + throw new Error('feeData has neither maxFeePerGas nor gasPrice'); + } + return { feePerGas: gasPrice, overrides: { gasPrice } }; + } + + /** + * Signer-global deny pre-check, run once per cycle before any denyMinter(). Returns + * { ok, helpers, overrides? }: + * - ok=true => helpers and fee overrides are ready; proceed to per-candidate deny loop (send must + * use the same overrides the balance floor / estimate priced against). * - ok=false => SKIP all denies this cycle. Paths that produce ok=false: * * cycle budget already below DENY_CONFIRM_MIN_USEFUL_MS at entry — defer (warn, no page), * * cycle deadline exhausted between sequential pre-check chain calls — defer (warn, no page), @@ -1160,7 +1232,7 @@ export class MinterGuardService { wallet: ethers.Wallet, candidates: Array<{ address: string }>, cycleStartedAt: number - ): Promise<{ ok: boolean; helpers: string[] }> { + ): Promise<{ ok: true; helpers: string[]; overrides: ethers.Overrides } | { ok: false; helpers: string[] }> { // Honour the single cycle deadline before any pre-check RPC: if remaining budget is below the // useful floor there is no point starting sequential votes/gas calls we cannot finish, and the // send loop would only defer anyway. Deferral is not a failure — candidates stay tracked/unmarked, @@ -1286,14 +1358,14 @@ export class MinterGuardService { if (this.cycleRemainingMs(cycleStartedAt) <= 0) { this.logger.warn( `MinterGuard: deny pre-check stopped (cycle deadline ${CYCLE_BUDGET_MS}ms); ` + - `getFeeData() not reached — deferring ${candidates.length} candidate(s) to the next cycle ` + + `resolveDenyFee() not reached — deferring ${candidates.length} candidate(s) to the next cycle ` + `(not marked done, no page — deferral is not a failure).` ); return { ok: false, helpers }; } - const feeData = await provider.getFeeData(); - const gasPrice = feeData.maxFeePerGas ?? feeData.gasPrice; - if (gasPrice === null || gasPrice === undefined) throw new Error('feeData has neither maxFeePerGas nor gasPrice'); + // Single fee resolution for both the arithmetic below and the send-path overrides: check and + // transaction must never disagree (see resolveDenyFee — ethers getFeeData 1 gwei tip inflation). + const { feePerGas, overrides } = await this.resolveDenyFee(); if (this.cycleRemainingMs(cycleStartedAt) <= 0) { this.logger.warn( `MinterGuard: deny pre-check stopped (cycle deadline ${CYCLE_BUDGET_MS}ms); ` + @@ -1310,8 +1382,9 @@ export class MinterGuardService { // balance against a helper-count-aware worst-case ceiling (denyGasCeiling * fee) up front // guarantees a gas shortfall ALWAYS pages, before estimateGas is ever attempted. helpers.length // is the post seed-drop-retry set. Native unit on Citrea is cBTC (18 decimals — - // ethers.formatEther is still correct). - const worstCaseCost = denyGasCeiling(helpers.length) * gasPrice; + // ethers.formatEther is still correct). feePerGas is the same value the send will use (not + // ethers' inflated getFeeData maxFeePerGas). + const worstCaseCost = denyGasCeiling(helpers.length) * feePerGas; if (balance < worstCaseCost) { this.logger.warn( `MinterGuard SKIP: signer ${signerAddress} low on gas ` + @@ -1356,7 +1429,7 @@ export class MinterGuardService { const gasEstimate: bigint = BigInt( await jusd.denyMinter.estimateGas(candidates[0].address, helpers, 'minter-guard gas estimate') ); - const estimatedCost = gasEstimate * gasPrice; + const estimatedCost = gasEstimate * feePerGas; if (balance < estimatedCost) { this.logger.warn( `MinterGuard SKIP: signer ${signerAddress} low on gas ` + @@ -1400,7 +1473,7 @@ export class MinterGuardService { ); } - return { ok: true, helpers }; + return { ok: true, helpers, overrides }; } catch (error) { // Unusable pre-check (RPC failure, or votesDelegated still failing with no usable seed-less set): // skip this cycle (logged, not silently swallowed). When candidates exist, also page under the @@ -1627,12 +1700,13 @@ export class MinterGuardService { const qual = await this.evaluateQualification(); - // Gas status: model denyMinter() cost with the helper-count-aware ceiling * live fee. + // Gas status: model denyMinter() cost with the helper-count-aware ceiling * the same fee the + // guard would actually pay on send (resolveDenyFee — not ethers getFeeData, which hardcodes a + // 1 gwei tip and inflates cheap-chain reservations ~1000×; see resolveDenyFee). Fail-loud: a + // fee-read failure throws (5xx) rather than reporting a fabricated gasEnough / estimatedDenyCost. const balance: bigint = await provider.getBalance(signerAddress); - const feeData = await provider.getFeeData(); - const gasPrice = feeData.maxFeePerGas ?? feeData.gasPrice; - if (gasPrice === null || gasPrice === undefined) throw new Error('feeData has neither maxFeePerGas nor gasPrice'); - const estimatedDenyCost = denyGasCeiling(qual.helperCount) * gasPrice; + const { feePerGas } = await this.resolveDenyFee(); + const estimatedDenyCost = denyGasCeiling(qual.helperCount) * feePerGas; return { enabled: true, From ffd923056b20d200cfe81536a194c563473c22d1 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:24:11 +0200 Subject: [PATCH 2/3] fix(minter-guard): price each send against the block it goes out with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/monitoringV2/minter-guard.service.ts | 177 ++++++++++++++++++----- 1 file changed, 140 insertions(+), 37 deletions(-) diff --git a/src/monitoringV2/minter-guard.service.ts b/src/monitoringV2/minter-guard.service.ts index 6a0feea..2e02eab 100644 --- a/src/monitoringV2/minter-guard.service.ts +++ b/src/monitoringV2/minter-guard.service.ts @@ -683,9 +683,12 @@ export class MinterGuardService { const precheck = await this.runDenyPrecheck(signerAddress, wallet, workingSet, cycleStartedAt); if (precheck.ok) { const helpers = precheck.helpers; - // Same fee overrides the pre-check priced the balance floor / estimate against — must match the - // deny send so the node cannot reject as underfunded after a green pre-check. - const overrides = precheck.overrides; + // Cycle-level tip / legacy gas price only — each send builds its own fee overrides from the + // block it just read for the TooLate window check (see denyFeeFor in the send path). The + // pre-check floor prices ONE deny against the block it used; a cycle can send several, so a + // signer funded for exactly one deny can still fail a later send for funds (ordinary + // per-candidate failure, not the dedicated gas page). + const feeInputs = precheck.feeInputs; let deferDeniesLogged = false; let candidatesStarted = 0; @@ -805,12 +808,16 @@ export class MinterGuardService { let confirmed = false; let txHash: string | undefined; try { - // Fee overrides are exactly those resolveDenyFee produced and the pre-check priced - // against (feePerGas / maxFeePerGas+tip or gasPrice), so a shortfall can no longer be - // discovered by the node after the pre-check passed. The failure this prevents: pinned - // ethers 6.7.1 hardcodes a 1 gwei priority fee while Citrea base fee is ~0.001 gwei, so - // an unchecked getFeeData path inflated the EIP-1559 reservation ~1000× and made a - // well-funded signer (e.g. 0.0000093 cBTC) look broke. + // Price this deny against the very block whose timestamp was just used for the TooLate + // window check above, so the EIP-1559 cap and chain state cannot drift apart within this + // send (sends are minutes apart while base fee moves every couple of seconds). Cycle-level + // tip/legacy inputs come from resolveFeeInputs; base fee is this candidate's latestBlock. + // WHY not ethers getFeeData for the tip: pinned ethers 6.7.1 hardcodes a 1 gwei priority + // fee while Citrea base fee is ~0.001 gwei, so an unchecked getFeeData path inflated the + // EIP-1559 reservation ~1000× and made a well-funded signer (e.g. 0.0000093 cBTC) look + // broke. The pre-check floor is for ONE deny only — a later send in the same cycle can + // still be rejected for funds after a green pre-check (ordinary per-candidate failure). + const { overrides } = this.denyFeeFor(latestBlock.baseFeePerGas, feeInputs); const tx = await juiceDollar.denyMinter(address, helpers, message, overrides); txHash = tx.hash; this.logger.warn(`Submitted denyMinter for ${address}: tx=${tx.hash}`); @@ -822,7 +829,7 @@ export class MinterGuardService { // // INVARIANT: the cycle deadline governs whether a send is STARTED, not how long an // in-flight transaction is awaited. Compute waitTimeoutMs HERE (after broadcast), not - // before denyMinter: even with fee overrides from resolveDenyFee, that call still + // before denyMinter: even with fee overrides from denyFeeFor, that call still // populates gas estimate and nonce, then signs and broadcasts — so a pre-send // remaining-budget value is stale by the whole submission duration and can starve the // wait. Once broadcast, the guard waits at least DENY_CONFIRM_MIN_WAIT_MS so the timeout @@ -1150,8 +1157,13 @@ export class MinterGuardService { } /** - * Resolve the per-gas fee the guard will actually put on a deny, once, so the balance check and the - * send path cannot disagree on reservation size. + * Cycle-level fee inputs that do not move per send: the chain's suggested priority tip (EIP-1559) + * or the legacy gasPrice (non-1559). Base fee is deliberately NOT resolved here — each send (and the + * pre-check floor) builds its cap via denyFeeFor against the specific block it is pricing for. + * + * Optional cycleStartedAt: when provided, check the cycle deadline before each sequential chain call + * (same convention as the resolve pass / pre-check votes path) and return null on overrun so the + * caller can defer without paging. When omitted (status path), no deadline bound applies. * * WHY not provider.getFeeData() for the tip: pinned ethers 6.7.1 hardcodes maxPriorityFeePerGas to * 1 gwei whenever the latest block has a baseFeePerGas (lib.commonjs/providers/abstract-provider.js @@ -1160,10 +1172,21 @@ export class MinterGuardService { * EIP-1559 reservation about 1000×. With a ~208_000-gas ceiling that makes the node demand ~0.000208 * cBTC reserved while the real cost is ~0.000000208 cBTC — a signer holding 0.0000093 cBTC (enough for * dozens of real denies) fails the pre-check floor and would be rejected as underfunded. This helper - * asks the chain for its own priority fee and prices / sends against that number. + * asks the chain for its own priority fee; denyFeeFor then prices each send against that tip. + * + * Return shape: tip is set (never null) on an EIP-1559 chain, legacyGasPrice is set (never null) + * otherwise; the other field is null in each case. null return means cycle-deadline deferral only. */ - private async resolveDenyFee(): Promise<{ feePerGas: bigint; overrides: ethers.Overrides }> { + private async resolveFeeInputs(cycleStartedAt?: number): Promise<{ tip: bigint | null; legacyGasPrice: bigint | null } | null> { const provider = this.providerService.provider; + + if (cycleStartedAt !== undefined && this.cycleRemainingMs(cycleStartedAt) <= 0) { + this.logger.warn( + `MinterGuard: fee input resolution stopped (cycle deadline ${CYCLE_BUDGET_MS}ms); ` + + `getBlock() not reached — deferring (not marked done, no page — deferral is not a failure).` + ); + return null; + } const latestBlock = await provider.getBlock('latest'); if (!latestBlock) { throw new Error("provider.getBlock('latest') returned null while resolving deny fee"); @@ -1171,50 +1194,96 @@ export class MinterGuardService { const baseFeePerGas = latestBlock.baseFeePerGas; if (baseFeePerGas !== null && baseFeePerGas !== undefined) { - let maxPriorityFeePerGas: bigint; + if (cycleStartedAt !== undefined && this.cycleRemainingMs(cycleStartedAt) <= 0) { + this.logger.warn( + `MinterGuard: fee input resolution stopped (cycle deadline ${CYCLE_BUDGET_MS}ms); ` + + `eth_maxPriorityFeePerGas() not reached — deferring (not marked done, no page — deferral is not a failure).` + ); + return null; + } + let tip: bigint; try { const result: unknown = await provider.send('eth_maxPriorityFeePerGas', []); if (result === null || result === undefined || result === '') { throw new Error(`eth_maxPriorityFeePerGas returned unusable value: ${String(result)}`); } - maxPriorityFeePerGas = BigInt(result as string | number | bigint); + tip = BigInt(result as string | number | bigint); } catch (error) { // Do not silently swallow: log the RPC / parse failure, then fall back with a further log. const errorMsg = typeof error?.message === 'string' && error.message ? error.message : String(error); this.logger.warn(`MinterGuard: eth_maxPriorityFeePerGas failed or unusable (${errorMsg}); falling back for priority fee`); + if (cycleStartedAt !== undefined && this.cycleRemainingMs(cycleStartedAt) <= 0) { + this.logger.warn( + `MinterGuard: fee input resolution stopped (cycle deadline ${CYCLE_BUDGET_MS}ms); ` + + `getFeeData() not reached — deferring (not marked done, no page — deferral is not a failure).` + ); + return null; + } const feeData = await provider.getFeeData(); if (feeData.maxPriorityFeePerGas !== null && feeData.maxPriorityFeePerGas !== undefined) { - maxPriorityFeePerGas = feeData.maxPriorityFeePerGas; + tip = feeData.maxPriorityFeePerGas; this.logger.warn( - `MinterGuard: using getFeeData().maxPriorityFeePerGas=${maxPriorityFeePerGas.toString()} after eth_maxPriorityFeePerGas failure` + `MinterGuard: using getFeeData().maxPriorityFeePerGas=${tip.toString()} after eth_maxPriorityFeePerGas failure` ); } else { // Zero tip can leave the transaction unmined on a busy chain — warn, do not hide. - maxPriorityFeePerGas = 0n; + tip = 0n; this.logger.warn( 'MinterGuard: falling back to maxPriorityFeePerGas=0 after eth_maxPriorityFeePerGas failure and no getFeeData tip; ' + 'a zero tip can leave the deny unmined on a busy chain' ); } } - const maxFeePerGas = baseFeePerGas * 2n + maxPriorityFeePerGas; - return { feePerGas: maxFeePerGas, overrides: { maxFeePerGas, maxPriorityFeePerGas } }; + return { tip, legacyGasPrice: null }; } // Non-EIP-1559 chain: price and send with legacy gasPrice only. + if (cycleStartedAt !== undefined && this.cycleRemainingMs(cycleStartedAt) <= 0) { + this.logger.warn( + `MinterGuard: fee input resolution stopped (cycle deadline ${CYCLE_BUDGET_MS}ms); ` + + `getFeeData() not reached — deferring (not marked done, no page — deferral is not a failure).` + ); + return null; + } const feeData = await provider.getFeeData(); const gasPrice = feeData.gasPrice; if (gasPrice === null || gasPrice === undefined) { throw new Error('feeData has neither maxFeePerGas nor gasPrice'); } + return { tip: null, legacyGasPrice: gasPrice }; + } + + /** + * Pure fee/overrides builder for a SPECIFIC block's baseFeePerGas plus cycle-level tip/legacy inputs + * from resolveFeeInputs. No RPC. On EIP-1559 (base fee present and tip set): maxFeePerGas = + * baseFeePerGas * 2n + tip. Otherwise: legacy gasPrice. Reachable inputs only — resolveFeeInputs + * always sets exactly one of tip / legacyGasPrice. + */ + private denyFeeFor( + baseFeePerGas: bigint | null | undefined, + resolved: { tip: bigint | null; legacyGasPrice: bigint | null } + ): { feePerGas: bigint; overrides: ethers.Overrides } { + if (baseFeePerGas !== null && baseFeePerGas !== undefined && resolved.tip !== null) { + const maxPriorityFeePerGas = resolved.tip; + const maxFeePerGas = baseFeePerGas * 2n + maxPriorityFeePerGas; + return { feePerGas: maxFeePerGas, overrides: { maxFeePerGas, maxPriorityFeePerGas } }; + } + // Non-EIP-1559: legacyGasPrice is set (never null) when tip is null. + const gasPrice = resolved.legacyGasPrice; + if (gasPrice === null) { + // Unreachable when resolveFeeInputs populated resolved correctly. + throw new Error('denyFeeFor: neither tip+baseFee nor legacyGasPrice available'); + } return { feePerGas: gasPrice, overrides: { gasPrice } }; } /** * Signer-global deny pre-check, run once per cycle before any denyMinter(). Returns - * { ok, helpers, overrides? }: - * - ok=true => helpers and fee overrides are ready; proceed to per-candidate deny loop (send must - * use the same overrides the balance floor / estimate priced against). + * { ok, helpers, feeInputs, overrides? }: + * - ok=true => helpers and cycle-level feeInputs are ready; proceed to per-candidate deny loop + * (each send builds its own overrides via denyFeeFor against the block it just read). overrides + * are the pre-check's own balance-floor pricing (one deny against the pre-check block) — useful + * as a starting value, not as a cycle-wide send cap. * - ok=false => SKIP all denies this cycle. Paths that produce ok=false: * * cycle budget already below DENY_CONFIRM_MIN_USEFUL_MS at entry — defer (warn, no page), * * cycle deadline exhausted between sequential pre-check chain calls — defer (warn, no page), @@ -1232,7 +1301,10 @@ export class MinterGuardService { wallet: ethers.Wallet, candidates: Array<{ address: string }>, cycleStartedAt: number - ): Promise<{ ok: true; helpers: string[]; overrides: ethers.Overrides } | { ok: false; helpers: string[] }> { + ): Promise< + | { ok: true; helpers: string[]; feeInputs: { tip: bigint | null; legacyGasPrice: bigint | null }; overrides: ethers.Overrides } + | { ok: false; helpers: string[] } + > { // Honour the single cycle deadline before any pre-check RPC: if remaining budget is below the // useful floor there is no point starting sequential votes/gas calls we cannot finish, and the // send loop would only defer anyway. Deferral is not a failure — candidates stay tracked/unmarked, @@ -1358,14 +1430,33 @@ export class MinterGuardService { if (this.cycleRemainingMs(cycleStartedAt) <= 0) { this.logger.warn( `MinterGuard: deny pre-check stopped (cycle deadline ${CYCLE_BUDGET_MS}ms); ` + - `resolveDenyFee() not reached — deferring ${candidates.length} candidate(s) to the next cycle ` + + `resolveFeeInputs() not reached — deferring ${candidates.length} candidate(s) to the next cycle ` + + `(not marked done, no page — deferral is not a failure).` + ); + return { ok: false, helpers }; + } + // Cycle-level tip / legacy gas price once; base-fee cap is built per use site via denyFeeFor + // (pre-check floor here; each send against its own JIT block — see send loop). Avoids ethers + // getFeeData 1 gwei tip inflation (see resolveFeeInputs). + const feeInputs = await this.resolveFeeInputs(cycleStartedAt); + if (feeInputs === null) { + // resolveFeeInputs already logged the deadline deferral. + return { ok: false, helpers }; + } + if (this.cycleRemainingMs(cycleStartedAt) <= 0) { + this.logger.warn( + `MinterGuard: deny pre-check stopped (cycle deadline ${CYCLE_BUDGET_MS}ms); ` + + `getBlock() not reached — deferring ${candidates.length} candidate(s) to the next cycle ` + `(not marked done, no page — deferral is not a failure).` ); return { ok: false, helpers }; } - // Single fee resolution for both the arithmetic below and the send-path overrides: check and - // transaction must never disagree (see resolveDenyFee — ethers getFeeData 1 gwei tip inflation). - const { feePerGas, overrides } = await this.resolveDenyFee(); + // Fresh latest block for the pre-check balance floor (no block was in hand at this point). + const feeBlock = await provider.getBlock('latest'); + if (!feeBlock) { + throw new Error("provider.getBlock('latest') returned null while resolving deny fee"); + } + const { feePerGas, overrides } = this.denyFeeFor(feeBlock.baseFeePerGas, feeInputs); if (this.cycleRemainingMs(cycleStartedAt) <= 0) { this.logger.warn( `MinterGuard: deny pre-check stopped (cycle deadline ${CYCLE_BUDGET_MS}ms); ` + @@ -1382,8 +1473,9 @@ export class MinterGuardService { // balance against a helper-count-aware worst-case ceiling (denyGasCeiling * fee) up front // guarantees a gas shortfall ALWAYS pages, before estimateGas is ever attempted. helpers.length // is the post seed-drop-retry set. Native unit on Citrea is cBTC (18 decimals — - // ethers.formatEther is still correct). feePerGas is the same value the send will use (not - // ethers' inflated getFeeData maxFeePerGas). + // ethers.formatEther is still correct). feePerGas is priced for ONE deny against the pre-check + // block (same formula as each send via denyFeeFor, not ethers' inflated getFeeData maxFeePerGas); + // a multi-send cycle can still exhaust a signer funded for exactly one deny after a green floor. const worstCaseCost = denyGasCeiling(helpers.length) * feePerGas; if (balance < worstCaseCost) { this.logger.warn( @@ -1473,7 +1565,7 @@ export class MinterGuardService { ); } - return { ok: true, helpers, overrides }; + return { ok: true, helpers, feeInputs, overrides }; } catch (error) { // Unusable pre-check (RPC failure, or votesDelegated still failing with no usable seed-less set): // skip this cycle (logged, not silently swallowed). When candidates exist, also page under the @@ -1700,12 +1792,23 @@ export class MinterGuardService { const qual = await this.evaluateQualification(); - // Gas status: model denyMinter() cost with the helper-count-aware ceiling * the same fee the - // guard would actually pay on send (resolveDenyFee — not ethers getFeeData, which hardcodes a - // 1 gwei tip and inflates cheap-chain reservations ~1000×; see resolveDenyFee). Fail-loud: a - // fee-read failure throws (5xx) rather than reporting a fabricated gasEnough / estimatedDenyCost. + // Gas status: model denyMinter() cost with the helper-count-aware ceiling * the same fee formula + // the guard uses on send (resolveFeeInputs + denyFeeFor — not ethers getFeeData, which hardcodes + // a 1 gwei tip and inflates cheap-chain reservations ~1000×). Fail-loud: a fee-read failure + // throws (5xx) rather than reporting a fabricated gasEnough / estimatedDenyCost. + // Single read-only status request is not a cycle — omit cycleStartedAt so it is not bounded by + // the cycle deadline. const balance: bigint = await provider.getBalance(signerAddress); - const { feePerGas } = await this.resolveDenyFee(); + const feeInputs = await this.resolveFeeInputs(); + // resolveFeeInputs never returns null without cycleStartedAt (deadline path is skipped). + if (feeInputs === null) { + throw new Error('resolveFeeInputs returned null without a cycle deadline'); + } + const latestBlock = await provider.getBlock('latest'); + if (!latestBlock) { + throw new Error("provider.getBlock('latest') returned null while resolving deny fee"); + } + const { feePerGas } = this.denyFeeFor(latestBlock.baseFeePerGas, feeInputs); const estimatedDenyCost = denyGasCeiling(qual.helperCount) * feePerGas; return { From 1d36be54621d5bf9a5403b6c92c17f8478af3c9e Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:49:35 +0200 Subject: [PATCH 3/3] fix(minter-guard): check affordability against the fee the send carries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/monitoringV2/minter-guard.service.ts | 64 ++++++++++++++++++------ 1 file changed, 50 insertions(+), 14 deletions(-) diff --git a/src/monitoringV2/minter-guard.service.ts b/src/monitoringV2/minter-guard.service.ts index 2e02eab..9f8f31a 100644 --- a/src/monitoringV2/minter-guard.service.ts +++ b/src/monitoringV2/minter-guard.service.ts @@ -684,11 +684,14 @@ export class MinterGuardService { if (precheck.ok) { const helpers = precheck.helpers; // Cycle-level tip / legacy gas price only — each send builds its own fee overrides from the - // block it just read for the TooLate window check (see denyFeeFor in the send path). The - // pre-check floor prices ONE deny against the block it used; a cycle can send several, so a - // signer funded for exactly one deny can still fail a later send for funds (ordinary - // per-candidate failure, not the dedicated gas page). + // block it just read for the TooLate window check (see denyFeeFor in the send path). balance + // is the pre-check's own snapshot, reused without a further chain call: within the cycle it + // can only decrease through this guard's own denies (confirmed denies happen serially in + // this loop before the next candidate is examined). Each send below checks affordability + // against THIS balance and the fee that specific send is about to carry, right before + // submitting it. const feeInputs = precheck.feeInputs; + const balance = precheck.balance; let deferDeniesLogged = false; let candidatesStarted = 0; @@ -815,9 +818,35 @@ export class MinterGuardService { // WHY not ethers getFeeData for the tip: pinned ethers 6.7.1 hardcodes a 1 gwei priority // fee while Citrea base fee is ~0.001 gwei, so an unchecked getFeeData path inflated the // EIP-1559 reservation ~1000× and made a well-funded signer (e.g. 0.0000093 cBTC) look - // broke. The pre-check floor is for ONE deny only — a later send in the same cycle can - // still be rejected for funds after a green pre-check (ordinary per-candidate failure). - const { overrides } = this.denyFeeFor(latestBlock.baseFeePerGas, feeInputs); + // broke. The node enforces the reservation against the fee the transaction actually + // carries, so the only affordability check that cannot be stale is the one made against + // that same fee, from the same block — which is the check directly below. Because of + // that check, a base-fee rise between the pre-check and this send can no longer cause a + // funds rejection, for the first send or any later one in the cycle. What remains (any + // read-then-broadcast scheme) is that the base fee can still move between THIS read + // and actual inclusion: the baseFeePerGas * 2n cap in denyFeeFor absorbs a doubling; + // beyond that the tx simply will not be mined at that cap and is retried next cycle — + // it is not a funds rejection. The pre-check floor remains a cheap cycle-level gate + // that avoids per-candidate work for an obviously unfunded signer; the per-send check + // below is the affordability guarantee. + const { feePerGas, overrides } = this.denyFeeFor(latestBlock.baseFeePerGas, feeInputs); + const reservation = denyGasCeiling(helpers.length) * feePerGas; + if (reservation > balance) { + this.logger.warn( + `MinterGuard: deferring deny of ${address} — reservation ` + + `${ethers.formatEther(reservation)} cBTC exceeds balance ` + + `${ethers.formatEther(balance)} cBTC (not marked done — deferred, not a failure)` + ); + await this.maybeAlertSkip( + 'gas', + `⚠️ *Minter guard low on cBTC — deny deferred*\n\n` + + `Candidate: \`${address}\`\n` + + `Required reservation: ${ethers.formatEther(reservation)} cBTC\n` + + `Balance: ${ethers.formatEther(balance)} cBTC\n\n` + + `Fund the signer with cBTC.` + ); + continue; + } const tx = await juiceDollar.denyMinter(address, helpers, message, overrides); txHash = tx.hash; this.logger.warn(`Submitted denyMinter for ${address}: tx=${tx.hash}`); @@ -1279,11 +1308,12 @@ export class MinterGuardService { /** * Signer-global deny pre-check, run once per cycle before any denyMinter(). Returns - * { ok, helpers, feeInputs, overrides? }: - * - ok=true => helpers and cycle-level feeInputs are ready; proceed to per-candidate deny loop - * (each send builds its own overrides via denyFeeFor against the block it just read). overrides - * are the pre-check's own balance-floor pricing (one deny against the pre-check block) — useful - * as a starting value, not as a cycle-wide send cap. + * { ok, helpers, feeInputs, overrides?, balance? }: + * - ok=true => helpers, cycle-level feeInputs, and the pre-check balance snapshot are ready; + * proceed to per-candidate deny loop (each send builds its own overrides via denyFeeFor against + * the block it just read, and checks affordability against balance). overrides are the + * pre-check's own balance-floor pricing (one deny against the pre-check block) — useful as a + * starting value, not as a cycle-wide send cap. * - ok=false => SKIP all denies this cycle. Paths that produce ok=false: * * cycle budget already below DENY_CONFIRM_MIN_USEFUL_MS at entry — defer (warn, no page), * * cycle deadline exhausted between sequential pre-check chain calls — defer (warn, no page), @@ -1302,7 +1332,13 @@ export class MinterGuardService { candidates: Array<{ address: string }>, cycleStartedAt: number ): Promise< - | { ok: true; helpers: string[]; feeInputs: { tip: bigint | null; legacyGasPrice: bigint | null }; overrides: ethers.Overrides } + | { + ok: true; + helpers: string[]; + feeInputs: { tip: bigint | null; legacyGasPrice: bigint | null }; + overrides: ethers.Overrides; + balance: bigint; + } | { ok: false; helpers: string[] } > { // Honour the single cycle deadline before any pre-check RPC: if remaining budget is below the @@ -1565,7 +1601,7 @@ export class MinterGuardService { ); } - return { ok: true, helpers, feeInputs, overrides }; + return { ok: true, helpers, feeInputs, overrides, balance }; } catch (error) { // Unusable pre-check (RPC failure, or votesDelegated still failing with no usable seed-less set): // skip this cycle (logged, not silently swallowed). When candidates exist, also page under the