feat(executor): pre-flight OpEx fee check before bundle submission - #111
Merged
Conversation
…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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
FAILEDwith a typedInsufficientFeesreason — 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 nullablefailure_detail jsonbcolumn onoperations_bundlesand 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
src/core/service/executor/preflight-opex-balance.ts: reads the fee-payer's XLM balance +numSubEntriesvia SorobangetLedgerEntries, simulates the channel-invoke transaction viasimulateTransactionto extractminResourceFee, then comparesbalance − (2 + numSubEntries) × BASE_RESERVE_STROOPSagainstNETWORK_FEE + minResourceFee.InsufficientFees(EXC_005) atsrc/core/service/executor/executor.errors.ts, carrying the four-field detail.Executor.executeNextbetweengetTransactionExpirationandsubmitTransactionToNetwork. The catch site fast-pathserror instanceof InsufficientFeesbefore reaching the existing retry helper: it setsBundleStatus.FAILED, persists the detail tofailure_detail, and does not touchretryCount.BundleDTO(toBundleDTO) and the bundle-status GET response schema both grewfailureDetail: Record<string, unknown> | null. Null for success states and existing free-form failures — back-compat preserved.BASE_RESERVE_STROOPS(optional, default5_000_000). Soroban RPC does not exposebase_reserveas aConfigSettingEntry, so this is read from env per the project convention; override on protocol upgrade. Documented in.env.example.0018_add_failure_detail_to_bundles.sql(additiveADD COLUMN IF NOT EXISTS).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 persistsFAILED+failure_detailwithout incrementingretryCount, and that the bundle-status DTO + response schema carry the four fields end-to-end.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, andwaitlist.test.tsnow pass a realLogger(vianewNoop()) and usebuildWaitlistRouter({log}).test:integrationgoes from 82/17 to 99/0.Migration note
0018_add_failure_detail_to_bundles.sqlwas force-added (migration files are gitignored by*.sql). Journal entries are chronological — idx 18when=1780531200000follows idx 17's1780300000000.Test plan
deno fmt --check— clean (300 files)deno lint— clean (258 files)deno task test:unit— 64 / 0deno task test:integration— 99 / 0 (was 82/17 onmainbefore this PR's test-stub fix)0.7.2(full local-devinfra-up.sh+setup-c+setup-pp):COMPLETED,failureDetail: null10,499,900stroops (just above the 10M reserve) → next deposit goes toFAILEDwithfailureDetail = { feePayerPubkey, availableXlm:"499900", requiredXlm:"1504155", shortfallXlm:"1004255" }submitTransactionToNetworkspan recorded for the failed bundle in Jaeger — pre-flight throws before any submission attempthandleExecutionFailure)when1780531200000)