Skip to content

feat(midnight-liquidation): make first-send priority fee configurable - #115

Merged
spennyp merged 6 commits into
mainfrom
feat/configurable-priority-fee
Jul 30, 2026
Merged

feat(midnight-liquidation): make first-send priority fee configurable#115
spennyp merged 6 commits into
mainfrom
feat/configurable-priority-fee

Conversation

@spennyp

@spennyp spennyp commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

What

Adds PRIORITY_FEE_GWEI to midnight-liquidation so the first-send maxPriorityFeePerGas is an operator knob instead of a hardcoded constant. Default 0.1 (was an implicit 1).

Why

The tip was a fixed 1 gwei constant in @repo/bot-kit. Measured over 1024 consecutive Base blocks via eth_feeHistory:

Metric Value
Base fee pinned at the 0.005 gwei floor for all 1024 blocks
Median in-block median tip 0.001 gwei
Median in-block p90 tip 0.005 gwei
Median in-block p99 tip 0.074 gwei
Blocks where in-block p99 >= 1 gwei 74 / 1024 (7.2%)

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_GWEI could not be used to tune it. 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 (initialFees clamps both fees to it, leaving maxFeePerGas == maxPriorityFeePerGas).

The default of 0.1 clears the in-block p95 in ~91% of blocks:

Tip >= p50 >= p75 >= p90 >= p95 >= p99
0.01 gwei 100% 98% 71% 36% 2%
0.1 gwei 100% 100% 99% 91% 62%
1 gwei 100% 100% 100% 100% 94%

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:29 scopes 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. initialFees takes the tip as an optional third argument defaulting to the previous 1 gwei, so blue-liquidation and midnight-crossed-books call 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:

  1. Non-numeric, or zero (abc, 0).
  2. Sub-wei (0.0000000001), which parseGwei rounds to 0n and would otherwise send an untipped transaction. The raw-string check missed this; the parsed bigint is validated instead.
  3. Within one bump of MAX_FEE_GWEI. bumpFees returns drop once 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=300 previously passed validation and would convert the first stuck transaction into a bot-wide send freeze. Guarded by a new hasBumpHeadroom in @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_GWEI drops from a 1 gwei to a 0.1 gwei opening bid. Set PRIORITY_FEE_GWEI=1 in 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 on RPC_URL_8453; they fail identically on main.
  • bun run --filter <pkg> typecheck on @repo/bot-kit and all three bots: exit 0.
  • bun lint: 0 warnings, 0 errors. bun format applied. knip: clean.
  • Mutation-checked each new test (flip the default, weaken the headroom guard, break the tip clamp) and confirmed the corresponding test fails, then reverted.
  • initialFees never returns maxPriorityFeePerGas > 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=300 is ~60,000x Base's current base fee, so the drop guard in bumpFees is effectively inert. Config-only change.
  • The fee-policy header comment says "EIP-1559 mandates a >=12.5% bump". EIP-1559 mandates nothing here; it is op-geth's PriceBump: 10. 12.5% is strictly conservative, so behavior is correct, but the stated basis is wrong.

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).
@spennyp
spennyp marked this pull request as ready for review July 30, 2026 16:59
@spennyp
spennyp requested a review from cashd July 30, 2026 16:59

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

Open in Devin Review

Comment thread packages/bot-kit/src/queue/fee-policy.ts Outdated
Comment on lines +42 to +50
/**
* 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
}

@devin-ai-integration devin-ai-integration Bot Jul 30, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread bots/midnight-liquidation/src/config.ts
@spennyp

spennyp commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@spennyp

spennyp commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

@claude review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread bots/midnight-liquidation/src/config.ts
Comment thread packages/bot-kit/src/queue/fee-policy.ts Outdated
Comment thread packages/bot-kit/src/queue/fee-policy.ts Outdated
Comment thread bots/midnight-liquidation/README.md Outdated
Comment thread bots/midnight-liquidation/README.md Outdated
spennyp added 4 commits July 30, 2026 10:09
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.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 4 new potential issues.

Open in Devin Review

Comment on lines +337 to +350
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})`
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +345 to +350
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})`
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 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).

Open in Devin Review

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:-}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +341 to +344
const priorityFeeWei = parseGwei(priorityFeeGwei)
if (priorityFeeWei <= 0n) {
throw new Error(`PRIORITY_FEE_GWEI must be at least 1 wei, got: ${env.PRIORITY_FEE_GWEI}`)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@spennyp
spennyp merged commit 462798d into main Jul 30, 2026
10 checks passed
@spennyp
spennyp deleted the feat/configurable-priority-fee branch July 30, 2026 17:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants