feat(midnight-liquidation): make first-send priority fee configurable - #115
Conversation
The first-send maxPriorityFeePerGas was a hardcoded 1 gwei constant in @repo/bot-kit. On Base that is ~1000x the median in-block tip (measured over 1024 consecutive blocks: base fee pinned at the 0.005 gwei floor, median in-block tip 0.001 gwei) and outbids the in-block p99 in ~94% of blocks. It cannot be tuned by lowering MAX_FEE_GWEI either: that is a ceiling on total price per gas, so any ceiling above baseFee + 1 gwei still pays the full tip, and a ceiling below it collapses all base-fee headroom. Adds PRIORITY_FEE_GWEI (default 0.1, which clears the in-block p95 in ~91% of Base blocks). initialFees takes the tip as an optional third argument defaulting to the previous 1 gwei, so blue-liquidation and midnight-crossed-books are unchanged. Config rejects three bad values up front, since the bump path adds at most 1.42x (3 attempts x 12.5%) and cannot rescue a bad opening bid: non-numeric, sub-wei values that parseGwei rounds to 0, and any tip within one bump of MAX_FEE_GWEI (bumpFees would return drop on the first stuck check, latching a nonce hole that blocks every later send).
| /** | ||
| * Whether a first send at `priorityFeeWei` leaves room for at least one replacement bump under | ||
| * `maxFeeWei`. A tip too close to the ceiling makes `bumpFees` return `drop` on the first stuck | ||
| * check, which latches a nonce hole and blocks every later send, so config should reject it up | ||
| * front. Ignores the base-fee component, which is only known at send time. Pure. | ||
| */ | ||
| export function hasBumpHeadroom(priorityFeeWei: bigint, maxFeeWei: bigint): boolean { | ||
| return bumped(priorityFeeWei) <= maxFeeWei | ||
| } |
There was a problem hiding this comment.
📝 Info: Headroom guard is necessary but not sufficient to prevent a drop
hasBumpHeadroom only compares the bumped tip to the ceiling, but bumpFees drops when max(bumped(maxFeePerGas), baseFee * 2 + newPriority) > maxFeeWei (packages/bot-kit/src/queue/fee-policy.ts:33-38). With a large base fee the bumped max can still exceed MAX_FEE_GWEI even though this startup check passed, so the nonce-hole scenario the guard is meant to prevent remains reachable at runtime. The JSDoc acknowledges the base-fee component is ignored; worth noting that the guard therefore reduces, not eliminates, the drop risk.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Acknowledged, and left as-is by author decision. This is inherent rather than a gap in the guard: once 2*baseFee + tip >= maxFeeWei the first send is already clamped AT the ceiling, so any bump exceeds it and bumpFees returns drop — that is the ceiling doing its job (drop rather than chase a spike). The sharp edge is that a drop latches a nonce hole, which is pre-existing behavior for every drop and not introduced here. Config cannot see the base fee, so it cannot check this; the hasBumpHeadroom JSDoc states the limitation explicitly. Unreachable at the 300 gwei default. Leaving unresolved for visibility.
🤖 Automated by Spencer's PR assistant, not sent by Spencer directly.
|
@codex review |
|
@claude review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 993104a3ff
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
CLAUDE.md requires utilities to be arrow constants, never function declarations.
…ve-number error The regex accepted '0', so it fell through to the sub-wei branch and told the operator the value must be at least 1 wei rather than that it must be positive. Matches the MAX_FEE_GWEI check directly above.
…ompose The service's environment mapping is an explicit allowlist, so the documented knob had no effect in the compose deployment surface.
Stuck detection fires at blockNumber - submittedAtBlock > STUCK_BLOCKS (4), so a bump lands every 5 blocks, and each successful replacement resets submittedAtBlock. Three bumps span ~15 blocks, not ~12.
…ee knob TIB-2026-05-28 scopes this bot as a safety-net liquidator that must not win races, and lists MEV-aware bidding as a non-goal. The mechanical claim (the first send is effectively the whole bid) stands; the motivation does not.
| const priorityFeeGwei = env.PRIORITY_FEE_GWEI?.trim() || DEFAULT_PRIORITY_FEE_GWEI | ||
| if (!/^\d+(\.\d+)?$/.test(priorityFeeGwei) || Number(priorityFeeGwei) <= 0) { | ||
| throw new Error(`PRIORITY_FEE_GWEI must be a positive number, got: ${env.PRIORITY_FEE_GWEI}`) | ||
| } | ||
| const priorityFeeWei = parseGwei(priorityFeeGwei) | ||
| if (priorityFeeWei <= 0n) { | ||
| throw new Error(`PRIORITY_FEE_GWEI must be at least 1 wei, got: ${env.PRIORITY_FEE_GWEI}`) | ||
| } | ||
| const maxFeeWei = parseGwei(maxFeeGwei) | ||
| if (!hasBumpHeadroom(priorityFeeWei, maxFeeWei)) { | ||
| throw new Error( | ||
| `PRIORITY_FEE_GWEI (${priorityFeeGwei}) leaves no room to bump under MAX_FEE_GWEI (${maxFeeGwei})` | ||
| ) | ||
| } |
There was a problem hiding this comment.
🟡 New startup validation failures use untyped errors instead of the repository's required named error classes
The three new configuration rejections raise a generic, untyped failure (throw new Error(...) at bots/midnight-liquidation/src/config.ts:339-349) instead of a dedicated named error class, which the repository's agent rules require for every expected configuration failure.
Impact: Operators and callers cannot distinguish a bad fee setting from any other startup failure programmatically.
Rule reference and surrounding pattern
CLAUDE.md ("Typed error isolation") states: "Every expected domain, application, infrastructure, configuration, CLI, provider, or tooling failure must use a named exported Error subclass. Put exactly one error class in its own kebab-case *.error.ts file... never use plain Error for expected failures." The new PRIORITY_FEE_GWEI validations at bots/midnight-liquidation/src/config.ts:337-350 are configuration failures thrown with plain Error. The surrounding file follows the same (pre-existing) untyped pattern, so a fix would ideally introduce a shared config-validation error class for this bot.
Prompt for agents
CLAUDE.md mandates that every expected configuration failure be thrown as a named exported Error subclass living alone in its own kebab-case *.error.ts file, never plain Error. The new PRIORITY_FEE_GWEI validation branches in bots/midnight-liquidation/src/config.ts (non-numeric/zero, sub-wei, no bump headroom) throw plain Error. Introduce a dedicated error class (e.g. ConfigValidationError in bots/midnight-liquidation/src/config-validation.error.ts) and use it for the new throws; consider migrating the neighbouring validations in the same file for consistency.
Was this helpful? React with 👍 or 👎 to provide feedback.
| const maxFeeWei = parseGwei(maxFeeGwei) | ||
| if (!hasBumpHeadroom(priorityFeeWei, maxFeeWei)) { | ||
| throw new Error( | ||
| `PRIORITY_FEE_GWEI (${priorityFeeGwei}) leaves no room to bump under MAX_FEE_GWEI (${maxFeeGwei})` | ||
| ) | ||
| } |
There was a problem hiding this comment.
📝 Info: Very low MAX_FEE_GWEI now blocks startup even without PRIORITY_FEE_GWEI set
The headroom check runs against the default tip too, so a deployment that sets MAX_FEE_GWEI below ~0.1125 gwei (which previously just produced a clamped, underpriced first send that the bump path could recover) now fails at boot with leaves no room to bump. Unlikely in practice given the 300 gwei default, but it is a new fail-loud path for operators who deliberately set a tiny ceiling (e.g. in test environments).
Was this helpful? React with 👍 or 👎 to provide feedback.
| ZEROX_API_KEY: ${ZEROX_API_KEY:-} | ||
| ONEINCH_API_KEY: ${ONEINCH_API_KEY:-} | ||
| ALLOW_BAD_DEBT_ONLY: ${ALLOW_BAD_DEBT_ONLY:-} | ||
| PRIORITY_FEE_GWEI: ${PRIORITY_FEE_GWEI:-} |
There was a problem hiding this comment.
🔍 PRIORITY_FEE_GWEI is not wired into the Railway deploy script
docker-compose.yml now forwards PRIORITY_FEE_GWEI, but bots/midnight-liquidation/scripts/deploy-railway.ts sets service variables explicitly (RPC_URL, keys, BetterStack, etc.) and does not handle it — the same is true of MAX_FEE_GWEI, so this is consistent with the existing pattern. Since the description notes redeploys silently move from a 1 gwei to a 0.1 gwei tip unless PRIORITY_FEE_GWEI=1 is set in Railway, that value has to be added to the Railway environment manually; the deploy script will not carry it.
Was this helpful? React with 👍 or 👎 to provide feedback.
| const priorityFeeWei = parseGwei(priorityFeeGwei) | ||
| if (priorityFeeWei <= 0n) { | ||
| throw new Error(`PRIORITY_FEE_GWEI must be at least 1 wei, got: ${env.PRIORITY_FEE_GWEI}`) | ||
| } |
There was a problem hiding this comment.
📝 Info: Sub-wei tip rejection depends on parseGwei rounding semantics
The <= 0n check after parseGwei catches values like 0.0000000001 (which round to 0n), but a value such as 0.0000000005 rounds up to 1n and passes validation, producing a 1-wei tip. That is arguably fine (non-zero, an operator's explicit choice), but the surfaced error message ("must be at least 1 wei") is the only guard, so there is no lower practical bound beyond 1 wei.
Was this helpful? React with 👍 or 👎 to provide feedback.
What
Adds
PRIORITY_FEE_GWEIto midnight-liquidation so the first-sendmaxPriorityFeePerGasis an operator knob instead of a hardcoded constant. Default0.1(was an implicit1).Why
The tip was a fixed 1 gwei constant in
@repo/bot-kit. Measured over 1024 consecutive Base blocks viaeth_feeHistory:So 1 gwei is ~1000x the median tip and outbids the in-block p99 in ~94% of blocks. Per 1M gas it is 0.001 ETH of tip against 0.000005 ETH of base fee: the tip is ~200x the entire base-fee cost of the transaction.
MAX_FEE_GWEIcould not be used to tune it. That is a ceiling on total price per gas, so any ceiling abovebaseFee + 1 gweistill pays the full tip, and a ceiling below it collapses all base-fee headroom (initialFeesclamps both fees to it, leavingmaxFeePerGas == maxPriorityFeePerGas).The default of
0.1clears the in-block p95 in ~91% of blocks:Caveat on the data: one 34-minute window on a day the base fee never left its floor. It says nothing about a congestion event.
On what "good" means here.
TIB-2026-05-28-midnight-liquidation-bot.md:29scopes this bot as a safety-net liquidator that "values reliability over latency — it must work correctly and predictably, not win races", and lists MEV-aware bidding as a non-goal. 0.1 gwei is sized for prompt inclusion, not for outbidding a competing liquidator: it is 20x the base fee and clears the in-block p90 in 99% of blocks. The old 1 gwei was buying race-winning this bot explicitly does not want, at 10x the cost.Scope
midnight-liquidation only.
initialFeestakes the tip as an optional third argument defaulting to the previous 1 gwei, soblue-liquidationandmidnight-crossed-bookscall sites and behavior are unchanged (pinned by a test).Validation rejects three bad values at startup
The bump path adds at most 1.42x (3 attempts x 12.5%, landing at +5/+10/+15 blocks), so the first-send tip is effectively the entire bid and cannot be rescued by escalation. Config therefore fails loud on:
abc,0).0.0000000001), whichparseGweirounds to0nand would otherwise send an untipped transaction. The raw-string check missed this; the parsed bigint is validated instead.MAX_FEE_GWEI.bumpFeesreturnsdroponce the bumped max exceeds the ceiling, and a drop latches a nonce hole that blocks every subsequent send.PRIORITY_FEE_GWEI=300 MAX_FEE_GWEI=300previously passed validation and would convert the first stuck transaction into a bot-wide send freeze. Guarded by a newhasBumpHeadroomin@repo/bot-kit, next to the bump policy it depends on.Behavior change on redeploy
A midnight-liquidation deployment that does not set
PRIORITY_FEE_GWEIdrops from a 1 gwei to a 0.1 gwei opening bid. SetPRIORITY_FEE_GWEI=1in Railway to keep the old behavior. The other two bots are unaffected.Verification
bun test: 1205 pass, 3 skip, 3 fail. The 3 failures are the fork/e2e suites gated onRPC_URL_8453; they fail identically onmain.bun run --filter <pkg> typecheckon@repo/bot-kitand all three bots: exit 0.bun lint: 0 warnings, 0 errors.bun formatapplied.knip: clean.initialFeesnever returnsmaxPriorityFeePerGas > maxFeePerGas: proven over the cross product of edge values and pinned as a test. Nodes reject that pair, and two independent clamps produce it.Follow-ups (not in this PR)
MAX_FEE_GWEI=300is ~60,000x Base's current base fee, so thedropguard inbumpFeesis effectively inert. Config-only change.PriceBump: 10. 12.5% is strictly conservative, so behavior is correct, but the stated basis is wrong.