Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
265 changes: 239 additions & 26 deletions src/monitoringV2/minter-guard.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,15 @@ export class MinterGuardService {
const precheck = await this.runDenyPrecheck(signerAddress, wallet, workingSet, cycleStartedAt);
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). 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;
Expand Down Expand Up @@ -802,7 +811,43 @@ export class MinterGuardService {
let confirmed = false;
let txHash: string | undefined;
try {
const tx = await juiceDollar.denyMinter(address, helpers, message);
// 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 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}`);
// Bounded wait: on timeout this throws and the minter is left unmarked to retry next
Expand All @@ -813,15 +858,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 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
// 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)
Expand Down Expand Up @@ -1141,8 +1186,134 @@ 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.
* 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
* 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; 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 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");
}

const baseFeePerGas = latestBlock.baseFeePerGas;
if (baseFeePerGas !== null && baseFeePerGas !== undefined) {
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)}`);
}
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) {
tip = feeData.maxPriorityFeePerGas;
this.logger.warn(
`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.
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'
);
}
}
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, 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),
Expand All @@ -1160,7 +1331,16 @@ export class MinterGuardService {
wallet: ethers.Wallet,
candidates: Array<{ address: string }>,
cycleStartedAt: number
): Promise<{ ok: boolean; helpers: string[] }> {
): Promise<
| {
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
// 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,
Expand Down Expand Up @@ -1286,14 +1466,33 @@ 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 ` +
`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 };
}
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');
// 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); ` +
Expand All @@ -1310,8 +1509,10 @@ 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 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(
`MinterGuard SKIP: signer ${signerAddress} low on gas ` +
Expand Down Expand Up @@ -1356,7 +1557,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 ` +
Expand Down Expand Up @@ -1400,7 +1601,7 @@ export class MinterGuardService {
);
}

return { ok: true, helpers };
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
Expand Down Expand Up @@ -1627,12 +1828,24 @@ 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 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 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 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 {
enabled: true,
Expand Down
Loading