Skip to content

feat(executor): pre-flight OpEx fee check before bundle submission - #111

Merged
AquiGorka merged 4 commits into
mainfrom
add-prebundle-opex-fee-check
Jun 4, 2026
Merged

feat(executor): pre-flight OpEx fee check before bundle submission#111
AquiGorka merged 4 commits into
mainfrom
add-prebundle-opex-fee-check

Conversation

@AquiGorka

Copy link
Copy Markdown
Contributor

Summary

Adds a pre-flight check on the per-provider OpEx Stellar account before the executor signs and submits a Soroban bundle. When the PP root account cannot cover the simulated tx fee after subtracting Stellar minimum reserves, the bundle is moved straight to terminal FAILED with a typed InsufficientFees reason — no retry, no mempool retention, no on-chain submission attempt.

The structured detail (feePayerPubkey, availableXlm, requiredXlm, shortfallXlm, all stroop strings) is persisted on a new nullable failure_detail jsonb column on operations_bundles and surfaced through the existing entity-scoped bundle-status endpoint so the submitting wallet can read the cause via the response it already polls.

Why terminal-fail (not retry): the mempool is shared across providers, and there is no mechanism today to notify a provider admin that OpEx is underfunded — retained bundles would just clog the shared mempool for every provider. The user must refund OpEx and resubmit.

ClickUp: https://app.clickup.com/t/86c9xfd2e
Milestone: Testnet Hardening.

What changed

  • Pre-flight check at src/core/service/executor/preflight-opex-balance.ts: reads the fee-payer's XLM balance + numSubEntries via Soroban getLedgerEntries, simulates the channel-invoke transaction via simulateTransaction to extract minResourceFee, then compares balance − (2 + numSubEntries) × BASE_RESERVE_STROOPS against NETWORK_FEE + minResourceFee.
  • Typed error InsufficientFees (EXC_005) at src/core/service/executor/executor.errors.ts, carrying the four-field detail.
  • Wired into Executor.executeNext between getTransactionExpiration and submitTransactionToNetwork. The catch site fast-paths error instanceof InsufficientFees before reaching the existing retry helper: it sets BundleStatus.FAILED, persists the detail to failure_detail, and does not touch retryCount.
  • DTO + API surface: BundleDTO (toBundleDTO) and the bundle-status GET response schema both grew failureDetail: Record<string, unknown> | null. Null for success states and existing free-form failures — back-compat preserved.
  • New env var BASE_RESERVE_STROOPS (optional, default 5_000_000). Soroban RPC does not expose base_reserve as a ConfigSettingEntry, so this is read from env per the project convention; override on protocol upgrade. Documented in .env.example.
  • Drizzle migration 0018_add_failure_detail_to_bundles.sql (additive ADD COLUMN IF NOT EXISTS).
  • Tests:
    • tests/unit/preflight-opex-balance.test.ts — 8 tests covering the math (sufficient / exact / shortfall-by-one / missing account / high subentry) and the typed-error instance contract.
    • tests/integration/service/executor-preflight-opex-fees.test.ts — 6 tests proving the catch-site persists FAILED + failure_detail without incrementing retryCount, and that the bundle-status DTO + response schema carry the four fields end-to-end.
  • Test stub fixes (test: commit, drift from PR feat: migrate to new backend logging convention #105 / the router-factory migration): executor-retry.test.ts, verifier-retry.test.ts, and waitlist.test.ts now pass a real Logger (via newNoop()) and use buildWaitlistRouter({log}). test:integration goes from 82/17 to 99/0.

Migration note

0018_add_failure_detail_to_bundles.sql was force-added (migration files are gitignored by *.sql). Journal entries are chronological — idx 18 when=1780531200000 follows idx 17's 1780300000000.

Test plan

  • deno fmt --check — clean (300 files)
  • deno lint — clean (258 files)
  • deno task test:unit — 64 / 0
  • deno task test:integration — 99 / 0 (was 82/17 on main before this PR's test-stub fix)
  • Local stack end-to-end on 0.7.2 (full local-dev infra-up.sh + setup-c + setup-pp):
    • happy-path bundle → COMPLETED, failureDetail: null
    • PP_1 root drained to 10,499,900 stroops (just above the 10M reserve) → next deposit goes to FAILED with failureDetail = { feePayerPubkey, availableXlm:"499900", requiredXlm:"1504155", shortfallXlm:"1004255" }
    • PP_1 re-funded via friendbot → subsequent bundle goes through normally
    • No submitTransactionToNetwork span recorded for the failed bundle in Jaeger — pre-flight throws before any submission attempt
  • Reviewer pass on the catch-site bypass (one fast-path before the existing retry helper; deliberately no refactor of handleExecutionFailure)
  • Reviewer pass on the migration ordering (idx 18 / when 1780531200000)

AquiGorka added 4 commits June 4, 2026 14:56
…etail

Adds an additive nullable `failure_detail jsonb` column to `operations_bundles`
and surfaces it through the entity-scoped bundle-status API. The column carries
structured terminal-failure payloads (the first consumer is `InsufficientFees`
in the executor pre-flight check) while the existing free-form
`last_failure_reason` remains unchanged for other failure modes.

Includes the matching migration journal entry, entity field, DTO field on
`BundleDTO`/`toBundleDTO`, and `failureDetail` on the GET
/providers/:ppPublicKey/entity/bundles/:bundleId response schema. The field is
null for success states and legacy failures, preserving back-compat.
…nal-fail

Before bundle submission, simulate the channel-invoke transaction the executor
is about to submit and verify the PP root Stellar account can cover
`baseInclusionFee + minResourceFee` after subtracting Stellar minimum reserves
(`(2 + numSubEntries) * BASE_RESERVE_STROOPS`). When the fee payer falls short,
throw a typed `InsufficientFees` error carrying
`{feePayerPubkey, availableXlm, requiredXlm, shortfallXlm}` as structured detail.

The executor catch site treats `InsufficientFees` as terminal: it persists
the four-field detail into `failure_detail`, transitions the bundle to
`BundleStatus.FAILED` directly, and does NOT increment `retryCount` or
re-enqueue. Other failure modes continue through the existing retry loop
unchanged.

`BASE_RESERVE_STROOPS` is read from env (defaults to 5_000_000 stroops) because
Soroban RPC does not expose base_reserve as a ConfigSettingEntry; operators
override on protocol upgrade.

Tests cover the math (sufficient, exact, off-by-one, missing account, high
subentry count), error-class behaviour and `instanceof` round-trip, and the
end-to-end persist-and-surface path: under-funded fee payer throws
`InsufficientFees`; catch-site persists `FAILED` + `failure_detail` without
incrementing `retryCount`; `toBundleDTO` and the bundle-status GET response
schema carry all four fields; `failureDetail` stays null for non-failed bundles.
…ntions

Three integration test files were broken on origin/main after the migration
to the new backend logging convention (PR #105) and the router-factory shape:

- tests/integration/service/executor-retry.test.ts and verifier-retry.test.ts
  called handleExecutionFailure / handleVerificationFailure without `log` in
  deps, so `deps.log.scope(...)` crashed inside `withSpan`. Pass `newNoop()`
  from @/utils/logger so the helpers can scope logs without side effects.

- tests/integration/http/waitlist.test.ts imported a non-existent default
  router export and called `.routes()` on undefined. Switch to the
  `buildWaitlistRouter({ log })` factory shape that production wiring uses.

No production code changed; this just brings the tests back in sync with
shapes introduced by PR #105 and merged into main.
@AquiGorka
AquiGorka merged commit cf0254d into main Jun 4, 2026
7 checks passed
@AquiGorka
AquiGorka deleted the add-prebundle-opex-fee-check branch June 4, 2026 19:33
AquiGorka added a commit that referenced this pull request Jun 8, 2026
Removes the demo surface introduced in #53. The `POST
/api/v1/pay/demo/simulate-kyc` route, its handler, the env-var gate
(`PAY_DEMO_ENABLED`), the local/standalone-network auto-mount, both demo
test files, and all README + `.env.example` mentions are deleted. No
transitional 410 stub — bare-delete.

Verified no consumers org-wide (PM grep across all 18 Moonlight-Protocol
repos, including local-dev setup scripts, testnet/, recording/,
playwright/ — zero hits outside this repo before this PR).

## Local verification

- `deno fmt --check`: clean
- `deno lint`: clean
- `deno task test:unit`: 64 passed | 0 failed
- `deno task test:integration`: 99 passed | 0 failed (matches PR #111
baseline exactly)
- `deno task test:pay`: 116 passed | 0 failed (= 132 main baseline − 16
deleted demo `Deno.test` calls)
- `curl POST /api/v1/pay/demo/simulate-kyc` on `NETWORK=local`: HTTP 404
- `curl POST /api/v1/pay/demo/simulate-kyc` on `NETWORK=testnet` +
`PAY_DEMO_ENABLED=true`: HTTP 404 (env var no longer wires anything)
- `curl POST /api/v1/pay/kyc` and `/pay/report`: HTTP 401 (unchanged
contract)

Last commit is the version bump `0.7.3 → 0.7.4` alone.
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